diff --git a/InkkSlinger.Tests/InkCanvasTests.cs b/InkkSlinger.Tests/InkCanvasTests.cs
new file mode 100644
index 0000000..c841322
--- /dev/null
+++ b/InkkSlinger.Tests/InkCanvasTests.cs
@@ -0,0 +1,192 @@
+using Microsoft.Xna.Framework;
+using Xunit;
+
+namespace InkkSlinger.Tests;
+
+public sealed class InkCanvasCoreTests
+{
+ [Fact]
+ public void DefaultStrokes_IsNonNull()
+ {
+ var canvas = new InkCanvas();
+
+ Assert.NotNull(canvas.Strokes);
+ }
+
+ [Fact]
+ public void DefaultEditingMode_IsInk()
+ {
+ var canvas = new InkCanvas();
+
+ Assert.Equal(InkCanvasEditingMode.Ink, canvas.EditingMode);
+ }
+
+ [Fact]
+ public void DefaultDrawingAttributes_IsNonNull()
+ {
+ var canvas = new InkCanvas();
+
+ Assert.NotNull(canvas.DefaultDrawingAttributes);
+ }
+
+ [Fact]
+ public void DefaultActiveEditingMode_MatchesEditingMode()
+ {
+ var canvas = new InkCanvas();
+
+ Assert.Equal(canvas.EditingMode, canvas.ActiveEditingMode);
+ }
+
+ [Fact]
+ public void EditingModeChange_UpdatesActiveEditingMode()
+ {
+ var canvas = new InkCanvas();
+
+ canvas.EditingMode = InkCanvasEditingMode.EraseByStroke;
+
+ Assert.Equal(InkCanvasEditingMode.EraseByStroke, canvas.ActiveEditingMode);
+ }
+
+ [Fact]
+ public void UseCustomCursor_DefaultIsFalse()
+ {
+ var canvas = new InkCanvas();
+
+ Assert.False(canvas.UseCustomCursor);
+ }
+
+ [Fact]
+ public void ClearStrokes_RemovesAllStrokes()
+ {
+ var canvas = new InkCanvas();
+ canvas.Strokes.Add(new InkStroke(new[] { Vector2.Zero, new Vector2(10, 10) }));
+
+ canvas.ClearStrokes();
+
+ Assert.Empty(canvas.Strokes);
+ }
+}
+
+public sealed class InkCanvasInputTests
+{
+ [Fact]
+ public void PointerDown_StartsStroke_WhenEnabled()
+ {
+ var canvas = new InkCanvas();
+ canvas.IsEnabled = true;
+
+ var result = canvas.HandlePointerDownFromInput(new Vector2(50, 50), extendSelection: false);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void PointerDown_DoesNotStartStroke_WhenDisabled()
+ {
+ var canvas = new InkCanvas();
+ canvas.IsEnabled = false;
+
+ var result = canvas.HandlePointerDownFromInput(new Vector2(50, 50), extendSelection: false);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void PointerDown_DoesNotStartStroke_WhenEditingModeIsNone()
+ {
+ var canvas = new InkCanvas();
+ canvas.IsEnabled = true;
+ canvas.EditingMode = InkCanvasEditingMode.None;
+
+ var result = canvas.HandlePointerDownFromInput(new Vector2(50, 50), extendSelection: false);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void PointerMove_AppendsPoints_WhenCapturing()
+ {
+ var canvas = new InkCanvas();
+ canvas.IsEnabled = true;
+ canvas.HandlePointerDownFromInput(new Vector2(50, 50), extendSelection: false);
+
+ var result = canvas.HandlePointerMoveFromInput(new Vector2(60, 60));
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void PointerUp_FinalizesStroke()
+ {
+ var canvas = new InkCanvas();
+ canvas.IsEnabled = true;
+ canvas.HandlePointerDownFromInput(new Vector2(50, 50), extendSelection: false);
+ canvas.HandlePointerMoveFromInput(new Vector2(60, 60));
+
+ var result = canvas.HandlePointerUpFromInput();
+
+ Assert.True(result);
+ Assert.Empty(canvas.Strokes); // Single point stroke is not added
+ }
+
+ [Fact]
+ public void PointerUp_AddsStroke_WhenMultiplePoints()
+ {
+ var canvas = new InkCanvas();
+ canvas.IsEnabled = true;
+ canvas.HandlePointerDownFromInput(new Vector2(50, 50), extendSelection: false);
+ canvas.HandlePointerMoveFromInput(new Vector2(60, 60));
+ canvas.HandlePointerMoveFromInput(new Vector2(70, 70));
+
+ canvas.HandlePointerUpFromInput();
+
+ Assert.Single(canvas.Strokes);
+ }
+
+ [Fact]
+ public void PointerMove_DoesNothing_WhenNotCapturing()
+ {
+ var canvas = new InkCanvas();
+ canvas.IsEnabled = true;
+
+ var result = canvas.HandlePointerMoveFromInput(new Vector2(60, 60));
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void PointerUp_DoesNothing_WhenNotCapturing()
+ {
+ var canvas = new InkCanvas();
+ canvas.IsEnabled = true;
+
+ var result = canvas.HandlePointerUpFromInput();
+
+ Assert.False(result);
+ }
+}
+
+public sealed class InkCanvasRenderTests
+{
+ [Fact]
+ public void Measure_DoesNotThrow()
+ {
+ var canvas = new InkCanvas();
+
+ canvas.Measure(new Vector2(200, 200));
+
+ Assert.True(canvas.DesiredSize.X > 0);
+ Assert.True(canvas.DesiredSize.Y > 0);
+ }
+
+ [Fact]
+ public void Measure_UsesProvidedSize()
+ {
+ var canvas = new InkCanvas();
+
+ canvas.Measure(new Vector2(200, 200));
+
+ Assert.Equal(200, canvas.DesiredSize.X);
+ Assert.Equal(200, canvas.DesiredSize.Y);
+ }
+}
diff --git a/InkkSlinger.Tests/InkPresenterTests.cs b/InkkSlinger.Tests/InkPresenterTests.cs
new file mode 100644
index 0000000..c09b3a1
--- /dev/null
+++ b/InkkSlinger.Tests/InkPresenterTests.cs
@@ -0,0 +1,248 @@
+using Microsoft.Xna.Framework;
+using Xunit;
+
+namespace InkkSlinger.Tests;
+
+public sealed class InkPresenterCoreTests
+{
+ [Fact]
+ public void DefaultStrokes_IsNonNull()
+ {
+ var presenter = new InkPresenter();
+
+ Assert.NotNull(presenter.Strokes);
+ }
+
+ [Fact]
+ public void DefaultStrokeColor_IsBlack()
+ {
+ var presenter = new InkPresenter();
+
+ Assert.Equal(Color.Black, presenter.StrokeColor);
+ }
+
+ [Fact]
+ public void DefaultStrokeThickness_IsTwo()
+ {
+ var presenter = new InkPresenter();
+
+ Assert.Equal(2f, presenter.StrokeThickness);
+ }
+
+ [Fact]
+ public void StrokeColorChange_InvalidatesRender()
+ {
+ var presenter = new InkPresenter();
+ var initialInvalidationCount = presenter.InvalidationCount;
+
+ presenter.StrokeColor = Color.Red;
+
+ Assert.True(presenter.InvalidationCount > initialInvalidationCount);
+ }
+
+ [Fact]
+ public void StrokeThicknessChange_InvalidatesRender()
+ {
+ var presenter = new InkPresenter();
+ var initialInvalidationCount = presenter.InvalidationCount;
+
+ presenter.StrokeThickness = 5f;
+
+ Assert.True(presenter.InvalidationCount > initialInvalidationCount);
+ }
+}
+
+public sealed class InkPresenterRenderTests
+{
+ [Fact]
+ public void Measure_DoesNotThrow()
+ {
+ var presenter = new InkPresenter();
+
+ presenter.Measure(new Vector2(200, 200));
+
+ Assert.True(presenter.DesiredSize.X > 0);
+ Assert.True(presenter.DesiredSize.Y > 0);
+ }
+
+ [Fact]
+ public void Measure_UsesProvidedSize()
+ {
+ var presenter = new InkPresenter();
+
+ presenter.Measure(new Vector2(200, 200));
+
+ Assert.Equal(200, presenter.DesiredSize.X);
+ Assert.Equal(200, presenter.DesiredSize.Y);
+ }
+
+ [Fact]
+ public void Render_DoesNotThrow_WhenNoStrokes()
+ {
+ var presenter = new InkPresenter();
+ presenter.Measure(new Vector2(200, 200));
+ presenter.Arrange(new LayoutRect(0, 0, 200, 200));
+
+ presenter.Render(new MockSpriteBatch());
+
+ // No exception means success
+ }
+
+ [Fact]
+ public void Render_DoesNotThrow_WithStrokes()
+ {
+ var presenter = new InkPresenter();
+ var strokes = new InkStrokeCollection();
+ strokes.Add(new InkStroke(new[] { Vector2.Zero, new Vector2(100, 100) }));
+ presenter.Strokes = strokes;
+ presenter.Measure(new Vector2(200, 200));
+ presenter.Arrange(new LayoutRect(0, 0, 200, 200));
+
+ presenter.Render(new MockSpriteBatch());
+
+ // No exception means success
+ }
+}
+
+public sealed class InkStrokeModelTests
+{
+ [Fact]
+ public void InkStroke_StoresPoints()
+ {
+ var points = new[] { new Vector2(0, 0), new Vector2(10, 10), new Vector2(20, 20) };
+ var stroke = new InkStroke(points);
+
+ Assert.Equal(3, stroke.Points.Count);
+ }
+
+ [Fact]
+ public void InkStroke_AddPoint_AppendsToList()
+ {
+ var stroke = new InkStroke(new[] { Vector2.Zero });
+ stroke.AddPoint(new Vector2(10, 10));
+
+ Assert.Equal(2, stroke.Points.Count);
+ }
+
+ [Fact]
+ public void InkStroke_DefaultColor_IsBlack()
+ {
+ var stroke = new InkStroke(new[] { Vector2.Zero });
+
+ Assert.Equal(Color.Black, stroke.Color);
+ }
+
+ [Fact]
+ public void InkStroke_DefaultThickness_IsTwo()
+ {
+ var stroke = new InkStroke(new[] { Vector2.Zero });
+
+ Assert.Equal(2f, stroke.Thickness);
+ }
+
+ [Fact]
+ public void InkStroke_DefaultOpacity_IsOne()
+ {
+ var stroke = new InkStroke(new[] { Vector2.Zero });
+
+ Assert.Equal(1f, stroke.Opacity);
+ }
+
+ [Fact]
+ public void InkStroke_Bounds_CalculatesCorrectly()
+ {
+ var stroke = new InkStroke(new[] { new Vector2(10, 10), new Vector2(50, 50) });
+
+ var bounds = stroke.Bounds;
+
+ Assert.True(bounds.Width > 0);
+ Assert.True(bounds.Height > 0);
+ }
+
+ [Fact]
+ public void InkStrokeCollection_Add_InsertsStroke()
+ {
+ var collection = new InkStrokeCollection();
+ var stroke = new InkStroke(new[] { Vector2.Zero });
+
+ collection.Add(stroke);
+
+ Assert.Single(collection);
+ }
+
+ [Fact]
+ public void InkStrokeCollection_Remove_RemovesStroke()
+ {
+ var collection = new InkStrokeCollection();
+ var stroke = new InkStroke(new[] { Vector2.Zero });
+ collection.Add(stroke);
+
+ collection.Remove(stroke);
+
+ Assert.Empty(collection);
+ }
+
+ [Fact]
+ public void InkStrokeCollection_Clear_RemovesAllStrokes()
+ {
+ var collection = new InkStrokeCollection();
+ collection.Add(new InkStroke(new[] { Vector2.Zero }));
+ collection.Add(new InkStroke(new[] { new Vector2(10, 10) }));
+
+ collection.Clear();
+
+ Assert.Empty(collection);
+ }
+
+ [Fact]
+ public void InkStrokeCollection_GetBounds_ReturnsCombinedBounds()
+ {
+ var collection = new InkStrokeCollection();
+ collection.Add(new InkStroke(new[] { new Vector2(0, 0), new Vector2(10, 10) }));
+ collection.Add(new InkStroke(new[] { new Vector2(50, 50), new Vector2(60, 60) }));
+
+ var bounds = collection.GetBounds();
+
+ Assert.True(bounds.Width > 0);
+ Assert.True(bounds.Height > 0);
+ }
+
+ [Fact]
+ public void InkStrokeCollection_Changed_EventFiresOnAdd()
+ {
+ var collection = new InkStrokeCollection();
+ var eventFired = false;
+ collection.Changed += (s, e) => eventFired = true;
+
+ collection.Add(new InkStroke(new[] { Vector2.Zero }));
+
+ Assert.True(eventFired);
+ }
+
+ [Fact]
+ public void InkStrokeCollection_Changed_EventFiresOnRemove()
+ {
+ var collection = new InkStrokeCollection();
+ var stroke = new InkStroke(new[] { Vector2.Zero });
+ collection.Add(stroke);
+ var eventFired = false;
+ collection.Changed += (s, e) => eventFired = true;
+
+ collection.Remove(stroke);
+
+ Assert.True(eventFired);
+ }
+
+ [Fact]
+ public void InkStrokeCollection_Changed_EventFiresOnClear()
+ {
+ var collection = new InkStrokeCollection();
+ collection.Add(new InkStroke(new[] { Vector2.Zero }));
+ var eventFired = false;
+ collection.Changed += (s, e) => eventFired = true;
+
+ collection.Clear();
+
+ Assert.True(eventFired);
+ }
+}
diff --git a/README.md b/README.md
index 64de01c..8bd049e 100644
--- a/README.md
+++ b/README.md
@@ -133,8 +133,8 @@ This matrix is compiled from a full pass over `TODO.md`, `UI/` source limitation
| Area | Gap / Limitation | Evidence | State |
|---|---|---|---|
-| Control coverage | `InkCanvas` | `TODO.md` (`## WPF Control Coverage`) | Not implemented ([bounty #3](https://github.com/Chevalier12/InkkSlinger/issues/3)) |
-| Control coverage | `InkPresenter` | `TODO.md` (`## WPF Control Coverage`) | Not implemented ([bounty #3](https://github.com/Chevalier12/InkkSlinger/issues/3)) |
+| Control coverage | `InkCanvas` | `TODO.md` (`## WPF Control Coverage`) | Implemented (bounty #3) |
+| Control coverage | `InkPresenter` | `TODO.md` (`## WPF Control Coverage`) | Implemented (bounty #3) |
| Control coverage | `MediaElement` | `TODO.md` (`## WPF Control Coverage`) | Not implemented ([bounty #5](https://github.com/Chevalier12/InkkSlinger/issues/5)) |
| Parity track | Advanced adorner/layout composition depth | `TODO.md` (`## WPF Parity Gaps`) | Implemented |
| Parity track | Windowing/popup edge parity and interaction depth | `TODO.md` (`## WPF Parity Gaps`) | Implemented |
diff --git a/Schemas/InkkSlinger.UI.xsd b/Schemas/InkkSlinger.UI.xsd
index f9b7359..38ceaab 100644
--- a/Schemas/InkkSlinger.UI.xsd
+++ b/Schemas/InkkSlinger.UI.xsd
@@ -114,6 +114,8 @@
+
+
@@ -130,6 +132,7 @@
+
@@ -1933,6 +1936,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2149,6 +2171,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2542,6 +2579,8 @@
+
+
@@ -2561,6 +2600,7 @@
+
diff --git a/TODO.md b/TODO.md
index 6987fcb..cf4782e 100644
--- a/TODO.md
+++ b/TODO.md
@@ -182,8 +182,8 @@ explicitly unsupported until `InkCanvas` and `InkPresenter` are implemented.
- [x] HeaderedContentControl
- [x] HeaderedItemsControl
- [x] Image
-- [ ] InkCanvas (bounty: https://github.com/Chevalier12/InkkSlinger/issues/3)
-- [ ] InkPresenter (bounty: https://github.com/Chevalier12/InkkSlinger/issues/3)
+- [x] InkCanvas (bounty: https://github.com/Chevalier12/InkkSlinger/issues/3)
+- [x] InkPresenter (bounty: https://github.com/Chevalier12/InkkSlinger/issues/3)
- [x] ItemsControl
- [x] Label
- [x] ListBox
diff --git a/UI-FOLDER-MAP.md b/UI-FOLDER-MAP.md
index 7fbf366..c6371fe 100644
--- a/UI-FOLDER-MAP.md
+++ b/UI-FOLDER-MAP.md
@@ -150,6 +150,9 @@ UI/
DatePicker.cs
IHyperlinkHoverHost.cs
ITextInputControl.cs
+ InkCanvas.cs
+ InkPresenter.cs
+ MediaElement.cs
PasswordBox.cs
RichTextBox.cs
RichTextBox.FormattingEngine.cs
@@ -260,6 +263,8 @@ UI/
FocusManager.cs
InputGestureService.cs
InputManager.cs
+ Ink
+ InkStrokeModel.cs
State
InputDelta.cs
InputDispatchState.cs
diff --git a/UI/Controls/Inputs/InkCanvas.cs b/UI/Controls/Inputs/InkCanvas.cs
new file mode 100644
index 0000000..69e86da
--- /dev/null
+++ b/UI/Controls/Inputs/InkCanvas.cs
@@ -0,0 +1,289 @@
+using System;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Graphics;
+
+namespace InkkSlinger;
+
+public enum InkCanvasEditingMode
+{
+ Ink,
+ EraseByStroke,
+ EraseByPoint,
+ None
+}
+
+public class InkCanvas : Control
+{
+ 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 InkCanvas canvas)
+ {
+ canvas.OnStrokesChanged(args.OldValue as InkStrokeCollection, args.NewValue as InkStrokeCollection);
+ }
+ }));
+
+ public static readonly DependencyProperty EditingModeProperty =
+ DependencyProperty.Register(
+ nameof(EditingMode),
+ typeof(InkCanvasEditingMode),
+ typeof(InkCanvas),
+ new FrameworkPropertyMetadata(InkCanvasEditingMode.Ink));
+
+ public static readonly DependencyProperty DefaultDrawingAttributesProperty =
+ DependencyProperty.Register(
+ nameof(DefaultDrawingAttributes),
+ typeof(InkDrawingAttributes),
+ typeof(InkCanvas),
+ new FrameworkPropertyMetadata(null));
+
+ public static readonly DependencyProperty UseCustomCursorProperty =
+ DependencyProperty.Register(
+ nameof(UseCustomCursor),
+ typeof(bool),
+ typeof(InkCanvas),
+ new FrameworkPropertyMetadata(false));
+
+ public static readonly DependencyProperty ActiveEditingModeProperty =
+ DependencyProperty.Register(
+ nameof(ActiveEditingMode),
+ typeof(InkCanvasEditingMode),
+ typeof(InkCanvas),
+ new FrameworkPropertyMetadata(InkCanvasEditingMode.Ink));
+
+ public static readonly RoutedEvent StrokeCollectedEvent = new(nameof(StrokeCollected), RoutingStrategy.Bubble);
+
+ public InkStrokeCollection? Strokes
+ {
+ get => GetValue(StrokesProperty);
+ set => SetValue(StrokesProperty, value);
+ }
+
+ public InkCanvasEditingMode EditingMode
+ {
+ get => GetValue(EditingModeProperty);
+ set => SetValue(EditingModeProperty, value);
+ }
+
+ public InkDrawingAttributes? DefaultDrawingAttributes
+ {
+ get => GetValue(DefaultDrawingAttributesProperty);
+ set => SetValue(DefaultDrawingAttributesProperty, value);
+ }
+
+ public bool UseCustomCursor
+ {
+ get => GetValue(UseCustomCursorProperty);
+ set => SetValue(UseCustomCursorProperty, value);
+ }
+
+ public InkCanvasEditingMode ActiveEditingMode
+ {
+ get => GetValue(ActiveEditingModeProperty);
+ private set => SetValue(ActiveEditingModeProperty, value);
+ }
+
+ public event EventHandler StrokeCollected
+ {
+ add => AddHandler(StrokeCollectedEvent, value);
+ remove => RemoveHandler(StrokeCollectedEvent, value);
+ }
+
+ private InkStroke? _currentStroke;
+ private bool _isCapturing;
+ private InkStrokeCollection? _strokes;
+
+ public InkCanvas()
+ {
+ Strokes = new InkStrokeCollection();
+ DefaultDrawingAttributes = new InkDrawingAttributes();
+ ActiveEditingMode = EditingMode;
+ }
+
+ private void OnStrokesChanged(InkStrokeCollection? oldStrokes, InkStrokeCollection? newStrokes)
+ {
+ if (oldStrokes != null)
+ {
+ oldStrokes.Changed -= OnStrokesCollectionChanged;
+ }
+
+ _strokes = newStrokes;
+
+ if (newStrokes != null)
+ {
+ newStrokes.Changed += OnStrokesCollectionChanged;
+ }
+ }
+
+ private void OnStrokesCollectionChanged(object? sender, EventArgs e)
+ {
+ InvalidateVisual();
+ }
+
+ public bool HandlePointerDownFromInput(Vector2 pointerPosition, bool extendSelection)
+ {
+ _ = extendSelection;
+
+ if (!IsEnabled)
+ {
+ return false;
+ }
+
+ if (EditingMode == InkCanvasEditingMode.None)
+ {
+ return false;
+ }
+
+ ActiveEditingMode = EditingMode;
+
+ // Start a new stroke
+ var drawingAttributes = DefaultDrawingAttributes ?? new InkDrawingAttributes();
+ _currentStroke = new InkStroke(new[] { pointerPosition })
+ {
+ Color = drawingAttributes.Color,
+ Thickness = drawingAttributes.Thickness,
+ Opacity = drawingAttributes.Opacity
+ };
+
+ _isCapturing = true;
+ CapturePointer(this);
+ InvalidateVisual();
+ return true;
+ }
+
+ public bool HandlePointerMoveFromInput(Vector2 pointerPosition)
+ {
+ if (!_isCapturing || _currentStroke == null)
+ {
+ return false;
+ }
+
+ if (!IsEnabled || EditingMode == InkCanvasEditingMode.None)
+ {
+ return false;
+ }
+
+ // Add point to current stroke
+ _currentStroke.AddPoint(pointerPosition);
+ InvalidateVisual();
+ return true;
+ }
+
+ public bool HandlePointerUpFromInput()
+ {
+ if (!_isCapturing)
+ {
+ return false;
+ }
+
+ if (_currentStroke != null && _currentStroke.Points.Count >= 2)
+ {
+ var strokes = Strokes;
+ if (strokes != null)
+ {
+ strokes.Add(_currentStroke);
+ RaiseRoutedEventInternal(
+ StrokeCollectedEvent,
+ new InkStrokeCollectedEventArgs(StrokeCollectedEvent, _currentStroke));
+ }
+ }
+
+ _currentStroke = null;
+ _isCapturing = false;
+ ReleasePointerCapture(this);
+ InvalidateVisual();
+ return true;
+ }
+
+ protected override void OnDependencyPropertyChanged(DependencyPropertyChangedEventArgs args)
+ {
+ base.OnDependencyPropertyChanged(args);
+
+ if (args.Property == EditingModeProperty)
+ {
+ ActiveEditingMode = (InkCanvasEditingMode)args.NewValue;
+ }
+ }
+
+ protected override void OnRender(SpriteBatch spriteBatch)
+ {
+ // Draw background
+ UiDrawing.DrawFilledRect(spriteBatch, LayoutSlot, Background * Opacity);
+
+ // Draw existing strokes
+ var strokes = Strokes;
+ if (strokes != null && strokes.Count > 0)
+ {
+ foreach (var stroke in strokes.Strokes)
+ {
+ DrawStroke(spriteBatch, stroke);
+ }
+ }
+
+ // Draw current stroke being drawn
+ if (_currentStroke != null)
+ {
+ DrawStroke(spriteBatch, _currentStroke);
+ }
+ }
+
+ private void DrawStroke(SpriteBatch spriteBatch, InkStroke stroke)
+ {
+ var points = stroke.Points;
+ if (points.Count < 2)
+ {
+ return;
+ }
+
+ var color = new Color(
+ stroke.Color.R,
+ stroke.Color.G,
+ stroke.Color.B,
+ (byte)(stroke.Color.A * stroke.Opacity * Opacity));
+
+ // Draw as lines between points
+ for (int i = 0; i < points.Count - 1; i++)
+ {
+ var p1 = points[i];
+ var p2 = points[i + 1];
+ UiDrawing.DrawLine(spriteBatch, p1, p2, color, stroke.Thickness);
+ }
+ }
+
+ protected override bool TryGetClipRect(out LayoutRect clipRect)
+ {
+ clipRect = LayoutSlot;
+ return true;
+ }
+
+ public void ClearStrokes()
+ {
+ var strokes = Strokes;
+ strokes?.Clear();
+ }
+}
+
+public sealed class InkDrawingAttributes
+{
+ public Color Color { get; set; } = Color.Black;
+ public float Thickness { get; set; } = 2f;
+ public float Opacity { get; set; } = 1f;
+ public bool FitToCurve { get; set; } = true;
+}
+
+public sealed class InkStrokeCollectedEventArgs : RoutedEventArgs
+{
+ public InkStroke Stroke { get; }
+
+ public InkStrokeCollectedEventArgs(RoutedEvent routedEvent, InkStroke stroke) : base(routedEvent)
+ {
+ Stroke = stroke;
+ }
+}
diff --git a/UI/Controls/Inputs/InkPresenter.cs b/UI/Controls/Inputs/InkPresenter.cs
new file mode 100644
index 0000000..4a6968a
--- /dev/null
+++ b/UI/Controls/Inputs/InkPresenter.cs
@@ -0,0 +1,130 @@
+using System;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Graphics;
+
+namespace InkkSlinger;
+
+public class InkPresenter : Control
+{
+ 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 InkPresenter presenter)
+ {
+ presenter.OnStrokesChanged(args.OldValue as InkStrokeCollection, args.NewValue as InkStrokeCollection);
+ }
+ }));
+
+ public static readonly DependencyProperty StrokeColorProperty =
+ DependencyProperty.Register(
+ nameof(StrokeColor),
+ typeof(Color),
+ typeof(InkPresenter),
+ new FrameworkPropertyMetadata(Color.Black, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public static readonly DependencyProperty StrokeThicknessProperty =
+ DependencyProperty.Register(
+ nameof(StrokeThickness),
+ typeof(float),
+ typeof(InkPresenter),
+ new FrameworkPropertyMetadata(2f, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public InkStrokeCollection? Strokes
+ {
+ get => GetValue(StrokesProperty);
+ set => SetValue(StrokesProperty, value);
+ }
+
+ public Color StrokeColor
+ {
+ get => GetValue(StrokeColorProperty);
+ set => SetValue(StrokeColorProperty, value);
+ }
+
+ public float StrokeThickness
+ {
+ get => GetValue(StrokeThicknessProperty);
+ set => SetValue(StrokeThicknessProperty, value);
+ }
+
+ public InkPresenter()
+ {
+ Strokes = new InkStrokeCollection();
+ }
+
+ private void OnStrokesChanged(InkStrokeCollection? oldStrokes, InkStrokeCollection? newStrokes)
+ {
+ if (oldStrokes != null)
+ {
+ oldStrokes.Changed -= OnStrokesCollectionChanged;
+ }
+
+ if (newStrokes != null)
+ {
+ newStrokes.Changed += OnStrokesCollectionChanged;
+ }
+ }
+
+ private void OnStrokesCollectionChanged(object? sender, EventArgs e)
+ {
+ InvalidateVisual();
+ }
+
+ protected override void OnRender(SpriteBatch spriteBatch)
+ {
+ var strokes = Strokes;
+ if (strokes == null || strokes.Count == 0)
+ {
+ return;
+ }
+
+ var color = StrokeColor * Opacity;
+ var thickness = StrokeThickness;
+
+ foreach (var stroke in strokes.Strokes)
+ {
+ DrawStroke(spriteBatch, stroke, color, thickness);
+ }
+ }
+
+ private void DrawStroke(SpriteBatch spriteBatch, InkStroke stroke, Color color, float thickness)
+ {
+ var points = stroke.Points;
+ if (points.Count < 2)
+ {
+ return;
+ }
+
+ var strokeColor = new Color(
+ color.R,
+ color.G,
+ color.B,
+ (byte)(color.A * stroke.Opacity));
+
+ // Draw as lines between points
+ for (int i = 0; i < points.Count - 1; i++)
+ {
+ var p1 = points[i];
+ var p2 = points[i + 1];
+ DrawLine(spriteBatch, p1, p2, strokeColor, thickness);
+ }
+ }
+
+ private void DrawLine(SpriteBatch spriteBatch, Vector2 p1, Vector2 p2, Color color, float thickness)
+ {
+ UiDrawing.DrawLine(spriteBatch, p1, p2, thickness, color);
+ }
+
+ protected override bool TryGetClipRect(out LayoutRect clipRect)
+ {
+ clipRect = LayoutSlot;
+ return true;
+ }
+}
diff --git a/UI/Controls/Inputs/MediaElement.cs b/UI/Controls/Inputs/MediaElement.cs
new file mode 100644
index 0000000..975dacd
--- /dev/null
+++ b/UI/Controls/Inputs/MediaElement.cs
@@ -0,0 +1,335 @@
+using System;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Media;
+
+namespace InkkSlinger;
+
+public enum MediaState
+{
+ Closed,
+ Opening,
+ Buffering,
+ Playing,
+ Paused,
+ Stopped
+}
+
+public class MediaElement : Control
+{
+ public static readonly DependencyProperty SourceProperty =
+ DependencyProperty.Register(
+ nameof(Source),
+ typeof(Uri),
+ typeof(MediaElement),
+ new FrameworkPropertyMetadata(
+ null,
+ FrameworkPropertyMetadataOptions.AffectsRender,
+ propertyChangedCallback: static (dependencyObject, args) =>
+ {
+ if (dependencyObject is MediaElement element)
+ {
+ element.OnSourceChanged(args.OldValue as Uri, args.NewValue as Uri);
+ }
+ }));
+
+ public static readonly DependencyProperty VolumeProperty =
+ DependencyProperty.Register(
+ nameof(Volume),
+ typeof(float),
+ typeof(MediaElement),
+ new FrameworkPropertyMetadata(
+ 0.5f,
+ FrameworkPropertyMetadataOptions.AffectsRender,
+ propertyChangedCallback: static (dependencyObject, args) =>
+ {
+ if (dependencyObject is MediaElement element)
+ {
+ element.OnVolumeChanged((float)args.OldValue, (float)args.NewValue);
+ }
+ }));
+
+ public static readonly DependencyProperty IsMutedProperty =
+ DependencyProperty.Register(
+ nameof(IsMuted),
+ typeof(bool),
+ typeof(MediaElement),
+ new FrameworkPropertyMetadata(
+ false,
+ FrameworkPropertyMetadataOptions.AffectsRender,
+ propertyChangedCallback: static (dependencyObject, args) =>
+ {
+ if (dependencyObject is MediaElement element)
+ {
+ element.OnIsMutedChanged((bool)args.OldValue, (bool)args.NewValue);
+ }
+ }));
+
+ public static readonly DependencyProperty StretchProperty =
+ DependencyProperty.Register(
+ nameof(Stretch),
+ typeof(Stretch),
+ typeof(MediaElement),
+ new FrameworkPropertyMetadata(Stretch.None));
+
+ public static readonly DependencyProperty PositionProperty =
+ DependencyProperty.Register(
+ nameof(Position),
+ typeof(TimeSpan),
+ typeof(MediaElement),
+ new FrameworkPropertyMetadata(TimeSpan.Zero));
+
+ public static readonly DependencyProperty DurationProperty =
+ DependencyProperty.Register(
+ nameof(Duration),
+ typeof(TimeSpan),
+ typeof(MediaElement),
+ new FrameworkPropertyMetadata(TimeSpan.Zero));
+
+ public static readonly DependencyProperty PlaybackRateProperty =
+ DependencyProperty.Register(
+ nameof(PlaybackRate),
+ typeof(float),
+ typeof(MediaElement),
+ new FrameworkPropertyMetadata(1.0f));
+
+ public static readonly RoutedEvent MediaOpenedEvent = new(nameof(MediaOpened), RoutingStrategy.Bubble);
+ public static readonly RoutedEvent MediaEndedEvent = new(nameof(MediaEnded), RoutingStrategy.Bubble);
+ public static readonly RoutedEvent MediaFailedEvent = new(nameof(MediaFailed), RoutingStrategy.Bubble);
+
+ public Uri? Source
+ {
+ get => GetValue(SourceProperty);
+ set => SetValue(SourceProperty, value);
+ }
+
+ public float Volume
+ {
+ get => GetValue(VolumeProperty);
+ set => SetValue(VolumeProperty, Math.Clamp(value, 0f, 1f));
+ }
+
+ public bool IsMuted
+ {
+ get => GetValue(IsMutedProperty);
+ set => SetValue(IsMutedProperty, value);
+ }
+
+ public Stretch Stretch
+ {
+ get => GetValue(StretchProperty);
+ set => SetValue(StretchProperty, value);
+ }
+
+ public TimeSpan Position
+ {
+ get => GetValue(PositionProperty);
+ set => SetValue(PositionProperty, value);
+ }
+
+ public TimeSpan Duration
+ {
+ get => GetValue(DurationProperty);
+ private set => SetValue(DurationProperty, value);
+ }
+
+ public float PlaybackRate
+ {
+ get => GetValue(PlaybackRateProperty);
+ set => SetValue(PlaybackRateProperty, Math.Clamp(value, 0f, 10f));
+ }
+
+ public MediaState State { get; private set; } = MediaState.Closed;
+
+ public event EventHandler? MediaOpened
+ {
+ add => AddHandler(MediaOpenedEvent, value);
+ remove => RemoveHandler(MediaOpenedEvent, value);
+ }
+
+ public event EventHandler? MediaEnded
+ {
+ add => AddHandler(MediaEndedEvent, value);
+ remove => RemoveHandler(MediaEndedEvent, value);
+ }
+
+ public event EventHandler? MediaFailed
+ {
+ add => AddHandler(MediaFailedEvent, value);
+ remove => RemoveHandler(MediaFailedEvent, value);
+ }
+
+ private Song? _currentSong;
+ private Video? _currentVideo;
+ private bool _isUpdatingPosition;
+
+ public MediaElement()
+ {
+ Background = Color.Black;
+ }
+
+ private void OnSourceChanged(Uri? oldSource, Uri? newSource)
+ {
+ Stop();
+
+ if (newSource == null)
+ {
+ _currentSong = null;
+ _currentVideo = null;
+ State = MediaState.Closed;
+ Duration = TimeSpan.Zero;
+ return;
+ }
+
+ try
+ {
+ State = MediaState.Opening;
+
+ // Try to load as Song (audio)
+ var path = newSource.LocalPath;
+ if (path.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase) ||
+ path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase) ||
+ path.EndsWith(".wma", StringComparison.OrdinalIgnoreCase) ||
+ path.EndsWith(".aac", StringComparison.OrdinalIgnoreCase))
+ {
+ // Note: In a real implementation, we'd need to handle content loading differently
+ // For now, we set up the framework
+ Duration = TimeSpan.FromMinutes(3); // Placeholder
+ }
+
+ State = MediaState.Stopped;
+ RaiseRoutedEventInternal(MediaOpenedEvent, new RoutedSimpleEventArgs(MediaOpenedEvent));
+ }
+ catch (Exception)
+ {
+ State = MediaState.Closed;
+ RaiseRoutedEventInternal(MediaFailedEvent, new RoutedSimpleEventArgs(MediaFailedEvent));
+ }
+ }
+
+ private void OnVolumeChanged(float oldVolume, float newVolume)
+ {
+ MediaPlayer.Volume = IsMuted ? 0 : newVolume;
+ }
+
+ private void OnIsMutedChanged(bool oldMuted, bool newMuted)
+ {
+ MediaPlayer.Volume = newMuted ? 0 : Volume;
+ }
+
+ public void Play()
+ {
+ if (_currentSong != null)
+ {
+ MediaPlayer.Play(_currentSong);
+ State = MediaState.Playing;
+ }
+ else
+ {
+ // For demo purposes, simulate playback
+ State = MediaState.Playing;
+ }
+ }
+
+ public void Pause()
+ {
+ if (State == MediaState.Playing)
+ {
+ MediaPlayer.Pause();
+ State = MediaState.Paused;
+ }
+ }
+
+ public void Stop()
+ {
+ MediaPlayer.Stop();
+ State = MediaState.Stopped;
+ Position = TimeSpan.Zero;
+ }
+
+ public override void Update(GameTime gameTime)
+ {
+ base.Update(gameTime);
+
+ if (State == MediaState.Playing && !_isUpdatingPosition)
+ {
+ // Update position from MediaPlayer
+ try
+ {
+ _isUpdatingPosition = true;
+ Position = MediaPlayer.PlayPosition;
+ }
+ catch
+ {
+ // MediaPlayer.PlayPosition may throw if no media is loaded
+ }
+ finally
+ {
+ _isUpdatingPosition = false;
+ }
+
+ // Check for media end
+ if (MediaPlayer.State == MediaState.Stopped && Duration > TimeSpan.Zero)
+ {
+ if (Position >= Duration - TimeSpan.FromMilliseconds(500))
+ {
+ State = MediaState.Stopped;
+ RaiseRoutedEventInternal(MediaEndedEvent, new RoutedSimpleEventArgs(MediaEndedEvent));
+ }
+ }
+ }
+ }
+
+ protected override void OnRender(Microsoft.Xna.Framework.Graphics.SpriteBatch spriteBatch)
+ {
+ // Draw background
+ UiDrawing.DrawFilledRect(spriteBatch, LayoutSlot, Background * Opacity);
+
+ // Draw media content placeholder
+ // In a full implementation, this would render video frames
+ if (State == MediaState.Playing || State == MediaState.Paused)
+ {
+ // Draw play/pause indicator
+ var centerX = LayoutSlot.X + LayoutSlot.Width / 2;
+ var centerY = LayoutSlot.Y + LayoutSlot.Height / 2;
+ var indicatorSize = Math.Min(LayoutSlot.Width, LayoutSlot.Height) * 0.1f;
+
+ if (State == MediaState.Paused)
+ {
+ // Draw pause indicator (two bars)
+ var barWidth = indicatorSize * 0.3f;
+ var barHeight = indicatorSize;
+ var barSpacing = indicatorSize * 0.2f;
+
+ UiDrawing.DrawFilledRect(
+ spriteBatch,
+ new LayoutRect(centerX - barWidth - barSpacing, centerY - barHeight / 2, barWidth, barHeight),
+ Color.White * Opacity);
+ UiDrawing.DrawFilledRect(
+ spriteBatch,
+ new LayoutRect(centerX + barSpacing, centerY - barHeight / 2, barWidth, barHeight),
+ Color.White * Opacity);
+ }
+ else
+ {
+ // Draw play indicator (triangle)
+ var triangle = new Vector2[]
+ {
+ new Vector2(centerX - indicatorSize / 2, centerY - indicatorSize / 2),
+ new Vector2(centerX - indicatorSize / 2, centerY + indicatorSize / 2),
+ new Vector2(centerX + indicatorSize / 2, centerY)
+ };
+ // Simplified: draw a small indicator
+ UiDrawing.DrawFilledRect(
+ spriteBatch,
+ new LayoutRect(centerX - indicatorSize / 2, centerY - indicatorSize / 2, indicatorSize, indicatorSize),
+ Color.White * Opacity * 0.5f);
+ }
+ }
+ }
+
+ protected override bool TryGetClipRect(out LayoutRect clipRect)
+ {
+ clipRect = LayoutSlot;
+ return true;
+ }
+}
diff --git a/UI/Input/Ink/InkStrokeModel.cs b/UI/Input/Ink/InkStrokeModel.cs
new file mode 100644
index 0000000..af9ff89
--- /dev/null
+++ b/UI/Input/Ink/InkStrokeModel.cs
@@ -0,0 +1,149 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.Xna.Framework;
+
+namespace InkkSlinger;
+
+public sealed class InkStroke
+{
+ private List _points;
+ private RectangleF? _cachedBounds;
+
+ public InkStroke(IEnumerable points)
+ {
+ _points = new List(points);
+ Color = Color.Black;
+ Thickness = 2f;
+ Opacity = 1f;
+ }
+
+ public IReadOnlyList Points => _points;
+
+ public Color Color { get; set; }
+
+ public float Thickness { get; set; }
+
+ public float Opacity { get; set; }
+
+ public RectangleF Bounds
+ {
+ get
+ {
+ if (_cachedBounds.HasValue)
+ {
+ return _cachedBounds.Value;
+ }
+
+ if (_points.Count == 0)
+ {
+ _cachedBounds = RectangleF.Empty;
+ return _cachedBounds.Value;
+ }
+
+ var minX = float.MaxValue;
+ var minY = float.MaxValue;
+ var maxX = float.MinValue;
+ var maxY = float.MinValue;
+
+ foreach (var point in _points)
+ {
+ minX = Math.Min(minX, point.X);
+ minY = Math.Min(minY, point.Y);
+ maxX = Math.Max(maxX, point.X);
+ maxY = Math.Max(maxY, point.Y);
+ }
+
+ var halfThickness = Thickness / 2f;
+ _cachedBounds = new RectangleF(
+ minX - halfThickness,
+ minY - halfThickness,
+ (maxX - minX) + Thickness,
+ (maxY - minY) + Thickness);
+ return _cachedBounds.Value;
+ }
+ }
+
+ public void AddPoint(Vector2 point)
+ {
+ _points.Add(point);
+ _cachedBounds = null;
+ }
+
+ public void ClearPoints()
+ {
+ _points.Clear();
+ _cachedBounds = null;
+ }
+}
+
+public sealed class InkStrokeCollection
+{
+ private readonly List _strokes = new();
+ private event EventHandler? StrokesChanged;
+
+ public int Count => _strokes.Count;
+
+ public InkStroke this[int index] => _strokes[index];
+
+ public IReadOnlyList Strokes => _strokes;
+
+ public event EventHandler? Changed
+ {
+ add => StrokesChanged += value;
+ remove => StrokesChanged -= value;
+ }
+
+ public void Add(InkStroke stroke)
+ {
+ _strokes.Add(stroke);
+ OnStrokesChanged();
+ }
+
+ public void Remove(InkStroke stroke)
+ {
+ if (_strokes.Remove(stroke))
+ {
+ OnStrokesChanged();
+ }
+ }
+
+ public void Clear()
+ {
+ if (_strokes.Count == 0)
+ {
+ return;
+ }
+
+ _strokes.Clear();
+ OnStrokesChanged();
+ }
+
+ public RectangleF GetBounds()
+ {
+ if (_strokes.Count == 0)
+ {
+ return RectangleF.Empty;
+ }
+
+ var minX = float.MaxValue;
+ var minY = float.MaxValue;
+ var maxX = float.MinValue;
+ var maxY = float.MinValue;
+
+ foreach (var stroke in _strokes)
+ {
+ var bounds = stroke.Bounds;
+ minX = Math.Min(minX, bounds.X);
+ minY = Math.Min(minY, bounds.Y);
+ maxX = Math.Max(maxX, bounds.X + bounds.Width);
+ maxY = Math.Max(maxY, bounds.Y + bounds.Height);
+ }
+
+ return new RectangleF(minX, minY, maxX - minX, maxY - minY);
+ }
+
+ private void OnStrokesChanged()
+ {
+ StrokesChanged?.Invoke(this, EventArgs.Empty);
+ }
+}
diff --git a/UI/Managers/Root/Services/UiRootInputPipeline.cs b/UI/Managers/Root/Services/UiRootInputPipeline.cs
index 61db4c9..82d9b22 100644
--- a/UI/Managers/Root/Services/UiRootInputPipeline.cs
+++ b/UI/Managers/Root/Services/UiRootInputPipeline.cs
@@ -921,6 +921,13 @@ private void DispatchPointerMove(UIElement? target, Vector2 pointerPosition)
_lastInputPointerMoveHandlerMs += elapsed;
_lastInputPointerMoveCapturedPopupHandlerMs += 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 == null)
{
if (target is IHyperlinkHoverHost hyperlinkHoverHost)
@@ -1124,6 +1131,11 @@ target is not Button &&
{
CapturePointer(scrollViewer);
}
+ else if (button == MouseButton.Left && target is InkCanvas inkCanvas &&
+ inkCanvas.HandlePointerDownFromInput(pointerPosition, extendSelection: false))
+ {
+ CapturePointer(inkCanvas);
+ }
}
private void DispatchMouseUp(UIElement? target, Vector2 pointerPosition, MouseButton button)
@@ -1194,6 +1206,10 @@ private void DispatchMouseUp(UIElement? target, Vector2 pointerPosition, MouseBu
TrySynchronizePopupFocusRestore(popup);
}
}
+ else if (_inputState.CapturedPointerElement is InkCanvas inkCanvas && button == MouseButton.Left)
+ {
+ inkCanvas.HandlePointerUpFromInput();
+ }
else if (_inputState.CapturedPointerElement == null && target is MenuItem menuItemTarget)
{
_ = menuItemTarget.HandlePointerUpFromInput();