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

* Initial plan

* Initial exploration - understanding TextField Caption and Title

Co-authored-by: tig <585482+tig@users.noreply.github.com>

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

Co-authored-by: tig <585482+tig@users.noreply.github.com>

* Add defensive check to ensure TitleTextFormatter.Text is set

Co-authored-by: tig <585482+tig@users.noreply.github.com>

* Final changes - all tests passing

Co-authored-by: tig <585482+tig@users.noreply.github.com>

* Fixed bugs.

* Add comprehensive tests for caption rendering with attributes validation

Co-authored-by: tig <585482+tig@users.noreply.github.com>

* 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 <585482+tig@users.noreply.github.com>

* 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 <585482+tig@users.noreply.github.com>

* Changes before error encountered

Co-authored-by: tig <585482+tig@users.noreply.github.com>

* 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 <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>
Co-authored-by: Tig <tig@users.noreply.github.com>
This commit is contained in:
Copilot
2025-10-27 07:50:09 -06:00
committed by GitHub
parent 1e32d5d5ce
commit f3fc20306e
15 changed files with 171 additions and 57 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,9 +22,9 @@ public partial class View // Command APIs
// HotKey - SetFocus and raise HandlingHotKey // HotKey - SetFocus and raise HandlingHotKey
AddCommand ( AddCommand (
Command.HotKey, Command.HotKey,
() => (ctx) =>
{ {
if (RaiseHandlingHotKey () is true) if (RaiseHandlingHotKey (ctx) is true)
{ {
return 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"/>. /// <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. /// event. The default <see cref="Command.HotKey"/> handler calls this method.
/// </summary> /// </summary>
/// <param name="ctx">The context to pass with the command.</param>
/// <returns> /// <returns>
/// <see langword="null"/> if no event was raised; input processing should continue. /// <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 /// <see langword="false"/> if the event was raised and was not handled (or cancelled); input processing should
/// continue. /// continue.
/// <see langword="true"/> if the event was raised and handled (or cancelled); input processing should stop. /// <see langword="true"/> if the event was raised and handled (or cancelled); input processing should stop.
/// </returns> /// </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})"); //Logging.Debug ($"{Title} ({args.Context?.Source?.Title})");
// Best practice is to invoke the virtual method first. // Best practice is to invoke the virtual method first.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,9 +28,6 @@ public class TextField : View, IDesignable
_selectedStart = -1; _selectedStart = -1;
_text = new (); _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; ReadOnly = false;
Autocomplete = new TextFieldAutocomplete (); Autocomplete = new TextFieldAutocomplete ();
Height = Dim.Auto (DimAutoStyle.Text, 1); Height = Dim.Auto (DimAutoStyle.Text, 1);
@@ -40,9 +37,6 @@ public class TextField : View, IDesignable
Used = true; Used = true;
WantMousePositionReports = true; WantMousePositionReports = true;
// By default, disable hotkeys (in case someome sets Title)
HotKeySpecifier = new ('\xffff');
_historyText.ChangeText += HistoryText_ChangeText; _historyText.ChangeText += HistoryText_ChangeText;
Initialized += TextField_Initialized; 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 // Default keybindings for this view
// We follow this as closely as possible: https://en.wikipedia.org/wiki/Table_of_keyboard_shortcuts // We follow this as closely as possible: https://en.wikipedia.org/wiki/Table_of_keyboard_shortcuts
KeyBindings.Add (Key.Delete, Command.DeleteCharRight); KeyBindings.Add (Key.Delete, Command.DeleteCharRight);
@@ -411,15 +429,6 @@ public class TextField : View, IDesignable
/// </summary> /// </summary>
public IAutocomplete Autocomplete { get; set; } 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> /// <summary>Get the Context Menu for this view.</summary>
[CanBeNull] [CanBeNull]
public PopoverMenu ContextMenu { get; private set; } public PopoverMenu ContextMenu { get; private set; }
@@ -920,7 +929,7 @@ public class TextField : View, IDesignable
_isDrawing = true; _isDrawing = true;
// Cache attributes as GetAttributeForRole might raise events // 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 readonlyAttribute = GetAttributeForRole (VisualRole.ReadOnly);
Attribute normalAttribute = GetAttributeForRole (VisualRole.Editable); Attribute normalAttribute = GetAttributeForRole (VisualRole.Editable);
@@ -943,7 +952,7 @@ public class TextField : View, IDesignable
{ {
// Disabled // Disabled
SetAttributeForRole (VisualRole.Disabled); SetAttributeForRole (VisualRole.Disabled);
} }
else if (idx == _cursorPosition && HasFocus && !Used && SelectedLength == 0 && !ReadOnly) else if (idx == _cursorPosition && HasFocus && !Used && SelectedLength == 0 && !ReadOnly)
{ {
// Selected text // Selected text
@@ -1157,7 +1166,6 @@ public class TextField : View, IDesignable
///// </summary> ///// </summary>
//public event EventHandler<StateEventArgs<string>> TextChanged; //public event EventHandler<StateEventArgs<string>> TextChanged;
/// <summary>Undoes the latest changes.</summary> /// <summary>Undoes the latest changes.</summary>
public void Undo () public void Undo ()
{ {
@@ -1699,25 +1707,33 @@ public class TextField : View, IDesignable
private void RenderCaption () private void RenderCaption ()
{ {
if (HasFocus if (HasFocus
|| Caption == null || string.IsNullOrEmpty (Title)
|| Caption.Length == 0
|| Text?.Length > 0) || Text?.Length > 0)
{ {
return; return;
} }
var color = new Attribute (CaptionColor, GetAttributeForRole (VisualRole.Editable).Background, GetAttributeForRole (VisualRole.Editable).Style); // Ensure TitleTextFormatter has the current Title text
SetAttribute (color); // (should already be set by the Title property setter, but being defensive)
if (TitleTextFormatter.Text != Title)
Move (0, 0);
string render = Caption;
if (render.GetColumns () > Viewport.Width)
{ {
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) private void SetClipboard (IEnumerable<Rune> text)
@@ -1814,11 +1830,11 @@ public class TextField : View, IDesignable
} }
} }
/// <inheritdoc /> /// <inheritdoc/>
public bool EnableForDesign () public bool EnableForDesign ()
{ {
Text = "This is a test."; Text = "This is a test.";
Caption = "Caption"; Title = "Caption";
return true; return true;
} }

View File

@@ -107,7 +107,7 @@ public class FileDialogTests ()
Assert.IsType<TextField> (dlg.MostFocused); Assert.IsType<TextField> (dlg.MostFocused);
Assert.Same (tf, 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 // Dialog has not yet been confirmed with a choice
Assert.True (dlg.Canceled); Assert.True (dlg.Canceled);

View File

@@ -145,7 +145,7 @@ public class TextFieldTests (ITestOutputHelper output)
TextField tf = GetTextFieldsInView (); TextField tf = GetTextFieldsInView ();
// Caption has no effect when focused // Caption has no effect when focused
tf.Caption = caption; tf.Title = caption;
Application.RaiseKeyDownEvent ('\t'); Application.RaiseKeyDownEvent ('\t');
Assert.False (tf.HasFocus); Assert.False (tf.HasFocus);
@@ -165,7 +165,7 @@ public class TextFieldTests (ITestOutputHelper output)
TextField tf = GetTextFieldsInView (); TextField tf = GetTextFieldsInView ();
tf.Caption = caption; tf.Title = caption;
Application.RaiseKeyDownEvent ('\t'); Application.RaiseKeyDownEvent ('\t');
Assert.False (tf.HasFocus); Assert.False (tf.HasFocus);
@@ -185,7 +185,7 @@ public class TextFieldTests (ITestOutputHelper output)
tf.Draw (); tf.Draw ();
DriverAssert.AssertDriverContentsAre ("", output); DriverAssert.AssertDriverContentsAre ("", output);
tf.Caption = "Enter txt"; tf.Title = "Enter txt";
Application.RaiseKeyDownEvent ('\t'); Application.RaiseKeyDownEvent ('\t');
// Caption should appear when not focused and no text // Caption should appear when not focused and no text
@@ -212,7 +212,7 @@ public class TextFieldTests (ITestOutputHelper output)
DriverAssert.AssertDriverContentsAre ("", output); DriverAssert.AssertDriverContentsAre ("", output);
// Caption has no effect when focused // Caption has no effect when focused
tf.Caption = "Enter txt"; tf.Title = "Enter txt";
Assert.True (tf.HasFocus); Assert.True (tf.HasFocus);
View.SetClipToScreen (); View.SetClipToScreen ();
tf.Draw (); tf.Draw ();
@@ -227,6 +227,104 @@ public class TextFieldTests (ITestOutputHelper output)
Application.Top.Dispose (); 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] [Fact]
[TextFieldTestsAutoInitShutdown] [TextFieldTestsAutoInitShutdown]
public void Changing_SelectedStart_Or_CursorPosition_Update_SelectedLength_And_SelectedText () public void Changing_SelectedStart_Or_CursorPosition_Update_SelectedLength_And_SelectedText ()