From 44373985084e9f2e8c74e216f62b33aa0228aa80 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Oct 2025 15:24:50 -0600 Subject: [PATCH] Refactor `Application.Keyboard` to support fully decoupled state and parallelizable unit tests (#4316) * Initial plan * Refactor keyboard handling to IKeyboard interface Co-authored-by: tig <585482+tig@users.noreply.github.com> * Add parallelizable keyboard tests and fix lazy initialization Co-authored-by: tig <585482+tig@users.noreply.github.com> * Fix keyboard settings preservation during Init Co-authored-by: tig <585482+tig@users.noreply.github.com> * Migrate all KeyboardTests to parallelizable and delete old non-parallelizable tests Co-authored-by: tig <585482+tig@users.noreply.github.com> * Decouple Keyboard from static Application class by adding IApplication reference Co-authored-by: tig <585482+tig@users.noreply.github.com> * Fix coding standards: use explicit types and target-typed new() properly Co-authored-by: tig <585482+tig@users.noreply.github.com> * Changes before error encountered Co-authored-by: tig <585482+tig@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tig <585482+tig@users.noreply.github.com> --- .../App/Application.Initialization.cs | 2 - Terminal.Gui/App/Application.Keyboard.cs | 286 +--------- Terminal.Gui/App/Application.Navigation.cs | 65 +-- Terminal.Gui/App/Application.Run.cs | 26 +- Terminal.Gui/App/Application.cs | 9 +- Terminal.Gui/App/ApplicationImpl.cs | 89 ++- Terminal.Gui/App/IApplication.cs | 30 + Terminal.Gui/App/Keyboard/IKeyboard.cs | 113 ++++ Terminal.Gui/App/Keyboard/Keyboard.cs | 381 +++++++++++++ Tests/UnitTests/Application/KeyboardTests.cs | 518 ------------------ .../Application/KeyboardTests.cs | 477 ++++++++++++++++ docfx/docs/keyboard.md | 78 ++- 12 files changed, 1206 insertions(+), 868 deletions(-) create mode 100644 Terminal.Gui/App/Keyboard/IKeyboard.cs create mode 100644 Terminal.Gui/App/Keyboard/Keyboard.cs delete mode 100644 Tests/UnitTests/Application/KeyboardTests.cs create mode 100644 Tests/UnitTestsParallelizable/Application/KeyboardTests.cs diff --git a/Terminal.Gui/App/Application.Initialization.cs b/Terminal.Gui/App/Application.Initialization.cs index ba8825e4c..04becee71 100644 --- a/Terminal.Gui/App/Application.Initialization.cs +++ b/Terminal.Gui/App/Application.Initialization.cs @@ -144,8 +144,6 @@ public static partial class Application // Initialization (Init/Shutdown) Debug.Assert (Popover is null); Popover = new (); - AddKeyBindings (); - try { MainLoop = Driver!.Init (); diff --git a/Terminal.Gui/App/Application.Keyboard.cs b/Terminal.Gui/App/Application.Keyboard.cs index 19f879eb1..133f56820 100644 --- a/Terminal.Gui/App/Application.Keyboard.cs +++ b/Terminal.Gui/App/Application.Keyboard.cs @@ -4,6 +4,16 @@ namespace Terminal.Gui.App; public static partial class Application // Keyboard handling { + /// + /// Static reference to the current . + /// + public static IKeyboard Keyboard + { + get => ApplicationImpl.Instance.Keyboard; + set => ApplicationImpl.Instance.Keyboard = value ?? + throw new ArgumentNullException(nameof(value)); + } + /// /// Called when the user presses a key (by the ). Raises the cancelable /// event, then calls on all top level views, and finally @@ -12,63 +22,7 @@ public static partial class Application // Keyboard handling /// Can be used to simulate key press events. /// /// if the key was handled. - public static bool RaiseKeyDownEvent (Key key) - { - Logging.Debug ($"{key}"); - - // TODO: Add a way to ignore certain keys, esp for debugging. - //#if DEBUG - // if (key == Key.Empty.WithAlt || key == Key.Empty.WithCtrl) - // { - // Logging.Debug ($"Ignoring {key}"); - // return false; - // } - //#endif - - // TODO: This should match standard event patterns - KeyDown?.Invoke (null, key); - - if (key.Handled) - { - return true; - } - - if (Popover?.DispatchKeyDown (key) is true) - { - return true; - } - - if (Top is null) - { - foreach (Toplevel topLevel in TopLevels.ToList ()) - { - if (topLevel.NewKeyDownEvent (key)) - { - return true; - } - - if (topLevel.Modal) - { - break; - } - } - } - else - { - if (Top.NewKeyDownEvent (key)) - { - return true; - } - } - - bool? commandHandled = InvokeCommandsBoundToKey (key); - if(commandHandled is true) - { - return true; - } - - return false; - } + public static bool RaiseKeyDownEvent (Key key) => Keyboard.RaiseKeyDownEvent (key); /// /// Invokes any commands bound at the Application-level to . @@ -79,38 +33,7 @@ public static partial class Application // Keyboard handling /// if the command was invoked and was not handled (or cancelled); input processing should continue. /// if the command was invoked the command was handled (or cancelled); input processing should stop. /// - public static bool? InvokeCommandsBoundToKey (Key key) - { - bool? handled = null; - // Invoke any Application-scoped KeyBindings. - // The first view that handles the key will stop the loop. - // foreach (KeyValuePair binding in KeyBindings.GetBindings (key)) - if (KeyBindings.TryGet (key, out KeyBinding binding)) - { - if (binding.Target is { }) - { - if (!binding.Target.Enabled) - { - return null; - } - - handled = binding.Target?.InvokeCommands (binding.Commands, binding); - } - else - { - bool? toReturn = null; - - foreach (Command command in binding.Commands) - { - toReturn = InvokeCommand (command, key, binding); - } - - handled = toReturn ?? true; - } - } - - return handled; - } + public static bool? InvokeCommandsBoundToKey (Key key) => Keyboard.InvokeCommandsBoundToKey (key); /// /// Invokes an Application-bound command. @@ -124,24 +47,7 @@ public static partial class Application // Keyboard handling /// if the command was invoked the command was handled (or cancelled); input processing should stop. /// /// - public static bool? InvokeCommand (Command command, Key key, KeyBinding binding) - { - if (!_commandImplementations!.ContainsKey (command)) - { - throw new NotSupportedException ( - @$"A KeyBinding was set up for the command {command} ({key}) but that command is not supported by Application." - ); - } - - if (_commandImplementations.TryGetValue (command, out View.CommandImplementation? implementation)) - { - CommandContext context = new (command, null, binding); // Create the context here - - return implementation (context); - } - - return null; - } + public static bool? InvokeCommand (Command command, Key key, KeyBinding binding) => Keyboard.InvokeCommand (command, key, binding); /// /// Raised when the user presses a key. @@ -155,7 +61,11 @@ public static partial class Application // Keyboard handling /// and events. /// Fired after and before . /// - public static event EventHandler? KeyDown; + public static event EventHandler? KeyDown + { + add => Keyboard.KeyDown += value; + remove => Keyboard.KeyDown -= value; + } /// /// Called when the user releases a key (by the ). Raises the cancelable @@ -166,168 +76,16 @@ public static partial class Application // Keyboard handling /// Can be used to simulate key release events. /// /// if the key was handled. - public static bool RaiseKeyUpEvent (Key key) - { - if (!Initialized) - { - return true; - } - - KeyUp?.Invoke (null, key); - - if (key.Handled) - { - return true; - } - - - // TODO: Add Popover support - - foreach (Toplevel topLevel in TopLevels.ToList ()) - { - if (topLevel.NewKeyUpEvent (key)) - { - return true; - } - - if (topLevel.Modal) - { - break; - } - } - - return false; - } - - #region Application-scoped KeyBindings - - static Application () - { - AddKeyBindings (); - } + public static bool RaiseKeyUpEvent (Key key) => Keyboard.RaiseKeyUpEvent (key); /// Gets the Application-scoped key bindings. - public static KeyBindings KeyBindings { get; internal set; } = new (null); + public static KeyBindings KeyBindings => Keyboard.KeyBindings; internal static void AddKeyBindings () { - _commandImplementations.Clear (); - - // Things Application knows how to do - AddCommand ( - Command.Quit, - static () => - { - RequestStop (); - - return true; - } - ); - AddCommand ( - Command.Suspend, - static () => - { - Driver?.Suspend (); - - return true; - } - ); - AddCommand ( - Command.NextTabStop, - static () => Navigation?.AdvanceFocus (NavigationDirection.Forward, TabBehavior.TabStop)); - - AddCommand ( - Command.PreviousTabStop, - static () => Navigation?.AdvanceFocus (NavigationDirection.Backward, TabBehavior.TabStop)); - - AddCommand ( - Command.NextTabGroup, - static () => Navigation?.AdvanceFocus (NavigationDirection.Forward, TabBehavior.TabGroup)); - - AddCommand ( - Command.PreviousTabGroup, - static () => Navigation?.AdvanceFocus (NavigationDirection.Backward, TabBehavior.TabGroup)); - - AddCommand ( - Command.Refresh, - static () => - { - LayoutAndDraw (true); - - return true; - } - ); - - AddCommand ( - Command.Arrange, - static () => - { - View? viewToArrange = Navigation?.GetFocused (); - - // Go up the superview hierarchy and find the first that is not ViewArrangement.Fixed - while (viewToArrange is { SuperView: { }, Arrangement: ViewArrangement.Fixed }) - { - viewToArrange = viewToArrange.SuperView; - } - - if (viewToArrange is { }) - { - return viewToArrange.Border?.EnterArrangeMode (ViewArrangement.Fixed); - } - - return false; - }); - - //SetKeysToHardCodedDefaults (); - - // Need to clear after setting the above to ensure actually clear - // because set_QuitKey etc.. may call Add - KeyBindings.Clear (); - - KeyBindings.Add (QuitKey, Command.Quit); - KeyBindings.Add (NextTabKey, Command.NextTabStop); - KeyBindings.Add (PrevTabKey, Command.PreviousTabStop); - KeyBindings.Add (NextTabGroupKey, Command.NextTabGroup); - KeyBindings.Add (PrevTabGroupKey, Command.PreviousTabGroup); - KeyBindings.Add (ArrangeKey, Command.Arrange); - - KeyBindings.Add (Key.CursorRight, Command.NextTabStop); - KeyBindings.Add (Key.CursorDown, Command.NextTabStop); - KeyBindings.Add (Key.CursorLeft, Command.PreviousTabStop); - KeyBindings.Add (Key.CursorUp, Command.PreviousTabStop); - - // TODO: Refresh Key should be configurable - KeyBindings.Add (Key.F5, Command.Refresh); - - // TODO: Suspend Key should be configurable - if (Environment.OSVersion.Platform == PlatformID.Unix) + if (Keyboard is Keyboard keyboard) { - KeyBindings.Add (Key.Z.WithCtrl, Command.Suspend); + keyboard.AddKeyBindings (); } } - - #endregion Application-scoped KeyBindings - - /// - /// - /// Sets the function that will be invoked for a . - /// - /// - /// If AddCommand has already been called for will - /// replace the old one. - /// - /// - /// - /// - /// This version of AddCommand is for commands that do not require a . - /// - /// - /// The command. - /// The function. - private static void AddCommand (Command command, Func f) { _commandImplementations! [command] = ctx => f (); } - - /// - /// Commands for Application. - /// - private static readonly Dictionary _commandImplementations = new (); } diff --git a/Terminal.Gui/App/Application.Navigation.cs b/Terminal.Gui/App/Application.Navigation.cs index c41b0e407..0b35b80c6 100644 --- a/Terminal.Gui/App/Application.Navigation.cs +++ b/Terminal.Gui/App/Application.Navigation.cs @@ -9,42 +9,22 @@ public static partial class Application // Navigation stuff /// public static ApplicationNavigation? Navigation { get; internal set; } - private static Key _nextTabGroupKey = Key.F6; // Resources/config.json overrides - private static Key _nextTabKey = Key.Tab; // Resources/config.json overrides - private static Key _prevTabGroupKey = Key.F6.WithShift; // Resources/config.json overrides - private static Key _prevTabKey = Key.Tab.WithShift; // Resources/config.json overrides - /// Alternative key to navigate forwards through views. Ctrl+Tab is the primary key. [ConfigurationProperty (Scope = typeof (SettingsScope))] public static Key NextTabGroupKey { - get => _nextTabGroupKey; - set - { - //if (_nextTabGroupKey != value) - { - KeyBindings.Replace (_nextTabGroupKey, value); - _nextTabGroupKey = value; - } - } + get => Keyboard.NextTabGroupKey; + set => Keyboard.NextTabGroupKey = value; } - /// Alternative key to navigate forwards through views. Ctrl+Tab is the primary key. + /// Alternative key to navigate forwards through views. Tab is the primary key. [ConfigurationProperty (Scope = typeof (SettingsScope))] public static Key NextTabKey { - get => _nextTabKey; - set - { - //if (_nextTabKey != value) - { - KeyBindings.Replace (_nextTabKey, value); - _nextTabKey = value; - } - } + get => Keyboard.NextTabKey; + set => Keyboard.NextTabKey = value; } - /// /// Raised when the user releases a key. /// @@ -57,34 +37,25 @@ public static partial class Application // Navigation stuff /// and events. /// Fired after . /// - public static event EventHandler? KeyUp; - /// Alternative key to navigate backwards through views. Shift+Ctrl+Tab is the primary key. - [ConfigurationProperty (Scope = typeof (SettingsScope))] - public static Key PrevTabGroupKey + public static event EventHandler? KeyUp { - get => _prevTabGroupKey; - set - { - //if (_prevTabGroupKey != value) - { - KeyBindings.Replace (_prevTabGroupKey, value); - _prevTabGroupKey = value; - } - } + add => Keyboard.KeyUp += value; + remove => Keyboard.KeyUp -= value; } /// Alternative key to navigate backwards through views. Shift+Ctrl+Tab is the primary key. [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static Key PrevTabGroupKey + { + get => Keyboard.PrevTabGroupKey; + set => Keyboard.PrevTabGroupKey = value; + } + + /// Alternative key to navigate backwards through views. Shift+Tab is the primary key. + [ConfigurationProperty (Scope = typeof (SettingsScope))] public static Key PrevTabKey { - get => _prevTabKey; - set - { - //if (_prevTabKey != value) - { - KeyBindings.Replace (_prevTabKey, value); - _prevTabKey = value; - } - } + get => Keyboard.PrevTabKey; + set => Keyboard.PrevTabKey = value; } } diff --git a/Terminal.Gui/App/Application.Run.cs b/Terminal.Gui/App/Application.Run.cs index 417d0b3af..9d5059c07 100644 --- a/Terminal.Gui/App/Application.Run.cs +++ b/Terminal.Gui/App/Application.Run.cs @@ -6,38 +6,20 @@ namespace Terminal.Gui.App; public static partial class Application // Run (Begin, Run, End, Stop) { - private static Key _quitKey = Key.Esc; // Resources/config.json overrides - /// Gets or sets the key to quit the application. [ConfigurationProperty (Scope = typeof (SettingsScope))] public static Key QuitKey { - get => _quitKey; - set - { - //if (_quitKey != value) - { - KeyBindings.Replace (_quitKey, value); - _quitKey = value; - } - } + get => Keyboard.QuitKey; + set => Keyboard.QuitKey = value; } - private static Key _arrangeKey = Key.F5.WithCtrl; // Resources/config.json overrides - /// Gets or sets the key to activate arranging views using the keyboard. [ConfigurationProperty (Scope = typeof (SettingsScope))] public static Key ArrangeKey { - get => _arrangeKey; - set - { - //if (_arrangeKey != value) - { - KeyBindings.Replace (_arrangeKey, value); - _arrangeKey = value; - } - } + get => Keyboard.ArrangeKey; + set => Keyboard.ArrangeKey = value; } // When `End ()` is called, it is possible `RunState.Toplevel` is a different object than `Top`. diff --git a/Terminal.Gui/App/Application.cs b/Terminal.Gui/App/Application.cs index 22b76b706..e7feaddef 100644 --- a/Terminal.Gui/App/Application.cs +++ b/Terminal.Gui/App/Application.cs @@ -243,6 +243,7 @@ public static partial class Application NotifyNewRunState = null; NotifyStopRunState = null; MouseGrabHandler = new MouseGrabHandler (); + // Keyboard will be lazy-initialized in ApplicationImpl on next access Initialized = false; // Mouse @@ -252,16 +253,12 @@ public static partial class Application CachedViewsUnderMouse.Clear (); MouseEvent = null; - // Keyboard - KeyDown = null; - KeyUp = null; + // Keyboard events and bindings are now managed by the Keyboard instance + SizeChanging = null; Navigation = null; - KeyBindings.Clear (); - AddKeyBindings (); - // Reset synchronization context to allow the user to run async/await, // as the main loop has been ended, the synchronization context from // gui.cs does no longer process any callbacks. See #1084 for more details: diff --git a/Terminal.Gui/App/ApplicationImpl.cs b/Terminal.Gui/App/ApplicationImpl.cs index 4f76c331b..9d0c89a62 100644 --- a/Terminal.Gui/App/ApplicationImpl.cs +++ b/Terminal.Gui/App/ApplicationImpl.cs @@ -37,6 +37,65 @@ public class ApplicationImpl : IApplication /// public IMouseGrabHandler MouseGrabHandler { get; set; } = new MouseGrabHandler (); + private IKeyboard? _keyboard; + + /// + /// Handles keyboard input and key bindings at the Application level + /// + public IKeyboard Keyboard + { + get + { + if (_keyboard is null) + { + _keyboard = new Keyboard { Application = this }; + } + return _keyboard; + } + set => _keyboard = value ?? throw new ArgumentNullException (nameof (value)); + } + + /// + public IConsoleDriver? Driver + { + get => Application.Driver; + set => Application.Driver = value; + } + + /// + public bool Initialized + { + get => Application.Initialized; + set => Application.Initialized = value; + } + + /// + public ApplicationPopover? Popover + { + get => Application.Popover; + set => Application.Popover = value; + } + + /// + public ApplicationNavigation? Navigation + { + get => Application.Navigation; + set => Application.Navigation = value; + } + + /// + public Toplevel? Top + { + get => Application.Top; + set => Application.Top = value; + } + + /// + public ConcurrentStack TopLevels => Application.TopLevels; + + /// + public void RequestStop () => Application.RequestStop (); + /// /// Creates a new instance of the Application backend. /// @@ -88,7 +147,28 @@ public class ApplicationImpl : IApplication Debug.Assert (Application.Popover is null); Application.Popover = new (); - Application.AddKeyBindings (); + // Preserve existing keyboard settings if they exist + bool hasExistingKeyboard = _keyboard is not null; + Key existingQuitKey = _keyboard?.QuitKey ?? Key.Esc; + Key existingArrangeKey = _keyboard?.ArrangeKey ?? Key.F5.WithCtrl; + Key existingNextTabKey = _keyboard?.NextTabKey ?? Key.Tab; + Key existingPrevTabKey = _keyboard?.PrevTabKey ?? Key.Tab.WithShift; + Key existingNextTabGroupKey = _keyboard?.NextTabGroupKey ?? Key.F6; + Key existingPrevTabGroupKey = _keyboard?.PrevTabGroupKey ?? Key.F6.WithShift; + + // Reset keyboard to ensure fresh state with default bindings + _keyboard = new Keyboard { Application = this }; + + // Restore previously set keys if they existed and were different from defaults + if (hasExistingKeyboard) + { + _keyboard.QuitKey = existingQuitKey; + _keyboard.ArrangeKey = existingArrangeKey; + _keyboard.NextTabKey = existingNextTabKey; + _keyboard.PrevTabKey = existingPrevTabKey; + _keyboard.NextTabGroupKey = existingNextTabGroupKey; + _keyboard.PrevTabGroupKey = existingPrevTabGroupKey; + } CreateDriver (driverName ?? _driverName); @@ -97,7 +177,7 @@ public class ApplicationImpl : IApplication Application.OnInitializedChanged (this, new (true)); Application.SubscribeDriverEvents (); - SynchronizationContext.SetSynchronizationContext (new MainLoopSyncContext ()); + SynchronizationContext.SetSynchronizationContext (new ()); Application.MainThreadId = Thread.CurrentThread.ManagedThreadId; } @@ -217,7 +297,7 @@ public class ApplicationImpl : IApplication Init (driver, null); } - var top = new T (); + T top = new (); Run (top, errorHandler); return top; } @@ -276,6 +356,7 @@ public class ApplicationImpl : IApplication } Application.Driver = null; + _keyboard = null; _lazyInstance = new (() => new ApplicationImpl ()); } @@ -291,7 +372,7 @@ public class ApplicationImpl : IApplication return; } - var ev = new ToplevelClosingEventArgs (top); + ToplevelClosingEventArgs ev = new (top); top.OnClosing (ev); if (ev.Cancel) diff --git a/Terminal.Gui/App/IApplication.cs b/Terminal.Gui/App/IApplication.cs index ef4989a18..1b343a1a8 100644 --- a/Terminal.Gui/App/IApplication.cs +++ b/Terminal.Gui/App/IApplication.cs @@ -20,6 +20,36 @@ public interface IApplication /// IMouseGrabHandler MouseGrabHandler { get; set; } + /// + /// Handles keyboard input and key bindings at the Application level. + /// + IKeyboard Keyboard { get; set; } + + /// Gets or sets the console driver being used. + IConsoleDriver? Driver { get; set; } + + /// Gets or sets whether the application has been initialized. + bool Initialized { get; set; } + + /// Gets or sets the popover manager. + ApplicationPopover? Popover { get; set; } + + /// Gets or sets the navigation manager. + ApplicationNavigation? Navigation { get; set; } + + /// Gets the currently active Toplevel. + Toplevel? Top { get; set; } + + /// Gets the stack of all Toplevels. + System.Collections.Concurrent.ConcurrentStack TopLevels { get; } + + /// Requests that the application stop running. + void RequestStop (); + + /// Forces all views to be laid out and drawn. + /// If true, clears the screen before drawing. + void LayoutAndDraw (bool clearScreen = false); + /// Initializes a new instance of Application. /// Call this method once per instance (or after has been called). /// diff --git a/Terminal.Gui/App/Keyboard/IKeyboard.cs b/Terminal.Gui/App/Keyboard/IKeyboard.cs new file mode 100644 index 000000000..9377db02b --- /dev/null +++ b/Terminal.Gui/App/Keyboard/IKeyboard.cs @@ -0,0 +1,113 @@ +#nullable enable +namespace Terminal.Gui.App; + +/// +/// Defines a contract for managing keyboard input and key bindings at the Application level. +/// +/// This interface decouples keyboard handling state from the static class, +/// enabling parallelizable unit tests and better testability. +/// +/// +public interface IKeyboard +{ + /// + /// Sets the application instance that this keyboard handler is associated with. + /// This provides access to application state without coupling to static Application class. + /// + IApplication? Application { get; set; } + + /// + /// Called when the user presses a key (by the ). Raises the cancelable + /// event, then calls on all top level views, and finally + /// if the key was not handled, invokes any Application-scoped . + /// + /// Can be used to simulate key press events. + /// + /// if the key was handled. + bool RaiseKeyDownEvent (Key key); + + /// + /// Called when the user releases a key (by the ). Raises the cancelable + /// + /// event + /// then calls on all top level views. Called after . + /// + /// Can be used to simulate key release events. + /// + /// if the key was handled. + bool RaiseKeyUpEvent (Key key); + + /// + /// Invokes any commands bound at the Application-level to . + /// + /// + /// + /// if no command was found; input processing should continue. + /// if the command was invoked and was not handled (or cancelled); input processing should continue. + /// if the command was invoked the command was handled (or cancelled); input processing should stop. + /// + bool? InvokeCommandsBoundToKey (Key key); + + /// + /// Invokes an Application-bound command. + /// + /// The Command to invoke + /// The Application-bound Key that was pressed. + /// Describes the binding. + /// + /// if no command was found; input processing should continue. + /// if the command was invoked and was not handled (or cancelled); input processing should continue. + /// if the command was invoked the command was handled (or cancelled); input processing should stop. + /// + /// + bool? InvokeCommand (Command command, Key key, KeyBinding binding); + + /// + /// Raised when the user presses a key. + /// + /// Set to to indicate the key was handled and to prevent + /// additional processing. + /// + /// + /// + /// All drivers support firing the event. Some drivers (Unix) do not support firing the + /// and events. + /// Fired after and before . + /// + event EventHandler? KeyDown; + + /// + /// Raised when the user releases a key. + /// + /// Set to to indicate the key was handled and to prevent + /// additional processing. + /// + /// + /// + /// All drivers support firing the event. Some drivers (Unix) do not support firing the + /// and events. + /// Fired after . + /// + event EventHandler? KeyUp; + + /// Gets the Application-scoped key bindings. + KeyBindings KeyBindings { get; } + + /// Gets or sets the key to quit the application. + Key QuitKey { get; set; } + + /// Gets or sets the key to activate arranging views using the keyboard. + Key ArrangeKey { get; set; } + + /// Alternative key to navigate forwards through views. Ctrl+Tab is the primary key. + Key NextTabGroupKey { get; set; } + + /// Alternative key to navigate forwards through views. Tab is the primary key. + Key NextTabKey { get; set; } + + /// Alternative key to navigate backwards through views. Shift+Ctrl+Tab is the primary key. + Key PrevTabGroupKey { get; set; } + + /// Alternative key to navigate backwards through views. Shift+Tab is the primary key. + Key PrevTabKey { get; set; } +} diff --git a/Terminal.Gui/App/Keyboard/Keyboard.cs b/Terminal.Gui/App/Keyboard/Keyboard.cs new file mode 100644 index 000000000..ff7a5f024 --- /dev/null +++ b/Terminal.Gui/App/Keyboard/Keyboard.cs @@ -0,0 +1,381 @@ +#nullable enable +namespace Terminal.Gui.App; + +/// +/// INTERNAL: Implements to manage keyboard input and key bindings at the Application level. +/// +/// This implementation decouples keyboard handling state from the static class, +/// enabling parallelizable unit tests and better testability. +/// +/// +/// See for usage details. +/// +/// +internal class Keyboard : IKeyboard +{ + private Key _quitKey = Key.Esc; // Resources/config.json overrides + private Key _arrangeKey = Key.F5.WithCtrl; // Resources/config.json overrides + private Key _nextTabGroupKey = Key.F6; // Resources/config.json overrides + private Key _nextTabKey = Key.Tab; // Resources/config.json overrides + private Key _prevTabGroupKey = Key.F6.WithShift; // Resources/config.json overrides + private Key _prevTabKey = Key.Tab.WithShift; // Resources/config.json overrides + + /// + /// Commands for Application. + /// + private readonly Dictionary _commandImplementations = new (); + + /// + public IApplication? Application { get; set; } + + /// + public KeyBindings KeyBindings { get; internal set; } = new (null); + + /// + public Key QuitKey + { + get => _quitKey; + set + { + KeyBindings.Replace (_quitKey, value); + _quitKey = value; + } + } + + /// + public Key ArrangeKey + { + get => _arrangeKey; + set + { + KeyBindings.Replace (_arrangeKey, value); + _arrangeKey = value; + } + } + + /// + public Key NextTabGroupKey + { + get => _nextTabGroupKey; + set + { + KeyBindings.Replace (_nextTabGroupKey, value); + _nextTabGroupKey = value; + } + } + + /// + public Key NextTabKey + { + get => _nextTabKey; + set + { + KeyBindings.Replace (_nextTabKey, value); + _nextTabKey = value; + } + } + + /// + public Key PrevTabGroupKey + { + get => _prevTabGroupKey; + set + { + KeyBindings.Replace (_prevTabGroupKey, value); + _prevTabGroupKey = value; + } + } + + /// + public Key PrevTabKey + { + get => _prevTabKey; + set + { + KeyBindings.Replace (_prevTabKey, value); + _prevTabKey = value; + } + } + + /// + public event EventHandler? KeyDown; + + /// + public event EventHandler? KeyUp; + + /// + /// Initializes keyboard bindings. + /// + public Keyboard () + { + AddKeyBindings (); + } + + /// + public bool RaiseKeyDownEvent (Key key) + { + Logging.Debug ($"{key}"); + + // TODO: Add a way to ignore certain keys, esp for debugging. + //#if DEBUG + // if (key == Key.Empty.WithAlt || key == Key.Empty.WithCtrl) + // { + // Logging.Debug ($"Ignoring {key}"); + // return false; + // } + //#endif + + // TODO: This should match standard event patterns + KeyDown?.Invoke (null, key); + + if (key.Handled) + { + return true; + } + + if (Application?.Popover?.DispatchKeyDown (key) is true) + { + return true; + } + + if (Application?.Top is null) + { + if (Application?.TopLevels is { }) + { + foreach (Toplevel topLevel in Application.TopLevels.ToList ()) + { + if (topLevel.NewKeyDownEvent (key)) + { + return true; + } + + if (topLevel.Modal) + { + break; + } + } + } + } + else + { + if (Application.Top.NewKeyDownEvent (key)) + { + return true; + } + } + + bool? commandHandled = InvokeCommandsBoundToKey (key); + if(commandHandled is true) + { + return true; + } + + return false; + } + + /// + public bool RaiseKeyUpEvent (Key key) + { + if (Application?.Initialized != true) + { + return true; + } + + KeyUp?.Invoke (null, key); + + if (key.Handled) + { + return true; + } + + + // TODO: Add Popover support + + if (Application?.TopLevels is { }) + { + foreach (Toplevel topLevel in Application.TopLevels.ToList ()) + { + if (topLevel.NewKeyUpEvent (key)) + { + return true; + } + + if (topLevel.Modal) + { + break; + } + } + } + + return false; + } + + /// + public bool? InvokeCommandsBoundToKey (Key key) + { + bool? handled = null; + // Invoke any Application-scoped KeyBindings. + // The first view that handles the key will stop the loop. + // foreach (KeyValuePair binding in KeyBindings.GetBindings (key)) + if (KeyBindings.TryGet (key, out KeyBinding binding)) + { + if (binding.Target is { }) + { + if (!binding.Target.Enabled) + { + return null; + } + + handled = binding.Target?.InvokeCommands (binding.Commands, binding); + } + else + { + bool? toReturn = null; + + foreach (Command command in binding.Commands) + { + toReturn = InvokeCommand (command, key, binding); + } + + handled = toReturn ?? true; + } + } + + return handled; + } + + /// + public bool? InvokeCommand (Command command, Key key, KeyBinding binding) + { + if (!_commandImplementations.ContainsKey (command)) + { + throw new NotSupportedException ( + @$"A KeyBinding was set up for the command {command} ({key}) but that command is not supported by Application." + ); + } + + if (_commandImplementations.TryGetValue (command, out View.CommandImplementation? implementation)) + { + CommandContext context = new (command, null, binding); // Create the context here + + return implementation (context); + } + + return null; + } + + /// + /// + /// Sets the function that will be invoked for a . + /// + /// + /// If AddCommand has already been called for will + /// replace the old one. + /// + /// + /// + /// + /// This version of AddCommand is for commands that do not require a . + /// + /// + /// The command. + /// The function. + private void AddCommand (Command command, Func f) { _commandImplementations [command] = ctx => f (); } + + internal void AddKeyBindings () + { + _commandImplementations.Clear (); + + // Things Application knows how to do + AddCommand ( + Command.Quit, + () => + { + Application?.RequestStop (); + + return true; + } + ); + AddCommand ( + Command.Suspend, + () => + { + Application?.Driver?.Suspend (); + + return true; + } + ); + AddCommand ( + Command.NextTabStop, + () => Application?.Navigation?.AdvanceFocus (NavigationDirection.Forward, TabBehavior.TabStop)); + + AddCommand ( + Command.PreviousTabStop, + () => Application?.Navigation?.AdvanceFocus (NavigationDirection.Backward, TabBehavior.TabStop)); + + AddCommand ( + Command.NextTabGroup, + () => Application?.Navigation?.AdvanceFocus (NavigationDirection.Forward, TabBehavior.TabGroup)); + + AddCommand ( + Command.PreviousTabGroup, + () => Application?.Navigation?.AdvanceFocus (NavigationDirection.Backward, TabBehavior.TabGroup)); + + AddCommand ( + Command.Refresh, + () => + { + Application?.LayoutAndDraw (true); + + return true; + } + ); + + AddCommand ( + Command.Arrange, + () => + { + View? viewToArrange = Application?.Navigation?.GetFocused (); + + // Go up the superview hierarchy and find the first that is not ViewArrangement.Fixed + while (viewToArrange is { SuperView: { }, Arrangement: ViewArrangement.Fixed }) + { + viewToArrange = viewToArrange.SuperView; + } + + if (viewToArrange is { }) + { + return viewToArrange.Border?.EnterArrangeMode (ViewArrangement.Fixed); + } + + return false; + }); + + //SetKeysToHardCodedDefaults (); + + // Need to clear after setting the above to ensure actually clear + // because set_QuitKey etc.. may call Add + KeyBindings.Clear (); + + KeyBindings.Add (QuitKey, Command.Quit); + KeyBindings.Add (NextTabKey, Command.NextTabStop); + KeyBindings.Add (PrevTabKey, Command.PreviousTabStop); + KeyBindings.Add (NextTabGroupKey, Command.NextTabGroup); + KeyBindings.Add (PrevTabGroupKey, Command.PreviousTabGroup); + KeyBindings.Add (ArrangeKey, Command.Arrange); + + KeyBindings.Add (Key.CursorRight, Command.NextTabStop); + KeyBindings.Add (Key.CursorDown, Command.NextTabStop); + KeyBindings.Add (Key.CursorLeft, Command.PreviousTabStop); + KeyBindings.Add (Key.CursorUp, Command.PreviousTabStop); + + // TODO: Refresh Key should be configurable + KeyBindings.Add (Key.F5, Command.Refresh); + + // TODO: Suspend Key should be configurable + if (Environment.OSVersion.Platform == PlatformID.Unix) + { + KeyBindings.Add (Key.Z.WithCtrl, Command.Suspend); + } + } +} diff --git a/Tests/UnitTests/Application/KeyboardTests.cs b/Tests/UnitTests/Application/KeyboardTests.cs deleted file mode 100644 index 86fc81d83..000000000 --- a/Tests/UnitTests/Application/KeyboardTests.cs +++ /dev/null @@ -1,518 +0,0 @@ -using UnitTests; -using Xunit.Abstractions; - -namespace UnitTests.ApplicationTests; - -/// -/// Application tests for keyboard support. -/// -public class KeyboardTests -{ - public KeyboardTests (ITestOutputHelper output) - { - _output = output; -#if DEBUG_IDISPOSABLE - View.Instances.Clear (); - RunState.Instances.Clear (); -#endif - } - - private readonly ITestOutputHelper _output; - - private object _timeoutLock; - - [Fact] - [AutoInitShutdown] - public void KeyBindings_Add_Adds () - { - Application.KeyBindings.Add (Key.A, Command.Accept); - Application.KeyBindings.Add (Key.B, Command.Accept); - - Assert.True (Application.KeyBindings.TryGet (Key.A, out KeyBinding binding)); - Assert.Null (binding.Target); - Assert.True (Application.KeyBindings.TryGet (Key.B, out binding)); - Assert.Null (binding.Target); - } - - [Fact] - [AutoInitShutdown] - public void KeyBindings_Remove_Removes () - { - Application.KeyBindings.Add (Key.A, Command.Accept); - - Assert.True (Application.KeyBindings.TryGet (Key.A, out _)); - - Application.KeyBindings.Remove (Key.A); - Assert.False (Application.KeyBindings.TryGet (Key.A, out _)); - } - - [Fact] - public void KeyBindings_OnKeyDown () - { - Application.Top = new (); - var view = new ScopedKeyBindingView (); - var keyWasHandled = false; - view.KeyDownNotHandled += (s, e) => keyWasHandled = true; - - Application.Top.Add (view); - - Application.RaiseKeyDownEvent (Key.A); - Assert.False (keyWasHandled); - Assert.True (view.ApplicationCommand); - - keyWasHandled = false; - view.ApplicationCommand = false; - Application.KeyBindings.Remove (KeyCode.A); - Application.RaiseKeyDownEvent (Key.A); // old - Assert.False (keyWasHandled); - Assert.False (view.ApplicationCommand); - Application.KeyBindings.Add (Key.A.WithCtrl, view, Command.Save); - Application.RaiseKeyDownEvent (Key.A); // old - Assert.False (keyWasHandled); - Assert.False (view.ApplicationCommand); - Application.RaiseKeyDownEvent (Key.A.WithCtrl); // new - Assert.False (keyWasHandled); - Assert.True (view.ApplicationCommand); - - keyWasHandled = false; - Application.RaiseKeyDownEvent (Key.H); - Assert.False (keyWasHandled); - Assert.True (view.HotKeyCommand); - - keyWasHandled = false; - Assert.False (view.HasFocus); - Application.RaiseKeyDownEvent (Key.F); - Assert.False (keyWasHandled); - - Assert.True (view.ApplicationCommand); - Assert.True (view.HotKeyCommand); - Assert.False (view.FocusedCommand); - Application.Top.Dispose (); - Application.ResetState (true); - } - - [Fact] - [AutoInitShutdown] - public void KeyBindings_OnKeyDown_Negative () - { - var view = new ScopedKeyBindingView (); - var keyWasHandled = false; - view.KeyDownNotHandled += (s, e) => keyWasHandled = true; - - var top = new Toplevel (); - top.Add (view); - Application.Begin (top); - - Application.RaiseKeyDownEvent (Key.A.WithCtrl); - Assert.False (keyWasHandled); - Assert.False (view.ApplicationCommand); - Assert.False (view.HotKeyCommand); - Assert.False (view.FocusedCommand); - - keyWasHandled = false; - Assert.False (view.HasFocus); - Application.RaiseKeyDownEvent (Key.Z); - Assert.False (keyWasHandled); - Assert.False (view.ApplicationCommand); - Assert.False (view.HotKeyCommand); - Assert.False (view.FocusedCommand); - top.Dispose (); - } - - [Fact] - public void NextTabGroupKey_Moves_Focus_To_TabStop_In_Next_TabGroup () - { - // Arrange - Application.Navigation = new (); - var top = new Toplevel (); - - var view1 = new View - { - Id = "view1", - CanFocus = true, - TabStop = TabBehavior.TabGroup - }; - - var subView1 = new View - { - Id = "subView1", - CanFocus = true, - TabStop = TabBehavior.TabStop - }; - - view1.Add (subView1); - - var view2 = new View - { - Id = "view2", - CanFocus = true, - TabStop = TabBehavior.TabGroup - }; - - var subView2 = new View - { - Id = "subView2", - CanFocus = true, - TabStop = TabBehavior.TabStop - }; - view2.Add (subView2); - - top.Add (view1, view2); - Application.Top = top; - view1.SetFocus (); - Assert.True (view1.HasFocus); - Assert.True (subView1.HasFocus); - - // Act - Application.RaiseKeyDownEvent (Application.NextTabGroupKey); - - // Assert - Assert.True (view2.HasFocus); - Assert.True (subView2.HasFocus); - - top.Dispose (); - Application.Navigation = null; - } - - [Fact] - [AutoInitShutdown] - public void NextTabGroupKey_PrevTabGroupKey_Tests () - { - Toplevel top = new (); // TabGroup - var w1 = new Window (); // TabGroup - var v1 = new TextField (); // TabStop - var v2 = new TextView (); // TabStop - w1.Add (v1, v2); - - var w2 = new Window (); // TabGroup - var v3 = new CheckBox (); // TabStop - var v4 = new Button (); // TabStop - w2.Add (v3, v4); - - top.Add (w1, w2); - - Application.Iteration += (s, a) => - { - Assert.True (v1.HasFocus); - - // Across TabGroups - Application.RaiseKeyDownEvent (Key.F6); - Assert.True (v3.HasFocus); - Application.RaiseKeyDownEvent (Key.F6); - Assert.True (v1.HasFocus); - - Application.RaiseKeyDownEvent (Key.F6.WithShift); - Assert.True (v3.HasFocus); - Application.RaiseKeyDownEvent (Key.F6.WithShift); - Assert.True (v1.HasFocus); - - // Restore? - Application.RaiseKeyDownEvent (Key.Tab); - Assert.True (v2.HasFocus); - - Application.RaiseKeyDownEvent (Key.F6); - Assert.True (v3.HasFocus); - - Application.RaiseKeyDownEvent (Key.F6); - Assert.True (v2.HasFocus); // previously focused view was preserved - - Application.RequestStop (); - }; - - Application.Run (top); - - // Replacing the defaults keys to avoid errors on others unit tests that are using it. - Application.NextTabGroupKey = Key.PageDown.WithCtrl; - Application.PrevTabGroupKey = Key.PageUp.WithCtrl; - Application.QuitKey = Key.Q.WithCtrl; - - Assert.Equal (KeyCode.PageDown | KeyCode.CtrlMask, Application.NextTabGroupKey.KeyCode); - Assert.Equal (KeyCode.PageUp | KeyCode.CtrlMask, Application.PrevTabGroupKey.KeyCode); - Assert.Equal (KeyCode.Q | KeyCode.CtrlMask, Application.QuitKey.KeyCode); - - top.Dispose (); - - // Shutdown must be called to safely clean up Application if Init has been called - Application.Shutdown (); - } - - [Fact] - public void NextTabKey_Moves_Focus_To_Next_TabStop () - { - // Arrange - Application.Navigation = new (); - var top = new Toplevel (); - var view1 = new View { Id = "view1", CanFocus = true }; - var view2 = new View { Id = "view2", CanFocus = true }; - top.Add (view1, view2); - Application.Top = top; - view1.SetFocus (); - - // Act - Application.RaiseKeyDownEvent (Application.NextTabKey); - - // Assert - Assert.True (view2.HasFocus); - - top.Dispose (); - Application.Navigation = null; - } - - [Fact] - public void PrevTabGroupKey_Moves_Focus_To_TabStop_In_Prev_TabGroup () - { - // Arrange - Application.Navigation = new (); - var top = new Toplevel (); - - var view1 = new View - { - Id = "view1", - CanFocus = true, - TabStop = TabBehavior.TabGroup - }; - - var subView1 = new View - { - Id = "subView1", - CanFocus = true, - TabStop = TabBehavior.TabStop - }; - - view1.Add (subView1); - - var view2 = new View - { - Id = "view2", - CanFocus = true, - TabStop = TabBehavior.TabGroup - }; - - var subView2 = new View - { - Id = "subView2", - CanFocus = true, - TabStop = TabBehavior.TabStop - }; - view2.Add (subView2); - - top.Add (view1, view2); - Application.Top = top; - view1.SetFocus (); - Assert.True (view1.HasFocus); - Assert.True (subView1.HasFocus); - - // Act - Application.RaiseKeyDownEvent (Application.PrevTabGroupKey); - - // Assert - Assert.True (view2.HasFocus); - Assert.True (subView2.HasFocus); - - top.Dispose (); - Application.Navigation = null; - } - - [Fact] - public void PrevTabKey_Moves_Focus_To_Prev_TabStop () - { - // Arrange - Application.Navigation = new (); - var top = new Toplevel (); - var view1 = new View { Id = "view1", CanFocus = true }; - var view2 = new View { Id = "view2", CanFocus = true }; - top.Add (view1, view2); - Application.Top = top; - view1.SetFocus (); - - // Act - Application.RaiseKeyDownEvent (Application.NextTabKey); - - // Assert - Assert.True (view2.HasFocus); - - top.Dispose (); - Application.Navigation = null; - } - - [Fact] - public void QuitKey_Default_Is_Esc () - { - Application.ResetState (true); - - // Before Init - Assert.Equal (Key.Esc, Application.QuitKey); - - Application.Init (null, "fakedriver"); - - // After Init - Assert.Equal (Key.Esc, Application.QuitKey); - - Application.Shutdown (); - } - - [Fact] - [AutoInitShutdown] - public void QuitKey_Getter_Setter () - { - Toplevel top = new (); - var isQuiting = false; - - top.Closing += (s, e) => - { - isQuiting = true; - e.Cancel = true; - }; - - Application.Begin (top); - top.Running = true; - - Key prevKey = Application.QuitKey; - - Application.RaiseKeyDownEvent (Application.QuitKey); - Assert.True (isQuiting); - - isQuiting = false; - Application.RaiseKeyDownEvent (Application.QuitKey); - Assert.True (isQuiting); - - isQuiting = false; - Application.QuitKey = Key.C.WithCtrl; - Application.RaiseKeyDownEvent (prevKey); // Should not quit - Assert.False (isQuiting); - Application.RaiseKeyDownEvent (Key.Q.WithCtrl); // Should not quit - Assert.False (isQuiting); - - Application.RaiseKeyDownEvent (Application.QuitKey); - Assert.True (isQuiting); - - // Reset the QuitKey to avoid throws errors on another tests - Application.QuitKey = prevKey; - top.Dispose (); - } - - [Fact] - public void QuitKey_Quits () - { - Assert.Null (_timeoutLock); - _timeoutLock = new (); - - uint abortTime = 500; - var initialized = false; - var iteration = 0; - var shutdown = false; - object timeout = null; - - Application.InitializedChanged += OnApplicationOnInitializedChanged; - - Application.Init (null, "fakedriver"); - Assert.True (initialized); - Assert.False (shutdown); - - _output.WriteLine ("Application.Run ().Dispose ().."); - Application.Run ().Dispose (); - _output.WriteLine ("Back from Application.Run ().Dispose ()"); - - Assert.True (initialized); - Assert.False (shutdown); - - Assert.Equal (1, iteration); - - Application.Shutdown (); - - Application.InitializedChanged -= OnApplicationOnInitializedChanged; - - lock (_timeoutLock) - { - if (timeout is { }) - { - Application.RemoveTimeout (timeout); - timeout = null; - } - } - - Assert.True (initialized); - Assert.True (shutdown); - -#if DEBUG_IDISPOSABLE - Assert.Empty (View.Instances); -#endif - lock (_timeoutLock) - { - _timeoutLock = null; - } - - return; - - void OnApplicationOnInitializedChanged (object s, EventArgs a) - { - _output.WriteLine ("OnApplicationOnInitializedChanged: {0}", a.Value); - - if (a.Value) - { - Application.Iteration += OnApplicationOnIteration; - initialized = true; - - lock (_timeoutLock) - { - timeout = Application.AddTimeout (TimeSpan.FromMilliseconds (abortTime), ForceCloseCallback); - } - } - else - { - Application.Iteration -= OnApplicationOnIteration; - shutdown = true; - } - } - - bool ForceCloseCallback () - { - lock (_timeoutLock) - { - _output.WriteLine ($"ForceCloseCallback. iteration: {iteration}"); - - if (timeout is { }) - { - timeout = null; - } - } - - Application.ResetState (true); - Assert.Fail ($"Failed to Quit with {Application.QuitKey} after {abortTime}ms. Force quit."); - - return false; - } - - void OnApplicationOnIteration (object s, IterationEventArgs a) - { - _output.WriteLine ("Iteration: {0}", iteration); - iteration++; - Assert.True (iteration < 2, "Too many iterations, something is wrong."); - - if (Application.Initialized) - { - _output.WriteLine (" Pressing QuitKey"); - Application.RaiseKeyDownEvent (Application.QuitKey); - } - } - } - - // Test View for testing Application key Bindings - public class ScopedKeyBindingView : View - { - public ScopedKeyBindingView () - { - AddCommand (Command.Save, () => ApplicationCommand = true); - AddCommand (Command.HotKey, () => HotKeyCommand = true); - AddCommand (Command.Left, () => FocusedCommand = true); - - Application.KeyBindings.Add (Key.A, this, Command.Save); - HotKey = KeyCode.H; - KeyBindings.Add (Key.F, Command.Left); - } - - public bool ApplicationCommand { get; set; } - public bool FocusedCommand { get; set; } - public bool HotKeyCommand { get; set; } - } -} diff --git a/Tests/UnitTestsParallelizable/Application/KeyboardTests.cs b/Tests/UnitTestsParallelizable/Application/KeyboardTests.cs new file mode 100644 index 000000000..74c6ab54b --- /dev/null +++ b/Tests/UnitTestsParallelizable/Application/KeyboardTests.cs @@ -0,0 +1,477 @@ +#nullable enable +using Terminal.Gui.App; + +namespace UnitTests_Parallelizable.ApplicationTests; + +/// +/// Parallelizable tests for keyboard handling. +/// These tests use isolated instances of to avoid static state dependencies. +/// +public class KeyboardTests +{ + [Fact] + public void Constructor_InitializesKeyBindings () + { + // Arrange & Act + var keyboard = new Keyboard (); + + // Assert + Assert.NotNull (keyboard.KeyBindings); + // Verify that some default bindings exist + Assert.True (keyboard.KeyBindings.TryGet (keyboard.QuitKey, out _)); + } + + [Fact] + public void QuitKey_DefaultValue_IsEsc () + { + // Arrange + var keyboard = new Keyboard (); + + // Assert + Assert.Equal (Key.Esc, keyboard.QuitKey); + } + + [Fact] + public void QuitKey_SetValue_UpdatesKeyBindings () + { + // Arrange + var keyboard = new Keyboard (); + Key newQuitKey = Key.Q.WithCtrl; + + // Act + keyboard.QuitKey = newQuitKey; + + // Assert + Assert.Equal (newQuitKey, keyboard.QuitKey); + Assert.True (keyboard.KeyBindings.TryGet (newQuitKey, out KeyBinding binding)); + Assert.Contains (Command.Quit, binding.Commands); + } + + [Fact] + public void ArrangeKey_DefaultValue_IsCtrlF5 () + { + // Arrange + var keyboard = new Keyboard (); + + // Assert + Assert.Equal (Key.F5.WithCtrl, keyboard.ArrangeKey); + } + + [Fact] + public void NextTabKey_DefaultValue_IsTab () + { + // Arrange + var keyboard = new Keyboard (); + + // Assert + Assert.Equal (Key.Tab, keyboard.NextTabKey); + } + + [Fact] + public void PrevTabKey_DefaultValue_IsShiftTab () + { + // Arrange + var keyboard = new Keyboard (); + + // Assert + Assert.Equal (Key.Tab.WithShift, keyboard.PrevTabKey); + } + + [Fact] + public void NextTabGroupKey_DefaultValue_IsF6 () + { + // Arrange + var keyboard = new Keyboard (); + + // Assert + Assert.Equal (Key.F6, keyboard.NextTabGroupKey); + } + + [Fact] + public void PrevTabGroupKey_DefaultValue_IsShiftF6 () + { + // Arrange + var keyboard = new Keyboard (); + + // Assert + Assert.Equal (Key.F6.WithShift, keyboard.PrevTabGroupKey); + } + + [Fact] + public void KeyBindings_Add_CanAddCustomBinding () + { + // Arrange + var keyboard = new Keyboard (); + Key customKey = Key.K.WithCtrl; + + // Act + keyboard.KeyBindings.Add (customKey, Command.Accept); + + // Assert + Assert.True (keyboard.KeyBindings.TryGet (customKey, out KeyBinding binding)); + Assert.Contains (Command.Accept, binding.Commands); + } + + [Fact] + public void KeyBindings_Remove_CanRemoveBinding () + { + // Arrange + var keyboard = new Keyboard (); + Key customKey = Key.K.WithCtrl; + keyboard.KeyBindings.Add (customKey, Command.Accept); + + // Act + keyboard.KeyBindings.Remove (customKey); + + // Assert + Assert.False (keyboard.KeyBindings.TryGet (customKey, out _)); + } + + [Fact] + public void KeyDown_Event_CanBeSubscribed () + { + // Arrange + var keyboard = new Keyboard (); + bool eventRaised = false; + + // Act + keyboard.KeyDown += (sender, key) => + { + eventRaised = true; + }; + + // Assert - event subscription doesn't throw + Assert.False (eventRaised); // Event hasn't been raised yet + } + + [Fact] + public void KeyUp_Event_CanBeSubscribed () + { + // Arrange + var keyboard = new Keyboard (); + bool eventRaised = false; + + // Act + keyboard.KeyUp += (sender, key) => + { + eventRaised = true; + }; + + // Assert - event subscription doesn't throw + Assert.False (eventRaised); // Event hasn't been raised yet + } + + [Fact] + public void InvokeCommand_WithInvalidCommand_ThrowsNotSupportedException () + { + // Arrange + var keyboard = new Keyboard (); + // Pick a command that isn't registered + Command invalidCommand = (Command)9999; + Key testKey = Key.A; + var binding = new KeyBinding ([invalidCommand]); + + // Act & Assert + Assert.Throws (() => keyboard.InvokeCommand (invalidCommand, testKey, binding)); + } + + [Fact] + public void Multiple_Keyboards_CanExistIndependently () + { + // Arrange & Act + var keyboard1 = new Keyboard (); + var keyboard2 = new Keyboard (); + + keyboard1.QuitKey = Key.Q.WithCtrl; + keyboard2.QuitKey = Key.X.WithCtrl; + + // Assert - each keyboard maintains independent state + Assert.Equal (Key.Q.WithCtrl, keyboard1.QuitKey); + Assert.Equal (Key.X.WithCtrl, keyboard2.QuitKey); + Assert.NotEqual (keyboard1.QuitKey, keyboard2.QuitKey); + } + + [Fact] + public void KeyBindings_Replace_UpdatesExistingBinding () + { + // Arrange + var keyboard = new Keyboard (); + Key oldKey = Key.Esc; + Key newKey = Key.Q.WithCtrl; + + // Verify initial state + Assert.True (keyboard.KeyBindings.TryGet (oldKey, out KeyBinding oldBinding)); + Assert.Contains (Command.Quit, oldBinding.Commands); + + // Act + keyboard.KeyBindings.Replace (oldKey, newKey); + + // Assert - old key should no longer have the binding + Assert.False (keyboard.KeyBindings.TryGet (oldKey, out _)); + // New key should have the binding + Assert.True (keyboard.KeyBindings.TryGet (newKey, out KeyBinding newBinding)); + Assert.Contains (Command.Quit, newBinding.Commands); + } + + [Fact] + public void KeyBindings_Clear_RemovesAllBindings () + { + // Arrange + var keyboard = new Keyboard (); + // Verify initial state has bindings + Assert.True (keyboard.KeyBindings.TryGet (keyboard.QuitKey, out _)); + + // Act + keyboard.KeyBindings.Clear (); + + // Assert - previously existing binding is gone + Assert.False (keyboard.KeyBindings.TryGet (keyboard.QuitKey, out _)); + } + + [Fact] + public void AddKeyBindings_PopulatesDefaultBindings () + { + // Arrange + var keyboard = new Keyboard (); + keyboard.KeyBindings.Clear (); + Assert.False (keyboard.KeyBindings.TryGet (keyboard.QuitKey, out _)); + + // Act + keyboard.AddKeyBindings (); + + // Assert + Assert.True (keyboard.KeyBindings.TryGet (keyboard.QuitKey, out KeyBinding binding)); + Assert.Contains (Command.Quit, binding.Commands); + } + + // Migrated from UnitTests/Application/KeyboardTests.cs + + [Fact] + public void KeyBindings_Add_Adds () + { + // Arrange + var keyboard = new Keyboard (); + + // Act + keyboard.KeyBindings.Add (Key.A, Command.Accept); + keyboard.KeyBindings.Add (Key.B, Command.Accept); + + // Assert + Assert.True (keyboard.KeyBindings.TryGet (Key.A, out KeyBinding binding)); + Assert.Null (binding.Target); + Assert.True (keyboard.KeyBindings.TryGet (Key.B, out binding)); + Assert.Null (binding.Target); + } + + [Fact] + public void KeyBindings_Remove_Removes () + { + // Arrange + var keyboard = new Keyboard (); + keyboard.KeyBindings.Add (Key.A, Command.Accept); + Assert.True (keyboard.KeyBindings.TryGet (Key.A, out _)); + + // Act + keyboard.KeyBindings.Remove (Key.A); + + // Assert + Assert.False (keyboard.KeyBindings.TryGet (Key.A, out _)); + } + + [Fact] + public void QuitKey_Default_Is_Esc () + { + // Arrange & Act + var keyboard = new Keyboard (); + + // Assert + Assert.Equal (Key.Esc, keyboard.QuitKey); + } + + [Fact] + public void QuitKey_Setter_UpdatesBindings () + { + // Arrange + var keyboard = new Keyboard (); + Key prevKey = keyboard.QuitKey; + + // Act - Change QuitKey + keyboard.QuitKey = Key.C.WithCtrl; + + // Assert - Old key should no longer trigger quit + Assert.False (keyboard.KeyBindings.TryGet (prevKey, out _)); + // New key should trigger quit + Assert.True (keyboard.KeyBindings.TryGet (Key.C.WithCtrl, out KeyBinding binding)); + Assert.Contains (Command.Quit, binding.Commands); + } + + [Fact] + public void NextTabKey_Setter_UpdatesBindings () + { + // Arrange + var keyboard = new Keyboard (); + Key prevKey = keyboard.NextTabKey; + Key newKey = Key.N.WithCtrl; + + // Act + keyboard.NextTabKey = newKey; + + // Assert + Assert.Equal (newKey, keyboard.NextTabKey); + Assert.True (keyboard.KeyBindings.TryGet (newKey, out KeyBinding binding)); + Assert.Contains (Command.NextTabStop, binding.Commands); + } + + [Fact] + public void PrevTabKey_Setter_UpdatesBindings () + { + // Arrange + var keyboard = new Keyboard (); + Key newKey = Key.P.WithCtrl; + + // Act + keyboard.PrevTabKey = newKey; + + // Assert + Assert.Equal (newKey, keyboard.PrevTabKey); + Assert.True (keyboard.KeyBindings.TryGet (newKey, out KeyBinding binding)); + Assert.Contains (Command.PreviousTabStop, binding.Commands); + } + + [Fact] + public void NextTabGroupKey_Setter_UpdatesBindings () + { + // Arrange + var keyboard = new Keyboard (); + Key newKey = Key.PageDown.WithCtrl; + + // Act + keyboard.NextTabGroupKey = newKey; + + // Assert + Assert.Equal (newKey, keyboard.NextTabGroupKey); + Assert.Equal (KeyCode.PageDown | KeyCode.CtrlMask, keyboard.NextTabGroupKey.KeyCode); + Assert.True (keyboard.KeyBindings.TryGet (newKey, out KeyBinding binding)); + Assert.Contains (Command.NextTabGroup, binding.Commands); + } + + [Fact] + public void PrevTabGroupKey_Setter_UpdatesBindings () + { + // Arrange + var keyboard = new Keyboard (); + Key newKey = Key.PageUp.WithCtrl; + + // Act + keyboard.PrevTabGroupKey = newKey; + + // Assert + Assert.Equal (newKey, keyboard.PrevTabGroupKey); + Assert.Equal (KeyCode.PageUp | KeyCode.CtrlMask, keyboard.PrevTabGroupKey.KeyCode); + Assert.True (keyboard.KeyBindings.TryGet (newKey, out KeyBinding binding)); + Assert.Contains (Command.PreviousTabGroup, binding.Commands); + } + + [Fact] + public void ArrangeKey_Setter_UpdatesBindings () + { + // Arrange + var keyboard = new Keyboard (); + Key newKey = Key.A.WithCtrl; + + // Act + keyboard.ArrangeKey = newKey; + + // Assert + Assert.Equal (newKey, keyboard.ArrangeKey); + Assert.True (keyboard.KeyBindings.TryGet (newKey, out KeyBinding binding)); + Assert.Contains (Command.Arrange, binding.Commands); + } + + [Fact] + public void KeyBindings_AddWithTarget_StoresTarget () + { + // Arrange + var keyboard = new Keyboard (); + var view = new View (); + + // Act + keyboard.KeyBindings.Add (Key.A.WithCtrl, view, Command.Accept); + + // Assert + Assert.True (keyboard.KeyBindings.TryGet (Key.A.WithCtrl, out KeyBinding binding)); + Assert.Equal (view, binding.Target); + Assert.Contains (Command.Accept, binding.Commands); + + view.Dispose (); + } + + [Fact] + public void InvokeCommandsBoundToKey_ReturnsNull_WhenNoBindingExists () + { + // Arrange + var keyboard = new Keyboard (); + Key unboundKey = Key.Z.WithAlt.WithCtrl; + + // Act + bool? result = keyboard.InvokeCommandsBoundToKey (unboundKey); + + // Assert + Assert.Null (result); + } + + [Fact] + public void InvokeCommandsBoundToKey_InvokesCommand_WhenBindingExists () + { + // Arrange + var keyboard = new Keyboard (); + // QuitKey has a bound command by default + + // Act + bool? result = keyboard.InvokeCommandsBoundToKey (keyboard.QuitKey); + + // Assert + // Command.Quit would normally call Application.RequestStop, + // but in isolation it should return true (handled) + Assert.NotNull (result); + } + + [Fact] + public void Multiple_Keyboards_Independent_KeyBindings () + { + // Arrange + var keyboard1 = new Keyboard (); + var keyboard2 = new Keyboard (); + + // Act + keyboard1.KeyBindings.Add (Key.X, Command.Accept); + keyboard2.KeyBindings.Add (Key.Y, Command.Cancel); + + // Assert + Assert.True (keyboard1.KeyBindings.TryGet (Key.X, out _)); + Assert.False (keyboard1.KeyBindings.TryGet (Key.Y, out _)); + + Assert.True (keyboard2.KeyBindings.TryGet (Key.Y, out _)); + Assert.False (keyboard2.KeyBindings.TryGet (Key.X, out _)); + } + + [Fact] + public void KeyBindings_Replace_PreservesCommandsForNewKey () + { + // Arrange + var keyboard = new Keyboard (); + Key oldKey = Key.Esc; + Key newKey = Key.Q.WithCtrl; + + // Get the commands from the old binding + Assert.True (keyboard.KeyBindings.TryGet (oldKey, out KeyBinding oldBinding)); + Command[] oldCommands = oldBinding.Commands.ToArray (); + + // Act + keyboard.KeyBindings.Replace (oldKey, newKey); + + // Assert - new key should have the same commands + Assert.True (keyboard.KeyBindings.TryGet (newKey, out KeyBinding newBinding)); + Assert.Equal (oldCommands, newBinding.Commands); + } +} diff --git a/docfx/docs/keyboard.md b/docfx/docs/keyboard.md index e02053f22..14071f69c 100644 --- a/docfx/docs/keyboard.md +++ b/docfx/docs/keyboard.md @@ -64,7 +64,7 @@ For **Application-scoped Key Bindings** there are two categories of Application- 1) **Application Command Key Bindings** - Bindings for `Command`s supported by @Terminal.Gui.App.Application. For example, @Terminal.Gui.App.Application.QuitKey, which is bound to `Command.Quit` and results in @Terminal.Gui.App.Application.RequestStop(Terminal.Gui.Views.Toplevel) being called. 2) **Application Key Bindings** - Bindings for `Command`s supported on arbitrary `Views` that are meant to be invoked regardless of which part of the application is visible/active. -Use @Terminal.Gui.App.Application.KeyBindings to add or modify Application-scoped Key Bindings. +Use @Terminal.Gui.App.Application.Keyboard.KeyBindings to add or modify Application-scoped Key Bindings. For backward compatibility, @Terminal.Gui.App.Application.KeyBindings also provides access to the same key bindings. **View-scoped Key Bindings** also have two categories: @@ -97,7 +97,7 @@ Keyboard events are retrieved from [Console Drivers](drivers.md) each iteration > Not all drivers/platforms support sensing distinct KeyUp events. These drivers will simulate KeyUp events by raising KeyUp after KeyDown. -@Terminal.Gui.App.Application.RaiseKeyDownEvent* raises @Terminal.Gui.App.Application.KeyDown and then calls @Terminal.Gui.ViewBase.View.NewKeyDownEvent* on all toplevel Views. If no View handles the key event, any Application-scoped key bindings will be invoked. +@Terminal.Gui.App.Application.RaiseKeyDownEvent* raises @Terminal.Gui.App.Application.KeyDown and then calls @Terminal.Gui.ViewBase.View.NewKeyDownEvent* on all toplevel Views. If no View handles the key event, any Application-scoped key bindings will be invoked. Application-scoped key bindings are managed through @Terminal.Gui.App.Application.Keyboard.KeyBindings. If a view is enabled, the @Terminal.Gui.ViewBase.View.NewKeyDownEvent* method will do the following: @@ -143,11 +143,79 @@ To define application key handling logic for an entire application in cases wher ## Application * Implements support for `KeyBindingScope.Application`. -* Exposes @Terminal.Gui.App.Application.KeyBindings. -* Exposes cancelable `KeyDown/Up` events (via `Handled = true`). The `OnKey/Down/Up/` methods are public and can be used to simulate keyboard input. +* Keyboard functionality is now encapsulated in the @Terminal.Gui.App.IKeyboard interface, accessed via @Terminal.Gui.App.Application.Keyboard. +* @Terminal.Gui.App.Application.Keyboard provides access to @Terminal.Gui.Input.KeyBindings, key binding configuration (QuitKey, ArrangeKey, navigation keys), and keyboard event handling. +* For backward compatibility, @Terminal.Gui.App.Application still exposes static properties/methods that delegate to @Terminal.Gui.App.Application.Keyboard (e.g., `Application.KeyBindings`, `Application.RaiseKeyDownEvent`, `Application.QuitKey`). +* Exposes cancelable `KeyDown/Up` events (via `Handled = true`). The `RaiseKeyDownEvent` and `RaiseKeyUpEvent` methods are public and can be used to simulate keyboard input. +* The @Terminal.Gui.App.IKeyboard interface enables testability with isolated keyboard instances that don't depend on static Application state. ## View * Implements support for `KeyBindings` and `HotKeyBindings`. * Exposes cancelable non-virtual methods for a new key event: `NewKeyDownEvent` and `NewKeyUpEvent`. These methods are called by `Application` can be called to simulate keyboard input. -* Exposes cancelable virtual methods for a new key event: `OnKeyDown` and `OnKeyUp`. These methods are called by `NewKeyDownEvent` and `NewKeyUpEvent` and can be overridden to handle keyboard input. \ No newline at end of file +* Exposes cancelable virtual methods for a new key event: `OnKeyDown` and `OnKeyUp`. These methods are called by `NewKeyDownEvent` and `NewKeyUpEvent` and can be overridden to handle keyboard input. + +## IKeyboard Architecture + +The @Terminal.Gui.App.IKeyboard interface provides a decoupled, testable architecture for keyboard handling in Terminal.Gui. This design allows for: + +### Key Features + +1. **Decoupled State** - All keyboard-related state (key bindings, navigation keys, events) is encapsulated in @Terminal.Gui.App.IKeyboard, separate from the static @Terminal.Gui.App.Application class. + +2. **Dependency Injection** - The @Terminal.Gui.App.Keyboard implementation receives an @Terminal.Gui.App.IApplication reference, enabling it to interact with application state without static dependencies. + +3. **Testability** - Unit tests can create isolated @Terminal.Gui.App.IKeyboard instances with mock @Terminal.Gui.App.IApplication references, enabling parallel test execution without interference. + +4. **Backward Compatibility** - All existing @Terminal.Gui.App.Application keyboard APIs (e.g., `Application.KeyBindings`, `Application.RaiseKeyDownEvent`, `Application.QuitKey`) remain available and delegate to `Application.Keyboard`. + +### Usage Examples + +**Accessing keyboard functionality:** + +```csharp +// Modern approach - using IKeyboard +Application.Keyboard.KeyBindings.Add(Key.F1, Command.HotKey); +Application.Keyboard.RaiseKeyDownEvent(Key.Enter); +Application.Keyboard.QuitKey = Key.Q.WithCtrl; + +// Legacy approach - still works (delegates to Application.Keyboard) +Application.KeyBindings.Add(Key.F1, Command.HotKey); +Application.RaiseKeyDownEvent(Key.Enter); +Application.QuitKey = Key.Q.WithCtrl; +``` + +**Testing with isolated keyboard instances:** + +```csharp +// Create independent keyboard instances for parallel tests +var keyboard1 = new Keyboard(); +keyboard1.QuitKey = Key.Q.WithCtrl; +keyboard1.KeyBindings.Add(Key.F1, Command.HotKey); + +var keyboard2 = new Keyboard(); +keyboard2.QuitKey = Key.X.WithCtrl; +keyboard2.KeyBindings.Add(Key.F2, Command.Accept); + +// keyboard1 and keyboard2 maintain completely separate state +Assert.Equal(Key.Q.WithCtrl, keyboard1.QuitKey); +Assert.Equal(Key.X.WithCtrl, keyboard2.QuitKey); +``` + +### Architecture Benefits + +- **Parallel Testing**: Multiple test methods can create and use separate @Terminal.Gui.App.IKeyboard instances simultaneously without state interference. +- **Dependency Inversion**: @Terminal.Gui.App.Keyboard depends on @Terminal.Gui.App.IApplication interface rather than static @Terminal.Gui.App.Application class. +- **Cleaner Code**: Keyboard functionality is organized in a dedicated interface rather than scattered across @Terminal.Gui.App.Application partial classes. +- **Mockability**: Tests can provide mock @Terminal.Gui.App.IApplication implementations to test keyboard behavior in isolation. + +### Implementation Details + +The @Terminal.Gui.App.Keyboard class implements @Terminal.Gui.App.IKeyboard and maintains: + +- **KeyBindings**: Application-scoped key binding dictionary +- **Navigation Keys**: QuitKey, ArrangeKey, NextTabKey, PrevTabKey, NextTabGroupKey, PrevTabGroupKey +- **Events**: KeyDown, KeyUp events for application-level keyboard monitoring +- **Command Implementations**: Handlers for Application-scoped commands (Quit, Suspend, Navigation, Refresh, Arrange) + +The @Terminal.Gui.App.ApplicationImpl class creates and manages the @Terminal.Gui.App.IKeyboard instance, setting its `Application` property to `this` to provide the necessary @Terminal.Gui.App.IApplication reference. \ No newline at end of file