From ef26952776f32b9fc11f133f3ee53128ef51956c Mon Sep 17 00:00:00 2001 From: will wade Date: Tue, 27 Oct 2026 07:04:06 +0000 Subject: [PATCH 1/2] fix(pickers): keyboard-mode window styles hang the native file dialogs "The one major issue is when you try to export/import your saved training text file, the whole program crashes, and goes into the white screen and then I have to go into task manager and end the task." (v0.1.33 tester report) In keyboard (direct-entry) mode the main window carries WS_EX_NOACTIVATE + WS_EX_LAYERED + TOPMOST so it floats over the target app without stealing keystrokes. A Win32 common dialog owned by that window never surfaces - it cannot take activation from a NOACTIVATE owner and is born behind the topmost one - so IFileDialog::Show pumps invisibly on the UI thread forever: the app paints white and only Task Manager can end it. Reproduced locally by invoking the training export from keyboard mode (no #32770 ever appears, UI thread blocked in the modal pump); the editor-mode repros that "worked" never had these styles. All four picker call sites (training import/export, Open, Save) now temporarily normalize the owner via WindowDialogStyles: clear WS_EX_NOACTIVATE, drop out of the topmost band, bring the window to the foreground, and restore both afterwards. Pure Win32 - the Avalonia Topmost/Opacity properties are untouched so the mode logic keeps owning them. Verified twice end-to-end with the exact failing flow (app restored into keyboard mode, Settings opened from the mini-bar, Export clicked): the save dialog now appears within a second and the app survives the dialog being cancelled. Repro harnesses are watchdog-protected (the app is force-killed after every run). Note: v0.1.33 predates #71 (async-void picker guards, crash-evidence retention) - both that and this fix must ride the next release together for the tester. Signed-off-by: will wade --- src/Dasher.Windows/Controls/SettingsPanel.cs | 34 +++-- src/Dasher.Windows/Services/PickerSession.cs | 122 ++++++++++++++++ .../Services/WindowDialogStyles.cs | 131 +++++++++++++++++ src/Dasher.Windows/Views/MainWindow.axaml.cs | 132 ++++++++++++------ 4 files changed, 363 insertions(+), 56 deletions(-) create mode 100644 src/Dasher.Windows/Services/PickerSession.cs create mode 100644 src/Dasher.Windows/Services/WindowDialogStyles.cs diff --git a/src/Dasher.Windows/Controls/SettingsPanel.cs b/src/Dasher.Windows/Controls/SettingsPanel.cs index 48f0ade..e0e44f4 100644 --- a/src/Dasher.Windows/Controls/SettingsPanel.cs +++ b/src/Dasher.Windows/Controls/SettingsPanel.cs @@ -892,12 +892,17 @@ void RefreshStatus() var topLevel = TopLevel.GetTopLevel(this); if (topLevel == null) return; var storageProvider = topLevel.StorageProvider; - var result = await storageProvider.OpenFilePickerAsync(new Avalonia.Platform.Storage.FilePickerOpenOptions + // Dialog-friendly owner — see the export handler. + System.Collections.Generic.IReadOnlyList result; + using (Services.PickerSession.Begin(topLevel.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero)) { - Title = Loc.Tr("training_import", "Import Training Text"), - FileTypeFilter = [new Avalonia.Platform.Storage.FilePickerFileType("Text Files") { Patterns = ["*.txt"] }], - AllowMultiple = false, - }); + result = await storageProvider.OpenFilePickerAsync(new Avalonia.Platform.Storage.FilePickerOpenOptions + { + Title = Loc.Tr("training_import", "Import Training Text"), + FileTypeFilter = [new Avalonia.Platform.Storage.FilePickerFileType("Text Files") { Patterns = ["*.txt"] }], + AllowMultiple = false, + }); + } if (result.Count == 0) return; var text = await System.IO.File.ReadAllTextAsync(result[0].Path.LocalPath); @@ -951,13 +956,20 @@ void RefreshStatus() var topLevel = TopLevel.GetTopLevel(this); if (topLevel == null) return; var storageProvider = topLevel.StorageProvider; - var result = await storageProvider.SaveFilePickerAsync(new Avalonia.Platform.Storage.FilePickerSaveOptions + // Dialog-friendly owner (keyboard mode's NOACTIVATE+TOPMOST + // styles make the native dialog unsurfaced → UI hangs in the + // invisible modal pump — the v0.1.33 "export white screen"). + Avalonia.Platform.Storage.IStorageFile? result; + using (Services.PickerSession.Begin(topLevel.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero)) { - Title = Loc.Tr("training_export", "Export Training Data"), - DefaultExtension = "txt", - SuggestedFileName = "dasher_training_export.txt", - FileTypeChoices = [new Avalonia.Platform.Storage.FilePickerFileType("Text Files") { Patterns = ["*.txt"] }], - }); + result = await storageProvider.SaveFilePickerAsync(new Avalonia.Platform.Storage.FilePickerSaveOptions + { + Title = Loc.Tr("training_export", "Export Training Data"), + DefaultExtension = "txt", + SuggestedFileName = "dasher_training_export.txt", + FileTypeChoices = [new Avalonia.Platform.Storage.FilePickerFileType("Text Files") { Patterns = ["*.txt"] }], + }); + } if (result == null) return; await System.IO.File.WriteAllTextAsync(result.Path.LocalPath, await System.IO.File.ReadAllTextAsync(trainingFile)); statusText.Text = string.Format(Loc.Tr("training_exported", "Exported to {0}"), result.Name); diff --git a/src/Dasher.Windows/Services/PickerSession.cs b/src/Dasher.Windows/Services/PickerSession.cs new file mode 100644 index 0000000..78ac79a --- /dev/null +++ b/src/Dasher.Windows/Services/PickerSession.cs @@ -0,0 +1,122 @@ +using System; +using System.Runtime.InteropServices; + +namespace Dasher.Windows.Services; + +/// +/// One place for "a native modal dialog is about to run" coordination: +/// normalizes the owner window (WindowDialogStyles) and suspends the +/// keyboard-mode target tracking while the dialog is up. +/// +/// The tracker suspension matters: with the hang fixed, the dialog CAN +/// take foreground for the first time in keyboard mode — and the +/// foreground-change machinery (WinEvent hooks, the window's Deactivated +/// handler) would otherwise record the DIALOG as the typing target, then +/// re-seed predictions from its controls. Worse, after the dialog closes +/// the recorded target is a dead HWND and the first injected characters +/// go nowhere until the user re-clicks their document (review loop 1 on +/// the picker-hang fix). +/// +/// MainWindow registers the one suspend/resume pair for the app's single +/// window; resume runs BEFORE the window styles come back so the previous +/// typing target can be given foreground while Dasher can still activate. +/// Handlers are invoked isolated: one throwing handler cannot abort the +/// session and resurrect the hang (or the focus regression). +/// +public static class PickerSession +{ + private static event Action? SuspendHandlers; + private static event Action? ResumeHandlers; + + /// Session depth: nested Begins (defensively impossible while + /// the modal disables the owner, but the invariant is now stated rather + /// than lucky) suspend once and resume when the LAST session ends — an + /// inner dispose must not re-arm tracking under a still-live dialog + /// (review loop 3). + private static int _depth; + + /// Diagnostics sink. MainWindow wires this to the keyboard + /// debug log: a swallowed handler exception must be visible in field + /// logs, not only in debug builds (review loop 3). + internal static Action? Log { private get; set; } + + /// Register the app-side suspend/resume actions (idempotent per delegate pair). + public static void Register(Action suspend, Action resume) + { + SuspendHandlers -= suspend; + ResumeHandlers -= resume; + SuspendHandlers += suspend; + ResumeHandlers += resume; + } + + public static void Unregister(Action suspend, Action resume) + { + SuspendHandlers -= suspend; + ResumeHandlers -= resume; + } + + /// Wrap a native picker invocation: suspend tracking, normalize + /// the owner; dispose resumes tracking (previous target back to + /// foreground) and then restores the window styles. + public static IDisposable Begin(IntPtr ownerHwnd) + { + if (_depth == 0) + InvokeIsolated(SuspendHandlers); + _depth++; + + var styleRestore = (IDisposable)NoopDisposable.Instance; + try + { + styleRestore = WindowDialogStyles.PickDialogFriendly(ownerHwnd); + } + catch + { + // Never leave tracking suspended with no dialog to wait for. + if (--_depth == 0) InvokeIsolated(ResumeHandlers); + throw; + } + + return new Session(() => + { + try + { + if (--_depth == 0) InvokeIsolated(ResumeHandlers); + } + finally + { + // Always: a throwing resume handler must not leave the + // window activatable in keyboard mode (focus stealing). + styleRestore.Dispose(); + } + }); + } + + private static void InvokeIsolated(Action? handlers) + { + if (handlers is null) return; + foreach (var d in handlers.GetInvocationList()) + { + try + { + ((Action)d)(); + } + catch (Exception ex) + { + // One bad handler must not defeat the session mechanism. + Log?.Invoke($"PickerSession handler threw: {ex.Message} ({ex.GetType()})"); + System.Diagnostics.Debug.WriteLine($"PickerSession handler threw: {ex}"); + } + } + } + + private sealed class NoopDisposable : IDisposable + { + public static readonly NoopDisposable Instance = new(); + public void Dispose() { } + } + + private sealed class Session(Action dispose) : IDisposable + { + public void Dispose() => dispose(); + } +} diff --git a/src/Dasher.Windows/Services/WindowDialogStyles.cs b/src/Dasher.Windows/Services/WindowDialogStyles.cs new file mode 100644 index 0000000..3183543 --- /dev/null +++ b/src/Dasher.Windows/Services/WindowDialogStyles.cs @@ -0,0 +1,131 @@ +using System; +using System.Runtime.InteropServices; + +namespace Dasher.Windows.Services; + +/// +/// Makes the main window a viable owner for native modal file dialogs. +/// +/// In keyboard (direct-entry) mode the window carries WS_EX_NOACTIVATE, +/// WS_EX_LAYERED and TOPMOST so it floats over the target application +/// without stealing keystrokes. A Win32 common dialog owned by such a +/// window never surfaces: it cannot take activation from a NOACTIVATE +/// owner and is born behind the topmost one — IFileDialog::Show then +/// pumps invisibly on the UI thread forever. To the user the app is a +/// frozen white screen ("export crashes the app", v0.1.33 tester report) +/// that only Task Manager can end — reproduced locally with the picker +/// invoked from keyboard mode. +/// +/// PickDialogFriendly temporarily clears WS_EX_NOACTIVATE and drops the +/// window out of the topmost band (restored by the returned dispose); +/// WS_EX_LAYERED is deliberately left alone — it does not block the +/// dialog and clearing it would break Window.Opacity. Pure Win32: the +/// Avalonia Topmost/Opacity properties are left alone too — they are +/// re-applied by the mode logic and must not fight the dialog. +/// The restore is surgical (only the NOACTIVATE bit and the topmost band +/// are touched), so a style another actor changed while the dialog was up +/// survives. +/// +public static class WindowDialogStyles +{ + internal const int GWL_EXSTYLE = -20; + internal const long WS_EX_TOPMOST = 0x00000008; + internal const long WS_EX_NOACTIVATE = 0x08000000; + internal const long WS_EX_LAYERED = 0x00080000; + + // 64-bit entry points; user32.dll has no *Ptr variants on 32-bit, so + // the accessors switch on IntPtr.Size (the pattern MainWindow used to + // duplicate — this is now the single source of truth for both). + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")] + private static extern IntPtr GetWindowLongPtr64(IntPtr hWnd, int nIndex); + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")] + private static extern IntPtr SetWindowLongPtr64(IntPtr hWnd, int nIndex, IntPtr dwNewLong); + + [DllImport("user32.dll", EntryPoint = "GetWindowLong")] + private static extern int GetWindowLong32(IntPtr hWnd, int nIndex); + + [DllImport("user32.dll", EntryPoint = "SetWindowLong")] + private static extern int SetWindowLong32(IntPtr hWnd, int nIndex, int dwNewLong); + + [DllImport("user32.dll")] + private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags); + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + private static readonly IntPtr HWND_NOTOPMOST = new(-2); + private static readonly IntPtr HWND_TOPMOST = new(-1); + private const uint SWP_NOMOVE = 0x0002; + private const uint SWP_NOSIZE = 0x0001; + private const uint SWP_NOACTIVATE = 0x0010; + + internal static long GetExStyle(IntPtr hwnd) => + IntPtr.Size == 8 ? GetWindowLongPtr64(hwnd, GWL_EXSTYLE).ToInt64() : GetWindowLong32(hwnd, GWL_EXSTYLE); + + internal static long SetExStyle(IntPtr hwnd, long style) => + IntPtr.Size == 8 + ? SetWindowLongPtr64(hwnd, GWL_EXSTYLE, (IntPtr)style).ToInt64() + : SetWindowLong32(hwnd, GWL_EXSTYLE, (int)style); + + internal static bool TrySetForeground(IntPtr hwnd) => hwnd != IntPtr.Zero && SetForegroundWindow(hwnd); + + /// + /// Clear NOACTIVATE, leave the topmost band and bring the window to the + /// foreground so the dialog it owns can activate and show above it. + /// + /// An IDisposable that restores the previous state. Never null; + /// a zero/missing handle — or a window that already dialog-friendly + /// (editor mode) — is a no-op. + public static IDisposable PickDialogFriendly(IntPtr hwnd) + { + if (hwnd == IntPtr.Zero) return Noop; + + var exStyle = GetExStyle(hwnd); + var wasTopmost = (exStyle & WS_EX_TOPMOST) != 0; + var hadNoActivate = (exStyle & WS_EX_NOACTIVATE) != 0; + if (!hadNoActivate && !wasTopmost) + { + // Editor-mode window: nothing to normalize, and no foreground + // push either — the user just clicked a button in this window. + return Noop; + } + + if (hadNoActivate) SetExStyle(hwnd, exStyle & ~WS_EX_NOACTIVATE); + if (wasTopmost) SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + SetForegroundWindow(hwnd); + return new Restore(hwnd, hadNoActivate, wasTopmost); + } + + private sealed class Restore(IntPtr hwnd, bool restoreNoActivate, bool restoreTopmost) : IDisposable + { + public void Dispose() + { + try + { + // Surgical: only the bits PickDialogFriendly cleared come + // back; anything another actor changed meanwhile survives. + if (restoreNoActivate) + { + var cur = GetExStyle(hwnd); + if ((cur & WS_EX_NOACTIVATE) == 0) SetExStyle(hwnd, cur | WS_EX_NOACTIVATE); + } + if (restoreTopmost && (GetExStyle(hwnd) & WS_EX_TOPMOST) == 0) + SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + } + catch + { + // Never throw from a dispose on the UI path; worst case the + // window keeps the dialog-friendly styles until the next + // keyboard-mode change re-applies them. + } + } + } + + private sealed class NoopDisposable : IDisposable + { + public void Dispose() { } + } + + private static readonly NoopDisposable Noop = new(); +} diff --git a/src/Dasher.Windows/Views/MainWindow.axaml.cs b/src/Dasher.Windows/Views/MainWindow.axaml.cs index 31d4c85..3ff6825 100644 --- a/src/Dasher.Windows/Views/MainWindow.axaml.cs +++ b/src/Dasher.Windows/Views/MainWindow.axaml.cs @@ -62,31 +62,15 @@ public partial class MainWindow : Window [DllImport("user32.dll")] private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); - [DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")] - private static extern IntPtr GetWindowLongPtr64(IntPtr hWnd, int nIndex); + // Ex-style access is centralized in Services.WindowDialogStyles (single + // source of truth for the 32/64-bit entry-point switch and the WS_EX_ + // constants — formerly duplicated here). + private const long WS_EX_NOACTIVATE = Services.WindowDialogStyles.WS_EX_NOACTIVATE; + private const long WS_EX_LAYERED = Services.WindowDialogStyles.WS_EX_LAYERED; - [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")] - private static extern IntPtr SetWindowLongPtr64(IntPtr hWnd, int nIndex, IntPtr dwNewLong); + private static long GetWindowExStyle(IntPtr hWnd) => Services.WindowDialogStyles.GetExStyle(hWnd); - [DllImport("user32.dll", EntryPoint = "GetWindowLong")] - private static extern IntPtr GetWindowLong32(IntPtr hWnd, int nIndex); - - [DllImport("user32.dll", EntryPoint = "SetWindowLong")] - private static extern IntPtr SetWindowLong32(IntPtr hWnd, int nIndex, IntPtr dwNewLong); - - private const int GWL_EXSTYLE = -20; - private const int WS_EX_NOACTIVATE = 0x08000000; - private const int WS_EX_LAYERED = 0x00080000; - - private static IntPtr GetWindowExStyle(IntPtr hWnd) - { - return IntPtr.Size == 8 ? GetWindowLongPtr64(hWnd, GWL_EXSTYLE) : GetWindowLong32(hWnd, GWL_EXSTYLE); - } - - private static IntPtr SetWindowExStyle(IntPtr hWnd, IntPtr value) - { - return IntPtr.Size == 8 ? SetWindowLongPtr64(hWnd, GWL_EXSTYLE, value) : SetWindowLong32(hWnd, GWL_EXSTYLE, value); - } + private static long SetWindowExStyle(IntPtr hWnd, long value) => Services.WindowDialogStyles.SetExStyle(hWnd, value); [StructLayout(LayoutKind.Sequential)] private struct INPUT @@ -331,6 +315,10 @@ protected override void OnOpened(EventArgs e) this.Deactivated += (_, _) => { + // A native picker dialog legitimately takes foreground while we + // are dialog-friendly (PickerSession); recording IT as the typing + // target would misroute post-dialog input (review loop 1). + if (_pickerSessionActive) return; var fg = GetForegroundWindow(); var ourHandle = TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; if (fg != ourHandle && fg != IntPtr.Zero) @@ -352,16 +340,55 @@ protected override void OnOpened(EventArgs e) // changes re-seed — the field text/caret is what matters, not the HWND. _targetTracker.TargetChanged += reason => { + if (_pickerSessionActive) return; // a picker dialog, not the user's target ArmSelectionWatch(); _ = SeedContextFromTargetAsync(reason); }; + // Native pickers (training import/export, Open, Save) coordinate + // through PickerSession: window styles normalized, target tracking + // suspended for the dialog's lifetime, previous target focused back. + Services.PickerSession.Register(SuspendTargetTracking, ResumeTargetTracking); + Services.PickerSession.Log = KbLog; + // RFC 0018: everything startup-blocking (data install, engine create, // v5 scan) runs off the UI thread so the overlay animates; the // spinner must never freeze and the window must never go dead. _ = StartupAsync(coreDataDir, dataDir); } + /// True while a native picker dialog owns the foreground: no + /// foreground-change tracking (the DIALOG would become the "target"). + private bool _pickerSessionActive; + + private void SuspendTargetTracking() + { + _pickerSessionActive = true; + KbLog("PickerSession: suspending target tracking"); + if (_vm is not { IsKeyboardMode: true }) return; + _targetTracker.Remove(); + // Pair with the editor-mode convention (ApplyPaneLayout): a stopped + // watch MUST clear _lastWatchTarget, else ArmSelectionWatch's + // same-target early-out leaves the caret re-seed dead after the + // dialog (review loop 2, issue A). + _lastWatchTarget = IntPtr.Zero; + Services.TargetContextReader.StopSelectionWatch(); + } + + private void ResumeTargetTracking() + { + _pickerSessionActive = false; + KbLog($"PickerSession: resuming (target=0x{_targetTracker.Current:X})"); + if (_vm is not { IsKeyboardMode: true }) return; + _targetTracker.Install(OurHwnd()); + ArmSelectionWatch(); + // Give the previous typing target its focus back BEFORE the window + // styles make Dasher non-activatable again (review loop 1: the dead + // dialog HWND would otherwise stay the recorded target and the first + // post-dialog characters would go nowhere). + Services.WindowDialogStyles.TrySetForeground(_targetTracker.Current); + } + private async Task StartupAsync(string coreDataDir, string dataDir) { bool needsMigration = false; @@ -677,6 +704,8 @@ private async void RestoreEyeGazeFromConfig() protected override void OnClosing(WindowClosingEventArgs e) { + Services.PickerSession.Unregister(SuspendTargetTracking, ResumeTargetTracking); + Services.PickerSession.Log = null; SaveWindowGeometry(); _canvas?.Shutdown(); _ = AnalyticsService.ShutdownAsync(); @@ -906,24 +935,29 @@ private void SetNoActivate(bool enable) { // Add both WS_EX_NOACTIVATE and WS_EX_LAYERED (layered is required // for Window.Opacity to work — Avalonia may not set it in time) - newStyle = exStyle.ToInt64() | WS_EX_NOACTIVATE | WS_EX_LAYERED; + newStyle = exStyle | WS_EX_NOACTIVATE | WS_EX_LAYERED; } else { - newStyle = exStyle.ToInt64() & ~WS_EX_NOACTIVATE; + newStyle = exStyle & ~WS_EX_NOACTIVATE; } - SetWindowExStyle(handle, (IntPtr)newStyle); + SetWindowExStyle(handle, newStyle); // Verify it stuck var verify = GetWindowExStyle(handle); - var hasFlag = (verify.ToInt64() & WS_EX_NOACTIVATE) != 0; - var hasLayered = (verify.ToInt64() & WS_EX_LAYERED) != 0; + var hasFlag = (verify & WS_EX_NOACTIVATE) != 0; + var hasLayered = (verify & WS_EX_LAYERED) != 0; KbLog($"SetNoActivate({enable}): style=0x{exStyle:X} → 0x{newStyle:X}, noActivate={hasFlag}, layered={hasLayered}"); } private void OnEngineOutput(object? sender, EngineOutputEventArgs e) { if (_vm == null || !_vm.IsKeyboardMode) return; + // A picker dialog owns the foreground mid-session: no injection, and + // (belt-and-braces, review loop 2) the tracker must not record the + // dialog as the typing target from our own output-triggered focus + // restoration. + if (_pickerSessionActive) return; switch (e.EventType) { @@ -1834,16 +1868,20 @@ private async void OnOpen(object? sender, RoutedEventArgs e) try { var storage = StorageProvider; - var result = await storage.OpenFilePickerAsync(new FilePickerOpenOptions + System.Collections.Generic.IReadOnlyList result; + using (Services.PickerSession.Begin(TryGetPlatformHandle()?.Handle ?? IntPtr.Zero)) { - Title = "Open text file", - AllowMultiple = false, - FileTypeFilter = - [ - new FilePickerFileType("Text files") { Patterns = ["*.txt"] }, - new FilePickerFileType("All files") { Patterns = ["*"] }, - ] - }); + result = await storage.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Open text file", + AllowMultiple = false, + FileTypeFilter = + [ + new FilePickerFileType("Text files") { Patterns = ["*.txt"] }, + new FilePickerFileType("All files") { Patterns = ["*"] }, + ] + }); + } if (result.Count == 0) return; @@ -1868,15 +1906,19 @@ private async void OnSave(object? sender, RoutedEventArgs e) // take down the process (see the training export/import handlers). try { - var result = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + Avalonia.Platform.Storage.IStorageFile? result; + using (Services.PickerSession.Begin(TryGetPlatformHandle()?.Handle ?? IntPtr.Zero)) { - Title = "Save text file", - SuggestedFileName = "dasher_output.txt", - FileTypeChoices = - [ - new FilePickerFileType("Text files") { Patterns = ["*.txt"] }, - ] - }); + result = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Save text file", + SuggestedFileName = "dasher_output.txt", + FileTypeChoices = + [ + new FilePickerFileType("Text files") { Patterns = ["*.txt"] }, + ] + }); + } if (result == null) return; From 719cdc5bb68065459e0388e75c482fd08dd8425c Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 30 Oct 2026 17:13:30 +0000 Subject: [PATCH 2/2] feat(analytics): RFC 0001 amendment - ui_action + setting_changed telemetry Implements the 2026-10-28 RFC 0001 amendment (governance rfc0001-ui-telemetry-amendment; Android and GTK already implemented) for Dasher-Windows, answering the UX team question "which settings are the priority and where should main-UI focus go". - ui_action {surface, action, state?}: main-UI control activations. Surfaces: main_toolbar, status_bar (incl. the bottom message-pane header), ime_toolbar (the keyboard-mode mini-bar). Fixed action vocabulary declared in analytics-events.json: new, open, save, settings, copy, copy_selection, cut, paste, select_all, speak, stop_speak, speed_up, speed_down, layout (state = pane position), direct_mode, game_mode, control_mode, reanchor. OnToggleMode maps entering (top-bar BtnKeyboard -> main_toolbar) vs leaving (mini-bar exit -> ime_toolbar); the BtnMode flyout items emit layout with the chosen position. - setting_changed {setting_id, value_type, value?, surface}: every user commit at the widget site. Settings: engine parameter rows (toggle/slider/stepper/enum/string), the access row (access_method, selection_method, eye_tracker_type, udp_port, Input Filter), palette picker, output font family + size, keyboard opacity, advanced toggle, typing-rate, analytics opt-in, and the Speech section (speech_engine, speech_voice, speech_rate, speech_pitch, speech_volume, Speak All On Stop, Speak Words). Main UI: Max Bitrate (speed steppers), Alphabet (language picker), speech_volume (status bar), Automatic Speed Control / Adaptive Learning (status toggles - view-side, attached after the startup value load and suppressed around programmatic writes). Amendment rules enforced: - Programmatic changes never emit: the language picker compares against the engine alphabet before emitting (also fixes the pre-existing startup alphabet_selected false-fire); voice-combo and selection-combo auto-population are guarded; status-toggle instrumentation attaches view-side post-startup; sliders emit on drag release / keyboard adjust (commit), never per ValueChanged tick; steppers and the speed buttons skip clamped no-op clicks. - String settings report the fact of the change only (free text and file paths are never collected); the string field skips unchanged focus-lost events. - Layout actions carry the pane position in state; stop_speak is its own action (schema enum extended accordingly). analytics-events.json updated (schema truth): both events, action + state vocabulary, surface notes, and the two new explicitly_not_collected entries from the amendment. Signed-off-by: will wade --- analytics-events.json | 23 +- src/Dasher.Windows/Controls/SettingsPanel.cs | 174 +++++++++++++- .../Services/AnalyticsService.cs | 39 ++++ src/Dasher.Windows/Views/MainWindow.axaml | 6 +- src/Dasher.Windows/Views/MainWindow.axaml.cs | 218 ++++++++++++++++-- 5 files changed, 431 insertions(+), 29 deletions(-) diff --git a/analytics-events.json b/analytics-events.json index 14cca37..018d980 100644 --- a/analytics-events.json +++ b/analytics-events.json @@ -61,6 +61,25 @@ "engine_log_tail": { "type": "string", "note": "Deferred reports only: last engine diagnostic lines captured with the crash" } }, "pii": false + }, + "ui_action": { + "description": "RFC 0001 2026-10-28 amendment: the user activated a control in the main UI (anything outside the settings windows). Opt-in gated like every event.", + "properties": { + "surface": { "type": "string", "enum": ["main_toolbar", "status_bar", "ime_toolbar"], "description": "ime_toolbar = the keyboard (direct-entry) mode mini-bar. status_bar also covers the bottom message-pane header (speak / copy / new / paste / enter-direct-mode controls). The RFC's ime_topbar surface has no Windows equivalent and is therefore never emitted." }, + "action": { "type": "string", "description": "Fixed vocabulary; new identifiers require a schema update", "enum": ["new", "open", "save", "settings", "copy", "copy_selection", "cut", "paste", "select_all", "speak", "stop_speak", "speed_up", "speed_down", "layout", "direct_mode", "game_mode", "control_mode", "reanchor", "hide"] }, + "state": { "type": "string", "enum": ["on", "off", "right", "left", "bottom", "top"], "description": "Toggles (direct_mode, game_mode, control_mode, hide) carry on/off; the layout action carries the pane position" } + }, + "pii": false + }, + "setting_changed": { + "description": "RFC 0001 2026-10-28 amendment: the user committed a settings change at the widget site. Programmatic changes (engine init, defaults, migrations) never emit this.", + "properties": { + "setting_id": { "type": "string", "description": "Canonical identifier: the DasherCore parameter display name (e.g. Max Speed (bits/sec)) or the frontend setting key (e.g. output_font_size, access_method). Locale caveat: engine parameter display names follow the UI locale (RFC 0003), so the same setting may report under its translated name — frontend keys are locale-stable. Speech engine credentials are deliberately excluded (secrets).", "example": "Max Speed (bits/sec)" }, + "value_type": { "type": "string", "enum": ["bool", "number", "enum", "string"] }, + "value": { "type": ["boolean", "number", "string"], "description": "Present only for bool/number/enum values from a fixed vocabulary. OMITTED for string settings - free text is never collected" }, + "surface": { "type": "string", "enum": ["settings", "main_ui", "ime"], "description": "Where the change was made. Windows currently emits settings and main_ui only" } + }, + "pii": false } }, "explicitly_not_collected": [ @@ -70,6 +89,8 @@ "User name, email, or account info", "IP address (scrubbed at ingest)", "Training text contents", - "Game mode target text" + "Game mode target text", + "Values of free-text/string settings (2026-10-28 amendment: only the fact that such a setting changed is collected)", + "File paths of any kind (2026-10-28 amendment)" ] } diff --git a/src/Dasher.Windows/Controls/SettingsPanel.cs b/src/Dasher.Windows/Controls/SettingsPanel.cs index e0e44f4..98fc9a7 100644 --- a/src/Dasher.Windows/Controls/SettingsPanel.cs +++ b/src/Dasher.Windows/Controls/SettingsPanel.cs @@ -313,8 +313,22 @@ void UpdateFontSize() OutputFontChanged?.Invoke(settings.FontFamily, fontSize); } - sizeDown.Click += (s, e) => { fontSize = Math.Max(8, fontSize - 1); UpdateFontSize(); }; - sizeUp.Click += (s, e) => { fontSize = Math.Min(72, fontSize + 1); UpdateFontSize(); }; + sizeDown.Click += (s, e) => + { + var before = fontSize; + fontSize = Math.Max(8, fontSize - 1); + if (fontSize == before) return; + UpdateFontSize(); + AnalyticsService.CaptureSettingChanged("output_font_size", "number", fontSize); + }; + sizeUp.Click += (s, e) => + { + var before = fontSize; + fontSize = Math.Min(72, fontSize + 1); + if (fontSize == before) return; + UpdateFontSize(); + AnalyticsService.CaptureSettingChanged("output_font_size", "number", fontSize); + }; sizeStack.Children.Add(sizeDown); sizeStack.Children.Add(sizeValue); @@ -327,6 +341,7 @@ void UpdateFontSize() { settings.FontFamily = OutputFontPresets[fontCombo.SelectedIndex]; settings.Save(); + AnalyticsService.CaptureSettingChanged("output_font_family", "enum", settings.FontFamily); OutputFontChanged?.Invoke(settings.FontFamily, fontSize); } }; @@ -381,6 +396,12 @@ void UpdateFontSize() } }; + // Commit-time setting_changed (drag release / keyboard), not per + // tick. The slider works in FRACTIONS (0.2–1.0); report percent. + AttachSliderCommit(slider, (int)(settings.KeyboardOpacity * 100), + pct => AnalyticsService.CaptureSettingChanged("keyboard_mode_opacity", "number", pct), + v => (int)Math.Round(v * 100)); + sliderRow.Children.Add(slider); sliderRow.Children.Add(valueLabel); panel.Children.Add(sliderRow); @@ -413,6 +434,7 @@ private Control BuildTypingRateSection() { settings.ShowTypingRate = isChecked; settings.Save(); + AnalyticsService.CaptureSettingChanged("show_typing_rate", "bool", isChecked); TypingRateVisibilityChanged?.Invoke(settings.ShowTypingRate); }); toggleRow.Margin = new Thickness(0, 4, 0, 0); @@ -437,6 +459,39 @@ private Control BuildTypingRateSection() public event Action? TypingRateVisibilityChanged; public event Action? ResetTypingStatsRequested; + private bool _populatingSelections; + private bool _populatingVoices; + private int _voiceLoadDepth; + + /// + /// RFC 0001 2026-10-28: setting_changed for a slider on COMMIT — drag + /// release or keyboard adjust (arrows/PageUp/PageDown/Home/End all move + /// Avalonia sliders) — deduped against the last committed value. A bare + /// ValueChanged handler fires per drag tick. Optional transform maps the + /// slider scale to the reported scale (e.g. 0.2–1.0 fraction → percent). + /// + private static void AttachSliderCommit(Avalonia.Controls.Slider slider, int initial, Action emit, + Func? transform = null) + { + int last = initial; + void Commit() + { + var v = transform != null ? transform(slider.Value) : (int)Math.Round(slider.Value); + if (v == last) return; + last = v; + emit(v); + } + slider.AddHandler(Avalonia.Input.InputElement.PointerReleasedEvent, + (_, _) => Commit(), Avalonia.Interactivity.RoutingStrategies.Bubble); + slider.KeyUp += (s, e) => + { + if (e.Key is Avalonia.Input.Key.Left or Avalonia.Input.Key.Right or Avalonia.Input.Key.Up + or Avalonia.Input.Key.Down or Avalonia.Input.Key.PageUp or Avalonia.Input.Key.PageDown + or Avalonia.Input.Key.Home or Avalonia.Input.Key.End) + Commit(); + }; + } + private Control? BuildInputSourceRow() { var panel = new StackPanel { Spacing = 8 }; @@ -483,14 +538,23 @@ private Control BuildTypingRateSection() void PopulateSelections(AccessMethod method) { - selectionCombo.Items.Clear(); - var valid = method.ValidFor(); - foreach (var s in valid) + // Auto-populated entries are NOT user commits: guard the + // selection handler's telemetry while the combo is rebuilt + // (review: changing Steering Method fired a false selection_method + // event for the app-picked default). + _populatingSelections = true; + try { - selectionCombo.Items.Add(s.DisplayName()); - if (s == config.Selection) selectionCombo.SelectedIndex = selectionCombo.Items.Count - 1; + selectionCombo.Items.Clear(); + var valid = method.ValidFor(); + foreach (var s in valid) + { + selectionCombo.Items.Add(s.DisplayName()); + if (s == config.Selection) selectionCombo.SelectedIndex = selectionCombo.Items.Count - 1; + } + if (selectionCombo.SelectedIndex < 0) selectionCombo.SelectedIndex = 0; } - if (selectionCombo.SelectedIndex < 0) selectionCombo.SelectedIndex = 0; + finally { _populatingSelections = false; } } PopulateSelections(config.Method); @@ -661,6 +725,7 @@ void UpdateTrackerVisibility() config.InputFilter = newFilter; config.Save(); NativeBridge.dasher_set_string_parameter(_handle, ParameterKeys.SP_INPUT_FILTER, newFilter); + AnalyticsService.CaptureSettingChanged("Input Filter", "enum", newFilter); // Rebuild the Input tab to show/hide filter-specific parameters ShowCategory(_currentCategory); }; @@ -669,6 +734,7 @@ void UpdateTrackerVisibility() { var method = methods[methodCombo.SelectedIndex]; config.Method = method; + AnalyticsService.CaptureSettingChanged("access_method", "enum", method.DisplayName()); PopulateSelections(method); var sel = method.ValidFor(); config.Selection = sel[selectionCombo.SelectedIndex]; @@ -689,6 +755,8 @@ void UpdateTrackerVisibility() if (selectionCombo.SelectedIndex >= 0 && selectionCombo.SelectedIndex < valid.Length) { config.Selection = valid[selectionCombo.SelectedIndex]; + if (!_populatingSelections) + AnalyticsService.CaptureSettingChanged("selection_method", "enum", config.Selection.DisplayName()); subtitle.Text = config.Selection.Subtitle(); // Keep the persisted input filter in step with the selection // choice (Apply no longer derives or pushes it). @@ -707,6 +775,7 @@ void UpdateTrackerVisibility() 3 => "Tobii", _ => "WindowsNative", }; + AnalyticsService.CaptureSettingChanged("eye_tracker_type", "enum", config.EyeTrackerType); UpdateTrackerVisibility(); ApplyAccessConfig(config); }; @@ -716,6 +785,18 @@ void UpdateTrackerVisibility() config.UdpPort = (int)portBox.Value; ApplyAccessConfig(config); }; + // setting_changed on commit (focus leaves the field), deduped against + // the last committed port — NumericUpDown ValueChanged fires per + // keystroke/spin, and a tab-through or panel rebuild blur must not + // emit a false change (review D1). + int lastPort = config.UdpPort; + portBox.LostFocus += (s, e) => + { + var p = (int)portBox.Value; + if (p == lastPort) return; + lastPort = p; + AnalyticsService.CaptureSettingChanged("udp_port", "number", p); + }; panel.Children.Add(methodCombo); panel.Children.Add(selectionLabel); @@ -1271,6 +1352,7 @@ private static bool ShouldHideParameter(ParameterDisplayInfo info, string catego if (s is Button b && b.Tag is string paletteName) { NativeBridge.dasher_set_palette(_handle, paletteName); + AnalyticsService.CaptureSettingChanged("Colour Palette", "enum", paletteName); // Refresh the customization section to show the new theme selection ShowCategory("Customization"); } @@ -1426,6 +1508,7 @@ private Control BuildFontDropdown(ParameterDisplayInfo info) { var font = DasherFontPresets[comboBox.SelectedIndex]; NativeBridge.dasher_set_string_parameter(_handle, info.Key, font == "System" ? "" : font); + AnalyticsService.CaptureSettingChanged(info.Name, "enum", font); } }; @@ -1461,6 +1544,7 @@ private Control BuildSimpleAdvancedRow(UiSettings settings) { settings.ShowAdvanced = toggle.IsChecked == true; settings.Save(); + AnalyticsService.CaptureSettingChanged("show_advanced", "bool", toggle.IsChecked == true); ShowCategory(_currentCategory); }; row.Children.Add(toggle); @@ -1479,6 +1563,7 @@ private Control BuildSwitch(ParameterDisplayInfo info) toggle.IsCheckedChanged += (s, e) => { NativeBridge.dasher_set_bool_parameter(_handle, info.Key, toggle.IsChecked == true ? 1 : 0); + AnalyticsService.CaptureSettingChanged(info.Name, "bool", toggle.IsChecked == true); }; return toggle; @@ -1555,6 +1640,12 @@ private Control BuildSlider(ParameterDisplayInfo info) valueText.Text = val.ToString(); }; + // RFC 0001 2026-10-28: setting_changed is for COMMITTED changes — + // ValueChanged fires per drag tick (dozens per gesture), so emit on + // drag release / keyboard adjust instead, deduped against the last + // committed value. + AttachSliderCommit(slider, current, v => AnalyticsService.CaptureSettingChanged(info.Name, "number", v)); + stack.Children.Add(slider); stack.Children.Add(valueText); return stack; @@ -1611,14 +1702,20 @@ void UpdateDisplay() downBtn.Click += (s, e) => { + var before = val; val = Math.Max(min, val - step); + if (val == before) return; // clamped at the bound: not a change UpdateDisplay(); + AnalyticsService.CaptureSettingChanged(info.Name, "number", val); }; upBtn.Click += (s, e) => { + var before = val; val = Math.Min(max, val + step); + if (val == before) return; // clamped at the bound: not a change UpdateDisplay(); + AnalyticsService.CaptureSettingChanged(info.Name, "number", val); }; stack.Children.Add(downBtn); @@ -1657,7 +1754,10 @@ private Control BuildEnum(ParameterDisplayInfo info) comboBox.SelectionChanged += (s, e) => { if (comboBox.SelectedItem is ComboBoxItem item && item.Tag is int val) + { NativeBridge.dasher_set_long_parameter(_handle, info.Key, val); + AnalyticsService.CaptureSettingChanged(info.Name, "enum", item.Content?.ToString() ?? ""); + } }; return comboBox; @@ -1682,9 +1782,21 @@ private Control BuildTextField(ParameterDisplayInfo info) HorizontalAlignment = HorizontalAlignment.Stretch, }; + // Last value actually applied to the engine: compare against + // THIS (not the build-time initial) so reverting to the original + // text still applies — and tab-through/ShowCategory rebuilds + // with unchanged text still skip (review N1: comparing against + // the build-time initial left the engine desynced on revert). + string lastApplied = current; textBox.LostFocus += (s, e) => { - NativeBridge.dasher_set_string_parameter(_handle, info.Key, textBox.Text ?? ""); + var text = textBox.Text ?? ""; + if (text == lastApplied) return; + NativeBridge.dasher_set_string_parameter(_handle, info.Key, text); + lastApplied = text; + // Free-text setting: the fact of the change only — values are + // never collected (RFC 0001 2026-10-28 amendment). + AnalyticsService.CaptureSettingChanged(info.Name, "string", null); }; return textBox; @@ -1729,7 +1841,12 @@ private Control BuildStringDropdown(ParameterDisplayInfo info) comboBox.SelectionChanged += (s, e) => { if (comboBox.SelectedIndex >= 0 && comboBox.SelectedIndex < names.Count) + { NativeBridge.dasher_set_string_parameter(_handle, info.Key, names[comboBox.SelectedIndex]); + // Values come from the engine's permitted-value list — a + // fixed vocabulary, so the chosen value is collectable. + AnalyticsService.CaptureSettingChanged(info.Name, "enum", names[comboBox.SelectedIndex]); + } }; return comboBox; @@ -1763,7 +1880,12 @@ private void BuildPrivacySettings() }); var toggleRow = BuildToggleRow(Loc.Tr("analytics_opt_in", "Share anonymous analytics and crash reports"), settings.OptedIn, isChecked => - AnalyticsService.SetOptIn(isChecked)); + { + AnalyticsService.SetOptIn(isChecked); + // setting_changed fires for the commit regardless of opt-in state + // — Capture drops it when not opted in, matching every event. + AnalyticsService.CaptureSettingChanged("analytics_opt_in", "bool", isChecked); + }); toggleRow.Margin = new Thickness(0, 8, 0, 0); section.Children.Add(toggleRow); @@ -2049,6 +2171,7 @@ void RebuildCredentials() svc.SelectedEngine = SpeechService.EngineNames[idx]; svc.InvalidateClient(); svc.SaveSettings(); + AnalyticsService.CaptureSettingChanged("speech_engine", "enum", svc.SelectedEngine); RebuildCredentials(); if (autoLoadVoices != null) await autoLoadVoices(); @@ -2197,11 +2320,20 @@ void RebuildCredentials() _panel.Children.Add(voiceHeader); - // Voice loading logic — used by button click AND auto-load on engine change + // Voice loading logic — used by button click AND auto-load on engine change. + // (_populatingVoices spans the await: a user selection made mid-load + // is deliberately not emitted — the load's own programmatic + // selection wins and the user's is a transient mid-rebuild state. + // Single-flight: a second overlapping load would clear the flag + // while the first still populates, so re-entrance just re-arms it.) async Task LoadVoicesIntoCombo() { loadVoicesBtn.IsEnabled = false; voiceCountLabel.Text = "Loading..."; + _voiceLoadDepth++; + // Auto-populated combo population is not a user commit: the + // SelectedIndex assignments below would fire the handler. + _populatingVoices = true; try { await svc.LoadVoicesAsync(); @@ -2226,6 +2358,8 @@ async Task LoadVoicesIntoCombo() finally { loadVoicesBtn.IsEnabled = true; + if (--_voiceLoadDepth == 0) + _populatingVoices = false; } } @@ -2233,11 +2367,20 @@ async Task LoadVoicesIntoCombo() voiceCombo.SelectionChanged += (s, e) => { + // Locale-stable frontend vocabulary for the sentinel entry — + // the "(Default)" item is a frontend affordance, not an engine + // value, so it reports the neutral "default". + string chosen = "default"; if (voiceCombo.SelectedIndex <= 0) svc.SelectedVoiceId = null; else if (voiceCombo.SelectedIndex - 1 < svc.AvailableVoices.Count) + { svc.SelectedVoiceId = svc.AvailableVoices[voiceCombo.SelectedIndex - 1].Id; + chosen = svc.AvailableVoices[voiceCombo.SelectedIndex - 1].Name; + } svc.SaveSettings(); + if (!_populatingVoices) + AnalyticsService.CaptureSettingChanged("speech_voice", "enum", chosen); }; previewBtn.Click += (s, e) => @@ -2254,6 +2397,7 @@ async Task LoadVoicesIntoCombo() { svc.SpeechRate = rateValues[rateCombo.SelectedIndex]; svc.SaveSettings(); + AnalyticsService.CaptureSettingChanged("speech_rate", "enum", rateNames[rateCombo.SelectedIndex]); } }; @@ -2263,6 +2407,7 @@ async Task LoadVoicesIntoCombo() { svc.SpeechPitch = pitchValues[pitchCombo.SelectedIndex]; svc.SaveSettings(); + AnalyticsService.CaptureSettingChanged("speech_pitch", "enum", pitchNames[pitchCombo.SelectedIndex]); } }; @@ -2273,6 +2418,11 @@ async Task LoadVoicesIntoCombo() svc.SaveSettings(); }; + // Commit-time setting_changed (drag release / keyboard), matching the + // generic parameter sliders — ValueChanged is a per-tick storm. + AttachSliderCommit(volSlider, svc.SpeechVolume, + v => AnalyticsService.CaptureSettingChanged("speech_volume", "number", v)); + // Auto-load voices on initial show autoLoadVoices = LoadVoicesIntoCombo; _ = LoadVoicesIntoCombo(); @@ -2315,6 +2465,7 @@ async Task LoadVoicesIntoCombo() speakOnStopToggle.IsCheckedChanged += (s, e) => { NativeBridge.dasher_set_bool_parameter(_handle, ParameterKeys.BP_SPEAK_ALL_ON_STOP, speakOnStopToggle.IsChecked == true ? 1 : 0); + AnalyticsService.CaptureSettingChanged("Speak on Stop", "bool", speakOnStopToggle.IsChecked == true); }; speakOnStopRow.Children.Add(speakOnStopToggle); _panel.Children.Add(speakOnStopRow); @@ -2339,6 +2490,7 @@ async Task LoadVoicesIntoCombo() speakWordsToggle.IsCheckedChanged += (s, e) => { NativeBridge.dasher_set_bool_parameter(_handle, ParameterKeys.BP_SPEAK_WORDS, speakWordsToggle.IsChecked == true ? 1 : 0); + AnalyticsService.CaptureSettingChanged("Speak Words", "bool", speakWordsToggle.IsChecked == true); }; speakWordsRow.Children.Add(speakWordsToggle); _panel.Children.Add(speakWordsRow); diff --git a/src/Dasher.Windows/Services/AnalyticsService.cs b/src/Dasher.Windows/Services/AnalyticsService.cs index cde6c06..53317fc 100644 --- a/src/Dasher.Windows/Services/AnalyticsService.cs +++ b/src/Dasher.Windows/Services/AnalyticsService.cs @@ -143,6 +143,45 @@ public static void Capture(string eventName, Dictionary? propert catch { } } + // ── RFC 0001, 2026-10-28 amendment: UI-interaction telemetry ───────────── + // + // Both events answer the UX team's question (which settings are the + // priority, where should main-UI focus go) and fire only after the + // existing opt-in. See analytics-events.json for the published schema — + // if it's not in the schema, it's not collected. + + /// + /// The user activated a control in the main UI (outside settings). + /// Surfaces: main_toolbar, status_bar, ime_toolbar (the keyboard-mode + /// mini-bar). Fixed action vocabulary — declared in analytics-events.json. + /// + public static void CaptureUiAction(string surface, string action, string? state = null) + { + var props = new Dictionary { ["surface"] = surface, ["action"] = action }; + if (state != null) props["state"] = state; + Capture("ui_action", props); + } + + /// + /// The user committed a settings change at the widget site. NEVER emit + /// for programmatic changes (engine init, defaults, migrations) — the + /// amendment explicitly scopes this to user commits. Values are included + /// only for bool/number/enum; string settings report the fact of the + /// change only (privacy: no free text, no file paths). + /// + public static void CaptureSettingChanged(string settingId, string valueType, object? value, string surface = "settings") + { + var props = new Dictionary + { + ["setting_id"] = settingId, + ["value_type"] = valueType, + ["surface"] = surface, + }; + if (valueType != "string" && value != null) + props["value"] = value; + Capture("setting_changed", props); + } + // ── Engine log ring buffer (RFC 0009) ────────────────────────────────────── /// diff --git a/src/Dasher.Windows/Views/MainWindow.axaml b/src/Dasher.Windows/Views/MainWindow.axaml index 8ce2bf5..db981c3 100644 --- a/src/Dasher.Windows/Views/MainWindow.axaml +++ b/src/Dasher.Windows/Views/MainWindow.axaml @@ -346,14 +346,14 @@ - + - + @@ -389,7 +389,7 @@ - diff --git a/src/Dasher.Windows/Views/MainWindow.axaml.cs b/src/Dasher.Windows/Views/MainWindow.axaml.cs index 3ff6825..539722b 100644 --- a/src/Dasher.Windows/Views/MainWindow.axaml.cs +++ b/src/Dasher.Windows/Views/MainWindow.axaml.cs @@ -659,8 +659,34 @@ private async Task CompleteStartupAsync(V5MigrationResult? migrationResult) { volSlider.Value = SpeechService.Instance.SpeechVolume; volSlider.ValueChanged += OnSpeakVolumeChanged; + // setting_changed fires on commit (drag release / keyboard + // adjust incl. Page/Home keys), deduped — never per tick. + int lastVol = SpeechService.Instance.SpeechVolume; + void VolCommit() + { + var v = (int)Math.Round(volSlider.Value); + if (v == lastVol) return; + lastVol = v; + OnSpeakVolumeCommitted(); + } + volSlider.AddHandler(Avalonia.Input.InputElement.PointerReleasedEvent, + (_, _) => VolCommit(), Avalonia.Interactivity.RoutingStrategies.Bubble); + volSlider.KeyUp += (s, e) => + { + if (e.Key is Avalonia.Input.Key.Left or Avalonia.Input.Key.Right or Avalonia.Input.Key.Up + or Avalonia.Input.Key.Down or Avalonia.Input.Key.PageUp or Avalonia.Input.Key.PageDown + or Avalonia.Input.Key.Home or Avalonia.Input.Key.End) + VolCommit(); + }; } + // RFC 0001 2026-10-28: status-bar Auto/Learning toggles are bound + // TwoWay to VM properties that also change programmatically + // (startup/reset), so instrumentation is attached view-side here — + // AFTER the startup value load — and suppressed around programmatic + // writes (see OnResetSettingsRequested). + InstrumentStatusToggles(); + #if !STORE _ = CheckForUpdatesAsync(); #endif @@ -907,6 +933,12 @@ private void SeedPaneText(MainWindowViewModel vm, TextBox box, string paneText) private void OnNewSession(object? sender, RoutedEventArgs e) { if (_vm == null || _vm.Handle == IntPtr.Zero) return; + // RFC 0001 2026-10-28: bound by the message-pane BtnNewSession + // (status_bar per the schema's message-pane note) and the mini-bar + // KbNewSession (ime_toolbar). + Services.AnalyticsService.CaptureUiAction( + _vm.IsKeyboardMode || !ReferenceEquals(sender, BtnNewSession) ? "ime_toolbar" : "status_bar", + "new"); NativeBridge.dasher_reset(_vm.Handle); KbLog("New session: engine reset (buffer + context)"); } @@ -1024,6 +1056,7 @@ private void ArmSelectionWatch() private void OnManualReanchor(object? sender, RoutedEventArgs e) { if (_vm is not { IsKeyboardMode: true }) return; + Services.AnalyticsService.CaptureUiAction("ime_toolbar", "reanchor"); KbLog("Manual re-anchor requested"); _ = SeedContextFromTargetAsync("manual re-anchor"); } @@ -1231,15 +1264,30 @@ private void SendCtrlChord(ushort key, string name) private void OnKbCopy(object? sender, RoutedEventArgs e) { if (_vm == null || _vm.Handle == IntPtr.Zero) return; + Services.AnalyticsService.CaptureUiAction("ime_toolbar", "copy"); // Copy ALL engine text via the system clipboard (v5 toolbar Copy). var text = _vm.OutputText; if (!string.IsNullOrEmpty(text)) SetClipboardText(text); } - private void OnKbCut(object? sender, RoutedEventArgs e) => SendCtrlChord(VK_KEY_X, "X"); - private void OnKbPaste(object? sender, RoutedEventArgs e) => SendCtrlChord(VK_KEY_V, "V"); - private void OnKbSelectAll(object? sender, RoutedEventArgs e) => SendCtrlChord(VK_KEY_A, "A"); + private void OnKbCut(object? sender, RoutedEventArgs e) + { + Services.AnalyticsService.CaptureUiAction("ime_toolbar", "cut"); + SendCtrlChord(VK_KEY_X, "X"); + } + + private void OnKbPaste(object? sender, RoutedEventArgs e) + { + Services.AnalyticsService.CaptureUiAction("ime_toolbar", "paste"); + SendCtrlChord(VK_KEY_V, "V"); + } + + private void OnKbSelectAll(object? sender, RoutedEventArgs e) + { + Services.AnalyticsService.CaptureUiAction("ime_toolbar", "select_all"); + SendCtrlChord(VK_KEY_A, "A"); + } private void UpdateWindowMinimum() { @@ -1257,16 +1305,51 @@ private void UpdateWindowMinimum() } } - private void OnModeRightSide(object? sender, RoutedEventArgs e) => SetPanePosition(PanePosition.Right); - private void OnModeLeftSide(object? sender, RoutedEventArgs e) => SetPanePosition(PanePosition.Left); - private void OnModeBottom(object? sender, RoutedEventArgs e) => SetPanePosition(PanePosition.Bottom); - private void OnModeTop(object? sender, RoutedEventArgs e) => SetPanePosition(PanePosition.Top); - private void OnModeKeyboard(object? sender, RoutedEventArgs e) => SetPanePosition(PanePosition.Keyboard); - private void OnToggleMode(object? sender, RoutedEventArgs e) { if (_vm == null) return; - SetPanePosition(_vm.IsKeyboardMode ? PanePosition.Right : PanePosition.Keyboard); + // RFC 0001 2026-10-28: three affordances route here — the top-bar + // BtnKeyboard (enter, main_toolbar; hidden in keyboard mode), the + // message-pane eye button (enter, status_bar per the schema's + // message-pane note), and the mini-bar exit (leave, ime_toolbar). + var entering = !_vm.IsKeyboardMode; + if (entering) + Services.AnalyticsService.CaptureUiAction( + ReferenceEquals(sender, PaneEyeModeBtn) ? "status_bar" : "main_toolbar", + "direct_mode", "on"); + else + Services.AnalyticsService.CaptureUiAction("ime_toolbar", "direct_mode", "off"); + SetPanePosition(entering ? PanePosition.Keyboard : PanePosition.Right); + } + + private void OnModeRightSide(object? sender, RoutedEventArgs e) + { + Services.AnalyticsService.CaptureUiAction("main_toolbar", "layout", "right"); + SetPanePosition(PanePosition.Right); + } + + private void OnModeLeftSide(object? sender, RoutedEventArgs e) + { + Services.AnalyticsService.CaptureUiAction("main_toolbar", "layout", "left"); + SetPanePosition(PanePosition.Left); + } + + private void OnModeBottom(object? sender, RoutedEventArgs e) + { + Services.AnalyticsService.CaptureUiAction("main_toolbar", "layout", "bottom"); + SetPanePosition(PanePosition.Bottom); + } + + private void OnModeTop(object? sender, RoutedEventArgs e) + { + Services.AnalyticsService.CaptureUiAction("main_toolbar", "layout", "top"); + SetPanePosition(PanePosition.Top); + } + + private void OnModeKeyboard(object? sender, RoutedEventArgs e) + { + Services.AnalyticsService.CaptureUiAction("main_toolbar", "direct_mode", "on"); + SetPanePosition(PanePosition.Keyboard); } private void SetPanePosition(PanePosition position) @@ -1360,6 +1443,7 @@ private void OnToggleStatusBar(object? sender, RoutedEventArgs e) { if (_vm == null) return; _vm.IsStatusBarHidden = !_vm.IsStatusBarHidden; + Services.AnalyticsService.CaptureUiAction("status_bar", "hide", _vm.IsStatusBarHidden ? "on" : "off"); new PaneSettings { PanePosition = _vm.PanePosition.ToString(), StatusBarHidden = _vm.IsStatusBarHidden }.Save(); } @@ -1655,6 +1739,13 @@ private void OnTogglePrefs(object? sender, RoutedEventArgs e) if (dock == null) return; var wasVisible = dock.IsVisible; + // RFC 0001 2026-10-28: count OPENINGS, not toggles (the dock close + // button routes here too). In keyboard (direct) mode the only entry + // point is the mini-bar (ime_toolbar); otherwise the top bar. + if (!wasVisible) + Services.AnalyticsService.CaptureUiAction( + _vm.IsKeyboardMode ? "ime_toolbar" : "main_toolbar", + "settings"); dock.IsVisible = !wasVisible; if (sender is Button btn) @@ -1761,8 +1852,15 @@ private async void OnResetSettingsRequested() // Refresh UI state — read from engine defaults _vm.LoadSpeedBounds(); _vm.LoadSpeedFromEngine(); - _vm.AutoSpeed = NativeBridge.dasher_get_bool_parameter(_vm.Handle, ParameterKeys.BP_AUTO_SPEEDCONTROL) != 0; - _vm.Learning = NativeBridge.dasher_get_bool_parameter(_vm.Handle, ParameterKeys.BP_LM_ADAPTIVE) != 0; + // Programmatic writes: suppress the view-side toggle telemetry + // (RFC: programmatic changes must not emit setting_changed). + _suppressStatusToggleTelemetry = true; + try + { + _vm.AutoSpeed = NativeBridge.dasher_get_bool_parameter(_vm.Handle, ParameterKeys.BP_AUTO_SPEEDCONTROL) != 0; + _vm.Learning = NativeBridge.dasher_get_bool_parameter(_vm.Handle, ParameterKeys.BP_LM_ADAPTIVE) != 0; + } + finally { _suppressStatusToggleTelemetry = false; } _controlModeActive = NativeBridge.dasher_get_bool_parameter(_vm.Handle, ParameterKeys.BP_CONTROL_MODE) != 0; UpdateControlModeLabel(); _vm.LoadAlphabets(); @@ -1855,6 +1953,7 @@ private void ActivateSettingsTab(int index) private void OnNew(object? sender, RoutedEventArgs e) { + Services.AnalyticsService.CaptureUiAction("main_toolbar", "new"); if (_canvas == null || _vm == null) return; NativeBridge.dasher_reset(_vm.Handle); _vm.OutputText = ""; @@ -1862,6 +1961,7 @@ private void OnNew(object? sender, RoutedEventArgs e) private async void OnOpen(object? sender, RoutedEventArgs e) { + Services.AnalyticsService.CaptureUiAction("main_toolbar", "open"); if (_vm == null) return; // Whole-handler guard (async void): a picker exception must not // take down the process (see the training export/import handlers). @@ -1901,6 +2001,7 @@ private async void OnOpen(object? sender, RoutedEventArgs e) private async void OnSave(object? sender, RoutedEventArgs e) { + Services.AnalyticsService.CaptureUiAction("main_toolbar", "save"); if (_vm == null) return; // Whole-handler guard (async void): a picker exception must not // take down the process (see the training export/import handlers). @@ -1942,13 +2043,51 @@ private void OnLanguageChanged(object? sender, SelectionChangedEventArgs e) if (_vm.SelectedLanguageIndex < _vm.Languages.Count) { var alphabet = _vm.Languages[_vm.SelectedLanguageIndex]; + // Programmatic guard (RFC 0001 2026-10-28: programmatic changes + // must not emit): startup/reset assign SelectedLanguageIndex for + // the engine's CURRENT alphabet — only emit when the pick is an + // actual change. This also fixes the pre-existing startup + // alphabet_selected false-fire for non-default alphabets. + var currentPtr = NativeBridge.dasher_get_alphabet_id(_vm.Handle); + var current = currentPtr != IntPtr.Zero ? Marshal.PtrToStringUTF8(currentPtr) ?? "" : ""; + if (alphabet == current) return; NativeBridge.dasher_set_alphabet_id(_vm.Handle, alphabet); AnalyticsService.Capture("alphabet_selected", new() { ["alphabet_id"] = alphabet }); + // RFC 0001 2026-10-28 amendment: also a settings change made + // from the main UI (the picker sits in the status bar; surface + // stays main_ui = anywhere outside the settings windows). + AnalyticsService.CaptureSettingChanged("Alphabet", "enum", alphabet, "main_ui"); } } - private void OnSpeedDown(object? sender, RoutedEventArgs e) => _vm?.DecreaseSpeed(); - private void OnSpeedUp(object? sender, RoutedEventArgs e) => _vm?.IncreaseSpeed(); + private void OnSpeedDown(object? sender, RoutedEventArgs e) + { + // The speed stepper sits in the bottom status bar. + Services.AnalyticsService.CaptureUiAction("status_bar", "speed_down"); + if (_vm == null) return; + var before = _vm.Handle == IntPtr.Zero ? 0 : NativeBridge.dasher_get_long_parameter(_vm.Handle, ParameterKeys.LP_MAX_BITRATE); + _vm.DecreaseSpeed(); + EmitSpeedIfChanged(before); + } + + private void OnSpeedUp(object? sender, RoutedEventArgs e) + { + Services.AnalyticsService.CaptureUiAction("status_bar", "speed_up"); + if (_vm == null) return; + var before = _vm.Handle == IntPtr.Zero ? 0 : NativeBridge.dasher_get_long_parameter(_vm.Handle, ParameterKeys.LP_MAX_BITRATE); + _vm.IncreaseSpeed(); + EmitSpeedIfChanged(before); + } + + /// setting_changed only when the value actually moved (the + /// steppers clamp at their bounds — a no-op click is not a change). + private void EmitSpeedIfChanged(long before) + { + if (_vm == null || _vm.Handle == IntPtr.Zero || before == 0) return; + var after = NativeBridge.dasher_get_long_parameter(_vm.Handle, ParameterKeys.LP_MAX_BITRATE); + if (after != before) + Services.AnalyticsService.CaptureSettingChanged("Max Speed (bits/sec)", "number", after, "main_ui"); + } private async void OnInputSourceChanged(object? sender, (EyeGazeIntegration.TrackerType trackerType, int udpPort) args) { @@ -2000,6 +2139,7 @@ private void OnJoystickRequested(object? sender, EventArgs e) private async void OnCopyAll(object? sender, RoutedEventArgs e) { if (_vm == null) return; + Services.AnalyticsService.CaptureUiAction("status_bar", "copy"); SetClipboardText(_vm.OutputText); } @@ -2007,12 +2147,14 @@ private async void OnCopySelection(object? sender, RoutedEventArgs e) { var messageArea = this.FindControl("MessageArea"); if (messageArea?.SelectedText == null) return; + Services.AnalyticsService.CaptureUiAction("status_bar", "copy_selection"); SetClipboardText(messageArea.SelectedText); } private async void OnPaste(object? sender, RoutedEventArgs e) { if (_vm == null) return; + Services.AnalyticsService.CaptureUiAction("status_bar", "paste"); var text = GetClipboardText(); if (text != null) _vm.OutputText += text; @@ -2021,11 +2163,13 @@ private async void OnPaste(object? sender, RoutedEventArgs e) private void OnQuickSpeak(object? sender, RoutedEventArgs e) { if (_vm == null || string.IsNullOrWhiteSpace(_vm.OutputText)) return; + Services.AnalyticsService.CaptureUiAction("status_bar", "speak"); _ = SpeechService.Instance.SpeakAsync(_vm.OutputText); } private void OnStopSpeak(object? sender, RoutedEventArgs e) { + Services.AnalyticsService.CaptureUiAction("status_bar", "stop_speak"); SpeechService.Instance.Stop(); } @@ -2035,6 +2179,45 @@ private void OnSpeakVolumeChanged(object? sender, Avalonia.Controls.Primitives.R SpeechService.Instance.SaveSettings(); } + /// Commit-time telemetry for the status-bar speech volume (the + /// ValueChanged storm is a drag, not N commits — RFC: setting_changed is + /// for committed changes). + private void OnSpeakVolumeCommitted() + { + Services.AnalyticsService.CaptureSettingChanged("speech_volume", "number", + SpeechService.Instance.SpeechVolume, "main_ui"); + } + + private bool _statusTogglesInstrumented; + private bool _suppressStatusToggleTelemetry; + + /// + /// RFC 0001 2026-10-28: attach setting_changed to the status-bar + /// Auto/Learning toggles at the VIEW, after startup values are loaded — + /// the VM properties are written programmatically at startup and by + /// Settings > Reset, and programmatic changes must not emit. Reset + /// suppresses via _suppressStatusToggleTelemetry. + /// + private void InstrumentStatusToggles() + { + if (_statusTogglesInstrumented) return; + _statusTogglesInstrumented = true; + var auto = this.FindControl("AutoSpeedToggle"); + if (auto != null) + auto.IsCheckedChanged += (_, _) => + { + if (!_suppressStatusToggleTelemetry) + Services.AnalyticsService.CaptureSettingChanged("Automatic Speed Control", "bool", auto.IsChecked == true, "main_ui"); + }; + var learning = this.FindControl("LearningToggle"); + if (learning != null) + learning.IsCheckedChanged += (_, _) => + { + if (!_suppressStatusToggleTelemetry) + Services.AnalyticsService.CaptureSettingChanged("Adaptive Language Model", "bool", learning.IsChecked == true, "main_ui"); + }; + } + private void OnEngineSpeak(IntPtr textPtr, int interrupt, IntPtr user_data) { if (textPtr == IntPtr.Zero) return; @@ -2143,6 +2326,11 @@ private void OnToggleControlMode(object? sender, RoutedEventArgs e) if (_vm == null) return; _controlModeActive = !_controlModeActive; + // RFC 0001 2026-10-28: the main-toolbar Control button and the + // keyboard-mode mini-bar's KbControlBtn share this handler. + Services.AnalyticsService.CaptureUiAction( + ReferenceEquals(sender, KbControlBtn) ? "ime_toolbar" : "main_toolbar", + "control_mode", _controlModeActive ? "on" : "off"); NativeBridge.dasher_set_bool_parameter(_vm.Handle, ParameterKeys.BP_CONTROL_MODE, _controlModeActive ? 1 : 0); UpdateControlModeLabel(); } @@ -2166,6 +2354,7 @@ private void OnToggleGameMode(object? sender, RoutedEventArgs e) if (_gameModeActive) { + Services.AnalyticsService.CaptureUiAction("main_toolbar", "game_mode", "off"); NativeBridge.dasher_leave_game_mode(_vm.Handle); _gameModeActive = false; NativeBridge.dasher_game_set_canvas_text(_vm.Handle, 1); @@ -2179,6 +2368,7 @@ private void OnToggleGameMode(object? sender, RoutedEventArgs e) var result = NativeBridge.dasher_enter_game_mode(_vm.Handle); if (result == 0) { + Services.AnalyticsService.CaptureUiAction("main_toolbar", "game_mode", "on"); _gameModeActive = true; NativeBridge.dasher_game_set_canvas_text(_vm.Handle, 0); GameIcon.Kind = LucideIconKind.Pause;