From 32e472a7c5bf4173ccdfe97b7856c0652d346567 Mon Sep 17 00:00:00 2001 From: jay77721 Date: Wed, 25 Mar 2026 01:03:46 +0800 Subject: [PATCH] feat: Implement InkCanvas and InkPresenter controls with core ink model - InkStroke, InkStrokeCollection, InkDrawingAttributes model types - InkPresenter control (rendering primitive) - InkCanvas control (hosting interaction) - Input pipeline integration for pointer capture - Core tests: stroke creation, input flow, collection management --- InkkSlinger.Tests/InkCanvasCoreTests.cs | 193 ++++++++++++++++++ InkkSlinger.Tests/InkCanvasInputTests.cs | 190 +++++++++++++++++ UI/Controls/Inputs/InkCanvas.cs | 172 ++++++++++++++++ UI/Controls/Inputs/InkPresenter.cs | 136 ++++++++++++ UI/Input/InkDrawingAttributes.cs | 30 +++ UI/Input/InkStroke.cs | 86 ++++++++ UI/Input/InkStrokeCollection.cs | 97 +++++++++ .../Root/Services/UiRootInputPipeline.cs | 16 ++ 8 files changed, 920 insertions(+) create mode 100644 InkkSlinger.Tests/InkCanvasCoreTests.cs create mode 100644 InkkSlinger.Tests/InkCanvasInputTests.cs create mode 100644 UI/Controls/Inputs/InkCanvas.cs create mode 100644 UI/Controls/Inputs/InkPresenter.cs create mode 100644 UI/Input/InkDrawingAttributes.cs create mode 100644 UI/Input/InkStroke.cs create mode 100644 UI/Input/InkStrokeCollection.cs diff --git a/InkkSlinger.Tests/InkCanvasCoreTests.cs b/InkkSlinger.Tests/InkCanvasCoreTests.cs new file mode 100644 index 0000000..4d99a9c --- /dev/null +++ b/InkkSlinger.Tests/InkCanvasCoreTests.cs @@ -0,0 +1,193 @@ +using System; +using Microsoft.Xna.Framework; +using Xunit; + +namespace InkkSlinger.Tests; + +public sealed class InkCanvasCoreTests +{ + [Fact] + public void DefaultStrokeCollection_IsNotNull() + { + var canvas = new InkCanvas(); + Assert.NotNull(canvas.Strokes); + Assert.Equal(0, canvas.Strokes!.Count); + } + + [Fact] + public void SettingStrokes_UpdatesPresenter() + { + var canvas = new InkCanvas(); + var strokes = new InkStrokeCollection(); + canvas.Strokes = strokes; + + Assert.Same(strokes, canvas.Presenter.Strokes); + } + + [Fact] + public void AddingStroke_UpdatesCollection() + { + var canvas = new InkCanvas(); + var stroke = new InkStroke(new[] { new Vector2(0, 0), new Vector2(10, 10) }); + canvas.Strokes!.Add(stroke); + + Assert.Equal(1, canvas.Strokes.Count); + Assert.Same(stroke, canvas.Strokes[0]); + } + + [Fact] + public void RemovingStroke_UpdatesCollection() + { + var canvas = new InkCanvas(); + var stroke = new InkStroke(new[] { new Vector2(0, 0), new Vector2(10, 10) }); + canvas.Strokes!.Add(stroke); + canvas.Strokes.Remove(stroke); + + Assert.Equal(0, canvas.Strokes.Count); + } + + [Fact] + public void ClearingStrokes_EmptiesCollection() + { + var canvas = new InkCanvas(); + canvas.Strokes!.Add(new InkStroke(new[] { new Vector2(0, 0), new Vector2(5, 5) })); + canvas.Strokes.Add(new InkStroke(new[] { new Vector2(10, 10), new Vector2(20, 20) })); + Assert.Equal(2, canvas.Strokes.Count); + + canvas.Strokes.Clear(); + Assert.Equal(0, canvas.Strokes.Count); + } + + [Fact] + public void InkStroke_GetBounds_CalculatesCorrectly() + { + var points = new[] { new Vector2(10, 20), new Vector2(30, 40) }; + var stroke = new InkStroke(points, new InkDrawingAttributes { Width = 4f, Height = 4f }); + + var bounds = stroke.GetBounds(); + Assert.Equal(8f, bounds.X); // 10 - 2 + Assert.Equal(18f, bounds.Y); // 20 - 2 + Assert.Equal(24f, bounds.Width); // (30+2) - (10-2) + Assert.Equal(24f, bounds.Height); // (40+2) - (20-2) + } + + [Fact] + public void InkStroke_GetBounds_CachesResult() + { + var points = new[] { new Vector2(10, 20), new Vector2(30, 40) }; + var stroke = new InkStroke(points); + + var bounds1 = stroke.GetBounds(); + var bounds2 = stroke.GetBounds(); + Assert.Equal(bounds1, bounds2); + } + + [Fact] + public void InkStroke_AddPoint_InvalidatesBounds() + { + var stroke = new InkStroke(new[] { new Vector2(0, 0) }); + var bounds1 = stroke.GetBounds(); + + stroke.AddPoint(new Vector2(100, 100)); + var bounds2 = stroke.GetBounds(); + + Assert.True(bounds2.Width > bounds1.Width); + Assert.True(bounds2.Height > bounds1.Height); + } + + [Fact] + public void InkDrawingAttributes_Clone_CreatesIndependentCopy() + { + var original = new InkDrawingAttributes + { + Color = Color.Red, + Width = 5f, + Height = 3f, + Opacity = 0.5f + }; + + var clone = original.Clone(); + clone.Color = Color.Blue; + clone.Width = 10f; + + Assert.Equal(Color.Red, original.Color); + Assert.Equal(5f, original.Width); + Assert.Equal(Color.Blue, clone.Color); + Assert.Equal(10f, clone.Width); + } + + [Fact] + public void InkStrokeCollection_Changed_FiresOnAdd() + { + var collection = new InkStrokeCollection(); + bool changed = false; + collection.Changed += (_, _) => changed = true; + + collection.Add(new InkStroke(new[] { Vector2.Zero })); + Assert.True(changed); + } + + [Fact] + public void InkStrokeCollection_Changed_FiresOnRemove() + { + var collection = new InkStrokeCollection(); + var stroke = new InkStroke(new[] { Vector2.Zero }); + collection.Add(stroke); + + bool changed = false; + collection.Changed += (_, _) => changed = true; + collection.Remove(stroke); + Assert.True(changed); + } + + [Fact] + public void InkStrokeCollection_Changed_FiresOnClear() + { + var collection = new InkStrokeCollection(); + collection.Add(new InkStroke(new[] { Vector2.Zero })); + + bool changed = false; + collection.Changed += (_, _) => changed = true; + collection.Clear(); + Assert.True(changed); + } + + [Fact] + public void InkStrokeCollection_Changed_DoesNotFireOnEmptyClear() + { + var collection = new InkStrokeCollection(); + bool changed = false; + collection.Changed += (_, _) => changed = true; + collection.Clear(); + Assert.False(changed); + } + + [Fact] + public void InkStroke_EmptyPoints_ReturnsEmptyBounds() + { + var stroke = new InkStroke(Array.Empty()); + var bounds = stroke.GetBounds(); + Assert.Equal(0f, bounds.Width); + Assert.Equal(0f, bounds.Height); + } + + [Fact] + public void InkCanvas_DefaultDrawingAttributes_CanBeSet() + { + var canvas = new InkCanvas(); + var attrs = new InkDrawingAttributes { Color = Color.Blue, Width = 8f }; + canvas.DefaultDrawingAttributes = attrs; + + Assert.Same(attrs, canvas.DefaultDrawingAttributes); + } + + [Fact] + public void InkPresenter_StrokesProperty_UpdatesRendering() + { + var presenter = new InkPresenter(); + var strokes = new InkStrokeCollection(); + presenter.Strokes = strokes; + + Assert.Same(strokes, presenter.Strokes); + } +} diff --git a/InkkSlinger.Tests/InkCanvasInputTests.cs b/InkkSlinger.Tests/InkCanvasInputTests.cs new file mode 100644 index 0000000..de9b19d --- /dev/null +++ b/InkkSlinger.Tests/InkCanvasInputTests.cs @@ -0,0 +1,190 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using Xunit; + +namespace InkkSlinger.Tests; + +public sealed class InkCanvasInputTests +{ + [Fact] + public void PointerDown_StartsStroke() + { + var canvas = CreateInkCanvas(); + var uiRoot = CreateUiRoot(canvas); + + var pointer = new Vector2(100f, 100f); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(pointer, leftPressed: true)); + + Assert.True(canvas.IsDrawing); + Assert.Equal(1, canvas.Strokes!.Count); + } + + [Fact] + public void PointerMove_AppendsPoints() + { + var canvas = CreateInkCanvas(); + var uiRoot = CreateUiRoot(canvas); + + var startPoint = new Vector2(50f, 50f); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(startPoint, leftPressed: true)); + + var movePoint = new Vector2(100f, 100f); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(movePoint, pointerMoved: true)); + + var stroke = canvas.Strokes![0]; + Assert.Equal(2, stroke.PointCount); + } + + [Fact] + public void PointerUp_FinalizesStroke() + { + var canvas = CreateInkCanvas(); + var uiRoot = CreateUiRoot(canvas); + + var startPoint = new Vector2(50f, 50f); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(startPoint, leftPressed: true)); + + var movePoint = new Vector2(100f, 100f); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(movePoint, pointerMoved: true)); + + uiRoot.RunInputDeltaForTests(CreatePointerDelta(movePoint, leftReleased: true)); + + Assert.False(canvas.IsDrawing); + Assert.Equal(1, canvas.Strokes!.Count); + + var stroke = canvas.Strokes[0]; + Assert.Equal(2, stroke.PointCount); + } + + [Fact] + public void MultipleStrokes_AreAccumulated() + { + var canvas = CreateInkCanvas(); + var uiRoot = CreateUiRoot(canvas); + + // First stroke + uiRoot.RunInputDeltaForTests(CreatePointerDelta(new Vector2(50f, 50f), leftPressed: true)); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(new Vector2(100f, 100f), pointerMoved: true)); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(new Vector2(100f, 100f), leftReleased: true)); + + // Second stroke + uiRoot.RunInputDeltaForTests(CreatePointerDelta(new Vector2(200f, 200f), leftPressed: true)); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(new Vector2(250f, 250f), pointerMoved: true)); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(new Vector2(250f, 250f), leftReleased: true)); + + Assert.Equal(2, canvas.Strokes!.Count); + } + + [Fact] + public void DisabledCanvas_DoesNotDraw() + { + var canvas = CreateInkCanvas(); + canvas.IsEnabled = false; + var uiRoot = CreateUiRoot(canvas); + + var pointer = new Vector2(100f, 100f); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(pointer, leftPressed: true)); + + Assert.False(canvas.IsDrawing); + Assert.Equal(0, canvas.Strokes!.Count); + } + + [Fact] + public void DrawingAttributes_AreAppliedToStrokes() + { + var canvas = CreateInkCanvas(); + canvas.DefaultDrawingAttributes = new InkDrawingAttributes + { + Color = Color.Red, + Width = 10f + }; + + var uiRoot = CreateUiRoot(canvas); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(new Vector2(50f, 50f), leftPressed: true)); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(new Vector2(50f, 50f), leftReleased: true)); + + var stroke = canvas.Strokes![0]; + Assert.Equal(Color.Red, stroke.DrawingAttributes.Color); + Assert.Equal(10f, stroke.DrawingAttributes.Width); + } + + [Fact] + public void StrokePoints_AreInCorrectSequence() + { + var canvas = CreateInkCanvas(); + var uiRoot = CreateUiRoot(canvas); + + var p1 = new Vector2(10f, 10f); + var p2 = new Vector2(20f, 20f); + var p3 = new Vector2(30f, 30f); + + uiRoot.RunInputDeltaForTests(CreatePointerDelta(p1, leftPressed: true)); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(p2, pointerMoved: true)); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(p3, pointerMoved: true)); + uiRoot.RunInputDeltaForTests(CreatePointerDelta(p3, leftReleased: true)); + + var stroke = canvas.Strokes![0]; + Assert.Equal(3, stroke.PointCount); + Assert.Equal(p1, stroke.Points[0]); + Assert.Equal(p2, stroke.Points[1]); + Assert.Equal(p3, stroke.Points[2]); + } + + private static InkCanvas CreateInkCanvas() + { + return new InkCanvas + { + Width = 400f, + Height = 300f + }; + } + + private static UiRoot CreateUiRoot(InkCanvas canvas) + { + var host = new Canvas + { + Width = 460f, + Height = 340f + }; + host.AddChild(canvas); + Canvas.SetLeft(canvas, 20f); + Canvas.SetTop(canvas, 20f); + + var uiRoot = new UiRoot(host); + RunLayout(uiRoot); + return uiRoot; + } + + private static InputDelta CreatePointerDelta( + Vector2 pointer, + bool pointerMoved = false, + bool leftPressed = false, + bool leftReleased = false) + { + return new InputDelta + { + Previous = new InputSnapshot(default, default, pointer), + Current = new InputSnapshot(default, default, pointer), + PressedKeys = new List(), + ReleasedKeys = new List(), + TextInput = new List(), + PointerMoved = pointerMoved || leftPressed || leftReleased, + WheelDelta = 0, + LeftPressed = leftPressed, + LeftReleased = leftReleased, + RightPressed = false, + RightReleased = false, + MiddlePressed = false, + MiddleReleased = false + }; + } + + private static void RunLayout(UiRoot uiRoot) + { + uiRoot.Update( + new GameTime(System.TimeSpan.FromMilliseconds(16), System.TimeSpan.FromMilliseconds(16)), + new Viewport(0, 0, 460, 340)); + } +} diff --git a/UI/Controls/Inputs/InkCanvas.cs b/UI/Controls/Inputs/InkCanvas.cs new file mode 100644 index 0000000..7485b57 --- /dev/null +++ b/UI/Controls/Inputs/InkCanvas.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework; + +namespace InkkSlinger; + +/// +/// A canvas control that supports ink (pen/stylus/mouse) input for drawing strokes. +/// Hosts an internally and manages pointer capture lifecycle. +/// Modeled after WPF System.Windows.Controls.InkCanvas. +/// +public class InkCanvas : Control +{ + private readonly InkPresenter _presenter; + private InkStroke? _activeStroke; + private readonly List _activePoints = new(); + + public static readonly DependencyProperty StrokesProperty = + DependencyProperty.Register( + nameof(Strokes), + typeof(InkStrokeCollection), + typeof(InkCanvas), + new FrameworkPropertyMetadata( + null, + FrameworkPropertyMetadataOptions.AffectsRender, + propertyChangedCallback: static (dependencyObject, args) => + { + if (dependencyObject is not InkCanvas canvas) + { + return; + } + + canvas._presenter.Strokes = args.NewValue as InkStrokeCollection; + })); + + public static readonly DependencyProperty DefaultDrawingAttributesProperty = + DependencyProperty.Register( + nameof(DefaultDrawingAttributes), + typeof(InkDrawingAttributes), + typeof(InkCanvas), + new FrameworkPropertyMetadata(null)); + + public InkCanvas() + { + _presenter = new InkPresenter(); + AddChild(_presenter); + + // Ensure we have a default stroke collection + var strokes = new InkStrokeCollection(); + Strokes = strokes; + } + + /// + /// The collection of strokes managed by this canvas. + /// + public InkStrokeCollection? Strokes + { + get => GetValue(StrokesProperty); + set => SetValue(StrokesProperty, value); + } + + /// + /// The default drawing attributes applied to new strokes. + /// + public InkDrawingAttributes? DefaultDrawingAttributes + { + get => GetValue(DefaultDrawingAttributesProperty); + set => SetValue(DefaultDrawingAttributesProperty, value); + } + + /// + /// True while a stroke is being actively drawn (between pointer down and pointer up). + /// + public bool IsDrawing => _activeStroke != null; + + /// + /// Internal presenter used for rendering. Exposed for testing. + /// + internal InkPresenter Presenter => _presenter; + + protected override Vector2 MeasureOverride(Vector2 availableSize) + { + _presenter.Measure(availableSize); + return availableSize; + } + + protected override Vector2 ArrangeOverride(Vector2 finalSize) + { + _presenter.Arrange(new LayoutRect(LayoutSlot.X, LayoutSlot.Y, finalSize.X, finalSize.Y)); + return finalSize; + } + + /// + /// Called by the input pipeline when the pointer is pressed over this canvas. + /// + internal bool HandlePointerDownFromInput(Vector2 pointerPosition) + { + if (!IsEnabled || !IsHitTestVisible) + { + return false; + } + + // Only handle if pointer is within our layout slot + if (!LayoutSlot.Contains(pointerPosition)) + { + return false; + } + + BeginStroke(pointerPosition); + return true; + } + + /// + /// Called by the input pipeline when a captured pointer moves. + /// + internal void HandlePointerMoveFromInput(Vector2 pointerPosition) + { + if (_activeStroke == null) + { + return; + } + + ContinueStroke(pointerPosition); + } + + /// + /// Called by the input pipeline when the captured pointer is released. + /// + internal void HandlePointerUpFromInput() + { + if (_activeStroke == null) + { + return; + } + + EndStroke(); + } + + private void BeginStroke(Vector2 position) + { + var strokes = Strokes; + if (strokes == null) + { + return; + } + + _activePoints.Clear(); + _activePoints.Add(position); + + var attrs = DefaultDrawingAttributes?.Clone() ?? new InkDrawingAttributes(); + _activeStroke = new InkStroke(_activePoints, attrs); + strokes.Add(_activeStroke); + } + + private void ContinueStroke(Vector2 position) + { + if (_activeStroke == null) + { + return; + } + + _activePoints.Add(position); + _activeStroke.AddPoint(position); + _presenter.InvalidateArrange(); + } + + private void EndStroke() + { + _activeStroke = null; + _activePoints.Clear(); + } +} diff --git a/UI/Controls/Inputs/InkPresenter.cs b/UI/Controls/Inputs/InkPresenter.cs new file mode 100644 index 0000000..3ab2642 --- /dev/null +++ b/UI/Controls/Inputs/InkPresenter.cs @@ -0,0 +1,136 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace InkkSlinger; + +/// +/// A rendering primitive that draws ink strokes. +/// Hosted by for the complete inking experience. +/// Modeled after WPF System.Windows.Controls.InkPresenter. +/// +public class InkPresenter : Control +{ + private InkStrokeCollection? _attachedStrokes; + + public static readonly DependencyProperty StrokesProperty = + DependencyProperty.Register( + nameof(Strokes), + typeof(InkStrokeCollection), + typeof(InkPresenter), + new FrameworkPropertyMetadata( + null, + FrameworkPropertyMetadataOptions.AffectsRender, + propertyChangedCallback: static (dependencyObject, args) => + { + if (dependencyObject is not InkPresenter presenter) + { + return; + } + + presenter.OnStrokesChanged( + args.OldValue as InkStrokeCollection, + args.NewValue as InkStrokeCollection); + })); + + public InkStrokeCollection? Strokes + { + get => GetValue(StrokesProperty); + set => SetValue(StrokesProperty, value); + } + + protected override Vector2 MeasureOverride(Vector2 availableSize) + { + return availableSize; + } + + protected override Vector2 ArrangeOverride(Vector2 finalSize) + { + return finalSize; + } + + protected override void OnRender(SpriteBatch spriteBatch) + { + base.OnRender(spriteBatch); + + var strokes = Strokes; + if (strokes == null || strokes.Count == 0) + { + return; + } + + if (TryGetClipRect(out var clipRect)) + { + UiDrawing.PushClip(spriteBatch, clipRect); + } + + try + { + for (int s = 0; s < strokes.Count; s++) + { + var stroke = strokes[s]; + var points = stroke.Points; + if (points.Count < 2) + { + continue; + } + + var attr = stroke.DrawingAttributes; + var color = attr.Color; + if (attr.Opacity < 1f) + { + color = new Color(color.R, color.G, color.B, (byte)(color.A * attr.Opacity)); + } + + float thickness = attr.Width; + + // Draw as polyline segments for performance on hot paths + UiDrawing.DrawPolyline( + spriteBatch, + points, + closed: false, + thickness, + color, + attr.Opacity); + } + } + finally + { + if (TryGetClipRect(out _)) + { + UiDrawing.PopClip(spriteBatch); + } + } + } + + protected override bool TryGetClipRect(out LayoutRect clipRect) + { + clipRect = LayoutSlot; + return true; + } + + private void OnStrokesChanged(InkStrokeCollection? oldStrokes, InkStrokeCollection? newStrokes) + { + if (ReferenceEquals(_attachedStrokes, newStrokes)) + { + return; + } + + if (_attachedStrokes != null) + { + _attachedStrokes.Changed -= OnStrokesCollectionChanged; + } + + _attachedStrokes = newStrokes; + + if (_attachedStrokes != null) + { + _attachedStrokes.Changed += OnStrokesCollectionChanged; + } + } + + private void OnStrokesCollectionChanged(object? sender, EventArgs e) + { + InvalidateArrange(); + } +} diff --git a/UI/Input/InkDrawingAttributes.cs b/UI/Input/InkDrawingAttributes.cs new file mode 100644 index 0000000..a18cd97 --- /dev/null +++ b/UI/Input/InkDrawingAttributes.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.Xna.Framework; + +namespace InkkSlinger; + +/// +/// Describes the visual attributes applied to an ink stroke. +/// Mirrors the WPF System.Windows.Ink.DrawingAttributes surface at a practical parity level. +/// +public sealed class InkDrawingAttributes +{ + public Color Color { get; set; } = Color.Black; + + public float Width { get; set; } = 2f; + + public float Height { get; set; } = 2f; + + public float Opacity { get; set; } = 1f; + + public InkDrawingAttributes Clone() + { + return new InkDrawingAttributes + { + Color = Color, + Width = Width, + Height = Height, + Opacity = Opacity + }; + } +} diff --git a/UI/Input/InkStroke.cs b/UI/Input/InkStroke.cs new file mode 100644 index 0000000..212522c --- /dev/null +++ b/UI/Input/InkStroke.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework; + +namespace InkkSlinger; + +/// +/// Represents a single ink stroke as a sequence of points with associated drawing attributes. +/// Modeled after WPF System.Windows.Ink.Stroke at a practical parity level. +/// +public class InkStroke +{ + private readonly List _points; + private LayoutRect _cachedBounds; + private bool _boundsDirty; + + public InkStroke(IReadOnlyList points, InkDrawingAttributes? attributes = null) + { + if (points == null) + { + throw new ArgumentNullException(nameof(points)); + } + + _points = new List(points.Count); + for (int i = 0; i < points.Count; i++) + { + _points.Add(points[i]); + } + + DrawingAttributes = attributes?.Clone() ?? new InkDrawingAttributes(); + _boundsDirty = true; + } + + public InkDrawingAttributes DrawingAttributes { get; } + + public IReadOnlyList Points => _points; + + public int PointCount => _points.Count; + + /// + /// Appends a single point to the stroke (used during live drawing). + /// + public void AddPoint(Vector2 point) + { + _points.Add(point); + _boundsDirty = true; + } + + /// + /// Returns the axis-aligned bounding box of the stroke. + /// The result is cached and invalidated when points change. + /// + public LayoutRect GetBounds() + { + if (!_boundsDirty) + { + return _cachedBounds; + } + + if (_points.Count == 0) + { + _cachedBounds = default; + _boundsDirty = false; + return _cachedBounds; + } + + float minX = float.MaxValue, minY = float.MaxValue; + float maxX = float.MinValue, maxY = float.MinValue; + + float halfWidth = DrawingAttributes.Width * 0.5f; + float halfHeight = DrawingAttributes.Height * 0.5f; + + for (int i = 0; i < _points.Count; i++) + { + var p = _points[i]; + if (p.X - halfWidth < minX) minX = p.X - halfWidth; + if (p.Y - halfHeight < minY) minY = p.Y - halfHeight; + if (p.X + halfWidth > maxX) maxX = p.X + halfWidth; + if (p.Y + halfHeight > maxY) maxY = p.Y + halfHeight; + } + + _cachedBounds = new LayoutRect(minX, minY, maxX - minX, maxY - minY); + _boundsDirty = false; + return _cachedBounds; + } +} diff --git a/UI/Input/InkStrokeCollection.cs b/UI/Input/InkStrokeCollection.cs new file mode 100644 index 0000000..8726d1f --- /dev/null +++ b/UI/Input/InkStrokeCollection.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace InkkSlinger; + +/// +/// An observable collection of instances. +/// Provides change notifications so that rendering controls can invalidate minimally. +/// Modeled after WPF System.Windows.Ink.StrokeCollection. +/// +public sealed class InkStrokeCollection : IList +{ + private readonly List _strokes = new(); + + /// Raised when strokes are added, removed, or the collection is cleared. + public event EventHandler? Changed; + + public int Count => _strokes.Count; + + public bool IsReadOnly => false; + + public InkStroke this[int index] + { + get => _strokes[index]; + set + { + _strokes[index] = value; + Changed?.Invoke(this, EventArgs.Empty); + } + } + + public void Add(InkStroke item) + { + _strokes.Add(item); + Changed?.Invoke(this, EventArgs.Empty); + } + + public void AddRange(IEnumerable items) + { + bool any = false; + foreach (var item in items) + { + _strokes.Add(item); + any = true; + } + + if (any) + { + Changed?.Invoke(this, EventArgs.Empty); + } + } + + public void Clear() + { + if (_strokes.Count == 0) + { + return; + } + + _strokes.Clear(); + Changed?.Invoke(this, EventArgs.Empty); + } + + public bool Contains(InkStroke item) => _strokes.Contains(item); + + public void CopyTo(InkStroke[] array, int arrayIndex) => _strokes.CopyTo(array, arrayIndex); + + public int IndexOf(InkStroke item) => _strokes.IndexOf(item); + + public void Insert(int index, InkStroke item) + { + _strokes.Insert(index, item); + Changed?.Invoke(this, EventArgs.Empty); + } + + public bool Remove(InkStroke item) + { + bool removed = _strokes.Remove(item); + if (removed) + { + Changed?.Invoke(this, EventArgs.Empty); + } + + return removed; + } + + public void RemoveAt(int index) + { + _strokes.RemoveAt(index); + Changed?.Invoke(this, EventArgs.Empty); + } + + public IEnumerator GetEnumerator() => _strokes.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/UI/Managers/Root/Services/UiRootInputPipeline.cs b/UI/Managers/Root/Services/UiRootInputPipeline.cs index 159df4e..0d55e4d 100644 --- a/UI/Managers/Root/Services/UiRootInputPipeline.cs +++ b/UI/Managers/Root/Services/UiRootInputPipeline.cs @@ -954,6 +954,13 @@ private void DispatchPointerMove(UIElement? target, Vector2 pointerPosition) _lastInputPointerMoveHandlerMs += elapsed; _lastInputPointerMoveCapturedScrollViewerHandlerMs += elapsed; } + else if (_inputState.CapturedPointerElement is InkCanvas dragInkCanvas) + { + var handlerStart = Stopwatch.GetTimestamp(); + dragInkCanvas.HandlePointerMoveFromInput(pointerPosition); + var elapsed = Stopwatch.GetElapsedTime(handlerStart).TotalMilliseconds; + _lastInputPointerMoveHandlerMs += elapsed; + } else if (_inputState.CapturedPointerElement is Slider dragSlider) { var handlerStart = Stopwatch.GetTimestamp(); @@ -1146,6 +1153,11 @@ target is not Button && { CapturePointer(target); } + else if (button == MouseButton.Left && target is InkCanvas inkCanvas && + inkCanvas.HandlePointerDownFromInput(pointerPosition)) + { + CapturePointer(target); + } else if (button == MouseButton.Left && target is Slider slider && slider.HandlePointerDownFromInput(pointerPosition)) { @@ -1229,6 +1241,10 @@ private void DispatchMouseUp(UIElement? target, Vector2 pointerPosition, MouseBu { scrollViewer.HandlePointerUpFromInput(); } + else if (_inputState.CapturedPointerElement is InkCanvas inkCanvas && button == MouseButton.Left) + { + inkCanvas.HandlePointerUpFromInput(); + } else if (_inputState.CapturedPointerElement is Slider slider && button == MouseButton.Left) { slider.HandlePointerUpFromInput();