a Signature Pad
This commit is contained in:
parent
ec54108cf0
commit
b049551448
3 changed files with 473 additions and 0 deletions
188
src/PostIt.Tests/SignaturePadControlTests.cs
Normal file
188
src/PostIt.Tests/SignaturePadControlTests.cs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using PostIt.Controls;
|
||||
using PostIt.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Targeted tests for <see cref="SignaturePadControl"/> and
|
||||
/// <see cref="SignaturePadData"/>.
|
||||
///
|
||||
/// The control exposes <c>internal</c> 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
|
||||
/// <see cref="Pointer_handlers_attach_when_capture_area_is_set"/>.
|
||||
/// </summary>
|
||||
public class SignaturePadControlTests
|
||||
{
|
||||
// --- SignaturePadData (pure) ---------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Data_empty_array_is_empty()
|
||||
{
|
||||
var d = new SignaturePadData(Array.Empty<int>());
|
||||
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<ArgumentNullException>(() => 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);
|
||||
}
|
||||
}
|
||||
204
src/PostIt/PostIt/Controls/SignaturePadControl.cs
Normal file
204
src/PostIt/PostIt/Controls/SignaturePadControl.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>[0, CoordinateMax]</c>.
|
||||
///
|
||||
/// The control is render-agnostic: it does not draw anything. The
|
||||
/// host view templates a <see cref="InputElement"/> (typically a
|
||||
/// <c>Border</c>) as <c>PART_CaptureArea</c> for pointer capture,
|
||||
/// and binds a separate visual layer (e.g. a <c>Canvas</c>) to
|
||||
/// <see cref="Strokes"/> for redraw. Keeping the control headless of
|
||||
/// rendering makes it usable from a headless test where no
|
||||
/// composition happens.
|
||||
///
|
||||
/// Wire format (see <see cref="SignaturePadData"/>):
|
||||
/// <code>int[] = [k0, x0, y0, ..., k1, x0, y0, ...]</code>
|
||||
/// with <c>x, y ∈ [0, 10_000]</c>.
|
||||
///
|
||||
/// Threading: pointer events are dispatched on the UI thread, which
|
||||
/// is the only thread that ever mutates <see cref="Strokes"/>. The
|
||||
/// buffer is safe to read from any thread as long as no read
|
||||
/// straddles a pointer event — for cross-thread transfer use
|
||||
/// <see cref="Snapshot"/>, which copies.
|
||||
/// </summary>
|
||||
public class SignaturePadControl : TemplatedControl
|
||||
{
|
||||
/// <summary>
|
||||
/// Styled property pointing at the <see cref="InputElement"/>
|
||||
/// that receives pointer events. Set it in the control's
|
||||
/// template (<c>PART_CaptureArea</c>).
|
||||
/// </summary>
|
||||
public static readonly StyledProperty<InputElement?> CaptureAreaProperty =
|
||||
AvaloniaProperty.Register<SignaturePadControl, InputElement?>(nameof(CaptureArea));
|
||||
|
||||
public InputElement? CaptureArea
|
||||
{
|
||||
get => GetValue(CaptureAreaProperty);
|
||||
set => SetValue(CaptureAreaProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public IReadOnlyList<int> Strokes => _strokes;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user finishes a stroke (pointer release).
|
||||
/// The argument is a snapshot of the buffer at release time.
|
||||
/// </summary>
|
||||
public event EventHandler<SignaturePadData>? StrokeCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the buffer changes: at the end of every stroke
|
||||
/// and on <see cref="Clear"/>. Mid-stroke points do not raise
|
||||
/// this event (pointer-move is too dense); bind a separate
|
||||
/// visual layer if you need a live preview.
|
||||
/// </summary>
|
||||
public event EventHandler? RedrawRequested;
|
||||
|
||||
private readonly List<int> _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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forget every captured stroke. Raises <see cref="RedrawRequested"/>.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_strokes.Clear();
|
||||
_pendingPoints = 0;
|
||||
_capturing = false;
|
||||
RedrawRequested?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defensive copy of the current buffer wrapped in a
|
||||
/// <see cref="SignaturePadData"/>. Cheap; call only when the
|
||||
/// view needs to ship the data off (e.g. to a backend).
|
||||
/// </summary>
|
||||
public SignaturePadData Snapshot() => new(_strokes.ToArray());
|
||||
|
||||
// --- Test-only surface (visible to PostIt.Tests) -------------------
|
||||
|
||||
/// <summary>
|
||||
/// Test hook: append a single normalised point without going
|
||||
/// through the pointer pipeline. Does not raise
|
||||
/// <see cref="RedrawRequested"/>.
|
||||
/// </summary>
|
||||
internal void AppendPointForTest(int x, int y)
|
||||
{
|
||||
_strokes.Add(x);
|
||||
_strokes.Add(y);
|
||||
_pendingPoints++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test hook: seal the currently-pending stroke with a length
|
||||
/// prefix. Mirrors what <see cref="OnCaptureReleased"/> does at
|
||||
/// pointer release time, including the
|
||||
/// <see cref="StrokeCompleted"/> and <see cref="RedrawRequested"/>
|
||||
/// events, so test scenarios observe the same notification
|
||||
/// contract as production. Idempotent: a second call without
|
||||
/// intermediate appends is a no-op.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
81
src/PostIt/PostIt/Models/SignaturePadData.cs
Normal file
81
src/PostIt/PostIt/Models/SignaturePadData.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
namespace PostIt.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Serialized form of a signature captured by
|
||||
/// <see cref="PostIt.Controls.SignaturePadControl"/>.
|
||||
///
|
||||
/// Wire format (length-prefixed, normalised):
|
||||
/// <code>
|
||||
/// int[] = [k0, x00, y00, x01, y01, ..., x0_{k0-1}, y0_{k0-1},
|
||||
/// k1, x10, y10, x11, y11, ..., x1_{k1-1}, y1_{k1-1},
|
||||
/// ...]
|
||||
/// </code>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>k_i</c> — number of (x, y) pairs in stroke <c>i</c>.</item>
|
||||
/// <item><c>x, y</c> — coordinates normalised to <c>[0, CoordinateMax]</c>
|
||||
/// (inclusive) on the control's client area. <see cref="CoordinateMax"/>
|
||||
/// is <c>10_000</c> 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 <c>int</c>.</item>
|
||||
/// <item>Total array length is even: each stroke contributes
|
||||
/// <c>1 + 2 * k_i</c> integers, and <c>1 + 2k</c> is always odd.
|
||||
/// Sum of <c>1 + 2k_i</c> 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
|
||||
/// <see cref="IsEmpty"/> to drop the array entirely).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// 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 <c>k = 1</c>: <c>[1, x, y]</c>.
|
||||
/// </summary>
|
||||
public sealed class SignaturePadData
|
||||
{
|
||||
/// <summary>
|
||||
/// Upper bound of normalised coordinates. <c>10_000</c> means a
|
||||
/// surface unit is represented as 0.0001 of the whole.
|
||||
/// </summary>
|
||||
public const int CoordinateMax = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Raw payload. See <see cref="SignaturePadData"/> for the layout.
|
||||
/// Never <c>null</c>; an empty array means "no strokes".
|
||||
/// </summary>
|
||||
public int[] Strokes { get; }
|
||||
|
||||
public SignaturePadData(int[] strokes)
|
||||
{
|
||||
if (strokes is null) throw new System.ArgumentNullException(nameof(strokes));
|
||||
Strokes = strokes;
|
||||
}
|
||||
|
||||
/// <summary>True if no stroke has been captured.</summary>
|
||||
public bool IsEmpty => Strokes.Length == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Number of distinct strokes (pen-down / pen-up cycles).
|
||||
/// Returns 0 when <see cref="IsEmpty"/> is true.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue