diff --git a/src/Spectre.Console.Tests/Extensions/ConsoleKeyExtensions.cs b/src/Spectre.Console.Tests/Extensions/ConsoleKeyExtensions.cs index fee4ec22d..e82c62f58 100644 --- a/src/Spectre.Console.Tests/Extensions/ConsoleKeyExtensions.cs +++ b/src/Spectre.Console.Tests/Extensions/ConsoleKeyExtensions.cs @@ -4,12 +4,28 @@ public static class ConsoleKeyExtensions { public static ConsoleKeyInfo ToConsoleKeyInfo(this ConsoleKey key) { - var ch = (char)key; - if (char.IsControl(ch)) + var ch = key.GetKeyChar(); + + return new ConsoleKeyInfo(ch, key, false, false, false); + } + + private static char GetKeyChar(this ConsoleKey key) + { + if (key is ConsoleKey.UpArrow + or ConsoleKey.DownArrow + or ConsoleKey.LeftArrow + or ConsoleKey.RightArrow + or ConsoleKey.Home + or ConsoleKey.End + or ConsoleKey.PageUp + or ConsoleKey.PageDown + or ConsoleKey.Insert + or ConsoleKey.Delete) { - ch = '\0'; + return '\0'; } - return new ConsoleKeyInfo(ch, key, false, false, false); + var ch = (char)key; + return char.IsControl(ch) ? '\0' : ch; } } \ No newline at end of file diff --git a/src/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs b/src/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs index eb5f48f8a..49d72b47f 100644 --- a/src/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs +++ b/src/Spectre.Console.Tests/Unit/Prompts/ListPromptStateTests.cs @@ -176,4 +176,116 @@ public void Should_Jump_Back_To_First_Item_When_Clearing_Search_Term() // Then state.Index.ShouldBe(0); } + + [Fact] + public void Should_Cycle_To_Next_Match_On_Tab() + { + // Given + var state = CreateListPromptState(20, 10, shouldWrap: true, searchEnabled: true); + + // When + state.Update(ConsoleKey.D1.ToConsoleKeyInfo()); + var moved = state.Update(ConsoleKey.Tab.ToConsoleKeyInfo()); + + // Then + moved.ShouldBeTrue(); + state.Index.ShouldBe(10); + + state.Update(ConsoleKey.Tab.ToConsoleKeyInfo()); + state.Index.ShouldBe(11); + } + + [Fact] + public void Should_Wrap_To_First_Match_On_Tab() + { + // Given + var state = CreateListPromptState(20, 10, shouldWrap: true, searchEnabled: true); + + // When + state.Update(ConsoleKey.D1.ToConsoleKeyInfo()); + + state.Update(ConsoleKey.End.ToConsoleKeyInfo()); + state.Update(ConsoleKey.Tab.ToConsoleKeyInfo()); + + // Then + state.Index.ShouldBe(1); + } + + [Fact] + public void Should_Not_Cycle_When_Search_Disabled() + { + // Given + var state = CreateListPromptState(20, 10, shouldWrap: true, searchEnabled: false); + var start = state.Index; + + // When + var moved = state.Update(ConsoleKey.Tab.ToConsoleKeyInfo()); + + // Then + moved.ShouldBeFalse(); + state.Index.ShouldBe(start); + } + + [Fact] + public void Should_Not_Cycle_When_Search_Empty() + { + // Given + var state = CreateListPromptState(20, 10, shouldWrap: true, searchEnabled: true); + var start = state.Index; + + // When + var moved = state.Update(ConsoleKey.Tab.ToConsoleKeyInfo()); + + // Then + moved.ShouldBeFalse(); + state.Index.ShouldBe(start); + } + + [Fact] + public void Should_Not_Move_When_No_Matches_For_Search() + { + // Given + var state = CreateListPromptState(10, 10, shouldWrap: true, searchEnabled: true); + state.Update(ConsoleKey.End.ToConsoleKeyInfo()); + var indexBefore = state.Index; + + // When + state.Update(ConsoleKey.X.ToConsoleKeyInfo()); + + var moved = state.Update(ConsoleKey.Tab.ToConsoleKeyInfo()); + + // Then + moved.ShouldBeFalse(); + state.Index.ShouldBe(indexBefore); + } + + [Fact] + public void Should_Return_True_When_Tab_Changes_Index() + { + // Given + var state = CreateListPromptState(20, 10, shouldWrap: true, searchEnabled: true); + + // When + state.Update(ConsoleKey.D1.ToConsoleKeyInfo()); + var moved = state.Update(ConsoleKey.Tab.ToConsoleKeyInfo()); + + // Then + moved.ShouldBeTrue(); + state.Index.ShouldBe(10); + } + + [Fact] + public void Should_Return_False_When_Tab_Noop() + { + // Given + var state = CreateListPromptState(20, 10, shouldWrap: true, searchEnabled: true); + var start = state.Index; + + // When + var moved = state.Update(ConsoleKey.Tab.ToConsoleKeyInfo()); + + // Then + moved.ShouldBeFalse(); + state.Index.ShouldBe(start); + } } \ No newline at end of file diff --git a/src/Spectre.Console/Prompts/List/ListPromptState.cs b/src/Spectre.Console/Prompts/List/ListPromptState.cs index 60c5e5596..46e218d92 100644 --- a/src/Spectre.Console/Prompts/List/ListPromptState.cs +++ b/src/Spectre.Console/Prompts/List/ListPromptState.cs @@ -166,6 +166,31 @@ public bool Update(ConsoleKeyInfo keyInfo) index = Items.IndexOf(item); } } + + if (keyInfo.Key == ConsoleKey.Tab && !string.IsNullOrEmpty(SearchText)) + { + var matches = Items.Select((item, i) => new { Item = item, Index = i }) + .Where(x => + _converter.Invoke(x.Item.Data).Contains(search, StringComparison.OrdinalIgnoreCase) && + (!x.Item.IsGroup || Mode != SelectionMode.Leaf)) + .Select(x => x.Index) + .ToList(); + + if (matches.Count > 0) + { + var matchIndex = matches.IndexOf(Index); + + if (matchIndex == -1) + { + index = matches[0]; + } + else + { + var nextMatchIndex = (matchIndex + 1) % matches.Count; + index = matches[nextMatchIndex]; + } + } + } } index = WrapAround