a Signature Pad

This commit is contained in:
Paul Schneider 2026-07-04 14:43:39 +01:00
commit b049551448
3 changed files with 473 additions and 0 deletions

View 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);
}
}

View 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;
}
}
}