Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Lessons.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
- Treat screenshot-reported UI precision bugs as shared geometry suspects first. Hit testing, caret placement, slider bounds, and popup placement usually belong in shared measurement, transform, bounds, or z-layer code rather than one-off component fixes.
- Keep the UI A/B sample useful as a validation tool, not just a showcase. Add visible state, labels, and layout previews when controls otherwise look inert or ambiguous.
- For retained text input, caret, selection, and scrolling should share measured text advances. Any fallback heuristic must be treated as a temporary approximation and tested against proportional text.
- For keyboard event contracts, treat Win32 `WasDown` as an auto-repeat signal only on key-down events. Key-up messages normally report the key was previously down and should not make `IsRepeat` true.
- Numeric UI controls should reject non-finite `Minimum`, `Maximum`, `Value`, and step values before clamping or render math runs. Letting `NaN` or infinities enter range state can poison slider/progress geometry.
- For retained UI controls that render text with measured glyph widths, auto-size measurement should use the same measured text path. Character-count heuristics are only safe as fallbacks when no render measurement context is available.

Expand Down
26 changes: 25 additions & 1 deletion src/ModernOverlay/Events.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,29 @@ public OverlayKeyboardEventArgs(
bool wasDown,
bool isTransitionState,
OverlayModifierKeys modifiers)
: this(
virtualKey,
isSystemKey,
repeatCount,
scanCode,
isExtendedKey,
wasDown,
isTransitionState,
modifiers,
wasDown || repeatCount > 1)
{
}

internal OverlayKeyboardEventArgs(
int virtualKey,
bool isSystemKey,
int repeatCount,
int scanCode,
bool isExtendedKey,
bool wasDown,
bool isTransitionState,
OverlayModifierKeys modifiers,
bool isRepeat)
{
VirtualKey = virtualKey;
IsSystemKey = isSystemKey;
Expand All @@ -138,6 +161,7 @@ public OverlayKeyboardEventArgs(
WasDown = wasDown;
IsTransitionState = isTransitionState;
Modifiers = modifiers;
IsRepeat = isRepeat;
}

public int VirtualKey { get; }
Expand All @@ -154,7 +178,7 @@ public OverlayKeyboardEventArgs(

public bool IsTransitionState { get; }

public bool IsRepeat => WasDown || RepeatCount > 1;
public bool IsRepeat { get; }

public OverlayModifierKeys Modifiers { get; }
}
Expand Down
3 changes: 2 additions & 1 deletion src/ModernOverlay/OverlayWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -865,7 +865,8 @@ private void HandleKeyboardEvent(Win32KeyboardEvent keyboardEvent)
keyboardEvent.IsExtendedKey,
keyboardEvent.WasDown,
keyboardEvent.IsTransitionState,
modifiers);
modifiers,
keyboardEvent.IsPressed && (keyboardEvent.WasDown || keyboardEvent.RepeatCount > 1));

if (keyboardEvent.IsPressed)
{
Expand Down
3 changes: 3 additions & 0 deletions tests/ModernOverlay.Tests/OverlayUiButtonControlTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ public async Task ToggleButtonTogglesByPointerKeyboardCommandAndSkipsDisabledInp
using OverlayUiRoot ui = OverlayUi.Attach(overlay, new OverlayUiOptions { RegisterInputRegions = false });
ToggleButton toggle = CreateButton<ToggleButton>("Toggle", 10f, 10f);
int checkedChanges = 0;
int checkStateChanges = 0;
int commandCalls = 0;
toggle.CheckedChanged += (_, _) => checkedChanges++;
toggle.CheckStateChanged += (_, _) => checkStateChanges++;
toggle.Command = new UiCommand(_ => commandCalls++);
ui.Root.Children.Add(toggle);
ui.Render(new DrawContext());
Expand All @@ -69,6 +71,7 @@ public async Task ToggleButtonTogglesByPointerKeyboardCommandAndSkipsDisabledInp

Assert.IsFalse(toggle.IsChecked);
Assert.AreEqual(2, checkedChanges);
Assert.AreEqual(2, checkStateChanges);
Assert.AreEqual(2, commandCalls);
}

Expand Down
9 changes: 9 additions & 0 deletions tests/ModernOverlay.Tests/OverlayUiCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,24 @@ public void UiCommandPassesParametersAndSkipsExecutionWhenUnavailable()
object? executedParameter = null;
bool canExecute = false;
UiCommand command = new(parameter => executedParameter = parameter, _ => canExecute);
int canExecuteChanges = 0;
command.CanExecuteChanged += (sender, args) =>
{
Assert.AreSame(command, sender);
Assert.AreSame(EventArgs.Empty, args);
canExecuteChanges++;
};

command.Execute("blocked");
Assert.IsNull(executedParameter);

canExecute = true;
command.RaiseCanExecuteChanged();
command.Execute("allowed");

Assert.AreEqual("allowed", executedParameter);
Assert.IsTrue(command.CanExecute("allowed"));
Assert.AreEqual(1, canExecuteChanges);
}

[TestMethod]
Expand Down
18 changes: 18 additions & 0 deletions tests/ModernOverlay.Tests/OverlayUiElementModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,39 @@ public async Task AddingAndRemovingChildUpdatesParentRootAndLifecycleEvents()
await using OverlayWindow overlay = await CreateOverlayAsync();
using OverlayUiRoot ui = OverlayUi.Attach(overlay, new OverlayUiOptions { RegisterInputRegions = false });
var child = new LifecycleElement();
int attachedEvents = 0;
int detachedEvents = 0;
child.Attached += (sender, args) =>
{
Assert.AreSame(child, sender);
Assert.AreSame(EventArgs.Empty, args);
attachedEvents++;
};
child.Detached += (sender, args) =>
{
Assert.AreSame(child, sender);
Assert.AreSame(EventArgs.Empty, args);
detachedEvents++;
};

ui.Root.Children.Add(child);

Assert.AreSame(ui.Root, child.Parent);
Assert.AreSame(ui, child.Root);
Assert.AreEqual(1, child.AttachedCount);
Assert.AreEqual(1, attachedEvents);
Assert.AreEqual(0, child.DetachedCount);
Assert.AreEqual(0, detachedEvents);
Assert.AreEqual(2, ui.Metrics.ElementCount);

Assert.IsTrue(ui.Root.Children.Remove(child));

Assert.IsNull(child.Parent);
Assert.IsNull(child.Root);
Assert.AreEqual(1, child.AttachedCount);
Assert.AreEqual(1, attachedEvents);
Assert.AreEqual(1, child.DetachedCount);
Assert.AreEqual(1, detachedEvents);
Assert.AreEqual(1, ui.Metrics.ElementCount);
}

Expand Down
39 changes: 39 additions & 0 deletions tests/ModernOverlay.Tests/OverlayUiFocusKeyboardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public sealed class OverlayUiFocusKeyboardTests
"parent-down:Bubble:True:Control",
"child-up:Direct",
];
private static readonly string[] ExpectedTextInputRoute = ["child:Direct:Å"];

[TestMethod]
[TestCategory("WindowsIntegration")]
Expand Down Expand Up @@ -140,6 +141,37 @@ public async Task KeyboardEventsRouteFromFocusedElementAndRespectHandled()
CollectionAssert.AreEqual(ExpectedKeyboardRoute, route);
}

[TestMethod]
[TestCategory("WindowsIntegration")]
public async Task TextInputEventsRouteFromFocusedElementAndRespectHandled()
{
await using OverlayWindow overlay = await CreateOverlayAsync();
using OverlayUiRoot ui = OverlayUi.Attach(overlay, new OverlayUiOptions { RegisterInputRegions = false });
Canvas parent = new()
{
Width = 120f,
Height = 80f,
};
ProbeElement child = CreateFocusableElement(10f, 10f, 40f, 20f);
List<string> route = [];
child.TextInput += (_, args) =>
{
Assert.AreSame(child, args.OriginalSource);
Assert.AreSame(child, args.Source);
Assert.AreEqual("Å", args.Text);
route.Add($"child:{args.RoutePhase}:{args.Text}");
args.Handled = true;
};
parent.TextInput += (_, _) => route.Add("parent");
parent.Children.Add(child);
ui.Root.Children.Add(parent);

child.Focus();
DispatchText(overlay, "Å");

CollectionAssert.AreEqual(ExpectedTextInputRoute, route);
}

private static async ValueTask<OverlayWindow> CreateOverlayAsync()
=> await OverlayWindow.CreateAsync(new OverlayWindowOptions
{
Expand Down Expand Up @@ -184,6 +216,13 @@ private static void DispatchKey(
method.Invoke(overlay, [new Win32KeyboardEvent(virtualKey, pressed, false, repeatCount, 0, false, wasDown, !pressed, modifiers)]);
}

private static void DispatchText(OverlayWindow overlay, string text)
{
MethodInfo method = typeof(OverlayWindow).GetMethod("HandleTextInputEvent", BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new MissingMethodException(nameof(OverlayWindow), "HandleTextInputEvent");
method.Invoke(overlay, [new Win32TextInputEvent(text, false)]);
}

private sealed class ProbeElement : UiElement
{
}
Expand Down
70 changes: 58 additions & 12 deletions tests/ModernOverlay.Tests/OverlayUiInputRoutingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,22 @@ public async Task PointerEventsRouteDirectThenBubble()
};
ProbeElement child = CreateInputElement(20f, 20f, 30f, 30f);
List<string> route = [];
child.PointerPressed += (_, args) => route.Add($"child:{args.RoutePhase}");
child.PointerPressed += (_, args) =>
{
Assert.AreSame(child, args.OriginalSource);
Assert.AreSame(child, args.Source);
Assert.AreEqual(OverlayPointerEventKind.Pressed, args.Kind);
Assert.AreEqual(OverlayPointerButton.Left, args.Button);
Assert.AreEqual(new PointF(25f, 25f), args.Position);
route.Add($"child:{args.RoutePhase}");
};
parent.PointerPressed += (_, args) =>
{
Assert.AreSame(child, args.OriginalSource);
Assert.AreSame(parent, args.Source);
Assert.AreEqual(OverlayPointerEventKind.Pressed, args.Kind);
Assert.AreEqual(OverlayPointerButton.Left, args.Button);
Assert.AreEqual(new PointF(25f, 25f), args.Position);
route.Add($"parent:{args.RoutePhase}");
};
parent.Children.Add(child);
Expand Down Expand Up @@ -81,17 +92,31 @@ public async Task PointerEnterLeaveClickDoubleClickAndWheelAreTranslated()
Canvas.SetTop(button, 10f);
int enterCount = 0;
int exitCount = 0;
int wheelDelta = 0;
bool horizontalWheel = false;
List<int> clickCounts = [];
button.PointerEntered += (_, _) => enterCount++;
button.PointerExited += (_, _) => exitCount++;
UiPointerEventArgs? entered = null;
UiPointerEventArgs? exited = null;
UiPointerEventArgs? wheel = null;
UiElement? wheelOriginalSource = null;
UiElement? wheelSource = null;
UiRoutedEventPhase? wheelRoutePhase = null;
List<UiClickEventArgs> clicks = [];
button.PointerEntered += (_, args) =>
{
enterCount++;
entered = args;
};
button.PointerExited += (_, args) =>
{
exitCount++;
exited = args;
};
button.PointerWheel += (_, args) =>
{
wheelDelta = args.WheelDelta;
horizontalWheel = args.IsHorizontalWheel;
wheel = args;
wheelOriginalSource = args.OriginalSource;
wheelSource = args.Source;
wheelRoutePhase = args.RoutePhase;
};
button.Click += (_, args) => clickCounts.Add(args.ClickCount);
button.Click += (_, args) => clicks.Add(args);
ui.Root.Children.Add(button);
ui.Render(new DrawContext());

Expand All @@ -103,9 +128,30 @@ public async Task PointerEnterLeaveClickDoubleClickAndWheelAreTranslated()

Assert.AreEqual(1, enterCount);
Assert.AreEqual(1, exitCount);
CollectionAssert.AreEqual(ExpectedClickCounts, clickCounts);
Assert.AreEqual(120, wheelDelta);
Assert.IsTrue(horizontalWheel);
Assert.IsNotNull(entered);
Assert.AreEqual(OverlayPointerEventKind.Moved, entered!.Kind);
Assert.AreEqual(OverlayPointerButton.None, entered.Button);
Assert.AreEqual(new PointF(20f, 20f), entered.Position);
Assert.IsNotNull(exited);
Assert.AreEqual(OverlayPointerEventKind.Moved, exited!.Kind);
Assert.AreEqual(OverlayPointerButton.None, exited.Button);
Assert.AreEqual(new PointF(150f, 100f), exited.Position);
CollectionAssert.AreEqual(ExpectedClickCounts, clicks.Select(click => click.ClickCount).ToArray());
Assert.AreEqual(new PointF(20f, 20f), clicks[0].Position);
Assert.AreEqual(OverlayPointerButton.Left, clicks[0].Button);
Assert.IsFalse(clicks[0].IsDoubleClick);
Assert.AreEqual(new PointF(20f, 20f), clicks[1].Position);
Assert.AreEqual(OverlayPointerButton.Left, clicks[1].Button);
Assert.IsTrue(clicks[1].IsDoubleClick);
Assert.IsNotNull(wheel);
Assert.AreEqual(OverlayPointerEventKind.Wheel, wheel!.Kind);
Assert.AreEqual(OverlayPointerButton.None, wheel.Button);
Assert.AreEqual(new PointF(20f, 20f), wheel.Position);
Assert.AreSame(button, wheelOriginalSource);
Assert.AreSame(button, wheelSource);
Assert.AreEqual(UiRoutedEventPhase.Direct, wheelRoutePhase);
Assert.AreEqual(120, wheel.WheelDelta);
Assert.IsTrue(wheel.IsHorizontalWheel);
}

[TestMethod]
Expand Down
3 changes: 3 additions & 0 deletions tests/ModernOverlay.Tests/OverlayUiRangeNumericTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ public async Task SliderClampsDragsCapturesTracksKeyboardDisablesAndRenders()
};
Canvas.SetLeft(slider, 10f);
Canvas.SetTop(slider, 10f);
int valueChanges = 0;
slider.ValueChanged += (_, _) => valueChanges++;
ui.Root.Children.Add(slider);
ui.Render(new DrawContext());

Expand Down Expand Up @@ -70,6 +72,7 @@ public async Task SliderClampsDragsCapturesTracksKeyboardDisablesAndRenders()
DispatchPointer(overlay, Win32PointerEventKind.Pressed, Win32PointerButton.Left, 60, 20);
DispatchKey(overlay, VirtualKeyLeft);
Assert.AreEqual(30f, slider.Value);
Assert.AreEqual(6, valueChanges);
}

[TestMethod]
Expand Down
Loading
Loading