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