using System.ComponentModel; using System.Diagnostics; 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); HotKeySpecifier = (Rune)'_'; TitleTextFormatter.HotKeyChanged += TitleTextFormatter_HotKeyChanged; // By default, the HotKey command sets the focus AddCommand (Command.HotKey, OnHotKey); // By default, the Accept command raises the Accept event AddCommand (Command.Accept, OnAccept); } /// /// Helper to dispose all things keyboard related for a View. Called from the View Dispose method. /// private void DisposeKeyboard () { TitleTextFormatter.HotKeyChanged -= TitleTextFormatter_HotKeyChanged; Application.RemoveKeyBindings (this); } #region HotKey Support /// /// Called when the HotKey command () is invoked. Causes this view to be focused. /// /// If the command was canceled. private bool? OnHotKey () { if (CanFocus) { SetFocus (); return true; } return false; } /// 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 the and commands. /// causes the view to be focused and does nothing. By default, the HotKey is /// automatically set to the first character of that is prefixed with . /// /// A HotKey is a keypress that selects a visible UI item. For selecting items across `s (e.g.a /// in a ) the keypress must include the /// modifier. For selecting items within a View that are not Views themselves, the keypress can be key without the /// Alt modifier. For example, in a Dialog, a Button with the text of "_Text" can be selected with Alt-T. 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. /// /// /// /// 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, [CanBeNull] 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 { return TitleTextFormatter.HotKeySpecifier; } set { TitleTextFormatter.HotKeySpecifier = TextFormatter.HotKeySpecifier = value; SetHotKeyFromTitle (); } } private void SetHotKeyFromTitle () { if (TitleTextFormatter == null || 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, processes a new key down event 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 calls to allow the /// view to pre-process the key press. If returns , this method then /// calls to invoke any key bindings. Then, only if no key bindings are /// handled, will be called allowing the view to process the key press. /// /// See for an overview of Terminal.Gui keyboard APIs. /// /// /// if the event was handled. public bool NewKeyDownEvent (Key keyEvent) { if (!Enabled) { return false; } // By default the KeyBindingScope is View if (Focused?.NewKeyDownEvent (keyEvent) == true) { return true; } // Before (fire the cancellable event) if (OnKeyDown (keyEvent)) { return true; } // During (this is what can be cancelled) InvokingKeyBindings?.Invoke (this, keyEvent); if (keyEvent.Handled) { return true; } // TODO: NewKeyDownEvent returns bool. It should be bool? so state of InvokeCommand can be reflected up stack bool? handled = OnInvokingKeyBindings (keyEvent, KeyBindingScope.HotKey | KeyBindingScope.Focused); if (handled is { } && (bool)handled) { return true; } // TODO: The below is not right. OnXXX handlers are supposed to fire the events. // TODO: But I've moved it outside of the v-function to test something. // After (fire the cancellable event) // fire event ProcessKeyDown?.Invoke (this, keyEvent); if (!keyEvent.Handled && OnProcessKeyDown (keyEvent)) { return true; } return keyEvent.Handled; } /// /// Low-level API called when the user presses a key, allowing a view to pre-process the key down event. This is /// called from before . /// /// 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. /// public virtual bool OnKeyDown (Key keyEvent) { // fire event KeyDown?.Invoke (this, keyEvent); return keyEvent.Handled; } /// /// Invoked when the user presses a key, allowing subscribers to pre-process the key down event. This is fired /// from before . 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; /// /// Low-level API called when the user presses a key, allowing views do things during key down events. This is /// called from after . /// /// 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. /// /// /// /// Override to override the behavior of how the base class processes key down /// events. /// /// /// For processing s and commands, use and /// instead. /// /// Fires the event. /// /// Not all terminals support distinct key up notifications; applications should avoid depending on distinct /// KeyUp events. /// /// public virtual bool OnProcessKeyDown (Key keyEvent) { //ProcessKeyDown?.Invoke (this, keyEvent); return keyEvent.Handled; } /// /// Invoked 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 before . /// /// /// /// SubViews can use the of their super view override the default behavior of when /// key bindings are invoked. /// /// /// Not all terminals support distinct key up notifications; applications should avoid depending on distinct /// KeyUp events. /// /// See for an overview of Terminal.Gui keyboard APIs. /// public event EventHandler ProcessKeyDown; #endregion KeyDown Event #region KeyUp Event /// /// If the view is enabled, processes a new key up event and returns if the event was /// handled. Called before . /// /// /// /// 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 calls , which is /// cancellable. /// /// See for an overview of Terminal.Gui keyboard APIs. /// /// /// if the event was handled. public bool NewKeyUpEvent (Key keyEvent) { if (!Enabled) { return false; } if (Focused?.NewKeyUpEvent (keyEvent) == true) { return true; } // Before (fire the cancellable event) if (OnKeyUp (keyEvent)) { return true; } // During (this is what can be cancelled) // TODO: Until there's a clear use-case, we will not define 'during' event (e.g. OnDuringKeyUp). // After (fire the cancellable event InvokingKeyBindings) // TODO: Until there's a clear use-case, we will not define an 'after' event (e.g. OnAfterKeyUp). return false; } /// Method invoked when a key is released. This method is called from . /// Contains the details about the key that produced the event. /// /// if the key stroke 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 keyEvent) { // fire event KeyUp?.Invoke (this, keyEvent); if (keyEvent.Handled) { return true; } 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; } private Dictionary> CommandImplementations { get; } = new (); /// /// Low-level API called when a user presses a key; invokes any key bindings set on the view. This is called /// during after has returned. /// /// /// Fires the event. /// See for an overview of Terminal.Gui keyboard APIs. /// /// Contains the details about the key that produced the event. /// The scope. /// /// if the key press was not handled. if the keypress was handled /// and no other view should see it. /// public virtual bool? OnInvokingKeyBindings (Key keyEvent, KeyBindingScope scope) { // fire event only if there's a hotkey binding for the key if (KeyBindings.TryGet (keyEvent, scope, out KeyBinding kb)) { InvokingKeyBindings?.Invoke (this, keyEvent); if (keyEvent.Handled) { return true; } } // * 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`). bool? handled = InvokeKeyBindings (keyEvent, 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 true; } if (Margin is { } && ProcessAdornmentKeyBindings (Margin, keyEvent, scope, ref handled)) { return true; } if (Padding is { } && ProcessAdornmentKeyBindings (Padding, keyEvent, scope, ref handled)) { return true; } if (Border is { } && ProcessAdornmentKeyBindings (Border, keyEvent, scope, ref handled)) { return true; } if (ProcessSubViewKeyBindings (keyEvent, scope, ref handled)) { return true; } return handled; } private bool ProcessAdornmentKeyBindings (Adornment adornment, Key keyEvent, KeyBindingScope scope, ref bool? handled) { if (adornment?.Subviews is null) { return false; } foreach (View subview in adornment.Subviews) { bool? subViewHandled = subview.OnInvokingKeyBindings (keyEvent, scope); if (subViewHandled is { }) { handled = subViewHandled; if ((bool)subViewHandled) { return true; } } } return false; } private bool ProcessSubViewKeyBindings (Key keyEvent, 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 (keyEvent, scope, out KeyBinding binding)) { if (binding.Scope == KeyBindingScope.Focused && !subview.HasFocus) { continue; } if (!invoke) { return true; } bool? subViewHandled = subview.OnInvokingKeyBindings (keyEvent, scope); if (subViewHandled is { }) { handled = subViewHandled; if ((bool)subViewHandled) { return true; } } } bool recurse = subview.ProcessSubViewKeyBindings (keyEvent, 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 IsHotKeyKeyBound (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.IsHotKeyKeyBound (key, out boundView)) { return true; } } return false; } /// /// Invoked 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 any binding that is registered on this and matches the /// See for an overview of Terminal.Gui keyboard APIs. /// /// The key event passed. /// The scope. /// /// if no command was bound the . if /// commands were invoked and at least one handled the command. if commands were invoked and at /// none handled the command. /// protected bool? InvokeKeyBindings (Key key, KeyBindingScope scope) { bool? toReturn = null; if (!KeyBindings.TryGet (key, scope, out KeyBinding binding)) { return null; } #if DEBUG // TODO: Determine if App scope bindings should be fired first or last (currently last). if (Application.KeyBindings.TryGet (key, KeyBindingScope.Focused | KeyBindingScope.HotKey, out KeyBinding b)) { //var boundView = views [0]; //var commandBinding = boundView.KeyBindings.Get (key); Debug.WriteLine ( $"WARNING: InvokeKeyBindings ({key}) - An Application scope binding exists for this key. The registered view will not invoke Command.");//{commandBinding.Commands [0]}: {boundView}."); } // 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?.IsHotKeyKeyBound (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 foreach (Command command in binding.Commands) { if (!CommandImplementations.ContainsKey (command)) { throw new NotSupportedException ( @$"A KeyBinding was set up for the command {command} ({key}) but that command is not supported by this View ({GetType ().Name})" ); } // each command has its own return value bool? thisReturn = InvokeCommand (command, key, binding); // if we haven't got anything yet, the current command result should be used toReturn ??= thisReturn; // if ever see a true then that's what we will return if (thisReturn ?? false) { toReturn = true; } } return toReturn; } /// /// Invokes the specified commands. /// /// /// The key that caused the commands to be invoked, if any. /// /// /// if no command was found. /// if the command was invoked the command was handled. /// if the command was invoked and the command was not handled. /// public bool? InvokeCommands (Command [] commands, [CanBeNull] Key key = null, [CanBeNull] KeyBinding? keyBinding = null) { bool? toReturn = null; foreach (Command command in commands) { if (!CommandImplementations.ContainsKey (command)) { throw new NotSupportedException (@$"{command} is not supported by ({GetType ().Name})."); } // each command has its own return value bool? thisReturn = InvokeCommand (command, key, keyBinding); // if we haven't got anything yet, the current command result should be used toReturn ??= thisReturn; // if ever see a true then that's what we will return if (thisReturn ?? false) { toReturn = true; } } return toReturn; } /// Invokes the specified command. /// The command to invoke. /// The key that caused the command to be invoked, if any. /// /// /// if no command was found. if the command was invoked, and it /// handled the command. if the command was invoked, and it did not handle the command. /// public bool? InvokeCommand (Command command, [CanBeNull] Key key = null, [CanBeNull] KeyBinding? keyBinding = null) { if (CommandImplementations.TryGetValue (command, out Func implementation)) { var context = new CommandContext (command, key, keyBinding); // Create the context here return implementation (context); } return null; } /// /// /// Sets the function that will be invoked for a . Views should call /// AddCommand for each command they support. /// /// /// If AddCommand has already been called for will /// replace the old one. /// /// /// /// /// This version of AddCommand is for commands that require . Use /// in cases where the command does not require a . /// /// /// The command. /// The function. protected void AddCommand (Command command, Func f) { CommandImplementations [command] = f; } /// /// /// Sets the function that will be invoked for a . Views should call /// AddCommand for each command they support. /// /// /// If AddCommand has already been called for will /// replace the old one. /// /// /// /// /// This version of AddCommand is for commands that do not require a . /// If the command requires context, use /// /// /// The command. /// The function. protected void AddCommand (Command command, Func f) { CommandImplementations [command] = ctx => f (); } /// Returns all commands that are supported by this . /// public IEnumerable GetSupportedCommands () { return CommandImplementations.Keys; } // TODO: Add GetKeysBoundToCommand() - given a Command, return all Keys that would invoke it #endregion Key Bindings }