Просмотр исходного кода

Remove TextField.Caption property; use Title with hotkey navigation support (#4352)

* Initial plan

* Initial exploration - understanding TextField Caption and Title

Co-authored-by: tig <[email protected]>

* Remove TextField.Caption and use Title instead with hotkey support

Co-authored-by: tig <[email protected]>

* Add defensive check to ensure TitleTextFormatter.Text is set

Co-authored-by: tig <[email protected]>

* Final changes - all tests passing

Co-authored-by: tig <[email protected]>

* Fixed bugs.

* Add comprehensive tests for caption rendering with attributes validation

Co-authored-by: tig <[email protected]>

* Fix: Disable TextField hotkey functionality to prevent input interception

TextField's Title is used as a caption/placeholder, not for hotkey
navigation. Hotkey visual formatting (underline) is still rendered
in the caption, but hotkey functionality is disabled to prevent
keys like 'E' and 'F' from being intercepted when typing in the field.

Updated test to expect "_Find" instead of "Find" to match resource change.

Co-authored-by: tig <[email protected]>

* Fix: Support Alt+key hotkey navigation while allowing normal typing

Override AddKeyBindingsForHotKey to only bind Alt+key combinations
(e.g., Alt+F for "_Find"), not the bare keys. This allows:
- Alt+F to navigate to the TextField with Title="_Find"
- Normal typing of 'F', 'E', etc. without interception

Previously, both bare key and Alt+key were bound, causing typing
issues. Now TextField properly supports hotkey navigation without
interfering with text input.

Co-authored-by: tig <[email protected]>

* Changes before error encountered

Co-authored-by: tig <[email protected]>

* Refactor hotkey handling to support command context

Refactored `RaiseHandlingHotKey` to accept an `ICommandContext? ctx` parameter, enabling context-aware hotkey handling. Updated `Command.HotKey` definitions across multiple classes (`View`, `CheckBox`, `Label`, `MenuBarv2`, `RadioGroup`, `TextField`) to utilize the new context parameter.

Enhanced XML documentation for `RaiseHandlingHotKey` to clarify its usage and return values. Added a context-aware hotkey handler to `TextField` with additional logic for focus handling.

Refactored attribute initialization and improved code readability in `TextField` by aligning parameters and removing unused `HotKeySpecifier` initialization. These changes improve flexibility, maintainability, and consistency across the codebase.

* Remove TextField.Caption property; use Title with hotkey navigation support

Co-authored-by: tig <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: tig <[email protected]>
Co-authored-by: Tig <[email protected]>
Copilot 1 месяц назад
Родитель
Сommit
f3fc20306e

+ 1 - 1
Examples/UICatalog/Scenarios/CharacterMap/CharacterMap.cs

@@ -85,7 +85,7 @@ public class CharacterMap : Scenario
             X = Pos.Right (jumpLabel) + 1,
             Y = Pos.Y (_charMap),
             Width = 17,
-            Caption = "e.g. 01BE3 or ✈"
+            Title = "e.g. 01BE3 or ✈"
 
             //SchemeName = "Dialog"
         };

+ 1 - 1
Examples/UICatalog/Scenarios/TextInputControls.cs

@@ -68,7 +68,7 @@ public class TextInputControls : Scenario
             X = Pos.Right (label) + 1,
             Y = Pos.Bottom (textField),
             Width = Dim.Percent (50) - 1,
-            Caption = "TextField with caption"
+            Title = "TextField with caption"
         };
 
         win.Add (textField);

+ 2 - 2
Terminal.Gui/Resources/Strings.Designer.cs

@@ -628,7 +628,7 @@ namespace Terminal.Gui.Resources {
         }
         
         /// <summary>
-        ///   Looks up a localized string similar to Enter Path.
+        ///   Looks up a localized string similar to _Enter Path.
         /// </summary>
         internal static string fdPathCaption {
             get {
@@ -682,7 +682,7 @@ namespace Terminal.Gui.Resources {
         }
         
         /// <summary>
-        ///   Looks up a localized string similar to Find.
+        ///   Looks up a localized string similar to _Find.
         /// </summary>
         internal static string fdSearchCaption {
             get {

+ 4 - 4
Terminal.Gui/Resources/Strings.resx

@@ -192,10 +192,7 @@
     <value>Modified</value>
   </data>
   <data name="fdPathCaption" xml:space="preserve">
-    <value>Enter Path</value>
-  </data>
-  <data name="fdSearchCaption" xml:space="preserve">
-    <value>Find</value>
+    <value>_Enter Path</value>
   </data>
   <data name="fdSize" xml:space="preserve">
     <value>Size</value>
@@ -359,4 +356,7 @@
     <value>_Tree</value>
     <comment>Show/Hide Tree View</comment>
   </data>
+  <data name="fdSearchCaption" xml:space="preserve">
+    <value>_Find</value>
+  </data>
 </root>

+ 5 - 4
Terminal.Gui/ViewBase/View.Command.cs

@@ -22,9 +22,9 @@ public partial class View // Command APIs
         // HotKey - SetFocus and raise HandlingHotKey
         AddCommand (
                     Command.HotKey,
-                    () =>
+                    (ctx) =>
                     {
-                        if (RaiseHandlingHotKey () is true)
+                        if (RaiseHandlingHotKey (ctx) is true)
                         {
                             return true;
                         }
@@ -257,15 +257,16 @@ public partial class View // Command APIs
     ///     <see cref="OnHandlingHotKey"/> which can be cancelled; if not cancelled raises <see cref="Accepting"/>.
     ///     event. The default <see cref="Command.HotKey"/> handler calls this method.
     /// </summary>
+    /// <param name="ctx">The context to pass with the command.</param>
     /// <returns>
     ///     <see langword="null"/> if no event was raised; input processing should continue.
     ///     <see langword="false"/> if the event was raised and was not handled (or cancelled); input processing should
     ///     continue.
     ///     <see langword="true"/> if the event was raised and handled (or cancelled); input processing should stop.
     /// </returns>
-    protected bool? RaiseHandlingHotKey ()
+    protected bool? RaiseHandlingHotKey (ICommandContext? ctx)
     {
-        CommandEventArgs args = new () { Context = new CommandContext<KeyBinding> { Command = Command.HotKey } };
+        CommandEventArgs args = new () { Context = ctx };
         //Logging.Debug ($"{Title} ({args.Context?.Source?.Title})");
 
         // Best practice is to invoke the virtual method first.

+ 1 - 1
Terminal.Gui/Views/CheckBox.cs

@@ -32,7 +32,7 @@ public class CheckBox : View
         // Hotkey - Advance state and raise Select event - DO NOT raise Accept
         AddCommand (Command.HotKey, ctx =>
                                     {
-                                        if (RaiseHandlingHotKey () is true)
+                                        if (RaiseHandlingHotKey (ctx) is true)
                                         {
                                             return true;
                                         }

+ 3 - 4
Terminal.Gui/Views/FileDialogs/FileDialog.cs

@@ -148,7 +148,7 @@ public class FileDialog : Dialog, IDesignable
                                      e.Handled = true;
                                  };
 
-        _tbPath = new () { Width = Dim.Fill (),/* CaptionColor = new (Color.Black)*/ };
+        _tbPath = new () { Width = Dim.Fill () };
 
         _tbPath.KeyDown += (s, k) =>
                            {
@@ -248,7 +248,6 @@ public class FileDialog : Dialog, IDesignable
             X = 0,
             Width = Dim.Fill (),
             Y = Pos.AnchorEnd (),
-            HotKey = Key.F.WithAlt,
             Id = "_tbFind",
         };
 
@@ -456,8 +455,8 @@ public class FileDialog : Dialog, IDesignable
         _btnBack.Text = GetBackButtonText ();
         _btnForward.Text = GetForwardButtonText ();
 
-        _tbPath.Caption = Style.PathCaption;
-        _tbFind.Caption = Style.SearchCaption;
+        _tbPath.Title = Style.PathCaption;
+        _tbFind.Title = Style.SearchCaption;
 
         _tbPath.Autocomplete.Scheme = new (_tbPath.GetScheme ())
         {

+ 1 - 1
Terminal.Gui/Views/Label.cs

@@ -60,7 +60,7 @@ public class Label : View, IDesignable
 
     private bool? InvokeHotKeyOnNextPeer (ICommandContext commandContext)
     {
-        if (RaiseHandlingHotKey () == true)
+        if (RaiseHandlingHotKey (commandContext) == true)
         {
             return true;
         }

+ 2 - 2
Terminal.Gui/Views/Menu/MenuBarv2.cs

@@ -31,11 +31,11 @@ public class MenuBarv2 : Menuv2, IDesignable
 
         AddCommand (
                     Command.HotKey,
-                    () =>
+                    (ctx) =>
                     {
                         // Logging.Debug ($"{Title} - Command.HotKey");
 
-                        if (RaiseHandlingHotKey () is true)
+                        if (RaiseHandlingHotKey (ctx) is true)
                         {
                             return true;
                         }

+ 1 - 1
Terminal.Gui/Views/RadioGroup.cs

@@ -94,7 +94,7 @@ public class RadioGroup : View, IDesignable, IOrientation
             return false;
         }
 
-        if (RaiseHandlingHotKey () == true)
+        if (RaiseHandlingHotKey (ctx) == true)
         {
             return true;
         }

+ 47 - 31
Terminal.Gui/Views/TextInput/TextField.cs

@@ -28,9 +28,6 @@ public class TextField : View, IDesignable
         _selectedStart = -1;
         _text = new ();
 
-        // TODO: Determine if this is a good choice. Previously this was hard coded to 
-        // TODO: DarkGray which was NOT a good choice.
-        CaptionColor = GetAttributeForRole (VisualRole.Normal).Foreground.GetBrighterColor();
         ReadOnly = false;
         Autocomplete = new TextFieldAutocomplete ();
         Height = Dim.Auto (DimAutoStyle.Text, 1);
@@ -40,9 +37,6 @@ public class TextField : View, IDesignable
         Used = true;
         WantMousePositionReports = true;
 
-        // By default, disable hotkeys (in case someome sets Title)
-        HotKeySpecifier = new ('\xffff');
-
         _historyText.ChangeText += HistoryText_ChangeText;
 
         Initialized += TextField_Initialized;
@@ -324,6 +318,30 @@ public class TextField : View, IDesignable
                     }
                    );
 
+        AddCommand (
+                    Command.HotKey,
+                    ctx =>
+                    {
+                        if (RaiseHandlingHotKey (ctx) is true)
+                        {
+                            return true;
+                        }
+
+                        // If we have focus, then ignore the hotkey because the user
+                        // means to enter it
+                        if (HasFocus)
+                        {
+                            return false;
+                        }
+
+                        // This is what the default HotKey handler does:
+                        SetFocus ();
+
+                        // Always return true on hotkey, even if SetFocus fails because 
+                        // hotkeys are always handled by the View (unless RaiseHandlingHotKey cancels).
+                        return true;
+                    });
+
         // Default keybindings for this view
         // We follow this as closely as possible: https://en.wikipedia.org/wiki/Table_of_keyboard_shortcuts
         KeyBindings.Add (Key.Delete, Command.DeleteCharRight);
@@ -411,15 +429,6 @@ public class TextField : View, IDesignable
     /// </summary>
     public IAutocomplete Autocomplete { get; set; }
 
-    /// <summary>
-    ///     Gets or sets the text to render in control when no value has been entered yet and the <see cref="View"/> does
-    ///     not yet have input focus.
-    /// </summary>
-    public string Caption { get; set; }
-
-    /// <summary>Gets or sets the foreground <see cref="Color"/> to use when rendering <see cref="Caption"/>.</summary>
-    public Color CaptionColor { get; set; }
-
     /// <summary>Get the Context Menu for this view.</summary>
     [CanBeNull]
     public PopoverMenu ContextMenu { get; private set; }
@@ -920,7 +929,7 @@ public class TextField : View, IDesignable
         _isDrawing = true;
 
         // Cache attributes as GetAttributeForRole might raise events
-        Attribute selectedAttribute = new Attribute (GetAttributeForRole (VisualRole.Active));
+        var selectedAttribute = new Attribute (GetAttributeForRole (VisualRole.Active));
         Attribute readonlyAttribute = GetAttributeForRole (VisualRole.ReadOnly);
         Attribute normalAttribute = GetAttributeForRole (VisualRole.Editable);
 
@@ -943,7 +952,7 @@ public class TextField : View, IDesignable
             {
                 // Disabled
                 SetAttributeForRole (VisualRole.Disabled);
-            } 
+            }
             else if (idx == _cursorPosition && HasFocus && !Used && SelectedLength == 0 && !ReadOnly)
             {
                 // Selected text
@@ -1157,7 +1166,6 @@ public class TextField : View, IDesignable
     ///// </summary>
     //public event EventHandler<StateEventArgs<string>> TextChanged;
 
-
     /// <summary>Undoes the latest changes.</summary>
     public void Undo ()
     {
@@ -1699,25 +1707,33 @@ public class TextField : View, IDesignable
     private void RenderCaption ()
     {
         if (HasFocus
-            || Caption == null
-            || Caption.Length == 0
+            || string.IsNullOrEmpty (Title)
             || Text?.Length > 0)
         {
             return;
         }
 
-        var color = new Attribute (CaptionColor, GetAttributeForRole (VisualRole.Editable).Background, GetAttributeForRole (VisualRole.Editable).Style);
-        SetAttribute (color);
-
-        Move (0, 0);
-        string render = Caption;
-
-        if (render.GetColumns () > Viewport.Width)
+        // Ensure TitleTextFormatter has the current Title text
+        // (should already be set by the Title property setter, but being defensive)
+        if (TitleTextFormatter.Text != Title)
         {
-            render = render [..Viewport.Width];
+            TitleTextFormatter.Text = Title;
         }
 
-        AddStr (render);
+        var captionAttribute = new Attribute (
+                                              GetAttributeForRole (VisualRole.Editable).Foreground.GetDimColor (),
+                                              GetAttributeForRole (VisualRole.Editable).Background);
+
+        var hotKeyAttribute = new Attribute (
+                                             GetAttributeForRole (VisualRole.Editable).Foreground.GetDimColor (),
+                                             GetAttributeForRole (VisualRole.Editable).Background,
+                                             GetAttributeForRole (VisualRole.Editable).Style | TextStyle.Underline);
+
+        // Use TitleTextFormatter to render the caption with hotkey support
+        TitleTextFormatter.Draw (
+                                 ViewportToScreen (new Rectangle (0, 0, Viewport.Width, 1)),
+                                 captionAttribute,
+                                 hotKeyAttribute);
     }
 
     private void SetClipboard (IEnumerable<Rune> text)
@@ -1814,11 +1830,11 @@ public class TextField : View, IDesignable
         }
     }
 
-    /// <inheritdoc />
+    /// <inheritdoc/>
     public bool EnableForDesign ()
     {
         Text = "This is a test.";
-        Caption = "Caption";
+        Title = "Caption";
 
         return true;
     }

+ 1 - 1
Tests/UnitTests/FileServices/FileDialogTests.cs

@@ -107,7 +107,7 @@ public class FileDialogTests ()
         Assert.IsType<TextField> (dlg.MostFocused);
         Assert.Same (tf, dlg.MostFocused);
 
-        Assert.Equal ("Find", tf.Caption);
+        Assert.Equal ("_Find", tf.Title);
 
         // Dialog has not yet been confirmed with a choice
         Assert.True (dlg.Canceled);

+ 102 - 4
Tests/UnitTests/Views/TextFieldTests.cs

@@ -145,7 +145,7 @@ public class TextFieldTests (ITestOutputHelper output)
         TextField tf = GetTextFieldsInView ();
 
         // Caption has no effect when focused
-        tf.Caption = caption;
+        tf.Title = caption;
         Application.RaiseKeyDownEvent ('\t');
         Assert.False (tf.HasFocus);
 
@@ -165,7 +165,7 @@ public class TextFieldTests (ITestOutputHelper output)
 
         TextField tf = GetTextFieldsInView ();
 
-        tf.Caption = caption;
+        tf.Title = caption;
         Application.RaiseKeyDownEvent ('\t');
         Assert.False (tf.HasFocus);
 
@@ -185,7 +185,7 @@ public class TextFieldTests (ITestOutputHelper output)
         tf.Draw ();
         DriverAssert.AssertDriverContentsAre ("", output);
 
-        tf.Caption = "Enter txt";
+        tf.Title = "Enter txt";
         Application.RaiseKeyDownEvent ('\t');
 
         // Caption should appear when not focused and no text
@@ -212,7 +212,7 @@ public class TextFieldTests (ITestOutputHelper output)
         DriverAssert.AssertDriverContentsAre ("", output);
 
         // Caption has no effect when focused
-        tf.Caption = "Enter txt";
+        tf.Title = "Enter txt";
         Assert.True (tf.HasFocus);
         View.SetClipToScreen ();
         tf.Draw ();
@@ -227,6 +227,104 @@ public class TextFieldTests (ITestOutputHelper output)
         Application.Top.Dispose ();
     }
 
+    [Fact]
+    [AutoInitShutdown]
+    public void Title_RendersAsCaption_WithCorrectAttributes ()
+    {
+        TextField tf = GetTextFieldsInView ();
+
+        // Set a title (caption)
+        tf.Title = "Enter text";
+        
+        // Remove focus so caption appears
+        Application.RaiseKeyDownEvent ('\t');
+        Assert.False (tf.HasFocus);
+
+        View.SetClipToScreen ();
+        tf.Draw ();
+        
+        // Verify the caption text is rendered
+        DriverAssert.AssertDriverContentsAre ("Enter text", output);
+
+        // Verify the caption uses dimmed color attribute
+        Attribute captionAttr = new Attribute (
+            tf.GetAttributeForRole (VisualRole.Editable).Foreground.GetDimColor (),
+            tf.GetAttributeForRole (VisualRole.Editable).Background);
+
+        // All characters in "Enter text" should have the caption attribute
+        DriverAssert.AssertDriverAttributesAre ("0000000000", output, Application.Driver, captionAttr);
+
+        Application.Top.Dispose ();
+    }
+
+    [Fact]
+    [AutoInitShutdown]
+    public void Title_WithHotkey_RendersUnderlined ()
+    {
+        TextField tf = GetTextFieldsInView ();
+
+        // Title with hotkey should be rendered with the hotkey underlined when not focused
+        tf.Title = "_Find";
+        
+        // Remove focus so caption appears
+        Application.RaiseKeyDownEvent ('\t');
+        Assert.False (tf.HasFocus);
+
+        View.SetClipToScreen ();
+        tf.Draw ();
+        
+        // The hotkey character 'F' should be rendered (without the underscore in the actual text)
+        DriverAssert.AssertDriverContentsAre ("Find", output);
+
+        // Verify the hotkey character 'F' has underline style
+        Attribute captionAttr = new Attribute (
+            tf.GetAttributeForRole (VisualRole.Editable).Foreground.GetDimColor (),
+            tf.GetAttributeForRole (VisualRole.Editable).Background);
+        Attribute hotkeyAttr = new Attribute (
+            tf.GetAttributeForRole (VisualRole.Editable).Foreground.GetDimColor (),
+            tf.GetAttributeForRole (VisualRole.Editable).Background,
+            tf.GetAttributeForRole (VisualRole.Editable).Style | TextStyle.Underline);
+
+        // F is underlined (index 1), remaining characters use normal caption attribute (index 0)
+        DriverAssert.AssertDriverAttributesAre ("1000", output, Application.Driver, captionAttr, hotkeyAttr);
+
+        Application.Top.Dispose ();
+    }
+
+    [Fact]
+    [AutoInitShutdown]
+    public void Title_WithHotkey_MiddleCharacter_RendersUnderlined ()
+    {
+        TextField tf = GetTextFieldsInView ();
+
+        // Title with hotkey in middle of text
+        tf.Title = "Enter _Text";
+        
+        // Remove focus so caption appears
+        Application.RaiseKeyDownEvent ('\t');
+        Assert.False (tf.HasFocus);
+
+        View.SetClipToScreen ();
+        tf.Draw ();
+        
+        // The underscore should not be rendered, 'T' should be underlined
+        DriverAssert.AssertDriverContentsAre ("Enter Text", output);
+
+        // Verify the hotkey character 'T' has underline style
+        Attribute captionAttr = new Attribute (
+            tf.GetAttributeForRole (VisualRole.Editable).Foreground.GetDimColor (),
+            tf.GetAttributeForRole (VisualRole.Editable).Background);
+        Attribute hotkeyAttr = new Attribute (
+            tf.GetAttributeForRole (VisualRole.Editable).Foreground.GetDimColor (),
+            tf.GetAttributeForRole (VisualRole.Editable).Background,
+            tf.GetAttributeForRole (VisualRole.Editable).Style | TextStyle.Underline);
+
+        // "Enter " (6 chars) + "T" (underlined) + "ext" (3 chars)
+        DriverAssert.AssertDriverAttributesAre ("0000001000", output, Application.Driver, captionAttr, hotkeyAttr);
+
+        Application.Top.Dispose ();
+    }
+
     [Fact]
     [TextFieldTestsAutoInitShutdown]
     public void Changing_SelectedStart_Or_CursorPosition_Update_SelectedLength_And_SelectedText ()

BIN
local_packages/Terminal.Gui.2.0.0.nupkg


BIN
local_packages/Terminal.Gui.2.0.0.snupkg