Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 25 additions & 16 deletions src/SharpMUTerm.Tui/SearchPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ internal static class SearchPrompt
/// <summary>The longest an entry is drawn before it is elided, when no width is supplied.</summary>
private const int DefaultEntryWidth = 72;

/// <summary>
/// The widest the window column is drawn in <c>⌥A</c> 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: <c>Snippet</c> 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
/// (<c>RailRenderer</c>) one surface over.
/// </summary>
internal const int MaxLabelWidth = 18;

/// <summary>
/// What one keystroke does, and the state it leaves behind. <paramref name="count"/> is how many rows
/// are listed, so the pointer wraps within what is actually on screen.
Expand Down Expand Up @@ -174,7 +183,9 @@ internal static List<string> 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<string>
Expand Down Expand Up @@ -230,18 +241,6 @@ internal static int Scroll(int first, int selected, int count, int listRows)
return Math.Clamp(top, 0, count - listRows);
}

/// <summary>The visible width of the widest rendered line — used to size the surface to its content.</summary>
internal static int MaxWidth(IReadOnlyList<string> lines)
{
var max = 0;
foreach (var line in lines)
{
max = Math.Max(max, VisibleLength(line));
}

return max;
}

/// <summary>
/// The two toggles and what they currently mean, in words rather than in glyphs: the surface has to
/// be able to say <em>which way they are set</em>, because both change what a query finds and neither
Expand Down Expand Up @@ -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)
{
Expand All @@ -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}[/]";
}

/// <summary>
/// One window-column cell: the label elided to the column and padded out to it, then the two-cell
/// gap. Padded by <em>visible</em> width, because a window may be called <c>[Chat]</c> and an escaped
/// bracket is two characters standing for one cell — <c>PadRight</c> shortened the column by one per
/// bracket and left that row's text a cell adrift of every other row's.
/// </summary>
private static string LabelCell(string label, int labelWidth)
{
var shown = label.Length <= labelWidth ? label : label[..(labelWidth - 1)] + "…";
return PadVisible(Escape(shown), labelWidth) + " ";
}

/// <summary>
/// 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
Expand Down
10 changes: 6 additions & 4 deletions src/SharpMUTerm.Tui/SearchSurface.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
21 changes: 20 additions & 1 deletion src/SharpMUTerm.Tui/SharpMUTermApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down Expand Up @@ -3946,6 +3953,17 @@ private void ToggleSearch()
/// activity boundary and <c>RepaintPanes</c> make. Labels go through <see cref="Snippet"/>: a window
/// title can be a <em>world's</em> text (the web view is titled from the page it loaded).
/// </para>
/// <para>
/// <b>Only windows a pane actually holds are searched</b> — <see cref="Workspace.WindowsFor"/>'s rule,
/// and for its reason: ⏎ takes the reader to the hit, and <see cref="Workspace.ActivateWindow"/>
/// refuses a window no pane holds, so a hit in one is a row that can never be shown. <c>_lines</c> is
/// wider than the workspace on purpose — <see cref="RestorePreviousSession"/> 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 <see cref="WorkspaceWindow"/> to be titled from, so
/// <see cref="WindowTitle"/> gave back the raw <c>spawn:24:World|Character:Target</c> 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".
/// </para>
/// </summary>
private IReadOnlyList<SearchCorpus> SearchableWindows(bool all)
{
Expand All @@ -3954,6 +3972,7 @@ private IReadOnlyList<SearchCorpus> 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)),
Expand Down
23 changes: 23 additions & 0 deletions tests/SharpMUTerm.Tui.Tests/SearchEndToEndTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,29 @@ private static (SharpMUTermApp App, WorldSession Session) Bound()

private static ConsoleKeyInfo Bare(ConsoleKey key) => new('\0', key, false, false, false);

/// <summary>
/// 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.
/// </summary>
[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()
{
Expand Down
60 changes: 60 additions & 0 deletions tests/SharpMUTerm.Tui.Tests/SearchPromptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,66 @@ public async Task AnUnselectedRowMarksWhereTheQueryLanded()
await Assert.That(lines.Any(l => l.Contains("[bold ") && l.Contains("goblin"))).IsTrue();
}

/// <summary>
/// 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
/// <c>spawn:24:World|Character:Target</c> id. <see cref="SearchEndToEndTests"/> keeps such a window
/// out of the corpus; this is the bound that holds whatever a title turns out to be.
/// </summary>
[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,
"<OOC> 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("<OOC>", StringComparison.Ordinal))
.IsEqualTo(SearchPrompt.MaxLabelWidth + 5);
}

/// <summary>
/// A window may be called <c>[Chat]</c>. Escaping turns each bracket into two characters standing for
/// one cell, so a column padded by <c>string.Length</c> comes up a cell short per bracket and leaves
/// that row's text adrift of every other row's.
/// </summary>
[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()
{
Expand Down
42 changes: 42 additions & 0 deletions tests/SharpMUTerm.Tui.Tests/SpawnWindowIdUpgradeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,48 @@ public async Task AnOldLogNoPaneClaimsIsLeftWhereItIs()
await Assert.That(app.WindowIds().Any(id => id.EndsWith(":Tells", StringComparison.Ordinal))).IsFalse();
}

/// <summary>
/// …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 — <c>Workspace.ActivateWindow</c> 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 <c>spawn:24:World|Character:Target</c> 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". <see cref="SearchEndToEndTests"/> holds the rest of ⌥A; this is the corpus it looks in.
/// </summary>
[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();
}

/// <summary>
/// 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
Expand Down
Loading