From f7d89341920336e3d18699a9605ce0b30ff467a6 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Tue, 11 Aug 2026 15:26:52 -0500 Subject: [PATCH 1/6] =?UTF-8?q?docs(plan):=20PR=203=20of=20the=20stack=20?= =?UTF-8?q?=E2=80=94=20=E2=8C=83F=20search=20across=20the=20panes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- .../plans/2026-08-11-pane-search.md | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-11-pane-search.md diff --git a/docs/superpowers/plans/2026-08-11-pane-search.md b/docs/superpowers/plans/2026-08-11-pane-search.md new file mode 100644 index 0000000..a04f407 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-pane-search.md @@ -0,0 +1,235 @@ +# PR 3 — `⌃F` search across the panes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan +> task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `⌃F` opens a modal results surface over the panes; typing filters the output the client is +holding, `⌥E` switches to regex, `⌥A` widens from the focused window to every window, `⏎` goes to a +hit and marks it with a bar, `⌥G` walks to the next one. + +**Architecture:** Matching is a pure Core function over plain text. The pane buffer carries that plain +text per line, computed once at append. The surface is the `HistorySearchPrompt`/`HistorySurface` split +verbatim — a pure class owning what a keystroke means and what the surface says, and a host owning +nothing but framework calls. Landing reuses the activity bar's machinery, which this PR finishes +extracting into two shared operations. + +**Tech Stack:** C# / .NET 10, SharpConsoleUI 2.5.14 (package only), TUnit. + +**Spec:** `docs/superpowers/specs/2026-08-11-pane-search-and-activity-design.md`, part 2. + +**Branch:** `feat/pane-search`, off `feat/window-activity-boundary`. Third of a three-PR stack. + +## Global Constraints + +- Target framework `net10.0`; file-scoped namespaces, 4-space C#, LF endings. +- `SharpMUTerm.Core` stays UI-agnostic — `OutputSearch` takes plain strings and knows nothing of panes. +- Run suites directly, never `dotnet test`, keep the ` Matches, string? Error); +public static class OutputSearch +{ + public const int MaxQueryLength = 200; + public static OutputSearchResult Match(IReadOnlyList lines, string query, bool regex); +} +``` + +**Decisions, all of which get a test:** + +- **Case-insensitive in both modes.** `HistorySearch` is (`OrdinalIgnoreCase`) and two search surfaces + in one client disagreeing about case would be a bug report. Regex mode says `(?-i)` inline when it + wants otherwise — a documented .NET feature rather than one we invented. +- **One match per line**, the first: the result is a list of *lines to go to*, and the offsets exist so + a row can show why it is listed. `HistorySearchPrompt.Row` does the same. +- **An empty query matches nothing**, unlike `HistorySearch`, where an empty query is the opening + chronological list. A pane buffer is thousands of lines and "everything, oldest first" is not a + result set anybody asked for; the surface says what to type instead. +- **An invalid pattern is a state.** `Error` carries the message, `Matches` is empty, nothing throws. A + regex is typed one character at a time, so most of the time a regex query is being typed it is + invalid. +- **A match timeout** (`TimeSpan.FromMilliseconds(100)`), and a timeout is an `Error` rather than an + exception: this runs on the UI thread on every keystroke, over every line of every window. +- **Order is oldest-first**, the buffer's own. The rows are a transcript, not a ranking, and the reader + is looking for a place in it. + +- [ ] **Step 1:** Write `OutputSearchTests` first — plain substring; case-insensitivity both ways; + `(?-i)` honoured in regex mode; a regex metacharacter treated *literally* in plain mode (`a.c` does + not match `abc`); empty query → no matches, no error; invalid pattern → empty + non-null `Error`; + `(a+)+$` against a long non-matching line → `Error` rather than a hang; over-long query rejected; + offsets point at the matched run; line indices are the caller's own. +- [ ] **Step 2:** Run: `dotnet run -c Release --project tests/SharpMUTerm.Core.Tests --treenode-filter "/*/*/OutputSearchTests/*" Render(IReadOnlyList rows, string query, string? error, bool regex, bool all, int held, int width = 0, int listRows = 0, int first = 0); + internal static int Scroll(int first, int selected, int count, int listRows); + internal static int MaxWidth(IReadOnlyList lines); +} +internal readonly record struct SearchRow(string WindowId, string WindowLabel, int LineIndex, string Text, int MatchStart, int MatchLength); +``` + +- `⌥E` and `⌥A` flip their flags and redraw. `⌃F` cancels (the toggle answers it in the running client; + spelt out here so a test can read the rule back). Escape cancels. `⏎` with nothing listed does + nothing and leaves the surface up — the query is what needs fixing. +- Printable characters filter; `⌫` un-filters; everything else is swallowed, because a modal that let + keys through would be typing into a command line the reader cannot see. +- The window column is drawn **only when `all`** — one window's results do not need a column saying + which window. +- The header carries the counted state and the searched bound: `12 of 38 · 4,812 lines held`. The bound + is on the frame rather than implied, because the search sees the pane buffer and not the session's + whole scrollback. +- An `Error` replaces the count with the message; the list is empty. +- **The footer names exactly the keys `Interpret` honours** — the honesty rule the settings screens and + the composer are held to, pinned by a test that presses every key it names and asserts each does + something. + +- [ ] **Step 1:** Write `SearchPromptTests` — one per rule above, plus the footer-honesty test. +- [ ] **Step 2:** Run, watch it fail. **Step 3:** Implement. **Step 4:** Re-run, PASS. **Step 5:** Commit. + +--- + +### Task 4: `SearchSurface`, the chord, and the scope + +**Files:** create `SearchSurface.cs`; modify `MacroKeys.cs`, `SharpMUTermApp.cs`; create +`tests/SharpMUTerm.Tui.Tests/SearchEndToEndTests.cs`. + +- `SearchSurface` is `HistorySurface` with a different prompt: same window construction, same + `PreviewKeyPressed` wiring, same `SimulateKey`/`SimulateTyping` seams, same size-once rule. +- It is handed **a function that returns the corpus** — for each searchable window, its id, its label + and its plain lines — read at the moment a key changes the query, so a hit's index is an index into + the buffer as it is *now*. +- `MacroKeys`: `new(ConsoleModifiers.Control, ConsoleKey.F, "searches the output")`, plus `⌥G` /`⌥⇧G`. + `ShortcutAction` gains the three arms. `⌃F` refuses over a settings screen or the composer and says + so (`ComposerIsInTheWay`), for the paste reason and because two modal `PreviewKeyPressed` handlers + cannot be driven headlessly. +- `AnyOverlayOpen` gains `_search.IsOpen`; `OpenOverlayName` gains "the search surface". +- Scope: focused window only, or every window in `_lines` except the web view (whose pane is not fed + from the buffer). The window label is `WindowTitle(id)`, `Snippet`-bounded — a window title can be a + *world's* text. + +- [ ] **Step 1:** `SearchEndToEndTests`: ⌃F opens it; typing lists hits from the focused window only; + `⌥A` brings in a background window's hits; `⌃F` again closes; ⌃F over an open settings screen refuses + and says so; nothing reaches the wire (a recording transport, `HistorySearchEndToEndTests`' shape). +- [ ] **Step 2:** Run, watch it fail. **Step 3:** Implement. **Step 4:** Whole Tui suite. **Step 5:** Commit. + +--- + +### Task 5: Landing — the bar, the jump, and `⌥G` + +**Files:** create `SearchBarRenderer.cs`; modify `SharpMUTermApp.cs`; extend `SearchEndToEndTests`. + +- `SearchBarRenderer.Bar(query, ordinal, total, accentHex)` → `⌕ goblin (12 of 38) ─── ⌥G next ──`, + fourth of the boundary bars, `Glyphs`-based, pure, unit-tested. The query is `MarkupText.Escape`d — + it is user text going into markup. +- **The chrome-row extraction the spec assigns to whichever PR needs it second.** There are now two + kinds of inserted row, so `InsertChromeRow(windowId, at, markup)` and `RemoveChromeRow(windowId, at)` + become the one place that fixes up everything indexing into a buffer: the freeze point, the pending + boundary, the away mark, and the search mark. `RemoveAwayBar` and the trim block call through them. +- `⏎` → `Activate(windowId)` (the one activation path — it selects the pane, raises the tab, adopts the + session), then insert the bar above the hit, `RepaintPane`, and reveal with the *measured* tail + height (`RevealAwayBar`'s arithmetic, generalised: a buffer index is not a viewport row). +- **One search bar client-wide**; `⌥G` moves it to the next hit, wrapping, and re-activates that + window. `⌥⇧G` goes back. With no search yet, both refuse out loud. +- **Not cleared by `Esc`.** A claimed Escape does not set `_escapeAt`, and `TryAltEnter` pairs an + unclaimed one with a following Enter to make `Alt+⏎`. Binding Escape here would break the newline + chord for as long as a search bar was on screen — a defect nobody would connect to search. It goes on + the next search, the next `⌃F`, or a trim that takes it. + +- [ ] **Step 1:** Tests — `⏎` on a hit in a *background* window activates that window (not the focused + one) and leaves the bar directly above the hit; the bar names the ordinal; `⌥G` moves it and wraps; + `⌥G` with no search refuses; a trim that passes the bar drops it; `Esc` in a pane leaves it alone and + `Alt+⏎` still makes a newline with a bar up. +- [ ] **Step 2:** Run, watch it fail. **Step 3:** Implement. **Step 4:** Whole Tui suite. **Step 5:** Commit. + +--- + +### Task 6: Frames, brief, PR + +- [ ] **Step 1:** Snapshot views `search`, `search-regex`, `search-all`, `search-landed` (the last over + a **split**, so the pane is narrower than the terminal — the geometry that catches a reveal landing at + the wrong row). Drive real keys through the surface's own handler, as `history-search-filter` does. +- [ ] **Step 2:** `dotnet build -c Release SharpMUTerm.slnx`, render each, look at the `.html`. +- [ ] **Step 3:** CLAUDE.md: the search entry (corpus, the plain-text field, the scheme-free bar, the + scope rule, the Escape reasoning), the new chords, and the four views. +- [ ] **Step 4:** Five suites green, build warning-free. +- [ ] **Step 5:** Push; `gh pr create --base feat/window-activity-boundary`. + +## Self-review notes + +- **Spec coverage:** part 2 in full — corpus and its bound, plain text at append, the Core matcher and + its five decisions, the surface and its footer, the scope toggle, the landing bar, `⌥G`. +- **Deliberately not here:** searching the file-backed spill or a session's `WorldSession.Scrollback`. + The spec rules both out — a spawn window's lines exist in neither — and the surface states the bound + it does search rather than implying a bigger one. From f632dc6f006585cd2d5973abbe9807c9436b9e13 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Tue, 11 Aug 2026 15:28:39 -0500 Subject: [PATCH 2/6] =?UTF-8?q?feat(search):=20OutputSearch=20=E2=80=94=20?= =?UTF-8?q?what=20=E2=8C=83F=20matches,=20in=20Core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain text in, matching lines out: nothing here knows about panes, markup or windows, which is what keeps it in Core and what makes a match mean what it looks like. A colour tag mid-word must not split a match, and nobody should be able to search for #ff0000 and find every red line. Five decisions, each pinned: case ignored in both modes (HistorySearch already is, and (?-i) is the way back, which is why there is no third toggle); one match per line, the first, with offsets so a row can show why it is listed; an empty query matches nothing, unlike history, because a pane buffer is thousands of lines and "everything, oldest first" is the pane you are already looking at; an invalid pattern is a state, because a regex is unparseable most of the time it is being typed; and a match timeout, because this runs on the UI thread on every keystroke over every line of every window. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- src/SharpMUTerm.Core/Text/OutputSearch.cs | 149 +++++++++++++++ .../OutputSearchTests.cs | 171 ++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 src/SharpMUTerm.Core/Text/OutputSearch.cs create mode 100644 tests/SharpMUTerm.Core.Tests/OutputSearchTests.cs diff --git a/src/SharpMUTerm.Core/Text/OutputSearch.cs b/src/SharpMUTerm.Core/Text/OutputSearch.cs new file mode 100644 index 0000000..f72ec68 --- /dev/null +++ b/src/SharpMUTerm.Core/Text/OutputSearch.cs @@ -0,0 +1,149 @@ +using System.Text.RegularExpressions; + +namespace SharpMUTerm.Core.Text; + +/// +/// One line the search surface lists: where it sits in the buffer it came from, its text, and where the +/// query landed inside it. +/// +/// Its index in the list handed to . +/// The line, verbatim. +/// Where the query matched. +/// How long the matched run is. +public readonly record struct OutputMatch(int LineIndex, string Text, int MatchStart, int MatchLength); + +/// +/// What one search came to: the lines it found, and — when the query could not be used at all — why. +/// +/// The matching lines, in the buffer's own order. +/// Why nothing could be searched for, or null when the query was usable. +public readonly record struct OutputSearchResult(IReadOnlyList Matches, string? Error); + +/// +/// Finds a query in a window's output. The matching half of ⌃F, and the whole of it that can be +/// reasoned about without a terminal. +/// +/// It takes plain text. Nothing here knows about panes, markup or windows: the caller strips its +/// own lines and hands over strings. That is what keeps this in Core, and it is also the rule that makes +/// a match mean what it looks like — a colour tag in the middle of a word must not split a match, and a +/// reader must not be able to search for #ff0000 and find every red line. +/// +/// +/// Case is ignored, in both modes. ignores it too, and two +/// search surfaces in one client disagreeing about case is a bug report waiting to happen. Regex mode +/// says (?-i) inline when it wants otherwise, which is a documented .NET feature rather than one +/// invented here — and is why there is no third toggle on the surface. +/// +/// +/// One match per line, the first. The result is a list of lines to go to; the offsets +/// exist so a row can show why it is listed, exactly as HistorySearchPrompt.Row uses them. +/// +/// +/// An empty query matches nothing, which is where this parts company with +/// : there an empty query is the opening chronological list, and a +/// command history is short enough to be one. A pane buffer is thousands of lines, and "everything, +/// oldest first" is not a result set anybody asked for — it is the pane they are already looking at. +/// +/// +public static class OutputSearch +{ + /// + /// The longest query this will look for. A search box is not a text editor, and an unbounded query + /// is an unbounded pattern compiled on every keystroke. + /// + public const int MaxQueryLength = 200; + + /// + /// How long one regex is given against one line. This runs on the UI thread, on every keystroke, + /// over every line of every window — so a pattern that backtracks catastrophically has to come back + /// as an error rather than wedge the client. + /// + private static readonly TimeSpan MatchTimeout = TimeSpan.FromMilliseconds(100); + + /// + /// The lines of matching , in the order they were + /// given — the buffer's own, because these rows are a transcript rather than a ranking and the reader + /// is looking for a place in it. + /// + /// The plain text of each line, oldest first. + /// What to look for; empty finds nothing. + /// Whether is a pattern rather than literal text. + public static OutputSearchResult Match(IReadOnlyList lines, string query, bool regex) + { + ArgumentNullException.ThrowIfNull(lines); + ArgumentNullException.ThrowIfNull(query); + + if (query.Length == 0) + { + return new OutputSearchResult(Array.Empty(), null); + } + + if (query.Length > MaxQueryLength) + { + return new OutputSearchResult( + Array.Empty(), $"query is longer than {MaxQueryLength} characters"); + } + + return regex ? ByPattern(lines, query) : ByText(lines, query); + } + + private static OutputSearchResult ByText(IReadOnlyList lines, string query) + { + var matches = new List(); + for (var index = 0; index < lines.Count; index++) + { + var text = lines[index] ?? string.Empty; + var at = text.IndexOf(query, StringComparison.OrdinalIgnoreCase); + if (at >= 0) + { + matches.Add(new OutputMatch(index, text, at, query.Length)); + } + } + + return new OutputSearchResult(matches, null); + } + + private static OutputSearchResult ByPattern(IReadOnlyList lines, string query) + { + Regex pattern; + try + { + pattern = new Regex(query, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, MatchTimeout); + } + catch (ArgumentException error) + { + // The whole of what "invalid pattern" means here. A regex is typed one character at a time, + // so most of the time a regex query is being typed it is unparseable; the surface says so + // and lists nothing, which is a state rather than a failure. + return new OutputSearchResult(Array.Empty(), error.Message); + } + + var matches = new List(); + for (var index = 0; index < lines.Count; index++) + { + var text = lines[index] ?? string.Empty; + try + { + var found = pattern.Match(text); + + // A zero-width match marks nothing, so it lists nothing: `x*` matches at position 0 of + // every line in the buffer, and a result set of "everything, highlighted nowhere" is + // worse than no result at all. + if (found.Success && found.Length > 0) + { + matches.Add(new OutputMatch(index, text, found.Index, found.Length)); + } + } + catch (RegexMatchTimeoutException) + { + // Reported against the whole search rather than skipping the line: a pattern that can + // take this long on one line will take it on the next, and a partial result set that + // silently omitted the expensive lines would be a search lying about what it found. + return new OutputSearchResult( + Array.Empty(), "pattern took too long — try a simpler one"); + } + } + + return new OutputSearchResult(matches, null); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/OutputSearchTests.cs b/tests/SharpMUTerm.Core.Tests/OutputSearchTests.cs new file mode 100644 index 0000000..b1e3cc8 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/OutputSearchTests.cs @@ -0,0 +1,171 @@ +using SharpMUTerm.Core.Text; + +namespace SharpMUTerm.Core.Tests; + +/// +/// What ⌃F matches. The rules are few and each one is a decision the surface above it depends on, so +/// they are pinned here rather than inferred from the surface's behaviour. +/// +public class OutputSearchTests +{ + private static readonly string[] Lines = + { + "The goblin snarls at you.", + " Ana: Goblin room is bugged", + "You hit the goblin for 12 damage.", + "A town guard stands watch by the northern gate.", + }; + + [Test] + public async Task APlainQueryFindsEveryLineHoldingIt() + { + var result = OutputSearch.Match(Lines, "goblin", regex: false); + + await Assert.That(result.Error).IsNull(); + await Assert.That(result.Matches.Select(m => m.LineIndex)).IsEquivalentTo(new[] { 0, 1, 2 }); + } + + /// + /// Case-insensitive, which is what HistorySearch does — two search surfaces in one client + /// disagreeing about case is a bug report waiting to happen. + /// + [Test] + public async Task CaseIsIgnoredInBothModes() + { + await Assert.That(OutputSearch.Match(Lines, "GOBLIN", regex: false).Matches.Count).IsEqualTo(3); + await Assert.That(OutputSearch.Match(Lines, "GOBLIN", regex: true).Matches.Count).IsEqualTo(3); + } + + /// + /// The way back for a reader who wants case to matter: an inline option, which is a documented .NET + /// feature rather than one this client invented. It is why there is no third toggle on the surface. + /// + [Test] + public async Task RegexModeHonoursAnInlineCaseOption() + { + var result = OutputSearch.Match(Lines, "(?-i)Goblin", regex: true); + + await Assert.That(result.Matches.Select(m => m.LineIndex)).IsEquivalentTo(new[] { 1 }); + } + + /// + /// Plain mode is plain: a query full of metacharacters is text, not a pattern. Anything else and a + /// reader searching for $5.00 or (OOC) gets a silent misfire or an error they did not + /// ask for. + /// + [Test] + public async Task PlainModeTreatsMetacharactersLiterally() + { + var lines = new[] { "abc", "a.c" }; + + var result = OutputSearch.Match(lines, "a.c", regex: false); + + await Assert.That(result.Matches.Select(m => m.LineIndex)).IsEquivalentTo(new[] { 1 }); + } + + [Test] + public async Task RegexModeMatchesAsAPattern() + { + var result = OutputSearch.Match(Lines, @"\d+ damage", regex: true); + + await Assert.That(result.Matches.Select(m => m.LineIndex)).IsEquivalentTo(new[] { 2 }); + } + + /// + /// An empty query matches nothing, which is where this parts company with HistorySearch: there + /// an empty query is the opening chronological list, and a command history is short. A pane buffer is + /// thousands of lines, and "everything, oldest first" is not a result set anybody asked for. + /// + [Test] + public async Task AnEmptyQueryMatchesNothingAndIsNotAnError() + { + var result = OutputSearch.Match(Lines, string.Empty, regex: false); + + await Assert.That(result.Matches).IsEmpty(); + await Assert.That(result.Error).IsNull(); + } + + /// + /// An invalid pattern is a state, not an exception. A regex is typed one character at a time, so + /// most of the time a regex query is being typed it is invalid — throwing, or listing stale results, + /// are both worse than saying so. + /// + [Test] + public async Task AnInvalidPatternIsReportedRatherThanThrown() + { + var result = OutputSearch.Match(Lines, "goblin(", regex: true); + + await Assert.That(result.Error).IsNotNull(); + await Assert.That(result.Matches).IsEmpty(); + } + + /// + /// The same characters in plain mode are a query, not a pattern, so they cannot be invalid. + /// + [Test] + public async Task ThatSameQueryIsFineInPlainMode() + { + await Assert.That(OutputSearch.Match(new[] { "goblin(x)" }, "goblin(", regex: false).Error).IsNull(); + } + + /// + /// This runs on the UI thread, on every keystroke, over every line of every window. A pattern that + /// backtracks catastrophically must come back as an error rather than wedge the client. + /// + [Test] + public async Task ARunawayPatternTimesOutIntoAnError() + { + var lines = new[] { new string('a', 4000) + "b" }; + + var result = OutputSearch.Match(lines, "(a+)+$", regex: true); + + await Assert.That(result.Error).IsNotNull(); + await Assert.That(result.Matches).IsEmpty(); + } + + [Test] + public async Task AnOverLongQueryIsRefused() + { + var result = OutputSearch.Match(Lines, new string('x', OutputSearch.MaxQueryLength + 1), regex: false); + + await Assert.That(result.Error).IsNotNull(); + await Assert.That(result.Matches).IsEmpty(); + } + + /// + /// One match per line, the first, and its offsets — the result is a list of lines to go to, + /// and the offsets are there so a row can show why it is listed. + /// + [Test] + public async Task AMatchCarriesTheLineAndWhereTheQueryLandedInIt() + { + var result = OutputSearch.Match(new[] { "You hit the goblin, and the goblin falls." }, "goblin", regex: false); + + var match = result.Matches.Single(); + await Assert.That(match.Text).IsEqualTo("You hit the goblin, and the goblin falls."); + await Assert.That(match.MatchStart).IsEqualTo(12); + await Assert.That(match.MatchLength).IsEqualTo(6); + } + + /// + /// Oldest first, the buffer's own order. The rows are a transcript rather than a ranking, and the + /// reader is looking for a place in it. + /// + [Test] + public async Task MatchesKeepTheBuffersOwnOrder() + { + var result = OutputSearch.Match(Lines, "the", regex: false); + + await Assert.That(result.Matches.Select(m => m.LineIndex).ToArray()) + .IsEquivalentTo(result.Matches.Select(m => m.LineIndex).OrderBy(i => i).ToArray()); + } + + /// A zero-width regex match must not produce a row claiming to mark nothing. + [Test] + public async Task AZeroWidthPatternMatchesNoLines() + { + var result = OutputSearch.Match(Lines, "x*", regex: true); + + await Assert.That(result.Matches).IsEmpty(); + } +} From b56b42aab284bf6d45016391e23a9958f4c3de05 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Tue, 11 Aug 2026 15:30:51 -0500 Subject: [PATCH 3/6] =?UTF-8?q?feat(search):=20the=20pane=20buffer=20carri?= =?UTF-8?q?es=20the=20text=20=E2=8C=83F=20will=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MarkupText.Plain is what a markup line actually puts on the screen, and PaneLine holds it beside the markup, computed once at append. On demand would restrip every line of every window on every keystroke of a query, and an incremental surface is the whole point. Plain rather than markup because a match has to mean what it looks like: a world may change colour mid-word, and matching the markup would find neither half — the same defect a URL split by a colour change has, one layer down — while a query for #ff0000 must not find every red line. Chrome rows carry no plain text, so a search cannot find its own boundary bars. Plain and VisibleLength are pinned against each other over a table of inputs: they share a protect-then-strip shape, and a divergence would put a match's offsets in a different coordinate system from the width renderers measure with. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- src/SharpMUTerm.Tui/MarkupText.cs | 24 +++++++ src/SharpMUTerm.Tui/PaneLine.cs | 16 ++++- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 4 +- .../MarkupTextPlainTests.cs | 64 +++++++++++++++++++ 4 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 tests/SharpMUTerm.Tui.Tests/MarkupTextPlainTests.cs diff --git a/src/SharpMUTerm.Tui/MarkupText.cs b/src/SharpMUTerm.Tui/MarkupText.cs index a2f7244..892b185 100644 --- a/src/SharpMUTerm.Tui/MarkupText.cs +++ b/src/SharpMUTerm.Tui/MarkupText.cs @@ -25,6 +25,30 @@ internal static int VisibleLength(string markup) return TagPattern.Replace(protectedText, string.Empty).Length; } + /// + /// The text a markup string actually puts on the screen: [tag] wrappers removed, and escaped + /// brackets ([[/]]) back to the single characters they stand for. + /// + /// This is what ⌃F searches, and it searches this rather than the markup because a match has to mean + /// what it looks like: a colour tag in the middle of a word must not split one, and a reader must not + /// be able to search for #ff0000 and find every red line. The same rule UrlDetector + /// follows one layer down — run over the line, never over its pieces. + /// + /// + /// It shares 's protect-then-strip shape deliberately, and the two are + /// held together by test: Plain(x).Length equals VisibleLength(x) for every input. A + /// divergence would put a match's offsets in a different coordinate system from the width every + /// renderer measures with. + /// + /// + internal static string Plain(string markup) + { + var protectedText = markup.Replace("[[", "\u0001").Replace("]]", "\u0002"); + return TagPattern.Replace(protectedText, string.Empty) + .Replace('\u0001', '[') + .Replace('\u0002', ']'); + } + /// Pads a markup string to a target *visible* column width, ignoring markup tags. internal static string PadVisible(string markup, int width) { diff --git a/src/SharpMUTerm.Tui/PaneLine.cs b/src/SharpMUTerm.Tui/PaneLine.cs index 7fe13e8..dbeeaf6 100644 --- a/src/SharpMUTerm.Tui/PaneLine.cs +++ b/src/SharpMUTerm.Tui/PaneLine.cs @@ -32,4 +32,18 @@ namespace SharpMUTerm.Tui; /// /// The line's own Spectre-style markup, with no gutter attached. /// When the line arrived, pre-formatted for the gutter, or null for no gutter. -internal readonly record struct PaneLine(string Markup, string? Stamp = null); +/// +/// The text the markup puts on the screen () — what ⌃F searches. +/// +/// Held rather than derived, because the search surface refilters on every keystroke over every line of +/// every window: stripping thousands of lines eight times while somebody types a word is the difference +/// between a search that feels instant and one that does not. The cost is one string per buffered line, +/// bounded by the same cap the buffer already has. +/// +/// +/// Empty for the client's own chrome — the away and search bars, which are inserted into the +/// buffer rather than appended through it. A search that could find its own boundary markers would let +/// ⌥G walk between them, and they are not output. +/// +/// +internal readonly record struct PaneLine(string Markup, string? Stamp = null, string Plain = ""); diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index c11f360..ea338c0 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -2403,7 +2403,9 @@ private void AppendWindowLine(string windowId, string markup, string? stamp = nu _missedFrom[windowId] = buffer.Count; } - buffer.Add(new PaneLine(markup, stamp)); + // The plain text is computed here, once, and not on demand: ⌃F refilters on every keystroke over + // every line of every window (see PaneLine.Plain). + buffer.Add(new PaneLine(markup, stamp, MarkupText.Plain(markup))); // Cap the UI-side buffer at the configured scrollback so a long session doesn't grow without diff --git a/tests/SharpMUTerm.Tui.Tests/MarkupTextPlainTests.cs b/tests/SharpMUTerm.Tui.Tests/MarkupTextPlainTests.cs new file mode 100644 index 0000000..b391d99 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/MarkupTextPlainTests.cs @@ -0,0 +1,64 @@ +using SharpMUTerm.Tui; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// — the text a markup line actually puts on the screen, and what ⌃F +/// searches. Its offsets are the ones the surface marks a row's match with, so they have to be in the +/// same coordinate system as the width every renderer measures with. +/// +public class MarkupTextPlainTests +{ + [Test] + public async Task TagsAreRemovedAndTheTextIsLeftAlone() + { + await Assert.That(MarkupText.Plain("[bold #ff0000]The goblin[/] snarls.")) + .IsEqualTo("The goblin snarls."); + } + + /// + /// The point of searching this rather than the markup: a world may change colour mid-word, and a + /// query for the word has to find it. Same defect as a URL split by a colour change, one layer down. + /// + [Test] + public async Task AColourChangeInsideAWordDoesNotSplitIt() + { + await Assert.That(MarkupText.Plain("gob[#00ff00]lin[/]")).IsEqualTo("goblin"); + } + + /// And the other half: a tag's own text is not searchable. + [Test] + public async Task ATagsContentsAreNotPartOfTheText() + { + await Assert.That(MarkupText.Plain("[#ff0000]red[/]")).DoesNotContain("ff0000"); + } + + [Test] + public async Task EscapedBracketsComeBackAsTheOneCharacterTheyStandFor() + { + await Assert.That(MarkupText.Plain("[[OOC]] Ana: hello")).IsEqualTo("[OOC] Ana: hello"); + } + + [Test] + public async Task ALinkSpanKeepsItsVisibleTextAndLosesItsTarget() + { + await Assert.That(MarkupText.Plain("see [link=https://example.com/map]the map[/] here")) + .IsEqualTo("see the map here"); + } + + /// + /// The invariant that keeps a match's offsets meaningful: this and + /// must agree on every input, or a row would mark a run at a column the renderer measures differently. + /// + [Test] + [Arguments("plain text")] + [Arguments("[bold]bold[/] and [dim]dim[/]")] + [Arguments("[[escaped]] and [#00ff00]coloured[/]")] + [Arguments("[link=https://example.com]a link[/]")] + [Arguments("")] + [Arguments("[dim]12:04[/] [bold]0001[/] · the courier's road")] + public async Task ItAgreesWithVisibleLength(string markup) + { + await Assert.That(MarkupText.Plain(markup).Length).IsEqualTo(MarkupText.VisibleLength(markup)); + } +} From 34bf9760acfb74f3ffb8650b7629bfaff49ad2f0 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Tue, 11 Aug 2026 15:33:12 -0500 Subject: [PATCH 4/6] =?UTF-8?q?feat(search):=20SearchPrompt=20=E2=80=94=20?= =?UTF-8?q?what=20=E2=8C=83F=20means=20by=20a=20keystroke,=20and=20what=20?= =?UTF-8?q?it=20says?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HistorySearchPrompt/HistorySurface split verbatim: the rules and the wording are exactly the part a headless test can pin. ⌥E and ⌥A both re-search from the top, because each changes what the list is and a pointer kept at row 12 of a different result set points at nothing the reader chose. The header states the bound it searched — '12 found · 4,812 lines held' — because ⌃F sees the pane buffer and not a session's whole history, and a reader who cannot find an old line should be able to see why rather than concluding the search is broken. The window column is drawn only when every window is searched; with one window it would be the same word on every row. The footer names only keys that work, pinned by a test that presses every one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- src/SharpMUTerm.Tui/SearchPrompt.cs | 334 ++++++++++++++++++ .../SearchPromptTests.cs | 238 +++++++++++++ 2 files changed, 572 insertions(+) create mode 100644 src/SharpMUTerm.Tui/SearchPrompt.cs create mode 100644 tests/SharpMUTerm.Tui.Tests/SearchPromptTests.cs diff --git a/src/SharpMUTerm.Tui/SearchPrompt.cs b/src/SharpMUTerm.Tui/SearchPrompt.cs new file mode 100644 index 0000000..da1f5c8 --- /dev/null +++ b/src/SharpMUTerm.Tui/SearchPrompt.cs @@ -0,0 +1,334 @@ +using static SharpMUTerm.Tui.MarkupText; +using static SharpMUTerm.Tui.ScreenPalette; + +namespace SharpMUTerm.Tui; + +/// What one keystroke means to the open search surface. +internal enum SearchAction +{ + /// Nothing. The key is swallowed and the surface stays exactly as it is. + None, + + /// The query, a toggle or the pointed-at row changed; re-search and redraw. + Redraw, + + /// Go to the pointed-at line: activate its window, mark it, and close. + Go, + + /// Close, changing nothing. + Cancel, +} + +/// The answer to one keystroke: what to do, and the state it leaves behind. +internal readonly record struct SearchDecision( + SearchAction Action, string Query, int Selected, bool Regex, bool AllWindows); + +/// +/// One row of results: which window the line is in, where it sits in that window's buffer, and where the +/// query landed inside it. +/// +internal readonly record struct SearchRow( + string WindowId, string WindowLabel, int LineIndex, string Text, int MatchStart, int MatchLength); + +/// +/// The search surface (⌃F), minus the window: what a keystroke means to it, and what it says. Pure, for +/// the same reason is — the rules and the wording are exactly the part +/// a headless test can pin, and is left with nothing but framework calls. +/// +/// A results surface and not an in-pane bar. A less-style bar under the pane has nowhere to +/// say which pane a hit is in, and searching more than one window is half of what was asked for. +/// This shape also inherits ⌃R's interaction, which the client already teaches. +/// +/// +/// The header states the bound it searched. ⌃F sees the lines the client is holding — the pane +/// buffer — and not a session's whole scrollback or the file-backed spill, because a spawn window's lines +/// exist in neither. So the count reads 12 of 38 · 4,812 lines held: a reader who cannot find +/// something from an hour ago can see why on the frame rather than guessing. +/// +/// +/// The footer names only keys that work. ↑↓ move (wrapping, as ⌃P's and ⌃R's do), ⏎ goes, ⌥E +/// switches regex on and off, ⌥A widens to every window, Esc cancels and ⌃F closes. Printable characters +/// filter and ⌫ un-filters. Nothing else is bound, so nothing else is advertised — see +/// SearchPromptTests, which presses every key this string names. +/// +/// +internal static class SearchPrompt +{ + /// + /// The keys, and the whole set of them. Every one is honoured by . ⌫ is + /// deliberately absent for 's reason: it is named on the + /// no matches line instead, which is the only moment a reader needs telling that the query can + /// be widened. + /// + internal const string Hints = + "type to search · ↑↓ pick · ⏎ go · ⌥E regex · ⌥A all windows · Esc cancel · ⌃F closes"; + + /// The longest an entry is drawn before it is elided, when no width is supplied. + private const int DefaultEntryWidth = 72; + + /// + /// What one keystroke does, and the state it leaves behind. is how many rows + /// are listed, so the pointer wraps within what is actually on screen. + /// + /// Anything unrecognised is swallowed rather than passed down: a modal surface that let stray keys + /// through would be typing into the command line the reader cannot currently see. + /// + /// + internal static SearchDecision Interpret( + ConsoleKeyInfo key, string query, int selected, int count, bool regex, bool all) + { + var state = new SearchDecision(SearchAction.None, query, selected, regex, all); + + if (key.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + // ⌃F is spelled out here as well as in the surface's toggle, for QuitPrompt's reason: this is + // where "what does this keystroke mean" is answered, and a rule living only in the app's + // shortcut table could not be read back by a test. In the running client a global shortcut + // runs before any window, so the chord never reaches this handler — the toggle answers it, + // the same way. + return key.Key == ConsoleKey.F ? state with { Action = SearchAction.Cancel } : state; + } + + if (key.Modifiers.HasFlag(ConsoleModifiers.Alt)) + { + // The two toggles. Both re-search from the top, because both change what the list *is* — a + // pointer kept at row 12 of a different result set points at nothing the reader chose. + return key.Key switch + { + ConsoleKey.E => state with { Action = SearchAction.Redraw, Regex = !regex, Selected = 0 }, + ConsoleKey.A => state with { Action = SearchAction.Redraw, AllWindows = !all, Selected = 0 }, + _ => state, + }; + } + + switch (key.Key) + { + case ConsoleKey.Escape: + return state with { Action = SearchAction.Cancel }; + + case ConsoleKey.Enter: + // With nothing listed there is nowhere to go, so ⏎ does nothing and leaves the surface + // up: the query is what needs fixing, and Esc is the advertised way out. + return count == 0 ? state : state with { Action = SearchAction.Go }; + + case ConsoleKey.UpArrow: + return state with { Action = SearchAction.Redraw, Selected = Step(selected, -1, count) }; + + case ConsoleKey.DownArrow: + return state with { Action = SearchAction.Redraw, Selected = Step(selected, 1, count) }; + + case ConsoleKey.Backspace: + // The pointer goes back to the first match: widening the query changes what row 0 is. + return query.Length == 0 + ? state + : state with { Action = SearchAction.Redraw, Query = query[..^1], Selected = 0 }; + } + + // Typing searches. Control characters are excluded so an undecoded sequence cannot end up in the + // query, and Tab (which arrives as one) is swallowed rather than becoming whitespace. + if (!char.IsControl(key.KeyChar) && key.KeyChar != '\0') + { + return state with { Action = SearchAction.Redraw, Query = query + key.KeyChar, Selected = 0 }; + } + + return state; + } + + /// Moves the pointer, wrapping — the same behaviour ⌃P's and ⌃R's lists have. + private static int Step(int selected, int delta, int count) => + count == 0 ? -1 : ((selected + delta) % count + count) % count; + + /// + /// The whole surface as markup lines: the query line with its state, the rows, and the keys. + /// + /// is how many rows the list area holds and which + /// match is at the top of it, which is what makes this a viewport rather than a dump. The area is + /// always drawn at full height — padded with blank rows — so the footer does not walk up and down the + /// screen on every keystroke. + /// + /// + /// The matches, in buffer order. + /// The query as typed so far. + /// Why the query could not be used, or null. + /// Whether the query is being read as a pattern. + /// Whether every window is being searched, rather than the focused one. + /// What the focused window is called, for the one-window case. + /// How many lines were searched — the bound, stated rather than implied. + /// Which row the pointer is on. + /// The surface's content width, when known. + /// How many rows the list area holds; zero means "as many as there are". + /// Which match is drawn at the top of the list area. + internal static List Render( + IReadOnlyList rows, + string query, + string? error, + bool regex, + bool all, + string scope, + int held, + int selected, + int width = 0, + int listRows = 0, + int first = 0) + { + ArgumentNullException.ThrowIfNull(rows); + ArgumentNullException.ThrowIfNull(query); + + var labelWidth = all ? rows.Select(r => VisibleLength(Escape(r.WindowLabel))).DefaultIfEmpty(0).Max() : 0; + var entryWidth = (width > 0 ? width : DefaultEntryWidth) - 4 - (labelWidth > 0 ? labelWidth + 2 : 0); + + var lines = new List + { + SpreadLR($"[{Label}]search[/] {QueryMarkup(query)}", $"[{Label}]{Escape(State(regex, all, scope))}[/]", width), + SpreadLR(string.Empty, $"[{Label}]{Escape(Counted(rows.Count, error, held))}[/]", width), + }; + + var body = new List(); + if (error is not null) + { + body.Add($"[{Muted}] {Escape(error)}[/]"); + } + else if (query.Length == 0) + { + body.Add($"[{Muted}] type to search {Escape(all ? "every window" : scope)}[/]"); + } + else if (rows.Count == 0) + { + body.Add($"[{Muted}] no matches — ⌫ widens the query[/]"); + } + + var top = Math.Clamp(first, 0, Math.Max(0, rows.Count - 1)); + var last = listRows > 0 ? Math.Min(rows.Count, top + listRows - body.Count) : rows.Count; + for (var i = top; i < last; i++) + { + body.Add(Row(rows[i], i == selected, entryWidth, labelWidth, width)); + } + + while (body.Count < listRows) + { + body.Add(string.Empty); + } + + lines.AddRange(body); + lines.Add(string.Empty); + lines.Add($"[{Label}]{Hints}[/]"); + return lines; + } + + /// + /// Where the list area's top must sit for to be inside it, moving as + /// little as possible from . + /// + internal static int Scroll(int first, int selected, int count, int listRows) + { + if (listRows <= 0 || count <= listRows || selected < 0) + { + return 0; + } + + var top = Math.Clamp(first, selected - listRows + 1, selected); + return Math.Clamp(top, 0, count - listRows); + } + + /// The visible width of the widest rendered line — used to size the surface to its content. + internal static int MaxWidth(IReadOnlyList lines) + { + var max = 0; + foreach (var line in lines) + { + max = Math.Max(max, VisibleLength(line)); + } + + return max; + } + + /// + /// The two toggles and what they currently mean, in words rather than in glyphs: the surface has to + /// be able to say which way they are set, because both change what a query finds and neither + /// is visible in the results themselves. + /// + private static string State(bool regex, bool all, string scope) => + $"{(regex ? "regex" : "text")} · {(all ? "every window" : scope)}"; + + /// + /// How many lines matched out of how many were looked at. The second figure is the bound this search + /// actually had — the pane buffer, not a session's whole history — and it is on the frame so a reader + /// who cannot find an old line can see why rather than concluding the search is broken. + /// + private static string Counted(int shown, string? error, int held) + { + var searched = $"{held:n0} line{(held == 1 ? string.Empty : "s")} held"; + return error is not null ? searched : $"{shown} found · {searched}"; + } + + /// + /// The query, with the accent block caret after it — the same caret the settings screens draw on an + /// open field, so this reads as something being typed into. + /// + private static string QueryMarkup(string query) => + query.Length == 0 + ? $"[{Ink} on {Accent}] [/]" + : $"[{Value}]{Escape(query)}[/][{Ink} on {Accent}] [/]"; + + private static string Row(SearchRow row, bool selected, int entryWidth, int labelWidth, int width) + { + var (text, matchStart, matchLength) = Elide(row, entryWidth); + var label = labelWidth > 0 + ? Escape(row.WindowLabel).PadRight(labelWidth) + " " + : string.Empty; + + if (selected) + { + // One continuous accent bar across the row, padded to the surface width. The matched run is + // not separately marked here: it would be a highlight inside a highlight, and the pointer is + // the fact this row is drawing. + var body = $" ▸ {label}{Escape(text)} "; + var pad = width > VisibleLength(body) ? new string(' ', width - VisibleLength(body)) : string.Empty; + return $"[{Ink} on {Accent}]{body}{pad}[/]"; + } + + var prefix = labelWidth > 0 ? $"[{Muted}] {label}[/]" : " "; + if (matchStart < 0 || matchLength == 0) + { + return $"{prefix}[{Value}]{Escape(text)}[/]"; + } + + // Mark where the query matched, so a row shows *why* it is in the list. + var before = Escape(text[..matchStart]); + var hit = Escape(text.Substring(matchStart, matchLength)); + var after = Escape(text[(matchStart + matchLength)..]); + return $"{prefix}[{Value}]{before}[/][bold {Accent}]{hit}[/][{Value}]{after}[/]"; + } + + /// + /// Shortens an over-long line to the surface's width, keeping the matched run visible: a pose is + /// hundreds of cells long, and a row clipped at the left edge would hide the very text the query + /// found. + /// + private static (string Text, int MatchStart, int MatchLength) Elide(SearchRow row, int entryWidth) + { + const string Ellipsis = "…"; + var text = row.Text; + if (entryWidth <= Ellipsis.Length || text.Length <= entryWidth) + { + return (text, row.MatchStart, row.MatchLength); + } + + var matchEnd = row.MatchStart < 0 ? 0 : row.MatchStart + row.MatchLength; + var start = Math.Max(0, Math.Min(matchEnd - entryWidth + Ellipsis.Length, text.Length - entryWidth)); + var length = Math.Min(entryWidth - Ellipsis.Length, text.Length - start); + + var head = start > 0 ? Ellipsis : string.Empty; + var tail = start + length < text.Length ? Ellipsis : string.Empty; + var shown = head + text.Substring(start, length) + tail; + + if (row.MatchStart < 0) + { + return (shown, -1, 0); + } + + var shifted = row.MatchStart - start + head.Length; + var visible = Math.Max(0, Math.Min(row.MatchLength, shown.Length - tail.Length - shifted)); + return shifted < 0 ? (shown, -1, 0) : (shown, shifted, visible); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/SearchPromptTests.cs b/tests/SharpMUTerm.Tui.Tests/SearchPromptTests.cs new file mode 100644 index 0000000..8cb9daf --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/SearchPromptTests.cs @@ -0,0 +1,238 @@ +using SharpMUTerm.Tui; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// What the ⌃F surface means by a keystroke, and what it says. The client wiring is +/// ' job; this is the part that needs no terminal. +/// +public class SearchPromptTests +{ + private static ConsoleKeyInfo Key(char c) => new(c, ConsoleKey.NoName, false, false, false); + + private static ConsoleKeyInfo Bare(ConsoleKey key) => new('\0', key, false, false, false); + + private static ConsoleKeyInfo Alt(ConsoleKey key) => new('\0', key, false, true, false); + + private static ConsoleKeyInfo Ctrl(ConsoleKey key) => new('\0', key, false, false, true); + + private static SearchDecision Interpret( + ConsoleKeyInfo key, string query = "gob", int selected = 0, int count = 3, + bool regex = false, bool all = false) => + SearchPrompt.Interpret(key, query, selected, count, regex, all); + + private static readonly SearchRow[] Rows = + { + new("main", "main", 12, "The goblin snarls at you.", 4, 6), + new("spawn:chat", "Chat", 3, " Ana: goblin room is bugged", 11, 6), + }; + + [Test] + public async Task TypingBuildsTheQueryAndPointsAtTheFirstRow() + { + var decision = Interpret(Key('x'), query: "gob", selected: 2); + + await Assert.That(decision.Action).IsEqualTo(SearchAction.Redraw); + await Assert.That(decision.Query).IsEqualTo("gobx"); + await Assert.That(decision.Selected).IsEqualTo(0); + } + + [Test] + public async Task BackspaceWidensTheQuery() + { + var decision = Interpret(Bare(ConsoleKey.Backspace), query: "gob"); + + await Assert.That(decision.Action).IsEqualTo(SearchAction.Redraw); + await Assert.That(decision.Query).IsEqualTo("go"); + } + + [Test] + public async Task BackspaceOnAnEmptyQueryDoesNothing() + { + await Assert.That(Interpret(Bare(ConsoleKey.Backspace), query: string.Empty).Action) + .IsEqualTo(SearchAction.None); + } + + [Test] + public async Task TheArrowsWalkTheRowsAndWrap() + { + await Assert.That(Interpret(Bare(ConsoleKey.DownArrow), selected: 2, count: 3).Selected).IsEqualTo(0); + await Assert.That(Interpret(Bare(ConsoleKey.UpArrow), selected: 0, count: 3).Selected).IsEqualTo(2); + } + + [Test] + public async Task EnterGoes() + { + await Assert.That(Interpret(Bare(ConsoleKey.Enter)).Action).IsEqualTo(SearchAction.Go); + } + + /// + /// With nothing listed there is nowhere to go, so ⏎ leaves the surface up: the query is what needs + /// fixing, and Esc is the advertised way out. + /// + [Test] + public async Task EnterWithNothingListedLeavesTheSurfaceUp() + { + await Assert.That(Interpret(Bare(ConsoleKey.Enter), count: 0).Action).IsEqualTo(SearchAction.None); + } + + [Test] + public async Task EscapeAndCtrlFBothClose() + { + await Assert.That(Interpret(Bare(ConsoleKey.Escape)).Action).IsEqualTo(SearchAction.Cancel); + await Assert.That(Interpret(Ctrl(ConsoleKey.F)).Action).IsEqualTo(SearchAction.Cancel); + } + + /// + /// Both toggles re-search from the top: each changes what the list *is*, and a pointer kept at row + /// 12 of a different result set points at nothing the reader chose. + /// + [Test] + public async Task AltEAndAltAFlipTheirTogglesAndResetThePointer() + { + var regex = Interpret(Alt(ConsoleKey.E), selected: 2, regex: false); + await Assert.That(regex.Action).IsEqualTo(SearchAction.Redraw); + await Assert.That(regex.Regex).IsTrue(); + await Assert.That(regex.Selected).IsEqualTo(0); + + var all = Interpret(Alt(ConsoleKey.A), selected: 2, all: false); + await Assert.That(all.AllWindows).IsTrue(); + await Assert.That(all.Selected).IsEqualTo(0); + + // And back again — they are toggles, not switches that only turn on. + await Assert.That(Interpret(Alt(ConsoleKey.E), regex: true).Regex).IsFalse(); + await Assert.That(Interpret(Alt(ConsoleKey.A), all: true).AllWindows).IsFalse(); + } + + /// + /// Anything unrecognised is swallowed. A modal over the workspace that let keys through would be + /// typing into the command line it is covering. + /// + [Test] + public async Task UnrecognisedKeysAreSwallowed() + { + await Assert.That(Interpret(Ctrl(ConsoleKey.K)).Action).IsEqualTo(SearchAction.None); + await Assert.That(Interpret(Alt(ConsoleKey.Z)).Action).IsEqualTo(SearchAction.None); + await Assert.That(Interpret(Bare(ConsoleKey.Tab)).Action).IsEqualTo(SearchAction.None); + await Assert.That(Interpret(Bare(ConsoleKey.F5)).Action).IsEqualTo(SearchAction.None); + } + + /// + /// The honesty rule the settings screens and the composer are held to: every key the footer names + /// does something. Pressed here rather than read, so a hint that outlived its binding fails. + /// + [Test] + public async Task EveryKeyTheFooterNamesDoesSomething() + { + var named = new (string Name, ConsoleKeyInfo Key)[] + { + ("↑", Bare(ConsoleKey.UpArrow)), + ("↓", Bare(ConsoleKey.DownArrow)), + ("⏎", Bare(ConsoleKey.Enter)), + ("⌥E", Alt(ConsoleKey.E)), + ("⌥A", Alt(ConsoleKey.A)), + ("Esc", Bare(ConsoleKey.Escape)), + ("⌃F", Ctrl(ConsoleKey.F)), + ("type", Key('g')), + }; + + foreach (var (name, key) in named) + { + await Assert.That(Interpret(key).Action).IsNotEqualTo(SearchAction.None).Because($"{name} is advertised"); + } + + // And the footer names each of them, so the two halves cannot drift apart. + foreach (var fragment in new[] { "↑↓", "⏎", "⌥E", "⌥A", "Esc", "⌃F", "type to search" }) + { + await Assert.That(SearchPrompt.Hints).Contains(fragment); + } + } + + /// + /// The surface says which way both toggles are set. Neither is visible in the results, and both + /// change what a query finds. + /// + [Test] + public async Task TheHeaderSaysWhichWayTheTogglesAreSet() + { + var text = string.Join('\n', SearchPrompt.Render(Rows, "gob", null, false, false, "main", 4812, 0)); + var pattern = string.Join('\n', SearchPrompt.Render(Rows, "gob", null, true, true, "main", 4812, 0)); + + await Assert.That(text).Contains("text"); + await Assert.That(text).Contains("main"); + await Assert.That(pattern).Contains("regex"); + await Assert.That(pattern).Contains("every window"); + } + + /// + /// The bound is on the frame. ⌃F searches the lines the client is holding, not a session's whole + /// history, and a reader who cannot find something from an hour ago should be able to see why. + /// + [Test] + public async Task TheHeaderStatesHowMuchWasSearched() + { + var lines = string.Join('\n', SearchPrompt.Render(Rows, "gob", null, false, false, "main", 4812, 0)); + + await Assert.That(lines).Contains("2 found"); + await Assert.That(lines).Contains("4,812 lines held"); + } + + [Test] + public async Task AnInvalidPatternIsSaidRatherThanShownAsNoMatches() + { + var lines = string.Join( + '\n', SearchPrompt.Render(Array.Empty(), "gob(", "unterminated group", true, false, "main", 10, -1)); + + await Assert.That(lines).Contains("unterminated group"); + await Assert.That(lines).DoesNotContain("no matches"); + } + + [Test] + public async Task AnEmptyQuerySaysWhatToDoRatherThanNothing() + { + var lines = string.Join( + '\n', SearchPrompt.Render(Array.Empty(), string.Empty, null, false, false, "main", 10, -1)); + + await Assert.That(lines).Contains("type to search main"); + } + + [Test] + public async Task NoMatchesNamesTheKeyThatWidensTheQuery() + { + var lines = string.Join( + '\n', SearchPrompt.Render(Array.Empty(), "zzz", null, false, false, "main", 10, -1)); + + await Assert.That(lines).Contains("⌫ widens"); + } + + /// + /// The window column is drawn only when there is more than one window in the results — with one + /// window it would be the same word on every row, saying nothing. + /// + [Test] + public async Task TheWindowColumnAppearsOnlyWhenEveryWindowIsSearched() + { + var one = string.Join('\n', SearchPrompt.Render(Rows, "gob", null, false, false, "main", 10, -1)); + var all = string.Join('\n', SearchPrompt.Render(Rows, "gob", null, false, true, "main", 10, -1)); + + await Assert.That(one).DoesNotContain("Chat"); + await Assert.That(all).Contains("Chat"); + } + + /// A row shows why it is listed: the matched run is marked in the accent. + [Test] + public async Task AnUnselectedRowMarksWhereTheQueryLanded() + { + var lines = SearchPrompt.Render(Rows, "goblin", null, false, false, "main", 10, selected: 1); + + await Assert.That(lines.Any(l => l.Contains("[bold ") && l.Contains("goblin"))).IsTrue(); + } + + [Test] + public async Task ScrollKeepsThePointedAtRowInsideTheListArea() + { + await Assert.That(SearchPrompt.Scroll(0, 9, 40, 5)).IsEqualTo(5); + await Assert.That(SearchPrompt.Scroll(5, 5, 40, 5)).IsEqualTo(5); + await Assert.That(SearchPrompt.Scroll(20, 0, 40, 5)).IsEqualTo(0); + } +} From 5920c55df2087ea913c77e6569b32066bfce8953 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Tue, 11 Aug 2026 15:43:28 -0500 Subject: [PATCH 5/6] =?UTF-8?q?feat(search):=20=E2=8C=83F=20opens=20the=20?= =?UTF-8?q?surface,=20=E2=8C=A5A=20widens=20it,=20=E2=8F=8E=20goes,=20?= =?UTF-8?q?=E2=8C=A5G=20walks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chord, the scope and the landing. ⏎ activates the window the line is really in — through Activate, the one activation path, so the pane, the tab and the session move together rather than a pane being scrolled behind the reader's back — and marks the line with a bar, fourth of the boundary bars, which points rather than repaints: the line is worth having because it is the game's own text in the game's own colours. Two things measured rather than chosen. ⌥⇧G is *not* bound: kitty writes it as CSI 103;4u, a kitty-keyboard-protocol sequence AnsiInputParser drops, so it could never arrive — ⌥⇧1's story one letter over. And Escape does not clear the bar: a claimed Escape does not set _escapeAt, and TryAltEnter pairs an unclaimed one with a following Enter to make Alt+⏎, so binding it would break the newline chord for as long as a bar was on screen. ⌥G removes the bar *before* re-running the search. The bar is itself a row, so a search run around it returns indices in a buffer about to lose one and every hit below it lands a row early — which is what the test caught. Two kinds of inserted chrome now, so the index bookkeeping every buffer mark depends on is one pair of methods (InsertChromeRow/RemoveChromeRow) rather than written out at each site: the freeze point, the pending boundary, the activity bar and the search bar all move together or none of them do. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- src/SharpMUTerm.Tui/Glyphs.cs | 6 + src/SharpMUTerm.Tui/MacroKeys.cs | 16 + src/SharpMUTerm.Tui/SearchBarRenderer.cs | 43 ++ src/SharpMUTerm.Tui/SearchSurface.cs | 265 ++++++++++++ src/SharpMUTerm.Tui/SharpMUTermApp.cs | 401 ++++++++++++++++-- .../SharpMUTerm.Tui.Tests/FreezeChordTests.cs | 14 +- .../SearchEndToEndTests.cs | 252 +++++++++++ 7 files changed, 968 insertions(+), 29 deletions(-) create mode 100644 src/SharpMUTerm.Tui/SearchBarRenderer.cs create mode 100644 src/SharpMUTerm.Tui/SearchSurface.cs create mode 100644 tests/SharpMUTerm.Tui.Tests/SearchEndToEndTests.cs diff --git a/src/SharpMUTerm.Tui/Glyphs.cs b/src/SharpMUTerm.Tui/Glyphs.cs index 2c16948..d16c73d 100644 --- a/src/SharpMUTerm.Tui/Glyphs.cs +++ b/src/SharpMUTerm.Tui/Glyphs.cs @@ -38,6 +38,12 @@ internal static class Glyphs /// public const string Away = "\uf070"; // nf-fa-eye_slash + /// + /// The bar marking the line \u2303F sent you to \u2014 see . A magnifying + /// glass, the one icon in this set that needs no explaining. + /// + public const string Search = "\uf002"; // nf-fa-search + /// /// The focused pane's marker, drawn on the active tab of the pane every workspace key acts on. Box /// drawing rather than a Nerd Font icon, deliberately: it is the one glyph here whose job is to be diff --git a/src/SharpMUTerm.Tui/MacroKeys.cs b/src/SharpMUTerm.Tui/MacroKeys.cs index 8d5e3e7..43f688b 100644 --- a/src/SharpMUTerm.Tui/MacroKeys.cs +++ b/src/SharpMUTerm.Tui/MacroKeys.cs @@ -117,6 +117,10 @@ private static AppShortcut[] BuildAppShortcuts() new(ConsoleModifiers.Control, ConsoleKey.P, "opens the command surface"), new(ConsoleModifiers.Control, ConsoleKey.B, "arms the pane prefix"), new(ConsoleModifiers.Control, ConsoleKey.R, "searches the command history"), + // ⌃F is find, which is what it means to everyone who has used a computer — the convention is + // worth the Ctrl chord, and freeze moved to ⌥F to make room. ⌃R searches what *you* typed; this + // searches what the *worlds* said, and the two chords sit one letter apart under one modifier. + new(ConsoleModifiers.Control, ConsoleKey.F, "searches the output"), // The connection pair, and it is a pair: ⌥D disconnects, ⌥R reconnects. One modifier, two // letters that spell the two words, opposite actions that look opposite on the keyboard. // @@ -146,6 +150,18 @@ private static AppShortcut[] BuildAppShortcuts() // No ⌃F alias is left behind, for the reason ⌃D was released rather than kept: a second key for // one action is either a secret or a duplicate row on every surface that lists chords. new(ConsoleModifiers.Alt, ConsoleKey.F, "freezes the pane"), + // The search repeat, so walking hits is one key rather than reopening the surface for each. + // + // It wraps forward and there is *no backward chord*, which was measured rather than chosen. The + // obvious partner is ⌥⇧G, and the parser would decode it — `ProcessEscape` reads the Shift flag + // out of `char.IsUpper`, so `ESC G` is Alt+Shift+G. The terminal does not send `ESC G`: read off + // a pty with `kitten @ send-key`, kitty writes ⌥⇧G as `CSI 103;4u`, a kitty-keyboard-protocol + // sequence `AnsiInputParser.DispatchCsi` has no case for and `UnixStdinReader` drops. It is + // ⌥⇧1's story exactly (`CSI 49;4u`), one letter over. A decode test is not an arrival test. + // + // So ⌥G is the whole repeat, and no surface advertises a chord that goes back — going back is + // ⌃F again, which is one keystroke more and is a key that exists. + new(ConsoleModifiers.Alt, ConsoleKey.G, "goes to the next search hit"), // The character cycle. Letters and not digits because the digit row is spent (⌥N windows, ⌃B N // panes) and there is no third digit-bearing modifier this terminal delivers: read off a pty, // kitty writes ⌥⇧1 as `CSI 49;4u` and ⌃⇧N as `CSI 110;6u` — kitty-keyboard-protocol sequences diff --git a/src/SharpMUTerm.Tui/SearchBarRenderer.cs b/src/SharpMUTerm.Tui/SearchBarRenderer.cs new file mode 100644 index 0000000..ffae197 --- /dev/null +++ b/src/SharpMUTerm.Tui/SearchBarRenderer.cs @@ -0,0 +1,43 @@ +namespace SharpMUTerm.Tui; + +/// +/// Renders the bar drawn above the line ⌃F sent you to: the one row saying which hit this is, out of how +/// many, and which key goes to the next. +/// +/// Fourth of the boundary bars, and it earns its row the same way , +/// RestoreBarRenderer and do: mark the boundary, never +/// restyle the content. Painting the matched span itself was the obvious alternative and is the wrong +/// one — the line is worth having because it is the game's own text in the game's own colours, and a +/// highlight over it would destroy the thing being pointed at. It also costs nothing in cells inside the +/// line, so a pane's rectangle does not move and no server is told its terminal changed size. +/// +/// +/// The query is escaped, because it is the reader's own text going into markup: a search for +/// [public] must appear on the bar rather than be eaten as a tag. +/// +/// Pure, so the markup is unit-testable without a terminal. +/// +internal static class SearchBarRenderer +{ + /// How long the trailing rule is. The same 48 cells the other three bars draw. + private const int RuleCells = 48; + + /// The chord the bar names, and the only one it can: see MacroKeys for why ⌥⇧G is not here. + internal const string NextChord = "⌥G next"; + + /// + /// The bar for hit of for + /// , on an already-resolved #rrggbb accent. + /// + public static string Bar(string query, int ordinal, int total, string accentHex) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentException.ThrowIfNullOrEmpty(accentHex); + + // The ordinal is what makes ⌥G legible: without it the bar moves and nothing says whether you + // are getting closer to the end of the results or going round in circles. + var counted = MarkupText.Escape($"{query} ({ordinal} of {total})"); + var rule = new string('─', RuleCells); + return $"[{accentHex}]{Glyphs.Search} {counted}[/] [dim]{rule} {NextChord}[/]"; + } +} diff --git a/src/SharpMUTerm.Tui/SearchSurface.cs b/src/SharpMUTerm.Tui/SearchSurface.cs new file mode 100644 index 0000000..74121db --- /dev/null +++ b/src/SharpMUTerm.Tui/SearchSurface.cs @@ -0,0 +1,265 @@ +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using SharpMUTerm.Core.Text; + +namespace SharpMUTerm.Tui; + +/// One window the search can look in: what it is called, and the plain text it holds. +/// The window's id — what hands back so the app can activate it. +/// What to call it in the window column. +/// Its lines' plain text, oldest first, indexed as the buffer indexes them. +internal readonly record struct SearchCorpus(string WindowId, string Label, IReadOnlyList Lines); + +/// +/// The search surface (⌃F): a modal list of the lines matching a query, which typing narrows. It is the +/// host and nothing else — owns what a keystroke means and what the surface +/// says, and owns the matching. The same split +/// uses, and the only reason any of it is testable without a terminal. +/// +/// Keys arrive on PreviewKeyPressed, before any control sees them, as they do for the quit prompt, +/// the settings screens and the history surface. There is no framework input control here for the same +/// reason those have none: the query is a buffer this class owns and the whole body is markup redrawn on +/// every key, so there is nothing for focus to land on and nothing to keep in step. +/// +/// +/// The corpus is read on every keystroke, not snapshotted at open. A window's buffer is trimmed +/// from the front as it grows, so an index taken a minute ago points at a different line — and ⏎ hands an +/// index back. Re-reading is also what makes a search over a live connection show what is there now. +/// +/// +internal sealed class SearchSurface +{ + /// + /// The narrowest the surface goes. Set by the footer: a surface too narrow for its own key hints + /// would wrap them onto a second row and push the list up. Checked by test against + /// rather than trusted. + /// + internal const int MinimumWidth = 78; + + /// + /// The rows the surface spends on something other than results: the query line, the count line, the + /// blank above the footer, and the footer. Keep in step with . + /// + private const int ChromeRows = 4; + + private readonly ConsoleWindowSystem _system; + private readonly Func> _corpus; + private readonly Action _go; + + private Window? _window; + private MarkupControl? _body; + private IReadOnlyList _rows = Array.Empty(); + private string _query = string.Empty; + private string? _error; + private string _scope = string.Empty; + private bool _regex; + private bool _all; + private int _held; + private int _selected = -1; + private int _contentWidth; + private int _listRows; + private int _first; + + /// + /// is asked for the windows to search — all of them, or just the focused + /// one — and is read afresh on every keystroke. is handed the chosen row, the + /// query, and its ordinal out of the total, which is what the bar above the landed line says. + /// + public SearchSurface( + ConsoleWindowSystem system, + Func> corpus, + Action go) + { + _system = system; + _corpus = corpus; + _go = go; + } + + public bool IsOpen => _window is not null; + + /// What the surface is currently showing, for a headless test to read back. + internal IReadOnlyList Lines => SearchPrompt.Render( + _rows, _query, _error, _regex, _all, _scope, _held, _selected, _contentWidth - 1, _listRows, _first); + + /// The query as typed so far. + internal string Query => _query; + + /// The rows currently listed, in buffer order — what ↑↓ walk and ⏎ picks from. + internal IReadOnlyList Rows => _rows; + + /// Whether the query is being read as a pattern. + internal bool Regex => _regex; + + /// Whether every window is being searched rather than the focused one. + internal bool AllWindows => _all; + + /// Opens the surface, or closes it when ⌃F arrives a second time. + public void Toggle() + { + if (_window is not null) + { + Close(); + return; + } + + Open(); + } + + /// Opens the surface into a headless frame (used by the search snapshot views). + public void OpenForSnapshot() => Open(); + + /// + /// Feeds one key to the very handler PreviewKeyPressed raises, for the same reason + /// exists: the framework only pumps keys inside + /// Run(), which a headless test or snapshot never enters. + /// + public void SimulateKey(ConsoleKeyInfo key) => OnKey(this, new KeyPressedEventArgs(key, false)); + + /// Types a whole query in, one real keystroke at a time. + public void SimulateTyping(string text) + { + foreach (var c in text) + { + SimulateKey(new ConsoleKeyInfo(c, ConsoleKey.NoName, false, false, false)); + } + } + + private void Open() + { + _query = string.Empty; + _error = null; + _regex = false; + _all = false; + _selected = -1; + _first = 0; + Refilter(_query, -1); + + var desktop = _system.DesktopDimensions; + + // Sized once and never again, HistorySurface's rule: narrowing must pad the list area rather than + // shrink the window, so the rows and the footer stay where the eye left them. There is no + // unfiltered list to size to here — an empty query matches nothing — so the height is the room + // there is rather than the room the results need. + _listRows = Math.Max(3, desktop.Height - ChromeRows - 6); + _contentWidth = Math.Clamp( + SearchPrompt.MaxWidth(Lines) + 2, MinimumWidth, Math.Max(MinimumWidth, desktop.Width - 6)); + + var width = _contentWidth + 2; // + the 1-cell left/right border + var height = Math.Min(_listRows + ChromeRows + 2, Math.Max(ChromeRows + 3, desktop.Height - 2)); + + _body = new MarkupControl(new List()); + + // Centred *after* WithSize, because the builder reads the bounds set so far and falls back to 80x25. + _window = new WindowBuilder(_system) + .WithTitle("Search output") + .AsModal() + .WithBorderStyle(BorderStyle.Single) + .WithBackgroundColor(new Color(ScreenPalette.MenuBg)) + .HideTitleButtons() + .Resizable(false) + .WithSize(width, height) + .Centered() + .AddControl(_body) + .OnClosed((_, _) => Reset()) + .Build(); + + _window.PreviewKeyPressed += OnKey; + _system.AddWindow(_window); + Paint(); + } + + private void OnKey(object? sender, KeyPressedEventArgs e) + { + if (_window is null) + { + return; + } + + var decision = SearchPrompt.Interpret(e.KeyInfo, _query, _selected, _rows.Count, _regex, _all); + e.Handled = true; + + switch (decision.Action) + { + case SearchAction.Go: + // Closed first: the reader is about to be looking at the pane this row is in, and a modal + // still painted over it would hide the thing they asked to see. + var row = _rows[_selected]; + var ordinal = _selected + 1; + var total = _rows.Count; + var query = _query; + Close(); + _go(row, query, ordinal, total); + break; + + case SearchAction.Cancel: + Close(); + break; + + case SearchAction.Redraw: + _regex = decision.Regex; + _all = decision.AllWindows; + Refilter(decision.Query, decision.Selected); + Paint(); + _window.Invalidate(redrawAll: true); + break; + + case SearchAction.None: + default: + break; + } + } + + /// + /// Re-reads the corpus and re-runs the search. The pointer is clamped to what the query actually + /// matched: a narrowing must not leave ⏎ aimed past the end of the list. + /// + private void Refilter(string query, int selected) + { + _query = query; + + var windows = _corpus(_all); + _scope = windows.Count == 1 ? windows[0].Label : "the focused window"; + _held = windows.Sum(w => w.Lines.Count); + + var rows = new List(); + string? error = null; + foreach (var window in windows) + { + var result = OutputSearch.Match(window.Lines, query, _regex); + if (result.Error is not null) + { + // One bad pattern is bad for every window, so it is reported once and the list is empty + // — a result set holding whatever the earlier windows managed would be a search that + // half-worked without saying which half. + error = result.Error; + rows.Clear(); + break; + } + + rows.AddRange(result.Matches.Select(m => + new SearchRow(window.WindowId, window.Label, m.LineIndex, m.Text, m.MatchStart, m.MatchLength))); + } + + _error = error; + _rows = rows; + _selected = _rows.Count == 0 ? -1 : Math.Clamp(selected, 0, _rows.Count - 1); + _first = SearchPrompt.Scroll(_first, _selected, _rows.Count, _listRows); + } + + private void Paint() => _body?.SetContent(new List(Lines)); + + private void Close() + { + if (_window is { } window) + { + _system.CloseModalWindow(window); + } + } + + private void Reset() + { + _window = null; + _body = null; + } +} diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index ea338c0..c3d7ada 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -265,6 +265,26 @@ private sealed class SizeReport /// private readonly HistorySurface _historySearch; + /// + /// The ⌃F search surface: the lines this client is holding, filtered by typing. ⏎ there goes to a + /// line and marks it; it never sends anything. Its sibling one letter away, ⌃R, searches what + /// you typed — this searches what the worlds said. + /// + private readonly SearchSurface _search; + + /// + /// The last search's terms, kept so ⌥G can walk to the next hit without reopening the surface: the + /// query, whether it was a pattern, and whether it covered every window. Null until something has + /// been searched for, which is what ⌥G refuses on. + /// + private (string Query, bool Regex, bool All)? _lastSearch; + + /// + /// Where the search bar sits, or null when no hit has been landed on. One client-wide, not one per + /// window: it marks the hit you went to, and ⌥G moves it rather than leaving a trail. + /// + private (string WindowId, int Index)? _searchMark; + /// Whether a confirmed quit has asked the loop to end — the headless view of the exit. private bool _exiting; @@ -684,6 +704,8 @@ public SharpMUTermApp( HistoryBarLabel, InsertHistoryEntry); + _search = new SearchSurface(_system, SearchableWindows, GoToSearchHit); + _window.OnResize += (_, _) => { // NAWS is deliberately not reported from here. At this moment the panes still carry the @@ -2448,6 +2470,14 @@ private void AppendWindowLine(string windowId, string markup, string? stamp = nu _awayMarks.Remove(windowId); } } + + // Same rule for the search bar: a bar trimmed off the top is gone, and a mark left pointing + // at row zero would have the next removal take a line of the game's output instead. + if (_searchMark is { } search && string.Equals(search.WindowId, windowId, StringComparison.Ordinal)) + { + var moved = search.Index - excess; + _searchMark = moved < 0 ? null : (windowId, moved); + } } if (_panes.TryGetValue(windowId, out var control)) @@ -2726,11 +2756,7 @@ private void MarkWhereTheReaderLeft(TimeSpan away) continue; } - buffer.Insert(at, new PaneLine(AwayBarRenderer.Bar(missed, away, accent))); - if (_freezePoints.TryGetValue(windowId, out var freeze) && freeze > at) - { - _freezePoints[windowId] = freeze + 1; - } + InsertChromeRow(windowId, at, AwayBarRenderer.Bar(missed, away, accent)); var mark = new AwayMark { Index = at, DrawnAfter = _focus.InputCount, DrawnAt = _time.GetUtcNow() }; _awayMarks[windowId] = mark; @@ -2801,11 +2827,7 @@ private void MarkMissedLines(bool reveal) at = Math.Clamp(at, 0, buffer.Count); } - buffer.Insert(at, new PaneLine(AwayBarRenderer.Missed(buffer.Count - at, FrozenAccentHex()))); - if (_freezePoints.TryGetValue(windowId, out var freeze) && freeze > at) - { - _freezePoints[windowId] = freeze + 1; - } + InsertChromeRow(windowId, at, AwayBarRenderer.Missed(buffer.Count - at, FrozenAccentHex())); var mark = new AwayMark { @@ -2984,42 +3006,271 @@ private void ConsumeReadAwayBars() } /// - /// Takes a window's away bar out of its line buffer, moving everything that indexes into that buffer - /// past it — the freeze point and the pending boundary — down by the row it freed. Does not repaint: - /// the callers either follow with one or are about to insert a replacement. + /// Puts one row of the client's own chrome into a window's line buffer, and moves everything that + /// indexes into that buffer past it up by the row it took. + /// + /// There are two kinds of inserted chrome now — the activity bar and the search bar — and every index + /// into a buffer has to survive both: the freeze point, the pending boundary, the other bar, and this + /// one. That bookkeeping lives here and in rather than being written out + /// at each site, because a site that forgot one of them would leave a mark pointing at a line of the + /// game's output, and the next removal would take that line instead. + /// + /// + /// It inserts, and does not repaint — the callers do, because they also have a reveal to sequence. + /// The row carries no plain text, so a search cannot find it (see ). + /// /// - /// Whether there was a bar to remove. - private bool RemoveAwayBar(string windowId) + private void InsertChromeRow(string windowId, int at, string markup) { - if (!_awayMarks.TryGetValue(windowId, out var mark) - || !_lines.TryGetValue(windowId, out var buffer) - || mark.Index < 0 - || mark.Index >= buffer.Count) + if (!_lines.TryGetValue(windowId, out var buffer)) { - return _awayMarks.Remove(windowId); + return; } - buffer.RemoveAt(mark.Index); - _awayMarks.Remove(windowId); + at = Math.Clamp(at, 0, buffer.Count); + buffer.Insert(at, new PaneLine(markup)); + + if (_freezePoints.TryGetValue(windowId, out var freeze) && freeze > at) + { + _freezePoints[windowId] = freeze + 1; + } - if (_freezePoints.TryGetValue(windowId, out var freeze) && freeze > mark.Index) + if (_awayMarks.TryGetValue(windowId, out var mark) && mark.Index >= at) + { + mark.Index++; + } + + if (_searchMark is { } search + && string.Equals(search.WindowId, windowId, StringComparison.Ordinal) + && search.Index >= at) + { + _searchMark = (windowId, search.Index + 1); + } + } + + /// + /// Takes one row of the client's own chrome out of a window's line buffer, moving everything that + /// indexes into that buffer past it down by the row it freed. The other half of + /// , and the same reason for existing. + /// + private void RemoveChromeRow(string windowId, int at) + { + if (!_lines.TryGetValue(windowId, out var buffer) || at < 0 || at >= buffer.Count) + { + return; + } + + buffer.RemoveAt(at); + + if (_freezePoints.TryGetValue(windowId, out var freeze) && freeze > at) { _freezePoints[windowId] = freeze - 1; } - if (_awayPending.TryGetValue(windowId, out var pending) && pending > mark.Index) + if (_awayPending.TryGetValue(windowId, out var pending) && pending > at) { _awayPending[windowId] = pending - 1; } - if (_awayBoundary.TryGetValue(windowId, out var boundary) && boundary > mark.Index) + if (_awayBoundary.TryGetValue(windowId, out var boundary) && boundary > at) { _awayBoundary[windowId] = boundary - 1; } + if (_missedFrom.TryGetValue(windowId, out var missed) && missed > at) + { + _missedFrom[windowId] = missed - 1; + } + + if (_awayMarks.TryGetValue(windowId, out var mark) && mark.Index > at) + { + mark.Index--; + } + + if (_searchMark is { } search + && string.Equals(search.WindowId, windowId, StringComparison.Ordinal) + && search.Index > at) + { + _searchMark = (windowId, search.Index - 1); + } + } + + /// + /// Takes a window's away bar out of its line buffer. Does not repaint: the callers either follow with + /// one or are about to insert a replacement. The index bookkeeping is 's, + /// which is the one place that knows everything pointing into a buffer. + /// + /// Whether there was a bar to remove. + private bool RemoveAwayBar(string windowId) + { + if (!_awayMarks.TryGetValue(windowId, out var mark)) + { + return false; + } + + var at = mark.Index; + _awayMarks.Remove(windowId); + RemoveChromeRow(windowId, at); return true; } + /// + /// Takes the search bar off whichever window is carrying it, and forgets where it was. Does not + /// repaint — every caller either repaints that pane or is about to put a new bar somewhere else. + /// + /// The window the bar was in, or null when there was no bar. + private string? RemoveSearchBar() + { + if (_searchMark is not { } mark) + { + return null; + } + + _searchMark = null; + RemoveChromeRow(mark.WindowId, mark.Index); + return mark.WindowId; + } + + /// + /// Goes to one search hit: activates its window, marks the line with a bar, and scrolls the pane so + /// the bar is on screen. + /// + /// Activation goes through , the one activation path — it selects the + /// pane, raises the tab and adopts the session, so ⏎ on a hit in a background pane leaves the client + /// in a consistent state rather than merely scrolling something the reader is not looking at. That is + /// the same reasoning that keeps every other "bring this window forward" gesture on that method. + /// + /// + /// One bar client-wide. It marks the hit you went to; ⌥G moves it rather than leaving + /// a trail behind, and a second search replaces it. It is not cleared by Escape: a claimed + /// Escape does not set _escapeAt, and pairs an unclaimed one with a + /// following Enter to make Alt+⏎ — so binding Escape here would break the newline chord for as long as + /// a search bar was on screen, which is a defect nobody would connect to search. + /// + /// + private void GoToSearchHit(SearchRow row, string query, int ordinal, int total) + { + _lastSearch = (query, _search.Regex, _search.AllWindows); + + var previous = RemoveSearchBar(); + if (previous is { } cleared && !string.Equals(cleared, row.WindowId, StringComparison.Ordinal)) + { + RepaintPane(cleared); + } + + if (!_lines.TryGetValue(row.WindowId, out var buffer)) + { + RefuseCommand("that window is gone"); + return; + } + + Activate(row.WindowId); + + var at = Math.Clamp(row.LineIndex, 0, buffer.Count); + InsertChromeRow(row.WindowId, at, SearchBarRenderer.Bar(query, ordinal, total, FrozenAccentHex())); + _searchMark = (row.WindowId, at); + + RepaintPane(row.WindowId); + RevealSearchBar(row.WindowId, at); + SyncScrollbackState(); + } + + /// + /// Goes to the hit after the one the bar is on, wrapping — the whole of ⌥G. It re-runs the last + /// search rather than keeping a result list, because the buffers move underneath one: lines arrive, + /// and a trim takes them off the front. Re-running also means ⌥G finds a hit that arrived since. + /// + private void NextSearchHit() + { + if (_lastSearch is not { } last) + { + RefuseCommand("nothing has been searched for yet — ⌃F searches the output"); + return; + } + + // The bar comes off *before* the search is re-run, and that is not tidiness: the bar is itself a + // row in the buffer, so a search run around it returns indices in a buffer that is about to lose + // one — every hit below the bar would be off by one, and the next ⌥G would land a row early. With + // it gone, the hit it was marking sits at exactly the index the bar had, which is also how "the + // one after this" is found below. + var was = _searchMark; + if (RemoveSearchBar() is { } cleared) + { + RepaintPane(cleared); + } + + var rows = new List(); + foreach (var window in SearchableWindows(last.All)) + { + var result = OutputSearch.Match(window.Lines, last.Query, last.Regex); + if (result.Error is not null) + { + RefuseCommand($"that search no longer works: {result.Error}"); + return; + } + + rows.AddRange(result.Matches.Select(m => + new SearchRow(window.WindowId, window.Label, m.LineIndex, m.Text, m.MatchStart, m.MatchLength))); + } + + if (rows.Count == 0) + { + RefuseCommand($"no lines hold “{Snippet(last.Query)}” any more"); + return; + } + + // The one after the hit the bar was on, wrapping. Found in the flat list rather than by index + // within a window, so with ⌥A on it walks out of one window and into the next in the order the + // surface listed them. A bar whose line has since been trimmed away is simply not found, and the + // walk starts again from the first hit — which is the only answer left, and a defensible one. + var next = 0; + if (was is { } mark) + { + var current = rows.FindIndex(r => + string.Equals(r.WindowId, mark.WindowId, StringComparison.Ordinal) && r.LineIndex == mark.Index); + next = current >= 0 ? (current + 1) % rows.Count : 0; + } + + GoToSearchHit(rows[next], last.Query, next + 1, rows.Count); + } + + /// + /// Scrolls a pane so a freshly drawn search bar is on screen, with the line it marks under it. The + /// away bar's arithmetic, and for its reason: a buffer index is not a viewport row — the panel's + /// offset counts display rows and a buffered line wraps into as many as it needs, so in a + /// narrow pane scrolling to the index lands hundreds of rows adrift. + /// + /// Unlike the away bar's reveal, this one runs whether or not the bar is already in view: the reader + /// asked to be taken to this line, so leaving the pane where it was would be answering "go there" + /// with "it is already roughly there". + /// + /// + private void RevealSearchBar(string windowId, int index) + { + if (_paneScrolls.GetValueOrDefault(windowId) is not { } panel + || !_lines.TryGetValue(windowId, out var buffer) + || panel.ViewportWidth <= 0 + || panel.ViewportHeight <= 0) + { + return; + } + + var origin = _freezePoints.TryGetValue(windowId, out var split) ? Math.Max(0, split) : 0; + if (index < origin) + { + return; + } + + var tailRows = MeasureRows(buffer, index, panel.ViewportWidth, _panes.GetValueOrDefault(windowId)); + if (tailRows <= 0) + { + return; + } + + var target = Math.Max(0, panel.TotalContentHeight - tailRows); + panel.ScrollVerticalBy(target - panel.VerticalScrollOffset); + } + /// /// Drives the return the terminal's focus report would have driven. The seam a headless test uses: /// is false for a headless driver by design, so the @@ -3450,7 +3701,68 @@ private void ToggleHistorySearch() /// private bool AnyOverlayOpen => _palette.IsOpen || _settings.IsOpen || _quit.IsOpen || _messageLog.IsOpen || _historySearch.IsOpen - || _prefixPanel.IsOpen || _composer.IsOpen; + || _prefixPanel.IsOpen || _composer.IsOpen || _search.IsOpen; + + /// + /// Opens the ⌃F search surface, or closes it when the chord arrives again. + /// + /// It refuses over any other overlay, and says so over the composer for the reason the composer + /// refuses over a settings screen: two modal windows with two PreviewKeyPressed handlers + /// cannot be driven headlessly, and `SettingsOverlay` takes paste off the *driver* because its + /// screens have no focusable target — a second modal in front of it would make both fire. + /// + /// + private void ToggleSearch() + { + if (_search.IsOpen) + { + _search.Toggle(); + return; + } + + if (_composer.IsOpen) + { + RefuseCommand("close the composer first — search cannot open over it"); + return; + } + + if (AnyOverlayOpen || _moveMode) + { + return; + } + + _search.Toggle(); + } + + /// + /// The windows ⌃F looks in: the focused one, or every window holding output when + /// — which is what ⌥A switches. + /// + /// What is searched is the pane buffer, and not a session's Scrollback or the + /// file-backed spill. RestoreLog's reasoning, one layer over: a spawn window's lines never + /// reach a session's scrollback at all (a gagging capture rule keeps them out of the transcript + /// entirely), so a session-keyed search would find nothing in exactly the windows people search + /// hardest. The surface states the bound it did search rather than implying a bigger one. + /// + /// + /// The web view is excluded because its pane is not fed from this buffer — the same exclusion the + /// activity boundary and RepaintPanes make. Labels go through : a window + /// title can be a world's text (the web view is titled from the page it loaded). + /// + /// + private IReadOnlyList SearchableWindows(bool all) + { + var ids = all + ? _lines.Keys.Where(id => !string.Equals(id, WebWindowId, StringComparison.Ordinal)) + : new[] { ActiveWindowId() }.Where(_lines.ContainsKey); + + return ids + .Select(id => new SearchCorpus( + id, + Snippet(WindowTitle(id)), + _lines[id].Select(line => line.Plain).ToArray())) + .ToArray(); + } /// /// Refuses a surface that would open over the composer, naming what is in the way. The @@ -3491,6 +3803,7 @@ private string OpenOverlayName() => : _quit.IsOpen ? "the quit prompt" : _messageLog.IsOpen ? "the client messages" : _historySearch.IsOpen ? "the history search" + : _search.IsOpen ? "the search surface" : _prefixPanel.IsOpen ? "the pane keys panel" : "what is open"; @@ -4911,6 +5224,14 @@ private void RegisterFocusReportTab() return () => { CycleCharacter(-1); return true; }; } + // ⌥G walks to the next hit of the last search, so a reader following a name down a transcript + // presses one key rather than reopening the surface for each. There is no backward chord — + // see MacroKeys, where the measurement that rules ⌥⇧G out is recorded. + if (claim.Key == ConsoleKey.G) + { + return () => { NextSearchHit(); return true; }; + } + // ⌥F freezes and resumes the focused pane. It was ⌃F, and moved so that search could have the // chord every reader on every platform reaches for. Same delivery story as ⌥D and ⌥R: ESC + a // printable byte, decoded as that letter with Alt set. @@ -4953,6 +5274,10 @@ private void RegisterFocusReportTab() // framework's parser turns byte 0x08 into Backspace with no Control modifier, so binding it // would take the command line's erase key and the app could not even tell the two apart. ConsoleKey.R => () => { ToggleHistorySearch(); return true; }, + // ⌃F is find. Freeze had it and moved to ⌥F: the convention is what every reader on every + // platform reaches for, and ⌃R one letter away searches the other half of a session — what + // you typed, where this is what the worlds said. + ConsoleKey.F => () => { ToggleSearch(); return true; }, _ => null, }; } @@ -8275,6 +8600,30 @@ private bool RouteToInput(ConsoleKeyInfo key) /// internal void SimulateHistorySearchKey(ConsoleKeyInfo key) => _historySearch.SimulateKey(key); + /// Whether the ⌃F search surface is up. + internal bool SearchIsOpen => _search.IsOpen; + + /// What the search surface is currently listing — what ↑↓ walk and ⏎ picks from. + internal IReadOnlyList SearchRows => _search.Rows; + + /// Feeds one key to the search surface's own handler, as the framework's pump would. + internal void SimulateSearchKey(ConsoleKeyInfo key) => _search.SimulateKey(key); + + /// Types a whole query into the search surface, one real keystroke at a time. + internal void SimulateSearchTyping(string text) => _search.SimulateTyping(text); + + /// Opens the search surface for a snapshot frame, bypassing the chord's overlay guards. + internal void OpenSearchForSnapshot() => _search.OpenForSnapshot(); + + /// + /// Where the search bar sits in a window's line buffer, or null when that window is not carrying it. + /// There is only ever one, client-wide. + /// + internal int? SearchBarIndex(string windowId) => + _searchMark is { } mark && string.Equals(mark.WindowId, windowId, StringComparison.Ordinal) + ? mark.Index + : null; + /// Types a filter into the open ⌃R surface, one real keystroke at a time. internal void SimulateHistorySearchTyping(string text) => _historySearch.SimulateTyping(text); diff --git a/tests/SharpMUTerm.Tui.Tests/FreezeChordTests.cs b/tests/SharpMUTerm.Tui.Tests/FreezeChordTests.cs index baf2ce5..94d986e 100644 --- a/tests/SharpMUTerm.Tui.Tests/FreezeChordTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/FreezeChordTests.cs @@ -59,12 +59,20 @@ public async Task CtrlFNoLongerFreezesAnything() await Assert.That(app.FrozenScrollbackOf(Main)).IsNull(); } + /// + /// Both halves of the move: ⌥F is freeze, and ⌃F is claimed by something that is not freeze. The + /// second half matters because the whole point of the move was to hand ⌃F to find — a claim list + /// where it had simply gone missing would mean the chord had been spent on nothing. + /// [Test] - public async Task TheClaimListNamesAltFAndNoLongerNamesCtrlF() + public async Task TheClaimListNamesAltFForFreezeAndCtrlFForSomethingElse() { var claims = MacroKeys.AppShortcuts; - await Assert.That(claims.Any(c => c.Modifiers == ConsoleModifiers.Alt && c.Key == ConsoleKey.F)).IsTrue(); - await Assert.That(claims.Any(c => c.Modifiers == ConsoleModifiers.Control && c.Key == ConsoleKey.F)).IsFalse(); + var alt = claims.Single(c => c.Modifiers == ConsoleModifiers.Alt && c.Key == ConsoleKey.F); + await Assert.That(alt.Does).Contains("freeze"); + + var ctrl = claims.Single(c => c.Modifiers == ConsoleModifiers.Control && c.Key == ConsoleKey.F); + await Assert.That(ctrl.Does).DoesNotContain("freeze"); } } diff --git a/tests/SharpMUTerm.Tui.Tests/SearchEndToEndTests.cs b/tests/SharpMUTerm.Tui.Tests/SearchEndToEndTests.cs new file mode 100644 index 0000000..f4a9082 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/SearchEndToEndTests.cs @@ -0,0 +1,252 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Session; +using SharpMUTerm.Graphics; +using SharpMUTerm.Tui; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// The ⌃F surface driven through the chord the app actually registers. +/// pins what the surface means and says; these pin that the client is wired to it — that the chord opens +/// it, that ⌥A widens what it looks at, that ⏎ takes the reader to the window the line is really in, and +/// that nothing it does reaches the wire. +/// +/// +/// Serialised for the reason every file that renders a frame is: rendering redirects the process-global +/// Console.Out, and the harness redirects Console.In. +/// +[NotInParallel] +public class SearchEndToEndTests +{ + private const int Width = 140; + private const int Height = 40; + private const string Main = "main"; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private static (SharpMUTermApp App, WorldSession Session) Bound() + { + Console.SetIn(TextReader.Null); + var config = DemoScene.Build(); + config.ScrollbackSpill.Enabled = false; + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(Width, Height)); + var session = app.BindWorldWithoutConnecting(config.Worlds[0]); + app.RenderSnapshot(); + return (app, session); + } + + private static ConsoleKeyInfo Ctrl(ConsoleKey key) => new('\0', key, false, false, true); + + private static ConsoleKeyInfo Alt(ConsoleKey key) => new('\0', key, false, true, false); + + private static ConsoleKeyInfo Bare(ConsoleKey key) => new('\0', key, false, false, false); + + [Test] + public async Task CtrlFOpensTheSurfaceAndCtrlFAgainClosesIt() + { + var (app, _) = Bound(); + + app.SimulateKey(Ctrl(ConsoleKey.F)); + await Assert.That(app.SearchIsOpen).IsTrue(); + + app.SimulateKey(Ctrl(ConsoleKey.F)); + await Assert.That(app.SearchIsOpen).IsFalse(); + } + + [Test] + public async Task TypingListsTheLinesHoldingTheQuery() + { + var (app, session) = Bound(); + session.PrintSystem("*** The goblin snarls at you."); + session.PrintSystem("*** A town guard stands watch."); + session.PrintSystem("*** You hit the goblin."); + + app.SimulateKey(Ctrl(ConsoleKey.F)); + app.SimulateSearchTyping("goblin"); + + await Assert.That(app.SearchRows.Count).IsEqualTo(2); + await Assert.That(app.SearchRows.All(r => r.WindowId == Main)).IsTrue(); + } + + /// + /// The scope toggle, and the reason it exists: the hit that matters is usually in a pane you are not + /// looking at. Narrow first — the focused window — and ⌥A widens. + /// + [Test] + public async Task AltAWidensFromTheFocusedWindowToEveryWindow() + { + var (app, session) = Bound(); + session.PrintSystem("*** the goblin snarls at you"); + app.SimulateWindowChange(DemoScene.ChatWindowId); + app.SimulateWindowChange(Main); + + app.SimulateKey(Ctrl(ConsoleKey.F)); + app.SimulateSearchTyping("the"); + var focused = app.SearchRows.Count; + + app.SimulateSearchKey(Alt(ConsoleKey.A)); + + await Assert.That(app.SearchRows.Count).IsGreaterThan(focused); + await Assert.That(app.SearchRows.Any(r => r.WindowId != Main)).IsTrue(); + } + + /// + /// ⌥E is the difference between a query and a pattern, and the frame says which way it is set — this + /// is the behaviour behind that label. + /// + [Test] + public async Task AltESwitchesTheQueryToAPattern() + { + var (app, session) = Bound(); + session.PrintSystem("*** You hit the goblin for 12 damage."); + session.PrintSystem("*** You hit the goblin for no damage."); + + app.SimulateKey(Ctrl(ConsoleKey.F)); + app.SimulateSearchTyping(@"\d+ damage"); + await Assert.That(app.SearchRows).IsEmpty(); + + app.SimulateSearchKey(Alt(ConsoleKey.E)); + + await Assert.That(app.SearchRows.Count).IsEqualTo(1); + } + + /// + /// The headline: ⏎ on a hit in a window the reader is not looking at takes them to that + /// window, not merely to that line. Activation is the app's one path, so the pane, the tab and the + /// session all move together. + /// + [Test] + public async Task EnterGoesToTheWindowTheLineIsActuallyIn() + { + var (app, session) = Bound(); + session.PrintSystem("*** the vault key is behind the bar"); + app.SimulateWindowChange(DemoScene.ChatWindowId); + + app.SimulateKey(Ctrl(ConsoleKey.F)); + app.SimulateSearchKey(Alt(ConsoleKey.A)); + app.SimulateSearchTyping("vault key"); + await Assert.That(app.SearchRows.Count).IsEqualTo(1); + + app.SimulateSearchKey(Bare(ConsoleKey.Enter)); + + await Assert.That(app.SearchIsOpen).IsFalse(); + await Assert.That(app.ActiveWindowId()).IsEqualTo(Main); + } + + /// + /// And it marks where it took them: a bar directly above the line, saying which hit this is out of + /// how many and which key goes to the next. + /// + [Test] + public async Task TheBarSitsDirectlyAboveTheLineItSentYouTo() + { + var (app, session) = Bound(); + session.PrintSystem("*** the vault key is behind the bar"); + + app.SimulateKey(Ctrl(ConsoleKey.F)); + app.SimulateSearchTyping("vault key"); + app.SimulateSearchKey(Bare(ConsoleKey.Enter)); + + var index = app.SearchBarIndex(Main); + await Assert.That(index).IsNotNull(); + + var rows = app.PaneLines(Main); + await Assert.That(rows[index!.Value]).Contains("(1 of 1)"); + await Assert.That(rows[index.Value]).Contains(SearchBarRenderer.NextChord); + await Assert.That(rows[index.Value + 1]).Contains("vault key"); + } + + /// ⌥G walks to the next hit and wraps, without the surface being reopened. + [Test] + public async Task AltGWalksToTheNextHitAndWraps() + { + var (app, session) = Bound(); + session.PrintSystem("*** the goblin snarls"); + session.PrintSystem("*** a quiet line"); + session.PrintSystem("*** the goblin falls"); + + app.SimulateKey(Ctrl(ConsoleKey.F)); + app.SimulateSearchTyping("goblin"); + app.SimulateSearchKey(Bare(ConsoleKey.Enter)); + var first = app.SearchBarIndex(Main)!.Value; + + app.SimulateKey(Alt(ConsoleKey.G)); + var second = app.SearchBarIndex(Main)!.Value; + await Assert.That(second).IsGreaterThan(first); + await Assert.That(app.PaneLines(Main)[second + 1]).Contains("goblin falls"); + + // And round again, rather than stopping at the end with nothing said. + app.SimulateKey(Alt(ConsoleKey.G)); + await Assert.That(app.PaneLines(Main)[app.SearchBarIndex(Main)!.Value + 1]).Contains("goblin snarls"); + } + + [Test] + public async Task AltGWithNothingSearchedForYetRefusesOutLoud() + { + var (app, _) = Bound(); + + app.SimulateKey(Alt(ConsoleKey.G)); + + await Assert.That(app.Messages.Entries.Any(m => m.Text.Contains("nothing has been searched for"))).IsTrue(); + } + + /// One bar, client-wide: a second landing moves it rather than leaving a trail. + [Test] + public async Task ASecondLandingMovesTheBarRatherThanAddingOne() + { + var (app, session) = Bound(); + session.PrintSystem("*** the goblin snarls"); + session.PrintSystem("*** the goblin falls"); + + app.SimulateKey(Ctrl(ConsoleKey.F)); + app.SimulateSearchTyping("goblin"); + app.SimulateSearchKey(Bare(ConsoleKey.Enter)); + app.SimulateKey(Alt(ConsoleKey.G)); + + var bars = app.PaneLines(Main).Count(l => l.Contains(Glyphs.Search)); + await Assert.That(bars).IsEqualTo(1); + } + + /// + /// The surface is modal chrome and sends nothing. A connected recording transport is the point: with + /// an unconnected session every "nothing reached the wire" assertion passes whatever the surface did. + /// + [Test] + public async Task NothingTheSurfaceDoesReachesTheWire() + { + Console.SetIn(TextReader.Null); + var config = DemoScene.Build(); + config.ScrollbackSpill.Enabled = false; + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(Width, Height)); + var telnet = new RecordingTelnetSession(); + app.TelnetFactory = _ => telnet; + var session = app.BindWorldWithoutConnecting(config.Worlds[0]); + await session.ConnectAsync(); + app.RenderSnapshot(); + session.PrintSystem("*** the goblin snarls"); + var before = telnet.Lines.Count; + + app.SimulateKey(Ctrl(ConsoleKey.F)); + app.SimulateSearchTyping("goblin"); + app.SimulateSearchKey(Bare(ConsoleKey.Enter)); + + await Assert.That(telnet.Lines.Count).IsEqualTo(before); + } + + /// + /// It refuses over the composer and says which surface is in the way — the composer's own guard is the + /// other half, and together they make the pair mutually exclusive rather than one-sided. + /// + [Test] + public async Task ItRefusesOverTheComposerAndSaysSo() + { + var (app, _) = Bound(); + app.SimulateKey(Bare(ConsoleKey.F1)); + + app.SimulateKey(Ctrl(ConsoleKey.F)); + + await Assert.That(app.SearchIsOpen).IsFalse(); + await Assert.That(app.Messages.Entries.Any(m => m.Text.Contains("composer"))).IsTrue(); + } +} From bee70319b336f440a2dfdb157bc411aea544d404 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Tue, 11 Aug 2026 15:48:22 -0500 Subject: [PATCH 6/6] docs(search): the four frames, the brief, and the demo's own scene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four views, because four things about ⌃F are only visible in a frame: a plain query with its hits marked, the same query read as a pattern (the header is the only place either state is said — compose/compose-literal's reasoning), the widened scope where the window column appears, and what ⏎ leaves behind. The last is over a split, so the pane is narrower than the terminal: that is the geometry that catches a landing scrolled to the wrong row. The demo scene now loads with _watching off. It pours a spawn window's whole history in before the first frame, so every line counted as missed and any frame that later made such a window visible carried an activity bar reporting the client's own setup as news — which is exactly what the first cut of the landed frame showed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- CLAUDE.md | 49 ++++++++++-- docs/design/README.md | 3 +- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 74 +++++++++++++++++++ .../SearchBarRendererTests.cs | 60 +++++++++++++++ 4 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 tests/SharpMUTerm.Tui.Tests/SearchBarRendererTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index f07a8f1..dab94d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,39 @@ fallbacks) for inline images/maps. Restored content is closed off by one `RestoreBarRenderer` row and the lines themselves are left alone. Restoring 3,000 lines costs ~18 ms before the first frame. `restore:` is the third member of the `save:`/`logRoot:` family — **null by default, so no test and no snapshot owns one**. +- **`⌃F` searches the output the client is holding** (`OutputSearch`, Core; `SearchPrompt`/`SearchSurface` + + `SearchBarRenderer`, Tui). A modal results surface — the `⌃R` idiom, and the same pure-prompt/host + split — over the pane buffers, with `⌥E` for regex, `⌥A` to widen from the focused window to every + window, `⏎` to go, and `⌥G` to walk to the next hit. Decisions worth not relitigating: + - **What is searched is the pane buffer**, not `WorldSession.Scrollback` and not the file-backed spill. + `RestoreLog`'s reasoning one layer over: a spawn window's lines reach neither, so a session-keyed + search would find nothing in exactly the windows people search hardest. The bound is *stated* — + `12 found · 4,812 lines held` — so a reader who cannot find an old line sees why rather than + concluding the search is broken. + - **`PaneLine.Plain` is held, not derived.** Matching runs over the visible text so a colour change + mid-word cannot split a match and `#ff0000` cannot find every red line (`UrlDetector`'s rule, one + layer down) — and it is computed once at append, because the surface refilters over every line of + every window on every keystroke. Chrome rows carry none, so a search cannot find its own bars. + - **Case is ignored in both modes**, matching `HistorySearch`; `(?-i)` is the way back, which is why + there is no third toggle. An invalid pattern is a *state* (a regex is unparseable most of the time it + is being typed), and there is a match timeout, because this runs on the UI thread per keystroke. + - **`⏎` goes through `Activate`**, the one activation path, so a hit in a background pane brings the + pane, the tab and the session forward together rather than scrolling something nobody is looking at. + One bar client-wide; `⌥G` moves it rather than leaving a trail. + - **`⌥G` removes the bar before re-running the search.** The bar is itself a row, so a search run + around it returns indices in a buffer about to lose one and every hit below it lands a row early. + - **Escape does not clear the bar.** A claimed Escape does not set `_escapeAt`, and `TryAltEnter` pairs + an unclaimed one with a following Enter to make `⌥⏎` — binding it here would break the newline chord + for as long as a bar was on screen, which nobody would connect to search. + - **There is no backward chord, and that was measured.** `⌥⇧G` would decode (`ProcessEscape` reads + Shift out of `char.IsUpper`), but kitty writes it as `CSI 103;4u` — a kitty-keyboard-protocol + sequence `DispatchCsi` drops. `⌥⇧1`'s story one letter over; a decode test is not an arrival test. + - **Two kinds of client chrome now live in the line buffers**, so `InsertChromeRow`/`RemoveChromeRow` + are the one place that fixes up everything indexing into one — the freeze point, the pending + boundary, the activity bar and the search bar move together or none of them do. + - **The demo scene loads with `_watching` off.** It pours a spawn window's whole history in before the + first frame, and every line would otherwise count as missed — so any frame that later made such a + window visible carried an activity bar reporting the client's own setup as news. - **Coming back to a window you were not watching leaves a bar where you left off, and that covers two different absences.** The *window* one is `NEW` and is the common case: a line lands while the window is not `Workspace.IsCaughtUp` — visible **and** at its live tail — and `_missedFrom` records the index @@ -419,7 +452,13 @@ python3 tools/ansi_frame_to_image.py frame.ansi frame.html # or .svg pane is left on its live tail, and the deep one, where more arrived than the pane holds and the client has scrolled the pane to the bar itself; the second is the only frame that can show a bottom-anchored pane being "caught up" while nothing has been read, and the only one that would catch a scroll landing - at the wrong row), `activity-bar` (the *other* absence — a window the reader was not watching: three + at the wrong row), `search`/`search-regex`/`search-all`/`search-landed` (the ⌃F surface: a plain + query with its hits marked, the same query read as a *pattern* — the header is the only place + either state is said, which is why they are a pair, `compose`/`compose-literal`'s reasoning — the + widened scope, where the window column appears and a background pane's hit is listed under it, and + what ⏎ leaves behind. The last is over a **split**, so the pane is narrower than the terminal: + that is the geometry that catches a landing scrolled to the wrong row, since a buffer index is not + a viewport row), `activity-bar` (the *other* absence — a window the reader was not watching: three lines land in the main window while Chat is in front of it, and picking main back lands on the `NEW` bar with those three under it. Separate from `away` because the two are separate facts with separate wording, and this is the one that happens many times an hour), `prefix-panel` (the ⌃B which-key @@ -586,11 +625,11 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] - **Deliberately left on Ctrl**, because the convention is worth more than the pattern: `⌃R` (readline's reverse history search), `⌃P` (command surface), `⌃Q` (quit — and safe here because `TerminalRawMode` clears `IXON`, so it is not XON), `⌃B` (tmux's prefix), `⌃O` (pane cycle), - `⌃N`/`⌃W`, and the command line's `⌃A`/`⌃E`/`⌃K`/`⌃U`/`⌃L`. A sweep that moved everything + `⌃N`/`⌃W`/`⌃F` (find), and the command line's `⌃A`/`⌃E`/`⌃K`/`⌃U`/`⌃L`. A sweep that moved everything would be as wrong as one that moved nothing. - - **Freeze is `⌥F`, and `⌃F` is find.** Freeze was on `⌃F` and left it for exactly the reason the - keys above stay where they are: `⌃F` means *find* to everyone who has used a computer, and that - convention outweighs freeze's claim on the chord. Freeze kept its letter and changed its modifier — + - **Freeze is `⌥F`, and `⌃F` is find** — the search surface, above. Freeze was on `⌃F` and left it for + exactly the reason the keys above stay where they are: `⌃F` means *find* to everyone who has used a + computer, and that convention outweighs freeze's claim on the chord. Freeze kept its letter and changed its modifier — the smallest move that frees it; `⌥F` is `ESC f`, measured. Nothing is left behind on `⌃F` as an alias (the `⌃D` rule). The label a **frozen** reader is looking at (`FreezeBarRenderer`) moves with the chord: a bar naming a key that no longer thaws the pane would be the worst possible place to diff --git a/docs/design/README.md b/docs/design/README.md index 4054d81..6947542 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -423,7 +423,8 @@ count on the tab, the rail character, and the rail world. ### Other keys -`⌃P` command surface · `⌥F` freeze/resume in focused pane · `⌃R` command-history search · +`⌃P` command surface · `⌃F` search the output (`⌥G` next hit) · `⌥F` freeze/resume in focused pane · +`⌃R` command-history search · `⌃N` next window · `⌥D`/`⌥R` disconnect/reconnect · `⌥↑`/`⌥↓` command history (`↑`/`↓` do it too, where the caret has nowhere further to go) · `⌥⏎`, or `⌃L`, newline in input · `F1` composer · `F2`–`F9` config · `Esc` close overlay. diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index c3d7ada..a6ac50e 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -845,7 +845,14 @@ public string RenderSnapshot(string? view = null) ShowTimestamps = true; } + // The demo scene is a scene, not an absence. It pours lines into windows the reader has never + // been in front of — a spawn window's whole history arrives before the first frame — and every + // one of them would count as missed, so any frame that later made such a window visible (a split, + // a tab change) would carry an activity bar reporting the client's own setup as news. Same + // reasoning as `_watching` itself, which the restore replay needs for the same shape of reason. + _watching = false; LoadDemoScene(); + _watching = true; // The reported bug, as a frame: the scene is already on screen with the column off, and *then* // the real ⌃P entry is dispatched. Under the old append-time gutter this frame was identical to @@ -1045,6 +1052,73 @@ public string RenderSnapshot(string? view = null) ReArmWholeFrame(); } + // The ⌃F search surface, over a client that has something to find. Four views, because four + // things about it are only visible in a frame: `search` is a plain query with its hits marked, + // `search-regex` is the same query read as a pattern (the header is the only place either state + // is said, which is why they are a pair — the same reasoning as compose/compose-literal), + // `search-all` is the widened scope, where the window column appears and hits from a pane the + // reader is not looking at are listed under it, and `search-landed` is what ⏎ leaves behind. + // + // Driven through the surface's own key handler, as `history-search-filter` is: the frame shows + // what the real filter produced rather than an impression of it. + if (view is not null && view.StartsWith("search", StringComparison.OrdinalIgnoreCase)) + { + // A split first, so the pane is narrower than the terminal. That is the geometry that catches + // a landing scrolled to the wrong row — a buffer index is not a viewport row, and in a narrow + // pane almost every line wraps. + if (string.Equals(view, "search-landed", StringComparison.OrdinalIgnoreCase)) + { + PaneCommands.Apply(_workspace.Layout, PaneCommand.SplitRight); + RebuildPaneArea(); + + // Laid out *before* the scene is loaded, so the windows are visible and at their live + // tails as the lines arrive. Without it the split's own rebuild leaves them uncaught-up + // for the length of the load, every line counts as missed, and the frame comes out + // carrying an activity bar — a true report of a state this view is not about. + RenderFrame(); + SettleScroll(); + } + + LoadLongScene(MainWindowId, 30); + foreach (var line in new[] + { + "The goblin snarls at you and misses.", + "You hit the goblin for 12 damage.", + "A goblin corpse lies here, still twitching.", + }) + { + AppendWindowLine(MainWindowId, MarkupText.Escape(line)); + } + + AppendWindowLine(DemoScene.ChatWindowId, MarkupText.Escape(" Ana: the goblin room is bugged")); + SettleScroll(); + + OpenSearchForSnapshot(); + if (string.Equals(view, "search-all", StringComparison.OrdinalIgnoreCase)) + { + SimulateSearchKey(new ConsoleKeyInfo('\0', ConsoleKey.A, false, true, false)); + } + + if (string.Equals(view, "search-regex", StringComparison.OrdinalIgnoreCase)) + { + SimulateSearchKey(new ConsoleKeyInfo('\0', ConsoleKey.E, false, true, false)); + SimulateSearchTyping(@"gobl\w+ (corpse|for)"); + } + else + { + SimulateSearchTyping("goblin"); + } + + if (string.Equals(view, "search-landed", StringComparison.OrdinalIgnoreCase)) + { + SimulateSearchKey(new ConsoleKeyInfo('\0', ConsoleKey.DownArrow, false, false, false)); + SimulateSearchKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + SettleScroll(); + } + + ReArmWholeFrame(); + } + // The other absence: a window the reader was not watching. `away` above is the terminal one, and // the two are worth separate frames because they are separate facts with separate wording — this // is the one that happens many times an hour, and the only frame where a NEW bar can be seen with diff --git a/tests/SharpMUTerm.Tui.Tests/SearchBarRendererTests.cs b/tests/SharpMUTerm.Tui.Tests/SearchBarRendererTests.cs new file mode 100644 index 0000000..abfc8d9 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/SearchBarRendererTests.cs @@ -0,0 +1,60 @@ +using SharpMUTerm.Tui; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// The bar drawn above the line ⌃F sent you to. Fourth of the boundary bars, and held to their rule: +/// mark the boundary, never restyle the content. +/// +public class SearchBarRendererTests +{ + [Test] + public async Task ItCarriesTheQueryTheOrdinalAndTheChordThatGoesToTheNextHit() + { + var bar = SearchBarRenderer.Bar("goblin", 12, 38, "#c678dd"); + + await Assert.That(bar).Contains($"[#c678dd]{Glyphs.Search} goblin (12 of 38)[/]"); + await Assert.That(bar).Contains(SearchBarRenderer.NextChord); + await Assert.That(bar).Contains("[dim]"); + await Assert.That(bar).Contains("─"); + } + + /// + /// The ordinal is what makes ⌥G legible: without it the bar moves and nothing says whether you are + /// getting closer to the end of the results or going round in circles. + /// + [Test] + public async Task TheOrdinalSaysWhereInTheResultsThisIs() + { + await Assert.That(SearchBarRenderer.Bar("key", 1, 1, "#c678dd")).Contains("(1 of 1)"); + } + + /// + /// The query is the reader's own text going into markup. A search for [public] must appear on + /// the bar rather than be eaten as a tag — the rule every renderer here follows for text it did not + /// write. + /// + [Test] + public async Task TheQueryIsEscapedRatherThanParsedAsMarkup() + { + var bar = SearchBarRenderer.Bar("[public]", 1, 2, "#c678dd"); + + await Assert.That(bar).Contains("[[public]]"); + } + + [Test] + public void ItRejectsAnEmptyAccent() + { + Assert.Throws(() => SearchBarRenderer.Bar("goblin", 1, 1, string.Empty)); + } + + /// + /// The chord it names is the only one there is. ⌥⇧G would be the obvious partner and cannot arrive — + /// kitty writes it as a CSI-u sequence this parser drops — so the bar must not offer it. + /// + [Test] + public async Task ItNamesNoChordThatCannotArrive() + { + await Assert.That(SearchBarRenderer.Bar("goblin", 1, 3, "#c678dd")).DoesNotContain("⇧"); + } +}