From 4566684fb075c05f3264aec31c6ecf479c8902db Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 30 Jul 2026 14:59:38 -0500 Subject: [PATCH 01/11] =?UTF-8?q?Alt+1=E2=80=939=20go=20to=20a=20numbered?= =?UTF-8?q?=20pane,=20and=20the=20numbering=20is=20the=20one=20on=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request was "ALT-1 2 3 4 5, or CTRL-1 2 3 4 5 — go to those numbered panes and bring them to the forefront". Alt, and nine rather than five. Ctrl+digit is not a chord this terminal has, read off a pty with `kitten @ send-key` at a raw-mode reader rather than remembered: every Alt+digit is ESC + the digit, while Ctrl+1/9/0 are the bare digits, Ctrl+2 is NUL, Ctrl+3 is Escape, Ctrl+4–7 are 0x1C–0x1F and Ctrl+8 is Backspace. Three of those are keys this client cannot afford to bind over and three are indistinguishable from typing. MacroKeys.DigitBytes records it per digit, which is what F4 now prints instead of "Ctrl+digit is dropped". Nine because that is the range the framework's own Alt+digit handler claims. InputCoordinator.HandleAltInput selects among top-level windows by index; it is reached from ProcessInput's fall-through for any unhandled Alt chord and — unlike the move and resize handlers beside it — is not gated on IsMovable/IsResizable, so Movable(false) did not switch it off. All nine digits are claimed as application shortcuts, in range or not: an out-of-range ⌥7 reports and stops rather than falling through to it. ⌥0 is left free, and stays bindable as a macro. The number is the rail's number: Layout.Panes order, which is what the sidebar's hosting column spells `pane N`. PaneLabel — the move and drag overlays — called the first pane `main`, the spelling the rail abandoned because `▪ main main` is two meanings in one line; the same pane read `pane 1` in the sidebar and `main` under the cursor. Harmless until a chord had to land on the pane a label names. Bringing a pane forward is the full Activate, so the command line ends up talking to the character whose pane you landed on. A zoom is carried with the jump (WorkspaceLayout.CarryZoomToFocused): a zoomed workspace realises one pane, so a mover that left the zoom behind put the selection, the session and the caret on a pane that is not on the screen. ⌃O had that bug and gets the same fix; the directional movers cannot and refuse out loud. Advertised in --help, in ⌃P (one `Go to pane N` entry per pane that exists, only on a split), and in MacroKeys.AppShortcuts so F4 says which pane has each digit and a claim with no action fails at startup. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GpL7Ht6sLBsSEtVNsYcXMM --- CLAUDE.md | 38 +- .../Commands/CommandCatalog.cs | 23 + src/SharpMUTerm.Core/Commands/CommandIds.cs | 21 + .../Workspace/WorkspaceLayout.cs | 25 + src/SharpMUTerm.Tui/MacroKeys.cs | 83 ++- src/SharpMUTerm.Tui/Program.cs | 10 + src/SharpMUTerm.Tui/SharpMUTermApp.cs | 131 +++- .../Commands/CommandCatalogTests.cs | 48 ++ .../Workspace/WorkspaceLayoutTests.cs | 28 + .../MacroKeyCaptureTests.cs | 8 +- tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs | 681 ++++++++++++++++++ 11 files changed, 1081 insertions(+), 15 deletions(-) create mode 100644 tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index c50d8402..11d14b95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,8 +114,8 @@ fallbacks) for inline images/maps. ```bash dotnet run -c Release --project tests/SharpMUTerm.Core.Tests Build( items.Add(new CommandItem(CommandGroup.Layout, "Focus pane down", "layout:focus-down", "⌃↓")); items.Add(new CommandItem(CommandGroup.Layout, "Focus the next pane", "layout:cycle", "⌃O · ⌃B o")); + // Numbered pane jumps, one entry per pane that exists — the one group here that is *not* listed + // unconditionally, because "Go to pane 4" on a workspace with two panes names a place there is no + // way to make. The rail already numbers the panes the same way in its hosting column, so the entry + // and the label a user is reading off the sidebar are the same number; that is the whole point of + // deriving both from Panes order rather than spelling either out. + // + // Only the first nine carry a chord: ⌥0 is not claimed (it stays bindable as a macro, and the + // framework's own Alt+digit handler ignores it), so a tenth pane gets an entry with no subtitle + // rather than one naming a key that does something else. An entry with no chord is the honest + // shape for a place only the mouse, ⌃O and the arrows can reach. + var paneCount = workspace.Layout.Panes.Count; + if (paneCount > 1) + { + for (var n = 1; n <= paneCount; n++) + { + items.Add(new CommandItem( + CommandGroup.Layout, + $"Go to pane {n}", + CommandIds.Pane(n), + n <= CommandIds.PaneJumpDigits ? $"⌥{n}" : null)); + } + } + // Pane size, in the plain words the request used ("increase/decrease a pane's horizontal or // vertical character size") rather than in the chord's terms. Listed for the same reason the // directional entries and the newline chord are: ⌥⇧+arrow is not a chord anybody guesses, and a diff --git a/src/SharpMUTerm.Core/Commands/CommandIds.cs b/src/SharpMUTerm.Core/Commands/CommandIds.cs index 02cd4f67..d297fe32 100644 --- a/src/SharpMUTerm.Core/Commands/CommandIds.cs +++ b/src/SharpMUTerm.Core/Commands/CommandIds.cs @@ -21,4 +21,25 @@ public static class CommandIds /// The id that activates the window named by . public static string Window(string windowId) => WindowPrefix + windowId; + + /// Prefix of a "go to this numbered pane" id; the remainder is the pane's 1-based number. + public const string PanePrefix = "layout:pane-"; + + /// + /// How many panes have a keyboard chord of their own: ⌥1–⌥9. Nine rather than the five that were + /// asked for because nine is what the digit row spells with one modifier, what the terminal's Alt + /// encoding covers (ESC + a printable digit), and what the framework's own Alt+1–9 window + /// selector claims — leaving one of those digits unclaimed would hand it back to that selector. + /// Panes past the ninth are still reachable by ⌃O, the arrows and the rail; they simply have no + /// chord, and no surface claims otherwise. + /// + public const int PaneJumpDigits = 9; + + /// + /// The id that focuses the th pane, counting the way every surface in this + /// client counts panes: order, which is + /// left-to-right then top-to-bottom, which is the order the connection rail's pane N column + /// numbers them in. The chord (⌥N), the rail's label and this id are three spellings of one number. + /// + public static string Pane(int number) => PanePrefix + number.ToString(System.Globalization.CultureInfo.InvariantCulture); } diff --git a/src/SharpMUTerm.Core/Workspace/WorkspaceLayout.cs b/src/SharpMUTerm.Core/Workspace/WorkspaceLayout.cs index 3aada187..eb85117b 100644 --- a/src/SharpMUTerm.Core/Workspace/WorkspaceLayout.cs +++ b/src/SharpMUTerm.Core/Workspace/WorkspaceLayout.cs @@ -138,6 +138,31 @@ public void CloseFocused() public void ToggleZoom() => ZoomedPaneId = ZoomedPaneId == FocusedPaneId ? null : FocusedPaneId; + /// + /// Re-points an existing zoom at whichever pane is now focused, and says whether it moved. Returns + /// false when nothing is zoomed — this never starts a zoom, it only carries one. + /// + /// Focus and zoom are separate fields, and a mover that changes one without the other leaves the + /// focused pane off screen: a zoomed workspace realises exactly one pane, so the selection, + /// the session the command line talks to and the caret would all be on a pane the user cannot see — + /// attention on one pane and keystrokes to another, which is the defect the per-window work exists to + /// eliminate. The ordinal movers (cycle, and jump-to-number) therefore carry the zoom with them, and + /// so the pane you asked for is the pane that fills the screen. The directional movers do + /// not, and cannot: while one pane is realised there is no pane "to the left" to ask for, which is + /// why ⌃← refuses out loud instead. + /// + /// + public bool CarryZoomToFocused() + { + if (ZoomedPaneId is null || ZoomedPaneId == FocusedPaneId) + { + return false; + } + + ZoomedPaneId = FocusedPaneId; + return true; + } + /// Toggles the frozen (split-scrollback) state of the focused pane. public void ToggleFreezeFocused() { diff --git a/src/SharpMUTerm.Tui/MacroKeys.cs b/src/SharpMUTerm.Tui/MacroKeys.cs index d4b2f005..feb7d0bb 100644 --- a/src/SharpMUTerm.Tui/MacroKeys.cs +++ b/src/SharpMUTerm.Tui/MacroKeys.cs @@ -1,4 +1,5 @@ using SharpMUTerm.Core.Automation; +using SharpMUTerm.Core.Commands; namespace SharpMUTerm.Tui; @@ -63,7 +64,40 @@ internal static class MacroKeys /// this list rather than alongside it, so a key the app takes is a key the keypad screen knows is /// taken — there is one list, not two that agree until someone edits one. /// - internal static IReadOnlyList AppShortcuts { get; } = new AppShortcut[] + internal static IReadOnlyList AppShortcuts { get; } = BuildAppShortcuts(); + + /// + /// The pane number a claimed stands for, or null when the key is not one of + /// the nine digits ⌥1–⌥9 are registered on. The one place the mapping is written down, so the + /// registration, the F4 screen and the app's action cannot disagree about which digit is which pane. + /// + internal static int? PaneJumpNumber(ConsoleKey key) => + key >= ConsoleKey.D1 && key <= ConsoleKey.D0 + CommandIds.PaneJumpDigits + ? key - ConsoleKey.D0 + : null; + + /// + /// The fixed claims are a local rather than a field: a static field initialiser runs in declaration + /// order, so a field read from this method would still be null when the property above calls it. + /// + private static AppShortcut[] BuildAppShortcuts() + { + var claims = new List(Fixed()); + + // ⌥1–⌥9 — jump to a numbered pane. Generated rather than written out nine times so the digit, the + // pane number and the sentence F4 prints are one expression; and claimed *here*, in the list the + // app registers from, because the framework claims Alt+1–9 for its own top-level window selector + // and that handler is not gated on anything we can switch off (see SharpMUTermApp.JumpToPane). + // Every one of the nine is claimed, in range or not — an unclaimed digit would fall through to it. + for (var n = 1; n <= CommandIds.PaneJumpDigits; n++) + { + claims.Add(new AppShortcut(ConsoleModifiers.Alt, ConsoleKey.D0 + n, $"goes to pane {n}")); + } + + return claims.ToArray(); + } + + private static AppShortcut[] Fixed() => new AppShortcut[] { new(ConsoleModifiers.Control, ConsoleKey.Q, "asks whether to quit"), new(ConsoleModifiers.Control, ConsoleKey.N, "picks the next window"), @@ -206,9 +240,12 @@ internal static MacroKeyVerdict Verdict(string? descriptor) /// /// What a letter or a digit is worth. Unmodified it is typing; with Ctrl it is a control byte, which - /// carries no Shift or Alt of its own and which four letters cannot produce at all because the - /// terminal spells those bytes Tab, Enter and Backspace; with Alt it is an ESC prefix, except for the - /// one letter the parser has already spent on its own SS3 introducer. + /// carries no Shift or Alt of its own, which four letters cannot produce at all because the terminal + /// spells those bytes Tab, Enter and Backspace, and which no digit produces usefully (see + /// ); with Alt it is an ESC prefix, except for the one letter the parser has + /// already spent on its own SS3 introducer. Alt+digit is the one modifier the digit row does deliver, + /// which is why the pane-jump chords are on it — then reports ⌥1–⌥9 as + /// taken through , and ⌥0 stays free for a macro. /// private static MacroKeyVerdict Chord(MacroKeyParts parts) { @@ -224,9 +261,9 @@ private static MacroKeyVerdict Chord(MacroKeyParts parts) return Never("a Ctrl chord loses Shift"); } - if (IsDigit(parts.Key)) + if (DigitBytes.TryGetValue(parts.Key, out var digitSpelling)) { - return Never("Ctrl+digit is dropped"); + return Never($"the terminal sends {digitSpelling} instead"); } return ControlBytes.TryGetValue(parts.Key, out var spelt) @@ -239,6 +276,40 @@ private static MacroKeyVerdict Chord(MacroKeyParts parts) : new MacroKeyVerdict(MacroKeyDelivery.Fires); } + /// + /// What a terminal actually writes for Ctrl+each digit — the reason the pane-jump chord is + /// Alt and not Ctrl, which is what was asked for. + /// + /// There is no Ctrl+digit encoding to speak of. The digit row has no control bytes of its own beyond + /// an accident of the ASCII table, so a terminal either sends the bare digit (1, 9, 0 — a chord the + /// app cannot even tell from typing) or a byte that already belongs to another key. Three of those are + /// keys this client cannot afford to lose: Ctrl+3 is Escape, which is how every overlay + /// here is closed and half of the Alt+⏎ reassembly; Ctrl+8 is Backspace; Ctrl+2 + /// is NUL. Binding any of them would break the plain key rather than add a chord — the same trap + /// records for Ctrl+H/I/J/M. + /// + /// + /// Observed, not remembered: the bytes were read off a real pty by driving each chord at a raw-mode + /// reader with kitten @ send-keyESC+digit for every Alt+digit, and this table for + /// Ctrl. A decode test could not have answered it, for the reason + /// TerminalKeyArrivalTests spells out: what the parser makes of a byte sequence is a smaller + /// claim than what the terminal sends. + /// + /// + private static readonly Dictionary DigitBytes = new(StringComparer.Ordinal) + { + ["0"] = "a bare 0", + ["1"] = "a bare 1", + ["2"] = "NUL", + ["3"] = "Escape", + ["4"] = "0x1C", + ["5"] = "0x1D", + ["6"] = "0x1E", + ["7"] = "0x1F", + ["8"] = "Backspace", + ["9"] = "a bare 9", + }; + /// The letters whose control byte the terminal has already spent on another key. private static readonly Dictionary ControlBytes = new(StringComparer.Ordinal) { diff --git a/src/SharpMUTerm.Tui/Program.cs b/src/SharpMUTerm.Tui/Program.cs index 0bf7b95a..fc91284a 100644 --- a/src/SharpMUTerm.Tui/Program.cs +++ b/src/SharpMUTerm.Tui/Program.cs @@ -252,6 +252,16 @@ private static void WriteUsage(TextWriter usage) usage.WriteLine(" command line); Ctrl+O cycles them; Tab switches command lines. The pane you are on and"); usage.WriteLine(" the line Enter sends from are both drawn lit, and the focused pane's tab is marked."); + // Alt, not Ctrl, and the page says why: Ctrl+digit is what a reader will try first (it is what was + // asked for) and it cannot work — no digit has a control byte of its own, so a terminal sends the + // bare digit, or one already spelt Escape or Backspace. Naming the working chord and the reason + // the obvious one is absent is the same honesty this page owes everywhere else. + usage.WriteLine("Panes: Alt+1..Alt+9 go straight to a numbered pane and bring it forward — the numbers the"); + usage.WriteLine(" sidebar shows beside each window ('pane 2', 'pane 3'...), counted left to right then"); + usage.WriteLine(" top to bottom. It says so when there is no pane with that number. Ctrl+digit is not"); + usage.WriteLine(" offered: no terminal sends a distinct Ctrl+digit — 3 and 8 arrive as Escape and"); + usage.WriteLine(" Backspace, and 1, 9 and 0 as the bare digit."); + // Alt+Shift+arrow is a chord this host does deliver — the parser reads both modifier bits out of // CSI 1;4 — which is why it may be named here at all; see TerminalKeyArrivalTests. It is // deliberately not Ctrl+Shift+arrow, which this page used to name: kitty_mod is ctrl+shift, and diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index 19130590..a5ef3c79 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -2699,6 +2699,68 @@ private void MoveFocus(PaneDirection direction) RefuseFocusMove(direction); } + /// + /// Goes to the th pane and brings it to the front — ⌥1–⌥9, and the ⌃P + /// Go to pane N entries. + /// + /// The number is the rail's number. Panes are counted in Layout.Panes order + /// (left-to-right, then top-to-bottom), which is the order the connection rail's hosting column + /// numbers them in, so ⌥3 goes to the pane the sidebar labels pane 3. There is no second + /// numbering to reconcile any more: the drag and move overlays used to call the first pane + /// main while the rail called it pane 1 (see ), which is a + /// mismatch a chord cannot survive — a key that lands somewhere other than the label says is worse + /// than no key. + /// + /// + /// Why Alt, and why the framework had to be outranked. Ctrl+digit was what was asked for and it + /// is not a chord this terminal has: the digit row has no control bytes of its own, so a terminal + /// sends the bare digit for 1/9/0 and, for the rest, a byte already spelt Escape, Backspace or NUL + /// (MacroKeys's DigitBytes, read off a real pty). Alt+digit is ESC + the digit and + /// arrives cleanly. But SharpConsoleUI already claims Alt+1–9: + /// InputCoordinator.HandleAltInput selects among top-level windows by index, and unlike the + /// move and resize handlers beside it, it is not gated on IsMovable/IsResizable — so + /// Movable(false) did not switch it off. It is reached only from the fall-through taken when + /// the active window did not handle the key, and a global shortcut (which this is, registered from + /// ) runs before the window is offered the key at all. All nine + /// digits are claimed for that reason, in range or not: an out-of-range ⌥7 reports here and stops, + /// rather than falling through to a window selector that would silently do something else. + /// + /// + /// Zoom follows. "Bring it to the forefront" over a zoomed workspace means the pane you named + /// is the one filling the screen, so an existing zoom is carried to the target + /// () instead of leaving the selection — and the + /// session, and the caret — on a pane that is not rendered. The zoom is not started and not + /// cancelled; ⌃B z still means what it meant. + /// + /// + private void JumpToPane(int number) + { + var panes = _workspace.Layout.Panes; + if (number < 1 || number > panes.Count) + { + // Never silent. A digit with no pane behind it is the commonest way to press this chord + // wrong, and the count is the whole answer — ⌃P's Go to pane entries list exactly the panes + // that exist, which is where a reader goes next. + Notice( + panes.Count == 1 + ? "the workspace has one pane — ⌃B | and ⌃B - split it" + : $"there is no pane {number} — this workspace has {panes.Count}", + MessageSeverity.Warning, + $"⌥{number}"); + return; + } + + var target = panes[number - 1].Id; + FocusPane(target); + + // After the focus move, so the zoom lands on the pane that is now selected. Rebuilding is what + // realises the change; FocusPane's own path only syncs the view to a pane it did not move. + if (_workspace.Layout.CarryZoomToFocused()) + { + RebuildPaneArea(); + } + } + /// /// Moves pane selection and brings the rest of the app in line with it, by activating the /// pane's own active window — the same path a tab click and a rail click take (see @@ -3239,7 +3301,19 @@ private void RegisterGlobalShortcuts() // (AnsiInputParser.ProcessEscape), so unlike most of the alphabet's Ctrl chords it genuinely // arrives. A lone Escape is flushed on its own after UnixStdinReader's 50 ms timeout, so // pressing Esc and later typing an r is two keys and not this one. - return claim.Key == ConsoleKey.R ? () => { Reconnect(); return true; } : null; + if (claim.Key == ConsoleKey.R) + { + return () => { Reconnect(); return true; }; + } + + // ⌥1–⌥9 go to the numbered pane. Same delivery story as Alt+R and one digit over: the + // terminal writes ESC + the digit and the parser reads it as that digit with Alt set. + if (MacroKeys.PaneJumpNumber(claim.Key) is { } number) + { + return () => { JumpToPane(number); return true; }; + } + + return null; } if (claim.Modifiers != ConsoleModifiers.Control) @@ -3935,7 +4009,10 @@ private IReadOnlyList BuildRail() { for (var i = 0; i < panes.Count; i++) { - paneLabels[panes[i].Id] = $"pane {i + 1}"; + // Through PaneLabel so the sidebar, the move/drag overlays, the ⌃P entries and the ⌥N + // chord are all reading one spelling of one number. They were two expressions and they + // disagreed about the first pane. + paneLabels[panes[i].Id] = PaneLabel(panes[i].Id); } } @@ -4191,6 +4268,25 @@ internal bool DispatchCommand(string id) return true; } + // A numbered pane entry runs the chord's own action, refusal and all — the surface is another + // door onto ⌥N, not a second way of focusing a pane. Parsed rather than switched on because the + // catalog emits one id per live pane and there is no fixed set of them. + if (id.StartsWith(CommandIds.PanePrefix, StringComparison.Ordinal)) + { + if (int.TryParse( + id[CommandIds.PanePrefix.Length..], + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out var paneNumber)) + { + JumpToPane(paneNumber); + return true; + } + + RefuseCommand($"{id} does not name a pane number"); + return false; + } + // A settings entry opens the very screen its F-key opens, through the same Toggle: the palette // is another door onto that key, not a second way of building the screen. if (id.StartsWith(ScreenCommandPrefix, StringComparison.Ordinal)) @@ -5906,6 +6002,13 @@ internal void SimulatePaste(string text) /// Every pane's id in layout order, for the tests that walk a geometry end to end. internal IReadOnlyList PaneIds => _workspace.Layout.Panes.Select(p => p.Id).ToArray(); + /// + /// The zoomed pane's id, or null when nothing is zoomed. Internal because the ordinal movers carry a + /// zoom with them, and "the pane jumped to is the one rendered" is a claim about this field as much as + /// about the frame. + /// + internal string? ZoomedPaneId => _workspace.Layout.ZoomedPaneId; + /// The pane hosting a window, or null when no pane does. Internal so a test can find the pane /// a split put a window in rather than assuming which side it landed on. internal string? PaneIdOf(string windowId) => _workspace.Layout.FindWindow(windowId)?.Id; @@ -6563,7 +6666,18 @@ private string DropLabel(string? paneId, Edge? edge) }; } - /// The rail's friendly name for a pane ("main" for the first, "pane N" after it). + /// + /// The name every surface in this client gives a pane: pane N, counting + /// in order from one. + /// + /// It used to call the first pane main — the spelling the rail's hosting column abandoned + /// because ▪ main main put two meanings in one line, the window named main beside + /// the pane also called main. The move and drag overlays kept it, so the same pane was pane 1 + /// in the sidebar and main under the cursor. That was survivable while nothing depended on the + /// number; ⌥1 is a chord that lands on the pane a label names, and two spellings of one pane is + /// exactly the mismatch that makes such a chord read as broken. + /// + /// private string PaneLabel(string paneId) { var index = 0; @@ -6571,7 +6685,7 @@ private string PaneLabel(string paneId) { if (pane.Id == paneId) { - return index == 0 ? "main" : $"pane {index + 1}"; + return $"pane {index + 1}"; } index++; @@ -6900,6 +7014,15 @@ private void CyclePane() // command line talks to and its drafts the ones in the bars. The keyboard stays where it was — // typing belongs to the armed command line, wherever the selection is. ActivateFocusedWindow(); + + // An existing zoom follows, exactly as it does for ⌥N: both are *ordinal* movers, and a zoomed + // workspace realises one pane, so cycling without this left the selection, the session the bar + // talks to and the caret on a pane that was not on screen. (The directional movers cannot have + // this: while one pane is realised there is no neighbour to ask for, and ⌃← refuses out loud.) + if (_workspace.Layout.CarryZoomToFocused()) + { + RebuildPaneArea(); + } } /// Closes the focused pane's active window (Ctrl+W). The main window can't be closed. diff --git a/tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs b/tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs index f4553d06..139fe409 100644 --- a/tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs @@ -48,6 +48,54 @@ public async Task StatefulCommands_ReadCurrentValue() await Assert.That(loggingOn.Any(c => c.Title == "Resume scrollback")).IsTrue(); } + /// + /// The numbered pane entries: one per pane that exists, in Panes order (which is the order the + /// shell's sidebar numbers them in), and only when there is more than one pane. The first nine carry + /// their chord; a tenth pane has none, and an entry naming a key that does something else would be + /// worse than a bare one. + /// + [Test] + public async Task NumberedPaneEntries_AppearOnlyOnASplit_AndOnlyTheFirstNineCarryAChord() + { + var one = CommandCatalog.Build(new Workspace(), Characters, null, new CommandContext()); + await Assert.That(one.Any(c => c.Id.StartsWith(CommandIds.PanePrefix, StringComparison.Ordinal))) + .IsFalse(); + + var ws = new Workspace(); + for (var i = 0; i < 10; i++) + { + ws.RouteSpawn($"w{i}"); + } + + // Ten panes. A split moves the focused pane's *other* tabs into the new one and leaves focus + // where it was, so the pane still holding a pile of tabs is the newest — focus that one to split + // again, or the second split has nothing to pull out. + while (ws.Layout.Panes.Count <= CommandIds.PaneJumpDigits) + { + ws.Layout.Focus(ws.Layout.Panes[^1].Id); + if (!ws.Layout.SplitFocused(SplitDirection.Row)) + { + break; + } + } + + var panes = ws.Layout.Panes.Count; + await Assert.That(panes).IsGreaterThan(CommandIds.PaneJumpDigits); + + var entries = CommandCatalog.Build(ws, Characters, null, new CommandContext()) + .Where(c => c.Id.StartsWith(CommandIds.PanePrefix, StringComparison.Ordinal)) + .ToList(); + + await Assert.That(entries.Count).IsEqualTo(panes); + for (var n = 1; n <= panes; n++) + { + var entry = entries[n - 1]; + await Assert.That(entry.Id).IsEqualTo(CommandIds.Pane(n)); + await Assert.That(entry.Title).IsEqualTo($"Go to pane {n}"); + await Assert.That(entry.Subtitle).IsEqualTo(n <= CommandIds.PaneJumpDigits ? $"⌥{n}" : null); + } + } + [Test] public async Task Catalog_CoversEveryGroup() { diff --git a/tests/SharpMUTerm.Core.Tests/Workspace/WorkspaceLayoutTests.cs b/tests/SharpMUTerm.Core.Tests/Workspace/WorkspaceLayoutTests.cs index ab47fed1..31e03a25 100644 --- a/tests/SharpMUTerm.Core.Tests/Workspace/WorkspaceLayoutTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Workspace/WorkspaceLayoutTests.cs @@ -135,6 +135,34 @@ public async Task ToggleZoom_SetsAndClears_AndClosingZoomedPaneResetsIt() await Assert.That(w.ZoomedPaneId).IsNull(); } + /// + /// A zoom carried onto whichever pane is now focused — what the ordinal movers (⌃O, ⌥N) do, so the + /// selection never lands on a pane a zoom is hiding. It never starts a zoom and never ends one. + /// + [Test] + public async Task CarryZoomToFocused_FollowsTheSelection_AndOnlyWhenSomethingIsZoomed() + { + var w = new WorkspaceLayout(new[] { "a", "b" }); + w.SplitFocused(SplitDirection.Row); + var first = w.FocusedPaneId; + + // Nothing zoomed: nothing to carry, and it must not invent one. + await Assert.That(w.CarryZoomToFocused()).IsFalse(); + await Assert.That(w.ZoomedPaneId).IsNull(); + + w.ToggleZoom(); + w.CycleFocus(); + await Assert.That(w.FocusedPaneId).IsNotEqualTo(first); + await Assert.That(w.ZoomedPaneId).IsEqualTo(first); // the state the carry exists to fix + + await Assert.That(w.CarryZoomToFocused()).IsTrue(); + await Assert.That(w.ZoomedPaneId).IsEqualTo(w.FocusedPaneId); + + // Already where it belongs: no change, and it says so. + await Assert.That(w.CarryZoomToFocused()).IsFalse(); + await Assert.That(w.ZoomedPaneId).IsEqualTo(w.FocusedPaneId); + } + [Test] public async Task ReorderActiveTab_MovesWithinPane_AndClampsAtEdges() { diff --git a/tests/SharpMUTerm.Tui.Tests/MacroKeyCaptureTests.cs b/tests/SharpMUTerm.Tui.Tests/MacroKeyCaptureTests.cs index 81c1f141..c8e81505 100644 --- a/tests/SharpMUTerm.Tui.Tests/MacroKeyCaptureTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/MacroKeyCaptureTests.cs @@ -61,7 +61,9 @@ private static void ArmCapture(SettingsSession session) [Arguments("Shift+F3", nameof(MacroKeyDelivery.Fires))] [Arguments("Ctrl+K", nameof(MacroKeyDelivery.Fires))] [Arguments("Alt+K", nameof(MacroKeyDelivery.Fires))] - [Arguments("Alt+5", nameof(MacroKeyDelivery.Fires))] + // ⌥0 is the one Alt+digit no surface claims: ⌥1–⌥9 go to numbered panes, and the tenth digit is + // deliberately left for a binding (the framework's own Alt+digit window selector ignores 0 too). + [Arguments("Alt+0", nameof(MacroKeyDelivery.Fires))] [Arguments("Ctrl+Up", nameof(MacroKeyDelivery.Fires))] [Arguments("Num5", nameof(MacroKeyDelivery.NeverArrives))] [Arguments("Num0", nameof(MacroKeyDelivery.NeverArrives))] @@ -76,6 +78,10 @@ private static void ArmCapture(SettingsSession session) [Arguments("F9", nameof(MacroKeyDelivery.Taken))] [Arguments("Ctrl+Q", nameof(MacroKeyDelivery.Taken))] [Arguments("Ctrl+P", nameof(MacroKeyDelivery.Taken))] + // Was Fires. ⌥5 now goes to pane 5, so F4 has to say the binding is dead and why — the same + // strengthening ⌃O and Alt+R got when they were claimed, not a weakening: Taken still carries a + // reason, which the second assertion below checks. + [Arguments("Alt+5", nameof(MacroKeyDelivery.Taken))] [Arguments("K", nameof(MacroKeyDelivery.Taken))] [Arguments("Up", nameof(MacroKeyDelivery.Taken))] public async Task TheVerdictSaysWhatWillHappenToAChord(string descriptor, string expected) diff --git a/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs new file mode 100644 index 00000000..01209ad8 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs @@ -0,0 +1,681 @@ +using System.Text.RegularExpressions; +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Workspaces; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// ⌥1–⌥9 go to a numbered pane and bring it forward. The request was "ALT-1 2 3 4 5, or +/// CTRL-1 2 3 4 5 — go to those numbered panes and bring them to the forefront"; this is the half of it +/// that can be delivered, and these are the four claims it stands on. +/// +/// 1. The number is the number on the screen. Panes are counted in Layout.Panes order, which +/// is the order the connection rail's hosting column spells pane N in — so the assertions read the +/// label off the live rail and press the digit that label names, rather than writing down which +/// pane ought to be third. A chord that lands somewhere other than the label says is worse than no chord, +/// and this repository has already paid for two spellings of one pane once (▪ main main). +/// +/// +/// 2. It is the full activation, on painted cells. "Bring it to the forefront" is not +/// FocusedPaneId being assigned: it is the pane's plane on the frame, its window active, and the +/// command line talking to its character. All three are asserted, the first off the frame the driver was +/// handed — a focus indicator can be set on a control arranged at zero rows and read back happily. +/// +/// +/// 3. Nothing falls through to the framework. SharpConsoleUI claims Alt+1–9 for its own top-level +/// window selector (InputCoordinator.HandleAltInput), which — unlike the move and resize handlers +/// beside it — is not gated on IsMovable/IsResizable, so Movable(false) did +/// not switch it off. All nine digits are claimed as application shortcuts, which +/// InputCoordinator tries before it offers the key to a window at all; an out-of-range digit +/// therefore reports here and stops rather than reaching a window selector that would do something else. +/// +/// +/// 4. Alt, because Ctrl+digit is not a chord this terminal has. Read off a real pty rather than +/// remembered: every Alt+digit is ESC + the digit, while Ctrl+digit is the bare digit for 1/9/0 and +/// a byte already spelt Escape (3), Backspace (8) or NUL (2) for the rest. MacroKeys.Verdict is +/// where that is recorded, and holds it. +/// +/// +/// +/// Serialised with the other end-to-end suites: constructing the app and rendering a frame both touch the +/// process-global console streams. +/// +[NotInParallel] +public class PaneJumpTests +{ + private const int Width = 160; + private const int Height = 40; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + // --- the numbering, against the rail's own labels ---------------------------------------------- + + /// + /// The claim, end to end. Three panes, three characters, one each. For every digit: press ⌥N, + /// and the pane whose plane the frame paints as focused is the pane the rail labels pane N — + /// with that pane's window active and its character on the command line. + /// + /// The label is read from after the jump, which is the rail the + /// app really drew. Nothing here writes down which pane is third: the assertion is that the two agree, + /// which is the only property that makes the chord usable. + /// + /// + [Test] + public async Task EachDigitLandsOnThePaneTheRailNumbersWithIt() + { + var three = await ThreePanes(); + var (focused, _) = three.App.PaneBandColors; + var rects = three.App.PaneOutputRects(); + + for (var n = 1; n <= 3; n++) + { + three.App.SimulateKey(Alt(n)); + var frame = three.App.RenderWholeFrame(); + + // The rail's own word for where the now-active window is. + await Assert.That(RailPaneLabel(three.App)) + .IsEqualTo($"pane {n}") + .Because($"⌥{n} must land on the pane the sidebar calls pane {n}"); + + // The session, so the command line is talking to the pane you are looking at. + await Assert.That(three.App.ActiveSessionKey).IsEqualTo(three.Sessions[n - 1]); + await Assert.That(three.App.ActiveWindowId()).IsEqualTo(three.Windows[n - 1]); + + // And the paint: this pane's rectangle carries the focused plane and no other one does. + var landed = three.App.FocusedPaneId; + await Assert.That(CellsPaintedIn(frame, rects[landed], focused)) + .IsGreaterThan(0) + .Because($"⌥{n} must paint pane {n} as the focused one"); + foreach (var other in three.App.PaneIds.Where(id => id != landed)) + { + await Assert.That(CellsPaintedIn(frame, rects[other], focused)).IsEqualTo(0); + } + } + } + + /// + /// And the line typed next goes to that pane's character. Asserted on the bytes the transport received, + /// because SendUserInputAsync returns immediately with nothing underneath it — "the right world + /// got it" against an unconnected session is true however broken the routing is. + /// + [Test] + public async Task TheLineTypedAfterAJumpReachesThatPanesCharacter() + { + var three = await ThreePanes(); + + three.App.SimulateKey(Alt(3)); + Send(three.App, "look"); + three.App.SimulateKey(Alt(2)); + Send(three.App, "score"); + + await Assert.That(three.Transports[2].Lines).IsEquivalentTo(new[] { "look" }); + await Assert.That(three.Transports[1].Lines).IsEquivalentTo(new[] { "score" }); + await Assert.That(three.Transports[0].Lines).IsEmpty(); + } + + /// + /// The pane label the ⌃P surface offers and the label the sidebar draws are the same number, for every + /// pane — the entry is a second door onto the chord, not a second numbering. + /// + [Test] + public async Task TheCommandSurfaceOffersOneEntryPerPaneAndAgreesWithTheChord() + { + var three = await ThreePanes(); + + var entries = three.App.BuildCatalog() + .Where(c => c.Id.StartsWith(CommandIds.PanePrefix, StringComparison.Ordinal)) + .ToList(); + await Assert.That(entries.Count).IsEqualTo(3); + + for (var n = 1; n <= 3; n++) + { + var entry = entries.Single(e => e.Id == CommandIds.Pane(n)); + await Assert.That(entry.Title).IsEqualTo($"Go to pane {n}"); + await Assert.That(entry.Subtitle) + .IsEqualTo($"⌥{n}") + .Because("an entry that named the wrong chord would be worse than a bare one"); + + // Both routes reach the same pane. + three.App.SimulateKey(Alt(1)); + three.App.SimulateKey(Alt(n)); + var viaKey = three.App.FocusedPaneId; + + three.App.SimulateKey(Alt(1)); + await Assert.That(three.App.DispatchCommand(CommandIds.Pane(n))).IsTrue(); + await Assert.That(three.App.FocusedPaneId).IsEqualTo(viaKey); + } + } + + /// + /// A single-pane workspace lists none of them. The directional entries are listed unconditionally + /// because they teach that the workspace splits at all; Go to pane 4 on a workspace with one + /// pane teaches nothing and names a place there is no way to make. + /// + [Test] + public async Task ASinglePaneWorkspaceOffersNoNumberedEntries() + { + var app = App(); + app.RenderSnapshot(); + + await Assert.That(app.PaneIds.Count).IsEqualTo(1); + await Assert.That(app.BuildCatalog().Any(c => c.Id.StartsWith(CommandIds.PanePrefix, StringComparison.Ordinal))) + .IsFalse(); + } + + // --- out of range: report, never a silent no-op ------------------------------------------------- + + /// + /// ⌥7 with three panes says so. A silent no-op is the most-repeated defect in this codebase's + /// history, and a digit with no pane behind it is the commonest way to press this chord wrong. The + /// notice names the digit and the count, and nothing moves. + /// + [Test] + public async Task AnOutOfRangeDigitReportsAndMovesNothing() + { + var three = await ThreePanes(); + three.App.SimulateKey(Alt(2)); + var pane = three.App.FocusedPaneId; + var session = three.App.ActiveSessionKey; + + foreach (var digit in new[] { 4, 7, 9 }) + { + three.App.SimulateKey(Alt(digit)); + + await Assert.That(three.App.StatusMarkup).Contains($"there is no pane {digit}"); + await Assert.That(three.App.StatusMarkup).Contains("3"); + await Assert.That(three.App.FocusedPaneId).IsEqualTo(pane); + await Assert.That(three.App.ActiveSessionKey).IsEqualTo(session); + } + } + + /// + /// On a workspace with one pane every digit past the first is out of range, and the refusal says the + /// useful thing instead of counting: how to make a second pane. The same sentence ⌃← gives. + /// + [Test] + public async Task OnOnePaneTheRefusalSaysHowToSplit() + { + var app = App(); + app.RenderSnapshot(); + + app.SimulateKey(Alt(2)); + + await Assert.That(app.StatusMarkup).Contains("one pane"); + await Assert.That(app.StatusMarkup).Contains("⌃B |"); + } + + /// + /// Every digit the framework's selector would act on is claimed. HandleAltInput matches + /// KeyChar '1'–'9' and selects a top-level window by index; it is reached from + /// InputCoordinator's fall-through, and a registered application shortcut is tried before the + /// key is offered to any window. Leaving one digit unclaimed — the out-of-range ones are the + /// temptation — would hand exactly that digit back to it. So the claim is the whole range, and the + /// app's own registration is the proof: RegisterGlobalShortcuts throws at startup for a claim + /// with no action, so an app that constructs at all has all nine wired to something. + /// + [Test] + public async Task AllNineDigitsAreClaimedSoNoneReachesTheFrameworksWindowSelector() + { + var app = App(); + app.RenderSnapshot(); // constructing and registering is itself half the assertion + + for (var n = 1; n <= 9; n++) + { + var key = ConsoleKey.D0 + n; + await Assert.That(MacroKeys.AppShortcuts.Any(s => s.Modifiers == ConsoleModifiers.Alt && s.Key == key)) + .IsTrue() + .Because($"⌥{n} must be claimed by this app, or the framework's window selector takes it"); + await Assert.That(MacroKeys.PaneJumpNumber(key)).IsEqualTo(n); + } + + // ⌥0 is deliberately outside the range: the framework ignores it too, so it costs nothing to + // leave bindable, and F4 says a macro on it fires. + await Assert.That(MacroKeys.PaneJumpNumber(ConsoleKey.D0)).IsNull(); + await Assert.That(MacroKeys.AppShortcuts.Any( + s => s.Modifiers == ConsoleModifiers.Alt && s.Key == ConsoleKey.D0)).IsFalse(); + await Assert.That(MacroKeys.Verdict("Alt+0").Fires).IsTrue(); + } + + /// + /// And an out-of-range digit really is consumed rather than merely ignored: it produced a notice, which + /// only this app can write. A key that fell through to the framework would leave the status line alone. + /// + [Test] + public async Task AnUnusedDigitIsConsumedByThisAppRatherThanPassedOn() + { + var app = App(); + app.RenderSnapshot(); + var before = app.StatusMarkup; + + var routed = app.SimulateKey(Alt(9)); + + await Assert.That(routed).IsNull(); // nothing was sent to a world + await Assert.That(app.StatusMarkup).IsNotEqualTo(before); + await Assert.That(app.ArmedInputText).DoesNotContain("9"); // and it did not type, either + } + + // --- what must not regress --------------------------------------------------------------------- + + /// + /// The focus pin is untouched. The chord moves pane selection and the session behind the command + /// line; it does not move framework keyboard focus, which stays on the armed bar — the fix for the + /// paste bug, and the reason typing lands where the caret is drawn. + /// + [Test] + public async Task JumpingLeavesTheKeyboardOnTheArmedBar() + { + var app = App(); + app.RenderSnapshot("split"); + + foreach (var digit in new[] { 2, 1, 2 }) + { + app.SimulateKey(Alt(digit)); + app.RenderNextFrame(); + await Assert.That(app.ArmedBarHasFocus).IsTrue(); + } + } + + /// + /// And it moves no pane rectangle, so no connected world is told a new terminal size. Restated for this + /// chord for the reason FocusIndicationTests.MovingFocusDoesNotMoveAnyPaneRectangle exists: the + /// indicator recolours what is drawn and may never grow a cell. + /// + [Test] + public async Task JumpingMovesNoPaneRectangle() + { + var app = App(); + app.RenderSnapshot("split"); + var before = app.PaneOutputRects().ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); + + app.SimulateKey(Alt(2)); + app.RenderNextFrame(); + app.SimulateKey(Alt(1)); + app.RenderNextFrame(); + + var after = app.PaneOutputRects(); + await Assert.That(after.Count).IsEqualTo(before.Count); + foreach (var (paneId, rect) in before) + { + await Assert.That(after[paneId]).IsEqualTo(rect); + } + } + + // --- zoom --------------------------------------------------------------------------------------- + + /// + /// A zoom follows the jump. A zoomed workspace realises exactly one pane, so a mover that + /// changed the selection and left the zoom where it was would put the selection, the session and the + /// caret on a pane that is not on the screen. ⌥2 over a zoomed pane 1 therefore shows pane 2 zoomed — + /// the pane you asked for is the one filling the screen — and ⌃B z still un-zooms. + /// + [Test] + public async Task JumpingWhileZoomedBringsTheTargetToTheFrontRatherThanHidingIt() + { + var app = App(); + app.RenderSnapshot("split"); + var first = app.FocusedPaneId; + var second = app.PaneIds.Single(id => id != first); + + await Assert.That(app.DispatchCommand("layout:zoom")).IsTrue(); + await Assert.That(app.ZoomedPaneId).IsEqualTo(first); + + app.SimulateKey(Alt(2)); + var frame = app.RenderWholeFrame(); + + await Assert.That(app.FocusedPaneId).IsEqualTo(second); + await Assert.That(app.ZoomedPaneId) + .IsEqualTo(second) + .Because("the pane jumped to has to be the one that is rendered"); + + // And it is genuinely still a zoom: one pane is realised, and it is the one selected. + var rects = app.PaneOutputRects(); + await Assert.That(rects.ContainsKey(second)).IsTrue(); + await Assert.That(rects.ContainsKey(first)).IsFalse(); + + var (focused, _) = app.PaneBandColors; + await Assert.That(CellsPaintedIn(frame, rects[second], focused)).IsGreaterThan(0); + } + + /// + /// ⌃O is the other ordinal mover and gets the same rule, so the two cannot come to mean different + /// things — it used to cycle the selection out from under a zoom and leave it invisible. + /// + [Test] + public async Task CyclingWhileZoomedCarriesTheZoomToo() + { + var app = App(); + app.RenderSnapshot("split"); + var first = app.FocusedPaneId; + + await Assert.That(app.DispatchCommand("layout:zoom")).IsTrue(); + await Assert.That(app.DispatchCommand("layout:cycle")).IsTrue(); + + await Assert.That(app.FocusedPaneId).IsNotEqualTo(first); + await Assert.That(app.ZoomedPaneId).IsEqualTo(app.FocusedPaneId); + } + + // --- one spelling of one pane ------------------------------------------------------------------- + + /// + /// The move overlay and the sidebar call the first pane the same thing. The overlay used to call + /// it main — the spelling the rail abandoned because it collided with the window named + /// main — so the same pane was pane 1 in the sidebar and main under the cursor. ⌥1 is a + /// chord that lands on a pane a label names, and it cannot survive two labels. + /// + [Test] + public async Task TheMoveOverlayAndTheSidebarSpellTheFirstPaneTheSameWay() + { + var app = App(); + app.RenderSnapshot("split"); + + // ⌃B m lifts the active window; 'a' targets the first pane. + app.SimulateKey(Ctrl(ConsoleKey.B)); + app.SimulateKey(Plain('m', ConsoleKey.M)); + app.SimulateKey(Plain('a', ConsoleKey.A)); + + await Assert.That(app.StatusMarkup).Contains("pane 1"); + await Assert.That(app.StatusMarkup) + .DoesNotContain("main") + .Because("the first pane is pane 1 everywhere, or ⌥1 names something the screen does not"); + await Assert.That(app.RailLines.Any(l => l.Contains("pane 1", StringComparison.Ordinal))).IsTrue(); + + app.SimulateKey(Plain('\x1b', ConsoleKey.Escape)); // leave move mode + } + + // --- honesty ------------------------------------------------------------------------------------ + + /// + /// --help names the chord that works and says why the one that was asked for is absent. Both + /// halves: a page that named Ctrl+digit would send a reader to press Escape and Backspace. + /// + [Test] + public async Task HelpNamesAltDigitAndSaysWhyNotCtrlDigit() + { + var help = Program.UsageText; + + await Assert.That(help).Contains("Alt+1..Alt+9"); + await Assert.That(help).Contains("Ctrl+digit is not"); + await Assert.That(help).Contains("Escape and"); + } + + /// + /// F4 reports each of the nine as taken, and the sentence it prints names the pane the chord goes to — + /// so a user who tried to bind a macro there is told what has it, not merely that something does. + /// + [Test] + public async Task TheKeypadScreenSaysWhatHasEachDigit() + { + for (var n = 1; n <= 9; n++) + { + var verdict = MacroKeys.Verdict($"Alt+{n}"); + + await Assert.That(verdict.Delivery).IsEqualTo(MacroKeyDelivery.Taken); + await Assert.That(verdict.Reason).Contains($"pane {n}"); + } + } + + /// + /// And every Ctrl+digit is reported as never arriving, each with the byte the terminal really sends — + /// observed on a pty, not assumed. Three of them are keys this client cannot afford to bind over. + /// + [Test] + [Arguments(0, "bare 0")] + [Arguments(1, "bare 1")] + [Arguments(2, "NUL")] + [Arguments(3, "Escape")] + [Arguments(8, "Backspace")] + [Arguments(9, "bare 9")] + public async Task CtrlDigitIsReportedAsUnreachableWithTheByteTheTerminalSends(int digit, string byteName) + { + var verdict = MacroKeys.Verdict($"Ctrl+{digit}"); + + await Assert.That(verdict.Delivery).IsEqualTo(MacroKeyDelivery.NeverArrives); + await Assert.That(verdict.Reason).Contains(byteName); + } + + // --- harness ------------------------------------------------------------------------------------ + + private static SharpMUTermApp App(int width = 120, int height = 34) + { + Console.SetIn(TextReader.Null); + return new SharpMUTermApp(DemoScene.Build(), Headless, new HeadlessConsoleDriver(width, height)); + } + + /// The chord as the terminal delivers it: ESC + the digit, decoded as that digit with Alt. + private static ConsoleKeyInfo Alt(int digit) => + new((char)('0' + digit), ConsoleKey.D0 + digit, false, true, false); + + private static ConsoleKeyInfo Ctrl(ConsoleKey key) => new('\0', key, false, false, true); + + private static ConsoleKeyInfo Plain(char c, ConsoleKey key) => new(c, key, false, false, false); + + /// Empties the armed command line and types into it, key by key. + private static void Type(SharpMUTermApp app, string text) + { + app.SimulateKey(Ctrl(ConsoleKey.E)); + app.SimulateKey(Ctrl(ConsoleKey.U)); + foreach (var c in text) + { + app.SimulateKey(Plain(c, ConsoleKey.NoName)); + } + } + + private static void Send(SharpMUTermApp app, string line) + { + Type(app, line); + app.SimulateKey(Plain('\r', ConsoleKey.Enter)); + } + + /// + /// The pane label the rail is drawing for the active character's window, e.g. pane 2. Read out + /// of the rendered rows rather than recomputed, because the whole assertion is that the chord and the + /// sidebar agree — a re-derived label would agree with itself. + /// + private static string RailPaneLabel(SharpMUTermApp app) + { + foreach (var line in app.RailLines) + { + var match = Regex.Match(line, @"pane \d+"); + if (match.Success) + { + return match.Value; + } + } + + throw new InvalidOperationException( + $"the rail is drawing no pane label: {string.Join(" / ", app.RailLines)}"); + } + + /// The truecolor background escape a colour is written as. + private static string Sgr(SharpConsoleUI.Color color) => $"48;2;{color.R};{color.G};{color.B}"; + + /// + /// Walks a frame into a {(row, column): background} grid, the way a terminal walks it. Note + /// 48 and not 38 — reading foreground here and concluding about planes is the classic + /// mistake. Same walker as , deliberately: this suite's claim is + /// about the same painted planes. + /// + private static Dictionary<(int Row, int Column), string?> Backgrounds(string ansi) + { + var cells = new Dictionary<(int, int), string?>(); + var current = (string?)null; + var (row, column) = (0, 0); + + foreach (Match token in Regex.Matches(ansi, @"\x1b\[([0-9;]*)([A-Za-z])|([^\x1b\r\n])|(\n)")) + { + if (token.Groups[4].Success) + { + row++; + column = 0; + continue; + } + + if (token.Groups[3].Success) + { + cells[(row, column)] = current; + column++; + continue; + } + + var parameters = token.Groups[1].Value; + switch (token.Groups[2].Value) + { + case "H": + var at = parameters.Split(';'); + row = at[0].Length > 0 ? int.Parse(at[0]) - 1 : 0; + column = at.Length > 1 && at[1].Length > 0 ? int.Parse(at[1]) - 1 : 0; + break; + case "m": + if (parameters.Length == 0 || parameters == "0" || parameters.Contains("49")) + { + current = null; + } + + if (parameters.Contains("48;2;")) + { + current = parameters[parameters.IndexOf("48;2;", StringComparison.Ordinal)..]; + } + + break; + } + } + + return cells; + } + + /// How many cells inside a rectangle are painted in a given background. + private static int CellsPaintedIn(string ansi, PaneRect rect, SharpConsoleUI.Color colour) + { + var wanted = Sgr(colour); + var cells = Backgrounds(ansi); + var count = 0; + for (var y = rect.Y; y < rect.Y + rect.Height; y++) + { + for (var x = rect.X; x < rect.X + rect.Width; x++) + { + if (cells.GetValueOrDefault((y, x))?.StartsWith(wanted, StringComparison.Ordinal) == true) + { + count++; + } + } + } + + return count; + } + + /// Three panes side by side, one connected character each, in a known order. + private sealed record Three( + SharpMUTermApp App, + IReadOnlyList Windows, + IReadOnlyList Sessions, + IReadOnlyList Transports); + + /// + /// A resumed workspace of three panes, each holding one character's window, built the way the + /// shell restores one. Three separate worlds so each session's writes are attributable by host — the + /// suite turns on which transport a line reached, and one world's characters would share one. + /// + /// The pane order is the split tree's, which is what Layout.Panes enumerates and what the rail + /// numbers; the windows are placed p1/p2/p3 so the expected pairing is stated once, here, and every + /// assertion afterwards reads the rail rather than restating it. + /// + /// + private static async Task ThreePanes() + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + var names = new[] { ("Alfa", "Ann"), ("Bravo", "Bob"), ("Cara", "Cal") }; + foreach (var (world, character) in names) + { + var definition = new WorldDefinition + { + Name = world, + Host = $"{world.ToLowerInvariant()}.example.org", + Port = 4000, + }; + definition.Characters.Add(new CharacterDefinition { Name = character, Logging = new LoggingSettings() }); + config.Worlds.Add(definition); + } + + var windows = new[] { "main", "char:Bravo.Bob", "char:Cara.Cal" }; + var sessions = names.Select(n => $"{n.Item1}.{n.Item2}").ToArray(); + + config.LastSession = new WorkspaceState + { + Windows = + { + new WorkspaceWindowState + { + Id = windows[0], Title = "Ann", Kind = WindowKind.Main, SessionKey = sessions[0], + }, + new WorkspaceWindowState + { + Id = windows[1], Title = "Bob", Kind = WindowKind.Main, SessionKey = sessions[1], + }, + new WorkspaceWindowState + { + Id = windows[2], Title = "Cal", Kind = WindowKind.Main, SessionKey = sessions[2], + }, + }, + Root = new LayoutNodeState + { + Type = "split", + Direction = SplitDirection.Row, + Children = + { + new LayoutNodeState { Type = "pane", Id = "p1", Tabs = { windows[0] }, ActiveIndex = 0 }, + new LayoutNodeState { Type = "pane", Id = "p2", Tabs = { windows[1] }, ActiveIndex = 0 }, + new LayoutNodeState { Type = "pane", Id = "p3", Tabs = { windows[2] }, ActiveIndex = 0 }, + }, + }, + FocusedPaneId = "p1", + }; + + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(Width, Height)); + var transports = new[] + { + new RecordingTelnetSession(), new RecordingTelnetSession(), new RecordingTelnetSession(), + }; + app.TelnetFactory = options => options.Host[0] switch + { + 'a' => transports[0], + 'b' => transports[1], + _ => transports[2], + }; + + foreach (var key in sessions) + { + if (!app.DispatchCommand(CommandIds.Character(key))) + { + throw new InvalidOperationException($"the app would not switch to {key}"); + } + + await app.FindSession(key)!.ConnectAsync(); + } + + app.RenderNextFrame(); + + // The fixture's own claim: three panes, in the order the windows were placed. Everything after + // this reads the rail, so a resumed layout that came back differently must fail here and not + // silently make the assertions vacuous. + if (app.PaneIds.Count != 3) + { + throw new InvalidOperationException($"the resumed workspace has {app.PaneIds.Count} panes, not 3"); + } + + for (var i = 0; i < 3; i++) + { + if (app.PaneIdOf(windows[i]) != app.PaneIds[i]) + { + throw new InvalidOperationException( + $"{windows[i]} is in {app.PaneIdOf(windows[i])}, not the {i + 1}th pane {app.PaneIds[i]}"); + } + } + + return new Three(app, windows, sessions, transports); + } +} From f64c950024dd995074edaa17977f71469192a472 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 30 Jul 2026 15:28:59 -0500 Subject: [PATCH 02/11] Move mode targets panes by their number, not a second alphabet Move mode lettered the panes a-j while the prompt one line below named the target 'pane N' - one ordering spelt two ways, so using the feature meant translating 'B' into 'pane 2' in your head. The badge is now the pane's own ordinal: the number the sidebar's hosting column prints, the number the prompt names, and the number Alt+N jumps to. Capped at 9 rather than 10 because there is no tenth digit; a tenth pane keeps no badge and stays a mouse drop target. An out-of-range digit leaves the current target alone, as an unmapped letter did. --- docs/design/README.md | 7 +-- docs/design/SharpMUTerm-TUI-v3.dc.html | 2 +- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 50 ++++++++++++-------- tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs | 46 +++++++++++++++++- 4 files changed, 80 insertions(+), 25 deletions(-) diff --git a/docs/design/README.md b/docs/design/README.md index 3be3bcc9..0f7c82c6 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -374,10 +374,11 @@ claimed chord cancels a pending prefix before opening its own surface. ### Move mode (`⌃B m`) — the keyboard path for window placement Drag is an accelerator, not the only route. Move mode: the active window lifts, every pane dims -and shows a large target letter (`a`–`j`), and the status bar becomes the prompt -`MOVE #public → [b] split right · a–j pane · ←↑↓→ edge · ⏎ commit · Esc cancel`. +and shows its own number (`1`–`9`, the same ordinal the sidebar labels it with and `⌥N` jumps to), +and the status bar becomes the prompt +`MOVE #public → split pane 2 right · 1–9 pane · ←↑↓→ edge · ⏎ commit · Esc cancel`. -- `a`–`j` or Tab picks the destination pane +- `1`–`9` or Tab picks the destination pane - arrows or `hjkl` toggle an edge (splits there instead of adding as a tab); pressing the same edge again clears it - ⏎ commits, Esc cancels diff --git a/docs/design/SharpMUTerm-TUI-v3.dc.html b/docs/design/SharpMUTerm-TUI-v3.dc.html index 19849d84..da81bd58 100644 --- a/docs/design/SharpMUTerm-TUI-v3.dc.html +++ b/docs/design/SharpMUTerm-TUI-v3.dc.html @@ -192,7 +192,7 @@ [{{ moveTargetLetter }}] {{ moveEdgeLabel }} - · a–j pane · ←↑↓→ edge + · 1–9 pane · ←↑↓→ edge
⏎ commit Esc cancel diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index a5ef3c79..72487078 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -288,7 +288,15 @@ private sealed class SizeReport private string? _moveWindowId; private string? _moveTargetPaneId; private Edge? _moveEdge; - private readonly Dictionary _moveLetters = new(StringComparer.Ordinal); + /// + /// The digit that targets each pane in move mode, which is the pane's own ordinal — the number + /// spells and ⌥N jumps to. It was a separate a–j alphabet, so the badge on + /// a pane and the prompt beside it named the same pane two ways (MOVE Corvid → split pane 2 + /// left under a badge reading B). Only panes 1–9 get an entry: there is no tenth digit, + /// and a badge whose key does not exist is worse than no badge. A tenth pane is still a drop target + /// for the mouse. + /// + private readonly Dictionary _moveOrdinals = new(StringComparer.Ordinal); /// /// The chords the app claims globally, by the action each runs. It is the same delegate @@ -5459,8 +5467,8 @@ private IWindowControl BuildLayoutNode(SharpMUTerm.Core.Workspaces.LayoutNode no } return OnSurface( - _moveMode && _moveLetters.TryGetValue(pane.Id, out var letter) - ? BuildMovePane(pane, letter) + _moveMode && _moveOrdinals.TryGetValue(pane.Id, out var ordinal) + ? BuildMovePane(pane, ordinal) : BuildPaneTabs(pane), pane.Id); } @@ -6544,32 +6552,36 @@ internal WorldSession BindWorldWithoutConnecting(WorldDefinition world) } /// - /// Enters move mode (⌃B m): the active window lifts, every pane dims and shows a target letter - /// (a–j), and the status bar becomes the move prompt. a–j pick the destination, arrows toggle an - /// edge (split there), ⏎ commits, Esc cancels. + /// Enters move mode (⌃B m): the active window lifts, every pane dims and shows its own number, and + /// the status bar becomes the move prompt. 1–9 pick the destination, arrows toggle an edge (split + /// there), ⏎ commits, Esc cancels. + /// + /// The digits are the pane ordinals, so the badge on a pane, the pane N the prompt names as + /// the target, the sidebar's hosting column and ⌥N are all one numbering. + /// /// private void EnterMoveMode() { _moveWindowId = ActiveWindowId(); _moveMode = true; _moveTargetPaneId = null; - _moveLetters.Clear(); - var letter = 'a'; + _moveOrdinals.Clear(); + var ordinal = 1; foreach (var pane in _workspace.Layout.Panes) { - if (letter > 'j') + if (ordinal > CommandIds.PaneJumpDigits) { break; } - _moveLetters[pane.Id] = letter++; + _moveOrdinals[pane.Id] = ordinal++; } RebuildPaneArea(); SetStatus(MovePromptMarkup(), displace: true); } - /// Handles a key while in move mode: pick pane (a–j), edge (arrows), commit (⏎), cancel (Esc). + /// Handles a key while in move mode: pick pane (1–9), edge (arrows), commit (⏎), cancel (Esc). private void HandleMoveKey(KeyPressedEventArgs e) { e.Handled = true; @@ -6598,10 +6610,10 @@ private void HandleMoveKey(KeyPressedEventArgs e) return; } - if (ch is >= 'a' and <= 'j') + if (ch is >= '1' and <= '9') { - // Only retarget on a real match — an unmapped letter must not clear the current target. - var match = _moveLetters.FirstOrDefault(kv => kv.Value == ch); + // Only retarget on a real match — a digit past the last pane must not clear the current target. + var match = _moveOrdinals.FirstOrDefault(kv => kv.Value == ch - '0'); if (match.Key is not null) { _moveTargetPaneId = match.Key; @@ -6634,7 +6646,7 @@ private void ExitMoveMode(bool commit) _moveWindowId = null; _moveTargetPaneId = null; _moveEdge = null; - _moveLetters.Clear(); + _moveOrdinals.Clear(); RebuildPaneArea(); UpdateStatus(); } @@ -6644,7 +6656,7 @@ private string MovePromptMarkup() { var name = _moveWindowId is { } id && _workspace.FindWindow(id) is { } w ? Escape(w.Title) : "window"; return $"[#e5c07b]MOVE[/] [bold]{name}[/] [dim]→[/] [#00f5b7]{DropLabel(_moveTargetPaneId, _moveEdge)}[/]" - + " [dim]a–j pane · ←↑↓→ edge · ⏎ commit · Esc cancel[/]"; + + " [dim]1–9 pane · ←↑↓→ edge · ⏎ commit · Esc cancel[/]"; } /// Human-readable description of a pending drop, for the move prompt and drag preview. @@ -6972,14 +6984,14 @@ private void OnUiThread(Action action) _system.EnqueueOnUIThread(action); } - /// A pane rendered as a move-mode target: a big letter over the dimmed window list. - private IWindowControl BuildMovePane(PaneNode pane, char letter) + /// A pane rendered as a move-mode target: its own number over the dimmed window list. + private IWindowControl BuildMovePane(PaneNode pane, int ordinal) { var selected = pane.Id == _moveTargetPaneId; var color = selected ? "#00f5b7" : "#e5c07b"; var lines = new List { string.Empty, string.Empty }; lines.Add($" [bold {color}]▛▀▀▜[/]"); - lines.Add($" [bold {color}]▌ {char.ToUpperInvariant(letter)} ▐[/]"); + lines.Add($" [bold {color}]▌ {ordinal} ▐[/]"); lines.Add($" [bold {color}]▙▄▄▟[/]"); lines.Add(string.Empty); if (selected) diff --git a/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs index 01209ad8..e04d7d56 100644 --- a/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs @@ -372,10 +372,10 @@ public async Task TheMoveOverlayAndTheSidebarSpellTheFirstPaneTheSameWay() var app = App(); app.RenderSnapshot("split"); - // ⌃B m lifts the active window; 'a' targets the first pane. + // ⌃B m lifts the active window; '1' targets the first pane — the same digit ⌥1 uses. app.SimulateKey(Ctrl(ConsoleKey.B)); app.SimulateKey(Plain('m', ConsoleKey.M)); - app.SimulateKey(Plain('a', ConsoleKey.A)); + app.SimulateKey(Plain('1', ConsoleKey.D1)); await Assert.That(app.StatusMarkup).Contains("pane 1"); await Assert.That(app.StatusMarkup) @@ -386,6 +386,48 @@ await Assert.That(app.StatusMarkup) app.SimulateKey(Plain('\x1b', ConsoleKey.Escape)); // leave move mode } + /// + /// Move mode targets by the same number as everything else. It used to letter the panes + /// aj while the prompt one line below named the target pane N — one ordering + /// spelt in two alphabets, which meant translating B into pane 2 in your head to use + /// the feature the prompt was explaining. + /// + /// Driven for every pane: press the digit, and the target the prompt reports is the pane the rail + /// numbers with that digit. The prompt is read rather than the badge because the prompt is what + /// names the pane in words; the badge is asserted separately by the digit having worked at all — an + /// unmapped digit leaves the target alone, so a wrong badge cannot produce a right prompt. + /// + /// + [Test] + public async Task MoveModeTargetsThePaneTheRailNumbersWithTheSameDigit() + { + var three = await ThreePanes(); + + three.App.SimulateKey(Ctrl(ConsoleKey.B)); + three.App.SimulateKey(Plain('m', ConsoleKey.M)); + + for (var n = 1; n <= 3; n++) + { + three.App.SimulateKey(Plain((char)('0' + n), ConsoleKey.D0 + n)); + + await Assert.That(three.App.StatusMarkup) + .Contains($"pane {n}") + .Because($"pressing {n} in move mode must target the pane the sidebar calls pane {n}"); + } + + // A digit past the last pane leaves the target where it was rather than clearing it. + three.App.SimulateKey(Plain('9', ConsoleKey.D9)); + await Assert.That(three.App.StatusMarkup) + .Contains("pane 3") + .Because("an out-of-range digit must not drop the target you already picked"); + + // And the letters are gone: 'b' is not a pane picker any more. + three.App.SimulateKey(Plain('b', ConsoleKey.B)); + await Assert.That(three.App.StatusMarkup).Contains("pane 3"); + + three.App.SimulateKey(Plain('\x1b', ConsoleKey.Escape)); + } + // --- honesty ------------------------------------------------------------------------------------ /// From a306c8ffe36d7fb0c1c468412b7018a0a67f2c9d Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 30 Jul 2026 15:32:44 -0500 Subject: [PATCH 03/11] Take TelnetNegotiationCore 2.6.5 Carries the five fixes merged upstream today: GMCP/MSDP/TTABLE no longer truncate at 8192 bytes silently, bodyless and separator-less GMCP messages are delivered, MSDP-over-GMCP forwards the JSON body instead of the package name, and MSSP keeps every value a variable carries. Verified the package actually contains them rather than trusting the number: SubnegotiationBuffer, MSDPOverGMCPPackage, TTABLE_REJECTED, OnGMCPMessageTooLarge and MSSPConfig.Variables are all present in lib/net10.0. All six suites green on it. --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index bd862e2f..f2d49e1d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,7 +10,7 @@ - + From 228278a0804ca638f2296380c38059fcbbbe75ca Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 30 Jul 2026 15:48:44 -0500 Subject: [PATCH 04/11] A trigger's route expands captures, so one rule can feed a pane per channel Rewrite and respond have always run their template through match.Result; the route did not, so one rule could only ever feed one statically-named window. ^<(.+?)> routing to 'Channel $1' now opens a pane per channel, each created on that channel's first line. The resolved name is guarded where the other two templates are not, because it becomes a durable named object - a window id, a tab title, a sidebar row - built from whatever the server sent. A name that is empty, over 64 characters, carries a control character, or still holds a group reference the pattern cannot supply is refused, and the line simply is not routed. Regex.Result leaves an out-of-range $2 as literal text rather than throwing, which is why that last case needs a guard of its own. No ceiling on how many panes a rule may open: a pattern that captures more loosely than its author meant is theirs to tighten, and a client-imposed cap would silently drop the channel they cared about. The route now carries its rule's pattern (SpawnRoute) instead of the Tui looking the rule up by comparing SpawnTarget to the window's name - a lookup that finds nothing once the name is dynamic, which would have left every such pane without its capture header. The F2 editor's '$1..$9 insert captures' caption now sits over the route field too. --- .../Automation/TriggerEngine.cs | 100 ++++++++++++++++-- src/SharpMUTerm.Core/Session/SessionEvents.cs | 6 +- src/SharpMUTerm.Core/Session/WorldSession.cs | 2 +- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 17 ++- src/SharpMUTerm.Tui/TriggersScreenRenderer.cs | 5 +- .../Automation/TriggerEngineTests.cs | 96 ++++++++++++++++- 6 files changed, 206 insertions(+), 20 deletions(-) diff --git a/src/SharpMUTerm.Core/Automation/TriggerEngine.cs b/src/SharpMUTerm.Core/Automation/TriggerEngine.cs index 5e87b097..63e79d35 100644 --- a/src/SharpMUTerm.Core/Automation/TriggerEngine.cs +++ b/src/SharpMUTerm.Core/Automation/TriggerEngine.cs @@ -6,6 +6,19 @@ namespace SharpMUTerm.Core.Automation; /// A script callback requested by a matched trigger, with its capture groups. public sealed record TriggerScriptInvocation(string Callback, Match Match); +/// +/// One line's route to a spawn window: the window's resolved name, and the pattern of the rule that +/// sent it there. +/// +/// The pattern rides along because the destination is no longer enough to identify the rule. A route +/// of Channel $1 resolves to Channel Public, Channel Newbie and so on, so a +/// consumer that wanted to know "which rule feeds this pane" and looked the rule up by comparing its +/// to the window's name would find nothing for every dynamic +/// pane. Carrying it costs one reference and removes the lookup. +/// +/// +public sealed record SpawnRoute(string Target, string Pattern); + /// The outcome of running the trigger engine over one output line. public sealed class TriggerResult { @@ -13,7 +26,7 @@ public TriggerResult( StyledLine line, bool suppress, IReadOnlyList responses, - IReadOnlyList spawnTargets, + IReadOnlyList spawnTargets, IReadOnlyList scriptInvocations, IReadOnlyList matched) { @@ -34,8 +47,8 @@ public TriggerResult( /// Commands to send back to the server, in order. public IReadOnlyList Responses { get; } - /// Named spawn windows this line should be routed to. - public IReadOnlyList SpawnTargets { get; } + /// The spawn windows this line should be routed to, with the rule that routed it. + public IReadOnlyList SpawnTargets { get; } /// Script callbacks to invoke, with their match data. public IReadOnlyList ScriptInvocations { get; } @@ -213,7 +226,7 @@ public TriggerResult Process(StyledLine line) var current = line; var suppress = false; List? responses = null; - List? spawns = null; + List? spawns = null; List? scripts = null; List? matched = null; @@ -266,9 +279,10 @@ actions.HighlightBackground is not null || (responses ??= new List()).Add(match.Result(actions.SendResponse)); } - if (!string.IsNullOrEmpty(actions.SpawnTarget)) + if (!string.IsNullOrEmpty(actions.SpawnTarget) && + ResolveSpawnTarget(actions.SpawnTarget, match) is { } target) { - (spawns ??= new List()).Add(actions.SpawnTarget); + (spawns ??= new List()).Add(new SpawnRoute(target, trigger.Pattern)); } if (!string.IsNullOrEmpty(actions.ScriptCallback)) @@ -299,11 +313,83 @@ actions.HighlightBackground is not null || current, suppress, (IReadOnlyList?)responses ?? Array.Empty(), - (IReadOnlyList?)spawns ?? Array.Empty(), + (IReadOnlyList?)spawns ?? Array.Empty(), (IReadOnlyList?)scripts ?? Array.Empty(), (IReadOnlyList?)matched ?? Array.Empty()); } + /// + /// The window a matched rule routes to: its with capture + /// groups substituted, or null when what came out cannot be a window name. + /// + /// Rewrite and respond have always expanded $1; the route did not, so one rule could only ever + /// feed one statically-named pane. ^<(.+?)> routing to Channel $1 is the case this + /// exists for: one rule, a pane per channel, each created on the channel's first line. + /// + /// + /// Why this is guarded and the other two are not. A rewrite's output is text on a line and a + /// response's output is a command the user's own rule chose to send. This one becomes a + /// durable named object — a window id, a tab title, a row in the sidebar — built out of + /// whatever the server put between the brackets. So a resolved name is trimmed and then refused if it + /// is empty, carries a control character (a name holding a stray escape or newline would corrupt every + /// surface that draws it), or runs past . Refusing means the line is not + /// routed; it still prints to the main window unless the rule also gags it, which is the quiet failure + /// rather than a pane named after a screenful of garbage. + /// + /// + /// There is deliberately no ceiling on how many panes a rule may create. A pattern that captures + /// more loosely than its author meant will open a pane per variant, and that is the author's to fix by + /// tightening the pattern — a client-imposed cap would silently drop the channel you cared about. + /// + /// + private static string? ResolveSpawnTarget(string template, Match match) + { + string resolved; + try + { + resolved = match.Result(template); + } + catch (FormatException) + { + // A template Regex.Result cannot parse at all. The rule is malformed rather than the line, so + // routing it anywhere would be a guess. + return null; + } + + resolved = resolved.Trim(); + + if (resolved.Length == 0 || resolved.Length > MaxTargetLength) + { + return null; + } + + for (var i = 0; i < resolved.Length; i++) + { + if (char.IsControl(resolved[i])) + { + return null; + } + + // A group reference that survived expansion: Regex.Result leaves "$3" as the two characters + // "$3" when the pattern has no third group, rather than throwing, so without this a typo in + // the template opens a pane literally named "$3" and keeps feeding it. Refusing is the honest + // reading — the rule asked for something the pattern cannot give it. + if (resolved[i] == '$' && i + 1 < resolved.Length && char.IsAsciiDigit(resolved[i + 1])) + { + return null; + } + } + + return resolved; + } + + /// + /// How long a resolved window name may be. A tab strip and the sidebar both draw it, and a capture is + /// only bounded by what the server sent — this is the point past which a "name" is really a line of + /// output that matched too much. + /// + public const int MaxTargetLength = 64; + private static StyledLine ApplyHighlight(StyledLine line, Match match, TriggerActions actions) { var restyled = StyledText.Restyle(line, match.Index, match.Length, style => diff --git a/src/SharpMUTerm.Core/Session/SessionEvents.cs b/src/SharpMUTerm.Core/Session/SessionEvents.cs index e8b106a5..95e30bdb 100644 --- a/src/SharpMUTerm.Core/Session/SessionEvents.cs +++ b/src/SharpMUTerm.Core/Session/SessionEvents.cs @@ -12,10 +12,14 @@ public enum ConnectionState } /// A line routed to a named spawn window by a matching trigger. -public sealed class SpawnLineEventArgs(string target, StyledLine line) : EventArgs +public sealed class SpawnLineEventArgs(string target, string pattern, StyledLine line) : EventArgs { + /// The window's name, with the rule's capture groups already substituted. public string Target { get; } = target; + /// The pattern of the rule that routed the line here — see SpawnRoute. + public string Pattern { get; } = pattern; + public StyledLine Line { get; } = line; } diff --git a/src/SharpMUTerm.Core/Session/WorldSession.cs b/src/SharpMUTerm.Core/Session/WorldSession.cs index d6fc1adb..7386f7c9 100644 --- a/src/SharpMUTerm.Core/Session/WorldSession.cs +++ b/src/SharpMUTerm.Core/Session/WorldSession.cs @@ -342,7 +342,7 @@ private void ProcessOutputLine(StyledLine line) foreach (var target in result.SpawnTargets) { - SpawnLine?.Invoke(this, new SpawnLineEventArgs(target, result.Line)); + SpawnLine?.Invoke(this, new SpawnLineEventArgs(target.Target, target.Pattern, result.Line)); } foreach (var response in result.Responses) diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index 72487078..6b9c7efc 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -1526,7 +1526,7 @@ private void BindSession(WorldSession session, string? windowId = null) UpdateStatus(); } }); - session.SpawnLine += (_, e) => OnUi(() => OnSpawnLine(session, e.Target, e.Line)); + session.SpawnLine += (_, e) => OnUi(() => OnSpawnLine(session, e.Target, e.Pattern, e.Line)); // The status row's encoding cell is live, so it has to be repainted when the thing it reports // changes. WorldSession has already put the change in the client message log by the time this @@ -1886,12 +1886,6 @@ private void RepaintPanes() } } - /// The trigger pattern that routes to a spawn , for its capture line. - private string? CaptureFor(string target) => - _config.TriggerSets.SelectMany(s => s.Triggers) - .FirstOrDefault(t => string.Equals(t.Actions.SpawnTarget, target, StringComparison.Ordinal)) - ?.Pattern; - /// /// Appends a line to a window's pane and badges it unread when the reader cannot see where it landed. /// @@ -1922,11 +1916,16 @@ private void OnLine(string windowId, StyledLine line) /// which world a link clicked in a spawn window sends to by it. /// /// - private void OnSpawnLine(WorldSession session, string target, StyledLine line) + private void OnSpawnLine(WorldSession session, string target, string pattern, StyledLine line) { var existed = _workspace.FindWindow(Workspace.SpawnWindowId(target)) is not null; var window = _workspace.RouteSpawn(target, session.SessionKey); - window.CapturePattern ??= CaptureFor(target); // label the pane with the trigger that feeds it + + // Label the pane with the rule that feeds it. The pattern comes with the line rather than being + // looked up from the target: a route of "Channel $1" resolves to a different name every time, so + // finding the rule by comparing its SpawnTarget to this window's name would find nothing. + window.CapturePattern ??= pattern; + // Its owner's own name, which for a session with no character is its world's. It used to fall back on // the *main window's* title, which is a different session's name as soon as more than one is open. window.OwnerLabel ??= SessionTitle(session); diff --git a/src/SharpMUTerm.Tui/TriggersScreenRenderer.cs b/src/SharpMUTerm.Tui/TriggersScreenRenderer.cs index 6143600d..e7f8abbf 100644 --- a/src/SharpMUTerm.Tui/TriggersScreenRenderer.cs +++ b/src/SharpMUTerm.Tui/TriggersScreenRenderer.cs @@ -648,7 +648,10 @@ private static List BuildEditor( "[dim]match pattern (regex)[/]", $" {ScreenChrome.Field(Escape(trigger.Pattern), pattern)}", string.Empty, - Heading("route to", route), + // The caption is here as well as over the actions section because the route expands captures + // too: "Channel $1" against ^<(.+?)> is one rule feeding a pane per channel, and a reader who + // has only seen the caption below would have no reason to try it here. + Heading("route to", route, ScreenChrome.ReadOnly("· $1..$9 insert captures")), // The window is a name you type, not one of a fixed set — the spawn windows that exist // are defined by what routes to them, so a closed list could only ever re-use one. It is diff --git a/tests/SharpMUTerm.Core.Tests/Automation/TriggerEngineTests.cs b/tests/SharpMUTerm.Core.Tests/Automation/TriggerEngineTests.cs index a16aabf6..45096b41 100644 --- a/tests/SharpMUTerm.Core.Tests/Automation/TriggerEngineTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Automation/TriggerEngineTests.cs @@ -77,7 +77,101 @@ public async Task SpawnTarget_IsCollected() var engine = new TriggerEngine(); engine.Add(new Trigger { Pattern = "chat", Actions = new TriggerActions { SpawnTarget = "Chat" } }); var result = engine.Process(Line("[chat] hi")); - await Assert.That(result.SpawnTargets).Contains("Chat"); + await Assert.That(result.SpawnTargets.Select(s => s.Target)).Contains("Chat"); + } + + /// + /// One rule, a pane per channel. The route expands $1 like rewrite and respond always + /// have, so ^<(.+?)> routing to Channel $1 sends each channel's lines to its own + /// window. Without this a rule can only ever feed one statically-named pane, and a client with a + /// hundred channels needs a hundred near-identical rules. + /// + [Test] + [Arguments(" Ann waves", "Channel Public")] + [Arguments(" Bob asks", "Channel Newbie")] + [Arguments(" Cal notes", "Channel Admin Chat")] + public async Task SpawnTarget_ExpandsCaptureGroups(string line, string expected) + { + var engine = new TriggerEngine(); + engine.Add(new Trigger + { + Pattern = "^<(.+?)>", + Actions = new TriggerActions { SpawnTarget = "Channel $1" }, + }); + + var result = engine.Process(Line(line)); + + await Assert.That(result.SpawnTargets.Select(s => s.Target)).Contains(expected); + } + + /// + /// The rule's pattern rides with the route, because the destination no longer identifies the rule: + /// Channel $1 resolves to a different name every time, so a consumer looking the rule up by + /// comparing it to the window's name would find nothing for any dynamic pane. + /// + [Test] + public async Task SpawnRoute_CarriesThePatternOfTheRuleThatRoutedIt() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger + { + Pattern = "^<(.+?)>", + Actions = new TriggerActions { SpawnTarget = "Channel $1" }, + }); + + var result = engine.Process(Line(" Ann waves")); + + await Assert.That(result.SpawnTargets.Single().Pattern).IsEqualTo("^<(.+?)>"); + } + + /// + /// A resolved name becomes a window id, a tab title and a sidebar row, built out of whatever the + /// server sent — so unlike a rewrite (text on a line) it is refused when it cannot be a name. The + /// line still prints; it is simply not routed. + /// + [Test] + [Arguments("<> hi", "$1", "an empty capture cannot name a window")] + [Arguments("< > hi", "$1", "a capture of nothing but spaces cannot name a window either")] + [Arguments("<> hi", "Channel $1", "a capture carrying a control character would corrupt every surface that draws it")] + [Arguments(" hi", "$3", "a template naming a group the pattern does not have is malformed")] + [Arguments(" hi", "Channel $2", "Regex.Result leaves an out-of-range group reference as literal text, so without a guard this opens a pane named \"Channel $2\"")] + public async Task SpawnTarget_ThatCannotNameAWindow_IsNotRouted(string line, string template, string because) + { + var engine = new TriggerEngine(); + engine.Add(new Trigger { Pattern = "^<(.*?)>", Actions = new TriggerActions { SpawnTarget = template } }); + + var result = engine.Process(Line(line)); + + await Assert.That(result.SpawnTargets).IsEmpty().Because(because); + } + + /// A capture longer than a name has any business being does not open a pane. + [Test] + public async Task SpawnTarget_LongerThanTheCeiling_IsNotRouted() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger { Pattern = "^<(.*?)>", Actions = new TriggerActions { SpawnTarget = "$1" } }); + + var atTheCeiling = new string('a', TriggerEngine.MaxTargetLength); + var overIt = new string('a', TriggerEngine.MaxTargetLength + 1); + + await Assert.That(engine.Process(Line($"<{atTheCeiling}> hi")).SpawnTargets).IsNotEmpty(); + await Assert.That(engine.Process(Line($"<{overIt}> hi")).SpawnTargets).IsEmpty(); + } + + /// + /// A route with no $ in it is unchanged — the overwhelmingly common case, and the one every + /// existing configuration is written in. + /// + [Test] + public async Task SpawnTarget_WithoutCaptures_IsUnchanged() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger { Pattern = "^<(.+?)>", Actions = new TriggerActions { SpawnTarget = "Chat" } }); + + var result = engine.Process(Line(" Ann waves")); + + await Assert.That(result.SpawnTargets.Single().Target).IsEqualTo("Chat"); } [Test] From 215ceb9b5351e8a12b2788c298504a2b23b0b857 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 30 Jul 2026 15:57:16 -0500 Subject: [PATCH 05/11] Delete the MSSP parser: TelnetNegotiationCore 2.6.5 reads it correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The byte-level MsspSubnegotiationParser existed for one reason — 2.6.0's MSSP reader destroyed the protocol's own data inside the library, before any consumer could see the bytes. PORT "80" "23" "4201" arrived as the integer 80234201, REFERRAL (array-only, and what a crawler follows) arrived null, booleans failed to bind from 1/0, CHARSET and every invented name were dropped, CRAWL_DELAY/MINIMUM_AGE bound to nothing, and a variable with no value wedged MSSP for the rest of the connection. All six are fixed upstream (PR #56, shipped in 2.6.5, which this repo already takes). MSSPConfig.Variables is now an ordered name → value-list map, so: - MsspSubnegotiationParser is deleted, along with TelnetSession's parallel wire scan and the degraded MsspSource.Interpreter fallback path it gated. The library's own callback is the one path again. - MsspData is now a projection with no parsing in it, built from MSSPConfig.Variables by MsspData.From. It survives because what it adds is ours rather than the protocol's: REFERRAL read as crawlable MsspHosts with normalising equality, CRAWL DELAY -1 read as "no preference" rather than a negative interval, ports validated as ports, and an immutable snapshot a report can be written from. - MsspVariables keeps only the names our accessors read by. The vocabulary rules — Canonicalise, IsOfficial, the 45-name official list — are now TelnetNegotiationCore.Models.MSSPVariables', derived from the same model that reads the wire. Two copies of a vocabulary drift. - MsspSource is gone: there is one source now. ObservationLog no longer writes a "source" field. The cases that pinned the parser now run against a real telnet session over the scripted server and assert on what MSSPConfig.Variables yields: multi-value PORT, REFERRAL as a list, the underscore and stray-whitespace spellings, unknown and unofficial variables, an empty value, a variable with no value (and that the rest of the report survives it), a payload split one byte per read, and another option's subnegotiation carrying the MSSP bytes by coincidence. All equivalent. Two are NOT equivalent, and are pinned by name as upstream defects rather than quietly accepted: - MSSPProtocol.FlushField decodes every field with Encoding.ASCII rather than the session's CurrentEncoding, so a non-ASCII NAME/WEBSITE/CONTACT comes back as question marks, one per byte ("Café" -> "Caf??"). The deleted parser read each field with whatever CHARSET had settled on. - IAC IAC inside a value loses the literal 0xFF: the state machine moves EscapingMSSPVal -> EvaluatingMSSPVal on IAC but registers no capture handler for that trigger. Only reachable on a payload MSSP forbids, so it is a one-byte loss rather than a lost report. Also: MSSP has no payload size cap upstream. SubnegotiationBuffer guards GMCP, MSDP and CHARSET's TTABLE; the MSSP field buffer is unbounded, so the parser's 64 KiB ceiling has no replacement and a hostile server can decide how much a crawler allocates for it. Reported, not patched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GpL7Ht6sLBsSEtVNsYcXMM --- CLAUDE.md | 19 +- src/SharpMUTerm.Core/Telnet/Mssp/MsspData.cs | 243 ++++------------ .../Telnet/Mssp/MsspSubnegotiationParser.cs | 271 ------------------ .../Telnet/Mssp/MsspVariables.cs | 142 +-------- src/SharpMUTerm.Core/Telnet/TelnetSession.cs | 59 +--- .../Output/ObservationLog.cs | 6 +- .../SharpMUTerm.Crawler.Tests/BackoffTests.cs | 6 +- .../MsspParsingTests.cs | 252 ++++++++++------ .../PersistenceTests.cs | 12 +- tests/SharpMUTerm.Crawler.Tests/ProbeTests.cs | 5 +- .../ReferralTests.cs | 5 +- .../Support/FakeProbe.cs | 8 +- .../Support/MsspWire.cs | 18 +- .../Support/ScriptedTransport.cs | 35 ++- 14 files changed, 303 insertions(+), 778 deletions(-) delete mode 100644 src/SharpMUTerm.Core/Telnet/Mssp/MsspSubnegotiationParser.cs diff --git a/CLAUDE.md b/CLAUDE.md index 11d14b95..c943aa87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ fallbacks) for inline images/maps. paged off an ephemeral per-session cache under `$XDG_CACHE_HOME`; absolute line indices, ranged reads capped at `MaxRangeLines`, and any disk failure degrades to memory-only. Emphatically **not** the session log — that stays `PlainTextLogSink`/`HtmlLogSink`, opt-in and kept), - `TcpTransport` (TLS + IPv6), `TelnetSession` (wraps TelnetNegotiationCore **2.6.0**), + `TcpTransport` (TLS + IPv6), `TelnetSession` (wraps TelnetNegotiationCore **2.6.5**), trigger/alias/macro engines + `IntervalScheduler`, plain-text + HTML logging, versioned JSON config (worlds → characters + shared trigger sets, with migration), `Theme`/`ThemeLibrary`, and `WorldSession`/`SessionManager` orchestration. @@ -477,13 +477,28 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] ## Other dependency notes -- **TelnetNegotiationCore 2.6.0** (repo owner is its author — extend it by PR rather than working +- **TelnetNegotiationCore 2.6.5** (repo owner is its author — extend it by PR rather than working around it). Fluent builder API; negotiates MCCP/MSDP/MXP itself; ships the keepalive interpreter (`WithKeepAlive(TimeSpan?, …)`, default 30s, clamped to 1s–24h). `TelnetSession` sets the init-only `CallbackOnByteAsync` reflectively to see raw bytes including unterminated prompts — a first-class `OnByte` builder hook remains a good upstream PR. It handles the option handshake (TELOPT, GA, TTYPE/MTTS, EOR, NAWS, CHARSET, MSSP, GMCP) — **Pueblo and all ANSI/MXP/Pueblo payload _parsing_ stay our layer.** +- **MSSP is read by the library, not by us — since 2.6.5, and that is the standing example of the + rule above.** 2.6.0's reader destroyed the protocol's own array notation inside the library: + `PORT "80" "23" "4201"` arrived as the integer `80234201`, `REFERRAL` (array-only, and the whole + reason a crawler connects) arrived null, booleans failed to bind from `1`/`0`, `CHARSET` and every + invented name were dropped, `CRAWL_DELAY`/`MINIMUM_AGE` bound to nothing, and a variable with no + value wedged MSSP for the rest of the connection. We carried a byte-level `MsspSubnegotiationParser` + for exactly as long as that was true; the fix went upstream (PR #56) and the parser is **deleted**. + Do not re-add one. `MSSPConfig.Variables` is now an ordered name → value-**list** map, + `MsspData.From` projects it, and `MsspData` is a projection with **no parsing in it** — what it adds + is ours: `REFERRAL` as crawlable `MsspHost`s, `CRAWL DELAY` −1 as "no preference", ports validated + as ports. Two upstream defects remain open and are pinned by name in `MsspParsingTests`: MSSP fields + are decoded as **ASCII** rather than the negotiated charset (a non-ASCII `NAME` comes back as + question marks, one per byte), and an escaped `IAC IAC` inside a value **loses the literal byte**. + MSSP also has no payload size cap upstream — `SubnegotiationBuffer` guards GMCP, MSDP and CHARSET's + TTABLE, but not this — so a hostile server can make the crawler buffer as much as it likes. - **Text encoding is CHARSET's answer, not a setting** (`SessionEncoding`, `TelnetSession.CurrentEncoding`). A world's `encoding` is `auto` by default — state the app's `CharsetOrder`, decode with whatever RFC 2066 settles on — and naming one is an *override*: still offered at the head of the order so a diff --git a/src/SharpMUTerm.Core/Telnet/Mssp/MsspData.cs b/src/SharpMUTerm.Core/Telnet/Mssp/MsspData.cs index 70dc68df..f2da947a 100644 --- a/src/SharpMUTerm.Core/Telnet/Mssp/MsspData.cs +++ b/src/SharpMUTerm.Core/Telnet/Mssp/MsspData.cs @@ -1,36 +1,28 @@ using System.Collections; using System.Globalization; +using TelnetNegotiationCore.Models; namespace SharpMUTerm.Core.Telnet.Mssp; -/// Where a set of MSSP values came from, and therefore how much of it survived. -public enum MsspSource -{ - /// - /// Parsed from the subnegotiation bytes by : every variable - /// the server sent, every value of every array, in wire order. - /// - Wire, - - /// - /// Projected from TelnetNegotiationCore's own MSSPConfig. Lossy — see - /// for exactly what the library discards. Only used - /// when the wire bytes were unavailable, which in practice means MCCP compression was already - /// running when the payload arrived. - /// - Interpreter, -} - /// -/// One server's MSSP report: every variable it sent, with every value, in the order it sent them. +/// One server's MSSP report, in the shape this application wants it: every variable it sent, with +/// every value, in the order it sent them, plus the domain readings a client and a crawler ask for. +/// +/// This projects; it does not parse. The bytes are read by TelnetNegotiationCore, which hands +/// back an ordered name → value-list map (MSSPConfig.Variables); everything here is built from +/// that. What this type adds over the library's own collection is the part that is ours rather than +/// the protocol's: REFERRAL read as s a crawler can follow and +/// deduplicate, CRAWL DELAY read as the specification's "no preference" rather than a negative +/// interval, ports validated as ports, and an immutable snapshot a report can be written from. +/// /// -/// The shape is a map from a canonical variable name (see ) -/// to an ordered list of values, and that is not incidental. MSSP has two ways to attach -/// several values to one variable — repeating the variable, and repeating MSSP_VAL under one -/// variable — and the specification gives both the same meaning: "multiple values should be ordered -/// from least to most relevant", with "the default value reported last". A model that kept one value -/// per variable would silently pick a server's least preferred port, and would lose -/// REFERRAL entirely, since a referral list is nothing but an array. +/// The shape is a map from a canonical variable name to an ordered list of values, and that +/// is not incidental. MSSP has two ways to attach several values to one variable — repeating the +/// variable, and repeating MSSP_VAL under one variable — and the specification gives both the +/// same meaning: "multiple values should be ordered from least to most relevant", with "the default +/// value reported last". A model that kept one value per variable would silently pick a server's +/// least preferred port, and would lose REFERRAL entirely, since a referral list is +/// nothing but an array. /// /// /// Nothing is discarded on the way in. Variables the specification does not define are kept beside @@ -44,18 +36,14 @@ public sealed class MsspData : IReadOnlyDictionary private readonly Dictionary> _values; private readonly List _order; - private MsspData(Dictionary> values, List order, MsspSource source) + private MsspData(Dictionary> values, List order) { _values = values; _order = order; - Source = source; } /// An empty report — a server that negotiated MSSP and then said nothing. - public static MsspData Empty { get; } = new([], [], MsspSource.Wire); - - /// How faithfully this data reflects what the server sent. - public MsspSource Source { get; } + public static MsspData Empty { get; } = new([], []); /// Variable names in the order the server first mentioned them. public IEnumerable Keys => _order; @@ -66,18 +54,18 @@ private MsspData(Dictionary> values, List /// Every value of , in wire order; empty when it was not sent. public IReadOnlyList this[string variable] => - _values.TryGetValue(MsspVariables.Canonicalise(variable), out var values) ? values : []; + _values.TryGetValue(MSSPVariables.Canonicalize(variable), out var values) ? values : []; /// The names in this report the specification defines, in wire order. - public IReadOnlyList OfficialNames => _order.Where(MsspVariables.IsOfficial).ToList(); + public IReadOnlyList OfficialNames => _order.Where(MSSPVariables.IsOfficial).ToList(); /// The names in this report the specification does not define, in wire order. - public IReadOnlyList UnofficialNames => _order.Where(n => !MsspVariables.IsOfficial(n)).ToList(); + public IReadOnlyList UnofficialNames => _order.Where(n => !MSSPVariables.IsOfficial(n)).ToList(); - public bool ContainsKey(string variable) => _values.ContainsKey(MsspVariables.Canonicalise(variable)); + public bool ContainsKey(string variable) => _values.ContainsKey(MSSPVariables.Canonicalize(variable)); public bool TryGetValue(string variable, out IReadOnlyList values) => - _values.TryGetValue(MsspVariables.Canonicalise(variable), out values!); + _values.TryGetValue(MSSPVariables.Canonicalize(variable), out values!); /// /// The default value of — the last one sent, per the @@ -183,6 +171,12 @@ public IReadOnlyList Referrals /// /// An MSSP integer, or null when unreported or unparseable. -1 is the specification's /// "data not available" marker for the World counts and resolves to null, not to minus one. + /// + /// This is deliberately narrower than the library's own MSSPVariableCollection.Integer, + /// which returns -1 as-is on the grounds that a caller may want to tell "the server said it + /// cannot count its rooms" from "the server never mentioned rooms". Everything reading this type + /// wants a count it can print or compare, and the raw string is still one indexer away. + /// /// public int? Integer(string variable) => int.TryParse(Default(variable), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) @@ -196,175 +190,42 @@ public IEnumerator>> GetEnumerator() IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); /// - /// Rebuilds a report from a flat name → values map (a persisted record read back from disk). - /// Names are canonicalised on the way in, so a store written by an older spelling still loads. - /// - public static MsspData FromValues( - IEnumerable>> values, - MsspSource source = MsspSource.Wire) - { - var builder = new Builder(source); - foreach (var (name, list) in values) - { - foreach (var value in list) - { - builder.Add(name, value); - } - } - - return builder.Build(); - } - - /// - /// Projects TelnetNegotiationCore's own MSSPConfig into this model — the degraded path, - /// used only when the subnegotiation bytes were not observable. + /// Projects a name → values map into this model, keeping every value of every variable in the + /// order given. The library's own MSSPConfig.Variables is exactly such a map, which is the + /// path a live session takes; a flat dictionary read back from a file is the other. /// - /// What the library loses, verified against 2.6.0. Its MSSP reader accumulates every - /// MSSP_VAL under one variable into a single byte buffer with no separator, so array - /// notation is destroyed before any model sees it: PORT 80 23 4201 arrives as the integer - /// 80234201, and REFERRAL — whose entire content is an array — is a concatenation - /// that then fails to bind to the list-typed property and is dropped to null. Boolean variables - /// (ANSI, UTF-8, PAY TO PLAY …) fail to bind from their string form and are - /// dropped. Variables outside the library's model — including the official CHARSET — are - /// dropped rather than collected into its Extended dictionary, which stays empty. - /// - /// - /// This projection therefore recovers the scalars and nothing else, and marks itself - /// so a consumer can tell. It is a fallback, not a design: - /// the fix belongs upstream, and is why the primary path - /// does not need it. + /// Names are canonicalised on the way in — by the library's , + /// so there is one vocabulary in the solution rather than two — which means a source that spells + /// MINIMUM_AGE and MINIMUM AGE separately still yields one variable. A name that + /// canonicalises to nothing is dropped; a variable with no values is kept, because "the server + /// mentioned this and said nothing" is a different fact from "the server never mentioned it". /// /// - public static MsspData FromInterpreterConfig(object? config) - { - var builder = new Builder(MsspSource.Interpreter); - if (config is null) - { - return builder.Build(); - } - - foreach (var property in config.GetType().GetProperties()) - { - if (property.GetIndexParameters().Length != 0 || !property.CanRead) - { - continue; - } - - object? value; - try - { - value = property.GetValue(config); - } - catch - { - continue; - } - - if (value is null) - { - continue; - } - - // The library tags each property with the MSSP name it came from; without that the model's - // C# spelling (Minimum_Age, UTF_8, XTerm_256_Colors) would leak out as if it were the - // protocol's, which is what this projection previously did. - var wireName = property.GetCustomAttributesData() - .FirstOrDefault(a => a.AttributeType.Name == "NameAttribute") - ?.ConstructorArguments is [{ Value: string named }, ..] - ? named - : null; - - if (value is IDictionary extended) - { - foreach (DictionaryEntry entry in extended) - { - if (entry.Key is string key && entry.Value is { } item) - { - builder.Add(key, item.ToString() ?? string.Empty); - } - } - - continue; - } - - if (wireName is null) - { - continue; - } - - if (value is IEnumerable items and not string) - { - foreach (var item in items) - { - if (item is not null) - { - builder.Add(wireName, item.ToString() ?? string.Empty); - } - } - - continue; - } - - builder.Add(wireName, value switch - { - bool flag => flag ? "1" : "0", - _ => value.ToString() ?? string.Empty, - }); - } - - return builder.Build(); - } - - /// Accumulates variables and values in wire order. - public sealed class Builder(MsspSource source = MsspSource.Wire) + public static MsspData From(IEnumerable>> variables) { - private readonly Dictionary> _values = []; - private readonly List _order = []; - - /// - /// Records one value of one variable. Repeating a variable appends to its list rather than - /// replacing it, which is what makes the two ways MSSP spells an array — repeated variables and - /// repeated values — end up in one place and keep their order. - /// - public Builder Add(string variable, string value) - { - List(variable)?.Add(value); - return this; - } + ArgumentNullException.ThrowIfNull(variables); - /// - /// Records that a variable was sent without recording a value for it. A variable with no - /// MSSP_VAL at all is malformed, but "the server mentioned this and said nothing" is a - /// different fact from "the server never mentioned it", and inventing an empty value to carry - /// the first would erase the difference. - /// - public Builder Declare(string variable) - { - List(variable); - return this; - } + var values = new Dictionary>(StringComparer.Ordinal); + var order = new List(); - private List? List(string variable) + foreach (var (variable, list) in variables) { - var name = MsspVariables.Canonicalise(variable); + var name = MSSPVariables.Canonicalize(variable); if (name.Length == 0) { - return null; + continue; } - if (!_values.TryGetValue(name, out var list)) + if (!values.TryGetValue(name, out var accumulated)) { - list = []; - _values[name] = list; - _order.Add(name); + accumulated = []; + values[name] = accumulated; + order.Add(name); } - return list; + accumulated.AddRange(list); } - public bool IsEmpty => _order.Count == 0; - - public MsspData Build() => - new(_values.ToDictionary(kv => kv.Key, kv => (IReadOnlyList)kv.Value), [.. _order], source); + return new MsspData(values.ToDictionary(kv => kv.Key, kv => (IReadOnlyList)kv.Value), order); } } diff --git a/src/SharpMUTerm.Core/Telnet/Mssp/MsspSubnegotiationParser.cs b/src/SharpMUTerm.Core/Telnet/Mssp/MsspSubnegotiationParser.cs deleted file mode 100644 index 348ad4b7..00000000 --- a/src/SharpMUTerm.Core/Telnet/Mssp/MsspSubnegotiationParser.cs +++ /dev/null @@ -1,271 +0,0 @@ -using System.Text; - -namespace SharpMUTerm.Core.Telnet.Mssp; - -/// -/// Reads MSSP subnegotiations straight off the inbound byte stream, incrementally and losslessly. -/// -/// Why this exists rather than the library's own reader. TelnetNegotiationCore negotiates MSSP -/// and hands back a MSSPConfig, but that model cannot represent what MSSP sends: every -/// MSSP_VAL under one variable is concatenated into a single buffer with no separator before -/// anything sees it. PORT 80 23 4201 becomes the integer 80234201, -/// and REFERRAL — which is nothing but an array — becomes a run-together string that then -/// fails to bind to its list-typed property and is discarded. The array is destroyed inside the -/// library, so no amount of post-processing recovers it, and REFERRAL is precisely the -/// variable MSSP exists to publish to crawlers. Boolean variables and every variable outside the -/// library's model (including the official CHARSET) are dropped as well. This is an upstream -/// bug and a good upstream PR; until then a crawler needs the bytes. -/// -/// -/// Scope. This parses; it does not negotiate. The option handshake stays with -/// TelnetNegotiationCore — which is what makes the server send the subnegotiation in the first place — -/// and this reads the payload it sends. There is no second telnet stack here: the only telnet framing -/// this understands is enough to find IAC SB MSSP … IAC SE in a stream and skip everything -/// else, including other options' subnegotiations, without being fooled by their payload bytes. -/// -/// -/// One limitation, deliberately. It reads the bytes as they arrive from the transport, so if -/// MCCP compression is already running when the payload arrives it sees compressed bytes and finds -/// nothing. covers that case by falling back to the library's own -/// (degraded) report, marked . In practice MSSP is sent during the -/// opening handshake, before compression starts. -/// -/// -public sealed class MsspSubnegotiationParser -{ - private const byte Iac = 255; - private const byte Se = 240; - private const byte Sb = 250; - private const byte Will = 251; - private const byte Dont = 254; - private const byte MsspOption = 70; - private const byte MsspVar = 1; - private const byte MsspVal = 2; - - /// - /// The default ceiling on one subnegotiation's payload. A legitimate MSSP report is a few hundred - /// bytes; a referral list from an enthusiastic server might reach a few thousand. 64 KiB is far - /// above anything real and far below anything that could exhaust a crawler probing many servers at - /// once — a remote server must not be able to decide how much memory we spend on it. - /// - public const int DefaultMaxPayloadBytes = 64 * 1024; - - private enum Scan - { - Data, - AfterIac, - SubnegotiationOption, - MsspPayload, - MsspPayloadAfterIac, - OtherSubnegotiation, - OtherSubnegotiationAfterIac, - SkipOptionByte, - } - - private readonly int _maxPayloadBytes; - private readonly List _field = []; - - private Scan _state = Scan.Data; - private MsspData.Builder? _builder; - private string? _variable; - private bool _fieldIsValue; - private bool _overflowed; - - public MsspSubnegotiationParser(Encoding? encoding = null, int maxPayloadBytes = DefaultMaxPayloadBytes) - { - ArgumentOutOfRangeException.ThrowIfLessThan(maxPayloadBytes, 64); - Encoding = encoding ?? Encoding.UTF8; - _maxPayloadBytes = maxPayloadBytes; - } - - /// - /// The encoding variable names and values are decoded with. Read at the moment a field completes, - /// not when the parser was built, so a CHARSET negotiation that settles mid-stream applies. - /// - public Encoding Encoding { get; set; } - - /// - /// True when a payload was abandoned for exceeding . Sticky for - /// the life of the parser, so a caller that only checks at the end still learns the report it got - /// (or did not get) was truncated rather than simply absent. - /// - public bool Truncated => _overflowed; - - /// - /// Feeds a chunk of inbound bytes and returns every MSSP report completed by it — normally none, - /// once in the connection's lifetime exactly one. State carries across calls, so a payload split - /// over any number of reads parses the same as one that arrives whole. - /// - public IReadOnlyList Consume(ReadOnlySpan bytes) - { - List? completed = null; - - foreach (var b in bytes) - { - switch (_state) - { - case Scan.Data: - if (b == Iac) - { - _state = Scan.AfterIac; - } - - break; - - case Scan.AfterIac: - // IAC IAC in the data stream is an escaped 0xFF, not a command; either way there is - // nothing here to collect, so both land back in Data. - _state = b switch - { - Sb => Scan.SubnegotiationOption, - >= Will and <= Dont => Scan.SkipOptionByte, - _ => Scan.Data, - }; - break; - - case Scan.SkipOptionByte: - _state = Scan.Data; - break; - - case Scan.SubnegotiationOption: - if (b == MsspOption) - { - BeginMssp(); - _state = Scan.MsspPayload; - } - else - { - // Some other option's subnegotiation. It must still be scanned to its IAC SE, - // with IAC IAC honoured, or a GMCP payload containing 0xFF 0xFA 0x46 would look - // like the start of an MSSP report. - _state = Scan.OtherSubnegotiation; - } - - break; - - case Scan.OtherSubnegotiation: - _state = b == Iac ? Scan.OtherSubnegotiationAfterIac : Scan.OtherSubnegotiation; - break; - - case Scan.OtherSubnegotiationAfterIac: - _state = b == Iac ? Scan.OtherSubnegotiation // escaped 0xFF inside the payload - : b == Se ? Scan.Data - : Scan.OtherSubnegotiation; - break; - - case Scan.MsspPayload: - if (b == Iac) - { - _state = Scan.MsspPayloadAfterIac; - } - else - { - Payload(b); - } - - break; - - case Scan.MsspPayloadAfterIac: - if (b == Se) - { - if (Finish() is { } report) - { - (completed ??= []).Add(report); - } - - _state = Scan.Data; - } - else - { - // IAC IAC is a literal 0xFF in the payload. Any other command byte here is a - // protocol violation; treating it as payload keeps the report rather than - // discarding a whole server's data over one stray byte. - Payload(b); - _state = Scan.MsspPayload; - } - - break; - } - } - - return completed ?? []; - } - - private void BeginMssp() - { - _builder = new MsspData.Builder(); - _field.Clear(); - _variable = null; - _fieldIsValue = false; - } - - private void Payload(byte b) - { - switch (b) - { - case MsspVar: - FlushField(); - _fieldIsValue = false; - _variable = null; - break; - - case MsspVal: - FlushField(); - _fieldIsValue = true; - break; - - default: - if (_field.Count >= _maxPayloadBytes) - { - _overflowed = true; - return; - } - - _field.Add(b); - break; - } - } - - private void FlushField() - { - if (_builder is null) - { - return; - } - - var text = _field.Count == 0 ? string.Empty : Encoding.GetString(_field.ToArray()); - _field.Clear(); - - if (_fieldIsValue) - { - // A value with no variable before it is malformed; there is nothing to attach it to. - if (_variable is not null) - { - _builder.Add(_variable, text); - } - - return; - } - - if (text.Length == 0) - { - return; - } - - // A variable name. Recorded before any value arrives, so a variable a server sends with no - // MSSP_VAL at all is still reported as present-but-empty rather than vanishing. - _variable = text; - _builder.Declare(text); - } - - private MsspData? Finish() - { - FlushField(); - var report = _builder?.Build(); - _builder = null; - _variable = null; - _fieldIsValue = false; - _field.Clear(); - return report; - } -} diff --git a/src/SharpMUTerm.Core/Telnet/Mssp/MsspVariables.cs b/src/SharpMUTerm.Core/Telnet/Mssp/MsspVariables.cs index 26ab83f3..133688de 100644 --- a/src/SharpMUTerm.Core/Telnet/Mssp/MsspVariables.cs +++ b/src/SharpMUTerm.Core/Telnet/Mssp/MsspVariables.cs @@ -1,22 +1,21 @@ namespace SharpMUTerm.Core.Telnet.Mssp; /// -/// The MSSP variable vocabulary: the names the specification defines, and the one canonical -/// spelling this codebase stores them under. +/// The canonical spellings of the MSSP variables this application reads by name — the ones +/// 's domain accessors are built on, and the ones an MSSP screen would label. /// -/// The specification (https://mudhalla.net/tintin/protocols/mssp/) says variable names -/// "should exist of upper case letters and may contain spaces", and that "as many programming -/// languages have difficulties with variable names which contain spaces clients and crawlers can -/// substitute spaces with underscores as the recommended solution". So a server may legitimately -/// send MINIMUM AGE or MINIMUM_AGE and mean one variable, and a case-insensitive -/// dictionary alone would treat them as two. is the single place that -/// decides they are the same thing; everything downstream keys on its output. +/// Names only. The vocabulary rules — which names are official, and the folding that makes +/// MINIMUM_AGE and MINIMUM AGE one variable — belong to +/// TelnetNegotiationCore.Models.MSSPVariables, which derives them from the same model that +/// reads the wire. Two copies of a vocabulary drift; this one exists so a domain accessor can say +/// what it is reading rather than repeating a string literal. /// /// /// Canonical form is the spaced, upper-case spelling — what the specification's own tables /// print — because that is what a human reading a report expects to see and what a server operator -/// would recognise from their own configuration. The underscore form is an encoding convenience, -/// not the name. +/// would recognise from their own configuration. The underscore form is an encoding convenience, not +/// the name; UTF-8 keeps its hyphen, because no underscore is involved. +/// See the specification. /// /// public static class MsspVariables @@ -31,14 +30,7 @@ public static class MsspVariables public const string Codebase = "CODEBASE"; public const string Contact = "CONTACT"; public const string CrawlDelay = "CRAWL DELAY"; - public const string Created = "CREATED"; - public const string Discord = "DISCORD"; public const string Hostname = "HOSTNAME"; - public const string Icon = "ICON"; - public const string Ip = "IP"; - public const string IpV6 = "IPV6"; - public const string Language = "LANGUAGE"; - public const string Location = "LOCATION"; public const string MinimumAge = "MINIMUM AGE"; public const string Port = "PORT"; public const string Referral = "REFERRAL"; @@ -48,119 +40,5 @@ public static class MsspVariables // ---- Categorisation ---- public const string Family = "FAMILY"; public const string Genre = "GENRE"; - public const string Gameplay = "GAMEPLAY"; public const string Status = "STATUS"; - public const string GameSystem = "GAMESYSTEM"; - public const string Intermud = "INTERMUD"; - public const string Subgenre = "SUBGENRE"; - - // ---- World ---- - public const string Areas = "AREAS"; - public const string Helpfiles = "HELPFILES"; - public const string Mobiles = "MOBILES"; - public const string Objects = "OBJECTS"; - public const string Rooms = "ROOMS"; - public const string Classes = "CLASSES"; - public const string Levels = "LEVELS"; - public const string Races = "RACES"; - public const string Skills = "SKILLS"; - - // ---- Protocols (all 1/0) ---- - public const string Ansi = "ANSI"; - public const string Utf8 = "UTF-8"; - public const string Vt100 = "VT100"; - public const string Xterm256Colors = "XTERM 256 COLORS"; - public const string XtermTrueColors = "XTERM TRUE COLORS"; - - // ---- Commercial / hiring (all 1/0) ---- - public const string PayToPlay = "PAY TO PLAY"; - public const string PayForPerks = "PAY FOR PERKS"; - public const string HiringBuilders = "HIRING BUILDERS"; - public const string HiringCoders = "HIRING CODERS"; - - /// - /// Every variable the specification's official tables list, in the order they appear there. - /// Anything outside this set is "unofficial" — either one of the widely-deployed extras below, - /// or something a codebase invented. Both are kept; neither is discarded. - /// - public static readonly IReadOnlyList Official = - [ - Name, Players, Uptime, - Charset, Codebase, Contact, CrawlDelay, Created, Discord, Hostname, Icon, Ip, IpV6, - Language, Location, MinimumAge, Port, Referral, Ssl, Website, - Family, Genre, Gameplay, Status, GameSystem, Intermud, Subgenre, - Areas, Helpfiles, Mobiles, Objects, Rooms, Classes, Levels, Races, Skills, - Ansi, Utf8, Vt100, Xterm256Colors, XtermTrueColors, - PayToPlay, PayForPerks, HiringBuilders, HiringCoders, - ]; - - /// - /// Variables that are not in the specification's tables but are sent by enough real servers to be - /// worth naming: the ones codebases and earlier drafts of MSSP shipped. They are recognised only - /// so a report can group them sensibly — an unrecognised variable is kept exactly as faithfully. - /// - public static readonly IReadOnlyList KnownUnofficial = - [ - "PUEBLO", "MSP", "MCCP", "MCP", "MXP", "GMCP", "MSDP", "SSH", "ATCP", "ZMP", - "MULTICLASSING", "NEWBIE FRIENDLY", "PLAYER KILLING", "ROLEPLAYING", "TRAINING SYSTEM", - "WORLD ORIGINALITY", "EQUIPMENT SYSTEM", "MULTIPLAYING", "PLAYERS ONLINE", "DBSIZE", - "EXITS", "EXTRA DESCRIPTIONS", "RESETS", "SHOPS", "SOCIALS", "VOLUME", - ]; - - private static readonly HashSet OfficialSet = new(Official, StringComparer.Ordinal); - - private static readonly HashSet KnownUnofficialSet = new(KnownUnofficial, StringComparer.Ordinal); - - /// - /// The one spelling a variable is stored under: trimmed, upper-cased, underscores folded to - /// spaces, and runs of whitespace collapsed to one space. - /// - /// Underscore folding is what makes MINIMUM_AGE and MINIMUM AGE one variable, per the - /// specification's own note that the substitution is a recommended encoding of the same name. It is - /// deliberately unconditional rather than restricted to names we recognise: a server that spells an - /// unofficial variable both ways in one payload should still get one entry, and we cannot know - /// which of its own names it considers canonical. - /// - /// - /// UTF-8 keeps its hyphen — the specification spells it that way and no underscore is - /// involved, so nothing here touches it. - /// - /// - public static string Canonicalise(string variable) - { - ArgumentNullException.ThrowIfNull(variable); - - var folded = new System.Text.StringBuilder(variable.Length); - var pendingSpace = false; - foreach (var ch in variable) - { - var c = ch == '_' ? ' ' : ch; - if (char.IsWhiteSpace(c)) - { - // Leading whitespace is dropped; interior runs become one space, decided when the - // next real character arrives so a trailing run costs nothing. - pendingSpace = folded.Length > 0; - continue; - } - - if (pendingSpace) - { - folded.Append(' '); - pendingSpace = false; - } - - folded.Append(char.ToUpperInvariant(c)); - } - - return folded.ToString(); - } - - /// True when is one of the specification's official variables. - public static bool IsOfficial(string variable) => OfficialSet.Contains(Canonicalise(variable)); - - /// - /// True when is unofficial but recognised — a variable real servers - /// send that the specification does not define. - /// - public static bool IsKnownUnofficial(string variable) => KnownUnofficialSet.Contains(Canonicalise(variable)); } diff --git a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs index 73844bde..7032fef9 100644 --- a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs @@ -302,20 +302,6 @@ public sealed class TelnetSession : ITelnetSession private SessionEncoding _lastReported; - /// - /// Reads MSSP off the wire in parallel with the library's own negotiation of it — the only way to - /// see array-valued variables at all, REFERRAL above all. See - /// for what the library loses and why. - /// - private readonly MsspSubnegotiationParser _mssp = new(); - - /// - /// Whether the parser above has reported anything this session. It gates the degraded fallback: the - /// library's own MSSP callback is only surfaced when the bytes were unreadable, which in practice - /// means MCCP compression started before the payload arrived. - /// - private bool _msspSeenOnWire; - private TelnetInterpreter? _interpreter; private CancellationTokenSource? _loopCts; private Task? _readLoop; @@ -637,44 +623,19 @@ private ValueTask OnMsdpAsync(TelnetInterpreter interpreter, string json) } /// - /// The library's own MSSP callback, kept only as a fallback. It fires for every MSSP payload, but - /// what it carries has already lost every array (see ), - /// so it is surfaced only when the wire parser saw nothing — the compressed case. Raising both - /// would deliver the same server twice, once complete and once degraded. + /// The library's MSSP callback: one report per IAC SB MSSP … IAC SE, carrying + /// MSSPConfig.Variables — every variable the server sent, with every value of every array, + /// in wire order. turns that into the shape this application reads. /// private ValueTask OnMsspAsync(MSSPConfig config) { - if (_msspSeenOnWire) - { - return ValueTask.CompletedTask; - } - - _logger.LogDebug( - "MSSP was not readable on the wire (compression?); falling back to the interpreter's own " - + "reduced view, which carries no arrays."); - MsspReceived?.Invoke(this, new MsspReceivedEventArgs(MsspData.FromInterpreterConfig(config))); + var report = MsspData.From(config.Variables); + _logger.LogInformation( + "MSSP: {Count} variables from {Name}.", report.Count, report.Name ?? "an unnamed server"); + MsspReceived?.Invoke(this, new MsspReceivedEventArgs(report)); return ValueTask.CompletedTask; } - /// - /// Runs the inbound bytes past the MSSP reader and raises anything it completed. The reader keeps - /// its own state, so a payload split across reads is no different from one that arrives whole. - /// - private void ScanForMssp(ReadOnlySpan bytes) - { - // The encoding can change mid-stream when CHARSET settles; the reader is told each time rather - // than capturing whatever was in force when the session was built. - _mssp.Encoding = CurrentEncoding.Encoding; - - foreach (var report in _mssp.Consume(bytes)) - { - _msspSeenOnWire = true; - _logger.LogInformation( - "MSSP: {Count} variables from {Name}.", report.Count, report.Name ?? "an unnamed server"); - MsspReceived?.Invoke(this, new MsspReceivedEventArgs(report)); - } - } - private ValueTask OnCompressionAsync(int version, bool enabled) { _logger.LogInformation("MCCP{Version} compression {State}.", version, enabled ? "enabled" : "disabled"); @@ -695,12 +656,6 @@ private async Task ReadLoopAsync(CancellationToken cancellationToken) break; // clean end of stream } - // Read MSSP off the raw bytes before handing them on. This has to happen here rather - // than through a library callback because the library flattens array-valued variables - // out of existence on the way to its own model; the negotiation that makes the server - // send this payload is still entirely the library's. - ScanForMssp(buffer.AsSpan(0, read)); - await _interpreter!.InterpretByteArrayAsync(buffer.AsMemory(0, read)).ConfigureAwait(false); // The second arm of charset observation: the direction whose callback the library diff --git a/src/SharpMUTerm.Crawler/Output/ObservationLog.cs b/src/SharpMUTerm.Crawler/Output/ObservationLog.cs index af6fcffe..4d1848f3 100644 --- a/src/SharpMUTerm.Crawler/Output/ObservationLog.cs +++ b/src/SharpMUTerm.Crawler/Output/ObservationLog.cs @@ -21,7 +21,7 @@ namespace SharpMUTerm.Crawler.Output; /// /// Variables are written as the protocol sends them — the canonical name against an array of /// values — so REFERRAL and a multi-valued PORT survive the round trip. Flattening to -/// one value per key here would throw away the same thing the telnet library throws away. +/// one value per key here would lose the arrays MSSP exists to publish. /// /// public sealed class ObservationLog(string path) : IDisposable @@ -56,7 +56,6 @@ public void Append(ProbeResult result) Outcome = result.Outcome, DurationMs = (long)result.Duration.TotalMilliseconds, Error = result.Error, - Source = result.Data?.Source.ToString(), Name = result.Data?.Name, Players = result.Data?.Players, Uptime = result.Data?.Uptime, @@ -108,9 +107,6 @@ private sealed class ObservationRecord public string? Error { get; init; } - /// Whether the values came off the wire complete, or from the library's reduced view. - public string? Source { get; init; } - public string? Name { get; init; } public int? Players { get; init; } diff --git a/tests/SharpMUTerm.Crawler.Tests/BackoffTests.cs b/tests/SharpMUTerm.Crawler.Tests/BackoffTests.cs index 8d503448..1ad12eb8 100644 --- a/tests/SharpMUTerm.Crawler.Tests/BackoffTests.cs +++ b/tests/SharpMUTerm.Crawler.Tests/BackoffTests.cs @@ -139,8 +139,7 @@ public async Task AServerAskingForALongerCrawlDelayGetsIt() var options = new CrawlOptions { RevisitInterval = TimeSpan.FromHours(6) }; var (frontier, host) = Seeded(options); - var data = new MsspSubnegotiationParser() - .Consume(MsspWire.Subnegotiation(("CRAWL DELAY", ["23"]))).Single(); + var data = MsspWire.Report(("CRAWL DELAY", ["23"])); frontier.Record(Result(host, CrawlOutcome.MsspReceived, data)); @@ -156,8 +155,7 @@ public async Task AServerAskingForAShorterCrawlDelayDoesNotGetVisitedMoreOften() var options = new CrawlOptions { RevisitInterval = TimeSpan.FromHours(24) }; var (frontier, host) = Seeded(options); - var data = new MsspSubnegotiationParser() - .Consume(MsspWire.Subnegotiation(("CRAWL DELAY", ["1"]))).Single(); + var data = MsspWire.Report(("CRAWL DELAY", ["1"])); frontier.Record(Result(host, CrawlOutcome.MsspReceived, data)); diff --git a/tests/SharpMUTerm.Crawler.Tests/MsspParsingTests.cs b/tests/SharpMUTerm.Crawler.Tests/MsspParsingTests.cs index 53163e9f..546793d1 100644 --- a/tests/SharpMUTerm.Crawler.Tests/MsspParsingTests.cs +++ b/tests/SharpMUTerm.Crawler.Tests/MsspParsingTests.cs @@ -1,25 +1,56 @@ using System.Text; using SharpMUTerm.Core.Telnet.Mssp; +using SharpMUTerm.Crawler.Model; +using SharpMUTerm.Crawler.Probing; using SharpMUTerm.Crawler.Tests.Support; +using TelnetNegotiationCore.Models; namespace SharpMUTerm.Crawler.Tests; /// -/// The wire parser, against payloads built byte by byte from the specification's own format. +/// What a server's MSSP report turns into, against payloads built byte by byte from the +/// specification's own format and fed through a real telnet session. +/// +/// There is no MSSP parser in this repository any more. These cases used to pin one, written +/// because TelnetNegotiationCore's reader destroyed arrays, booleans and unknown variables before any +/// consumer saw them. That is fixed upstream (2.6.5), so the same cases now run against the library's +/// own MSSPConfig.Variables by way of , and prove the replacement keeps +/// what the workaround kept. The two that diverge are named as such and say why. +/// /// public class MsspParsingTests { - private static MsspData ParseOne(params (string Variable, string[] Values)[] entries) + private static MsspHost Host => MsspHost.Create("server.example.org", 4201)!; + + private static CrawlOptions Options => new() + { + ConnectTimeout = TimeSpan.FromSeconds(5), + MsspTimeout = TimeSpan.FromSeconds(5), + }; + + /// + /// One server, one report: a scripted server offers MSSP, answers the crawler's DO with + /// , and the report the probe came back with is returned. + /// + private static Task Read(params (string Variable, string[] Values)[] entries) => + ReadRaw(MsspWire.Subnegotiation(entries)); + + private static async Task ReadRaw(byte[] payload, bool fragmented = false) { - var parser = new MsspSubnegotiationParser(); - var reports = parser.Consume(MsspWire.Subnegotiation(entries)); - return reports.Single(); + var transport = new ScriptedTransport { Greeting = MsspWire.Offer(), Fragmented = fragmented } + .RespondingToDo(MsspWire.Mssp, payload); + + var result = await new TelnetMsspProbe(Options, _ => transport).ProbeAsync(Host, CancellationToken.None); + + return result.Outcome == CrawlOutcome.MsspReceived && result.Data is { } data + ? data + : throw new InvalidOperationException($"No MSSP report: {result.Outcome} ({result.Error})."); } [Test] public async Task AFullPayloadKeepsEveryVariableItWasSent() { - var data = ParseOne(MsspWire.RepresentativeReport("mud.example.net 4000")); + var data = await Read(MsspWire.RepresentativeReport("mud.example.net 4000")); await Assert.That(data.Name).IsEqualTo("Corvid Nest"); await Assert.That(data.Players).IsEqualTo(17); @@ -27,28 +58,40 @@ public async Task AFullPayloadKeepsEveryVariableItWasSent() await Assert.That(data.Hostname).IsEqualTo("corvid.example.org"); await Assert.That(data.Contact).IsEqualTo("admin@corvid.example.org"); await Assert.That(data.Website).IsEqualTo("https://corvid.example.org/"); + await Assert.That(data.Count).IsEqualTo(22); } [Test] public async Task ArrayNotationKeepsEveryValueInOrderWithTheDefaultLast() { // "It's also possible to attach several values to a single variable by using MSSP_VAL more than - // once, with the default value reported last." This is the case TelnetNegotiationCore 2.6.0 - // concatenates into the integer 80234201. - var data = ParseOne(("PORT", ["80", "23", "4201"])); + // once, with the default value reported last." This is the case 2.6.0 concatenated into the + // integer 80234201, and the reason a parser was written here at all. + var data = await Read(("PORT", ["80", "23", "4201"])); await Assert.That(data["PORT"]).IsEquivalentTo(new[] { "80", "23", "4201" }); await Assert.That(data.Ports).IsEquivalentTo(new[] { 80, 23, 4201 }); await Assert.That(data.Port).IsEqualTo(4201); } + [Test] + public async Task TheReferralListArrivesAsAList() + { + // REFERRAL is array-only and is what a crawler follows; 2.6.0 delivered it as null. + var data = await Read(("REFERRAL", ["a.example.org 4000", "b.example.net 23", "2001:db8::5 4201"])); + + await Assert.That(data["REFERRAL"]).Count().IsEqualTo(3); + await Assert.That(data.Referrals.Select(r => r.ToReferralString())) + .IsEquivalentTo(new[] { "a.example.org 4000", "b.example.net 23", "2001:db8::5 4201" }); + } + [Test] public async Task ARepeatedVariableAccumulatesRatherThanReplacing() { // "The same variable can be send more than once with different values, in which case the last // reported value should be used as the default value." The two spellings of an array must end up // in one place, or a consumer would have to know which one a server chose. - var data = ParseOne(("CODEBASE", ["Merc 2.1"]), ("CODEBASE", ["ROM 2.4"])); + var data = await Read(("CODEBASE", ["Merc 2.1"]), ("CODEBASE", ["ROM 2.4"])); await Assert.That(data["CODEBASE"]).IsEquivalentTo(new[] { "Merc 2.1", "ROM 2.4" }); await Assert.That(data.Codebase).IsEqualTo("ROM 2.4"); @@ -57,7 +100,7 @@ public async Task ARepeatedVariableAccumulatesRatherThanReplacing() [Test] public async Task UnofficialAndWhollyUnknownVariablesAreKept() { - var data = ParseOne(MsspWire.RepresentativeReport()); + var data = await Read(MsspWire.RepresentativeReport()); // Unofficial but real. await Assert.That(data.Flag("PUEBLO")).IsTrue(); @@ -76,9 +119,9 @@ await Assert.That(data.UnofficialNames) [Test] public async Task BooleanAndCharsetVariablesSurvive() { - // Every one of these is dropped by the telnet library: the booleans fail to bind from their - // string form, and CHARSET is official but absent from its model. - var data = ParseOne(MsspWire.RepresentativeReport()); + // Every one of these was dropped by 2.6.0: the booleans failed to bind from their string form, + // and CHARSET is official but had no property on the model. + var data = await Read(MsspWire.RepresentativeReport()); await Assert.That(data.Flag("ANSI")).IsTrue(); await Assert.That(data.Flag("UTF-8")).IsTrue(); @@ -89,40 +132,46 @@ public async Task BooleanAndCharsetVariablesSurvive() [Test] public async Task TheVocabularyMatchesTheSpecificationsOwnTables() { - // A crawler's whole job is to report what the protocol defines, so the list it defines it - // against is worth pinning: the three required variables, the ones whose names contain spaces - // (the case the underscore substitution exists for), and the boundary between official and not. - await Assert.That(MsspVariables.Official.Count).IsEqualTo(45); + // The vocabulary is the telnet library's now — one list, derived from the same model that reads + // the wire — so this pins *its* list rather than a second copy. The three required variables, + // the ones whose names contain spaces (the case the underscore substitution exists for), and + // the boundary between official and not. + await Assert.That(MSSPVariables.Official.Count).IsEqualTo(45); foreach (var required in new[] { "NAME", "PLAYERS", "UPTIME" }) { - await Assert.That(MsspVariables.IsOfficial(required)).IsTrue(); + await Assert.That(MSSPVariables.IsOfficial(required)).IsTrue(); } foreach (var spaced in new[] { "CRAWL DELAY", "MINIMUM AGE", "XTERM 256 COLORS", "PAY TO PLAY", "HIRING CODERS" }) { - await Assert.That(MsspVariables.IsOfficial(spaced)).IsTrue(); - await Assert.That(MsspVariables.IsOfficial(spaced.Replace(' ', '_'))).IsTrue(); + await Assert.That(MSSPVariables.IsOfficial(spaced)).IsTrue(); + await Assert.That(MSSPVariables.IsOfficial(spaced.Replace(' ', '_'))).IsTrue(); } - // Unofficial but widely deployed: recognised, and correctly not claimed as official. - await Assert.That(MsspVariables.IsKnownUnofficial("PUEBLO")).IsTrue(); - await Assert.That(MsspVariables.IsOfficial("PUEBLO")).IsFalse(); + // The two the specification's tables omit and 2.6.5 added: CHARSET and DISCORD. + await Assert.That(MSSPVariables.IsOfficial(MsspVariables.Charset)).IsTrue(); + await Assert.That(MSSPVariables.IsOfficial("DISCORD")).IsTrue(); - // Wholly unknown: neither, and still kept by the parser. - await Assert.That(MsspVariables.IsOfficial("CORVID SPECIFIC")).IsFalse(); - await Assert.That(MsspVariables.IsKnownUnofficial("CORVID SPECIFIC")).IsFalse(); + // Unofficial but widely deployed: modelled, and correctly not claimed as official. + await Assert.That(MSSPVariables.IsKnown("PUEBLO")).IsTrue(); + await Assert.That(MSSPVariables.IsOfficial("PUEBLO")).IsFalse(); + + // Wholly unknown: neither, and still kept in the report. + await Assert.That(MSSPVariables.IsOfficial("CORVID SPECIFIC")).IsFalse(); + await Assert.That(MSSPVariables.IsKnown("CORVID SPECIFIC")).IsFalse(); // UTF-8 keeps its hyphen; nothing in the folding touches it. - await Assert.That(MsspVariables.Canonicalise("utf-8")).IsEqualTo("UTF-8"); + await Assert.That(MSSPVariables.Canonicalize("utf-8")).IsEqualTo("UTF-8"); } [Test] public async Task TheUnderscoreSpellingIsTheSameVariableAsTheSpacedOne() { // The specification's own recommendation: "clients and crawlers can substitute spaces with - // underscores". A server may use either and mean one variable. - var data = ParseOne(("MINIMUM_AGE", ["13"]), ("MINIMUM AGE", ["18"])); + // underscores". A server may use either and mean one variable. 2.6.0 bound neither, because it + // matched names with a culture-sensitive ToUpper() and no substitution. + var data = await Read(("MINIMUM_AGE", ["13"]), ("MINIMUM AGE", ["18"])); await Assert.That(data.Count).IsEqualTo(1); await Assert.That(data["MINIMUM AGE"]).IsEquivalentTo(new[] { "13", "18" }); @@ -132,7 +181,7 @@ public async Task TheUnderscoreSpellingIsTheSameVariableAsTheSpacedOne() [Test] public async Task VariableNamesAreMatchedWithoutRegardToCaseOrStrayWhitespace() { - var data = ParseOne((" crawl delay ", ["11"])); + var data = await Read((" crawl delay ", ["11"])); await Assert.That(data.ContainsKey("CRAWL DELAY")).IsTrue(); await Assert.That(data.CrawlDelay).IsEqualTo(TimeSpan.FromHours(11)); @@ -141,25 +190,32 @@ public async Task VariableNamesAreMatchedWithoutRegardToCaseOrStrayWhitespace() [Test] public async Task ACrawlDelayOfMinusOneMeansNoPreferenceRatherThanANegativeInterval() { - // "Send -1 to use the crawler's default." - await Assert.That(ParseOne(("CRAWL DELAY", ["-1"])).CrawlDelay).IsNull(); - await Assert.That(ParseOne(("CRAWL DELAY", ["23"])).CrawlDelay).IsEqualTo(TimeSpan.FromHours(23)); + // "Send -1 to use the crawler's default." The library's own Integer() hands -1 back as-is, + // deliberately; the reading that a scheduler can use is this projection's. + await Assert.That((await Read(("CRAWL DELAY", ["-1"]))).CrawlDelay).IsNull(); + await Assert.That((await Read(("CRAWL DELAY", ["23"]))).CrawlDelay).IsEqualTo(TimeSpan.FromHours(23)); } [Test] public async Task AWorldCountOfMinusOneMeansUnavailableRatherThanMinusOne() { // "If your Mud can't calculate one of the numeric values for the World variables you can use - // -1 to indicate that the data is not available." - await Assert.That(ParseOne(("ROOMS", ["-1"])).Integer("ROOMS")).IsNull(); - await Assert.That(ParseOne(("ROOMS", ["0"])).Integer("ROOMS")).IsEqualTo(0); + // -1 to indicate that the data is not available." Again the library returns -1 and this + // projection returns null; the raw string is still one indexer away. + var unavailable = await Read(("ROOMS", ["-1"])); + await Assert.That(unavailable.Integer("ROOMS")).IsNull(); + await Assert.That(unavailable["ROOMS"]).IsEquivalentTo(new[] { "-1" }); + + await Assert.That((await Read(("ROOMS", ["0"]))).Integer("ROOMS")).IsEqualTo(0); } [Test] public async Task AnEmptyValueIsAValueAndAVariableWithNoValueIsNot() { - // "The value can be an empty string." - var data = ParseOne(("CONTACT", [""]), ("ICON", [])); + // "The value can be an empty string." A variable with no MSSP_VAL at all is malformed, but a + // server that sends one used to wedge MSSP dead for the whole connection — EscapingMSSPVar + // permitted only IAC, so the SE that followed the name went unhandled. + var data = await Read(("CONTACT", [""]), ("ICON", [])); await Assert.That(data.ContainsKey("CONTACT")).IsTrue(); await Assert.That(data.Contact).IsEqualTo(string.Empty); @@ -170,33 +226,34 @@ public async Task AnEmptyValueIsAValueAndAVariableWithNoValueIsNot() } [Test] - public async Task APayloadSplitAcrossReadsParsesTheSameAsAWholeOne() + public async Task AVariableWithNoValueDoesNotStopTheRestOfTheReportBeingRead() { - var whole = MsspWire.Subnegotiation(MsspWire.RepresentativeReport("a.example.org 4000")); + // The other half of the wedge: everything after the valueless variable has to survive. + var data = await Read(("ICON", []), ("NAME", ["After"]), ("PORT", ["23", "4201"])); - var parser = new MsspSubnegotiationParser(); - MsspData? report = null; + await Assert.That(data["ICON"]).IsEmpty(); + await Assert.That(data.Name).IsEqualTo("After"); + await Assert.That(data.Ports).IsEquivalentTo(new[] { 23, 4201 }); + } - // One byte at a time — the worst case a TCP stream can produce, and the one an incremental - // parser has to survive. - foreach (var b in whole) - { - foreach (var completed in parser.Consume([b])) - { - report = completed; - } - } + [Test] + public async Task APayloadSplitAcrossReadsParsesTheSameAsAWholeOne() + { + // One byte per read — the worst case a TCP stream can produce. + var data = await ReadRaw( + MsspWire.Subnegotiation(MsspWire.RepresentativeReport("a.example.org 4000")), + fragmented: true); - await Assert.That(report).IsNotNull(); - await Assert.That(report!.Name).IsEqualTo("Corvid Nest"); - await Assert.That(report.Referrals.Single().ToReferralString()).IsEqualTo("a.example.org 4000"); + await Assert.That(data.Name).IsEqualTo("Corvid Nest"); + await Assert.That(data.Ports).IsEquivalentTo(new[] { 80, 23, 4201 }); + await Assert.That(data.Referrals.Single().ToReferralString()).IsEqualTo("a.example.org 4000"); } [Test] public async Task AnotherOptionsSubnegotiationCannotBeMistakenForMssp() { // A GMCP payload is arbitrary text and can contain the bytes 0xFF 0xFA 0x46 by coincidence. A - // scanner that looked for MSSP anywhere in the stream would read that as the start of a report. + // reader that looked for MSSP anywhere in the stream would read that as the start of a report. var stream = new List(); stream.AddRange([MsspWire.Iac, MsspWire.Sb, MsspWire.Gmcp]); stream.AddRange(Encoding.UTF8.GetBytes("Core.Hello {\"client\":\"x\"}")); @@ -207,61 +264,72 @@ public async Task AnotherOptionsSubnegotiationCannotBeMistakenForMssp() stream.AddRange(Encoding.UTF8.GetBytes("Not A Real Report")); stream.AddRange([MsspWire.Iac, MsspWire.Se]); - var parser = new MsspSubnegotiationParser(); - await Assert.That(parser.Consume([.. stream])).IsEmpty(); - // …and the real one that follows is still read. - var real = parser.Consume(MsspWire.Subnegotiation(("NAME", ["Real"]))); - await Assert.That(real.Single().Name).IsEqualTo("Real"); + stream.AddRange(MsspWire.Subnegotiation(("NAME", ["Real"]))); + + await Assert.That((await ReadRaw([.. stream])).Name).IsEqualTo("Real"); } [Test] - public async Task AnEscapedIacInsideAValueIsALiteralByte() + public async Task NegotiationForOtherOptionsIsSteppedOverWithoutConfusingTheReader() { - var bytes = new List { MsspWire.Iac, MsspWire.Sb, MsspWire.Mssp, MsspWire.Var }; - bytes.AddRange(Encoding.UTF8.GetBytes("NAME")); - bytes.Add(MsspWire.Val); - bytes.AddRange(Encoding.Latin1.GetBytes("a")); - bytes.AddRange([MsspWire.Iac, MsspWire.Iac]); - bytes.AddRange(Encoding.Latin1.GetBytes("b")); - bytes.AddRange([MsspWire.Iac, MsspWire.Se]); + // IAC DO/WILL/WONT/DONT internal sealed class ScriptedTransport : ITransport @@ -45,6 +45,13 @@ public byte[] Sent /// Bytes to deliver as soon as the connection opens — the server's opening negotiation. public byte[] Greeting { get; init; } = []; + /// + /// Delivers everything one byte per read — the worst case a TCP stream can produce, and the one an + /// incremental reader has to survive. A payload split this way must parse the same as one that + /// arrives whole. + /// + public bool Fragmented { get; init; } + /// /// When the client answers IAC DO <option>, deliver . This is /// how the MSSP report is made conditional on the client having asked for it. @@ -60,7 +67,7 @@ public Task ConnectAsync(CancellationToken cancellationToken = default) IsConnected = true; if (Greeting.Length > 0) { - _inbound.Writer.TryWrite(Greeting); + Deliver(Greeting); } return Task.CompletedTask; @@ -81,7 +88,7 @@ public ValueTask SendAsync(ReadOnlyMemory data, CancellationToken cancella if (i + 2 < bytes.Length && bytes[i] == Iac && bytes[i + 1] == Do && _onDo.TryGetValue(bytes[i + 2], out var response)) { - _inbound.Writer.TryWrite(response); + Deliver(response); } } @@ -116,7 +123,25 @@ public async ValueTask ReceiveAsync(Memory buffer, CancellationToken } /// Delivers bytes to the client, as a server would mid-conversation. - public void SendToClient(params byte[] bytes) => _inbound.Writer.TryWrite(bytes); + public void SendToClient(params byte[] bytes) => Deliver(bytes); + + /// + /// Queues bytes for the reader. hands back at most one queued chunk per + /// call, so queuing one byte at a time is what makes arrive that way. + /// + private void Deliver(byte[] bytes) + { + if (!Fragmented) + { + _inbound.Writer.TryWrite(bytes); + return; + } + + foreach (var b in bytes) + { + _inbound.Writer.TryWrite([b]); + } + } /// /// Waits (up to a second) for the client to have written . Polling, but From 3612b641b758bfb3a8c3adf5a6d6ac1b41a8fad8 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 30 Jul 2026 16:07:35 -0500 Subject: [PATCH 06/11] Number panes by creation, and say in the rail who is in each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panes were counted in tree order, so a pane's number was a function of where it sat: dropping a window on the left edge of pane 2 made that pane into pane 3, and Alt+2 stopped meaning what it meant while the user was doing something else. PaneNode now carries a per-workspace creation Sequence, persisted in LayoutNodeState, and WorkspaceLayout.Panes sorts by it — so a pane keeps its number for as long as it is open. The number is the index in that list rather than the sequence itself, which is what compacts the numbering on a close: shut pane 2 of three and the survivors are 1 and 2, not 1 and 3 with Alt+2 silently doing nothing. A layout restored without sequences (a config written before the field) is seeded from tree order, which is the numbering it was saved under, so an existing workspace comes back reading exactly as it was left. Ctrl+O cycles in the same order. It read tree order, which agreed with the numbering back when the numbering was tree order; the two ordinal movers have to count one sequence. Alt+N was already global — it indexes the workspace's one split tree, not the active character's windows — but only the active character's window rows were drawn, so the other panes' numbers were invisible from anywhere you could use them. Every character row now carries the pane its session is in. The owner filter stays: a window row under a character still means that window is theirs. It costs no width — across every multi-pane snapshot view the frame changes by exactly the six cells of the new column, and no pane rectangle moves. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GpL7Ht6sLBsSEtVNsYcXMM --- CLAUDE.md | 28 +- src/SharpMUTerm.Core/Commands/CommandIds.cs | 12 +- src/SharpMUTerm.Core/Workspace/LayoutNode.cs | 41 ++- .../Workspace/WorkspaceLayout.cs | 75 +++- .../Workspace/WorkspaceState.cs | 15 +- src/SharpMUTerm.Core/Workspaces/RailModel.cs | 33 +- src/SharpMUTerm.Tui/RailRenderer.cs | 21 +- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 84 ++++- .../Workspace/PaneNumberingTests.cs | 330 ++++++++++++++++++ tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs | 14 +- .../PaneNumberingRailTests.cs | 312 +++++++++++++++++ 11 files changed, 937 insertions(+), 28 deletions(-) create mode 100644 tests/SharpMUTerm.Core.Tests/Workspace/PaneNumberingTests.cs create mode 100644 tests/SharpMUTerm.Tui.Tests/PaneNumberingRailTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 11d14b95..0cb0ba5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -288,12 +288,36 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] `9`→`0x39`, `0`→`0x30`. So three of them are keys the client cannot afford to bind over and three are indistinguishable from typing — binding any breaks the plain key, exactly as with Ctrl+H/I/J/M. Recorded per digit in `MacroKeys.DigitBytes`, which is what F4 prints. -- **Panes are numbered one way, everywhere: `pane N` in `Layout.Panes` order** (left-to-right, then - top-to-bottom). The rail's hosting column, the ⌃P `Go to pane N` entries, the move/drag overlays +- **Panes are numbered one way, everywhere: `pane N` in `Layout.Panes` order, which is *creation* + order.** The rail's hosting column, the ⌃P `Go to pane N` entries, the move/drag overlays (`PaneLabel`) and the ⌥N chord are four spellings of that one number. `PaneLabel` used to call the first pane `main` — the spelling the rail abandoned because `▪ main main` is two meanings in one line — so the same pane read `pane 1` in the sidebar and `main` under the cursor. Harmless until a *chord* had to land on the pane a label names; a key that disagrees with the label is worse than no key. + - **`Layout.Panes` is creation order; `LayoutNode.Panes()` is tree order, and they are different + things.** Tree order (left-to-right, then top-to-bottom) is geometry and is what `LayoutSolver`, + `PaneResize` and the renderer walk. It used to be the numbering too, and a number that is a function + of *where a pane is* moves when a pane is inserted before it: dropping a window on the left edge of + pane 2 made that pane into pane 3, so ⌥2 stopped meaning what it meant while the user was doing + something else. `PaneNode.Sequence` is a per-workspace counter assigned at creation, persisted in + `LayoutNodeState`, and never reused; a pane restored without one (a config written before the field) + is seeded from tree order, which is the numbering it was saved under. + - **The number is the *index* in `Layout.Panes`, never the `Sequence` itself.** Sequences have holes + after a close; the numbering may not, or ⌥2 is a silent no-op with panes 1 and 3 on the screen. + Closing pane 2 of three leaves 1 and 2 (`PaneNumberingTests`, + `PaneNumberingRailTests.ClosingAPaneCompactsTheNumberingOnTheChordAndInTheSidebar`). + - **⌃O cycles in that same order.** It read tree order, which agreed with the numbering back when the + numbering *was* tree order. The two ordinal movers have to count one sequence or three presses of + ⌃O from pane 1 don't land where ⌥4 does. +- **The pane number is global, and the rail is where you read it.** ⌥N has always indexed the + workspace's one split tree rather than the active character's windows, so it has always been able to + reach another character's pane — that is what makes the nine chords a character switcher. But window + rows are drawn for the *active* character only (`BuildRailWindows`'s owner filter, which stays: a + window row under a character means that window is theirs), so the other panes' numbers were invisible + from anywhere you could use them. Every **character** row now carries the pane its session is in + (`CharacterPaneLabel` → `RailCharacter.Pane`), active or not — one column on a row that already + exists, in the same `pane N` vocabulary, costing no width at rest because a window row is indented + deeper and carries the pen field as well. - **The ordinal pane movers carry a zoom; the directional ones cannot.** A zoomed workspace realises exactly one pane, so ⌃O or ⌥N moving the selection and leaving `ZoomedPaneId` behind puts the selection, the session the bar talks to and the caret on a pane that is **not on the screen** — the diff --git a/src/SharpMUTerm.Core/Commands/CommandIds.cs b/src/SharpMUTerm.Core/Commands/CommandIds.cs index d297fe32..05069a8e 100644 --- a/src/SharpMUTerm.Core/Commands/CommandIds.cs +++ b/src/SharpMUTerm.Core/Commands/CommandIds.cs @@ -37,9 +37,15 @@ public static class CommandIds /// /// The id that focuses the th pane, counting the way every surface in this - /// client counts panes: order, which is - /// left-to-right then top-to-bottom, which is the order the connection rail's pane N column - /// numbers them in. The chord (⌥N), the rail's label and this id are three spellings of one number. + /// client counts panes: order, which is + /// creation order, which is the order the connection rail's pane N column numbers them + /// in. The chord (⌥N), the rail's label and this id are three spellings of one number. + /// + /// It was tree order — left-to-right then top-to-bottom — and that renumbered panes that already + /// existed whenever one was inserted before them, so a number a user had learnt moved without being + /// touched. Creation order is stable while a pane is open, and closing one compacts the rest so the + /// range stays contiguous. + /// /// public static string Pane(int number) => PanePrefix + number.ToString(System.Globalization.CultureInfo.InvariantCulture); } diff --git a/src/SharpMUTerm.Core/Workspace/LayoutNode.cs b/src/SharpMUTerm.Core/Workspace/LayoutNode.cs index b1ee7fb9..37efdaa0 100644 --- a/src/SharpMUTerm.Core/Workspace/LayoutNode.cs +++ b/src/SharpMUTerm.Core/Workspace/LayoutNode.cs @@ -22,7 +22,17 @@ public enum Edge /// A node in the workspace split tree: either a or a . public abstract class LayoutNode { - /// Enumerates every pane at or under this node, in left-to-right / top-to-bottom order. + /// + /// Enumerates every pane at or under this node in tree order — left-to-right / + /// top-to-bottom, which is geometry and nothing else. + /// + /// This is the order the renderer and the resizer want, and it is not the order panes are + /// numbered in. Tree order is a function of where a pane sits, so creating one renumbers whatever + /// it was inserted before: splitting the second of two panes on its left edge makes the old pane 2 + /// into pane 3, and ⌥2 stops meaning what it meant a moment earlier. The numbering users see and + /// press comes from , which is creation order. + /// + /// public abstract IEnumerable Panes(); } @@ -32,17 +42,44 @@ public abstract class LayoutNode /// public sealed class PaneNode : LayoutNode { - public PaneNode(string id, IEnumerable? tabs = null, int activeIndex = 0, bool frozen = false) + public PaneNode( + string id, + IEnumerable? tabs = null, + int activeIndex = 0, + bool frozen = false, + int sequence = Unsequenced) { Id = id ?? throw new ArgumentNullException(nameof(id)); Tabs = tabs is null ? new List() : new List(tabs); ActiveIndex = Tabs.Count == 0 ? -1 : Math.Clamp(activeIndex, 0, Tabs.Count - 1); Frozen = frozen; + Sequence = sequence; } + /// + /// The of a pane nobody has numbered yet — a pane restored from a workspace + /// persisted before panes carried a creation order. replaces it on + /// load, from tree order, so an old configuration comes back numbered the way it was left rather + /// than scrambled. + /// + public const int Unsequenced = 0; + /// Stable pane identity, unique within a workspace. public string Id { get; } + /// + /// When this pane was created, as a per-workspace counter that only ever goes up. Panes are + /// numbered by this (see ) rather than by where they + /// sit, so a pane's number is fixed for as long as it is open: creating one somewhere to its left + /// no longer pushes it from ⌥2 to ⌥3. + /// + /// It is a sort key and not the number itself. The number is the pane's position in the sorted + /// list, which is what keeps ⌥N contiguous when a pane in the middle closes — see + /// . + /// + /// + public int Sequence { get; internal set; } + /// The window ids hosted here, in tab order. public List Tabs { get; } diff --git a/src/SharpMUTerm.Core/Workspace/WorkspaceLayout.cs b/src/SharpMUTerm.Core/Workspace/WorkspaceLayout.cs index eb85117b..f60ad372 100644 --- a/src/SharpMUTerm.Core/Workspace/WorkspaceLayout.cs +++ b/src/SharpMUTerm.Core/Workspace/WorkspaceLayout.cs @@ -14,6 +14,14 @@ public sealed class WorkspaceLayout private readonly HashSet _removed = new(); private int _paneCounter; + /// + /// The last handed out. Kept apart from + /// (which only feeds the pN ids) because the two can start out of step: a workspace restored + /// from a configuration written before panes carried a sequence has its numbering assigned here from + /// tree order, and its ids may be anything a previous version wrote. + /// + private int _sequenceCounter; + /// Creates a workspace with a single pane, optionally seeded with window ids. public WorkspaceLayout(IEnumerable? initialTabs = null) { @@ -26,6 +34,16 @@ public WorkspaceLayout(IEnumerable? initialTabs = null) /// Rebuilds a layout from a pre-constructed tree (used when resuming a saved session). Focus and /// zoom fall back to a valid pane when the given ids are stale, and the internal pane counter is /// advanced past the highest pN id so future splits never collide with restored ids. + /// + /// A pane restored without a creation sequence is given one here, from tree order. Panes are + /// numbered by and a configuration written before that field existed + /// carries none, so without this every restored pane would sort equal and the numbering would be + /// whatever the sort happened to do. Tree order is the numbering those workspaces were saved under, + /// which is why it is the right seed: an existing configuration comes back reading exactly as it + /// read when it was closed, and is stable from then on. Any pane that does carry a sequence + /// keeps it, and unsequenced ones are numbered after the highest that is already taken, so a + /// half-migrated tree cannot produce two panes with one number. + /// /// public WorkspaceLayout(LayoutNode root, string focusedPaneId, string? zoomedPaneId = null) { @@ -39,6 +57,12 @@ public WorkspaceLayout(LayoutNode root, string focusedPaneId, string? zoomedPane FocusedPaneId = panes.Any(p => p.Id == focusedPaneId) ? focusedPaneId : panes[0].Id; ZoomedPaneId = zoomedPaneId is not null && panes.Any(p => p.Id == zoomedPaneId) ? zoomedPaneId : null; _paneCounter = panes.Select(p => ParsePaneCounter(p.Id)).DefaultIfEmpty(0).Max(); + + _sequenceCounter = panes.Select(p => p.Sequence).DefaultIfEmpty(PaneNode.Unsequenced).Max(); + foreach (var pane in panes.Where(p => p.Sequence <= PaneNode.Unsequenced)) + { + pane.Sequence = ++_sequenceCounter; + } } /// Extracts the numeric suffix of a pN pane id, or 0 for an unrecognised shape. @@ -54,8 +78,33 @@ private static int ParsePaneCounter(string id) => /// The id of the zoomed pane (rendered full-area), or null when nothing is zoomed. public string? ZoomedPaneId { get; private set; } - /// Every pane, in left-to-right / top-to-bottom order. - public IReadOnlyList Panes => Root.Panes().ToList(); + /// + /// Every pane in creation order — the one order this client numbers panes in. The Nth entry is + /// pane N in the connection rail, the ⌃P Go to pane N entry, the move/drag overlays' + /// label and the ⌥N chord; those are four spellings of this index and there is deliberately no + /// second ordering for any of them to drift onto. + /// + /// Why not tree order. It used to be Root.Panes(), which is geometry: a pane's position + /// left-to-right / top-to-bottom. Creating a pane therefore renumbered every pane after the insertion + /// point — dropping a window on the left edge of pane 2 made that pane into pane 3 — so ⌥2 silently + /// stopped meaning what it meant a moment earlier, on a workspace the user had not otherwise + /// rearranged. Creation order cannot do that: a pane's number is fixed for as long as it is open, and + /// a new pane always appears at the end. + /// + /// + /// The number is the index, not the sequence. Sequences are never reused, so reading them + /// directly would leave holes — close pane 2 of three and the survivors would be 1 and 3, with ⌥2 + /// doing nothing while two panes sat on the screen. Taking the position in this list instead compacts + /// the numbering on every close, which is what keeps ⌥1–⌥N contiguous. + /// + /// + /// Geometry does not read this. The renderer (LayoutSolver), the resizer (PaneResize) + /// and directional movement (PaneNavigation, which works on arranged rectangles) all go + /// through or the tree itself, so ordering here changes what panes are + /// called and never where they are drawn. + /// + /// + public IReadOnlyList Panes => Root.Panes().OrderBy(p => p.Sequence).ToList(); /// The focused pane. public PaneNode FocusedPane => FindPane(FocusedPaneId)!; @@ -79,7 +128,16 @@ public bool Focus(string paneId) return true; } - /// Cycles focus to the next pane in tree order (tmux o). + /// + /// Cycles focus to the next pane by number (tmux o) — ⌃O from pane 2 goes to + /// pane 3, and wraps from the last back to pane 1. + /// + /// It counts in order, which is the order ⌥N counts in, because these are the + /// two ordinal movers and a user pressing them alternately must not be counting two + /// different sequences: three presses of ⌃O from pane 1 land where ⌥4 does. It used to be tree + /// order — the same order ⌥N used at the time, so they agreed then and would not now. + /// + /// public void CycleFocus() { var panes = Panes; @@ -284,7 +342,12 @@ public bool SetActiveTab(string paneId, string windowId) return true; } - private PaneNode NewPane(IEnumerable? tabs = null) => new($"p{++_paneCounter}", tabs); + /// + /// Mints a pane: a fresh id, and the next creation sequence, which is what puts it last in + /// and so gives it the highest pane number rather than displacing anyone's. + /// + private PaneNode NewPane(IEnumerable? tabs = null) => + new($"p{++_paneCounter}", tabs, sequence: ++_sequenceCounter); /// Removes a window from whichever pane hosts it, fixing that pane's active index. private bool DetachWindow(string windowId) @@ -368,7 +431,9 @@ private void PruneAndFix() if (FindPane(FocusedPaneId) is null) { - FocusedPaneId = Root.Panes().First().Id; + // The lowest-numbered survivor — pane 1 — rather than the topmost-leftmost, so closing the + // focused pane lands you where the label says it did. + FocusedPaneId = Panes[0].Id; } if (ZoomedPaneId is not null && FindPane(ZoomedPaneId) is null) diff --git a/src/SharpMUTerm.Core/Workspace/WorkspaceState.cs b/src/SharpMUTerm.Core/Workspace/WorkspaceState.cs index 87074509..0daac721 100644 --- a/src/SharpMUTerm.Core/Workspace/WorkspaceState.cs +++ b/src/SharpMUTerm.Core/Workspace/WorkspaceState.cs @@ -26,6 +26,18 @@ public sealed class LayoutNodeState public int ActiveIndex { get; set; } public bool Frozen { get; set; } + /// + /// The pane's creation sequence (), which is what its ⌥N number is + /// derived from. Persisted so a resumed workspace comes back numbered the way it was left rather + /// than renumbered by the shape of its split tree. + /// + /// — the value a configuration written before this field existed + /// deserialises to — means "nobody has numbered this pane"; assigns + /// one from tree order on load, which is the numbering such a workspace was saved under. + /// + /// + public int Sequence { get; set; } = PaneNode.Unsequenced; + // split public SplitDirection Direction { get; set; } public List Sizes { get; set; } = new(); @@ -89,6 +101,7 @@ public Workspace Restore() Tabs = new List(pane.Tabs), ActiveIndex = pane.ActiveIndex, Frozen = pane.Frozen, + Sequence = pane.Sequence, }, SplitNode split => new LayoutNodeState { @@ -110,7 +123,7 @@ private static LayoutNode BuildNode(LayoutNodeState state) if (string.Equals(state.Type, "pane", StringComparison.OrdinalIgnoreCase)) { - return new PaneNode(state.Id ?? "p1", state.Tabs, state.ActiveIndex, state.Frozen); + return new PaneNode(state.Id ?? "p1", state.Tabs, state.ActiveIndex, state.Frozen, state.Sequence); } // A typo or a newer serialized node type shouldn't silently degrade to a pane and lose structure. diff --git a/src/SharpMUTerm.Core/Workspaces/RailModel.cs b/src/SharpMUTerm.Core/Workspaces/RailModel.cs index 3ab2f708..8e29ef3b 100644 --- a/src/SharpMUTerm.Core/Workspaces/RailModel.cs +++ b/src/SharpMUTerm.Core/Workspaces/RailModel.cs @@ -51,8 +51,27 @@ public sealed record RailRow( /// A world as projected into the rail: its identity, accent, and characters. public sealed record RailWorld(string Name, string Host, int Port, TerminalColor Accent, IReadOnlyList Characters); -/// A character in the rail: connection/active state, unread total, and its windows. -public sealed record RailCharacter(string Name, string SessionKey, bool Connected, bool Active, int Unread, IReadOnlyList Windows); +/// +/// A character in the rail: connection/active state, unread total, its windows, and the pane its +/// session lives in. +/// +/// is what makes the pane numbering visible across characters. Only the +/// active character's windows are listed (see ), so before this +/// field a reader looking at character A could see that pane 3 existed only if one of A's own +/// windows happened to be in it — B's pane was on the screen, numbered, and reachable with ⌥3, and +/// nothing anywhere said so. The number is useless if it cannot be read off the sidebar, and the whole +/// point of ⌥N being global is that it switches character. Null when the workspace has a single pane +/// (one pane is no information) or the character has no window open. +/// +/// +public sealed record RailCharacter( + string Name, + string SessionKey, + bool Connected, + bool Active, + int Unread, + IReadOnlyList Windows, + string? Pane = null); /// /// A window in the rail: its title, the workspace id a click activates, hosting pane (or closed), @@ -69,6 +88,15 @@ public sealed record RailWindow(string Title, string Id, string? Pane, int Unrea /// "no characters". (A world's host:port is deliberately not a row; the rail shows /// name and characters only, which RailModelTests pins.) Pure and unit-testable. /// +/// Every character row carries its own hosting pane (), active or +/// not, and that is the one thing here that is not scoped to the active character. It has to be: the +/// pane numbering is a property of the workspace rather than of whoever is in front, ⌥3 is a character +/// switch as much as a pane switch, and a number that is only legible once you are already there is a +/// number nobody presses. Listing the other characters' windows would have said the same thing +/// and cost the rail its one unambiguous reading — a window row means "this is yours", which is why the +/// owner filter exists. +/// +/// /// Every row that names somewhere you can go also carries the a click /// dispatches. The ids are the shell's own (), so the rail is a second door /// onto the ⌃P surface's actions rather than a second implementation of switching. @@ -113,6 +141,7 @@ public static IReadOnlyList Build(IReadOnlyList worlds) Active: character.Active, Connected: character.Connected, Unread: character.Unread, + Pane: character.Pane, Target: CommandIds.Character(character.SessionKey))); if (!character.Active) diff --git a/src/SharpMUTerm.Tui/RailRenderer.cs b/src/SharpMUTerm.Tui/RailRenderer.cs index eb00c296..8f1bb600 100644 --- a/src/SharpMUTerm.Tui/RailRenderer.cs +++ b/src/SharpMUTerm.Tui/RailRenderer.cs @@ -119,12 +119,31 @@ public static List RenderCollapsed(IReadOnlyList rows) return lines; } + /// + /// A character row: the active marker, the connected dot, the name, its unread total — and, in the + /// same right-hand column the window rows use, the pane its session is in. + /// + /// That column is how the pane numbering is legible from anywhere. Window rows are drawn for the + /// active character only, so pane 3 used to be visible only to whoever was already in it, + /// while ⌥3 was reaching it from every other character. It is drawn on the active character's row + /// too, deliberately: a column that appeared and vanished as you switched would be a third thing to + /// learn, and repeating "where this character is" above its window rows is redundant rather than + /// ambiguous — unlike ▪ main main, both columns here mean the same thing and say it in the + /// one vocabulary (pane N). + /// + /// + /// It costs the sidebar nothing at rest: a window row is indented one level deeper and carries the + /// pen field as well, so it is the wider row wherever one exists, and the model leaves + /// null on a single-pane workspace exactly as it does for windows. + /// + /// private static string Character(RailRow row) { var marker = row.Active ? "[bold]▸[/]" : " "; var dot = row.Connected ? "●" : "○"; var name = row.Active ? $"[bold]{Escape(row.Label)}[/]" : Escape(row.Label); - return $"{Indent(row)}{Link(row, $"{marker} [{Accent(row)}]{dot}[/] {name}{UnreadField(row.Unread)}")}"; + var tail = row.Pane is { Length: > 0 } pane ? $" [dim]{Escape(pane)}[/]" : string.Empty; + return $"{Indent(row)}{Link(row, $"{marker} [{Accent(row)}]{dot}[/] {name}{UnreadField(row.Unread)}{tail}")}"; } /// diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index 72487078..87434aa3 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -2712,12 +2712,21 @@ private void MoveFocus(PaneDirection direction) /// Go to pane N entries. /// /// The number is the rail's number. Panes are counted in Layout.Panes order - /// (left-to-right, then top-to-bottom), which is the order the connection rail's hosting column - /// numbers them in, so ⌥3 goes to the pane the sidebar labels pane 3. There is no second - /// numbering to reconcile any more: the drag and move overlays used to call the first pane - /// main while the rail called it pane 1 (see ), which is a - /// mismatch a chord cannot survive — a key that lands somewhere other than the label says is worse - /// than no key. + /// (creation order), which is the order the connection rail's hosting column numbers them + /// in, so ⌥3 goes to the pane the sidebar labels pane 3. There is no second numbering to + /// reconcile any more: the drag and move overlays used to call the first pane main while the + /// rail called it pane 1 (see ), which is a mismatch a chord cannot + /// survive — a key that lands somewhere other than the label says is worse than no key. + /// + /// + /// The numbering is global, and it is stable. Global because it always was: a workspace has + /// one split tree whoever is connected in it, so ⌥3 has always reached a pane holding another + /// character's window — which is what makes these nine chords a character switcher as well as a pane + /// switcher. Stable is the part that had to be built. Panes used to be counted in tree order, so + /// creating one renumbered every pane after the insertion point and ⌥2 stopped meaning what it meant + /// while the user was doing something else entirely. That the rail now says which character is in + /// each pane () is the other half: a number nobody can see is a number nobody + /// presses. /// /// /// Why Alt, and why the framework had to be outranked. Ctrl+digit was what was asked for and it @@ -4044,7 +4053,8 @@ private IReadOnlyList BuildRail() Connected: connected.Contains(key), Active: active, Unread: windows.Sum(w => w.Unread), - windows)); + windows, + Pane: CharacterPaneLabel(key, paneLabels))); } worlds.Add(new RailWorld(world.Name, world.Host, world.Port, accent, characters)); @@ -4093,6 +4103,58 @@ private IReadOnlyList BuildRailWindows( return windows; } + /// + /// Which pane a character's session is in — the pane N its rail row carries, whether or not + /// it is the active character. + /// + /// This is what makes ⌥N usable as a character switch. The chord has always been global + /// ( indexes the workspace's one pane tree, not the active character's + /// windows), but the rail lists window rows for the active character only — so a reader looking at + /// Ann could see pane 1 and nothing else, while ⌥2 and ⌥3 sat on the screen holding Bob and + /// Cal. The pane number was global; only the way to read it was not. + /// + /// + /// The character row rather than more window rows, because 's + /// owner filter is load-bearing: a window row under a character means that window is that + /// character's, and listing everyone's windows everywhere would take that reading away for the sake + /// of a fact one column can carry. One row per character already exists, it is exactly the row a + /// user clicks to reach that character, and the answer belongs on it. + /// + /// + /// The session window when there is one, else any window the character owns that a pane still holds + /// — a character with a spawn window open and its main window closed is still somewhere, and the row + /// should say where rather than go blank. Null when the workspace has one pane, because + /// is empty then and "which of the one pane" is not information. + /// + /// + private string? CharacterPaneLabel(string sessionKey, IReadOnlyDictionary paneLabels) + { + if (paneLabels.Count == 0) + { + return null; + } + + string? fallback = null; + foreach (var window in _workspace.Windows) + { + if (!string.Equals(window.SessionKey, sessionKey, StringComparison.Ordinal) || + _workspace.Layout.FindWindow(window.Id) is not { } pane || + paneLabels.GetValueOrDefault(pane.Id) is not { } label) + { + continue; + } + + if (window.Kind == WindowKind.Main) + { + return label; + } + + fallback ??= label; + } + + return fallback; + } + /// /// What a window row is called in the rail. A character's own session window reads /// main; everything else keeps its title (a spawn target's name, the web page's title). @@ -6680,7 +6742,13 @@ private string DropLabel(string? paneId, Edge? edge) /// /// The name every surface in this client gives a pane: pane N, counting - /// in order from one. + /// creation order — from one. + /// + /// The number a pane wears is its position in that list, so it does not move while the pane is open + /// and it closes up behind a pane that goes away. Under the tree order this used to count in, a pane + /// created to the left of pane 2 made it pane 3 without the user having touched it, and ⌥2 quietly + /// went somewhere else. + /// /// /// It used to call the first pane main — the spelling the rail's hosting column abandoned /// because ▪ main main put two meanings in one line, the window named main beside diff --git a/tests/SharpMUTerm.Core.Tests/Workspace/PaneNumberingTests.cs b/tests/SharpMUTerm.Core.Tests/Workspace/PaneNumberingTests.cs new file mode 100644 index 00000000..1ee0a80f --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Workspace/PaneNumberingTests.cs @@ -0,0 +1,330 @@ +using SharpMUTerm.Core.Workspaces; + +namespace SharpMUTerm.Core.Tests.Workspaces; + +/// +/// A pane's number is when it was made, not where it sits. The request was that pane numbering be +/// global and in order of creation, so ⌥1–⌥9 double as a way to reach another character; global it always +/// was (a workspace has one split tree whoever is connected in it), and this is the half that had to be +/// built. +/// +/// What was wrong. WorkspaceLayout.Panes was Root.Panes() — tree order, left-to-right +/// then top-to-bottom, which is a function of where a pane is. So creating a pane renumbered every +/// pane after the insertion point: drop a window on the left edge of pane 2 and the pane that had been +/// pane 2 became pane 3, while the user was doing something else entirely and had no reason to look. ⌥2 +/// then went somewhere new without any surface having said so, which is the same defect as a label and a +/// chord disagreeing — the thing this repository has already paid for twice. +/// +/// +/// Number versus sequence. is a sort key that is never reused; the +/// number is the pane's position in the sorted list. That distinction is the whole of the +/// compaction rule: reading sequences directly would leave holes after a close (1 and 3, with ⌥2 doing +/// nothing while two panes sat on the screen), and reading positions closes them up. +/// +/// +public class PaneNumberingTests +{ + // --- creation order, and what it is not --------------------------------------------------------- + + /// + /// The defect, pinned. Two panes; a third is created to the left of the second. Under + /// tree order the second pane's number went 2 → 3. It must not move: it is the same pane, in the same + /// place, and nobody asked for it to be renamed. + /// + [Test] + public async Task CreatingAPaneToTheLeftOfAnotherDoesNotRenumberIt() + { + var layout = new WorkspaceLayout(new[] { "a", "b" }); + layout.SplitFocused(SplitDirection.Row); + var second = layout.Panes[1].Id; + + layout.SplitWithWindow("c", second, Edge.Left); + + await Assert.That(Number(layout, second)) + .IsEqualTo(2) + .Because("a pane keeps its number for as long as it is open"); + await Assert.That(layout.Panes.Count).IsEqualTo(3); + } + + /// And the new pane takes the number after the last one, wherever on the screen it landed. + [Test] + public async Task ANewPaneIsNumberedLastEvenWhenItIsDrawnFirst() + { + var layout = new WorkspaceLayout(new[] { "a", "b" }); + layout.SplitFocused(SplitDirection.Row); + var first = layout.Panes[0].Id; + + layout.SplitWithWindow("c", first, Edge.Left); + var newest = layout.FocusedPaneId; // SplitWithWindow focuses the pane it made + + // Drawn leftmost — tree order puts it first — and numbered third. + await Assert.That(layout.Root.Panes().First().Id).IsEqualTo(newest); + await Assert.That(Number(layout, newest)).IsEqualTo(3); + await Assert.That(Number(layout, first)).IsEqualTo(1); + } + + /// + /// Tree order still exists and still means geometry — this is the assertion that the two orders are + /// genuinely different, so the tests above are not passing by coincidence on a layout where they agree. + /// + [Test] + public async Task TreeOrderAndTheNumberingAreAllowedToDisagree() + { + var layout = new WorkspaceLayout(new[] { "a", "b" }); + layout.SplitFocused(SplitDirection.Row); + layout.SplitWithWindow("c", layout.Panes[0].Id, Edge.Left); + + var numbered = Order(layout.Panes); + var drawn = Order(layout.Root.Panes()); + + await Assert.That(numbered).IsNotEqualTo(drawn); + await Assert.That(numbered.Split(',').Order()).IsEquivalentTo(drawn.Split(',').Order()); // same panes + } + + // --- compaction --------------------------------------------------------------------------------- + + /// + /// Closing pane 2 of three leaves panes 1 and 2, not 1 and 3. The numbering has to stay + /// contiguous or ⌥2 is a chord that does nothing while two panes are on the screen — a silent no-op, + /// which is this codebase's most-repeated defect. Sequences are never reused, so it is the position in + /// the list and not the sequence that is the number. + /// + [Test] + public async Task ClosingAPaneInTheMiddleCompactsTheNumbering() + { + var layout = ThreePanes(); + var (first, second, third) = (layout.Panes[0].Id, layout.Panes[1].Id, layout.Panes[2].Id); + + layout.Focus(second); + layout.CloseFocused(); + + await Assert.That(layout.Panes.Count).IsEqualTo(2); + await Assert.That(Number(layout, first)).IsEqualTo(1); + await Assert.That(Number(layout, third)) + .IsEqualTo(2) + .Because("⌥2 must reach a pane, not a hole where one used to be"); + } + + /// Closing the first one compacts too: what was 2 and 3 becomes 1 and 2, in the same order. + [Test] + public async Task ClosingTheFirstPaneShiftsTheRestDownRatherThanLeavingAGap() + { + var layout = ThreePanes(); + var (first, second, third) = (layout.Panes[0].Id, layout.Panes[1].Id, layout.Panes[2].Id); + + layout.Focus(first); + layout.CloseFocused(); + + await Assert.That(Order(layout.Panes)).IsEqualTo($"{second},{third}"); + await Assert.That(Number(layout, second)).IsEqualTo(1); + await Assert.That(Number(layout, third)).IsEqualTo(2); + } + + /// + /// And a pane created after a close takes the free number at the end rather than reusing the closed + /// pane's — the surviving panes' numbers are what must not move, and reusing would mean the new pane + /// appeared in the middle of an ordering that is supposed to be chronological. + /// + [Test] + public async Task APaneMadeAfterACloseGoesOnTheEnd() + { + var layout = ThreePanes(); + var (first, third) = (layout.Panes[0].Id, layout.Panes[2].Id); + + layout.Focus(layout.Panes[1].Id); + layout.CloseFocused(); + layout.SplitWithWindow("d", first, Edge.Left); + var newest = layout.FocusedPaneId; + + await Assert.That(Number(layout, first)).IsEqualTo(1); + await Assert.That(Number(layout, third)).IsEqualTo(2); + await Assert.That(Number(layout, newest)).IsEqualTo(3); + } + + // --- the other ordinal mover -------------------------------------------------------------------- + + /// + /// ⌃O counts the way ⌥N counts. These are the two ordinal movers, and a user pressing them + /// alternately must not be counting two sequences: three presses of cycle from pane 1 land where the + /// digit 4 would. Cycle read tree order before, which agreed with the numbering then and would not now. + /// + [Test] + public async Task CyclingWalksTheNumberingInOrderAndWraps() + { + var layout = ThreePanes(); + layout.SplitWithWindow("d", layout.Panes[0].Id, Edge.Left); // a fourth, drawn first, numbered last + var order = layout.Panes.Select(p => p.Id).ToList(); + + layout.Focus(order[0]); + foreach (var expected in order.Skip(1).Append(order[0])) + { + layout.CycleFocus(); + await Assert.That(layout.FocusedPaneId).IsEqualTo(expected); + } + } + + /// Closing the focused pane falls back to pane 1, which is what the surviving labels say. + [Test] + public async Task ClosingTheFocusedPaneFallsBackToPaneOne() + { + var layout = ThreePanes(); + layout.SplitWithWindow("d", layout.Panes[0].Id, Edge.Left); + var firstNumbered = layout.Panes[0].Id; + + layout.Focus(layout.Panes[3].Id); + layout.CloseFocused(); + + await Assert.That(layout.FocusedPaneId).IsEqualTo(firstNumbered); + await Assert.That(Number(layout, layout.FocusedPaneId)).IsEqualTo(1); + } + + // --- persistence -------------------------------------------------------------------------------- + + /// + /// A resumed workspace comes back numbered the way it was left. The numbering is only worth + /// learning if it survives a restart, so the sequence is persisted rather than re-derived — re-deriving + /// it from the saved tree would hand back exactly the tree-order numbering this change removes. + /// + [Test] + public async Task TheNumberingSurvivesACaptureAndRestore() + { + var layout = new WorkspaceLayout(new[] { "a", "b" }); + layout.SplitFocused(SplitDirection.Row); + layout.SplitWithWindow("c", layout.Panes[0].Id, Edge.Left); + var before = Order(layout.Panes); + + var restored = WorkspaceState.Capture(new Workspace( + new[] + { + new WorkspaceWindow("a", "a"), new WorkspaceWindow("b", "b"), new WorkspaceWindow("c", "c"), + }, + layout)).Restore(); + + await Assert.That(Order(restored.Layout.Panes)).IsEqualTo(before); + await Assert.That(Order(restored.Layout.Panes)) + .IsNotEqualTo(Order(restored.Layout.Root.Panes())) + .Because("the fixture must still be one where tree order would give a different answer"); + } + + /// + /// A configuration written before panes carried a sequence does not come back scrambled. Such a + /// tree deserialises with every sequence at , and every pane sorting + /// equal would leave the numbering to whatever the sort did. Tree order is the numbering that + /// configuration was saved under, so that is what it is seeded with: the workspace reads exactly as it + /// read when it was closed, and is stable from then on. + /// + [Test] + public async Task ALayoutRestoredWithoutSequencesIsNumberedFromTreeOrder() + { + var state = new WorkspaceState + { + Windows = + { + new WorkspaceWindowState { Id = "a", Title = "a" }, + new WorkspaceWindowState { Id = "b", Title = "b" }, + new WorkspaceWindowState { Id = "c", Title = "c" }, + }, + Root = new LayoutNodeState + { + Type = "split", + Direction = SplitDirection.Row, + Children = + { + // Ids deliberately out of order, so an implementation reading the number off the + // `pN` id would disagree with one reading tree order. + new LayoutNodeState { Type = "pane", Id = "p7", Tabs = { "a" } }, + new LayoutNodeState { Type = "pane", Id = "p2", Tabs = { "b" } }, + new LayoutNodeState { Type = "pane", Id = "p5", Tabs = { "c" } }, + }, + }, + FocusedPaneId = "p2", + }; + + var layout = state.Restore().Layout; + + await Assert.That(Order(layout.Panes)).IsEqualTo("p7,p2,p5"); + await Assert.That(string.Join(",", layout.Panes.Select(p => p.Sequence))).IsEqualTo("1,2,3"); + } + + /// + /// And a pane created afterwards is numbered after them rather than colliding — the counter picks up + /// from the highest sequence in the tree, not from the ids, which a legacy tree gives no guarantees + /// about. + /// + [Test] + public async Task APaneAddedToARestoredLegacyLayoutTakesTheNextNumber() + { + var state = new WorkspaceState + { + Windows = { new WorkspaceWindowState { Id = "a", Title = "a" } }, + Root = new LayoutNodeState { Type = "pane", Id = "legacy-pane", Tabs = { "a" } }, + FocusedPaneId = "legacy-pane", + }; + + var layout = state.Restore().Layout; + layout.SplitWithWindow("b", "legacy-pane", Edge.Left); + + await Assert.That(layout.Panes.Select(p => p.Sequence).Distinct().Count()) + .IsEqualTo(2) + .Because("two panes may never share a number"); + await Assert.That(Number(layout, "legacy-pane")).IsEqualTo(1); + } + + /// + /// A half-migrated tree — some panes numbered, some not — puts the unnumbered ones after the numbered + /// ones rather than on top of them. There is no path that writes such a tree today; the guard is here + /// because "assign 1..n from tree order" is the obvious implementation and it would silently give two + /// panes the same number the first time one arrives. + /// + [Test] + public async Task UnsequencedPanesAreNumberedAfterSequencedOnes() + { + var root = new SplitNode( + SplitDirection.Row, + new LayoutNode[] + { + new PaneNode("fresh", new[] { "a" }), // Unsequenced + new PaneNode("known", new[] { "b" }, sequence: 4), + }); + + var layout = new WorkspaceLayout(root, "known"); + + await Assert.That(Order(layout.Panes)).IsEqualTo("known,fresh"); + await Assert.That(layout.FindPane("fresh")!.Sequence).IsEqualTo(5); + } + + // --- harness ------------------------------------------------------------------------------------ + + /// + /// Pane ids in the order given, as one string. Ordered comparison is the point of most of this + /// suite, and TUnit's IsEquivalentTo compares collections as sets — it passed happily + /// on [p1,p2,p3] against [p3,p1,p2], which is exactly the difference being asserted. + /// + private static string Order(IEnumerable panes) => string.Join(",", panes.Select(p => p.Id)); + + /// The number a pane wears — its 1-based position in the numbering. + private static int Number(WorkspaceLayout layout, string paneId) + { + var panes = layout.Panes; + for (var i = 0; i < panes.Count; i++) + { + if (panes[i].Id == paneId) + { + return i + 1; + } + } + + return -1; + } + + /// Three panes side by side, made in that order. + private static WorkspaceLayout ThreePanes() + { + var layout = new WorkspaceLayout(new[] { "a", "b", "c" }); + layout.SplitFocused(SplitDirection.Row); + layout.Focus(layout.Panes[1].Id); + layout.SplitFocused(SplitDirection.Row); + layout.Focus(layout.Panes[0].Id); + return layout; + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs index e04d7d56..fdbb0eb2 100644 --- a/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs @@ -513,13 +513,19 @@ private static void Send(SharpMUTermApp app, string line) } /// - /// The pane label the rail is drawing for the active character's window, e.g. pane 2. Read out - /// of the rendered rows rather than recomputed, because the whole assertion is that the chord and the - /// sidebar agree — a re-derived label would agree with itself. + /// The pane label the rail is drawing for the active character's window, e.g. pane 2. + /// Read out of the rendered rows rather than recomputed, because the whole assertion is that the chord + /// and the sidebar agree — a re-derived label would agree with itself. + /// + /// Window rows are picked out by their bullet, because character rows now carry the same + /// column: the rail says which pane every character is in, active or not, which is what makes the + /// numbering readable from a character other than the one you are looking at. Taking the first + /// pane N on any row would read the active character's own row and answer 1 for ever. + /// /// private static string RailPaneLabel(SharpMUTermApp app) { - foreach (var line in app.RailLines) + foreach (var line in app.RailLines.Where(l => l.Contains('▪', StringComparison.Ordinal))) { var match = Regex.Match(line, @"pane \d+"); if (match.Success) diff --git a/tests/SharpMUTerm.Tui.Tests/PaneNumberingRailTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneNumberingRailTests.cs new file mode 100644 index 00000000..946514fd --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/PaneNumberingRailTests.cs @@ -0,0 +1,312 @@ +using System.Text.RegularExpressions; +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Workspaces; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// The pane numbering is readable from any character, not only the one you are in. +/// +/// ⌥N has always been global — JumpToPane indexes the workspace's one split tree, not the active +/// character's windows — so ⌥3 already reached a pane holding somebody else's session. The rail did not +/// say so. Window rows are listed for the active character only (BuildRailWindows's owner +/// filter, which is load-bearing: a window row under a character means that window is theirs), so a reader +/// looking at Ann saw pane 1 and nothing else while Bob and Cal sat in panes 2 and 3 with chords +/// pointing at them. A number that cannot be read off the screen is a number nobody presses, which is what +/// "pane numbering should be global" was reporting. +/// +/// +/// The fix is one column on a row that already exists: every character row carries the pane its session is +/// in. No new rows, no other character's windows listed under yours, and the same pane N vocabulary +/// the window rows, the ⌃P entries, the move overlay and the chord already use. +/// +/// +/// +/// Serialised with the other end-to-end suites: constructing the app and rendering a frame both touch the +/// process-global console streams. +/// +[NotInParallel] +public class PaneNumberingRailTests +{ + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + /// + /// The claim. Ann is active; the rail names the pane every one of the three characters is in. + /// Before this it named exactly one, and it was always Ann's. + /// + [Test] + public async Task TheRailNamesThePaneOfEveryCharacterAndNotJustTheActiveOne() + { + var app = await ThreePanes(); + app.SimulateKey(Alt(1)); + app.RenderNextFrame(); + + await Assert.That(app.ActiveSessionKey).IsEqualTo("Alfa.Ann"); + await Assert.That(CharacterPane(app, "Ann")).IsEqualTo("pane 1"); + await Assert.That(CharacterPane(app, "Bob")) + .IsEqualTo("pane 2") + .Because("⌥2 goes to Bob from here, and the sidebar has to be where you find that out"); + await Assert.That(CharacterPane(app, "Cal")).IsEqualTo("pane 3"); + } + + /// + /// The loop closes. For each character: read the digit the rail prints against their name while + /// somebody else is active, press it, and land on them. Nothing here writes down which pane holds + /// whom — the digit comes out of the rendered sidebar, which is the only property that makes the number + /// worth printing. + /// + [Test] + public async Task PressingTheDigitTheRailPrintsAgainstACharacterGoesToThatCharacter() + { + var app = await ThreePanes(); + + foreach (var (name, key) in new[] { ("Cal", "Cara.Cal"), ("Bob", "Bravo.Bob"), ("Ann", "Alfa.Ann") }) + { + // Stand somewhere else first, so the row being read is an inactive character's. + app.SimulateKey(Alt(1)); + app.RenderNextFrame(); + + var digit = int.Parse(CharacterPane(app, name)!["pane ".Length..]); + app.SimulateKey(Alt(digit)); + + await Assert.That(app.ActiveSessionKey) + .IsEqualTo(key) + .Because($"the rail said {name} was in pane {digit}"); + } + } + + /// + /// It survives the character switch it enables: after ⌥3 the rail still names all three, with the + /// marker moved. A column that only rendered for characters other than the active one would be a + /// third thing to learn and would change the rows' widths on every switch. + /// + [Test] + public async Task TheColumnIsStillThereAfterSwitching() + { + var app = await ThreePanes(); + app.SimulateKey(Alt(3)); + app.RenderNextFrame(); + + await Assert.That(app.ActiveSessionKey).IsEqualTo("Cara.Cal"); + await Assert.That(CharacterPane(app, "Ann")).IsEqualTo("pane 1"); + await Assert.That(CharacterPane(app, "Bob")).IsEqualTo("pane 2"); + await Assert.That(CharacterPane(app, "Cal")).IsEqualTo("pane 3"); + } + + /// + /// And switching character moves no pane rectangle. The rail's width is its widest row and the + /// panes get what is left, so a column that changed width as the active character moved would + /// re-announce a new terminal size to every connected server over per-pane NAWS — the reason + /// FocusIndicationTests.MovingFocusDoesNotMoveAnyPaneRectangle exists, restated for the row this + /// change writes to. + /// + [Test] + public async Task SwitchingCharacterMovesNoPaneRectangle() + { + var app = await ThreePanes(); + app.SimulateKey(Alt(1)); + app.RenderNextFrame(); + var before = app.PaneOutputRects().ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); + + foreach (var digit in new[] { 2, 3, 1 }) + { + app.SimulateKey(Alt(digit)); + app.RenderNextFrame(); + + var after = app.PaneOutputRects(); + await Assert.That(after.Count).IsEqualTo(before.Count); + foreach (var (paneId, rect) in before) + { + await Assert.That(after[paneId]).IsEqualTo(rect); + } + } + } + + /// + /// Closing pane 2 of three makes the third pane into pane 2, on the chord and in the sidebar + /// together. Creation sequences are never reused, so a number read straight off one would leave a + /// hole here: ⌥2 would report "there is no pane 2" while two panes sat on the screen, and ⌥3 would be + /// the only way to reach the second of them. The number is the pane's position in the numbering for + /// exactly this reason, and the two surfaces are asserted together because a chord that disagrees with + /// the label is the defect this whole numbering exists to avoid. + /// + [Test] + public async Task ClosingAPaneCompactsTheNumberingOnTheChordAndInTheSidebar() + { + var app = await ThreePanes(); + + app.SimulateKey(Alt(2)); // stand in Bob's pane + await Assert.That(app.DispatchCommand("layout:close")).IsTrue(); + app.RenderNextFrame(); + await Assert.That(app.PaneIds.Count).IsEqualTo(2); + + await Assert.That(CharacterPane(app, "Ann")).IsEqualTo("pane 1"); + await Assert.That(CharacterPane(app, "Cal")) + .IsEqualTo("pane 2") + .Because("the panes on the screen must be numbered 1 and 2, not 1 and 3"); + + app.SimulateKey(Alt(1)); + app.SimulateKey(Alt(2)); + await Assert.That(app.ActiveSessionKey) + .IsEqualTo("Cara.Cal") + .Because("⌥2 must reach the second pane rather than the hole the closed one left"); + + app.SimulateKey(Alt(3)); + await Assert.That(app.StatusMarkup).Contains("there is no pane 3"); + await Assert.That(app.ActiveSessionKey).IsEqualTo("Cara.Cal"); + } + + /// + /// A single-pane workspace prints no pane column at all — on character rows for the same reason + /// window rows have never had one there: with one pane there is one answer, and three cells of sidebar + /// come out of the pane the user is reading. + /// + [Test] + public async Task ASinglePaneWorkspaceNamesNoPaneOnAnyRow() + { + Console.SetIn(TextReader.Null); + var app = new SharpMUTermApp(DemoScene.Build(), Headless, new HeadlessConsoleDriver(120, 34)); + app.RenderSnapshot(); + + await Assert.That(app.PaneIds.Count).IsEqualTo(1); + await Assert.That(app.RailLines.Any(l => Regex.IsMatch(l, @"pane \d+"))) + .IsFalse() + .Because("with one pane, naming it says nothing and costs the panes their columns"); + } + + /// + /// The rail's answer for a character and the app's own pane N label for the pane holding that + /// character's window are the same string — one numbering, read two ways, so a future change to either + /// cannot quietly produce two. + /// + [Test] + public async Task TheRailsColumnAgreesWithTheAppsOwnLabelForTheSamePane() + { + var app = await ThreePanes(); + app.RenderNextFrame(); + + foreach (var (name, window) in new[] { ("Ann", "main"), ("Bob", "char:Bravo.Bob"), ("Cal", "char:Cara.Cal") }) + { + var paneId = app.PaneIdOf(window); + var ordinal = app.PaneIds.ToList().IndexOf(paneId!) + 1; + await Assert.That(CharacterPane(app, name)).IsEqualTo($"pane {ordinal}"); + } + } + + // --- harness ------------------------------------------------------------------------------------ + + /// + /// The pane N the rail prints on 's own row, or null. + /// + /// Read off the row's visible cells, with the markup stripped first. A rail row is wrapped in + /// a [link=cmd%3Acharacter%3AAlfa.Ann] span, and a world row's target is one of its characters' + /// — so matching the raw markup finds "Ann" on the Alfa row above hers, which has no pane and + /// never should. Character rows are then told apart from window rows by the bullet the latter + /// carry, both now using this same column. + /// + /// + private static string? CharacterPane(SharpMUTermApp app, string character) + { + foreach (var line in app.RailLines.Select(Visible)) + { + if (line.Contains('▪', StringComparison.Ordinal) || + !Regex.IsMatch(line, $@"(?A rail row's cells, with its style and link markup removed. + private static string Visible(string markup) => + Regex.Replace(markup, @"\[(?:/|[^\]\[]*)\]", string.Empty).Replace("[[", "[").Replace("]]", "]"); + + private static ConsoleKeyInfo Alt(int digit) => + new((char)('0' + digit), ConsoleKey.D0 + digit, false, true, false); + + /// + /// A resumed workspace of three panes holding one character each, from three separate worlds. Same + /// shape as 's fixture, which is the suite this one is the sidebar half of. + /// + private static async Task ThreePanes() + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + var names = new[] { ("Alfa", "Ann"), ("Bravo", "Bob"), ("Cara", "Cal") }; + foreach (var (world, character) in names) + { + var definition = new WorldDefinition + { + Name = world, + Host = $"{world.ToLowerInvariant()}.example.org", + Port = 4000, + }; + definition.Characters.Add(new CharacterDefinition { Name = character, Logging = new LoggingSettings() }); + config.Worlds.Add(definition); + } + + var windows = new[] { "main", "char:Bravo.Bob", "char:Cara.Cal" }; + var sessions = names.Select(n => $"{n.Item1}.{n.Item2}").ToArray(); + + config.LastSession = new WorkspaceState + { + Windows = + { + new WorkspaceWindowState + { + Id = windows[0], Title = "Ann", Kind = WindowKind.Main, SessionKey = sessions[0], + }, + new WorkspaceWindowState + { + Id = windows[1], Title = "Bob", Kind = WindowKind.Main, SessionKey = sessions[1], + }, + new WorkspaceWindowState + { + Id = windows[2], Title = "Cal", Kind = WindowKind.Main, SessionKey = sessions[2], + }, + }, + Root = new LayoutNodeState + { + Type = "split", + Direction = SplitDirection.Row, + Children = + { + new LayoutNodeState { Type = "pane", Id = "p1", Tabs = { windows[0] }, ActiveIndex = 0, Sequence = 1 }, + new LayoutNodeState { Type = "pane", Id = "p2", Tabs = { windows[1] }, ActiveIndex = 0, Sequence = 2 }, + new LayoutNodeState { Type = "pane", Id = "p3", Tabs = { windows[2] }, ActiveIndex = 0, Sequence = 3 }, + }, + }, + FocusedPaneId = "p1", + }; + + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(160, 40)); + app.TelnetFactory = _ => new RecordingTelnetSession(); + foreach (var key in sessions) + { + if (!app.DispatchCommand(CommandIds.Character(key))) + { + throw new InvalidOperationException($"the app would not switch to {key}"); + } + + await app.FindSession(key)!.ConnectAsync(); + } + + app.RenderNextFrame(); + if (app.PaneIds.Count != 3) + { + throw new InvalidOperationException($"the resumed workspace has {app.PaneIds.Count} panes, not 3"); + } + + return app; + } +} From 59e5683c271470d42af25ad1d3dd41f11975957c Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 30 Jul 2026 16:11:24 -0500 Subject: [PATCH 07/11] Reordering a tab moves the strip, not only the model behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌃B < / > reordered the focused pane's tabs in the workspace model and then called RefreshTabTitles, which repaints each TabPage by its own Tag and leaves the pages in the order they were built in. TabControl has no way to move a page — TabPages is a copy and the only mutators add — so the strip on screen never reordered. That is not a cosmetic lag, because the refusal is read against the model. With three tabs and the middle one active, ⌃B > swapped it to the end in the model while the screen still showed it in the middle; the move looked like a no-op, and the next press refused with "the tab is already at that end of the strip" about a tab the user could see was not. Rebuilding the pane area is what every other ⌃B command that changes what the strip holds already does, and the kept viewports survive it (ScrollViewFor), so scroll positions do not move. The test is the whole 3 tabs × 2 directions × 3 positions grid, asserting the strip as well as the model: the model half passed throughout, which is why two tabs and a model-only assertion never caught this. The two existing reorder tests compared order with IsEquivalentTo, which is order-insensitive in TUnit and so asserted nothing about order at all; both now compare ordered strings and check the strip too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GpL7Ht6sLBsSEtVNsYcXMM --- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 10 +- .../PanePrefixEndToEndTests.cs | 114 +++++++++++++++++- 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index 6b9c7efc..bfe27d3e 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -6446,7 +6446,15 @@ private void RunPrefixCommand(char key) var tabs = _workspace.Layout.FocusedPane.Tabs.Count; if (_workspace.Layout.ReorderActiveTab(key == '<' ? -1 : 1)) { - RefreshTabTitles(); + // The strip has to be rebuilt, not retitled. A reorder changes the *order* of the + // pane's tabs, and `TabControl` has no way to move a page — `TabPages` is a copy and + // the only mutators are Add/Insert — so `RefreshTabTitles`, which repaints each page + // by its own `Tag`, left the strip exactly as it was. That is not a cosmetic lag: the + // model then holds an order the screen does not, and the *refusal* is read against the + // model. Reordering the middle of three tabs looked like nothing happened, and the + // next press — genuinely at the end, in a strip still showing it in the middle — said + // "the tab is already at that end of the strip", which is how it was reported. + RebuildPaneArea(); break; } diff --git a/tests/SharpMUTerm.Tui.Tests/PanePrefixEndToEndTests.cs b/tests/SharpMUTerm.Tui.Tests/PanePrefixEndToEndTests.cs index 3014cf3c..a6217733 100644 --- a/tests/SharpMUTerm.Tui.Tests/PanePrefixEndToEndTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/PanePrefixEndToEndTests.cs @@ -41,6 +41,64 @@ private static SharpMUTermApp OneTab() return new SharpMUTermApp(new AppConfiguration(), Headless, new HeadlessConsoleDriver(Width, Height)); } + private static readonly string ChatId = Workspace.SpawnWindowId("Chat"); + + private static readonly string OocId = Workspace.SpawnWindowId("OOC"); + + /// + /// One pane holding three tabs — main, a Chat spawn and an OOC spawn — with the tab at + /// the visible one. Three is the smallest strip that has a + /// middle, and a middle is the only place a reorder can move a tab without it also + /// arriving at an end; with two tabs every legal move lands at one, which is what let the strip + /// and the model disagree for as long as they did. + /// + private static SharpMUTermApp ThreeTabs(int activeIndex) + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration + { + LastSession = new WorkspaceState + { + Windows = + { + new WorkspaceWindowState { Id = "main", Title = "Main", Kind = WindowKind.Main }, + new WorkspaceWindowState { Id = ChatId, Title = "Chat", Kind = WindowKind.Spawn }, + new WorkspaceWindowState { Id = OocId, Title = "OOC", Kind = WindowKind.Spawn }, + }, + Root = new LayoutNodeState + { + Type = "pane", + Id = "p1", + Tabs = { "main", ChatId, OocId }, + ActiveIndex = activeIndex, + }, + FocusedPaneId = "p1", + }, + }; + + return new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(Width, Height)); + } + + /// The window ids of , by the short names the test cases name them. + private static string WindowId(string shortName) => shortName switch + { + "chat" => ChatId, + "ooc" => OocId, + _ => "main", + }; + + /// The inverse: a window id as the short name a test case spells it. + private static string ShortName(string windowId) => + windowId == ChatId ? "chat" : windowId == OocId ? "ooc" : windowId; + + /// + /// A tab order as one comparable string, which is the whole reason it is a string: TUnit's + /// IsEquivalentTo defaults to order-insensitive collection equivalence, so + /// [a,b] and [b,a] satisfy it — and a reorder assertion that cannot see order is not + /// an assertion at all. + /// + private static string Order(IEnumerable windowIds) => string.Join(",", windowIds.Select(ShortName)); + private static ConsoleKeyInfo Key(char c) => new(c, ConsoleKey.NoName, false, false, false); private static ConsoleKeyInfo Arrow(ConsoleKey key) => new('\0', key, false, false, false); @@ -86,7 +144,9 @@ public async Task ThePrefixThenAngleBracket_ReordersTheActiveTab() app.SimulatePrefixedKey(Key('>')); - await Assert.That(Panes(app).Single().Tabs).IsEquivalentTo(new[] { before[1], before[0] }); + await Assert.That(Order(Panes(app).Single().Tabs)).IsEqualTo(Order(new[] { before[1], before[0] })); + await Assert.That(Order(app.PaneTabStrip("p1").Select(t => t.WindowId))) + .IsEqualTo(Order(new[] { before[1], before[0] })); } /// @@ -100,10 +160,58 @@ public async Task ThePrefixThenAnArrow_ReordersTheActiveTabJustLikeTheAngleBrack var before = Panes(app).Single().Tabs.ToList(); app.SimulatePrefixedKey(Arrow(ConsoleKey.RightArrow)); - await Assert.That(Panes(app).Single().Tabs).IsEquivalentTo(new[] { before[1], before[0] }); + await Assert.That(Order(Panes(app).Single().Tabs)).IsEqualTo(Order(new[] { before[1], before[0] })); + await Assert.That(Order(app.PaneTabStrip("p1").Select(t => t.WindowId))) + .IsEqualTo(Order(new[] { before[1], before[0] })); app.SimulatePrefixedKey(Arrow(ConsoleKey.LeftArrow)); - await Assert.That(Panes(app).Single().Tabs).IsEquivalentTo(before); + await Assert.That(Order(Panes(app).Single().Tabs)).IsEqualTo(Order(before)); + await Assert.That(Order(app.PaneTabStrip("p1").Select(t => t.WindowId))).IsEqualTo(Order(before)); + } + + /// + /// The reorder has to move the strip the user is looking at, not only the model behind it. + /// Three tabs, both directions, from all three positions — the grid the report only named one cell + /// of ("3 tabs, move the middle one right, it says it is already at the end"). + /// + /// The refusal was reading a model the screen had stopped agreeing with. TabControl cannot + /// move a page — TabPages is a copy and the only mutators add — so the old + /// RefreshTabTitles, which repaints each page by its own Tag, left the strip in its + /// original order after every reorder. The move looked like a no-op, and the press after it refused + /// truthfully about a model that had the tab at the end and falsely about a strip that showed it in + /// the middle. Asserting the *strip* is the point of this test: the model half passed throughout. + /// + /// + [Test] + [Arguments(0, '<', "main,chat,ooc", "main", true)] + [Arguments(0, '>', "chat,main,ooc", "main", false)] + [Arguments(1, '<', "chat,main,ooc", "chat", false)] + [Arguments(1, '>', "main,ooc,chat", "chat", false)] + [Arguments(2, '<', "main,ooc,chat", "ooc", false)] + [Arguments(2, '>', "main,chat,ooc", "ooc", true)] + public async Task ReorderingATabMovesItOnTheStripAndRefusesOnlyAtARealEnd( + int activeIndex, char key, string expected, string moved, bool refused) + { + var app = ThreeTabs(activeIndex); + + app.SimulatePrefixedKey(Key(key)); + + // The model, the strip on screen, and which tab is still the active one — all three, because the + // bug was exactly the model moving while the other two stayed where they were. + await Assert.That(Order(Panes(app).Single().Tabs)).IsEqualTo(expected); + await Assert.That(Order(app.PaneTabStrip("p1").Select(t => t.WindowId))).IsEqualTo(expected); + await Assert.That(app.PaneActiveTab("p1")).IsEqualTo(WindowId(moved)); + + // Loud either way: a tab genuinely at the end still says so, and one that had somewhere to go + // must not be told it did not. + if (refused) + { + await Assert.That(app.StatusMarkup).Contains("already at that end"); + } + else + { + await Assert.That(app.StatusMarkup).DoesNotContain("already at that end"); + } } /// Any other key spends the prefix and does nothing — the next key is typing again. From c8965e4748fb16a3cdedef402cfc4f78ea88d274 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 30 Jul 2026 16:15:12 -0500 Subject: [PATCH 08/11] Say the pane in the sidebar as the chord, not as words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosting column read 'pane 3' behind a two-space gap. The sidebar's width is taken out of the pane area and reported to every connected session over NAWS, so four cells on every row is four cells off every pane. It now reads '⌥3' behind one space: three cells where the column appears at all, down from seven. The sigil is not decoration. It names the key that goes there, which the words left the reader to infer, and it is what keeps the column apart from the unread badge that ends immediately before it - '2 3' is two numbers with nothing to tell them apart, and the word used to do that job. PaneLabel still says 'pane N' wherever the noun carries meaning: split pane 2 left, Go to pane 3, there is no pane 7. Same ordinal, shorter spelling, only in the one surface that pays for its width by the cell. --- src/SharpMUTerm.Tui/RailRenderer.cs | 13 ++++---- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 31 ++++++++++++++++--- tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs | 6 ++-- .../PaneNumberingRailTests.cs | 24 +++++++------- .../RailWindowRowTests.cs | 11 ++++--- 5 files changed, 56 insertions(+), 29 deletions(-) diff --git a/src/SharpMUTerm.Tui/RailRenderer.cs b/src/SharpMUTerm.Tui/RailRenderer.cs index 8f1bb600..1f138992 100644 --- a/src/SharpMUTerm.Tui/RailRenderer.cs +++ b/src/SharpMUTerm.Tui/RailRenderer.cs @@ -142,7 +142,7 @@ private static string Character(RailRow row) var marker = row.Active ? "[bold]▸[/]" : " "; var dot = row.Connected ? "●" : "○"; var name = row.Active ? $"[bold]{Escape(row.Label)}[/]" : Escape(row.Label); - var tail = row.Pane is { Length: > 0 } pane ? $" [dim]{Escape(pane)}[/]" : string.Empty; + var tail = row.Pane is { Length: > 0 } pane ? $" [dim]{Escape(pane)}[/]" : string.Empty; return $"{Indent(row)}{Link(row, $"{marker} [{Accent(row)}]{dot}[/] {name}{UnreadField(row.Unread)}{tail}")}"; } @@ -203,11 +203,12 @@ private static string Window(RailRow row) var name = Escape(row.Label); var where = row.Closed ? "closed" : row.Pane is { Length: > 0 } pane ? pane : null; - // Two spaces, not three. The reserved badge fields sit between the label and this column and are - // blank far more often than not, so they already hold the gap open; a third on top of them would - // be paid for in sidebar columns, which come out of the panes. Not one, though: a populated - // unread badge ends right here, and `2 pane 2` reads as one thing rather than two. - var tail = where is null ? string.Empty : $" [dim]{Escape(where)}[/]"; + // One space. The reserved badge fields sit between the label and this column and are blank far more + // often than not, so they already hold the gap open; anything on top of them is paid for in sidebar + // columns, which come out of the panes. Two used to be needed because the column said `pane 2` and a + // populated unread badge ending right here made `2 pane 2` read as one thing; the sigil in `⌥2` + // now does that work in a cell that carries meaning of its own. + var tail = where is null ? string.Empty : $" [dim]{Escape(where)}[/]"; return $"{Indent(row)}{Link(row, $"[dim]▪[/] {name}{Unsent(row.Unsent)}{UnreadField(row.Unread)}{tail}")}"; } diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index 7521331d..4415c9b3 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -4025,10 +4025,10 @@ private IReadOnlyList BuildRail() { for (var i = 0; i < panes.Count; i++) { - // Through PaneLabel so the sidebar, the move/drag overlays, the ⌃P entries and the ⌥N - // chord are all reading one spelling of one number. They were two expressions and they - // disagreed about the first pane. - paneLabels[panes[i].Id] = PaneLabel(panes[i].Id); + // Through PaneOrdinal so the sidebar, the move/drag overlays, the ⌃P entries and the ⌥N + // chord are all reading one number. They were two expressions and they disagreed about + // the first pane. + paneLabels[panes[i].Id] = RailPaneLabel(i + 1); } } @@ -4126,6 +4126,29 @@ private IReadOnlyList BuildRailWindows( /// is empty then and "which of the one pane" is not information. /// /// + /// + /// What the sidebar's hosting column calls pane : the chord that goes there, + /// ⌥3, rather than the words pane 3. + /// + /// The sidebar's width comes out of the pane area and is reported to every connected session over + /// NAWS, so four cells on every row is four cells off every pane. This is the one surface where the + /// noun is redundant — the column's position already says "where this is" — and dropping it pays for + /// itself twice: it is shorter, and ⌥3 names the key that goes there, which pane 3 left + /// the reader to infer. + /// + /// + /// It is not a second spelling of the number. still says pane N + /// everywhere the noun carries meaning — split pane 2 left, Go to pane 3, there is no + /// pane 7 — and both read the same ordinal. What changed is the abbreviation, not the count. + /// + /// + /// The sigil is also what keeps the column legible beside the unread badge. A bare 3 after a + /// count of 2 is 2 3, two numbers with nothing to tell them apart; the word used to do + /// that work, and something has to. + /// + /// + private static string RailPaneLabel(int ordinal) => $"⌥{ordinal}"; + private string? CharacterPaneLabel(string sessionKey, IReadOnlyDictionary paneLabels) { if (paneLabels.Count == 0) diff --git a/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs index fdbb0eb2..8e8d15fc 100644 --- a/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs @@ -78,7 +78,7 @@ public async Task EachDigitLandsOnThePaneTheRailNumbersWithIt() // The rail's own word for where the now-active window is. await Assert.That(RailPaneLabel(three.App)) - .IsEqualTo($"pane {n}") + .IsEqualTo($"⌥{n}") .Because($"⌥{n} must land on the pane the sidebar calls pane {n}"); // The session, so the command line is talking to the pane you are looking at. @@ -381,7 +381,7 @@ public async Task TheMoveOverlayAndTheSidebarSpellTheFirstPaneTheSameWay() await Assert.That(app.StatusMarkup) .DoesNotContain("main") .Because("the first pane is pane 1 everywhere, or ⌥1 names something the screen does not"); - await Assert.That(app.RailLines.Any(l => l.Contains("pane 1", StringComparison.Ordinal))).IsTrue(); + await Assert.That(app.RailLines.Any(l => l.Contains("⌥1", StringComparison.Ordinal))).IsTrue(); app.SimulateKey(Plain('\x1b', ConsoleKey.Escape)); // leave move mode } @@ -527,7 +527,7 @@ private static string RailPaneLabel(SharpMUTermApp app) { foreach (var line in app.RailLines.Where(l => l.Contains('▪', StringComparison.Ordinal))) { - var match = Regex.Match(line, @"pane \d+"); + var match = Regex.Match(line, @"⌥\d+"); if (match.Success) { return match.Value; diff --git a/tests/SharpMUTerm.Tui.Tests/PaneNumberingRailTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneNumberingRailTests.cs index 946514fd..0b19960d 100644 --- a/tests/SharpMUTerm.Tui.Tests/PaneNumberingRailTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/PaneNumberingRailTests.cs @@ -46,11 +46,11 @@ public async Task TheRailNamesThePaneOfEveryCharacterAndNotJustTheActiveOne() app.RenderNextFrame(); await Assert.That(app.ActiveSessionKey).IsEqualTo("Alfa.Ann"); - await Assert.That(CharacterPane(app, "Ann")).IsEqualTo("pane 1"); + await Assert.That(CharacterPane(app, "Ann")).IsEqualTo("⌥" + "1"); await Assert.That(CharacterPane(app, "Bob")) - .IsEqualTo("pane 2") + .IsEqualTo("⌥" + "2") .Because("⌥2 goes to Bob from here, and the sidebar has to be where you find that out"); - await Assert.That(CharacterPane(app, "Cal")).IsEqualTo("pane 3"); + await Assert.That(CharacterPane(app, "Cal")).IsEqualTo("⌥" + "3"); } /// @@ -70,7 +70,7 @@ public async Task PressingTheDigitTheRailPrintsAgainstACharacterGoesToThatCharac app.SimulateKey(Alt(1)); app.RenderNextFrame(); - var digit = int.Parse(CharacterPane(app, name)!["pane ".Length..]); + var digit = int.Parse(CharacterPane(app, name)![1..]); app.SimulateKey(Alt(digit)); await Assert.That(app.ActiveSessionKey) @@ -92,9 +92,9 @@ public async Task TheColumnIsStillThereAfterSwitching() app.RenderNextFrame(); await Assert.That(app.ActiveSessionKey).IsEqualTo("Cara.Cal"); - await Assert.That(CharacterPane(app, "Ann")).IsEqualTo("pane 1"); - await Assert.That(CharacterPane(app, "Bob")).IsEqualTo("pane 2"); - await Assert.That(CharacterPane(app, "Cal")).IsEqualTo("pane 3"); + await Assert.That(CharacterPane(app, "Ann")).IsEqualTo("⌥" + "1"); + await Assert.That(CharacterPane(app, "Bob")).IsEqualTo("⌥" + "2"); + await Assert.That(CharacterPane(app, "Cal")).IsEqualTo("⌥" + "3"); } /// @@ -144,9 +144,9 @@ public async Task ClosingAPaneCompactsTheNumberingOnTheChordAndInTheSidebar() app.RenderNextFrame(); await Assert.That(app.PaneIds.Count).IsEqualTo(2); - await Assert.That(CharacterPane(app, "Ann")).IsEqualTo("pane 1"); + await Assert.That(CharacterPane(app, "Ann")).IsEqualTo("⌥" + "1"); await Assert.That(CharacterPane(app, "Cal")) - .IsEqualTo("pane 2") + .IsEqualTo("⌥" + "2") .Because("the panes on the screen must be numbered 1 and 2, not 1 and 3"); app.SimulateKey(Alt(1)); @@ -173,7 +173,7 @@ public async Task ASinglePaneWorkspaceNamesNoPaneOnAnyRow() app.RenderSnapshot(); await Assert.That(app.PaneIds.Count).IsEqualTo(1); - await Assert.That(app.RailLines.Any(l => Regex.IsMatch(l, @"pane \d+"))) + await Assert.That(app.RailLines.Any(l => Regex.IsMatch(l, @"⌥\d+"))) .IsFalse() .Because("with one pane, naming it says nothing and costs the panes their columns"); } @@ -193,7 +193,7 @@ public async Task TheRailsColumnAgreesWithTheAppsOwnLabelForTheSamePane() { var paneId = app.PaneIdOf(window); var ordinal = app.PaneIds.ToList().IndexOf(paneId!) + 1; - await Assert.That(CharacterPane(app, name)).IsEqualTo($"pane {ordinal}"); + await Assert.That(CharacterPane(app, name)).IsEqualTo($"⌥{ordinal}"); } } @@ -219,7 +219,7 @@ public async Task TheRailsColumnAgreesWithTheAppsOwnLabelForTheSamePane() continue; } - var match = Regex.Match(line, @"pane \d+"); + var match = Regex.Match(line, @"⌥\d+"); return match.Success ? match.Value : null; } diff --git a/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs b/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs index 2377c178..3ea0197f 100644 --- a/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs @@ -77,7 +77,8 @@ await Assert.That(windows.Select(r => r.TrimEnd()).Select(r => r.TrimStart()).To /// /// The two columns never wear the same word. The hosting-pane column called the first pane /// "main" too, so a row could read ▪ main main — the naive fix for the label, and two different - /// meanings in one line. Panes are spelt pane N, which no window title is. + /// meanings in one line. The sidebar spells a pane ⌥N — the chord that goes there — which no + /// window title is, and which is four cells narrower than the words it replaced. /// [Test] public async Task TheHostingPaneColumnNeverRepeatsTheWindowsOwnName() @@ -91,7 +92,7 @@ public async Task TheHostingPaneColumnNeverRepeatsTheWindowsOwnName() foreach (var row in windows) { - await Assert.That(row).Contains("pane "); + await Assert.That(row).Contains("⌥"); await Assert.That(row.Trim()).IsNotEqualTo("▪ main main"); } } @@ -109,18 +110,20 @@ public async Task WithOnePaneTheHostingColumnIsNotDrawn() await Assert.That(app.PaneIds.Count).IsEqualTo(1); foreach (var row in Rail(app).Where(r => r.TrimStart().StartsWith("▪", StringComparison.Ordinal))) { - await Assert.That(row).DoesNotContain("pane "); + await Assert.That(row).DoesNotContain("⌥"); } // The rows do end in blanks now, and that is the reserved badge fields rather than slack — so the // claim this used to make with DoesNotEndWith(" ") is made by width instead, which is the thing // that actually mattered: a single-pane rail must not pay for a column with nothing in it. (It // used to: three spaces were emitted unconditionally and the sidebar was three cells wider.) + // The column is now `⌥N` behind a single space rather than `pane N` behind two, so what it costs + // when it *is* drawn is three cells, not seven. var single = MainWindowRowWidth(app); await Assert.That(app.DispatchCommand("layout:split-right")).IsTrue(); app.RenderNextFrame(); - await Assert.That(Rail(app).Single(MainRow)).Contains("pane "); + await Assert.That(Rail(app).Single(MainRow)).Contains("⌥"); await Assert.That(MainWindowRowWidth(app)).IsGreaterThan(single); } From e4f97f1bc93cbcca7ea8856ad39029d574c7b423 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 30 Jul 2026 16:25:57 -0500 Subject: [PATCH 09/11] Every pane comes back, because the log is keyed by window and not by session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restarting the client emptied every pane. The layout came back — LastSession persists the pane tree and the window registry — and the content did not, because nothing kept it: the file-backed spill purges its root on startup and says so in its own comment, and LastSession's doc explicitly excludes scrollback. The obvious fix is to persist WorldSession.Scrollback, and it is wrong. A spawn window's lines never reach it: ProcessOutputLine raises SpawnLine and the shell appends the result straight into that window's markup buffer, and the ordinary channel rule gags the line so it is not in the transcript at all. A session-keyed restore therefore refills the main windows and hands the reader an empty Chat pane, which is the failure this exists to remove. RestoreLogEndToEndTests pins that premise before anything is built on it. So the log is keyed by window id, the same ids LastSession already records, and it is fed from the shell at OnLine and OnSpawnLine — not from AppendWindowLine, which also carries the client's own chrome and the restore replay and would have each launch re-record its own history. The two halves are joined loosely: a window in the log the saved workspace forgot is buffered anyway and its pane opens holding its history when that channel next speaks; a saved window with no log starts empty. Neither throws. One 0600 file per window under restore/, beside config.json and secrets.json. Header, then length-prefixed CRC-32 records whose payload is StyledLineCodec — markup would have frozen the lines in one theme's colours and lost the palette indices, the rule colour and every span's interaction. Appends flush to the OS per line, so a crash loses nothing and a polite exit is not required; there is deliberately no fsync per line. The bound is 500 lines per window and is in lines, never bytes, because a byte-bounded ring hangs for ever making room for a block bigger than itself. Space is reclaimed by compaction: a byte-range copy of the newest records through an atomic rename, amortising to one extra record written per record appended. Damage degrades rather than throws, which matters because the only caller is startup: a record that fails its checksum is skipped, and reading stops only where the framing itself stops making sense, so a crash's torn tail costs the newest few lines and a bad sector costs one. Restored content is closed off by a single RestoreBarRenderer row naming how much came back and when; the lines themselves are left in the game's own colours, since recolouring them to prove they were restored would destroy the thing being restored. Restoring 3,000 lines costs ~18 ms before the first frame (~5.5 ms of app construction becomes ~23 ms), of which ~2.8 ms is reading and decoding. F9 gains a `restore` row per character; clearing it stops the writing and drops that character's stored content on the next launch. ⌃P gains "Purge the restore log", which deletes everything now and keeps working. `restore:` is the third member of the save:/logRoot: family and is null by default, so no test and no snapshot owns one. Also: the CRC-32 the spill kept privately is now shared with the restore log rather than copied, and PaneContentFor fills a newly created control from its window's buffer, which is what lets a forgotten window's history reappear. --- .gitignore | 8 +- CLAUDE.md | 17 + .../Commands/CommandCatalog.cs | 12 + .../Configuration/AppConfiguration.cs | 14 +- .../Configuration/CharacterDefinition.cs | 7 +- .../Configuration/WorldDefinition.cs | 19 + src/SharpMUTerm.Core/Text/Crc32.cs | 46 + .../Text/FileScrollbackSpill.cs | 37 - src/SharpMUTerm.Core/Text/RestoreLog.cs | 941 ++++++++++++++++++ .../Text/RestoreLogOptions.cs | 62 ++ src/SharpMUTerm.Tui/Glyphs.cs | 6 + src/SharpMUTerm.Tui/Program.cs | 18 +- src/SharpMUTerm.Tui/RestoreBarRenderer.cs | 50 + src/SharpMUTerm.Tui/SharpMUTermApp.cs | 226 ++++- src/SharpMUTerm.Tui/WorldsScreenRenderer.cs | 69 +- .../Text/RestoreLogTests.cs | 495 +++++++++ .../RestoreBarRendererTests.cs | 68 ++ .../RestoreLogEndToEndTests.cs | 505 ++++++++++ .../SharpMUTerm.Tui.Tests/ScreenModelTests.cs | 28 +- 19 files changed, 2567 insertions(+), 61 deletions(-) create mode 100644 src/SharpMUTerm.Core/Text/Crc32.cs create mode 100644 src/SharpMUTerm.Core/Text/RestoreLog.cs create mode 100644 src/SharpMUTerm.Core/Text/RestoreLogOptions.cs create mode 100644 src/SharpMUTerm.Tui/RestoreBarRenderer.cs create mode 100644 tests/SharpMUTerm.Core.Tests/Text/RestoreLogTests.cs create mode 100644 tests/SharpMUTerm.Tui.Tests/RestoreBarRendererTests.cs create mode 100644 tests/SharpMUTerm.Tui.Tests/RestoreLogEndToEndTests.cs diff --git a/.gitignore b/.gitignore index daaf225a..cb6f99eb 100644 --- a/.gitignore +++ b/.gitignore @@ -60,13 +60,19 @@ secrets.json # A user's own configuration and session data must never be committable. None of these are # written inside the repository today — config.json and the diagnostics log live beside each # other under the per-user config directory, session transcripts go wherever a character's -# logging settings point, and the scrollback spill is an ephemeral cache under XDG_CACHE_HOME. +# logging settings point, the scrollback spill is an ephemeral cache under XDG_CACHE_HOME, and +# the restore log is a `restore/` directory beside config.json holding one bounded file per pane. # They are listed because a character's password is persisted in config.json in plaintext, so # the one realistic route into git is a human copying a real config in to reproduce something. config.json client-diagnostics-*.log *.log +# The restore log's own directory. Its files are already covered by the `*.log` above — that is why +# they carry that extension — but the directory is named too, because "why is this ignored" should be +# answerable by reading this file rather than by knowing which extension the format happened to pick. +restore/ + # Python bytecode cache __pycache__/ *.pyc diff --git a/CLAUDE.md b/CLAUDE.md index 11d14b95..eb7053ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,23 @@ fallbacks) for inline images/maps. single-line dividers) and the **connection rail** now rendered as well — and **clickable**: a world, character or window row switches to it, dispatched through the rail control's *own* `LinkClicked` (never the output panes' handler, so a world cannot drive the client's UI from the wire). +- **Panes come back after a restart, and the log that does it is keyed by *window*** (`RestoreLog`, + Core; `restore/` beside `config.json`, one `0600` file per window, 500 lines each). This is the third + thing in the repository that puts session text on disk and it is none of the other two: the spill is + an ephemeral cache purged next launch, the transcripts are opt-in files a user keeps, and this is a + small bounded tail nothing but startup reads. **It cannot be built on `WorldSession.Scrollback`** — a + spawn window's lines never go there (`ProcessOutputLine` raises `SpawnLine`, and a gagging capture + rule keeps the line out of the transcript entirely), so a session-keyed restore refills the main + windows and leaves every channel pane empty, which is the exact failure it exists to remove. It is + therefore fed from the shell, at `OnLine`/`OnSpawnLine`, and **not** from `AppendWindowLine`: that + seam also carries the client's own chrome and the restore *replay*, so logging there would have each + launch re-record its own history. Payload is `StyledLineCodec`, not markup, so the game's colours and + a span's interaction survive and the current theme still renders them. Appends flush to the OS per + line (a crash loses nothing; there is deliberately no `fsync` per line), the bound is in **lines** + and never bytes, and space is reclaimed by compaction — a byte-range copy through an atomic rename. + 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**. - **A launch connects nothing unless it is told to** (`StartupConnections.Resolve`, Core). A host on the command line wins outright; otherwise it is every character with `ConnectAtStartup` (F5's `at start`), in configuration order; otherwise none, and the client says which of the two empty states it is in. diff --git a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs index 65fb0a55..233f88ee 100644 --- a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs +++ b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs @@ -117,6 +117,18 @@ public static IReadOnlyList Build( CommandGroup.Terminal, "Back to live output", "term:scroll-live", "⌃End")); } + // The restore log's one control that is not a settings field. It is listed unconditionally, and + // it is listed *here* rather than only on F9, because "delete what this client has written down + // about my session" is a thing a person wants to do now — after saying something they would + // rather was not on disk — and a purge you have to find in a settings screen is a purge that + // happens tomorrow. The per-character switch on F9 is the other half: this one is immediate and + // total, that one is a standing preference. + items.Add(new CommandItem( + CommandGroup.Terminal, + "Purge the restore log", + "term:restore-purge", + "deletes every pane's saved content")); + // The client's own messages — the status-line notices that dismiss themselves — kept out of the // output window (and so out of the session log) and readable here instead. items.Add(new CommandItem( diff --git a/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs b/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs index e7120459..d83bcad3 100644 --- a/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs +++ b/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs @@ -38,6 +38,15 @@ public sealed class AppConfiguration /// public ScrollbackSpillOptions ScrollbackSpill { get; set; } = new(); + /// + /// How much of each pane's recent content is kept so that restarting the client refills the panes it + /// came from. Unlike this is meant to survive — it is the one + /// thing in 's neighbourhood that persists text rather than structure — and + /// unlike the session transcripts it is on by default and read only by the client itself. See + /// . + /// + public RestoreLogOptions RestoreLog { get; set; } = new(); + /// /// Forces a graphics protocol regardless of capability detection: one of /// none, halfblock, sixel, kitty. Null means auto-detect. @@ -71,7 +80,10 @@ public sealed class AppConfiguration /// /// The last workspace layout (panes, windows, focus) so the app can resume where it left off. /// Null on a fresh config; the shell rebuilds a live workspace from it at startup via - /// . Scrollback is not persisted — only structure. + /// . Only structure lives here — the content of each + /// window is the restore log's business (), keyed by the same window ids + /// this records, and deliberately not in config.json: a hundred thousand lines of chat would + /// make the one file people hand-edit and paste into bug reports unreadable and unshareable. /// public WorkspaceState? LastSession { get; set; } diff --git a/src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs b/src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs index eabd664b..7b2db3fc 100644 --- a/src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs +++ b/src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs @@ -154,7 +154,12 @@ public sealed class CharacterDefinition OnConnect = OnConnect, OnDisconnect = OnDisconnect, TriggerSets = new List(TriggerSets), - Logging = new LoggingSettings { Format = Logging.Format, Directory = Logging.Directory }, + Logging = new LoggingSettings + { + Format = Logging.Format, + Directory = Logging.Directory, + RestoreLog = Logging.RestoreLog, + }, }; /// diff --git a/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs b/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs index 8475cded..dd8f956f 100644 --- a/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs +++ b/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs @@ -42,6 +42,25 @@ public sealed class LoggingSettings /// Directory for log files. Defaults to a per-session folder under the config dir. public string? Directory { get; set; } + + /// + /// Whether this character's panes come back with their previous session's content after a restart + /// (). On by default, and the per-character opt-out for + /// somebody who does not want one world's text on disk between sessions. + /// + /// It sits beside because F9 is where a character's "what of mine is written + /// down" questions are answered, and it is a separate switch from it because the two settings are + /// different things: a transcript is a file you keep, read and choose a format for, and this + /// is a small bounded tail nothing but the client's own startup ever reads. Turning one off has never + /// implied anything about the other. + /// + /// + /// Clearing it stops the writing and drops whatever is already stored for that character's + /// windows on the next launch — an opt-out that left the last session's text lying there would be + /// answering a different question from the one it was asked. + /// + /// + public bool RestoreLog { get; set; } = true; } /// diff --git a/src/SharpMUTerm.Core/Text/Crc32.cs b/src/SharpMUTerm.Core/Text/Crc32.cs new file mode 100644 index 00000000..bb3be85b --- /dev/null +++ b/src/SharpMUTerm.Core/Text/Crc32.cs @@ -0,0 +1,46 @@ +namespace SharpMUTerm.Core.Text; + +/// +/// CRC-32 (IEEE 802.3, reflected) over a record payload. Hand-rolled rather than taking a dependency +/// on System.IO.Hashing for twenty lines of table lookup. +/// +/// Shared by the two byte-framed stores in this namespace — 's +/// ephemeral spill segments and 's kept per-window logs. Both frame a +/// payload as length · payload · checksum, and a second copy of +/// this table would be a second thing to keep identical for no gain: a checksum that disagreed +/// between the writer and the reader is indistinguishable from corruption. +/// +/// +public static class Crc32 +{ + private static readonly uint[] Table = BuildTable(); + + /// The CRC-32 of . + public static uint Compute(ReadOnlySpan data) + { + var crc = 0xFFFFFFFFu; + foreach (var b in data) + { + crc = Table[(crc ^ b) & 0xFF] ^ (crc >> 8); + } + + return crc ^ 0xFFFFFFFFu; + } + + private static uint[] BuildTable() + { + var table = new uint[256]; + for (var i = 0u; i < 256u; i++) + { + var value = i; + for (var bit = 0; bit < 8; bit++) + { + value = (value & 1) != 0 ? 0xEDB88320u ^ (value >> 1) : value >> 1; + } + + table[i] = value; + } + + return table; + } +} diff --git a/src/SharpMUTerm.Core/Text/FileScrollbackSpill.cs b/src/SharpMUTerm.Core/Text/FileScrollbackSpill.cs index 99e599bc..6c6d7636 100644 --- a/src/SharpMUTerm.Core/Text/FileScrollbackSpill.cs +++ b/src/SharpMUTerm.Core/Text/FileScrollbackSpill.cs @@ -919,41 +919,4 @@ private sealed class Segment(string path, SafeFileHandle handle, long firstIndex /// Logical length in bytes, including anything still staged in the write buffer. public long Length { get; set; } } - - /// - /// CRC-32 (IEEE 802.3, reflected) over a record's payload. Hand-rolled rather than taking a - /// dependency on System.IO.Hashing for twenty lines of table lookup. - /// - private static class Crc32 - { - private static readonly uint[] Table = BuildTable(); - - public static uint Compute(ReadOnlySpan data) - { - var crc = 0xFFFFFFFFu; - foreach (var b in data) - { - crc = Table[(crc ^ b) & 0xFF] ^ (crc >> 8); - } - - return crc ^ 0xFFFFFFFFu; - } - - private static uint[] BuildTable() - { - var table = new uint[256]; - for (var i = 0u; i < 256u; i++) - { - var value = i; - for (var bit = 0; bit < 8; bit++) - { - value = (value & 1) != 0 ? 0xEDB88320u ^ (value >> 1) : value >> 1; - } - - table[i] = value; - } - - return table; - } - } } diff --git a/src/SharpMUTerm.Core/Text/RestoreLog.cs b/src/SharpMUTerm.Core/Text/RestoreLog.cs new file mode 100644 index 00000000..238700a8 --- /dev/null +++ b/src/SharpMUTerm.Core/Text/RestoreLog.cs @@ -0,0 +1,941 @@ +using System.Buffers.Binary; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace SharpMUTerm.Core.Text; + +/// One line read back out of a : what it said, and when it arrived. +/// The styled line, exactly as it was written to the pane. +/// +/// The arrival time the pane recorded, or null when the line carried none. It is the rendered +/// stamp rather than a timestamp, for the same reason the live buffer holds one: the timestamp column +/// is a render-time decision, and a restored line has to be able to answer it the same way a live one +/// does. +/// +public sealed record RestoredLine(StyledLine Line, string? Stamp); + +/// One window's worth of restored content. +/// The window id the lines were logged under — the key the whole design turns on. +/// What that window called itself when the log was created. Diagnostic only. +/// When the file was last touched, i.e. roughly when the previous session ended. +/// The readable lines, oldest first, at most . +/// +/// How many bytes of the file could not be turned into lines — a record that failed its checksum, plus +/// whatever torn remainder a crash left at the end. Zero on a clean log. Reported rather than thrown: +/// see the class remarks. +/// +public sealed record RestoredWindow( + string WindowId, + string Title, + DateTimeOffset LastWritten, + IReadOnlyList Lines, + long Lost); + +/// +/// The kept, per-window record of recent pane content, so that starting the client back up refills the +/// panes the content came from instead of showing every one of them empty. +/// +/// Keyed by window, not by session. That is the whole design. A character's own transcript is +/// already in WorldSession.Scrollback, but a spawn window's content never goes there — +/// WorldSession.ProcessOutputLine raises SpawnLine and the shell appends the result +/// straight into that window's markup buffer — so restoring session scrollback would bring the main +/// windows back and leave every channel pane blank, which is precisely the failure this exists to +/// avoid. Logging per window covers both kinds with one mechanism, and it agrees with +/// AppConfiguration.LastSession, which persists window ids and where their panes sit. The two +/// are joined loosely on purpose: a window id in the log that the saved workspace no longer knows about +/// is buffered anyway (so the pane refills if that window is ever opened again), and a window in the +/// saved workspace with no log simply starts empty. Neither is an error. +/// +/// +/// Not the spill, and not a transcript. is an ephemeral cache +/// that purges itself on the next launch — most of a restore log and deliberately not one. +/// PlainTextLogSink/HtmlLogSink are opt-in transcripts a user keeps, formats and reads. +/// This is a third thing: a small, bounded, machine-readable tail of each pane, on by default, whose +/// only consumer is the client's own startup. It is plaintext at rest, in the configuration directory, +/// owner-only (0600) like secrets.json; a character opts out on F9 and ⌃P purges the lot. +/// +/// +/// Layout. One file per window under the root, named for a sanitised form of the window id plus a +/// checksum of it (the id itself may hold characters a filename may not, and two ids must never collide +/// on one file). Each file is a variable-length header — magic, format version, and the window's id and +/// title, checksummed — followed by length · payload · CRC-32 records whose payload is a +/// line with its arrival stamp. Reusing the codec is not only economy: it +/// is the one format in this codebase that can carry a palette index, a span's +/// and a line's rule colour without lying about any of them. +/// +/// +/// Append as you go, not flush on exit. Every line is written and flushed to the operating system +/// as it arrives, so a crash — an unhandled exception, an OOM kill, a kill -9, a closed terminal +/// — loses nothing at all: the bytes are already the kernel's. A restore log that only worked when you +/// quit politely would fail in exactly the case that motivates it. What is deliberately not +/// done is an fsync per line: that is a device round-trip for every line a busy game sends, and +/// the only failure it would additionally cover is losing mains power mid-session, where losing the last +/// few lines of chat is an acceptable outcome. A periodic flush was the other candidate and is strictly +/// worse — it trades the same syscall cost for a window of seconds in which a crash loses output, and +/// interesting output is what tends to precede a crash. +/// +/// +/// Bounded in lines. Each file holds at most twice +/// records and is compacted back down to the bound — a contiguous copy of the newest records into a +/// temporary file, then an atomic rename — so the amortised cost is one extra record written per record +/// appended, and space is reclaimed without ever rewriting the file per line. The bound is in lines and +/// must stay in lines; see . +/// +/// +/// Nothing here throws at a caller. Not on a missing directory, a permission error, a truncated +/// file, a bad checksum, or a header from a future version. A read skips a record that fails +/// its checksum and carries on, and stops only where the framing itself stops making sense — +/// so a crash's torn tail costs the newest few lines rather than the file, and one damaged record costs +/// one line rather than everything after it. Either way the lost byte count comes back for the caller +/// to log. A write that fails takes that one window's file out of service for the session and leaves +/// the pane itself untouched. Startup is the caller here, and a client that refuses to start because of +/// a cache of old chat lines would be a worse client than one that starts with an empty pane. +/// +/// Thread safety. All members are safe to call concurrently; every operation is serialised on one lock. +/// +public sealed class RestoreLog : IDisposable +{ + /// Magic bytes at the head of every window log. + private static readonly byte[] Magic = "SMTRSTOR"u8.ToArray(); + + /// + /// Bumped if the record or header layout ever changes. Unlike the spill's, these files do + /// outlive the process that wrote them, so this is a real gate: a file whose version this build does + /// not know is left alone and read as nothing rather than reinterpreted. + /// + private const uint FormatVersion = 1; + + private const int MagicBytes = 8; + private const int FixedHeaderBytes = MagicBytes + (3 * sizeof(uint)); // magic, version, payload length, payload CRC + private const int LengthPrefixBytes = sizeof(uint); + private const int ChecksumBytes = sizeof(uint); + private const int FrameOverheadBytes = LengthPrefixBytes + ChecksumBytes; + + /// Largest record we will write or trust. A line beyond this is logged as a blank one. + private const int MaxRecordBytes = 1024 * 1024; + + /// The extension every window log carries — chosen so .gitignore's *.log already covers it. + internal const string FileExtension = ".log"; + + /// Where a compaction stages the new file before the rename that replaces the old one. + private const string TempExtension = ".tmp"; + + /// + /// The mode these files are written with on Unix: 0600, the same as secrets.json. They + /// hold what was said in a private game, which is nobody else's business on a shared machine. + /// + /// + /// Spelt out here rather than taken from SecretsStore.OwnerOnlyMode so that + /// SharpMUTerm.Core.Text keeps pointing at nothing in SharpMUTerm.Core.Configuration — + /// the dependency already runs the other way (AppConfiguration holds a + /// ). + /// + private const UnixFileMode OwnerOnlyFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + /// And the directory holding them, 0700 — a file name here is a channel's name. + private const UnixFileMode OwnerOnlyDirectoryMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + private readonly object _gate = new(); + private readonly Dictionary _writers = new(StringComparer.Ordinal); + private readonly MemoryStream _scratch = new(256); + private readonly BinaryWriter _scratchWriter; + private readonly int _maxLines; + private bool _disposed; + private bool _faultLogged; + + /// Creates a log over . Nothing touches the disk until a line is appended or read. + /// The directory holding the per-window files. Created on first write. + /// Bounds; null means the defaults. + public RestoreLog(string root, RestoreLogOptions? options = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(root); + Root = root; + _maxLines = Math.Max(1, (options ?? new RestoreLogOptions()).MaxLinesPerWindow); + _scratchWriter = new BinaryWriter(_scratch, StyledLineCodec.TextEncoding, leaveOpen: true); + } + + /// The directory holding the per-window files. + public string Root { get; } + + /// Where the logs go for a given configuration file: a restore directory beside it. + public static string DefaultRoot(string configurationPath) + { + ArgumentException.ThrowIfNullOrEmpty(configurationPath); + var directory = Path.GetDirectoryName(Path.GetFullPath(configurationPath)) ?? string.Empty; + return Path.Combine(directory, RestoreLogOptions.DirectoryName); + } + + /// Where diagnostics go. Defaults to nowhere, so Core stays free of a logging implementation. + public ILogger Logger { get; set; } = NullLogger.Instance; + + /// + /// Records one line as having been shown in . Cheap and synchronous: one + /// encode and one buffered write, flushed to the operating system before returning. + /// + /// The window the line was drawn in — main window or spawn window alike. + /// What that window calls itself, recorded in the file's header on creation. + /// The line. + /// The arrival stamp the pane recorded, or null. + public void Append(string windowId, string title, StyledLine line, string? stamp) + { + ArgumentException.ThrowIfNullOrEmpty(windowId); + ArgumentNullException.ThrowIfNull(line); + + lock (_gate) + { + if (_disposed) + { + return; + } + + var writer = WriterFor(windowId, title ?? string.Empty); + if (writer is null) + { + return; + } + + try + { + var payload = EncodeRecord(line, stamp); + writer.Append(payload, _maxLines); + } + catch (Exception ex) + { + Fault(ex, windowId); + } + } + } + + /// + /// Reads every window log under the root, newest-written first. Never throws: a file that cannot be + /// opened is skipped, and one that is damaged contributes whatever prefix of it is trustworthy with + /// the rest counted into . + /// + public IReadOnlyList Read() + { + lock (_gate) + { + var restored = new List(); + IEnumerable files; + try + { + if (!Directory.Exists(Root)) + { + return restored; + } + + files = Directory.EnumerateFiles(Root, "*" + FileExtension).OrderBy(f => f, StringComparer.Ordinal); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Listing the restore log under {Root} failed", Root); + return restored; + } + + foreach (var file in files) + { + if (ReadFile(file) is { } window) + { + restored.Add(window); + } + } + + return restored + .OrderByDescending(w => w.LastWritten) + .ToList(); + } + } + + /// + /// Deletes every window log and the directory itself, and hands back how many files went. The log + /// keeps working afterwards — this is "forget what is on disk now", not "switch the feature off" — + /// so a pane that goes on printing starts a fresh file. + /// + public int Purge() + { + lock (_gate) + { + CloseWriters(); + + var removed = 0; + try + { + if (!Directory.Exists(Root)) + { + return 0; + } + + foreach (var file in Directory.EnumerateFiles(Root, "*" + FileExtension) + .Concat(Directory.EnumerateFiles(Root, "*" + FileExtension + TempExtension))) + { + if (TryDelete(file)) + { + removed++; + } + } + + // Only when we emptied it: a root the user pointed at something else keeps whatever + // else is in there, and an empty directory is not worth a second failure mode. + if (!Directory.EnumerateFileSystemEntries(Root).Any()) + { + Directory.Delete(Root); + } + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Purging the restore log under {Root} failed", Root); + } + + return removed; + } + } + + /// + /// Drops one window's log — what a character's opt-out does to content already on disk. A window + /// with no file simply returns false. + /// + public bool Forget(string windowId) + { + ArgumentException.ThrowIfNullOrEmpty(windowId); + lock (_gate) + { + if (_writers.Remove(windowId, out var writer)) + { + writer.Dispose(); + } + + return TryDelete(PathFor(windowId)); + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + CloseWriters(); + _scratchWriter.Dispose(); + _scratch.Dispose(); + } + } + + /// The file one window's lines live in: a readable stem plus a checksum of the exact id. + /// + /// The stem is only for a human looking at the directory. The checksum is what makes the name + /// correct: window ids carry characters a filename may not (spawn:Chat), and two ids + /// that sanitise to the same stem — a channel called Chat/OOC and one called Chat OOC — + /// must not share a file. The id itself is stored in the header, so nothing ever has to read it back + /// out of the name. + /// + internal string PathFor(string windowId) + { + var bytes = StyledLineCodec.TextEncoding.GetBytes(windowId); + var stem = new string(windowId.Where(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_').Take(40).ToArray()); + if (stem.Length == 0) + { + stem = "window"; + } + + return Path.Combine(Root, $"{stem}-{Crc32.Compute(bytes):x8}{FileExtension}"); + } + + /// Encodes one record's payload: the arrival stamp, then the line. + private byte[] EncodeRecord(StyledLine line, string? stamp) + { + _scratch.SetLength(0); + _scratchWriter.Write((byte)(stamp is null ? 0 : 1)); + if (stamp is not null) + { + _scratchWriter.Write(stamp); + } + + StyledLineCodec.Write(_scratchWriter, line); + _scratchWriter.Flush(); + + if (_scratch.Length > MaxRecordBytes) + { + // A server dumping a megabyte without a newline. Keep the row (so the pane comes back the + // right length) and drop the text, rather than refusing the write or growing the file. + _scratch.SetLength(0); + _scratchWriter.Write((byte)0); + StyledLineCodec.Write(_scratchWriter, StyledLine.Empty); + _scratchWriter.Flush(); + } + + return _scratch.ToArray(); + } + + private Writer? WriterFor(string windowId, string title) + { + if (_writers.TryGetValue(windowId, out var writer)) + { + return writer; + } + + try + { + EnsureRoot(); + writer = Writer.Open(PathFor(windowId), windowId, title, Logger); + _writers[windowId] = writer; + return writer; + } + catch (Exception ex) + { + Fault(ex, windowId); + return null; + } + } + + private void EnsureRoot() + { + if (Directory.Exists(Root)) + { + return; + } + + Directory.CreateDirectory(Root); + if (!OperatingSystem.IsWindows()) + { + try + { + File.SetUnixFileMode(Root, OwnerOnlyDirectoryMode); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + Logger.LogDebug(ex, "Narrowing the restore log directory {Root} to 0700 failed", Root); + } + } + } + + /// + /// Takes one window out of service for the rest of the session. The line it was asked to record is + /// already on the screen and in the live buffer, so nothing the user can see is affected; the cost + /// is that this window will not come back next launch. + /// + private void Fault(Exception ex, string windowId) + { + if (_writers.Remove(windowId, out var writer)) + { + try + { + writer.Dispose(); + } + catch (Exception disposeFailure) + { + Logger.LogDebug(disposeFailure, "Closing the restore log for {WindowId} failed", windowId); + } + } + + if (_faultLogged) + { + return; + } + + _faultLogged = true; + Logger.LogWarning( + ex, + "The restore log under {Root} could not be written for window {WindowId}; that pane will start empty next launch", + Root, + windowId); + } + + private void CloseWriters() + { + foreach (var writer in _writers.Values) + { + try + { + writer.Dispose(); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Closing a restore log file failed"); + } + } + + _writers.Clear(); + } + + private bool TryDelete(string path) + { + try + { + if (!File.Exists(path)) + { + return false; + } + + File.Delete(path); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Logger.LogDebug(ex, "Removing the restore log file {Path} failed", path); + return false; + } + } + + /// + /// Reads one file into a , or null when it is not one of ours at all + /// (wrong magic, a version this build does not know, a header that fails its own checksum). + /// + private RestoredWindow? ReadFile(string path) + { + byte[] bytes; + DateTimeOffset written; + try + { + bytes = File.ReadAllBytes(path); + written = new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Reading the restore log file {Path} failed", path); + return null; + } + + if (!TryReadHeader(bytes, out var windowId, out var title, out var dataStart)) + { + Logger.LogWarning( + "{Path} is not a restore log this build can read; the window it belongs to starts empty", path); + return null; + } + + var lines = new List(); + var offset = dataStart; + long lost = 0; + while (offset < bytes.Length) + { + if (!TryFrameRecord(bytes, offset, out var payloadStart, out var payloadLength)) + { + // The framing itself no longer makes sense: a half-written record at the end of a file a + // crash interrupted, or damage a length prefix did not survive. Everything from here on + // is unreadable by construction, so stop and keep what came before. + lost += bytes.Length - offset; + break; + } + + var frameEnd = payloadStart + payloadLength + ChecksumBytes; + if (!ChecksumHolds(bytes, payloadStart, payloadLength)) + { + // One damaged record. Its neighbours are still framed, so skip exactly it. + lost += frameEnd - offset; + offset = frameEnd; + continue; + } + + try + { + lines.Add(DecodeRecord(bytes, payloadStart, payloadLength)); + } + catch (Exception ex) + { + // Checksum-clean and still undecodable: the payload disagrees with this reader rather + // than being damaged. Same treatment — lose the line, keep the file. + Logger.LogDebug(ex, "A restore log record in {Path} could not be decoded", path); + lost += frameEnd - offset; + } + + offset = frameEnd; + } + + if (lost > 0) + { + Logger.LogWarning( + "{Lost} byte(s) of the restore log for window {WindowId} could not be read — most likely a session " + + "that did not exit cleanly. {Kept} line(s) were restored and the rest discarded", + lost, + windowId, + lines.Count); + } + + // The file may hold up to twice the bound between compactions; the caller asked for the bound. + if (lines.Count > _maxLines) + { + lines.RemoveRange(0, lines.Count - _maxLines); + } + + return new RestoredWindow(windowId, title, written, lines, lost); + } + + private static RestoredLine DecodeRecord(byte[] bytes, int payloadStart, int payloadLength) + { + using var stream = new MemoryStream(bytes, payloadStart, payloadLength, writable: false); + using var reader = new BinaryReader(stream, StyledLineCodec.TextEncoding, leaveOpen: true); + var stamp = reader.ReadByte() != 0 ? reader.ReadString() : null; + return new RestoredLine(StyledLineCodec.Read(reader), stamp); + } + + /// + /// Locates one record's payload from its length prefix, checking only that the frame is present and + /// fits inside the file. False means the framing has run out — stop reading. Whether the record's + /// contents can be trusted is 's separate question, and keeping + /// the two apart is what lets a reader skip one damaged line without abandoning the ones after it. + /// + private static bool TryFrameRecord(byte[] bytes, int offset, out int payloadStart, out int payloadLength) + { + payloadStart = 0; + payloadLength = 0; + if (bytes.Length - offset < FrameOverheadBytes) + { + return false; + } + + var declared = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset)); + if (declared > MaxRecordBytes || declared + (long)FrameOverheadBytes > bytes.Length - offset) + { + return false; + } + + payloadStart = offset + LengthPrefixBytes; + payloadLength = (int)declared; + return true; + } + + /// Whether a framed record's payload still matches the CRC-32 written after it. + private static bool ChecksumHolds(byte[] bytes, int payloadStart, int payloadLength) => + Crc32.Compute(bytes.AsSpan(payloadStart, payloadLength)) + == BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(payloadStart + payloadLength)); + + /// Frames a payload into length · payload · CRC-32. + private static byte[] Frame(ReadOnlySpan payload) + { + var frame = new byte[payload.Length + FrameOverheadBytes]; + BinaryPrimitives.WriteUInt32LittleEndian(frame, (uint)payload.Length); + payload.CopyTo(frame.AsSpan(LengthPrefixBytes)); + BinaryPrimitives.WriteUInt32LittleEndian( + frame.AsSpan(LengthPrefixBytes + payload.Length), + Crc32.Compute(payload)); + return frame; + } + + /// Builds the file header: magic, version, then a checksummed payload naming the window. + private static byte[] BuildHeader(string windowId, string title) + { + using var stream = new MemoryStream(64); + using (var writer = new BinaryWriter(stream, StyledLineCodec.TextEncoding, leaveOpen: true)) + { + writer.Write(windowId); + writer.Write(title); + } + + var payload = stream.ToArray(); + var header = new byte[FixedHeaderBytes + payload.Length]; + Magic.CopyTo(header, 0); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(MagicBytes), FormatVersion); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(MagicBytes + sizeof(uint)), (uint)payload.Length); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(MagicBytes + (2 * sizeof(uint))), Crc32.Compute(payload)); + payload.CopyTo(header, FixedHeaderBytes); + return header; + } + + /// + /// Reads and validates a header. Everything about it is checked before a single record is trusted, + /// because a file replaced wholesale — restored from a backup, copied from another machine, or simply + /// not ours — is something per-record checksums would only notice one record at a time. + /// + private static bool TryReadHeader(byte[] bytes, out string windowId, out string title, out int dataStart) + { + windowId = string.Empty; + title = string.Empty; + dataStart = 0; + + if (bytes.Length < FixedHeaderBytes + || !bytes.AsSpan(0, MagicBytes).SequenceEqual(Magic) + || BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(MagicBytes)) != FormatVersion) + { + return false; + } + + var payloadLength = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(MagicBytes + sizeof(uint))); + var expected = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(MagicBytes + (2 * sizeof(uint)))); + if (payloadLength > MaxRecordBytes || FixedHeaderBytes + (long)payloadLength > bytes.Length) + { + return false; + } + + var payload = bytes.AsSpan(FixedHeaderBytes, (int)payloadLength); + if (Crc32.Compute(payload) != expected) + { + return false; + } + + try + { + using var stream = new MemoryStream(bytes, FixedHeaderBytes, (int)payloadLength, writable: false); + using var reader = new BinaryReader(stream, StyledLineCodec.TextEncoding, leaveOpen: true); + windowId = reader.ReadString(); + title = reader.ReadString(); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or ArgumentException) + { + return false; + } + + dataStart = FixedHeaderBytes + (int)payloadLength; + return windowId.Length > 0; + } + + /// + /// One window's open file: the stream, where every record in it starts, and how long it is. The + /// offsets are what make compaction a byte-range copy rather than a re-encode — records are + /// contiguous, so "keep the newest N" is one span. + /// + private sealed class Writer : IDisposable + { + private readonly string _path; + private readonly string _windowId; + private readonly ILogger _logger; + private readonly List _offsets = new(); + private FileStream _stream; + private int _dataStart; + + private Writer(string path, string windowId, FileStream stream, int dataStart, ILogger logger) + { + _path = path; + _windowId = windowId; + _stream = stream; + _dataStart = dataStart; + _logger = logger; + } + + /// + /// Opens (or creates) one window's file and indexes what is already in it, so a second session + /// continues the log rather than replacing it — restarting twice in a minute must not + /// leave a pane with two lines in it. + /// + /// A torn tail is truncated away here, at the last well-framed record. It costs nothing (the + /// reader had already stopped there) and it means the next append lands on a clean boundary + /// instead of behind rubbish that would poison every record after it. + /// + /// + public static Writer Open(string path, string windowId, string title, ILogger logger) + { + var existing = ReadExisting(path, windowId, out var dataStart, out var offsets, out var usable); + FileStream stream; + + if (existing) + { + // The mode is fixed up rather than set at creation: this file already has an inode, and + // one written by an older build (or copied in) should still end up owner-only. + RestrictToOwner(path, logger); + stream = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.Read); + if (usable < stream.Length) + { + logger.LogInformation( + "Trimming {Bytes} unreadable byte(s) from the end of the restore log for window {WindowId}", + stream.Length - usable, + windowId); + stream.SetLength(usable); + } + } + else + { + // UnixCreateMode, not a chmod afterwards: the mode is applied by the open that makes the + // inode, so the umask never gets a chance to widen it and there is no window in which a + // world-readable file already holds someone's chat. + stream = new FileStream(path, CreateOwnerOnly(FileShare.Read)); + + var header = BuildHeader(windowId, title); + stream.Write(header); + stream.Flush(); + dataStart = header.Length; + offsets = new List(); + } + + stream.Seek(0, SeekOrigin.End); + var writer = new Writer(path, windowId, stream, dataStart, logger); + writer._offsets.AddRange(offsets); + return writer; + } + + /// Appends one framed record and pushes it to the operating system; compacts when the file has doubled. + public void Append(byte[] payload, int maxLines) + { + var offset = _stream.Length; + _stream.Write(Frame(payload)); + + // Flush, not FlushAsync and not Flush(true). This puts the bytes in the kernel — which is + // what makes the log survive the client dying — without an fsync per line of chat. + _stream.Flush(); + _offsets.Add(offset); + + if (_offsets.Count > 2 * maxLines) + { + Compact(maxLines); + } + } + + public void Dispose() => _stream.Dispose(); + + /// + /// Rewrites the file with only the newest records, through a + /// temporary file and an atomic rename — so a crash mid-compaction leaves either the whole old + /// file or the whole new one, never a half of either. + /// + private void Compact(int maxLines) + { + var keepFrom = _offsets[^maxLines]; + var temporary = _path + TempExtension; + + try + { + _stream.Flush(); + using (var source = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var destination = new FileStream(temporary, CreateOwnerOnly(FileShare.None))) + { + var header = new byte[_dataStart]; + source.ReadExactly(header); + destination.Write(header); + + source.Seek(keepFrom, SeekOrigin.Begin); + source.CopyTo(destination); + } + + _stream.Dispose(); + File.Move(temporary, _path, overwrite: true); + + _stream = new FileStream(_path, FileMode.Open, FileAccess.Write, FileShare.Read); + _stream.Seek(0, SeekOrigin.End); + + var shift = keepFrom - _dataStart; + var kept = _offsets.Skip(_offsets.Count - maxLines).Select(o => o - shift).ToList(); + _offsets.Clear(); + _offsets.AddRange(kept); + } + catch (Exception ex) + { + // Compaction failing is not worth losing the log over: the file simply keeps growing + // until the next attempt succeeds, and the reader takes the newest `maxLines` anyway. + _logger.LogDebug(ex, "Compacting the restore log for window {WindowId} failed", _windowId); + TryRemove(temporary); + if (!_stream.CanWrite) + { + _stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read); + } + } + } + + private static void TryRemove(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A stray .tmp is harmless: it is not a *.log and the next compaction overwrites it. + } + } + + /// + /// Indexes an existing file: its header, every well-framed record in it, and the offset the + /// first unusable byte starts at. Framing only — the checksums are the reader's business, and a + /// damaged record in the middle must still be *skipped over* rather than truncating everything + /// written after it. + /// + private static bool ReadExisting( + string path, string windowId, out int dataStart, out List offsets, out long usable) + { + dataStart = 0; + offsets = new List(); + usable = 0; + + byte[] bytes; + try + { + if (!File.Exists(path)) + { + return false; + } + + bytes = File.ReadAllBytes(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + + if (!TryReadHeader(bytes, out var storedId, out _, out dataStart) + || !string.Equals(storedId, windowId, StringComparison.Ordinal)) + { + // Not ours, or a checksum collision on the name. Starting the file over is the only safe + // move: appending our records to somebody else's file would corrupt both. + return false; + } + + var offset = (long)dataStart; + while (offset < bytes.Length) + { + if (bytes.Length - offset < FrameOverheadBytes) + { + break; + } + + var declared = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan((int)offset)); + var frame = declared + (long)FrameOverheadBytes; + if (declared > MaxRecordBytes || offset + frame > bytes.Length) + { + break; + } + + offsets.Add(offset); + offset += frame; + } + + usable = offset; + return true; + } + + /// + /// Options for creating a file owner-only. The mode is applied by the open that makes the + /// inode — never a chmod afterwards, which would leave a window in which a world-readable file + /// already held someone's chat. Windows takes no equivalent action, for the reason + /// SecretsStore.RestrictToOwner gives at length: the file inherits a user-only ACL from + /// %APPDATA%, and hand-rolling a DACL would be untestable code with a lockout for a + /// failure mode. + /// + private static FileStreamOptions CreateOwnerOnly(FileShare share) + { + var options = new FileStreamOptions + { + Mode = FileMode.Create, + Access = FileAccess.Write, + Share = share, + }; + + if (!OperatingSystem.IsWindows()) + { + options.UnixCreateMode = OwnerOnlyFileMode; + } + + return options; + } + + private static void RestrictToOwner(string path, ILogger logger) + { + if (OperatingSystem.IsWindows()) + { + return; + } + + try + { + if (File.GetUnixFileMode(path) != OwnerOnlyFileMode) + { + File.SetUnixFileMode(path, OwnerOnlyFileMode); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + logger.LogDebug(ex, "Narrowing the restore log file {Path} to 0600 failed", path); + } + } + } +} diff --git a/src/SharpMUTerm.Core/Text/RestoreLogOptions.cs b/src/SharpMUTerm.Core/Text/RestoreLogOptions.cs new file mode 100644 index 00000000..154f0bf0 --- /dev/null +++ b/src/SharpMUTerm.Core/Text/RestoreLogOptions.cs @@ -0,0 +1,62 @@ +namespace SharpMUTerm.Core.Text; + +/// +/// How much of each pane's recent output survives a restart, and where it is kept. Part of +/// AppConfiguration, so these are user-editable settings. +/// +/// The restore log is the one piece of session text in this client that is kept on purpose and +/// is not a transcript. The other two are neither: 's spill +/// is an ephemeral cache purged on the next launch, and PlainTextLogSink/HtmlLogSink are +/// opt-in transcripts a user keeps and reads. This exists so that restarting the client does not +/// empty every pane — see . +/// +/// +public sealed class RestoreLogOptions +{ + /// + /// The default per-window bound, in lines. See for the + /// argument; it is named here so a test can assert the shipped value rather than a literal. + /// + public const int DefaultMaxLinesPerWindow = 500; + + /// The directory name, under the configuration directory, holding the per-window logs. + public const string DirectoryName = "restore"; + + /// + /// Whether panes come back with their previous session's content. On by default — a client whose + /// channel windows are empty after a restart is the state this feature exists to remove. Switching + /// it off here stops all of it; a single character opts out on F9 instead + /// (). + /// + public bool Enabled { get; set; } = true; + + /// + /// How many lines of each window are kept, and so how many come back. + /// + /// Lines, never bytes. The design notes record BeipMU hanging for ever trying to make room in + /// a byte-bounded ring when one block was larger than the whole ring + /// (docs/superpowers/specs/2026-07-30-scrollback-design.md). A line bound cannot reach that + /// state: dropping one line always makes room for one line, whatever either of them weighs. + /// + /// + /// Why 500. It is deliberately far under the live 10,000-line window + /// () because the two answer different questions. The + /// live buffer is "how far can I scroll back in this session"; this is "what was on screen when I + /// quit", and 500 lines is ten to sixteen paneful screens — more context than anyone re-reads after + /// a restart, and enough that a quiet channel window still has its last conversation in it. It is + /// also what makes the cost bearable at both ends: at roughly 120 bytes a line encoded it is 60 KB + /// per window, so a dozen windows sit under a megabyte beside config.json, and the startup + /// read that runs before the first frame stays in the low milliseconds. Raising it costs startup + /// latency on every launch to buy scrollback depth the live buffer already provides for the session + /// you are actually in. + /// + /// + public int MaxLinesPerWindow { get; set; } = DefaultMaxLinesPerWindow; + + /// + /// Where the logs live. Null (the default) means a restore directory inside the configuration + /// directory, beside config.json and secrets.json — a data location, not a cache one, + /// because unlike the spill these files are meant to survive. + /// + public string? Directory { get; set; } +} diff --git a/src/SharpMUTerm.Tui/Glyphs.cs b/src/SharpMUTerm.Tui/Glyphs.cs index 2c99aa8f..7b8a9013 100644 --- a/src/SharpMUTerm.Tui/Glyphs.cs +++ b/src/SharpMUTerm.Tui/Glyphs.cs @@ -26,6 +26,12 @@ internal static class Glyphs public const string Log = "\uf0f6"; // nf-fa-file_text_o — log indicator (was the fisheye) public const string World = "\uf0ac"; // nf-fa-globe — world/server accent (paired with the spine) + /// + /// The bar marking where the previous session's content ends and this one begins — see + /// . A clock rewinding, because that is exactly what the rows above it are. + /// + public const string Restored = "\uf1da"; // nf-fa-history + /// /// 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/Program.cs b/src/SharpMUTerm.Tui/Program.cs index fc91284a..5d1bf829 100644 --- a/src/SharpMUTerm.Tui/Program.cs +++ b/src/SharpMUTerm.Tui/Program.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Text; using SharpMUTerm.Graphics; using SharpConsoleUI.Drivers; @@ -91,12 +92,27 @@ private static int Main(string[] args) loadLogger.LogWarning("{Notice}", notice); } + // The panes' own memory between runs, beside the configuration whose LastSession says where each + // pane goes. Resolved here for the same reason logRoot is: only this code knows it is the live + // client, so only it hands over a directory to write in. It is created unconditionally even when + // the feature is off — constructing one touches no disk, and the ⌃P purge has to be able to + // clear what an earlier, enabled run left behind. + using var restore = new RestoreLog( + string.IsNullOrWhiteSpace(config.RestoreLog.Directory) + ? RestoreLog.DefaultRoot(ConfigurationStore.DefaultPath) + : config.RestoreLog.Directory!, + config.RestoreLog) + { + Logger = diagnostics.For("SharpMUTerm.RestoreLog"), + }; + var liveApp = new SharpMUTermApp( config, capabilities, diagnostics: diagnostics, save: saved => ConfigurationStore.Save(ConfigurationStore.DefaultPath, saved), - logRoot: logRoot); + logRoot: logRoot, + restore: restore); var exitCode = liveApp.Run(startup); // blocks on the SharpConsoleUI main loop until exit // Persist the workspace so the next launch resumes where this one left off. diff --git a/src/SharpMUTerm.Tui/RestoreBarRenderer.cs b/src/SharpMUTerm.Tui/RestoreBarRenderer.cs new file mode 100644 index 00000000..eaf73f71 --- /dev/null +++ b/src/SharpMUTerm.Tui/RestoreBarRenderer.cs @@ -0,0 +1,50 @@ +using System.Globalization; + +namespace SharpMUTerm.Tui; + +/// +/// Renders the bar that closes a pane's restored content: the one row telling a reader that everything +/// above it is from a previous run of the client and everything below it is live. +/// +/// Why a bar and not dimmed text. BeipMU prints "Content restored" and that is the right shape — +/// mark the boundary, not the content. Restoring is only worth doing if what comes back is the +/// game's own text with the game's own colours in it, so recolouring the restored lines to prove they +/// are restored would destroy the thing being restored. One row, drawn like the freeze bar (which +/// divides the same pane for the same kind of reason), costs a line and lies about nothing. The +/// restored lines also keep their original timestamps, so the timestamp column is a second, free answer +/// to "how old is this" for anyone who has it on. +/// +/// +/// It sits below the restored lines rather than above them, because that is where the reader's +/// eye already is: a pane bottom-anchors, so the boundary is what you land on, and "the previous +/// session ended here" is a statement about the row above it. +/// +/// Pure, so the markup is unit-testable without a terminal. +/// +internal static class RestoreBarRenderer +{ + /// How long the trailing rule is. The same 48 cells the freeze bar draws, for the same reason. + private const int RuleCells = 48; + + /// The label, kept as a constant so a test can look for the exact words a reader will see. + internal const string Label = "RESTORED"; + + /// + /// The bar for lines last written at , on an + /// already-resolved #rrggbb accent. + /// + public static string Bar(int lines, DateTimeOffset when, string accentHex) + { + ArgumentException.ThrowIfNullOrEmpty(accentHex); + + // The count and the time are both here because they answer different questions, and a reader + // asks both: "is this all of it?" (a pane that stops at the bound looks truncated, and it is — + // saying how many says so) and "how stale is this?" (an hour ago is context; a fortnight ago is + // a different game). Local time in the same 24-hour form the timestamp gutter uses. + var count = lines == 1 ? "1 line" : $"{lines} lines"; + var stamp = when.ToLocalTime().ToString("d MMM HH:mm", CultureInfo.CurrentCulture); + var rule = new string('─', RuleCells); + return $"[{accentHex}]{Glyphs.Restored} {Label}[/] " + + $"[dim]{MarkupText.Escape($"{count} from the previous session · {stamp}")} {rule}[/]"; + } +} diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index 6b9c7efc..8b7edaca 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -319,6 +319,13 @@ private sealed class SizeReport /// private readonly string? _logRoot; + /// + /// Where each pane's recent content is kept so a restart refills it, or null for an app that owns + /// no restore log — which is the default, and is what every test and every snapshot gets. Third of + /// the same family as and , for the same reason. + /// + private readonly RestoreLog? _restore; + /// The pane the live mouse drag is hovering, and the edge it would split — null when idle. private string? _dragTargetPaneId; private Edge? _dragEdge; @@ -372,6 +379,15 @@ private sealed class SizeReport /// and a fixture naming an absolute path is still a test reaching outside itself. /// /// + /// + /// The this app reads its panes' previous content out of and writes their + /// new content into, or null for an app that owns no restore log — which is the default, and is + /// what every test and every snapshot gets. The third member of the / + /// family, handed in for exactly their reason: this one writes a file + /// per window under the user's configuration directory, and an app that is not the live entry point + /// has no business creating those. It is also what keeps the demo scene honest — a + /// --demo-config snapshot would otherwise restore your panes into the demo's. + /// public SharpMUTermApp( AppConfiguration config, TerminalCapabilities capabilities, @@ -379,11 +395,13 @@ public SharpMUTermApp( TimeProvider? time = null, ClientDiagnostics? diagnostics = null, Action? save = null, - string? logRoot = null) + string? logRoot = null, + RestoreLog? restore = null) { _config = config; _save = save; _logRoot = string.IsNullOrWhiteSpace(logRoot) ? null : logRoot; + _restore = restore; _capabilities = capabilities; _time = time ?? TimeProvider.System; _diagnostics = diagnostics ?? ClientDiagnostics.InMemory(); @@ -563,6 +581,11 @@ public SharpMUTermApp( // desktop cells, which is exactly what a drag between panes needs. _system.ConsoleDriver.MouseEvent += OnDriverMouseEvent; RegisterGlobalShortcuts(); + + // Before the window is shown, so the first frame already has the panes' previous content in + // them — a pane that filled itself in a frame or two later would read as a glitch, and this is + // also the last moment at which no live line has arrived to be restored *above*. + RestorePreviousSession(); _system.AddWindow(_window); } @@ -1204,6 +1227,101 @@ private static Workspace ResumeOrNew(AppConfiguration config) return new Workspace(MainWindowId, "Main"); } + /// + /// Refills every pane with what it was showing when the client last ran, and marks where that ends. + /// + /// It is the content half of . That rebuilds the workspace — which + /// windows exist, which pane each sits in, which was focused — from + /// ; this refills those windows from the restore log, + /// keyed by the same window ids. Both halves are needed and neither implies the other: a client that + /// resumed its layout and showed every pane empty is what the whole feature is about. + /// + /// + /// The two halves are joined loosely, on purpose. A window in the log that the saved + /// workspace no longer holds — a spawn window whose pane was closed — is still buffered, because + /// is keyed by window and not by anything the workspace has to agree with; if + /// that channel speaks again its pane reopens with its history already in it + /// (). A window in the saved workspace with no log simply starts empty. + /// Neither is an error and neither throws, which is the property that matters most here: this runs + /// before the first frame, and a client that will not start because of a cache of old chat lines + /// would be a worse client than one that starts with an empty pane. + /// + /// + /// Nothing restored is written back. The log is fed from and + /// — the two places a world's output reaches a pane — and this + /// replay goes straight to , so a restart cannot echo its own history + /// into the log and double every pane. + /// + /// + private void RestorePreviousSession() + { + if (_restore is null || !_config.RestoreLog.Enabled) + { + return; + } + + var restoredWindows = 0; + var restoredLines = 0; + foreach (var window in _restore.Read()) + { + // A character who opted out gets their content dropped rather than merely un-drawn: an + // opt-out that left the last session's text lying in the config directory would be + // answering a different question from the one it was asked. + if (!RestoreLogWanted(_workspace.FindWindow(window.WindowId)?.SessionKey)) + { + _restore.Forget(window.WindowId); + continue; + } + + if (window.Lines.Count == 0) + { + continue; + } + + foreach (var line in window.Lines) + { + AppendWindowLine(window.WindowId, _formatter.ToMarkup(line.Line), line.Stamp); + } + + // The boundary marker carries no stamp: it did not arrive, it was drawn, and a timestamp + // gutter beside it would be claiming a time for a row the game never sent. + AppendWindowLine( + window.WindowId, + RestoreBarRenderer.Bar(window.Lines.Count, window.LastWritten, FrozenAccentHex())); + + restoredWindows++; + restoredLines += window.Lines.Count; + } + + if (restoredWindows == 0) + { + return; + } + + _diagnostics.Logger.LogInformation( + "Restored {Lines} line(s) into {Windows} pane(s) from the previous session", + restoredLines, + restoredWindows); + } + + /// + /// Whether a window owned by takes part in the restore log. An + /// unowned window — the main window before any session adopts it, the web view — is allowed: no + /// character has said otherwise, and the app-wide switch is checked by the caller. + /// + private bool RestoreLogWanted(string? sessionKey) => + sessionKey is null || CharacterFor(sessionKey)?.Logging.RestoreLog != false; + + /// + /// The configured character behind a world.character session key, or null when the key names + /// no character this configuration still holds (an anonymous connection, or one whose character has + /// since been renamed or deleted). + /// + private CharacterDefinition? CharacterFor(string sessionKey) => _config.Worlds + .SelectMany(world => world.Characters.Select(character => (Key: $"{world.Name}.{character.Name}", character))) + .FirstOrDefault(pair => string.Equals(pair.Key, sessionKey, StringComparison.Ordinal)) + .character; + /// /// Sets the demo's focused/connected character from the resumed config (snapshot chrome). It asks /// rather than reaching for the first world's first character, @@ -1503,7 +1621,7 @@ private void BindSession(WorldSession session, string? windowId = null) AppendWindowLine(windowId, _formatter.ToMarkup(line)); } - session.LinePrinted += (_, line) => OnUi(() => OnLine(windowId, line)); + session.LinePrinted += (_, line) => OnUi(() => OnLine(session, windowId, line)); session.PromptChanged += (_, _) => OnUi(UpdateStatus); // Every connect reports the automation it is running — see ReportAutomation. Hung off the state @@ -1780,6 +1898,46 @@ private void AppendWindowLine(string windowId, string markup, string? stamp = nu } } + /// + /// Records one of a world's lines in the restore log, so the pane it landed in comes back holding + /// it next launch. + /// + /// It is called from and and from nowhere else, and + /// deliberately not from even though that is the seam every + /// line goes through. Two reasons, and both are bugs avoided rather than tidiness. That seam also + /// carries the client's own chrome — /graphics, /triggers, the restore bar itself — + /// which is not session content and has no business surviving into a later run. And it carries the + /// restore replay, so logging there would have each launch re-record its own history and + /// double every pane against the bound. + /// + /// + /// The is what is stored rather than the markup it was rendered to, because + /// markup is the theme's answer and not the world's: a line logged as markup would come back frozen + /// in whatever colours were configured the day it arrived, and would have lost the palette indices, + /// the rule colour and each span's interaction on the way (see ). + /// + /// + private void RecordForRestore(WorldSession session, string windowId, string title, StyledLine line, string? stamp) + { + if (_restore is null || !_config.RestoreLog.Enabled) + { + return; + } + + // Read off the session rather than the window's recorded owner: it is the character whose line + // this is, it cannot be stale, and a session with no character (a host typed on the command + // line) has nobody to have opted out. + if (session.Character?.Logging.RestoreLog == false) + { + return; + } + + _restore.Append(windowId, title, line, stamp); + } + + /// A window's title, or its id when the workspace does not (yet) know it. + private string WindowTitle(string windowId) => _workspace.FindWindow(windowId)?.Title ?? windowId; + /// /// Puts lines of a window's buffer, starting at , /// into one output control. @@ -1895,9 +2053,11 @@ private void RepaintPanes() /// badge is the only thing that could tell them. /// /// - private void OnLine(string windowId, StyledLine line) + private void OnLine(WorldSession session, string windowId, StyledLine line) { - AppendWindowLine(windowId, _formatter.ToMarkup(line), StampNow()); + var stamp = StampNow(); + AppendWindowLine(windowId, _formatter.ToMarkup(line), stamp); + RecordForRestore(session, windowId, WindowTitle(windowId), line, stamp); if (!_workspace.IsCaughtUp(windowId)) { @@ -1930,7 +2090,13 @@ private void OnSpawnLine(WorldSession session, string target, string pattern, St // the *main window's* title, which is a different session's name as soon as more than one is open. window.OwnerLabel ??= SessionTitle(session); PaneContentFor(window.Id, window.Title); // ensure the live control exists before buffering - AppendWindowLine(window.Id, _formatter.ToMarkup(line), StampNow()); + + // The restore log is fed here as well as in OnLine, and that is the crux of the whole feature: + // a spawn window's content never reaches WorldSession.Scrollback, so a restore built on session + // scrollback would bring the main windows back and leave every channel pane empty. + var stamp = StampNow(); + AppendWindowLine(window.Id, _formatter.ToMarkup(line), stamp); + RecordForRestore(session, window.Id, window.Title, line, stamp); // A first-seen spawn adds a tab to its pane, so rebuild; otherwise just refresh badges. if (existed) @@ -4375,6 +4541,9 @@ internal bool DispatchCommand(string id) case "term:messages": _messageLog.Toggle(); return true; + case "term:restore-purge": + PurgeRestoreLog(); + return true; case "term:history": ToggleHistorySearch(); return true; @@ -4562,6 +4731,34 @@ private async Task DisconnectAsync(WorldSession session) /// *** Logging to … over a client that opened no file is the defect this gate exists to stop. /// /// + /// + /// ⌃P ▸ Purge the restore log: deletes every window's saved content, now. + /// + /// It is "forget what is on disk", not "switch the feature off" — a pane that goes on printing + /// starts a fresh file on its next line, and the standing preference is F9's per-character + /// restore row. Nor does it blank the panes: what has already been drawn is on your screen + /// and clearing it would be answering a question nobody asked, while leaving it would be leaving a + /// file. The count comes back so the entry reports what it actually did rather than claiming + /// success at nothing, which is the difference between a purge and a placebo. + /// + /// + private void PurgeRestoreLog() + { + if (_restore is null) + { + RefuseCommand("nothing to purge — this client owns no restore log"); + return; + } + + var removed = _restore.Purge(); + Notice( + removed == 0 + ? "restore log purged — there was nothing saved" + : $"restore log purged — {removed} window{(removed == 1 ? string.Empty : "s")} forgotten", + MessageSeverity.Info, + "⌃P"); + } + private void StartLogging() { if (_logRoot is null) @@ -5106,7 +5303,18 @@ private ImageControl WebImageControlFor(WebImageBlock block) return control; } - /// The content control for a window, created (with link routing) on first use. + /// + /// The content control for a window, created (with link routing) on first use — and filled from + /// that window's line buffer if one is already there. + /// + /// The fill is what makes a control and its buffer the same thing at every moment rather than only + /// after the next re-feed. It matters because a buffer can now outlive every control for its window + /// and predate the first one: the restore log fills for a window the saved + /// workspace no longer holds, and the control only comes into being later, when a capture reopens + /// that spawn window. Without this the pane would open holding one line — the one that reopened it + /// — with its restored history sitting invisibly in the buffer behind it. + /// + /// private MarkupControl PaneContentFor(string id, string title) { if (_panes.TryGetValue(id, out var existing)) @@ -5117,6 +5325,12 @@ private MarkupControl PaneContentFor(string id, string title) var control = new MarkupControl(new List()); control.LinkClicked += (_, e) => OnLinkClicked(id, e.Url); _panes[id] = control; + + if (_lines.TryGetValue(id, out var buffer) && buffer.Count > 0) + { + FeedRange(control, buffer, 0, buffer.Count); + } + return control; } diff --git a/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs b/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs index d7fc5c42..68cca399 100644 --- a/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs +++ b/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs @@ -99,6 +99,16 @@ internal static class WorldsScreenRenderer internal const int LogDirectoryField = 6; + /// + /// — whether this character's panes come back holding their + /// previous session's content. Appended after the two log rows rather than inserted among them, + /// because it is the third answer to "what does this client write down about me" and reads in that + /// order: what a transcript is, where it goes, and then whether the panes remember. It is a field + /// and not a toggle for the same reason is — a character row draws no + /// checkbox, so a value on the row's toggle would have no affordance at all. + /// + internal const int RestoreLogField = 7; + /// /// The trigger-set row's field ordinals. A set's name leads, as every list row's does — and here it /// is more than a label: a character opts into automation by name, so renaming a set is a @@ -282,6 +292,33 @@ internal static string HeaderLine( private static readonly string[] StartupChoices = { StartupOn, StartupOff }; + /// + /// The row for . It says restore and not "restore + /// log", because the two rows above it already carry the word log and mean the transcript by + /// it; a third row wearing it would read as a third transcript setting, which is exactly what this + /// is not. The note beside it is what carries the meaning. + /// + internal const string RestoreLabel = "restore"; + + /// + /// What the restore row says it does. Short because the CHARACTER panel is 48 cells and + /// CharField has already spent fourteen of them on the indent and the label column — the + /// same budget and are written to, and the one + /// a longer sentence here quietly overran until ScreenReadOnlyTests caught it. + /// + internal const string RestoreNote = "panes refill after a restart"; + + /// The default: the panes come back. + internal const string RestoreOn = "on"; + + /// + /// Opted out. It reads off to match at start and the world's keepalive row — + /// the screen's other "this feature is simply not on" values — and is drawn in the same muted ink. + /// + internal const string RestoreOff = "off"; + + private static readonly string[] RestoreChoices = { RestoreOn, RestoreOff }; + /// /// The screen's four navigable panes, in ⇥ order: the WORLDS list (no checkbox on a world's row, /// but ⏎ opens the world's own fields — the ones the detail column lists), the selected world's @@ -367,7 +404,12 @@ internal static ScreenModel Model( v => c.ConnectAtStartup = string.Equals(v, StartupOn, StringComparison.OrdinalIgnoreCase), StartupChoices), ScreenField.Enumeration("log", () => c.Logging.Format, v => c.Logging.Format = v), - ScreenField.Optional("log folder", () => c.Logging.Directory, v => c.Logging.Directory = v))) + ScreenField.Optional("log folder", () => c.Logging.Directory, v => c.Logging.Directory = v), + ScreenField.Choice( + RestoreLabel, + () => c.Logging.RestoreLog ? RestoreOn : RestoreOff, + v => c.Logging.RestoreLog = string.Equals(v, RestoreOn, StringComparison.OrdinalIgnoreCase), + RestoreChoices))) .Concat(CharacterButtons(world, selectedCharacter)) .ToArray(); @@ -879,9 +921,10 @@ private static string Checkbox(string label, bool value, string hint) /// /// The character form — labels left-aligned with their values, one field per row. The editable ones /// are the character row's own fields (name, password, connect line, on-connect, at start, - /// then the two log values) and are the only seven drawn in a field well. The other two deliberately - /// are not: login is derived from the fields above it and there is nothing there to - /// set, and the session line is a report of what the connection is doing rather than a setting. + /// the two log values, then restore) and are the only eight drawn in a field well. The other + /// two deliberately are not: login is derived from the fields above it and there is + /// nothing there to set, and the session line is a report of what the connection is doing rather + /// than a setting. /// /// A row here draws no checkbox at all (), so every settable thing /// about a character is a field on this form and there is nothing Space can reach. That used not to @@ -977,12 +1020,22 @@ internal static List FormColumn( selectedCharacter, LogDirectoryField, pane: CharactersPane)), + CharField(RestoreLabel, Field( + character.Logging.RestoreLog + ? $"[{Value}]{RestoreOn}[/]" + : $"[{Label}]{RestoreOff}[/]", + cursor, + selectedCharacter, + RestoreLogField, + pane: CharactersPane)), + CharField(string.Empty, $"[{Label}]{RestoreNote}[/]"), }; - // The log format is the second-to-last row of this block, so its list is drawn upward — the one - // case on these screens where a downward list would have nowhere to go. The block's height is a - // grid row in WorldsScreenView, which is the other half of why the list overlays rather than - // pushes: growing this list would resize the pane and shove the whole screen about. + // The log format and `restore` both sit near the foot of this block, so their lists are drawn + // upward — the one place on these screens where a downward list would have nowhere to go, and + // Choices picks the direction from the room it has rather than being told. The block's height + // is a grid row in WorldsScreenView, which is the other half of why a list overlays rather than + // pushes: growing one would resize the pane and shove the whole screen about. return ScreenChrome.Choices(form, cursor.Edit, CharDetailColumnWidth); } diff --git a/tests/SharpMUTerm.Core.Tests/Text/RestoreLogTests.cs b/tests/SharpMUTerm.Core.Tests/Text/RestoreLogTests.cs new file mode 100644 index 00000000..0bb94f88 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Text/RestoreLogTests.cs @@ -0,0 +1,495 @@ +using SharpMUTerm.Core.Text; + +namespace SharpMUTerm.Core.Tests.Text; + +/// +/// The kept, per-window record of pane content that refills the panes after a restart: that a window's +/// lines come back under the window's own id, that the bound is enforced in lines and enforced by +/// compaction rather than by rewriting, that a session continues the previous session's log rather +/// than replacing it — and, above all, that nothing a damaged file can be made to look like +/// throws, because the only caller is startup. +/// +public class RestoreLogTests +{ + private const string Main = "main"; + private const string Chat = "spawn:Chat"; + + private static StyledLine Line(string text) => StyledLine.FromText(text, TextStyle.Default); + + /// A throwaway directory that is removed however the test ends. + private sealed class TempRoot : IDisposable + { + public TempRoot() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"smuterm-restore-{Guid.NewGuid():N}"); + } + + /// The log root itself — deliberately not created, so "nothing on disk yet" is the start state. + public string Path { get; } + + public void Dispose() + { + try + { + if (System.IO.Directory.Exists(Path)) + { + System.IO.Directory.Delete(Path, recursive: true); + } + } + catch (Exception) + { + // Nothing a test should fail over. + } + } + } + + private static RestoreLog Open(TempRoot root, int maxLines = 500) => + new(root.Path, new RestoreLogOptions { MaxLinesPerWindow = maxLines }); + + [Test] + public async Task DefaultRoot_SitsBesideTheConfigurationAndItsSecrets() + { + // The config directory, not a cache one: unlike the spill these files are meant to survive. + var config = Path.Combine("/somewhere", "SharpMUTerm", "config.json"); + await Assert.That(RestoreLog.DefaultRoot(config)) + .IsEqualTo(Path.Combine("/somewhere", "SharpMUTerm", RestoreLogOptions.DirectoryName)); + } + + [Test] + public async Task NothingIsWrittenUntilALineIsAppended() + { + using var root = new TempRoot(); + using var log = Open(root); + + await Assert.That(Directory.Exists(root.Path)).IsFalse(); + await Assert.That(log.Read()).IsEmpty(); + } + + /// + /// The crux the whole design turns on: a spawn window's content never reaches + /// WorldSession.Scrollback, so the log has to be keyed by window. Two windows, two files, and + /// each window's lines come back under its own id and nobody else's. + /// + [Test] + public async Task EachWindowsLinesComeBackUnderItsOwnId() + { + using var root = new TempRoot(); + using (var log = Open(root)) + { + log.Append(Main, "Corvid", Line("The Grand Plaza"), "09:24"); + log.Append(Chat, "Chat", Line("[Chat] Rivane: crypt run?"), "09:25"); + log.Append(Main, "Corvid", Line("A town guard stands watch."), "09:26"); + } + + using var reopened = Open(root); + var restored = reopened.Read(); + + await Assert.That(restored.Count).IsEqualTo(2); + + var main = restored.Single(w => w.WindowId == Main); + await Assert.That(main.Title).IsEqualTo("Corvid"); + await Assert.That(main.Lost).IsEqualTo(0L); + await Assert.That(main.Lines.Select(l => l.Line.Text).ToList()) + .IsEquivalentTo(new[] { "The Grand Plaza", "A town guard stands watch." }); + await Assert.That(main.Lines[0].Stamp).IsEqualTo("09:24"); + + var chat = restored.Single(w => w.WindowId == Chat); + await Assert.That(chat.Lines.Count).IsEqualTo(1); + await Assert.That(chat.Lines[0].Line.Text).IsEqualTo("[Chat] Rivane: crypt run?"); + await Assert.That(chat.Lines[0].Stamp).IsEqualTo("09:25"); + } + + /// + /// A window id is not a file name — spawn:Chat holds a colon, and on Windows that is not + /// merely ugly but illegal. It round-trips because the id is stored in the header, not derived from + /// the name, and the name carries a checksum so two channels cannot land on one file. + /// + [Test] + public async Task WindowIdsThatAreNotFileNamesStillRoundTrip() + { + using var root = new TempRoot(); + var awkward = new[] { "spawn:Chat", "spawn:Chat/OOC", "spawn:Chat OOC", "spawn:..", "spawn:*" }; + + using (var log = Open(root)) + { + foreach (var id in awkward) + { + log.Append(id, id, Line($"in {id}"), null); + } + } + + using var reopened = Open(root); + var restored = reopened.Read(); + + await Assert.That(restored.Select(w => w.WindowId).OrderBy(i => i, StringComparer.Ordinal).ToList()) + .IsEquivalentTo(awkward.OrderBy(i => i, StringComparer.Ordinal).ToList()); + foreach (var window in restored) + { + await Assert.That(window.Lines.Single().Line.Text).IsEqualTo($"in {window.WindowId}"); + } + } + + /// + /// The style, the palette index, the rule colour and a span's interaction all survive, because the + /// payload is and not markup. A restored channel line that came back + /// monochrome would be a restored line nobody wanted. + /// + [Test] + public async Task ALinesStyleRuleAndInteractionSurvive() + { + using var root = new TempRoot(); + var line = new StyledLine( + new[] + { + new StyledSpan("Exits: ", TextStyle.Default), + new StyledSpan( + "north", + new TextStyle(TerminalColor.FromIndex(11), TerminalColor.FromRgb(1, 2, 3), TextAttributes.Underline), + SpanInteraction.Command("north")), + }, + TerminalColor.FromRgb(0x00, 0xf5, 0xb7)); + + using (var log = Open(root)) + { + log.Append(Main, "Corvid", line, "09:24"); + } + + using var reopened = Open(root); + var restored = reopened.Read().Single().Lines.Single().Line; + + await Assert.That(restored.RuleColor).IsEqualTo(line.RuleColor); + await Assert.That(restored.Spans.Count).IsEqualTo(2); + await Assert.That(restored.Spans[1].Style.Foreground).IsEqualTo(TerminalColor.FromIndex(11)); + await Assert.That(restored.Spans[1].Style.Background).IsEqualTo(TerminalColor.FromRgb(1, 2, 3)); + await Assert.That(restored.Spans[1].Style.Attributes).IsEqualTo(TextAttributes.Underline); + await Assert.That(restored.Spans[1].Interaction!.Kind).IsEqualTo(InteractionKind.SendCommand); + await Assert.That(restored.Spans[1].Interaction!.Target).IsEqualTo("north"); + } + + /// + /// The bound is in lines, and it holds. It is checked well past the compaction threshold so the + /// answer is not "the file simply never grew": the file is rewritten repeatedly during this and the + /// newest lines are still the ones that come back, in order. + /// + [Test] + public async Task OnlyTheNewestLinesUpToTheBoundComeBack() + { + using var root = new TempRoot(); + const int bound = 20; + + using (var log = Open(root, bound)) + { + for (var i = 1; i <= 500; i++) + { + log.Append(Main, "Corvid", Line($"line {i}"), null); + } + } + + using var reopened = Open(root, bound); + var lines = reopened.Read().Single().Lines; + + await Assert.That(lines.Count).IsEqualTo(bound); + await Assert.That(lines[0].Line.Text).IsEqualTo("line 481"); + await Assert.That(lines[^1].Line.Text).IsEqualTo("line 500"); + } + + /// + /// And the file is bounded too, not merely the read. Compaction is what stops an append-only log + /// growing for ever, and it is a byte-range copy through an atomic rename — so after enough appends + /// the file on disk is small, and no .tmp is left lying about. + /// + [Test] + public async Task CompactionKeepsTheFileBoundedAndLeavesNoTemporary() + { + using var root = new TempRoot(); + const int bound = 10; + + using (var log = Open(root, bound)) + { + for (var i = 0; i < 40; i++) + { + log.Append(Main, "Corvid", Line("a line of quite ordinary length, as they go"), "09:24"); + } + + var files = Directory.GetFiles(root.Path); + await Assert.That(files.Length).IsEqualTo(1); + await Assert.That(Path.GetExtension(files[0])).IsEqualTo(".log"); + + // Never more than twice the bound's worth of records, whatever the append count. + await Assert.That(new FileInfo(files[0]).Length).IsLessThan(2 * bound * 200); + } + + using var reopened = Open(root, bound); + await Assert.That(reopened.Read().Single().Lines.Count).IsEqualTo(bound); + } + + /// + /// A second run continues the log rather than starting it over. Restarting the client twice in a + /// minute must not leave a pane holding two lines — which is what truncating on open would do. + /// + [Test] + public async Task ASecondSessionContinuesTheLogInsteadOfReplacingIt() + { + using var root = new TempRoot(); + + using (var first = Open(root)) + { + first.Append(Main, "Corvid", Line("before the restart"), "09:24"); + } + + using (var second = Open(root)) + { + second.Append(Main, "Corvid", Line("after the restart"), "09:25"); + } + + using var third = Open(root); + await Assert.That(third.Read().Single().Lines.Select(l => l.Line.Text).ToList()) + .IsEquivalentTo(new[] { "before the restart", "after the restart" }); + } + + /// + /// The corruption case, and the one this feature cannot afford to get wrong. A file cut off + /// mid-record is exactly what a crash leaves behind. Reading it must give back every whole line + /// before the cut, count the rest as lost, and — the part that matters — not throw into startup. + /// + [Test] + public async Task ATruncatedFileRestoresWhatIsReadableAndReportsTheRest() + { + using var root = new TempRoot(); + using (var log = Open(root)) + { + for (var i = 1; i <= 6; i++) + { + log.Append(Main, "Corvid", Line($"line {i}"), "09:24"); + } + } + + // Cut the file mid-record: not on a frame boundary, and not merely a byte or two short. + var path = Directory.GetFiles(root.Path).Single(); + var whole = new FileInfo(path).Length; + using (var file = new FileStream(path, FileMode.Open, FileAccess.Write)) + { + file.SetLength(whole - 11); + } + + using var reopened = Open(root); + var restored = reopened.Read().Single(); + + await Assert.That(restored.WindowId).IsEqualTo(Main); + await Assert.That(restored.Lines.Count).IsEqualTo(5); + await Assert.That(restored.Lines[^1].Line.Text).IsEqualTo("line 5"); + await Assert.That(restored.Lost).IsGreaterThan(0L); + + // And the next session appends onto the trimmed file rather than behind the rubbish. + reopened.Append(Main, "Corvid", Line("line 7"), "09:25"); + using var again = Open(root); + await Assert.That(again.Read().Single().Lines.Select(l => l.Line.Text).ToList()) + .IsEquivalentTo(new[] { "line 1", "line 2", "line 3", "line 4", "line 5", "line 7" }); + } + + /// + /// One damaged record in the middle costs one line, not every line after it. The framing is + /// a length prefix and the checksum is separate, so a reader can step over the bad one — which is + /// the difference between losing a line to a bad sector and losing the session. + /// + [Test] + public async Task OneDamagedRecordCostsOneLine() + { + using var root = new TempRoot(); + using (var log = Open(root)) + { + for (var i = 1; i <= 5; i++) + { + log.Append(Main, "Corvid", Line($"line {i}"), null); + } + } + + // Flip a byte inside the third record's payload. Its length prefix is untouched, so the frames + // after it are still findable; only its own checksum fails. + var path = Directory.GetFiles(root.Path).Single(); + var bytes = File.ReadAllBytes(path); + var third = Array.IndexOf(bytes, (byte)'3'); + await Assert.That(third).IsGreaterThan(0); // the fixture found what it meant to damage + bytes[third] = (byte)'X'; + File.WriteAllBytes(path, bytes); + + using var reopened = Open(root); + var restored = reopened.Read().Single(); + + await Assert.That(restored.Lines.Select(l => l.Line.Text).ToList()) + .IsEquivalentTo(new[] { "line 1", "line 2", "line 4", "line 5" }); + await Assert.That(restored.Lost).IsGreaterThan(0L); + } + + /// + /// Rubbish that is not a log of ours at all — a stray file, a truncated header, or one whose version + /// this build does not know — is ignored rather than reinterpreted, and never throws. + /// + [Test] + [Arguments("")] + [Arguments("not a restore log at all")] + [Arguments("SMTRSTOR")] + public async Task RubbishInTheDirectoryIsIgnored(string content) + { + using var root = new TempRoot(); + Directory.CreateDirectory(root.Path); + File.WriteAllText(Path.Combine(root.Path, "stray.log"), content); + + using var log = Open(root); + await Assert.That(log.Read()).IsEmpty(); + } + + [Test] + public async Task AHeaderFromAFutureVersionIsLeftAloneRatherThanReinterpreted() + { + using var root = new TempRoot(); + using (var log = Open(root)) + { + log.Append(Main, "Corvid", Line("line 1"), null); + } + + var path = Directory.GetFiles(root.Path).Single(); + var bytes = File.ReadAllBytes(path); + bytes[8] = 99; // the format version, immediately after the 8-byte magic + File.WriteAllBytes(path, bytes); + + using var reopened = Open(root); + await Assert.That(reopened.Read()).IsEmpty(); + } + + /// + /// A log whose root cannot be created at all — because a file is sitting where the + /// directory should be — degrades to writing nothing. It is the shape every permission failure has, + /// and it must not reach the caller. + /// + [Test] + public async Task AnUnusableRootDegradesToWritingNothing() + { + var path = Path.Combine(Path.GetTempPath(), $"smuterm-restore-blocked-{Guid.NewGuid():N}"); + File.WriteAllText(path, "not a directory"); + try + { + using var log = new RestoreLog(path); + log.Append(Main, "Corvid", Line("line 1"), null); + await Assert.That(log.Read()).IsEmpty(); + } + finally + { + File.Delete(path); + } + } + + [Test] + public async Task PurgeRemovesEveryWindowsLogAndTheLogGoesOnWorking() + { + using var root = new TempRoot(); + using var log = Open(root); + log.Append(Main, "Corvid", Line("line 1"), null); + log.Append(Chat, "Chat", Line("line 2"), null); + + await Assert.That(log.Purge()).IsEqualTo(2); + await Assert.That(Directory.Exists(root.Path)).IsFalse(); + await Assert.That(log.Read()).IsEmpty(); + + // Purging is "forget what is on disk", not "switch the feature off". + log.Append(Main, "Corvid", Line("line 3"), null); + await Assert.That(log.Read().Single().Lines.Single().Line.Text).IsEqualTo("line 3"); + } + + /// What a character's opt-out does to content already stored: one window's file, gone. + [Test] + public async Task ForgetDropsOneWindowAndLeavesTheOthers() + { + using var root = new TempRoot(); + using var log = Open(root); + log.Append(Main, "Corvid", Line("line 1"), null); + log.Append(Chat, "Chat", Line("line 2"), null); + + await Assert.That(log.Forget(Main)).IsTrue(); + await Assert.That(log.Forget(Main)).IsFalse(); // already gone; not an error + + var remaining = log.Read(); + await Assert.That(remaining.Count).IsEqualTo(1); + await Assert.That(remaining[0].WindowId).IsEqualTo(Chat); + } + + [Test] + public async Task FilesAreOwnerOnlyLikeTheSecretsBesideThem() + { + if (OperatingSystem.IsWindows()) + { + return; // no Unix mode to assert; the ACL is inherited (see SecretsStore). + } + + using var root = new TempRoot(); + using var log = Open(root); + log.Append(Main, "Corvid", Line("what was said in a private game"), null); + + var expected = UnixFileMode.UserRead | UnixFileMode.UserWrite; + await Assert.That(File.GetUnixFileMode(Directory.GetFiles(root.Path).Single())).IsEqualTo(expected); + await Assert.That(File.GetUnixFileMode(root.Path)) + .IsEqualTo(expected | UnixFileMode.UserExecute); + } + + /// + /// A line nobody could have meant — megabytes with no newline — is stored as a blank row rather than + /// dropped or written whole. The pane comes back the right length and the file stays bounded. + /// + [Test] + public async Task APathologicallyLongLineIsKeptAsABlankRow() + { + using var root = new TempRoot(); + using (var log = Open(root)) + { + log.Append(Main, "Corvid", Line("before"), null); + log.Append(Main, "Corvid", Line(new string('x', 4 * 1024 * 1024)), null); + log.Append(Main, "Corvid", Line("after"), null); + } + + using var reopened = Open(root); + var lines = reopened.Read().Single().Lines; + + await Assert.That(lines.Count).IsEqualTo(3); + await Assert.That(lines[1].Line.Text).IsEqualTo(string.Empty); + await Assert.That(lines[2].Line.Text).IsEqualTo("after"); + await Assert.That(new FileInfo(Directory.GetFiles(root.Path).Single()).Length).IsLessThan(1024 * 1024); + } + + /// + /// The disk half of the startup cost, measured rather than asserted about. Six windows at the + /// shipped bound — 3,000 lines — read and decoded in ~2.8 ms on this machine, which is the + /// I/O-and-decode share of the ~18 ms the whole restore costs + /// (RestoreLogEndToEndTests.RestoringAFullLogCosts… has the breakdown). The ceiling is two + /// orders above that because it runs cold in the suite; it still catches a regression into a scan + /// per line or a re-read per window. + /// + [Test] + public async Task AFullLogIsReadFastEnoughToRunBeforeTheFirstFrame() + { + using var root = new TempRoot(); + var windows = new[] { "main", "spawn:Chat", "spawn:OOC", "spawn:Tells", "spawn:Guild", "spawn:Events" }; + + using (var log = Open(root)) + { + foreach (var window in windows) + { + for (var i = 0; i < RestoreLogOptions.DefaultMaxLinesPerWindow; i++) + { + log.Append(window, window, Line($"[{window}] Rivane says something of a fairly typical length here"), "09:24"); + } + } + } + + using var reopened = Open(root); + reopened.Read(); // warm the file cache, so this measures the work and not the first disk touch + + var clock = System.Diagnostics.Stopwatch.StartNew(); + var restored = reopened.Read(); + clock.Stop(); + + await Assert.That(restored.Count).IsEqualTo(windows.Length); + await Assert.That(restored.Sum(w => w.Lines.Count)) + .IsEqualTo(windows.Length * RestoreLogOptions.DefaultMaxLinesPerWindow); + await Assert.That(clock.ElapsedMilliseconds).IsLessThan(250); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/RestoreBarRendererTests.cs b/tests/SharpMUTerm.Tui.Tests/RestoreBarRendererTests.cs new file mode 100644 index 00000000..cd9a9cfa --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/RestoreBarRendererTests.cs @@ -0,0 +1,68 @@ +namespace SharpMUTerm.Tui.Tests; + +/// +/// The one row that tells a reader where the previous session's content ends. Its whole job is to be +/// unambiguous about a boundary, so what it says — and what it must not accidentally say — is worth +/// pinning apart from the end-to-end restore. +/// +public class RestoreBarRendererTests +{ + private const string Accent = "#c678dd"; + + private static readonly DateTimeOffset Whenever = new(2026, 7, 30, 15, 27, 0, TimeSpan.Zero); + + [Test] + public async Task ItNamesTheBoundaryTheCountAndWhen() + { + var bar = RestoreBarRenderer.Bar(128, Whenever, Accent); + + await Assert.That(bar).Contains(RestoreBarRenderer.Label); + await Assert.That(bar).Contains("128 lines from the previous session"); + await Assert.That(bar).Contains(Whenever.ToLocalTime().ToString("d MMM HH:mm")); + await Assert.That(bar).Contains(Accent); + } + + /// + /// A pane holding exactly one restored line must not read "1 lines". Small, and the sort of thing + /// that survives for years in a string nobody tests because it only appears in one state. + /// + [Test] + public async Task OneLineIsSingular() + { + await Assert.That(RestoreBarRenderer.Bar(1, Whenever, Accent)).Contains("1 line from"); + await Assert.That(RestoreBarRenderer.Bar(1, Whenever, Accent)).DoesNotContain("1 lines"); + } + + /// + /// The bar ends in a rule, the way the freeze bar does, so it reads as a divider rather than as a + /// line of output. The two mark the same pane for related reasons and should not look like different + /// kinds of thing. + /// + [Test] + public async Task ItEndsInARuleLikeTheFreezeBar() + { + await Assert.That(RestoreBarRenderer.Bar(3, Whenever, Accent)).EndsWith("────[/]"); + await Assert.That(FreezeBarRenderer.Bar(Accent)).EndsWith("────[/]"); + } + + /// + /// Nothing in it comes from a world, so nothing in it can carry markup — but the date is + /// culture-formatted and a locale's month abbreviation is not this code's to vouch for, so it goes + /// through the same escape every other composed row does. Asserted by feeding a culture whose date + /// format is unusual and checking the row still has exactly the tags it was built with. + /// + [Test] + public async Task ItCarriesOnlyTheTagsItBuilt() + { + var bar = RestoreBarRenderer.Bar(9, Whenever, Accent); + + // Two opening tags (the accent and the dim) and two closes, and no stray bracket anywhere else. + await Assert.That(bar.Count(c => c == '[')).IsEqualTo(4); + await Assert.That(bar.Count(c => c == ']')).IsEqualTo(4); + } + + [Test] + public async Task ItRefusesAnEmptyAccent() => + await Assert.That(() => RestoreBarRenderer.Bar(1, Whenever, string.Empty)) + .Throws(); +} diff --git a/tests/SharpMUTerm.Tui.Tests/RestoreLogEndToEndTests.cs b/tests/SharpMUTerm.Tui.Tests/RestoreLogEndToEndTests.cs new file mode 100644 index 00000000..6598a525 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/RestoreLogEndToEndTests.cs @@ -0,0 +1,505 @@ +using System.Diagnostics; +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Automation; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Session; +using SharpMUTerm.Core.Text; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// Panes coming back with their previous session's content, driven the whole distance: server bytes into +/// a connected world, through its triggers into a main window and a spawn window, out to the restore log +/// on disk, and back into a second app's panes — which is the only arrangement in which the +/// claim "restarting the client does not empty every pane" can actually be made. +/// +/// The premise the design rests on is pinned first +/// (): a spawn window's lines are not +/// in , so a restore built on session scrollback would bring the +/// main windows back and leave every channel pane empty. That is the whole reason the log is keyed by +/// window id and written from the shell rather than from the session. +/// +/// +/// +/// Serialised with the other end-to-end suites: constructing the app and rendering a frame both touch +/// the process-global console streams. +/// +[NotInParallel] +public class RestoreLogEndToEndTests +{ + private const int Width = 160; + private const int Height = 40; + private const string MainWindow = "main"; + private const string ChatWindow = "spawn:Chat"; + + /// The startup ceiling asserted below — see that test for the measured figure it guards. + private const int Ceiling = 400; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + /// A throwaway restore-log root, removed however the test ends. + private sealed class TempRoot : IDisposable + { + public TempRoot() => + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"smuterm-restore-e2e-{Guid.NewGuid():N}"); + + public string Path { get; } + + public IReadOnlyList Files => + System.IO.Directory.Exists(Path) ? System.IO.Directory.GetFiles(Path) : Array.Empty(); + + public void Dispose() + { + try + { + if (System.IO.Directory.Exists(Path)) + { + System.IO.Directory.Delete(Path, recursive: true); + } + } + catch (Exception) + { + // Nothing a test should fail over. + } + } + } + + // ---- The premise ------------------------------------------------------------------------ + + /// + /// Why this feature could not have been built on session scrollback. A capture rule that gags + /// its line — the ordinary way a channel is given a window of its own and kept out of the main + /// stream — puts that line in the spawn pane and in no session's transcript at all. Restoring + /// would therefore refill the main window and hand the reader + /// an empty Chat pane, which is precisely the failure the whole thing exists to remove. + /// + [Test] + public async Task ASpawnWindowsContentNeverReachesTheSessionsScrollback() + { + using var root = new TempRoot(); + await using var run = await Session(root); + + run.Receive("[Chat] Rivane: anyone up for the crypt run?\n"); + run.Receive("The Grand Plaza.\n"); + + // The pane the reader is looking at has it... + await Assert.That(string.Join("\n", run.App.PaneLines(ChatWindow))).Contains("crypt run"); + + // ...and the session's own scrollback — the only per-session history there is — does not. + var scrollback = string.Join("\n", run.Session.Scrollback.Snapshot().Select(l => l.Text)); + await Assert.That(scrollback).DoesNotContain("crypt run"); + await Assert.That(scrollback).Contains("The Grand Plaza"); // the gag is the rule's, not a bug + } + + // ---- The headline ----------------------------------------------------------------------- + + /// + /// The user's ask, end to end: quit, start again, and every pane is holding what it held — the main + /// window and the spawn window, which is the half a session-scrollback design would have + /// dropped. + /// + [Test] + public async Task EveryPaneComesBackIncludingASpawnWindow() + { + using var root = new TempRoot(); + var config = Configuration(); + + await using (var first = await Session(root, config)) + { + first.Receive("[Chat] Rivane: anyone up for the crypt run?\n"); + first.Receive("The Grand Plaza.\n"); + config.LastSession = first.App.CaptureSession(); + } + + await using var second = await Restarted(root, config); + + await Assert.That(string.Join("\n", second.App.PaneLines(MainWindow))).Contains("The Grand Plaza"); + await Assert.That(string.Join("\n", second.App.PaneLines(ChatWindow))).Contains("crypt run"); + } + + /// + /// Restored content is marked, so a reader can tell where the previous session ended — one bar, at + /// the boundary, below the restored lines and above whatever arrives next. The lines are + /// left alone deliberately: restoring is only worth doing if the game's own colours come back with + /// the text, so the boundary is what is drawn, not the content. + /// + [Test] + public async Task TheRestoreBarMarksWhereThePreviousSessionEndedAndNothingElse() + { + using var root = new TempRoot(); + var config = Configuration(); + + await using (var first = await Session(root, config)) + { + first.Receive("The Grand Plaza.\n"); + config.LastSession = first.App.CaptureSession(); + } + + await using var second = await Restarted(root, config); + second.Receive("A town guard stands watch.\n"); + var lines = second.App.PaneLines(MainWindow).ToList(); + + // Exactly one bar, once, however much arrives after it. + var bar = lines.FindIndex(l => l.Contains(RestoreBarRenderer.Label, StringComparison.Ordinal)); + await Assert.That(bar).IsGreaterThanOrEqualTo(0); + await Assert.That(lines.Count(l => l.Contains(RestoreBarRenderer.Label, StringComparison.Ordinal))) + .IsEqualTo(1); + + // Everything above it is the previous session and everything below it is this one — including + // the connect banner, which is this session announcing itself and belongs on its own side. + await Assert.That(lines[bar - 1]).Contains("The Grand Plaza"); + await Assert.That(string.Join("\n", lines[..bar])).DoesNotContain("A town guard stands watch."); + await Assert.That(string.Join("\n", lines[(bar + 1)..])).Contains("A town guard stands watch."); + await Assert.That(lines[^1]).Contains("A town guard stands watch."); + } + + // ---- The gate --------------------------------------------------------------------------- + + /// + /// An app handed no restore log writes none — the same gate save and logRoot have, for + /// the same reason. Every test and every snapshot in this repository gets that default, so a + /// --demo-config frame cannot spill the demo's panes into the developer's configuration + /// directory, nor restore the developer's panes into the demo's. + /// + [Test] + public async Task AnAppWithNoRestoreLogWritesNothing() + { + using var root = new TempRoot(); + await using var run = await Session(root, restore: null); + + run.Receive("[Chat] Rivane: anyone up for the crypt run?\n"); + run.Receive("The Grand Plaza.\n"); + + await Assert.That(string.Join("\n", run.App.PaneLines(ChatWindow))).Contains("crypt run"); + await Assert.That(Directory.Exists(root.Path)).IsFalse(); + } + + // ---- Where the log and the saved workspace disagree ------------------------------------- + + /// + /// A window in the log that LastSession no longer holds — a spawn pane that was closed before + /// quitting — must not throw, must not be lost, and must not be resurrected as a window nobody asked + /// for. It is buffered instead, so the moment that channel speaks again its pane opens already + /// holding its history. + /// + [Test] + public async Task AWindowTheSavedWorkspaceForgotComesBackWhenItsChannelSpeaksAgain() + { + using var root = new TempRoot(); + var config = Configuration(); + + await using (var first = await Session(root, config)) + { + first.Receive("[Chat] Rivane: anyone up for the crypt run?\n"); + + // Quit with the Chat pane closed: the log knows the window, the saved workspace does not. + config.LastSession = first.App.CaptureSession(); + config.LastSession.Windows.RemoveAll(w => w.Id == ChatWindow); + } + + await using var second = await Restarted(root, config); + await Assert.That(second.App.WindowIds()).DoesNotContain(ChatWindow); + + // One new line reopens the pane — and the history is already in it, above the new line. + second.Receive("[Chat] Bob: aye, meet me at the gate\n"); + var lines = second.App.PaneLines(ChatWindow).ToList(); + + await Assert.That(string.Join("\n", lines)).Contains("crypt run"); + await Assert.That(lines[^1]).Contains("meet me at the gate"); + } + + /// The other direction: a saved window with no log simply starts empty, and says nothing. + [Test] + public async Task ASavedWindowWithNothingLoggedStartsEmpty() + { + using var root = new TempRoot(); + var config = Configuration(); + + await using (var first = await Session(root, config)) + { + first.Receive("[Chat] Rivane: anyone up for the crypt run?\n"); + config.LastSession = first.App.CaptureSession(); + } + + // Drop only the main window's file, keeping Chat's. + foreach (var file in root.Files.Where(f => Path.GetFileName(f).StartsWith("main-", StringComparison.Ordinal))) + { + File.Delete(file); + } + + await using var second = await Restarted(root, config); + + await Assert.That(second.App.PaneLines(MainWindow) + .Any(l => l.Contains(RestoreBarRenderer.Label, StringComparison.Ordinal))).IsFalse(); + await Assert.That(string.Join("\n", second.App.PaneLines(ChatWindow))).Contains("crypt run"); + } + + // ---- Damage ----------------------------------------------------------------------------- + + /// + /// A log a crash left half-written must not take the client down with it. Every file is + /// truncated mid-record — the shape a kill leaves — and the client still starts, still restores the + /// whole lines that were there, and still marks them. + /// + [Test] + public async Task ATruncatedLogRestoresWhatIsReadableAndStillStarts() + { + using var root = new TempRoot(); + var config = Configuration(); + + await using (var first = await Session(root, config)) + { + for (var i = 1; i <= 8; i++) + { + first.Receive($"line {i} of the plaza\n"); + } + + config.LastSession = first.App.CaptureSession(); + } + + foreach (var file in root.Files) + { + using var stream = new FileStream(file, FileMode.Open, FileAccess.Write); + stream.SetLength(stream.Length - 9); // mid-record, not on a frame boundary + } + + await using var second = await Restarted(root, config); + var lines = second.App.PaneLines(MainWindow).ToList(); + var bar = lines.FindIndex(l => l.Contains(RestoreBarRenderer.Label, StringComparison.Ordinal)); + + // The whole lines before the cut came back, and the boundary still closes them off. + await Assert.That(bar).IsGreaterThanOrEqualTo(0); + await Assert.That(string.Join("\n", lines[..bar])).Contains("line 7 of the plaza"); + } + + /// And a directory of outright rubbish is startup-safe too, restoring nothing. + [Test] + public async Task RubbishInTheRestoreDirectoryIsStartupSafe() + { + using var root = new TempRoot(); + Directory.CreateDirectory(root.Path); + File.WriteAllText(Path.Combine(root.Path, "main-deadbeef.log"), "not a restore log at all"); + + await using var run = await Session(root); + + await Assert.That(run.App.PaneLines(MainWindow) + .Any(l => l.Contains(RestoreBarRenderer.Label, StringComparison.Ordinal))).IsFalse(); + } + + // ---- Opting out and purging ------------------------------------------------------------- + + /// + /// A character who turned restore off on F9 has nothing written — and anything an earlier, + /// opted-in run left behind is deleted on the next launch rather than merely left undrawn. An + /// opt-out that kept the file would be answering a different question from the one it was asked. + /// + [Test] + public async Task ACharacterThatOptedOutIsNeitherLoggedNorRestored() + { + using var root = new TempRoot(); + var config = Configuration(); + + await using (var first = await Session(root, config)) + { + first.Receive("The Grand Plaza.\n"); + config.LastSession = first.App.CaptureSession(); + } + + await Assert.That(root.Files.Count).IsGreaterThan(0); // there is something to opt out of + config.Worlds[0].Characters[0].Logging.RestoreLog = false; + + await using (var second = await Restarted(root, config)) + { + await Assert.That(second.App.PaneLines(MainWindow) + .Any(l => l.Contains(RestoreBarRenderer.Label, StringComparison.Ordinal))).IsFalse(); + + second.Receive("A town guard stands watch.\n"); + } + + // Neither the old content nor the new: the main window's file is gone and was not rewritten. + await Assert.That(root.Files.Any(f => Path.GetFileName(f).StartsWith("main-", StringComparison.Ordinal))) + .IsFalse(); + } + + /// + /// ⌃P ▸ Purge the restore log deletes what is on disk, now, and says how much it removed. It + /// does not blank the panes — what is already drawn is on the reader's screen — and it does not + /// switch the feature off, so the next line starts a fresh file. + /// + [Test] + public async Task ThePurgeCommandRemovesEverySavedPaneAndKeepsWorking() + { + using var root = new TempRoot(); + await using var run = await Session(root); + run.Receive("[Chat] Rivane: anyone up for the crypt run?\n"); + run.Receive("The Grand Plaza.\n"); + await Assert.That(root.Files.Count).IsEqualTo(2); + + await Assert.That(run.App.DispatchCommand("term:restore-purge")).IsTrue(); + + await Assert.That(root.Files).IsEmpty(); + await Assert.That(run.App.StatusMarkup).Contains("restore log purged"); + await Assert.That(string.Join("\n", run.App.PaneLines(MainWindow))).Contains("The Grand Plaza"); + + run.Receive("A town guard stands watch.\n"); + await Assert.That(root.Files.Count).IsEqualTo(1); + } + + /// The entry is offered whether or not anything is saved, so it is findable when wanted. + [Test] + public async Task ThePurgeEntryIsInTheCommandSurface() + { + using var root = new TempRoot(); + await using var run = await Session(root); + + await Assert.That(run.App.BuildCatalog().Select(c => c.Id)).Contains("term:restore-purge"); + } + + // ---- What it costs at startup ----------------------------------------------------------- + + /// + /// The startup cost of a full log, measured where it is actually paid: constructing the app, which + /// is what runs before the first frame. Six windows at the shipped 500-line bound — 3,000 lines, a + /// busier workspace than most people keep. + /// + /// What it costs, measured on this machine. Constructing the app with that log takes ~23 ms + /// against ~5.5 ms for the same app with no log, so restoring 3,000 lines costs about 18 ms, + /// or 6 µs a line. Reading and decoding them off the disk is only ~2.8 ms of that + /// (RestoreLogTests.AFullLogIsReadFastEnough…); the rest is rendering each line to markup and + /// buffering it, which is the same work the line cost when it first arrived. An ordinary two-window + /// workspace is nearer 6 ms. That is well under a frame and does not need deferring off the startup + /// path — but it is linear in the bound, which is half the argument for keeping the bound at 500. + /// + /// + /// The ceiling is far above the measured figure on purpose: this runs cold in the suite, and the + /// claim being defended is "not visible at startup", not a benchmark. It is still tight enough that + /// a regression into per-line I/O, an fsync per line, or a whole-buffer re-parse per restored line + /// would trip it by an order of magnitude. + /// + /// + [Test] + public async Task RestoringAFullLogCostsLittleEnoughToRunBeforeTheFirstFrame() + { + using var root = new TempRoot(); + var config = Configuration(); + var windows = new[] { MainWindow, ChatWindow, "spawn:OOC", "spawn:Tells", "spawn:Guild", "spawn:Events" }; + + using (var seed = new RestoreLog(root.Path, config.RestoreLog)) + { + foreach (var window in windows) + { + for (var i = 0; i < RestoreLogOptions.DefaultMaxLinesPerWindow; i++) + { + seed.Append( + window, + window, + StyledLine.FromText($"[{window}] Rivane says something of a typical length", TextStyle.Default), + "09:24"); + } + } + } + + using var log = new RestoreLog(root.Path, config.RestoreLog); + Console.SetIn(TextReader.Null); + + var clock = Stopwatch.StartNew(); + await using var app = new SharpMUTermApp( + config, Headless, new HeadlessConsoleDriver(Width, Height), restore: log); + clock.Stop(); + + await Assert.That(app.PaneLines(MainWindow).Count) + .IsEqualTo(RestoreLogOptions.DefaultMaxLinesPerWindow + 1); // + the boundary bar + await Assert.That(clock.ElapsedMilliseconds).IsLessThan(Ceiling); + } + + // ---- Harness ---------------------------------------------------------------------------- + + /// One connected world, its transport, and the app it prints into. + private sealed record Run(SharpMUTermApp App, RecordingTelnetSession Telnet, WorldSession Session, RestoreLog? Log) + : IAsyncDisposable + { + /// Delivers server text and renders, the way a live read loop and a frame do. + public void Receive(string text) + { + Telnet.Receive(text); + App.RenderNextFrame(); + } + + public async ValueTask DisposeAsync() + { + await App.DisposeAsync(); + Log?.Dispose(); + } + } + + /// + /// A configuration with one world, one character, and a capture rule that gags [Chat] into a + /// spawn window — the ordinary channel arrangement, and the one in which a spawn pane's content + /// exists nowhere but the pane. + /// + private static AppConfiguration Configuration() + { + var config = new AppConfiguration(); + config.TriggerSets.Add(new TriggerSet + { + Name = "chat", + Triggers = + { + new Trigger + { + Name = "chat", + Pattern = @"^\[Chat\]", + Actions = new TriggerActions { SpawnTarget = "Chat", Gag = true }, + }, + }, + }); + + config.Worlds.Add(new WorldDefinition + { + Name = "Aetherfall", + Host = "aetherfall.example.org", + Port = 4201, + Characters = + { + new CharacterDefinition { Name = "Corvid", Logging = new LoggingSettings(), TriggerSets = { "chat" } }, + }, + }); + + return config; + } + + /// Opens an app over with its one character connected. + private static Task Session(TempRoot root, AppConfiguration? config = null) => + Start(config ?? Configuration(), new RestoreLog(root.Path)); + + /// The same, but with an explicit (possibly null) log — for the "owns no restore log" gate. + private static Task Session(TempRoot root, RestoreLog? restore) => Start(Configuration(), restore); + + /// + /// A second launch over the same root and the same configuration: what the user does when they quit + /// and start the client again. + /// + private static Task Restarted(TempRoot root, AppConfiguration config) => + Start(config, new RestoreLog(root.Path)); + + private static async Task Start(AppConfiguration config, RestoreLog? restore) + { + Console.SetIn(TextReader.Null); + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(Width, Height), restore: restore); + var telnet = new RecordingTelnetSession(); + app.TelnetFactory = _ => telnet; + + if (!app.DispatchCommand(CommandIds.Character("Aetherfall.Corvid"))) + { + throw new InvalidOperationException("the app would not switch to Aetherfall.Corvid"); + } + + var session = app.FindSession("Aetherfall.Corvid")!; + await session.ConnectAsync(); + app.RenderNextFrame(); + return new Run(app, telnet, session, restore); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs b/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs index aad15477..c4d569a0 100644 --- a/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs @@ -463,18 +463,24 @@ public async Task Options_CommandLineHeightsRefuseValuesTheBarCannotHonour() } /// - /// Logging is two more fields of the character's own row — the format (whose None is "off", - /// so one control covers one stored value) and the folder. This replaces F9's screen, whose rows - /// edited whichever character happened to be active. + /// Logging is three more fields of the character's own row — the format (whose None is "off", + /// so one control covers one stored value), the folder, and restore. This replaces F9's + /// screen, whose rows edited whichever character happened to be active. /// - /// The count is seven because the password, the connect line and at start are fields of this + /// The count is eight because the password, the connect line and at start are fields of this /// row too, drawn between the name and the log values. The ordinals are addressed by name, which is /// what let them be inserted in drawn order rather than appended past the log values — see /// . /// + /// + /// restore is a separate switch from the format and not a fourth value of it, because the two + /// are different things: a transcript is a file the user keeps and reads, and the restore log is a + /// bounded tail nothing but the client's own startup ever opens. Turning one off has never implied + /// anything about the other. + /// /// [Test] - public async Task Worlds_TheCharacterRowCarriesItsLogFormatAndFolder() + public async Task Worlds_TheCharacterRowCarriesItsLogFormatFolderAndRestoreSwitch() { var worlds = Worlds(); var character = worlds[0].Characters[0]; @@ -482,7 +488,7 @@ public async Task Worlds_TheCharacterRowCarriesItsLogFormatAndFolder() var model = WorldsScreenRenderer.Model(worlds, Sets(), 0, 0); var row = model.RowAt(WorldsScreenRenderer.CharactersPane, 0); - await Assert.That(row.FieldCount).IsEqualTo(7); + await Assert.That(row.FieldCount).IsEqualTo(8); await Assert.That(row.FieldAt(WorldsScreenRenderer.CharacterNameField)!.Value.Get()).IsEqualTo("Kaz"); await Assert.That(row.FieldAt(WorldsScreenRenderer.LogFormatField)!.Value.Get()).IsEqualTo("Html"); await Assert.That(row.FieldAt(WorldsScreenRenderer.LogDirectoryField)!.Value.Get()).IsEqualTo("/logs/kaz"); @@ -490,6 +496,16 @@ public async Task Worlds_TheCharacterRowCarriesItsLogFormatAndFolder() // The format cycles like every other enum field, and None is how logging is turned off. await Assert.That(row.FieldAt(WorldsScreenRenderer.LogFormatField)!.Value.Choices) .IsEquivalentTo(new[] { "None", "Plain", "Html", "Both" }); + + // On by default, a closed two-value choice like `at start`, and it writes through to the + // character it was drawn for. + var restore = row.FieldAt(WorldsScreenRenderer.RestoreLogField)!.Value; + await Assert.That(restore.Get()).IsEqualTo(WorldsScreenRenderer.RestoreOn); + await Assert.That(restore.Choices) + .IsEquivalentTo(new[] { WorldsScreenRenderer.RestoreOn, WorldsScreenRenderer.RestoreOff }); + + restore.Set(WorldsScreenRenderer.RestoreOff); + await Assert.That(character.Logging.RestoreLog).IsFalse(); } /// From 5d8d9c5f8bf6b9e90d1d7c1f5142e07ad0d7ff52 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 30 Jul 2026 16:36:58 -0500 Subject: [PATCH 10/11] Tint the tab a line arrived in, and cap the count it already carried The count was already there. Workspace.NoteActivity has always kept it, ActivateWindow and SetScrolledBack have always cleared it, the rail has always drawn it, and TabTitles has always appended "(n)" - OutputScrollbackTests even reads it off the tab strip. Two things were missing. The tab had no colour. TabTitles said tab titles were plain text and so a colour could not ride on the label; TabControl.Rendering parses every label with MarkupParser.Parse and measures it - the paint, the desired width, and the click hit test alike - with MarkupParser.StripLength. A tag therefore costs no cells and moves no hit test, which is what makes a tint affordable on a surface where a cell may not be spent. The name and the count are drawn in the app accent; the focus marker stays outside it, because focus and activity are independent and a tab can have both. The two cues are different channels: focus is said entirely in backgrounds from the theme's chrome family, activity in a foreground no plane is painted in. That claim also meant a title was never escaped, so a window called [Chat] - or a web view titled from the page it loaded - had that eaten as a tag by the parser and by the hit test. Titles are escaped now. And the count was uncapped where the sidebar's was capped, so one number had two spellings: 99+ in the rail beside (4127) on the tab. Cap, field width and colour move into UnreadBadge, which both surfaces format through; the rail's hardcoded #00f5b7 becomes the palette's accent. The strip is deliberately not given the rail's reserved-width treatment. A rail row's width sizes the sidebar's column and the pane area is what is left over, so a badge appearing there narrows every pane; a tab strip is a TabControl arranged Fill+Stretch inside the pane it already fills, and the framework pads the header out to the pane's edge, so a longer label only shifts the tabs beside it. Reserving three cells per tab would cost width on every strip for ever to prevent a reflow this layout cannot produce. ActivityMovesNoPaneRectangle proves it across 0 -> 1 -> 9 -> 10 -> 99 -> 100 -> 150 at four terminal sizes, and ActivityTellsNoServerANewSize proves it on the bytes. Writing that test found the real one. The status row's scrollback segment counts lines below the viewport - a number that grows unbidden from the wire - and wrote it out raw. Its own comment warned that a wordier phrasing had once wrapped the row and that a status line growing a row takes one off the workspace; the number was never guarded. At 80 columns the 99 -> 100 step took the row from 80 cells to 81, it wrapped, every pane lost a row, and per-pane NAWS re-announced the new size to every connected server. It is now in a reserved field capped the same way. Clearing is untouched and stays untouched: both surfaces render from WorkspaceWindow.Unread, so picking the tab of a scrolled-back window clears neither badge and returning to the live tail clears both. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GpL7Ht6sLBsSEtVNsYcXMM --- CLAUDE.md | 32 ++ src/SharpMUTerm.Tui/RailRenderer.cs | 25 +- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 11 +- src/SharpMUTerm.Tui/TabTitles.cs | 38 +- src/SharpMUTerm.Tui/UnreadBadge.cs | 39 ++ .../TabActivityIndicatorTests.cs | 536 ++++++++++++++++++ tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs | 71 ++- 7 files changed, 727 insertions(+), 25 deletions(-) create mode 100644 src/SharpMUTerm.Tui/UnreadBadge.cs create mode 100644 tests/SharpMUTerm.Tui.Tests/TabActivityIndicatorTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 0f5ebc39..29bebaa4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -380,6 +380,38 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] `FocusIndicationTests.TypingDoesNotMoveAnyPaneRectangle` are the pins — the latter is the typing counterpart of `MovingFocusDoesNotMoveAnyPaneRectangle`, and it has to read the rail's *widest* row, because in the demo scene the Chat row's unread badge coincidentally masked the pen's two cells. +- **A tab title is *markup*, and that is what makes the new-activity tint free.** `TabControl.Rendering` + parses each label with `MarkupParser.Parse`, and every width it is measured by — the header paint, the + strip's desired width, and the click hit test that picks the tab and its `×` — is + `MarkupParser.StripLength`. So a `[#rrggbb]` tag on a tab costs **no cells** and moves no hit test. + `TabTitles` claimed the opposite for as long as it existed, and the claim had two costs: the unread + count went out untinted, and a window title was never escaped — a window called `[Chat]`, or a web view + titled from the page it loaded, had that eaten as a tag by the parser *and* by the hit test. Titles are + `MarkupText.Escape`d now. The tint covers the name and the count only; the `▌` stays outside it, because + focus and activity are independent facts and a marker that changed colour on an incoming line would be + reporting the wrong one. **The two cues are different channels on purpose** — focus is said entirely in + *backgrounds* from the theme's chrome family, activity in a *foreground* no plane is painted in. +- **One unread count, one spelling: `UnreadBadge`.** The sidebar and the tab strip are two views of + `WorkspaceWindow.Unread`, and they had two formatters — the rail capped at `99+`, the tab printed the + raw integer, so a busy channel read `99+` in one place and `(4127)` in the other. Cap, field width and + tint colour (the app accent, previously a `#00f5b7` literal in `RailRenderer`) now live in one type. + **The tab strip deliberately does *not* get the rail's reserved-width treatment**, and the asymmetry is + real rather than an oversight: a rail row's width sizes the sidebar's grid column and the pane area is + what is left over, so there a badge that appears narrows every pane; a tab strip is a `TabControl` + arranged `Fill`+`Stretch` inside the pane it already fills, and the framework pads the header row out to + the pane's own edge, so a longer label only shifts the tabs beside it. Reserving three cells per tab + would cost width on every strip for ever to prevent a reflow this layout cannot produce. The cap is kept + for the *other* two reasons: the two surfaces must agree, and an unbounded count must not push a narrow + pane's later tabs off the end. `TabActivityIndicatorTests.ActivityMovesNoPaneRectangle` is the pin. +- **The status row's scrollback distance was the same bug one field over, and it was live.** That segment + counts lines below the viewport — a number that grows *unbidden from the wire* while a reader sits in + their scrollback — and it was written out raw. Its own comment already warned that a wordier phrasing + had once wrapped the row and that "a status line that grows a row takes one off the workspace"; the + number was never guarded. At 80 columns the 99 → 100 step took the row from 80 cells to 81, it wrapped, + every pane lost a row, and per-pane NAWS re-announced the new size to every connected server. It is now + in a reserved field capped by `UnreadBadge`, the same as the rail's. **The lesson generalises: any cell + on the chrome whose width is a function of something arriving from the wire needs a reserved field, and + "it is only one digit" is exactly the size of every instance of this bug so far.** - **A rail window row is `what` then `where`, and neither column may wear the other's word.** A character's own session window reads `main` (`RailWindowLabel`); its title names the *connection*, which the row's own ancestors — its character, under its world — have already said. The hosting-pane column diff --git a/src/SharpMUTerm.Tui/RailRenderer.cs b/src/SharpMUTerm.Tui/RailRenderer.cs index 1f138992..cf3ff745 100644 --- a/src/SharpMUTerm.Tui/RailRenderer.cs +++ b/src/SharpMUTerm.Tui/RailRenderer.cs @@ -1,4 +1,3 @@ -using System.Globalization; using SharpConsoleUI.Parsing; using SharpMUTerm.Core.Text; using SharpMUTerm.Core.Workspaces; @@ -161,11 +160,12 @@ private static string Character(RailRow row) /// private const int UnsentFieldWidth = 2; - /// Cells kept for an unread count, blank when there is none. See . - private const int UnreadFieldWidth = 3; - - /// The largest count drawn in full; above it the badge reads 99+ and stops growing. - private const int MaxUnread = 99; + /// + /// Cells kept for an unread count, blank when there is none. See . It is + /// because the badge's own cap is what makes the field finite — + /// the two numbers are one fact and may not be written down twice. + /// + private const int UnreadFieldWidth = UnreadBadge.FieldWidth; /// The pen, or the same width in blanks. See . private static string Unsent(bool unsent) => @@ -176,16 +176,17 @@ private static string Unsent(bool unsent) => /// the pen is () and with more urgency: unread arrives unbidden from /// the wire, so an unreserved badge resizes the sidebar — and every connected server's idea of its /// terminal — on a line of output the reader did not ask for, and again at 9 → 10 when it takes a - /// second digit. The cap is what makes the field finite: a count past reads - /// 99+, which is the same three cells and the same information at a glance. + /// second digit. The cap is what makes the field finite: a count past + /// reads 99+, which is the same three cells and the same information at a glance. + /// + /// Both the wording and the colour come from , which the pane tab labels draw + /// from as well, so the sidebar and the strip cannot come to say different things about one count. + /// /// private static string UnreadField(int unread) => unread <= 0 ? new string(' ', UnreadFieldWidth) - : $"[#00f5b7]{Badge(unread).PadLeft(UnreadFieldWidth)}[/]"; - - private static string Badge(int unread) => - unread > MaxUnread ? $"{MaxUnread}+" : unread.ToString(CultureInfo.InvariantCulture); + : $"[{UnreadBadge.Tint}]{UnreadBadge.Format(unread).PadLeft(UnreadFieldWidth)}[/]"; /// /// A window row: what the window is, then — when there is anything to say — where it is. diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index 3b09e05b..351e3393 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -3060,8 +3060,17 @@ private string ScrollbackStatus() // three-cell gap, so a wordier phrasing wrapped the status line onto a second row at 120 columns // — and a status line that grows a row takes one off the workspace (SyncInputHeights counts the // chrome), which is a pane getting shorter because you scrolled it. + // + // The *number* is the same hazard and was not guarded: it counts lines below the viewport, which + // grows unbidden from the wire while a reader sits in their scrollback, so the segment widened at + // 9 → 10 and again at 99 → 100 with nobody touching a key. At 80 columns that second step took the + // row from 80 cells to 81, it wrapped, and every pane lost a row — which per-pane NAWS then + // re-announced to every connected server, reflowing the game's output. It is now written into a + // reserved field capped the way the sidebar's unread badge is (RailRenderer.UnreadField), so the + // segment is the same width whatever it says. var below = Math.Max(1, panel.TotalContentHeight - panel.ViewportHeight - panel.VerticalScrollOffset); - return $"[#e5c07b]{Glyphs.Scrollback} scrollback[/] [dim]{below} · ⌃End live[/]"; + var distance = UnreadBadge.Format(below).PadLeft(UnreadBadge.FieldWidth); + return $"[#e5c07b]{Glyphs.Scrollback} scrollback[/] [dim]{distance} · ⌃End live[/]"; } /// diff --git a/src/SharpMUTerm.Tui/TabTitles.cs b/src/SharpMUTerm.Tui/TabTitles.cs index e224c9bb..dadf8511 100644 --- a/src/SharpMUTerm.Tui/TabTitles.cs +++ b/src/SharpMUTerm.Tui/TabTitles.cs @@ -9,12 +9,21 @@ namespace SharpMUTerm.Tui; /// the one currently focused. Pure so it can be unit-tested without a terminal. /// /// -/// SharpConsoleUI renders tab titles as plain text, so the design's per-character accent -/// colour dot can't ride on the label — the traceability signal that survives is the -/// cross-character marker, which this emits. Accent colour still shows in the rail; the focused -/// pane is marked by the this emits plus the lit plane it is painted on -/// (), because a pane cannot be given a border without changing its -/// rectangle and so the per-pane NAWS size it reports. +/// A tab title is markup, not plain text. This file used to say the opposite, and the claim +/// had a cost: it is why the unread count went out untinted and why a window title was never escaped. +/// TabControl.Rendering runs each label through MarkupParser.Parse, and every width it is +/// measured by — the header paint, the strip's desired width, and the click hit test that decides which +/// tab and which × a press landed on — is MarkupParser.StripLength. So a colour tag here +/// costs no cells and moves no hit test, which is what makes the activity tint affordable on a +/// surface where a cell may not be spent. +/// It also means configured and world-supplied text has to be escaped on the way in +/// (): a window titled [Chat] — or a web view titled from the page +/// it loaded — would otherwise have that eaten as a tag by the parser and by the hit test alike. +/// The focused pane is marked by the this emits plus the lit plane it is painted +/// on (), because a pane cannot be given a border without changing +/// its rectangle and so the per-pane NAWS size it reports. The is deliberately left +/// outside the activity tint: focus and activity are independent, a tab can have both, and a +/// marker that changed colour when a line arrived would be reporting the wrong fact. /// The close affordance is deliberately not here. A written into the label is /// just text: the framework's tab hit test sees it as part of the title and a click on it merely /// selects the tab. The real close button is TabPage.IsClosable, which the framework draws @@ -43,10 +52,15 @@ public static string For( // traceable to its character once dragged into another pane. A character's own main window // needs no prefix — the focused-character context already identifies it. var owner = window.Kind != WindowKind.Main && !string.IsNullOrEmpty(window.OwnerLabel) - ? window.OwnerLabel + " - " + ? MarkupText.Escape(window.OwnerLabel) + " - " : string.Empty; - var unread = window.Unread > 0 ? $" ({window.Unread})" : string.Empty; + // Capped the way the sidebar's badge is, from the same formatter. Not for the sidebar's reason — + // a tab strip is laid out along a row the framework fills to the pane's edge, so a label that grows + // moves the tabs beside it and never the pane's own rectangle. The cap is here so the two surfaces + // reading one number cannot print different answers, and so an unbounded count arriving from the + // wire cannot push a pane's other tabs off the end of a narrow strip. + var unread = window.Unread > 0 ? $" ({UnreadBadge.Format(window.Unread)})" : string.Empty; var pen = window.HasUnsentInput ? $" {Glyphs.Draft}" : string.Empty; // ⌁ marks a window owned by a character other than the focused one, so a pane holding @@ -59,6 +73,12 @@ public static string For( var focus = focusedPane ? Glyphs.FocusedPane + " " : string.Empty; - return focus + owner + window.Title + unread + pen + cross; + // The activity tint. It covers the window's name and its count and stops there: the ▌ ahead of it + // is the focus marker and the ✎ / ⌁ behind it are other facts, and a signal that recoloured them + // would be claiming they had changed too. Zero cells — see the remarks on this class. + var named = owner + MarkupText.Escape(window.Title) + unread; + var body = window.Unread > 0 ? $"[{UnreadBadge.Tint}]{named}[/]" : named; + + return focus + body + pen + cross; } } diff --git a/src/SharpMUTerm.Tui/UnreadBadge.cs b/src/SharpMUTerm.Tui/UnreadBadge.cs new file mode 100644 index 00000000..1f2da74f --- /dev/null +++ b/src/SharpMUTerm.Tui/UnreadBadge.cs @@ -0,0 +1,39 @@ +using System.Globalization; + +namespace SharpMUTerm.Tui; + +/// +/// The one spelling of an unread count, shared by the two surfaces that draw one: the connection rail's +/// per-row badge () and a pane's tab label (). +/// +/// It is shared because the sidebar and the tab strip are two views of a single number — +/// WorkspaceWindow.Unread — and two formatters would eventually disagree about it. They already +/// did: the rail capped at while the tab printed the raw integer, so a busy channel +/// read 99+ in the sidebar and (4127) on its tab, which is two answers to one question. +/// +/// +internal static class UnreadBadge +{ + /// The largest count written in full; above it the badge reads 99+ and stops growing. + internal const int Max = 99; + + /// Cells a badge can occupy, which is the width of 99+. See . + internal const int FieldWidth = 3; + + /// + /// The markup colour a count is drawn in — the app accent, on both surfaces. + /// + /// One colour on purpose. It is not the focus colour and cannot be mistaken for it: focus is said + /// entirely in backgrounds drawn from the theme's own chrome family + /// ( behind a pane, + /// behind the focused pane's active tab chip), while this is a foreground and the one hue in + /// the workspace that no plane is ever painted in. So the two cues are orthogonal — a tab can be + /// focused, unread, both or neither, and each of the four states reads distinctly. + /// + /// + internal const string Tint = ScreenPalette.Accent; + + /// A count as it is written, capped at . + internal static string Format(int unread) => + unread > Max ? $"{Max}+" : unread.ToString(CultureInfo.InvariantCulture); +} diff --git a/tests/SharpMUTerm.Tui.Tests/TabActivityIndicatorTests.cs b/tests/SharpMUTerm.Tui.Tests/TabActivityIndicatorTests.cs new file mode 100644 index 00000000..1f4d6526 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/TabActivityIndicatorTests.cs @@ -0,0 +1,536 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using SharpConsoleUI.Drivers; +using SharpConsoleUI.Parsing; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Session; +using SharpMUTerm.Core.Workspaces; +using SharpMUTerm.Graphics; +using SharpMUTerm.Tui; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// The new-activity indicator on a pane's tab strip: how many lines a tab has that you have not seen, +/// and the tint that makes the tab itself say so. +/// +/// Most of this already existed and is not what these tests are for. Workspace.NoteActivity +/// has always kept the count, the sidebar has always drawn it, and TabTitles has always appended +/// (n). What was missing was that the count was uncapped where the sidebar's was capped — so one +/// number had two spellings — and that the tab carried no colour at all, which is the half of the signal +/// a reader sees without reading. +/// +/// +/// The test to keep above the others is . Per-pane NAWS is +/// derived from the pane rectangle, and unread arrives unbidden from the wire: an indicator that +/// cost a cell as it appeared, or another as the count took a second digit, would re-announce a new +/// terminal size to every connected server on a line of output nobody asked for, and the game would +/// reflow. That is the reported failure this repository keeps paying for, and it is why the sidebar's +/// badge sits in a reserved field. The tab strip needs no reserved field — see that test for why — but it +/// still has to prove it. +/// +/// +/// +/// Serialised for the same reason is: rendering a frame redirects the +/// process-global Console.Out, and the harness redirects Console.In. +/// +[NotInParallel] +public class TabActivityIndicatorTests +{ + private const int Width = 120; + private const int Height = 32; + private const string Main = "main"; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private static ConsoleKeyInfo Chord(ConsoleKey key, bool ctrl = false) => new('\0', key, false, false, ctrl); + + /// + /// The demo app with a session bound to the main window but no socket under it, so + /// WorldSession.PrintSystem drives the app's real line handler and therefore its real unread + /// accounting. The same shape OutputScrollbackTests.Bound uses, and for the same reason: a test + /// that set the counter itself would be asserting about a number this code does not read. + /// + private static (SharpMUTermApp App, WorldSession Session) Bound( + int width = Width, int height = Height, TimeProvider? clock = null) + { + Console.SetIn(TextReader.Null); + var config = DemoScene.Build(); + var app = clock is null + ? new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(width, height)) + : new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(width, height), clock); + return (app, app.BindWorldWithoutConnecting(config.Worlds[0])); + } + + /// + /// Scrolls the main window off its live tail and prints into it, so the count + /// accrues on a window that is visible. That arm is deliberate: it is the one + /// WorkspaceTests.NoteActivity_OnAVisibleWindowScrolledBack_StillBadges pins, and the one where + /// a tab can be both focused and unread — which is exactly where a tint could be mistaken for the + /// focus cue. + /// + private static void AccrueOnScrolledBackMain(SharpMUTermApp app, WorldSession session, int lines) + { + app.LoadLongScene(Main, SharpMUTermApp.ScrollbackSceneLines); + app.RenderNextFrame(); + app.RenderNextFrame(); // auto-scroll settles on the second frame; see SettleScroll + app.SimulateKey(Chord(ConsoleKey.PageUp)); + + for (var i = 0; i < lines; i++) + { + session.PrintSystem($"*** unseen {i}"); + } + + app.RenderNextFrame(); + } + + /// The label the main window's tab is actually carrying, off the strip the framework will draw. + private static string MainTabLabel(SharpMUTermApp app) => + app.PaneTabStrip(app.PaneIdOf(Main)!).Single(t => t.WindowId == Main).Title; + + // --- the title -------------------------------------------------------------------------------- + + /// + /// The asked-for shape, end to end and on a real app: Corvid (36) with thirty-six unread and + /// plain Corvid at zero — read off the tab the framework will draw. (The demo's main window is + /// titled after its character, because that is what a live BindSession writes; the requested + /// Mannaz (36) spelling is pinned on the formatter in .) + /// + /// The is on this label too, because this window's pane holds the focus. So the assertion is + /// on the label's printable width — the string itself is markup, and measuring it any other + /// way would be measuring the colour tag. + /// + /// + [Test] + public async Task ATabWithUnreadReadsItsNameAndCountAndLosesBothWhenCaughtUp() + { + var (app, session) = Bound(); + AccrueOnScrolledBackMain(app, session, 36); + + await Assert.That(app.UnreadOf(Main)).IsEqualTo(36); + await Assert.That(MainTabLabel(app)).Contains("Corvid (36)"); + await Assert.That(MarkupParser.StripLength(MainTabLabel(app))) + .IsEqualTo($"{Glyphs.FocusedPane} Corvid (36)".Length); + + app.SimulateKey(Chord(ConsoleKey.End, ctrl: true)); // back to the live tail: caught up + app.RenderNextFrame(); + + await Assert.That(app.UnreadOf(Main)).IsEqualTo(0); + await Assert.That(MainTabLabel(app)).IsEqualTo($"{Glyphs.FocusedPane} Corvid"); + } + + /// + /// The sidebar and the tab strip never print different numbers for one count. They are two views of + /// WorkspaceWindow.Unread and they format it through the same ; before + /// that, the rail capped at ninety-nine and the tab did not, so a busy channel read 99+ in one + /// place and (150) in the other. + /// + [Test] + [Arguments(3)] + [Arguments(99)] + [Arguments(150)] + public async Task TheSidebarAndTheTabPrintTheSameCount(int lines) + { + var (app, session) = Bound(); + AccrueOnScrolledBackMain(app, session, lines); + + var badge = UnreadBadge.Format(lines); + await Assert.That(app.UnreadOf(Main)).IsEqualTo(lines); + await Assert.That(MainTabLabel(app)).Contains($"({badge})"); + + // The rail's own rows carry the same badge, right-aligned in the field it reserves for one. + var railRows = app.RailLines + .Where(l => Regex.IsMatch(l, $@"\[{Regex.Escape(UnreadBadge.Tint)}\]\s*\d+\+?\[/\]")) + .ToList(); + await Assert.That(railRows).IsNotEmpty(); + foreach (var railRow in railRows) + { + await Assert.That(railRow) + .Contains($"[{UnreadBadge.Tint}]{badge.PadLeft(UnreadBadge.FieldWidth)}[/]"); + } + + // Once the cap has bitten, the uncapped number is on neither surface. This is the assertion that + // fails on a tab which formats the raw integer while the sidebar beside it says 99+. + var raw = lines.ToString(CultureInfo.InvariantCulture); + if (raw != badge) + { + await Assert.That(MainTabLabel(app)).DoesNotContain(raw); + await Assert.That(string.Concat(railRows)).DoesNotContain(raw); + } + } + + // --- the tint --------------------------------------------------------------------------------- + + /// + /// The tint is on the frame, on the tab strip's own row, and it is a foreground. Read off the + /// painted cells rather than off the label, because a colour tag can be emitted into a string that + /// nothing draws — which is precisely the way pane focus was once "indicated". + /// + [Test] + public async Task TheTintIsPaintedOnTheTabStripRow() + { + var (app, session) = Bound(); + + var before = Cells(app.RenderWholeFrame()); + await Assert.That(Tinted(before, StripRow(app), app)).IsEmpty(); + + AccrueOnScrolledBackMain(app, session, 36); + var after = Cells(app.RenderWholeFrame()); + + // The tinted run *is* the name and the count — it stops before the ▌ and before the strip's rule. + var tinted = Tinted(after, StripRow(app), app); + await Assert.That(string.Concat(tinted.Select(c => c.Char))).IsEqualTo("Corvid (36)"); + await Assert.That(RowText(after, StripRow(app))).Contains("Corvid (36)"); + } + + /// + /// The tint cannot be confused with the focus cue. Focus is said entirely in + /// backgrounds drawn from the theme's chrome family — the pane's plane and the chip behind the + /// active tab; activity is said in a foreground no plane in the workspace is ever painted in. + /// So the two are separate channels and the four combinations are four distinct looks. This checks the + /// one that would break a weaker design — a tab that is focused and unread — and asserts + /// legibility as a measured contrast on both planes a chip can have, rather than assuming it. + /// + [Test] + public async Task AFocusedTabWithUnreadShowsBothCuesAndTheyAreDifferentChannels() + { + var (app, session) = Bound(); + AccrueOnScrolledBackMain(app, session, 36); + + // This window's pane holds the focus, so its active chip is painted in the armed band. + await Assert.That(app.FocusedPaneId).IsEqualTo(app.PaneIdOf(Main)); + + var cells = Cells(app.RenderWholeFrame()); + var tinted = Tinted(cells, StripRow(app), app); + await Assert.That(tinted).IsNotEmpty(); + + var (focusedPlane, unfocusedPlane) = app.PaneBandColors; + var planes = new[] { focusedPlane, unfocusedPlane }.Select(c => Hex(c.R, c.G, c.B)).ToList(); + + // The tint is nothing the focus cue is ever painted in, and it stays clear of both planes by a + // wide margin — so it reads whichever pane the tab is in. + foreach (var plane in planes) + { + await Assert.That(plane).IsNotEqualTo(UnreadBadge.Tint); + await Assert.That(Contrast(UnreadBadge.Tint, plane)).IsGreaterThan(4.5); + } + + // The tinted cells are all drawn on one background — the focused chip's — which is what makes the + // point: same cells, two channels, and the background is doing the focus half on its own. + var chip = tinted.Select(c => c.Background).Distinct().ToList(); + await Assert.That(chip.Count).IsEqualTo(1); + await Assert.That(chip[0]).IsNotEqualTo(UnreadBadge.Tint); + await Assert.That(Contrast(UnreadBadge.Tint, chip[0]!)).IsGreaterThan(4.5); + } + + /// + /// The focus marker keeps its own colour. It says which pane holds the keyboard — a fact that is true + /// or false whatever the count is — so a line of output arriving may not recolour it, or the two + /// signals would be reporting each other. + /// + [Test] + public async Task TheFocusMarkerKeepsItsOwnColourWhileTheTabIsUnread() + { + var (app, session) = Bound(); + AccrueOnScrolledBackMain(app, session, 36); + await Assert.That(MainTabLabel(app)).StartsWith(Glyphs.FocusedPane); + + var cells = Cells(app.RenderWholeFrame()); + var marker = cells.Values.Single(c => c.Row == StripRow(app) && c.Char == Glyphs.FocusedPane[0]); + await Assert.That(marker.Foreground).IsNotEqualTo(UnreadBadge.Tint); + } + + // --- the NAWS trap ---------------------------------------------------------------------------- + + /// + /// Activity moves no pane rectangle — not when the badge appears out of nothing, not at 9 → 10 + /// when it takes a second digit, and not at 99 → 100 when it takes a third and the cap changes its + /// spelling. Every one of those is a width change on a label that arrives from the wire. + /// + /// And the strip is deliberately not given the sidebar's reserved-width treatment. The + /// two surfaces are laid out differently and the argument does not carry across. A rail row's width + /// feeds SharpMUTermApp.RailWidth, which sizes the sidebar's grid column, and the pane area is + /// what is left over — so there a badge that appears really does narrow every pane. A tab strip is a + /// TabControl arranged Fill + Stretch inside the pane it already fills; the + /// framework paints the labels left to right and then fills the rest of the header row out to the + /// pane's own edge (TabControl.Rendering), so a longer label moves the tabs beside it along a + /// row whose width was never a function of them. Reserving three cells per tab would cost real width + /// on every strip for ever — pushing a narrow pane's later tabs off the end — to prevent a reflow that + /// this layout cannot produce. So: measured, argued, and declined. This test is the proof, and it is + /// checked narrow as well as roomy because a one-cell change hides in a roomy default. + /// + /// + [Test] + [Arguments(160, 48)] + [Arguments(120, 34)] + [Arguments(100, 24)] + [Arguments(80, 20)] + public async Task ActivityMovesNoPaneRectangle(int width, int height) + { + var (app, session) = Bound(width, height); + app.RenderSnapshot("split"); // two panes, so the strip shares its row with a neighbour + app.LoadLongScene(Main, SharpMUTermApp.ScrollbackSceneLines); + app.RenderNextFrame(); + app.RenderNextFrame(); + app.SimulateKey(Chord(ConsoleKey.PageUp)); + app.RenderNextFrame(); + + await Assert.That(app.UnreadOf(Main)).IsEqualTo(0); + var before = app.PaneOutputRects().ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); + var railBefore = app.RailColumnWidth; + var widths = new List(); + + // Every boundary the badge crosses: nothing → 1, one digit → two, two → three, and past the cap. + foreach (var stop in new[] { 1, 9, 10, 99, 100, 150 }) + { + while (app.UnreadOf(Main) < stop) + { + session.PrintSystem("*** unseen"); + } + + app.RenderNextFrame(); + await Assert.That(app.UnreadOf(Main)).IsEqualTo(stop); + widths.Add(MarkupParser.StripLength(MainTabLabel(app))); + + await Assert.That(app.RailColumnWidth).IsEqualTo(railBefore); + foreach (var (paneId, rect) in before) + { + await Assert.That(app.PaneOutputRects()[paneId]).IsEqualTo(rect); + } + } + + // The label really did change width on the way — otherwise this passes on an indicator that + // never appeared, which is how a NAWS test quietly stops testing anything. + await Assert.That(widths.Distinct().Count()).IsGreaterThan(1); + + // …and stopped changing at the cap, which is the bound the sidebar's field also relies on. + await Assert.That(widths[^1]).IsEqualTo(widths[^2]); + } + + /// + /// The same claim on the bytes a server would receive: a connected world is not told a new terminal + /// size because a line arrived in a tab nobody is looking at. Driven on a + /// and wound past the report interval, because the NAWS throttle has a + /// trailing flush — on a still clock a changed size is coalesced and never delivered inside the test, + /// so this would pass on a broken client without the wind. + /// + [Test] + public async Task ActivityTellsNoServerANewSize() + { + var clock = new ManualTimeProvider(); + var (app, printing) = Bound(clock: clock); + app.RenderSnapshot("split"); + + // Two sessions on the one window, because neither seam does both jobs: the demo-bound session is + // what drives the app's real line handler and so its real unread accounting, while + // AttachSession — which only registers a session's window for the size report — is the only way to + // put a recording transport behind that pane. Both point at `main`, so the bytes below are the + // sizes a server connected to this pane would have received. + var telnet = new RecordingTelnetSession(); + var watcher = new WorldSession( + new WorldDefinition { Name = "W", Host = "h", Port = 1 }, sessionFactory: _ => telnet); + await watcher.ConnectAsync(); + app.AttachSession(watcher, Main); + + // More output than the pane holds, then up off the live tail — otherwise there is nothing below + // the viewport, the window stays caught up, and no line that arrives is unread at all. + app.LoadLongScene(Main, SharpMUTermApp.ScrollbackSceneLines); + app.RenderNextFrame(); + app.RenderNextFrame(); + app.SimulateKey(Chord(ConsoleKey.PageUp)); + app.RenderNextFrame(); + clock.Advance(TimeSpan.FromSeconds(1)); + app.RenderNextFrame(); + + var before = telnet.Sizes.Distinct().ToList(); + await Assert.That(before).IsNotEmpty(); + + for (var i = 0; i < 150; i++) + { + printing.PrintSystem($"*** unseen {i}"); + } + + app.RenderNextFrame(); + clock.Advance(TimeSpan.FromSeconds(1)); + app.RenderNextFrame(); + + // Past the cap, so every width step the badge can take has been taken. + await Assert.That(app.UnreadOf(Main)).IsGreaterThan(UnreadBadge.Max); + await Assert.That(telnet.Sizes.Distinct().ToList()).IsEquivalentTo(before); + } + + // --- clearing --------------------------------------------------------------------------------- + + /// + /// The tab clears on exactly the condition the sidebar clears on, because it reads the same field. + /// Both render from WorkspaceWindow.Unread, so the interesting case is the one where a rule of + /// one's own would get it wrong: Workspace.ActivateWindow deliberately does not clear a + /// window the reader has scrolled back, because the unread lines are still below the viewport + /// (WorkspaceTests.ActivateWindow_DoesNotClearUnreadOfAScrolledBackWindow). Picking the tab + /// therefore leaves both badges up, and returning to the live tail takes both down together. + /// + [Test] + public async Task PickingTheTabOfAScrolledBackWindowClearsNeitherBadgeAndTheTailClearsBoth() + { + var (app, session) = Bound(); + AccrueOnScrolledBackMain(app, session, 7); + + await Assert.That(app.UnreadOf(Main)).IsEqualTo(7); + await Assert.That(MainTabLabel(app)).Contains("(7)"); + await Assert.That(RailShowsABadge(app)).IsTrue(); + + // Activating the window it is already on: the sidebar keeps its badge, so the tab must keep its own. + app.SimulateWindowChange(Main); + app.RenderNextFrame(); + + await Assert.That(app.UnreadOf(Main)).IsEqualTo(7); + await Assert.That(MainTabLabel(app)).Contains("(7)"); + await Assert.That(RailShowsABadge(app)).IsTrue(); + + // ⌃End is catching up, and it is the one gesture that clears — for both surfaces at once. + app.SimulateKey(Chord(ConsoleKey.End, ctrl: true)); + app.RenderNextFrame(); + + await Assert.That(app.UnreadOf(Main)).IsEqualTo(0); + await Assert.That(MainTabLabel(app)).DoesNotContain("("); + await Assert.That(MainTabLabel(app)).DoesNotContain(UnreadBadge.Tint); + await Assert.That(RailShowsABadge(app)).IsFalse(); + } + + /// + /// Whether any rail row is drawing an unread badge — the sidebar's half of the signal. Matched on the + /// field (a count right-aligned into ) and not merely on + /// the accent, because the rail paints its world spine and its connection dots in that colour too. + /// + private static bool RailShowsABadge(SharpMUTermApp app) => + app.RailLines.Any(l => Regex.IsMatch( + l, $@"\[{Regex.Escape(UnreadBadge.Tint)}\]\s*\d+\+?\[/\]")); + + // --- frame decoding --------------------------------------------------------------------------- + + private readonly record struct Cell(int Row, int Column, char Char, string? Foreground, string? Background); + + /// + /// Walks a frame into cells carrying both channels. Note the 38 as well as the + /// 48: this indicator lives in the foreground and the focus cue it must not be confused with + /// lives in the background, so a decoder that read only one of them could not tell them apart. + /// + private static Dictionary<(int Row, int Column), Cell> Cells(string ansi) + { + var cells = new Dictionary<(int, int), Cell>(); + var (row, column) = (0, 0); + string? fg = null, bg = null; + + foreach (Match token in Regex.Matches(ansi, @"\x1b\[([0-9;]*)([A-Za-z])|([^\x1b\r\n])|(\n)")) + { + if (token.Groups[4].Success) + { + row++; + column = 0; + continue; + } + + if (token.Groups[3].Success) + { + cells[(row, column)] = new Cell(row, column, token.Groups[3].Value[0], fg, bg); + column++; + continue; + } + + var parameters = token.Groups[1].Value; + switch (token.Groups[2].Value) + { + case "H": + var at = parameters.Split(';'); + row = at[0].Length > 0 ? int.Parse(at[0]) - 1 : 0; + column = at.Length > 1 && at[1].Length > 0 ? int.Parse(at[1]) - 1 : 0; + break; + case "m": + if (parameters.Length == 0 || parameters == "0") + { + fg = bg = null; + } + + fg = Truecolor(parameters, "38;2;") ?? fg; + bg = Truecolor(parameters, "48;2;") ?? bg; + break; + } + } + + return cells; + } + + /// Reads an r;g;b triple out of an SGR parameter list as #rrggbb, or null. + private static string? Truecolor(string parameters, string introducer) + { + var at = parameters.IndexOf(introducer, StringComparison.Ordinal); + if (at < 0) + { + return null; + } + + var parts = parameters[(at + introducer.Length)..].Split(';'); + return parts.Length < 3 ? null : Hex(byte.Parse(parts[0]), byte.Parse(parts[1]), byte.Parse(parts[2])); + } + + private static string Hex(int r, int g, int b) => $"#{r:x2}{g:x2}{b:x2}"; + + /// + /// The frame row a pane's tab strip is drawn on: the header sits immediately above the pane's output + /// rectangle, which is what PaneOutputRects reports. + /// + private static int StripRow(SharpMUTermApp app) => app.PaneOutputRects()[app.PaneIdOf(Main)!].Y - 1; + + /// + /// The tinted cells on one row, in column order and within that pane's own columns. + /// + /// Scoped rather than swept, because is the app accent and the + /// chrome already uses it elsewhere — the header's connected ●, the rail's world spine ▚ and its status + /// dots are all painted in it. That is fine where it is: those are fixed decorations in fixed places, + /// and inside a tab strip nothing else is accent-coloured, so on this row the tint is unambiguous. But + /// a test that swept the whole frame for the colour would be counting them too. + /// + /// + private static List Tinted( + Dictionary<(int Row, int Column), Cell> cells, int row, SharpMUTermApp app) + { + var rect = app.PaneOutputRects()[app.PaneIdOf(Main)!]; + return cells.Values + .Where(c => c.Row == row + && c.Column >= rect.X && c.Column < rect.X + rect.Width + && c.Foreground == UnreadBadge.Tint) + .OrderBy(c => c.Column) + .ToList(); + } + + private static string RowText(Dictionary<(int Row, int Column), Cell> cells, int row) + { + var onRow = cells.Values.Where(c => c.Row == row).ToList(); + return onRow.Count == 0 + ? string.Empty + : string.Concat(Enumerable.Range(0, onRow.Max(c => c.Column) + 1) + .Select(x => cells.TryGetValue((row, x), out var c) ? c.Char : ' ')); + } + + /// WCAG relative-contrast ratio between two #rrggbb colours. + private static double Contrast(string a, string b) + { + var (la, lb) = (Luminance(a), Luminance(b)); + return (Math.Max(la, lb) + 0.05) / (Math.Min(la, lb) + 0.05); + } + + private static double Luminance(string hex) + { + double Channel(int v) + { + var s = v / 255.0; + return s <= 0.03928 ? s / 12.92 : Math.Pow((s + 0.055) / 1.055, 2.4); + } + + var r = Convert.ToInt32(hex.Substring(1, 2), 16); + var g = Convert.ToInt32(hex.Substring(3, 2), 16); + var b = Convert.ToInt32(hex.Substring(5, 2), 16); + return (0.2126 * Channel(r)) + (0.7152 * Channel(g)) + (0.0722 * Channel(b)); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs b/tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs index 24024cc6..078c0908 100644 --- a/tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs @@ -1,3 +1,4 @@ +using SharpConsoleUI.Parsing; using SharpMUTerm.Core.Workspaces; using SharpMUTerm.Tui; @@ -5,6 +6,23 @@ namespace SharpMUTerm.Tui.Tests; public class TabTitlesTests { + /// + /// A background window carrying unread lines, accrued through the workspace's + /// own counter rather than written onto the window — WorkspaceWindow.Unread is settable only + /// inside Core, and going round it would be testing a number this code does not read. + /// + private static WorkspaceWindow Background(string title, int unread) + { + var ws = new Workspace(); + var window = ws.RouteSpawn(title); // opens in the background, so the first route already counts 1 + for (var i = 1; i < unread; i++) + { + ws.NoteActivity(window.Id); + } + + return window; + } + [Test] public async Task PlainWindow_IsJustItsTitle() { @@ -18,7 +36,53 @@ public async Task Unread_AppendsACountBadge() var ws = new Workspace(); ws.RouteSpawn("Chat"); var chat = ws.RouteSpawn("Chat"); // two background routes → unread 2 - await Assert.That(TabTitles.For(chat)).IsEqualTo("Chat (2)"); + await Assert.That(TabTitles.For(chat)).IsEqualTo($"[{UnreadBadge.Tint}]Chat (2)[/]"); + } + + /// + /// The count is capped exactly as the sidebar's badge is, from the same formatter. Uncapped it grew a + /// digit at a time from the wire — and the rail, which is capped, then read 99+ beside + /// a tab reading (150): two answers to one number. + /// + [Test] + [Arguments(99, "99")] + [Arguments(100, "99+")] + [Arguments(4127, "99+")] + public async Task Unread_IsCappedTheWayTheSidebarCapsIt(int unread, string badge) + { + var window = Background("Mannaz", unread); + await Assert.That(window.Unread).IsEqualTo(unread); + await Assert.That(TabTitles.For(window)).IsEqualTo($"[{UnreadBadge.Tint}]Mannaz ({badge})[/]"); + } + + /// + /// The tint covers the name and the count and nothing else. The ahead of it says which pane + /// holds the keyboard — an independent fact, true or false whatever the count is — so recolouring it + /// would make one signal look like the other, which is the confusion this indicator has to avoid. + /// + [Test] + public async Task TheFocusMarkerStaysOutsideTheActivityTint() + { + var window = Background("Mannaz", 36); + var label = TabTitles.For(window, focusedPane: true); + + await Assert.That(label).IsEqualTo($"{Glyphs.FocusedPane} [{UnreadBadge.Tint}]Mannaz (36)[/]"); + await Assert.That(label.StartsWith(Glyphs.FocusedPane, StringComparison.Ordinal)).IsTrue(); + } + + /// + /// A window title is world- and user-supplied text, and the label is markup — the framework parses it + /// and measures every width, including the click hit test, with StripLength. So a title + /// containing brackets has to survive as brackets rather than being eaten as a tag. + /// + [Test] + public async Task ATitleWithBracketsIsEscapedRatherThanParsedAsMarkup() + { + var window = new WorkspaceWindow("w1", "[Chat] room", WindowKind.Spawn) { OwnerLabel = "[Corvid]" }; + var label = TabTitles.For(window); + + await Assert.That(label).IsEqualTo("[[Corvid]] - [[Chat]] room"); + await Assert.That(MarkupParser.StripLength(label)).IsEqualTo("[Corvid] - [Chat] room".Length); } [Test] @@ -37,7 +101,7 @@ public async Task UnreadAndUnsent_ShowBoth() var ws = new Workspace(); var chat = ws.RouteSpawn("Chat"); // unread 1, background ws.SetUnsentInput(chat.Id, true); - await Assert.That(TabTitles.For(chat)).IsEqualTo($"Chat (1) {Glyphs.Draft}"); + await Assert.That(TabTitles.For(chat)).IsEqualTo($"[{UnreadBadge.Tint}]Chat (1)[/] {Glyphs.Draft}"); } [Test] @@ -65,7 +129,8 @@ public async Task ChildWindow_OwnerPrefixPrecedesBadges() var chat = ws.RouteSpawn("Chat"); // unread 1, background chat.OwnerLabel = "Corvid"; ws.SetUnsentInput(chat.Id, true); - await Assert.That(TabTitles.For(chat)).IsEqualTo($"Corvid - Chat (1) {Glyphs.Draft}"); + await Assert.That(TabTitles.For(chat)) + .IsEqualTo($"[{UnreadBadge.Tint}]Corvid - Chat (1)[/] {Glyphs.Draft}"); } [Test] From d0298158c5e3732280f93f0653589ca71fa58cca Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 30 Jul 2026 17:02:57 -0500 Subject: [PATCH 11/11] Stop the brief claiming the encoding seed reaches MSSP, because it does not CLAUDE.md said the seeded CurrentEncoding is used for GMCP, MSDP and MSSP. It is right about the first two -- GMCPProtocol calls context.CurrentEncoding.GetString throughout and MSDPServerHandler passes telnet.CurrentEncoding -- and wrong about the third. MSSPProtocol.FlushField decodes every name and value with a hardcoded Encoding.ASCII, so a game named Cafe Noir with an accent reports one question mark per byte regardless of what CHARSET settled on or what we seed. The bytes are gone before any callback sees them. Recorded rather than worked around, with the caveat that it is arguably conformant: RFC 2066 scopes CHARSET to text and not commands, and a subnegotiation is a command. The complaint that survives is narrower -- it is lossy where it need not be, and Encoding.Latin1 would round-trip all 256 values at no cost. That is the upstream PR. Two details a reader would otherwise rediscover the hard way: the plaintext MSSP-REQUEST fallback does not go through MSSPProtocol at all, so the same server read the two ways disagrees byte for byte; and at 2.6.0 the same call is inlined at four sites which also ToUpper() names with the current culture, a Turkish-I hazard. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 17afefed..a0cb78ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -578,10 +578,21 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] cooperative server agrees, but used regardless of what it says. Four things about this library will bite you, and all four already have: - **`TelnetInterpreter.CurrentEncoding` defaults to `Encoding.ASCII`**, and that default is not inert: - it is handed to `CallbackOnByteAsync`/`CallbackOnSubmitAsync` for every byte and used for GMCP/MSDP/ - MSSP and everything we send. On a server that never negotiates CHARSET — most MU\* servers — every + it is handed to `CallbackOnByteAsync`/`CallbackOnSubmitAsync` for every byte and used for GMCP, MSDP + and everything we send. On a server that never negotiates CHARSET — most MU\* servers — every byte above 0x7F became `?`. `TelnetSession` seeds that property (reflectively, `internal set`, the same way `CharsetProtocol` itself writes it) with the head of the stated order. + **MSSP is the exception, and the seed does not reach it.** `MSSPProtocol.FlushField` decodes every + MSSP name and value with a hardcoded `Encoding.ASCII` (at 2.6.0 the same call was inlined at four + sites), so a game whose `NAME` is `Café Noir` reports `Caf? Noir` no matter what CHARSET settled on + and no matter what we seed. The bytes are gone by the time any callback sees them. This is arguably + *conformant* — RFC 2066 scopes CHARSET to text, not commands, and a subnegotiation is a command — + but it is lossy where it need not be, since `Encoding.Latin1` would round-trip all 256 byte values + and cost nothing. It is a good upstream PR, and until it lands, treat non-ASCII in an MSSP field as + unrecoverable. Two consequences worth knowing: the plaintext `MSSP-REQUEST` fallback does **not** + go through `MSSPProtocol`, so the same server read the two ways disagrees byte for byte; and 2.6.0 + additionally `ToUpper()`s variable names with the *current culture*, which is a Turkish-I hazard in + the same lines. - **The encodings we state must be the platform provider's own instances** (`Encoding.UTF8`, *not* `new UTF8Encoding(false)`). `CharsetProtocol` ranks a server's offer by `IndexOf` over our list against encodings from `Encoding.GetEncodings()`, and `UTF8Encoding.Equals` compares the BOM flag —