diff --git a/Terminal.Gui/Views/DropDownList.cs b/Terminal.Gui/Views/DropDownList.cs index a9f198ff88..954d9f0b2b 100644 --- a/Terminal.Gui/Views/DropDownList.cs +++ b/Terminal.Gui/Views/DropDownList.cs @@ -73,6 +73,15 @@ namespace Terminal.Gui.Views; /// /// Alt+Down Toggles the dropdown list open or closed. /// +/// +/// Space Toggles the dropdown list open or closed. +/// +/// +/// Up Selects the previous item in the list (when closed). +/// +/// +/// Down Selects the next item in the list (when closed). +/// /// /// Default mouse bindings: /// @@ -96,7 +105,9 @@ public class DropDownList : TextField /// public new static Dictionary? DefaultKeyBindings { get; set; } = new () { - [Command.Toggle] = Bind.All (Key.F4, Key.CursorDown.WithAlt) + [Command.Toggle] = Bind.All (Key.F4, Key.CursorDown.WithAlt, Key.Space), + [Command.Up] = Bind.All (Key.CursorUp), + [Command.Down] = Bind.All (Key.CursorDown) }; private readonly Button? _toggleButton; @@ -155,7 +166,7 @@ public DropDownList () // This ensures the Normal attribute is always that of the host _listPopover.GettingAttributeForRole += (sender, args) => { - if (sender is not View view || args.Role != VisualRole.Normal) + if (sender is not View || args.Role != VisualRole.Normal) { return; } @@ -185,6 +196,10 @@ public DropDownList () // Add command handler for toggle AddCommand (Command.Toggle, ToggleDropDown); + // Add command handlers for navigating items when dropdown is closed + AddCommand (Command.Up, MoveSelectionUp); + AddCommand (Command.Down, MoveSelectionDown); + // Apply layered key bindings (base View layer + DropDownList-specific layer) ApplyKeyBindings (View.DefaultKeyBindings, DefaultKeyBindings); @@ -340,7 +355,19 @@ protected override bool OnGettingAttributeForRole (in VisualRole role, ref Attri /// /// This property delegates to the property of the internal . /// - public IListDataSource? Source { get => _listPopover?.ContentView?.Source; set => _listPopover?.ContentView?.Source = value; } + public IListDataSource? Source + { + get => _listPopover?.ContentView?.Source; + set + { + if (_listPopover?.ContentView is { } contentView) + { + contentView.Source = value; + } + + KeystrokeNavigator.Collection = value?.ToList (); + } + } /// /// Provides the anchor rectangle for positioning the popover below the DropDownList. @@ -409,6 +436,148 @@ private void OpenDropDown () _listPopover.MakeVisible (); } + /// + /// Gets the that searches the collection as the + /// user types when the dropdown is closed. + /// + public IListCollectionNavigator KeystrokeNavigator { get; } = new CollectionNavigator (); + + /// + protected override bool OnKeyDown (Key key) + { + // Only handle collection navigation when dropdown is closed and in ReadOnly mode + if (_listPopover is { Visible: true } || !ReadOnly) + { + return base.OnKeyDown (key); + } + + // If the key is bound to a command, let normal processing happen + if (KeyBindings.TryGet (key, out _)) + { + return false; + } + + // Enable user to find & select an item by typing text + if (Source is null) + { + return false; + } + + if (!KeystrokeNavigator.Matcher.IsCompatibleKey (key)) + { + return false; + } + + int currentIndex = GetCurrentSelectedIndex () ?? -1; + int? newItem = KeystrokeNavigator.GetNextMatchingItem (currentIndex >= 0 ? currentIndex : null, (char)key); + + if (newItem is null or -1) + { + return false; + } + + SelectItemAtIndex (newItem.Value); + + return true; + } + + /// + /// Moves the selection to the previous item in the list. Does nothing if already at the first item. + /// + private bool? MoveSelectionUp () + { + // If the dropdown is open, let the popover handle it + if (_listPopover is { Visible: true }) + { + return null; + } + + int? currentIndex = GetCurrentSelectedIndex (); + + if (currentIndex is null or <= 0) + { + return true; // At start or no source — do nothing but consume the key + } + + SelectItemAtIndex (currentIndex.Value - 1); + + return true; + } + + /// + /// Moves the selection to the next item in the list. Does nothing if already at the last item. + /// + private bool? MoveSelectionDown () + { + // If the dropdown is open, let the popover handle it + if (_listPopover is { Visible: true }) + { + return null; + } + + int? currentIndex = GetCurrentSelectedIndex (); + int count = Source?.Count ?? 0; + + if (count == 0) + { + return true; + } + + int nextIndex = (currentIndex ?? -1) + 1; + + if (nextIndex >= count) + { + return true; // At end — do nothing but consume the key + } + + SelectItemAtIndex (nextIndex); + + return true; + } + + /// + /// Gets the index of the currently selected item based on the current . + /// + private int? GetCurrentSelectedIndex () + { + IList? items = Source?.ToList (); + + if (items is null) + { + return null; + } + + for (var i = 0; i < items.Count; i++) + { + if (string.Equals (items [i]?.ToString (), Text, StringComparison.Ordinal)) + { + return i; + } + } + + return null; + } + + /// + /// Selects the item at the specified index, updating . + /// + private void SelectItemAtIndex (int index) + { + IList? items = Source?.ToList (); + + if (items is null) + { + return; + } + + if (index < 0 || index >= items.Count) + { + return; + } + + Text = items [index]?.ToString () ?? string.Empty; + } + /// /// /// diff --git a/Tests/UnitTestsParallelizable/Views/DropDownListTests.cs b/Tests/UnitTestsParallelizable/Views/DropDownListTests.cs index 680a132fd7..413a7b08cb 100644 --- a/Tests/UnitTestsParallelizable/Views/DropDownListTests.cs +++ b/Tests/UnitTestsParallelizable/Views/DropDownListTests.cs @@ -1014,6 +1014,171 @@ public void VisiblePopover_LayoutsHeight_WhenTerminalIsResized () app.End (token!); } + [Fact] + public void Space_OpensDropdown_WhenReadOnly () + { + // Copilot + using IApplication app = Application.Create (); + + DropDownList dropdown = new () { Source = new ListWrapper (new ObservableCollection (["Item1", "Item2"])), ReadOnly = true }; + dropdown.App = app; + dropdown.BeginInit (); + dropdown.EndInit (); + dropdown.SetFocus (); + + dropdown.NewKeyDownEvent (Key.Space); + + IPopoverView? popover = FindDropDownPopover (app); + Assert.NotNull (popover); + Assert.True (popover.Visible); + + dropdown.Dispose (); + } + + [Fact] + public void Down_SelectsNextItem_WhenClosed () + { + // Copilot + using IApplication app = Application.Create (); + + DropDownList dropdown = new () + { + Source = new ListWrapper (new ObservableCollection (["Apple", "Banana", "Cherry"])), + ReadOnly = true, + Text = "Apple" + }; + dropdown.App = app; + dropdown.BeginInit (); + dropdown.EndInit (); + dropdown.SetFocus (); + + dropdown.NewKeyDownEvent (Key.CursorDown); + + Assert.Equal ("Banana", dropdown.Text); + + dropdown.Dispose (); + } + + [Fact] + public void Up_SelectsPreviousItem_WhenClosed () + { + // Copilot + using IApplication app = Application.Create (); + + DropDownList dropdown = new () + { + Source = new ListWrapper (new ObservableCollection (["Apple", "Banana", "Cherry"])), + ReadOnly = true, + Text = "Banana" + }; + dropdown.App = app; + dropdown.BeginInit (); + dropdown.EndInit (); + dropdown.SetFocus (); + + dropdown.NewKeyDownEvent (Key.CursorUp); + + Assert.Equal ("Apple", dropdown.Text); + + dropdown.Dispose (); + } + + [Fact] + public void Down_DoesNothing_WhenAtEnd () + { + // Copilot + using IApplication app = Application.Create (); + + DropDownList dropdown = new () + { + Source = new ListWrapper (new ObservableCollection (["Apple", "Banana", "Cherry"])), + ReadOnly = true, + Text = "Cherry" + }; + dropdown.App = app; + dropdown.BeginInit (); + dropdown.EndInit (); + dropdown.SetFocus (); + + dropdown.NewKeyDownEvent (Key.CursorDown); + + Assert.Equal ("Cherry", dropdown.Text); + + dropdown.Dispose (); + } + + [Fact] + public void Up_DoesNothing_WhenAtStart () + { + // Copilot + using IApplication app = Application.Create (); + + DropDownList dropdown = new () + { + Source = new ListWrapper (new ObservableCollection (["Apple", "Banana", "Cherry"])), + ReadOnly = true, + Text = "Apple" + }; + dropdown.App = app; + dropdown.BeginInit (); + dropdown.EndInit (); + dropdown.SetFocus (); + + dropdown.NewKeyDownEvent (Key.CursorUp); + + Assert.Equal ("Apple", dropdown.Text); + + dropdown.Dispose (); + } + + [Fact] + public void CollectionNavigation_SelectsMatchingItem_WhenClosed () + { + // Copilot + using IApplication app = Application.Create (); + + DropDownList dropdown = new () + { + Source = new ListWrapper (new ObservableCollection (["Apple", "Banana", "Cherry"])), + ReadOnly = true, + Text = "Apple" + }; + dropdown.App = app; + dropdown.BeginInit (); + dropdown.EndInit (); + dropdown.SetFocus (); + + // Type 'c' to navigate to "Cherry" + dropdown.NewKeyDownEvent (Key.C); + + Assert.Equal ("Cherry", dropdown.Text); + + dropdown.Dispose (); + } + + [Fact] + public void Down_SelectsFirstItem_WhenNoSelection () + { + // Copilot + using IApplication app = Application.Create (); + + DropDownList dropdown = new () + { + Source = new ListWrapper (new ObservableCollection (["Apple", "Banana", "Cherry"])), + ReadOnly = true + }; + dropdown.App = app; + dropdown.BeginInit (); + dropdown.EndInit (); + dropdown.SetFocus (); + + dropdown.NewKeyDownEvent (Key.CursorDown); + + Assert.Equal ("Apple", dropdown.Text); + + dropdown.Dispose (); + } + // Helper to find the DropDownList popover (excludes the context menu popover) private static IPopoverView? FindDropDownPopover (IApplication app) => app.Popovers?.Popovers.OfType> ().FirstOrDefault (); }