Skip to content

Plan A: Windows app cutover from WPF to Avalonia#19

Merged
Zanges merged 12 commits into
mainfrom
plan-a-avalonia-wip
May 21, 2026
Merged

Plan A: Windows app cutover from WPF to Avalonia#19
Zanges merged 12 commits into
mainfrom
plan-a-avalonia-wip

Conversation

@Zanges
Copy link
Copy Markdown
Owner

@Zanges Zanges commented May 20, 2026

Summary

  • Lands Plan A — the Windows app moves off WPF onto Avalonia 11 in one big-bang cutover, so the single codebase is GUI-portable before Plan B (Linux). No behavior change for users, with one explicit redesign (tooltip authoring API → fluent builder).
  • Spike S2 PASSed (2026-05-20), so this follows the "WPF dropped entirely" fork — overlay also migrates to Avalonia; zero remaining WPF references in src/ or tests/.
  • Engine / Persistence / Contracts / Input / VirtualPad / Platform.Abstractions are byte-unchanged (Plan 0 already decoupled them). 339 non-UI tests pass unmodified.
  • Implementation diary lives in PHASE3_WIP_STATUS.md; the canonical write-down is PLAN_A_WINDOWS_AVALONIA.md.

What's in the box

  • 5 windows re-authored as Avalonia .axaml + code-behind: MainWindow (5 tabs), BindingEditorWindow, WidgetEditorWindow, OverlayWindow, Editor/CurveEditorWindow. Compiled bindings + x:DataType everywhere; theme-aware brushes for hint text.
  • Custom controls re-authored as Avalonia Controls: KeyCaptureBox, CurveEditorCanvas, ChainPreviewControl. WPF NumericUpDown + PlaceholderText retired in favor of Avalonia built-ins (Avalonia.Controls.NumericUpDown, TextBox.Watermark).
  • 8 overlay widgets re-authored (Background, Button, ButtonGrid, Axis, TwoAxis, MouseActivity, EngineStatusIndicator, Status) using Render(DrawingContext). StatusWidget per-glyph rotation math preserved.
  • Win32 interop re-hosted, zero Avalonia coupling: panic hotkey + WindowsGlobalHotkey now live on a self-owned message-only window with its own STA pump thread — survives engine crash and main-window destruction.
  • Tray — Avalonia TrayIcon + NativeMenu behind ITrayIcon. Ships a 32×32 joypad-glyph .ico as the tray + window icon.
  • Tooltip API redesigned to a fluent builder: Tooltip.Build().Typical(…).Description(…).Advice(…) from C#, {tt:Tooltip …} from AXAML. Rendering (Typical italic+dim → Description → Advice, 320 px auto-wrap) preserved exactly. The only intentional behavior-adjacent change in Plan A.

Verification

  • dotnet build Mouse2Joy.sln0 warnings, 0 errors.
  • dotnet test Mouse2Joy.sln348/348 pass (61 Contracts + 73 Persistence + 5 Platform.Abstractions + 200 Engine + 9 UI).
  • Manual UI walkthrough per the PLAN.md Phase 3 exit gate run by the user on 2026-05-21 — PASS across all 5 windows, tray, panic hotkey, overlay click-through above borderless-fullscreen, persistence round-trip, and the regression scan.
  • WPF reference grep (PresentationCore / PresentationFramework / WindowsBase / System.Xaml / System.Windows.{Controls,Media,Shapes,…} / <UseWPF> / Hardcodet.NotifyIcon.Wpf) — 0 hits across src/ and tests/.

Test plan

  • dotnet build Mouse2Joy.sln green
  • dotnet test Mouse2Joy.sln 348/348
  • Manual UI walkthrough (per-window + cross-cutting + regression scan) — PASS
  • WPF removal sweep — 0 hits

Commits

8 commits, each green at the boundary:

  • 00eb16c — foundation (build broken, foundation only)
  • fca7c73 — Chunk A: green build with Avalonia stubs
  • 250925a — lock in decisions
  • 386ed12 — Chunk B: MainWindow + tray icon
  • bed460b — Chunk C: BindingEditor + CurveEditor
  • e8f62d1 — Chunk D: WidgetEditorWindow
  • e792921 — Chunk E: tooltip call-site sweep
  • 8ff6971 — Chunk F: implementation write-down

Notes for review

  • Any small follow-up issues that surface from continued real-world use will be handled as separate fixes per the user's explicit instruction; this PR is the cutover, not a polish pass.
  • Plan B (Linux) is now the next executable plan; its overlay track is pre-shaped by the S2 PASS recorded here.

Zanges and others added 8 commits May 20, 2026 23:46
First chunk of the WPF -> Avalonia main-UI cutover. Lands the
toolkit-neutral foundation so subsequent sessions can iterate per-window
without re-litigating settled decisions. See
ai-docs/plans/windows-avalonia/PHASE3_WIP_STATUS.md for the full
status, decisions, and remaining work.

Locked in this session:
- Avalonia 11.2.3 + Fluent theme (system variant) + Inter font.
- In-place retarget of Mouse2Joy.App (no new host project).
- Fluent Tooltip.Build().Typical(...).Description(...).Advice(...) API +
  {tt:Tooltip ...} markup extension. Visible rendering preserved.
- Panic hotkey rehosted on a self-owned Win32 message-only window +
  dedicated message-pump thread in Platform.Windows (no HwndSource).
- UiThread helper in Mouse2Joy.UI wrapping Dispatcher.UIThread.
  No new IDispatcher port in Platform.Abstractions.
- Toolkit-agnostic Interop (WindowStyles takes raw HWND, MonitorInfo
  uses a PixelRect struct, no System.Windows dependency).

Build is intentionally red on this branch: the WPF views/controls were
deleted to make room for the Avalonia rewrites in chunks A-F. The first
follow-up session ("chunk A") brings the build green by porting the
custom controls + overlay widgets + stubbing the 5 windows so the AXAML
re-author can land per-window from there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restores `dotnet build Mouse2Joy.sln` (0 warnings, 0 errors) and
`dotnet test Mouse2Joy.sln` (348/348 passing) on the Avalonia branch.
UI is still placeholder text in each window; chunks B–F land the real
AXAML.

Ports:
- Custom controls: KeyCaptureBox, CurveEditorCanvas, ChainPreviewControl
  to Avalonia primitives (StyledProperty, Pointer events, FormattedText
  new ctor, AffectsRender, StreamGeometryContext.LineTo/EndFigure).
- PlaceholderText deleted — Avalonia's built-in TextBox.Watermark covers
  the case; AXAML call sites use it directly during chunks B–D.
- 8 overlay widgets to Avalonia Control + Render(DrawingContext):
  Background, Button, ButtonGrid, Axis, TwoAxis, MouseActivity,
  EngineStatusIndicator, Status. Status preserves the 644-line per-glyph
  layout; switched WPF RotateTransform(angle, cx, cy) to composed
  Matrix.CreateTranslation*CreateRotation*CreateTranslation and fixed
  non-mutating Rect.Union by reassigning each step.
- OverlayWidget base: IBrush/IPen, Color.TryParse, DrawRectangle(...,
  radiusX, radiusY) for rounded rects.
- OverlayCoordinator + OverlayWidgetHost on Avalonia Canvas; dispatcher
  marshaling via UiThread; window placement delegated to
  OverlayWindow.ApplyMonitor(MonitorInfo) so the PixelPoint-vs-DIP
  split lives in one place.
- MonitorInfo: dropped our PixelRect struct in favour of
  Avalonia.PixelRect (same shape, avoids name collision in view code);
  re-added BoundsDip as a tuple for DIP-space sizing.

Wider WPF removal:
- WindowsGlobalHotkey rewritten with the same self-owned Win32 message-
  only window pattern as PanicHotkey. Cross-thread RegisterHotKey is
  ferried to the pump thread via PostMessageW(WM_APP_REGISTER) because
  the call is tied to the registering thread's queue. The whole
  Mouse2Joy.Platform.Windows assembly is now WPF-free.
- Platform.Windows.csproj drops <UseWPF> and the Hardcodet WPF tray
  package; tray is now Avalonia in Mouse2Joy.App.
- Mouse2Joy.UI.Tests drops <UseWPF>.
- ModifierParamProxies.OpenEditor uses
  IClassicDesktopStyleApplicationLifetime.Windows to find the owner
  and calls ShowDialog(owner) instead of WPF's Window.Owner.

Packages:
- Added Microsoft.Win32.SystemEvents 8.0.0 (used to come in via WPF;
  OverlayCoordinator hooks DisplaySettingsChanged for monitor hotplug).

Stub windows authored so the composition root compiles:
- MainWindow, BindingEditorWindow, WidgetEditorWindow, CurveEditorWindow
  (single TextBlock each), and OverlayWindow with full click-through
  wiring via WindowStyles.MakeOverlay on Opened + 60 Hz DispatcherTimer
  sampling InputEngine.Current. Real layouts land in chunks B–F.

PHASE3_WIP_STATUS.md updated with the chunk-A completion record and the
remaining chunk-B-through-F roadmap.

Verification: dotnet build succeeds (0/0); dotnet test passes
348/348 across Contracts (61), Platform.Abstractions (5), UI (9),
Persistence (73), Engine (200). The app has not been launched yet —
there is no real UI to validate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the "Open questions for next session" list with a "Decisions
locked in" block so chunks B-F don't re-litigate settled choices:

- Compiled bindings everywhere (no `{Binding}` fallback; refactor or
  use explicit `{CompiledBinding}` with `x:DataType` for dynamic
  DataContexts like BindingEditorViewModel.SelectedProxy).
- Theme-aware brushes (`DynamicResource SystemControlForegroundBaseMediumLowBrush`
  or closest equivalent) for dim/hint text; drop hard-coded DimGray.
- DPI manifest stays PerMonitorV2 (confirmed, no action).
- Tray icon: ship a minimal .ico embedded as a resource (asset work
  in chunk B).
- Overlay tick rate stays 60 Hz DispatcherTimer per S2 spike;
  documented semantic change from WPF v1's InputEngine.Tick subscription.
- OverlayWindow parameterless ctor gates on Design.IsDesignMode so the
  Avalonia previewer doesn't run EnumDisplayMonitors against the host.
- Trust SystemEvents.DisplaySettingsChanged for display hotplug;
  smoke-test during chunk D, fall back to WM_DISPLAYCHANGE subclass
  per OverlayWindow only if it doesn't fire under Avalonia.
- BindingEditorWindow ctors stay () and (Binding?); window owns its
  VM internally.
- StatusWidget per-glyph rotation correctness validated by chunk E
  manual walkthrough; regression test added only if it visibly breaks.
- Application.ShutdownMode = OnExplicitShutdown confirmed; closing the
  main window minimizes-to-tray.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-author MainWindow.axaml as the real 5-tab UI (Profiles, Hotkeys,
Overlay, Settings, Setup), replacing the Chunk A green-build stub.
Compiled bindings, theme-aware brushes, and fluent tooltips throughout.

Avalonia idiom adaptations in the code-behind:
- MouseLeftButtonUp -> PointerReleased with MouseButton.Left guard
- CheckBox.Click -> IsCheckedChanged, with load-time suppression flags so
  settings don't get re-saved on window-open
- ListView -> ListBox; per-item style via <ListBox.Styles>
- WPF DataTrigger italic+dim cue -> Avalonia class selector
  (Classes.auto-label="{Binding IsAutoLabel}")
- ShowDialog awaited; dialog-opening handlers are async void
- MessageBox.Show -> WindowsMessageBox.Warn / AskYesNoCancel
- Application.Current.Shutdown() ->
  IClassicDesktopStyleApplicationLifetime.Shutdown()

Moved WindowsMessageBox from Mouse2Joy.App to Mouse2Joy.Platform.Windows
so UI code-behind can call it without UI depending on App. Added
AskYesNoCancel for the destructive remove-widget-with-children prompt.

Stub editor windows updated for the new dialog flow:
- BindingEditorWindow.Result (Binding?) -- left null in Chunk B
- WidgetEditorWindow.Result (WidgetConfig?) plus the (existing,
  siblings, monitors) ctor matching the WPF call shape

Branded 32x32 joypad-glyph Mouse2Joy.ico generated and wired:
- Packaged as <AvaloniaResource> in Mouse2Joy.App.csproj
- Tray icon loads via AssetLoader.Open("avares://Mouse2Joy/Assets/...")
- MainWindow consumes via Icon="avares://..." in AXAML
- Set as the exe's ApplicationIcon

Verification: dotnet build green (0/0); dotnet test 348/348 passing.
The app still has not been launched -- overlay window and the binding /
widget editors are stubs; full manual walkthrough is Chunk E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-author the modifier-chain binding editor and the curve-editor popout
as full Avalonia windows. Build remains green; 348/348 tests still pass.

BindingEditorWindow
- 6-section layout matching the WPF original: Label, Source/Target +
  Suppress, modifier chain (list + selected-card param pane), preview +
  auto-insert notice, validation banner, OK/Cancel.
- All 20 modifier-proxy DataTemplates inlined in Window.DataTemplates.
  ResourceDictionary cannot host unkeyed DataTemplate children
  (AVLN3000); a side ResourceInclude is not an option in Avalonia.
- Compiled bindings on every binding; x:DataType per template.
  SelectedValueBinding on the 4 enum combos uses ReflectionBinding for
  the item-level Tag binding (Tag lives on ComboBoxItem, not the proxy).
- Modifier-card selected affordance via class selector
  (Classes.selected="{Binding Selected}") instead of WPF DataTrigger.
- ListBoxItem chrome scoped via Style Selector="ListBox.chain-list
  ListBoxItem" so the system selection highlight doesn't fight the
  card's dark background.
- KeyCaptureBox commit subscribes to CapturedKeyProperty observable so
  the VM sees a fresh KeySource the moment the user presses a key.
- Auto-label preview surfaced via the TextBox's built-in Watermark
  property, mirrored from the VM's AutoLabel on Source/Target changes.
- Unbound KeySource guard surfaces through WindowsMessageBox.Warn (same
  Win32 helper used elsewhere); validation banner uses InvBoolConverter.

CurveEditorWindow popout
- 4-row layout (canvas / Symmetric + Points / hint / Close) hosting
  CurveEditorCanvas from Chunk A.
- CurveEditorWindowViewModel promoted to public so AXAML compiled
  bindings can resolve x:DataType against it. Linear resampling on
  PointCount change preserved from the WPF original.

MainWindow needed no edits; its add/edit/duplicate flows already check
dlg.Result, and the editor now populates it on OK.

Verification
- dotnet build Mouse2Joy.sln — 0 warnings, 0 errors.
- dotnet test Mouse2Joy.sln — 348/348 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Port the WPF widget Add/Edit dialog to Avalonia with the 17-row scaffold
preserved (Type / Label / Visible / Position / Monitor / Parent / Anchor
+ Self anchor / Offset X+reset / Offset Y+reset / Size / Width / Lock+
Swap / Height / Font (Status-only) / Options / Cancel-Save) and the
dynamic Options + Font panels rebuilt against Avalonia controls.

- Use the built-in Avalonia NumericUpDown (decimal-based) in place of
  the WPF custom one; convert at the staging-record boundary.
- Lock-aspect + B/I/U "checked" tint via a scoped
  Style Selector="ToggleButton.toggle-highlight:checked
                 /template/ ContentPresenter"
  replacing WPF's triggered-style override.
- ToolTip.SetTip for dynamic tooltip swaps (lock chip, monitor combo).
- TextBox.Watermark-based auto-label preview, matching the BindingEditor
  pattern from Chunk C.
- ItemsControl ItemsSource re-poke idiom for the dynamic Options + Font
  panels (Avalonia doesn't observe in-place List mutations).
- FontManager.Current.SystemFonts replaces WPF SystemFontFamilies.
- Color.TryParse replaces ColorConverter.ConvertFromString; brushes no
  longer Frozen.
- (IBrush?) cast on Brushes.Transparent for well-typed ?? coalesce.

OverlayWindow needed no config-mode chrome — it was permanently click-
through in WPF too — so the Chunk A stub is already the production
shape. PHASE3_WIP_STATUS.md updated with the Session 3 Chunk D log and
the remaining-work list reduced to Chunks E + F.

Verification:
- dotnet build Mouse2Joy.sln: 0 warnings, 0 errors.
- dotnet test Mouse2Joy.sln: 348/348 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewed every tooltip across the 5 windows against the WPF originals
and the TOOLTIP_AUTO_WRAP.md convention. Chunks B-D preserved tooltip
shape 1:1 with WPF, so the sweep is a minimal-delta outcome:

- WidgetEditor Label TextBox: upgraded the auto-label hint from a plain
  string to {tt:Tooltip Typical='Leave blank to auto-label by Type (and
  #N when there are siblings)', Description='...'} so the Typical line
  surfaces the "what happens if I leave this empty" answer where it's
  most useful (parallels BindingEditor's LabelTooltip resource).
- Added the tt: xmlns to WidgetEditorWindow root for the markup
  extension.

Remaining ~13 plain-string tooltips correctly stay plain (single-thought
content with no Typical/Advice line) and route through the app-wide
ToolTip MaxWidth=320 auto-wrap style.

DimGray sweep clean: 0 matches across src/Mouse2Joy.UI (Chunk-A
theme-aware-brushes decision fully landed).

PHASE3_WIP_STATUS.md updated with the Chunk E session block and a full
user-driven manual walkthrough checklist (per-window, cross-cutting,
regression scan) covering the Phase 3 exit gate that requires elevated
shell + hardware.

Verification:
- dotnet build Mouse2Joy.sln: 0 warnings, 0 errors
- dotnet test Mouse2Joy.sln: 348/348 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Plan A summary write-down per repo convention. Captures the
WPF → Avalonia cutover as a single Context / What-changed / Key-decisions
/ Files-touched / Follow-ups record so future work doesn't have to
reconstruct the rationale from the per-chunk PHASE3_WIP_STATUS diary.

Phase 4 WPF-removal confirmation:
- grep across src/ + tests/ for PresentationCore, PresentationFramework,
  WindowsBase, System.Xaml, System.Windows.{Controls,Media,Shapes,...},
  <UseWPF>, Hardcodet.NotifyIcon.Wpf returns 0 hits.
- System.Windows.Input.ICommand in ModifierParamProxies is the BCL type
  (System.ObjectModel.dll), not WPF.

Manual UI walkthrough was run by the user on plan-a-avalonia-wip on
2026-05-21 and PASSed (5-window + cross-cutting + regression checklist
from PLAN.md). Recorded in the write-down.

Verification:
- dotnet build Mouse2Joy.sln: 0 warnings, 0 errors
- dotnet test Mouse2Joy.sln: 348/348 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 20, 2026 23:43
@github-actions github-actions Bot added tests Test-only changes docs Documentation-only changes ui Changes to Mouse2Joy.UI app Changes to Mouse2Joy.App build Build, packaging, installer changes labels May 20, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR executes the “Plan A” Windows UI cutover by removing WPF and re-hosting the application UI, overlay, tray, and related infrastructure on Avalonia 11, while keeping non-UI layers unchanged and redesigning tooltip authoring to a fluent builder/markup-extension API.

Changes:

  • Re-authors the Windows UI (main window + editors) and overlay (window + widgets + host) from WPF XAML/controls to Avalonia AXAML/controls.
  • Removes WPF dependencies across src/ and tests/, adds Avalonia packages/resources, and rehomes Win32 interop (tray, dialogs, hotkeys) outside WPF.
  • Replaces tooltip authoring with Tooltip.Build() / {tt:Tooltip ...} while preserving the rendered tooltip layout.

Reviewed changes

Copilot reviewed 61 out of 62 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/Mouse2Joy.UI.Tests/Mouse2Joy.UI.Tests.csproj Drops <UseWPF> from UI tests project.
src/Mouse2Joy.UI/Views/WidgetEditorWindow.axaml Re-authors Widget editor window in Avalonia AXAML.
src/Mouse2Joy.UI/Views/OverlayWindow.xaml.cs Removes WPF overlay window code-behind.
src/Mouse2Joy.UI/Views/OverlayWindow.xaml Removes WPF overlay window XAML.
src/Mouse2Joy.UI/Views/OverlayWindow.axaml.cs Adds Avalonia overlay window code-behind + tick loop.
src/Mouse2Joy.UI/Views/OverlayWindow.axaml Adds Avalonia overlay window AXAML.
src/Mouse2Joy.UI/Views/MainWindow.xaml Removes WPF MainWindow XAML.
src/Mouse2Joy.UI/Views/MainWindow.axaml Adds Avalonia MainWindow AXAML.
src/Mouse2Joy.UI/Views/Editor/CurveEditorWindow.axaml.cs Ports curve editor window code-behind to Avalonia.
src/Mouse2Joy.UI/Views/Editor/CurveEditorWindow.axaml Ports curve editor window layout to AXAML + compiled bindings.
src/Mouse2Joy.UI/Views/BindingEditorWindow.xaml.cs Removes WPF binding editor code-behind.
src/Mouse2Joy.UI/Views/BindingEditorWindow.xaml Removes WPF binding editor XAML.
src/Mouse2Joy.UI/Views/BindingEditorWindow.axaml.cs Adds Avalonia binding editor code-behind and wiring.
src/Mouse2Joy.UI/ViewModels/MainViewModel.cs Switches UI marshalling to UiThread helper.
src/Mouse2Joy.UI/ViewModels/Editor/ModifierParamProxies.cs Updates curve editor dialog ownership for Avalonia desktop lifetime.
src/Mouse2Joy.UI/UiThread.cs Introduces Avalonia UI-thread dispatch wrapper.
src/Mouse2Joy.UI/Tooltips/TooltipTemplateSelector.cs Removes WPF tooltip template selector.
src/Mouse2Joy.UI/Tooltips/TooltipExtension.cs Adds AXAML markup extension for structured tooltips.
src/Mouse2Joy.UI/Tooltips/TooltipContent.cs Updates tooltip content carrier for Avalonia templates + builder flow.
src/Mouse2Joy.UI/Tooltips/Tooltip.cs Adds fluent tooltip builder API.
src/Mouse2Joy.UI/Overlay/Widgets/TwoAxisWidget.cs Ports overlay widget rendering to Avalonia drawing APIs.
src/Mouse2Joy.UI/Overlay/Widgets/MouseActivityWidget.cs Ports overlay widget rendering to Avalonia drawing APIs.
src/Mouse2Joy.UI/Overlay/Widgets/EngineStatusIndicatorWidget.cs Ports overlay widget rendering to Avalonia drawing APIs.
src/Mouse2Joy.UI/Overlay/Widgets/ButtonWidget.cs Ports overlay widget rendering to Avalonia drawing APIs.
src/Mouse2Joy.UI/Overlay/Widgets/ButtonGridWidget.cs Ports overlay widget rendering + text drawing to Avalonia.
src/Mouse2Joy.UI/Overlay/Widgets/BackgroundWidget.cs Ports overlay widget rendering to Avalonia drawing APIs.
src/Mouse2Joy.UI/Overlay/Widgets/AxisWidget.cs Ports overlay widget rendering and pen/brush types to Avalonia.
src/Mouse2Joy.UI/Overlay/OverlayWidgetHost.cs Switches widget host canvas type to Avalonia.
src/Mouse2Joy.UI/Overlay/OverlayWidget.cs Ports overlay widget base class from WPF FrameworkElement to Avalonia Control.
src/Mouse2Joy.UI/Overlay/OverlayCoordinator.cs Switches display-change marshal to UiThread and updates window sizing flow.
src/Mouse2Joy.UI/Mouse2Joy.UI.csproj Removes WPF; adds Avalonia packages and compiled-bindings default.
src/Mouse2Joy.UI/Interop/WindowStyles.cs Reworks overlay HWND styling helper to be toolkit-agnostic (HWND-based).
src/Mouse2Joy.UI/Interop/MonitorInfo.cs Switches monitor pixel types to Avalonia PixelRect and updates DIP helper.
src/Mouse2Joy.UI/Converters/InvBoolToVisConverter.cs Removes WPF bool-to-Visibility converter.
src/Mouse2Joy.UI/Converters/InvBoolConverter.cs Adds Avalonia inverse-bool converter for IsVisible.
src/Mouse2Joy.UI/Controls/PlaceholderText.cs Removes WPF placeholder adorner behavior (replaced by TextBox.Watermark).
src/Mouse2Joy.UI/Controls/NumericUpDown.xaml.cs Removes custom WPF NumericUpDown control.
src/Mouse2Joy.UI/Controls/NumericUpDown.xaml Removes custom WPF NumericUpDown XAML.
src/Mouse2Joy.UI/Controls/KeyCaptureBox.cs Ports key capture control to Avalonia input + styled properties.
src/Mouse2Joy.UI/Controls/CurveEditorCanvas.cs Ports curve editor canvas control to Avalonia rendering + pointer handling.
src/Mouse2Joy.UI/Controls/ChainPreviewControl.cs Ports chain preview control rendering to Avalonia.
src/Mouse2Joy.Platform.Windows/WindowsMessageBox.cs Adds Win32 MessageBox wrapper for blocking prompts outside toolkit dialogs.
src/Mouse2Joy.Platform.Windows/WindowsGlobalHotkey.cs Rehosts global hotkeys on dedicated message-only window + STA pump thread.
src/Mouse2Joy.Platform.Windows/WindowsAppPaths.cs Cleans unused import after WPF removal.
src/Mouse2Joy.Platform.Windows/PanicHotkey.cs Rehosts panic hotkey on dedicated message-only window + STA pump thread.
src/Mouse2Joy.Platform.Windows/Mouse2Joy.Platform.Windows.csproj Removes WPF + Hardcodet tray dependency from Platform.Windows.
src/Mouse2Joy.App/Program.cs Adds Avalonia entry point.
src/Mouse2Joy.App/Mouse2Joy.App.csproj Converts app host to Avalonia, adds icon as Avalonia resource, sets COM interop support.
src/Mouse2Joy.App/AvaloniaTrayIcon.cs Adds Avalonia-backed tray icon + native menu wrapper.
src/Mouse2Joy.App/App.xaml Removes WPF application resources/styles.
src/Mouse2Joy.App/App.axaml.cs Reworks composition root/bootstrap/teardown for Avalonia lifetime + tray + panic.
src/Mouse2Joy.App/App.axaml Adds Avalonia application styles + tooltip DataTemplate and max-width rule.
Directory.Packages.props Removes WPF tray package; adds Avalonia + SystemEvents versions.
ai-docs/implementations/PLAN_A_WINDOWS_AVALONIA.md Adds implementation write-up for the Avalonia cutover.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Mouse2Joy.UI/Controls/KeyCaptureBox.cs
Comment thread src/Mouse2Joy.UI/UiThread.cs Outdated
Comment thread src/Mouse2Joy.Platform.Windows/WindowsGlobalHotkey.cs Outdated
Comment thread src/Mouse2Joy.Platform.Windows/WindowsGlobalHotkey.cs
Comment thread src/Mouse2Joy.Platform.Windows/PanicHotkey.cs Outdated
Comment thread src/Mouse2Joy.UI/Views/BindingEditorWindow.axaml.cs
Zanges and others added 2 commits May 21, 2026 01:49
CI's `dotnet format Mouse2Joy.sln --verify-no-changes` rejected an
aligned-column layout in the AnchorPointOnRect switch expression
(arrows + arguments padded with extra spaces to line up across the
9 cases). The repo's analyzer rules don't allow trailing whitespace
inside expression arms, so the alignment had to go. Behavior
unchanged; formatter output verified clean (exit 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the 6 inline review comments from Copilot:

1. KeyCaptureBox: Extend static MapScancode table to cover NumPad 0-9
   + decimal/multiply/divide/add/subtract, US OEM punctuation (~ - = [
   ] \ ; ' , . / + backslash), and the lock/special keys (CapsLock,
   NumLock, Scroll, PrintScreen, Pause). For anything still uncovered
   (browser/media/launcher keys, locale-specific OEM), add a Win32
   MapVirtualKeyW(vk, MAPVK_VK_TO_VSC_EX) fallback so the captured
   scancode is correct instead of silently dropping to PhysicalKey.None.

2. UiThread.Invoke: rewrite the doc to accurately describe post-on-off-
   thread semantics ("queued asynchronously when off the UI thread")
   and add a new InvokeAsync(Action) -> Task for callers that actually
   need wait-for-completion across threads.

3. WindowsGlobalHotkey.Register: bound the pending.Done.Wait on a 5 s
   timeout. On timeout, remove the pending entry under the lock and
   throw TimeoutException so a wedged pump thread cannot deadlock the
   caller (e.g. UI thread during startup).

4. WindowsGlobalHotkey.Dispose: replace PostMessageW(_hwnd, WM_QUIT, ..)
   (WM_QUIT is a thread-queue message, not dispatched to a WndProc)
   with a custom WM_APP_QUIT that the WndProc handles by calling
   DestroyWindow -> WM_DESTROY -> PostQuitMessage. Guarantees the pump
   loop exits via the documented Win32 shutdown idiom. Dropped the now-
   unused WM_QUIT constant.

5. PanicHotkey.Dispose: same WM_APP_QUIT fix as WindowsGlobalHotkey.

6. BindingEditorWindow: store the IDisposable returned by Subscribe on
   KeyCaptureBox.CapturedKeyProperty and dispose it (plus unhook
   PropertyChanged) on the window's Closed event so the dialog can be
   GC'd cleanly after close.

Verification:
- dotnet build Mouse2Joy.sln -c Release: 0 warnings, 0 errors
- dotnet test Mouse2Joy.sln: 348/348 pass
- dotnet format Mouse2Joy.sln --verify-no-changes: clean (exit 0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 20, 2026 23:59
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 60 out of 61 changed files in this pull request and generated 1 comment.

Comment thread src/Mouse2Joy.UI/Views/BindingEditorWindow.axaml.cs
…nges

Copilot review on PR #19 (comment 3277855467) flagged that the chain
preview goes stale during edit because BindingEditorViewModel never
raises PropertyChanged(nameof(Modifiers)) for add / remove / reorder /
per-card edits — the VM uses a stable ObservableCollection reference,
with per-card mutations flowing through ModifierCardViewModel's
ModifierChanged event.

Hook _vm.Modifiers.CollectionChanged + each card's ModifierChanged,
refresh the preview from both, keep per-card hooks in sync on
collection mutations, and unhook everything on the window's Closed
event. Also dropped the dead nameof(Modifiers) arm from
OnVmPropertyChanged with a comment explaining the new routing.

Verification:
- dotnet build Mouse2Joy.sln -c Release: 0 warnings, 0 errors
- dotnet test Mouse2Joy.sln: 348/348 pass
- dotnet format Mouse2Joy.sln --verify-no-changes: clean (exit 0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Zanges
Copy link
Copy Markdown
Owner Author

Zanges commented May 21, 2026

Multi-angle local review — plan-a-avalonia-wip

Ran the 8-angle local reviewer (/review-pr) over the branch against origin/main. Posting the consolidated findings here so they can be tracked alongside the PR.

Critical (4)

  1. src/Mouse2Joy.UI/Overlay/OverlayCoordinator.cs:65DisplaySettingsChanged fires on a background thread and dispatches via UiThread.Invoke(...). If the OS posts the event during shutdown, the dispatched lambda can run on the UI thread after Dispose() has cleared _windows. It then re-enters EnsureWindowsForMonitors, constructs fresh OverlayWindow instances, and adds them to _windows — those windows are never closed and their per-window 60 Hz DispatcherTimers leak for the rest of the process lifetime. Guard the dispatched callback with if (_disposed) return; and/or check _disposed inside EnsureWindowsForMonitors.

  2. src/Mouse2Joy.App/AvaloniaTrayIcon.cs:83ShowNotification routes through Avalonia's WindowNotificationManager, which is a child of the main window's visual tree. Two problems compound:

    • _notifier is cached lazily against the first MainWindow we see (_notifier ??= new WindowNotificationManager(top)). After the window is closed-to-tray and reopened, _notifier still points at the disposed window — every subsequent notification silently drops.
    • Even before that, when MainWindow is hidden via Close-to-tray, the toast renders inside a hidden window and the user sees nothing.
    • Net effect: when the panic hotkey fires (precisely when the user is in a game with Mouse2Joy minimized), the "emulation forced Off" toast never reaches the user.
    • Fix: use a real shell toast (e.g. Microsoft.Toolkit.Uwp.Notifications) for tray-originated notifications, or at minimum flash the tray icon / change its tooltip text so the user gets some feedback. (Flagged by both UX and Stability — this is the most important fix on the branch.)
  3. src/Mouse2Joy.Platform.Windows/WindowsGlobalHotkey.cs:218 — Race between Register()'s timeout cleanup and the pump thread's WM_APP_REGISTER handler. On the timeout path, Register() removes the entry from _pending and disposes pending.Done outside _gate. If RegisterHotKey is slow (system under load), the pump can still call p.Done.Set() on the already-disposed ManualResetEventSlim, throwing ObjectDisposedException straight out of DispatchMessageW. The pump's outer try/catch is around the GetMessageW loop, not per-dispatch, so this kills the pump and strands every other registered hotkey until Dispose. Fix: hold _gate across p.Result/Win32Error/Done.Set() in the WndProc handler so removal and set are mutually exclusive — or don't dispose pending.Done on the timeout branch.

  4. src/Mouse2Joy.UI/Views/BindingEditorWindow.axaml:451 — All four section containers (Source, Target, Modifier chain, Selected modifier) are <HeaderedContentControl ... Classes="groupbox">, but no Style Selector="HeaderedContentControl.groupbox" is defined anywhere in the project. FluentTheme renders an unstyled HeaderedContentControl as just a header TextBlock over content with no border — the four sections collapse into a flat wall of controls with only bold labels, a visible regression vs. the WPF <GroupBox> chrome. Either define a groupbox style (rounded border, padded header bar, content padding), or wrap each section in a <Border> with a header <TextBlock>.

High-value suggestions

Correctness

  • src/Mouse2Joy.UI/Controls/KeyCaptureBox.cs:220AvKey.Pause => (0x45, false) collides with AvKey.NumLock => (0x45, false). ScancodeHidMap.FromScancode(0x45, false) maps both to a single HID usage — capturing Pause silently binds to NumLock's slot. Either drop the Pause entry and let the MapVirtualKey fallback handle it, or explicitly mark Pause as unsupported as a hotkey.
  • src/Mouse2Joy.UI/Controls/CurveEditorCanvas.cs:111PixelToCurve divides by Bounds.Width/Bounds.Height with no zero guard. A pointer event before first layout (or a parent collapsed to 0) yields NaN that survives the Math.Clamp and writes into the persisted curve points. Add an early-return if (w <= 0 || h <= 0) return (XMin, YMin);.

Stability

  • src/Mouse2Joy.Platform.Windows/PanicHotkey.cs:241 — No double-Dispose guard. WindowsGlobalHotkey.Dispose() already has the pattern (lines 401–408); match it.
  • src/Mouse2Joy.Platform.Windows/PanicHotkey.cs:139_ready.Wait() has no timeout. A pump-init deadlock would hang app launch silently — add _ready.Wait(TimeSpan.FromSeconds(5)) plus a logged warning.
  • src/Mouse2Joy.Platform.Windows/WindowsGlobalHotkey.cs:313 — Pump's finally block reads _hwnd from the field; Dispose may have nulled it. Snapshot to a local at the top of PumpThread.
  • src/Mouse2Joy.App/App.axaml.cs:204 — Teardown wraps every dispose call in try { ... } catch { } with no logging. Log the swallowed exception before falling through; the log flush happens after, so the line will make it to disk.
  • src/Mouse2Joy.UI/Views/OverlayWindow.axaml.cs:108StartTickTimerIfNeeded is called from both AttachEngine (before Show) and OnOpened. The first call starts the 60 Hz timer against a not-yet-shown window. Only start in OnOpened.
  • src/Mouse2Joy.UI/Interop/WindowStyles.cs:62SetWindowLongPtrW failure currently logs to Debug.WriteLine only. Route it through Serilog so a "my overlay catches clicks" bug report has a trace.

Performance — new 60 Hz overlay redraw timer

The branch moved overlay redraw from InputEngine.Tick subscription to a 60 Hz DispatcherTimer in OverlayWindow.axaml.cs (deliberate, documented). That makes per-widget Render allocations a per-frame cost. None are individually catastrophic, but the pattern is widespread:

  • src/Mouse2Joy.UI/Overlay/OverlayWidget.cs:79ReadColorBrush / ReadColorPen allocate a fresh SolidColorBrush / Pen on every call. Root cause of per-frame allocations across most widgets. Cache resolved brushes; invalidate when config changes.
  • src/Mouse2Joy.UI/Overlay/Widgets/StatusWidget.cs:156BuildPlan allocates Typeface + FontFamily + FormattedText + two List<...> + per-glyph Geometry per frame. The displayed string usually doesn't change frame-to-frame — memoize RenderPlan against (config-hash, current-text).
  • src/Mouse2Joy.UI/Overlay/Widgets/ButtonGridWidget.cs:54 — Up to 15 labels × 60 Hz = ~900 FormattedText + 900 Typeface allocations / second per visible widget, for labels that never change. Hoist Typeface and cache FormattedText per (label, fontSize, brush).
  • src/Mouse2Joy.UI/Overlay/Widgets/EngineStatusIndicatorWidget.cs:46 — Allocates up to 6 brushes/frame for fallbacks that are usually unused.
  • src/Mouse2Joy.UI/Overlay/Widgets/MouseActivityWidget.cs:54new Pen(...) per active-mouse frame.
  • src/Mouse2Joy.UI/Views/OverlayWindow.axaml.cs:102 — 60 Hz timer keeps firing when the overlay is Hide()d (vs. Close()d). Avalonia skips the actual paint, but the tick walks every widget and calls InvalidateVisual. Pause on IsVisible=false.
  • (Nit) src/Mouse2Joy.UI/Overlay/Widgets/StatusWidget.cs:80 — Setting Width/Height inside Render fights Avalonia's layout system; trust MeasureOverride.

Code reuse

  • src/Mouse2Joy.Platform.Windows/PanicHotkey.cs + src/Mouse2Joy.Platform.Windows/WindowsGlobalHotkey.cs — ~150 lines of message-only-window plumbing duplicated verbatim across both files (WNDCLASSW, MSG, ten DllImports, PumpThread body, WM_APP_QUIT shutdown). WindowsGlobalHotkey.cs:23 even comments "same pattern as PanicHotkey". Both files were rewritten in this PR (Plan A drop of HwndSource) — extracting an internal MessageOnlyWindow / PumpThreadHost helper now is cheaper than later. PanicHotkey supplies an on-hotkey-id callback; WindowsGlobalHotkey adds the WM_APP_REGISTER / WM_APP_UNREGISTER routing.

UX

  • src/Mouse2Joy.UI/Views/MainWindow.axaml:91 — Button label Activate (SoftMuted) exposes internal enum jargon. The tooltip explains it well — rename the button text to match (e.g. Activate (soft mute) or Activate (armed, input passes through)).
  • src/Mouse2Joy.UI/Views/MainWindow.axaml:246 — Disabled "Start with Windows" checkbox is accompanied by an italic helper line pointing at the tooltip. Avalonia shows tooltips on disabled controls by default, so the helper line is redundant.
  • src/Mouse2Joy.UI/Views/BindingEditorWindow.axaml:78 — The Stick Dynamics Mode ComboBox uses SelectedValueBinding="{ReflectionBinding Tag}". With x:CompileBindings="True" on the window, this is the only reflection-based binding in the file — fragile to enum renames.
  • (Nit) src/Mouse2Joy.UI/Views/Editor/CurveEditorWindow.axaml:31 — Point-count TextBox is missing explicit Mode=TwoWay. Every sibling binding declares it; the inconsistency is a maintenance trap.

Convention compliance

  • src/Mouse2Joy.UI/Controls/KeyCaptureBox.cs:200MapScancode (Avalonia.Key → Win32 scancode + extended bit) and the new AvaloniaKeyToWin32Vk fallback table are ~90 pure-logic switch cases — the kind of surface CLAUDE.md asks for tests on. The fallback to MapVirtualKeyW is new logic. Suggest extracting both tables into a static helper class (e.g. KeyCaptureBox.ScancodeMap) and adding KeyCaptureBoxTests covering numpad, E0-extended (arrows/Home/End/PgUp/PgDn/Insert/Delete/Divide/PrintScreen), OEM punctuation, and the new fallback path. (Acknowledge: WPF original was untested too — this is a "while you're here" add, not a regression.)
  • (Nit) ai-docs/implementations/PLAN_A_WINDOWS_AVALONIA.md:80 — Write-down claims tooltip dim uses SystemControlForegroundBaseMediumLowBrush, but the actual App.axaml Typical line uses Opacity="0.7". Visually equivalent against the theme's default foreground; reconcile doc or code for a future reader.

Clean angles

  • Security — 0 findings. P/Invoke surface uses SetLastError correctly, GUID-suffixed window class names avoid collisions, added Avalonia + Microsoft.Win32.SystemEvents packages are standard signed. No new untrusted-input paths, no persistence schema changes, interception.dll SHA256 pin unchanged.
  • Regression — 0 findings. Panic hotkey stays on a dedicated message-only window with its own pump thread (Avalonia-independent), engine capture remains always-on with EnableEmulation as the user toggle, kernel-driver split untouched, no persistence touched. The overlay-redraw move from InputEngine.Tick to a 60 Hz DispatcherTimer is the one semantic shift and is explicitly documented in PLAN_A_WINDOWS_AVALONIA.md with rationale.

Totals: 4 critical, ~20 suggestions, ~5 nits across 8 angles. Generated locally — not posted by CI. Happy to dive deeper into any specific finding.

Copy link
Copy Markdown
Owner Author

@Zanges Zanges left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-angle local review — PR #19

Reviewed across 8 angles (security, correctness, regression, UX, stability, convention compliance, performance, code-reuse) via the review-pr skill. Findings below.


Critical (2)

  1. OverlayCoordinatorDisplaySettingsChanged race with Dispose (src/Mouse2Joy.UI/Overlay/OverlayCoordinator.cs#L65-L93)
    OnDisplaySettingsChanged fires on a background thread and Posts UI work via UiThread.Invoke. A callback already queued (or in flight on the bg thread) when Dispose runs executes its UI-thread lambda after _disposed = true; EnsureWindowsForMonitors then re-constructs OverlayWindow instances even though we just closed them, and they leak (never closed, not in _windows).
    Fix: gate the dispatched lambda on _disposed (and arguably set _disposed under a lock the lambda also takes), or capture _disposed at the bottom of OnDisplaySettingsChanged before dispatching.

  2. AvaloniaTrayIcon — notifier cached against a stale TopLevel (src/Mouse2Joy.App/AvaloniaTrayIcon.cs#L83-L100)
    _notifier ??= new WindowNotificationManager(top) caches against the first main window observed. The main window is recreated on every ShowMain() after a close-to-tray cycle (App.axaml.cs sets _main = null on Closed and new MainWindow on next show), so subsequent tray notifications target a disposed/closed TopLevel and disappear silently.
    Fix: recreate the notifier whenever top differs from the previously-attached TopLevel, or null it out on the main window's Closed event.


Performance — new ~60 Hz hot path (4 critical, 5 suggestions)

The overlay redraw is now ~60 Hz across all visible monitors and the per-Render() paths in all 8 widgets allocate heavily.

  • critical OverlayWidget.ReadColorBrush/ReadColorPen allocate a new SolidColorBrush/Pen on every call — hundreds of brush allocations/sec across the overlay. Cache per option-key, invalidate on Config reassignment. (OverlayWidget.cs#L79-L111)
  • critical ButtonGridWidget.Render builds a fresh FormattedText + Typeface("Consolas") for each of the 15 buttons, every frame — ~900 FormattedText allocations/sec/monitor. Hoist the Typeface to a static readonly field, cache the 15 FormattedText instances (label set is constant). (ButtonGridWidget.cs#L54-L69)
  • critical StatusWidget.Render unconditionally runs BuildPlan every frame (Typeface, FontFamily, FormattedText, BuildGeometry, plus per-glyph List<> × 3 + per-char FormattedText/ToString()). Memoise on (composed text + typeface inputs). (StatusWidget.cs#L73-L148)
  • critical MouseActivityWidget allocates new Pen(accent, 2 * refScale) every frame while the mouse is moving. Cache as field, invalidate when accent/refScale change. (MouseActivityWidget.cs#L54)
  • suggestion StatusWidget assigns Width/Height from inside Render() — inverts Avalonia's measure → arrange → render flow and schedules another measure pass next tick. Drive Width/Height from MeasureOverride or from the host. (StatusWidget.cs#L80-L88)
  • suggestion BuildPerGlyphPlan allocates three lists + per-char FormattedText/string per frame. Collapse passes / pool / memoise. (StatusWidget.cs#L251-L305)
  • suggestion EngineStatusIndicatorWidget fallback SolidColorBrush args are constructed even on the ReadColorBrush success path (C# eager arg evaluation). Cache fallbacks or take a Color fallback. (EngineStatusIndicatorWidget.cs#L46-L49)
  • suggestion OverlayWidget.RenderState calls InvalidateVisual() on every tick regardless of snapshot delta — add a cheap dirty check on the widget's relevant subset. (OverlayWidget.cs#L23-L27)

Stability (suggestions)

  • PanicHotkey.Register() blocks indefinitely on _ready.Wait() — if the pump thread crashes before Set(), startup hangs forever. WindowsGlobalHotkey already has a RegisterTimeout pattern; apply it here. (PanicHotkey.cs#L141-L151)
  • Empty pump-thread catch in both PanicHotkey (L205-L217) and WindowsGlobalHotkey (L319-L322). A WndProc throw silently kills the hotkey for the session. Log it.
  • UnregisterHotKey gated on _registerOk — the catch path forces _registerOk = false, so a successful register followed by a pump exception leaks the atom. Track registered state with a separate _registered bool. (PanicHotkey.cs#L211-L216)
  • App.Teardown swallows every exception silently — shutdown is where you most want diagnostics. Log inside each catch. (App.axaml.cs#L204-L217)
  • OverlayWindow.ApplyMonitor doesn't re-pin overlay styles — after a DPI/resolution change the OS can reset layered/topmost state. Re-call WindowStyles.MakeOverlay in ApplyMonitor (idempotent). (OverlayWindow.axaml.cs#L92-L100)
  • nits: UiThread.cs has no guard if Dispatcher.UIThread is uninitialized (test-host only); AvaloniaTrayIcon.Dispose doesn't detach the cached notifier.

UX

Mostly preserved. The biggest visible change is a subtle live-update regression in TextBoxes that previously used UpdateSourceTrigger=PropertyChanged — Avalonia TextBox.Text defaults to LostFocus for source updates.

  • suggestion Profile name + Tick rate Hz TextBoxes lost UpdateSourceTrigger=PropertyChanged. Profile-list label and tick-rate-dependent state no longer update live as the user types. Avalonia honours the same keyword — re-add it. (MainWindow.axaml#L78-L86)
  • suggestion Modifier-param TextBoxes are LostFocus while sibling sliders update live with TwoWay. Slider drives textbox, but typing into the textbox does not drive the slider/preview until focus leaves. Reconfirm intent. (BindingEditorWindow.axaml#L85-L89)
  • suggestion Aspect-lock chain toggle has no ToolTip — ambiguous icon. (WidgetEditorWindow.axaml#L146-L150)
  • suggestion "Show overlay" checkbox has no tooltip describing the per-monitor click-through window it spawns. (MainWindow.axaml#L171-L173)
  • suggestion Hotkeys tab does not surface the always-on panic hotkey (Ctrl+Shift+F12) — that audience most needs to know. (MainWindow.axaml#L159-L164)
  • suggestion Offset X/Y NumericUpDowns are unlabeled with units and anchor-relative semantics. (WidgetEditorWindow.axaml#L101-L124)
  • suggestion Setup tab ? initial state is not actionable. Consider "Checking..." + tooltips explaining Installed/Missing/OK transitions. (MainWindow.axaml#L274-L303)
  • nit DeltaScale Factor caps at 3 while OutputScale caps at 5 — silent ceiling. (BindingEditorWindow.axaml#L119-L122)
  • nits (pre-existing, missed-opportunity flags): KeyCaptureBox shows HID:XX hex (unreadable); OverlayWindow hard-codes 16 ms redraw interval.

Correctness (suggestions)


Code-reuse (suggestions)

  • suggestion PanicHotkey and WindowsGlobalHotkey independently rebuild the same Win32 message-only-window + STA pump-thread machinery — identical WNDCLASSW/MSG/P-Invokes, same WM_APP_QUIT idiom, same _ready setup. Self-acknowledged at WindowsGlobalHotkey.cs:23 ("same pattern as PanicHotkey"). Extract a Win32MessageOnlyWindowPump helper. (WindowsGlobalHotkey.cs#L27-L130)
  • suggestion StatusWidget.ReadOptionBool/String/Int/ColorBrush are exact reimplementations of OverlayWidget's instance helpers (because Status calls them from static methods). Promote the base helpers to protected static taking WidgetConfig and drop the duplicates. (StatusWidget.cs#L460-L510)
  • nit CurveEditorCanvas and ChainPreviewControl define identical Bg/Grid/Line/Hint brushes and an identical DrawHint — extract a CanvasTheme. (CurveEditorCanvas.cs#L54-L60)
  • nit App.axaml.cs:184 and AvaloniaTrayIcon.cs:99 call Dispatcher.UIThread.Post directly, bypassing the UiThread.Post wrapper this same PR introduced.

Regression (suggestion)

  • ai-docs/implementations/TOOLTIP_AUTO_WRAP.md is stale — references removed files (TooltipTemplateSelector.cs, App.xaml, the old .xaml views) and describes the pre-fluent API. The new PLAN_A_WINDOWS_AVALONIA.md does not supersede this area-specific doc. Update file refs and the Key decisions section to reflect the fluent builder + {tt:Tooltip} markup extension that shipped. (ai-docs/implementations/TOOLTIP_AUTO_WRAP.md#L16-L46)

Security (no criticals)

Solid posture. Substantial new P/Invoke follows correct patterns (SetLastError where it matters, WndProc delegate pinned in instance field, per-instance class atom via Guid.NewGuid(), cleanup in finally). New packages (Avalonia 11.2.3 family, Microsoft.Win32.SystemEvents 8.0.0) are reputable and signed. No kernel-driver bundling, no new HKLM writes, no new crypto, no new untrusted-input deserialization. All four Process.Start call sites use hardcoded literals/controlled paths with UseShellExecute=true.

  • suggestion DestroyWindow/DefWindowProcW/pump-loop P/Invokes lack SetLastError = true — cheap hardening. (PanicHotkey.cs#L107-L126, same in WindowsGlobalHotkey)
  • suggestion PanicHotkey.Dispose joins pump with 500 ms timeout and unconditionally clears _hwnd — if the join times out, the HWND + class atom leak silently. Log timeouts. (PanicHotkey.cs#L290-L296)
  • nit MessageBoxW is called with hWnd = 0; for live-UI AskYesNoCancel, a real owner HWND would make it properly modal. (WindowsMessageBox.cs#L38-L39)

Convention compliance (no criticals)

Followed. PLAN_A_WINDOWS_AVALONIA.md matches the template. No TODO/HACK/workaround markers in new code. WPF removal complete (<UseWPF> removed from test csproj, no PresentationCore/Hardcodet.NotifyIcon.Wpf). Pre-existing BindingEditorViewModelTests survived.

  • nit TooltipBuilder is the one fully-pure new surface and has no tests — trivial today, but a small test file (null/empty per section) would establish the foothold while the surface is small.
  • nit AvaloniaTrayIcon hardcodes MaxItems = 3 — borderline against "Keep everything user configurable". Either lift to settings or comment why fixed.

What looks good

  • Load-bearing contracts preserved: panic hotkey self-owned on its own STA pump (independent of MainWindow lifetime, survives engine crash), engine capture stays always-on, persistence schema untouched, interception.dll + .sha256 unchanged, no tests removed.
  • Tooltip rendering contract (Typical italic+dim → Description → Advice, 320 px auto-wrap) preserved by App.axaml's DataTemplate + Style; only the authoring API changed (intentionally, per PR description).
  • Avalonia migration discipline: compiled bindings + x:DataType throughout, built-in NumericUpDown + Watermark replace the deleted WPF custom controls cleanly.

Generated locally by the review-pr skill (8 angles, sequential). No CI tokens consumed.

Critical:
- OverlayCoordinator: guard DisplaySettingsChanged dispatch on _disposed
  so a late-arriving display event after Dispose doesn't resurrect closed
  overlay windows (which would leak their 60Hz timers for process lifetime).
- AvaloniaTrayIcon: rebind WindowNotificationManager when the MainWindow
  TopLevel changes (close-to-tray destroys the prior window; the cached
  notifier was targeting a dead TopLevel and silently dropping toasts).
- WindowsGlobalHotkey: hold _gate across WM_APP_REGISTER handler so the
  Register() timeout-cleanup path can't dispose the ManualResetEventSlim
  between TryGetValue and Set (which would throw ObjectDisposedException
  out of DispatchMessageW and kill the pump). Also handle the race-won
  case where the pump completes after Wait() times out.
- BindingEditor: add HeaderedContentControl.groupbox style emulating the
  WPF GroupBox chrome that FluentTheme strips by default.

Perf (60Hz overlay hot path):
- OverlayWidget: cache parsed brushes/pens per Config instance (was
  allocating a fresh SolidColorBrush every Read on every frame).
- ButtonGridWidget: hoist Typeface to static field; cache 15
  FormattedText instances keyed on font size.
- StatusWidget: memoize RenderPlan against a key of every option +
  resolved snapshot text; drive size from MeasureOverride/InvalidateMeasure
  instead of mutating Width/Height inside Render (which inverted the
  Avalonia layout flow).
- MouseActivityWidget: cache the arrow Pen keyed on (accent, thickness).
- EngineStatusIndicatorWidget: hoist fallback brushes to static fields.
- OverlayWindow: pause the 60Hz tick timer when IsVisible=false (was
  walking every widget + InvalidateVisual while Hide()d).

Stability:
- PanicHotkey: add ReadyTimeout guard on Register, double-dispose guard,
  separate _registered flag (so UnregisterHotKey runs even if a later
  exception flipped _registerOk back), snapshot HWND into the pump-thread
  local for finally cleanup, log every swallowed Win32/pump exception.
- WindowsGlobalHotkey: snapshot HWND for finally; log pump exceptions
  instead of silently swallowing.
- App.Teardown: log every per-step swallowed exception (shutdown is when
  you most want diagnostics; Log.CloseAndFlush still flushes after).
- OverlayWindow: only start tick timer from OnOpened (not AttachEngine);
  re-pin overlay styles in ApplyMonitor (OS can reset layered/topmost
  state after DPI/resolution change).
- WindowStyles: route SetWindowLongPtr / SetWindowPos failures through
  Serilog instead of Debug.WriteLine.

Correctness:
- KeyCaptureBox: drop the Pause => (0x45, false) arm (collided with
  NumLock's HID slot, silently misbinding to NumLock). Let the
  MapVirtualKey fallback surface unsupported instead.
- CurveEditorCanvas.PixelToCurve: early-return on zero size to prevent
  NaN from a pre-layout pointer event propagating into persisted curve
  points.
- Linear curve resamplers (ModifierParamProxies, CurveEditorWindow):
  guard against two control points sharing an X (divide-by-zero produced
  NaN that propagated through Fritsch-Carlson math).

UX:
- MainWindow: rename "Activate (SoftMuted)" to "Activate (soft mute)"
  (drop internal enum jargon).
- MainWindow: re-add UpdateSourceTrigger=PropertyChanged on Profile name
  + Tick rate Hz TextBoxes so dependent UI updates live.
- MainWindow: drop redundant "(Start with Windows is not yet wired up)"
  helper line — Avalonia shows tooltips on disabled controls.
- BindingEditor: switch all 32 modifier-param TextBoxes to
  UpdateSourceTrigger=PropertyChanged so typing drives the sibling slider
  and chain preview live.
- BindingEditor: replace 4 `{ReflectionBinding Tag}` lookups with
  `{Binding Tag, DataType=ComboBoxItem}` (compiled).
- CurveEditorWindow: add Mode=TwoWay to the point-count TextBox binding.

Docs:
- PLAN_A_WINDOWS_AVALONIA.md: reconcile the tooltip-dim claim with the
  actual code (Opacity=0.7 on the TextBlock + SystemControlForeground
  BaseMediumLowBrush on chrome — explain when each is used).
- TOOLTIP_AUTO_WRAP.md: add a Plan A update section noting the WPF
  mechanics described above are gone and the user-facing rendering
  contract is preserved; new authoring is the fluent builder + {tt:Tooltip}
  markup extension.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 21, 2026 11:21
@Zanges Zanges enabled auto-merge (squash) May 21, 2026 11:22
@Zanges Zanges merged commit d42f80b into main May 21, 2026
6 checks passed
@Zanges Zanges deleted the plan-a-avalonia-wip branch May 21, 2026 11:25
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 61 out of 62 changed files in this pull request and generated 2 comments.

Comment thread src/Mouse2Joy.UI/Tooltips/Tooltip.cs
Comment thread ai-docs/implementations/TOOLTIP_AUTO_WRAP.md
@Zanges Zanges mentioned this pull request May 23, 2026
6 tasks
Zanges added a commit that referenced this pull request May 23, 2026
Cuts the first release. Significant work has landed since the 0.1.0
placeholder (Avalonia cutover in #19, FakeTickTimer race fixes in #20),
warranting a minor bump rather than a patch.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app Changes to Mouse2Joy.App build Build, packaging, installer changes docs Documentation-only changes tests Test-only changes ui Changes to Mouse2Joy.UI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants