From 9e0b3e38325c6edd6e491a17e4ffdcd8fc997aef Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Wed, 10 Jun 2026 22:13:54 -0700 Subject: [PATCH 1/2] Add swiftDialog lock parity: button1disabled, quitkey, hidedefaultkeyboardaction Adds the dismissal-control surface needed to run csharpDialog as a locked provisioning progress window, matching how swiftDialog is driven on macOS: - --button1disabled renders button1 disabled and engages a close lock: the title-bar X and Alt+F4 refuse to close the window until a legitimate dismissal path runs (quit command, quit key, timeout, or a runtime 'button1: enable' followed by a click). - --quitkey makes Ctrl+ close the dialog (Windows analog of swiftDialog's Cmd-based quit key). - --hidedefaultkeyboardaction suppresses Esc, Enter, and Alt+F4. - Command file gains 'button1: enable|disable' (toggles the button and the close lock together) and 'button1text:' is now actually applied to the footer button; both were parsed but unimplemented. - --button1text/--button2text accepted as CLI aliases of --button1/--button2; the README documented them but the parser did not accept them. - Timeout and quit paths set AllowClose so they keep working under the lock. Compile-validated with dotnet build -p:EnableWindowsTargeting=true (CLI and WPF projects, zero warnings). --- README.md | 23 +++ src/CsharpDialog.Core/CommandLineParser.cs | 23 ++- src/CsharpDialog.Core/DialogConfiguration.cs | 21 +++ .../Services/CommandParser.cs | 3 +- .../ProgressDialogWindow.xaml.cs | 8 + .../Services/WpfDialogService.cs | 140 +++++++++++++++++- 6 files changed, 213 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index de10bb2..654dfd3 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,23 @@ csharpdialog --title "Auto-close" --message "This will close in 10 seconds" --ti |--------|-------------|---------| | `--commandfile` | Enable live updates via command file | `--commandfile "C:\temp\commands.txt"` | +### Lock / Dismissal Control (swiftDialog parity) +| Option | Description | Example | +|--------|-------------|---------| +| `--button1disabled` | Disable button1; window refuses to close (X, Alt+F4) until `quit`, quit key, timeout, or `button1: enable` | `--button1disabled` | +| `--quitkey` | Single character; Ctrl+\ closes the dialog | `--quitkey 0` | +| `--hidedefaultkeyboardaction` | Suppress Esc/Enter/Alt+F4 dismissal | `--hidedefaultkeyboardaction` | + +Use all three together for a non-blocking but undismissable progress window +that only the controlling script can close (via the command file), matching +swiftDialog's locked provisioning dialogs: + +```powershell +dialog --window --centeronscreen --topmost --button1text "Please wait" ` + --button1disabled --hidedefaultkeyboardaction --quitkey 0 ` + --commandfile "C:\temp\commands.txt" +``` + ### Styling (Legacy) | Option | Description | Example | |--------|-------------|---------| @@ -191,6 +208,12 @@ message: Updated message button1text: Continue ``` +#### Enable / Disable Button1 (releases or engages the close lock) +``` +button1text: Get Started +button1: enable +``` + #### Close Dialog ``` quit diff --git a/src/CsharpDialog.Core/CommandLineParser.cs b/src/CsharpDialog.Core/CommandLineParser.cs index 7bc7d4b..3407267 100644 --- a/src/CsharpDialog.Core/CommandLineParser.cs +++ b/src/CsharpDialog.Core/CommandLineParser.cs @@ -37,6 +37,7 @@ public static DialogConfiguration ParseArguments(string[] args) i++; break; case "--button1": + case "--button1text": if (!string.IsNullOrEmpty(value)) { config.Buttons.Add(new DialogButton { Text = value, Action = "button1", IsDefault = true }); @@ -44,12 +45,26 @@ public static DialogConfiguration ParseArguments(string[] args) } break; case "--button2": + case "--button2text": if (!string.IsNullOrEmpty(value)) { config.Buttons.Add(new DialogButton { Text = value, Action = "button2" }); i++; } break; + case "--button1disabled": + config.Button1Disabled = true; + break; + case "--quitkey": + if (!string.IsNullOrEmpty(value)) + { + config.QuitKey = value; + i++; + } + break; + case "--hidedefaultkeyboardaction": + config.HideDefaultKeyboardAction = true; + break; case "--timeout": if (int.TryParse(value, out int timeout)) { @@ -195,8 +210,12 @@ private static void ShowHelp() Console.WriteLine(" --title, -t Set dialog title"); Console.WriteLine(" --message, -m Set dialog message"); Console.WriteLine(" --icon, -i Set dialog icon"); - Console.WriteLine(" --button1 Set first button text"); - Console.WriteLine(" --button2 Set second button text"); + Console.WriteLine(" --button1 Set first button text (alias: --button1text)"); + Console.WriteLine(" --button2 Set second button text (alias: --button2text)"); + Console.WriteLine(" --button1disabled Disable button1; window cannot be closed until"); + Console.WriteLine(" quit command, quit key, timeout, or button1: enable"); + Console.WriteLine(" --quitkey Ctrl+ closes the dialog"); + Console.WriteLine(" --hidedefaultkeyboardaction Suppress Esc/Enter/Alt+F4 dismissal"); Console.WriteLine(" --timeout Auto-close after timeout"); Console.WriteLine(" --width Set dialog width"); Console.WriteLine(" --height Set dialog height"); diff --git a/src/CsharpDialog.Core/DialogConfiguration.cs b/src/CsharpDialog.Core/DialogConfiguration.cs index 5e86878..765cdfa 100644 --- a/src/CsharpDialog.Core/DialogConfiguration.cs +++ b/src/CsharpDialog.Core/DialogConfiguration.cs @@ -29,6 +29,27 @@ public class DialogConfiguration public string CommandFilePath { get; set; } = string.Empty; public bool EnableCommandFile { get; set; } = false; public bool AutoClearCommandFile { get; set; } = true; + + // Lock / dismissal control (swiftDialog parity) + /// + /// Renders button1 disabled. While disabled the window also refuses to + /// close via the title-bar X, so the dialog can only be dismissed by a + /// quit command, the quit key, a timeout, or a later "button1: enable". + /// Mirrors swiftDialog's --button1disabled. + /// + public bool Button1Disabled { get; set; } = false; + + /// + /// Single character; Ctrl+<char> closes the dialog (the Windows + /// analog of swiftDialog's Cmd-based --quitkey). Empty = no quit key. + /// + public string QuitKey { get; set; } = string.Empty; + + /// + /// Suppresses the default keyboard dismissals (Esc, Enter, Alt+F4). + /// Mirrors swiftDialog's --hidedefaultkeyboardaction. + /// + public bool HideDefaultKeyboardAction { get; set; } = false; // Progress bar configuration public bool ShowProgressBar { get; set; } = false; diff --git a/src/CsharpDialog.Core/Services/CommandParser.cs b/src/CsharpDialog.Core/Services/CommandParser.cs index 58f0d10..a9b9d77 100644 --- a/src/CsharpDialog.Core/Services/CommandParser.cs +++ b/src/CsharpDialog.Core/Services/CommandParser.cs @@ -11,7 +11,8 @@ public class CommandParser : ICommandParser private static readonly string[] ValidCommands = { "title", "message", "progress", "progresstext", "progressincrement", "progressreset", "quit", "listitem", "list", "config", "style", "theme", "execute", "executepowershell", - "executeoutput", "width", "height", "position", "icon", "image", "button1text", "button2text" + "executeoutput", "width", "height", "position", "icon", "image", "button1text", "button2text", + "button1", "button2" }; /// diff --git a/src/CsharpDialog.WPF/ProgressDialogWindow.xaml.cs b/src/CsharpDialog.WPF/ProgressDialogWindow.xaml.cs index 882f1e9..b779a88 100644 --- a/src/CsharpDialog.WPF/ProgressDialogWindow.xaml.cs +++ b/src/CsharpDialog.WPF/ProgressDialogWindow.xaml.cs @@ -14,6 +14,13 @@ public partial class ProgressDialogWindow : Window { private bool _isDarkMode; + /// + /// Set by legitimate dismissal paths (button click, quit command, quit + /// key, timeout) so the close-lock in WpfDialogService lets the window + /// close while --button1disabled is in effect. + /// + public bool AllowClose { get; set; } = false; + // Win32 API for dark mode title bar [DllImport("dwmapi.dll", PreserveSig = true)] private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize); @@ -124,6 +131,7 @@ private void ApplyTheme(bool isDarkMode) private void OkButton_Click(object sender, RoutedEventArgs e) { + AllowClose = true; this.Close(); } } diff --git a/src/CsharpDialog.WPF/Services/WpfDialogService.cs b/src/CsharpDialog.WPF/Services/WpfDialogService.cs index 72a5c6a..432024b 100644 --- a/src/CsharpDialog.WPF/Services/WpfDialogService.cs +++ b/src/CsharpDialog.WPF/Services/WpfDialogService.cs @@ -21,6 +21,11 @@ public class WpfDialogService : IDialogService private readonly Dictionary _listItems = new(); private int _nextListItemIndex = 0; private bool _isDarkMode = false; + // True while button1 is disabled: the window refuses to close via the + // title-bar X or Alt+F4 until a legitimate dismissal path sets + // _window.AllowClose (quit command, quit key, timeout, button1: enable + // followed by a click). swiftDialog --button1disabled parity. + private bool _closeLocked = false; public event EventHandler? CommandReceived; public bool IsCommandMonitoringActive => _commandMonitor?.IsMonitoring ?? false; @@ -68,7 +73,57 @@ public DialogResult ShowDialog(DialogConfiguration configuration) if (!string.IsNullOrEmpty(configuration.Title)) _window.Title = configuration.Title; - + + // Lock / dismissal control (swiftDialog parity) + _closeLocked = configuration.Button1Disabled; + if (_closeLocked) + { + _window.Closing += (s, e) => + { + if (_closeLocked && !_window.AllowClose) + { + e.Cancel = true; + Console.WriteLine($"[DEBUG] Close prevented - button1disabled lock active"); + } + }; + } + + if (configuration.HideDefaultKeyboardAction || !string.IsNullOrEmpty(configuration.QuitKey)) + { + var quitKey = configuration.QuitKey; + _window.PreviewKeyDown += (s, e) => + { + // Quit key: Ctrl+ closes the dialog (Windows analog + // of swiftDialog's Cmd-based --quitkey). + if (!string.IsNullOrEmpty(quitKey) && + System.Windows.Input.Keyboard.Modifiers == System.Windows.Input.ModifierKeys.Control && + KeyMatchesChar(e.Key, quitKey[0])) + { + Console.WriteLine($"[DEBUG] Quit key pressed (Ctrl+{quitKey}) - closing"); + result.ButtonPressed = "quit"; + _window.AllowClose = true; + _window.Close(); + e.Handled = true; + return; + } + + if (configuration.HideDefaultKeyboardAction) + { + // Suppress Esc/Enter and Alt+F4 (the default keyboard + // dismissals). swiftDialog --hidedefaultkeyboardaction. + if (e.Key == System.Windows.Input.Key.Escape || + e.Key == System.Windows.Input.Key.Enter || + e.Key == System.Windows.Input.Key.Return || + (e.SystemKey == System.Windows.Input.Key.F4 && + (System.Windows.Input.Keyboard.Modifiers & System.Windows.Input.ModifierKeys.Alt) != 0)) + { + e.Handled = true; + Console.WriteLine($"[DEBUG] Default keyboard action suppressed ({e.Key})"); + } + } + }; + } + // Wait for window to load before accessing content elements _window.Loaded += (s, e) => { @@ -88,6 +143,21 @@ public DialogResult ShowDialog(DialogConfiguration configuration) if (messageText != null && !string.IsNullOrEmpty(configuration.Message)) messageText.Text = configuration.Message; + // Apply button1 text and disabled state (swiftDialog parity) + var okButton = _window.FindName("OkButton") as Button; + if (okButton != null) + { + var button1 = configuration.Buttons.FirstOrDefault(b => b.Action is "button1" or "ok"); + if (button1 != null && !string.IsNullOrEmpty(button1.Text)) + okButton.Content = button1.Text; + + if (configuration.Button1Disabled) + { + okButton.IsEnabled = false; + Console.WriteLine($"[DEBUG] Button1 disabled (--button1disabled)"); + } + } + // Hide progress bar if not enabled var progressBar = _window.FindName("ProgressBarControl") as ProgressBar; var progressText = _window.FindName("ProgressText") as TextBlock; @@ -186,6 +256,7 @@ public DialogResult ShowDialog(DialogConfiguration configuration) timeoutTimer.Stop(); Console.WriteLine($"[DEBUG] Timeout reached - closing window"); result.ButtonPressed = "timeout"; + _window.AllowClose = true; _window.Close(); }; timeoutTimer.Start(); @@ -312,8 +383,37 @@ private void ProcessCommandSync(Command command) ProcessListItemCommand(command); break; + case "button1text": + var okBtnText = _window!.FindName("OkButton") as Button; + if (okBtnText != null) + okBtnText.Content = command.Value; + break; + + case "button1": + // "button1: enable" / "button1: disable" — toggles the button + // and the close lock together (swiftDialog parity: enabling + // button1 restores a legitimate dismissal path). + var okBtnState = _window!.FindName("OkButton") as Button; + if (okBtnState != null) + { + if (command.Value.Equals("enable", StringComparison.OrdinalIgnoreCase)) + { + okBtnState.IsEnabled = true; + _closeLocked = false; + Console.WriteLine($"[DEBUG] Button1 enabled - close lock released"); + } + else if (command.Value.Equals("disable", StringComparison.OrdinalIgnoreCase)) + { + okBtnState.IsEnabled = false; + _closeLocked = true; + Console.WriteLine($"[DEBUG] Button1 disabled - close lock engaged"); + } + } + break; + case "quit": - _window!.Close(); + _window!.AllowClose = true; + _window.Close(); break; } } @@ -324,6 +424,18 @@ private void ProcessCommandSync(Command command) } } + // Maps a WPF Key to a quit-key character (letters and digits). + private static bool KeyMatchesChar(System.Windows.Input.Key key, char ch) + { + var upper = char.ToUpperInvariant(ch); + if (upper >= 'A' && upper <= 'Z') + return key.ToString().Equals(upper.ToString(), StringComparison.Ordinal); + if (upper >= '0' && upper <= '9') + return key.ToString().Equals("D" + upper, StringComparison.Ordinal) || + key.ToString().Equals("NumPad" + upper, StringComparison.Ordinal); + return false; + } + private void OnCommandError(object? sender, CommandFileErrorEventArgs e) { Console.WriteLine($"Command file error: {e.Message}"); @@ -370,7 +482,31 @@ await _window.Dispatcher.InvokeAsync(() => ProcessListItemCommand(command); break; + case "button1text": + var okBtnText = _window.FindName("OkButton") as Button; + if (okBtnText != null) + okBtnText.Content = command.Value; + break; + + case "button1": + var okBtnState = _window.FindName("OkButton") as Button; + if (okBtnState != null) + { + if (command.Value.Equals("enable", StringComparison.OrdinalIgnoreCase)) + { + okBtnState.IsEnabled = true; + _closeLocked = false; + } + else if (command.Value.Equals("disable", StringComparison.OrdinalIgnoreCase)) + { + okBtnState.IsEnabled = false; + _closeLocked = true; + } + } + break; + case "quit": + _window.AllowClose = true; _window.Close(); break; } From bb76df4bb3e38c48b7e5271c07217a33328e3bbb Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Wed, 10 Jun 2026 22:37:47 -0700 Subject: [PATCH 2/2] Address review: runtime close-lock, bare quit, button result, quitkey normalization - Attach the Closing gate unconditionally so a runtime 'button1: disable' command actually enforces the close lock, not just the startup flag. - Accept bare valid-command lines (no colon) in the command file parser so the documented 'quit' form works; it was previously rejected, meaning the documented close path never fired. - Record the configured button1 action in DialogResult.ButtonPressed on click instead of always reporting the default 'ok'. - Normalize --quitkey to its first character at parse time. - Fix the close-lock field comment to match the actual release behavior (released immediately on 'button1: enable'). --- src/CsharpDialog.Core/CommandLineParser.cs | 4 +-- .../Services/CommandParser.cs | 23 +++++++++---- .../Services/WpfDialogService.cs | 32 +++++++++++-------- 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/CsharpDialog.Core/CommandLineParser.cs b/src/CsharpDialog.Core/CommandLineParser.cs index 3407267..5e8484a 100644 --- a/src/CsharpDialog.Core/CommandLineParser.cs +++ b/src/CsharpDialog.Core/CommandLineParser.cs @@ -56,9 +56,9 @@ public static DialogConfiguration ParseArguments(string[] args) config.Button1Disabled = true; break; case "--quitkey": - if (!string.IsNullOrEmpty(value)) + if (!string.IsNullOrWhiteSpace(value)) { - config.QuitKey = value; + config.QuitKey = value.Trim().Substring(0, 1); i++; } break; diff --git a/src/CsharpDialog.Core/Services/CommandParser.cs b/src/CsharpDialog.Core/Services/CommandParser.cs index a9b9d77..c3603b5 100644 --- a/src/CsharpDialog.Core/Services/CommandParser.cs +++ b/src/CsharpDialog.Core/Services/CommandParser.cs @@ -26,16 +26,27 @@ public class CommandParser : ICommandParser } var trimmedLine = commandLine.Trim(); - - // Basic command format: "command: value" + + // Basic command format: "command: value". A bare command name with no + // colon is also accepted for value-less commands (e.g. "quit"), which + // is the form the documentation and existing scripts use. var colonIndex = trimmedLine.IndexOf(':'); + string commandType; + string commandValue; if (colonIndex == -1) { - return null; // Invalid format + commandType = trimmedLine.ToLowerInvariant(); + commandValue = string.Empty; + if (!ValidCommands.Contains(commandType)) + { + return null; // Not a bare valid command + } + } + else + { + commandType = trimmedLine[..colonIndex].Trim().ToLowerInvariant(); + commandValue = trimmedLine[(colonIndex + 1)..].Trim(); } - - var commandType = trimmedLine[..colonIndex].Trim().ToLowerInvariant(); - var commandValue = trimmedLine[(colonIndex + 1)..].Trim(); if (!ValidCommands.Contains(commandType)) { diff --git a/src/CsharpDialog.WPF/Services/WpfDialogService.cs b/src/CsharpDialog.WPF/Services/WpfDialogService.cs index 432024b..a67095c 100644 --- a/src/CsharpDialog.WPF/Services/WpfDialogService.cs +++ b/src/CsharpDialog.WPF/Services/WpfDialogService.cs @@ -22,9 +22,10 @@ public class WpfDialogService : IDialogService private int _nextListItemIndex = 0; private bool _isDarkMode = false; // True while button1 is disabled: the window refuses to close via the - // title-bar X or Alt+F4 until a legitimate dismissal path sets - // _window.AllowClose (quit command, quit key, timeout, button1: enable - // followed by a click). swiftDialog --button1disabled parity. + // title-bar X or Alt+F4. The lock clears when a legitimate dismissal + // path sets _window.AllowClose (quit command, quit key, timeout, button + // click) or immediately on a runtime "button1: enable" command. + // swiftDialog --button1disabled parity. private bool _closeLocked = false; public event EventHandler? CommandReceived; @@ -74,19 +75,18 @@ public DialogResult ShowDialog(DialogConfiguration configuration) if (!string.IsNullOrEmpty(configuration.Title)) _window.Title = configuration.Title; - // Lock / dismissal control (swiftDialog parity) + // Lock / dismissal control (swiftDialog parity). The handler is + // always attached because the lock can also be engaged at runtime + // via a "button1: disable" command-file command. _closeLocked = configuration.Button1Disabled; - if (_closeLocked) + _window.Closing += (s, e) => { - _window.Closing += (s, e) => + if (_closeLocked && !_window.AllowClose) { - if (_closeLocked && !_window.AllowClose) - { - e.Cancel = true; - Console.WriteLine($"[DEBUG] Close prevented - button1disabled lock active"); - } - }; - } + e.Cancel = true; + Console.WriteLine($"[DEBUG] Close prevented - button1disabled lock active"); + } + }; if (configuration.HideDefaultKeyboardAction || !string.IsNullOrEmpty(configuration.QuitKey)) { @@ -151,6 +151,12 @@ public DialogResult ShowDialog(DialogConfiguration configuration) if (button1 != null && !string.IsNullOrEmpty(button1.Text)) okButton.Content = button1.Text; + // Record the configured action so the dialog result (and + // the CLI exit-code mapping) reflects which button closed + // the window instead of always reporting the default "ok". + var button1Action = button1?.Action ?? "button1"; + okButton.Click += (cs, ce) => result.ButtonPressed = button1Action; + if (configuration.Button1Disabled) { okButton.IsEnabled = false;