Plan A: Windows app cutover from WPF to Avalonia#19
Conversation
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>
There was a problem hiding this comment.
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/andtests/, 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.
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>
…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>
Multi-angle local review —
|
Zanges
left a comment
There was a problem hiding this comment.
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)
-
OverlayCoordinator—DisplaySettingsChangedrace withDispose(src/Mouse2Joy.UI/Overlay/OverlayCoordinator.cs#L65-L93)
OnDisplaySettingsChangedfires on a background thread and Posts UI work viaUiThread.Invoke. A callback already queued (or in flight on the bg thread) whenDisposeruns executes its UI-thread lambda after_disposed = true;EnsureWindowsForMonitorsthen re-constructsOverlayWindowinstances even though we just closed them, and they leak (never closed, not in_windows).
Fix: gate the dispatched lambda on_disposed(and arguably set_disposedunder a lock the lambda also takes), or capture_disposedat the bottom ofOnDisplaySettingsChangedbefore dispatching. -
AvaloniaTrayIcon— notifier cached against a staleTopLevel(src/Mouse2Joy.App/AvaloniaTrayIcon.cs#L83-L100)
_notifier ??= new WindowNotificationManager(top)caches against the first main window observed. The main window is recreated on everyShowMain()after a close-to-tray cycle (App.axaml.cssets_main = nullonClosedandnew MainWindowon next show), so subsequent tray notifications target a disposed/closedTopLeveland disappear silently.
Fix: recreate the notifier whenevertopdiffers from the previously-attachedTopLevel, or null it out on the main window'sClosedevent.
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/ReadColorPenallocate a newSolidColorBrush/Penon every call — hundreds of brush allocations/sec across the overlay. Cache per option-key, invalidate onConfigreassignment. (OverlayWidget.cs#L79-L111) - critical
ButtonGridWidget.Renderbuilds a freshFormattedText+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.Renderunconditionally runsBuildPlanevery frame (Typeface, FontFamily, FormattedText,BuildGeometry, plus per-glyphList<>× 3 + per-char FormattedText/ToString()). Memoise on (composed text + typeface inputs). (StatusWidget.cs#L73-L148) - critical
MouseActivityWidgetallocatesnew Pen(accent, 2 * refScale)every frame while the mouse is moving. Cache as field, invalidate when accent/refScale change. (MouseActivityWidget.cs#L54) - suggestion
StatusWidgetassignsWidth/Heightfrom insideRender()— inverts Avalonia's measure → arrange → render flow and schedules another measure pass next tick. Drive Width/Height fromMeasureOverrideor from the host. (StatusWidget.cs#L80-L88) - suggestion
BuildPerGlyphPlanallocates three lists + per-char FormattedText/string per frame. Collapse passes / pool / memoise. (StatusWidget.cs#L251-L305) - suggestion
EngineStatusIndicatorWidgetfallbackSolidColorBrushargs are constructed even on theReadColorBrushsuccess path (C# eager arg evaluation). Cache fallbacks or take aColorfallback. (EngineStatusIndicatorWidget.cs#L46-L49) - suggestion
OverlayWidget.RenderStatecallsInvalidateVisual()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 beforeSet(), startup hangs forever.WindowsGlobalHotkeyalready has aRegisterTimeoutpattern; apply it here. (PanicHotkey.cs#L141-L151)- Empty pump-thread
catchin bothPanicHotkey(L205-L217) andWindowsGlobalHotkey(L319-L322). A WndProc throw silently kills the hotkey for the session. Log it. UnregisterHotKeygated 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_registeredbool. (PanicHotkey.cs#L211-L216)App.Teardownswallows every exception silently — shutdown is where you most want diagnostics. Log inside each catch. (App.axaml.cs#L204-L217)OverlayWindow.ApplyMonitordoesn't re-pin overlay styles — after a DPI/resolution change the OS can reset layered/topmost state. Re-callWindowStyles.MakeOverlayinApplyMonitor(idempotent). (OverlayWindow.axaml.cs#L92-L100)- nits:
UiThread.cshas no guard if Dispatcher.UIThread is uninitialized (test-host only);AvaloniaTrayIcon.Disposedoesn'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
LostFocuswhile sibling sliders update live withTwoWay. 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
Factorcaps at 3 whileOutputScalecaps at 5 — silent ceiling. (BindingEditorWindow.axaml#L119-L122) - nits (pre-existing, missed-opportunity flags):
KeyCaptureBoxshowsHID:XXhex (unreadable);OverlayWindowhard-codes 16 ms redraw interval.
Correctness (suggestions)
- Divide-by-zero in linear curve resamplers when two control points share an X (possible via shift-snap or OrderBy with ties) → NaN propagates into Fritsch-Carlson math.
- nit
CurveEditorCanvas.PixelToCurvedivides byBounds.Width/Heightwithout an early-return for zero size; pre-layout pointer events could yield NaN. (CurveEditorCanvas.cs#L110-L137) - nit
WidgetEditorWindowkeepSizeuses direct float equality on values that survive a JSON round-trip; ULP drift can flip "keep size" on an unedited widget. (WidgetEditorWindow.axaml.cs#L447-L449) - nit
KeyCaptureBoxPause maps to the same scancode/ext as NumLock and is effectively unreachable — drop the arm or document. (KeyCaptureBox.cs#L273-L276)
Code-reuse (suggestions)
- suggestion
PanicHotkeyandWindowsGlobalHotkeyindependently rebuild the same Win32 message-only-window + STA pump-thread machinery — identicalWNDCLASSW/MSG/P-Invokes, sameWM_APP_QUITidiom, same_readysetup. Self-acknowledged atWindowsGlobalHotkey.cs:23("same pattern as PanicHotkey"). Extract aWin32MessageOnlyWindowPumphelper. (WindowsGlobalHotkey.cs#L27-L130) - suggestion
StatusWidget.ReadOptionBool/String/Int/ColorBrushare exact reimplementations ofOverlayWidget's instance helpers (because Status calls them from static methods). Promote the base helpers toprotected statictakingWidgetConfigand drop the duplicates. (StatusWidget.cs#L460-L510) - nit
CurveEditorCanvasandChainPreviewControldefine identicalBg/Grid/Line/Hintbrushes and an identicalDrawHint— extract aCanvasTheme. (CurveEditorCanvas.cs#L54-L60) - nit
App.axaml.cs:184andAvaloniaTrayIcon.cs:99callDispatcher.UIThread.Postdirectly, bypassing theUiThread.Postwrapper this same PR introduced.
Regression (suggestion)
ai-docs/implementations/TOOLTIP_AUTO_WRAP.mdis stale — references removed files (TooltipTemplateSelector.cs,App.xaml, the old.xamlviews) and describes the pre-fluent API. The newPLAN_A_WINDOWS_AVALONIA.mddoes 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 lackSetLastError = true— cheap hardening. (PanicHotkey.cs#L107-L126, same inWindowsGlobalHotkey) - suggestion
PanicHotkey.Disposejoins 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
MessageBoxWis called withhWnd = 0; for live-UIAskYesNoCancel, 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
TooltipBuilderis 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
AvaloniaTrayIconhardcodesMaxItems = 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+.sha256unchanged, 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:DataTypethroughout, built-inNumericUpDown+Watermarkreplace 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>
Summary
src/ortests/.What's in the box
.axaml+ code-behind:MainWindow(5 tabs),BindingEditorWindow,WidgetEditorWindow,OverlayWindow,Editor/CurveEditorWindow. Compiled bindings +x:DataTypeeverywhere; theme-aware brushes for hint text.Controls:KeyCaptureBox,CurveEditorCanvas,ChainPreviewControl. WPFNumericUpDown+PlaceholderTextretired in favor of Avalonia built-ins (Avalonia.Controls.NumericUpDown,TextBox.Watermark).Background,Button,ButtonGrid,Axis,TwoAxis,MouseActivity,EngineStatusIndicator,Status) usingRender(DrawingContext).StatusWidgetper-glyph rotation math preserved.WindowsGlobalHotkeynow live on a self-owned message-only window with its own STA pump thread — survives engine crash and main-window destruction.TrayIcon+NativeMenubehindITrayIcon. Ships a 32×32 joypad-glyph.icoas the tray + window icon.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.sln— 0 warnings, 0 errors.dotnet test Mouse2Joy.sln— 348/348 pass (61 Contracts + 73 Persistence + 5 Platform.Abstractions + 200 Engine + 9 UI).PresentationCore/PresentationFramework/WindowsBase/System.Xaml/System.Windows.{Controls,Media,Shapes,…}/<UseWPF>/Hardcodet.NotifyIcon.Wpf) — 0 hits acrosssrc/andtests/.Test plan
dotnet build Mouse2Joy.slngreendotnet test Mouse2Joy.sln348/348Commits
8 commits, each green at the boundary:
00eb16c— foundation (build broken, foundation only)fca7c73— Chunk A: green build with Avalonia stubs250925a— lock in decisions386ed12— Chunk B: MainWindow + tray iconbed460b— Chunk C: BindingEditor + CurveEditore8f62d1— Chunk D: WidgetEditorWindowe792921— Chunk E: tooltip call-site sweep8ff6971— Chunk F: implementation write-downNotes for review