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