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 ();
}