From 9f3eb97c2b6ac5dc7093d9c334a9fd4d71c58755 Mon Sep 17 00:00:00 2001 From: will wade Date: Thu, 18 Jun 2026 08:23:37 +0100 Subject: [PATCH 1/4] Update DasherCore: null guard in CreateInputFilter (#19) Signed-off-by: will wade --- DasherCore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DasherCore b/DasherCore index 26403f7..5533eb9 160000 --- a/DasherCore +++ b/DasherCore @@ -1 +1 @@ -Subproject commit 26403f7c5825daf090f54177be836d4d707d3ce1 +Subproject commit 5533eb9c7ee695e8abe2e5ac17904c68c89b4e99 From da1f206668fe86ffaec67c11933e47aff8383f4b Mon Sep 17 00:00:00 2001 From: will wade Date: Thu, 18 Jun 2026 08:34:49 +0100 Subject: [PATCH 2/4] Implement v5 migration (RFC 0005) for Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects Dasher v5 data at %APPDATA%\dasher.rc\settings.xml and offers to import settings on first launch. V5MigrationService: - Parses v5 settings.xml (, , elements) - Maps 23 bool, 34 long, 9 string parameters to v6 enums - Transforms DasherFontSize (index → points), start mode (2 bools → enum) - Defers BP_CONTROL_MODE, SP_INPUT_FILTER, SP_ALPHABET_ID until after Realize (matching Dasher-Apple pattern) - Copies custom alphabet/colour/control/training files (skip-if-exists) - Marks migration as completed Startup flow restructured into phases: 1. Create engine (dasher_create) 2. Migration prompt (applies params before Realize) 3. Start engine (dasher_set_screen_size → Realize) 4. Apply deferred parameters 5. Continue UI setup Updated DasherCore submodule with null guard fix (#19). Matches Dasher-Apple's macOS implementation pattern from RFC 0005. Signed-off-by: will wade --- src/Dasher.Windows/Controls/DasherCanvas.cs | 8 + .../Services/V5MigrationService.cs | 400 ++++++++++++++++++ src/Dasher.Windows/Views/MainWindow.axaml.cs | 139 +++++- 3 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 src/Dasher.Windows/Services/V5MigrationService.cs diff --git a/src/Dasher.Windows/Controls/DasherCanvas.cs b/src/Dasher.Windows/Controls/DasherCanvas.cs index 632384a..b9d9804 100644 --- a/src/Dasher.Windows/Controls/DasherCanvas.cs +++ b/src/Dasher.Windows/Controls/DasherCanvas.cs @@ -58,6 +58,14 @@ public void Initialize(string dataDir, string userDir) var errorMsg = errorPtr != IntPtr.Zero ? Marshal.PtrToStringUTF8(errorPtr) ?? "Unknown error" : "Unknown error"; throw new InvalidOperationException($"Failed to create Dasher session: {errorMsg}"); } + } + + /// + /// Starts the engine (sets screen size, triggers Realize, starts timer). + /// Call AFTER any pre-Realize parameter migration. + /// + public void StartEngine() + { NativeBridge.dasher_set_screen_size(_handle, 700, 640); _timer.Start(); } diff --git a/src/Dasher.Windows/Services/V5MigrationService.cs b/src/Dasher.Windows/Services/V5MigrationService.cs new file mode 100644 index 0000000..2437ec6 --- /dev/null +++ b/src/Dasher.Windows/Services/V5MigrationService.cs @@ -0,0 +1,400 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Xml.Linq; +using Dasher.Windows.Engine; + +namespace Dasher.Windows.Services; + +public class V5MigrationResult +{ + public List Imported { get; set; } = new(); + public List Skipped { get; set; } = new(); + public List CopiedFiles { get; set; } = new(); + public List<(int key, string value)> DeferredParameters { get; set; } = new(); + public bool HasData { get; set; } + public string Alphabet { get; set; } = ""; + public string Colour { get; set; } = ""; + public string Speed { get; set; } = ""; + public bool ControlMode { get; set; } + public int CustomFileCount { get; set; } +} + +/// +/// Detects and imports Dasher v5 settings and user data on Windows. +/// v5 stores data in %APPDATA%\dasher.rc\ — v6 uses %APPDATA%\Dasher\. +/// +public static class V5MigrationService +{ + private static readonly string V5Dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "dasher.rc"); + private static readonly string V5SettingsFile = Path.Combine(V5Dir, "settings.xml"); + + private static readonly string V6Dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Dasher"); + + private static readonly string MigrationFlagFile = Path.Combine(V6Dir, "v5_migration_completed"); + + public static bool HasBeenOffered => File.Exists(MigrationFlagFile); + public static bool HasV5Data => File.Exists(V5SettingsFile); + + // v5 regName → v6 enum name for bool parameters + private static readonly Dictionary BoolMappings = new() + { + ["DrawMouseLine"] = "BP_DRAW_MOUSE_LINE", + ["DrawMouse"] = "BP_DRAW_MOUSE", + ["CurveMouseLine"] = "BP_CURVE_MOUSE_LINE", + ["StartOnLeft"] = "BP_START_MOUSE", + ["StartOnSpace"] = "BP_START_SPACE", + ["ControlMode"] = "BP_CONTROL_MODE", + ["PaletteChange"] = "BP_PALETTE_CHANGE", + ["TurboMode"] = "BP_TURBO_MODE", + ["ExactDynamics"] = "BP_EXACT_DYNAMICS", + ["Autocalibrate"] = "BP_AUTOCALIBRATE", + ["RemapXtreme"] = "BP_REMAP_XTREME", + ["AutoSpeedControl"] = "BP_AUTO_SPEEDCONTROL", + ["LMAdaptive"] = "BP_LM_ADAPTIVE", + ["NonlinearY"] = "BP_NONLINEAR_Y", + ["PauseOutside"] = "BP_STOP_OUTSIDE", + ["BackoffButton"] = "BP_BACKOFF_BUTTON", + ["TwoButtonReverse"] = "BP_TWOBUTTON_REVERSE", + ["TwoButtonInvertDouble"] = "BP_2B_INVERT_DOUBLE", + ["SlowStart"] = "BP_SLOW_START", + ["CopyOnStop"] = "BP_COPY_ALL_ON_STOP", + ["SpeakOnStop"] = "BP_SPEAK_ALL_ON_STOP", + ["SpeakWords"] = "BP_SPEAK_WORDS", + ["SlowControlBox"] = "BP_SLOW_CONTROL_BOX", + }; + + // Parameters that must be deferred until after Realize() + private static readonly HashSet DeferredEnums = new() + { + "BP_CONTROL_MODE", "SP_INPUT_FILTER", "SP_ALPHABET_ID" + }; + + // v5 regName → v6 enum name for long parameters + private static readonly Dictionary LongMappings = new() + { + ["ScreenOrientation"] = "LP_ORIENTATION", + ["MaxBitRateTimes100"] = "LP_MAX_BITRATE", + ["UniformTimes1000"] = "LP_UNIFORM", + ["LMAlpha"] = "LP_LM_ALPHA", + ["LMBeta"] = "LP_LM_BETA", + ["LMMaxOrder"] = "LP_LM_MAX_ORDER", + ["LMExclusion"] = "LP_LM_EXCLUSION", + ["LMUpdateExclusion"] = "LP_LM_UPDATE_EXCLUSION", + ["LMMixture"] = "LP_LM_MIXTURE", + ["LineWidth"] = "LP_LINE_WIDTH", + ["Zoomsteps"] = "LP_ZOOMSTEPS", + ["NodeBudget"] = "LP_NODE_BUDGET", + ["MarginWidth"] = "LP_MARGIN_WIDTH", + ["TargetOffset"] = "LP_TARGET_OFFSET", + ["XLimitSpeed"] = "LP_X_LIMIT_SPEED", + ["MinNodeSize"] = "LP_MIN_NODE_SIZE", + ["OutlineWidth"] = "LP_OUTLINE_WIDTH", + ["NonLinearX"] = "LP_NONLINEAR_X", + ["AutospeedSensitivity"] = "LP_AUTOSPEED_SENSITIVITY", + ["Geometry"] = "LP_GEOMETRY", + ["WordAlpha"] = "LP_LM_WORD_ALPHA", + ["MessageFontSize"] = "LP_MESSAGE_FONTSIZE", + ["RenderStyle"] = "LP_SHAPE_TYPE", + ["CirclePercent"] = "LP_CIRCLE_PERCENT", + ["TwoButtonOffset"] = "LP_TWO_BUTTON_OFFSET", + ["HoldTime"] = "LP_HOLD_TIME", + ["MultipressTime"] = "LP_MULTIPRESS_TIME", + ["SlowStartTime"] = "LP_SLOW_START_TIME", + ["TapTime"] = "LP_TAP_TIME", + ["ClickMaxZoom"] = "LP_MAXZOOM", + ["DynamicSpeedInc"] = "LP_DYNAMIC_SPEED_INC", + ["DynamicSpeedFreq"] = "LP_DYNAMIC_SPEED_FREQ", + ["DynamicSpeedDec"] = "LP_DYNAMIC_SPEED_DEC", + ["MousePositionBoxDistance"] = "LP_MOUSEPOSDIST", + }; + + // v5 regName → v6 enum name for string parameters + private static readonly Dictionary StringMappings = new() + { + ["AlphabetID"] = "SP_ALPHABET_ID", + ["DasherFont"] = "SP_DASHER_FONT", + ["GameTextFile"] = "SP_GAME_TEXT_FILE", + ["InputFilter"] = "SP_INPUT_FILTER", + ["InputDevice"] = "SP_INPUT_DEVICE", + ["Alphabet1"] = "SP_ALPHABET_1", + ["Alphabet2"] = "SP_ALPHABET_2", + ["Alphabet3"] = "SP_ALPHABET_3", + ["Alphabet4"] = "SP_ALPHABET_4", + }; + + // v5 platform-specific keys with no v6 equivalent + private static readonly HashSet PlatformOnlyKeys = new() + { + "AppStyle", "EditFont", "EditFontSize", "EditHeight", "EditWidth", + "FileEncodingFormat", "FullScreen", "MirrorLayout", "PopupEnable", + "PopupFont", "PopupFullScreen", "PopupInfront", "ScreenHeight", + "ScreenHeightH", "ScreenWidth", "ScreenWidthH", "TimeStampNewFiles", + "ToolbarID", "ViewStatusbar", "ViewToolbar", "WindowState", + "XPosition", "YPosition", "ConfirmUnsavedFiles", + "Button0", "Button1", "Button2", "Button3", "Button4", "Button10", + "ButtonCompassModeRightZoom", "ButtonMenuBoxes", "ButtonMenuSafety", + "ButtonMenuScanTime", "ButtonModeNonuniformity", "DemoNoiseMag", + "DemoNoiseMem", "DemoSpring", "DynamicButtonLag", "EditSize", + "FrameRate", "GameHelpDistance", "GameHelpTime", "LanguageModelID", + "MessageTime", "PYProbabilitySortThreshold", "SocketInputXMaxTimes1000", + "SocketInputXMinTimes1000", "SocketInputYMaxTimes1000", + "SocketInputYMinTimes1000", "SocketPort", "Static1BTime", "Static1BZoom", + "TwoPushLong", "TwoPushOuter", "TwoPushShort", "TwoPushTolerance", + "UserLogLevelMask", "YScaling", "SocketInputDebug", "GlobalKeyboard", + "GameDrawPath", "TwoPushReleaseTime", "ControlBoxID", "JoystickDevice", + "SocketInputXLabel", "SocketInputYLabel", + }; + + /// + /// Scan for v5 data without importing. Returns summary for display. + /// + public static V5MigrationResult Scan() + { + var result = new V5MigrationResult(); + + if (!File.Exists(V5SettingsFile)) + return result; + + result.HasData = true; + + try + { + var doc = XDocument.Load(V5SettingsFile); + var settings = doc.Root!; + + foreach (var el in settings.Elements()) + { + var name = el.Attribute("name")?.Value ?? ""; + var value = el.Attribute("value")?.Value ?? ""; + + if (name == "AlphabetID") result.Alphabet = value; + else if (name == "ColourID" && !string.IsNullOrEmpty(value)) result.Colour = value; + else if (name == "MaxBitRateTimes100") result.Speed = (int.Parse(value) / 100.0).ToString("F1") + "x"; + else if (name == "ControlMode" && value == "True") result.ControlMode = true; + } + } + catch { } + + // Count custom files + result.CustomFileCount = CountCustomFiles(); + + return result; + } + + private static int CountCustomFiles() + { + int count = 0; + try + { + foreach (var f in Directory.GetFiles(V5Dir, "*.*", SearchOption.TopDirectoryOnly)) + { + var name = Path.GetFileName(f); + if (name.StartsWith("alphabet.") || name.StartsWith("colour.") || + name.StartsWith("color.") || name.StartsWith("control.") || + name.StartsWith("training_")) + count++; + } + } + catch { } + return count; + } + + /// + /// Import v5 settings. Must be called BEFORE dasher_set_screen_size(). + /// Returns deferred parameters to apply AFTER screen size is set. + /// + public static V5MigrationResult Import(IntPtr handle) + { + var result = Scan(); + if (!result.HasData) + return result; + + try + { + var doc = XDocument.Load(V5SettingsFile); + var settings = doc.Root!; + + foreach (var el in settings.Elements()) + { + var name = el.Attribute("name")?.Value ?? ""; + var value = el.Attribute("value")?.Value ?? ""; + var tag = el.Name.LocalName; + + switch (tag) + { + case "bool" when BoolMappings.TryGetValue(name, out var enumName): + ImportBool(handle, result, enumName, value == "True", name); + break; + + case "long" when LongMappings.TryGetValue(name, out var enumName): + ImportLong(handle, result, enumName, value, name); + break; + + case "string" when StringMappings.TryGetValue(name, out var enumName): + ImportString(handle, result, enumName, value, name); + break; + + case "long" when name == "DasherFontSize": + ImportFontSize(handle, result, value); + break; + + // Start mode from two bools + case "bool" when name == "CircleStart" && value == "True": + SetStartMode(handle, result, 2); + break; + case "bool" when name == "StartOnMousePosition" && value == "True": + SetStartMode(handle, result, 1); + break; + + // Colour ID + case "string" when name == "ColourID" && !string.IsNullOrEmpty(value): + ImportColourId(handle, result, value); + break; + + // Skip platform-only and unknown keys + default: + if (!PlatformOnlyKeys.Contains(name) && + !BoolMappings.ContainsKey(name) && + !LongMappings.ContainsKey(name) && + !StringMappings.ContainsKey(name) && + name != "DasherFontSize" && name != "ColourID" && + name != "CircleStart" && name != "StartOnMousePosition") + { + result.Skipped.Add(name); + } + break; + } + } + } + catch { } + + // Copy user data files + CopyUserDataFiles(result); + + // Mark as completed + MarkCompleted(); + + return result; + } + + private static void ImportBool(IntPtr handle, V5MigrationResult result, string enumName, bool value, string regName) + { + var key = NativeBridge.dasher_find_parameter_key(enumName); + if (key < 0) { result.Skipped.Add(regName); return; } + + if (DeferredEnums.Contains(enumName)) + { + result.DeferredParameters.Add((key, value ? "true" : "false")); + result.Imported.Add($"{regName} = {value}"); + } + else + { + NativeBridge.dasher_set_bool_parameter(handle, key, value ? 1 : 0); + result.Imported.Add($"{regName} = {value}"); + } + } + + private static void ImportLong(IntPtr handle, V5MigrationResult result, string enumName, string valueStr, string regName) + { + if (!int.TryParse(valueStr, out var value)) { result.Skipped.Add(regName); return; } + var key = NativeBridge.dasher_find_parameter_key(enumName); + if (key < 0) { result.Skipped.Add(regName); return; } + + NativeBridge.dasher_set_long_parameter(handle, key, value); + result.Imported.Add($"{regName} = {value}"); + } + + private static void ImportString(IntPtr handle, V5MigrationResult result, string enumName, string value, string regName) + { + var key = NativeBridge.dasher_find_parameter_key(enumName); + if (key < 0) { result.Skipped.Add(regName); return; } + + if (DeferredEnums.Contains(enumName)) + { + result.DeferredParameters.Add((key, value)); + result.Imported.Add($"{regName} = \"{value}\""); + } + else + { + NativeBridge.dasher_set_string_parameter(handle, key, value); + result.Imported.Add($"{regName} = \"{value}\""); + } + } + + private static void ImportFontSize(IntPtr handle, V5MigrationResult result, string valueStr) + { + if (!int.TryParse(valueStr, out var index)) return; + var points = index switch + { + 0 => 14, 1 => 18, 2 => 22, 3 => 28, 4 => 36, + _ => Math.Min(index * 8, 72), + }; + var key = NativeBridge.dasher_find_parameter_key("LP_DASHER_FONTSIZE"); + if (key >= 0) + { + NativeBridge.dasher_set_long_parameter(handle, key, points); + result.Imported.Add($"DasherFontSize: index {index} → {points}pt"); + } + } + + private static void SetStartMode(IntPtr handle, V5MigrationResult result, int mode) + { + var key = NativeBridge.dasher_find_parameter_key("LP_START_MODE"); + if (key >= 0) + { + NativeBridge.dasher_set_long_parameter(handle, key, mode); + result.Imported.Add($"StartMode = {mode}"); + } + } + + private static void ImportColourId(IntPtr handle, V5MigrationResult result, string value) + { + var key = NativeBridge.dasher_find_parameter_key("SP_COLOUR_ID"); + if (key >= 0) + { + NativeBridge.dasher_set_string_parameter(handle, key, value); + result.Imported.Add($"ColourID = \"{value}\""); + } + } + + private static void CopyUserDataFiles(V5MigrationResult result) + { + try + { + Directory.CreateDirectory(V6Dir); + + foreach (var f in Directory.GetFiles(V5Dir, "*.*", SearchOption.TopDirectoryOnly)) + { + var name = Path.GetFileName(f); + var shouldCopy = name.StartsWith("alphabet.") || + name.StartsWith("colour.") || + name.StartsWith("color.") || + name.StartsWith("control.") || + name.StartsWith("training_"); + if (!shouldCopy) continue; + + var dest = Path.Combine(V6Dir, name); + if (File.Exists(dest)) + { + result.Skipped.Add($"File: {name} (already exists)"); + } + else + { + File.Copy(f, dest); + result.CopiedFiles.Add(name); + } + } + } + catch { } + } + + public static void MarkCompleted() + { + try { File.WriteAllText(MigrationFlagFile, DateTime.UtcNow.ToString("o")); } + catch { } + } +} diff --git a/src/Dasher.Windows/Views/MainWindow.axaml.cs b/src/Dasher.Windows/Views/MainWindow.axaml.cs index 3051a23..54b65ed 100644 --- a/src/Dasher.Windows/Views/MainWindow.axaml.cs +++ b/src/Dasher.Windows/Views/MainWindow.axaml.cs @@ -136,6 +136,7 @@ protected override void OnOpened(EventArgs e) var coreDataDir = FindCoreDataDir(); CopyDataIfNeeded(coreDataDir, dataDir); + // Phase 1: Create engine (but don't start it yet) _canvas.Initialize(dataDir, dataDir); _canvas.EngineMessage += OnEngineMessage; _vm.SetHandle(_canvas.GetHandle()); @@ -161,7 +162,143 @@ protected override void OnOpened(EventArgs e) var accessConfig = AccessConfiguration.Load(); accessConfig.Apply(_vm.Handle); - _vm.ApplySpeed(); + // Phase 2: Check for v5 migration BEFORE starting engine + if (V5MigrationService.HasV5Data && !V5MigrationService.HasBeenOffered) + { + ShowV5MigrationPrompt(); + } + else + { + CompleteStartup(null); + } + } + + private void ShowV5MigrationPrompt() + { + var scan = V5MigrationService.Scan(); + + var dialog = new Window + { + Title = "Dasher 5 settings found", + Width = 460, + Height = 400, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + CanResize = false, + ShowInTaskbar = false, + }; + + var panel = new StackPanel + { + Margin = new Thickness(24), + Spacing = 12, + }; + + panel.Children.Add(new TextBlock + { + Text = "We found your Dasher 5 configuration", + FontSize = 16, + FontWeight = FontWeight.Bold, + Foreground = new SolidColorBrush(Color.FromRgb(0x0F, 0x4B, 0x75)), + }); + + var summary = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(scan.Alphabet)) + summary.AppendLine($" Alphabet: {scan.Alphabet}"); + if (!string.IsNullOrEmpty(scan.Colour)) + summary.AppendLine($" Colour palette: {scan.Colour}"); + if (!string.IsNullOrEmpty(scan.Speed)) + summary.AppendLine($" Speed: {scan.Speed}"); + if (scan.ControlMode) + summary.AppendLine(" Control mode: enabled"); + if (scan.CustomFileCount > 0) + summary.AppendLine($" {scan.CustomFileCount} custom file(s)"); + + panel.Children.Add(new TextBlock + { + Text = summary.ToString().TrimEnd(), + FontSize = 13, + TextWrapping = TextWrapping.Wrap, + Foreground = new SolidColorBrush(Color.FromRgb(0x5A, 0x62, 0x70)), + }); + + panel.Children.Add(new TextBlock + { + Text = "Would you like to import these settings into Dasher 6?", + FontSize = 13, + TextWrapping = TextWrapping.Wrap, + Foreground = new SolidColorBrush(Color.FromRgb(0x5A, 0x62, 0x70)), + }); + + var btnRow = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 12, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 8, 0, 0), + }; + + var laterBtn = new Button + { + Content = "Start fresh", + Padding = new Thickness(20, 8), + Background = Brushes.Transparent, + BorderBrush = new SolidColorBrush(Color.FromRgb(0xE0, 0xE6, 0xE8)), + }; + + var importBtn = new Button + { + Content = "Import settings", + Padding = new Thickness(20, 8), + Background = new SolidColorBrush(Color.FromRgb(0x99, 0xD4, 0xCD)), + Foreground = new SolidColorBrush(Color.FromRgb(0x0F, 0x4B, 0x75)), + FontWeight = FontWeight.SemiBold, + BorderThickness = new Thickness(0), + }; + + V5MigrationResult? migrationResult = null; + + laterBtn.Click += (_, _) => + { + V5MigrationService.MarkCompleted(); + dialog.Close(); + }; + + importBtn.Click += (_, _) => + { + migrationResult = V5MigrationService.Import(_vm!.Handle); + dialog.Close(); + }; + + btnRow.Children.Add(laterBtn); + btnRow.Children.Add(importBtn); + panel.Children.Add(btnRow); + + dialog.Content = panel; + dialog.Closed += (_, _) => CompleteStartup(migrationResult); + dialog.ShowDialog(this); + } + + private void CompleteStartup(V5MigrationResult? migrationResult) + { + // Phase 3: Start engine (triggers Realize) + _canvas!.StartEngine(); + + // Phase 4: Apply deferred parameters (must be after Realize) + if (migrationResult != null) + { + foreach (var (key, value) in migrationResult.DeferredParameters) + { + if (value == "true" || value == "false") + NativeBridge.dasher_set_bool_parameter(_vm!.Handle, key, value == "true" ? 1 : 0); + else if (int.TryParse(value, out var intVal)) + NativeBridge.dasher_set_long_parameter(_vm!.Handle, key, intVal); + else + NativeBridge.dasher_set_string_parameter(_vm!.Handle, key, value); + } + } + + // Phase 5: Continue with UI setup + _vm!.ApplySpeed(); _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; _controlModeActive = NativeBridge.dasher_get_bool_parameter(_vm.Handle, ParameterKeys.BP_CONTROL_MODE) != 0; From 9cf3d6672a7a611889a9d05440eb26be506190d5 Mon Sep 17 00:00:00 2001 From: will wade Date: Thu, 18 Jun 2026 09:07:57 +0100 Subject: [PATCH 3/4] Add Dasher 5 import and reset settings to Privacy tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Privacy tab now has three sections: 1. Analytics (existing) — opt-in toggle, anonymous ID, reset ID 2. Dasher 5 Import — shows detected v5 data status with re-import button (re-runs migration and applies params immediately since engine is already realized) 3. Reset Settings — red 'Reset to defaults' button that deletes dasher_settings.xml so defaults load on next launch Also fixes crash: all migrated parameters now deferred until after Realize (LP_NODE_BUDSET etc. trigger handlers that dereference null m_pDasherModel before Realize) Signed-off-by: will wade --- src/Dasher.Windows/Controls/SettingsPanel.cs | 125 ++++++++++++++++++ .../Services/V5MigrationService.cs | 41 ++---- src/Dasher.Windows/Views/MainWindow.axaml.cs | 3 +- 3 files changed, 139 insertions(+), 30 deletions(-) diff --git a/src/Dasher.Windows/Controls/SettingsPanel.cs b/src/Dasher.Windows/Controls/SettingsPanel.cs index d98a4c9..6bb1844 100644 --- a/src/Dasher.Windows/Controls/SettingsPanel.cs +++ b/src/Dasher.Windows/Controls/SettingsPanel.cs @@ -1273,6 +1273,131 @@ private void BuildPrivacySettings() Foreground = mutedText, }); + // ── Dasher 5 Import section ── + section.Children.Add(new Border + { + BorderBrush = Application.Current?.FindResource("BorderLight") as IBrush ?? Brushes.LightGray, + BorderThickness = new Thickness(0, 1, 0, 0), + Margin = new Thickness(0, 24, 0, 0), + }); + + section.Children.Add(new TextBlock + { + Text = "Dasher 5 Import", + FontSize = 15, + FontWeight = FontWeight.SemiBold, + Foreground = titleText, + }); + + if (V5MigrationService.HasV5Data) + { + var scan = V5MigrationService.Scan(); + + var statusText = V5MigrationService.HasBeenOffered + ? "Status: Imported" + : "Status: Not imported (Dasher 5 data detected)"; + + section.Children.Add(new TextBlock + { + Text = statusText, + FontSize = 12, + Foreground = bodyText, + }); + + if (!string.IsNullOrEmpty(scan.Alphabet)) + section.Children.Add(new TextBlock { Text = $" Alphabet: {scan.Alphabet}", FontSize = 11, Foreground = mutedText }); + if (!string.IsNullOrEmpty(scan.Colour)) + section.Children.Add(new TextBlock { Text = $" Colour: {scan.Colour}", FontSize = 11, Foreground = mutedText }); + if (!string.IsNullOrEmpty(scan.Speed)) + section.Children.Add(new TextBlock { Text = $" Speed: {scan.Speed}", FontSize = 11, Foreground = mutedText }); + if (scan.ControlMode) + section.Children.Add(new TextBlock { Text = " Control mode: enabled", FontSize = 11, Foreground = mutedText }); + if (scan.CustomFileCount > 0) + section.Children.Add(new TextBlock { Text = $" {scan.CustomFileCount} custom file(s)", FontSize = 11, Foreground = mutedText }); + + var importBtn = new Button + { + Content = "Import from Dasher 5", + Padding = new Thickness(16, 6), + FontSize = 12, + Margin = new Thickness(0, 8, 0, 0), + HorizontalAlignment = HorizontalAlignment.Left, + Background = Application.Current?.FindResource("ControlBg") as IBrush ?? Brushes.LightGray, + BorderThickness = new Thickness(0), + }; + importBtn.Click += (s, e) => + { + var result = V5MigrationService.Import(_handle); + // Apply deferred params immediately (engine is already realized) + foreach (var (key, value) in result.DeferredParameters) + { + if (value == "true" || value == "false") + NativeBridge.dasher_set_bool_parameter(_handle, key, value == "true" ? 1 : 0); + else if (int.TryParse(value, out var intVal)) + NativeBridge.dasher_set_long_parameter(_handle, key, intVal); + else + NativeBridge.dasher_set_string_parameter(_handle, key, value); + } + // Refresh the panel + ShowCategory("Privacy"); + }; + section.Children.Add(importBtn); + } + else + { + section.Children.Add(new TextBlock + { + Text = "No Dasher 5 data found on this computer.", + FontSize = 12, + Foreground = mutedText, + }); + } + + // ── Reset to defaults ── + section.Children.Add(new Border + { + BorderBrush = Application.Current?.FindResource("BorderLight") as IBrush ?? Brushes.LightGray, + BorderThickness = new Thickness(0, 1, 0, 0), + Margin = new Thickness(0, 24, 0, 0), + }); + + section.Children.Add(new TextBlock + { + Text = "Reset Settings", + FontSize = 15, + FontWeight = FontWeight.SemiBold, + Foreground = titleText, + }); + + section.Children.Add(new TextBlock + { + Text = "Restore all Dasher settings to their default values. This cannot be undone.", + FontSize = 12, + TextWrapping = TextWrapping.Wrap, + Foreground = bodyText, + }); + + var resetSettingsBtn = new Button + { + Content = "Reset to defaults", + Padding = new Thickness(16, 6), + FontSize = 12, + Margin = new Thickness(0, 8, 0, 0), + HorizontalAlignment = HorizontalAlignment.Left, + Background = new SolidColorBrush(Color.FromRgb(0xEB, 0x5B, 0x5C)), + Foreground = Brushes.White, + BorderThickness = new Thickness(0), + }; + resetSettingsBtn.Click += (s, e) => + { + // Delete settings file so engine loads defaults on next launch + var settingsPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Dasher", "dasher_settings.xml"); + try { if (System.IO.File.Exists(settingsPath)) System.IO.File.Delete(settingsPath); } catch { } + }; + section.Children.Add(resetSettingsBtn); + _panel.Children.Add(section); } diff --git a/src/Dasher.Windows/Services/V5MigrationService.cs b/src/Dasher.Windows/Services/V5MigrationService.cs index 2437ec6..9c816fe 100644 --- a/src/Dasher.Windows/Services/V5MigrationService.cs +++ b/src/Dasher.Windows/Services/V5MigrationService.cs @@ -67,11 +67,10 @@ public static class V5MigrationService ["SlowControlBox"] = "BP_SLOW_CONTROL_BOX", }; - // Parameters that must be deferred until after Realize() - private static readonly HashSet DeferredEnums = new() - { - "BP_CONTROL_MODE", "SP_INPUT_FILTER", "SP_ALPHABET_ID" - }; + // All parameters must be deferred until after Realize() — some trigger + // handlers that dereference m_pDasherModel/m_pNCManager which are null + // before Realize. Deferring everything is safest. + // (Previously only 3 were deferred, but LP_NODE_BUDGET etc. also crash.) // v5 regName → v6 enum name for long parameters private static readonly Dictionary LongMappings = new() @@ -286,16 +285,8 @@ private static void ImportBool(IntPtr handle, V5MigrationResult result, string e var key = NativeBridge.dasher_find_parameter_key(enumName); if (key < 0) { result.Skipped.Add(regName); return; } - if (DeferredEnums.Contains(enumName)) - { - result.DeferredParameters.Add((key, value ? "true" : "false")); - result.Imported.Add($"{regName} = {value}"); - } - else - { - NativeBridge.dasher_set_bool_parameter(handle, key, value ? 1 : 0); - result.Imported.Add($"{regName} = {value}"); - } + result.DeferredParameters.Add((key, value ? "true" : "false")); + result.Imported.Add($"{regName} = {value}"); } private static void ImportLong(IntPtr handle, V5MigrationResult result, string enumName, string valueStr, string regName) @@ -304,7 +295,7 @@ private static void ImportLong(IntPtr handle, V5MigrationResult result, string e var key = NativeBridge.dasher_find_parameter_key(enumName); if (key < 0) { result.Skipped.Add(regName); return; } - NativeBridge.dasher_set_long_parameter(handle, key, value); + result.DeferredParameters.Add((key, value.ToString())); result.Imported.Add($"{regName} = {value}"); } @@ -313,16 +304,8 @@ private static void ImportString(IntPtr handle, V5MigrationResult result, string var key = NativeBridge.dasher_find_parameter_key(enumName); if (key < 0) { result.Skipped.Add(regName); return; } - if (DeferredEnums.Contains(enumName)) - { - result.DeferredParameters.Add((key, value)); - result.Imported.Add($"{regName} = \"{value}\""); - } - else - { - NativeBridge.dasher_set_string_parameter(handle, key, value); - result.Imported.Add($"{regName} = \"{value}\""); - } + result.DeferredParameters.Add((key, value)); + result.Imported.Add($"{regName} = \"{value}\""); } private static void ImportFontSize(IntPtr handle, V5MigrationResult result, string valueStr) @@ -336,7 +319,7 @@ private static void ImportFontSize(IntPtr handle, V5MigrationResult result, stri var key = NativeBridge.dasher_find_parameter_key("LP_DASHER_FONTSIZE"); if (key >= 0) { - NativeBridge.dasher_set_long_parameter(handle, key, points); + result.DeferredParameters.Add((key, points.ToString())); result.Imported.Add($"DasherFontSize: index {index} → {points}pt"); } } @@ -346,7 +329,7 @@ private static void SetStartMode(IntPtr handle, V5MigrationResult result, int mo var key = NativeBridge.dasher_find_parameter_key("LP_START_MODE"); if (key >= 0) { - NativeBridge.dasher_set_long_parameter(handle, key, mode); + result.DeferredParameters.Add((key, mode.ToString())); result.Imported.Add($"StartMode = {mode}"); } } @@ -356,7 +339,7 @@ private static void ImportColourId(IntPtr handle, V5MigrationResult result, stri var key = NativeBridge.dasher_find_parameter_key("SP_COLOUR_ID"); if (key >= 0) { - NativeBridge.dasher_set_string_parameter(handle, key, value); + result.DeferredParameters.Add((key, value)); result.Imported.Add($"ColourID = \"{value}\""); } } diff --git a/src/Dasher.Windows/Views/MainWindow.axaml.cs b/src/Dasher.Windows/Views/MainWindow.axaml.cs index 54b65ed..1d2109f 100644 --- a/src/Dasher.Windows/Views/MainWindow.axaml.cs +++ b/src/Dasher.Windows/Views/MainWindow.axaml.cs @@ -274,7 +274,8 @@ private void ShowV5MigrationPrompt() panel.Children.Add(btnRow); dialog.Content = panel; - dialog.Closed += (_, _) => CompleteStartup(migrationResult); + dialog.Closed += (_, _) => + Avalonia.Threading.Dispatcher.UIThread.Post(() => CompleteStartup(migrationResult)); dialog.ShowDialog(this); } From e9dbd17dee0e57e79b6e150b4741f00bb2fe3e0a Mon Sep 17 00:00:00 2001 From: will wade Date: Thu, 18 Jun 2026 09:18:31 +0100 Subject: [PATCH 4/4] Fix reset-to-defaults: destroy and recreate engine in-place Reset now works immediately without app restart. Destroys the engine, deletes the saved settings file, recreates the engine with defaults, re-wires all callbacks, and refreshes the settings panel. Output text is cleared and UI state (speed, alphabet, control mode) is reloaded. Signed-off-by: will wade --- src/Dasher.Windows/Controls/SettingsPanel.cs | 13 ++-- src/Dasher.Windows/Views/MainWindow.axaml.cs | 67 ++++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/Dasher.Windows/Controls/SettingsPanel.cs b/src/Dasher.Windows/Controls/SettingsPanel.cs index 6bb1844..405b9c3 100644 --- a/src/Dasher.Windows/Controls/SettingsPanel.cs +++ b/src/Dasher.Windows/Controls/SettingsPanel.cs @@ -210,6 +210,7 @@ private void ShowCategoryCore(string category) public event EventHandler<(EyeGazeIntegration.TrackerType trackerType, int udpPort)>? InputSourceChanged; public event EventHandler? JoystickRequested; public event Action? OutputFontChanged; + public event Action? ResetSettingsRequested; public event Action? KeyboardOpacityChanged; private static readonly string[] OutputFontPresets = @@ -1390,11 +1391,13 @@ private void BuildPrivacySettings() }; resetSettingsBtn.Click += (s, e) => { - // Delete settings file so engine loads defaults on next launch - var settingsPath = System.IO.Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "Dasher", "dasher_settings.xml"); - try { if (System.IO.File.Exists(settingsPath)) System.IO.File.Delete(settingsPath); } catch { } + var btn = s as Button; + if (btn != null) + { + btn.Content = "Restarting engine..."; + btn.IsEnabled = false; + } + ResetSettingsRequested?.Invoke(); }; section.Children.Add(resetSettingsBtn); diff --git a/src/Dasher.Windows/Views/MainWindow.axaml.cs b/src/Dasher.Windows/Views/MainWindow.axaml.cs index 1d2109f..c9b2f94 100644 --- a/src/Dasher.Windows/Views/MainWindow.axaml.cs +++ b/src/Dasher.Windows/Views/MainWindow.axaml.cs @@ -133,6 +133,15 @@ protected override void OnOpened(EventArgs e) var dataDir = Path.Combine(appData, "Dasher"); Directory.CreateDirectory(dataDir); + // Check for reset-to-defaults flag (written by Settings > Privacy > Reset) + var resetFlag = Path.Combine(dataDir, "reset_settings_pending"); + if (File.Exists(resetFlag)) + { + var settingsFile = Path.Combine(dataDir, "dasher_settings.xml"); + try { if (File.Exists(settingsFile)) File.Delete(settingsFile); } catch { } + try { File.Delete(resetFlag); } catch { } + } + var coreDataDir = FindCoreDataDir(); CopyDataIfNeeded(coreDataDir, dataDir); @@ -697,10 +706,68 @@ private void InitializeSettingsPanel() panel.KeyboardOpacityChanged += OnKeyboardOpacityChanged; panel.InputSourceChanged += OnInputSourceChanged; panel.JoystickRequested += OnJoystickRequested; + panel.ResetSettingsRequested += OnResetSettingsRequested; BuildSettingsTabs(panel); } + private void OnResetSettingsRequested() + { + if (_canvas == null || _vm == null) return; + + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var dataDir = Path.Combine(appData, "Dasher"); + + // Destroy engine (this saves current settings to file) + _canvas.Shutdown(); + + // Delete the saved settings so defaults load + var settingsFile = Path.Combine(dataDir, "dasher_settings.xml"); + try { if (File.Exists(settingsFile)) File.Delete(settingsFile); } catch { } + + // Recreate engine with defaults + _canvas.Initialize(dataDir, dataDir); + _canvas.EngineMessage += OnEngineMessage; + _vm.SetHandle(_canvas.GetHandle()); + + // Re-wire callbacks + if (_speakCallback != null) + NativeBridge.dasher_set_speak_callback(_vm.Handle, _speakCallback, IntPtr.Zero); + if (_parameterCallback != null) + NativeBridge.dasher_set_parameter_callback(_vm.Handle, _parameterCallback, IntPtr.Zero); + + var accessConfig = AccessConfiguration.Load(); + accessConfig.Apply(_vm.Handle); + + // Start engine + _canvas.StartEngine(); + + // Refresh UI state + _vm.ApplySpeed(); + _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; + _controlModeActive = NativeBridge.dasher_get_bool_parameter(_vm.Handle, ParameterKeys.BP_CONTROL_MODE) != 0; + UpdateControlModeLabel(); + _vm.LoadAlphabets(); + var currentAlphaPtr = NativeBridge.dasher_get_alphabet_id(_vm.Handle); + var currentAlpha = currentAlphaPtr != IntPtr.Zero + ? Marshal.PtrToStringUTF8(currentAlphaPtr) ?? "" : ""; + _vm.SelectedLanguageIndex = Math.Max(0, _vm.Languages.IndexOf(currentAlpha)); + + // Re-initialize settings panel with new handle + _settingsInitialized = false; + var panel = this.FindControl("DockedSettingsPanel"); + if (panel != null) + { + panel.Initialize(_vm.Handle); + panel.ShowCategory("Privacy"); + } + + // Clear output + _vm.OutputText = ""; + _previousOutput = ""; + } + private void BuildSettingsTabs(SettingsPanel settingsPanel) { var container = this.FindControl("SettingsTabContainer");