diff --git a/CLAUDE.md b/CLAUDE.md index 9a3a471..228a342 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,7 +183,10 @@ python3 tools/ansi_frame_to_image.py frame.ansi frame.html # or .svg `messages`, `quit`, `connections` (**two connections on one world** — the one view where the header's fraction, the rail's dots and the quit prompt's count are all visible together and all have to agree; every other view has at most one character connected per world, which is what hid a header dividing - connections by *worlds* and a quit prompt reducing them to distinct world names), `deletions`, + connections by *worlds* and a quit prompt reducing them to distinct world names), `characters` (**two + characters genuinely open** — the one state the rail's ⌥J/⌥K column can be seen in, and the one thing + `connections` cannot fake: that view marks dots connected and opens no session, while the cycle walks + the sessions this client holds), `deletions`, `mssp`/`mssp-none`/`mssp-never` (the **three** states of the F5 ▸ `i` server-information report — a report, a server that answered and publishes none, and a world nothing has dialled; all three reached by driving the real `i` into a real F5, and all three needed because the two empty ones are @@ -318,51 +321,109 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] the least warning: it is reached from `ProcessInput`'s fall-through for *any* unhandled Alt chord and, unlike the move and resize handlers on the two lines above it, is **not** gated on `IsMovable`/`IsResizable` — so `Movable(false)`, which switched off the ⌃X-swallowing move handler, - did nothing here. `⌥1`–`⌥9` are ours (`JumpToPane`, go to the numbered pane) and are claimed as + did nothing here. `⌥1`–`⌥9` are ours (`JumpToWindow`, go to the numbered **window**) and are claimed as **application shortcuts**, which `InputCoordinator` tries before it offers a key to any window at all; `PreviewKeyPressed` would also have won (`WindowEventDispatcher.ProcessInput` raises it first and returns immediately when handled), one step later. **All nine are claimed, in range or not**: an out-of-range ⌥7 reports and stops rather than falling through to a window selector that would do something else. `⌥0` is deliberately left free — the framework ignores it too, so it stays bindable. -- **Ctrl+digit is not a chord this terminal has, and that is why the pane jump is Alt.** Read off a +- **Ctrl+digit is not a chord this terminal has, and that is why the numbered jump is Alt.** Read off a pty with `kitten @ send-key` at a raw-mode reader, the same way `Alt+Shift+arrow` was established: every `Alt+digit` is `ESC` + the digit (one Alt chord out of `ProcessEscape`), while Ctrl+digit is `1`→`0x31`, `2`→NUL, `3`→**`0x1B` (Escape)**, `4`–`7`→`0x1C`–`0x1F`, `8`→**`0x7F` (Backspace)**, `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, 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. +- **Where a chord could be Ctrl or Alt, it is Alt — and "safer" is measured, not preferred.** Alt+key is + `ESC` + the key and arrives intact; Ctrl+key collapses onto a byte the terminal may already spend + (`⌃H` Backspace, `⌃I` Tab, `⌃M`/`⌃J` Enter, **`⌃Tab` a bare Tab**), and Ctrl+digit has no usable + encoding at all. Every chord in `MacroKeys.AppShortcuts` has been driven at a raw-mode reader with + `kitten @ send-key`; the bytes are in `DigitBytes`/`ControlBytes` and F4 reads them. + - **The connection pair is `⌥D` / `⌥R`.** Disconnect was `⌃D` on one justification ("the terminal's + hang-up chord") and reconnect `⌥R` on a different one ("one modifier over from `⌃R`") — each fine + alone, and together making a reader learn two modifiers for one concept. `⌃D` is **released**, not + kept as an alias: a second key for one action is either a secret or a duplicate row on every surface + that lists chords, and letting it go hands a clean Ctrl chord back for macros. Nothing takes it — + `HandleMoveInput` is gated on `IsMovable` (false here) and acts only on the arrows and `X`. + - **`⌃Tab` is gone and was never real.** It was claimed as a second spelling of "next window"; a + terminal writes `0x09` for it, which *is* Tab, so the parser reports `ConsoleKey.Tab` with no + Control bit and the claim could never have matched — while `Claimed` is consulted first, so F4 told + users a chord was taken that cannot arrive. `⌃N` is the chord; the byte is in `ControlBytes`. + - **Deliberately left on Ctrl**, because the convention is worth more than the pattern: `⌃R` + (readline's reverse history search), `⌃P` (command surface), `⌃Q` (quit — and safe here because + `TerminalRawMode` clears `IXON`, so it is not XON), `⌃B` (tmux's prefix), `⌃O` (pane cycle), + `⌃N`/`⌃W`/`⌃F`, and the command line's `⌃A`/`⌃E`/`⌃K`/`⌃U`/`⌃L`. A sweep that moved everything + would be as wrong as one that moved nothing. + - **Known and not fixed here**: `⌃N` and `⌃O` have no reverse (the character cycle does — `⌥J`/`⌥K`), + and `⌃W` and `⌃B x` are two chords for one action. Both are shape complaints rather than defects, + and both are behaviour changes rather than modifier moves. +- **There are two numberings and one cycle, over three different sets, spelt differently everywhere.** + **Windows** are `⌥N` in `Workspace.WindowsFor(active)` order; **panes** are `pane N` in `Layout.Panes` + order; **characters** have no number at all and are reached by the `⌥J`/`⌥K` cycle. Both numberings are + *creation* order, both take the number from the **index** rather than the sequence, and neither is ever + written in the other's vocabulary. That separation is the whole safety property: the digits are the + same ten characters, so if a surface could print a bare number that might be either, a user reading it + cannot know which key to press. + - **⌥1–⌥9 go to a window of the *active character*, numbered from 1 within that character** + (`JumpToWindow`). Windows rather than panes because a capture sharing a pane with its character's own + window had no number of its own and was reachable only while it happened to be that pane's active tab + — "switch not just characters, but captures, etc." **Scoped rather than global**, because global + failed on a real client the first day: three characters sharing pane 1 as tabs, and the sidebar + giving all three of them `⌥1`. Nine digits also do not stretch over everybody's windows. + An **unowned** window (the web view) is in *every* character's list, so it wears a different digit + under each — which is exactly the set `BuildRailWindows` draws, so the sidebar and the chord are one + list read twice. `WorkspaceWindow.Sequence` is a per-workspace counter assigned at creation, + persisted in `WorkspaceWindowState`, never reused; a window restored without one is seeded from the + saved order. + - **⌥J / ⌥K cycle characters**, forward and back. Letters and not a third digit row because there is + no third digit-bearing modifier this terminal delivers: kitty writes `⌥⇧1` as `CSI 49;4u` and `⌃⇧N` + as `CSI 110;6u`, kitty-keyboard-protocol sequences the parser does not decode and silently drops + (measured, not assumed). It walks **only the characters already open**, because + `SwitchToCharacter` *creates* a session and a window for one that is not, and a cycle key that did + that per press would dial through a configuration by accident. + **`Workspace.Windows` is a dictionary's values and is not the numbering** — its order is unspecified + after a removal, so opening a window into a closed one's slot renumbers everything after it + (`WindowNumberingTests.AWindowOpenedIntoAClosedOnesSlotStillTakesTheLastNumber` is the pin, and it + took a four-window fixture to expose: with fewer, both orders agree). + Only **placed** windows are numbered — one no pane holds is drawn `closed` and a digit for it would + name nowhere to go. + - **⌃B 1–⌃B 9 go to a pane.** It was ⌥N until windows took that; it is on the prefix because that is + where the rest of the pane keymap lives, and it is kept rather than dropped because the pane + numbering does not go away with the chord — move mode badges each pane with it, the move and drag + prompts say `pane 2`, the ⌃P entry says `Go to pane 2`, and ⌃O counts in it. It also refuses on a + single-pane workspace *including ⌃B 1*, because the which-key panel dims that row. - **`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 + pane 2 made that pane into pane 3, so the digit that meant it stopped meaning it while the user was + doing something else. `PaneNode.Sequence` is the same mechanism as the window one, seeded from tree + order for a config written before the field. + - **The number is the *index*, never the `Sequence` itself,** in both numberings. Sequences have holes + after a close; the numbering may not, or a digit is a silent no-op with the things still on the + screen. `PaneNumberingTests`, `WindowNumberingTests`, + `WindowJumpTests.ClosingAWindowCompactsTheNumberingOnTheChordAndInTheSidebar`. + - **⌃O cycles panes in the pane order.** It read tree order once, which agreed with the numbering back + when the numbering *was* tree order. Its partner is ⌃B N, not ⌥N — the window movers (⌥N, ⌃N) are a + separate ladder — and three presses of ⌃O from pane 1 must land where ⌃B 4 does. +- **The rail is the only place you read either chord, and every row carries the one that reaches it.** + Window rows carry their own `⌥N`; the two character rows either side of you in the cycle carry `⌥J` + and `⌥K`, and every other character row carries nothing, because nothing else is one keystroke away. + A character row used to carry the chord of its own *window* — which, once the numbering was scoped, + printed `⌥1` against every character on the screen. **The sidebar prints `⌥…` and never `pane N`**: it + is about windows and characters, and a pane noun there would be a second reading of the same column. + - **The chord leads the row; the badges trail it.** The reported complaint was the gap — five blank + cells (the reserved pen and unread fields) between a window's name and its chord. Those fields + cannot be removed (a cell that costs only when it has something to say resizes the sidebar on a + keystroke or a line of output), so the chord moved to the front instead, against the name it names. + The row's measured width is unchanged by the move: the demo rail is 22 columns before and after. + - **The chord field is reserved per *row kind*.** Reserved, so the width does not move as unread + arrives, as a draft is typed, or as the `⌥J`/`⌥K` pair travels between rows. Per kind, because with + fewer than two characters open no character row can hold a cycle chord, and reserving across both + spent three cells on every character row of the commonest client there is. `RailChordColumnTests` is + the pin, on the sidebar's column count *and* the pane rectangles. +- **The ordinal movers carry a zoom; the directional ones cannot.** A zoomed workspace realises + exactly one pane, so ⌃O, ⌃B N 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 "attention on one pane, keystrokes to another" defect again. `WorkspaceLayout.CarryZoomToFocused` re-points an existing zoom (it never starts or ends one) and both movers call it. ⌃←/→/↑/↓ do not and @@ -455,11 +516,12 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] 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 - exists only in a split (one pane, one possible answer) and spells panes `pane N`, because it used to call - the first pane `main` too and `▪ main main` is two meanings in one line. +- **A rail window row is `what` then `how you get there`, 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 second column + is the `⌥N` that goes to that window and exists only once there are two windows (one window, one place + to be). It used to be the hosting *pane*, from when ⌥N named panes — and before that it called the + first pane `main` too, and `▪ main main` is two meanings in one line. - **Focus is indicated by recolouring what is already drawn — never by spending a cell.** Per-pane NAWS is derived from the pane rectangle (`PaneOutputRects`), so a border, gutter or marker column that only the focused pane has would re-announce a different terminal size to every connected server on every diff --git a/docs/design/README.md b/docs/design/README.md index 0f7c82c..3513603 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -244,7 +244,9 @@ than the one currently focused, and `✕` on the active tab only. Tabs keep natu strip scrolls horizontally when they overflow, with a `»N` counter on the right. Right of that: `▯▯` split-right, `⌸` split-down, `⤢` zoom. -Below the strip, spawn windows show their capture pattern as a dim line: `⇱ capture ^\[public\]`. +Spawn windows used to show their capture pattern under the strip as a dim `⇱ capture ^\[public\]` +line. **They no longer do** — it was asked for and removed, and a spawn window now renders exactly +like any other output window. Which rule feeds a pane is F2's answer, not the pane's. Output view: timestamp column (optional), then styled spans. Trigger-highlighted lines get a 2-col left rule in the trigger's colour plus a tinted background. diff --git a/src/SharpMUTerm.Core/Automation/TriggerEngine.cs b/src/SharpMUTerm.Core/Automation/TriggerEngine.cs index 63e79d3..d01c5e9 100644 --- a/src/SharpMUTerm.Core/Automation/TriggerEngine.cs +++ b/src/SharpMUTerm.Core/Automation/TriggerEngine.cs @@ -6,19 +6,6 @@ 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 { @@ -26,7 +13,7 @@ public TriggerResult( StyledLine line, bool suppress, IReadOnlyList responses, - IReadOnlyList spawnTargets, + IReadOnlyList spawnTargets, IReadOnlyList scriptInvocations, IReadOnlyList matched) { @@ -47,8 +34,13 @@ public TriggerResult( /// Commands to send back to the server, in order. public IReadOnlyList Responses { get; } - /// The spawn windows this line should be routed to, with the rule that routed it. - public IReadOnlyList SpawnTargets { get; } + /// + /// The spawn windows this line should be routed to, each already resolved from its rule's + /// template with the match's capture groups substituted, so + /// one rule can feed a window per channel. A template that resolves to something that cannot be a + /// window name is refused and simply does not appear here. + /// + public IReadOnlyList SpawnTargets { get; } /// Script callbacks to invoke, with their match data. public IReadOnlyList ScriptInvocations { get; } @@ -226,7 +218,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; @@ -282,7 +274,7 @@ actions.HighlightBackground is not null || if (!string.IsNullOrEmpty(actions.SpawnTarget) && ResolveSpawnTarget(actions.SpawnTarget, match) is { } target) { - (spawns ??= new List()).Add(new SpawnRoute(target, trigger.Pattern)); + (spawns ??= new List()).Add(target); } if (!string.IsNullOrEmpty(actions.ScriptCallback)) @@ -313,7 +305,7 @@ 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()); } diff --git a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs index 233f88e..71a2882 100644 --- a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs +++ b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs @@ -3,7 +3,19 @@ namespace SharpMUTerm.Core.Commands; /// A character the command surface can switch to, with enough detail to label the entry. -public sealed record CharacterRef(string WorldName, string CharacterName, string SessionKey, bool Connected); +/// Whether its socket is up. +/// +/// Whether the client has a session for it in this run — which is a different question from +/// : a character you have switched to and then disconnected is open and +/// offline. It is what filters on, because the cycle keys +/// may not create anything. +/// +public sealed record CharacterRef( + string WorldName, + string CharacterName, + string SessionKey, + bool Connected, + bool Open = false); /// Live flags the catalog reads so stateful commands show their current value. /// Whether the focused character is logging. @@ -44,6 +56,30 @@ public sealed record SettingsEntry(string Title, string Id, string Shortcut); /// public static class CommandCatalog { + /// + /// The characters ⌥J and ⌥K walk, in the order the connection rail draws them. The one + /// definition of the cycle, read by the chord, by the rail's ⌥J/⌥K column and by the ⌃P + /// entries' subtitles, so a key and the label that advertises it cannot come to disagree. + /// + /// Only the characters that are already open. Switching to a character the client has never + /// opened creates a session and a window (the shell's SwitchToCharacter), and a cycle + /// key that opened a session per press would dial through a configuration by accident — the user + /// asked for a way to move between the characters they are using, not a way to start all of them. + /// The ones you have not opened are still one click away in the rail and one entry away in ⌃P, and + /// both of those are gestures that mean "open it". + /// + /// + /// Configuration order, not the order they were opened, because that is the order the sidebar lists + /// them in and the sidebar is where the cycle is read off. An order the rail did not draw would make + /// "the row below me" and "the next character" two different things. + /// + /// + public static List CharacterCycle(IReadOnlyList characters) + { + ArgumentNullException.ThrowIfNull(characters); + return characters.Where(c => c.Open).ToList(); + } + public static IReadOnlyList Build( Workspace workspace, IReadOnlyList characters, @@ -59,6 +95,19 @@ public static IReadOnlyList Build( var activeWindow = workspace.Layout.FocusedPane.ActiveTab; // GO TO — switch character, then jump to windows. + // + // The two neighbours in the character cycle carry its chords. Only those two: ⌥J and ⌥K move one + // step, so they are the honest answer for the rows either side of you and a lie for anybody + // further away. There is deliberately no direct-selection chord to put on the rest — the digit + // row is spent on windows and panes, and every remaining digit-bearing modifier (⌥⇧, ⌃⇧) has no + // legacy encoding at all on this terminal, which was measured rather than assumed. + var cycle = CharacterCycle(characters); + var here = cycle.FindIndex(c => c.SessionKey == focusedSessionKey); + var next = here >= 0 && cycle.Count > 1 ? cycle[(here + 1) % cycle.Count].SessionKey : null; + var previous = here >= 0 && cycle.Count > 1 + ? cycle[(here - 1 + cycle.Count) % cycle.Count].SessionKey + : null; + foreach (var character in characters) { if (character.SessionKey == focusedSessionKey) @@ -67,11 +116,27 @@ public static IReadOnlyList Build( } var state = character.Connected ? "connected" : "offline"; + var chord = character.SessionKey == next ? "⌥J · " + : character.SessionKey == previous ? "⌥K · " + : string.Empty; items.Add(new CommandItem( CommandGroup.GoTo, $"Switch to {character.CharacterName}", CommandIds.Character(character.SessionKey), - $"{character.WorldName} · {state}")); + $"{chord}{character.WorldName} · {state}")); + } + + // The chord each window's entry names, from the one place windows are numbered — the *focused* + // character's list, because that is what ⌥N indexes. A window belonging to somebody else has no + // chord from here and correctly gets none: pressing ⌥2 would reach the focused character's second + // window, not this entry, and an entry naming a key that goes elsewhere is the defect the + // numbering exists to prevent. Built as a lookup rather than read per entry because the entries + // walk the whole registry, including windows no pane holds. + var windowOrdinals = new Dictionary(StringComparer.Ordinal); + var reachable = workspace.WindowsFor(focusedSessionKey); + for (var i = 0; i < reachable.Count && i < CommandIds.WindowJumpDigits; i++) + { + windowOrdinals[reachable[i].Id] = i + 1; } foreach (var window in workspace.Windows) @@ -83,18 +148,24 @@ public static IReadOnlyList Build( var owner = window.SessionKey ?? "unowned"; var unread = window.Unread > 0 ? $" · {window.Unread} unread" : string.Empty; + + // The chord leads, because it is the part a reader is here to learn — the owner and the count + // describe the window and this says how to reach it without the surface at all. Windows past + // the ninth get the same subtitle minus the chord, rather than one naming a key that would do + // something else. + var chord = windowOrdinals.TryGetValue(window.Id, out var ordinal) ? $"⌥{ordinal} · " : string.Empty; items.Add(new CommandItem( CommandGroup.GoTo, $"Go to {window.Title}", CommandIds.Window(window.Id), - $"{owner}{unread}")); + $"{chord}{owner}{unread}")); } // WORLD // Both carry their chord. The surface is where this client is discovered from, and a key nobody // can find is the same as no feature — ⌃L's newline sat unused until it was reported missing. - items.Add(new CommandItem(CommandGroup.World, "Reconnect", "world:reconnect", "Alt+R")); - items.Add(new CommandItem(CommandGroup.World, "Disconnect", "world:disconnect", "⌃D")); + items.Add(new CommandItem(CommandGroup.World, "Reconnect", "world:reconnect", "⌥R")); + items.Add(new CommandItem(CommandGroup.World, "Disconnect", "world:disconnect", "⌥D")); // TERMINAL — stateful labels. items.Add(context.LoggingOn @@ -175,14 +246,14 @@ public static IReadOnlyList Build( // 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. + // way to make. The number is the one the move and drag overlays badge each pane with, so the entry + // and the digit a user is about to press in move mode are the same number. // - // 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. + // The chord is ⌃B N and no longer ⌥N: ⌥N names a *window* now, and a pane and a window are + // different destinations that cannot share one key. ⌃B is where the rest of the pane keymap lives. + // Only the first nine carry it, so a tenth pane gets an entry with no subtitle rather than one + // naming a key that does something else — the honest shape for a place only the mouse, ⌃O, the + // arrows and this entry can reach. var paneCount = workspace.Layout.Panes.Count; if (paneCount > 1) { @@ -192,7 +263,7 @@ public static IReadOnlyList Build( CommandGroup.Layout, $"Go to pane {n}", CommandIds.Pane(n), - n <= CommandIds.PaneJumpDigits ? $"⌥{n}" : null)); + n <= CommandIds.PaneJumpDigits ? $"⌃B {n}" : null)); } } diff --git a/src/SharpMUTerm.Core/Commands/CommandIds.cs b/src/SharpMUTerm.Core/Commands/CommandIds.cs index 05069a8..fe13a08 100644 --- a/src/SharpMUTerm.Core/Commands/CommandIds.cs +++ b/src/SharpMUTerm.Core/Commands/CommandIds.cs @@ -22,24 +22,48 @@ public static class CommandIds /// The id that activates the window named by . public static string Window(string windowId) => WindowPrefix + windowId; + /// + /// How many windows have a keyboard chord of their own: ⌥1–⌥9, counting + /// . Nine 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, so all nine are claimed whether or + /// not there is a window behind them. Windows past the ninth are still reachable by ⌃N, the tab + /// strip, the rail and the ⌃P surface; they simply have no chord, and no surface claims otherwise. + /// + /// The chord counts windows because that is what was asked for: "switch not just + /// characters, but captures, etc." — a capture window only ever shared a pane's number when it + /// happened to be that pane's active tab, so under a pane-numbered chord most of them were + /// unreachable. + /// + /// + public const int WindowJumpDigits = 9; + /// 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. + /// How many panes have a keyboard chord of their own: ⌃B 1–⌃B 9. On the prefix rather than + /// on Alt because ⌥N now names a window and one chord cannot mean two things. ⌃B is where every other + /// pane command already lives (split, zoom, close, cycle, move), so the ordinal one joining them costs + /// a reader nothing new to learn. Panes past the ninth are reachable by ⌃O, the arrows, the rail and + /// the ⌃P entry. + /// + /// Derived from rather than written down again. The two are the + /// same fact — one row of digits, which is what a keyboard has — and the reason they agree is the + /// keyboard rather than a coincidence anybody chose. This said "Nine to match + /// WindowJumpDigits" beside an independent literal 9, which is a documented invariant + /// nothing enforced: editing either would have left the comment claiming an agreement that had + /// stopped being true. + /// /// - public const int PaneJumpDigits = 9; + public const int PaneJumpDigits = WindowJumpDigits; /// /// The id that focuses the th pane, counting the way every surface in this /// 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. + /// creation order. The chord (⌃B N), the move and drag overlays' pane N 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 diff --git a/src/SharpMUTerm.Core/Session/SessionEvents.cs b/src/SharpMUTerm.Core/Session/SessionEvents.cs index 95e30bd..70eed50 100644 --- a/src/SharpMUTerm.Core/Session/SessionEvents.cs +++ b/src/SharpMUTerm.Core/Session/SessionEvents.cs @@ -11,15 +11,20 @@ public enum ConnectionState Faulted, } -/// A line routed to a named spawn window by a matching trigger. -public sealed class SpawnLineEventArgs(string target, string pattern, StyledLine line) : EventArgs +/// +/// A line routed to a named spawn window by a matching trigger. +/// +/// It carried the routing rule's Pattern as well, for the one consumer that wanted it: the dim +/// ⇱ capture … header a spawn pane drew over its output. That header is gone, and with it the +/// only reason the rule's identity ever left the trigger engine — a routed line is a line and where it +/// goes, and nothing downstream needs to know which rule sent it. +/// +/// +public sealed class SpawnLineEventArgs(string target, 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 b67719e..2135679 100644 --- a/src/SharpMUTerm.Core/Session/WorldSession.cs +++ b/src/SharpMUTerm.Core/Session/WorldSession.cs @@ -357,7 +357,7 @@ private void ProcessOutputLine(StyledLine line) foreach (var target in result.SpawnTargets) { - SpawnLine?.Invoke(this, new SpawnLineEventArgs(target.Target, target.Pattern, result.Line)); + SpawnLine?.Invoke(this, new SpawnLineEventArgs(target, result.Line)); } foreach (var response in result.Responses) diff --git a/src/SharpMUTerm.Core/Workspace/LayoutNode.cs b/src/SharpMUTerm.Core/Workspace/LayoutNode.cs index 37efdaa..2f96568 100644 --- a/src/SharpMUTerm.Core/Workspace/LayoutNode.cs +++ b/src/SharpMUTerm.Core/Workspace/LayoutNode.cs @@ -29,7 +29,7 @@ public abstract class LayoutNode /// 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 + /// into pane 3, and the digit that meant pane 2 stops meaning it. The numbering users see and /// press comes from , which is creation order. /// /// @@ -71,10 +71,10 @@ public PaneNode( /// 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. + /// no longer pushes it from pane 2 to pane 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 + /// list, which is what keeps the pane numbering contiguous when a pane in the middle closes — see /// . /// /// diff --git a/src/SharpMUTerm.Core/Workspace/Workspace.cs b/src/SharpMUTerm.Core/Workspace/Workspace.cs index ac63129..6a1b242 100644 --- a/src/SharpMUTerm.Core/Workspace/Workspace.cs +++ b/src/SharpMUTerm.Core/Workspace/Workspace.cs @@ -11,35 +11,122 @@ public sealed class Workspace { private readonly Dictionary _windows = new(StringComparer.Ordinal); + /// + /// The last handed out. Never reused, so a number is a + /// window's for as long as it is open — see for why the ordinal is then + /// taken from the sorted position rather than from this. + /// + private int _sequenceCounter; + /// Creates a workspace with a single main window in one pane. public Workspace(string mainWindowId = "main", string mainTitle = "Main", string? sessionKey = null) { ArgumentException.ThrowIfNullOrEmpty(mainWindowId); Layout = new WorkspaceLayout(new[] { mainWindowId }); - var main = new WorkspaceWindow(mainWindowId, mainTitle, WindowKind.Main, sessionKey); - _windows[main.Id] = main; + Register(new WorkspaceWindow(mainWindowId, mainTitle, WindowKind.Main, sessionKey)); } /// /// Rebuilds a workspace from a restored set of windows and a pre-built layout (session resume). /// The two are assumed consistent — every window id referenced by a pane tab should have a window. + /// + /// A window restored without a creation sequence is given one here, from the order it arrived + /// in. Windows are numbered by and a configuration + /// written before that field existed carries none, so without this every restored window would sort + /// equal and the numbering would be whatever the sort happened to do. The saved order is the + /// numbering such a workspace was saved under, which is why it is the right seed. Any window that + /// does carry a sequence keeps it, and unsequenced ones are numbered after the highest + /// already taken, so a half-migrated set cannot produce two windows with one number. Same shape, + /// and the same reasoning, as 's restoring constructor. + /// /// public Workspace(IEnumerable windows, WorkspaceLayout layout) { ArgumentNullException.ThrowIfNull(windows); Layout = layout ?? throw new ArgumentNullException(nameof(layout)); - foreach (var window in windows) + var restored = windows.ToList(); + foreach (var window in restored) { _windows[window.Id] = window; } + + _sequenceCounter = restored.Select(w => w.Sequence).DefaultIfEmpty(WorkspaceWindow.Unsequenced).Max(); + foreach (var window in restored.Where(w => w.Sequence <= WorkspaceWindow.Unsequenced)) + { + window.Sequence = ++_sequenceCounter; + } } /// The pane tree. public WorkspaceLayout Layout { get; } - /// Every known window, in insertion order. + /// + /// Every known window, whether or not a pane still holds it. The order is the registry's, which is a + /// dictionary's — fine for "what exists", and not what anything numbers windows by; see + /// . + /// public IReadOnlyCollection Windows => _windows.Values; + /// + /// The windows can reach, in creation order — the one order this + /// client numbers windows in. The Nth entry is the window ⌥N goes to while that character is + /// active, the ⌥N the connection rail prints on that window's row, and the chord the ⌃P + /// Go to … entry for it names. Those are three spellings of this index and there is + /// deliberately no second ordering for any of them to drift onto. + /// + /// Scoped to the active character, and re-based from 1 for each. It was global — every window + /// in the workspace in one sequence — and that failed on a real client the first day it was used: + /// with three characters sharing pane 1 as tabs, every character's row read ⌥1 because their + /// windows happened to be numbered from the same run. Nine digits also do not stretch across + /// everybody's windows; six windows over three characters already crowds them. Scoped, ⌥1 is + /// this character's main window whoever you are, ⌥2 their first capture, and the digits mean + /// the same thing wherever you stand. + /// + /// + /// An unowned window is in everybody's list. The web view belongs to no session and is + /// reachable from wherever you are, so it takes a digit under each character — a different one under + /// each, since it sits after that character's own windows. That is not a second numbering: this + /// method is exactly the set the rail draws window rows for (its owner filter admits a character's + /// own windows plus the unowned ones), so the digit on the screen and the digit in the chord are the + /// same list read twice. + /// + /// + /// Creation order, for the reason panes are in creation order. Any ordering that is a function + /// of where a window sits — its tab index, its pane's position — moves when something is + /// inserted before it, so dragging a channel one slot left would renumber every window after it and + /// ⌥4 would stop meaning what it meant while the user was doing something else entirely. A window's + /// number is fixed for as long as it is open, and a new one 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 the second of three windows and the survivors would be 1 and + /// 3, with ⌥2 doing nothing while two windows sat on the screen. + /// + /// + /// Placed, because ⌥N has to land somewhere. A window the registry still knows and no pane + /// holds is drawn in the rail as closed; giving it a number would spend a digit on a place + /// there is no way to go, and would shift every window after it for a row that names nothing. + /// + /// + /// + /// The active character, or null when nothing is active — which leaves the unowned windows, the only + /// ones there is anywhere to go to. + /// + public IReadOnlyList WindowsFor(string? sessionKey) => + _windows.Values + .Where(w => Layout.FindWindow(w.Id) is not null) + .Where(w => w.SessionKey is null || string.Equals(w.SessionKey, sessionKey, StringComparison.Ordinal)) + .OrderBy(w => w.Sequence) + .ToList(); + + /// Files a window in the registry, giving it the next creation sequence. + private WorkspaceWindow Register(WorkspaceWindow window) + { + window.Sequence = ++_sequenceCounter; + _windows[window.Id] = window; + return window; + } + /// Looks up a window by id, or null. public WorkspaceWindow? FindWindow(string id) => _windows.GetValueOrDefault(id); @@ -61,8 +148,7 @@ public WorkspaceWindow OpenWindow( return existing; } - var window = new WorkspaceWindow(id, title, kind, sessionKey); - _windows[id] = window; + var window = Register(new WorkspaceWindow(id, title, kind, sessionKey)); Layout.AddWindow(id, paneId); return window; } @@ -78,8 +164,7 @@ public WorkspaceWindow RouteSpawn(string target, string? sessionKey = null) var id = SpawnWindowId(target); if (!_windows.TryGetValue(id, out var window)) { - window = new WorkspaceWindow(id, target, WindowKind.Spawn, sessionKey); - _windows[id] = window; + window = Register(new WorkspaceWindow(id, target, WindowKind.Spawn, sessionKey)); Layout.AddWindow(id, activate: false); // spawns open in the background and accrue unread } diff --git a/src/SharpMUTerm.Core/Workspace/WorkspaceLayout.cs b/src/SharpMUTerm.Core/Workspace/WorkspaceLayout.cs index f60ad37..52f40b2 100644 --- a/src/SharpMUTerm.Core/Workspace/WorkspaceLayout.cs +++ b/src/SharpMUTerm.Core/Workspace/WorkspaceLayout.cs @@ -80,22 +80,25 @@ private static int ParsePaneCounter(string id) => /// /// 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. + /// pane N in the ⌃P Go to pane N entry, the move/drag overlays' label and badge, and the + /// ⌃B N chord; those are three spellings of this index and there is deliberately no second ordering + /// for any of them to drift onto. It is not what ⌥N counts — that chord names a window + /// (), which is a different set of things in a different order, + /// and the two are kept apart in the vocabulary as well: a pane is pane N, a window's chord is + /// ⌥N, and the sidebar prints only the second. /// /// 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. + /// point — dropping a window on the left edge of pane 2 made that pane into pane 3 — so the digit that + /// meant it silently stopped meaning it, 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. + /// directly would leave holes — close pane 2 of three and the survivors would be 1 and 3, with the + /// digit 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) @@ -132,10 +135,18 @@ public bool Focus(string paneId) /// 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. + /// It counts in order, because these are the two ordinal pane movers and + /// a user pressing them alternately must not be counting two different sequences: three presses of + /// ⌃O from pane 1 land where ⌃B 4 does, and where the move overlay's badge 4 is drawn. It used + /// to be tree order, which was the numbering at the time and stopped being it. + /// + /// + /// The partner it has to agree with changed, and the order did not. That sentence used to name + /// ⌥N, which counted panes; ⌥N counts windows now and the numbered pane jump moved to ⌃B N. + /// Cycling still counts panes in this order because every surface that still says pane N does + /// — the move and drag overlays, the split and resize refusals, the ⌃P entry and ⌃B N. Nothing about + /// the window chord gives this a reason to count differently; the window movers are ⌥N and ⌃N, and + /// they are a separate ladder. /// /// public void CycleFocus() diff --git a/src/SharpMUTerm.Core/Workspace/WorkspaceState.cs b/src/SharpMUTerm.Core/Workspace/WorkspaceState.cs index 0daac72..eb8bba0 100644 --- a/src/SharpMUTerm.Core/Workspace/WorkspaceState.cs +++ b/src/SharpMUTerm.Core/Workspace/WorkspaceState.cs @@ -8,7 +8,18 @@ public sealed class WorkspaceWindowState public WindowKind Kind { get; set; } = WindowKind.Main; public string? SessionKey { get; set; } public string? OwnerLabel { get; set; } - public string? CapturePattern { get; set; } + + /// + /// The window'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 whatever order the registry happens to enumerate in. + /// + /// — the value a configuration written before this field + /// existed deserialises to — means "nobody has numbered this window"; assigns + /// one on load from the saved order, which is the numbering such a workspace was saved under. + /// + /// + public int Sequence { get; set; } = WorkspaceWindow.Unsequenced; } /// @@ -27,9 +38,10 @@ public sealed class LayoutNodeState 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 pane's creation sequence (), which is what its pane N + /// number — and so the ⌃B N chord that goes there — 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 @@ -63,14 +75,17 @@ public static WorkspaceState Capture(Workspace workspace) ArgumentNullException.ThrowIfNull(workspace); return new WorkspaceState { - Windows = workspace.Windows.Select(w => new WorkspaceWindowState + // Ordered by the sequence rather than by the registry, so the file itself reads in the + // numbering the user sees — and so a configuration written before the field existed, whose + // sequences all deserialise to Unsequenced, is re-seeded from an order that means something. + Windows = workspace.Windows.OrderBy(w => w.Sequence).Select(w => new WorkspaceWindowState { Id = w.Id, Title = w.Title, Kind = w.Kind, SessionKey = w.SessionKey, OwnerLabel = w.OwnerLabel, - CapturePattern = w.CapturePattern, + Sequence = w.Sequence, }).ToList(), Root = CaptureNode(workspace.Layout.Root), FocusedPaneId = workspace.Layout.FocusedPaneId, @@ -85,7 +100,7 @@ public Workspace Restore() new WorkspaceWindow(w.Id, w.Title, w.Kind, w.SessionKey) { OwnerLabel = w.OwnerLabel, - CapturePattern = w.CapturePattern, + Sequence = w.Sequence, }); var layout = new WorkspaceLayout(BuildNode(Root), FocusedPaneId, ZoomedPaneId); diff --git a/src/SharpMUTerm.Core/Workspace/WorkspaceWindow.cs b/src/SharpMUTerm.Core/Workspace/WorkspaceWindow.cs index 60830c9..2d972c8 100644 --- a/src/SharpMUTerm.Core/Workspace/WorkspaceWindow.cs +++ b/src/SharpMUTerm.Core/Workspace/WorkspaceWindow.cs @@ -31,6 +31,32 @@ public WorkspaceWindow(string id, string title, WindowKind kind = WindowKind.Mai /// Stable window identity, unique within a workspace and referenced by pane tabs. public string Id { get; } + /// + /// When this window was created, as a per-workspace counter that only ever goes up. Windows are + /// numbered by this (see ) rather than by where their + /// tab happens to sit, so a window's ⌥N number is fixed for as long as it is open. + /// + /// It is a sort key and not the number itself. The number is the window's position in the sorted + /// list, which is what keeps ⌥1–⌥N contiguous when a window in the middle closes. + /// + /// + /// Deliberately the same mechanism as , and deliberately not the + /// registry's own order. is a dictionary's values: its order is + /// unspecified after a removal, so a client that closed a channel and opened another could find the + /// new one dropped into the freed slot — the number moving under a user who had not touched it, + /// which is the exact defect creation order was given to panes to prevent. + /// + /// + public int Sequence { get; internal set; } = Unsequenced; + + /// + /// The of a window nobody has numbered yet — one restored from a workspace + /// persisted before windows carried a creation order. replaces it on load, + /// from the order the windows were saved in, so an old configuration comes back numbered the way it + /// was left rather than scrambled. Same value and same reasoning as . + /// + public const int Unsequenced = 0; + /// Display title (rail entry, tab label). public string Title { get; set; } @@ -60,12 +86,6 @@ public WorkspaceWindow(string id, string title, WindowKind kind = WindowKind.Mai /// Whether the window holds a typed-but-unsent input draft (the marker). public bool HasUnsentInput { get; internal set; } - /// - /// For a window, the trigger pattern that routes lines here — shown - /// as a dim ⇱ capture … line under the tab strip. Null for windows with no capture rule. - /// - public string? CapturePattern { get; set; } - /// /// The display name of the character this window belongs to (typically its main window's title), /// used to prefix a child window's tab as Owner: Name so a spawn window scattered into another diff --git a/src/SharpMUTerm.Core/Workspaces/RailModel.cs b/src/SharpMUTerm.Core/Workspaces/RailModel.cs index 8e29ef3..14901ad 100644 --- a/src/SharpMUTerm.Core/Workspaces/RailModel.cs +++ b/src/SharpMUTerm.Core/Workspaces/RailModel.cs @@ -45,23 +45,28 @@ public sealed record RailRow( bool Unsent = false, bool Closed = false, int Unread = 0, - string? Pane = null, + string? Chord = null, string? Target = null); /// 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, its windows, and the pane its -/// session lives in. +/// A character in the rail: connection/active state, unread total, its windows, and the chord that +/// goes to them. /// -/// 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. +/// is what makes the window numbering visible across characters. Only +/// the active character's windows are listed (see ), so without +/// this field a reader looking at character A could see A's own ⌥Ns and nothing else — B's +/// windows were on the screen, numbered, and one keystroke away, and nothing anywhere said which +/// keystroke. The number is useless if it cannot be read off the sidebar, and switching character is +/// the commonest thing these nine chords are for. +/// +/// +/// It is the chord of the character's own window — their session window when they have one — +/// so pressing it goes to that character, and so a character row and the window row beneath it that +/// names the same window carry the same digit. Null when the workspace holds a single window (one +/// window is no information) or the character has none open. /// /// public sealed record RailCharacter( @@ -71,14 +76,19 @@ public sealed record RailCharacter( bool Active, int Unread, IReadOnlyList Windows, - string? Pane = null); + string? Chord = null); /// -/// A window in the rail: its title, the workspace id a click activates, hosting pane (or closed), -/// unread, and unsent marker. The id is required rather than optional because a row the rail draws -/// as a destination and cannot name is a row that silently does nothing when clicked. +/// A window in the rail: its title, the workspace id a click activates, the ⌥N chord that goes +/// to it (or none), unread, and unsent marker. The id is required rather than optional because a row +/// the rail draws as a destination and cannot name is a row that silently does nothing when clicked. +/// +/// The second column used to be the hosting pane, back when ⌥N named a pane. It names the +/// window itself now, which is the row it is drawn on — so the number beside a window is the number +/// that goes to that window, rather than to whatever else happens to share its pane. +/// /// -public sealed record RailWindow(string Title, string Id, string? Pane, int Unread, bool HasUnsent, bool Closed); +public sealed record RailWindow(string Title, string Id, string? Chord, int Unread, bool HasUnsent, bool Closed); /// /// Projects the worlds/characters/windows tree into a flat list of s for the @@ -88,13 +98,13 @@ 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 character row carries its own chord (), active or not, +/// and that is the one thing here that is not scoped to the active character. It has to be: the window +/// numbering is a property of the workspace rather than of whoever is in front, ⌥3 is a character switch +/// as much as a window 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 @@ -141,7 +151,7 @@ public static IReadOnlyList Build(IReadOnlyList worlds) Active: character.Active, Connected: character.Connected, Unread: character.Unread, - Pane: character.Pane, + Chord: character.Chord, Target: CommandIds.Character(character.SessionKey))); if (!character.Active) @@ -159,7 +169,7 @@ public static IReadOnlyList Build(IReadOnlyList worlds) Unsent: window.HasUnsent, Closed: window.Closed, Unread: window.Unread, - Pane: window.Pane, + Chord: window.Chord, // Closed windows get a target too. The shell answers a "go to" for a window no // pane holds by saying it is not open any more, which is what the row's own // "closed" label already tells you — and it is the same answer the ⌃P entry for diff --git a/src/SharpMUTerm.Tui/CaptureLineRenderer.cs b/src/SharpMUTerm.Tui/CaptureLineRenderer.cs deleted file mode 100644 index 287950f..0000000 --- a/src/SharpMUTerm.Tui/CaptureLineRenderer.cs +++ /dev/null @@ -1,17 +0,0 @@ -using static SharpMUTerm.Tui.MarkupText; - -namespace SharpMUTerm.Tui; - -/// -/// Renders a spawn window's capture line — the dim ⇱ capture ^\[public\] row shown under the -/// tab strip that tells you which trigger pattern routes lines into the window (design pane area). -/// Pure so the markup is unit-testable without a terminal. -/// -internal static class CaptureLineRenderer -{ - public static string Line(string pattern) - { - ArgumentNullException.ThrowIfNull(pattern); - return $"[dim]{Glyphs.Capture} capture {Escape(pattern)}[/]"; - } -} diff --git a/src/SharpMUTerm.Tui/DemoScene.cs b/src/SharpMUTerm.Tui/DemoScene.cs index 5781ba3..d72b87f 100644 --- a/src/SharpMUTerm.Tui/DemoScene.cs +++ b/src/SharpMUTerm.Tui/DemoScene.cs @@ -262,7 +262,6 @@ private static WorkspaceState BuildLastSession() Kind = WindowKind.Spawn, SessionKey = ActiveSessionKey, OwnerLabel = "Corvid", - CapturePattern = @"^\[Chat\]", }, }, Root = new LayoutNodeState diff --git a/src/SharpMUTerm.Tui/MacroKeys.cs b/src/SharpMUTerm.Tui/MacroKeys.cs index feb7d0b..2b6da3c 100644 --- a/src/SharpMUTerm.Tui/MacroKeys.cs +++ b/src/SharpMUTerm.Tui/MacroKeys.cs @@ -67,12 +67,13 @@ internal static class MacroKeys 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. + /// The window 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 + /// window. /// - internal static int? PaneJumpNumber(ConsoleKey key) => - key >= ConsoleKey.D1 && key <= ConsoleKey.D0 + CommandIds.PaneJumpDigits + internal static int? WindowJumpNumber(ConsoleKey key) => + key >= ConsoleKey.D1 && key <= ConsoleKey.D0 + CommandIds.WindowJumpDigits ? key - ConsoleKey.D0 : null; @@ -84,14 +85,18 @@ 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++) + // ⌥1–⌥9 — jump to a numbered window. Generated rather than written out nine times so the digit, + // the window 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.JumpToWindow). Every one of the nine is claimed, in range or not — an unclaimed + // digit would fall through to it. + // + // The numbered *pane* jump is not here: it is ⌃B N, a key on the prefix keymap rather than a + // global shortcut, so it never reaches this list and F4 reports the bare digits as the prompt's. + for (var n = 1; n <= CommandIds.WindowJumpDigits; n++) { - claims.Add(new AppShortcut(ConsoleModifiers.Alt, ConsoleKey.D0 + n, $"goes to pane {n}")); + claims.Add(new AppShortcut(ConsoleModifiers.Alt, ConsoleKey.D0 + n, $"goes to window {n}")); } return claims.ToArray(); @@ -101,19 +106,46 @@ private static AppShortcut[] BuildAppShortcuts() { new(ConsoleModifiers.Control, ConsoleKey.Q, "asks whether to quit"), new(ConsoleModifiers.Control, ConsoleKey.N, "picks the next window"), - new(ConsoleModifiers.Control, ConsoleKey.Tab, "picks the next window"), + // ⌃Tab is deliberately absent, and its absence is measured rather than assumed: a terminal writes + // 0x09 for it, byte-identical to a bare Tab (read off a pty with `kitten @ send-key`), so the + // parser reports ConsoleKey.Tab with no Control bit and this claim could never once have matched. + // It is the ⌃H/⌃I/⌃M/⌃J trap one key over — and worse than dead, because Claimed is consulted + // before anything else, so F4 was telling users a chord was taken that cannot arrive at all. The + // byte is recorded in ControlBytes instead, which is what makes Verdict say so. ⌃N is the chord. new(ConsoleModifiers.Control, ConsoleKey.W, "closes the window"), new(ConsoleModifiers.Control, ConsoleKey.O, "cycles the panes"), new(ConsoleModifiers.Control, ConsoleKey.P, "opens the command surface"), new(ConsoleModifiers.Control, ConsoleKey.B, "arms the pane prefix"), new(ConsoleModifiers.Control, ConsoleKey.F, "freezes the pane"), new(ConsoleModifiers.Control, ConsoleKey.R, "searches the command history"), - // The connection pair. ⌃D is the terminal's own hang-up chord and is spent on the action it - // idiomatically means; Alt+R spells "Reconnect" one modifier over from the ⌃R this app has - // already given to the history surface. Both act at once — ⌃Q is the only key in this client - // that asks anything. - new(ConsoleModifiers.Control, ConsoleKey.D, "disconnects the focused character"), + // The connection pair, and it is a pair: ⌥D disconnects, ⌥R reconnects. One modifier, two + // letters that spell the two words, opposite actions that look opposite on the keyboard. + // + // Disconnect was ⌃D, and the justification given for the split was two separate ones bolted + // together — "⌃D is the terminal's own hang-up chord" and "Alt+R is one modifier over from the ⌃R + // the history surface already has". Each is fine alone; together they made a reader learn two + // modifiers for one concept, which is what got reported ("It's 'CTRL-D' to disconnect, but + // 'ALT-R' to reconnect? Why are they not both under Alt?"). A pair of opposite actions that do + // not share a modifier is not a pair. + // + // ⌃D is *released* rather than kept as a second binding. A second key for one action has to be + // advertised or it is a secret, and advertising it makes every surface that lists chords — F4, + // ⌃P, --help — carry one action twice and read as two features. Letting it go also hands it back + // to the user: Verdict("Ctrl+D") now says it fires, so a macro can be bound to it. Nothing in the + // framework takes it — HandleMoveInput is gated on IsMovable, which this app sets false, and it + // only acts on the arrows and X anyway — and InputBarControl's Ctrl table has no D. + // + // Both act at once; ⌃Q is the only key in this client that asks anything. + new(ConsoleModifiers.Alt, ConsoleKey.D, "disconnects the focused character"), new(ConsoleModifiers.Alt, ConsoleKey.R, "reconnects the focused character"), + // The character cycle. Letters and not digits because the digit row is spent (⌥N windows, ⌃B N + // panes) and there is no third digit-bearing modifier this terminal delivers: read off a pty, + // kitty writes ⌥⇧1 as `CSI 49;4u` and ⌃⇧N as `CSI 110;6u` — kitty-keyboard-protocol sequences + // this client's parser does not decode — while ⌥j and ⌥k are a plain `ESC j` / `ESC k`. + // Down and up, because the rail lists characters down the sidebar and that is where the chords + // are read off. + new(ConsoleModifiers.Alt, ConsoleKey.J, "goes to the next character"), + new(ConsoleModifiers.Alt, ConsoleKey.K, "goes to the previous character"), new((ConsoleModifiers)0, ConsoleKey.F2, "opens Triggers"), new((ConsoleModifiers)0, ConsoleKey.F3, "opens Aliases"), new((ConsoleModifiers)0, ConsoleKey.F4, "opens this screen"), @@ -235,6 +267,13 @@ internal static MacroKeyVerdict Verdict(string? descriptor) : new MacroKeyVerdict(MacroKeyDelivery.Taken, "unmodified, this belongs to the prompt"); } + // A named key whose Ctrl form the terminal has already spent — Tab is the one that is not a + // letter, and it is here rather than in Chord() because Chord() only sees letters and digits. + if (parts.Ctrl && ControlBytes.TryGetValue(parts.Key, out var spentOn)) + { + return Never($"the terminal sends {spentOn} instead"); + } + return Never("only F-keys and Ctrl/Alt chords arrive"); } @@ -244,7 +283,7 @@ internal static MacroKeyVerdict Verdict(string? descriptor) /// 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 + /// which is why the window-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) @@ -277,7 +316,7 @@ private static MacroKeyVerdict Chord(MacroKeyParts parts) } /// - /// What a terminal actually writes for Ctrl+each digit — the reason the pane-jump chord is + /// What a terminal actually writes for Ctrl+each digit — the reason the numbered-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 @@ -310,13 +349,19 @@ private static MacroKeyVerdict Chord(MacroKeyParts parts) ["9"] = "a bare 9", }; - /// The letters whose control byte the terminal has already spent on another key. + /// + /// The keys whose control byte the terminal has already spent on another key. Every entry is a byte + /// observed on a pty, not a guess: the four letters, and Tab itself — ⌃Tab is + /// 0x09, which is what a bare Tab is, so it is the same trap wearing a different name and was + /// claimed as a shortcut here until it was measured. + /// private static readonly Dictionary ControlBytes = new(StringComparer.Ordinal) { ["I"] = "Tab", ["M"] = "Enter", ["J"] = "Enter", ["H"] = "Backspace", + ["Tab"] = "a bare Tab", }; /// The keys that are navigation rather than text — bindable, but only as part of a chord. diff --git a/src/SharpMUTerm.Tui/PrefixPanel.cs b/src/SharpMUTerm.Tui/PrefixPanel.cs index 90ee9ff..95c0ba4 100644 --- a/src/SharpMUTerm.Tui/PrefixPanel.cs +++ b/src/SharpMUTerm.Tui/PrefixPanel.cs @@ -106,7 +106,7 @@ internal static class PrefixPanel /// brackets reads as a direction and the arrows are what a reader reaches for — so they work too /// (SharpMUTermApp.PrefixKey) and the strip says so rather than leaving it to be discovered. /// - internal const string StripKeys = "| - z o x b m i < > ← →"; + internal const string StripKeys = "| - z o 1–9 x b m i < > ← →"; /// /// How the way out is named, everywhere it is named. The prefix used to have none: Esc worked only by @@ -148,6 +148,12 @@ internal static IReadOnlyList Entries(PrefixFacts facts) new PrefixEntry("-", "split this pane top and bottom", needsTab), new PrefixEntry("z", facts.Zoomed ? "unzoom this pane" : "zoom this pane full-window", needsPane), new PrefixEntry("o", "go to the next pane", needsPane), + + // The ordinal pane mover, beside the cycle it counts with. It moved here from ⌥1–⌥9 when that + // was given to *windows*: a pane and a window are different destinations and one key cannot + // name both. Blocked on the same fact as zoom and cycle — with one pane there is nowhere to + // go — and listed as the range rather than nine rows, which is how the reorder pair is listed. + new PrefixEntry("1–9", "go to that numbered pane", needsPane), new PrefixEntry("x", "close this tab", facts.ActiveWindowIsMain ? MainWindowStays : null), new PrefixEntry("b", facts.RailCollapsed ? "show the connection rail" : "hide the connection rail", null), new PrefixEntry("m", "move this window to another pane", null), diff --git a/src/SharpMUTerm.Tui/Program.cs b/src/SharpMUTerm.Tui/Program.cs index 5e0d17e..57db260 100644 --- a/src/SharpMUTerm.Tui/Program.cs +++ b/src/SharpMUTerm.Tui/Program.cs @@ -294,11 +294,23 @@ private static void WriteUsage(TextWriter usage) // 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."); + // + // It says *window* and not pane. The chord counted panes until a capture window sharing a pane + // turned out to be the thing people wanted to reach; the numbered pane jump is ⌃B N now, and both + // are named here so a reader is not left thinking one replaced the other silently. + // The page names chords in ASCII throughout — it is printed before the TUI starts, into whatever + // is on the other end of stdout, which may be a pipe or a terminal with no ⌥ glyph. But where a + // sentence claims to reproduce what is *on the screen*, it has to be verbatim: these quotes are + // the sidebar's own cells, and a reader who searches the sidebar for "Alt+2" finds nothing. + usage.WriteLine("Windows: Alt+1..Alt+9 go straight to a numbered window and bring it forward — a character's"); + usage.WriteLine(" own window, a capture window, the web view. The numbers are the ones the sidebar"); + usage.WriteLine(" prints beside each window ('⌥2', '⌥3'...), in the order the windows were opened."); + usage.WriteLine(" It says so when there is no window with that number."); + usage.WriteLine(" Ctrl+digit is not offered: no terminal sends a distinct Ctrl+digit — 3 and 8 arrive"); + usage.WriteLine(" as Escape and Backspace, and 1, 9 and 0 as the bare digit."); + usage.WriteLine("Panes: Ctrl+B then 1..9 goes to a numbered pane instead — the pane itself, whatever tab it"); + usage.WriteLine(" is showing. Panes are numbered in the order they were created, which is the numbering"); + usage.WriteLine(" Ctrl+O counts in and the one the move overlay (Ctrl+B m) badges each pane with."); // 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 @@ -319,8 +331,11 @@ private static void WriteUsage(TextWriter usage) // spelt out because a reconnect on a live connection is a disconnect with a dial after it and the // reader has to know that before pressing it. usage.WriteLine("World: Alt+R reconnects the focused character (drops the connection and redials it at once);"); - usage.WriteLine(" Ctrl+D disconnects it at once. Neither asks. With nothing connected, each says so."); - usage.WriteLine("Panes: Ctrl+B then | - z o x b m i < > splits, zooms, closes and moves; Esc or Ctrl+B"); + usage.WriteLine(" Alt+D disconnects it at once. Neither asks. With nothing connected, each says so."); + usage.WriteLine(" Alt+J and Alt+K move to the next and previous character you have open — the two the"); + usage.WriteLine(" sidebar marks '⌥J' and '⌥K'. They never open one that is not; the sidebar and"); + usage.WriteLine(" Ctrl+P do that."); + usage.WriteLine("Panes: Ctrl+B then | - z o 1-9 x b m i < > splits, zooms, goes and moves; Esc or Ctrl+B"); usage.WriteLine(" cancels, and pausing after Ctrl+B pops a panel naming each key. Or drag a tab"); usage.WriteLine(" strip onto another pane — middle drops it as a tab, an edge splits there."); } diff --git a/src/SharpMUTerm.Tui/RailRenderer.cs b/src/SharpMUTerm.Tui/RailRenderer.cs index cf3ff74..268b55b 100644 --- a/src/SharpMUTerm.Tui/RailRenderer.cs +++ b/src/SharpMUTerm.Tui/RailRenderer.cs @@ -8,7 +8,7 @@ namespace SharpMUTerm.Tui; /// /// Renders rows into markup lines for the connection rail: a header, worlds /// with an accent spine, characters with a connected dot and active marker, and windows with -/// unread/unsent/pane detail. Pure so the rail layout is unit-testable. +/// unread/unsent detail and the chord that goes to them. Pure so the rail layout is unit-testable. /// /// A row carrying a is wrapped in a [link=…] span, which is how /// clicking it switches. The span is invisible chrome: [link=…] emits no cell, so the rail @@ -34,30 +34,47 @@ internal static class RailRenderer public static List Render(IReadOnlyList rows, int maxWidth = int.MaxValue) { ArgumentNullException.ThrowIfNull(rows); + + // Whether the chord column is drawn, decided once for the whole rail and **per row kind**. + // + // Reserved, because a field that costs a cell only when it has something in it resizes the + // sidebar — and the sidebar's width comes out of the pane area, which every connected server is + // told over NAWS (see UnsentFieldWidth for the reported instance of that bug). Within a kind the + // width therefore does not move as unread arrives, as a draft is typed, or as the ⌥J/⌥K pair + // travels from row to row on a character switch. + // + // Per kind, because the two kinds carry different mechanics and one of them is often empty: a + // window row's chord is the ⌥N numbering, a character row's is the cycle, and with fewer than two + // characters open no character row can have one at all. Reserving across both spent three cells + // on every character row of a client with one character open — which is the common case, and the + // opposite of the complaint this layout change exists to answer. + var reserveWindow = rows.Any(r => r.Kind == RailRowKind.Window && r.Chord is { Length: > 0 }); + var reserveCharacter = rows.Any(r => r.Kind == RailRowKind.Character && r.Chord is { Length: > 0 }); + var lines = new List(rows.Count); foreach (var row in rows) { - lines.Add(Fit(row, maxWidth, RenderRow)); + lines.Add(Fit(row, maxWidth, r => RenderRow(r, reserveWindow, reserveCharacter))); } return lines; } - private static string RenderRow(RailRow row) => row.Kind switch + private static string RenderRow(RailRow row, bool reserveWindow, bool reserveCharacter) => row.Kind switch { RailRowKind.Header => $"[dim]┌ {Glyphs.Connections} CONNECTIONS[/]", RailRowKind.World => Link(row, $"[{Accent(row)}]▚[/] [bold]{Escape(row.Label)}[/]"), RailRowKind.Host => $"{Indent(row)}[dim]{Escape(row.Label)}[/]", RailRowKind.Empty => $"{Indent(row)}{Link(row, $"[dim]{Escape(row.Label)}[/]")}", - RailRowKind.Character => Character(row), - RailRowKind.Window => Window(row), + RailRowKind.Character => Character(row, reserveCharacter), + RailRowKind.Window => Window(row, reserveWindow), _ => Escape(row.Label), }; /// /// Renders a row, and if it does not fit, renders it again with its shortened /// by however much it overran. The label is the only part that may give ground: the accent spine, the - /// connected dot, the unread count, the ✎ pen and the pane column are all information. Measured with the + /// connected dot, the unread count, the ✎ pen and the chord column are all information. Measured with the /// app's own , because that is the measure the sidebar's width is /// derived from — anything else could agree here and disagree where it matters. /// @@ -65,9 +82,10 @@ public static List Render(IReadOnlyList rows, int maxWidth = in /// one cell whatever it says (the spine, the ● / ○ dot, the ▸ active marker, the ▪ bullet) or occupies a /// reserved field that is blank when it has nothing to say (, /// ). That is what stops a keystroke or a line of output resizing the - /// sidebar and, through it, every connected server's terminal size. The pane column is the one - /// remaining variable part and it is deliberately left so: it exists only in a split and appears when - /// the layout changes, which is already a relayout that re-reports every pane. + /// sidebar and, through it, every connected server's terminal size. The chord column is the one + /// remaining variable part and it is deliberately left so: it is absent only while the workspace holds + /// a single window, and it appears when a second one opens — which is a structural change that + /// rebuilds the pane area and re-reports every pane anyway. /// /// private static string Fit(RailRow row, int maxWidth, Func render) @@ -119,30 +137,68 @@ public static List RenderCollapsed(IReadOnlyList rows) } /// - /// 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. + /// A character row: its chord, the active marker, the connected dot, the name and its unread total. /// - /// 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). + /// The chord leads, in the same column the window rows put theirs. It is ⌥J on the + /// character one step forward in the cycle and ⌥K one step back — the only two that are a + /// single keystroke away — and blank on everybody else, including the row you are standing on, whose + /// already says so. It used to be the chord of that character's own window, from + /// when window numbering ran across the whole workspace; scoped to the active character, that printed + /// ⌥1 against every character on the screen, which is the confusion this replaced. /// /// - /// 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. + /// Reserved, and on the left. The field is cells whether or not + /// there is a chord, so a row does not change width when the cycle moves — the same rule the pen and + /// the unread count follow, and for the same reason: the rail's width is its widest row, the sidebar + /// takes its columns out of the pane area, and every connected server is told its pane's size. And it + /// is on the left because the reader's complaint was the gap: with the chord at the end of the row it + /// sat behind two blank status fields, five cells of nothing between a window's name and the key that + /// goes to it. /// /// - private static string Character(RailRow row) + private static string Character(RailRow row, bool reserve) { 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; - return $"{Indent(row)}{Link(row, $"{marker} [{Accent(row)}]{dot}[/] {name}{UnreadField(row.Unread)}{tail}")}"; + return $"{Indent(row)}{ChordField(row.Chord, reserve)}" + + Link(row, $"{marker} [{Accent(row)}]{dot}[/] {name}{UnreadField(row.Unread)}"); + } + + /// + /// Cells the sidebar keeps for a row's chord, whether or not it has one. Three: the sigil, one + /// digit or letter, and the space that separates it from the row's own glyph. + /// + /// Reserved for the reason and are — a + /// cell that appears only when there is something to say resizes the sidebar, and the sidebar's width + /// comes out of the panes, which every connected server is told over NAWS. This one moves on events a + /// reader does not think of as structural: a capture window opening past the ninth loses its chord, and + /// the ⌥J/⌥K pair moves from row to row on every character switch. + /// + /// + private const int ChordFieldWidth = 3; + + /// + /// A chord in its fixed-width field, the same width in blanks when this row has none, or nothing at + /// all when no row in the rail has one. Outside the row's span deliberately: it is + /// chrome that names a key rather than part of the thing the row points at, so keeping it out leaves + /// the click target on the name. + /// + private static string ChordField(string? chord, bool reserve) + { + if (!reserve) + { + return string.Empty; + } + + if (chord is not { Length: > 0 } value) + { + return new string(' ', ChordFieldWidth); + } + + var escaped = Escape(value); + var pad = Math.Max(1, ChordFieldWidth - SharpMUTermApp.MarkupWidth(escaped)); + return $"[dim]{escaped}[/]{new string(' ', pad)}"; } /// @@ -189,28 +245,37 @@ private static string UnreadField(int unread) => : $"[{UnreadBadge.Tint}]{UnreadBadge.Format(unread).PadLeft(UnreadFieldWidth)}[/]"; /// - /// A window row: what the window is, then — when there is anything to say — where it is. + /// A window row: how you get to it, then what it is, then its badges. + /// + /// The chord leads, in the same reserved column the character rows use. That is the reported + /// complaint — "there is still way too much room after a window's name before it hits 'alt-1'". The + /// gap was the two badge fields, which are blank far more often than not and sat between the name and + /// the key. They cannot be removed (see : a field that costs a cell only + /// when it has something to say resizes the sidebar on a keystroke or a line of output, and the + /// sidebar's width comes out of the panes, which every connected server is told over NAWS) — so the + /// chord moved to the front instead, where nothing blank separates it from the name it belongs to, + /// and the badges ended up at the right edge where status belongs. The row's measured width is + /// unchanged by the move; only the order is. + /// + /// + /// The column earns its place only once the character holds a second window: with one, there is one + /// place to be, so the model leaves null and the field is not drawn at + /// all. A window past the ninth has no chord and shows blanks, which is the honest reading — the row + /// is still clickable and still reachable by ⌃N and the tab strip, and a column claiming a key that + /// would go somewhere else is the one thing this numbering exists to prevent. + /// /// - /// The second column is the hosting pane, and it earns its place only in a split: with one pane there - /// is one place a window can be, so the model leaves null and nothing is - /// drawn. That is not only tidiness. The three spaces of the gap were emitted unconditionally, so a - /// single-pane rail measured three cells wider than its content — and the rail's width is taken out of - /// the pane area, which is what every connected session is told over NAWS. + /// closed is a state rather than a destination, so it is drawn where the badges are rather than + /// in the chord's field: a closed window has no chord, and putting the word where a key goes would be + /// the two-meanings-in-one-column mistake again. /// - /// closed is a state rather than a place, so it always shows. /// - private static string Window(RailRow row) + private static string Window(RailRow row, bool reserve) { var name = Escape(row.Label); - var where = row.Closed ? "closed" : row.Pane is { Length: > 0 } pane ? pane : null; - - // 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}")}"; + var closed = row.Closed ? " [dim]closed[/]" : string.Empty; + return $"{Indent(row)}{ChordField(row.Closed ? null : row.Chord, reserve)}" + + Link(row, $"[dim]▪[/] {name}{Unsent(row.Unsent)}{UnreadField(row.Unread)}{closed}"); } /// diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index 8acd555..a40addf 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -703,7 +703,12 @@ public string RenderSnapshot(string? view = null) DispatchCommand("term:timestamps-on"); } - // Activate the Chat spawn window so its dim "⇱ capture …" header renders under the tab strip. + // Bring the Chat spawn window to the front, so one frame shows a *routed* window as the active tab + // with the character's own window sitting behind it in the same strip. It used to exist for the dim + // "⇱ capture …" header a spawn pane drew over its output; that header is gone (the user asked for + // it to go) and the view is not, because two other suites drive it — a spawn tab being closable + // (PaneTabCloseTests) and the timestamp gutter reaching a window whose history is markup and + // nothing else (TimestampGutterTests) are both claims about a spawn window in front. if (string.Equals(view, "spawn", StringComparison.OrdinalIgnoreCase)) { _workspace.ActivateWindow(Workspace.SpawnWindowId("Chat")); @@ -984,6 +989,40 @@ public string RenderSnapshot(string? view = null) _shortcuts[(ConsoleModifiers.Control, ConsoleKey.Q)](); } + // Two characters genuinely *open*, which is the one state the ⌥J/⌥K column can be seen in — and + // it cannot be faked the way `connections` fakes its dots. The cycle walks the characters this + // client holds a session for (CommandCatalog.CharacterCycle), so `_demoConnectedKeys` does not + // reach it: that set makes the header's fraction and the rail's dots say "connected" and opens + // nothing. These are real sessions, bound and not dialled, which is exactly what the shell has + // between opening a character and its socket coming up. + if (string.Equals(view, "characters", StringComparison.OrdinalIgnoreCase)) + { + // Every view here runs against whatever configuration is loaded, and `--demo-config` is the + // caller's choice rather than this method's — so a snapshot of a machine with no worlds, or + // with a first world nobody has put a character in, reaches this code. The keys are gathered + // through the same guard that opens them and the frame is posed from that list, so there is + // no second, unguarded way to name the first character. (`quit` above takes the same care: + // `ElementAtOrDefault(1) is { Characters.Count: > 0 }`.) + var opened = _config.Worlds + .Where(w => w.Characters.Count > 0) + .Take(2) + .Select(w => $"{w.Name}.{w.Characters[0].Name}") + .ToList(); + + foreach (var key in opened) + { + SwitchToCharacter(key); + } + + // Back to the first, so the frame shows a character with the marker on it and its neighbour's + // chord rather than the arbitrary place the loop finished. + if (opened.Count > 0) + { + SwitchToCharacter(opened[0]); + RebuildPaneArea(); + } + } + // The deletion review, reached the only way a user can reach it: open F5, take the selected world // out with Delete, then leave with Esc. Everything in the frame — the wording, the count of // characters going with the world, which button ⏎ is standing on — is what the real keys produce. @@ -1712,7 +1751,7 @@ private void BindSession(WorldSession session, string? windowId = null) UpdateStatus(); } }); - session.SpawnLine += (_, e) => OnUi(() => OnSpawnLine(session, e.Target, e.Pattern, e.Line)); + session.SpawnLine += (_, e) => OnUi(() => OnSpawnLine(session, e.Target, 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 @@ -2144,16 +2183,11 @@ private void OnLine(WorldSession session, string windowId, StyledLine line) /// which world a link clicked in a spawn window sends to by it. /// /// - private void OnSpawnLine(WorldSession session, string target, string pattern, StyledLine line) + private void OnSpawnLine(WorldSession session, string target, StyledLine line) { var existed = _workspace.FindWindow(Workspace.SpawnWindowId(target)) is not null; var window = _workspace.RouteSpawn(target, session.SessionKey); - // 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); @@ -2941,32 +2975,54 @@ private void MoveFocus(PaneDirection direction) } /// - /// Goes to the th pane and brings it to the front — ⌥1–⌥9, and the ⌃P - /// Go to pane N entries. + /// Goes to the th window and brings it to the front — ⌥1–⌥9. + /// + /// What it targets, and why it is not the pane. The request was "Alt-1-9 to switch between + /// characters… I want it to be able to go between tabs? Panes? Whichever it is that allows me to + /// switch not just characters, but captures, etc." The thing that answers all of those at once is the + /// window: a character's main window, a capture window, the web view. It used to be the + /// pane, and a pane is a container — a capture sharing a pane with its character's main window had no + /// number of its own and was reachable only when it happened to be that pane's active tab, which is + /// exactly the half of the request the pane chord could not serve. + /// + /// + /// The number is the rail's number. Windows are counted in + /// order (creation order), which is exactly the set and + /// the order the rail draws window rows in, and what the ⌃P Go to … entries carry in their + /// subtitles. A key that lands somewhere other than the label says is worse than no key, and this + /// repository has already paid for two spellings of one thing once (▪ main main). + /// + /// + /// The numbering is scoped to the active character, and re-based from 1 for each. It was + /// global, and that failed on a real client the first day it was used: three characters sharing one + /// pane as tabs, and the sidebar giving all three of them ⌥1 — "I am looking for the + /// characters to have different numbers? Am I not communicating something right here?" Nine digits + /// also do not stretch over everybody's windows; six over three characters already crowds them. + /// Scoped, ⌥1 is this character's own window whoever you are, ⌥2 their first capture, and a + /// digit means the same kind of thing wherever you stand. Characters are reached by the ⌥J/⌥K cycle + /// instead (), which is the trade the user chose when the two were put + /// side by side. + /// + /// + /// Stable within a character: the order is creation order and never position, so a window's digit is + /// fixed while it is open, a new one lands at the end, and a close compacts what is left. + /// + /// + /// Arrival is the pane jump's, unchanged. The window is activated + /// () rather than merely focused, so its pane takes the selection, its tab + /// comes to the front of that pane's strip, the command line starts talking to its character and the + /// drafts follow — the one activation path, so a chord and a click cannot mean different things. And + /// an existing zoom is carried to the pane that now holds the selection + /// (), because a zoomed workspace realises exactly + /// one pane and a mover that left the zoom behind would put the selection, the session and the caret + /// on a pane that is not on the screen. + /// /// - /// The number is the rail's number. Panes are counted in Layout.Panes order - /// (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 - /// 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: + /// Why Alt, and why the framework had to be outranked. Ctrl+digit was what was originally 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 @@ -2975,18 +3031,77 @@ private void MoveFocus(PaneDirection direction) /// 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. /// + /// + private void JumpToWindow(int number) + { + var windows = _workspace.WindowsFor(ActiveCharacterKey()); + if (number < 1 || number > windows.Count) + { + // Never silent. A digit with no window behind it is the commonest way to press this chord + // wrong, and the count is the whole answer. It names *whose* windows are being counted, + // because the numbering is per character now and "there is no window 5" without a subject + // would read as a claim about the whole workspace. + var whose = _active is { } active ? SessionTitle(active) : "this client"; + Notice( + windows.Count == 1 + ? $"{whose} has one window — ⌥J and ⌥K move between characters" + : $"there is no window {number} — {whose} has {windows.Count}", + MessageSeverity.Warning, + $"⌥{number}"); + return; + } + + Activate(windows[number - 1].Id); + + // After the activation, so the zoom lands on the pane that is now selected. Rebuilding is what + // realises the change; Activate's own path only syncs the view to a pane it did not move. + if (_workspace.Layout.CarryZoomToFocused()) + { + RebuildPaneArea(); + } + } + + /// + /// Goes to the th pane and brings it to the front — ⌃B 1–⌃B 9, and the ⌃P + /// Go to pane N entries. + /// + /// It is on the prefix because ⌥N is spent. This chord was ⌥1–⌥9 until that was given to + /// windows, and a pane and a window are different destinations that one key cannot name. ⌃B is where + /// every other pane command lives — split, zoom, close, cycle, move, freeze — so the ordinal one + /// joining them is one keymap rather than a new idea, and the which-key panel lists it beside them. + /// + /// + /// It is kept rather than dropped, and the argument is that panes are still named. Every pane + /// is reachable by ⌥N through whatever window it holds, so this is not the only way there. But the + /// pane numbering does not go away with the chord: move mode badges each pane with its digit, the + /// drag overlay and the move prompt both say pane 2, the split and resize refusals name panes, + /// and ⌃O counts them. A numbering the client prints, and asks you to press inside a mode, with no + /// key outside that mode that acts on it, is a numbering that only half exists. This is also the one + /// motion that moves to a pane without naming what is in it — the ordinal member of the + /// ⌃O / ⌃arrow family, which would otherwise be the only family here with a gap in it. + /// + /// + /// The number is 's numberLayout.Panes order, which is + /// creation order, which is what the move overlay badges and the ⌃P entry says. Panes used to be + /// counted in tree order, so creating one renumbered every pane after the insertion point and a digit + /// stopped meaning what it meant while the user was doing something else entirely. + /// /// - /// 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. + /// Zoom follows, for the reason 's does: the pane you named has to be + /// the one filling the screen. 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) + + // A single-pane workspace refuses *every* digit, ⌃B 1 included. Going to the pane you are already + // standing in is a keystroke that changes nothing, and the which-key panel dims this row on + // exactly that fact (`needs a second pane`, the same note zoom and cycle carry) — a panel that + // says a key is unavailable and a key that quietly succeeds are the two halves of the defect the + // panel exists to remove. + if (panes.Count == 1 || 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 @@ -2996,7 +3111,7 @@ private void JumpToPane(int number) ? "the workspace has one pane — ⌃B | and ⌃B - split it" : $"there is no pane {number} — this workspace has {panes.Count}", MessageSeverity.Warning, - $"⌥{number}"); + $"⌃B {number}"); return; } @@ -3565,11 +3680,32 @@ private void RegisterGlobalShortcuts() return () => { Reconnect(); return true; }; } - // ⌥1–⌥9 go to the numbered pane. Same delivery story as Alt+R and one digit over: the + // ⌥D drops the focused character's connection at once. It deliberately does *not* end the + // client — that is ⌃Q, which asks first. It was ⌃D, the shell's own hang-up chord, and moved + // here so that disconnect and reconnect share a modifier: two opposite actions under two + // different ones is two things to learn for one concept. With nothing connected it says so. + if (claim.Key == ConsoleKey.D) + { + return () => { Disconnect(); return true; }; + } + + // ⌥J / ⌥K walk the open characters. Same delivery story as Alt+R: ESC + a printable byte, + // decoded as that letter with Alt. + if (claim.Key == ConsoleKey.J) + { + return () => { CycleCharacter(1); return true; }; + } + + if (claim.Key == ConsoleKey.K) + { + return () => { CycleCharacter(-1); return true; }; + } + + // ⌥1–⌥9 go to the numbered window. 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) + if (MacroKeys.WindowJumpNumber(claim.Key) is { } number) { - return () => { JumpToPane(number); return true; }; + return () => { JumpToWindow(number); return true; }; } return null; @@ -3586,8 +3722,10 @@ private void RegisterGlobalShortcuts() // toggle every other surface in this client is on, and the only reading under which a held // or twice-fumbled chord cannot quit on its own. See QuitPrompt. ConsoleKey.Q => () => { _quit.Toggle(); return true; }, - // Next window (Ctrl+N, plus Ctrl+Tab where the terminal reports it) and close window (Ctrl+W). - ConsoleKey.N or ConsoleKey.Tab => () => { NextWindow(); return true; }, + // Next window. ⌃Tab used to be listed here as a second spelling "where the terminal reports + // it"; no terminal does — it writes 0x09, which is a bare Tab — so the arm was dead and the + // claim behind it was telling F4 a chord was taken that cannot arrive. ⌃N is the chord. + ConsoleKey.N => () => { NextWindow(); return true; }, ConsoleKey.W => () => { CloseActiveWindow(); return true; }, ConsoleKey.O => () => { CyclePane(); return true; }, ConsoleKey.P => () => { ToggleMenu(); return true; }, @@ -3598,11 +3736,6 @@ private void RegisterGlobalShortcuts() // framework's parser turns byte 0x08 into Backspace with no Control modifier, so binding it // would take the command line's erase key and the app could not even tell the two apart. ConsoleKey.R => () => { ToggleHistorySearch(); return true; }, - // ⌃D is the idiomatic disconnect/EOF chord, and this client spends it on exactly that: it - // drops the focused character's connection at once. It deliberately does *not* end the client - // — that is ⌃Q, which asks first — so the shell reflex it borrows lands on the smaller of the - // two meanings. With nothing connected it says so and does nothing at all. - ConsoleKey.D => () => { Disconnect(); return true; }, _ => null, }; } @@ -4297,8 +4430,9 @@ private static string AccentHex(TerminalColor accent) => /// /// Projects live config + workspace state into rail rows: each world (with an accent), its - /// characters (connected dot, active marker), and — under the active character — the workspace's - /// windows with their unread/unsent/pane detail. Ranking/markup stays in the tested Core/renderer. + /// characters (connected dot, active marker, the chord that goes to them), and — under the active + /// character — the workspace's windows with their unread/unsent detail and their own chords. + /// Ranking/markup stays in the tested Core/renderer. /// private IReadOnlyList BuildRail() { @@ -4307,23 +4441,14 @@ private IReadOnlyList BuildRail() // The same set the header's fraction counts, so a dot and the count cannot disagree. var connected = new HashSet(ConnectedCharacters(), StringComparer.Ordinal); - // Where each window is, for the window rows' second column — and only when there is more than one - // answer. On a single-pane workspace a window can only be in the one pane, so the column says - // nothing; it also used to call that pane "main", which collided head-on with the *window* named - // "main" in the column beside it (`▪ main main`: two different meanings wearing one word). Every - // pane is now spelt "pane N", which a window title cannot be mistaken for. - var panes = _workspace.Layout.Panes; - var paneLabels = new Dictionary(StringComparer.Ordinal); - if (panes.Count > 1) - { - for (var i = 0; i < panes.Count; i++) - { - // 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); - } - } + // The chord that goes to each window, for the rows' chord column — and only when there is more + // than one answer. With a single window there is one place to be, so the column says nothing. + var chords = WindowChords(); + + // And the two characters ⌥J/⌥K reach from here. A separate map because these are a different + // mechanic over a different set; they share the column because they answer the same question of + // whichever row they are on — "which key gets me here". + var characterChords = CharacterChords(); var worlds = new List(); var index = 0; @@ -4338,7 +4463,7 @@ private IReadOnlyList BuildRail() { var key = $"{world.Name}.{character.Name}"; var active = key == activeKey; - var windows = active ? BuildRailWindows(key, paneLabels) : Array.Empty(); + var windows = active ? BuildRailWindows(key, chords) : Array.Empty(); characters.Add(new RailCharacter( character.Name, key, @@ -4346,7 +4471,7 @@ private IReadOnlyList BuildRail() Active: active, Unread: windows.Sum(w => w.Unread), windows, - Pane: CharacterPaneLabel(key, paneLabels))); + Chord: characterChords.GetValueOrDefault(key))); } worlds.Add(new RailWorld(world.Name, world.Host, world.Port, accent, characters)); @@ -4370,7 +4495,7 @@ private IReadOnlyList BuildRail() /// /// private IReadOnlyList BuildRailWindows( - string owner, IReadOnlyDictionary paneLabels) + string owner, IReadOnlyDictionary chords) { var windows = new List(); foreach (var window in _workspace.Windows) @@ -4381,95 +4506,115 @@ private IReadOnlyList BuildRailWindows( continue; } - var pane = _workspace.Layout.FindWindow(window.Id); - var label = pane is not null ? paneLabels.GetValueOrDefault(pane.Id) : null; windows.Add(new RailWindow( RailWindowLabel(window, mine), window.Id, - label, + chords.GetValueOrDefault(window.Id), window.Unread, window.HasUnsentInput, - Closed: pane is null)); + Closed: _workspace.Layout.FindWindow(window.Id) is null)); } 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. + /// The ⌥N each window is reached by, keyed by window id — the one place the sidebar's column, + /// the ⌃P entries' subtitles and take their number from. /// - /// 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. + /// Empty when the workspace holds one window, exactly as the hosting-pane column it replaced was + /// empty on a single-pane workspace: with one destination the digit is not information, and three + /// cells of sidebar come out of the pane the user is reading. /// /// - /// 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. + /// Windows past the ninth are absent rather than numbered. ⌥0 is not claimed and there is no tenth + /// chord, so a row for such a window would either name a key that does nothing or name one that goes + /// somewhere else; it stays clickable, and ⌃N and the tab strip still reach it. /// /// + private Dictionary WindowChords() + { + var chords = new Dictionary(StringComparer.Ordinal); + var windows = _workspace.WindowsFor(ActiveCharacterKey()); + if (windows.Count <= 1) + { + return chords; + } + + for (var i = 0; i < windows.Count && i < CommandIds.WindowJumpDigits; i++) + { + chords[windows[i].Id] = RailChordLabel(i + 1); + } + + return chords; + } + /// - /// What the sidebar's hosting column calls pane : the chord that goes there, - /// ⌥3, rather than the words pane 3. + /// The chord each character's row carries: ⌥J on the character one step forward in the cycle, + /// ⌥K on the one step back, and nothing on anybody else — including on the row you are + /// standing on, whose marker already says so. /// - /// 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. + /// Only the two neighbours, because only they are one keystroke away. The row used to carry + /// the chord of that character's own window, back when window numbering was global; scoped + /// to the active character that would print ⌥1 against every character on the screen, which is + /// precisely the confusion this design replaced — "I am looking for the characters to have different + /// numbers?" A row three steps down the cycle has no single key, and the honest thing for it to carry + /// is nothing. The invariant holds either way: the chord on a row is the chord that reaches that row. /// /// - /// 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. + /// It costs the sidebar nothing. At most two rows ever carry it, a character row is indented one + /// level less than a window row and has no pen field, so a window row is the wider of the two + /// wherever one exists — and the rail's width is its widest row. /// /// - private static string RailPaneLabel(int ordinal) => $"⌥{ordinal}"; - - private string? CharacterPaneLabel(string sessionKey, IReadOnlyDictionary paneLabels) + private Dictionary CharacterChords() { - if (paneLabels.Count == 0) + var chords = new Dictionary(StringComparer.Ordinal); + var cycle = CommandCatalog.CharacterCycle(BuildCharacterRefs()); + var here = cycle.FindIndex(c => c.SessionKey == _active?.SessionKey); + if (here < 0 || cycle.Count <= 1) { - return null; + return chords; } - 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; - } + chords[cycle[(here + 1) % cycle.Count].SessionKey] = "⌥J"; - if (window.Kind == WindowKind.Main) - { - return label; - } - - fallback ??= label; + // Two characters make one neighbour wearing both chords, and ⌥K is the one that loses: with a + // pair, ⌥J and ⌥K land in the same place and printing both on one row would suggest otherwise. + var back = cycle[(here - 1 + cycle.Count) % cycle.Count].SessionKey; + if (!chords.ContainsKey(back)) + { + chords[back] = "⌥K"; } - return fallback; + return chords; } + /// + /// What the sidebar's second column calls window : the chord that goes + /// there, ⌥3, and not a noun. + /// + /// The sidebar's width comes out of the pane area and is reported to every connected session over + /// NAWS, so every cell on every row is a cell off every pane. The column's position already says + /// "how you get to this", so the sigil and the digit are the whole message — and ⌥3 names the + /// key, which a spelt-out ordinal would have left the reader to infer. + /// + /// + /// It is a different vocabulary from on purpose. Panes are + /// pane N everywhere the noun carries meaning — split pane 2 left, Go to pane 3, + /// there is no pane 7, the badge move mode paints on each pane — and windows are ⌥N. + /// They are two numberings over two different sets, and the two spellings are how a reader tells + /// which one they are looking at. The sidebar prints only the second, because ⌥N is the chord it + /// exists to make readable. + /// + /// + /// 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. + /// + /// + private static string RailChordLabel(int ordinal) => $"⌥{ordinal}"; + + /// /// 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). @@ -4531,21 +4676,88 @@ internal IReadOnlyList BuildCatalog() _workspace, BuildCharacterRefs(), _active?.SessionKey, context, SettingsCommands()); } + /// + /// Every configured character, in the order the rail draws them, with the two facts the surfaces + /// need: whether its socket is up, and whether this client has a session for it at all. + /// + /// Connected comes from , the one derivation the header's + /// fraction and the quit prompt already count. It used to be _active?.SessionKey == key — + /// "is this the character I am standing on" — which is a different question and produced a wrong + /// answer for every row that could be seen: the catalog skips the focused character, so the only + /// entries it drew were ones this expression reported false for, and every + /// Switch to … entry read offline however many worlds were live. + /// + /// + /// Open is a session existing, not a socket. It is what the ⌥J/⌥K cycle walks, and the + /// two must not be conflated: a character you switched to and then disconnected is still somewhere + /// you want the cycle to take you, and one you have never opened is somewhere the cycle may not + /// create. + /// + /// private IReadOnlyList BuildCharacterRefs() { + var connected = new HashSet(ConnectedCharacters(), StringComparer.Ordinal); var refs = new List(); foreach (var world in _config.Worlds) { foreach (var character in world.Characters) { var key = $"{world.Name}.{character.Name}"; - refs.Add(new CharacterRef(world.Name, character.Name, key, _active?.SessionKey == key)); + refs.Add(new CharacterRef( + world.Name, + character.Name, + key, + Connected: connected.Contains(key), + Open: _sessions.Find(key) is not null)); } } return refs; } + /// + /// Moves to the next ( 1) or previous (−1) character in the cycle — ⌥J and + /// ⌥K, and the ⌃P entries that name them. + /// + /// Why a cycle and not nine more digits. Direct selection was the first choice and the + /// terminal refused it: the digit row is spent (⌥N windows, ⌃B N panes), and every remaining + /// digit-bearing modifier has no legacy encoding at all — kitty writes ⌥⇧1 as CSI 49;4u and + /// ⌃⇧N as CSI 110;6u, both of them kitty-keyboard-protocol sequences this client's parser does + /// not decode and would silently drop. That was read off a pty, the way MacroKeys.DigitBytes + /// was, rather than assumed. ⌥J and ⌥K are plain ESC j / ESC k and arrive. + /// + /// + /// It walks only the characters already open (), + /// because opens a session and a window for one that is not — a cycle + /// key that did that per press would dial through a configuration by accident. Unopened characters + /// stay one rail click or one ⌃P entry away, and both of those mean "open it". + /// + /// + /// Never silent: with nothing open, or only the one you are on, it says so rather than appearing dead. + /// + /// + private void CycleCharacter(int delta) + { + var cycle = CommandCatalog.CharacterCycle(BuildCharacterRefs()); + var key = delta > 0 ? "⌥J" : "⌥K"; + if (cycle.Count <= 1) + { + Notice( + cycle.Count == 0 + ? "no character is open — the sidebar and ⌃P open one" + : $"{SessionTitle(_active!)} is the only character open — the sidebar and ⌃P open another", + MessageSeverity.Warning, + key); + return; + } + + var here = cycle.FindIndex(c => c.SessionKey == _active?.SessionKey); + var target = here < 0 + ? cycle[delta > 0 ? 0 : ^1] + : cycle[((here + delta) % cycle.Count + cycle.Count) % cycle.Count]; + SwitchToCharacter(target.SessionKey); + } + /// The F-key of the settings screen currently open over the workspace, or null when none is. internal ConsoleKey? OpenSettingsKey => _settings.OpenKey; @@ -6036,9 +6248,17 @@ private IWindowControl BuildPaneTabs(PaneNode pane) } /// - /// Chooses a tab's content: a frozen active window gets the pinned/live split; a spawn - /// window with a capture pattern gets a dim ⇱ capture … header over its output; everything - /// else shows the plain live control. + /// Chooses a tab's content: a frozen active window gets the pinned/live split, the web view + /// gets the picture, and everything else shows the plain live control. + /// + /// A spawn window used to get a fourth arm — a dim ⇱ capture ^\[Chat\] row between the tab + /// strip and the output, naming the trigger pattern that routes lines in. It was asked to go + /// ("do not show the capture line for capture panels") and it took a whole column of plumbing with + /// it: the pattern had ridden from TriggerEngine through SpawnLineEventArgs onto + /// WorkspaceWindow and into the saved workspace for this one row, and nothing else ever read + /// it. Every spawn window now renders exactly like every other output window, which is one fewer row + /// of pane taken from the output and one fewer shape a pane can be in. + /// /// private IWindowControl BuildTabContent(PaneNode pane, string windowId, WorkspaceWindow window) { @@ -6047,11 +6267,6 @@ private IWindowControl BuildTabContent(PaneNode pane, string windowId, Workspace return BuildFrozenContent(windowId, window.Title); } - if (window.Kind == WindowKind.Spawn && !string.IsNullOrEmpty(window.CapturePattern)) - { - return BuildSpawnContent(windowId, window); - } - if (windowId == WebWindowId) { return BuildWebContent(window.Title); @@ -6060,21 +6275,6 @@ private IWindowControl BuildTabContent(PaneNode pane, string windowId, Workspace return OutputViewFor(windowId, window.Title); } - /// Wraps a spawn window's output under a dim capture line naming its trigger pattern. - private IWindowControl BuildSpawnContent(string windowId, WorkspaceWindow window) - { - var header = new MarkupControl(new List { CaptureLineRenderer.Line(window.CapturePattern!) }); - var output = OutputViewFor(windowId, window.Title); - - var grid = Controls.Grid() - .WithAlignment(HorizontalAlignment.Stretch) - .WithVerticalAlignment(VerticalAlignment.Fill); - grid.Rows(GridLength.Cells(1), GridLength.Star(1)).Columns(GridLength.Star(1)); - grid.Place(header, 0, 0, 1, 1); - grid.Place(output, 1, 0, 1, 1); - return grid.Build(); - } - /// /// Builds a frozen window's content: a vertical split of pinned scrollback (buffer up to the freeze /// point), the ▲ FROZEN ⌃F bar, and the live tail (buffer since the freeze). The tail is the @@ -6158,7 +6358,7 @@ private void RebuildPaneArea() /// The TabControl of the focused pane, or null if none is realised. private TabControl? FocusedTabs() => _paneTabs.GetValueOrDefault(_workspace.Layout.FocusedPaneId); - /// Cycles to the next window tab in the focused pane, wrapping (Ctrl+N / Ctrl+Tab). + /// Cycles to the next window tab in the focused pane, wrapping (⌃N). private void NextWindow() { if (FocusedTabs() is { TabCount: > 1 } tabs) @@ -6435,6 +6635,27 @@ 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 windows ⌥1–⌥9 reach from where the client is standing, in that order — the fixture's own + /// sanity check, so a suite that then reads its digits off the rendered sidebar fails loudly if the + /// workspace came back in an order it did not expect, rather than asserting something vacuous. + /// + internal IReadOnlyList NumberedWindowIds => + _workspace.WindowsFor(ActiveCharacterKey()).Select(w => w.Id).ToArray(); + + /// + /// Opens a window belonging to nobody — the shape the web view has — so a test can check that an + /// unowned window is numbered under every character. There is no other way to reach that + /// state headlessly: the web view needs a page, and every other window is opened by a session and + /// carries its owner. + /// + internal void OpenUnownedWindowForTest(string id, string title) + { + _workspace.OpenWindow(id, title, WindowKind.Auxiliary); + PaneContentFor(id, title); + RebuildPaneArea(); + } + /// /// 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 @@ -6903,6 +7124,19 @@ private void RunPrefixCommand(char key) break; default: + // ⌃B 1–⌃B 9 go to the numbered pane. On the prefix and not on Alt because ⌥N names a + // *window* now, and a pane and a window are different destinations that one key cannot + // mean both of. It costs no new key: the digits were the one part of this keymap nothing + // claimed, and every other pane command is already here. + // + // Out of range reports, exactly as the Alt chord's did — this is JumpToPane's own refusal, + // so a digit past the last pane says so instead of disarming silently. + if (key is >= '1' and <= '9' && key - '0' <= CommandIds.PaneJumpDigits) + { + JumpToPane(key - '0'); + break; + } + break; // any other key just disarms } } @@ -6989,8 +7223,21 @@ internal WorldSession BindWorldWithoutConnecting(WorldDefinition world) /// 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. + /// It stays pane-numbered, because a pane is what a window is moved into. Windows are the + /// thing being moved; they are not destinations here, so there is nothing for the ⌥N numbering to do + /// in this mode. The digits are the pane ordinals, so the badge painted on a pane, the pane N + /// the prompt names as the target, the ⌃P Go to pane N entry and ⌃B N are one numbering. + /// + /// + /// How it and ⌥N avoid reading as one numbering. Three things keep them apart, and all three + /// are needed because the digits are the same ten characters. They are never live at the same time: + /// this is a mode, its digits are bare keys it consumes itself, and while it is up the whole + /// screen is dimmed behind badges. They are spelt differently everywhere either is written down — a + /// pane is pane 2 and a window's chord is ⌥2 (), so no + /// surface prints a bare digit that could be either. And they are drawn in different places: a pane's + /// number is painted on that pane, only during this mode and the drag, while a window's is in + /// the sidebar beside the window's own row. Reading a badge and pressing ⌥ with it is the mistake + /// available here, and it is not available while the badges are on screen. /// /// private void EnterMoveMode() @@ -7117,17 +7364,20 @@ private string DropLabel(string? paneId, Edge? edge) /// /// 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. + /// created to the left of pane 2 made it pane 3 without the user having touched it, and the digit + /// that meant it 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 - /// 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 + /// It used to call the first pane main, which collided with the window named main — + /// ▪ main main, two meanings in one line. That was survivable while nothing depended on the + /// number; ⌃B 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. /// + /// + /// The noun is load-bearing now that ⌥N means a window. Panes are pane N and windows + /// are ⌥N () — two numberings over two different sets, told apart + /// by how they are written wherever either appears. + /// /// private string PaneLabel(string paneId) { diff --git a/tests/SharpMUTerm.Core.Tests/Automation/TriggerEngineTests.cs b/tests/SharpMUTerm.Core.Tests/Automation/TriggerEngineTests.cs index 45096b4..15b05f4 100644 --- a/tests/SharpMUTerm.Core.Tests/Automation/TriggerEngineTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Automation/TriggerEngineTests.cs @@ -77,7 +77,7 @@ 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.Select(s => s.Target)).Contains("Chat"); + await Assert.That(result.SpawnTargets).Contains("Chat"); } /// @@ -101,27 +101,7 @@ public async Task SpawnTarget_ExpandsCaptureGroups(string line, string expected) 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("^<(.+?)>"); + await Assert.That(result.SpawnTargets).Contains(expected); } /// @@ -171,7 +151,7 @@ public async Task SpawnTarget_WithoutCaptures_IsUnchanged() var result = engine.Process(Line(" Ann waves")); - await Assert.That(result.SpawnTargets.Single().Target).IsEqualTo("Chat"); + await Assert.That(result.SpawnTargets.Single()).IsEqualTo("Chat"); } [Test] diff --git a/tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs b/tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs index 139fe40..14d67e9 100644 --- a/tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs @@ -50,9 +50,9 @@ public async Task StatefulCommands_ReadCurrentValue() /// /// 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. + /// move overlay badges them in), and only when there is more than one pane. The first nine carry + /// their chord — ⌃B N, since ⌥N goes to a window now — and a tenth pane has none, because an + /// entry naming a key that does something else would be worse than a bare one. /// [Test] public async Task NumberedPaneEntries_AppearOnlyOnASplit_AndOnlyTheFirstNineCarryAChord() @@ -92,7 +92,54 @@ await Assert.That(one.Any(c => c.Id.StartsWith(CommandIds.PanePrefix, StringComp 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); + await Assert.That(entry.Subtitle).IsEqualTo(n <= CommandIds.PaneJumpDigits ? $"⌃B {n}" : null); + } + } + + /// + /// A window's entry names the chord that goes to it, and the chord is the numbering's. The ⌃P + /// surface is a second door onto ⌥N rather than a second way of switching window, so the subtitle + /// leads with the digit — and the digit is read out of WindowsFor, the one order windows + /// are numbered in, rather than counted here. + /// + /// The tenth window and beyond carry no chord: ⌥0 is unclaimed and there is no tenth key, so the + /// subtitle drops back to the owner alone rather than naming something that would go elsewhere. + /// + /// + [Test] + public async Task WindowEntries_LeadWithTheirChord_AndOnlyTheFirstNineHaveOne() + { + var ws = new Workspace(mainWindowId: "main", mainTitle: "Main", sessionKey: "Aetherfall.Corvid"); + for (var i = 1; i <= 10; i++) + { + ws.RouteSpawn($"w{i}", "Aetherfall.Corvid"); + } + + var placed = ws.WindowsFor("Aetherfall.Corvid"); + await Assert.That(placed.Count).IsEqualTo(11); + + // No window is active, so every one gets an entry — the skip is only for the focused window. The + // focused *character* is Corvid, because the numbering is scoped to whoever is active and a + // catalog built for nobody would correctly hand out no chords at all. + ws.Layout.FocusedPane.ActiveIndex = -1; + var entries = CommandCatalog.Build(ws, Characters, "Aetherfall.Corvid", new CommandContext()) + .Where(c => c.Id.StartsWith(CommandIds.WindowPrefix, StringComparison.Ordinal)) + .ToDictionary(c => c.Id, c => c.Subtitle, StringComparer.Ordinal); + + for (var n = 1; n <= placed.Count; n++) + { + var subtitle = entries[CommandIds.Window(placed[n - 1].Id)]; + await Assert.That(subtitle).IsNotNull(); + if (n <= CommandIds.WindowJumpDigits) + { + await Assert.That(subtitle!).StartsWith($"⌥{n} · "); + } + else + { + await Assert.That(subtitle!.Contains('⌥')) + .IsFalse() + .Because($"window {n} has no chord, and naming one would name a key that goes elsewhere"); + } } } diff --git a/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs b/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs index 4558e89..7586bf8 100644 --- a/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs @@ -14,8 +14,8 @@ public async Task Build_EmitsHeaderThenWorldAndCharacters() { new RailCharacter("Corvid", "Aetherfall.Corvid", Connected: true, Active: true, Unread: 3, new[] { - new RailWindow("main", "w:main", "p1", 0, false, false), - new RailWindow("#public", "w:public", "p2", 3, true, false), + new RailWindow("main", "w:main", "⌥1", 0, false, false), + new RailWindow("#public", "w:public", "⌥2", 3, true, false), }), new RailCharacter("Rookery", "Aetherfall.Rookery", Connected: false, Active: false, Unread: 0, Array.Empty()), }); @@ -38,8 +38,8 @@ public async Task Windows_AreExpandedOnlyUnderTheActiveCharacter() { var world = new RailWorld("Aetherfall", "h", 1, Accent, new[] { - new RailCharacter("Corvid", "k1", true, Active: true, 0, new[] { new RailWindow("main", "w:main", "p1", 0, false, false) }), - new RailCharacter("Rookery", "k2", false, Active: false, 0, new[] { new RailWindow("hidden", "w:hidden", "p9", 0, false, false) }), + new RailCharacter("Corvid", "k1", true, Active: true, 0, new[] { new RailWindow("main", "w:main", "⌥1", 0, false, false) }), + new RailCharacter("Rookery", "k2", false, Active: false, 0, new[] { new RailWindow("hidden", "w:hidden", "⌥9", 0, false, false) }), }); var rows = RailModel.Build(new[] { world }); @@ -50,17 +50,17 @@ public async Task Windows_AreExpandedOnlyUnderTheActiveCharacter() } [Test] - public async Task Window_CarriesUnsentUnreadAndPane() + public async Task Window_CarriesUnsentUnreadAndItsChord() { var world = new RailWorld("W", "h", 1, Accent, new[] { - new RailCharacter("C", "k", true, true, 3, new[] { new RailWindow("#public", "w:public", "p2", 3, HasUnsent: true, Closed: false) }), + new RailCharacter("C", "k", true, true, 3, new[] { new RailWindow("#public", "w:public", "⌥2", 3, HasUnsent: true, Closed: false) }), }); var win = RailModel.Build(new[] { world }).Single(r => r.Kind == RailRowKind.Window); await Assert.That(win.Unsent).IsTrue(); await Assert.That(win.Unread).IsEqualTo(3); - await Assert.That(win.Pane).IsEqualTo("p2"); + await Assert.That(win.Chord).IsEqualTo("⌥2"); } [Test] @@ -213,8 +213,8 @@ await Assert.That(row.Target is null).IsEqualTo(expected) { new RailCharacter("Corvid", "Aetherfall.Corvid", Connected: true, Active: true, Unread: 3, new[] { - new RailWindow("main", "main", "p1", 0, false, false), - new RailWindow("#public", "spawn:#public", "p2", 3, true, false), + new RailWindow("main", "main", "⌥1", 0, false, false), + new RailWindow("#public", "spawn:#public", "⌥2", 3, true, false), }), new RailCharacter("Rookery", "Aetherfall.Rookery", Connected: false, Active: false, Unread: 0, Array.Empty()), diff --git a/tests/SharpMUTerm.Core.Tests/Workspace/WindowNumberingTests.cs b/tests/SharpMUTerm.Core.Tests/Workspace/WindowNumberingTests.cs new file mode 100644 index 0000000..4cc9c03 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Workspace/WindowNumberingTests.cs @@ -0,0 +1,239 @@ +using SharpMUTerm.Core.Workspaces; + +namespace SharpMUTerm.Core.Tests.Workspaces; + +/// +/// A window's number is when it was opened, not where its tab sits. ⌥1–⌥9 name windows, so the +/// order they are counted in is a thing a user learns and presses; +/// is that order and this suite is what it stands on. +/// +/// Why not the registry's order. is a dictionary's values, and a +/// dictionary's enumeration order is unspecified after a removal: closing a channel and opening another +/// can drop the newcomer into the freed slot, renumbering everything after it. That is the same defect +/// creation order was given to panes to prevent — a number moving under someone who had not touched it — +/// and it is worse here, because a capture window opens unbidden from the wire. +/// +/// +/// Number versus sequence. is a sort key that is never +/// reused; the number is the window's position in the sorted list. That distinction is the whole +/// of the compaction rule: reading sequences directly would leave holes after a close, with a digit doing +/// nothing while the windows sat on the screen. +/// +/// +/// Placed, because a chord has to land somewhere. A window the registry still knows and no pane +/// holds is drawn in the rail as closed; numbering it would spend a digit on a place there is no +/// way to go, and would shift every window after it. +/// +/// +public class WindowNumberingTests +{ + // --- creation order, and what it is not --------------------------------------------------------- + + /// Windows are numbered in the order they were opened, whatever pane they end up in. + [Test] + public async Task WindowsAreNumberedInTheOrderTheyWereOpened() + { + var ws = new Workspace(mainWindowId: "main", mainTitle: "Main", sessionKey: "W.C"); + ws.RouteSpawn("Chat", "W.C"); + ws.OpenWindow("web", "Web"); + + await Assert.That(Order(ws)).IsEqualTo("main,spawn:Chat,web"); + } + + /// + /// The defect this ordering exists to prevent. Moving a window's tab to the front of another + /// pane changes where it is and must not change what it is called: it is the same window, the user + /// asked for it to be somewhere else, and nobody asked for its chord to move. + /// + [Test] + public async Task MovingAWindowToAnotherPaneDoesNotRenumberAnything() + { + var ws = new Workspace(mainWindowId: "main", mainTitle: "Main", sessionKey: "W.C"); + ws.RouteSpawn("Chat", "W.C"); + ws.RouteSpawn("Trade", "W.C"); + var before = Order(ws); + + ws.Layout.SplitWithWindow("spawn:Trade", ws.Layout.FocusedPaneId, Edge.Left); + + await Assert.That(Order(ws)).IsEqualTo(before); + } + + /// + /// And reordering the tabs inside a pane does not either — the strip's order is a view, and the + /// numbering is not a function of it. + /// + [Test] + public async Task ReorderingTabsDoesNotRenumberAnything() + { + var ws = new Workspace(mainWindowId: "main", mainTitle: "Main", sessionKey: "W.C"); + ws.RouteSpawn("Chat", "W.C"); + var before = Order(ws); + + ws.ActivateWindow("spawn:Chat"); + await Assert.That(ws.Layout.ReorderActiveTab(-1)).IsTrue(); + + await Assert.That(Order(ws)).IsEqualTo(before); + } + + // --- compaction --------------------------------------------------------------------------------- + + /// + /// Closing the second of three leaves 1 and 2, not 1 and 3. Sequences have holes after a close + /// and the numbering may not, or a digit is a silent no-op with two windows on the screen. + /// + [Test] + public async Task ClosingAWindowCompactsTheNumbering() + { + var ws = new Workspace(mainWindowId: "main", mainTitle: "Main", sessionKey: "W.C"); + ws.RouteSpawn("Chat", "W.C"); + ws.RouteSpawn("Trade", "W.C"); + + ws.CloseWindow("spawn:Chat"); + + await Assert.That(Order(ws)).IsEqualTo("main,spawn:Trade"); + await Assert.That(ws.WindowsFor(Owner)[1].Sequence) + .IsGreaterThan(2) + .Because("the sequence keeps its hole; only the position closes up"); + } + + /// + /// A new window always lands at the end, even in the slot a closed one freed. This is the + /// registry-order bug in the one shape that makes it visible: a + /// enumerates in insertion order right up until an entry is removed, and then the next insert + /// reuses the freed slot and is enumerated from the middle. So closing the second of four windows and + /// opening a fifth puts the newcomer where the closed one was — every digit after it moves, on a + /// keystroke nobody made, and the sidebar and the chords both follow. A weaker fixture (open, close, + /// open, with two windows left) passes under either ordering and proves nothing. + /// + [Test] + public async Task AWindowOpenedIntoAClosedOnesSlotStillTakesTheLastNumber() + { + var ws = new Workspace(mainWindowId: "main", mainTitle: "Main", sessionKey: "W.C"); + ws.RouteSpawn("Chat", "W.C"); + ws.RouteSpawn("Trade", "W.C"); + ws.RouteSpawn("Newbie", "W.C"); + + ws.CloseWindow("spawn:Chat"); // frees the second slot + ws.RouteSpawn("Guild", "W.C"); // which the registry hands straight back out + + await Assert.That(Order(ws)).IsEqualTo("main,spawn:Trade,spawn:Newbie,spawn:Guild") + .Because("the newcomer is ⌥4, and Trade and Newbie are still ⌥2 and ⌥3"); + } + + // --- placed only -------------------------------------------------------------------------------- + + /// + /// A window no pane holds is still registered — the rail draws it as closed — and carries no + /// number, because a digit that named it would name nowhere to go. + /// + [Test] + public async Task AWindowNoPaneHoldsIsNotNumbered() + { + var ws = new Workspace(mainWindowId: "main", mainTitle: "Main", sessionKey: "W.C"); + ws.RouteSpawn("Chat", "W.C"); + ws.RouteSpawn("Trade", "W.C"); + + ws.Layout.RemoveWindow("spawn:Chat"); // out of the tree, still in the registry + + await Assert.That(ws.Windows.Select(w => w.Id)).Contains("spawn:Chat"); + await Assert.That(Order(ws)).IsEqualTo("main,spawn:Trade"); + } + + // --- across a restart --------------------------------------------------------------------------- + + /// + /// The numbering survives a resume. Captured and restored the way the shell does it, every + /// window comes back in the position it was in — which is what persisting the sequence is for. + /// + [Test] + public async Task TheNumberingComesBackAfterAResume() + { + var ws = new Workspace(mainWindowId: "main", mainTitle: "Main", sessionKey: "W.C"); + ws.RouteSpawn("Chat", "W.C"); + ws.RouteSpawn("Trade", "W.C"); + ws.CloseWindow("spawn:Chat"); + ws.RouteSpawn("Newbie", "W.C"); + var before = Order(ws); + + var resumed = WorkspaceState.Capture(ws).Restore(); + + await Assert.That(Order(resumed)).IsEqualTo(before); + } + + /// + /// A configuration written before windows carried a sequence comes back in the order it was saved + /// in, rather than sorting equal and landing wherever the sort happened to put them. That is the + /// same migration PaneNode.Unsequenced gets, and it matters for the same reason: an existing + /// client reads exactly as it read when it was closed. + /// + [Test] + public async Task AWorkspaceSavedWithoutSequencesIsNumberedFromItsSavedOrder() + { + var state = new WorkspaceState + { + Windows = + { + new WorkspaceWindowState { Id = "main", Title = "Main", Kind = WindowKind.Main }, + new WorkspaceWindowState { Id = "spawn:Chat", Title = "Chat", Kind = WindowKind.Spawn }, + new WorkspaceWindowState { Id = "web", Title = "Web", Kind = WindowKind.Auxiliary }, + }, + Root = new LayoutNodeState + { + Type = "pane", + Id = "p1", + Tabs = { "main", "spawn:Chat", "web" }, + ActiveIndex = 0, + }, + FocusedPaneId = "p1", + }; + + var ws = state.Restore(); + + await Assert.That(Order(ws)).IsEqualTo("main,spawn:Chat,web"); + await Assert.That(ws.WindowsFor(Owner).All(w => w.Sequence > WorkspaceWindow.Unsequenced)) + .IsTrue() + .Because("every window is numbered on load, or a later one would collide with an unnumbered one"); + } + + /// + /// A half-migrated set — some windows carrying a sequence, some not — cannot produce two windows with + /// one number: the unsequenced ones are numbered after the highest already taken. + /// + [Test] + public async Task AHalfMigratedWorkspaceGivesNoTwoWindowsOneNumber() + { + var state = new WorkspaceState + { + Windows = + { + new WorkspaceWindowState { Id = "main", Title = "Main", Kind = WindowKind.Main, Sequence = 4 }, + new WorkspaceWindowState { Id = "spawn:Chat", Title = "Chat", Kind = WindowKind.Spawn }, + }, + Root = new LayoutNodeState + { + Type = "pane", Id = "p1", Tabs = { "main", "spawn:Chat" }, ActiveIndex = 0, + }, + FocusedPaneId = "p1", + }; + + var ws = state.Restore(); + + await Assert.That(ws.WindowsFor(Owner).Select(w => w.Sequence).Distinct().Count()).IsEqualTo(2); + await Assert.That(ws.WindowsFor(Owner)[0].Id).IsEqualTo("main"); + await Assert.That(ws.WindowsFor(Owner)[1].Id).IsEqualTo("spawn:Chat"); + } + + // --- harness ------------------------------------------------------------------------------------ + + /// + /// The numbered windows' ids in order, as one string. Ordered comparison is the point of this whole + /// suite, and TUnit's IsEquivalentTo compares collections as sets — it passes happily + /// on [a,b,c] against [a,c,b], which is exactly the difference being asserted. The same + /// trap is recorded in PaneNumberingTests, and this suite fell into it once before it was. + /// + /// The character every window in these fixtures belongs to. + private const string Owner = "W.C"; + + private static string Order(Workspace workspace) => + string.Join(",", workspace.WindowsFor(Owner).Select(w => w.Id)); +} diff --git a/tests/SharpMUTerm.Core.Tests/Workspace/WorkspaceStateTests.cs b/tests/SharpMUTerm.Core.Tests/Workspace/WorkspaceStateTests.cs index 66647ea..2083109 100644 --- a/tests/SharpMUTerm.Core.Tests/Workspace/WorkspaceStateTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Workspace/WorkspaceStateTests.cs @@ -12,7 +12,6 @@ private static Workspace BuildScene() var ws = new Workspace(mainWindowId: "main", mainTitle: "main", sessionKey: "Aetherfall.Corvid"); var chat = ws.RouteSpawn("Chat", "Aetherfall.Corvid"); chat.OwnerLabel = "Corvid"; - chat.CapturePattern = @"^\[Chat\]"; ws.ActivateWindow("main"); ws.Layout.SplitFocused(SplitDirection.Row); // moves Chat into a new pane return ws; @@ -30,7 +29,6 @@ public async Task Capture_ThenRestore_PreservesWindowsAndTree() var chat = restored.FindWindow(Workspace.SpawnWindowId("Chat"))!; await Assert.That(chat.Kind).IsEqualTo(WindowKind.Spawn); await Assert.That(chat.OwnerLabel).IsEqualTo("Corvid"); - await Assert.That(chat.CapturePattern).IsEqualTo(@"^\[Chat\]"); await Assert.That(chat.SessionKey).IsEqualTo("Aetherfall.Corvid"); // The split tree survives: two panes, main and Chat separated. diff --git a/tests/SharpMUTerm.Tui.Tests/CaptureLineRendererTests.cs b/tests/SharpMUTerm.Tui.Tests/CaptureLineRendererTests.cs deleted file mode 100644 index 5513f28..0000000 --- a/tests/SharpMUTerm.Tui.Tests/CaptureLineRendererTests.cs +++ /dev/null @@ -1,23 +0,0 @@ -using SharpMUTerm.Tui; - -namespace SharpMUTerm.Tui.Tests; - -public class CaptureLineRendererTests -{ - [Test] - public async Task Line_IsADimCaptureRow() - { - var line = CaptureLineRenderer.Line("^chat:"); - - await Assert.That(line).IsEqualTo($"[dim]{Glyphs.Capture} capture ^chat:[/]"); - } - - [Test] - public async Task Line_EscapesMarkupBracketsInThePattern() - { - // A regex like ^\[public\] must not be parsed as markup tags. - var line = CaptureLineRenderer.Line(@"^\[public\]"); - - await Assert.That(line).IsEqualTo($@"[dim]{Glyphs.Capture} capture ^\[[public\]][/]"); - } -} diff --git a/tests/SharpMUTerm.Tui.Tests/ConnectionKeysTests.cs b/tests/SharpMUTerm.Tui.Tests/ConnectionKeysTests.cs index 69a5f54..8e93888 100644 --- a/tests/SharpMUTerm.Tui.Tests/ConnectionKeysTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/ConnectionKeysTests.cs @@ -8,15 +8,24 @@ namespace SharpMUTerm.Tui.Tests; /// -/// The dedicated chords for Reconnect and Disconnect: ⌃D drops the focused character's connection and -/// Alt+R drops and redials it, both at once and neither asking anything. +/// The dedicated chords for Reconnect and Disconnect: ⌥D drops the focused character's connection and +/// ⌥R drops and redials it, both at once and neither asking anything. /// -/// The claim that needs proving first is that the chords arrive. ⌃D is the raw control byte 0x04 -/// and Alt+R is an ESC-prefixed printable, so both survive SharpConsoleUI's input parser — unlike -/// ⌃I/⌃M/⌃J/⌃H, which collapse onto Tab, Enter and Backspace and have already cost this client four -/// features. The second is that they act on the connection in the window in front of you: nothing -/// asks before either of these runs, so a session resolved from _active rather than the focused -/// window would drop the wrong world's connection on one keystroke and say nothing about it. +/// They share a modifier, and that is the point of the pair. Disconnect was ⌃D — "⌃D is the +/// terminal's own hang-up chord" and "Alt+R is one modifier over from ⌃R" being two separate +/// justifications bolted together, each fine alone and jointly making a reader learn two modifiers for +/// one concept. It was reported as exactly that: "It's 'CTRL-D' to disconnect, but 'ALT-R' to reconnect? +/// Why are they not both under Alt?" ⌃D is released rather than kept as a second binding, because a +/// second key for one action is either a secret or a duplicate row on every surface that lists chords. +/// +/// +/// The claim that needs proving first is that the chords arrive. Both are ESC-prefixed +/// printables (1b 64 and 1b 72, read off a pty), so both survive SharpConsoleUI's input +/// parser — unlike ⌃I/⌃M/⌃J/⌃H, which collapse onto Tab, Enter and Backspace and have already cost this +/// client four features. The second is that they act on the connection in the window in front of +/// you: nothing asks before either of these runs, so a session resolved from _active rather +/// than the focused window would drop the wrong world's connection on one keystroke and say nothing +/// about it. /// /// /// @@ -59,6 +68,9 @@ private static (SharpMUTermApp App, Transports Telnet) App() return (app, telnet); } + private static ConsoleKeyInfo AltD() => new('d', ConsoleKey.D, false, true, false); + + /// The chord disconnect used to be on, kept so a test can prove it is now inert. private static ConsoleKeyInfo CtrlD() => new('\x04', ConsoleKey.D, false, false, true); private static ConsoleKeyInfo AltR() => new('r', ConsoleKey.R, false, true, false); @@ -70,10 +82,36 @@ private static async Task Connect(SharpMUTermApp app, string sessionKey) await app.LastCommand; } + /// + /// Releasing ⌃D hands it to nothing. That was the risk worth checking rather than assuming: + /// the framework's HandleMoveInput swallows unclaimed Ctrl chords and its X case closes + /// the active window — the defect that made ⌃X blank the UI. It cannot fire here for two independent + /// reasons (it is gated on IsMovable, which this app sets false, and it only acts on the + /// arrows and X), and InputBarControl's Ctrl table has no D either. So the key + /// is inert: the connection stays up, the window stays open, and nothing is typed. + /// + [Test] + public async Task CtrlDIsInertNowThatNothingClaimsIt() + { + var (app, _) = App(); + await Connect(app, Corvid); + var windows = app.WindowIds().Count; + + app.SimulateKey(CtrlD()); + + await Assert.That(app.FindSession(Corvid)!.IsConnected) + .IsTrue() + .Because("⌃D no longer disconnects, and must not have been taken by anything else either"); + await Assert.That(app.WindowIds().Count) + .IsEqualTo(windows) + .Because("the framework's move handler closes a window on an unclaimed Ctrl chord"); + await Assert.That(app.ArmedInputText).DoesNotContain("\x04"); + } + // ---- the chords reach their actions ---------------------------------------------------- /// - /// ⌃D runs Disconnect and Alt+R runs Reconnect, driven through the very shortcut table the app + /// ⌥D runs Disconnect and ⌥R runs Reconnect, driven through the very shortcut table the app /// registers. With nothing selected both refuse out loud, which is the state that shows the key /// arrived at all: the refusals are written by Disconnect and Reconnect and by nothing /// else, so a chord the parser never delivered could not produce them. @@ -86,7 +124,7 @@ public async Task BothChordsReachTheirCommands() await Assert.That(reconnect.StatusMarkup).Contains("nothing to reconnect"); var (disconnect, _) = App(); - disconnect.SimulateKey(CtrlD()); + disconnect.SimulateKey(AltD()); await Assert.That(disconnect.StatusMarkup).Contains("nothing to disconnect"); } @@ -98,12 +136,19 @@ public async Task BothChordsReachTheirCommands() public async Task TheChordsAreClaimedInTheOneShortcutList() { await Assert.That(MacroKeys.AppShortcuts.Any( - s => s.Modifiers == ConsoleModifiers.Control && s.Key == ConsoleKey.D)).IsTrue(); + s => s.Modifiers == ConsoleModifiers.Alt && s.Key == ConsoleKey.D)).IsTrue(); await Assert.That(MacroKeys.AppShortcuts.Any( s => s.Modifiers == ConsoleModifiers.Alt && s.Key == ConsoleKey.R)).IsTrue(); + // And ⌃D is genuinely released rather than merely unused: nothing claims it, so F4 offers it. + await Assert.That(MacroKeys.AppShortcuts.Any( + s => s.Modifiers == ConsoleModifiers.Control && s.Key == ConsoleKey.D)) + .IsFalse() + .Because("a second key for one action is a duplicate row on every surface that lists chords"); + await Assert.That(MacroKeys.Verdict("Ctrl+D").Fires).IsTrue(); + // Which is what makes F4 honest about a macro bound to either of them. - foreach (var descriptor in new[] { "Ctrl+D", "Alt+R" }) + foreach (var descriptor in new[] { "Alt+D", "Alt+R" }) { var verdict = MacroKeys.Verdict(descriptor); await Assert.That(verdict.Delivery).IsEqualTo(MacroKeyDelivery.Taken).Because(descriptor); @@ -113,8 +158,8 @@ await Assert.That(MacroKeys.AppShortcuts.Any( /// /// The chords are ones this terminal can deliver, which is the trap that has caught four features - /// here. ⌃D is the control byte 0x04 and Alt+R is an ESC-prefixed printable — neither is one of the - /// four letters whose control byte the terminal has already spent, and neither loses its modifier. + /// here. ⌥D and ⌥R are both ESC-prefixed printables — neither is one of the four letters whose control + /// byte the terminal has already spent, and neither loses its modifier. /// [Test] public async Task NeitherChordIsOneTheTerminalCannotReport() @@ -126,26 +171,26 @@ await Assert.That(MacroKeys.Verdict(dead).Delivery) .Because($"{dead} collapses onto its ASCII byte — this is the list the new chords avoid"); } - // Ctrl+D and Alt+R are Taken (by this change) rather than NeverArrives: they do arrive, and the - // app is what claims them. Capture proves the round trip from a keystroke to a descriptor. - await Assert.That(MacroKeys.Capture(CtrlD())).IsEqualTo("Ctrl+D"); + // Alt+D and Alt+R are Taken rather than NeverArrives: they do arrive, and the app is what claims + // them. Capture proves the round trip from a keystroke to a descriptor. + await Assert.That(MacroKeys.Capture(AltD())).IsEqualTo("Alt+D"); await Assert.That(MacroKeys.Capture(AltR())).IsEqualTo("Alt+R"); } // ---- what one keystroke does ------------------------------------------------------------- /// - /// ⌃D on a live connection drops it there and then. Nothing is asked — ⌃Q is the only key in this + /// ⌥D on a live connection drops it there and then. Nothing is asked — ⌃Q is the only key in this /// client that asks anything, and what it asks about is ending the client rather than a connection. /// [Test] - public async Task CtrlDDropsTheConnectionAtOnce() + public async Task AltDDropsTheConnectionAtOnce() { var (app, _) = App(); await Connect(app, Corvid); await Assert.That(app.FindSession(Corvid)!.IsConnected).IsTrue(); - app.SimulateKey(CtrlD()); + app.SimulateKey(AltD()); await app.LastCommand; await Assert.That(app.FindSession(Corvid)!.IsConnected).IsFalse(); @@ -187,16 +232,16 @@ public async Task AltROnADeadSessionDials() } /// - /// ⌃D with nothing connected says so and touches nothing — the pre-existing refusal, and where a - /// shell user's "⌃D ends the session" reflex is likeliest to fire. + /// ⌥D with nothing connected says so and touches nothing — the pre-existing refusal, and where a + /// shell user's "⌃D ends the session" reflex (which now lands on nothing) is likeliest to fire. /// [Test] - public async Task CtrlDOnADeadSessionSaysSoAndEndsNothing() + public async Task AltDOnADeadSessionSaysSoAndEndsNothing() { var (app, _) = App(); app.DispatchCommand($"char:{Corvid}"); - app.SimulateKey(CtrlD()); + app.SimulateKey(AltD()); await Assert.That(app.ExitRequested).IsFalse(); await Assert.That(app.StatusMarkup).Contains("is not connected"); @@ -213,8 +258,8 @@ public async Task TheSurfaceEntryAndTheChordBehaveIdentically() { foreach (var (id, chord, connectFirst) in new (string Id, ConsoleKeyInfo Chord, bool Connect)[] { - ("world:disconnect", CtrlD(), true), - ("world:disconnect", CtrlD(), false), + ("world:disconnect", AltD(), true), + ("world:disconnect", AltD(), false), ("world:reconnect", AltR(), true), ("world:reconnect", AltR(), false), }) @@ -264,7 +309,7 @@ public async Task TheChordActsOnTheSessionTheFocusedWindowIsShowing() await Connect(app, Rookery); // the last to connect, and the active window app.SimulateWindowChange("main"); // Corvid kept the main window - app.SimulateKey(CtrlD()); + app.SimulateKey(AltD()); await app.LastCommand; await Assert.That(app.FindSession(Corvid)!.IsConnected).IsFalse(); @@ -287,12 +332,12 @@ public async Task AWindowThatOwnsNoConnectionRefusesRatherThanGuessing() await Connect(app, Corvid); app.SimulateWebPage(); // activates a window belonging to no connection - app.SimulateKey(CtrlD()); + app.SimulateKey(AltD()); await app.LastCommand; await Assert.That(app.FindSession(Corvid)!.IsConnected) .IsTrue() - .Because("⌃D on a window with no connection must not drop somebody else's"); + .Because("⌥D on a window with no connection must not drop somebody else's"); await Assert.That(app.StatusMarkup).Contains("nothing to disconnect"); } @@ -310,8 +355,8 @@ public async Task TheSurfaceNamesTheChordsThatRunItsEntries() app.RenderSnapshot(); var catalog = app.BuildCatalog(); - await Assert.That(catalog.Single(c => c.Id == "world:disconnect").Subtitle).IsEqualTo("⌃D"); - await Assert.That(catalog.Single(c => c.Id == "world:reconnect").Subtitle).IsEqualTo("Alt+R"); + await Assert.That(catalog.Single(c => c.Id == "world:disconnect").Subtitle).IsEqualTo("⌥D"); + await Assert.That(catalog.Single(c => c.Id == "world:reconnect").Subtitle).IsEqualTo("⌥R"); } /// @@ -325,7 +370,7 @@ public async Task HelpNamesBothChordsAndDescribesWhatTheyDo() var help = Program.UsageText; await Assert.That(help).Contains("Alt+R reconnects"); - await Assert.That(help).Contains("Ctrl+D disconnects"); + await Assert.That(help).Contains("Alt+D disconnects"); await Assert.That(help).Contains("redials"); await Assert.That(help).Contains("Neither asks"); } diff --git a/tests/SharpMUTerm.Tui.Tests/DemoConfigs.cs b/tests/SharpMUTerm.Tui.Tests/DemoConfigs.cs new file mode 100644 index 0000000..77e3b9a --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/DemoConfigs.cs @@ -0,0 +1,34 @@ +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Workspaces; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// Variations on that more than one suite needs, built once here. +/// +/// A fixture copied into three suites is three things that can drift, and the drift is silent: each copy +/// still builds a workspace and each suite still goes green, while they have quietly stopped agreeing +/// about what they are testing against. +/// +/// +internal static class DemoConfigs +{ + /// + /// The demo scene with its capture window taken out, leaving one window in one pane — the state where + /// the rail's chord column has nothing to say and must therefore not be drawn, and where every ⌥ digit + /// past the first is out of range. + /// + /// The window is removed from both halves of the saved session: the registry + /// () and the pane's tab list. Taking it out of one leaves a + /// workspace that is internally inconsistent rather than smaller — a pane referencing a window that + /// does not exist, or a window no pane holds, which is the closed state and not this one. + /// + /// + internal static AppConfiguration SingleWindow() + { + var config = DemoScene.Build(); + config.LastSession!.Windows.RemoveAll(w => w.Kind == WindowKind.Spawn); + config.LastSession.Root.Tabs.RemoveAll(t => t.StartsWith("spawn:", StringComparison.Ordinal)); + return config; + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/FocusIndicationTests.cs b/tests/SharpMUTerm.Tui.Tests/FocusIndicationTests.cs index 4d3f794..a937435 100644 --- a/tests/SharpMUTerm.Tui.Tests/FocusIndicationTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/FocusIndicationTests.cs @@ -68,88 +68,6 @@ private static void Type(SharpMUTermApp app, string text) /// The demo workspace's spawn window — the second tab, and after a split the second pane. private static string ChatWindowId => Workspace.SpawnWindowId("Chat"); - /// The truecolor background escape a colour is written as, e.g. 48;2;51;57;76. - 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: the last - /// 48;2;r;g;b seen is the background of every cell written until the next one. Note 48 - /// and not 38 — reading foreground here and concluding about bands is the classic mistake. - /// - 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 of a frame are painted in a given background. - private static int CellsPainted(string ansi, SharpConsoleUI.Color colour) - { - var wanted = Sgr(colour); - return Backgrounds(ansi).Values.Count(bg => bg?.StartsWith(wanted, StringComparison.Ordinal) == true); - } - - /// 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; - } // --- item 1: the input bands ------------------------------------------------------------------ @@ -166,9 +84,9 @@ public async Task BothInputBandsArePaintedAndAreDifferentColours(int width, int var ansi = app.RenderSnapshot("draft2"); // two bars, the second armed var (armed, idle) = app.InputBandColors; - await Assert.That(Sgr(armed)).IsNotEqualTo(Sgr(idle)); - await Assert.That(CellsPainted(ansi, armed)).IsGreaterThanOrEqualTo(width); - await Assert.That(CellsPainted(ansi, idle)).IsGreaterThanOrEqualTo(width); + await Assert.That(FrameGrid.Sgr(armed)).IsNotEqualTo(FrameGrid.Sgr(idle)); + await Assert.That(FrameGrid.CellsPainted(ansi, armed)).IsGreaterThanOrEqualTo(width); + await Assert.That(FrameGrid.CellsPainted(ansi, idle)).IsGreaterThanOrEqualTo(width); } /// @@ -184,8 +102,8 @@ public async Task ThePaintedBandsAreMoreThanTwiceAsFarApartAsTheReportedPair() var (armed, idle) = app.InputBandColors; // Both are genuinely on the frame — a distance between two colours nothing painted is arithmetic. - await Assert.That(CellsPainted(ansi, armed)).IsGreaterThan(0); - await Assert.That(CellsPainted(ansi, idle)).IsGreaterThan(0); + await Assert.That(FrameGrid.CellsPainted(ansi, armed)).IsGreaterThan(0); + await Assert.That(FrameGrid.CellsPainted(ansi, idle)).IsGreaterThan(0); var reported = Luma(0x33, 0x39, 0x4c) - Luma(0x26, 0x2b, 0x3a); var now = Luma(armed.R, armed.G, armed.B) - Luma(idle.R, idle.G, idle.B); @@ -214,8 +132,8 @@ public async Task ArmingTheOtherBarSwapsTheBands() // The armed tone still covers as many cells as before — it moved bar, it did not disappear. var (armed, idle) = app.InputBandColors; - await Assert.That(CellsPainted(after, armed)).IsEqualTo(CellsPainted(before, armed)); - await Assert.That(CellsPainted(after, idle)).IsEqualTo(CellsPainted(before, idle)); + await Assert.That(FrameGrid.CellsPainted(after, armed)).IsEqualTo(FrameGrid.CellsPainted(before, armed)); + await Assert.That(FrameGrid.CellsPainted(after, idle)).IsEqualTo(FrameGrid.CellsPainted(before, idle)); // And the frames differ, which is the whole claim: arming a bar changes what is on the screen. await Assert.That(after).IsNotEqualTo(before); @@ -275,17 +193,17 @@ public async Task TheFocusedPaneIsPaintedOnItsOwnPlane(int width, int height) var ansi = app.RenderSnapshot("split"); var (focused, unfocused) = app.PaneBandColors; - await Assert.That(Sgr(focused)).IsNotEqualTo(Sgr(unfocused)); + await Assert.That(FrameGrid.Sgr(focused)).IsNotEqualTo(FrameGrid.Sgr(unfocused)); var rects = app.PaneOutputRects(); var focusedId = app.FocusedPaneId; var otherId = app.PaneIds.Single(id => id != focusedId); // Each plane dominates its own pane's output rectangle, and is not what the other is painted in. - await Assert.That(CellsPaintedIn(ansi, rects[focusedId], focused)).IsGreaterThan(0); - await Assert.That(CellsPaintedIn(ansi, rects[otherId], unfocused)).IsGreaterThan(0); - await Assert.That(CellsPaintedIn(ansi, rects[otherId], focused)).IsEqualTo(0); - await Assert.That(CellsPaintedIn(ansi, rects[focusedId], unfocused)).IsEqualTo(0); + await Assert.That(FrameGrid.CellsPaintedIn(ansi, rects[focusedId], focused)).IsGreaterThan(0); + await Assert.That(FrameGrid.CellsPaintedIn(ansi, rects[otherId], unfocused)).IsGreaterThan(0); + await Assert.That(FrameGrid.CellsPaintedIn(ansi, rects[otherId], focused)).IsEqualTo(0); + await Assert.That(FrameGrid.CellsPaintedIn(ansi, rects[focusedId], unfocused)).IsEqualTo(0); } /// @@ -304,15 +222,15 @@ public async Task ThePlaneFollowsTheFocus() var rects = app.PaneOutputRects(); var before = app.RenderWholeFrame(); - await Assert.That(CellsPaintedIn(before, rects[first], focused)).IsGreaterThan(0); - await Assert.That(CellsPaintedIn(before, rects[second], focused)).IsEqualTo(0); + await Assert.That(FrameGrid.CellsPaintedIn(before, rects[first], focused)).IsGreaterThan(0); + await Assert.That(FrameGrid.CellsPaintedIn(before, rects[second], focused)).IsEqualTo(0); app.SimulateKey(Ctrl(ConsoleKey.RightArrow)); var after = app.RenderWholeFrame(); await Assert.That(app.FocusedPaneId).IsEqualTo(second); - await Assert.That(CellsPaintedIn(after, rects[second], focused)).IsGreaterThan(0); - await Assert.That(CellsPaintedIn(after, rects[first], focused)).IsEqualTo(0); + await Assert.That(FrameGrid.CellsPaintedIn(after, rects[second], focused)).IsGreaterThan(0); + await Assert.That(FrameGrid.CellsPaintedIn(after, rects[first], focused)).IsEqualTo(0); } /// @@ -776,8 +694,8 @@ public async Task CyclingAndMovingAgreeAboutTheIndicator() app.DispatchCommand("layout:cycle"); var cycled = app.RenderWholeFrame(); await Assert.That(app.FocusedPaneId).IsNotEqualTo(first); - await Assert.That(CellsPaintedIn(cycled, rects[app.FocusedPaneId], focused)).IsGreaterThan(0); - await Assert.That(CellsPaintedIn(cycled, rects[first], focused)).IsEqualTo(0); + await Assert.That(FrameGrid.CellsPaintedIn(cycled, rects[app.FocusedPaneId], focused)).IsGreaterThan(0); + await Assert.That(FrameGrid.CellsPaintedIn(cycled, rects[first], focused)).IsEqualTo(0); } // --- item 2: word movement moved rather than vanished ------------------------------------------ diff --git a/tests/SharpMUTerm.Tui.Tests/FrameGrid.cs b/tests/SharpMUTerm.Tui.Tests/FrameGrid.cs index 7199859..3effe60 100644 --- a/tests/SharpMUTerm.Tui.Tests/FrameGrid.cs +++ b/tests/SharpMUTerm.Tui.Tests/FrameGrid.cs @@ -7,8 +7,9 @@ namespace SharpMUTerm.Tui.Tests; /// It exists because the questions worth asking about the input area are about painted cells. A caret /// test written against InputBarControl.GetLogicalCursorPosition agrees with the code it is /// testing and can disagree with the screen for as long as nobody looks; the frame cannot lie. Only the -/// characters are kept — colour is RailRendererTests' and FocusIndicationTests' business, -/// and both already read the SGR they need out of the same escapes. +/// characters are kept by — the background of each cell is +/// 's answer, beside it, because the two questions are asked of the same +/// escapes and a suite that walked them twice could come to two views of one frame. /// /// internal static class FrameGrid @@ -87,4 +88,109 @@ internal static IReadOnlyList Decode(string frame, int width, int height return lines; } + + /// The truecolor background escape a colour is written as, e.g. 48;2;51;57;76. + internal 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: the last + /// 48;2;r;g;b seen is the background of every cell written until the next one. Note 48 + /// and not 38 — reading foreground here and concluding about bands is the classic mistake. + /// + /// One copy, deliberately. This walker parses SGR parameters and cursor addressing, and it lived + /// verbatim in three suites (FocusIndicationTests, PaneJumpTests, + /// WindowJumpTests) that all assert about painted planes. Three copies of a parser can drift + /// into disagreeing about which cells are painted, and the failure that produces is a suite going + /// quietly green on a frame it has misread — not a visible break. + /// + /// + internal static Dictionary<(int Row, int Column), string?> Backgrounds(string ansi) + { + ArgumentNullException.ThrowIfNull(ansi); + + var cells = new Dictionary<(int, int), string?>(); + var current = (string?)null; + var (row, column) = (0, 0); + + foreach (System.Text.RegularExpressions.Match token in + System.Text.RegularExpressions.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 of a frame are painted in a given background. + internal static int CellsPainted(string ansi, SharpConsoleUI.Color colour) + { + var wanted = Sgr(colour); + return Backgrounds(ansi).Values.Count(bg => bg?.StartsWith(wanted, StringComparison.Ordinal) == true); + } + + /// How many cells inside a rectangle are painted in a given background. + internal static int CellsPaintedIn(string ansi, SharpMUTerm.Core.Workspaces.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; + } + + /// + /// Markup with its style and link tags removed and its [[/]] escapes undone — a row's + /// visible cells, which is what a rail assertion is about. A rail row is wrapped in a + /// [link=cmd%3Acharacter%3AAlfa.Ann] span, so matching raw markup finds names inside link + /// targets as well as in the text, which is a false positive nobody spots. + /// + internal static string Visible(string markup) => + System.Text.RegularExpressions.Regex + .Replace(markup, @"\[(?:/|[^\]\[]*)\]", string.Empty) + .Replace("[[", "[") + .Replace("]]", "]"); } diff --git a/tests/SharpMUTerm.Tui.Tests/MacroKeyCaptureTests.cs b/tests/SharpMUTerm.Tui.Tests/MacroKeyCaptureTests.cs index c8e8150..452a108 100644 --- a/tests/SharpMUTerm.Tui.Tests/MacroKeyCaptureTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/MacroKeyCaptureTests.cs @@ -60,8 +60,16 @@ private static void ArmCapture(SettingsSession session) [Arguments("F12", nameof(MacroKeyDelivery.Fires))] [Arguments("Shift+F3", nameof(MacroKeyDelivery.Fires))] [Arguments("Ctrl+K", nameof(MacroKeyDelivery.Fires))] - [Arguments("Alt+K", nameof(MacroKeyDelivery.Fires))] - // ⌥0 is the one Alt+digit no surface claims: ⌥1–⌥9 go to numbered panes, and the tenth digit is + // ⌥K is the *previous character* chord, and ⌥J its partner — the pair the sidebar prints. ⌥Y stands + // in for "an Alt+letter this app has not spent", which is what this row used to be checking. + [Arguments("Alt+Y", nameof(MacroKeyDelivery.Fires))] + [Arguments("Alt+K", nameof(MacroKeyDelivery.Taken))] + [Arguments("Alt+J", nameof(MacroKeyDelivery.Taken))] + [Arguments("Alt+D", nameof(MacroKeyDelivery.Taken))] + // ⌃D was the disconnect chord and is now free — disconnect moved to ⌥D so that it and ⌥R read as one + // pair. Releasing it hands a clean Ctrl chord back to whoever wants to bind a macro there. + [Arguments("Ctrl+D", nameof(MacroKeyDelivery.Fires))] + // ⌥0 is the one Alt+digit no surface claims: ⌥1–⌥9 go to numbered windows, 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))] diff --git a/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs index 8e8d15f..e9319bd 100644 --- a/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs @@ -8,35 +8,31 @@ 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. +/// ⌃B 1–⌃B 9 go to a numbered pane and bring it to the front. This chord was ⌥1–⌥9 until that was +/// given to windows (see for why); a pane and a window are +/// different destinations and one key cannot mean both. These are the claims the move leaves standing. /// -/// 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). +/// 1. It is kept rather than dropped. Every pane is reachable by ⌥N through whatever window it +/// holds, so this is not the only route there — but the pane numbering does not go away with the +/// chord. Move mode badges each pane with its digit, the move prompt and the drag overlay both say +/// pane 2, the ⌃P entry says Go to pane 2, and ⌃O counts in the same order. A numbering the +/// client prints and asks you to press inside a mode, with no key outside that mode that acts on it, +/// would be a numbering that only half exists. It is also the one motion that moves to a pane +/// without naming what is in it: the ordinal member of the ⌃O / ⌃arrow family. /// /// -/// 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. +/// 2. It is on ⌃B because that is where the pane keymap lives — split, zoom, close, cycle, move, +/// freeze, rail. The digits were the one part of that keymap nothing claimed, so it costs no key, and the +/// which-key panel lists it beside the others rather than leaving it to be found. /// /// -/// 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. +/// 3. The number on the screen is the number pressed. Panes are counted in Layout.Panes +/// order (creation order), which is what move mode badges and what the ⌃P entry names, so the assertions +/// drive the overlay and read its words rather than writing down which pane ought to be third. /// /// -/// 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. +/// 4. Arrival is unchanged. Full activation of the pane's own window, the session and command line +/// following, the zoom carried, and an out-of-range digit reported rather than swallowed. /// /// /// @@ -52,20 +48,20 @@ public class PaneJumpTests private static readonly TerminalCapabilities Headless = new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); - // --- the numbering, against the rail's own labels ---------------------------------------------- + // --- the numbering ------------------------------------------------------------------------------ /// - /// 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 claim, end to end. Three panes, three characters. For every digit: ⌃B N, and the pane + /// whose plane the frame paints as focused is the pane move mode calls 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. + /// The label comes out of the real move overlay rather than being written down here, so the two + /// surfaces are held against each other. A chord that lands somewhere other than the label says is + /// worse than no chord. /// /// [Test] - public async Task EachDigitLandsOnThePaneTheRailNumbersWithIt() + public async Task EachDigitLandsOnThePaneTheMoveOverlayNumbersWithIt() { var three = await ThreePanes(); var (focused, _) = three.App.PaneBandColors; @@ -73,56 +69,43 @@ public async Task EachDigitLandsOnThePaneTheRailNumbersWithIt() for (var n = 1; n <= 3; n++) { - three.App.SimulateKey(Alt(n)); + Prefix(three.App, 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($"⌥{n}") - .Because($"⌥{n} must land on the pane the sidebar calls pane {n}"); + var landed = three.App.FocusedPaneId; - // The session, so the command line is talking to the pane you are looking at. + // The session and the window, 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)) + await Assert.That(FrameGrid.CellsPaintedIn(frame, rects[landed], focused)) .IsGreaterThan(0) - .Because($"⌥{n} must paint pane {n} as the focused one"); + .Because($"⌃B {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); + await Assert.That(FrameGrid.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(); + // And the overlay, which is where a user reads the pane's number, targets the same pane with + // the same digit — driven through a real commit, so the answer is the id the overlay stored. + var (target, prompt) = MoveOverlayTarget(three.App, n); + await Assert.That(target) + .IsEqualTo(landed) + .Because($"⌃B {n} must land on the pane the overlay picks for {n}"); + await Assert.That(prompt) + .Contains($"pane {n}") + .Because("the prompt's words and the digit it accepted are one number"); + } } /// - /// 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. + /// The pane label the ⌃P surface offers names this chord and not the old one — the entry is a second + /// door onto ⌃B N, and an entry still saying ⌥2 would send a reader to a key that now goes to a + /// window. Both routes are driven and reach the same pane. /// [Test] - public async Task TheCommandSurfaceOffersOneEntryPerPaneAndAgreesWithTheChord() + public async Task TheCommandSurfaceOffersOneEntryPerPaneAndNamesTheNewChord() { var three = await ThreePanes(); @@ -136,15 +119,15 @@ public async Task TheCommandSurfaceOffersOneEntryPerPaneAndAgreesWithTheChord() 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}") + .IsEqualTo($"⌃B {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)); + Prefix(three.App, 1); + Prefix(three.App, n); var viaKey = three.App.FocusedPaneId; - three.App.SimulateKey(Alt(1)); + Prefix(three.App, 1); await Assert.That(three.App.DispatchCommand(CommandIds.Pane(n))).IsTrue(); await Assert.That(three.App.FocusedPaneId).IsEqualTo(viaKey); } @@ -166,27 +149,65 @@ await Assert.That(app.BuildCatalog().Any(c => c.Id.StartsWith(CommandIds.PanePre .IsFalse(); } + /// + /// Closing pane 2 of three makes the third pane into pane 2, on the chord and in the overlay + /// together. Creation sequences are never reused, so a number read straight off one would leave a + /// hole here: ⌃B 2 would report "there is no pane 2" while two panes sat on the screen. The number is + /// the pane's position in the numbering for exactly this reason. + /// + [Test] + public async Task ClosingAPaneCompactsTheNumberingOnTheChordAndInTheOverlay() + { + var three = await ThreePanes(); + + Prefix(three.App, 2); // stand in Bob's pane + await Assert.That(three.App.DispatchCommand("layout:close")).IsTrue(); + three.App.RenderNextFrame(); + await Assert.That(three.App.PaneIds.Count).IsEqualTo(2); + + var entries = three.App.BuildCatalog() + .Where(c => c.Id.StartsWith(CommandIds.PanePrefix, StringComparison.Ordinal)) + .Select(c => c.Id) + .ToList(); + await Assert.That(entries) + .IsEquivalentTo(new[] { CommandIds.Pane(1), CommandIds.Pane(2) }) + .Because("the panes on the screen must be numbered 1 and 2, not 1 and 3"); + + Prefix(three.App, 1); + Prefix(three.App, 2); + await Assert.That(three.App.ActiveSessionKey) + .IsEqualTo("Cara.Cal") + .Because("⌃B 2 must reach the second pane rather than the hole the closed one left"); + + Prefix(three.App, 3); + await Assert.That(three.App.StatusMarkup).Contains("there is no pane 3"); + await Assert.That(three.App.ActiveSessionKey).IsEqualTo("Cara.Cal"); + } + // --- 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. + /// ⌃B 7 with three panes says so, and names the chord it is answering. 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 wrong. Nothing moves. /// [Test] public async Task AnOutOfRangeDigitReportsAndMovesNothing() { var three = await ThreePanes(); - three.App.SimulateKey(Alt(2)); + Prefix(three.App, 2); var pane = three.App.FocusedPaneId; var session = three.App.ActiveSessionKey; foreach (var digit in new[] { 4, 7, 9 }) { - three.App.SimulateKey(Alt(digit)); + Prefix(three.App, 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.StatusMarkup) + .Contains("⌃B") + .Because("the notice names the chord that was pressed, and ⌥ is no longer it"); await Assert.That(three.App.FocusedPaneId).IsEqualTo(pane); await Assert.That(three.App.ActiveSessionKey).IsEqualTo(session); } @@ -202,115 +223,61 @@ public async Task OnOnePaneTheRefusalSaysHowToSplit() var app = App(); app.RenderSnapshot(); - app.SimulateKey(Alt(2)); + Prefix(app, 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(); - } + // --- what the move off ⌥ must not have broken --------------------------------------------------- /// - /// 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. + /// The digits are not global shortcuts, and must not become them. ⌃B 1 is a key on the prefix + /// keymap, consumed by the armed prefix; a bare 1 with no prefix is typing, which is what F4 + /// reports and what the command line receives. Registering these as application shortcuts would take + /// the digit row away from the prompt entirely. /// [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() + public async Task ABareDigitStillTypesRatherThanJumping() { var app = App(); app.RenderSnapshot("split"); + var pane = app.FocusedPaneId; - foreach (var digit in new[] { 2, 1, 2 }) - { - app.SimulateKey(Alt(digit)); - app.RenderNextFrame(); - await Assert.That(app.ArmedBarHasFocus).IsTrue(); - } + app.SimulateKey(Plain('2', ConsoleKey.D2)); + + await Assert.That(app.ArmedInputText).Contains("2"); + await Assert.That(app.FocusedPaneId).IsEqualTo(pane); + await Assert.That(MacroKeys.Verdict("2").Delivery).IsEqualTo(MacroKeyDelivery.Taken); + await Assert.That(MacroKeys.AppShortcuts.Any(s => s.Modifiers == 0 && s.Key == ConsoleKey.D2)).IsFalse(); } /// - /// 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. + /// The which-key panel lists it. This surface is where the ⌃B keymap is discovered from, and a + /// chord it does not name is a chord nobody finds — the state ⌃L's newline sat in until it was + /// reported as missing. It is blocked on the same fact as zoom and cycle: with one pane there is + /// nowhere to go. /// [Test] - public async Task JumpingMovesNoPaneRectangle() + public async Task ThePrefixPanelNamesTheNumberedPaneJump() { - var app = App(); - app.RenderSnapshot("split"); - var before = app.PaneOutputRects().ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); + var onOne = PrefixPanel.Entries(PrefixFacts.Fresh).Single(e => e.Keys == "1–9"); + await Assert.That(onOne.Title).Contains("numbered pane"); + await Assert.That(onOne.Available) + .IsFalse() + .Because("with one pane it can do nothing, and a panel listing it as live sends a reader to press it"); - app.SimulateKey(Alt(2)); - app.RenderNextFrame(); - app.SimulateKey(Alt(1)); - app.RenderNextFrame(); + var onTwo = PrefixPanel.Entries(PrefixFacts.Fresh with { PaneCount = 2 }).Single(e => e.Keys == "1–9"); + await Assert.That(onTwo.Available).IsTrue(); - 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); - } + await Assert.That(PrefixPanel.StripKeys).Contains("1–9"); } - // --- 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. + /// caret on a pane that is not on the screen. ⌃B 2 over a zoomed pane 1 therefore shows pane 2 zoomed, + /// and ⌃B z still un-zooms. /// [Test] public async Task JumpingWhileZoomedBringsTheTargetToTheFrontRatherThanHidingIt() @@ -323,7 +290,7 @@ public async Task JumpingWhileZoomedBringsTheTargetToTheFrontRatherThanHidingIt( await Assert.That(app.DispatchCommand("layout:zoom")).IsTrue(); await Assert.That(app.ZoomedPaneId).IsEqualTo(first); - app.SimulateKey(Alt(2)); + Prefix(app, 2); var frame = app.RenderWholeFrame(); await Assert.That(app.FocusedPaneId).IsEqualTo(second); @@ -337,20 +304,53 @@ await Assert.That(app.ZoomedPaneId) await Assert.That(rects.ContainsKey(first)).IsFalse(); var (focused, _) = app.PaneBandColors; - await Assert.That(CellsPaintedIn(frame, rects[second], focused)).IsGreaterThan(0); + await Assert.That(FrameGrid.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. + /// ⌃O is the other ordinal pane mover and counts the same order, so the two cannot come to mean + /// different things: n presses of ⌃O from pane 1 land where ⌃B n+1 does, for every + /// n the fixture has — and the last one wraps back to pane 1, which is the step where a + /// cycle and a numbering are most easily written to disagree. + /// + /// Driven for the whole cycle rather than at one arbitrary depth. This used to press ⌃O twice and + /// compare against ⌃B 3 while its own sentence claimed three presses and ⌃B 4 — a fixture of three + /// panes has no pane 4, so the prose described a property the test did not reach and could not have. + /// The property was the right one; the test was the weak half. + /// + /// + /// It also still carries a zoom; it used to cycle the selection out from under one and leave it + /// invisible. + /// /// [Test] - public async Task CyclingWhileZoomedCarriesTheZoomToo() + public async Task CyclingCountsTheSameOrderAsTheChordAndCarriesTheZoomToo() { + var three = await ThreePanes(); + var panes = three.App.PaneIds.Count; + await Assert.That(panes).IsEqualTo(3); + + for (var presses = 1; presses <= panes; presses++) + { + Prefix(three.App, 1); + for (var i = 0; i < presses; i++) + { + three.App.SimulateKey(Ctrl(ConsoleKey.O)); + } + + var viaCycle = three.App.FocusedPaneId; + + // n presses from pane 1 is pane n+1, wrapping — so the last lap comes back to pane 1. + var expected = presses % panes + 1; + Prefix(three.App, expected); + await Assert.That(three.App.FocusedPaneId) + .IsEqualTo(viaCycle) + .Because($"{presses} press(es) of ⌃O from pane 1 must land where ⌃B {expected} does"); + } + 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(); @@ -361,18 +361,23 @@ public async Task CyclingWhileZoomedCarriesTheZoomToo() // --- 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. + /// The move overlay and the ⌃P entry 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 one place and main under the cursor. + /// ⌃B 1 is a chord that lands on a pane a label names, and it cannot survive two labels. + /// + /// The sidebar is the other half now, and it is asserted the other way round: it prints ⌥N, + /// which is the window numbering, and must never print pane N — two numberings sharing + /// one vocabulary is the thing that would make either chord unreadable. + /// /// [Test] - public async Task TheMoveOverlayAndTheSidebarSpellTheFirstPaneTheSameWay() + public async Task TheMoveOverlaySpellsTheFirstPanePaneOneAndTheSidebarNeverSaysPane() { var app = App(); app.RenderSnapshot("split"); - // ⌃B m lifts the active window; '1' targets the first pane — the same digit ⌥1 uses. + // ⌃B m lifts the active window; '1' targets the first pane — the same digit ⌃B 1 uses. app.SimulateKey(Ctrl(ConsoleKey.B)); app.SimulateKey(Plain('m', ConsoleKey.M)); app.SimulateKey(Plain('1', ConsoleKey.D1)); @@ -380,42 +385,42 @@ public async Task TheMoveOverlayAndTheSidebarSpellTheFirstPaneTheSameWay() 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("⌥1", StringComparison.Ordinal))).IsTrue(); + .Because("the first pane is pane 1 everywhere, or ⌃B 1 names something the screen does not"); app.SimulateKey(Plain('\x1b', ConsoleKey.Escape)); // leave move mode + + await Assert.That(app.RailLines.Any(l => l.Contains("pane ", StringComparison.Ordinal))) + .IsFalse() + .Because("the sidebar numbers windows, and a pane noun there would be a second reading of ⌥N"); + await Assert.That(app.RailLines.Any(l => Regex.IsMatch(l, @"⌥\d"))).IsTrue(); } /// - /// 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. - /// + /// Move mode targets by the same number as the chord. It used to letter the panes a– + /// j while the prompt one line below named the target pane N — one ordering spelt in two + /// alphabets. Driven for every pane: press the digit, and the target the prompt reports is the pane + /// ⌃B with the same digit goes to. /// [Test] - public async Task MoveModeTargetsThePaneTheRailNumbersWithTheSameDigit() + public async Task MoveModeTargetsThePaneTheChordGoesToWithTheSameDigit() { 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}"); + Prefix(three.App, n); + var viaChord = three.App.FocusedPaneId; + var (target, prompt) = MoveOverlayTarget(three.App, n); + await Assert.That(target) + .IsEqualTo(viaChord) + .Because($"pressing {n} in move mode must target the pane ⌃B {n} goes to"); + await Assert.That(prompt).Contains($"pane {n}"); } // A digit past the last pane leaves the target where it was rather than clearing it. + three.App.SimulateKey(Ctrl(ConsoleKey.B)); + three.App.SimulateKey(Plain('m', ConsoleKey.M)); + three.App.SimulateKey(Plain('3', ConsoleKey.D3)); three.App.SimulateKey(Plain('9', ConsoleKey.D9)); await Assert.That(three.App.StatusMarkup) .Contains("pane 3") @@ -428,57 +433,6 @@ await Assert.That(three.App.StatusMarkup) three.App.SimulateKey(Plain('\x1b', ConsoleKey.Escape)); } - // --- 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) @@ -487,150 +441,51 @@ private static SharpMUTermApp App(int width = 120, int height = 34) 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) + /// The chord, as the two keystrokes it is: ⌃B and then the bare digit. + private static void Prefix(SharpMUTermApp app, int digit) { - 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)); + app.SimulateKey(Ctrl(ConsoleKey.B)); + app.SimulateKey(Plain((char)('0' + digit), ConsoleKey.D0 + digit)); } /// - /// 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. + /// Which pane the real move overlay targets for , and what its prompt calls + /// that pane. /// - /// 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. + /// Read by driving the overlay and committing: ⏎ moves the active window into the pane the + /// overlay picked, so the answer comes back through PaneDrop from the id the overlay stored. + /// Nothing here indexes Layout.Panes — that is the list under test, and a helper that read it + /// would agree with the chord by construction. /// /// - private static string RailPaneLabel(SharpMUTermApp app) + private static (string Pane, string Prompt) MoveOverlayTarget(SharpMUTermApp app, int digit) { - foreach (var line in app.RailLines.Where(l => l.Contains('▪', StringComparison.Ordinal))) - { - var match = Regex.Match(line, @"⌥\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)..]; - } + var window = app.ActiveWindowId(); + app.SimulateKey(Ctrl(ConsoleKey.B)); + app.SimulateKey(Plain('m', ConsoleKey.M)); + app.SimulateKey(Plain((char)('0' + digit), ConsoleKey.D0 + digit)); - break; - } - } + var prompt = FrameGrid.Visible(app.StatusMarkup); + app.SimulateKey(Plain('\r', ConsoleKey.Enter)); // commit: the window lands in the targeted pane - return cells; + return (app.PaneIdOf(window) ?? throw new InvalidOperationException($"{window} is in no pane"), prompt); } - /// 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); + IReadOnlyList Sessions); /// /// 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. - /// + /// shell restores one. Three separate worlds so each pane holds a different session. /// private static async Task ThreePanes() { @@ -658,15 +513,15 @@ private static async Task ThreePanes() { new WorkspaceWindowState { - Id = windows[0], Title = "Ann", Kind = WindowKind.Main, SessionKey = sessions[0], + Id = windows[0], Title = "Ann", Kind = WindowKind.Main, SessionKey = sessions[0], Sequence = 1, }, new WorkspaceWindowState { - Id = windows[1], Title = "Bob", Kind = WindowKind.Main, SessionKey = sessions[1], + Id = windows[1], Title = "Bob", Kind = WindowKind.Main, SessionKey = sessions[1], Sequence = 2, }, new WorkspaceWindowState { - Id = windows[2], Title = "Cal", Kind = WindowKind.Main, SessionKey = sessions[2], + Id = windows[2], Title = "Cal", Kind = WindowKind.Main, SessionKey = sessions[2], Sequence = 3, }, }, Root = new LayoutNodeState @@ -675,25 +530,25 @@ private static async Task ThreePanes() 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 }, + 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(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], - }; + app.TelnetFactory = _ => new RecordingTelnetSession(); foreach (var key in sessions) { @@ -708,7 +563,7 @@ private static async Task ThreePanes() 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 + // this reads the overlay, so a resumed layout that came back differently must fail here and not // silently make the assertions vacuous. if (app.PaneIds.Count != 3) { @@ -724,6 +579,6 @@ private static async Task ThreePanes() } } - return new Three(app, windows, sessions, transports); + return new Three(app, windows, sessions); } } diff --git a/tests/SharpMUTerm.Tui.Tests/PaneNumberingRailTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneNumberingRailTests.cs deleted file mode 100644 index 0b19960..0000000 --- a/tests/SharpMUTerm.Tui.Tests/PaneNumberingRailTests.cs +++ /dev/null @@ -1,312 +0,0 @@ -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("⌥" + "1"); - await Assert.That(CharacterPane(app, "Bob")) - .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("⌥" + "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)![1..]); - 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("⌥" + "1"); - await Assert.That(CharacterPane(app, "Bob")).IsEqualTo("⌥" + "2"); - await Assert.That(CharacterPane(app, "Cal")).IsEqualTo("⌥" + "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("⌥" + "1"); - await Assert.That(CharacterPane(app, "Cal")) - .IsEqualTo("⌥" + "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, @"⌥\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($"⌥{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; - } -} diff --git a/tests/SharpMUTerm.Tui.Tests/RailChordColumnTests.cs b/tests/SharpMUTerm.Tui.Tests/RailChordColumnTests.cs new file mode 100644 index 0000000..8ff9c16 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/RailChordColumnTests.cs @@ -0,0 +1,354 @@ +using System.Text.RegularExpressions; +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Automation; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Workspaces; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// The chord column, and the width invariant it must not break. +/// +/// The reported complaint was the gap: "there is still way too much room after a window's name on the +/// left sidebar, before it hits 'alt-1' for instance, taking up too much room that could be spent on +/// other things." The five cells between the two were the reserved unsent-pen and unread-badge fields +/// (RailRenderer.UnsentFieldWidth / UnreadFieldWidth), which are blank far more often than +/// not — and which cannot simply be removed: a cell that appears only when it has something to say +/// widens the sidebar, the sidebar's width comes out of the pane area, and per-pane NAWS then re-announces +/// a new terminal size to every connected server. That is the reported "the screen jumps when I start +/// typing" bug and it is not being reintroduced to save three cells. +/// +/// +/// So the chord moved to the front of the row instead, where nothing blank separates it from the +/// name it belongs to, and the badges sit at the right edge where status belongs. The row's measured width +/// is unchanged by the move. This suite pins both halves: the gap is gone, and the width still does not +/// move on anything a keystroke or a line of output can do. +/// +/// +/// +/// 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 RailChordColumnTests +{ + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + // --- the complaint ------------------------------------------------------------------------------ + + /// + /// Nothing sits between a window's name and its chord. Read off the rendered rail rather than + /// the markup: the assertion is about cells on a screen. + /// + [Test] + public async Task TheChordSitsAgainstTheWindowsNameWithNoGap() + { + var app = Demo(); + app.RenderSnapshot(); + + var rows = app.RailLines.Select(FrameGrid.Visible).Where(l => l.Contains('▪', StringComparison.Ordinal)).ToList(); + await Assert.That(rows).IsNotEmpty(); + + foreach (var row in rows) + { + var match = Regex.Match(row, @"⌥\d(\s*)▪"); + await Assert.That(match.Success) + .IsTrue() + .Because($"the chord must lead the row it names, and this one reads '{row.Trim()}'"); + await Assert.That(match.Groups[1].Value.Length) + .IsEqualTo(1) + .Because("one space separates the chord from the bullet; the badges live at the far end"); + } + } + + /// + /// And the badges are still there, at the end — this is a reordering, not a removal. A row that lost + /// its pen or its count would pass the gap assertion above and be a worse rail. + /// + [Test] + public async Task TheBadgesSurviveTheReordering() + { + var app = Demo(); + app.RenderSnapshot(); + + var rows = app.RailLines.Select(FrameGrid.Visible).Where(l => l.Contains('▪', StringComparison.Ordinal)).ToList(); + await Assert.That(rows.Any(r => r.Contains(Glyphs.Draft, StringComparison.Ordinal))) + .IsTrue() + .Because("the demo leaves a draft in the main window"); + await Assert.That(rows.Any(r => Regex.IsMatch(r, @"\d\s*$"))) + .IsTrue() + .Because("the demo's Chat window has unread lines"); + } + + /// + /// Where --help quotes the sidebar, the quote is the sidebar's own cells. The page names + /// chords in ASCII everywhere else on purpose — it is printed before the TUI starts, into whatever is + /// on the other end of stdout — but a sentence that says "the sidebar marks …" is making a claim about + /// the screen, and it said 'Alt+J' while the screen said ⌥J. A reader searching the + /// sidebar for the quoted text found nothing. + /// + [Test] + public async Task WhereHelpQuotesTheSidebarItQuotesWhatTheSidebarDraws() + { + var help = Program.UsageText; + + // Every single-quoted chord on the page uses the sidebar's own sigil rather than the page's + // ASCII prose spelling. The claim is about the *spelling*, not about which digits happen to + // exist: '⌥2', '⌥3' is a series naming the form, and a rail with two windows only has two. + // Anchored on something that can only start a chord, because the page has ordinary apostrophes in + // it ("F5's 'login' row") and a looser pattern pairs them across sentences. + var quoted = Regex.Matches(help, @"'((?:⌥|⌃|Alt\+|Ctrl\+)[^']{0,12})'") + .Select(m => m.Groups[1].Value) + .ToList(); + await Assert.That(quoted).IsNotEmpty().Because("the page does quote the sidebar"); + + foreach (var chord in quoted) + { + await Assert.That(chord.StartsWith('⌥') || chord.StartsWith('⌃')) + .IsTrue() + .Because($"--help quotes {chord} as something the sidebar prints, and the sidebar " + + "spells its chords with ⌥ and ⌃"); + } + + // And the spelling is checked against a real rail rather than only against itself: the one the + // demo genuinely draws has to be among them. + var app = Demo(); + app.RenderSnapshot(); + var rail = app.RailLines.Select(FrameGrid.Visible).ToList(); + await Assert.That(quoted.Any(c => rail.Any(l => l.Contains(c, StringComparison.Ordinal)))) + .IsTrue() + .Because("at least one quoted chord must be one this rail actually prints"); + } + + // --- the invariant ------------------------------------------------------------------------------ + + /// + /// The rail's width does not move on an unread count, at any of the sizes where a reserved field + /// could burst. 0 → 1 is the badge appearing, 9 → 10 the second digit, 99 → 100 the cap. Each + /// arrives unbidden from the wire, which is what makes this the worse of the two fields: the + /// sidebar would narrow every pane on output the reader never asked for. + /// + /// Asserted on the sidebar's own column count and on the pane rectangles, because the column + /// is the cause and the rectangles are what is reported over NAWS — the same pairing + /// TabActivityIndicatorTests.ActivityMovesNoPaneRectangle uses for the tab strip. + /// + /// + [Test] + public async Task AnUnreadCountMovesNeitherTheRailNorAnyPaneRectangle() + { + var wired = await Wired(); + wired.App.RenderNextFrame(); + + var railBefore = wired.App.RailColumnWidth; + var before = wired.App.PaneOutputRects().ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); + + var delivered = 0; + foreach (var target in new[] { 1, 9, 10, 99, 100, 130 }) + { + while (delivered < target) + { + wired.Telnet.Receive(" a lamp is offered\n"); + delivered++; + } + + wired.App.RenderNextFrame(); + + await Assert.That(wired.App.RailColumnWidth) + .IsEqualTo(railBefore) + .Because($"{delivered} unread must not widen the sidebar"); + foreach (var (paneId, rect) in before) + { + await Assert.That(wired.App.PaneOutputRects()[paneId]) + .IsEqualTo(rect) + .Because($"{delivered} unread must not re-announce a pane size"); + } + } + + // And the badge really did grow past its cap, so this cannot have passed by never rendering one. + await Assert.That(wired.App.RailLines.Any(l => l.Contains("99+", StringComparison.Ordinal))).IsTrue(); + } + + /// + /// The same for a draft: the ✎ appears on the first keystroke of every line, which is the moment the + /// original defect was reported at. + /// + [Test] + public async Task ADraftMovesNeitherTheRailNorAnyPaneRectangle() + { + var wired = await Wired(); + wired.App.RenderNextFrame(); + + var railBefore = wired.App.RailColumnWidth; + var before = wired.App.PaneOutputRects().ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); + + foreach (var c in "hello there") + { + wired.App.SimulateKey(new ConsoleKeyInfo(c, ConsoleKey.NoName, false, false, false)); + wired.App.RenderNextFrame(); + + await Assert.That(wired.App.RailColumnWidth).IsEqualTo(railBefore); + foreach (var (paneId, rect) in before) + { + await Assert.That(wired.App.PaneOutputRects()[paneId]).IsEqualTo(rect); + } + } + + await Assert.That(wired.App.RailLines.Any(l => l.Contains(Glyphs.Draft, StringComparison.Ordinal))) + .IsTrue() + .Because("the pen must really have appeared, or this passed by toggling nothing"); + } + + /// + /// The ⌥J/⌥K pair travelling from row to row does not change any row's width. That column is + /// new, it is on character rows, and the two chords land on different rows after every switch — a + /// field that changes what it holds on a plain keystroke, which is exactly the shape the reserved + /// fields exist to contain. + /// + /// Asserted at the renderer, on one set of rows with the chords on one pair and another with them on + /// a different pair, because that isolates the claim. The sidebar's width is legitimately a + /// function of who is active — window rows are drawn for the active character only, so switching + /// changes which rows exist at all — and a test that pinned the column count across a character + /// switch would be pinning that unrelated fact and would fail for the right reason. + /// + /// + [Test] + public async Task TheCycleChordsMovingBetweenRowsChangesNoRowsWidth() + { + var onFirstPair = Rail(("Ann", null), ("Bob", "⌥J"), ("Cal", "⌥K")); + var onSecondPair = Rail(("Ann", "⌥K"), ("Bob", null), ("Cal", "⌥J")); + + var a = RailRenderer.Render(onFirstPair).Select(SharpMUTermApp.MarkupWidth).ToList(); + var b = RailRenderer.Render(onSecondPair).Select(SharpMUTermApp.MarkupWidth).ToList(); + + await Assert.That(b).IsEquivalentTo(a); + await Assert.That(RailRenderer.Render(onFirstPair).Any(l => l.Contains("⌥J", StringComparison.Ordinal))) + .IsTrue() + .Because("the chords must really be drawn, or this passed by rendering nothing"); + } + + /// + /// A client with nothing to put in a column does not pay for it. The field is reserved per row + /// kind: with fewer than two characters open no character row can carry a cycle chord, and + /// reserving across both kinds spent three cells on every character row of the commonest client there + /// is. Measured as a width, because that is what it costs. + /// + [Test] + public async Task ARailWithNoCharacterChordsDoesNotReserveTheColumnOnCharacterRows() + { + var app = Demo(); + app.RenderSnapshot(); + + // The demo has one character's windows and no second character open: window rows carry chords, + // character rows cannot. + await Assert.That(app.RailLines.Any(l => Regex.IsMatch(FrameGrid.Visible(l), @"⌥\d"))).IsTrue(); + await Assert.That(app.RailLines.Any(l => Regex.IsMatch(FrameGrid.Visible(l), @"⌥[JK]"))).IsFalse(); + + foreach (var row in app.RailLines.Select(FrameGrid.Visible)) + { + if (row.Contains('▪', StringComparison.Ordinal) || !Regex.IsMatch(row, @"[●○]")) + { + continue; + } + + // Four of indent, then the active marker (a glyph on the active row, a blank on the others) + // and one space. A reserved chord field would put three more in front of all of them. + await Assert.That(Regex.Match(row, @"^\s*").Value.Length) + .IsLessThanOrEqualTo(6) + .Because($"a character row with no chord to hold pays nothing for the column: '{row}'"); + } + } + + // --- harness ------------------------------------------------------------------------------------ + + private static SharpMUTermApp Demo(int width = 120, int height = 34) + { + Console.SetIn(TextReader.Null); + return new SharpMUTermApp(DemoScene.Build(), Headless, new HeadlessConsoleDriver(width, height)); + } + + + /// Three character rows under one world, each with the chord it is given. + private static IReadOnlyList Rail(params (string Name, string? Chord)[] characters) => + RailModel.Build(new[] + { + new RailWorld("Alfa", "h", 1, default, characters.Select(c => new RailCharacter( + c.Name, $"Alfa.{c.Name}", Connected: true, Active: false, 0, + Array.Empty(), c.Chord)).ToList()), + }); + + private sealed record WiredApp(SharpMUTermApp App, RecordingTelnetSession Telnet) + { + /// Switches to a character the way ⌃P and the rail do, and connects it. + internal async Task Open(string sessionKey) + { + App.TelnetFactory = _ => new RecordingTelnetSession(); + if (!App.DispatchCommand(CommandIds.Character(sessionKey))) + { + throw new InvalidOperationException($"the app would not switch to {sessionKey}"); + } + + await App.FindSession(sessionKey)!.ConnectAsync(); + } + } + + /// + /// Three characters over three worlds, one connected over a recording transport with a live capture + /// rule — so unread can be made to arrive the way it really does, off the wire and into a background + /// window, rather than by poking a counter. + /// + private static async Task Wired() + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + config.TriggerSets.Add(new TriggerSet + { + Name = "Comms", + Triggers = + { + new Trigger + { + Name = "Trade", + Pattern = "^", + Actions = new TriggerActions { SpawnTarget = "Trade" }, + }, + }, + }); + + foreach (var (world, character) in new[] { ("Alfa", "Ann"), ("Bravo", "Bob"), ("Cara", "Cal") }) + { + var definition = new WorldDefinition + { + Name = world, + Host = $"{world.ToLowerInvariant()}.example.org", + Port = 4000, + }; + definition.Characters.Add(new CharacterDefinition + { + Name = character, + Logging = new LoggingSettings(), + TriggerSets = { "Comms" }, + }); + config.Worlds.Add(definition); + } + + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(120, 34)); + var telnet = new RecordingTelnetSession(); + app.TelnetFactory = _ => telnet; + if (!app.DispatchCommand(CommandIds.Character("Alfa.Ann"))) + { + throw new InvalidOperationException("the app would not switch to Alfa.Ann"); + } + + await app.FindSession("Alfa.Ann")!.ConnectAsync(); + + // A background capture window, so the unread the test drives lands somewhere the reader is not + // looking — which is the only state a badge is drawn in. + telnet.Receive(" opening\n"); + app.RenderNextFrame(); + + return new WiredApp(app, telnet); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs b/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs index 3ea0197..6989461 100644 --- a/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs @@ -48,7 +48,7 @@ public async Task ACharactersOwnWindowRowReadsMainRatherThanRepeatingTheWorld() var app = await LongWorld(); var rows = Rail(app); - var window = rows.Single(r => r.TrimStart().StartsWith("▪", StringComparison.Ordinal)); + var window = rows.Single(r => r.Contains('▪', StringComparison.Ordinal)); await Assert.That(window.Trim()).IsEqualTo("▪ main"); await Assert.That(rows.Count(r => r.Contains("Convergence MUSH", StringComparison.Ordinal))).IsEqualTo(1); @@ -64,71 +64,92 @@ public async Task ASpawnWindowRowKeepsItsTargetName() var app = App(); app.RenderSnapshot(); // the demo resumes Corvid's main window plus a Chat spawn - var windows = Rail(app).Where(r => r.TrimStart().StartsWith("▪", StringComparison.Ordinal)).ToList(); + var windows = Rail(app).Where(r => r.Contains('▪', StringComparison.Ordinal)).ToList(); // The demo leaves a line half-typed in the main window, so that row also carries the ✎ pen. The // gaps are the reserved badge fields: the pen's two cells and the unread count's three are always // there, blank when there is nothing to put in them, so that a keystroke or a line of output // cannot resize the sidebar (see RailRenderer.UnsentFieldWidth). + // The chord *leads* each row: the demo holds two of Corvid's windows, so ⌥1 and ⌥2 both name + // somewhere to go, and each sits against the name it belongs to rather than behind two blank + // badge fields (see RailChordColumnTests, which is where that reordering is pinned). await Assert.That(windows.Select(r => r.TrimEnd()).Select(r => r.TrimStart()).ToList()) - .IsEquivalentTo(new[] { "▪ main " + Glyphs.Draft, "▪ Chat 2" }); + .IsEquivalentTo(new[] { "⌥1 ▪ main " + Glyphs.Draft, "⌥2 ▪ Chat 2" }); } /// - /// 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. 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. + /// The two columns never wear the same word. The second column used to be the hosting pane and + /// it 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. It is now the ⌥N that goes to the window + /// the row is, which no window title can be mistaken for, and which the panes' own + /// pane N vocabulary cannot be mistaken for either. /// [Test] - public async Task TheHostingPaneColumnNeverRepeatsTheWindowsOwnName() + public async Task TheChordColumnNeverRepeatsTheWindowsOwnName() { var app = App(); - app.RenderSnapshot("split"); // two panes, so the column has something to say + app.RenderSnapshot("split"); // two panes, so both windows are visible at once app.RenderNextFrame(); - var windows = Rail(app).Where(r => r.TrimStart().StartsWith("▪", StringComparison.Ordinal)).ToList(); + var windows = Rail(app).Where(r => r.Contains('▪', StringComparison.Ordinal)).ToList(); await Assert.That(windows).IsNotEmpty(); foreach (var row in windows) { await Assert.That(row).Contains("⌥"); await Assert.That(row.Trim()).IsNotEqualTo("▪ main main"); + await Assert.That(row) + .DoesNotContain("pane ") + .Because("panes are numbered separately, and a pane noun here would be a second reading of ⌥N"); } } /// - /// And with one pane the column is not drawn at all — there is one place a window can be, so naming it - /// says nothing, and the three cells of the gap came out of the pane area through the rail's width. + /// With one window the column is not drawn at all — there is one place to be, so naming it says + /// nothing, and the three cells it costs come out of the pane area through the rail's width. + /// + /// The condition is the window count and no longer the pane count: the column named the + /// hosting pane until ⌥N was given to windows, so it appeared only in a split. It now appears as soon + /// as there is somewhere else to go, which on a fresh client with one capture open is immediately — + /// and that is the point, because the capture is what the chord was asked to reach. + /// /// [Test] - public async Task WithOnePaneTheHostingColumnIsNotDrawn() + public async Task WithOneWindowTheChordColumnIsNotDrawn() { - var app = App(); + var app = OneWindow(); app.RenderSnapshot(); - await Assert.That(app.PaneIds.Count).IsEqualTo(1); - foreach (var row in Rail(app).Where(r => r.TrimStart().StartsWith("▪", StringComparison.Ordinal))) + await Assert.That(app.NumberedWindowIds.Count).IsEqualTo(1); + foreach (var row in Rail(app).Where(r => r.Contains('▪', StringComparison.Ordinal))) { await Assert.That(row).DoesNotContain("⌥"); } - // The rows do end in blanks now, and that is the reserved badge fields rather than slack — so the + // The rows do end in blanks, 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. + // that actually mattered: a rail with nothing to say in this column must not pay for it. var single = MainWindowRowWidth(app); - await Assert.That(app.DispatchCommand("layout:split-right")).IsTrue(); - app.RenderNextFrame(); - await Assert.That(Rail(app).Single(MainRow)).Contains("⌥"); - await Assert.That(MainWindowRowWidth(app)).IsGreaterThan(single); + var two = App(); + two.RenderSnapshot(); // the demo resumes with a Chat capture beside the main window + await Assert.That(two.NumberedWindowIds.Count).IsEqualTo(2); + + await Assert.That(Rail(two).Single(MainRow)).Contains("⌥"); + await Assert.That(MainWindowRowWidth(two)) + .IsEqualTo(single + 3) + .Because("the column is `⌥N` and one space — three cells, and only when it has something to say"); + } + + /// The demo scene with its capture window taken out, so one window is left in one pane. + private static SharpMUTermApp OneWindow() + { + Console.SetIn(TextReader.Null); + return new SharpMUTermApp(DemoConfigs.SingleWindow(), Headless, new HeadlessConsoleDriver(Width, Height)); } private static bool MainRow(string row) => - row.TrimStart().StartsWith("▪ main", StringComparison.Ordinal); + row.Contains("▪ main", StringComparison.Ordinal); private static int MainWindowRowWidth(SharpMUTermApp app) => app.RailLines.Select(SharpMUTermApp.MarkupWidth) diff --git a/tests/SharpMUTerm.Tui.Tests/SnapshotViewEmptyConfigTests.cs b/tests/SharpMUTerm.Tui.Tests/SnapshotViewEmptyConfigTests.cs new file mode 100644 index 0000000..c33f499 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/SnapshotViewEmptyConfigTests.cs @@ -0,0 +1,86 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// Every snapshot view survives a configuration with nothing in it. +/// +/// --demo-config is the caller's choice, not RenderSnapshot's: without it the +/// snapshot renders whatever configuration is on the machine, which on a fresh install is a world list +/// of length zero. A view that reaches for Worlds[0] — or for the first character of a world +/// nobody has put one in — therefore throws out of a path whose whole job is to produce a frame. +/// +/// +/// The characters view did exactly that: it guarded the loop that opened the two sessions and +/// then indexed _config.Worlds[0].Characters[0] unguarded on the line after, to pose the frame. +/// A guard on one indexer and not its neighbour is how this arrives, so this walks every view +/// rather than the one that was reported — the sibling views (quit, the MSSP screen, +/// ActiveWorldIndex) were each already careful, and this is what keeps them that way. +/// +/// +/// +/// 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 SnapshotViewEmptyConfigTests +{ + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + /// + /// Every view --help and docs/ name, rendered against a configuration with no worlds at + /// all. Listed out rather than reflected over the source so that a view added without a thought for + /// this case is a line somebody has to write here. + /// + private static readonly string[] Views = + [ + "collapsed", "prefix", "prefix-panel", "timestamps", "timestamps-toggled", "spawn", "split", + "focus", "focus-moved", "freeze", "freeze-scrollback", "move", "drag", "scrollback", + "scrollback-up", "web", "rail-long", "history", "history-search", "history-search-filter", + "draft", "draft2", "menu", "menu-split", "messages", "quit", "connections", "characters", + "deletions", "textansi", "input", "keypad", "password", "startup", "logging", "set", "triggers", + "route", "highlight", "worlds", "settings", "mssp", "mssp-none", "mssp-never", + ]; + + /// + /// The reported crash, and the sweep behind it. No worlds: the first character of the first + /// world is not a thing that exists, and no view may assume otherwise. + /// + [Test] + [MethodDataSource(nameof(Views))] + public async Task AViewRendersAgainstAConfigurationWithNoWorlds(string view) + { + Console.SetIn(TextReader.Null); + var app = new SharpMUTermApp(new AppConfiguration(), Headless, new HeadlessConsoleDriver(120, 34)); + + var frame = app.RenderSnapshot(view); + + await Assert.That(frame).IsNotNull().Because($"--view {view} must render against an empty config"); + await Assert.That(frame.Length).IsGreaterThan(0); + } + + /// + /// And the half-populated case the characters view actually tripped over: a world exists, but + /// nobody has added a character to it. Worlds[0] resolves and Characters[0] does not, + /// which is the shape a guard on the outer index alone would still miss. + /// + [Test] + [MethodDataSource(nameof(Views))] + public async Task AViewRendersAgainstAWorldWithNoCharacters(string view) + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + config.Worlds.Add(new WorldDefinition { Name = "Bare", Host = "bare.example.org", Port = 4000 }); + + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(120, 34)); + + var frame = app.RenderSnapshot(view); + + await Assert.That(frame.Length) + .IsGreaterThan(0) + .Because($"--view {view} must render against a world nobody has put a character in"); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/WindowJumpTests.cs b/tests/SharpMUTerm.Tui.Tests/WindowJumpTests.cs new file mode 100644 index 0000000..ef2b20e --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/WindowJumpTests.cs @@ -0,0 +1,973 @@ +using SharpMUTerm.Core.Automation; +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 window of the active character, and bring it forward. The +/// request was "I want it to be able to go between tabs? Panes? Whichever it is that allows me to switch +/// not just characters, but captures, etc." — answered by the window — and then, once a global numbering +/// was in front of them: "I am looking for the characters to have different numbers? Am I not +/// communicating something right here? … Let's create a different mechanic to easily be able to switch +/// characters then!" These are the claims the scoped numbering stands on. +/// +/// 1. It reaches a capture window, which is the half the pane chord could not do. ⌥N used to name +/// a pane, and a capture sharing a pane with its character's own window had no number: it was reachable +/// only while it happened to be that pane's active tab. Every window has a digit now, whichever tab is +/// in front. +/// +/// +/// 2. The digits re-base per character. ⌥1 is this character's own window whoever you +/// are, ⌥2 their first capture. Globally numbered, three characters sharing pane 1 as tabs all read +/// ⌥1 on the sidebar, and nine digits do not stretch over everybody's windows. Characters are +/// reached by the ⌥J/⌥K cycle instead. The fixture puts a capture second in creation order on +/// purpose, so the window digits are not the pane digits wearing a new name. +/// +/// +/// 3. The number is the number on the screen. Windows are counted in WindowsFor order, +/// which is exactly the set and order the rail draws window rows in — so the assertions read the label +/// off the live rail and press the digit that label names, rather than writing down which +/// window ought to be second. 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 thing once +/// (▪ main main). +/// +/// +/// 4. It is the full activation, on painted cells. "Bring it forward" is not FocusedPaneId +/// being assigned: it is the window active in its pane's strip, that pane's plane on the frame, and the +/// command line talking to its character. All three are asserted, the plane off the frame the driver was +/// handed — a focus indicator can be set on a control arranged at zero rows and read back happily. +/// +/// +/// 5. 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. +/// +/// +/// 6. 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. See +/// for the numbered pane jump this displaced onto ⌃B. +/// +/// +/// +/// 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 WindowJumpTests +{ + 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 characters in three panes, and one of them with a capture + /// window as well — four windows, four digits. For every digit: press ⌥N, and the window that comes + /// to the front is the window the rail labels ⌥N, with its pane focused on the painted frame + /// 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 window is third: the assertion is that the two + /// agree, which is the only property that makes the chord usable. + /// + /// + [Test] + public async Task EachDigitLandsOnTheWindowTheRailNumbersWithIt() + { + var scene = await BuildScene(); + var (focused, _) = scene.App.PaneBandColors; + + // Ann is active and owns two of the four windows, so her digits are 1 and 2 — not 1 and 2 of a + // run that continues into Bob's and Cal's. + await Assert.That(scene.App.NumberedWindowIds).IsEquivalentTo(scene.Ann); + + for (var n = 1; n <= scene.Ann.Count; n++) + { + scene.App.SimulateKey(Alt(n)); + var frame = scene.App.RenderWholeFrame(); + + await Assert.That(scene.App.ActiveWindowId()) + .IsEqualTo(scene.Ann[n - 1]) + .Because($"⌥{n} must bring forward the window the sidebar labels ⌥{n}"); + + // The rail's own word for the window that is now in front. + await Assert.That(WindowRowChord(scene.App, scene.AnnLabels[n - 1])) + .IsEqualTo($"⌥{n}") + .Because("the digit pressed and the digit drawn beside the window that arrived are one number"); + + // The session, so the command line is talking to the window you are looking at. + await Assert.That(scene.App.ActiveSessionKey).IsEqualTo("Alfa.Ann"); + + // And the paint: the hosting pane's rectangle carries the focused plane and no other one does. + var rects = scene.App.PaneOutputRects(); + var landed = scene.App.FocusedPaneId; + await Assert.That(landed).IsEqualTo(scene.App.PaneIdOf(scene.Ann[n - 1])); + await Assert.That(FrameGrid.CellsPaintedIn(frame, rects[landed], focused)) + .IsGreaterThan(0) + .Because($"⌥{n} must paint the pane holding window {n} as the focused one"); + foreach (var other in scene.App.PaneIds.Where(id => id != landed)) + { + await Assert.That(FrameGrid.CellsPaintedIn(frame, rects[other], focused)).IsEqualTo(0); + } + } + } + + /// + /// The claim the redesign turned on: the digits re-base when the character does. Ann has two + /// windows and Bob has one, so ⌥1 is Ann's own window while she is active and Bob's the + /// moment he is — and ⌥2, which reached Ann's capture, has nothing behind it under Bob and says so. + /// + /// Globally numbered this was the reported confusion: every character's row read ⌥1 because + /// their windows happened to share a run, and a digit meant a different thing depending on who you + /// had last been. + /// + /// + [Test] + public async Task TheDigitsAreTheActiveCharactersAndReBaseWhenItChanges() + { + var scene = await BuildScene(); + + scene.App.SimulateKey(Alt(1)); + await Assert.That(scene.App.ActiveWindowId()).IsEqualTo("main"); + await Assert.That(scene.App.NumberedWindowIds).IsEquivalentTo(scene.Ann); + + scene.App.SimulateKey(AltKey(ConsoleKey.J)); // to Bob + await Assert.That(scene.App.ActiveSessionKey).IsEqualTo("Bravo.Bob"); + await Assert.That(scene.App.NumberedWindowIds) + .IsEquivalentTo(new[] { "char:Bravo.Bob" }) + .Because("Bob owns one window, so his numbering is just ⌥1"); + + scene.App.SimulateKey(Alt(1)); + await Assert.That(scene.App.ActiveWindowId()) + .IsEqualTo("char:Bravo.Bob") + .Because("⌥1 is whoever is active's own window, not a fixed window in the workspace"); + + scene.App.SimulateKey(Alt(2)); + await Assert.That(scene.App.StatusMarkup) + .Contains("Bob has one window") + .Because("Ann's second window is not Bob's second window; it is not his at all, and the " + + "refusal names whose windows it counted"); + await Assert.That(scene.App.ActiveWindowId()).IsEqualTo("char:Bravo.Bob"); + } + + /// + /// A window nobody owns is in everybody's numbering. The web view belongs to no session and is + /// reachable from wherever you are, so it takes a digit under each character — a different one under + /// each, since it sits after that character's own windows. That is not a second numbering: it is + /// exactly the set the rail draws window rows for, which admits a character's own windows plus the + /// unowned ones, so the sidebar and the chord read one list. + /// + [Test] + public async Task AnUnownedWindowIsNumberedUnderEveryCharacter() + { + var scene = await BuildScene(); + scene.App.OpenUnownedWindowForTest("web", "Web"); + scene.App.RenderNextFrame(); + + // Ann owns two, so the web view is her third. + scene.App.SimulateKey(Alt(1)); + scene.App.RenderNextFrame(); + await Assert.That(WindowRowChord(scene.App, "Web")).IsEqualTo("⌥3"); + scene.App.SimulateKey(Alt(3)); + await Assert.That(scene.App.ActiveWindowId()).IsEqualTo("web"); + + // Bob owns one, so it is his second — a different digit for the same window, which is what + // "re-based per character" means and why the sidebar has to print it rather than be inferred. + scene.App.SimulateKey(AltKey(ConsoleKey.J)); + scene.App.RenderNextFrame(); + await Assert.That(WindowRowChord(scene.App, "Web")).IsEqualTo("⌥2"); + scene.App.SimulateKey(Alt(2)); + await Assert.That(scene.App.ActiveWindowId()).IsEqualTo("web"); + } + + /// + /// The half a pane-numbered chord could not do: a capture window behind another tab. Ann's + /// Chat window shares a pane with Ann's own window and is not the tab in front — under the old chord + /// there was no digit that reached it, because the pane's digit went to whatever the pane was already + /// showing. Pressing its digit switches the tab as well as the pane. + /// + [Test] + public async Task ADigitReachesACaptureWindowSittingBehindAnotherTabInItsPane() + { + var scene = await BuildScene(); + var chat = Workspace.SpawnWindowId("Chat"); + var chatPane = scene.App.PaneIdOf(chat)!; + + // Stand in Chat's own pane, on the other tab, so nothing but the tab has to move. + scene.App.SimulateKey(Alt(1)); + await Assert.That(scene.App.PaneIdOf("main")).IsEqualTo(chatPane); + await Assert.That(scene.App.ActiveWindowId()).IsNotEqualTo(chat); + + scene.App.SimulateKey(Alt(2)); + + await Assert.That(scene.App.ActiveWindowId()) + .IsEqualTo(chat) + .Because("a capture window is exactly what the chord was asked to reach"); + await Assert.That(scene.App.FocusedPaneId).IsEqualTo(chatPane); + } + + /// + /// And the line typed next goes to the character you cycled to. 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. + /// + /// Driven with ⌥K so the backwards half of the cycle is exercised too: Ann is first, so ⌥K wraps to + /// Cal and a second one lands on Bob. + /// + /// + [Test] + public async Task TheLineTypedAfterACharacterCycleReachesThatCharacter() + { + var scene = await BuildScene(); + + scene.App.SimulateKey(AltKey(ConsoleKey.K)); // Ann -> Cal, wrapping + await Assert.That(scene.App.ActiveSessionKey).IsEqualTo("Cara.Cal"); + Send(scene.App, "look"); + + scene.App.SimulateKey(AltKey(ConsoleKey.K)); // Cal -> Bob + await Assert.That(scene.App.ActiveSessionKey).IsEqualTo("Bravo.Bob"); + Send(scene.App, "score"); + + await Assert.That(scene.Transports[2].Lines).IsEquivalentTo(new[] { "look" }); + await Assert.That(scene.Transports[1].Lines).IsEquivalentTo(new[] { "score" }); + await Assert.That(scene.Transports[0].Lines).IsEmpty(); + } + + /// + /// The ⌃P surface offers each window with the chord that goes to it, and the digit it names is the + /// digit the sidebar draws — the entry is a second door onto the chord, not a second numbering. Both + /// routes are then driven and land on the same window. + /// + [Test] + public async Task TheCommandSurfaceNamesEachWindowsChordAndAgreesWithIt() + { + var scene = await BuildScene(); + + // Stand somewhere fixed: the catalog skips whichever window is active, so the entry under test + // has to be one of the others. + scene.App.SimulateKey(Alt(1)); + + var entries = scene.App.BuildCatalog() + .Where(c => c.Id.StartsWith(CommandIds.WindowPrefix, StringComparison.Ordinal)) + .ToDictionary(c => c.Id, c => c.Subtitle, StringComparer.Ordinal); + + for (var n = 2; n <= scene.Ann.Count; n++) + { + var subtitle = entries[CommandIds.Window(scene.Ann[n - 1])]; + await Assert.That(subtitle).IsNotNull(); + await Assert.That(subtitle!) + .StartsWith($"⌥{n} · ") + .Because("an entry that named the wrong chord would be worse than a bare one"); + + scene.App.SimulateKey(Alt(n)); + var viaKey = scene.App.ActiveWindowId(); + + scene.App.SimulateKey(Alt(1)); + await Assert.That(scene.App.DispatchCommand(CommandIds.Window(scene.Ann[n - 1]))).IsTrue(); + await Assert.That(scene.App.ActiveWindowId()).IsEqualTo(viaKey); + } + + // And a window belonging to somebody else carries no chord at all: ⌥2 from here reaches Ann's + // capture, not Bob's window, so an entry claiming a digit for it would name a key that goes + // somewhere else. + await Assert.That(entries[CommandIds.Window("char:Bravo.Bob")]!.Contains('⌥')) + .IsFalse() + .Because("the numbering is the active character's, and Bob's window is not in it"); + } + + /// + /// The chord the rail prints against a character reaches that character. Window rows are drawn + /// for the active character only, so the way to another character has to be legible on their own row + /// or the cycle is a key nobody finds. Only the two neighbours carry one, because only they + /// are a single keystroke away; the chord is read out of the rendered sidebar and pressed. + /// + /// The row used to carry the chord of that character's own window. Scoped to the active character + /// that printed ⌥1 against every character on the screen, which is the confusion this design + /// replaced. + /// + /// + [Test] + public async Task PressingTheChordTheRailPrintsAgainstACharacterGoesToThatCharacter() + { + var scene = await BuildScene(); + + // Three characters: from each one, exactly two rows carry a chord and pressing either arrives. + foreach (var _ in new[] { 1, 2, 3 }) + { + scene.App.RenderNextFrame(); + var here = scene.App.ActiveSessionKey!; + + var forward = CharacterWearing(scene.App, "⌥J"); + var back = CharacterWearing(scene.App, "⌥K"); + await Assert.That(forward).IsNotNull(); + await Assert.That(back).IsNotNull(); + await Assert.That(forward).IsNotEqualTo(back); + await Assert.That(forward).IsNotEqualTo(NameOf(here)); + + // Nobody else wears one — including the row you are standing on, whose ▸ already says so. + await Assert.That(CharactersWearingAChord(scene.App).Count) + .IsEqualTo(2) + .Because("only the two neighbours are one keystroke away, and a third would be a lie"); + + scene.App.SimulateKey(AltKey(ConsoleKey.K)); + await Assert.That(NameOf(scene.App.ActiveSessionKey!)) + .IsEqualTo(back) + .Because($"the rail said ⌥K went to {back} from {here}"); + + scene.App.SimulateKey(AltKey(ConsoleKey.J)); + await Assert.That(scene.App.ActiveSessionKey) + .IsEqualTo(here) + .Because("⌥J undoes ⌥K"); + + scene.App.SimulateKey(AltKey(ConsoleKey.J)); + } + } + + /// + /// The cycle never opens a character it was not already holding. Switching to one the client + /// has never opened creates a session and a window (the shell's SwitchToCharacter), + /// and a cycle key that did that per press would dial through a configuration by accident. Driven + /// twice: round a fixture with three characters open and two more configured and untouched, where the + /// count must not move; and on a client with none open, where it says so rather than appearing dead. + /// + [Test] + public async Task TheCycleWalksOnlyOpenCharactersAndOpensNothing() + { + var scene = await BuildScene(); + + // Two more characters exist in the configuration and have never been opened. + var configured = scene.App.BuildCatalog() + .Count(c => c.Id.StartsWith(CommandIds.CharacterPrefix, StringComparison.Ordinal)); + await Assert.That(configured).IsGreaterThanOrEqualTo(2); + + var windows = scene.App.WindowIds().Count; + var visited = new List(); + for (var i = 0; i < 6; i++) + { + scene.App.SimulateKey(AltKey(ConsoleKey.J)); + visited.Add(scene.App.ActiveSessionKey!); + } + + await Assert.That(scene.App.WindowIds().Count) + .IsEqualTo(windows) + .Because("a cycle key must not open a session for a character you have never been to"); + await Assert.That(visited.Distinct().Count()) + .IsEqualTo(3) + .Because("six steps round three open characters visit those three and nobody else"); + + // And with nothing open at all it reports rather than doing nothing quietly. + var fresh = App(); + fresh.RenderSnapshot(); + var before = fresh.WindowIds().Count; + fresh.SimulateKey(AltKey(ConsoleKey.J)); + + await Assert.That(fresh.StatusMarkup).Contains("no character is open"); + await Assert.That(fresh.WindowIds().Count).IsEqualTo(before); + } + + /// + /// Closing a window compacts the numbering, on the chord and in the sidebar together. Creation + /// sequences are never reused, so a number read straight off one would leave a hole: a digit would + /// report "there is no window" while the windows sat on the screen. The number is the 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 numbering exists to avoid. + /// + [Test] + public async Task ClosingAWindowCompactsTheNumberingOnTheChordAndInTheSidebar() + { + var scene = await BuildScene(); + scene.App.OpenUnownedWindowForTest("web", "Web"); + scene.App.SimulateKey(Alt(1)); + scene.App.RenderNextFrame(); + + // Ann: main ⌥1, Chat ⌥2, the web view ⌥3. + await Assert.That(WindowRowChord(scene.App, "Web")).IsEqualTo("⌥3"); + + scene.App.SimulateKey(Alt(2)); // Chat + await Assert.That(scene.App.ActiveWindowId()).IsEqualTo(Workspace.SpawnWindowId("Chat")); + await Assert.That(scene.App.DispatchCommand("layout:close")).IsTrue(); + scene.App.RenderNextFrame(); + + await Assert.That(scene.App.NumberedWindowIds).DoesNotContain(Workspace.SpawnWindowId("Chat")); + await Assert.That(WindowRowChord(scene.App, "Web")) + .IsEqualTo("⌥2") + .Because("the windows on the screen must be numbered without a hole where the closed one was"); + + scene.App.SimulateKey(Alt(1)); + scene.App.SimulateKey(Alt(2)); + await Assert.That(scene.App.ActiveWindowId()) + .IsEqualTo("web") + .Because("the last window's digit must follow the compaction the sidebar drew"); + + scene.App.SimulateKey(Alt(3)); + await Assert.That(scene.App.StatusMarkup).Contains("there is no window 3"); + await Assert.That(scene.App.ActiveWindowId()).IsEqualTo("web"); + } + + /// + /// A window arriving from the wire lands at the end and displaces nobody. A capture opening is + /// the commonest way this numbering changes and it is not a thing the user did — so if the order were + /// a function of position rather than of creation, a channel's first line would renumber the chords + /// under someone mid-sentence. Driven through a real trigger set and real server text, because the + /// claim is about the path that actually opens these windows. + /// + [Test] + public async Task AWindowOpenedByTheWireTakesTheNextDigitAndMovesNobodyElses() + { + var scene = await BuildScene(); + scene.App.SimulateKey(Alt(1)); + scene.App.RenderNextFrame(); + + var before = new Dictionary(StringComparer.Ordinal) + { + ["main"] = WindowRowChord(scene.App, "main"), + ["Chat"] = WindowRowChord(scene.App, "Chat"), + }; + + scene.Transports[0].Receive(" Ann offers a lamp\n"); + scene.App.RenderNextFrame(); + + var arrival = Workspace.SpawnWindowId("Trade"); + await Assert.That(scene.App.NumberedWindowIds).Contains(arrival); + + foreach (var (what, chord) in before) + { + await Assert.That(WindowRowChord(scene.App, what)) + .IsEqualTo(chord) + .Because($"{what} was {chord} before a channel opened and must still be"); + } + + await Assert.That(WindowRowChord(scene.App, "Trade")).IsEqualTo($"⌥{scene.Ann.Count + 1}"); + + scene.App.SimulateKey(Alt(scene.Ann.Count + 1)); + await Assert.That(scene.App.ActiveWindowId()).IsEqualTo(arrival); + } + + /// + /// A single-window workspace lists no chord on any row: with one place to be, the digit is not + /// information, and three cells of sidebar come out of the pane the reader is looking at. + /// + [Test] + public async Task ASingleWindowWorkspaceNamesNoChordOnAnyRow() + { + var app = SingleWindowApp(); + + await Assert.That(app.NumberedWindowIds.Count).IsEqualTo(1); + await Assert.That(app.RailLines.Any(l => Regex.IsMatch(l, @"⌥\d"))) + .IsFalse() + .Because("with one window, naming it says nothing and costs the panes their columns"); + } + + // --- out of range: report, never a silent no-op ------------------------------------------------- + + /// + /// ⌥7 with two windows says so. A silent no-op is the most-repeated defect in this codebase's + /// history, and a digit with no window 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 scene = await BuildScene(); + scene.App.SimulateKey(Alt(2)); + var window = scene.App.ActiveWindowId(); + var session = scene.App.ActiveSessionKey; + + foreach (var digit in new[] { 3, 7, 9 }) + { + scene.App.SimulateKey(Alt(digit)); + + await Assert.That(scene.App.StatusMarkup).Contains($"there is no window {digit}"); + await Assert.That(scene.App.StatusMarkup) + .Contains("Ann") + .Because("the numbering is per character, so a count with no subject names the wrong set"); + await Assert.That(scene.App.StatusMarkup).Contains(scene.Ann.Count.ToString()); + await Assert.That(scene.App.ActiveWindowId()).IsEqualTo(window); + await Assert.That(scene.App.ActiveSessionKey).IsEqualTo(session); + } + } + + /// + /// With one window of your own every digit past the first is out of range, and the refusal says the + /// useful thing instead of counting: the numbering is yours, so the way out of it is a character. + /// + [Test] + public async Task OnOneWindowTheRefusalPointsAtTheCharacterCycle() + { + var app = SingleWindowApp(); + + app.SimulateKey(Alt(2)); + + await Assert.That(app.StatusMarkup).Contains("one window"); + await Assert.That(app.StatusMarkup) + .Contains("⌥J") + .Because("with one window of your own, the useful next move is another character"); + } + + /// + /// 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.WindowJumpNumber(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.WindowJumpNumber(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 which window is in front 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 which window is in front 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. ⌥N onto a window in another pane + /// therefore shows that pane zoomed — the window 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); + var elsewhere = app.NumberedWindowIds.ToList().FindIndex(id => app.PaneIdOf(id) == second) + 1; + await Assert.That(elsewhere).IsGreaterThan(0); + + await Assert.That(app.DispatchCommand("layout:zoom")).IsTrue(); + await Assert.That(app.ZoomedPaneId).IsEqualTo(first); + + app.SimulateKey(Alt(elsewhere)); + var frame = app.RenderWholeFrame(); + + await Assert.That(app.FocusedPaneId).IsEqualTo(second); + await Assert.That(app.ZoomedPaneId) + .IsEqualTo(second) + .Because("the pane holding the window 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(FrameGrid.CellsPaintedIn(frame, rects[second], focused)).IsGreaterThan(0); + } + + // --- 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. It says + /// window, because a page still calling this a pane jump would send a reader looking for the + /// pane numbering on the sidebar, which is not where it is drawn. + /// + [Test] + public async Task HelpNamesAltDigitAsAWindowJumpAndSaysWhyNotCtrlDigit() + { + var help = Program.UsageText; + + await Assert.That(help).Contains("Alt+1..Alt+9"); + await Assert.That(help).Contains("numbered window"); + await Assert.That(help).Contains("Ctrl+digit is not"); + await Assert.That(help).Contains("Escape"); + } + + /// + /// F4 reports each of the nine as taken, and the sentence it prints names the window 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($"window {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 demo with its capture window removed, rendered once — one window in one pane, which is both + /// "the chord column has nothing to say" and "every digit past the first is out of range". + /// + private static SharpMUTermApp SingleWindowApp() + { + Console.SetIn(TextReader.Null); + var app = new SharpMUTermApp(DemoConfigs.SingleWindow(), Headless, new HeadlessConsoleDriver(120, 34)); + app.RenderSnapshot(); + return app; + } + + /// 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); + + /// An Alt+letter chord, as the terminal delivers it: ESC + the letter. + private static ConsoleKeyInfo AltKey(ConsoleKey key) => + new(char.ToLowerInvariant(key.ToString()[0]), key, 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 bar and sends from wherever the client is. + private static void Send(SharpMUTermApp app, string line) + { + app.SimulateKey(Ctrl(ConsoleKey.E)); + app.SimulateKey(Ctrl(ConsoleKey.U)); + foreach (var c in line) + { + app.SimulateKey(Plain(c, ConsoleKey.NoName)); + } + + app.SimulateKey(Plain('\r', ConsoleKey.Enter)); + } + + /// + /// The ⌥N the rail prints on the window row labelled . 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 carry the same column; the rail calls a character's own + /// session window main and gives everything else its title. + /// + private static string? WindowRowChord(SharpMUTermApp app, string label) + { + foreach (var line in app.RailLines.Select(FrameGrid.Visible)) + { + if (!Regex.IsMatch(line, $@"▪ {Regex.Escape(label)}(?![^\s])")) + { + continue; + } + + var match = Regex.Match(line, @"⌥\d"); + return match.Success ? match.Value : null; + } + + throw new InvalidOperationException( + $"no rail window row for {label}: {string.Join(" / ", app.RailLines.Select(FrameGrid.Visible))}"); + } + + /// + /// The character whose rail row carries , or null when no row does. + /// + /// Read off the rows' visible cells, with the markup stripped first. A rail row is wrapped in + /// a [link=cmd%3Acharacter%3AAlfa.Ann] span, so matching raw markup would find a name in a + /// link target as well as in the text. Character rows are told apart from window rows by the + /// bullet the latter carry; both use this same column. + /// + /// + private static string? CharacterWearing(SharpMUTermApp app, string chord) + { + foreach (var line in app.RailLines.Select(FrameGrid.Visible)) + { + if (line.Contains('▪', StringComparison.Ordinal) || !line.Contains(chord, StringComparison.Ordinal)) + { + continue; + } + + var match = Regex.Match(line, @"[A-Za-z][A-Za-z0-9]*\s*$"); + var name = Regex.Match(line.Replace(chord, string.Empty, StringComparison.Ordinal), @"[A-Za-z]+"); + return name.Success ? name.Value : match.Value.Trim(); + } + + return null; + } + + /// Every character row currently carrying a chord, so "and nobody else" is checkable. + private static List CharactersWearingAChord(SharpMUTermApp app) => + app.RailLines.Select(FrameGrid.Visible) + .Where(l => !l.Contains('▪', StringComparison.Ordinal) && Regex.IsMatch(l, @"⌥[JK]")) + .ToList(); + + /// The character half of a world.character key. + private static string NameOf(string sessionKey) => sessionKey[(sessionKey.IndexOf('.') + 1)..]; + + + + /// Three panes, one connected character each, plus one capture window behind a tab. + /// + /// Ann's own windows in numbering order — the digits ⌥1… mean while she is the active character, and + /// the whole of the numbering, because the other two characters' windows are not in it. + /// + /// + /// What the rail calls each of them on its own row, parallel to : a character's + /// own session window reads main, a capture keeps its title. + /// + private sealed record Scene( + SharpMUTermApp App, + IReadOnlyList Ann, + IReadOnlyList AnnLabels, + IReadOnlyList Transports); + + /// + /// A resumed workspace built the way the shell restores one: three panes, one character's + /// window in each, and a fourth window — Ann's Chat capture — sharing Ann's pane as a + /// background tab. Three separate worlds so each session's writes are attributable by host; one + /// world's characters would share a transport. + /// + /// The capture is the point of the fixture. It is the window the old pane-numbered chord could not + /// reach, and it is placed second in creation order so the digits are not simply the pane + /// numbers wearing a new name — a suite where the two orders coincided would pass against either. + /// + /// + private static async Task BuildScene() + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + + // A live capture rule, so the "a window arrives from the wire" case can be driven through the real + // path — a trigger matching a real line — rather than by poking a window into the workspace. + config.TriggerSets.Add(new TriggerSet + { + Name = "Comms", + Triggers = + { + new Trigger + { + Name = "Trade", + Pattern = "^", + Actions = new TriggerActions { SpawnTarget = "Trade" }, + }, + }, + }); + + 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(), + TriggerSets = { "Comms" }, + }); + config.Worlds.Add(definition); + } + + var chat = Workspace.SpawnWindowId("Chat"); + var windows = new[] { "main", chat, "char:Bravo.Bob", "char:Cara.Cal" }; + + // Ann's two, in creation order — which is the whole of ⌥N while she is active. Bob's and Cal's + // windows sit in the same workspace with higher sequences and are deliberately *not* numbered + // from here; that is the claim the redesign turned on. + var ann = new[] { "main", chat }; + var annLabels = new[] { "main", "Chat" }; + var sessions = names.Select(n => $"{n.Item1}.{n.Item2}").ToArray(); + var owners = new[] { sessions[0], sessions[0], sessions[1], sessions[2] }; + + config.LastSession = new WorkspaceState + { + Windows = + { + new WorkspaceWindowState + { + Id = windows[0], Title = "Ann", Kind = WindowKind.Main, SessionKey = owners[0], Sequence = 1, + }, + new WorkspaceWindowState + { + Id = windows[1], Title = "Chat", Kind = WindowKind.Spawn, SessionKey = owners[1], + OwnerLabel = "Ann", Sequence = 2, + }, + new WorkspaceWindowState + { + Id = windows[2], Title = "Bob", Kind = WindowKind.Main, SessionKey = owners[2], Sequence = 3, + }, + new WorkspaceWindowState + { + Id = windows[3], Title = "Cal", Kind = WindowKind.Main, SessionKey = owners[3], Sequence = 4, + }, + }, + Root = new LayoutNodeState + { + Type = "split", + Direction = SplitDirection.Row, + Children = + { + new LayoutNodeState + { + Type = "pane", Id = "p1", Tabs = { windows[0], windows[1] }, ActiveIndex = 0, Sequence = 1, + }, + new LayoutNodeState + { + Type = "pane", Id = "p2", Tabs = { windows[2] }, ActiveIndex = 0, Sequence = 2, + }, + new LayoutNodeState + { + Type = "pane", Id = "p3", Tabs = { windows[3] }, ActiveIndex = 0, Sequence = 3, + }, + }, + }, + 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(); + } + + // Back to Ann, so the numbering under test is hers and the cycle starts at a known place. + app.DispatchCommand(CommandIds.Character(sessions[0])); + app.RenderNextFrame(); + + // The fixture's own claim: Ann is active and her two windows are the numbering. Everything after + // this reads the rail, so a resumed workspace that came back differently must fail here and not + // silently make the assertions vacuous. + if (app.ActiveSessionKey != sessions[0] || !app.NumberedWindowIds.SequenceEqual(ann)) + { + throw new InvalidOperationException( + $"{app.ActiveSessionKey} is active and numbers {string.Join(", ", app.NumberedWindowIds)}"); + } + + return new Scene(app, ann, annLabels, transports); + } +}