diff --git a/CLAUDE.md b/CLAUDE.md
index 9f9200c3..1486a1ee 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -112,6 +112,20 @@ fallbacks) for inline images/maps.
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.
+ - **Only windows a pane holds are searched**, `Workspace.WindowsFor`'s rule and for its reason: `⏎`
+ takes the reader to the hit, and `ActivateWindow` refuses a window no pane holds. `_lines` is wider
+ than the workspace deliberately — `RestorePreviousSession` buffers a restore log the workspace cannot
+ place under its own id, so its pane refills if that channel speaks again — and such a window has no
+ `WorkspaceWindow` to be titled from, so `WindowTitle` handed back the raw
+ `spawn:24:World|Character:Target` id. That was the reported "the results take up a small amount of
+ room": a sixty-cell window column of blank against a twenty-cell result. `GoToSearchHit` now honours
+ `Activate`'s answer as well, rather than inserting its bar into a buffer nothing paints.
+ - **Both dimensions are the room there is.** There is no unfiltered list to size to — an empty query
+ matches nothing — so the width used to be measured against the only content an empty surface has,
+ which is its own footer, and the surface opened at eighty-odd cells on any terminal while eliding
+ every result to fit a window sized by a key hint. The height always took the desktop; the width does
+ now too. `SearchPrompt.MaxLabelWidth` bounds the window column on top of that, because a title is not
+ this client's text to trust and every row is padded to the widest one.
- **`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
diff --git a/src/SharpMUTerm.Tui/SearchPrompt.cs b/src/SharpMUTerm.Tui/SearchPrompt.cs
index da1f5c85..63cc7a99 100644
--- a/src/SharpMUTerm.Tui/SearchPrompt.cs
+++ b/src/SharpMUTerm.Tui/SearchPrompt.cs
@@ -66,6 +66,15 @@ internal static class SearchPrompt
/// The longest an entry is drawn before it is elided, when no width is supplied.
private const int DefaultEntryWidth = 72;
+ ///
+ /// The widest the window column is drawn in ⌥A scope. Every row is padded to the widest label
+ /// in the list, so an unbounded column is a column one long title can spend the whole row on — and a
+ /// title is not this client's text to trust: Snippet caps it at sixty cells, which was sixty
+ /// cells of blank against a twenty-cell result. A label past this is elided, which is the rail's rule
+ /// (RailRenderer) one surface over.
+ ///
+ internal const int MaxLabelWidth = 18;
+
///
/// 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.
@@ -174,7 +183,9 @@ internal static List Render(
ArgumentNullException.ThrowIfNull(rows);
ArgumentNullException.ThrowIfNull(query);
- var labelWidth = all ? rows.Select(r => VisibleLength(Escape(r.WindowLabel))).DefaultIfEmpty(0).Max() : 0;
+ var labelWidth = all
+ ? Math.Min(MaxLabelWidth, rows.Select(r => r.WindowLabel.Length).DefaultIfEmpty(0).Max())
+ : 0;
var entryWidth = (width > 0 ? width : DefaultEntryWidth) - 4 - (labelWidth > 0 ? labelWidth + 2 : 0);
var lines = new List
@@ -230,18 +241,6 @@ internal static int Scroll(int first, int selected, int count, int listRows)
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
@@ -273,9 +272,7 @@ private static string QueryMarkup(string query) =>
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;
+ var label = labelWidth > 0 ? LabelCell(row.WindowLabel, labelWidth) : string.Empty;
if (selected)
{
@@ -300,6 +297,18 @@ private static string Row(SearchRow row, bool selected, int entryWidth, int labe
return $"{prefix}[{Value}]{before}[/][bold {Accent}]{hit}[/][{Value}]{after}[/]";
}
+ ///
+ /// One window-column cell: the label elided to the column and padded out to it, then the two-cell
+ /// gap. Padded by visible width, because a window may be called [Chat] and an escaped
+ /// bracket is two characters standing for one cell — PadRight shortened the column by one per
+ /// bracket and left that row's text a cell adrift of every other row's.
+ ///
+ private static string LabelCell(string label, int labelWidth)
+ {
+ var shown = label.Length <= labelWidth ? label : label[..(labelWidth - 1)] + "…";
+ return PadVisible(Escape(shown), labelWidth) + " ";
+ }
+
///
/// 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
diff --git a/src/SharpMUTerm.Tui/SearchSurface.cs b/src/SharpMUTerm.Tui/SearchSurface.cs
index 74121db9..fbf71c7f 100644
--- a/src/SharpMUTerm.Tui/SearchSurface.cs
+++ b/src/SharpMUTerm.Tui/SearchSurface.cs
@@ -139,11 +139,13 @@ private void Open()
// 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.
+ // unfiltered list to size to here — an empty query matches nothing — so *both* dimensions are the
+ // room there is rather than the room the results need. The height always was; the width was
+ // measured against the only content an empty surface has, which is its own footer, so it opened
+ // at eighty-odd cells on any terminal and every result was elided to fit a window sized by a key
+ // hint. What the surface holds is a game's own lines, and they are wider than that by design.
_listRows = Math.Max(3, desktop.Height - ChromeRows - 6);
- _contentWidth = Math.Clamp(
- SearchPrompt.MaxWidth(Lines) + 2, MinimumWidth, Math.Max(MinimumWidth, desktop.Width - 6));
+ _contentWidth = 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));
diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs
index b2ab11f1..09bf4b71 100644
--- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs
+++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs
@@ -3326,7 +3326,14 @@ private void GoToSearchHit(SearchRow row, string query, int ordinal, int total)
return;
}
- Activate(row.WindowId);
+ // Honoured, not fired and forgotten: a window no pane holds cannot be activated, and going on
+ // would insert the bar into a buffer nothing paints and then scroll a pane that is not there.
+ // SearchableWindows already keeps such a window out of the corpus; this is the second line.
+ if (!Activate(row.WindowId))
+ {
+ RefuseCommand($"{Snippet(WindowTitle(row.WindowId))} is not in any pane");
+ return;
+ }
var at = Math.Clamp(row.LineIndex, 0, buffer.Count);
InsertChromeRow(row.WindowId, at, SearchBarRenderer.Bar(query, ordinal, total, FrozenAccentHex()));
@@ -3946,6 +3953,17 @@ private void ToggleSearch()
/// 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).
///
+ ///
+ /// Only windows a pane actually holds are searched — 's rule,
+ /// and for its reason: ⏎ takes the reader to the hit, and
+ /// refuses a window no pane holds, so a hit in one is a row that can never be shown. _lines is
+ /// wider than the workspace on purpose — buffers a restore log
+ /// the workspace cannot place under its own id, so its pane refills if that channel speaks again —
+ /// and those ids have no to be titled from, so
+ /// gave back the raw spawn:24:World|Character:Target id. That is
+ /// what padded the window column to sixty cells and squeezed every result into the twenty that were
+ /// left: the reported "the results take up a small amount of room".
+ ///
///
private IReadOnlyList SearchableWindows(bool all)
{
@@ -3954,6 +3972,7 @@ private IReadOnlyList SearchableWindows(bool all)
: new[] { ActiveWindowId() }.Where(_lines.ContainsKey);
return ids
+ .Where(id => _workspace.Layout.FindWindow(id) is not null)
.Select(id => new SearchCorpus(
id,
Snippet(WindowTitle(id)),
diff --git a/tests/SharpMUTerm.Tui.Tests/SearchEndToEndTests.cs b/tests/SharpMUTerm.Tui.Tests/SearchEndToEndTests.cs
index f4a90826..a975a991 100644
--- a/tests/SharpMUTerm.Tui.Tests/SearchEndToEndTests.cs
+++ b/tests/SharpMUTerm.Tui.Tests/SearchEndToEndTests.cs
@@ -42,6 +42,29 @@ private static (SharpMUTermApp App, WorldSession Session) Bound()
private static ConsoleKeyInfo Bare(ConsoleKey key) => new('\0', key, false, false, false);
+ ///
+ /// The surface opens at the room the terminal has, not at the width of its own footer. It has no
+ /// unfiltered list to size to — an empty query matches nothing — so it used to measure the only
+ /// content it had, which is the key hints, and open at eighty-odd cells on any terminal; the results
+ /// were then elided to fit a window sized by a hint. The height has always taken the room there is,
+ /// and this is the other half of that rule.
+ ///
+ [Test]
+ public async Task TheSurfaceOpensAtTheRoomTheTerminalHasRatherThanAtItsFootersWidth()
+ {
+ var (app, session) = Bound();
+ session.PrintSystem("*** the vault key is behind the bar");
+
+ app.SimulateKey(Ctrl(ConsoleKey.F));
+ app.SimulateSearchTyping("vault");
+ var rows = FrameGrid.Decode(app.RenderWholeFrame(), Width, Height);
+
+ var footer = rows.Single(r => r.Contains("type to search", StringComparison.Ordinal));
+ await Assert.That(footer.TrimEnd().Length).IsGreaterThan(Width - 8);
+ await Assert.That(rows.Any(r => r.Contains("vault key is behind the bar", StringComparison.Ordinal)))
+ .IsTrue();
+ }
+
[Test]
public async Task CtrlFOpensTheSurfaceAndCtrlFAgainClosesIt()
{
diff --git a/tests/SharpMUTerm.Tui.Tests/SearchPromptTests.cs b/tests/SharpMUTerm.Tui.Tests/SearchPromptTests.cs
index 8cb9daf3..ca4321a3 100644
--- a/tests/SharpMUTerm.Tui.Tests/SearchPromptTests.cs
+++ b/tests/SharpMUTerm.Tui.Tests/SearchPromptTests.cs
@@ -228,6 +228,66 @@ public async Task AnUnselectedRowMarksWhereTheQueryLanded()
await Assert.That(lines.Any(l => l.Contains("[bold ") && l.Contains("goblin"))).IsTrue();
}
+ ///
+ /// Every row is padded to the widest label, so an unbounded window column is one long title spending
+ /// the whole row. The reported frame had a sixty-cell column of blank against a twenty-cell result:
+ /// a restore log the workspace could not place was searched under its raw
+ /// spawn:24:World|Character:Target id. keeps such a window
+ /// out of the corpus; this is the bound that holds whatever a title turns out to be.
+ ///
+ [Test]
+ public async Task ALongWindowNameIsElidedRatherThanSpendingTheRowOnItself()
+ {
+ var rows = new[]
+ {
+ new SearchRow("main", "main", 12, "The goblin snarls at you.", 4, 6),
+ new SearchRow(
+ "spawn:24:Convergence MUSH|Mannaz:O-Gatecrashers",
+ "spawn:24:Convergence MUSH|Mannaz:O-Gatecrashers",
+ 3,
+ " Ana: goblin room is bugged",
+ 11,
+ 6),
+ };
+
+ var listed = SearchPrompt.Render(rows, "gob", null, false, true, "main", 10, -1, width: 100)
+ .Where(l => l.Contains("goblin"))
+ .Select(MarkupText.Plain)
+ .ToArray();
+
+ await Assert.That(listed.Length).IsEqualTo(2);
+ await Assert.That(string.Join('\n', listed)).DoesNotContain("O-Gatecrashers");
+
+ // The column is the bound rather than the label: three cells of pointer, the column, two of gap.
+ await Assert.That(listed[0].IndexOf("The goblin", StringComparison.Ordinal))
+ .IsEqualTo(SearchPrompt.MaxLabelWidth + 5);
+ await Assert.That(listed[1].IndexOf("", StringComparison.Ordinal))
+ .IsEqualTo(SearchPrompt.MaxLabelWidth + 5);
+ }
+
+ ///
+ /// A window may be called [Chat]. Escaping turns each bracket into two characters standing for
+ /// one cell, so a column padded by string.Length comes up a cell short per bracket and leaves
+ /// that row's text adrift of every other row's.
+ ///
+ [Test]
+ public async Task ABracketedWindowNamePadsToTheSameColumnAsEveryOtherRow()
+ {
+ var rows = new[]
+ {
+ new SearchRow("main", "[Chat]", 12, "The goblin snarls at you.", 4, 6),
+ new SearchRow("spawn:chat", "Ansible", 3, "The goblin room is bugged", 4, 6),
+ };
+
+ var listed = SearchPrompt.Render(rows, "gob", null, false, true, "main", 10, -1, width: 100)
+ .Where(l => l.Contains("goblin"))
+ .Select(l => MarkupText.Plain(l).IndexOf("The goblin", StringComparison.Ordinal))
+ .ToArray();
+
+ await Assert.That(listed.Length).IsEqualTo(2);
+ await Assert.That(listed[0]).IsEqualTo(listed[1]);
+ }
+
[Test]
public async Task ScrollKeepsThePointedAtRowInsideTheListArea()
{
diff --git a/tests/SharpMUTerm.Tui.Tests/SpawnWindowIdUpgradeTests.cs b/tests/SharpMUTerm.Tui.Tests/SpawnWindowIdUpgradeTests.cs
index 77b7fd46..da311b40 100644
--- a/tests/SharpMUTerm.Tui.Tests/SpawnWindowIdUpgradeTests.cs
+++ b/tests/SharpMUTerm.Tui.Tests/SpawnWindowIdUpgradeTests.cs
@@ -168,6 +168,48 @@ public async Task AnOldLogNoPaneClaimsIsLeftWhereItIs()
await Assert.That(app.WindowIds().Any(id => id.EndsWith(":Tells", StringComparison.Ordinal))).IsFalse();
}
+ ///
+ /// …and ⌃F does not offer it. Those lines are buffered under an id no pane holds, so ⏎ on one has
+ /// nowhere to take the reader — Workspace.ActivateWindow refuses a window with no pane, and
+ /// the surface would insert its bar into a buffer nothing paints. It was also drawing them under the
+ /// raw spawn:24:World|Character:Target id, which padded the window column to sixty cells and
+ /// left every result squeezed into what was left: the reported "the results take up a small amount
+ /// of room". holds the rest of ⌥A; this is the corpus it looks in.
+ ///
+ [Test]
+ public async Task ABufferedWindowNoPaneHoldsIsNotSearched()
+ {
+ using var root = new TempRoot();
+ SeedLegacyLog(root);
+ using (var seed = new RestoreLog(root.Path))
+ {
+ seed.Append("spawn:Tells", "Tells", StyledLine.FromText("Rivane pages: hello", TextStyle.Default), "09:24");
+ }
+
+ var config = OldConfiguration();
+ using var log = new RestoreLog(root.Path, config.RestoreLog);
+ Console.SetIn(TextReader.Null);
+ await using var app = new SharpMUTermApp(
+ config, Headless, new HeadlessConsoleDriver(Width, Height), restore: log);
+
+ // The placed window's restored lines are found, so an empty result for the other one is the pane
+ // rule at work rather than a search that finds nothing restored at all.
+ app.SimulateKey(new ConsoleKeyInfo('\0', ConsoleKey.F, false, false, true));
+ app.SimulateSearchKey(new ConsoleKeyInfo('\0', ConsoleKey.A, false, true, false));
+ app.SimulateSearchTyping("crypt run");
+ await Assert.That(app.SearchRows.Count).IsEqualTo(1);
+
+ foreach (var _ in "crypt run")
+ {
+ app.SimulateSearchKey(new ConsoleKeyInfo('\0', ConsoleKey.Backspace, false, false, false));
+ }
+
+ app.SimulateSearchTyping("Rivane pages");
+
+ await Assert.That(app.SearchRows).IsEmpty();
+ await Assert.That(log.Read().Any(w => w.WindowId == "spawn:Tells")).IsTrue();
+ }
+
///
/// And the fix survives the round trip it is most likely to be undone by. Two characters capture one
/// target, the workspace is saved and reopened, and each still has a pane of their own holding their