diff --git a/CHANGELOG.md b/CHANGELOG.md index af67866..3d0fede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,12 @@ is not part of this repository. - A button that cannot be pressed has no fill, and one that can has a slightly lighter fill than before. - The state and startup type marks in the list and in the details panel are a little larger. +- Tab in the search box writes the highlighted suggestion, the same as Enter, and keeps the cursor + in the box - so `sta`, Tab, Tab gives `status:running`. When there is nothing to write, or the + list shows the example questions, Tab moves on to the next control as before. While an input + method is still composing a character, neither Tab nor Enter writes a suggestion. +- The list of suggestions opens when you type, not when you only move the cursor with the arrow + keys or a click. Down still opens it wherever the cursor is. ### Removed diff --git a/README.md b/README.md index 40ab2a5..410e85b 100644 --- a/README.md +++ b/README.md @@ -492,7 +492,8 @@ the third ends the process. shows every component of the window in every state, and reads nothing from your machine. Three lists on the switch above the search box - *Services*, *Drivers*, *All* - each saying -how big it is. The search box takes the query language and suggests as you type. The filter +how big it is. The search box takes the query language and suggests as you type - Tab or Enter +writes the highlighted word. The filter buttons write into the box. *Columns* chooses what the list shows, a right-click on a column heading narrows the list to that value or puts the column away, and the layout you leave is the layout it opens in. A row's menu previews every operation before offering it, and copies the name or diff --git a/src/Bws.Gui/MainWindow.Keyboard.cs b/src/Bws.Gui/MainWindow.Keyboard.cs index 5694a60..f96d072 100644 --- a/src/Bws.Gui/MainWindow.Keyboard.cs +++ b/src/Bws.Gui/MainWindow.Keyboard.cs @@ -80,6 +80,11 @@ protected override async void OnPreviewKeyDown(KeyEventArgs e) /// And in the box, Enter belongs to the list UNDER the box while that list is open - and /// to nobody while it is closed, which is what it was before the list existed (decision 8 of /// the design). Down and Up belong to that list from the box and to the grid from the grid. + /// + /// Tab writes a word in the box while a list of WORDS is open, and walks on otherwise - + /// in the box with the questions open or nothing to write, and everywhere outside the box. + /// Owner's decision of 2026-09-25, and why the questions are left out is at + /// . /// internal Shortcut Wanted(Key key, ModifierKeys modifiers, bool inTheBox, bool inTheGrid) { @@ -89,14 +94,15 @@ internal Shortcut Wanted(Key key, ModifierKeys modifiers, bool inTheBox, bool in { return wanted switch { - Shortcut.OpenDetails => _model.Suggesting.IsOpen ? Shortcut.TakeSuggestion : Shortcut.None, + Shortcut.OpenDetails => _model.Suggesting.CanTake ? Shortcut.TakeSuggestion : Shortcut.None, + Shortcut.CompleteWord => _model.Suggesting.TabWrites ? Shortcut.TakeSuggestion : Shortcut.None, Shortcut.CopyRow => Shortcut.None, _ => wanted }; } var theListPress = wanted is Shortcut.OpenDetails or Shortcut.CopyRow; - var theBoxPress = wanted is Shortcut.NextSuggestion or Shortcut.PreviousSuggestion; + var theBoxPress = wanted is Shortcut.NextSuggestion or Shortcut.PreviousSuggestion or Shortcut.CompleteWord; return (theListPress && !inTheGrid) || theBoxPress ? Shortcut.None : wanted; } diff --git a/src/Bws.Gui/MainWindow.Suggesting.cs b/src/Bws.Gui/MainWindow.Suggesting.cs index c582177..2980089 100644 --- a/src/Bws.Gui/MainWindow.Suggesting.cs +++ b/src/Bws.Gui/MainWindow.Suggesting.cs @@ -125,11 +125,23 @@ private void WatchTheBoxForSuggestions() // THE BOX'S TEXT, NOT THE MODEL'S. The binding to QueryText waits 400 ms so that the list // of entries does not narrow under every keystroke, and a list of completions that waited // with it would be a list answering the previous keystroke. The caret and the text arrive - // as two events and both recompute from the current values, so their order is not relied - // on. + // as two events and both go to Follow with the current values, which tells typing from a + // caret moving through unchanged text by the text itself - so their order is not relied on. box.TextChanged += (_, _) => FollowTheBox(); box.SelectionChanged += (_, _) => FollowTheBox(); + // NOTHING IS WRITTEN OVER A CHARACTER STILL BEING COMPOSED - review of PR 22, and + // Suggesting.CanTake says why. The start and the end of every text composition in the box, + // handled or not, because an input method's own handling is exactly the case this is for. + box.AddHandler( + TextCompositionManager.PreviewTextInputStartEvent, + new TextCompositionEventHandler((_, _) => list.Composing(open: true)), + handledEventsToo: true); + box.AddHandler( + TextCompositionManager.PreviewTextInputEvent, + new TextCompositionEventHandler((_, _) => list.Composing(open: false)), + handledEventsToo: true); + Search.List.PreviewMouseLeftButtonUp += (_, e) => TakeUnderThePointer(e); Deactivated += (_, _) => list.Close(); @@ -257,6 +269,11 @@ private bool Write(Suggestion? taken) box.SelectedText = taken.Written; box.CaretIndex = start + taken.Written.Length; + // Followed as typing, because the last thing the box just reported is its caret moving + // through unchanged text - which closes the list since 2026-09-25, and would take away the + // values that belong after a field just written. Suggesting.Wrote says it in full. + _model.Suggesting.Wrote(box.Text, box.CaretIndex); + return true; } } diff --git a/src/Bws.Gui/Resources/gui.en.json b/src/Bws.Gui/Resources/gui.en.json index 1b029ac..6ac5935 100644 --- a/src/Bws.Gui/Resources/gui.en.json +++ b/src/Bws.Gui/Resources/gui.en.json @@ -121,6 +121,7 @@ "gui.example.waitingOnTrigger": "Stopped, waiting on a trigger", "gui.suggest.keys": "Up and Down choose, Enter writes it, Escape closes", + "gui.suggest.keys.words": "Tab or Enter writes it, Up and Down choose, Escape closes", "gui.suggest.caption.questions": "Questions to start from", "gui.suggest.caption.words": "What can go here", "gui.suggest.spoken": "{0}, {1}, {2} of {3}", diff --git a/src/Bws.Gui/Shortcuts.cs b/src/Bws.Gui/Shortcuts.cs index 05b506c..ca117ae 100644 --- a/src/Bws.Gui/Shortcuts.cs +++ b/src/Bws.Gui/Shortcuts.cs @@ -51,7 +51,8 @@ internal static Shortcut For(Key key, ModifierKeys modifiers) // DOWN AND UP MEAN THE LIST UNDER THE SEARCH BOX, since 2026-09-15 - and only there, which // is the window's half to decide, the same way Enter means the details only from the grid. - // From anywhere else they are the arrows every control already has an opinion about. + // From anywhere else they are the arrows every control already has an opinion about. Tab + // the same, since 2026-09-25 - and only without a modifier, so Shift+Tab always walks back. return key switch { Key.F5 => Shortcut.Refresh, @@ -60,6 +61,7 @@ internal static Shortcut For(Key key, ModifierKeys modifiers) Key.Enter => Shortcut.OpenDetails, Key.Down => Shortcut.NextSuggestion, Key.Up => Shortcut.PreviousSuggestion, + Key.Tab => Shortcut.CompleteWord, _ => Shortcut.None }; } @@ -157,5 +159,16 @@ internal enum Shortcut /// Enter, and Enter from the grid means the details - which of the two a press means depends /// on where the keyboard is and whether the list is open, and both are the window's to know. /// - TakeSuggestion + TakeSuggestion, + + /// + /// Tab: write the chosen WORD of the list under the search box - the reflex from PowerShell and + /// every editor, owner's decision of 2026-09-25 (`docs/PROJEKT-PODPOWIEDZI-UX-20260925.md`, T1). + /// + /// Never carried out as itself. Tab walks through the window everywhere else, so the + /// window turns this into in the box while a list of words is open + /// and into in every other case - handed back, the press moves the keyboard on + /// as it always did. + /// + CompleteWord } diff --git a/src/Bws.Gui/ViewModels/MainViewModel.Checking.cs b/src/Bws.Gui/ViewModels/MainViewModel.Checking.cs index 6d38af7..090145b 100644 --- a/src/Bws.Gui/ViewModels/MainViewModel.Checking.cs +++ b/src/Bws.Gui/ViewModels/MainViewModel.Checking.cs @@ -34,7 +34,7 @@ protected override IEnumerable ErrorsOf(string property) => /// private void AboutTheQuery(string problem) { - if (Says.AboutTheQuery(_queryText, problem)) + if (Says.AboutTheQuery(problem)) { RaiseErrors(nameof(QueryText)); } diff --git a/src/Bws.Gui/ViewModels/Says.cs b/src/Bws.Gui/ViewModels/Says.cs index bb0250f..9a07519 100644 --- a/src/Bws.Gui/ViewModels/Says.cs +++ b/src/Bws.Gui/ViewModels/Says.cs @@ -23,7 +23,6 @@ public sealed class Says : Observable private string _status = Texts.Of("gui.status.reading"); private string _queryProblem = string.Empty; - private bool _asking; private string _refusal = string.Empty; private string _layout = string.Empty; private string _done = string.Empty; @@ -129,40 +128,30 @@ private void Admit(Admitted admitted) public string QueryProblem => _queryProblem; /// - /// The line under the search box: what is wrong with the query, or else what the answer to it - /// has to admit. + /// What the search field says about its query: what is wrong with it, or else what the answer + /// to it has to admit. Since 2026-09-25 in a short form at the field's right end and in full in + /// the panel under it - it stood in a line of its own under the box until then. /// - /// One line rather than two, and the mistake wins - a query that does not read has no + /// One sentence rather than two, and the mistake wins - a query that does not read has no /// answer of its own to qualify, and the list under it is the previous one. + /// + /// It still speaks with the box empty, and that is rule 8. A column on screen asks for a + /// family too (MainViewModel.Asked), so a shown Memory column gets a note here while the window + /// reads what fills it - or the window reads for seconds with no sentence about it. /// public string AnswerLine => _queryProblem.Length > 0 ? _queryProblem : _admitted.Reservations; /// Whether is a mistake - what colours it. public bool AnswerLineIsProblem => _queryProblem.Length > 0; - /// - /// Whether the line under the box takes any room: while there is text in the box, or while - /// the line has something to say. - /// - /// Not always, because the window's chrome already takes nearly half its height - /// (UX-GUI-007). Not only while it has words, because then the list would jump each time a - /// reading note came and went under somebody's typing. Tied to the box instead, the line appears - /// with the first character and goes when the box is emptied - a move the person made. - /// - /// UNLESS IT STILL HAS SOMETHING TO SAY, and that half is rule 8 rather than layout. A - /// column on screen asks for a family too (MainViewModel.Asked), so with the box empty a shown - /// Memory column still gets a note while the window reads what fills it. The reservations no - /// longer stand under the list, so without this half that note would be said nowhere - the - /// window reading for seconds with no sentence about it. The review of PR #11 caught the user - /// changelog promising only the first half. - /// - public bool AnswerLineShown => _asking || AnswerLine.Length > 0; + // AnswerLineShown and the flag behind it went on 2026-09-25 (backlog 461): they said whether + // a line under the box took room, and that line went with the palette the same day, when the + // sentence moved into the field so that typing no longer moves the window. private void RaiseTheAnswerLine() { Raise(nameof(AnswerLine)); Raise(nameof(AnswerLineIsProblem)); - Raise(nameof(AnswerLineShown)); } /// @@ -413,35 +402,29 @@ internal void Moved() } /// - /// What is wrong with the query in the box, which may be nothing, and whether the box holds - /// anything at all. Answers whether the sentence changed, so the caller can tell the box - /// without telling it once a second for nothing. + /// What is wrong with the query in the box, which may be nothing. Answers whether the sentence + /// changed, so the caller can tell the box without telling it once a second for nothing. /// /// The second half of the sentence is written here rather than by the caller, because it /// is true of every mistake: the list stays, so it is the previous answer. /// - internal bool AboutTheQuery(string text, string problem) + internal bool AboutTheQuery(string problem) { var sentence = problem.Length == 0 ? string.Empty : problem + " " + Texts.Of("gui.query.listIsPrevious"); - var asking = text.Length > 0; - - if (_queryProblem == sentence && _asking == asking) + if (_queryProblem == sentence) { return false; } - var changed = _queryProblem != sentence; - _queryProblem = sentence; - _asking = asking; Raise(nameof(QueryProblem)); RaiseTheAnswerLine(); - return changed; + return true; } /// diff --git a/src/Bws.Gui/ViewModels/Suggesting.cs b/src/Bws.Gui/ViewModels/Suggesting.cs index 9a38ad8..c30a190 100644 --- a/src/Bws.Gui/ViewModels/Suggesting.cs +++ b/src/Bws.Gui/ViewModels/Suggesting.cs @@ -58,11 +58,19 @@ public sealed class Suggesting : Observable private Suggestion? _chosen; private bool _keyboardHere; private bool _questions; + private bool _composing; + + // What the box held, and where its caret stood, the last time the list was told - so that a + // caret moving through text nobody changed can be told apart from typing. See Follow. + private string? _followedText; + private int _followedCaret; // Constants rather than literals in the expression below, because TextKeyGuards knows the // shapes a key is used in and a literal inside a conditional is not one of them. private const string QuestionsCaption = "gui.suggest.caption.questions"; private const string WordsCaption = "gui.suggest.caption.words"; + private const string QuestionsKeys = "gui.suggest.keys"; + private const string WordsKeys = "gui.suggest.keys.words"; /// /// The chips are read rather than copied, because their labels are read in whatever language @@ -106,8 +114,43 @@ public Suggestion? Chosen /// The sentence under the list saying which keys do what - under the list rather than in the /// tooltip, because under the list is where somebody is looking while the keys matter. /// Decision 9 of the design. + /// + /// Two sentences since 2026-09-25, because Tab means two things - it writes a word and + /// it walks past the questions. `docs/PROJEKT-PODPOWIEDZI-UX-20260925.md`, P3. + /// + public string Keys => Texts.Of(_questions ? QuestionsKeys : WordsKeys); + + /// + /// Whether Tab writes the chosen row - the owner's reflex from PowerShell and every editor, + /// 2026-09-25, which reversed decision 7 of the first design. + /// + /// For a list of WORDS and never for the questions. The questions open when somebody + /// arrives at an empty box, and arriving by Tab is one of the ways - so a Tab that wrote them + /// would make walking through the window with the keyboard type a query into the box and stop + /// there. With nothing to write, Tab walks on as it always did. + /// + public bool TabWrites => CanTake && !_questions; + + /// + /// Whether Enter or Tab may write the chosen row now: a list is open, and no input method is in + /// the middle of composing a character in the box. + /// + /// Review of PR 22. An input method - the way Chinese, Japanese and Korean are typed - + /// holds the characters it is still composing in the box until they are committed, and a row + /// written over them would replace text the person has not finished. Whether a key reaches the + /// window as itself during a composition depends on the input method, and there is none on the + /// machine this was built on - NOT MEASURED - so the list simply takes nothing until the + /// composition ends. is told by the window. + /// + public bool CanTake => IsOpen && !_composing; + + /// + /// An input method began composing text in the box, or finished. Said by the window from the + /// text composition events. Whether ordinary typing raises the start too was not checked - if + /// it does, the end follows with the character, so between two keystrokes this is false either + /// way. /// - public string Keys => Texts.Of("gui.suggest.keys"); + public void Composing(bool open) => _composing = open; /// /// What kind of list is open, said over it: the questions on an empty box, or what can go where @@ -164,6 +207,9 @@ public void Keyboard(bool present) if (!present) { + // A composition does not outlive the keyboard leaving the box - and one abandoned + // without an end said to the window would otherwise keep Tab and Enter from writing. + _composing = false; Close(); } } @@ -193,19 +239,63 @@ public void Arrived(string? text) public void Left() => Keyboard(present: false); /// - /// The text or the caret moved. The list follows - and opens only while the keyboard is in - /// the box and nothing is selected. Read from the box's own text rather than from the + /// The text or the caret moved. The list follows TYPING - and opens only while the keyboard is + /// in the box and nothing is selected. Read from the box's own text rather than from the /// model's, which is 400 ms behind it. + /// + /// A caret moving through text nobody changed CLOSES the list, since 2026-09-25 - owner's + /// decision P2 of `docs/PROJEKT-PODPOWIEDZI-UX-20260925.md`. Until then one Left arrow in + /// status:running opened a list of one row saying running, and correcting a query + /// with the arrows blinked a list at every press. Down still asks for it on purpose. + /// + /// Told apart by the text, not by which event arrived, because the box raises two for + /// one keystroke - its text changed and its selection changed - and the window does not rely + /// on their order. A new text is typing. The same text with the caret elsewhere is a move. The + /// same text at the same caret is the second event of a keystroke already followed, and leaves + /// the list as it is. /// public void Follow(string? text, int caret, int selectionLength) { - if (!_keyboardHere || selectionLength > 0) + var sameText = string.Equals(text, _followedText, StringComparison.Ordinal); + var sameCaret = caret == _followedCaret; + + _followedText = text; + _followedCaret = caret; + + if (!_keyboardHere || selectionLength > 0 || (sameText && !sameCaret)) { Close(); return; } + if (!sameText) + { + _questions = false; + Offer(Rows(QueryCompletions.WhileTyping(text, caret))); + } + } + + /// + /// The window has just written a row into the box. Followed as typing, whatever the box's own + /// events said on the way. + /// + /// Needed because of the rule in . The window writes through the + /// selection and then puts the caret after what it wrote - so the last thing the box reports + /// is a caret moving through text that did not change, which closes the list. That would take + /// away the values offered the moment a field is written, and sta Tab Tab giving + /// status:running is the whole of the Tab decision. + /// + public void Wrote(string? text, int caret) + { + _followedText = text; + _followedCaret = caret; + + if (!_keyboardHere) + { + return; + } + _questions = false; Offer(Rows(QueryCompletions.WhileTyping(text, caret))); } @@ -267,6 +357,7 @@ public bool Close() Chosen = null; Raise(nameof(IsOpen)); Raise(nameof(Caption)); + Raise(nameof(Keys)); return true; } @@ -314,6 +405,7 @@ private void Offer(IReadOnlyList rows) Offered = rows; Raise(nameof(Caption)); + Raise(nameof(Keys)); Chosen = rows.FirstOrDefault(row => string.Equals(row.Word, kept, StringComparison.Ordinal)) ?? rows[0]; if (!wasOpen) diff --git a/tests/Bws.Gui.Tests/AnswerLineTests.cs b/tests/Bws.Gui.Tests/AnswerLineTests.cs index 2cc9ffe..3364d2c 100644 --- a/tests/Bws.Gui.Tests/AnswerLineTests.cs +++ b/tests/Bws.Gui.Tests/AnswerLineTests.cs @@ -70,7 +70,6 @@ public async Task A_reservation_about_the_answer_stands_under_the_box() Assert.Contains(Texts.Of("gui.query.unreadSignatures"), model.Says.Reservations, StringComparison.Ordinal); Assert.Equal(model.Says.Reservations, model.Says.AnswerLine); Assert.False(model.Says.AnswerLineIsProblem); - Assert.True(model.Says.AnswerLineShown); Assert.DoesNotContain(Texts.Of("gui.query.unreadSignatures"), model.Says.NoticeLine, StringComparison.Ordinal); } @@ -97,35 +96,14 @@ public async Task A_mistake_takes_the_line_under_the_box() } /// - /// The line takes room while there is text in the box, even with nothing to say - so a reading - /// note that comes and goes under somebody's typing does not move the list - and gives the room - /// back when the box is emptied. - /// - [Fact] - public async Task The_line_takes_room_while_the_box_holds_text_and_gives_it_back_when_emptied() - { - var model = await Loaded(); - - Assert.False(model.Says.AnswerLineShown); - - model.QueryText = "spool"; - - Assert.Equal(string.Empty, model.Says.AnswerLine); - Assert.True(model.Says.AnswerLineShown); - - model.ClearQuery(); - - Assert.False(model.Says.AnswerLineShown); - } - - /// - /// WITH THE BOX EMPTY THE LINE STILL SPEAKS WHILE IT HAS SOMETHING TO SAY - the half of - /// AnswerLineShown the user changelog left out until the review of PR #11. + /// WITH THE BOX EMPTY THE FIELD STILL SPEAKS WHILE IT HAS SOMETHING TO SAY - written for the + /// review of PR #11, when a line under the box took its room from this, and kept when that line + /// went with the palette on 2026-09-25 (backlog 461), because the half it held was never layout. /// - /// This half is rule 8, not layout. A shown column asks for its family too, and the - /// reservations no longer stand under the list, so a Memory column turned on with nothing typed - /// is said here while its pass is out, or nowhere. The pass is held open by a reader that waits, - /// so the state is looked at while it is true rather than raced. + /// It is rule 8. A shown column asks for its family too, and the reservations no longer + /// stand under the list, so a Memory column turned on with nothing typed is said here while its + /// pass is out, or nowhere. The pass is held open by a reader that waits, so the state is looked + /// at while it is true rather than raced. /// [Fact] public async Task A_shown_column_still_being_read_is_said_under_an_empty_box() @@ -146,14 +124,12 @@ public async Task A_shown_column_still_being_read_is_said_under_an_empty_box() Assert.Equal(string.Empty, model.QueryText); Assert.Equal(Texts.Of("gui.query.readingMemory"), model.Says.AnswerLine); - Assert.True(model.Says.AnswerLineShown); reader.Release(); await refresh; - // Read, so nothing is left to say, and the room goes back with the words. + // Read, so nothing is left to say. Assert.Equal(string.Empty, model.Says.AnswerLine); - Assert.False(model.Says.AnswerLineShown); } /// diff --git a/tests/Bws.Gui.Tests/KeyboardTests.cs b/tests/Bws.Gui.Tests/KeyboardTests.cs index c0727a8..593731e 100644 --- a/tests/Bws.Gui.Tests/KeyboardTests.cs +++ b/tests/Bws.Gui.Tests/KeyboardTests.cs @@ -279,6 +279,80 @@ public void Enter_in_the_box_takes_a_row_only_while_the_list_is_open() WpfHost.On(window.Close); } + /// + /// Tab means the word under the box - and only Tab alone, so Shift+Tab always walks back. + /// Owner's decision of 2026-09-25, `docs/PROJEKT-PODPOWIEDZI-UX-20260925.md` T1. + /// + [Fact] + public void Tab_alone_means_the_word_under_the_box_and_Shift_Tab_never_does() => + Assert.Equal( + [Shortcut.CompleteWord, Shortcut.None, Shortcut.None], + [Shortcuts.For(Key.Tab, ModifierKeys.None), Shortcuts.For(Key.Tab, ModifierKeys.Shift), Shortcuts.For(Key.Tab, ModifierKeys.Control)]); + + /// + /// Tab in the box writes the chosen word while a list of WORDS is open, and is handed back - so + /// the keyboard walks on - with the questions open, with nothing open, and anywhere outside the + /// box. The questions are the case that matters: arriving at an empty box by Tab opens them, and + /// a Tab that wrote one would stop anybody walking through the window in the search box. + /// + [Fact] + public void Tab_in_the_box_writes_a_word_and_walks_on_everywhere_else() + { + var window = WpfHost.Window(); + var model = WpfHost.On(() => (MainViewModel)window.DataContext); + + Assert.Equal(Shortcut.None, Wanted(window, Key.Tab, inTheBox: true, inTheGrid: false)); + + WpfHost.On(() => + { + model.Suggesting.Keyboard(present: true); + model.Suggesting.Arrived(string.Empty); + }); + + Assert.True(model.Suggesting.IsOpen); + Assert.Equal(Shortcut.None, Wanted(window, Key.Tab, inTheBox: true, inTheGrid: false)); + + WpfHost.On(() => model.Suggesting.Ask("sta", 3, 0)); + + Assert.Equal(Shortcut.TakeSuggestion, Wanted(window, Key.Tab, inTheBox: true, inTheGrid: false)); + Assert.Equal(Shortcut.None, Wanted(window, Key.Tab, inTheBox: false, inTheGrid: true)); + Assert.Equal(Shortcut.None, Wanted(window, Key.Tab, inTheBox: false, inTheGrid: false)); + + WpfHost.On(window.Close); + } + + /// + /// sta Tab Tab gives status:running with the list closed and the caret at the end + /// - the reflex the owner reached for, through the same road a real press takes. The second Tab + /// only works because the values are offered after the field is written, which the caret rule + /// of 2026-09-25 would take away without Suggesting.Wrote. + /// + [Fact] + public void Tab_twice_writes_a_field_and_then_its_first_value() + { + var window = WpfHost.Window(); + var model = WpfHost.On(() => (MainViewModel)window.DataContext); + + Typed(window, string.Empty, 0); + Keyed(window, "sta"); + + Assert.Equal("sta", WpfHost.On(() => window.Search.Box.Text)); + Assert.True(model.Suggesting.TabWrites); + Assert.True(WpfHost.On(() => window.Act(window.Wanted(Key.Tab, ModifierKeys.None, inTheBox: true, inTheGrid: false), out _))); + Assert.Equal("status:", WpfHost.On(() => window.Search.Box.Text)); + Assert.True(model.Suggesting.TabWrites); + + Assert.True(WpfHost.On(() => window.Act(window.Wanted(Key.Tab, ModifierKeys.None, inTheBox: true, inTheGrid: false), out _))); + Assert.Equal("status:running ", WpfHost.On(() => window.Search.Box.Text)); + Assert.Equal(15, WpfHost.On(() => window.Search.Box.CaretIndex)); + Assert.False(model.Suggesting.IsOpen); + + // And the third walks on, because there is nothing left to write. + Assert.Equal(Shortcut.None, Wanted(window, Key.Tab, inTheBox: true, inTheGrid: false)); + + WpfHost.On(window.Close); + } + [Fact] public void Down_on_a_closed_list_opens_it_and_moves_through_it_once_open() { @@ -450,6 +524,29 @@ private static Shortcut Wanted(MainWindow window, Key key, bool inTheBox, bool i /// (BindingExpression.AttachToContext under DataBindEngine.Run), not by reasoning. A shown /// window has drained that queue long before anybody types - this host never shows one. /// + /// + /// Characters typed into the box the way a keyboard types them - through the text input event + /// the box's own editor answers - rather than by setting its text. + /// + /// The difference is the order of the box's two events, and since 2026-09-25 the list cares. + /// Setting Text reports the new text with the caret at zero and moves the caret after, which the + /// list reads as typing followed by a caret move - and a caret move closes it. A keystroke + /// inserts at the caret and reports the text with the caret already past what it inserted. + /// + private static void Keyed(MainWindow window, string characters) => + WpfHost.On(() => + { + foreach (var character in characters) + { + var composition = new TextComposition(InputManager.Current, window.Search.Box, character.ToString()); + + window.Search.Box.RaiseEvent(new TextCompositionEventArgs(Keyboard.PrimaryDevice, composition) + { + RoutedEvent = TextCompositionManager.TextInputEvent + }); + } + }); + private static void Typed(MainWindow window, string text, int caret) { WpfHost.Settled(); diff --git a/tests/Bws.Gui.Tests/SuggestingTests.cs b/tests/Bws.Gui.Tests/SuggestingTests.cs index 091b136..d21ef7e 100644 --- a/tests/Bws.Gui.Tests/SuggestingTests.cs +++ b/tests/Bws.Gui.Tests/SuggestingTests.cs @@ -135,11 +135,13 @@ public void A_selection_is_never_completed() Assert.False(suggesting.IsOpen); Assert.False(suggesting.Ask("status:r", 7, 1)); - // And it closes a list that was open when the selection arrives. - suggesting.Follow("status:r", 8, 0); + // And it closes a list that was open when the selection arrives. Opened by TYPING the next + // letter since 2026-09-25 - collapsing the selection with the text unchanged is a caret + // move, and a caret move closes (SuggestingTypingTests). + suggesting.Follow("status:ru", 9, 0); Assert.True(suggesting.IsOpen); - suggesting.Follow("status:r", 7, 1); + suggesting.Follow("status:ru", 7, 2); Assert.False(suggesting.IsOpen); } @@ -382,11 +384,4 @@ public void A_value_carries_the_label_of_its_chip_and_nothing_where_there_is_no_ Assert.Equal(string.Empty, excluding.Offered.Single(row => row.Word == "driver").Meaning); } - - [Fact] - public void The_sentence_about_the_keys_is_the_one_under_the_list() - { - Assert.Equal(Texts.Of("gui.suggest.keys"), Fresh().Keys); - Assert.DoesNotContain("gui.", Fresh().Keys, StringComparison.Ordinal); - } } diff --git a/tests/Bws.Gui.Tests/SuggestingTypingTests.cs b/tests/Bws.Gui.Tests/SuggestingTypingTests.cs new file mode 100644 index 0000000..97578c3 --- /dev/null +++ b/tests/Bws.Gui.Tests/SuggestingTypingTests.cs @@ -0,0 +1,233 @@ +using System.Windows; +using System.Windows.Input; +using Bws.Core.Querying; +using Bws.Gui.ViewModels; + +namespace Bws.Gui.Tests; + +/// +/// The list under the search box follows TYPING, and Tab writes a word - the owner's two decisions +/// of 2026-09-25, `docs/PROJEKT-PODPOWIEDZI-UX-20260925.md` (T1 and P2), checked without a window. +/// +/// Its own file because both reverse something the first design settled - Tab walked on +/// (decision 7) and a caret move was followed like a keystroke - and the reasons belong beside the +/// tests that hold them rather than scattered through SuggestingTests. +/// +public sealed class SuggestingTypingTests +{ + private static Suggesting Typing(string text, int caret) + { + var suggesting = new MainViewModel(new LiveMachine(Rows.Entry("Spooler")), new SteppedClock()).Suggesting; + + suggesting.Keyboard(present: true); + suggesting.Follow(text, caret, 0); + + return suggesting; + } + + /// + /// One Left arrow in status:running opened a list of one row saying running - + /// measured on the core on 2026-09-25 - and correcting a query with the arrows blinked a list at + /// every press. A caret moving through text nobody changed closes the list and opens none. + /// + [Fact] + public void A_caret_moving_through_unchanged_text_opens_nothing_and_closes_what_was_open() + { + var suggesting = Typing("status:running", 14); + + Assert.False(suggesting.IsOpen); + + suggesting.Follow("status:running", 13, 0); + Assert.False(suggesting.IsOpen); + + suggesting.Follow("status:running", 7, 0); + Assert.False(suggesting.IsOpen); + + // Open by typing, then moved away from by the caret alone. + suggesting.Follow("status:r", 8, 0); + Assert.True(suggesting.IsOpen); + + suggesting.Follow("status:r", 7, 0); + Assert.False(suggesting.IsOpen); + + // Down still asks for it on purpose, wherever the caret stands. + Assert.True(suggesting.Ask("status:r", 7, 0)); + } + + /// + /// One keystroke is two events from the box - its text changed and its selection changed - and + /// the second one arrives with the same text at the same caret. It must leave the list the first + /// one opened, or every letter would open a list and close it again. + /// + [Fact] + public void The_second_event_of_one_keystroke_leaves_the_list_as_it_is() + { + var suggesting = Typing("sta", 3); + + Assert.Equal(["status", "start"], suggesting.Offered.Select(row => row.Word)); + + suggesting.Next(); + suggesting.Follow("sta", 3, 0); + + Assert.True(suggesting.IsOpen); + Assert.Equal("start", suggesting.Chosen!.Word); + } + + /// + /// The window writes a row through the selection and then puts the caret after it, so the last + /// thing the box reports is a caret move - which closes. Written is followed as typing, so the + /// values of a field just written are offered at once, and sta Tab Tab still gives + /// status:running. + /// + [Fact] + public void A_row_just_written_is_followed_as_typing_even_after_the_caret_moved() + { + var suggesting = Typing("sta", 3); + + // What the box reports while the window writes "status:" over "sta". + suggesting.Follow("sta", 0, 3); + suggesting.Follow("status:", 0, 7); + suggesting.Follow("status:", 7, 0); + + Assert.False(suggesting.IsOpen); + + suggesting.Wrote("status:", 7); + + Assert.True(suggesting.IsOpen); + Assert.Equal( + QueryCompletions.WhileTyping("status:", 7).Select(completion => completion.Word), + suggesting.Offered.Select(row => row.Word)); + Assert.Contains("running", suggesting.Offered.Select(row => row.Word)); + Assert.True(suggesting.TabWrites); + + // With the keyboard gone nothing opens - the rule every other road into the list keeps. + suggesting.Left(); + suggesting.Wrote("status:", 7); + + Assert.False(suggesting.IsOpen); + } + + /// + /// Tab writes a WORD and never a question. The questions open when somebody arrives at an empty + /// box - by Tab among other ways - so a Tab that wrote one would type a query into the box of + /// anybody walking through the window with the keyboard. + /// + [Fact] + public void Tab_writes_a_word_and_walks_past_the_questions_and_past_nothing() + { + Assert.True(Typing("sta", 3).TabWrites); + + var arriving = Typing(string.Empty, 0); + + arriving.Arrived(string.Empty); + + Assert.True(arriving.IsOpen); + Assert.False(arriving.TabWrites); + + Assert.False(Typing("spooler", 7).TabWrites); + } + + /// + /// Review of PR 22: nothing is written over a character an input method is still composing - + /// neither by Tab nor by Enter. The list stays open, and the keyboard leaving the box ends the + /// composition, so one abandoned without an end cannot keep the keys from writing afterwards. + /// + [Fact] + public void Nothing_is_taken_while_an_input_method_is_composing() + { + var suggesting = Typing("sta", 3); + + Assert.True(suggesting.CanTake); + Assert.True(suggesting.TabWrites); + + suggesting.Composing(open: true); + + Assert.True(suggesting.IsOpen); + Assert.False(suggesting.CanTake); + Assert.False(suggesting.TabWrites); + + suggesting.Composing(open: false); + + Assert.True(suggesting.TabWrites); + + suggesting.Composing(open: true); + suggesting.Left(); + suggesting.Keyboard(present: true); + suggesting.Follow("stat", 4, 0); + + Assert.True(suggesting.TabWrites); + } + + /// + /// The window's half: a composition opening in the box stops Tab and Enter from writing, and + /// its end lets them write again. Raised on the box as the input system raises them. + /// + [Fact] + public void The_window_hears_a_composition_open_in_the_box_and_close() + { + var window = WpfHost.Window(); + var model = WpfHost.On(() => (MainViewModel)window.DataContext); + + WpfHost.On(() => + { + model.Suggesting.Keyboard(present: true); + model.Suggesting.Ask("sta", 3, 0); + }); + + Assert.Equal([Shortcut.TakeSuggestion, Shortcut.TakeSuggestion], Presses(window)); + + WpfHost.On(() => Composed(window, TextCompositionManager.PreviewTextInputStartEvent)); + + Assert.Equal([Shortcut.None, Shortcut.None], Presses(window)); + + WpfHost.On(() => Composed(window, TextCompositionManager.PreviewTextInputEvent)); + + Assert.Equal([Shortcut.TakeSuggestion, Shortcut.TakeSuggestion], Presses(window)); + + WpfHost.On(window.Close); + } + + /// What Tab and Enter mean in the box, in that order. + private static Shortcut[] Presses(MainWindow window) => WpfHost.On(() => new[] + { + window.Wanted(Key.Tab, ModifierKeys.None, inTheBox: true, inTheGrid: false), + window.Wanted(Key.Enter, ModifierKeys.None, inTheBox: true, inTheGrid: false) + }); + + private static void Composed(MainWindow window, RoutedEvent routed) => + window.Search.Box.RaiseEvent( + new TextCompositionEventArgs(Keyboard.PrimaryDevice, new TextComposition(InputManager.Current, window.Search.Box, string.Empty)) + { + RoutedEvent = routed + }); + + /// + /// The sentence under the list names Tab for the words and leaves it out for the questions, + /// because there it walks on - `docs/PROJEKT-PODPOWIEDZI-UX-20260925.md`, P3. + /// + [Fact] + public void The_sentence_about_the_keys_names_Tab_only_where_Tab_writes() + { + var words = Typing("sta", 3); + + Assert.Equal(Texts.Of("gui.suggest.keys.words"), words.Keys); + Assert.Contains("Tab", words.Keys, StringComparison.Ordinal); + + var questions = Typing(string.Empty, 0); + + questions.Arrived(string.Empty); + + Assert.Equal(Texts.Of("gui.suggest.keys"), questions.Keys); + Assert.DoesNotContain("Tab", questions.Keys, StringComparison.Ordinal); + Assert.DoesNotContain("gui.", questions.Keys, StringComparison.Ordinal); + + // And the window is told when it changes, or the sentence bound under the list would keep + // saying the questions' keys over a list of words. + var announced = new List(); + + questions.PropertyChanged += (_, changed) => announced.Add(changed.PropertyName!); + questions.Follow("s", 1, 0); + + Assert.Contains(nameof(Suggesting.Keys), announced); + } +}