#nullable enable using System.Diagnostics; using Microsoft.CodeAnalysis.FlowAnalysis; namespace Terminal.Gui; public partial class View // Keyboard APIs { /// /// Helper to configure all things keyboard related for a View. Called from the View constructor. /// private void SetupKeyboard () { KeyBindings = new (this); KeyBindings.Add (Key.Space, Command.Select); KeyBindings.Add (Key.Enter, Command.Accept); // Note, setting HotKey will bind HotKey to Command.HotKey HotKeySpecifier = (Rune)'_'; TitleTextFormatter.HotKeyChanged += TitleTextFormatter_HotKeyChanged; } /// /// Helper to dispose all things keyboard related for a View. Called from the View Dispose method. /// private void DisposeKeyboard () { TitleTextFormatter.HotKeyChanged -= TitleTextFormatter_HotKeyChanged; } #region HotKey Support /// Invoked when the is changed. public event EventHandler? HotKeyChanged; private Key _hotKey = new (); private void TitleTextFormatter_HotKeyChanged (object? sender, KeyChangedEventArgs e) { HotKeyChanged?.Invoke (this, e); } /// /// Gets or sets the hot key defined for this view. Pressing the hot key on the keyboard while this view has focus will /// invoke . By default, the HotKey is set to the first character of /// that is prefixed with . /// /// A HotKey is a keypress that causes a visible UI item to perform an action. For example, in a Dialog, /// with a Button with the text of "_Text" Alt+T will cause the button to gain focus and to raise its /// event. /// Or, in a /// with "_File _Edit", Alt+F will select (show) the "_File" menu. If the "_File" menu /// has a /// sub-menu of "_New" Alt+N or N will ONLY select the "_New" sub-menu if the "_File" menu is already /// opened. /// /// /// View subclasses can use to /// define the /// behavior of the hot key. /// /// /// /// See for an overview of Terminal.Gui keyboard APIs. /// /// This is a helper API for configuring a key binding for the hot key. By default, this property is set whenever /// changes. /// /// /// By default, when the Hot Key is set, key bindings are added for both the base key (e.g. /// ) and the Alt-shifted key (e.g. . /// ). This behavior can be overriden by overriding /// . /// /// /// By default, when the HotKey is set to through key bindings will /// be added for both the un-shifted and shifted versions. This means if the HotKey is , key /// bindings for Key.A and Key.A.WithShift will be added. This behavior can be overriden by /// overriding . /// /// If the hot key is changed, the event is fired. /// Set to to disable the hot key. /// public virtual Key HotKey { get => _hotKey; set { if (value is null) { throw new ArgumentException ( @"HotKey must not be null. Use Key.Empty to clear the HotKey.", nameof (value) ); } if (AddKeyBindingsForHotKey (_hotKey, value)) { // This will cause TextFormatter_HotKeyChanged to be called, firing HotKeyChanged // BUGBUG: _hotkey should be set BEFORE setting TextFormatter.HotKey _hotKey = value; TitleTextFormatter.HotKey = value; } } } /// /// Adds key bindings for the specified HotKey. Useful for views that contain multiple items that each have their /// own HotKey such as . /// /// /// /// By default, key bindings are added for both the base key (e.g. ) and the Alt-shifted key /// (e.g. Key.D3.WithAlt) This behavior can be overriden by overriding /// . /// /// /// By default, when is through key bindings /// will be added for both the un-shifted and shifted versions. This means if the HotKey is , /// key bindings for Key.A and Key.A.WithShift will be added. This behavior can be overriden by /// overriding . /// /// /// The HotKey is replacing. Key bindings for this key will be removed. /// The new HotKey. If bindings will be removed. /// Arbitrary context that can be associated with this key binding. /// if the HotKey bindings were added. /// public virtual bool AddKeyBindingsForHotKey (Key prevHotKey, Key hotKey, object? context = null) { if (_hotKey == hotKey) { return false; } Key newKey = hotKey; Key baseKey = newKey.NoAlt.NoShift.NoCtrl; if (newKey != Key.Empty && (baseKey == Key.Space || Rune.IsControl (baseKey.AsRune))) { throw new ArgumentException (@$"HotKey must be a printable (and non-space) key ({hotKey})."); } if (newKey != baseKey) { if (newKey.IsCtrl) { throw new ArgumentException (@$"HotKey does not support CtrlMask ({hotKey})."); } // Strip off the shift mask if it's A...Z if (baseKey.IsKeyCodeAtoZ) { newKey = newKey.NoShift; } // Strip off the Alt mask newKey = newKey.NoAlt; } // Remove base version if (KeyBindings.TryGet (prevHotKey, out _)) { KeyBindings.Remove (prevHotKey); } // Remove the Alt version if (KeyBindings.TryGet (prevHotKey.WithAlt, out _)) { KeyBindings.Remove (prevHotKey.WithAlt); } if (_hotKey.IsKeyCodeAtoZ) { // Remove the shift version if (KeyBindings.TryGet (prevHotKey.WithShift, out _)) { KeyBindings.Remove (prevHotKey.WithShift); } // Remove alt | shift version if (KeyBindings.TryGet (prevHotKey.WithShift.WithAlt, out _)) { KeyBindings.Remove (prevHotKey.WithShift.WithAlt); } } // Add the new if (newKey != Key.Empty) { KeyBinding keyBinding = new ([Command.HotKey], KeyBindingScope.HotKey, context); // Add the base and Alt key KeyBindings.Remove (newKey); KeyBindings.Add (newKey, keyBinding); KeyBindings.Remove (newKey.WithAlt); KeyBindings.Add (newKey.WithAlt, keyBinding); // If the Key is A..Z, add ShiftMask and AltMask | ShiftMask if (newKey.IsKeyCodeAtoZ) { KeyBindings.Remove (newKey.WithShift); KeyBindings.Add (newKey.WithShift, keyBinding); KeyBindings.Remove (newKey.WithShift.WithAlt); KeyBindings.Add (newKey.WithShift.WithAlt, keyBinding); } } return true; } /// /// Gets or sets the specifier character for the hot key (e.g. '_'). Set to '\xffff' to disable automatic hot key /// setting support for this View instance. The default is '\xffff'. /// public virtual Rune HotKeySpecifier { get => TitleTextFormatter.HotKeySpecifier; set { TitleTextFormatter.HotKeySpecifier = TextFormatter.HotKeySpecifier = value; SetHotKeyFromTitle (); } } private void SetHotKeyFromTitle () { if (HotKeySpecifier == new Rune ('\xFFFF')) { return; // throw new InvalidOperationException ("Can't set HotKey unless a TextFormatter has been created"); } if (TextFormatter.FindHotKey (_title, HotKeySpecifier, out _, out Key hk)) { if (_hotKey != hk) { HotKey = hk; } } else { HotKey = Key.Empty; } } #endregion HotKey Support #region Low-level Key handling #region Key Down Event /// /// If the view is enabled, raises the related key down events on the view, and returns if the /// event was /// handled. /// /// /// /// If the view has a sub view that is focused, will be called on the focused view /// first. /// /// /// If the focused sub view does not handle the key press, this method raises / /// to allow the /// view to pre-process the key press. If / is not handled /// / will be raised to invoke any key /// bindings. /// Then, only if no key bindings are /// handled, / will be raised allowing the view to /// process the key press. /// /// /// Calling this method for a key bound to the view via an Application-scoped keybinding will have no effect. /// Instead, /// use . /// /// See for an overview of Terminal.Gui keyboard APIs. /// /// /// if the event was handled. public bool NewKeyDownEvent (Key key) { if (!Enabled) { return false; } // If there's a Focused subview, give it a chance (this recurses down the hierarchy) if (Focused?.NewKeyDownEvent (key) == true) { return true; } // Before (fire the cancellable event) if (RaiseKeyDown (key) || key.Handled) { return true; } // During (this is what can be cancelled) // TODO: NewKeyDownEvent returns bool. It should be bool? so state of RaiseInvokingKeyBindingsAndInvokeCommands can be reflected up stack if (RaiseInvokingKeyBindingsAndInvokeCommands (key) is true || key.Handled) { return true; } // After // TODO: Is ProcessKeyDown really the right name? if (RaiseProcessKeyDown (key) || key.Handled) { return true; } return key.Handled; bool RaiseKeyDown (Key k) { // Before (fire the cancellable event) if (OnKeyDown (k) || k.Handled) { return true; } // fire event KeyDown?.Invoke (this, k); return k.Handled; } bool RaiseProcessKeyDown (Key k) { if (OnProcessKeyDown (k) || k.Handled) { return true; } ProcessKeyDown?.Invoke (this, k); return false; } } /// /// Called when the user presses a key, allowing subscribers to pre-process the key down event. Called /// before and are raised. Set /// to true to /// stop the key from being processed by other views. /// /// Contains the details about the key that produced the event. /// /// if the key press was not handled. if the keypress was handled /// and no other view should see it. /// /// /// /// For processing s and commands, use and /// instead. /// /// Fires the event. /// protected virtual bool OnKeyDown (Key key) { return false; } /// /// Raised when the user presses a key, allowing subscribers to pre-process the key down event. Raised /// before and . Set to true to /// stop the key from being processed by other views. /// /// /// /// Not all terminals support key distinct up notifications, Applications should avoid depending on distinct /// KeyUp events. /// /// See for an overview of Terminal.Gui keyboard APIs. /// public event EventHandler? KeyDown; /// /// Called when the user presses a key, allowing views do things during key down events. This is /// called after the after are raised. /// /// /// /// For processing s and commands, use and /// instead. /// /// /// Not all terminals support distinct key up notifications; applications should avoid depending on distinct /// KeyUp events. /// /// /// Contains the details about the key that produced the event. /// /// if the key press was not handled. if the keypress was handled /// and no other view should see it. /// protected virtual bool OnProcessKeyDown (Key key) { return key.Handled; } /// /// Raised when the user presses a key, allowing subscribers to do things during key down events. Set /// to true to stop the key from being processed by other views. Invoked after /// and . /// /// /// /// For processing s and commands, use and /// instead. /// /// /// SubViews can use the of their super view override the default behavior of when /// key bindings are invoked. /// /// See for an overview of Terminal.Gui keyboard APIs. /// public event EventHandler? ProcessKeyDown; #endregion KeyDown Event #region KeyUp Event /// /// If the view is enabled, raises the related key up events on the view, and returns if the /// event was /// handled. /// /// /// /// Not all terminals support key distinct down/up notifications, Applications should avoid depending on distinct /// KeyUp events. /// /// /// If the view has a sub view that is focused, will be called on the focused view /// first. /// /// /// If the focused sub view does not handle the key press, this method raises / /// to allow the /// view to pre-process the key press. If /. /// /// See for an overview of Terminal.Gui keyboard APIs. /// /// /// if the event was handled. public bool NewKeyUpEvent (Key key) { if (!Enabled) { return false; } // Before if (RaiseKeyUp (key) || key.Handled) { return true; } // During // After return false; bool RaiseKeyUp (Key k) { // Before (fire the cancellable event) if (OnKeyUp (k) || k.Handled) { return true; } // fire event KeyUp?.Invoke (this, k); return k.Handled; } } /// Method invoked when a key is released. This method is called from . /// Contains the details about the key that produced the event. /// /// if the keys up event was not handled. if no other view should see /// it. /// /// /// Not all terminals support key distinct down/up notifications, Applications should avoid depending on distinct KeyUp /// events. /// /// Overrides must call into the base and return if the base returns /// . /// /// See for an overview of Terminal.Gui keyboard APIs. /// public virtual bool OnKeyUp (Key key) { return false; } /// /// Invoked when a key is released. Set to true to stop the key up event from being processed /// by other views. /// /// Not all terminals support key distinct down/up notifications, Applications should avoid depending on /// distinct KeyDown and KeyUp events and instead should use . /// See for an overview of Terminal.Gui keyboard APIs. /// /// public event EventHandler? KeyUp; #endregion KeyUp Event #endregion Low-level Key handling #region Key Bindings /// Gets the key bindings for this view. public KeyBindings KeyBindings { get; internal set; } = null!; private Dictionary CommandImplementations { get; } = new (); /// /// /// /// /// /// if no command was invoked or there was no matching key binding; input processing should continue. /// if a command was invoked and was not handled (or cancelled); input processing should /// continue. /// if was handled or a command was invoked and handled (or cancelled); input processing should stop. /// internal bool? RaiseInvokingKeyBindingsAndInvokeCommands (Key key) { KeyBindingScope scope = KeyBindingScope.Focused | KeyBindingScope.HotKey; // During if (OnInvokingKeyBindings (key, scope)) { return true; } // BUGBUG: The proper pattern is for the v-method (OnInvokingKeyBindings) to be called first, then the event InvokingKeyBindings?.Invoke (this, key); if (key.Handled) { return true; } bool? handled; // After // * If no key binding was found, `InvokeKeyBindings` returns `null`. // Continue passing the event (return `false` from `OnInvokeKeyBindings`). // * If key bindings were found, but none handled the key (all `Command`s returned `false`), // `InvokeKeyBindings` returns `false`. Continue passing the event (return `false` from `OnInvokeKeyBindings`).. // * If key bindings were found, and any handled the key (at least one `Command` returned `true`), // `InvokeKeyBindings` returns `true`. Continue passing the event (return `false` from `OnInvokeKeyBindings`). handled = InvokeCommands (key, scope); if (handled is { } && (bool)handled) { // Stop processing if any key binding handled the key. // DO NOT stop processing if there are no matching key bindings or none of the key bindings handled the key return handled; } if (Margin is { } && ProcessAdornmentKeyBindings (Margin, key, scope, ref handled)) { return true; } if (Padding is { } && ProcessAdornmentKeyBindings (Padding, key, scope, ref handled)) { return true; } if (Border is { } && ProcessAdornmentKeyBindings (Border, key, scope, ref handled)) { return true; } if (ProcessSubViewKeyBindings (key, scope, ref handled)) { return true; } return handled; } private bool ProcessAdornmentKeyBindings (Adornment adornment, Key key, KeyBindingScope scope, ref bool? handled) { bool? adornmentHandled = adornment.OnInvokingKeyBindings (key, scope); if (adornmentHandled is true) { return true; } if (adornment?.Subviews is null) { return false; } foreach (View subview in adornment.Subviews) { bool? subViewHandled = subview.OnInvokingKeyBindings (key, scope); if (subViewHandled is { }) { handled = subViewHandled; if ((bool)subViewHandled) { return true; } } } return false; } private bool ProcessSubViewKeyBindings (Key key, KeyBindingScope scope, ref bool? handled, bool invoke = true) { // Now, process any key bindings in the subviews that are tagged to KeyBindingScope.HotKey. foreach (View subview in Subviews) { if (subview == Focused) { continue; } if (subview.KeyBindings.TryGet (key, scope, out KeyBinding binding)) { if (binding.Scope == KeyBindingScope.Focused && !subview.HasFocus) { continue; } if (!invoke) { return true; } bool? subViewHandled = subview.RaiseInvokingKeyBindingsAndInvokeCommands (key); if (subViewHandled is { }) { handled = subViewHandled; if ((bool)subViewHandled) { return true; } } } bool recurse = subview.ProcessSubViewKeyBindings (key, scope, ref handled, invoke); if (recurse || (handled is { } && (bool)handled)) { return true; } } return false; } // TODO: This is a "prototype" debug check. It may be too annoying vs. useful. // TODO: A better approach would be to have Application hold a list of bound Hotkeys, similar to // TODO: how Application holds a list of Application Scoped key bindings and then check that list. /// /// Returns true if Key is bound in this view hierarchy. For debugging /// /// The key to test. /// Returns the view the key is bound to. /// public bool IsHotKeyBound (Key key, out View? boundView) { // recurse through the subviews to find the views that has the key bound boundView = null; foreach (View subview in Subviews) { if (subview.KeyBindings.TryGet (key, KeyBindingScope.HotKey, out _)) { boundView = subview; return true; } if (subview.IsHotKeyBound (key, out boundView)) { return true; } } return false; } /// /// Called when a key is pressed that may be mapped to a key binding. Set to true to /// stop the key from being processed by other views. /// /// /// See for an overview of Terminal.Gui keyboard APIs. /// /// Contains the details about the key that produced the event. /// The scope. /// /// if the event was raised and was not handled (or cancelled); input processing should /// continue. /// if the event was raised and handled (or cancelled); input processing should stop. /// protected virtual bool OnInvokingKeyBindings (Key key, KeyBindingScope scope) { return false; } // TODO: This does not carry KeyBindingScope, but OnInvokingKeyBindings does /// /// Raised when a key is pressed that may be mapped to a key binding. Set to true to /// stop the key from being processed by other views. /// public event EventHandler? InvokingKeyBindings; /// /// Invokes the Commands bound to . /// See for an overview of Terminal.Gui keyboard APIs. /// /// The key event passed. /// The scope. /// /// if no command was invoked; input processing should continue. /// if at least one command was invoked and was not handled (or cancelled); input processing /// should continue. /// if at least one command was invoked and handled (or cancelled); input processing should stop. /// protected bool? InvokeCommands (Key key, KeyBindingScope scope) { bool? toReturn = null; if (!KeyBindings.TryGet (key, scope, out KeyBinding binding)) { return null; } #if DEBUG if (Application.KeyBindings.TryGet (key, KeyBindingScope.Focused | KeyBindingScope.HotKey, out KeyBinding b)) { Debug.WriteLine ( $"WARNING: InvokeKeyBindings ({key}) - An Application scope binding exists for this key. The registered view will not invoke Command."); } // TODO: This is a "prototype" debug check. It may be too annoying vs. useful. // Scour the bindings up our View hierarchy // to ensure that the key is not already bound to a different set of commands. if (SuperView?.IsHotKeyBound (key, out View? previouslyBoundView) ?? false) { Debug.WriteLine ($"WARNING: InvokeKeyBindings ({key}) - A subview or peer has bound this Key and will not see it: {previouslyBoundView}."); } #endif return InvokeCommands (binding.Commands, key, binding); } #endregion Key Bindings }