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/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. 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/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/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/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/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/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 c11f360..a6ac50e 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 @@ -823,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 @@ -1023,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 @@ -2403,7 +2499,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 @@ -2446,6 +2544,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)) @@ -2724,11 +2830,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; @@ -2799,11 +2901,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 { @@ -2982,42 +3080,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; + } + + at = Math.Clamp(at, 0, buffer.Count); + buffer.Insert(at, new PaneLine(markup)); + + if (_freezePoints.TryGetValue(windowId, out var freeze) && freeze > at) { - return _awayMarks.Remove(windowId); + _freezePoints[windowId] = freeze + 1; } - buffer.RemoveAt(mark.Index); - _awayMarks.Remove(windowId); + if (_awayMarks.TryGetValue(windowId, out var mark) && mark.Index >= at) + { + mark.Index++; + } - if (_freezePoints.TryGetValue(windowId, out var freeze) && freeze > 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 @@ -3448,7 +3775,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 @@ -3489,6 +3877,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"; @@ -4909,6 +5298,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. @@ -4951,6 +5348,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, }; } @@ -8273,6 +8674,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.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(); + } +} 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/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)); + } +} 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("⇧"); + } +} 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(); + } +} 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); + } +}