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