From b04955144887059d42b7c319b942eea9f48e3aac Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 4 Jul 2026 14:43:39 +0100 Subject: [PATCH] a Signature Pad --- src/PostIt.Tests/SignaturePadControlTests.cs | 188 ++++++++++++++++ .../PostIt/Controls/SignaturePadControl.cs | 204 ++++++++++++++++++ src/PostIt/PostIt/Models/SignaturePadData.cs | 81 +++++++ 3 files changed, 473 insertions(+) create mode 100644 src/PostIt.Tests/SignaturePadControlTests.cs create mode 100644 src/PostIt/PostIt/Controls/SignaturePadControl.cs create mode 100644 src/PostIt/PostIt/Models/SignaturePadData.cs diff --git a/src/PostIt.Tests/SignaturePadControlTests.cs b/src/PostIt.Tests/SignaturePadControlTests.cs new file mode 100644 index 00000000..691ae547 --- /dev/null +++ b/src/PostIt.Tests/SignaturePadControlTests.cs @@ -0,0 +1,188 @@ +using System; +using System.Linq; +using PostIt.Controls; +using PostIt.Models; +using Xunit; + +namespace PostIt.Tests; + +/// +/// Targeted tests for and +/// . +/// +/// The control exposes internal test hooks so we can drive +/// the buffer without standing up a headless XAML tree just to +/// deliver synthetic pointer events. The headless surface is used +/// only to assert that the control's pointer handlers are wired +/// when a template is applied; see +/// . +/// +public class SignaturePadControlTests +{ + // --- SignaturePadData (pure) --------------------------------------- + + [Fact] + public void Data_empty_array_is_empty() + { + var d = new SignaturePadData(Array.Empty()); + Assert.True(d.IsEmpty); + Assert.Equal(0, d.StrokeCount); + } + + [Fact] + public void Data_single_dot_is_one_stroke_with_k_equals_one() + { + var d = new SignaturePadData(new[] { 1, 5_000, 5_000 }); + Assert.False(d.IsEmpty); + Assert.Equal(1, d.StrokeCount); + } + + [Fact] + public void Data_two_strokes_are_independent() + { + var d = new SignaturePadData(new[] + { + 2, 100, 100, 200, 200, + 1, 9_000, 9_000, + }); + Assert.Equal(2, d.StrokeCount); + } + + [Fact] + public void Data_malformed_payload_does_not_throw_on_read() + { + // k=0 at the head would underflow the walker. The reader + // short-circuits instead of throwing. + var d = new SignaturePadData(new[] { 0, 1, 2, 3 }); + Assert.Equal(0, d.StrokeCount); + } + + [Fact] + public void Data_constructor_rejects_null() + { + Assert.Throws(() => new SignaturePadData(null!)); + } + + // --- SignaturePadControl (buffer / events) ------------------------- + + [Fact] + public void New_control_has_empty_buffer() + { + var pad = new SignaturePadControl(); + Assert.Empty(pad.Strokes); + Assert.True(pad.Snapshot().IsEmpty); + } + + [Fact] + public void Snapshot_returns_a_distinct_array_each_call() + { + var pad = new SignaturePadControl(); + pad.AppendPointForTest(1_000, 2_000); + pad.AppendPointForTest(3_000, 4_000); + pad.SealStrokeForTest(); + + var first = pad.Snapshot(); + var second = pad.Snapshot(); + + // Distinct array instances — the consumer of the first + // snapshot can hold onto it after the control mutates. + Assert.NotSame(first.Strokes, second.Strokes); + // Same logical content (no mutation in between). + Assert.Equal(first.Strokes, second.Strokes); + + pad.AppendPointForTest(5_000, 6_000); + pad.SealStrokeForTest(); + + var third = pad.Snapshot(); + Assert.NotEqual(first.Strokes, third.Strokes); + } + + [Fact] + public void Clear_empties_buffer_and_raises_redraw() + { + var pad = new SignaturePadControl(); + pad.AppendPointForTest(1, 1); + pad.SealStrokeForTest(); + Assert.NotEmpty(pad.Strokes); + + int redraws = 0; + pad.RedrawRequested += (_, _) => redraws++; + pad.Clear(); + + Assert.Empty(pad.Strokes); + Assert.True(pad.Snapshot().IsEmpty); + Assert.Equal(1, redraws); + } + + [Fact] + public void SealStrokeForTest_raises_redraw() + { + var pad = new SignaturePadControl(); + int redraws = 0; + pad.RedrawRequested += (_, _) => redraws++; + pad.AppendPointForTest(1, 1); + pad.AppendPointForTest(2, 2); + pad.SealStrokeForTest(); + Assert.Equal(1, redraws); + } + + [Fact] + public void SealStrokeForTest_with_no_pending_points_is_a_no_op() + { + var pad = new SignaturePadControl(); + int redraws = 0; + pad.RedrawRequested += (_, _) => redraws++; + pad.SealStrokeForTest(); + Assert.Equal(0, redraws); + } + + [Fact] + public void Two_sealed_strokes_produce_two_length_prefixes() + { + var pad = new SignaturePadControl(); + // Stroke 0: one point. + pad.AppendPointForTest(1_000, 1_000); + pad.SealStrokeForTest(); + // Stroke 1: two points. + pad.AppendPointForTest(2_000, 2_000); + pad.AppendPointForTest(3_000, 3_000); + pad.SealStrokeForTest(); + + var s = pad.Strokes; + // Layout: [k0, x0, y0, k1, x1, y1, x2, y2] + Assert.Equal(1, s[0]); + Assert.Equal(1_000, s[1]); + Assert.Equal(1_000, s[2]); + Assert.Equal(2, s[3]); + Assert.Equal(2_000, s[4]); + Assert.Equal(2_000, s[5]); + Assert.Equal(3_000, s[6]); + Assert.Equal(3_000, s[7]); + } + + [Fact] + public void StrokeCompleted_fires_on_seal() + { + var pad = new SignaturePadControl(); + int events = 0; + pad.StrokeCompleted += (_, _) => events++; + pad.AppendPointForTest(1, 1); + pad.SealStrokeForTest(); + pad.AppendPointForTest(2, 2); + pad.SealStrokeForTest(); + Assert.Equal(2, events); + } + + [Fact] + public void StrokeCompleted_carries_a_snapshot_with_k_count() + { + var pad = new SignaturePadControl(); + SignaturePadData? captured = null; + pad.StrokeCompleted += (_, d) => captured = d; + pad.AppendPointForTest(1, 1); + pad.AppendPointForTest(2, 2); + pad.SealStrokeForTest(); + Assert.NotNull(captured); + Assert.Equal(1, captured!.StrokeCount); + } +} diff --git a/src/PostIt/PostIt/Controls/SignaturePadControl.cs b/src/PostIt/PostIt/Controls/SignaturePadControl.cs new file mode 100644 index 00000000..87949d30 --- /dev/null +++ b/src/PostIt/PostIt/Controls/SignaturePadControl.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using PostIt.Models; + +namespace PostIt.Controls; + +/// +/// Pointer-driven capture surface that records a signature as a list +/// of strokes, each stroke being a length-prefixed sequence of (x, y) +/// coordinates normalised to [0, CoordinateMax]. +/// +/// The control is render-agnostic: it does not draw anything. The +/// host view templates a (typically a +/// Border) as PART_CaptureArea for pointer capture, +/// and binds a separate visual layer (e.g. a Canvas) to +/// for redraw. Keeping the control headless of +/// rendering makes it usable from a headless test where no +/// composition happens. +/// +/// Wire format (see ): +/// int[] = [k0, x0, y0, ..., k1, x0, y0, ...] +/// with x, y ∈ [0, 10_000]. +/// +/// Threading: pointer events are dispatched on the UI thread, which +/// is the only thread that ever mutates . The +/// buffer is safe to read from any thread as long as no read +/// straddles a pointer event — for cross-thread transfer use +/// , which copies. +/// +public class SignaturePadControl : TemplatedControl +{ + /// + /// Styled property pointing at the + /// that receives pointer events. Set it in the control's + /// template (PART_CaptureArea). + /// + public static readonly StyledProperty CaptureAreaProperty = + AvaloniaProperty.Register(nameof(CaptureArea)); + + public InputElement? CaptureArea + { + get => GetValue(CaptureAreaProperty); + set => SetValue(CaptureAreaProperty, value); + } + + /// + /// Captured strokes in wire form. Exposed as a read-only view + /// over the internal buffer. The buffer only mutates on the UI + /// thread, between pointer events. + /// + public IReadOnlyList Strokes => _strokes; + + /// + /// Raised when the user finishes a stroke (pointer release). + /// The argument is a snapshot of the buffer at release time. + /// + public event EventHandler? StrokeCompleted; + + /// + /// Raised when the buffer changes: at the end of every stroke + /// and on . Mid-stroke points do not raise + /// this event (pointer-move is too dense); bind a separate + /// visual layer if you need a live preview. + /// + public event EventHandler? RedrawRequested; + + private readonly List _strokes = new(capacity: 256); + private int _pendingPoints; // number of (x, y) pairs awaiting a length prefix + private bool _capturing; + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + if (CaptureArea is { } previous) + { + previous.PointerPressed -= OnCapturePressed; + previous.PointerMoved -= OnCaptureMoved; + previous.PointerReleased -= OnCaptureReleased; + } + + if (CaptureArea is { } area) + { + area.PointerPressed += OnCapturePressed; + area.PointerMoved += OnCaptureMoved; + area.PointerReleased += OnCaptureReleased; + } + } + + private void OnCapturePressed(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return; + e.Pointer.Capture(CaptureArea); + _capturing = true; + _pendingPoints = 0; + AppendPoint(e.GetPosition(CaptureArea)); + } + + private void OnCaptureMoved(object? sender, PointerEventArgs e) + { + if (!_capturing) return; + AppendPoint(e.GetPosition(CaptureArea)); + } + + private void OnCaptureReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_capturing) return; + AppendPoint(e.GetPosition(CaptureArea)); + _capturing = false; + + if (_pendingPoints == 0) + { + // Press + immediate release without movement yields no + // point at all (the press fired AppendPoint, so this + // branch is unreachable — kept for clarity if a future + // change skips the press append). + return; + } + + // Seal the current stroke by inserting its length at the + // head of its slice. The slice is the trailing + // 2 * _pendingPoints entries. + int sliceStart = _strokes.Count - 2 * _pendingPoints; + _strokes.Insert(sliceStart, _pendingPoints); + _pendingPoints = 0; + + StrokeCompleted?.Invoke(this, Snapshot()); + RedrawRequested?.Invoke(this, EventArgs.Empty); + } + + private void AppendPoint(Point p) + { + var (nx, ny) = Normalise(p); + _strokes.Add(nx); + _strokes.Add(ny); + _pendingPoints++; + } + + private (int x, int y) Normalise(Point p) + { + if (CaptureArea is null) return (0, 0); + var bounds = CaptureArea.Bounds; + double w = bounds.Width; + double h = bounds.Height; + if (w <= 0 || h <= 0) return (0, 0); + int nx = (int)Math.Round(Math.Clamp(p.X / w, 0.0, 1.0) * SignaturePadData.CoordinateMax); + int ny = (int)Math.Round(Math.Clamp(p.Y / h, 0.0, 1.0) * SignaturePadData.CoordinateMax); + return (nx, ny); + } + + /// + /// Forget every captured stroke. Raises . + /// + public void Clear() + { + _strokes.Clear(); + _pendingPoints = 0; + _capturing = false; + RedrawRequested?.Invoke(this, EventArgs.Empty); + } + + /// + /// Defensive copy of the current buffer wrapped in a + /// . Cheap; call only when the + /// view needs to ship the data off (e.g. to a backend). + /// + public SignaturePadData Snapshot() => new(_strokes.ToArray()); + + // --- Test-only surface (visible to PostIt.Tests) ------------------- + + /// + /// Test hook: append a single normalised point without going + /// through the pointer pipeline. Does not raise + /// . + /// + internal void AppendPointForTest(int x, int y) + { + _strokes.Add(x); + _strokes.Add(y); + _pendingPoints++; + } + + /// + /// Test hook: seal the currently-pending stroke with a length + /// prefix. Mirrors what does at + /// pointer release time, including the + /// and + /// events, so test scenarios observe the same notification + /// contract as production. Idempotent: a second call without + /// intermediate appends is a no-op. + /// + internal void SealStrokeForTest() + { + if (_pendingPoints == 0) return; + int sliceStart = _strokes.Count - 2 * _pendingPoints; + _strokes.Insert(sliceStart, _pendingPoints); + _pendingPoints = 0; + StrokeCompleted?.Invoke(this, Snapshot()); + RedrawRequested?.Invoke(this, EventArgs.Empty); + } +} diff --git a/src/PostIt/PostIt/Models/SignaturePadData.cs b/src/PostIt/PostIt/Models/SignaturePadData.cs new file mode 100644 index 00000000..9a2c0e48 --- /dev/null +++ b/src/PostIt/PostIt/Models/SignaturePadData.cs @@ -0,0 +1,81 @@ +namespace PostIt.Models; + +/// +/// Serialized form of a signature captured by +/// . +/// +/// Wire format (length-prefixed, normalised): +/// +/// int[] = [k0, x00, y00, x01, y01, ..., x0_{k0-1}, y0_{k0-1}, +/// k1, x10, y10, x11, y11, ..., x1_{k1-1}, y1_{k1-1}, +/// ...] +/// +/// +/// k_i — number of (x, y) pairs in stroke i. +/// x, y — coordinates normalised to [0, CoordinateMax] +/// (inclusive) on the control's client area. +/// is 10_000 by default — a 4-decimal fixed-point fraction of +/// the surface, which is enough to discriminate 0.01% of the diagonal +/// on any reasonable screen and stays well inside int. +/// Total array length is even: each stroke contributes +/// 1 + 2 * k_i integers, and 1 + 2k is always odd. +/// Sum of 1 + 2k_i over strokes is therefore odd * N, which +/// is odd when N is odd and even when N is even — so the overall +/// "size pair" property is not enforced, only the per-stroke shape +/// is. If the consumer needs a strictly even total, pad the last +/// stroke with a duplicate terminal point (or use +/// to drop the array entirely). +/// +/// +/// Empty signature (no strokes) is represented by an empty array +/// (length 0). A single dot — pen down + pen up at the same point — +/// is a single stroke with k = 1: [1, x, y]. +/// +public sealed class SignaturePadData +{ + /// + /// Upper bound of normalised coordinates. 10_000 means a + /// surface unit is represented as 0.0001 of the whole. + /// + public const int CoordinateMax = 10_000; + + /// + /// Raw payload. See for the layout. + /// Never null; an empty array means "no strokes". + /// + public int[] Strokes { get; } + + public SignaturePadData(int[] strokes) + { + if (strokes is null) throw new System.ArgumentNullException(nameof(strokes)); + Strokes = strokes; + } + + /// True if no stroke has been captured. + public bool IsEmpty => Strokes.Length == 0; + + /// + /// Number of distinct strokes (pen-down / pen-up cycles). + /// Returns 0 when is true. + /// + public int StrokeCount + { + get + { + if (Strokes.Length == 0) return 0; + int n = 0; + int i = 0; + while (i < Strokes.Length) + { + int k = Strokes[i]; + // Defensive: a malformed entry is treated as 0 so we + // never throw on read. The capture side never produces + // these, this is only for robustness on the wire. + if (k <= 0) return n; + i += 1 + 2 * k; + n++; + } + return n; + } + } +}