diff --git a/.gitignore b/.gitignore index daaf225a..cb6f99eb 100644 --- a/.gitignore +++ b/.gitignore @@ -60,13 +60,19 @@ secrets.json # A user's own configuration and session data must never be committable. None of these are # written inside the repository today — config.json and the diagnostics log live beside each # other under the per-user config directory, session transcripts go wherever a character's -# logging settings point, and the scrollback spill is an ephemeral cache under XDG_CACHE_HOME. +# logging settings point, the scrollback spill is an ephemeral cache under XDG_CACHE_HOME, and +# the restore log is a `restore/` directory beside config.json holding one bounded file per pane. # They are listed because a character's password is persisted in config.json in plaintext, so # the one realistic route into git is a human copying a real config in to reproduce something. config.json client-diagnostics-*.log *.log +# The restore log's own directory. Its files are already covered by the `*.log` above — that is why +# they carry that extension — but the directory is named too, because "why is this ignored" should be +# answerable by reading this file rather than by knowing which extension the format happened to pick. +restore/ + # Python bytecode cache __pycache__/ *.pyc diff --git a/CLAUDE.md b/CLAUDE.md index c50d8402..a0cb78ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ fallbacks) for inline images/maps. paged off an ephemeral per-session cache under `$XDG_CACHE_HOME`; absolute line indices, ranged reads capped at `MaxRangeLines`, and any disk failure degrades to memory-only. Emphatically **not** the session log — that stays `PlainTextLogSink`/`HtmlLogSink`, opt-in and kept), - `TcpTransport` (TLS + IPv6), `TelnetSession` (wraps TelnetNegotiationCore **2.6.0**), + `TcpTransport` (TLS + IPv6), `TelnetSession` (wraps TelnetNegotiationCore **2.6.5**), trigger/alias/macro engines + `IntervalScheduler`, plain-text + HTML logging, versioned JSON config (worlds → characters + shared trigger sets, with migration), `Theme`/`ThemeLibrary`, and `WorldSession`/`SessionManager` orchestration. @@ -86,6 +86,23 @@ fallbacks) for inline images/maps. single-line dividers) and the **connection rail** now rendered as well — and **clickable**: a world, character or window row switches to it, dispatched through the rail control's *own* `LinkClicked` (never the output panes' handler, so a world cannot drive the client's UI from the wire). +- **Panes come back after a restart, and the log that does it is keyed by *window*** (`RestoreLog`, + Core; `restore/` beside `config.json`, one `0600` file per window, 500 lines each). This is the third + thing in the repository that puts session text on disk and it is none of the other two: the spill is + an ephemeral cache purged next launch, the transcripts are opt-in files a user keeps, and this is a + small bounded tail nothing but startup reads. **It cannot be built on `WorldSession.Scrollback`** — a + spawn window's lines never go there (`ProcessOutputLine` raises `SpawnLine`, and a gagging capture + rule keeps the line out of the transcript entirely), so a session-keyed restore refills the main + windows and leaves every channel pane empty, which is the exact failure it exists to remove. It is + therefore fed from the shell, at `OnLine`/`OnSpawnLine`, and **not** from `AppendWindowLine`: that + seam also carries the client's own chrome and the restore *replay*, so logging there would have each + launch re-record its own history. Payload is `StyledLineCodec`, not markup, so the game's colours and + a span's interaction survive and the current theme still renders them. Appends flush to the OS per + line (a crash loses nothing; there is deliberately no `fsync` per line), the bound is in **lines** + and never bytes, and space is reclaimed by compaction — a byte-range copy through an atomic rename. + Restored content is closed off by one `RestoreBarRenderer` row and the lines themselves are left + alone. Restoring 3,000 lines costs ~18 ms before the first frame. `restore:` is the third member of + the `save:`/`logRoot:` family — **null by default, so no test and no snapshot owns one**. - **A launch connects nothing unless it is told to** (`StartupConnections.Resolve`, Core). A host on the command line wins outright; otherwise it is every character with `ConnectAtStartup` (F5's `at start`), in configuration order; otherwise none, and the client says which of the two empty states it is in. @@ -114,8 +131,8 @@ fallbacks) for inline images/maps. ```bash dotnet run -c Release --project tests/SharpMUTerm.Core.Tests - + diff --git a/docs/design/README.md b/docs/design/README.md index 3be3bcc9..0f7c82c6 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -374,10 +374,11 @@ claimed chord cancels a pending prefix before opening its own surface. ### Move mode (`⌃B m`) — the keyboard path for window placement Drag is an accelerator, not the only route. Move mode: the active window lifts, every pane dims -and shows a large target letter (`a`–`j`), and the status bar becomes the prompt -`MOVE #public → [b] split right · a–j pane · ←↑↓→ edge · ⏎ commit · Esc cancel`. +and shows its own number (`1`–`9`, the same ordinal the sidebar labels it with and `⌥N` jumps to), +and the status bar becomes the prompt +`MOVE #public → split pane 2 right · 1–9 pane · ←↑↓→ edge · ⏎ commit · Esc cancel`. -- `a`–`j` or Tab picks the destination pane +- `1`–`9` or Tab picks the destination pane - arrows or `hjkl` toggle an edge (splits there instead of adding as a tab); pressing the same edge again clears it - ⏎ commits, Esc cancels diff --git a/docs/design/SharpMUTerm-TUI-v3.dc.html b/docs/design/SharpMUTerm-TUI-v3.dc.html index 19849d84..da81bd58 100644 --- a/docs/design/SharpMUTerm-TUI-v3.dc.html +++ b/docs/design/SharpMUTerm-TUI-v3.dc.html @@ -192,7 +192,7 @@ [{{ moveTargetLetter }}] {{ moveEdgeLabel }} - · a–j pane · ←↑↓→ edge + · 1–9 pane · ←↑↓→ edge
⏎ commit Esc cancel diff --git a/src/SharpMUTerm.Core/Automation/TriggerEngine.cs b/src/SharpMUTerm.Core/Automation/TriggerEngine.cs index 5e87b097..63e79d35 100644 --- a/src/SharpMUTerm.Core/Automation/TriggerEngine.cs +++ b/src/SharpMUTerm.Core/Automation/TriggerEngine.cs @@ -6,6 +6,19 @@ namespace SharpMUTerm.Core.Automation; /// A script callback requested by a matched trigger, with its capture groups. public sealed record TriggerScriptInvocation(string Callback, Match Match); +/// +/// One line's route to a spawn window: the window's resolved name, and the pattern of the rule that +/// sent it there. +/// +/// The pattern rides along because the destination is no longer enough to identify the rule. A route +/// of Channel $1 resolves to Channel Public, Channel Newbie and so on, so a +/// consumer that wanted to know "which rule feeds this pane" and looked the rule up by comparing its +/// to the window's name would find nothing for every dynamic +/// pane. Carrying it costs one reference and removes the lookup. +/// +/// +public sealed record SpawnRoute(string Target, string Pattern); + /// The outcome of running the trigger engine over one output line. public sealed class TriggerResult { @@ -13,7 +26,7 @@ public TriggerResult( StyledLine line, bool suppress, IReadOnlyList responses, - IReadOnlyList spawnTargets, + IReadOnlyList spawnTargets, IReadOnlyList scriptInvocations, IReadOnlyList matched) { @@ -34,8 +47,8 @@ public TriggerResult( /// Commands to send back to the server, in order. public IReadOnlyList Responses { get; } - /// Named spawn windows this line should be routed to. - public IReadOnlyList SpawnTargets { get; } + /// The spawn windows this line should be routed to, with the rule that routed it. + public IReadOnlyList SpawnTargets { get; } /// Script callbacks to invoke, with their match data. public IReadOnlyList ScriptInvocations { get; } @@ -213,7 +226,7 @@ public TriggerResult Process(StyledLine line) var current = line; var suppress = false; List? responses = null; - List? spawns = null; + List? spawns = null; List? scripts = null; List? matched = null; @@ -266,9 +279,10 @@ actions.HighlightBackground is not null || (responses ??= new List()).Add(match.Result(actions.SendResponse)); } - if (!string.IsNullOrEmpty(actions.SpawnTarget)) + if (!string.IsNullOrEmpty(actions.SpawnTarget) && + ResolveSpawnTarget(actions.SpawnTarget, match) is { } target) { - (spawns ??= new List()).Add(actions.SpawnTarget); + (spawns ??= new List()).Add(new SpawnRoute(target, trigger.Pattern)); } if (!string.IsNullOrEmpty(actions.ScriptCallback)) @@ -299,11 +313,83 @@ actions.HighlightBackground is not null || current, suppress, (IReadOnlyList?)responses ?? Array.Empty(), - (IReadOnlyList?)spawns ?? Array.Empty(), + (IReadOnlyList?)spawns ?? Array.Empty(), (IReadOnlyList?)scripts ?? Array.Empty(), (IReadOnlyList?)matched ?? Array.Empty()); } + /// + /// The window a matched rule routes to: its with capture + /// groups substituted, or null when what came out cannot be a window name. + /// + /// Rewrite and respond have always expanded $1; the route did not, so one rule could only ever + /// feed one statically-named pane. ^<(.+?)> routing to Channel $1 is the case this + /// exists for: one rule, a pane per channel, each created on the channel's first line. + /// + /// + /// Why this is guarded and the other two are not. A rewrite's output is text on a line and a + /// response's output is a command the user's own rule chose to send. This one becomes a + /// durable named object — a window id, a tab title, a row in the sidebar — built out of + /// whatever the server put between the brackets. So a resolved name is trimmed and then refused if it + /// is empty, carries a control character (a name holding a stray escape or newline would corrupt every + /// surface that draws it), or runs past . Refusing means the line is not + /// routed; it still prints to the main window unless the rule also gags it, which is the quiet failure + /// rather than a pane named after a screenful of garbage. + /// + /// + /// There is deliberately no ceiling on how many panes a rule may create. A pattern that captures + /// more loosely than its author meant will open a pane per variant, and that is the author's to fix by + /// tightening the pattern — a client-imposed cap would silently drop the channel you cared about. + /// + /// + private static string? ResolveSpawnTarget(string template, Match match) + { + string resolved; + try + { + resolved = match.Result(template); + } + catch (FormatException) + { + // A template Regex.Result cannot parse at all. The rule is malformed rather than the line, so + // routing it anywhere would be a guess. + return null; + } + + resolved = resolved.Trim(); + + if (resolved.Length == 0 || resolved.Length > MaxTargetLength) + { + return null; + } + + for (var i = 0; i < resolved.Length; i++) + { + if (char.IsControl(resolved[i])) + { + return null; + } + + // A group reference that survived expansion: Regex.Result leaves "$3" as the two characters + // "$3" when the pattern has no third group, rather than throwing, so without this a typo in + // the template opens a pane literally named "$3" and keeps feeding it. Refusing is the honest + // reading — the rule asked for something the pattern cannot give it. + if (resolved[i] == '$' && i + 1 < resolved.Length && char.IsAsciiDigit(resolved[i + 1])) + { + return null; + } + } + + return resolved; + } + + /// + /// How long a resolved window name may be. A tab strip and the sidebar both draw it, and a capture is + /// only bounded by what the server sent — this is the point past which a "name" is really a line of + /// output that matched too much. + /// + public const int MaxTargetLength = 64; + private static StyledLine ApplyHighlight(StyledLine line, Match match, TriggerActions actions) { var restyled = StyledText.Restyle(line, match.Index, match.Length, style => diff --git a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs index db112a12..233f88ee 100644 --- a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs +++ b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs @@ -117,6 +117,18 @@ public static IReadOnlyList Build( CommandGroup.Terminal, "Back to live output", "term:scroll-live", "⌃End")); } + // The restore log's one control that is not a settings field. It is listed unconditionally, and + // it is listed *here* rather than only on F9, because "delete what this client has written down + // about my session" is a thing a person wants to do now — after saying something they would + // rather was not on disk — and a purge you have to find in a settings screen is a purge that + // happens tomorrow. The per-character switch on F9 is the other half: this one is immediate and + // total, that one is a standing preference. + items.Add(new CommandItem( + CommandGroup.Terminal, + "Purge the restore log", + "term:restore-purge", + "deletes every pane's saved content")); + // The client's own messages — the status-line notices that dismiss themselves — kept out of the // output window (and so out of the session log) and readable here instead. items.Add(new CommandItem( @@ -161,6 +173,29 @@ public static IReadOnlyList Build( items.Add(new CommandItem(CommandGroup.Layout, "Focus pane down", "layout:focus-down", "⌃↓")); items.Add(new CommandItem(CommandGroup.Layout, "Focus the next pane", "layout:cycle", "⌃O · ⌃B o")); + // Numbered pane jumps, one entry per pane that exists — the one group here that is *not* listed + // unconditionally, because "Go to pane 4" on a workspace with two panes names a place there is no + // way to make. The rail already numbers the panes the same way in its hosting column, so the entry + // and the label a user is reading off the sidebar are the same number; that is the whole point of + // deriving both from Panes order rather than spelling either out. + // + // Only the first nine carry a chord: ⌥0 is not claimed (it stays bindable as a macro, and the + // framework's own Alt+digit handler ignores it), so a tenth pane gets an entry with no subtitle + // rather than one naming a key that does something else. An entry with no chord is the honest + // shape for a place only the mouse, ⌃O and the arrows can reach. + var paneCount = workspace.Layout.Panes.Count; + if (paneCount > 1) + { + for (var n = 1; n <= paneCount; n++) + { + items.Add(new CommandItem( + CommandGroup.Layout, + $"Go to pane {n}", + CommandIds.Pane(n), + n <= CommandIds.PaneJumpDigits ? $"⌥{n}" : null)); + } + } + // Pane size, in the plain words the request used ("increase/decrease a pane's horizontal or // vertical character size") rather than in the chord's terms. Listed for the same reason the // directional entries and the newline chord are: ⌥⇧+arrow is not a chord anybody guesses, and a diff --git a/src/SharpMUTerm.Core/Commands/CommandIds.cs b/src/SharpMUTerm.Core/Commands/CommandIds.cs index 02cd4f67..05069a8e 100644 --- a/src/SharpMUTerm.Core/Commands/CommandIds.cs +++ b/src/SharpMUTerm.Core/Commands/CommandIds.cs @@ -21,4 +21,31 @@ public static class CommandIds /// The id that activates the window named by . public static string Window(string windowId) => WindowPrefix + windowId; + + /// Prefix of a "go to this numbered pane" id; the remainder is the pane's 1-based number. + public const string PanePrefix = "layout:pane-"; + + /// + /// How many panes have a keyboard chord of their own: ⌥1–⌥9. Nine rather than the five that were + /// asked for because nine is what the digit row spells with one modifier, what the terminal's Alt + /// encoding covers (ESC + a printable digit), and what the framework's own Alt+1–9 window + /// selector claims — leaving one of those digits unclaimed would hand it back to that selector. + /// Panes past the ninth are still reachable by ⌃O, the arrows and the rail; they simply have no + /// chord, and no surface claims otherwise. + /// + public const int PaneJumpDigits = 9; + + /// + /// The id that focuses the th pane, counting the way every surface in this + /// client counts panes: order, which is + /// creation order, which is the order the connection rail's pane N column numbers them + /// in. The chord (⌥N), the rail's label and this id are three spellings of one number. + /// + /// It was tree order — left-to-right then top-to-bottom — and that renumbered panes that already + /// existed whenever one was inserted before them, so a number a user had learnt moved without being + /// touched. Creation order is stable while a pane is open, and closing one compacts the rest so the + /// range stays contiguous. + /// + /// + public static string Pane(int number) => PanePrefix + number.ToString(System.Globalization.CultureInfo.InvariantCulture); } diff --git a/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs b/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs index e7120459..d83bcad3 100644 --- a/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs +++ b/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs @@ -38,6 +38,15 @@ public sealed class AppConfiguration /// public ScrollbackSpillOptions ScrollbackSpill { get; set; } = new(); + /// + /// How much of each pane's recent content is kept so that restarting the client refills the panes it + /// came from. Unlike this is meant to survive — it is the one + /// thing in 's neighbourhood that persists text rather than structure — and + /// unlike the session transcripts it is on by default and read only by the client itself. See + /// . + /// + public RestoreLogOptions RestoreLog { get; set; } = new(); + /// /// Forces a graphics protocol regardless of capability detection: one of /// none, halfblock, sixel, kitty. Null means auto-detect. @@ -71,7 +80,10 @@ public sealed class AppConfiguration /// /// The last workspace layout (panes, windows, focus) so the app can resume where it left off. /// Null on a fresh config; the shell rebuilds a live workspace from it at startup via - /// . Scrollback is not persisted — only structure. + /// . Only structure lives here — the content of each + /// window is the restore log's business (), keyed by the same window ids + /// this records, and deliberately not in config.json: a hundred thousand lines of chat would + /// make the one file people hand-edit and paste into bug reports unreadable and unshareable. /// public WorkspaceState? LastSession { get; set; } diff --git a/src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs b/src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs index eabd664b..7b2db3fc 100644 --- a/src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs +++ b/src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs @@ -154,7 +154,12 @@ public sealed class CharacterDefinition OnConnect = OnConnect, OnDisconnect = OnDisconnect, TriggerSets = new List(TriggerSets), - Logging = new LoggingSettings { Format = Logging.Format, Directory = Logging.Directory }, + Logging = new LoggingSettings + { + Format = Logging.Format, + Directory = Logging.Directory, + RestoreLog = Logging.RestoreLog, + }, }; /// diff --git a/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs b/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs index 8475cded..dd8f956f 100644 --- a/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs +++ b/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs @@ -42,6 +42,25 @@ public sealed class LoggingSettings /// Directory for log files. Defaults to a per-session folder under the config dir. public string? Directory { get; set; } + + /// + /// Whether this character's panes come back with their previous session's content after a restart + /// (). On by default, and the per-character opt-out for + /// somebody who does not want one world's text on disk between sessions. + /// + /// It sits beside because F9 is where a character's "what of mine is written + /// down" questions are answered, and it is a separate switch from it because the two settings are + /// different things: a transcript is a file you keep, read and choose a format for, and this + /// is a small bounded tail nothing but the client's own startup ever reads. Turning one off has never + /// implied anything about the other. + /// + /// + /// Clearing it stops the writing and drops whatever is already stored for that character's + /// windows on the next launch — an opt-out that left the last session's text lying there would be + /// answering a different question from the one it was asked. + /// + /// + public bool RestoreLog { get; set; } = true; } /// diff --git a/src/SharpMUTerm.Core/Session/SessionEvents.cs b/src/SharpMUTerm.Core/Session/SessionEvents.cs index e8b106a5..95e30bdb 100644 --- a/src/SharpMUTerm.Core/Session/SessionEvents.cs +++ b/src/SharpMUTerm.Core/Session/SessionEvents.cs @@ -12,10 +12,14 @@ public enum ConnectionState } /// A line routed to a named spawn window by a matching trigger. -public sealed class SpawnLineEventArgs(string target, StyledLine line) : EventArgs +public sealed class SpawnLineEventArgs(string target, string pattern, StyledLine line) : EventArgs { + /// The window's name, with the rule's capture groups already substituted. public string Target { get; } = target; + /// The pattern of the rule that routed the line here — see SpawnRoute. + public string Pattern { get; } = pattern; + public StyledLine Line { get; } = line; } diff --git a/src/SharpMUTerm.Core/Session/WorldSession.cs b/src/SharpMUTerm.Core/Session/WorldSession.cs index d6fc1adb..7386f7c9 100644 --- a/src/SharpMUTerm.Core/Session/WorldSession.cs +++ b/src/SharpMUTerm.Core/Session/WorldSession.cs @@ -342,7 +342,7 @@ private void ProcessOutputLine(StyledLine line) foreach (var target in result.SpawnTargets) { - SpawnLine?.Invoke(this, new SpawnLineEventArgs(target, result.Line)); + SpawnLine?.Invoke(this, new SpawnLineEventArgs(target.Target, target.Pattern, result.Line)); } foreach (var response in result.Responses) diff --git a/src/SharpMUTerm.Core/Telnet/Mssp/MsspData.cs b/src/SharpMUTerm.Core/Telnet/Mssp/MsspData.cs index 70dc68df..f2da947a 100644 --- a/src/SharpMUTerm.Core/Telnet/Mssp/MsspData.cs +++ b/src/SharpMUTerm.Core/Telnet/Mssp/MsspData.cs @@ -1,36 +1,28 @@ using System.Collections; using System.Globalization; +using TelnetNegotiationCore.Models; namespace SharpMUTerm.Core.Telnet.Mssp; -/// Where a set of MSSP values came from, and therefore how much of it survived. -public enum MsspSource -{ - /// - /// Parsed from the subnegotiation bytes by : every variable - /// the server sent, every value of every array, in wire order. - /// - Wire, - - /// - /// Projected from TelnetNegotiationCore's own MSSPConfig. Lossy — see - /// for exactly what the library discards. Only used - /// when the wire bytes were unavailable, which in practice means MCCP compression was already - /// running when the payload arrived. - /// - Interpreter, -} - /// -/// One server's MSSP report: every variable it sent, with every value, in the order it sent them. +/// One server's MSSP report, in the shape this application wants it: every variable it sent, with +/// every value, in the order it sent them, plus the domain readings a client and a crawler ask for. +/// +/// This projects; it does not parse. The bytes are read by TelnetNegotiationCore, which hands +/// back an ordered name → value-list map (MSSPConfig.Variables); everything here is built from +/// that. What this type adds over the library's own collection is the part that is ours rather than +/// the protocol's: REFERRAL read as s a crawler can follow and +/// deduplicate, CRAWL DELAY read as the specification's "no preference" rather than a negative +/// interval, ports validated as ports, and an immutable snapshot a report can be written from. +/// /// -/// The shape is a map from a canonical variable name (see ) -/// to an ordered list of values, and that is not incidental. MSSP has two ways to attach -/// several values to one variable — repeating the variable, and repeating MSSP_VAL under one -/// variable — and the specification gives both the same meaning: "multiple values should be ordered -/// from least to most relevant", with "the default value reported last". A model that kept one value -/// per variable would silently pick a server's least preferred port, and would lose -/// REFERRAL entirely, since a referral list is nothing but an array. +/// The shape is a map from a canonical variable name to an ordered list of values, and that +/// is not incidental. MSSP has two ways to attach several values to one variable — repeating the +/// variable, and repeating MSSP_VAL under one variable — and the specification gives both the +/// same meaning: "multiple values should be ordered from least to most relevant", with "the default +/// value reported last". A model that kept one value per variable would silently pick a server's +/// least preferred port, and would lose REFERRAL entirely, since a referral list is +/// nothing but an array. /// /// /// Nothing is discarded on the way in. Variables the specification does not define are kept beside @@ -44,18 +36,14 @@ public sealed class MsspData : IReadOnlyDictionary private readonly Dictionary> _values; private readonly List _order; - private MsspData(Dictionary> values, List order, MsspSource source) + private MsspData(Dictionary> values, List order) { _values = values; _order = order; - Source = source; } /// An empty report — a server that negotiated MSSP and then said nothing. - public static MsspData Empty { get; } = new([], [], MsspSource.Wire); - - /// How faithfully this data reflects what the server sent. - public MsspSource Source { get; } + public static MsspData Empty { get; } = new([], []); /// Variable names in the order the server first mentioned them. public IEnumerable Keys => _order; @@ -66,18 +54,18 @@ private MsspData(Dictionary> values, List /// Every value of , in wire order; empty when it was not sent. public IReadOnlyList this[string variable] => - _values.TryGetValue(MsspVariables.Canonicalise(variable), out var values) ? values : []; + _values.TryGetValue(MSSPVariables.Canonicalize(variable), out var values) ? values : []; /// The names in this report the specification defines, in wire order. - public IReadOnlyList OfficialNames => _order.Where(MsspVariables.IsOfficial).ToList(); + public IReadOnlyList OfficialNames => _order.Where(MSSPVariables.IsOfficial).ToList(); /// The names in this report the specification does not define, in wire order. - public IReadOnlyList UnofficialNames => _order.Where(n => !MsspVariables.IsOfficial(n)).ToList(); + public IReadOnlyList UnofficialNames => _order.Where(n => !MSSPVariables.IsOfficial(n)).ToList(); - public bool ContainsKey(string variable) => _values.ContainsKey(MsspVariables.Canonicalise(variable)); + public bool ContainsKey(string variable) => _values.ContainsKey(MSSPVariables.Canonicalize(variable)); public bool TryGetValue(string variable, out IReadOnlyList values) => - _values.TryGetValue(MsspVariables.Canonicalise(variable), out values!); + _values.TryGetValue(MSSPVariables.Canonicalize(variable), out values!); /// /// The default value of — the last one sent, per the @@ -183,6 +171,12 @@ public IReadOnlyList Referrals /// /// An MSSP integer, or null when unreported or unparseable. -1 is the specification's /// "data not available" marker for the World counts and resolves to null, not to minus one. + /// + /// This is deliberately narrower than the library's own MSSPVariableCollection.Integer, + /// which returns -1 as-is on the grounds that a caller may want to tell "the server said it + /// cannot count its rooms" from "the server never mentioned rooms". Everything reading this type + /// wants a count it can print or compare, and the raw string is still one indexer away. + /// /// public int? Integer(string variable) => int.TryParse(Default(variable), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) @@ -196,175 +190,42 @@ public IEnumerator>> GetEnumerator() IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); /// - /// Rebuilds a report from a flat name → values map (a persisted record read back from disk). - /// Names are canonicalised on the way in, so a store written by an older spelling still loads. - /// - public static MsspData FromValues( - IEnumerable>> values, - MsspSource source = MsspSource.Wire) - { - var builder = new Builder(source); - foreach (var (name, list) in values) - { - foreach (var value in list) - { - builder.Add(name, value); - } - } - - return builder.Build(); - } - - /// - /// Projects TelnetNegotiationCore's own MSSPConfig into this model — the degraded path, - /// used only when the subnegotiation bytes were not observable. + /// Projects a name → values map into this model, keeping every value of every variable in the + /// order given. The library's own MSSPConfig.Variables is exactly such a map, which is the + /// path a live session takes; a flat dictionary read back from a file is the other. /// - /// What the library loses, verified against 2.6.0. Its MSSP reader accumulates every - /// MSSP_VAL under one variable into a single byte buffer with no separator, so array - /// notation is destroyed before any model sees it: PORT 80 23 4201 arrives as the integer - /// 80234201, and REFERRAL — whose entire content is an array — is a concatenation - /// that then fails to bind to the list-typed property and is dropped to null. Boolean variables - /// (ANSI, UTF-8, PAY TO PLAY …) fail to bind from their string form and are - /// dropped. Variables outside the library's model — including the official CHARSET — are - /// dropped rather than collected into its Extended dictionary, which stays empty. - /// - /// - /// This projection therefore recovers the scalars and nothing else, and marks itself - /// so a consumer can tell. It is a fallback, not a design: - /// the fix belongs upstream, and is why the primary path - /// does not need it. + /// Names are canonicalised on the way in — by the library's , + /// so there is one vocabulary in the solution rather than two — which means a source that spells + /// MINIMUM_AGE and MINIMUM AGE separately still yields one variable. A name that + /// canonicalises to nothing is dropped; a variable with no values is kept, because "the server + /// mentioned this and said nothing" is a different fact from "the server never mentioned it". /// /// - public static MsspData FromInterpreterConfig(object? config) - { - var builder = new Builder(MsspSource.Interpreter); - if (config is null) - { - return builder.Build(); - } - - foreach (var property in config.GetType().GetProperties()) - { - if (property.GetIndexParameters().Length != 0 || !property.CanRead) - { - continue; - } - - object? value; - try - { - value = property.GetValue(config); - } - catch - { - continue; - } - - if (value is null) - { - continue; - } - - // The library tags each property with the MSSP name it came from; without that the model's - // C# spelling (Minimum_Age, UTF_8, XTerm_256_Colors) would leak out as if it were the - // protocol's, which is what this projection previously did. - var wireName = property.GetCustomAttributesData() - .FirstOrDefault(a => a.AttributeType.Name == "NameAttribute") - ?.ConstructorArguments is [{ Value: string named }, ..] - ? named - : null; - - if (value is IDictionary extended) - { - foreach (DictionaryEntry entry in extended) - { - if (entry.Key is string key && entry.Value is { } item) - { - builder.Add(key, item.ToString() ?? string.Empty); - } - } - - continue; - } - - if (wireName is null) - { - continue; - } - - if (value is IEnumerable items and not string) - { - foreach (var item in items) - { - if (item is not null) - { - builder.Add(wireName, item.ToString() ?? string.Empty); - } - } - - continue; - } - - builder.Add(wireName, value switch - { - bool flag => flag ? "1" : "0", - _ => value.ToString() ?? string.Empty, - }); - } - - return builder.Build(); - } - - /// Accumulates variables and values in wire order. - public sealed class Builder(MsspSource source = MsspSource.Wire) + public static MsspData From(IEnumerable>> variables) { - private readonly Dictionary> _values = []; - private readonly List _order = []; - - /// - /// Records one value of one variable. Repeating a variable appends to its list rather than - /// replacing it, which is what makes the two ways MSSP spells an array — repeated variables and - /// repeated values — end up in one place and keep their order. - /// - public Builder Add(string variable, string value) - { - List(variable)?.Add(value); - return this; - } + ArgumentNullException.ThrowIfNull(variables); - /// - /// Records that a variable was sent without recording a value for it. A variable with no - /// MSSP_VAL at all is malformed, but "the server mentioned this and said nothing" is a - /// different fact from "the server never mentioned it", and inventing an empty value to carry - /// the first would erase the difference. - /// - public Builder Declare(string variable) - { - List(variable); - return this; - } + var values = new Dictionary>(StringComparer.Ordinal); + var order = new List(); - private List? List(string variable) + foreach (var (variable, list) in variables) { - var name = MsspVariables.Canonicalise(variable); + var name = MSSPVariables.Canonicalize(variable); if (name.Length == 0) { - return null; + continue; } - if (!_values.TryGetValue(name, out var list)) + if (!values.TryGetValue(name, out var accumulated)) { - list = []; - _values[name] = list; - _order.Add(name); + accumulated = []; + values[name] = accumulated; + order.Add(name); } - return list; + accumulated.AddRange(list); } - public bool IsEmpty => _order.Count == 0; - - public MsspData Build() => - new(_values.ToDictionary(kv => kv.Key, kv => (IReadOnlyList)kv.Value), [.. _order], source); + return new MsspData(values.ToDictionary(kv => kv.Key, kv => (IReadOnlyList)kv.Value), order); } } diff --git a/src/SharpMUTerm.Core/Telnet/Mssp/MsspSubnegotiationParser.cs b/src/SharpMUTerm.Core/Telnet/Mssp/MsspSubnegotiationParser.cs deleted file mode 100644 index 348ad4b7..00000000 --- a/src/SharpMUTerm.Core/Telnet/Mssp/MsspSubnegotiationParser.cs +++ /dev/null @@ -1,271 +0,0 @@ -using System.Text; - -namespace SharpMUTerm.Core.Telnet.Mssp; - -/// -/// Reads MSSP subnegotiations straight off the inbound byte stream, incrementally and losslessly. -/// -/// Why this exists rather than the library's own reader. TelnetNegotiationCore negotiates MSSP -/// and hands back a MSSPConfig, but that model cannot represent what MSSP sends: every -/// MSSP_VAL under one variable is concatenated into a single buffer with no separator before -/// anything sees it. PORT 80 23 4201 becomes the integer 80234201, -/// and REFERRAL — which is nothing but an array — becomes a run-together string that then -/// fails to bind to its list-typed property and is discarded. The array is destroyed inside the -/// library, so no amount of post-processing recovers it, and REFERRAL is precisely the -/// variable MSSP exists to publish to crawlers. Boolean variables and every variable outside the -/// library's model (including the official CHARSET) are dropped as well. This is an upstream -/// bug and a good upstream PR; until then a crawler needs the bytes. -/// -/// -/// Scope. This parses; it does not negotiate. The option handshake stays with -/// TelnetNegotiationCore — which is what makes the server send the subnegotiation in the first place — -/// and this reads the payload it sends. There is no second telnet stack here: the only telnet framing -/// this understands is enough to find IAC SB MSSP … IAC SE in a stream and skip everything -/// else, including other options' subnegotiations, without being fooled by their payload bytes. -/// -/// -/// One limitation, deliberately. It reads the bytes as they arrive from the transport, so if -/// MCCP compression is already running when the payload arrives it sees compressed bytes and finds -/// nothing. covers that case by falling back to the library's own -/// (degraded) report, marked . In practice MSSP is sent during the -/// opening handshake, before compression starts. -/// -/// -public sealed class MsspSubnegotiationParser -{ - private const byte Iac = 255; - private const byte Se = 240; - private const byte Sb = 250; - private const byte Will = 251; - private const byte Dont = 254; - private const byte MsspOption = 70; - private const byte MsspVar = 1; - private const byte MsspVal = 2; - - /// - /// The default ceiling on one subnegotiation's payload. A legitimate MSSP report is a few hundred - /// bytes; a referral list from an enthusiastic server might reach a few thousand. 64 KiB is far - /// above anything real and far below anything that could exhaust a crawler probing many servers at - /// once — a remote server must not be able to decide how much memory we spend on it. - /// - public const int DefaultMaxPayloadBytes = 64 * 1024; - - private enum Scan - { - Data, - AfterIac, - SubnegotiationOption, - MsspPayload, - MsspPayloadAfterIac, - OtherSubnegotiation, - OtherSubnegotiationAfterIac, - SkipOptionByte, - } - - private readonly int _maxPayloadBytes; - private readonly List _field = []; - - private Scan _state = Scan.Data; - private MsspData.Builder? _builder; - private string? _variable; - private bool _fieldIsValue; - private bool _overflowed; - - public MsspSubnegotiationParser(Encoding? encoding = null, int maxPayloadBytes = DefaultMaxPayloadBytes) - { - ArgumentOutOfRangeException.ThrowIfLessThan(maxPayloadBytes, 64); - Encoding = encoding ?? Encoding.UTF8; - _maxPayloadBytes = maxPayloadBytes; - } - - /// - /// The encoding variable names and values are decoded with. Read at the moment a field completes, - /// not when the parser was built, so a CHARSET negotiation that settles mid-stream applies. - /// - public Encoding Encoding { get; set; } - - /// - /// True when a payload was abandoned for exceeding . Sticky for - /// the life of the parser, so a caller that only checks at the end still learns the report it got - /// (or did not get) was truncated rather than simply absent. - /// - public bool Truncated => _overflowed; - - /// - /// Feeds a chunk of inbound bytes and returns every MSSP report completed by it — normally none, - /// once in the connection's lifetime exactly one. State carries across calls, so a payload split - /// over any number of reads parses the same as one that arrives whole. - /// - public IReadOnlyList Consume(ReadOnlySpan bytes) - { - List? completed = null; - - foreach (var b in bytes) - { - switch (_state) - { - case Scan.Data: - if (b == Iac) - { - _state = Scan.AfterIac; - } - - break; - - case Scan.AfterIac: - // IAC IAC in the data stream is an escaped 0xFF, not a command; either way there is - // nothing here to collect, so both land back in Data. - _state = b switch - { - Sb => Scan.SubnegotiationOption, - >= Will and <= Dont => Scan.SkipOptionByte, - _ => Scan.Data, - }; - break; - - case Scan.SkipOptionByte: - _state = Scan.Data; - break; - - case Scan.SubnegotiationOption: - if (b == MsspOption) - { - BeginMssp(); - _state = Scan.MsspPayload; - } - else - { - // Some other option's subnegotiation. It must still be scanned to its IAC SE, - // with IAC IAC honoured, or a GMCP payload containing 0xFF 0xFA 0x46 would look - // like the start of an MSSP report. - _state = Scan.OtherSubnegotiation; - } - - break; - - case Scan.OtherSubnegotiation: - _state = b == Iac ? Scan.OtherSubnegotiationAfterIac : Scan.OtherSubnegotiation; - break; - - case Scan.OtherSubnegotiationAfterIac: - _state = b == Iac ? Scan.OtherSubnegotiation // escaped 0xFF inside the payload - : b == Se ? Scan.Data - : Scan.OtherSubnegotiation; - break; - - case Scan.MsspPayload: - if (b == Iac) - { - _state = Scan.MsspPayloadAfterIac; - } - else - { - Payload(b); - } - - break; - - case Scan.MsspPayloadAfterIac: - if (b == Se) - { - if (Finish() is { } report) - { - (completed ??= []).Add(report); - } - - _state = Scan.Data; - } - else - { - // IAC IAC is a literal 0xFF in the payload. Any other command byte here is a - // protocol violation; treating it as payload keeps the report rather than - // discarding a whole server's data over one stray byte. - Payload(b); - _state = Scan.MsspPayload; - } - - break; - } - } - - return completed ?? []; - } - - private void BeginMssp() - { - _builder = new MsspData.Builder(); - _field.Clear(); - _variable = null; - _fieldIsValue = false; - } - - private void Payload(byte b) - { - switch (b) - { - case MsspVar: - FlushField(); - _fieldIsValue = false; - _variable = null; - break; - - case MsspVal: - FlushField(); - _fieldIsValue = true; - break; - - default: - if (_field.Count >= _maxPayloadBytes) - { - _overflowed = true; - return; - } - - _field.Add(b); - break; - } - } - - private void FlushField() - { - if (_builder is null) - { - return; - } - - var text = _field.Count == 0 ? string.Empty : Encoding.GetString(_field.ToArray()); - _field.Clear(); - - if (_fieldIsValue) - { - // A value with no variable before it is malformed; there is nothing to attach it to. - if (_variable is not null) - { - _builder.Add(_variable, text); - } - - return; - } - - if (text.Length == 0) - { - return; - } - - // A variable name. Recorded before any value arrives, so a variable a server sends with no - // MSSP_VAL at all is still reported as present-but-empty rather than vanishing. - _variable = text; - _builder.Declare(text); - } - - private MsspData? Finish() - { - FlushField(); - var report = _builder?.Build(); - _builder = null; - _variable = null; - _fieldIsValue = false; - _field.Clear(); - return report; - } -} diff --git a/src/SharpMUTerm.Core/Telnet/Mssp/MsspVariables.cs b/src/SharpMUTerm.Core/Telnet/Mssp/MsspVariables.cs index 26ab83f3..133688de 100644 --- a/src/SharpMUTerm.Core/Telnet/Mssp/MsspVariables.cs +++ b/src/SharpMUTerm.Core/Telnet/Mssp/MsspVariables.cs @@ -1,22 +1,21 @@ namespace SharpMUTerm.Core.Telnet.Mssp; /// -/// The MSSP variable vocabulary: the names the specification defines, and the one canonical -/// spelling this codebase stores them under. +/// The canonical spellings of the MSSP variables this application reads by name — the ones +/// 's domain accessors are built on, and the ones an MSSP screen would label. /// -/// The specification (https://mudhalla.net/tintin/protocols/mssp/) says variable names -/// "should exist of upper case letters and may contain spaces", and that "as many programming -/// languages have difficulties with variable names which contain spaces clients and crawlers can -/// substitute spaces with underscores as the recommended solution". So a server may legitimately -/// send MINIMUM AGE or MINIMUM_AGE and mean one variable, and a case-insensitive -/// dictionary alone would treat them as two. is the single place that -/// decides they are the same thing; everything downstream keys on its output. +/// Names only. The vocabulary rules — which names are official, and the folding that makes +/// MINIMUM_AGE and MINIMUM AGE one variable — belong to +/// TelnetNegotiationCore.Models.MSSPVariables, which derives them from the same model that +/// reads the wire. Two copies of a vocabulary drift; this one exists so a domain accessor can say +/// what it is reading rather than repeating a string literal. /// /// /// Canonical form is the spaced, upper-case spelling — what the specification's own tables /// print — because that is what a human reading a report expects to see and what a server operator -/// would recognise from their own configuration. The underscore form is an encoding convenience, -/// not the name. +/// would recognise from their own configuration. The underscore form is an encoding convenience, not +/// the name; UTF-8 keeps its hyphen, because no underscore is involved. +/// See the specification. /// /// public static class MsspVariables @@ -31,14 +30,7 @@ public static class MsspVariables public const string Codebase = "CODEBASE"; public const string Contact = "CONTACT"; public const string CrawlDelay = "CRAWL DELAY"; - public const string Created = "CREATED"; - public const string Discord = "DISCORD"; public const string Hostname = "HOSTNAME"; - public const string Icon = "ICON"; - public const string Ip = "IP"; - public const string IpV6 = "IPV6"; - public const string Language = "LANGUAGE"; - public const string Location = "LOCATION"; public const string MinimumAge = "MINIMUM AGE"; public const string Port = "PORT"; public const string Referral = "REFERRAL"; @@ -48,119 +40,5 @@ public static class MsspVariables // ---- Categorisation ---- public const string Family = "FAMILY"; public const string Genre = "GENRE"; - public const string Gameplay = "GAMEPLAY"; public const string Status = "STATUS"; - public const string GameSystem = "GAMESYSTEM"; - public const string Intermud = "INTERMUD"; - public const string Subgenre = "SUBGENRE"; - - // ---- World ---- - public const string Areas = "AREAS"; - public const string Helpfiles = "HELPFILES"; - public const string Mobiles = "MOBILES"; - public const string Objects = "OBJECTS"; - public const string Rooms = "ROOMS"; - public const string Classes = "CLASSES"; - public const string Levels = "LEVELS"; - public const string Races = "RACES"; - public const string Skills = "SKILLS"; - - // ---- Protocols (all 1/0) ---- - public const string Ansi = "ANSI"; - public const string Utf8 = "UTF-8"; - public const string Vt100 = "VT100"; - public const string Xterm256Colors = "XTERM 256 COLORS"; - public const string XtermTrueColors = "XTERM TRUE COLORS"; - - // ---- Commercial / hiring (all 1/0) ---- - public const string PayToPlay = "PAY TO PLAY"; - public const string PayForPerks = "PAY FOR PERKS"; - public const string HiringBuilders = "HIRING BUILDERS"; - public const string HiringCoders = "HIRING CODERS"; - - /// - /// Every variable the specification's official tables list, in the order they appear there. - /// Anything outside this set is "unofficial" — either one of the widely-deployed extras below, - /// or something a codebase invented. Both are kept; neither is discarded. - /// - public static readonly IReadOnlyList Official = - [ - Name, Players, Uptime, - Charset, Codebase, Contact, CrawlDelay, Created, Discord, Hostname, Icon, Ip, IpV6, - Language, Location, MinimumAge, Port, Referral, Ssl, Website, - Family, Genre, Gameplay, Status, GameSystem, Intermud, Subgenre, - Areas, Helpfiles, Mobiles, Objects, Rooms, Classes, Levels, Races, Skills, - Ansi, Utf8, Vt100, Xterm256Colors, XtermTrueColors, - PayToPlay, PayForPerks, HiringBuilders, HiringCoders, - ]; - - /// - /// Variables that are not in the specification's tables but are sent by enough real servers to be - /// worth naming: the ones codebases and earlier drafts of MSSP shipped. They are recognised only - /// so a report can group them sensibly — an unrecognised variable is kept exactly as faithfully. - /// - public static readonly IReadOnlyList KnownUnofficial = - [ - "PUEBLO", "MSP", "MCCP", "MCP", "MXP", "GMCP", "MSDP", "SSH", "ATCP", "ZMP", - "MULTICLASSING", "NEWBIE FRIENDLY", "PLAYER KILLING", "ROLEPLAYING", "TRAINING SYSTEM", - "WORLD ORIGINALITY", "EQUIPMENT SYSTEM", "MULTIPLAYING", "PLAYERS ONLINE", "DBSIZE", - "EXITS", "EXTRA DESCRIPTIONS", "RESETS", "SHOPS", "SOCIALS", "VOLUME", - ]; - - private static readonly HashSet OfficialSet = new(Official, StringComparer.Ordinal); - - private static readonly HashSet KnownUnofficialSet = new(KnownUnofficial, StringComparer.Ordinal); - - /// - /// The one spelling a variable is stored under: trimmed, upper-cased, underscores folded to - /// spaces, and runs of whitespace collapsed to one space. - /// - /// Underscore folding is what makes MINIMUM_AGE and MINIMUM AGE one variable, per the - /// specification's own note that the substitution is a recommended encoding of the same name. It is - /// deliberately unconditional rather than restricted to names we recognise: a server that spells an - /// unofficial variable both ways in one payload should still get one entry, and we cannot know - /// which of its own names it considers canonical. - /// - /// - /// UTF-8 keeps its hyphen — the specification spells it that way and no underscore is - /// involved, so nothing here touches it. - /// - /// - public static string Canonicalise(string variable) - { - ArgumentNullException.ThrowIfNull(variable); - - var folded = new System.Text.StringBuilder(variable.Length); - var pendingSpace = false; - foreach (var ch in variable) - { - var c = ch == '_' ? ' ' : ch; - if (char.IsWhiteSpace(c)) - { - // Leading whitespace is dropped; interior runs become one space, decided when the - // next real character arrives so a trailing run costs nothing. - pendingSpace = folded.Length > 0; - continue; - } - - if (pendingSpace) - { - folded.Append(' '); - pendingSpace = false; - } - - folded.Append(char.ToUpperInvariant(c)); - } - - return folded.ToString(); - } - - /// True when is one of the specification's official variables. - public static bool IsOfficial(string variable) => OfficialSet.Contains(Canonicalise(variable)); - - /// - /// True when is unofficial but recognised — a variable real servers - /// send that the specification does not define. - /// - public static bool IsKnownUnofficial(string variable) => KnownUnofficialSet.Contains(Canonicalise(variable)); } diff --git a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs index 73844bde..7032fef9 100644 --- a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs @@ -302,20 +302,6 @@ public sealed class TelnetSession : ITelnetSession private SessionEncoding _lastReported; - /// - /// Reads MSSP off the wire in parallel with the library's own negotiation of it — the only way to - /// see array-valued variables at all, REFERRAL above all. See - /// for what the library loses and why. - /// - private readonly MsspSubnegotiationParser _mssp = new(); - - /// - /// Whether the parser above has reported anything this session. It gates the degraded fallback: the - /// library's own MSSP callback is only surfaced when the bytes were unreadable, which in practice - /// means MCCP compression started before the payload arrived. - /// - private bool _msspSeenOnWire; - private TelnetInterpreter? _interpreter; private CancellationTokenSource? _loopCts; private Task? _readLoop; @@ -637,44 +623,19 @@ private ValueTask OnMsdpAsync(TelnetInterpreter interpreter, string json) } /// - /// The library's own MSSP callback, kept only as a fallback. It fires for every MSSP payload, but - /// what it carries has already lost every array (see ), - /// so it is surfaced only when the wire parser saw nothing — the compressed case. Raising both - /// would deliver the same server twice, once complete and once degraded. + /// The library's MSSP callback: one report per IAC SB MSSP … IAC SE, carrying + /// MSSPConfig.Variables — every variable the server sent, with every value of every array, + /// in wire order. turns that into the shape this application reads. /// private ValueTask OnMsspAsync(MSSPConfig config) { - if (_msspSeenOnWire) - { - return ValueTask.CompletedTask; - } - - _logger.LogDebug( - "MSSP was not readable on the wire (compression?); falling back to the interpreter's own " - + "reduced view, which carries no arrays."); - MsspReceived?.Invoke(this, new MsspReceivedEventArgs(MsspData.FromInterpreterConfig(config))); + var report = MsspData.From(config.Variables); + _logger.LogInformation( + "MSSP: {Count} variables from {Name}.", report.Count, report.Name ?? "an unnamed server"); + MsspReceived?.Invoke(this, new MsspReceivedEventArgs(report)); return ValueTask.CompletedTask; } - /// - /// Runs the inbound bytes past the MSSP reader and raises anything it completed. The reader keeps - /// its own state, so a payload split across reads is no different from one that arrives whole. - /// - private void ScanForMssp(ReadOnlySpan bytes) - { - // The encoding can change mid-stream when CHARSET settles; the reader is told each time rather - // than capturing whatever was in force when the session was built. - _mssp.Encoding = CurrentEncoding.Encoding; - - foreach (var report in _mssp.Consume(bytes)) - { - _msspSeenOnWire = true; - _logger.LogInformation( - "MSSP: {Count} variables from {Name}.", report.Count, report.Name ?? "an unnamed server"); - MsspReceived?.Invoke(this, new MsspReceivedEventArgs(report)); - } - } - private ValueTask OnCompressionAsync(int version, bool enabled) { _logger.LogInformation("MCCP{Version} compression {State}.", version, enabled ? "enabled" : "disabled"); @@ -695,12 +656,6 @@ private async Task ReadLoopAsync(CancellationToken cancellationToken) break; // clean end of stream } - // Read MSSP off the raw bytes before handing them on. This has to happen here rather - // than through a library callback because the library flattens array-valued variables - // out of existence on the way to its own model; the negotiation that makes the server - // send this payload is still entirely the library's. - ScanForMssp(buffer.AsSpan(0, read)); - await _interpreter!.InterpretByteArrayAsync(buffer.AsMemory(0, read)).ConfigureAwait(false); // The second arm of charset observation: the direction whose callback the library diff --git a/src/SharpMUTerm.Core/Text/Crc32.cs b/src/SharpMUTerm.Core/Text/Crc32.cs new file mode 100644 index 00000000..bb3be85b --- /dev/null +++ b/src/SharpMUTerm.Core/Text/Crc32.cs @@ -0,0 +1,46 @@ +namespace SharpMUTerm.Core.Text; + +/// +/// CRC-32 (IEEE 802.3, reflected) over a record payload. Hand-rolled rather than taking a dependency +/// on System.IO.Hashing for twenty lines of table lookup. +/// +/// Shared by the two byte-framed stores in this namespace — 's +/// ephemeral spill segments and 's kept per-window logs. Both frame a +/// payload as length · payload · checksum, and a second copy of +/// this table would be a second thing to keep identical for no gain: a checksum that disagreed +/// between the writer and the reader is indistinguishable from corruption. +/// +/// +public static class Crc32 +{ + private static readonly uint[] Table = BuildTable(); + + /// The CRC-32 of . + public static uint Compute(ReadOnlySpan data) + { + var crc = 0xFFFFFFFFu; + foreach (var b in data) + { + crc = Table[(crc ^ b) & 0xFF] ^ (crc >> 8); + } + + return crc ^ 0xFFFFFFFFu; + } + + private static uint[] BuildTable() + { + var table = new uint[256]; + for (var i = 0u; i < 256u; i++) + { + var value = i; + for (var bit = 0; bit < 8; bit++) + { + value = (value & 1) != 0 ? 0xEDB88320u ^ (value >> 1) : value >> 1; + } + + table[i] = value; + } + + return table; + } +} diff --git a/src/SharpMUTerm.Core/Text/FileScrollbackSpill.cs b/src/SharpMUTerm.Core/Text/FileScrollbackSpill.cs index 99e599bc..6c6d7636 100644 --- a/src/SharpMUTerm.Core/Text/FileScrollbackSpill.cs +++ b/src/SharpMUTerm.Core/Text/FileScrollbackSpill.cs @@ -919,41 +919,4 @@ private sealed class Segment(string path, SafeFileHandle handle, long firstIndex /// Logical length in bytes, including anything still staged in the write buffer. public long Length { get; set; } } - - /// - /// CRC-32 (IEEE 802.3, reflected) over a record's payload. Hand-rolled rather than taking a - /// dependency on System.IO.Hashing for twenty lines of table lookup. - /// - private static class Crc32 - { - private static readonly uint[] Table = BuildTable(); - - public static uint Compute(ReadOnlySpan data) - { - var crc = 0xFFFFFFFFu; - foreach (var b in data) - { - crc = Table[(crc ^ b) & 0xFF] ^ (crc >> 8); - } - - return crc ^ 0xFFFFFFFFu; - } - - private static uint[] BuildTable() - { - var table = new uint[256]; - for (var i = 0u; i < 256u; i++) - { - var value = i; - for (var bit = 0; bit < 8; bit++) - { - value = (value & 1) != 0 ? 0xEDB88320u ^ (value >> 1) : value >> 1; - } - - table[i] = value; - } - - return table; - } - } } diff --git a/src/SharpMUTerm.Core/Text/RestoreLog.cs b/src/SharpMUTerm.Core/Text/RestoreLog.cs new file mode 100644 index 00000000..238700a8 --- /dev/null +++ b/src/SharpMUTerm.Core/Text/RestoreLog.cs @@ -0,0 +1,941 @@ +using System.Buffers.Binary; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace SharpMUTerm.Core.Text; + +/// One line read back out of a : what it said, and when it arrived. +/// The styled line, exactly as it was written to the pane. +/// +/// The arrival time the pane recorded, or null when the line carried none. It is the rendered +/// stamp rather than a timestamp, for the same reason the live buffer holds one: the timestamp column +/// is a render-time decision, and a restored line has to be able to answer it the same way a live one +/// does. +/// +public sealed record RestoredLine(StyledLine Line, string? Stamp); + +/// One window's worth of restored content. +/// The window id the lines were logged under — the key the whole design turns on. +/// What that window called itself when the log was created. Diagnostic only. +/// When the file was last touched, i.e. roughly when the previous session ended. +/// The readable lines, oldest first, at most . +/// +/// How many bytes of the file could not be turned into lines — a record that failed its checksum, plus +/// whatever torn remainder a crash left at the end. Zero on a clean log. Reported rather than thrown: +/// see the class remarks. +/// +public sealed record RestoredWindow( + string WindowId, + string Title, + DateTimeOffset LastWritten, + IReadOnlyList Lines, + long Lost); + +/// +/// The kept, per-window record of recent pane content, so that starting the client back up refills the +/// panes the content came from instead of showing every one of them empty. +/// +/// Keyed by window, not by session. That is the whole design. A character's own transcript is +/// already in WorldSession.Scrollback, but a spawn window's content never goes there — +/// WorldSession.ProcessOutputLine raises SpawnLine and the shell appends the result +/// straight into that window's markup buffer — so restoring session scrollback would bring the main +/// windows back and leave every channel pane blank, which is precisely the failure this exists to +/// avoid. Logging per window covers both kinds with one mechanism, and it agrees with +/// AppConfiguration.LastSession, which persists window ids and where their panes sit. The two +/// are joined loosely on purpose: a window id in the log that the saved workspace no longer knows about +/// is buffered anyway (so the pane refills if that window is ever opened again), and a window in the +/// saved workspace with no log simply starts empty. Neither is an error. +/// +/// +/// Not the spill, and not a transcript. is an ephemeral cache +/// that purges itself on the next launch — most of a restore log and deliberately not one. +/// PlainTextLogSink/HtmlLogSink are opt-in transcripts a user keeps, formats and reads. +/// This is a third thing: a small, bounded, machine-readable tail of each pane, on by default, whose +/// only consumer is the client's own startup. It is plaintext at rest, in the configuration directory, +/// owner-only (0600) like secrets.json; a character opts out on F9 and ⌃P purges the lot. +/// +/// +/// Layout. One file per window under the root, named for a sanitised form of the window id plus a +/// checksum of it (the id itself may hold characters a filename may not, and two ids must never collide +/// on one file). Each file is a variable-length header — magic, format version, and the window's id and +/// title, checksummed — followed by length · payload · CRC-32 records whose payload is a +/// line with its arrival stamp. Reusing the codec is not only economy: it +/// is the one format in this codebase that can carry a palette index, a span's +/// and a line's rule colour without lying about any of them. +/// +/// +/// Append as you go, not flush on exit. Every line is written and flushed to the operating system +/// as it arrives, so a crash — an unhandled exception, an OOM kill, a kill -9, a closed terminal +/// — loses nothing at all: the bytes are already the kernel's. A restore log that only worked when you +/// quit politely would fail in exactly the case that motivates it. What is deliberately not +/// done is an fsync per line: that is a device round-trip for every line a busy game sends, and +/// the only failure it would additionally cover is losing mains power mid-session, where losing the last +/// few lines of chat is an acceptable outcome. A periodic flush was the other candidate and is strictly +/// worse — it trades the same syscall cost for a window of seconds in which a crash loses output, and +/// interesting output is what tends to precede a crash. +/// +/// +/// Bounded in lines. Each file holds at most twice +/// records and is compacted back down to the bound — a contiguous copy of the newest records into a +/// temporary file, then an atomic rename — so the amortised cost is one extra record written per record +/// appended, and space is reclaimed without ever rewriting the file per line. The bound is in lines and +/// must stay in lines; see . +/// +/// +/// Nothing here throws at a caller. Not on a missing directory, a permission error, a truncated +/// file, a bad checksum, or a header from a future version. A read skips a record that fails +/// its checksum and carries on, and stops only where the framing itself stops making sense — +/// so a crash's torn tail costs the newest few lines rather than the file, and one damaged record costs +/// one line rather than everything after it. Either way the lost byte count comes back for the caller +/// to log. A write that fails takes that one window's file out of service for the session and leaves +/// the pane itself untouched. Startup is the caller here, and a client that refuses to start because of +/// a cache of old chat lines would be a worse client than one that starts with an empty pane. +/// +/// Thread safety. All members are safe to call concurrently; every operation is serialised on one lock. +/// +public sealed class RestoreLog : IDisposable +{ + /// Magic bytes at the head of every window log. + private static readonly byte[] Magic = "SMTRSTOR"u8.ToArray(); + + /// + /// Bumped if the record or header layout ever changes. Unlike the spill's, these files do + /// outlive the process that wrote them, so this is a real gate: a file whose version this build does + /// not know is left alone and read as nothing rather than reinterpreted. + /// + private const uint FormatVersion = 1; + + private const int MagicBytes = 8; + private const int FixedHeaderBytes = MagicBytes + (3 * sizeof(uint)); // magic, version, payload length, payload CRC + private const int LengthPrefixBytes = sizeof(uint); + private const int ChecksumBytes = sizeof(uint); + private const int FrameOverheadBytes = LengthPrefixBytes + ChecksumBytes; + + /// Largest record we will write or trust. A line beyond this is logged as a blank one. + private const int MaxRecordBytes = 1024 * 1024; + + /// The extension every window log carries — chosen so .gitignore's *.log already covers it. + internal const string FileExtension = ".log"; + + /// Where a compaction stages the new file before the rename that replaces the old one. + private const string TempExtension = ".tmp"; + + /// + /// The mode these files are written with on Unix: 0600, the same as secrets.json. They + /// hold what was said in a private game, which is nobody else's business on a shared machine. + /// + /// + /// Spelt out here rather than taken from SecretsStore.OwnerOnlyMode so that + /// SharpMUTerm.Core.Text keeps pointing at nothing in SharpMUTerm.Core.Configuration — + /// the dependency already runs the other way (AppConfiguration holds a + /// ). + /// + private const UnixFileMode OwnerOnlyFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + /// And the directory holding them, 0700 — a file name here is a channel's name. + private const UnixFileMode OwnerOnlyDirectoryMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + private readonly object _gate = new(); + private readonly Dictionary _writers = new(StringComparer.Ordinal); + private readonly MemoryStream _scratch = new(256); + private readonly BinaryWriter _scratchWriter; + private readonly int _maxLines; + private bool _disposed; + private bool _faultLogged; + + /// Creates a log over . Nothing touches the disk until a line is appended or read. + /// The directory holding the per-window files. Created on first write. + /// Bounds; null means the defaults. + public RestoreLog(string root, RestoreLogOptions? options = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(root); + Root = root; + _maxLines = Math.Max(1, (options ?? new RestoreLogOptions()).MaxLinesPerWindow); + _scratchWriter = new BinaryWriter(_scratch, StyledLineCodec.TextEncoding, leaveOpen: true); + } + + /// The directory holding the per-window files. + public string Root { get; } + + /// Where the logs go for a given configuration file: a restore directory beside it. + public static string DefaultRoot(string configurationPath) + { + ArgumentException.ThrowIfNullOrEmpty(configurationPath); + var directory = Path.GetDirectoryName(Path.GetFullPath(configurationPath)) ?? string.Empty; + return Path.Combine(directory, RestoreLogOptions.DirectoryName); + } + + /// Where diagnostics go. Defaults to nowhere, so Core stays free of a logging implementation. + public ILogger Logger { get; set; } = NullLogger.Instance; + + /// + /// Records one line as having been shown in . Cheap and synchronous: one + /// encode and one buffered write, flushed to the operating system before returning. + /// + /// The window the line was drawn in — main window or spawn window alike. + /// What that window calls itself, recorded in the file's header on creation. + /// The line. + /// The arrival stamp the pane recorded, or null. + public void Append(string windowId, string title, StyledLine line, string? stamp) + { + ArgumentException.ThrowIfNullOrEmpty(windowId); + ArgumentNullException.ThrowIfNull(line); + + lock (_gate) + { + if (_disposed) + { + return; + } + + var writer = WriterFor(windowId, title ?? string.Empty); + if (writer is null) + { + return; + } + + try + { + var payload = EncodeRecord(line, stamp); + writer.Append(payload, _maxLines); + } + catch (Exception ex) + { + Fault(ex, windowId); + } + } + } + + /// + /// Reads every window log under the root, newest-written first. Never throws: a file that cannot be + /// opened is skipped, and one that is damaged contributes whatever prefix of it is trustworthy with + /// the rest counted into . + /// + public IReadOnlyList Read() + { + lock (_gate) + { + var restored = new List(); + IEnumerable files; + try + { + if (!Directory.Exists(Root)) + { + return restored; + } + + files = Directory.EnumerateFiles(Root, "*" + FileExtension).OrderBy(f => f, StringComparer.Ordinal); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Listing the restore log under {Root} failed", Root); + return restored; + } + + foreach (var file in files) + { + if (ReadFile(file) is { } window) + { + restored.Add(window); + } + } + + return restored + .OrderByDescending(w => w.LastWritten) + .ToList(); + } + } + + /// + /// Deletes every window log and the directory itself, and hands back how many files went. The log + /// keeps working afterwards — this is "forget what is on disk now", not "switch the feature off" — + /// so a pane that goes on printing starts a fresh file. + /// + public int Purge() + { + lock (_gate) + { + CloseWriters(); + + var removed = 0; + try + { + if (!Directory.Exists(Root)) + { + return 0; + } + + foreach (var file in Directory.EnumerateFiles(Root, "*" + FileExtension) + .Concat(Directory.EnumerateFiles(Root, "*" + FileExtension + TempExtension))) + { + if (TryDelete(file)) + { + removed++; + } + } + + // Only when we emptied it: a root the user pointed at something else keeps whatever + // else is in there, and an empty directory is not worth a second failure mode. + if (!Directory.EnumerateFileSystemEntries(Root).Any()) + { + Directory.Delete(Root); + } + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Purging the restore log under {Root} failed", Root); + } + + return removed; + } + } + + /// + /// Drops one window's log — what a character's opt-out does to content already on disk. A window + /// with no file simply returns false. + /// + public bool Forget(string windowId) + { + ArgumentException.ThrowIfNullOrEmpty(windowId); + lock (_gate) + { + if (_writers.Remove(windowId, out var writer)) + { + writer.Dispose(); + } + + return TryDelete(PathFor(windowId)); + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + CloseWriters(); + _scratchWriter.Dispose(); + _scratch.Dispose(); + } + } + + /// The file one window's lines live in: a readable stem plus a checksum of the exact id. + /// + /// The stem is only for a human looking at the directory. The checksum is what makes the name + /// correct: window ids carry characters a filename may not (spawn:Chat), and two ids + /// that sanitise to the same stem — a channel called Chat/OOC and one called Chat OOC — + /// must not share a file. The id itself is stored in the header, so nothing ever has to read it back + /// out of the name. + /// + internal string PathFor(string windowId) + { + var bytes = StyledLineCodec.TextEncoding.GetBytes(windowId); + var stem = new string(windowId.Where(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_').Take(40).ToArray()); + if (stem.Length == 0) + { + stem = "window"; + } + + return Path.Combine(Root, $"{stem}-{Crc32.Compute(bytes):x8}{FileExtension}"); + } + + /// Encodes one record's payload: the arrival stamp, then the line. + private byte[] EncodeRecord(StyledLine line, string? stamp) + { + _scratch.SetLength(0); + _scratchWriter.Write((byte)(stamp is null ? 0 : 1)); + if (stamp is not null) + { + _scratchWriter.Write(stamp); + } + + StyledLineCodec.Write(_scratchWriter, line); + _scratchWriter.Flush(); + + if (_scratch.Length > MaxRecordBytes) + { + // A server dumping a megabyte without a newline. Keep the row (so the pane comes back the + // right length) and drop the text, rather than refusing the write or growing the file. + _scratch.SetLength(0); + _scratchWriter.Write((byte)0); + StyledLineCodec.Write(_scratchWriter, StyledLine.Empty); + _scratchWriter.Flush(); + } + + return _scratch.ToArray(); + } + + private Writer? WriterFor(string windowId, string title) + { + if (_writers.TryGetValue(windowId, out var writer)) + { + return writer; + } + + try + { + EnsureRoot(); + writer = Writer.Open(PathFor(windowId), windowId, title, Logger); + _writers[windowId] = writer; + return writer; + } + catch (Exception ex) + { + Fault(ex, windowId); + return null; + } + } + + private void EnsureRoot() + { + if (Directory.Exists(Root)) + { + return; + } + + Directory.CreateDirectory(Root); + if (!OperatingSystem.IsWindows()) + { + try + { + File.SetUnixFileMode(Root, OwnerOnlyDirectoryMode); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + Logger.LogDebug(ex, "Narrowing the restore log directory {Root} to 0700 failed", Root); + } + } + } + + /// + /// Takes one window out of service for the rest of the session. The line it was asked to record is + /// already on the screen and in the live buffer, so nothing the user can see is affected; the cost + /// is that this window will not come back next launch. + /// + private void Fault(Exception ex, string windowId) + { + if (_writers.Remove(windowId, out var writer)) + { + try + { + writer.Dispose(); + } + catch (Exception disposeFailure) + { + Logger.LogDebug(disposeFailure, "Closing the restore log for {WindowId} failed", windowId); + } + } + + if (_faultLogged) + { + return; + } + + _faultLogged = true; + Logger.LogWarning( + ex, + "The restore log under {Root} could not be written for window {WindowId}; that pane will start empty next launch", + Root, + windowId); + } + + private void CloseWriters() + { + foreach (var writer in _writers.Values) + { + try + { + writer.Dispose(); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Closing a restore log file failed"); + } + } + + _writers.Clear(); + } + + private bool TryDelete(string path) + { + try + { + if (!File.Exists(path)) + { + return false; + } + + File.Delete(path); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Logger.LogDebug(ex, "Removing the restore log file {Path} failed", path); + return false; + } + } + + /// + /// Reads one file into a , or null when it is not one of ours at all + /// (wrong magic, a version this build does not know, a header that fails its own checksum). + /// + private RestoredWindow? ReadFile(string path) + { + byte[] bytes; + DateTimeOffset written; + try + { + bytes = File.ReadAllBytes(path); + written = new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Reading the restore log file {Path} failed", path); + return null; + } + + if (!TryReadHeader(bytes, out var windowId, out var title, out var dataStart)) + { + Logger.LogWarning( + "{Path} is not a restore log this build can read; the window it belongs to starts empty", path); + return null; + } + + var lines = new List(); + var offset = dataStart; + long lost = 0; + while (offset < bytes.Length) + { + if (!TryFrameRecord(bytes, offset, out var payloadStart, out var payloadLength)) + { + // The framing itself no longer makes sense: a half-written record at the end of a file a + // crash interrupted, or damage a length prefix did not survive. Everything from here on + // is unreadable by construction, so stop and keep what came before. + lost += bytes.Length - offset; + break; + } + + var frameEnd = payloadStart + payloadLength + ChecksumBytes; + if (!ChecksumHolds(bytes, payloadStart, payloadLength)) + { + // One damaged record. Its neighbours are still framed, so skip exactly it. + lost += frameEnd - offset; + offset = frameEnd; + continue; + } + + try + { + lines.Add(DecodeRecord(bytes, payloadStart, payloadLength)); + } + catch (Exception ex) + { + // Checksum-clean and still undecodable: the payload disagrees with this reader rather + // than being damaged. Same treatment — lose the line, keep the file. + Logger.LogDebug(ex, "A restore log record in {Path} could not be decoded", path); + lost += frameEnd - offset; + } + + offset = frameEnd; + } + + if (lost > 0) + { + Logger.LogWarning( + "{Lost} byte(s) of the restore log for window {WindowId} could not be read — most likely a session " + + "that did not exit cleanly. {Kept} line(s) were restored and the rest discarded", + lost, + windowId, + lines.Count); + } + + // The file may hold up to twice the bound between compactions; the caller asked for the bound. + if (lines.Count > _maxLines) + { + lines.RemoveRange(0, lines.Count - _maxLines); + } + + return new RestoredWindow(windowId, title, written, lines, lost); + } + + private static RestoredLine DecodeRecord(byte[] bytes, int payloadStart, int payloadLength) + { + using var stream = new MemoryStream(bytes, payloadStart, payloadLength, writable: false); + using var reader = new BinaryReader(stream, StyledLineCodec.TextEncoding, leaveOpen: true); + var stamp = reader.ReadByte() != 0 ? reader.ReadString() : null; + return new RestoredLine(StyledLineCodec.Read(reader), stamp); + } + + /// + /// Locates one record's payload from its length prefix, checking only that the frame is present and + /// fits inside the file. False means the framing has run out — stop reading. Whether the record's + /// contents can be trusted is 's separate question, and keeping + /// the two apart is what lets a reader skip one damaged line without abandoning the ones after it. + /// + private static bool TryFrameRecord(byte[] bytes, int offset, out int payloadStart, out int payloadLength) + { + payloadStart = 0; + payloadLength = 0; + if (bytes.Length - offset < FrameOverheadBytes) + { + return false; + } + + var declared = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset)); + if (declared > MaxRecordBytes || declared + (long)FrameOverheadBytes > bytes.Length - offset) + { + return false; + } + + payloadStart = offset + LengthPrefixBytes; + payloadLength = (int)declared; + return true; + } + + /// Whether a framed record's payload still matches the CRC-32 written after it. + private static bool ChecksumHolds(byte[] bytes, int payloadStart, int payloadLength) => + Crc32.Compute(bytes.AsSpan(payloadStart, payloadLength)) + == BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(payloadStart + payloadLength)); + + /// Frames a payload into length · payload · CRC-32. + private static byte[] Frame(ReadOnlySpan payload) + { + var frame = new byte[payload.Length + FrameOverheadBytes]; + BinaryPrimitives.WriteUInt32LittleEndian(frame, (uint)payload.Length); + payload.CopyTo(frame.AsSpan(LengthPrefixBytes)); + BinaryPrimitives.WriteUInt32LittleEndian( + frame.AsSpan(LengthPrefixBytes + payload.Length), + Crc32.Compute(payload)); + return frame; + } + + /// Builds the file header: magic, version, then a checksummed payload naming the window. + private static byte[] BuildHeader(string windowId, string title) + { + using var stream = new MemoryStream(64); + using (var writer = new BinaryWriter(stream, StyledLineCodec.TextEncoding, leaveOpen: true)) + { + writer.Write(windowId); + writer.Write(title); + } + + var payload = stream.ToArray(); + var header = new byte[FixedHeaderBytes + payload.Length]; + Magic.CopyTo(header, 0); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(MagicBytes), FormatVersion); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(MagicBytes + sizeof(uint)), (uint)payload.Length); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(MagicBytes + (2 * sizeof(uint))), Crc32.Compute(payload)); + payload.CopyTo(header, FixedHeaderBytes); + return header; + } + + /// + /// Reads and validates a header. Everything about it is checked before a single record is trusted, + /// because a file replaced wholesale — restored from a backup, copied from another machine, or simply + /// not ours — is something per-record checksums would only notice one record at a time. + /// + private static bool TryReadHeader(byte[] bytes, out string windowId, out string title, out int dataStart) + { + windowId = string.Empty; + title = string.Empty; + dataStart = 0; + + if (bytes.Length < FixedHeaderBytes + || !bytes.AsSpan(0, MagicBytes).SequenceEqual(Magic) + || BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(MagicBytes)) != FormatVersion) + { + return false; + } + + var payloadLength = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(MagicBytes + sizeof(uint))); + var expected = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(MagicBytes + (2 * sizeof(uint)))); + if (payloadLength > MaxRecordBytes || FixedHeaderBytes + (long)payloadLength > bytes.Length) + { + return false; + } + + var payload = bytes.AsSpan(FixedHeaderBytes, (int)payloadLength); + if (Crc32.Compute(payload) != expected) + { + return false; + } + + try + { + using var stream = new MemoryStream(bytes, FixedHeaderBytes, (int)payloadLength, writable: false); + using var reader = new BinaryReader(stream, StyledLineCodec.TextEncoding, leaveOpen: true); + windowId = reader.ReadString(); + title = reader.ReadString(); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or ArgumentException) + { + return false; + } + + dataStart = FixedHeaderBytes + (int)payloadLength; + return windowId.Length > 0; + } + + /// + /// One window's open file: the stream, where every record in it starts, and how long it is. The + /// offsets are what make compaction a byte-range copy rather than a re-encode — records are + /// contiguous, so "keep the newest N" is one span. + /// + private sealed class Writer : IDisposable + { + private readonly string _path; + private readonly string _windowId; + private readonly ILogger _logger; + private readonly List _offsets = new(); + private FileStream _stream; + private int _dataStart; + + private Writer(string path, string windowId, FileStream stream, int dataStart, ILogger logger) + { + _path = path; + _windowId = windowId; + _stream = stream; + _dataStart = dataStart; + _logger = logger; + } + + /// + /// Opens (or creates) one window's file and indexes what is already in it, so a second session + /// continues the log rather than replacing it — restarting twice in a minute must not + /// leave a pane with two lines in it. + /// + /// A torn tail is truncated away here, at the last well-framed record. It costs nothing (the + /// reader had already stopped there) and it means the next append lands on a clean boundary + /// instead of behind rubbish that would poison every record after it. + /// + /// + public static Writer Open(string path, string windowId, string title, ILogger logger) + { + var existing = ReadExisting(path, windowId, out var dataStart, out var offsets, out var usable); + FileStream stream; + + if (existing) + { + // The mode is fixed up rather than set at creation: this file already has an inode, and + // one written by an older build (or copied in) should still end up owner-only. + RestrictToOwner(path, logger); + stream = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.Read); + if (usable < stream.Length) + { + logger.LogInformation( + "Trimming {Bytes} unreadable byte(s) from the end of the restore log for window {WindowId}", + stream.Length - usable, + windowId); + stream.SetLength(usable); + } + } + else + { + // UnixCreateMode, not a chmod afterwards: the mode is applied by the open that makes the + // inode, so the umask never gets a chance to widen it and there is no window in which a + // world-readable file already holds someone's chat. + stream = new FileStream(path, CreateOwnerOnly(FileShare.Read)); + + var header = BuildHeader(windowId, title); + stream.Write(header); + stream.Flush(); + dataStart = header.Length; + offsets = new List(); + } + + stream.Seek(0, SeekOrigin.End); + var writer = new Writer(path, windowId, stream, dataStart, logger); + writer._offsets.AddRange(offsets); + return writer; + } + + /// Appends one framed record and pushes it to the operating system; compacts when the file has doubled. + public void Append(byte[] payload, int maxLines) + { + var offset = _stream.Length; + _stream.Write(Frame(payload)); + + // Flush, not FlushAsync and not Flush(true). This puts the bytes in the kernel — which is + // what makes the log survive the client dying — without an fsync per line of chat. + _stream.Flush(); + _offsets.Add(offset); + + if (_offsets.Count > 2 * maxLines) + { + Compact(maxLines); + } + } + + public void Dispose() => _stream.Dispose(); + + /// + /// Rewrites the file with only the newest records, through a + /// temporary file and an atomic rename — so a crash mid-compaction leaves either the whole old + /// file or the whole new one, never a half of either. + /// + private void Compact(int maxLines) + { + var keepFrom = _offsets[^maxLines]; + var temporary = _path + TempExtension; + + try + { + _stream.Flush(); + using (var source = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var destination = new FileStream(temporary, CreateOwnerOnly(FileShare.None))) + { + var header = new byte[_dataStart]; + source.ReadExactly(header); + destination.Write(header); + + source.Seek(keepFrom, SeekOrigin.Begin); + source.CopyTo(destination); + } + + _stream.Dispose(); + File.Move(temporary, _path, overwrite: true); + + _stream = new FileStream(_path, FileMode.Open, FileAccess.Write, FileShare.Read); + _stream.Seek(0, SeekOrigin.End); + + var shift = keepFrom - _dataStart; + var kept = _offsets.Skip(_offsets.Count - maxLines).Select(o => o - shift).ToList(); + _offsets.Clear(); + _offsets.AddRange(kept); + } + catch (Exception ex) + { + // Compaction failing is not worth losing the log over: the file simply keeps growing + // until the next attempt succeeds, and the reader takes the newest `maxLines` anyway. + _logger.LogDebug(ex, "Compacting the restore log for window {WindowId} failed", _windowId); + TryRemove(temporary); + if (!_stream.CanWrite) + { + _stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read); + } + } + } + + private static void TryRemove(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A stray .tmp is harmless: it is not a *.log and the next compaction overwrites it. + } + } + + /// + /// Indexes an existing file: its header, every well-framed record in it, and the offset the + /// first unusable byte starts at. Framing only — the checksums are the reader's business, and a + /// damaged record in the middle must still be *skipped over* rather than truncating everything + /// written after it. + /// + private static bool ReadExisting( + string path, string windowId, out int dataStart, out List offsets, out long usable) + { + dataStart = 0; + offsets = new List(); + usable = 0; + + byte[] bytes; + try + { + if (!File.Exists(path)) + { + return false; + } + + bytes = File.ReadAllBytes(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + + if (!TryReadHeader(bytes, out var storedId, out _, out dataStart) + || !string.Equals(storedId, windowId, StringComparison.Ordinal)) + { + // Not ours, or a checksum collision on the name. Starting the file over is the only safe + // move: appending our records to somebody else's file would corrupt both. + return false; + } + + var offset = (long)dataStart; + while (offset < bytes.Length) + { + if (bytes.Length - offset < FrameOverheadBytes) + { + break; + } + + var declared = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan((int)offset)); + var frame = declared + (long)FrameOverheadBytes; + if (declared > MaxRecordBytes || offset + frame > bytes.Length) + { + break; + } + + offsets.Add(offset); + offset += frame; + } + + usable = offset; + return true; + } + + /// + /// Options for creating a file owner-only. The mode is applied by the open that makes the + /// inode — never a chmod afterwards, which would leave a window in which a world-readable file + /// already held someone's chat. Windows takes no equivalent action, for the reason + /// SecretsStore.RestrictToOwner gives at length: the file inherits a user-only ACL from + /// %APPDATA%, and hand-rolling a DACL would be untestable code with a lockout for a + /// failure mode. + /// + private static FileStreamOptions CreateOwnerOnly(FileShare share) + { + var options = new FileStreamOptions + { + Mode = FileMode.Create, + Access = FileAccess.Write, + Share = share, + }; + + if (!OperatingSystem.IsWindows()) + { + options.UnixCreateMode = OwnerOnlyFileMode; + } + + return options; + } + + private static void RestrictToOwner(string path, ILogger logger) + { + if (OperatingSystem.IsWindows()) + { + return; + } + + try + { + if (File.GetUnixFileMode(path) != OwnerOnlyFileMode) + { + File.SetUnixFileMode(path, OwnerOnlyFileMode); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + logger.LogDebug(ex, "Narrowing the restore log file {Path} to 0600 failed", path); + } + } + } +} diff --git a/src/SharpMUTerm.Core/Text/RestoreLogOptions.cs b/src/SharpMUTerm.Core/Text/RestoreLogOptions.cs new file mode 100644 index 00000000..154f0bf0 --- /dev/null +++ b/src/SharpMUTerm.Core/Text/RestoreLogOptions.cs @@ -0,0 +1,62 @@ +namespace SharpMUTerm.Core.Text; + +/// +/// How much of each pane's recent output survives a restart, and where it is kept. Part of +/// AppConfiguration, so these are user-editable settings. +/// +/// The restore log is the one piece of session text in this client that is kept on purpose and +/// is not a transcript. The other two are neither: 's spill +/// is an ephemeral cache purged on the next launch, and PlainTextLogSink/HtmlLogSink are +/// opt-in transcripts a user keeps and reads. This exists so that restarting the client does not +/// empty every pane — see . +/// +/// +public sealed class RestoreLogOptions +{ + /// + /// The default per-window bound, in lines. See for the + /// argument; it is named here so a test can assert the shipped value rather than a literal. + /// + public const int DefaultMaxLinesPerWindow = 500; + + /// The directory name, under the configuration directory, holding the per-window logs. + public const string DirectoryName = "restore"; + + /// + /// Whether panes come back with their previous session's content. On by default — a client whose + /// channel windows are empty after a restart is the state this feature exists to remove. Switching + /// it off here stops all of it; a single character opts out on F9 instead + /// (). + /// + public bool Enabled { get; set; } = true; + + /// + /// How many lines of each window are kept, and so how many come back. + /// + /// Lines, never bytes. The design notes record BeipMU hanging for ever trying to make room in + /// a byte-bounded ring when one block was larger than the whole ring + /// (docs/superpowers/specs/2026-07-30-scrollback-design.md). A line bound cannot reach that + /// state: dropping one line always makes room for one line, whatever either of them weighs. + /// + /// + /// Why 500. It is deliberately far under the live 10,000-line window + /// () because the two answer different questions. The + /// live buffer is "how far can I scroll back in this session"; this is "what was on screen when I + /// quit", and 500 lines is ten to sixteen paneful screens — more context than anyone re-reads after + /// a restart, and enough that a quiet channel window still has its last conversation in it. It is + /// also what makes the cost bearable at both ends: at roughly 120 bytes a line encoded it is 60 KB + /// per window, so a dozen windows sit under a megabyte beside config.json, and the startup + /// read that runs before the first frame stays in the low milliseconds. Raising it costs startup + /// latency on every launch to buy scrollback depth the live buffer already provides for the session + /// you are actually in. + /// + /// + public int MaxLinesPerWindow { get; set; } = DefaultMaxLinesPerWindow; + + /// + /// Where the logs live. Null (the default) means a restore directory inside the configuration + /// directory, beside config.json and secrets.json — a data location, not a cache one, + /// because unlike the spill these files are meant to survive. + /// + public string? Directory { get; set; } +} diff --git a/src/SharpMUTerm.Core/Workspace/LayoutNode.cs b/src/SharpMUTerm.Core/Workspace/LayoutNode.cs index b1ee7fb9..37efdaa0 100644 --- a/src/SharpMUTerm.Core/Workspace/LayoutNode.cs +++ b/src/SharpMUTerm.Core/Workspace/LayoutNode.cs @@ -22,7 +22,17 @@ public enum Edge /// A node in the workspace split tree: either a or a . public abstract class LayoutNode { - /// Enumerates every pane at or under this node, in left-to-right / top-to-bottom order. + /// + /// Enumerates every pane at or under this node in tree order — left-to-right / + /// top-to-bottom, which is geometry and nothing else. + /// + /// This is the order the renderer and the resizer want, and it is not the order panes are + /// numbered in. Tree order is a function of where a pane sits, so creating one renumbers whatever + /// it was inserted before: splitting the second of two panes on its left edge makes the old pane 2 + /// into pane 3, and ⌥2 stops meaning what it meant a moment earlier. The numbering users see and + /// press comes from , which is creation order. + /// + /// public abstract IEnumerable Panes(); } @@ -32,17 +42,44 @@ public abstract class LayoutNode /// public sealed class PaneNode : LayoutNode { - public PaneNode(string id, IEnumerable? tabs = null, int activeIndex = 0, bool frozen = false) + public PaneNode( + string id, + IEnumerable? tabs = null, + int activeIndex = 0, + bool frozen = false, + int sequence = Unsequenced) { Id = id ?? throw new ArgumentNullException(nameof(id)); Tabs = tabs is null ? new List() : new List(tabs); ActiveIndex = Tabs.Count == 0 ? -1 : Math.Clamp(activeIndex, 0, Tabs.Count - 1); Frozen = frozen; + Sequence = sequence; } + /// + /// The of a pane nobody has numbered yet — a pane restored from a workspace + /// persisted before panes carried a creation order. replaces it on + /// load, from tree order, so an old configuration comes back numbered the way it was left rather + /// than scrambled. + /// + public const int Unsequenced = 0; + /// Stable pane identity, unique within a workspace. public string Id { get; } + /// + /// When this pane was created, as a per-workspace counter that only ever goes up. Panes are + /// numbered by this (see ) rather than by where they + /// sit, so a pane's number is fixed for as long as it is open: creating one somewhere to its left + /// no longer pushes it from ⌥2 to ⌥3. + /// + /// It is a sort key and not the number itself. The number is the pane's position in the sorted + /// list, which is what keeps ⌥N contiguous when a pane in the middle closes — see + /// . + /// + /// + public int Sequence { get; internal set; } + /// The window ids hosted here, in tab order. public List Tabs { get; } diff --git a/src/SharpMUTerm.Core/Workspace/WorkspaceLayout.cs b/src/SharpMUTerm.Core/Workspace/WorkspaceLayout.cs index 3aada187..f60ad372 100644 --- a/src/SharpMUTerm.Core/Workspace/WorkspaceLayout.cs +++ b/src/SharpMUTerm.Core/Workspace/WorkspaceLayout.cs @@ -14,6 +14,14 @@ public sealed class WorkspaceLayout private readonly HashSet _removed = new(); private int _paneCounter; + /// + /// The last handed out. Kept apart from + /// (which only feeds the pN ids) because the two can start out of step: a workspace restored + /// from a configuration written before panes carried a sequence has its numbering assigned here from + /// tree order, and its ids may be anything a previous version wrote. + /// + private int _sequenceCounter; + /// Creates a workspace with a single pane, optionally seeded with window ids. public WorkspaceLayout(IEnumerable? initialTabs = null) { @@ -26,6 +34,16 @@ public WorkspaceLayout(IEnumerable? initialTabs = null) /// Rebuilds a layout from a pre-constructed tree (used when resuming a saved session). Focus and /// zoom fall back to a valid pane when the given ids are stale, and the internal pane counter is /// advanced past the highest pN id so future splits never collide with restored ids. + /// + /// A pane restored without a creation sequence is given one here, from tree order. Panes are + /// numbered by and a configuration written before that field existed + /// carries none, so without this every restored pane would sort equal and the numbering would be + /// whatever the sort happened to do. Tree order is the numbering those workspaces were saved under, + /// which is why it is the right seed: an existing configuration comes back reading exactly as it + /// read when it was closed, and is stable from then on. Any pane that does carry a sequence + /// keeps it, and unsequenced ones are numbered after the highest that is already taken, so a + /// half-migrated tree cannot produce two panes with one number. + /// /// public WorkspaceLayout(LayoutNode root, string focusedPaneId, string? zoomedPaneId = null) { @@ -39,6 +57,12 @@ public WorkspaceLayout(LayoutNode root, string focusedPaneId, string? zoomedPane FocusedPaneId = panes.Any(p => p.Id == focusedPaneId) ? focusedPaneId : panes[0].Id; ZoomedPaneId = zoomedPaneId is not null && panes.Any(p => p.Id == zoomedPaneId) ? zoomedPaneId : null; _paneCounter = panes.Select(p => ParsePaneCounter(p.Id)).DefaultIfEmpty(0).Max(); + + _sequenceCounter = panes.Select(p => p.Sequence).DefaultIfEmpty(PaneNode.Unsequenced).Max(); + foreach (var pane in panes.Where(p => p.Sequence <= PaneNode.Unsequenced)) + { + pane.Sequence = ++_sequenceCounter; + } } /// Extracts the numeric suffix of a pN pane id, or 0 for an unrecognised shape. @@ -54,8 +78,33 @@ private static int ParsePaneCounter(string id) => /// The id of the zoomed pane (rendered full-area), or null when nothing is zoomed. public string? ZoomedPaneId { get; private set; } - /// Every pane, in left-to-right / top-to-bottom order. - public IReadOnlyList Panes => Root.Panes().ToList(); + /// + /// Every pane in creation order — the one order this client numbers panes in. The Nth entry is + /// pane N in the connection rail, the ⌃P Go to pane N entry, the move/drag overlays' + /// label and the ⌥N chord; those are four spellings of this index and there is deliberately no + /// second ordering for any of them to drift onto. + /// + /// Why not tree order. It used to be Root.Panes(), which is geometry: a pane's position + /// left-to-right / top-to-bottom. Creating a pane therefore renumbered every pane after the insertion + /// point — dropping a window on the left edge of pane 2 made that pane into pane 3 — so ⌥2 silently + /// stopped meaning what it meant a moment earlier, on a workspace the user had not otherwise + /// rearranged. Creation order cannot do that: a pane's number is fixed for as long as it is open, and + /// a new pane always appears at the end. + /// + /// + /// The number is the index, not the sequence. Sequences are never reused, so reading them + /// directly would leave holes — close pane 2 of three and the survivors would be 1 and 3, with ⌥2 + /// doing nothing while two panes sat on the screen. Taking the position in this list instead compacts + /// the numbering on every close, which is what keeps ⌥1–⌥N contiguous. + /// + /// + /// Geometry does not read this. The renderer (LayoutSolver), the resizer (PaneResize) + /// and directional movement (PaneNavigation, which works on arranged rectangles) all go + /// through or the tree itself, so ordering here changes what panes are + /// called and never where they are drawn. + /// + /// + public IReadOnlyList Panes => Root.Panes().OrderBy(p => p.Sequence).ToList(); /// The focused pane. public PaneNode FocusedPane => FindPane(FocusedPaneId)!; @@ -79,7 +128,16 @@ public bool Focus(string paneId) return true; } - /// Cycles focus to the next pane in tree order (tmux o). + /// + /// Cycles focus to the next pane by number (tmux o) — ⌃O from pane 2 goes to + /// pane 3, and wraps from the last back to pane 1. + /// + /// It counts in order, which is the order ⌥N counts in, because these are the + /// two ordinal movers and a user pressing them alternately must not be counting two + /// different sequences: three presses of ⌃O from pane 1 land where ⌥4 does. It used to be tree + /// order — the same order ⌥N used at the time, so they agreed then and would not now. + /// + /// public void CycleFocus() { var panes = Panes; @@ -138,6 +196,31 @@ public void CloseFocused() public void ToggleZoom() => ZoomedPaneId = ZoomedPaneId == FocusedPaneId ? null : FocusedPaneId; + /// + /// Re-points an existing zoom at whichever pane is now focused, and says whether it moved. Returns + /// false when nothing is zoomed — this never starts a zoom, it only carries one. + /// + /// Focus and zoom are separate fields, and a mover that changes one without the other leaves the + /// focused pane off screen: a zoomed workspace realises exactly one pane, so the selection, + /// the session the command line talks to and the caret would all be on a pane the user cannot see — + /// attention on one pane and keystrokes to another, which is the defect the per-window work exists to + /// eliminate. The ordinal movers (cycle, and jump-to-number) therefore carry the zoom with them, and + /// so the pane you asked for is the pane that fills the screen. The directional movers do + /// not, and cannot: while one pane is realised there is no pane "to the left" to ask for, which is + /// why ⌃← refuses out loud instead. + /// + /// + public bool CarryZoomToFocused() + { + if (ZoomedPaneId is null || ZoomedPaneId == FocusedPaneId) + { + return false; + } + + ZoomedPaneId = FocusedPaneId; + return true; + } + /// Toggles the frozen (split-scrollback) state of the focused pane. public void ToggleFreezeFocused() { @@ -259,7 +342,12 @@ public bool SetActiveTab(string paneId, string windowId) return true; } - private PaneNode NewPane(IEnumerable? tabs = null) => new($"p{++_paneCounter}", tabs); + /// + /// Mints a pane: a fresh id, and the next creation sequence, which is what puts it last in + /// and so gives it the highest pane number rather than displacing anyone's. + /// + private PaneNode NewPane(IEnumerable? tabs = null) => + new($"p{++_paneCounter}", tabs, sequence: ++_sequenceCounter); /// Removes a window from whichever pane hosts it, fixing that pane's active index. private bool DetachWindow(string windowId) @@ -343,7 +431,9 @@ private void PruneAndFix() if (FindPane(FocusedPaneId) is null) { - FocusedPaneId = Root.Panes().First().Id; + // The lowest-numbered survivor — pane 1 — rather than the topmost-leftmost, so closing the + // focused pane lands you where the label says it did. + FocusedPaneId = Panes[0].Id; } if (ZoomedPaneId is not null && FindPane(ZoomedPaneId) is null) diff --git a/src/SharpMUTerm.Core/Workspace/WorkspaceState.cs b/src/SharpMUTerm.Core/Workspace/WorkspaceState.cs index 87074509..0daac721 100644 --- a/src/SharpMUTerm.Core/Workspace/WorkspaceState.cs +++ b/src/SharpMUTerm.Core/Workspace/WorkspaceState.cs @@ -26,6 +26,18 @@ public sealed class LayoutNodeState public int ActiveIndex { get; set; } public bool Frozen { get; set; } + /// + /// The pane's creation sequence (), which is what its ⌥N number is + /// derived from. Persisted so a resumed workspace comes back numbered the way it was left rather + /// than renumbered by the shape of its split tree. + /// + /// — the value a configuration written before this field existed + /// deserialises to — means "nobody has numbered this pane"; assigns + /// one from tree order on load, which is the numbering such a workspace was saved under. + /// + /// + public int Sequence { get; set; } = PaneNode.Unsequenced; + // split public SplitDirection Direction { get; set; } public List Sizes { get; set; } = new(); @@ -89,6 +101,7 @@ public Workspace Restore() Tabs = new List(pane.Tabs), ActiveIndex = pane.ActiveIndex, Frozen = pane.Frozen, + Sequence = pane.Sequence, }, SplitNode split => new LayoutNodeState { @@ -110,7 +123,7 @@ private static LayoutNode BuildNode(LayoutNodeState state) if (string.Equals(state.Type, "pane", StringComparison.OrdinalIgnoreCase)) { - return new PaneNode(state.Id ?? "p1", state.Tabs, state.ActiveIndex, state.Frozen); + return new PaneNode(state.Id ?? "p1", state.Tabs, state.ActiveIndex, state.Frozen, state.Sequence); } // A typo or a newer serialized node type shouldn't silently degrade to a pane and lose structure. diff --git a/src/SharpMUTerm.Core/Workspaces/RailModel.cs b/src/SharpMUTerm.Core/Workspaces/RailModel.cs index 3ab2f708..8e29ef3b 100644 --- a/src/SharpMUTerm.Core/Workspaces/RailModel.cs +++ b/src/SharpMUTerm.Core/Workspaces/RailModel.cs @@ -51,8 +51,27 @@ public sealed record RailRow( /// A world as projected into the rail: its identity, accent, and characters. public sealed record RailWorld(string Name, string Host, int Port, TerminalColor Accent, IReadOnlyList Characters); -/// A character in the rail: connection/active state, unread total, and its windows. -public sealed record RailCharacter(string Name, string SessionKey, bool Connected, bool Active, int Unread, IReadOnlyList Windows); +/// +/// A character in the rail: connection/active state, unread total, its windows, and the pane its +/// session lives in. +/// +/// is what makes the pane numbering visible across characters. Only the +/// active character's windows are listed (see ), so before this +/// field a reader looking at character A could see that pane 3 existed only if one of A's own +/// windows happened to be in it — B's pane was on the screen, numbered, and reachable with ⌥3, and +/// nothing anywhere said so. The number is useless if it cannot be read off the sidebar, and the whole +/// point of ⌥N being global is that it switches character. Null when the workspace has a single pane +/// (one pane is no information) or the character has no window open. +/// +/// +public sealed record RailCharacter( + string Name, + string SessionKey, + bool Connected, + bool Active, + int Unread, + IReadOnlyList Windows, + string? Pane = null); /// /// A window in the rail: its title, the workspace id a click activates, hosting pane (or closed), @@ -69,6 +88,15 @@ public sealed record RailWindow(string Title, string Id, string? Pane, int Unrea /// "no characters". (A world's host:port is deliberately not a row; the rail shows /// name and characters only, which RailModelTests pins.) Pure and unit-testable. /// +/// Every character row carries its own hosting pane (), active or +/// not, and that is the one thing here that is not scoped to the active character. It has to be: the +/// pane numbering is a property of the workspace rather than of whoever is in front, ⌥3 is a character +/// switch as much as a pane switch, and a number that is only legible once you are already there is a +/// number nobody presses. Listing the other characters' windows would have said the same thing +/// and cost the rail its one unambiguous reading — a window row means "this is yours", which is why the +/// owner filter exists. +/// +/// /// Every row that names somewhere you can go also carries the a click /// dispatches. The ids are the shell's own (), so the rail is a second door /// onto the ⌃P surface's actions rather than a second implementation of switching. @@ -113,6 +141,7 @@ public static IReadOnlyList Build(IReadOnlyList worlds) Active: character.Active, Connected: character.Connected, Unread: character.Unread, + Pane: character.Pane, Target: CommandIds.Character(character.SessionKey))); if (!character.Active) diff --git a/src/SharpMUTerm.Crawler/Output/ObservationLog.cs b/src/SharpMUTerm.Crawler/Output/ObservationLog.cs index af6fcffe..4d1848f3 100644 --- a/src/SharpMUTerm.Crawler/Output/ObservationLog.cs +++ b/src/SharpMUTerm.Crawler/Output/ObservationLog.cs @@ -21,7 +21,7 @@ namespace SharpMUTerm.Crawler.Output; /// /// Variables are written as the protocol sends them — the canonical name against an array of /// values — so REFERRAL and a multi-valued PORT survive the round trip. Flattening to -/// one value per key here would throw away the same thing the telnet library throws away. +/// one value per key here would lose the arrays MSSP exists to publish. /// /// public sealed class ObservationLog(string path) : IDisposable @@ -56,7 +56,6 @@ public void Append(ProbeResult result) Outcome = result.Outcome, DurationMs = (long)result.Duration.TotalMilliseconds, Error = result.Error, - Source = result.Data?.Source.ToString(), Name = result.Data?.Name, Players = result.Data?.Players, Uptime = result.Data?.Uptime, @@ -108,9 +107,6 @@ private sealed class ObservationRecord public string? Error { get; init; } - /// Whether the values came off the wire complete, or from the library's reduced view. - public string? Source { get; init; } - public string? Name { get; init; } public int? Players { get; init; } diff --git a/src/SharpMUTerm.Tui/Glyphs.cs b/src/SharpMUTerm.Tui/Glyphs.cs index 2c99aa8f..7b8a9013 100644 --- a/src/SharpMUTerm.Tui/Glyphs.cs +++ b/src/SharpMUTerm.Tui/Glyphs.cs @@ -26,6 +26,12 @@ internal static class Glyphs public const string Log = "\uf0f6"; // nf-fa-file_text_o — log indicator (was the fisheye) public const string World = "\uf0ac"; // nf-fa-globe — world/server accent (paired with the spine) + /// + /// The bar marking where the previous session's content ends and this one begins — see + /// . A clock rewinding, because that is exactly what the rows above it are. + /// + public const string Restored = "\uf1da"; // nf-fa-history + /// /// The focused pane's marker, drawn on the active tab of the pane every workspace key acts on. Box /// drawing rather than a Nerd Font icon, deliberately: it is the one glyph here whose job is to be diff --git a/src/SharpMUTerm.Tui/MacroKeys.cs b/src/SharpMUTerm.Tui/MacroKeys.cs index d4b2f005..feb7d0bb 100644 --- a/src/SharpMUTerm.Tui/MacroKeys.cs +++ b/src/SharpMUTerm.Tui/MacroKeys.cs @@ -1,4 +1,5 @@ using SharpMUTerm.Core.Automation; +using SharpMUTerm.Core.Commands; namespace SharpMUTerm.Tui; @@ -63,7 +64,40 @@ internal static class MacroKeys /// this list rather than alongside it, so a key the app takes is a key the keypad screen knows is /// taken — there is one list, not two that agree until someone edits one. /// - internal static IReadOnlyList AppShortcuts { get; } = new AppShortcut[] + internal static IReadOnlyList AppShortcuts { get; } = BuildAppShortcuts(); + + /// + /// The pane number a claimed stands for, or null when the key is not one of + /// the nine digits ⌥1–⌥9 are registered on. The one place the mapping is written down, so the + /// registration, the F4 screen and the app's action cannot disagree about which digit is which pane. + /// + internal static int? PaneJumpNumber(ConsoleKey key) => + key >= ConsoleKey.D1 && key <= ConsoleKey.D0 + CommandIds.PaneJumpDigits + ? key - ConsoleKey.D0 + : null; + + /// + /// The fixed claims are a local rather than a field: a static field initialiser runs in declaration + /// order, so a field read from this method would still be null when the property above calls it. + /// + private static AppShortcut[] BuildAppShortcuts() + { + var claims = new List(Fixed()); + + // ⌥1–⌥9 — jump to a numbered pane. Generated rather than written out nine times so the digit, the + // pane number and the sentence F4 prints are one expression; and claimed *here*, in the list the + // app registers from, because the framework claims Alt+1–9 for its own top-level window selector + // and that handler is not gated on anything we can switch off (see SharpMUTermApp.JumpToPane). + // Every one of the nine is claimed, in range or not — an unclaimed digit would fall through to it. + for (var n = 1; n <= CommandIds.PaneJumpDigits; n++) + { + claims.Add(new AppShortcut(ConsoleModifiers.Alt, ConsoleKey.D0 + n, $"goes to pane {n}")); + } + + return claims.ToArray(); + } + + private static AppShortcut[] Fixed() => new AppShortcut[] { new(ConsoleModifiers.Control, ConsoleKey.Q, "asks whether to quit"), new(ConsoleModifiers.Control, ConsoleKey.N, "picks the next window"), @@ -206,9 +240,12 @@ internal static MacroKeyVerdict Verdict(string? descriptor) /// /// What a letter or a digit is worth. Unmodified it is typing; with Ctrl it is a control byte, which - /// carries no Shift or Alt of its own and which four letters cannot produce at all because the - /// terminal spells those bytes Tab, Enter and Backspace; with Alt it is an ESC prefix, except for the - /// one letter the parser has already spent on its own SS3 introducer. + /// carries no Shift or Alt of its own, which four letters cannot produce at all because the terminal + /// spells those bytes Tab, Enter and Backspace, and which no digit produces usefully (see + /// ); with Alt it is an ESC prefix, except for the one letter the parser has + /// already spent on its own SS3 introducer. Alt+digit is the one modifier the digit row does deliver, + /// which is why the pane-jump chords are on it — then reports ⌥1–⌥9 as + /// taken through , and ⌥0 stays free for a macro. /// private static MacroKeyVerdict Chord(MacroKeyParts parts) { @@ -224,9 +261,9 @@ private static MacroKeyVerdict Chord(MacroKeyParts parts) return Never("a Ctrl chord loses Shift"); } - if (IsDigit(parts.Key)) + if (DigitBytes.TryGetValue(parts.Key, out var digitSpelling)) { - return Never("Ctrl+digit is dropped"); + return Never($"the terminal sends {digitSpelling} instead"); } return ControlBytes.TryGetValue(parts.Key, out var spelt) @@ -239,6 +276,40 @@ private static MacroKeyVerdict Chord(MacroKeyParts parts) : new MacroKeyVerdict(MacroKeyDelivery.Fires); } + /// + /// What a terminal actually writes for Ctrl+each digit — the reason the pane-jump chord is + /// Alt and not Ctrl, which is what was asked for. + /// + /// There is no Ctrl+digit encoding to speak of. The digit row has no control bytes of its own beyond + /// an accident of the ASCII table, so a terminal either sends the bare digit (1, 9, 0 — a chord the + /// app cannot even tell from typing) or a byte that already belongs to another key. Three of those are + /// keys this client cannot afford to lose: Ctrl+3 is Escape, which is how every overlay + /// here is closed and half of the Alt+⏎ reassembly; Ctrl+8 is Backspace; Ctrl+2 + /// is NUL. Binding any of them would break the plain key rather than add a chord — the same trap + /// records for Ctrl+H/I/J/M. + /// + /// + /// Observed, not remembered: the bytes were read off a real pty by driving each chord at a raw-mode + /// reader with kitten @ send-keyESC+digit for every Alt+digit, and this table for + /// Ctrl. A decode test could not have answered it, for the reason + /// TerminalKeyArrivalTests spells out: what the parser makes of a byte sequence is a smaller + /// claim than what the terminal sends. + /// + /// + private static readonly Dictionary DigitBytes = new(StringComparer.Ordinal) + { + ["0"] = "a bare 0", + ["1"] = "a bare 1", + ["2"] = "NUL", + ["3"] = "Escape", + ["4"] = "0x1C", + ["5"] = "0x1D", + ["6"] = "0x1E", + ["7"] = "0x1F", + ["8"] = "Backspace", + ["9"] = "a bare 9", + }; + /// The letters whose control byte the terminal has already spent on another key. private static readonly Dictionary ControlBytes = new(StringComparer.Ordinal) { diff --git a/src/SharpMUTerm.Tui/Program.cs b/src/SharpMUTerm.Tui/Program.cs index 0bf7b95a..5d1bf829 100644 --- a/src/SharpMUTerm.Tui/Program.cs +++ b/src/SharpMUTerm.Tui/Program.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Text; using SharpMUTerm.Graphics; using SharpConsoleUI.Drivers; @@ -91,12 +92,27 @@ private static int Main(string[] args) loadLogger.LogWarning("{Notice}", notice); } + // The panes' own memory between runs, beside the configuration whose LastSession says where each + // pane goes. Resolved here for the same reason logRoot is: only this code knows it is the live + // client, so only it hands over a directory to write in. It is created unconditionally even when + // the feature is off — constructing one touches no disk, and the ⌃P purge has to be able to + // clear what an earlier, enabled run left behind. + using var restore = new RestoreLog( + string.IsNullOrWhiteSpace(config.RestoreLog.Directory) + ? RestoreLog.DefaultRoot(ConfigurationStore.DefaultPath) + : config.RestoreLog.Directory!, + config.RestoreLog) + { + Logger = diagnostics.For("SharpMUTerm.RestoreLog"), + }; + var liveApp = new SharpMUTermApp( config, capabilities, diagnostics: diagnostics, save: saved => ConfigurationStore.Save(ConfigurationStore.DefaultPath, saved), - logRoot: logRoot); + logRoot: logRoot, + restore: restore); var exitCode = liveApp.Run(startup); // blocks on the SharpConsoleUI main loop until exit // Persist the workspace so the next launch resumes where this one left off. @@ -252,6 +268,16 @@ private static void WriteUsage(TextWriter usage) usage.WriteLine(" command line); Ctrl+O cycles them; Tab switches command lines. The pane you are on and"); usage.WriteLine(" the line Enter sends from are both drawn lit, and the focused pane's tab is marked."); + // Alt, not Ctrl, and the page says why: Ctrl+digit is what a reader will try first (it is what was + // asked for) and it cannot work — no digit has a control byte of its own, so a terminal sends the + // bare digit, or one already spelt Escape or Backspace. Naming the working chord and the reason + // the obvious one is absent is the same honesty this page owes everywhere else. + usage.WriteLine("Panes: Alt+1..Alt+9 go straight to a numbered pane and bring it forward — the numbers the"); + usage.WriteLine(" sidebar shows beside each window ('pane 2', 'pane 3'...), counted left to right then"); + usage.WriteLine(" top to bottom. It says so when there is no pane with that number. Ctrl+digit is not"); + usage.WriteLine(" offered: no terminal sends a distinct Ctrl+digit — 3 and 8 arrive as Escape and"); + usage.WriteLine(" Backspace, and 1, 9 and 0 as the bare digit."); + // Alt+Shift+arrow is a chord this host does deliver — the parser reads both modifier bits out of // CSI 1;4 — which is why it may be named here at all; see TerminalKeyArrivalTests. It is // deliberately not Ctrl+Shift+arrow, which this page used to name: kitty_mod is ctrl+shift, and diff --git a/src/SharpMUTerm.Tui/RailRenderer.cs b/src/SharpMUTerm.Tui/RailRenderer.cs index eb00c296..cf3ff745 100644 --- a/src/SharpMUTerm.Tui/RailRenderer.cs +++ b/src/SharpMUTerm.Tui/RailRenderer.cs @@ -1,4 +1,3 @@ -using System.Globalization; using SharpConsoleUI.Parsing; using SharpMUTerm.Core.Text; using SharpMUTerm.Core.Workspaces; @@ -119,12 +118,31 @@ public static List RenderCollapsed(IReadOnlyList rows) return lines; } + /// + /// A character row: the active marker, the connected dot, the name, its unread total — and, in the + /// same right-hand column the window rows use, the pane its session is in. + /// + /// That column is how the pane numbering is legible from anywhere. Window rows are drawn for the + /// active character only, so pane 3 used to be visible only to whoever was already in it, + /// while ⌥3 was reaching it from every other character. It is drawn on the active character's row + /// too, deliberately: a column that appeared and vanished as you switched would be a third thing to + /// learn, and repeating "where this character is" above its window rows is redundant rather than + /// ambiguous — unlike ▪ main main, both columns here mean the same thing and say it in the + /// one vocabulary (pane N). + /// + /// + /// It costs the sidebar nothing at rest: a window row is indented one level deeper and carries the + /// pen field as well, so it is the wider row wherever one exists, and the model leaves + /// null on a single-pane workspace exactly as it does for windows. + /// + /// private static string Character(RailRow row) { var marker = row.Active ? "[bold]▸[/]" : " "; var dot = row.Connected ? "●" : "○"; var name = row.Active ? $"[bold]{Escape(row.Label)}[/]" : Escape(row.Label); - return $"{Indent(row)}{Link(row, $"{marker} [{Accent(row)}]{dot}[/] {name}{UnreadField(row.Unread)}")}"; + var tail = row.Pane is { Length: > 0 } pane ? $" [dim]{Escape(pane)}[/]" : string.Empty; + return $"{Indent(row)}{Link(row, $"{marker} [{Accent(row)}]{dot}[/] {name}{UnreadField(row.Unread)}{tail}")}"; } /// @@ -142,11 +160,12 @@ private static string Character(RailRow row) /// private const int UnsentFieldWidth = 2; - /// Cells kept for an unread count, blank when there is none. See . - private const int UnreadFieldWidth = 3; - - /// The largest count drawn in full; above it the badge reads 99+ and stops growing. - private const int MaxUnread = 99; + /// + /// Cells kept for an unread count, blank when there is none. See . It is + /// because the badge's own cap is what makes the field finite — + /// the two numbers are one fact and may not be written down twice. + /// + private const int UnreadFieldWidth = UnreadBadge.FieldWidth; /// The pen, or the same width in blanks. See . private static string Unsent(bool unsent) => @@ -157,16 +176,17 @@ private static string Unsent(bool unsent) => /// the pen is () and with more urgency: unread arrives unbidden from /// the wire, so an unreserved badge resizes the sidebar — and every connected server's idea of its /// terminal — on a line of output the reader did not ask for, and again at 9 → 10 when it takes a - /// second digit. The cap is what makes the field finite: a count past reads - /// 99+, which is the same three cells and the same information at a glance. + /// second digit. The cap is what makes the field finite: a count past + /// reads 99+, which is the same three cells and the same information at a glance. + /// + /// Both the wording and the colour come from , which the pane tab labels draw + /// from as well, so the sidebar and the strip cannot come to say different things about one count. + /// /// private static string UnreadField(int unread) => unread <= 0 ? new string(' ', UnreadFieldWidth) - : $"[#00f5b7]{Badge(unread).PadLeft(UnreadFieldWidth)}[/]"; - - private static string Badge(int unread) => - unread > MaxUnread ? $"{MaxUnread}+" : unread.ToString(CultureInfo.InvariantCulture); + : $"[{UnreadBadge.Tint}]{UnreadBadge.Format(unread).PadLeft(UnreadFieldWidth)}[/]"; /// /// A window row: what the window is, then — when there is anything to say — where it is. @@ -184,11 +204,12 @@ private static string Window(RailRow row) var name = Escape(row.Label); var where = row.Closed ? "closed" : row.Pane is { Length: > 0 } pane ? pane : null; - // Two spaces, not three. The reserved badge fields sit between the label and this column and are - // blank far more often than not, so they already hold the gap open; a third on top of them would - // be paid for in sidebar columns, which come out of the panes. Not one, though: a populated - // unread badge ends right here, and `2 pane 2` reads as one thing rather than two. - var tail = where is null ? string.Empty : $" [dim]{Escape(where)}[/]"; + // One space. The reserved badge fields sit between the label and this column and are blank far more + // often than not, so they already hold the gap open; anything on top of them is paid for in sidebar + // columns, which come out of the panes. Two used to be needed because the column said `pane 2` and a + // populated unread badge ending right here made `2 pane 2` read as one thing; the sigil in `⌥2` + // now does that work in a cell that carries meaning of its own. + var tail = where is null ? string.Empty : $" [dim]{Escape(where)}[/]"; return $"{Indent(row)}{Link(row, $"[dim]▪[/] {name}{Unsent(row.Unsent)}{UnreadField(row.Unread)}{tail}")}"; } diff --git a/src/SharpMUTerm.Tui/RestoreBarRenderer.cs b/src/SharpMUTerm.Tui/RestoreBarRenderer.cs new file mode 100644 index 00000000..eaf73f71 --- /dev/null +++ b/src/SharpMUTerm.Tui/RestoreBarRenderer.cs @@ -0,0 +1,50 @@ +using System.Globalization; + +namespace SharpMUTerm.Tui; + +/// +/// Renders the bar that closes a pane's restored content: the one row telling a reader that everything +/// above it is from a previous run of the client and everything below it is live. +/// +/// Why a bar and not dimmed text. BeipMU prints "Content restored" and that is the right shape — +/// mark the boundary, not the content. Restoring is only worth doing if what comes back is the +/// game's own text with the game's own colours in it, so recolouring the restored lines to prove they +/// are restored would destroy the thing being restored. One row, drawn like the freeze bar (which +/// divides the same pane for the same kind of reason), costs a line and lies about nothing. The +/// restored lines also keep their original timestamps, so the timestamp column is a second, free answer +/// to "how old is this" for anyone who has it on. +/// +/// +/// It sits below the restored lines rather than above them, because that is where the reader's +/// eye already is: a pane bottom-anchors, so the boundary is what you land on, and "the previous +/// session ended here" is a statement about the row above it. +/// +/// Pure, so the markup is unit-testable without a terminal. +/// +internal static class RestoreBarRenderer +{ + /// How long the trailing rule is. The same 48 cells the freeze bar draws, for the same reason. + private const int RuleCells = 48; + + /// The label, kept as a constant so a test can look for the exact words a reader will see. + internal const string Label = "RESTORED"; + + /// + /// The bar for lines last written at , on an + /// already-resolved #rrggbb accent. + /// + public static string Bar(int lines, DateTimeOffset when, string accentHex) + { + ArgumentException.ThrowIfNullOrEmpty(accentHex); + + // The count and the time are both here because they answer different questions, and a reader + // asks both: "is this all of it?" (a pane that stops at the bound looks truncated, and it is — + // saying how many says so) and "how stale is this?" (an hour ago is context; a fortnight ago is + // a different game). Local time in the same 24-hour form the timestamp gutter uses. + var count = lines == 1 ? "1 line" : $"{lines} lines"; + var stamp = when.ToLocalTime().ToString("d MMM HH:mm", CultureInfo.CurrentCulture); + var rule = new string('─', RuleCells); + return $"[{accentHex}]{Glyphs.Restored} {Label}[/] " + + $"[dim]{MarkupText.Escape($"{count} from the previous session · {stamp}")} {rule}[/]"; + } +} diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index 19130590..1cd74f55 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -288,7 +288,15 @@ private sealed class SizeReport private string? _moveWindowId; private string? _moveTargetPaneId; private Edge? _moveEdge; - private readonly Dictionary _moveLetters = new(StringComparer.Ordinal); + /// + /// The digit that targets each pane in move mode, which is the pane's own ordinal — the number + /// spells and ⌥N jumps to. It was a separate a–j alphabet, so the badge on + /// a pane and the prompt beside it named the same pane two ways (MOVE Corvid → split pane 2 + /// left under a badge reading B). Only panes 1–9 get an entry: there is no tenth digit, + /// and a badge whose key does not exist is worse than no badge. A tenth pane is still a drop target + /// for the mouse. + /// + private readonly Dictionary _moveOrdinals = new(StringComparer.Ordinal); /// /// The chords the app claims globally, by the action each runs. It is the same delegate @@ -311,6 +319,13 @@ private sealed class SizeReport /// private readonly string? _logRoot; + /// + /// Where each pane's recent content is kept so a restart refills it, or null for an app that owns + /// no restore log — which is the default, and is what every test and every snapshot gets. Third of + /// the same family as and , for the same reason. + /// + private readonly RestoreLog? _restore; + /// The pane the live mouse drag is hovering, and the edge it would split — null when idle. private string? _dragTargetPaneId; private Edge? _dragEdge; @@ -364,6 +379,15 @@ private sealed class SizeReport /// and a fixture naming an absolute path is still a test reaching outside itself. /// /// + /// + /// The this app reads its panes' previous content out of and writes their + /// new content into, or null for an app that owns no restore log — which is the default, and is + /// what every test and every snapshot gets. The third member of the / + /// family, handed in for exactly their reason: this one writes a file + /// per window under the user's configuration directory, and an app that is not the live entry point + /// has no business creating those. It is also what keeps the demo scene honest — a + /// --demo-config snapshot would otherwise restore your panes into the demo's. + /// public SharpMUTermApp( AppConfiguration config, TerminalCapabilities capabilities, @@ -371,11 +395,13 @@ public SharpMUTermApp( TimeProvider? time = null, ClientDiagnostics? diagnostics = null, Action? save = null, - string? logRoot = null) + string? logRoot = null, + RestoreLog? restore = null) { _config = config; _save = save; _logRoot = string.IsNullOrWhiteSpace(logRoot) ? null : logRoot; + _restore = restore; _capabilities = capabilities; _time = time ?? TimeProvider.System; _diagnostics = diagnostics ?? ClientDiagnostics.InMemory(); @@ -555,6 +581,11 @@ public SharpMUTermApp( // desktop cells, which is exactly what a drag between panes needs. _system.ConsoleDriver.MouseEvent += OnDriverMouseEvent; RegisterGlobalShortcuts(); + + // Before the window is shown, so the first frame already has the panes' previous content in + // them — a pane that filled itself in a frame or two later would read as a glitch, and this is + // also the last moment at which no live line has arrived to be restored *above*. + RestorePreviousSession(); _system.AddWindow(_window); } @@ -1196,6 +1227,101 @@ private static Workspace ResumeOrNew(AppConfiguration config) return new Workspace(MainWindowId, "Main"); } + /// + /// Refills every pane with what it was showing when the client last ran, and marks where that ends. + /// + /// It is the content half of . That rebuilds the workspace — which + /// windows exist, which pane each sits in, which was focused — from + /// ; this refills those windows from the restore log, + /// keyed by the same window ids. Both halves are needed and neither implies the other: a client that + /// resumed its layout and showed every pane empty is what the whole feature is about. + /// + /// + /// The two halves are joined loosely, on purpose. A window in the log that the saved + /// workspace no longer holds — a spawn window whose pane was closed — is still buffered, because + /// is keyed by window and not by anything the workspace has to agree with; if + /// that channel speaks again its pane reopens with its history already in it + /// (). A window in the saved workspace with no log simply starts empty. + /// Neither is an error and neither throws, which is the property that matters most here: this runs + /// before the first frame, and a client that will not start because of a cache of old chat lines + /// would be a worse client than one that starts with an empty pane. + /// + /// + /// Nothing restored is written back. The log is fed from and + /// — the two places a world's output reaches a pane — and this + /// replay goes straight to , so a restart cannot echo its own history + /// into the log and double every pane. + /// + /// + private void RestorePreviousSession() + { + if (_restore is null || !_config.RestoreLog.Enabled) + { + return; + } + + var restoredWindows = 0; + var restoredLines = 0; + foreach (var window in _restore.Read()) + { + // A character who opted out gets their content dropped rather than merely un-drawn: an + // opt-out that left the last session's text lying in the config directory would be + // answering a different question from the one it was asked. + if (!RestoreLogWanted(_workspace.FindWindow(window.WindowId)?.SessionKey)) + { + _restore.Forget(window.WindowId); + continue; + } + + if (window.Lines.Count == 0) + { + continue; + } + + foreach (var line in window.Lines) + { + AppendWindowLine(window.WindowId, _formatter.ToMarkup(line.Line), line.Stamp); + } + + // The boundary marker carries no stamp: it did not arrive, it was drawn, and a timestamp + // gutter beside it would be claiming a time for a row the game never sent. + AppendWindowLine( + window.WindowId, + RestoreBarRenderer.Bar(window.Lines.Count, window.LastWritten, FrozenAccentHex())); + + restoredWindows++; + restoredLines += window.Lines.Count; + } + + if (restoredWindows == 0) + { + return; + } + + _diagnostics.Logger.LogInformation( + "Restored {Lines} line(s) into {Windows} pane(s) from the previous session", + restoredLines, + restoredWindows); + } + + /// + /// Whether a window owned by takes part in the restore log. An + /// unowned window — the main window before any session adopts it, the web view — is allowed: no + /// character has said otherwise, and the app-wide switch is checked by the caller. + /// + private bool RestoreLogWanted(string? sessionKey) => + sessionKey is null || CharacterFor(sessionKey)?.Logging.RestoreLog != false; + + /// + /// The configured character behind a world.character session key, or null when the key names + /// no character this configuration still holds (an anonymous connection, or one whose character has + /// since been renamed or deleted). + /// + private CharacterDefinition? CharacterFor(string sessionKey) => _config.Worlds + .SelectMany(world => world.Characters.Select(character => (Key: $"{world.Name}.{character.Name}", character))) + .FirstOrDefault(pair => string.Equals(pair.Key, sessionKey, StringComparison.Ordinal)) + .character; + /// /// Sets the demo's focused/connected character from the resumed config (snapshot chrome). It asks /// rather than reaching for the first world's first character, @@ -1495,7 +1621,7 @@ private void BindSession(WorldSession session, string? windowId = null) AppendWindowLine(windowId, _formatter.ToMarkup(line)); } - session.LinePrinted += (_, line) => OnUi(() => OnLine(windowId, line)); + session.LinePrinted += (_, line) => OnUi(() => OnLine(session, windowId, line)); session.PromptChanged += (_, _) => OnUi(UpdateStatus); // Every connect reports the automation it is running — see ReportAutomation. Hung off the state @@ -1518,7 +1644,7 @@ private void BindSession(WorldSession session, string? windowId = null) UpdateStatus(); } }); - session.SpawnLine += (_, e) => OnUi(() => OnSpawnLine(session, e.Target, e.Line)); + session.SpawnLine += (_, e) => OnUi(() => OnSpawnLine(session, e.Target, e.Pattern, e.Line)); // The status row's encoding cell is live, so it has to be repainted when the thing it reports // changes. WorldSession has already put the change in the client message log by the time this @@ -1772,6 +1898,46 @@ private void AppendWindowLine(string windowId, string markup, string? stamp = nu } } + /// + /// Records one of a world's lines in the restore log, so the pane it landed in comes back holding + /// it next launch. + /// + /// It is called from and and from nowhere else, and + /// deliberately not from even though that is the seam every + /// line goes through. Two reasons, and both are bugs avoided rather than tidiness. That seam also + /// carries the client's own chrome — /graphics, /triggers, the restore bar itself — + /// which is not session content and has no business surviving into a later run. And it carries the + /// restore replay, so logging there would have each launch re-record its own history and + /// double every pane against the bound. + /// + /// + /// The is what is stored rather than the markup it was rendered to, because + /// markup is the theme's answer and not the world's: a line logged as markup would come back frozen + /// in whatever colours were configured the day it arrived, and would have lost the palette indices, + /// the rule colour and each span's interaction on the way (see ). + /// + /// + private void RecordForRestore(WorldSession session, string windowId, string title, StyledLine line, string? stamp) + { + if (_restore is null || !_config.RestoreLog.Enabled) + { + return; + } + + // Read off the session rather than the window's recorded owner: it is the character whose line + // this is, it cannot be stale, and a session with no character (a host typed on the command + // line) has nobody to have opted out. + if (session.Character?.Logging.RestoreLog == false) + { + return; + } + + _restore.Append(windowId, title, line, stamp); + } + + /// A window's title, or its id when the workspace does not (yet) know it. + private string WindowTitle(string windowId) => _workspace.FindWindow(windowId)?.Title ?? windowId; + /// /// Puts lines of a window's buffer, starting at , /// into one output control. @@ -1878,12 +2044,6 @@ private void RepaintPanes() } } - /// The trigger pattern that routes to a spawn , for its capture line. - private string? CaptureFor(string target) => - _config.TriggerSets.SelectMany(s => s.Triggers) - .FirstOrDefault(t => string.Equals(t.Actions.SpawnTarget, target, StringComparison.Ordinal)) - ?.Pattern; - /// /// Appends a line to a window's pane and badges it unread when the reader cannot see where it landed. /// @@ -1893,9 +2053,11 @@ private void RepaintPanes() /// badge is the only thing that could tell them. /// /// - private void OnLine(string windowId, StyledLine line) + private void OnLine(WorldSession session, string windowId, StyledLine line) { - AppendWindowLine(windowId, _formatter.ToMarkup(line), StampNow()); + var stamp = StampNow(); + AppendWindowLine(windowId, _formatter.ToMarkup(line), stamp); + RecordForRestore(session, windowId, WindowTitle(windowId), line, stamp); if (!_workspace.IsCaughtUp(windowId)) { @@ -1914,16 +2076,27 @@ private void OnLine(string windowId, StyledLine line) /// which world a link clicked in a spawn window sends to by it. /// /// - private void OnSpawnLine(WorldSession session, string target, StyledLine line) + private void OnSpawnLine(WorldSession session, string target, string pattern, StyledLine line) { var existed = _workspace.FindWindow(Workspace.SpawnWindowId(target)) is not null; var window = _workspace.RouteSpawn(target, session.SessionKey); - window.CapturePattern ??= CaptureFor(target); // label the pane with the trigger that feeds it + + // Label the pane with the rule that feeds it. The pattern comes with the line rather than being + // looked up from the target: a route of "Channel $1" resolves to a different name every time, so + // finding the rule by comparing its SpawnTarget to this window's name would find nothing. + window.CapturePattern ??= pattern; + // Its owner's own name, which for a session with no character is its world's. It used to fall back on // the *main window's* title, which is a different session's name as soon as more than one is open. window.OwnerLabel ??= SessionTitle(session); PaneContentFor(window.Id, window.Title); // ensure the live control exists before buffering - AppendWindowLine(window.Id, _formatter.ToMarkup(line), StampNow()); + + // The restore log is fed here as well as in OnLine, and that is the crux of the whole feature: + // a spawn window's content never reaches WorldSession.Scrollback, so a restore built on session + // scrollback would bring the main windows back and leave every channel pane empty. + var stamp = StampNow(); + AppendWindowLine(window.Id, _formatter.ToMarkup(line), stamp); + RecordForRestore(session, window.Id, window.Title, line, stamp); // A first-seen spawn adds a tab to its pane, so rebuild; otherwise just refresh badges. if (existed) @@ -2699,6 +2872,77 @@ private void MoveFocus(PaneDirection direction) RefuseFocusMove(direction); } + /// + /// Goes to the th pane and brings it to the front — ⌥1–⌥9, and the ⌃P + /// Go to pane N entries. + /// + /// The number is the rail's number. Panes are counted in Layout.Panes order + /// (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: + /// InputCoordinator.HandleAltInput selects among top-level windows by index, and unlike the + /// move and resize handlers beside it, it is not gated on IsMovable/IsResizable — so + /// Movable(false) did not switch it off. It is reached only from the fall-through taken when + /// the active window did not handle the key, and a global shortcut (which this is, registered from + /// ) runs before the window is offered the key at all. All nine + /// digits are claimed for that reason, in range or not: an out-of-range ⌥7 reports here and stops, + /// rather than falling through to a window selector that would silently do something else. + /// + /// + /// Zoom follows. "Bring it to the forefront" over a zoomed workspace means the pane you named + /// is the one filling the screen, so an existing zoom is carried to the target + /// () instead of leaving the selection — and the + /// session, and the caret — on a pane that is not rendered. The zoom is not started and not + /// cancelled; ⌃B z still means what it meant. + /// + /// + private void JumpToPane(int number) + { + var panes = _workspace.Layout.Panes; + if (number < 1 || number > panes.Count) + { + // Never silent. A digit with no pane behind it is the commonest way to press this chord + // wrong, and the count is the whole answer — ⌃P's Go to pane entries list exactly the panes + // that exist, which is where a reader goes next. + Notice( + panes.Count == 1 + ? "the workspace has one pane — ⌃B | and ⌃B - split it" + : $"there is no pane {number} — this workspace has {panes.Count}", + MessageSeverity.Warning, + $"⌥{number}"); + return; + } + + var target = panes[number - 1].Id; + FocusPane(target); + + // After the focus move, so the zoom lands on the pane that is now selected. Rebuilding is what + // realises the change; FocusPane's own path only syncs the view to a pane it did not move. + if (_workspace.Layout.CarryZoomToFocused()) + { + RebuildPaneArea(); + } + } + /// /// Moves pane selection and brings the rest of the app in line with it, by activating the /// pane's own active window — the same path a tab click and a rail click take (see @@ -2982,8 +3226,17 @@ private string ScrollbackStatus() // three-cell gap, so a wordier phrasing wrapped the status line onto a second row at 120 columns // — and a status line that grows a row takes one off the workspace (SyncInputHeights counts the // chrome), which is a pane getting shorter because you scrolled it. + // + // The *number* is the same hazard and was not guarded: it counts lines below the viewport, which + // grows unbidden from the wire while a reader sits in their scrollback, so the segment widened at + // 9 → 10 and again at 99 → 100 with nobody touching a key. At 80 columns that second step took the + // row from 80 cells to 81, it wrapped, and every pane lost a row — which per-pane NAWS then + // re-announced to every connected server, reflowing the game's output. It is now written into a + // reserved field capped the way the sidebar's unread badge is (RailRenderer.UnreadField), so the + // segment is the same width whatever it says. var below = Math.Max(1, panel.TotalContentHeight - panel.ViewportHeight - panel.VerticalScrollOffset); - return $"[#e5c07b]{Glyphs.Scrollback} scrollback[/] [dim]{below} · ⌃End live[/]"; + var distance = UnreadBadge.Format(below).PadLeft(UnreadBadge.FieldWidth); + return $"[#e5c07b]{Glyphs.Scrollback} scrollback[/] [dim]{distance} · ⌃End live[/]"; } /// @@ -3239,7 +3492,19 @@ private void RegisterGlobalShortcuts() // (AnsiInputParser.ProcessEscape), so unlike most of the alphabet's Ctrl chords it genuinely // arrives. A lone Escape is flushed on its own after UnixStdinReader's 50 ms timeout, so // pressing Esc and later typing an r is two keys and not this one. - return claim.Key == ConsoleKey.R ? () => { Reconnect(); return true; } : null; + if (claim.Key == ConsoleKey.R) + { + return () => { Reconnect(); return true; }; + } + + // ⌥1–⌥9 go to the numbered pane. Same delivery story as Alt+R and one digit over: the + // terminal writes ESC + the digit and the parser reads it as that digit with Alt set. + if (MacroKeys.PaneJumpNumber(claim.Key) is { } number) + { + return () => { JumpToPane(number); return true; }; + } + + return null; } if (claim.Modifiers != ConsoleModifiers.Control) @@ -3935,7 +4200,10 @@ private IReadOnlyList BuildRail() { for (var i = 0; i < panes.Count; i++) { - paneLabels[panes[i].Id] = $"pane {i + 1}"; + // 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); } } @@ -3959,7 +4227,8 @@ private IReadOnlyList BuildRail() Connected: connected.Contains(key), Active: active, Unread: windows.Sum(w => w.Unread), - windows)); + windows, + Pane: CharacterPaneLabel(key, paneLabels))); } worlds.Add(new RailWorld(world.Name, world.Host, world.Port, accent, characters)); @@ -4008,6 +4277,81 @@ private IReadOnlyList BuildRailWindows( return windows; } + /// + /// Which pane a character's session is in — the pane N its rail row carries, whether or not + /// it is the active character. + /// + /// This is what makes ⌥N usable as a character switch. The chord has always been global + /// ( indexes the workspace's one pane tree, not the active character's + /// windows), but the rail lists window rows for the active character only — so a reader looking at + /// Ann could see pane 1 and nothing else, while ⌥2 and ⌥3 sat on the screen holding Bob and + /// Cal. The pane number was global; only the way to read it was not. + /// + /// + /// The character row rather than more window rows, because 's + /// owner filter is load-bearing: a window row under a character means that window is that + /// character's, and listing everyone's windows everywhere would take that reading away for the sake + /// of a fact one column can carry. One row per character already exists, it is exactly the row a + /// user clicks to reach that character, and the answer belongs on it. + /// + /// + /// The session window when there is one, else any window the character owns that a pane still holds + /// — a character with a spawn window open and its main window closed is still somewhere, and the row + /// should say where rather than go blank. Null when the workspace has one pane, because + /// is empty then and "which of the one pane" is not information. + /// + /// + /// + /// What the sidebar's hosting column calls pane : the chord that goes there, + /// ⌥3, rather than the words pane 3. + /// + /// The sidebar's width comes out of the pane area and is reported to every connected session over + /// NAWS, so four cells on every row is four cells off every pane. This is the one surface where the + /// noun is redundant — the column's position already says "where this is" — and dropping it pays for + /// itself twice: it is shorter, and ⌥3 names the key that goes there, which pane 3 left + /// the reader to infer. + /// + /// + /// It is not a second spelling of the number. still says pane N + /// everywhere the noun carries meaning — split pane 2 left, Go to pane 3, there is no + /// pane 7 — and both read the same ordinal. What changed is the abbreviation, not the count. + /// + /// + /// The sigil is also what keeps the column legible beside the unread badge. A bare 3 after a + /// count of 2 is 2 3, two numbers with nothing to tell them apart; the word used to do + /// that work, and something has to. + /// + /// + private static string RailPaneLabel(int ordinal) => $"⌥{ordinal}"; + + private string? CharacterPaneLabel(string sessionKey, IReadOnlyDictionary paneLabels) + { + if (paneLabels.Count == 0) + { + return null; + } + + string? fallback = null; + foreach (var window in _workspace.Windows) + { + if (!string.Equals(window.SessionKey, sessionKey, StringComparison.Ordinal) || + _workspace.Layout.FindWindow(window.Id) is not { } pane || + paneLabels.GetValueOrDefault(pane.Id) is not { } label) + { + continue; + } + + if (window.Kind == WindowKind.Main) + { + return label; + } + + fallback ??= label; + } + + return fallback; + } + /// /// What a window row is called in the rail. A character's own session window reads /// main; everything else keeps its title (a spawn target's name, the web page's title). @@ -4191,6 +4535,25 @@ internal bool DispatchCommand(string id) return true; } + // A numbered pane entry runs the chord's own action, refusal and all — the surface is another + // door onto ⌥N, not a second way of focusing a pane. Parsed rather than switched on because the + // catalog emits one id per live pane and there is no fixed set of them. + if (id.StartsWith(CommandIds.PanePrefix, StringComparison.Ordinal)) + { + if (int.TryParse( + id[CommandIds.PanePrefix.Length..], + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out var paneNumber)) + { + JumpToPane(paneNumber); + return true; + } + + RefuseCommand($"{id} does not name a pane number"); + return false; + } + // A settings entry opens the very screen its F-key opens, through the same Toggle: the palette // is another door onto that key, not a second way of building the screen. if (id.StartsWith(ScreenCommandPrefix, StringComparison.Ordinal)) @@ -4272,6 +4635,9 @@ internal bool DispatchCommand(string id) case "term:messages": _messageLog.Toggle(); return true; + case "term:restore-purge": + PurgeRestoreLog(); + return true; case "term:history": ToggleHistorySearch(); return true; @@ -4459,6 +4825,34 @@ private async Task DisconnectAsync(WorldSession session) /// *** Logging to … over a client that opened no file is the defect this gate exists to stop. /// /// + /// + /// ⌃P ▸ Purge the restore log: deletes every window's saved content, now. + /// + /// It is "forget what is on disk", not "switch the feature off" — a pane that goes on printing + /// starts a fresh file on its next line, and the standing preference is F9's per-character + /// restore row. Nor does it blank the panes: what has already been drawn is on your screen + /// and clearing it would be answering a question nobody asked, while leaving it would be leaving a + /// file. The count comes back so the entry reports what it actually did rather than claiming + /// success at nothing, which is the difference between a purge and a placebo. + /// + /// + private void PurgeRestoreLog() + { + if (_restore is null) + { + RefuseCommand("nothing to purge — this client owns no restore log"); + return; + } + + var removed = _restore.Purge(); + Notice( + removed == 0 + ? "restore log purged — there was nothing saved" + : $"restore log purged — {removed} window{(removed == 1 ? string.Empty : "s")} forgotten", + MessageSeverity.Info, + "⌃P"); + } + private void StartLogging() { if (_logRoot is null) @@ -5003,7 +5397,18 @@ private ImageControl WebImageControlFor(WebImageBlock block) return control; } - /// The content control for a window, created (with link routing) on first use. + /// + /// The content control for a window, created (with link routing) on first use — and filled from + /// that window's line buffer if one is already there. + /// + /// The fill is what makes a control and its buffer the same thing at every moment rather than only + /// after the next re-feed. It matters because a buffer can now outlive every control for its window + /// and predate the first one: the restore log fills for a window the saved + /// workspace no longer holds, and the control only comes into being later, when a capture reopens + /// that spawn window. Without this the pane would open holding one line — the one that reopened it + /// — with its restored history sitting invisibly in the buffer behind it. + /// + /// private MarkupControl PaneContentFor(string id, string title) { if (_panes.TryGetValue(id, out var existing)) @@ -5014,6 +5419,12 @@ private MarkupControl PaneContentFor(string id, string title) var control = new MarkupControl(new List()); control.LinkClicked += (_, e) => OnLinkClicked(id, e.Url); _panes[id] = control; + + if (_lines.TryGetValue(id, out var buffer) && buffer.Count > 0) + { + FeedRange(control, buffer, 0, buffer.Count); + } + return control; } @@ -5363,8 +5774,8 @@ private IWindowControl BuildLayoutNode(SharpMUTerm.Core.Workspaces.LayoutNode no } return OnSurface( - _moveMode && _moveLetters.TryGetValue(pane.Id, out var letter) - ? BuildMovePane(pane, letter) + _moveMode && _moveOrdinals.TryGetValue(pane.Id, out var ordinal) + ? BuildMovePane(pane, ordinal) : BuildPaneTabs(pane), pane.Id); } @@ -5906,6 +6317,13 @@ internal void SimulatePaste(string text) /// Every pane's id in layout order, for the tests that walk a geometry end to end. internal IReadOnlyList PaneIds => _workspace.Layout.Panes.Select(p => p.Id).ToArray(); + /// + /// The zoomed pane's id, or null when nothing is zoomed. Internal because the ordinal movers carry a + /// zoom with them, and "the pane jumped to is the one rendered" is a claim about this field as much as + /// about the frame. + /// + internal string? ZoomedPaneId => _workspace.Layout.ZoomedPaneId; + /// The pane hosting a window, or null when no pane does. Internal so a test can find the pane /// a split put a window in rather than assuming which side it landed on. internal string? PaneIdOf(string windowId) => _workspace.Layout.FindWindow(windowId)?.Id; @@ -6336,7 +6754,15 @@ private void RunPrefixCommand(char key) var tabs = _workspace.Layout.FocusedPane.Tabs.Count; if (_workspace.Layout.ReorderActiveTab(key == '<' ? -1 : 1)) { - RefreshTabTitles(); + // The strip has to be rebuilt, not retitled. A reorder changes the *order* of the + // pane's tabs, and `TabControl` has no way to move a page — `TabPages` is a copy and + // the only mutators are Add/Insert — so `RefreshTabTitles`, which repaints each page + // by its own `Tag`, left the strip exactly as it was. That is not a cosmetic lag: the + // model then holds an order the screen does not, and the *refusal* is read against the + // model. Reordering the middle of three tabs looked like nothing happened, and the + // next press — genuinely at the end, in a strip still showing it in the middle — said + // "the tab is already at that end of the strip", which is how it was reported. + RebuildPaneArea(); break; } @@ -6441,32 +6867,36 @@ internal WorldSession BindWorldWithoutConnecting(WorldDefinition world) } /// - /// Enters move mode (⌃B m): the active window lifts, every pane dims and shows a target letter - /// (a–j), and the status bar becomes the move prompt. a–j pick the destination, arrows toggle an - /// edge (split there), ⏎ commits, Esc cancels. + /// Enters move mode (⌃B m): the active window lifts, every pane dims and shows its own number, and + /// the status bar becomes the move prompt. 1–9 pick the destination, arrows toggle an edge (split + /// there), ⏎ commits, Esc cancels. + /// + /// The digits are the pane ordinals, so the badge on a pane, the pane N the prompt names as + /// the target, the sidebar's hosting column and ⌥N are all one numbering. + /// /// private void EnterMoveMode() { _moveWindowId = ActiveWindowId(); _moveMode = true; _moveTargetPaneId = null; - _moveLetters.Clear(); - var letter = 'a'; + _moveOrdinals.Clear(); + var ordinal = 1; foreach (var pane in _workspace.Layout.Panes) { - if (letter > 'j') + if (ordinal > CommandIds.PaneJumpDigits) { break; } - _moveLetters[pane.Id] = letter++; + _moveOrdinals[pane.Id] = ordinal++; } RebuildPaneArea(); SetStatus(MovePromptMarkup(), displace: true); } - /// Handles a key while in move mode: pick pane (a–j), edge (arrows), commit (⏎), cancel (Esc). + /// Handles a key while in move mode: pick pane (1–9), edge (arrows), commit (⏎), cancel (Esc). private void HandleMoveKey(KeyPressedEventArgs e) { e.Handled = true; @@ -6495,10 +6925,10 @@ private void HandleMoveKey(KeyPressedEventArgs e) return; } - if (ch is >= 'a' and <= 'j') + if (ch is >= '1' and <= '9') { - // Only retarget on a real match — an unmapped letter must not clear the current target. - var match = _moveLetters.FirstOrDefault(kv => kv.Value == ch); + // Only retarget on a real match — a digit past the last pane must not clear the current target. + var match = _moveOrdinals.FirstOrDefault(kv => kv.Value == ch - '0'); if (match.Key is not null) { _moveTargetPaneId = match.Key; @@ -6531,7 +6961,7 @@ private void ExitMoveMode(bool commit) _moveWindowId = null; _moveTargetPaneId = null; _moveEdge = null; - _moveLetters.Clear(); + _moveOrdinals.Clear(); RebuildPaneArea(); UpdateStatus(); } @@ -6541,7 +6971,7 @@ private string MovePromptMarkup() { var name = _moveWindowId is { } id && _workspace.FindWindow(id) is { } w ? Escape(w.Title) : "window"; return $"[#e5c07b]MOVE[/] [bold]{name}[/] [dim]→[/] [#00f5b7]{DropLabel(_moveTargetPaneId, _moveEdge)}[/]" - + " [dim]a–j pane · ←↑↓→ edge · ⏎ commit · Esc cancel[/]"; + + " [dim]1–9 pane · ←↑↓→ edge · ⏎ commit · Esc cancel[/]"; } /// Human-readable description of a pending drop, for the move prompt and drag preview. @@ -6563,7 +6993,24 @@ private string DropLabel(string? paneId, Edge? edge) }; } - /// The rail's friendly name for a pane ("main" for the first, "pane N" after it). + /// + /// The name every surface in this client gives a pane: pane N, counting + /// creation order — from one. + /// + /// The number a pane wears is its position in that list, so it does not move while the pane is open + /// and it closes up behind a pane that goes away. Under the tree order this used to count in, a pane + /// created to the left of pane 2 made it pane 3 without the user having touched it, and ⌥2 quietly + /// went somewhere else. + /// + /// + /// It used to call the first pane main — the spelling the rail's hosting column abandoned + /// because ▪ main main put two meanings in one line, the window named main beside + /// the pane also called main. The move and drag overlays kept it, so the same pane was pane 1 + /// in the sidebar and main under the cursor. That was survivable while nothing depended on the + /// number; ⌥1 is a chord that lands on the pane a label names, and two spellings of one pane is + /// exactly the mismatch that makes such a chord read as broken. + /// + /// private string PaneLabel(string paneId) { var index = 0; @@ -6571,7 +7018,7 @@ private string PaneLabel(string paneId) { if (pane.Id == paneId) { - return index == 0 ? "main" : $"pane {index + 1}"; + return $"pane {index + 1}"; } index++; @@ -6858,14 +7305,14 @@ private void OnUiThread(Action action) _system.EnqueueOnUIThread(action); } - /// A pane rendered as a move-mode target: a big letter over the dimmed window list. - private IWindowControl BuildMovePane(PaneNode pane, char letter) + /// A pane rendered as a move-mode target: its own number over the dimmed window list. + private IWindowControl BuildMovePane(PaneNode pane, int ordinal) { var selected = pane.Id == _moveTargetPaneId; var color = selected ? "#00f5b7" : "#e5c07b"; var lines = new List { string.Empty, string.Empty }; lines.Add($" [bold {color}]▛▀▀▜[/]"); - lines.Add($" [bold {color}]▌ {char.ToUpperInvariant(letter)} ▐[/]"); + lines.Add($" [bold {color}]▌ {ordinal} ▐[/]"); lines.Add($" [bold {color}]▙▄▄▟[/]"); lines.Add(string.Empty); if (selected) @@ -6900,6 +7347,15 @@ private void CyclePane() // command line talks to and its drafts the ones in the bars. The keyboard stays where it was — // typing belongs to the armed command line, wherever the selection is. ActivateFocusedWindow(); + + // An existing zoom follows, exactly as it does for ⌥N: both are *ordinal* movers, and a zoomed + // workspace realises one pane, so cycling without this left the selection, the session the bar + // talks to and the caret on a pane that was not on screen. (The directional movers cannot have + // this: while one pane is realised there is no neighbour to ask for, and ⌃← refuses out loud.) + if (_workspace.Layout.CarryZoomToFocused()) + { + RebuildPaneArea(); + } } /// Closes the focused pane's active window (Ctrl+W). The main window can't be closed. diff --git a/src/SharpMUTerm.Tui/TabTitles.cs b/src/SharpMUTerm.Tui/TabTitles.cs index e224c9bb..dadf8511 100644 --- a/src/SharpMUTerm.Tui/TabTitles.cs +++ b/src/SharpMUTerm.Tui/TabTitles.cs @@ -9,12 +9,21 @@ namespace SharpMUTerm.Tui; /// the one currently focused. Pure so it can be unit-tested without a terminal. /// /// -/// SharpConsoleUI renders tab titles as plain text, so the design's per-character accent -/// colour dot can't ride on the label — the traceability signal that survives is the -/// cross-character marker, which this emits. Accent colour still shows in the rail; the focused -/// pane is marked by the this emits plus the lit plane it is painted on -/// (), because a pane cannot be given a border without changing its -/// rectangle and so the per-pane NAWS size it reports. +/// A tab title is markup, not plain text. This file used to say the opposite, and the claim +/// had a cost: it is why the unread count went out untinted and why a window title was never escaped. +/// TabControl.Rendering runs each label through MarkupParser.Parse, and every width it is +/// measured by — the header paint, the strip's desired width, and the click hit test that decides which +/// tab and which × a press landed on — is MarkupParser.StripLength. So a colour tag here +/// costs no cells and moves no hit test, which is what makes the activity tint affordable on a +/// surface where a cell may not be spent. +/// It also means configured and world-supplied text has to be escaped on the way in +/// (): a window titled [Chat] — or a web view titled from the page +/// it loaded — would otherwise have that eaten as a tag by the parser and by the hit test alike. +/// The focused pane is marked by the this emits plus the lit plane it is painted +/// on (), because a pane cannot be given a border without changing +/// its rectangle and so the per-pane NAWS size it reports. The is deliberately left +/// outside the activity tint: focus and activity are independent, a tab can have both, and a +/// marker that changed colour when a line arrived would be reporting the wrong fact. /// The close affordance is deliberately not here. A written into the label is /// just text: the framework's tab hit test sees it as part of the title and a click on it merely /// selects the tab. The real close button is TabPage.IsClosable, which the framework draws @@ -43,10 +52,15 @@ public static string For( // traceable to its character once dragged into another pane. A character's own main window // needs no prefix — the focused-character context already identifies it. var owner = window.Kind != WindowKind.Main && !string.IsNullOrEmpty(window.OwnerLabel) - ? window.OwnerLabel + " - " + ? MarkupText.Escape(window.OwnerLabel) + " - " : string.Empty; - var unread = window.Unread > 0 ? $" ({window.Unread})" : string.Empty; + // Capped the way the sidebar's badge is, from the same formatter. Not for the sidebar's reason — + // a tab strip is laid out along a row the framework fills to the pane's edge, so a label that grows + // moves the tabs beside it and never the pane's own rectangle. The cap is here so the two surfaces + // reading one number cannot print different answers, and so an unbounded count arriving from the + // wire cannot push a pane's other tabs off the end of a narrow strip. + var unread = window.Unread > 0 ? $" ({UnreadBadge.Format(window.Unread)})" : string.Empty; var pen = window.HasUnsentInput ? $" {Glyphs.Draft}" : string.Empty; // ⌁ marks a window owned by a character other than the focused one, so a pane holding @@ -59,6 +73,12 @@ public static string For( var focus = focusedPane ? Glyphs.FocusedPane + " " : string.Empty; - return focus + owner + window.Title + unread + pen + cross; + // The activity tint. It covers the window's name and its count and stops there: the ▌ ahead of it + // is the focus marker and the ✎ / ⌁ behind it are other facts, and a signal that recoloured them + // would be claiming they had changed too. Zero cells — see the remarks on this class. + var named = owner + MarkupText.Escape(window.Title) + unread; + var body = window.Unread > 0 ? $"[{UnreadBadge.Tint}]{named}[/]" : named; + + return focus + body + pen + cross; } } diff --git a/src/SharpMUTerm.Tui/TriggersScreenRenderer.cs b/src/SharpMUTerm.Tui/TriggersScreenRenderer.cs index 6143600d..e7f8abbf 100644 --- a/src/SharpMUTerm.Tui/TriggersScreenRenderer.cs +++ b/src/SharpMUTerm.Tui/TriggersScreenRenderer.cs @@ -648,7 +648,10 @@ private static List BuildEditor( "[dim]match pattern (regex)[/]", $" {ScreenChrome.Field(Escape(trigger.Pattern), pattern)}", string.Empty, - Heading("route to", route), + // The caption is here as well as over the actions section because the route expands captures + // too: "Channel $1" against ^<(.+?)> is one rule feeding a pane per channel, and a reader who + // has only seen the caption below would have no reason to try it here. + Heading("route to", route, ScreenChrome.ReadOnly("· $1..$9 insert captures")), // The window is a name you type, not one of a fixed set — the spawn windows that exist // are defined by what routes to them, so a closed list could only ever re-use one. It is diff --git a/src/SharpMUTerm.Tui/UnreadBadge.cs b/src/SharpMUTerm.Tui/UnreadBadge.cs new file mode 100644 index 00000000..1f2da74f --- /dev/null +++ b/src/SharpMUTerm.Tui/UnreadBadge.cs @@ -0,0 +1,39 @@ +using System.Globalization; + +namespace SharpMUTerm.Tui; + +/// +/// The one spelling of an unread count, shared by the two surfaces that draw one: the connection rail's +/// per-row badge () and a pane's tab label (). +/// +/// It is shared because the sidebar and the tab strip are two views of a single number — +/// WorkspaceWindow.Unread — and two formatters would eventually disagree about it. They already +/// did: the rail capped at while the tab printed the raw integer, so a busy channel +/// read 99+ in the sidebar and (4127) on its tab, which is two answers to one question. +/// +/// +internal static class UnreadBadge +{ + /// The largest count written in full; above it the badge reads 99+ and stops growing. + internal const int Max = 99; + + /// Cells a badge can occupy, which is the width of 99+. See . + internal const int FieldWidth = 3; + + /// + /// The markup colour a count is drawn in — the app accent, on both surfaces. + /// + /// One colour on purpose. It is not the focus colour and cannot be mistaken for it: focus is said + /// entirely in backgrounds drawn from the theme's own chrome family + /// ( behind a pane, + /// behind the focused pane's active tab chip), while this is a foreground and the one hue in + /// the workspace that no plane is ever painted in. So the two cues are orthogonal — a tab can be + /// focused, unread, both or neither, and each of the four states reads distinctly. + /// + /// + internal const string Tint = ScreenPalette.Accent; + + /// A count as it is written, capped at . + internal static string Format(int unread) => + unread > Max ? $"{Max}+" : unread.ToString(CultureInfo.InvariantCulture); +} diff --git a/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs b/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs index d7fc5c42..68cca399 100644 --- a/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs +++ b/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs @@ -99,6 +99,16 @@ internal static class WorldsScreenRenderer internal const int LogDirectoryField = 6; + /// + /// — whether this character's panes come back holding their + /// previous session's content. Appended after the two log rows rather than inserted among them, + /// because it is the third answer to "what does this client write down about me" and reads in that + /// order: what a transcript is, where it goes, and then whether the panes remember. It is a field + /// and not a toggle for the same reason is — a character row draws no + /// checkbox, so a value on the row's toggle would have no affordance at all. + /// + internal const int RestoreLogField = 7; + /// /// The trigger-set row's field ordinals. A set's name leads, as every list row's does — and here it /// is more than a label: a character opts into automation by name, so renaming a set is a @@ -282,6 +292,33 @@ internal static string HeaderLine( private static readonly string[] StartupChoices = { StartupOn, StartupOff }; + /// + /// The row for . It says restore and not "restore + /// log", because the two rows above it already carry the word log and mean the transcript by + /// it; a third row wearing it would read as a third transcript setting, which is exactly what this + /// is not. The note beside it is what carries the meaning. + /// + internal const string RestoreLabel = "restore"; + + /// + /// What the restore row says it does. Short because the CHARACTER panel is 48 cells and + /// CharField has already spent fourteen of them on the indent and the label column — the + /// same budget and are written to, and the one + /// a longer sentence here quietly overran until ScreenReadOnlyTests caught it. + /// + internal const string RestoreNote = "panes refill after a restart"; + + /// The default: the panes come back. + internal const string RestoreOn = "on"; + + /// + /// Opted out. It reads off to match at start and the world's keepalive row — + /// the screen's other "this feature is simply not on" values — and is drawn in the same muted ink. + /// + internal const string RestoreOff = "off"; + + private static readonly string[] RestoreChoices = { RestoreOn, RestoreOff }; + /// /// The screen's four navigable panes, in ⇥ order: the WORLDS list (no checkbox on a world's row, /// but ⏎ opens the world's own fields — the ones the detail column lists), the selected world's @@ -367,7 +404,12 @@ internal static ScreenModel Model( v => c.ConnectAtStartup = string.Equals(v, StartupOn, StringComparison.OrdinalIgnoreCase), StartupChoices), ScreenField.Enumeration("log", () => c.Logging.Format, v => c.Logging.Format = v), - ScreenField.Optional("log folder", () => c.Logging.Directory, v => c.Logging.Directory = v))) + ScreenField.Optional("log folder", () => c.Logging.Directory, v => c.Logging.Directory = v), + ScreenField.Choice( + RestoreLabel, + () => c.Logging.RestoreLog ? RestoreOn : RestoreOff, + v => c.Logging.RestoreLog = string.Equals(v, RestoreOn, StringComparison.OrdinalIgnoreCase), + RestoreChoices))) .Concat(CharacterButtons(world, selectedCharacter)) .ToArray(); @@ -879,9 +921,10 @@ private static string Checkbox(string label, bool value, string hint) /// /// The character form — labels left-aligned with their values, one field per row. The editable ones /// are the character row's own fields (name, password, connect line, on-connect, at start, - /// then the two log values) and are the only seven drawn in a field well. The other two deliberately - /// are not: login is derived from the fields above it and there is nothing there to - /// set, and the session line is a report of what the connection is doing rather than a setting. + /// the two log values, then restore) and are the only eight drawn in a field well. The other + /// two deliberately are not: login is derived from the fields above it and there is + /// nothing there to set, and the session line is a report of what the connection is doing rather + /// than a setting. /// /// A row here draws no checkbox at all (), so every settable thing /// about a character is a field on this form and there is nothing Space can reach. That used not to @@ -977,12 +1020,22 @@ internal static List FormColumn( selectedCharacter, LogDirectoryField, pane: CharactersPane)), + CharField(RestoreLabel, Field( + character.Logging.RestoreLog + ? $"[{Value}]{RestoreOn}[/]" + : $"[{Label}]{RestoreOff}[/]", + cursor, + selectedCharacter, + RestoreLogField, + pane: CharactersPane)), + CharField(string.Empty, $"[{Label}]{RestoreNote}[/]"), }; - // The log format is the second-to-last row of this block, so its list is drawn upward — the one - // case on these screens where a downward list would have nowhere to go. The block's height is a - // grid row in WorldsScreenView, which is the other half of why the list overlays rather than - // pushes: growing this list would resize the pane and shove the whole screen about. + // The log format and `restore` both sit near the foot of this block, so their lists are drawn + // upward — the one place on these screens where a downward list would have nowhere to go, and + // Choices picks the direction from the room it has rather than being told. The block's height + // is a grid row in WorldsScreenView, which is the other half of why a list overlays rather than + // pushes: growing one would resize the pane and shove the whole screen about. return ScreenChrome.Choices(form, cursor.Edit, CharDetailColumnWidth); } diff --git a/tests/SharpMUTerm.Core.Tests/Automation/TriggerEngineTests.cs b/tests/SharpMUTerm.Core.Tests/Automation/TriggerEngineTests.cs index a16aabf6..45096b41 100644 --- a/tests/SharpMUTerm.Core.Tests/Automation/TriggerEngineTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Automation/TriggerEngineTests.cs @@ -77,7 +77,101 @@ public async Task SpawnTarget_IsCollected() var engine = new TriggerEngine(); engine.Add(new Trigger { Pattern = "chat", Actions = new TriggerActions { SpawnTarget = "Chat" } }); var result = engine.Process(Line("[chat] hi")); - await Assert.That(result.SpawnTargets).Contains("Chat"); + await Assert.That(result.SpawnTargets.Select(s => s.Target)).Contains("Chat"); + } + + /// + /// One rule, a pane per channel. The route expands $1 like rewrite and respond always + /// have, so ^<(.+?)> routing to Channel $1 sends each channel's lines to its own + /// window. Without this a rule can only ever feed one statically-named pane, and a client with a + /// hundred channels needs a hundred near-identical rules. + /// + [Test] + [Arguments(" Ann waves", "Channel Public")] + [Arguments(" Bob asks", "Channel Newbie")] + [Arguments(" Cal notes", "Channel Admin Chat")] + public async Task SpawnTarget_ExpandsCaptureGroups(string line, string expected) + { + var engine = new TriggerEngine(); + engine.Add(new Trigger + { + Pattern = "^<(.+?)>", + Actions = new TriggerActions { SpawnTarget = "Channel $1" }, + }); + + var result = engine.Process(Line(line)); + + await Assert.That(result.SpawnTargets.Select(s => s.Target)).Contains(expected); + } + + /// + /// The rule's pattern rides with the route, because the destination no longer identifies the rule: + /// Channel $1 resolves to a different name every time, so a consumer looking the rule up by + /// comparing it to the window's name would find nothing for any dynamic pane. + /// + [Test] + public async Task SpawnRoute_CarriesThePatternOfTheRuleThatRoutedIt() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger + { + Pattern = "^<(.+?)>", + Actions = new TriggerActions { SpawnTarget = "Channel $1" }, + }); + + var result = engine.Process(Line(" Ann waves")); + + await Assert.That(result.SpawnTargets.Single().Pattern).IsEqualTo("^<(.+?)>"); + } + + /// + /// A resolved name becomes a window id, a tab title and a sidebar row, built out of whatever the + /// server sent — so unlike a rewrite (text on a line) it is refused when it cannot be a name. The + /// line still prints; it is simply not routed. + /// + [Test] + [Arguments("<> hi", "$1", "an empty capture cannot name a window")] + [Arguments("< > hi", "$1", "a capture of nothing but spaces cannot name a window either")] + [Arguments("<> hi", "Channel $1", "a capture carrying a control character would corrupt every surface that draws it")] + [Arguments(" hi", "$3", "a template naming a group the pattern does not have is malformed")] + [Arguments(" hi", "Channel $2", "Regex.Result leaves an out-of-range group reference as literal text, so without a guard this opens a pane named \"Channel $2\"")] + public async Task SpawnTarget_ThatCannotNameAWindow_IsNotRouted(string line, string template, string because) + { + var engine = new TriggerEngine(); + engine.Add(new Trigger { Pattern = "^<(.*?)>", Actions = new TriggerActions { SpawnTarget = template } }); + + var result = engine.Process(Line(line)); + + await Assert.That(result.SpawnTargets).IsEmpty().Because(because); + } + + /// A capture longer than a name has any business being does not open a pane. + [Test] + public async Task SpawnTarget_LongerThanTheCeiling_IsNotRouted() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger { Pattern = "^<(.*?)>", Actions = new TriggerActions { SpawnTarget = "$1" } }); + + var atTheCeiling = new string('a', TriggerEngine.MaxTargetLength); + var overIt = new string('a', TriggerEngine.MaxTargetLength + 1); + + await Assert.That(engine.Process(Line($"<{atTheCeiling}> hi")).SpawnTargets).IsNotEmpty(); + await Assert.That(engine.Process(Line($"<{overIt}> hi")).SpawnTargets).IsEmpty(); + } + + /// + /// A route with no $ in it is unchanged — the overwhelmingly common case, and the one every + /// existing configuration is written in. + /// + [Test] + public async Task SpawnTarget_WithoutCaptures_IsUnchanged() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger { Pattern = "^<(.+?)>", Actions = new TriggerActions { SpawnTarget = "Chat" } }); + + var result = engine.Process(Line(" Ann waves")); + + await Assert.That(result.SpawnTargets.Single().Target).IsEqualTo("Chat"); } [Test] diff --git a/tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs b/tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs index f4553d06..139fe409 100644 --- a/tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Commands/CommandCatalogTests.cs @@ -48,6 +48,54 @@ public async Task StatefulCommands_ReadCurrentValue() await Assert.That(loggingOn.Any(c => c.Title == "Resume scrollback")).IsTrue(); } + /// + /// The numbered pane entries: one per pane that exists, in Panes order (which is the order the + /// shell's sidebar numbers them in), and only when there is more than one pane. The first nine carry + /// their chord; a tenth pane has none, and an entry naming a key that does something else would be + /// worse than a bare one. + /// + [Test] + public async Task NumberedPaneEntries_AppearOnlyOnASplit_AndOnlyTheFirstNineCarryAChord() + { + var one = CommandCatalog.Build(new Workspace(), Characters, null, new CommandContext()); + await Assert.That(one.Any(c => c.Id.StartsWith(CommandIds.PanePrefix, StringComparison.Ordinal))) + .IsFalse(); + + var ws = new Workspace(); + for (var i = 0; i < 10; i++) + { + ws.RouteSpawn($"w{i}"); + } + + // Ten panes. A split moves the focused pane's *other* tabs into the new one and leaves focus + // where it was, so the pane still holding a pile of tabs is the newest — focus that one to split + // again, or the second split has nothing to pull out. + while (ws.Layout.Panes.Count <= CommandIds.PaneJumpDigits) + { + ws.Layout.Focus(ws.Layout.Panes[^1].Id); + if (!ws.Layout.SplitFocused(SplitDirection.Row)) + { + break; + } + } + + var panes = ws.Layout.Panes.Count; + await Assert.That(panes).IsGreaterThan(CommandIds.PaneJumpDigits); + + var entries = CommandCatalog.Build(ws, Characters, null, new CommandContext()) + .Where(c => c.Id.StartsWith(CommandIds.PanePrefix, StringComparison.Ordinal)) + .ToList(); + + await Assert.That(entries.Count).IsEqualTo(panes); + for (var n = 1; n <= panes; n++) + { + var entry = entries[n - 1]; + await Assert.That(entry.Id).IsEqualTo(CommandIds.Pane(n)); + await Assert.That(entry.Title).IsEqualTo($"Go to pane {n}"); + await Assert.That(entry.Subtitle).IsEqualTo(n <= CommandIds.PaneJumpDigits ? $"⌥{n}" : null); + } + } + [Test] public async Task Catalog_CoversEveryGroup() { diff --git a/tests/SharpMUTerm.Core.Tests/Text/RestoreLogTests.cs b/tests/SharpMUTerm.Core.Tests/Text/RestoreLogTests.cs new file mode 100644 index 00000000..0bb94f88 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Text/RestoreLogTests.cs @@ -0,0 +1,495 @@ +using SharpMUTerm.Core.Text; + +namespace SharpMUTerm.Core.Tests.Text; + +/// +/// The kept, per-window record of pane content that refills the panes after a restart: that a window's +/// lines come back under the window's own id, that the bound is enforced in lines and enforced by +/// compaction rather than by rewriting, that a session continues the previous session's log rather +/// than replacing it — and, above all, that nothing a damaged file can be made to look like +/// throws, because the only caller is startup. +/// +public class RestoreLogTests +{ + private const string Main = "main"; + private const string Chat = "spawn:Chat"; + + private static StyledLine Line(string text) => StyledLine.FromText(text, TextStyle.Default); + + /// A throwaway directory that is removed however the test ends. + private sealed class TempRoot : IDisposable + { + public TempRoot() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"smuterm-restore-{Guid.NewGuid():N}"); + } + + /// The log root itself — deliberately not created, so "nothing on disk yet" is the start state. + public string Path { get; } + + public void Dispose() + { + try + { + if (System.IO.Directory.Exists(Path)) + { + System.IO.Directory.Delete(Path, recursive: true); + } + } + catch (Exception) + { + // Nothing a test should fail over. + } + } + } + + private static RestoreLog Open(TempRoot root, int maxLines = 500) => + new(root.Path, new RestoreLogOptions { MaxLinesPerWindow = maxLines }); + + [Test] + public async Task DefaultRoot_SitsBesideTheConfigurationAndItsSecrets() + { + // The config directory, not a cache one: unlike the spill these files are meant to survive. + var config = Path.Combine("/somewhere", "SharpMUTerm", "config.json"); + await Assert.That(RestoreLog.DefaultRoot(config)) + .IsEqualTo(Path.Combine("/somewhere", "SharpMUTerm", RestoreLogOptions.DirectoryName)); + } + + [Test] + public async Task NothingIsWrittenUntilALineIsAppended() + { + using var root = new TempRoot(); + using var log = Open(root); + + await Assert.That(Directory.Exists(root.Path)).IsFalse(); + await Assert.That(log.Read()).IsEmpty(); + } + + /// + /// The crux the whole design turns on: a spawn window's content never reaches + /// WorldSession.Scrollback, so the log has to be keyed by window. Two windows, two files, and + /// each window's lines come back under its own id and nobody else's. + /// + [Test] + public async Task EachWindowsLinesComeBackUnderItsOwnId() + { + using var root = new TempRoot(); + using (var log = Open(root)) + { + log.Append(Main, "Corvid", Line("The Grand Plaza"), "09:24"); + log.Append(Chat, "Chat", Line("[Chat] Rivane: crypt run?"), "09:25"); + log.Append(Main, "Corvid", Line("A town guard stands watch."), "09:26"); + } + + using var reopened = Open(root); + var restored = reopened.Read(); + + await Assert.That(restored.Count).IsEqualTo(2); + + var main = restored.Single(w => w.WindowId == Main); + await Assert.That(main.Title).IsEqualTo("Corvid"); + await Assert.That(main.Lost).IsEqualTo(0L); + await Assert.That(main.Lines.Select(l => l.Line.Text).ToList()) + .IsEquivalentTo(new[] { "The Grand Plaza", "A town guard stands watch." }); + await Assert.That(main.Lines[0].Stamp).IsEqualTo("09:24"); + + var chat = restored.Single(w => w.WindowId == Chat); + await Assert.That(chat.Lines.Count).IsEqualTo(1); + await Assert.That(chat.Lines[0].Line.Text).IsEqualTo("[Chat] Rivane: crypt run?"); + await Assert.That(chat.Lines[0].Stamp).IsEqualTo("09:25"); + } + + /// + /// A window id is not a file name — spawn:Chat holds a colon, and on Windows that is not + /// merely ugly but illegal. It round-trips because the id is stored in the header, not derived from + /// the name, and the name carries a checksum so two channels cannot land on one file. + /// + [Test] + public async Task WindowIdsThatAreNotFileNamesStillRoundTrip() + { + using var root = new TempRoot(); + var awkward = new[] { "spawn:Chat", "spawn:Chat/OOC", "spawn:Chat OOC", "spawn:..", "spawn:*" }; + + using (var log = Open(root)) + { + foreach (var id in awkward) + { + log.Append(id, id, Line($"in {id}"), null); + } + } + + using var reopened = Open(root); + var restored = reopened.Read(); + + await Assert.That(restored.Select(w => w.WindowId).OrderBy(i => i, StringComparer.Ordinal).ToList()) + .IsEquivalentTo(awkward.OrderBy(i => i, StringComparer.Ordinal).ToList()); + foreach (var window in restored) + { + await Assert.That(window.Lines.Single().Line.Text).IsEqualTo($"in {window.WindowId}"); + } + } + + /// + /// The style, the palette index, the rule colour and a span's interaction all survive, because the + /// payload is and not markup. A restored channel line that came back + /// monochrome would be a restored line nobody wanted. + /// + [Test] + public async Task ALinesStyleRuleAndInteractionSurvive() + { + using var root = new TempRoot(); + var line = new StyledLine( + new[] + { + new StyledSpan("Exits: ", TextStyle.Default), + new StyledSpan( + "north", + new TextStyle(TerminalColor.FromIndex(11), TerminalColor.FromRgb(1, 2, 3), TextAttributes.Underline), + SpanInteraction.Command("north")), + }, + TerminalColor.FromRgb(0x00, 0xf5, 0xb7)); + + using (var log = Open(root)) + { + log.Append(Main, "Corvid", line, "09:24"); + } + + using var reopened = Open(root); + var restored = reopened.Read().Single().Lines.Single().Line; + + await Assert.That(restored.RuleColor).IsEqualTo(line.RuleColor); + await Assert.That(restored.Spans.Count).IsEqualTo(2); + await Assert.That(restored.Spans[1].Style.Foreground).IsEqualTo(TerminalColor.FromIndex(11)); + await Assert.That(restored.Spans[1].Style.Background).IsEqualTo(TerminalColor.FromRgb(1, 2, 3)); + await Assert.That(restored.Spans[1].Style.Attributes).IsEqualTo(TextAttributes.Underline); + await Assert.That(restored.Spans[1].Interaction!.Kind).IsEqualTo(InteractionKind.SendCommand); + await Assert.That(restored.Spans[1].Interaction!.Target).IsEqualTo("north"); + } + + /// + /// The bound is in lines, and it holds. It is checked well past the compaction threshold so the + /// answer is not "the file simply never grew": the file is rewritten repeatedly during this and the + /// newest lines are still the ones that come back, in order. + /// + [Test] + public async Task OnlyTheNewestLinesUpToTheBoundComeBack() + { + using var root = new TempRoot(); + const int bound = 20; + + using (var log = Open(root, bound)) + { + for (var i = 1; i <= 500; i++) + { + log.Append(Main, "Corvid", Line($"line {i}"), null); + } + } + + using var reopened = Open(root, bound); + var lines = reopened.Read().Single().Lines; + + await Assert.That(lines.Count).IsEqualTo(bound); + await Assert.That(lines[0].Line.Text).IsEqualTo("line 481"); + await Assert.That(lines[^1].Line.Text).IsEqualTo("line 500"); + } + + /// + /// And the file is bounded too, not merely the read. Compaction is what stops an append-only log + /// growing for ever, and it is a byte-range copy through an atomic rename — so after enough appends + /// the file on disk is small, and no .tmp is left lying about. + /// + [Test] + public async Task CompactionKeepsTheFileBoundedAndLeavesNoTemporary() + { + using var root = new TempRoot(); + const int bound = 10; + + using (var log = Open(root, bound)) + { + for (var i = 0; i < 40; i++) + { + log.Append(Main, "Corvid", Line("a line of quite ordinary length, as they go"), "09:24"); + } + + var files = Directory.GetFiles(root.Path); + await Assert.That(files.Length).IsEqualTo(1); + await Assert.That(Path.GetExtension(files[0])).IsEqualTo(".log"); + + // Never more than twice the bound's worth of records, whatever the append count. + await Assert.That(new FileInfo(files[0]).Length).IsLessThan(2 * bound * 200); + } + + using var reopened = Open(root, bound); + await Assert.That(reopened.Read().Single().Lines.Count).IsEqualTo(bound); + } + + /// + /// A second run continues the log rather than starting it over. Restarting the client twice in a + /// minute must not leave a pane holding two lines — which is what truncating on open would do. + /// + [Test] + public async Task ASecondSessionContinuesTheLogInsteadOfReplacingIt() + { + using var root = new TempRoot(); + + using (var first = Open(root)) + { + first.Append(Main, "Corvid", Line("before the restart"), "09:24"); + } + + using (var second = Open(root)) + { + second.Append(Main, "Corvid", Line("after the restart"), "09:25"); + } + + using var third = Open(root); + await Assert.That(third.Read().Single().Lines.Select(l => l.Line.Text).ToList()) + .IsEquivalentTo(new[] { "before the restart", "after the restart" }); + } + + /// + /// The corruption case, and the one this feature cannot afford to get wrong. A file cut off + /// mid-record is exactly what a crash leaves behind. Reading it must give back every whole line + /// before the cut, count the rest as lost, and — the part that matters — not throw into startup. + /// + [Test] + public async Task ATruncatedFileRestoresWhatIsReadableAndReportsTheRest() + { + using var root = new TempRoot(); + using (var log = Open(root)) + { + for (var i = 1; i <= 6; i++) + { + log.Append(Main, "Corvid", Line($"line {i}"), "09:24"); + } + } + + // Cut the file mid-record: not on a frame boundary, and not merely a byte or two short. + var path = Directory.GetFiles(root.Path).Single(); + var whole = new FileInfo(path).Length; + using (var file = new FileStream(path, FileMode.Open, FileAccess.Write)) + { + file.SetLength(whole - 11); + } + + using var reopened = Open(root); + var restored = reopened.Read().Single(); + + await Assert.That(restored.WindowId).IsEqualTo(Main); + await Assert.That(restored.Lines.Count).IsEqualTo(5); + await Assert.That(restored.Lines[^1].Line.Text).IsEqualTo("line 5"); + await Assert.That(restored.Lost).IsGreaterThan(0L); + + // And the next session appends onto the trimmed file rather than behind the rubbish. + reopened.Append(Main, "Corvid", Line("line 7"), "09:25"); + using var again = Open(root); + await Assert.That(again.Read().Single().Lines.Select(l => l.Line.Text).ToList()) + .IsEquivalentTo(new[] { "line 1", "line 2", "line 3", "line 4", "line 5", "line 7" }); + } + + /// + /// One damaged record in the middle costs one line, not every line after it. The framing is + /// a length prefix and the checksum is separate, so a reader can step over the bad one — which is + /// the difference between losing a line to a bad sector and losing the session. + /// + [Test] + public async Task OneDamagedRecordCostsOneLine() + { + using var root = new TempRoot(); + using (var log = Open(root)) + { + for (var i = 1; i <= 5; i++) + { + log.Append(Main, "Corvid", Line($"line {i}"), null); + } + } + + // Flip a byte inside the third record's payload. Its length prefix is untouched, so the frames + // after it are still findable; only its own checksum fails. + var path = Directory.GetFiles(root.Path).Single(); + var bytes = File.ReadAllBytes(path); + var third = Array.IndexOf(bytes, (byte)'3'); + await Assert.That(third).IsGreaterThan(0); // the fixture found what it meant to damage + bytes[third] = (byte)'X'; + File.WriteAllBytes(path, bytes); + + using var reopened = Open(root); + var restored = reopened.Read().Single(); + + await Assert.That(restored.Lines.Select(l => l.Line.Text).ToList()) + .IsEquivalentTo(new[] { "line 1", "line 2", "line 4", "line 5" }); + await Assert.That(restored.Lost).IsGreaterThan(0L); + } + + /// + /// Rubbish that is not a log of ours at all — a stray file, a truncated header, or one whose version + /// this build does not know — is ignored rather than reinterpreted, and never throws. + /// + [Test] + [Arguments("")] + [Arguments("not a restore log at all")] + [Arguments("SMTRSTOR")] + public async Task RubbishInTheDirectoryIsIgnored(string content) + { + using var root = new TempRoot(); + Directory.CreateDirectory(root.Path); + File.WriteAllText(Path.Combine(root.Path, "stray.log"), content); + + using var log = Open(root); + await Assert.That(log.Read()).IsEmpty(); + } + + [Test] + public async Task AHeaderFromAFutureVersionIsLeftAloneRatherThanReinterpreted() + { + using var root = new TempRoot(); + using (var log = Open(root)) + { + log.Append(Main, "Corvid", Line("line 1"), null); + } + + var path = Directory.GetFiles(root.Path).Single(); + var bytes = File.ReadAllBytes(path); + bytes[8] = 99; // the format version, immediately after the 8-byte magic + File.WriteAllBytes(path, bytes); + + using var reopened = Open(root); + await Assert.That(reopened.Read()).IsEmpty(); + } + + /// + /// A log whose root cannot be created at all — because a file is sitting where the + /// directory should be — degrades to writing nothing. It is the shape every permission failure has, + /// and it must not reach the caller. + /// + [Test] + public async Task AnUnusableRootDegradesToWritingNothing() + { + var path = Path.Combine(Path.GetTempPath(), $"smuterm-restore-blocked-{Guid.NewGuid():N}"); + File.WriteAllText(path, "not a directory"); + try + { + using var log = new RestoreLog(path); + log.Append(Main, "Corvid", Line("line 1"), null); + await Assert.That(log.Read()).IsEmpty(); + } + finally + { + File.Delete(path); + } + } + + [Test] + public async Task PurgeRemovesEveryWindowsLogAndTheLogGoesOnWorking() + { + using var root = new TempRoot(); + using var log = Open(root); + log.Append(Main, "Corvid", Line("line 1"), null); + log.Append(Chat, "Chat", Line("line 2"), null); + + await Assert.That(log.Purge()).IsEqualTo(2); + await Assert.That(Directory.Exists(root.Path)).IsFalse(); + await Assert.That(log.Read()).IsEmpty(); + + // Purging is "forget what is on disk", not "switch the feature off". + log.Append(Main, "Corvid", Line("line 3"), null); + await Assert.That(log.Read().Single().Lines.Single().Line.Text).IsEqualTo("line 3"); + } + + /// What a character's opt-out does to content already stored: one window's file, gone. + [Test] + public async Task ForgetDropsOneWindowAndLeavesTheOthers() + { + using var root = new TempRoot(); + using var log = Open(root); + log.Append(Main, "Corvid", Line("line 1"), null); + log.Append(Chat, "Chat", Line("line 2"), null); + + await Assert.That(log.Forget(Main)).IsTrue(); + await Assert.That(log.Forget(Main)).IsFalse(); // already gone; not an error + + var remaining = log.Read(); + await Assert.That(remaining.Count).IsEqualTo(1); + await Assert.That(remaining[0].WindowId).IsEqualTo(Chat); + } + + [Test] + public async Task FilesAreOwnerOnlyLikeTheSecretsBesideThem() + { + if (OperatingSystem.IsWindows()) + { + return; // no Unix mode to assert; the ACL is inherited (see SecretsStore). + } + + using var root = new TempRoot(); + using var log = Open(root); + log.Append(Main, "Corvid", Line("what was said in a private game"), null); + + var expected = UnixFileMode.UserRead | UnixFileMode.UserWrite; + await Assert.That(File.GetUnixFileMode(Directory.GetFiles(root.Path).Single())).IsEqualTo(expected); + await Assert.That(File.GetUnixFileMode(root.Path)) + .IsEqualTo(expected | UnixFileMode.UserExecute); + } + + /// + /// A line nobody could have meant — megabytes with no newline — is stored as a blank row rather than + /// dropped or written whole. The pane comes back the right length and the file stays bounded. + /// + [Test] + public async Task APathologicallyLongLineIsKeptAsABlankRow() + { + using var root = new TempRoot(); + using (var log = Open(root)) + { + log.Append(Main, "Corvid", Line("before"), null); + log.Append(Main, "Corvid", Line(new string('x', 4 * 1024 * 1024)), null); + log.Append(Main, "Corvid", Line("after"), null); + } + + using var reopened = Open(root); + var lines = reopened.Read().Single().Lines; + + await Assert.That(lines.Count).IsEqualTo(3); + await Assert.That(lines[1].Line.Text).IsEqualTo(string.Empty); + await Assert.That(lines[2].Line.Text).IsEqualTo("after"); + await Assert.That(new FileInfo(Directory.GetFiles(root.Path).Single()).Length).IsLessThan(1024 * 1024); + } + + /// + /// The disk half of the startup cost, measured rather than asserted about. Six windows at the + /// shipped bound — 3,000 lines — read and decoded in ~2.8 ms on this machine, which is the + /// I/O-and-decode share of the ~18 ms the whole restore costs + /// (RestoreLogEndToEndTests.RestoringAFullLogCosts… has the breakdown). The ceiling is two + /// orders above that because it runs cold in the suite; it still catches a regression into a scan + /// per line or a re-read per window. + /// + [Test] + public async Task AFullLogIsReadFastEnoughToRunBeforeTheFirstFrame() + { + using var root = new TempRoot(); + var windows = new[] { "main", "spawn:Chat", "spawn:OOC", "spawn:Tells", "spawn:Guild", "spawn:Events" }; + + using (var log = Open(root)) + { + foreach (var window in windows) + { + for (var i = 0; i < RestoreLogOptions.DefaultMaxLinesPerWindow; i++) + { + log.Append(window, window, Line($"[{window}] Rivane says something of a fairly typical length here"), "09:24"); + } + } + } + + using var reopened = Open(root); + reopened.Read(); // warm the file cache, so this measures the work and not the first disk touch + + var clock = System.Diagnostics.Stopwatch.StartNew(); + var restored = reopened.Read(); + clock.Stop(); + + await Assert.That(restored.Count).IsEqualTo(windows.Length); + await Assert.That(restored.Sum(w => w.Lines.Count)) + .IsEqualTo(windows.Length * RestoreLogOptions.DefaultMaxLinesPerWindow); + await Assert.That(clock.ElapsedMilliseconds).IsLessThan(250); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Workspace/PaneNumberingTests.cs b/tests/SharpMUTerm.Core.Tests/Workspace/PaneNumberingTests.cs new file mode 100644 index 00000000..1ee0a80f --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Workspace/PaneNumberingTests.cs @@ -0,0 +1,330 @@ +using SharpMUTerm.Core.Workspaces; + +namespace SharpMUTerm.Core.Tests.Workspaces; + +/// +/// A pane's number is when it was made, not where it sits. The request was that pane numbering be +/// global and in order of creation, so ⌥1–⌥9 double as a way to reach another character; global it always +/// was (a workspace has one split tree whoever is connected in it), and this is the half that had to be +/// built. +/// +/// What was wrong. WorkspaceLayout.Panes was Root.Panes() — tree order, left-to-right +/// then top-to-bottom, which is a function of where a pane is. So creating a pane renumbered every +/// pane after the insertion point: drop a window on the left edge of pane 2 and the pane that had been +/// pane 2 became pane 3, while the user was doing something else entirely and had no reason to look. ⌥2 +/// then went somewhere new without any surface having said so, which is the same defect as a label and a +/// chord disagreeing — the thing this repository has already paid for twice. +/// +/// +/// Number versus sequence. is a sort key that is never reused; the +/// number is the pane's position in the sorted list. That distinction is the whole of the +/// compaction rule: reading sequences directly would leave holes after a close (1 and 3, with ⌥2 doing +/// nothing while two panes sat on the screen), and reading positions closes them up. +/// +/// +public class PaneNumberingTests +{ + // --- creation order, and what it is not --------------------------------------------------------- + + /// + /// The defect, pinned. Two panes; a third is created to the left of the second. Under + /// tree order the second pane's number went 2 → 3. It must not move: it is the same pane, in the same + /// place, and nobody asked for it to be renamed. + /// + [Test] + public async Task CreatingAPaneToTheLeftOfAnotherDoesNotRenumberIt() + { + var layout = new WorkspaceLayout(new[] { "a", "b" }); + layout.SplitFocused(SplitDirection.Row); + var second = layout.Panes[1].Id; + + layout.SplitWithWindow("c", second, Edge.Left); + + await Assert.That(Number(layout, second)) + .IsEqualTo(2) + .Because("a pane keeps its number for as long as it is open"); + await Assert.That(layout.Panes.Count).IsEqualTo(3); + } + + /// And the new pane takes the number after the last one, wherever on the screen it landed. + [Test] + public async Task ANewPaneIsNumberedLastEvenWhenItIsDrawnFirst() + { + var layout = new WorkspaceLayout(new[] { "a", "b" }); + layout.SplitFocused(SplitDirection.Row); + var first = layout.Panes[0].Id; + + layout.SplitWithWindow("c", first, Edge.Left); + var newest = layout.FocusedPaneId; // SplitWithWindow focuses the pane it made + + // Drawn leftmost — tree order puts it first — and numbered third. + await Assert.That(layout.Root.Panes().First().Id).IsEqualTo(newest); + await Assert.That(Number(layout, newest)).IsEqualTo(3); + await Assert.That(Number(layout, first)).IsEqualTo(1); + } + + /// + /// Tree order still exists and still means geometry — this is the assertion that the two orders are + /// genuinely different, so the tests above are not passing by coincidence on a layout where they agree. + /// + [Test] + public async Task TreeOrderAndTheNumberingAreAllowedToDisagree() + { + var layout = new WorkspaceLayout(new[] { "a", "b" }); + layout.SplitFocused(SplitDirection.Row); + layout.SplitWithWindow("c", layout.Panes[0].Id, Edge.Left); + + var numbered = Order(layout.Panes); + var drawn = Order(layout.Root.Panes()); + + await Assert.That(numbered).IsNotEqualTo(drawn); + await Assert.That(numbered.Split(',').Order()).IsEquivalentTo(drawn.Split(',').Order()); // same panes + } + + // --- compaction --------------------------------------------------------------------------------- + + /// + /// Closing pane 2 of three leaves panes 1 and 2, not 1 and 3. The numbering has to stay + /// contiguous or ⌥2 is a chord that does nothing while two panes are on the screen — a silent no-op, + /// which is this codebase's most-repeated defect. Sequences are never reused, so it is the position in + /// the list and not the sequence that is the number. + /// + [Test] + public async Task ClosingAPaneInTheMiddleCompactsTheNumbering() + { + var layout = ThreePanes(); + var (first, second, third) = (layout.Panes[0].Id, layout.Panes[1].Id, layout.Panes[2].Id); + + layout.Focus(second); + layout.CloseFocused(); + + await Assert.That(layout.Panes.Count).IsEqualTo(2); + await Assert.That(Number(layout, first)).IsEqualTo(1); + await Assert.That(Number(layout, third)) + .IsEqualTo(2) + .Because("⌥2 must reach a pane, not a hole where one used to be"); + } + + /// Closing the first one compacts too: what was 2 and 3 becomes 1 and 2, in the same order. + [Test] + public async Task ClosingTheFirstPaneShiftsTheRestDownRatherThanLeavingAGap() + { + var layout = ThreePanes(); + var (first, second, third) = (layout.Panes[0].Id, layout.Panes[1].Id, layout.Panes[2].Id); + + layout.Focus(first); + layout.CloseFocused(); + + await Assert.That(Order(layout.Panes)).IsEqualTo($"{second},{third}"); + await Assert.That(Number(layout, second)).IsEqualTo(1); + await Assert.That(Number(layout, third)).IsEqualTo(2); + } + + /// + /// And a pane created after a close takes the free number at the end rather than reusing the closed + /// pane's — the surviving panes' numbers are what must not move, and reusing would mean the new pane + /// appeared in the middle of an ordering that is supposed to be chronological. + /// + [Test] + public async Task APaneMadeAfterACloseGoesOnTheEnd() + { + var layout = ThreePanes(); + var (first, third) = (layout.Panes[0].Id, layout.Panes[2].Id); + + layout.Focus(layout.Panes[1].Id); + layout.CloseFocused(); + layout.SplitWithWindow("d", first, Edge.Left); + var newest = layout.FocusedPaneId; + + await Assert.That(Number(layout, first)).IsEqualTo(1); + await Assert.That(Number(layout, third)).IsEqualTo(2); + await Assert.That(Number(layout, newest)).IsEqualTo(3); + } + + // --- the other ordinal mover -------------------------------------------------------------------- + + /// + /// ⌃O counts the way ⌥N counts. These are the two ordinal movers, and a user pressing them + /// alternately must not be counting two sequences: three presses of cycle from pane 1 land where the + /// digit 4 would. Cycle read tree order before, which agreed with the numbering then and would not now. + /// + [Test] + public async Task CyclingWalksTheNumberingInOrderAndWraps() + { + var layout = ThreePanes(); + layout.SplitWithWindow("d", layout.Panes[0].Id, Edge.Left); // a fourth, drawn first, numbered last + var order = layout.Panes.Select(p => p.Id).ToList(); + + layout.Focus(order[0]); + foreach (var expected in order.Skip(1).Append(order[0])) + { + layout.CycleFocus(); + await Assert.That(layout.FocusedPaneId).IsEqualTo(expected); + } + } + + /// Closing the focused pane falls back to pane 1, which is what the surviving labels say. + [Test] + public async Task ClosingTheFocusedPaneFallsBackToPaneOne() + { + var layout = ThreePanes(); + layout.SplitWithWindow("d", layout.Panes[0].Id, Edge.Left); + var firstNumbered = layout.Panes[0].Id; + + layout.Focus(layout.Panes[3].Id); + layout.CloseFocused(); + + await Assert.That(layout.FocusedPaneId).IsEqualTo(firstNumbered); + await Assert.That(Number(layout, layout.FocusedPaneId)).IsEqualTo(1); + } + + // --- persistence -------------------------------------------------------------------------------- + + /// + /// A resumed workspace comes back numbered the way it was left. The numbering is only worth + /// learning if it survives a restart, so the sequence is persisted rather than re-derived — re-deriving + /// it from the saved tree would hand back exactly the tree-order numbering this change removes. + /// + [Test] + public async Task TheNumberingSurvivesACaptureAndRestore() + { + var layout = new WorkspaceLayout(new[] { "a", "b" }); + layout.SplitFocused(SplitDirection.Row); + layout.SplitWithWindow("c", layout.Panes[0].Id, Edge.Left); + var before = Order(layout.Panes); + + var restored = WorkspaceState.Capture(new Workspace( + new[] + { + new WorkspaceWindow("a", "a"), new WorkspaceWindow("b", "b"), new WorkspaceWindow("c", "c"), + }, + layout)).Restore(); + + await Assert.That(Order(restored.Layout.Panes)).IsEqualTo(before); + await Assert.That(Order(restored.Layout.Panes)) + .IsNotEqualTo(Order(restored.Layout.Root.Panes())) + .Because("the fixture must still be one where tree order would give a different answer"); + } + + /// + /// A configuration written before panes carried a sequence does not come back scrambled. Such a + /// tree deserialises with every sequence at , and every pane sorting + /// equal would leave the numbering to whatever the sort did. Tree order is the numbering that + /// configuration was saved under, so that is what it is seeded with: the workspace reads exactly as it + /// read when it was closed, and is stable from then on. + /// + [Test] + public async Task ALayoutRestoredWithoutSequencesIsNumberedFromTreeOrder() + { + var state = new WorkspaceState + { + Windows = + { + new WorkspaceWindowState { Id = "a", Title = "a" }, + new WorkspaceWindowState { Id = "b", Title = "b" }, + new WorkspaceWindowState { Id = "c", Title = "c" }, + }, + Root = new LayoutNodeState + { + Type = "split", + Direction = SplitDirection.Row, + Children = + { + // Ids deliberately out of order, so an implementation reading the number off the + // `pN` id would disagree with one reading tree order. + new LayoutNodeState { Type = "pane", Id = "p7", Tabs = { "a" } }, + new LayoutNodeState { Type = "pane", Id = "p2", Tabs = { "b" } }, + new LayoutNodeState { Type = "pane", Id = "p5", Tabs = { "c" } }, + }, + }, + FocusedPaneId = "p2", + }; + + var layout = state.Restore().Layout; + + await Assert.That(Order(layout.Panes)).IsEqualTo("p7,p2,p5"); + await Assert.That(string.Join(",", layout.Panes.Select(p => p.Sequence))).IsEqualTo("1,2,3"); + } + + /// + /// And a pane created afterwards is numbered after them rather than colliding — the counter picks up + /// from the highest sequence in the tree, not from the ids, which a legacy tree gives no guarantees + /// about. + /// + [Test] + public async Task APaneAddedToARestoredLegacyLayoutTakesTheNextNumber() + { + var state = new WorkspaceState + { + Windows = { new WorkspaceWindowState { Id = "a", Title = "a" } }, + Root = new LayoutNodeState { Type = "pane", Id = "legacy-pane", Tabs = { "a" } }, + FocusedPaneId = "legacy-pane", + }; + + var layout = state.Restore().Layout; + layout.SplitWithWindow("b", "legacy-pane", Edge.Left); + + await Assert.That(layout.Panes.Select(p => p.Sequence).Distinct().Count()) + .IsEqualTo(2) + .Because("two panes may never share a number"); + await Assert.That(Number(layout, "legacy-pane")).IsEqualTo(1); + } + + /// + /// A half-migrated tree — some panes numbered, some not — puts the unnumbered ones after the numbered + /// ones rather than on top of them. There is no path that writes such a tree today; the guard is here + /// because "assign 1..n from tree order" is the obvious implementation and it would silently give two + /// panes the same number the first time one arrives. + /// + [Test] + public async Task UnsequencedPanesAreNumberedAfterSequencedOnes() + { + var root = new SplitNode( + SplitDirection.Row, + new LayoutNode[] + { + new PaneNode("fresh", new[] { "a" }), // Unsequenced + new PaneNode("known", new[] { "b" }, sequence: 4), + }); + + var layout = new WorkspaceLayout(root, "known"); + + await Assert.That(Order(layout.Panes)).IsEqualTo("known,fresh"); + await Assert.That(layout.FindPane("fresh")!.Sequence).IsEqualTo(5); + } + + // --- harness ------------------------------------------------------------------------------------ + + /// + /// Pane ids in the order given, as one string. Ordered comparison is the point of most of this + /// suite, and TUnit's IsEquivalentTo compares collections as sets — it passed happily + /// on [p1,p2,p3] against [p3,p1,p2], which is exactly the difference being asserted. + /// + private static string Order(IEnumerable panes) => string.Join(",", panes.Select(p => p.Id)); + + /// The number a pane wears — its 1-based position in the numbering. + private static int Number(WorkspaceLayout layout, string paneId) + { + var panes = layout.Panes; + for (var i = 0; i < panes.Count; i++) + { + if (panes[i].Id == paneId) + { + return i + 1; + } + } + + return -1; + } + + /// Three panes side by side, made in that order. + private static WorkspaceLayout ThreePanes() + { + var layout = new WorkspaceLayout(new[] { "a", "b", "c" }); + layout.SplitFocused(SplitDirection.Row); + layout.Focus(layout.Panes[1].Id); + layout.SplitFocused(SplitDirection.Row); + layout.Focus(layout.Panes[0].Id); + return layout; + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Workspace/WorkspaceLayoutTests.cs b/tests/SharpMUTerm.Core.Tests/Workspace/WorkspaceLayoutTests.cs index ab47fed1..31e03a25 100644 --- a/tests/SharpMUTerm.Core.Tests/Workspace/WorkspaceLayoutTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Workspace/WorkspaceLayoutTests.cs @@ -135,6 +135,34 @@ public async Task ToggleZoom_SetsAndClears_AndClosingZoomedPaneResetsIt() await Assert.That(w.ZoomedPaneId).IsNull(); } + /// + /// A zoom carried onto whichever pane is now focused — what the ordinal movers (⌃O, ⌥N) do, so the + /// selection never lands on a pane a zoom is hiding. It never starts a zoom and never ends one. + /// + [Test] + public async Task CarryZoomToFocused_FollowsTheSelection_AndOnlyWhenSomethingIsZoomed() + { + var w = new WorkspaceLayout(new[] { "a", "b" }); + w.SplitFocused(SplitDirection.Row); + var first = w.FocusedPaneId; + + // Nothing zoomed: nothing to carry, and it must not invent one. + await Assert.That(w.CarryZoomToFocused()).IsFalse(); + await Assert.That(w.ZoomedPaneId).IsNull(); + + w.ToggleZoom(); + w.CycleFocus(); + await Assert.That(w.FocusedPaneId).IsNotEqualTo(first); + await Assert.That(w.ZoomedPaneId).IsEqualTo(first); // the state the carry exists to fix + + await Assert.That(w.CarryZoomToFocused()).IsTrue(); + await Assert.That(w.ZoomedPaneId).IsEqualTo(w.FocusedPaneId); + + // Already where it belongs: no change, and it says so. + await Assert.That(w.CarryZoomToFocused()).IsFalse(); + await Assert.That(w.ZoomedPaneId).IsEqualTo(w.FocusedPaneId); + } + [Test] public async Task ReorderActiveTab_MovesWithinPane_AndClampsAtEdges() { diff --git a/tests/SharpMUTerm.Crawler.Tests/BackoffTests.cs b/tests/SharpMUTerm.Crawler.Tests/BackoffTests.cs index 8d503448..1ad12eb8 100644 --- a/tests/SharpMUTerm.Crawler.Tests/BackoffTests.cs +++ b/tests/SharpMUTerm.Crawler.Tests/BackoffTests.cs @@ -139,8 +139,7 @@ public async Task AServerAskingForALongerCrawlDelayGetsIt() var options = new CrawlOptions { RevisitInterval = TimeSpan.FromHours(6) }; var (frontier, host) = Seeded(options); - var data = new MsspSubnegotiationParser() - .Consume(MsspWire.Subnegotiation(("CRAWL DELAY", ["23"]))).Single(); + var data = MsspWire.Report(("CRAWL DELAY", ["23"])); frontier.Record(Result(host, CrawlOutcome.MsspReceived, data)); @@ -156,8 +155,7 @@ public async Task AServerAskingForAShorterCrawlDelayDoesNotGetVisitedMoreOften() var options = new CrawlOptions { RevisitInterval = TimeSpan.FromHours(24) }; var (frontier, host) = Seeded(options); - var data = new MsspSubnegotiationParser() - .Consume(MsspWire.Subnegotiation(("CRAWL DELAY", ["1"]))).Single(); + var data = MsspWire.Report(("CRAWL DELAY", ["1"])); frontier.Record(Result(host, CrawlOutcome.MsspReceived, data)); diff --git a/tests/SharpMUTerm.Crawler.Tests/MsspParsingTests.cs b/tests/SharpMUTerm.Crawler.Tests/MsspParsingTests.cs index 53163e9f..546793d1 100644 --- a/tests/SharpMUTerm.Crawler.Tests/MsspParsingTests.cs +++ b/tests/SharpMUTerm.Crawler.Tests/MsspParsingTests.cs @@ -1,25 +1,56 @@ using System.Text; using SharpMUTerm.Core.Telnet.Mssp; +using SharpMUTerm.Crawler.Model; +using SharpMUTerm.Crawler.Probing; using SharpMUTerm.Crawler.Tests.Support; +using TelnetNegotiationCore.Models; namespace SharpMUTerm.Crawler.Tests; /// -/// The wire parser, against payloads built byte by byte from the specification's own format. +/// What a server's MSSP report turns into, against payloads built byte by byte from the +/// specification's own format and fed through a real telnet session. +/// +/// There is no MSSP parser in this repository any more. These cases used to pin one, written +/// because TelnetNegotiationCore's reader destroyed arrays, booleans and unknown variables before any +/// consumer saw them. That is fixed upstream (2.6.5), so the same cases now run against the library's +/// own MSSPConfig.Variables by way of , and prove the replacement keeps +/// what the workaround kept. The two that diverge are named as such and say why. +/// /// public class MsspParsingTests { - private static MsspData ParseOne(params (string Variable, string[] Values)[] entries) + private static MsspHost Host => MsspHost.Create("server.example.org", 4201)!; + + private static CrawlOptions Options => new() + { + ConnectTimeout = TimeSpan.FromSeconds(5), + MsspTimeout = TimeSpan.FromSeconds(5), + }; + + /// + /// One server, one report: a scripted server offers MSSP, answers the crawler's DO with + /// , and the report the probe came back with is returned. + /// + private static Task Read(params (string Variable, string[] Values)[] entries) => + ReadRaw(MsspWire.Subnegotiation(entries)); + + private static async Task ReadRaw(byte[] payload, bool fragmented = false) { - var parser = new MsspSubnegotiationParser(); - var reports = parser.Consume(MsspWire.Subnegotiation(entries)); - return reports.Single(); + var transport = new ScriptedTransport { Greeting = MsspWire.Offer(), Fragmented = fragmented } + .RespondingToDo(MsspWire.Mssp, payload); + + var result = await new TelnetMsspProbe(Options, _ => transport).ProbeAsync(Host, CancellationToken.None); + + return result.Outcome == CrawlOutcome.MsspReceived && result.Data is { } data + ? data + : throw new InvalidOperationException($"No MSSP report: {result.Outcome} ({result.Error})."); } [Test] public async Task AFullPayloadKeepsEveryVariableItWasSent() { - var data = ParseOne(MsspWire.RepresentativeReport("mud.example.net 4000")); + var data = await Read(MsspWire.RepresentativeReport("mud.example.net 4000")); await Assert.That(data.Name).IsEqualTo("Corvid Nest"); await Assert.That(data.Players).IsEqualTo(17); @@ -27,28 +58,40 @@ public async Task AFullPayloadKeepsEveryVariableItWasSent() await Assert.That(data.Hostname).IsEqualTo("corvid.example.org"); await Assert.That(data.Contact).IsEqualTo("admin@corvid.example.org"); await Assert.That(data.Website).IsEqualTo("https://corvid.example.org/"); + await Assert.That(data.Count).IsEqualTo(22); } [Test] public async Task ArrayNotationKeepsEveryValueInOrderWithTheDefaultLast() { // "It's also possible to attach several values to a single variable by using MSSP_VAL more than - // once, with the default value reported last." This is the case TelnetNegotiationCore 2.6.0 - // concatenates into the integer 80234201. - var data = ParseOne(("PORT", ["80", "23", "4201"])); + // once, with the default value reported last." This is the case 2.6.0 concatenated into the + // integer 80234201, and the reason a parser was written here at all. + var data = await Read(("PORT", ["80", "23", "4201"])); await Assert.That(data["PORT"]).IsEquivalentTo(new[] { "80", "23", "4201" }); await Assert.That(data.Ports).IsEquivalentTo(new[] { 80, 23, 4201 }); await Assert.That(data.Port).IsEqualTo(4201); } + [Test] + public async Task TheReferralListArrivesAsAList() + { + // REFERRAL is array-only and is what a crawler follows; 2.6.0 delivered it as null. + var data = await Read(("REFERRAL", ["a.example.org 4000", "b.example.net 23", "2001:db8::5 4201"])); + + await Assert.That(data["REFERRAL"]).Count().IsEqualTo(3); + await Assert.That(data.Referrals.Select(r => r.ToReferralString())) + .IsEquivalentTo(new[] { "a.example.org 4000", "b.example.net 23", "2001:db8::5 4201" }); + } + [Test] public async Task ARepeatedVariableAccumulatesRatherThanReplacing() { // "The same variable can be send more than once with different values, in which case the last // reported value should be used as the default value." The two spellings of an array must end up // in one place, or a consumer would have to know which one a server chose. - var data = ParseOne(("CODEBASE", ["Merc 2.1"]), ("CODEBASE", ["ROM 2.4"])); + var data = await Read(("CODEBASE", ["Merc 2.1"]), ("CODEBASE", ["ROM 2.4"])); await Assert.That(data["CODEBASE"]).IsEquivalentTo(new[] { "Merc 2.1", "ROM 2.4" }); await Assert.That(data.Codebase).IsEqualTo("ROM 2.4"); @@ -57,7 +100,7 @@ public async Task ARepeatedVariableAccumulatesRatherThanReplacing() [Test] public async Task UnofficialAndWhollyUnknownVariablesAreKept() { - var data = ParseOne(MsspWire.RepresentativeReport()); + var data = await Read(MsspWire.RepresentativeReport()); // Unofficial but real. await Assert.That(data.Flag("PUEBLO")).IsTrue(); @@ -76,9 +119,9 @@ await Assert.That(data.UnofficialNames) [Test] public async Task BooleanAndCharsetVariablesSurvive() { - // Every one of these is dropped by the telnet library: the booleans fail to bind from their - // string form, and CHARSET is official but absent from its model. - var data = ParseOne(MsspWire.RepresentativeReport()); + // Every one of these was dropped by 2.6.0: the booleans failed to bind from their string form, + // and CHARSET is official but had no property on the model. + var data = await Read(MsspWire.RepresentativeReport()); await Assert.That(data.Flag("ANSI")).IsTrue(); await Assert.That(data.Flag("UTF-8")).IsTrue(); @@ -89,40 +132,46 @@ public async Task BooleanAndCharsetVariablesSurvive() [Test] public async Task TheVocabularyMatchesTheSpecificationsOwnTables() { - // A crawler's whole job is to report what the protocol defines, so the list it defines it - // against is worth pinning: the three required variables, the ones whose names contain spaces - // (the case the underscore substitution exists for), and the boundary between official and not. - await Assert.That(MsspVariables.Official.Count).IsEqualTo(45); + // The vocabulary is the telnet library's now — one list, derived from the same model that reads + // the wire — so this pins *its* list rather than a second copy. The three required variables, + // the ones whose names contain spaces (the case the underscore substitution exists for), and + // the boundary between official and not. + await Assert.That(MSSPVariables.Official.Count).IsEqualTo(45); foreach (var required in new[] { "NAME", "PLAYERS", "UPTIME" }) { - await Assert.That(MsspVariables.IsOfficial(required)).IsTrue(); + await Assert.That(MSSPVariables.IsOfficial(required)).IsTrue(); } foreach (var spaced in new[] { "CRAWL DELAY", "MINIMUM AGE", "XTERM 256 COLORS", "PAY TO PLAY", "HIRING CODERS" }) { - await Assert.That(MsspVariables.IsOfficial(spaced)).IsTrue(); - await Assert.That(MsspVariables.IsOfficial(spaced.Replace(' ', '_'))).IsTrue(); + await Assert.That(MSSPVariables.IsOfficial(spaced)).IsTrue(); + await Assert.That(MSSPVariables.IsOfficial(spaced.Replace(' ', '_'))).IsTrue(); } - // Unofficial but widely deployed: recognised, and correctly not claimed as official. - await Assert.That(MsspVariables.IsKnownUnofficial("PUEBLO")).IsTrue(); - await Assert.That(MsspVariables.IsOfficial("PUEBLO")).IsFalse(); + // The two the specification's tables omit and 2.6.5 added: CHARSET and DISCORD. + await Assert.That(MSSPVariables.IsOfficial(MsspVariables.Charset)).IsTrue(); + await Assert.That(MSSPVariables.IsOfficial("DISCORD")).IsTrue(); - // Wholly unknown: neither, and still kept by the parser. - await Assert.That(MsspVariables.IsOfficial("CORVID SPECIFIC")).IsFalse(); - await Assert.That(MsspVariables.IsKnownUnofficial("CORVID SPECIFIC")).IsFalse(); + // Unofficial but widely deployed: modelled, and correctly not claimed as official. + await Assert.That(MSSPVariables.IsKnown("PUEBLO")).IsTrue(); + await Assert.That(MSSPVariables.IsOfficial("PUEBLO")).IsFalse(); + + // Wholly unknown: neither, and still kept in the report. + await Assert.That(MSSPVariables.IsOfficial("CORVID SPECIFIC")).IsFalse(); + await Assert.That(MSSPVariables.IsKnown("CORVID SPECIFIC")).IsFalse(); // UTF-8 keeps its hyphen; nothing in the folding touches it. - await Assert.That(MsspVariables.Canonicalise("utf-8")).IsEqualTo("UTF-8"); + await Assert.That(MSSPVariables.Canonicalize("utf-8")).IsEqualTo("UTF-8"); } [Test] public async Task TheUnderscoreSpellingIsTheSameVariableAsTheSpacedOne() { // The specification's own recommendation: "clients and crawlers can substitute spaces with - // underscores". A server may use either and mean one variable. - var data = ParseOne(("MINIMUM_AGE", ["13"]), ("MINIMUM AGE", ["18"])); + // underscores". A server may use either and mean one variable. 2.6.0 bound neither, because it + // matched names with a culture-sensitive ToUpper() and no substitution. + var data = await Read(("MINIMUM_AGE", ["13"]), ("MINIMUM AGE", ["18"])); await Assert.That(data.Count).IsEqualTo(1); await Assert.That(data["MINIMUM AGE"]).IsEquivalentTo(new[] { "13", "18" }); @@ -132,7 +181,7 @@ public async Task TheUnderscoreSpellingIsTheSameVariableAsTheSpacedOne() [Test] public async Task VariableNamesAreMatchedWithoutRegardToCaseOrStrayWhitespace() { - var data = ParseOne((" crawl delay ", ["11"])); + var data = await Read((" crawl delay ", ["11"])); await Assert.That(data.ContainsKey("CRAWL DELAY")).IsTrue(); await Assert.That(data.CrawlDelay).IsEqualTo(TimeSpan.FromHours(11)); @@ -141,25 +190,32 @@ public async Task VariableNamesAreMatchedWithoutRegardToCaseOrStrayWhitespace() [Test] public async Task ACrawlDelayOfMinusOneMeansNoPreferenceRatherThanANegativeInterval() { - // "Send -1 to use the crawler's default." - await Assert.That(ParseOne(("CRAWL DELAY", ["-1"])).CrawlDelay).IsNull(); - await Assert.That(ParseOne(("CRAWL DELAY", ["23"])).CrawlDelay).IsEqualTo(TimeSpan.FromHours(23)); + // "Send -1 to use the crawler's default." The library's own Integer() hands -1 back as-is, + // deliberately; the reading that a scheduler can use is this projection's. + await Assert.That((await Read(("CRAWL DELAY", ["-1"]))).CrawlDelay).IsNull(); + await Assert.That((await Read(("CRAWL DELAY", ["23"]))).CrawlDelay).IsEqualTo(TimeSpan.FromHours(23)); } [Test] public async Task AWorldCountOfMinusOneMeansUnavailableRatherThanMinusOne() { // "If your Mud can't calculate one of the numeric values for the World variables you can use - // -1 to indicate that the data is not available." - await Assert.That(ParseOne(("ROOMS", ["-1"])).Integer("ROOMS")).IsNull(); - await Assert.That(ParseOne(("ROOMS", ["0"])).Integer("ROOMS")).IsEqualTo(0); + // -1 to indicate that the data is not available." Again the library returns -1 and this + // projection returns null; the raw string is still one indexer away. + var unavailable = await Read(("ROOMS", ["-1"])); + await Assert.That(unavailable.Integer("ROOMS")).IsNull(); + await Assert.That(unavailable["ROOMS"]).IsEquivalentTo(new[] { "-1" }); + + await Assert.That((await Read(("ROOMS", ["0"]))).Integer("ROOMS")).IsEqualTo(0); } [Test] public async Task AnEmptyValueIsAValueAndAVariableWithNoValueIsNot() { - // "The value can be an empty string." - var data = ParseOne(("CONTACT", [""]), ("ICON", [])); + // "The value can be an empty string." A variable with no MSSP_VAL at all is malformed, but a + // server that sends one used to wedge MSSP dead for the whole connection — EscapingMSSPVar + // permitted only IAC, so the SE that followed the name went unhandled. + var data = await Read(("CONTACT", [""]), ("ICON", [])); await Assert.That(data.ContainsKey("CONTACT")).IsTrue(); await Assert.That(data.Contact).IsEqualTo(string.Empty); @@ -170,33 +226,34 @@ public async Task AnEmptyValueIsAValueAndAVariableWithNoValueIsNot() } [Test] - public async Task APayloadSplitAcrossReadsParsesTheSameAsAWholeOne() + public async Task AVariableWithNoValueDoesNotStopTheRestOfTheReportBeingRead() { - var whole = MsspWire.Subnegotiation(MsspWire.RepresentativeReport("a.example.org 4000")); + // The other half of the wedge: everything after the valueless variable has to survive. + var data = await Read(("ICON", []), ("NAME", ["After"]), ("PORT", ["23", "4201"])); - var parser = new MsspSubnegotiationParser(); - MsspData? report = null; + await Assert.That(data["ICON"]).IsEmpty(); + await Assert.That(data.Name).IsEqualTo("After"); + await Assert.That(data.Ports).IsEquivalentTo(new[] { 23, 4201 }); + } - // One byte at a time — the worst case a TCP stream can produce, and the one an incremental - // parser has to survive. - foreach (var b in whole) - { - foreach (var completed in parser.Consume([b])) - { - report = completed; - } - } + [Test] + public async Task APayloadSplitAcrossReadsParsesTheSameAsAWholeOne() + { + // One byte per read — the worst case a TCP stream can produce. + var data = await ReadRaw( + MsspWire.Subnegotiation(MsspWire.RepresentativeReport("a.example.org 4000")), + fragmented: true); - await Assert.That(report).IsNotNull(); - await Assert.That(report!.Name).IsEqualTo("Corvid Nest"); - await Assert.That(report.Referrals.Single().ToReferralString()).IsEqualTo("a.example.org 4000"); + await Assert.That(data.Name).IsEqualTo("Corvid Nest"); + await Assert.That(data.Ports).IsEquivalentTo(new[] { 80, 23, 4201 }); + await Assert.That(data.Referrals.Single().ToReferralString()).IsEqualTo("a.example.org 4000"); } [Test] public async Task AnotherOptionsSubnegotiationCannotBeMistakenForMssp() { // A GMCP payload is arbitrary text and can contain the bytes 0xFF 0xFA 0x46 by coincidence. A - // scanner that looked for MSSP anywhere in the stream would read that as the start of a report. + // reader that looked for MSSP anywhere in the stream would read that as the start of a report. var stream = new List(); stream.AddRange([MsspWire.Iac, MsspWire.Sb, MsspWire.Gmcp]); stream.AddRange(Encoding.UTF8.GetBytes("Core.Hello {\"client\":\"x\"}")); @@ -207,61 +264,72 @@ public async Task AnotherOptionsSubnegotiationCannotBeMistakenForMssp() stream.AddRange(Encoding.UTF8.GetBytes("Not A Real Report")); stream.AddRange([MsspWire.Iac, MsspWire.Se]); - var parser = new MsspSubnegotiationParser(); - await Assert.That(parser.Consume([.. stream])).IsEmpty(); - // …and the real one that follows is still read. - var real = parser.Consume(MsspWire.Subnegotiation(("NAME", ["Real"]))); - await Assert.That(real.Single().Name).IsEqualTo("Real"); + stream.AddRange(MsspWire.Subnegotiation(("NAME", ["Real"]))); + + await Assert.That((await ReadRaw([.. stream])).Name).IsEqualTo("Real"); } [Test] - public async Task AnEscapedIacInsideAValueIsALiteralByte() + public async Task NegotiationForOtherOptionsIsSteppedOverWithoutConfusingTheReader() { - var bytes = new List { MsspWire.Iac, MsspWire.Sb, MsspWire.Mssp, MsspWire.Var }; - bytes.AddRange(Encoding.UTF8.GetBytes("NAME")); - bytes.Add(MsspWire.Val); - bytes.AddRange(Encoding.Latin1.GetBytes("a")); - bytes.AddRange([MsspWire.Iac, MsspWire.Iac]); - bytes.AddRange(Encoding.Latin1.GetBytes("b")); - bytes.AddRange([MsspWire.Iac, MsspWire.Se]); + // IAC DO/WILL/WONT/DONT internal sealed class ScriptedTransport : ITransport @@ -45,6 +45,13 @@ public byte[] Sent /// Bytes to deliver as soon as the connection opens — the server's opening negotiation. public byte[] Greeting { get; init; } = []; + /// + /// Delivers everything one byte per read — the worst case a TCP stream can produce, and the one an + /// incremental reader has to survive. A payload split this way must parse the same as one that + /// arrives whole. + /// + public bool Fragmented { get; init; } + /// /// When the client answers IAC DO <option>, deliver . This is /// how the MSSP report is made conditional on the client having asked for it. @@ -60,7 +67,7 @@ public Task ConnectAsync(CancellationToken cancellationToken = default) IsConnected = true; if (Greeting.Length > 0) { - _inbound.Writer.TryWrite(Greeting); + Deliver(Greeting); } return Task.CompletedTask; @@ -81,7 +88,7 @@ public ValueTask SendAsync(ReadOnlyMemory data, CancellationToken cancella if (i + 2 < bytes.Length && bytes[i] == Iac && bytes[i + 1] == Do && _onDo.TryGetValue(bytes[i + 2], out var response)) { - _inbound.Writer.TryWrite(response); + Deliver(response); } } @@ -116,7 +123,25 @@ public async ValueTask ReceiveAsync(Memory buffer, CancellationToken } /// Delivers bytes to the client, as a server would mid-conversation. - public void SendToClient(params byte[] bytes) => _inbound.Writer.TryWrite(bytes); + public void SendToClient(params byte[] bytes) => Deliver(bytes); + + /// + /// Queues bytes for the reader. hands back at most one queued chunk per + /// call, so queuing one byte at a time is what makes arrive that way. + /// + private void Deliver(byte[] bytes) + { + if (!Fragmented) + { + _inbound.Writer.TryWrite(bytes); + return; + } + + foreach (var b in bytes) + { + _inbound.Writer.TryWrite([b]); + } + } /// /// Waits (up to a second) for the client to have written . Polling, but diff --git a/tests/SharpMUTerm.Tui.Tests/MacroKeyCaptureTests.cs b/tests/SharpMUTerm.Tui.Tests/MacroKeyCaptureTests.cs index 81c1f141..c8e81505 100644 --- a/tests/SharpMUTerm.Tui.Tests/MacroKeyCaptureTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/MacroKeyCaptureTests.cs @@ -61,7 +61,9 @@ private static void ArmCapture(SettingsSession session) [Arguments("Shift+F3", nameof(MacroKeyDelivery.Fires))] [Arguments("Ctrl+K", nameof(MacroKeyDelivery.Fires))] [Arguments("Alt+K", nameof(MacroKeyDelivery.Fires))] - [Arguments("Alt+5", nameof(MacroKeyDelivery.Fires))] + // ⌥0 is the one Alt+digit no surface claims: ⌥1–⌥9 go to numbered panes, and the tenth digit is + // deliberately left for a binding (the framework's own Alt+digit window selector ignores 0 too). + [Arguments("Alt+0", nameof(MacroKeyDelivery.Fires))] [Arguments("Ctrl+Up", nameof(MacroKeyDelivery.Fires))] [Arguments("Num5", nameof(MacroKeyDelivery.NeverArrives))] [Arguments("Num0", nameof(MacroKeyDelivery.NeverArrives))] @@ -76,6 +78,10 @@ private static void ArmCapture(SettingsSession session) [Arguments("F9", nameof(MacroKeyDelivery.Taken))] [Arguments("Ctrl+Q", nameof(MacroKeyDelivery.Taken))] [Arguments("Ctrl+P", nameof(MacroKeyDelivery.Taken))] + // Was Fires. ⌥5 now goes to pane 5, so F4 has to say the binding is dead and why — the same + // strengthening ⌃O and Alt+R got when they were claimed, not a weakening: Taken still carries a + // reason, which the second assertion below checks. + [Arguments("Alt+5", nameof(MacroKeyDelivery.Taken))] [Arguments("K", nameof(MacroKeyDelivery.Taken))] [Arguments("Up", nameof(MacroKeyDelivery.Taken))] public async Task TheVerdictSaysWhatWillHappenToAChord(string descriptor, string expected) diff --git a/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs new file mode 100644 index 00000000..8e8d15fc --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/PaneJumpTests.cs @@ -0,0 +1,729 @@ +using System.Text.RegularExpressions; +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Workspaces; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// ⌥1–⌥9 go to a numbered pane and bring it forward. The request was "ALT-1 2 3 4 5, or +/// CTRL-1 2 3 4 5 — go to those numbered panes and bring them to the forefront"; this is the half of it +/// that can be delivered, and these are the four claims it stands on. +/// +/// 1. The number is the number on the screen. Panes are counted in Layout.Panes order, which +/// is the order the connection rail's hosting column spells pane N in — so the assertions read the +/// label off the live rail and press the digit that label names, rather than writing down which +/// pane ought to be third. A chord that lands somewhere other than the label says is worse than no chord, +/// and this repository has already paid for two spellings of one pane once (▪ main main). +/// +/// +/// 2. It is the full activation, on painted cells. "Bring it to the forefront" is not +/// FocusedPaneId being assigned: it is the pane's plane on the frame, its window active, and the +/// command line talking to its character. All three are asserted, the first off the frame the driver was +/// handed — a focus indicator can be set on a control arranged at zero rows and read back happily. +/// +/// +/// 3. Nothing falls through to the framework. SharpConsoleUI claims Alt+1–9 for its own top-level +/// window selector (InputCoordinator.HandleAltInput), which — unlike the move and resize handlers +/// beside it — is not gated on IsMovable/IsResizable, so Movable(false) did +/// not switch it off. All nine digits are claimed as application shortcuts, which +/// InputCoordinator tries before it offers the key to a window at all; an out-of-range digit +/// therefore reports here and stops rather than reaching a window selector that would do something else. +/// +/// +/// 4. Alt, because Ctrl+digit is not a chord this terminal has. Read off a real pty rather than +/// remembered: every Alt+digit is ESC + the digit, while Ctrl+digit is the bare digit for 1/9/0 and +/// a byte already spelt Escape (3), Backspace (8) or NUL (2) for the rest. MacroKeys.Verdict is +/// where that is recorded, and holds it. +/// +/// +/// +/// Serialised with the other end-to-end suites: constructing the app and rendering a frame both touch the +/// process-global console streams. +/// +[NotInParallel] +public class PaneJumpTests +{ + private const int Width = 160; + private const int Height = 40; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + // --- the numbering, against the rail's own labels ---------------------------------------------- + + /// + /// The claim, end to end. Three panes, three characters, one each. For every digit: press ⌥N, + /// and the pane whose plane the frame paints as focused is the pane the rail labels pane N — + /// with that pane's window active and its character on the command line. + /// + /// The label is read from after the jump, which is the rail the + /// app really drew. Nothing here writes down which pane is third: the assertion is that the two agree, + /// which is the only property that makes the chord usable. + /// + /// + [Test] + public async Task EachDigitLandsOnThePaneTheRailNumbersWithIt() + { + var three = await ThreePanes(); + var (focused, _) = three.App.PaneBandColors; + var rects = three.App.PaneOutputRects(); + + for (var n = 1; n <= 3; n++) + { + three.App.SimulateKey(Alt(n)); + var frame = three.App.RenderWholeFrame(); + + // The rail's own word for where the now-active window is. + await Assert.That(RailPaneLabel(three.App)) + .IsEqualTo($"⌥{n}") + .Because($"⌥{n} must land on the pane the sidebar calls pane {n}"); + + // The session, so the command line is talking to the pane you are looking at. + await Assert.That(three.App.ActiveSessionKey).IsEqualTo(three.Sessions[n - 1]); + await Assert.That(three.App.ActiveWindowId()).IsEqualTo(three.Windows[n - 1]); + + // And the paint: this pane's rectangle carries the focused plane and no other one does. + var landed = three.App.FocusedPaneId; + await Assert.That(CellsPaintedIn(frame, rects[landed], focused)) + .IsGreaterThan(0) + .Because($"⌥{n} must paint pane {n} as the focused one"); + foreach (var other in three.App.PaneIds.Where(id => id != landed)) + { + await Assert.That(CellsPaintedIn(frame, rects[other], focused)).IsEqualTo(0); + } + } + } + + /// + /// And the line typed next goes to that pane's character. Asserted on the bytes the transport received, + /// because SendUserInputAsync returns immediately with nothing underneath it — "the right world + /// got it" against an unconnected session is true however broken the routing is. + /// + [Test] + public async Task TheLineTypedAfterAJumpReachesThatPanesCharacter() + { + var three = await ThreePanes(); + + three.App.SimulateKey(Alt(3)); + Send(three.App, "look"); + three.App.SimulateKey(Alt(2)); + Send(three.App, "score"); + + await Assert.That(three.Transports[2].Lines).IsEquivalentTo(new[] { "look" }); + await Assert.That(three.Transports[1].Lines).IsEquivalentTo(new[] { "score" }); + await Assert.That(three.Transports[0].Lines).IsEmpty(); + } + + /// + /// The pane label the ⌃P surface offers and the label the sidebar draws are the same number, for every + /// pane — the entry is a second door onto the chord, not a second numbering. + /// + [Test] + public async Task TheCommandSurfaceOffersOneEntryPerPaneAndAgreesWithTheChord() + { + var three = await ThreePanes(); + + var entries = three.App.BuildCatalog() + .Where(c => c.Id.StartsWith(CommandIds.PanePrefix, StringComparison.Ordinal)) + .ToList(); + await Assert.That(entries.Count).IsEqualTo(3); + + for (var n = 1; n <= 3; n++) + { + var entry = entries.Single(e => e.Id == CommandIds.Pane(n)); + await Assert.That(entry.Title).IsEqualTo($"Go to pane {n}"); + await Assert.That(entry.Subtitle) + .IsEqualTo($"⌥{n}") + .Because("an entry that named the wrong chord would be worse than a bare one"); + + // Both routes reach the same pane. + three.App.SimulateKey(Alt(1)); + three.App.SimulateKey(Alt(n)); + var viaKey = three.App.FocusedPaneId; + + three.App.SimulateKey(Alt(1)); + await Assert.That(three.App.DispatchCommand(CommandIds.Pane(n))).IsTrue(); + await Assert.That(three.App.FocusedPaneId).IsEqualTo(viaKey); + } + } + + /// + /// A single-pane workspace lists none of them. The directional entries are listed unconditionally + /// because they teach that the workspace splits at all; Go to pane 4 on a workspace with one + /// pane teaches nothing and names a place there is no way to make. + /// + [Test] + public async Task ASinglePaneWorkspaceOffersNoNumberedEntries() + { + var app = App(); + app.RenderSnapshot(); + + await Assert.That(app.PaneIds.Count).IsEqualTo(1); + await Assert.That(app.BuildCatalog().Any(c => c.Id.StartsWith(CommandIds.PanePrefix, StringComparison.Ordinal))) + .IsFalse(); + } + + // --- out of range: report, never a silent no-op ------------------------------------------------- + + /// + /// ⌥7 with three panes says so. A silent no-op is the most-repeated defect in this codebase's + /// history, and a digit with no pane behind it is the commonest way to press this chord wrong. The + /// notice names the digit and the count, and nothing moves. + /// + [Test] + public async Task AnOutOfRangeDigitReportsAndMovesNothing() + { + var three = await ThreePanes(); + three.App.SimulateKey(Alt(2)); + var pane = three.App.FocusedPaneId; + var session = three.App.ActiveSessionKey; + + foreach (var digit in new[] { 4, 7, 9 }) + { + three.App.SimulateKey(Alt(digit)); + + await Assert.That(three.App.StatusMarkup).Contains($"there is no pane {digit}"); + await Assert.That(three.App.StatusMarkup).Contains("3"); + await Assert.That(three.App.FocusedPaneId).IsEqualTo(pane); + await Assert.That(three.App.ActiveSessionKey).IsEqualTo(session); + } + } + + /// + /// On a workspace with one pane every digit past the first is out of range, and the refusal says the + /// useful thing instead of counting: how to make a second pane. The same sentence ⌃← gives. + /// + [Test] + public async Task OnOnePaneTheRefusalSaysHowToSplit() + { + var app = App(); + app.RenderSnapshot(); + + app.SimulateKey(Alt(2)); + + await Assert.That(app.StatusMarkup).Contains("one pane"); + await Assert.That(app.StatusMarkup).Contains("⌃B |"); + } + + /// + /// Every digit the framework's selector would act on is claimed. HandleAltInput matches + /// KeyChar '1'–'9' and selects a top-level window by index; it is reached from + /// InputCoordinator's fall-through, and a registered application shortcut is tried before the + /// key is offered to any window. Leaving one digit unclaimed — the out-of-range ones are the + /// temptation — would hand exactly that digit back to it. So the claim is the whole range, and the + /// app's own registration is the proof: RegisterGlobalShortcuts throws at startup for a claim + /// with no action, so an app that constructs at all has all nine wired to something. + /// + [Test] + public async Task AllNineDigitsAreClaimedSoNoneReachesTheFrameworksWindowSelector() + { + var app = App(); + app.RenderSnapshot(); // constructing and registering is itself half the assertion + + for (var n = 1; n <= 9; n++) + { + var key = ConsoleKey.D0 + n; + await Assert.That(MacroKeys.AppShortcuts.Any(s => s.Modifiers == ConsoleModifiers.Alt && s.Key == key)) + .IsTrue() + .Because($"⌥{n} must be claimed by this app, or the framework's window selector takes it"); + await Assert.That(MacroKeys.PaneJumpNumber(key)).IsEqualTo(n); + } + + // ⌥0 is deliberately outside the range: the framework ignores it too, so it costs nothing to + // leave bindable, and F4 says a macro on it fires. + await Assert.That(MacroKeys.PaneJumpNumber(ConsoleKey.D0)).IsNull(); + await Assert.That(MacroKeys.AppShortcuts.Any( + s => s.Modifiers == ConsoleModifiers.Alt && s.Key == ConsoleKey.D0)).IsFalse(); + await Assert.That(MacroKeys.Verdict("Alt+0").Fires).IsTrue(); + } + + /// + /// And an out-of-range digit really is consumed rather than merely ignored: it produced a notice, which + /// only this app can write. A key that fell through to the framework would leave the status line alone. + /// + [Test] + public async Task AnUnusedDigitIsConsumedByThisAppRatherThanPassedOn() + { + var app = App(); + app.RenderSnapshot(); + var before = app.StatusMarkup; + + var routed = app.SimulateKey(Alt(9)); + + await Assert.That(routed).IsNull(); // nothing was sent to a world + await Assert.That(app.StatusMarkup).IsNotEqualTo(before); + await Assert.That(app.ArmedInputText).DoesNotContain("9"); // and it did not type, either + } + + // --- what must not regress --------------------------------------------------------------------- + + /// + /// The focus pin is untouched. The chord moves pane selection and the session behind the command + /// line; it does not move framework keyboard focus, which stays on the armed bar — the fix for the + /// paste bug, and the reason typing lands where the caret is drawn. + /// + [Test] + public async Task JumpingLeavesTheKeyboardOnTheArmedBar() + { + var app = App(); + app.RenderSnapshot("split"); + + foreach (var digit in new[] { 2, 1, 2 }) + { + app.SimulateKey(Alt(digit)); + app.RenderNextFrame(); + await Assert.That(app.ArmedBarHasFocus).IsTrue(); + } + } + + /// + /// And it moves no pane rectangle, so no connected world is told a new terminal size. Restated for this + /// chord for the reason FocusIndicationTests.MovingFocusDoesNotMoveAnyPaneRectangle exists: the + /// indicator recolours what is drawn and may never grow a cell. + /// + [Test] + public async Task JumpingMovesNoPaneRectangle() + { + var app = App(); + app.RenderSnapshot("split"); + var before = app.PaneOutputRects().ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); + + app.SimulateKey(Alt(2)); + app.RenderNextFrame(); + app.SimulateKey(Alt(1)); + app.RenderNextFrame(); + + var after = app.PaneOutputRects(); + await Assert.That(after.Count).IsEqualTo(before.Count); + foreach (var (paneId, rect) in before) + { + await Assert.That(after[paneId]).IsEqualTo(rect); + } + } + + // --- zoom --------------------------------------------------------------------------------------- + + /// + /// A zoom follows the jump. A zoomed workspace realises exactly one pane, so a mover that + /// changed the selection and left the zoom where it was would put the selection, the session and the + /// caret on a pane that is not on the screen. ⌥2 over a zoomed pane 1 therefore shows pane 2 zoomed — + /// the pane you asked for is the one filling the screen — and ⌃B z still un-zooms. + /// + [Test] + public async Task JumpingWhileZoomedBringsTheTargetToTheFrontRatherThanHidingIt() + { + var app = App(); + app.RenderSnapshot("split"); + var first = app.FocusedPaneId; + var second = app.PaneIds.Single(id => id != first); + + await Assert.That(app.DispatchCommand("layout:zoom")).IsTrue(); + await Assert.That(app.ZoomedPaneId).IsEqualTo(first); + + app.SimulateKey(Alt(2)); + var frame = app.RenderWholeFrame(); + + await Assert.That(app.FocusedPaneId).IsEqualTo(second); + await Assert.That(app.ZoomedPaneId) + .IsEqualTo(second) + .Because("the pane jumped to has to be the one that is rendered"); + + // And it is genuinely still a zoom: one pane is realised, and it is the one selected. + var rects = app.PaneOutputRects(); + await Assert.That(rects.ContainsKey(second)).IsTrue(); + await Assert.That(rects.ContainsKey(first)).IsFalse(); + + var (focused, _) = app.PaneBandColors; + await Assert.That(CellsPaintedIn(frame, rects[second], focused)).IsGreaterThan(0); + } + + /// + /// ⌃O is the other ordinal mover and gets the same rule, so the two cannot come to mean different + /// things — it used to cycle the selection out from under a zoom and leave it invisible. + /// + [Test] + public async Task CyclingWhileZoomedCarriesTheZoomToo() + { + var app = App(); + app.RenderSnapshot("split"); + var first = app.FocusedPaneId; + + await Assert.That(app.DispatchCommand("layout:zoom")).IsTrue(); + await Assert.That(app.DispatchCommand("layout:cycle")).IsTrue(); + + await Assert.That(app.FocusedPaneId).IsNotEqualTo(first); + await Assert.That(app.ZoomedPaneId).IsEqualTo(app.FocusedPaneId); + } + + // --- one spelling of one pane ------------------------------------------------------------------- + + /// + /// The move overlay and the sidebar call the first pane the same thing. The overlay used to call + /// it main — the spelling the rail abandoned because it collided with the window named + /// main — so the same pane was pane 1 in the sidebar and main under the cursor. ⌥1 is a + /// chord that lands on a pane a label names, and it cannot survive two labels. + /// + [Test] + public async Task TheMoveOverlayAndTheSidebarSpellTheFirstPaneTheSameWay() + { + var app = App(); + app.RenderSnapshot("split"); + + // ⌃B m lifts the active window; '1' targets the first pane — the same digit ⌥1 uses. + app.SimulateKey(Ctrl(ConsoleKey.B)); + app.SimulateKey(Plain('m', ConsoleKey.M)); + app.SimulateKey(Plain('1', ConsoleKey.D1)); + + 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(); + + app.SimulateKey(Plain('\x1b', ConsoleKey.Escape)); // leave move mode + } + + /// + /// Move mode targets by the same number as everything else. It used to letter the panes + /// aj while the prompt one line below named the target pane N — one ordering + /// spelt in two alphabets, which meant translating B into pane 2 in your head to use + /// the feature the prompt was explaining. + /// + /// Driven for every pane: press the digit, and the target the prompt reports is the pane the rail + /// numbers with that digit. The prompt is read rather than the badge because the prompt is what + /// names the pane in words; the badge is asserted separately by the digit having worked at all — an + /// unmapped digit leaves the target alone, so a wrong badge cannot produce a right prompt. + /// + /// + [Test] + public async Task MoveModeTargetsThePaneTheRailNumbersWithTheSameDigit() + { + var three = await ThreePanes(); + + three.App.SimulateKey(Ctrl(ConsoleKey.B)); + three.App.SimulateKey(Plain('m', ConsoleKey.M)); + + for (var n = 1; n <= 3; n++) + { + three.App.SimulateKey(Plain((char)('0' + n), ConsoleKey.D0 + n)); + + await Assert.That(three.App.StatusMarkup) + .Contains($"pane {n}") + .Because($"pressing {n} in move mode must target the pane the sidebar calls pane {n}"); + } + + // A digit past the last pane leaves the target where it was rather than clearing it. + three.App.SimulateKey(Plain('9', ConsoleKey.D9)); + await Assert.That(three.App.StatusMarkup) + .Contains("pane 3") + .Because("an out-of-range digit must not drop the target you already picked"); + + // And the letters are gone: 'b' is not a pane picker any more. + three.App.SimulateKey(Plain('b', ConsoleKey.B)); + await Assert.That(three.App.StatusMarkup).Contains("pane 3"); + + three.App.SimulateKey(Plain('\x1b', ConsoleKey.Escape)); + } + + // --- honesty ------------------------------------------------------------------------------------ + + /// + /// --help names the chord that works and says why the one that was asked for is absent. Both + /// halves: a page that named Ctrl+digit would send a reader to press Escape and Backspace. + /// + [Test] + public async Task HelpNamesAltDigitAndSaysWhyNotCtrlDigit() + { + var help = Program.UsageText; + + await Assert.That(help).Contains("Alt+1..Alt+9"); + await Assert.That(help).Contains("Ctrl+digit is not"); + await Assert.That(help).Contains("Escape and"); + } + + /// + /// F4 reports each of the nine as taken, and the sentence it prints names the pane the chord goes to — + /// so a user who tried to bind a macro there is told what has it, not merely that something does. + /// + [Test] + public async Task TheKeypadScreenSaysWhatHasEachDigit() + { + for (var n = 1; n <= 9; n++) + { + var verdict = MacroKeys.Verdict($"Alt+{n}"); + + await Assert.That(verdict.Delivery).IsEqualTo(MacroKeyDelivery.Taken); + await Assert.That(verdict.Reason).Contains($"pane {n}"); + } + } + + /// + /// And every Ctrl+digit is reported as never arriving, each with the byte the terminal really sends — + /// observed on a pty, not assumed. Three of them are keys this client cannot afford to bind over. + /// + [Test] + [Arguments(0, "bare 0")] + [Arguments(1, "bare 1")] + [Arguments(2, "NUL")] + [Arguments(3, "Escape")] + [Arguments(8, "Backspace")] + [Arguments(9, "bare 9")] + public async Task CtrlDigitIsReportedAsUnreachableWithTheByteTheTerminalSends(int digit, string byteName) + { + var verdict = MacroKeys.Verdict($"Ctrl+{digit}"); + + await Assert.That(verdict.Delivery).IsEqualTo(MacroKeyDelivery.NeverArrives); + await Assert.That(verdict.Reason).Contains(byteName); + } + + // --- harness ------------------------------------------------------------------------------------ + + private static SharpMUTermApp App(int width = 120, int height = 34) + { + Console.SetIn(TextReader.Null); + return new SharpMUTermApp(DemoScene.Build(), Headless, new HeadlessConsoleDriver(width, height)); + } + + /// The chord as the terminal delivers it: ESC + the digit, decoded as that digit with Alt. + private static ConsoleKeyInfo Alt(int digit) => + new((char)('0' + digit), ConsoleKey.D0 + digit, false, true, false); + + private static ConsoleKeyInfo Ctrl(ConsoleKey key) => new('\0', key, false, false, true); + + private static ConsoleKeyInfo Plain(char c, ConsoleKey key) => new(c, key, false, false, false); + + /// Empties the armed command line and types into it, key by key. + private static void Type(SharpMUTermApp app, string text) + { + app.SimulateKey(Ctrl(ConsoleKey.E)); + app.SimulateKey(Ctrl(ConsoleKey.U)); + foreach (var c in text) + { + app.SimulateKey(Plain(c, ConsoleKey.NoName)); + } + } + + private static void Send(SharpMUTermApp app, string line) + { + Type(app, line); + app.SimulateKey(Plain('\r', ConsoleKey.Enter)); + } + + /// + /// The pane label the rail is drawing for the active character's window, e.g. pane 2. + /// Read out of the rendered rows rather than recomputed, because the whole assertion is that the chord + /// and the sidebar agree — a re-derived label would agree with itself. + /// + /// Window rows are picked out by their bullet, because character rows now carry the same + /// column: the rail says which pane every character is in, active or not, which is what makes the + /// numbering readable from a character other than the one you are looking at. Taking the first + /// pane N on any row would read the active character's own row and answer 1 for ever. + /// + /// + private static string RailPaneLabel(SharpMUTermApp app) + { + foreach (var line in app.RailLines.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)..]; + } + + break; + } + } + + return cells; + } + + /// How many cells inside a rectangle are painted in a given background. + private static int CellsPaintedIn(string ansi, PaneRect rect, SharpConsoleUI.Color colour) + { + var wanted = Sgr(colour); + var cells = Backgrounds(ansi); + var count = 0; + for (var y = rect.Y; y < rect.Y + rect.Height; y++) + { + for (var x = rect.X; x < rect.X + rect.Width; x++) + { + if (cells.GetValueOrDefault((y, x))?.StartsWith(wanted, StringComparison.Ordinal) == true) + { + count++; + } + } + } + + return count; + } + + /// Three panes side by side, one connected character each, in a known order. + private sealed record Three( + SharpMUTermApp App, + IReadOnlyList Windows, + IReadOnlyList Sessions, + IReadOnlyList Transports); + + /// + /// A resumed workspace of three panes, each holding one character's window, built the way the + /// shell restores one. Three separate worlds so each session's writes are attributable by host — the + /// suite turns on which transport a line reached, and one world's characters would share one. + /// + /// The pane order is the split tree's, which is what Layout.Panes enumerates and what the rail + /// numbers; the windows are placed p1/p2/p3 so the expected pairing is stated once, here, and every + /// assertion afterwards reads the rail rather than restating it. + /// + /// + private static async Task ThreePanes() + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + var names = new[] { ("Alfa", "Ann"), ("Bravo", "Bob"), ("Cara", "Cal") }; + foreach (var (world, character) in names) + { + var definition = new WorldDefinition + { + Name = world, + Host = $"{world.ToLowerInvariant()}.example.org", + Port = 4000, + }; + definition.Characters.Add(new CharacterDefinition { Name = character, Logging = new LoggingSettings() }); + config.Worlds.Add(definition); + } + + var windows = new[] { "main", "char:Bravo.Bob", "char:Cara.Cal" }; + var sessions = names.Select(n => $"{n.Item1}.{n.Item2}").ToArray(); + + config.LastSession = new WorkspaceState + { + Windows = + { + new WorkspaceWindowState + { + Id = windows[0], Title = "Ann", Kind = WindowKind.Main, SessionKey = sessions[0], + }, + new WorkspaceWindowState + { + Id = windows[1], Title = "Bob", Kind = WindowKind.Main, SessionKey = sessions[1], + }, + new WorkspaceWindowState + { + Id = windows[2], Title = "Cal", Kind = WindowKind.Main, SessionKey = sessions[2], + }, + }, + Root = new LayoutNodeState + { + Type = "split", + Direction = SplitDirection.Row, + Children = + { + new LayoutNodeState { Type = "pane", Id = "p1", Tabs = { windows[0] }, ActiveIndex = 0 }, + new LayoutNodeState { Type = "pane", Id = "p2", Tabs = { windows[1] }, ActiveIndex = 0 }, + new LayoutNodeState { Type = "pane", Id = "p3", Tabs = { windows[2] }, ActiveIndex = 0 }, + }, + }, + FocusedPaneId = "p1", + }; + + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(Width, Height)); + var transports = new[] + { + new RecordingTelnetSession(), new RecordingTelnetSession(), new RecordingTelnetSession(), + }; + app.TelnetFactory = options => options.Host[0] switch + { + 'a' => transports[0], + 'b' => transports[1], + _ => transports[2], + }; + + foreach (var key in sessions) + { + if (!app.DispatchCommand(CommandIds.Character(key))) + { + throw new InvalidOperationException($"the app would not switch to {key}"); + } + + await app.FindSession(key)!.ConnectAsync(); + } + + app.RenderNextFrame(); + + // The fixture's own claim: three panes, in the order the windows were placed. Everything after + // this reads the rail, so a resumed layout that came back differently must fail here and not + // silently make the assertions vacuous. + if (app.PaneIds.Count != 3) + { + throw new InvalidOperationException($"the resumed workspace has {app.PaneIds.Count} panes, not 3"); + } + + for (var i = 0; i < 3; i++) + { + if (app.PaneIdOf(windows[i]) != app.PaneIds[i]) + { + throw new InvalidOperationException( + $"{windows[i]} is in {app.PaneIdOf(windows[i])}, not the {i + 1}th pane {app.PaneIds[i]}"); + } + } + + return new Three(app, windows, sessions, transports); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/PaneNumberingRailTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneNumberingRailTests.cs new file mode 100644 index 00000000..0b19960d --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/PaneNumberingRailTests.cs @@ -0,0 +1,312 @@ +using System.Text.RegularExpressions; +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Workspaces; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// The pane numbering is readable from any character, not only the one you are in. +/// +/// ⌥N has always been global — JumpToPane indexes the workspace's one split tree, not the active +/// character's windows — so ⌥3 already reached a pane holding somebody else's session. The rail did not +/// say so. Window rows are listed for the active character only (BuildRailWindows's owner +/// filter, which is load-bearing: a window row under a character means that window is theirs), so a reader +/// looking at Ann saw pane 1 and nothing else while Bob and Cal sat in panes 2 and 3 with chords +/// pointing at them. A number that cannot be read off the screen is a number nobody presses, which is what +/// "pane numbering should be global" was reporting. +/// +/// +/// The fix is one column on a row that already exists: every character row carries the pane its session is +/// in. No new rows, no other character's windows listed under yours, and the same pane N vocabulary +/// the window rows, the ⌃P entries, the move overlay and the chord already use. +/// +/// +/// +/// Serialised with the other end-to-end suites: constructing the app and rendering a frame both touch the +/// process-global console streams. +/// +[NotInParallel] +public class PaneNumberingRailTests +{ + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + /// + /// The claim. Ann is active; the rail names the pane every one of the three characters is in. + /// Before this it named exactly one, and it was always Ann's. + /// + [Test] + public async Task TheRailNamesThePaneOfEveryCharacterAndNotJustTheActiveOne() + { + var app = await ThreePanes(); + app.SimulateKey(Alt(1)); + app.RenderNextFrame(); + + await Assert.That(app.ActiveSessionKey).IsEqualTo("Alfa.Ann"); + await Assert.That(CharacterPane(app, "Ann")).IsEqualTo("⌥" + "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/PanePrefixEndToEndTests.cs b/tests/SharpMUTerm.Tui.Tests/PanePrefixEndToEndTests.cs index 3014cf3c..a6217733 100644 --- a/tests/SharpMUTerm.Tui.Tests/PanePrefixEndToEndTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/PanePrefixEndToEndTests.cs @@ -41,6 +41,64 @@ private static SharpMUTermApp OneTab() return new SharpMUTermApp(new AppConfiguration(), Headless, new HeadlessConsoleDriver(Width, Height)); } + private static readonly string ChatId = Workspace.SpawnWindowId("Chat"); + + private static readonly string OocId = Workspace.SpawnWindowId("OOC"); + + /// + /// One pane holding three tabs — main, a Chat spawn and an OOC spawn — with the tab at + /// the visible one. Three is the smallest strip that has a + /// middle, and a middle is the only place a reorder can move a tab without it also + /// arriving at an end; with two tabs every legal move lands at one, which is what let the strip + /// and the model disagree for as long as they did. + /// + private static SharpMUTermApp ThreeTabs(int activeIndex) + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration + { + LastSession = new WorkspaceState + { + Windows = + { + new WorkspaceWindowState { Id = "main", Title = "Main", Kind = WindowKind.Main }, + new WorkspaceWindowState { Id = ChatId, Title = "Chat", Kind = WindowKind.Spawn }, + new WorkspaceWindowState { Id = OocId, Title = "OOC", Kind = WindowKind.Spawn }, + }, + Root = new LayoutNodeState + { + Type = "pane", + Id = "p1", + Tabs = { "main", ChatId, OocId }, + ActiveIndex = activeIndex, + }, + FocusedPaneId = "p1", + }, + }; + + return new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(Width, Height)); + } + + /// The window ids of , by the short names the test cases name them. + private static string WindowId(string shortName) => shortName switch + { + "chat" => ChatId, + "ooc" => OocId, + _ => "main", + }; + + /// The inverse: a window id as the short name a test case spells it. + private static string ShortName(string windowId) => + windowId == ChatId ? "chat" : windowId == OocId ? "ooc" : windowId; + + /// + /// A tab order as one comparable string, which is the whole reason it is a string: TUnit's + /// IsEquivalentTo defaults to order-insensitive collection equivalence, so + /// [a,b] and [b,a] satisfy it — and a reorder assertion that cannot see order is not + /// an assertion at all. + /// + private static string Order(IEnumerable windowIds) => string.Join(",", windowIds.Select(ShortName)); + private static ConsoleKeyInfo Key(char c) => new(c, ConsoleKey.NoName, false, false, false); private static ConsoleKeyInfo Arrow(ConsoleKey key) => new('\0', key, false, false, false); @@ -86,7 +144,9 @@ public async Task ThePrefixThenAngleBracket_ReordersTheActiveTab() app.SimulatePrefixedKey(Key('>')); - await Assert.That(Panes(app).Single().Tabs).IsEquivalentTo(new[] { before[1], before[0] }); + await Assert.That(Order(Panes(app).Single().Tabs)).IsEqualTo(Order(new[] { before[1], before[0] })); + await Assert.That(Order(app.PaneTabStrip("p1").Select(t => t.WindowId))) + .IsEqualTo(Order(new[] { before[1], before[0] })); } /// @@ -100,10 +160,58 @@ public async Task ThePrefixThenAnArrow_ReordersTheActiveTabJustLikeTheAngleBrack var before = Panes(app).Single().Tabs.ToList(); app.SimulatePrefixedKey(Arrow(ConsoleKey.RightArrow)); - await Assert.That(Panes(app).Single().Tabs).IsEquivalentTo(new[] { before[1], before[0] }); + await Assert.That(Order(Panes(app).Single().Tabs)).IsEqualTo(Order(new[] { before[1], before[0] })); + await Assert.That(Order(app.PaneTabStrip("p1").Select(t => t.WindowId))) + .IsEqualTo(Order(new[] { before[1], before[0] })); app.SimulatePrefixedKey(Arrow(ConsoleKey.LeftArrow)); - await Assert.That(Panes(app).Single().Tabs).IsEquivalentTo(before); + await Assert.That(Order(Panes(app).Single().Tabs)).IsEqualTo(Order(before)); + await Assert.That(Order(app.PaneTabStrip("p1").Select(t => t.WindowId))).IsEqualTo(Order(before)); + } + + /// + /// The reorder has to move the strip the user is looking at, not only the model behind it. + /// Three tabs, both directions, from all three positions — the grid the report only named one cell + /// of ("3 tabs, move the middle one right, it says it is already at the end"). + /// + /// The refusal was reading a model the screen had stopped agreeing with. TabControl cannot + /// move a page — TabPages is a copy and the only mutators add — so the old + /// RefreshTabTitles, which repaints each page by its own Tag, left the strip in its + /// original order after every reorder. The move looked like a no-op, and the press after it refused + /// truthfully about a model that had the tab at the end and falsely about a strip that showed it in + /// the middle. Asserting the *strip* is the point of this test: the model half passed throughout. + /// + /// + [Test] + [Arguments(0, '<', "main,chat,ooc", "main", true)] + [Arguments(0, '>', "chat,main,ooc", "main", false)] + [Arguments(1, '<', "chat,main,ooc", "chat", false)] + [Arguments(1, '>', "main,ooc,chat", "chat", false)] + [Arguments(2, '<', "main,ooc,chat", "ooc", false)] + [Arguments(2, '>', "main,chat,ooc", "ooc", true)] + public async Task ReorderingATabMovesItOnTheStripAndRefusesOnlyAtARealEnd( + int activeIndex, char key, string expected, string moved, bool refused) + { + var app = ThreeTabs(activeIndex); + + app.SimulatePrefixedKey(Key(key)); + + // The model, the strip on screen, and which tab is still the active one — all three, because the + // bug was exactly the model moving while the other two stayed where they were. + await Assert.That(Order(Panes(app).Single().Tabs)).IsEqualTo(expected); + await Assert.That(Order(app.PaneTabStrip("p1").Select(t => t.WindowId))).IsEqualTo(expected); + await Assert.That(app.PaneActiveTab("p1")).IsEqualTo(WindowId(moved)); + + // Loud either way: a tab genuinely at the end still says so, and one that had somewhere to go + // must not be told it did not. + if (refused) + { + await Assert.That(app.StatusMarkup).Contains("already at that end"); + } + else + { + await Assert.That(app.StatusMarkup).DoesNotContain("already at that end"); + } } /// Any other key spends the prefix and does nothing — the next key is typing again. diff --git a/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs b/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs index 2377c178..3ea0197f 100644 --- a/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs @@ -77,7 +77,8 @@ await Assert.That(windows.Select(r => r.TrimEnd()).Select(r => r.TrimStart()).To /// /// The two columns never wear the same word. The hosting-pane column called the first pane /// "main" too, so a row could read ▪ main main — the naive fix for the label, and two different - /// meanings in one line. Panes are spelt pane N, which no window title is. + /// meanings in one line. The sidebar spells a pane ⌥N — the chord that goes there — which no + /// window title is, and which is four cells narrower than the words it replaced. /// [Test] public async Task TheHostingPaneColumnNeverRepeatsTheWindowsOwnName() @@ -91,7 +92,7 @@ public async Task TheHostingPaneColumnNeverRepeatsTheWindowsOwnName() foreach (var row in windows) { - await Assert.That(row).Contains("pane "); + await Assert.That(row).Contains("⌥"); await Assert.That(row.Trim()).IsNotEqualTo("▪ main main"); } } @@ -109,18 +110,20 @@ public async Task WithOnePaneTheHostingColumnIsNotDrawn() await Assert.That(app.PaneIds.Count).IsEqualTo(1); foreach (var row in Rail(app).Where(r => r.TrimStart().StartsWith("▪", StringComparison.Ordinal))) { - await Assert.That(row).DoesNotContain("pane "); + await Assert.That(row).DoesNotContain("⌥"); } // The rows do end in blanks now, and that is the reserved badge fields rather than slack — so the // claim this used to make with DoesNotEndWith(" ") is made by width instead, which is the thing // that actually mattered: a single-pane rail must not pay for a column with nothing in it. (It // used to: three spaces were emitted unconditionally and the sidebar was three cells wider.) + // The column is now `⌥N` behind a single space rather than `pane N` behind two, so what it costs + // when it *is* drawn is three cells, not seven. var single = MainWindowRowWidth(app); await Assert.That(app.DispatchCommand("layout:split-right")).IsTrue(); app.RenderNextFrame(); - await Assert.That(Rail(app).Single(MainRow)).Contains("pane "); + await Assert.That(Rail(app).Single(MainRow)).Contains("⌥"); await Assert.That(MainWindowRowWidth(app)).IsGreaterThan(single); } diff --git a/tests/SharpMUTerm.Tui.Tests/RestoreBarRendererTests.cs b/tests/SharpMUTerm.Tui.Tests/RestoreBarRendererTests.cs new file mode 100644 index 00000000..cd9a9cfa --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/RestoreBarRendererTests.cs @@ -0,0 +1,68 @@ +namespace SharpMUTerm.Tui.Tests; + +/// +/// The one row that tells a reader where the previous session's content ends. Its whole job is to be +/// unambiguous about a boundary, so what it says — and what it must not accidentally say — is worth +/// pinning apart from the end-to-end restore. +/// +public class RestoreBarRendererTests +{ + private const string Accent = "#c678dd"; + + private static readonly DateTimeOffset Whenever = new(2026, 7, 30, 15, 27, 0, TimeSpan.Zero); + + [Test] + public async Task ItNamesTheBoundaryTheCountAndWhen() + { + var bar = RestoreBarRenderer.Bar(128, Whenever, Accent); + + await Assert.That(bar).Contains(RestoreBarRenderer.Label); + await Assert.That(bar).Contains("128 lines from the previous session"); + await Assert.That(bar).Contains(Whenever.ToLocalTime().ToString("d MMM HH:mm")); + await Assert.That(bar).Contains(Accent); + } + + /// + /// A pane holding exactly one restored line must not read "1 lines". Small, and the sort of thing + /// that survives for years in a string nobody tests because it only appears in one state. + /// + [Test] + public async Task OneLineIsSingular() + { + await Assert.That(RestoreBarRenderer.Bar(1, Whenever, Accent)).Contains("1 line from"); + await Assert.That(RestoreBarRenderer.Bar(1, Whenever, Accent)).DoesNotContain("1 lines"); + } + + /// + /// The bar ends in a rule, the way the freeze bar does, so it reads as a divider rather than as a + /// line of output. The two mark the same pane for related reasons and should not look like different + /// kinds of thing. + /// + [Test] + public async Task ItEndsInARuleLikeTheFreezeBar() + { + await Assert.That(RestoreBarRenderer.Bar(3, Whenever, Accent)).EndsWith("────[/]"); + await Assert.That(FreezeBarRenderer.Bar(Accent)).EndsWith("────[/]"); + } + + /// + /// Nothing in it comes from a world, so nothing in it can carry markup — but the date is + /// culture-formatted and a locale's month abbreviation is not this code's to vouch for, so it goes + /// through the same escape every other composed row does. Asserted by feeding a culture whose date + /// format is unusual and checking the row still has exactly the tags it was built with. + /// + [Test] + public async Task ItCarriesOnlyTheTagsItBuilt() + { + var bar = RestoreBarRenderer.Bar(9, Whenever, Accent); + + // Two opening tags (the accent and the dim) and two closes, and no stray bracket anywhere else. + await Assert.That(bar.Count(c => c == '[')).IsEqualTo(4); + await Assert.That(bar.Count(c => c == ']')).IsEqualTo(4); + } + + [Test] + public async Task ItRefusesAnEmptyAccent() => + await Assert.That(() => RestoreBarRenderer.Bar(1, Whenever, string.Empty)) + .Throws(); +} diff --git a/tests/SharpMUTerm.Tui.Tests/RestoreLogEndToEndTests.cs b/tests/SharpMUTerm.Tui.Tests/RestoreLogEndToEndTests.cs new file mode 100644 index 00000000..6598a525 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/RestoreLogEndToEndTests.cs @@ -0,0 +1,505 @@ +using System.Diagnostics; +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Automation; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Session; +using SharpMUTerm.Core.Text; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// Panes coming back with their previous session's content, driven the whole distance: server bytes into +/// a connected world, through its triggers into a main window and a spawn window, out to the restore log +/// on disk, and back into a second app's panes — which is the only arrangement in which the +/// claim "restarting the client does not empty every pane" can actually be made. +/// +/// The premise the design rests on is pinned first +/// (): a spawn window's lines are not +/// in , so a restore built on session scrollback would bring the +/// main windows back and leave every channel pane empty. That is the whole reason the log is keyed by +/// window id and written from the shell rather than from the session. +/// +/// +/// +/// Serialised with the other end-to-end suites: constructing the app and rendering a frame both touch +/// the process-global console streams. +/// +[NotInParallel] +public class RestoreLogEndToEndTests +{ + private const int Width = 160; + private const int Height = 40; + private const string MainWindow = "main"; + private const string ChatWindow = "spawn:Chat"; + + /// The startup ceiling asserted below — see that test for the measured figure it guards. + private const int Ceiling = 400; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + /// A throwaway restore-log root, removed however the test ends. + private sealed class TempRoot : IDisposable + { + public TempRoot() => + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"smuterm-restore-e2e-{Guid.NewGuid():N}"); + + public string Path { get; } + + public IReadOnlyList Files => + System.IO.Directory.Exists(Path) ? System.IO.Directory.GetFiles(Path) : Array.Empty(); + + public void Dispose() + { + try + { + if (System.IO.Directory.Exists(Path)) + { + System.IO.Directory.Delete(Path, recursive: true); + } + } + catch (Exception) + { + // Nothing a test should fail over. + } + } + } + + // ---- The premise ------------------------------------------------------------------------ + + /// + /// Why this feature could not have been built on session scrollback. A capture rule that gags + /// its line — the ordinary way a channel is given a window of its own and kept out of the main + /// stream — puts that line in the spawn pane and in no session's transcript at all. Restoring + /// would therefore refill the main window and hand the reader + /// an empty Chat pane, which is precisely the failure the whole thing exists to remove. + /// + [Test] + public async Task ASpawnWindowsContentNeverReachesTheSessionsScrollback() + { + using var root = new TempRoot(); + await using var run = await Session(root); + + run.Receive("[Chat] Rivane: anyone up for the crypt run?\n"); + run.Receive("The Grand Plaza.\n"); + + // The pane the reader is looking at has it... + await Assert.That(string.Join("\n", run.App.PaneLines(ChatWindow))).Contains("crypt run"); + + // ...and the session's own scrollback — the only per-session history there is — does not. + var scrollback = string.Join("\n", run.Session.Scrollback.Snapshot().Select(l => l.Text)); + await Assert.That(scrollback).DoesNotContain("crypt run"); + await Assert.That(scrollback).Contains("The Grand Plaza"); // the gag is the rule's, not a bug + } + + // ---- The headline ----------------------------------------------------------------------- + + /// + /// The user's ask, end to end: quit, start again, and every pane is holding what it held — the main + /// window and the spawn window, which is the half a session-scrollback design would have + /// dropped. + /// + [Test] + public async Task EveryPaneComesBackIncludingASpawnWindow() + { + using var root = new TempRoot(); + var config = Configuration(); + + await using (var first = await Session(root, config)) + { + first.Receive("[Chat] Rivane: anyone up for the crypt run?\n"); + first.Receive("The Grand Plaza.\n"); + config.LastSession = first.App.CaptureSession(); + } + + await using var second = await Restarted(root, config); + + await Assert.That(string.Join("\n", second.App.PaneLines(MainWindow))).Contains("The Grand Plaza"); + await Assert.That(string.Join("\n", second.App.PaneLines(ChatWindow))).Contains("crypt run"); + } + + /// + /// Restored content is marked, so a reader can tell where the previous session ended — one bar, at + /// the boundary, below the restored lines and above whatever arrives next. The lines are + /// left alone deliberately: restoring is only worth doing if the game's own colours come back with + /// the text, so the boundary is what is drawn, not the content. + /// + [Test] + public async Task TheRestoreBarMarksWhereThePreviousSessionEndedAndNothingElse() + { + using var root = new TempRoot(); + var config = Configuration(); + + await using (var first = await Session(root, config)) + { + first.Receive("The Grand Plaza.\n"); + config.LastSession = first.App.CaptureSession(); + } + + await using var second = await Restarted(root, config); + second.Receive("A town guard stands watch.\n"); + var lines = second.App.PaneLines(MainWindow).ToList(); + + // Exactly one bar, once, however much arrives after it. + var bar = lines.FindIndex(l => l.Contains(RestoreBarRenderer.Label, StringComparison.Ordinal)); + await Assert.That(bar).IsGreaterThanOrEqualTo(0); + await Assert.That(lines.Count(l => l.Contains(RestoreBarRenderer.Label, StringComparison.Ordinal))) + .IsEqualTo(1); + + // Everything above it is the previous session and everything below it is this one — including + // the connect banner, which is this session announcing itself and belongs on its own side. + await Assert.That(lines[bar - 1]).Contains("The Grand Plaza"); + await Assert.That(string.Join("\n", lines[..bar])).DoesNotContain("A town guard stands watch."); + await Assert.That(string.Join("\n", lines[(bar + 1)..])).Contains("A town guard stands watch."); + await Assert.That(lines[^1]).Contains("A town guard stands watch."); + } + + // ---- The gate --------------------------------------------------------------------------- + + /// + /// An app handed no restore log writes none — the same gate save and logRoot have, for + /// the same reason. Every test and every snapshot in this repository gets that default, so a + /// --demo-config frame cannot spill the demo's panes into the developer's configuration + /// directory, nor restore the developer's panes into the demo's. + /// + [Test] + public async Task AnAppWithNoRestoreLogWritesNothing() + { + using var root = new TempRoot(); + await using var run = await Session(root, restore: null); + + run.Receive("[Chat] Rivane: anyone up for the crypt run?\n"); + run.Receive("The Grand Plaza.\n"); + + await Assert.That(string.Join("\n", run.App.PaneLines(ChatWindow))).Contains("crypt run"); + await Assert.That(Directory.Exists(root.Path)).IsFalse(); + } + + // ---- Where the log and the saved workspace disagree ------------------------------------- + + /// + /// A window in the log that LastSession no longer holds — a spawn pane that was closed before + /// quitting — must not throw, must not be lost, and must not be resurrected as a window nobody asked + /// for. It is buffered instead, so the moment that channel speaks again its pane opens already + /// holding its history. + /// + [Test] + public async Task AWindowTheSavedWorkspaceForgotComesBackWhenItsChannelSpeaksAgain() + { + using var root = new TempRoot(); + var config = Configuration(); + + await using (var first = await Session(root, config)) + { + first.Receive("[Chat] Rivane: anyone up for the crypt run?\n"); + + // Quit with the Chat pane closed: the log knows the window, the saved workspace does not. + config.LastSession = first.App.CaptureSession(); + config.LastSession.Windows.RemoveAll(w => w.Id == ChatWindow); + } + + await using var second = await Restarted(root, config); + await Assert.That(second.App.WindowIds()).DoesNotContain(ChatWindow); + + // One new line reopens the pane — and the history is already in it, above the new line. + second.Receive("[Chat] Bob: aye, meet me at the gate\n"); + var lines = second.App.PaneLines(ChatWindow).ToList(); + + await Assert.That(string.Join("\n", lines)).Contains("crypt run"); + await Assert.That(lines[^1]).Contains("meet me at the gate"); + } + + /// The other direction: a saved window with no log simply starts empty, and says nothing. + [Test] + public async Task ASavedWindowWithNothingLoggedStartsEmpty() + { + using var root = new TempRoot(); + var config = Configuration(); + + await using (var first = await Session(root, config)) + { + first.Receive("[Chat] Rivane: anyone up for the crypt run?\n"); + config.LastSession = first.App.CaptureSession(); + } + + // Drop only the main window's file, keeping Chat's. + foreach (var file in root.Files.Where(f => Path.GetFileName(f).StartsWith("main-", StringComparison.Ordinal))) + { + File.Delete(file); + } + + await using var second = await Restarted(root, config); + + await Assert.That(second.App.PaneLines(MainWindow) + .Any(l => l.Contains(RestoreBarRenderer.Label, StringComparison.Ordinal))).IsFalse(); + await Assert.That(string.Join("\n", second.App.PaneLines(ChatWindow))).Contains("crypt run"); + } + + // ---- Damage ----------------------------------------------------------------------------- + + /// + /// A log a crash left half-written must not take the client down with it. Every file is + /// truncated mid-record — the shape a kill leaves — and the client still starts, still restores the + /// whole lines that were there, and still marks them. + /// + [Test] + public async Task ATruncatedLogRestoresWhatIsReadableAndStillStarts() + { + using var root = new TempRoot(); + var config = Configuration(); + + await using (var first = await Session(root, config)) + { + for (var i = 1; i <= 8; i++) + { + first.Receive($"line {i} of the plaza\n"); + } + + config.LastSession = first.App.CaptureSession(); + } + + foreach (var file in root.Files) + { + using var stream = new FileStream(file, FileMode.Open, FileAccess.Write); + stream.SetLength(stream.Length - 9); // mid-record, not on a frame boundary + } + + await using var second = await Restarted(root, config); + var lines = second.App.PaneLines(MainWindow).ToList(); + var bar = lines.FindIndex(l => l.Contains(RestoreBarRenderer.Label, StringComparison.Ordinal)); + + // The whole lines before the cut came back, and the boundary still closes them off. + await Assert.That(bar).IsGreaterThanOrEqualTo(0); + await Assert.That(string.Join("\n", lines[..bar])).Contains("line 7 of the plaza"); + } + + /// And a directory of outright rubbish is startup-safe too, restoring nothing. + [Test] + public async Task RubbishInTheRestoreDirectoryIsStartupSafe() + { + using var root = new TempRoot(); + Directory.CreateDirectory(root.Path); + File.WriteAllText(Path.Combine(root.Path, "main-deadbeef.log"), "not a restore log at all"); + + await using var run = await Session(root); + + await Assert.That(run.App.PaneLines(MainWindow) + .Any(l => l.Contains(RestoreBarRenderer.Label, StringComparison.Ordinal))).IsFalse(); + } + + // ---- Opting out and purging ------------------------------------------------------------- + + /// + /// A character who turned restore off on F9 has nothing written — and anything an earlier, + /// opted-in run left behind is deleted on the next launch rather than merely left undrawn. An + /// opt-out that kept the file would be answering a different question from the one it was asked. + /// + [Test] + public async Task ACharacterThatOptedOutIsNeitherLoggedNorRestored() + { + using var root = new TempRoot(); + var config = Configuration(); + + await using (var first = await Session(root, config)) + { + first.Receive("The Grand Plaza.\n"); + config.LastSession = first.App.CaptureSession(); + } + + await Assert.That(root.Files.Count).IsGreaterThan(0); // there is something to opt out of + config.Worlds[0].Characters[0].Logging.RestoreLog = false; + + await using (var second = await Restarted(root, config)) + { + await Assert.That(second.App.PaneLines(MainWindow) + .Any(l => l.Contains(RestoreBarRenderer.Label, StringComparison.Ordinal))).IsFalse(); + + second.Receive("A town guard stands watch.\n"); + } + + // Neither the old content nor the new: the main window's file is gone and was not rewritten. + await Assert.That(root.Files.Any(f => Path.GetFileName(f).StartsWith("main-", StringComparison.Ordinal))) + .IsFalse(); + } + + /// + /// ⌃P ▸ Purge the restore log deletes what is on disk, now, and says how much it removed. It + /// does not blank the panes — what is already drawn is on the reader's screen — and it does not + /// switch the feature off, so the next line starts a fresh file. + /// + [Test] + public async Task ThePurgeCommandRemovesEverySavedPaneAndKeepsWorking() + { + using var root = new TempRoot(); + await using var run = await Session(root); + run.Receive("[Chat] Rivane: anyone up for the crypt run?\n"); + run.Receive("The Grand Plaza.\n"); + await Assert.That(root.Files.Count).IsEqualTo(2); + + await Assert.That(run.App.DispatchCommand("term:restore-purge")).IsTrue(); + + await Assert.That(root.Files).IsEmpty(); + await Assert.That(run.App.StatusMarkup).Contains("restore log purged"); + await Assert.That(string.Join("\n", run.App.PaneLines(MainWindow))).Contains("The Grand Plaza"); + + run.Receive("A town guard stands watch.\n"); + await Assert.That(root.Files.Count).IsEqualTo(1); + } + + /// The entry is offered whether or not anything is saved, so it is findable when wanted. + [Test] + public async Task ThePurgeEntryIsInTheCommandSurface() + { + using var root = new TempRoot(); + await using var run = await Session(root); + + await Assert.That(run.App.BuildCatalog().Select(c => c.Id)).Contains("term:restore-purge"); + } + + // ---- What it costs at startup ----------------------------------------------------------- + + /// + /// The startup cost of a full log, measured where it is actually paid: constructing the app, which + /// is what runs before the first frame. Six windows at the shipped 500-line bound — 3,000 lines, a + /// busier workspace than most people keep. + /// + /// What it costs, measured on this machine. Constructing the app with that log takes ~23 ms + /// against ~5.5 ms for the same app with no log, so restoring 3,000 lines costs about 18 ms, + /// or 6 µs a line. Reading and decoding them off the disk is only ~2.8 ms of that + /// (RestoreLogTests.AFullLogIsReadFastEnough…); the rest is rendering each line to markup and + /// buffering it, which is the same work the line cost when it first arrived. An ordinary two-window + /// workspace is nearer 6 ms. That is well under a frame and does not need deferring off the startup + /// path — but it is linear in the bound, which is half the argument for keeping the bound at 500. + /// + /// + /// The ceiling is far above the measured figure on purpose: this runs cold in the suite, and the + /// claim being defended is "not visible at startup", not a benchmark. It is still tight enough that + /// a regression into per-line I/O, an fsync per line, or a whole-buffer re-parse per restored line + /// would trip it by an order of magnitude. + /// + /// + [Test] + public async Task RestoringAFullLogCostsLittleEnoughToRunBeforeTheFirstFrame() + { + using var root = new TempRoot(); + var config = Configuration(); + var windows = new[] { MainWindow, ChatWindow, "spawn:OOC", "spawn:Tells", "spawn:Guild", "spawn:Events" }; + + using (var seed = new RestoreLog(root.Path, config.RestoreLog)) + { + foreach (var window in windows) + { + for (var i = 0; i < RestoreLogOptions.DefaultMaxLinesPerWindow; i++) + { + seed.Append( + window, + window, + StyledLine.FromText($"[{window}] Rivane says something of a typical length", TextStyle.Default), + "09:24"); + } + } + } + + using var log = new RestoreLog(root.Path, config.RestoreLog); + Console.SetIn(TextReader.Null); + + var clock = Stopwatch.StartNew(); + await using var app = new SharpMUTermApp( + config, Headless, new HeadlessConsoleDriver(Width, Height), restore: log); + clock.Stop(); + + await Assert.That(app.PaneLines(MainWindow).Count) + .IsEqualTo(RestoreLogOptions.DefaultMaxLinesPerWindow + 1); // + the boundary bar + await Assert.That(clock.ElapsedMilliseconds).IsLessThan(Ceiling); + } + + // ---- Harness ---------------------------------------------------------------------------- + + /// One connected world, its transport, and the app it prints into. + private sealed record Run(SharpMUTermApp App, RecordingTelnetSession Telnet, WorldSession Session, RestoreLog? Log) + : IAsyncDisposable + { + /// Delivers server text and renders, the way a live read loop and a frame do. + public void Receive(string text) + { + Telnet.Receive(text); + App.RenderNextFrame(); + } + + public async ValueTask DisposeAsync() + { + await App.DisposeAsync(); + Log?.Dispose(); + } + } + + /// + /// A configuration with one world, one character, and a capture rule that gags [Chat] into a + /// spawn window — the ordinary channel arrangement, and the one in which a spawn pane's content + /// exists nowhere but the pane. + /// + private static AppConfiguration Configuration() + { + var config = new AppConfiguration(); + config.TriggerSets.Add(new TriggerSet + { + Name = "chat", + Triggers = + { + new Trigger + { + Name = "chat", + Pattern = @"^\[Chat\]", + Actions = new TriggerActions { SpawnTarget = "Chat", Gag = true }, + }, + }, + }); + + config.Worlds.Add(new WorldDefinition + { + Name = "Aetherfall", + Host = "aetherfall.example.org", + Port = 4201, + Characters = + { + new CharacterDefinition { Name = "Corvid", Logging = new LoggingSettings(), TriggerSets = { "chat" } }, + }, + }); + + return config; + } + + /// Opens an app over with its one character connected. + private static Task Session(TempRoot root, AppConfiguration? config = null) => + Start(config ?? Configuration(), new RestoreLog(root.Path)); + + /// The same, but with an explicit (possibly null) log — for the "owns no restore log" gate. + private static Task Session(TempRoot root, RestoreLog? restore) => Start(Configuration(), restore); + + /// + /// A second launch over the same root and the same configuration: what the user does when they quit + /// and start the client again. + /// + private static Task Restarted(TempRoot root, AppConfiguration config) => + Start(config, new RestoreLog(root.Path)); + + private static async Task Start(AppConfiguration config, RestoreLog? restore) + { + Console.SetIn(TextReader.Null); + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(Width, Height), restore: restore); + var telnet = new RecordingTelnetSession(); + app.TelnetFactory = _ => telnet; + + if (!app.DispatchCommand(CommandIds.Character("Aetherfall.Corvid"))) + { + throw new InvalidOperationException("the app would not switch to Aetherfall.Corvid"); + } + + var session = app.FindSession("Aetherfall.Corvid")!; + await session.ConnectAsync(); + app.RenderNextFrame(); + return new Run(app, telnet, session, restore); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs b/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs index aad15477..c4d569a0 100644 --- a/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs @@ -463,18 +463,24 @@ public async Task Options_CommandLineHeightsRefuseValuesTheBarCannotHonour() } /// - /// Logging is two more fields of the character's own row — the format (whose None is "off", - /// so one control covers one stored value) and the folder. This replaces F9's screen, whose rows - /// edited whichever character happened to be active. + /// Logging is three more fields of the character's own row — the format (whose None is "off", + /// so one control covers one stored value), the folder, and restore. This replaces F9's + /// screen, whose rows edited whichever character happened to be active. /// - /// The count is seven because the password, the connect line and at start are fields of this + /// The count is eight because the password, the connect line and at start are fields of this /// row too, drawn between the name and the log values. The ordinals are addressed by name, which is /// what let them be inserted in drawn order rather than appended past the log values — see /// . /// + /// + /// restore is a separate switch from the format and not a fourth value of it, because the two + /// are different things: a transcript is a file the user keeps and reads, and the restore log is a + /// bounded tail nothing but the client's own startup ever opens. Turning one off has never implied + /// anything about the other. + /// /// [Test] - public async Task Worlds_TheCharacterRowCarriesItsLogFormatAndFolder() + public async Task Worlds_TheCharacterRowCarriesItsLogFormatFolderAndRestoreSwitch() { var worlds = Worlds(); var character = worlds[0].Characters[0]; @@ -482,7 +488,7 @@ public async Task Worlds_TheCharacterRowCarriesItsLogFormatAndFolder() var model = WorldsScreenRenderer.Model(worlds, Sets(), 0, 0); var row = model.RowAt(WorldsScreenRenderer.CharactersPane, 0); - await Assert.That(row.FieldCount).IsEqualTo(7); + await Assert.That(row.FieldCount).IsEqualTo(8); await Assert.That(row.FieldAt(WorldsScreenRenderer.CharacterNameField)!.Value.Get()).IsEqualTo("Kaz"); await Assert.That(row.FieldAt(WorldsScreenRenderer.LogFormatField)!.Value.Get()).IsEqualTo("Html"); await Assert.That(row.FieldAt(WorldsScreenRenderer.LogDirectoryField)!.Value.Get()).IsEqualTo("/logs/kaz"); @@ -490,6 +496,16 @@ public async Task Worlds_TheCharacterRowCarriesItsLogFormatAndFolder() // The format cycles like every other enum field, and None is how logging is turned off. await Assert.That(row.FieldAt(WorldsScreenRenderer.LogFormatField)!.Value.Choices) .IsEquivalentTo(new[] { "None", "Plain", "Html", "Both" }); + + // On by default, a closed two-value choice like `at start`, and it writes through to the + // character it was drawn for. + var restore = row.FieldAt(WorldsScreenRenderer.RestoreLogField)!.Value; + await Assert.That(restore.Get()).IsEqualTo(WorldsScreenRenderer.RestoreOn); + await Assert.That(restore.Choices) + .IsEquivalentTo(new[] { WorldsScreenRenderer.RestoreOn, WorldsScreenRenderer.RestoreOff }); + + restore.Set(WorldsScreenRenderer.RestoreOff); + await Assert.That(character.Logging.RestoreLog).IsFalse(); } /// diff --git a/tests/SharpMUTerm.Tui.Tests/TabActivityIndicatorTests.cs b/tests/SharpMUTerm.Tui.Tests/TabActivityIndicatorTests.cs new file mode 100644 index 00000000..1f4d6526 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/TabActivityIndicatorTests.cs @@ -0,0 +1,536 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using SharpConsoleUI.Drivers; +using SharpConsoleUI.Parsing; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Session; +using SharpMUTerm.Core.Workspaces; +using SharpMUTerm.Graphics; +using SharpMUTerm.Tui; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// The new-activity indicator on a pane's tab strip: how many lines a tab has that you have not seen, +/// and the tint that makes the tab itself say so. +/// +/// Most of this already existed and is not what these tests are for. Workspace.NoteActivity +/// has always kept the count, the sidebar has always drawn it, and TabTitles has always appended +/// (n). What was missing was that the count was uncapped where the sidebar's was capped — so one +/// number had two spellings — and that the tab carried no colour at all, which is the half of the signal +/// a reader sees without reading. +/// +/// +/// The test to keep above the others is . Per-pane NAWS is +/// derived from the pane rectangle, and unread arrives unbidden from the wire: an indicator that +/// cost a cell as it appeared, or another as the count took a second digit, would re-announce a new +/// terminal size to every connected server on a line of output nobody asked for, and the game would +/// reflow. That is the reported failure this repository keeps paying for, and it is why the sidebar's +/// badge sits in a reserved field. The tab strip needs no reserved field — see that test for why — but it +/// still has to prove it. +/// +/// +/// +/// Serialised for the same reason is: rendering a frame redirects the +/// process-global Console.Out, and the harness redirects Console.In. +/// +[NotInParallel] +public class TabActivityIndicatorTests +{ + private const int Width = 120; + private const int Height = 32; + private const string Main = "main"; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private static ConsoleKeyInfo Chord(ConsoleKey key, bool ctrl = false) => new('\0', key, false, false, ctrl); + + /// + /// The demo app with a session bound to the main window but no socket under it, so + /// WorldSession.PrintSystem drives the app's real line handler and therefore its real unread + /// accounting. The same shape OutputScrollbackTests.Bound uses, and for the same reason: a test + /// that set the counter itself would be asserting about a number this code does not read. + /// + private static (SharpMUTermApp App, WorldSession Session) Bound( + int width = Width, int height = Height, TimeProvider? clock = null) + { + Console.SetIn(TextReader.Null); + var config = DemoScene.Build(); + var app = clock is null + ? new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(width, height)) + : new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(width, height), clock); + return (app, app.BindWorldWithoutConnecting(config.Worlds[0])); + } + + /// + /// Scrolls the main window off its live tail and prints into it, so the count + /// accrues on a window that is visible. That arm is deliberate: it is the one + /// WorkspaceTests.NoteActivity_OnAVisibleWindowScrolledBack_StillBadges pins, and the one where + /// a tab can be both focused and unread — which is exactly where a tint could be mistaken for the + /// focus cue. + /// + private static void AccrueOnScrolledBackMain(SharpMUTermApp app, WorldSession session, int lines) + { + app.LoadLongScene(Main, SharpMUTermApp.ScrollbackSceneLines); + app.RenderNextFrame(); + app.RenderNextFrame(); // auto-scroll settles on the second frame; see SettleScroll + app.SimulateKey(Chord(ConsoleKey.PageUp)); + + for (var i = 0; i < lines; i++) + { + session.PrintSystem($"*** unseen {i}"); + } + + app.RenderNextFrame(); + } + + /// The label the main window's tab is actually carrying, off the strip the framework will draw. + private static string MainTabLabel(SharpMUTermApp app) => + app.PaneTabStrip(app.PaneIdOf(Main)!).Single(t => t.WindowId == Main).Title; + + // --- the title -------------------------------------------------------------------------------- + + /// + /// The asked-for shape, end to end and on a real app: Corvid (36) with thirty-six unread and + /// plain Corvid at zero — read off the tab the framework will draw. (The demo's main window is + /// titled after its character, because that is what a live BindSession writes; the requested + /// Mannaz (36) spelling is pinned on the formatter in .) + /// + /// The is on this label too, because this window's pane holds the focus. So the assertion is + /// on the label's printable width — the string itself is markup, and measuring it any other + /// way would be measuring the colour tag. + /// + /// + [Test] + public async Task ATabWithUnreadReadsItsNameAndCountAndLosesBothWhenCaughtUp() + { + var (app, session) = Bound(); + AccrueOnScrolledBackMain(app, session, 36); + + await Assert.That(app.UnreadOf(Main)).IsEqualTo(36); + await Assert.That(MainTabLabel(app)).Contains("Corvid (36)"); + await Assert.That(MarkupParser.StripLength(MainTabLabel(app))) + .IsEqualTo($"{Glyphs.FocusedPane} Corvid (36)".Length); + + app.SimulateKey(Chord(ConsoleKey.End, ctrl: true)); // back to the live tail: caught up + app.RenderNextFrame(); + + await Assert.That(app.UnreadOf(Main)).IsEqualTo(0); + await Assert.That(MainTabLabel(app)).IsEqualTo($"{Glyphs.FocusedPane} Corvid"); + } + + /// + /// The sidebar and the tab strip never print different numbers for one count. They are two views of + /// WorkspaceWindow.Unread and they format it through the same ; before + /// that, the rail capped at ninety-nine and the tab did not, so a busy channel read 99+ in one + /// place and (150) in the other. + /// + [Test] + [Arguments(3)] + [Arguments(99)] + [Arguments(150)] + public async Task TheSidebarAndTheTabPrintTheSameCount(int lines) + { + var (app, session) = Bound(); + AccrueOnScrolledBackMain(app, session, lines); + + var badge = UnreadBadge.Format(lines); + await Assert.That(app.UnreadOf(Main)).IsEqualTo(lines); + await Assert.That(MainTabLabel(app)).Contains($"({badge})"); + + // The rail's own rows carry the same badge, right-aligned in the field it reserves for one. + var railRows = app.RailLines + .Where(l => Regex.IsMatch(l, $@"\[{Regex.Escape(UnreadBadge.Tint)}\]\s*\d+\+?\[/\]")) + .ToList(); + await Assert.That(railRows).IsNotEmpty(); + foreach (var railRow in railRows) + { + await Assert.That(railRow) + .Contains($"[{UnreadBadge.Tint}]{badge.PadLeft(UnreadBadge.FieldWidth)}[/]"); + } + + // Once the cap has bitten, the uncapped number is on neither surface. This is the assertion that + // fails on a tab which formats the raw integer while the sidebar beside it says 99+. + var raw = lines.ToString(CultureInfo.InvariantCulture); + if (raw != badge) + { + await Assert.That(MainTabLabel(app)).DoesNotContain(raw); + await Assert.That(string.Concat(railRows)).DoesNotContain(raw); + } + } + + // --- the tint --------------------------------------------------------------------------------- + + /// + /// The tint is on the frame, on the tab strip's own row, and it is a foreground. Read off the + /// painted cells rather than off the label, because a colour tag can be emitted into a string that + /// nothing draws — which is precisely the way pane focus was once "indicated". + /// + [Test] + public async Task TheTintIsPaintedOnTheTabStripRow() + { + var (app, session) = Bound(); + + var before = Cells(app.RenderWholeFrame()); + await Assert.That(Tinted(before, StripRow(app), app)).IsEmpty(); + + AccrueOnScrolledBackMain(app, session, 36); + var after = Cells(app.RenderWholeFrame()); + + // The tinted run *is* the name and the count — it stops before the ▌ and before the strip's rule. + var tinted = Tinted(after, StripRow(app), app); + await Assert.That(string.Concat(tinted.Select(c => c.Char))).IsEqualTo("Corvid (36)"); + await Assert.That(RowText(after, StripRow(app))).Contains("Corvid (36)"); + } + + /// + /// The tint cannot be confused with the focus cue. Focus is said entirely in + /// backgrounds drawn from the theme's chrome family — the pane's plane and the chip behind the + /// active tab; activity is said in a foreground no plane in the workspace is ever painted in. + /// So the two are separate channels and the four combinations are four distinct looks. This checks the + /// one that would break a weaker design — a tab that is focused and unread — and asserts + /// legibility as a measured contrast on both planes a chip can have, rather than assuming it. + /// + [Test] + public async Task AFocusedTabWithUnreadShowsBothCuesAndTheyAreDifferentChannels() + { + var (app, session) = Bound(); + AccrueOnScrolledBackMain(app, session, 36); + + // This window's pane holds the focus, so its active chip is painted in the armed band. + await Assert.That(app.FocusedPaneId).IsEqualTo(app.PaneIdOf(Main)); + + var cells = Cells(app.RenderWholeFrame()); + var tinted = Tinted(cells, StripRow(app), app); + await Assert.That(tinted).IsNotEmpty(); + + var (focusedPlane, unfocusedPlane) = app.PaneBandColors; + var planes = new[] { focusedPlane, unfocusedPlane }.Select(c => Hex(c.R, c.G, c.B)).ToList(); + + // The tint is nothing the focus cue is ever painted in, and it stays clear of both planes by a + // wide margin — so it reads whichever pane the tab is in. + foreach (var plane in planes) + { + await Assert.That(plane).IsNotEqualTo(UnreadBadge.Tint); + await Assert.That(Contrast(UnreadBadge.Tint, plane)).IsGreaterThan(4.5); + } + + // The tinted cells are all drawn on one background — the focused chip's — which is what makes the + // point: same cells, two channels, and the background is doing the focus half on its own. + var chip = tinted.Select(c => c.Background).Distinct().ToList(); + await Assert.That(chip.Count).IsEqualTo(1); + await Assert.That(chip[0]).IsNotEqualTo(UnreadBadge.Tint); + await Assert.That(Contrast(UnreadBadge.Tint, chip[0]!)).IsGreaterThan(4.5); + } + + /// + /// The focus marker keeps its own colour. It says which pane holds the keyboard — a fact that is true + /// or false whatever the count is — so a line of output arriving may not recolour it, or the two + /// signals would be reporting each other. + /// + [Test] + public async Task TheFocusMarkerKeepsItsOwnColourWhileTheTabIsUnread() + { + var (app, session) = Bound(); + AccrueOnScrolledBackMain(app, session, 36); + await Assert.That(MainTabLabel(app)).StartsWith(Glyphs.FocusedPane); + + var cells = Cells(app.RenderWholeFrame()); + var marker = cells.Values.Single(c => c.Row == StripRow(app) && c.Char == Glyphs.FocusedPane[0]); + await Assert.That(marker.Foreground).IsNotEqualTo(UnreadBadge.Tint); + } + + // --- the NAWS trap ---------------------------------------------------------------------------- + + /// + /// Activity moves no pane rectangle — not when the badge appears out of nothing, not at 9 → 10 + /// when it takes a second digit, and not at 99 → 100 when it takes a third and the cap changes its + /// spelling. Every one of those is a width change on a label that arrives from the wire. + /// + /// And the strip is deliberately not given the sidebar's reserved-width treatment. The + /// two surfaces are laid out differently and the argument does not carry across. A rail row's width + /// feeds SharpMUTermApp.RailWidth, which sizes the sidebar's grid column, and the pane area is + /// what is left over — so there a badge that appears really does narrow every pane. A tab strip is a + /// TabControl arranged Fill + Stretch inside the pane it already fills; the + /// framework paints the labels left to right and then fills the rest of the header row out to the + /// pane's own edge (TabControl.Rendering), so a longer label moves the tabs beside it along a + /// row whose width was never a function of them. Reserving three cells per tab would cost real width + /// on every strip for ever — pushing a narrow pane's later tabs off the end — to prevent a reflow that + /// this layout cannot produce. So: measured, argued, and declined. This test is the proof, and it is + /// checked narrow as well as roomy because a one-cell change hides in a roomy default. + /// + /// + [Test] + [Arguments(160, 48)] + [Arguments(120, 34)] + [Arguments(100, 24)] + [Arguments(80, 20)] + public async Task ActivityMovesNoPaneRectangle(int width, int height) + { + var (app, session) = Bound(width, height); + app.RenderSnapshot("split"); // two panes, so the strip shares its row with a neighbour + app.LoadLongScene(Main, SharpMUTermApp.ScrollbackSceneLines); + app.RenderNextFrame(); + app.RenderNextFrame(); + app.SimulateKey(Chord(ConsoleKey.PageUp)); + app.RenderNextFrame(); + + await Assert.That(app.UnreadOf(Main)).IsEqualTo(0); + var before = app.PaneOutputRects().ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); + var railBefore = app.RailColumnWidth; + var widths = new List(); + + // Every boundary the badge crosses: nothing → 1, one digit → two, two → three, and past the cap. + foreach (var stop in new[] { 1, 9, 10, 99, 100, 150 }) + { + while (app.UnreadOf(Main) < stop) + { + session.PrintSystem("*** unseen"); + } + + app.RenderNextFrame(); + await Assert.That(app.UnreadOf(Main)).IsEqualTo(stop); + widths.Add(MarkupParser.StripLength(MainTabLabel(app))); + + await Assert.That(app.RailColumnWidth).IsEqualTo(railBefore); + foreach (var (paneId, rect) in before) + { + await Assert.That(app.PaneOutputRects()[paneId]).IsEqualTo(rect); + } + } + + // The label really did change width on the way — otherwise this passes on an indicator that + // never appeared, which is how a NAWS test quietly stops testing anything. + await Assert.That(widths.Distinct().Count()).IsGreaterThan(1); + + // …and stopped changing at the cap, which is the bound the sidebar's field also relies on. + await Assert.That(widths[^1]).IsEqualTo(widths[^2]); + } + + /// + /// The same claim on the bytes a server would receive: a connected world is not told a new terminal + /// size because a line arrived in a tab nobody is looking at. Driven on a + /// and wound past the report interval, because the NAWS throttle has a + /// trailing flush — on a still clock a changed size is coalesced and never delivered inside the test, + /// so this would pass on a broken client without the wind. + /// + [Test] + public async Task ActivityTellsNoServerANewSize() + { + var clock = new ManualTimeProvider(); + var (app, printing) = Bound(clock: clock); + app.RenderSnapshot("split"); + + // Two sessions on the one window, because neither seam does both jobs: the demo-bound session is + // what drives the app's real line handler and so its real unread accounting, while + // AttachSession — which only registers a session's window for the size report — is the only way to + // put a recording transport behind that pane. Both point at `main`, so the bytes below are the + // sizes a server connected to this pane would have received. + var telnet = new RecordingTelnetSession(); + var watcher = new WorldSession( + new WorldDefinition { Name = "W", Host = "h", Port = 1 }, sessionFactory: _ => telnet); + await watcher.ConnectAsync(); + app.AttachSession(watcher, Main); + + // More output than the pane holds, then up off the live tail — otherwise there is nothing below + // the viewport, the window stays caught up, and no line that arrives is unread at all. + app.LoadLongScene(Main, SharpMUTermApp.ScrollbackSceneLines); + app.RenderNextFrame(); + app.RenderNextFrame(); + app.SimulateKey(Chord(ConsoleKey.PageUp)); + app.RenderNextFrame(); + clock.Advance(TimeSpan.FromSeconds(1)); + app.RenderNextFrame(); + + var before = telnet.Sizes.Distinct().ToList(); + await Assert.That(before).IsNotEmpty(); + + for (var i = 0; i < 150; i++) + { + printing.PrintSystem($"*** unseen {i}"); + } + + app.RenderNextFrame(); + clock.Advance(TimeSpan.FromSeconds(1)); + app.RenderNextFrame(); + + // Past the cap, so every width step the badge can take has been taken. + await Assert.That(app.UnreadOf(Main)).IsGreaterThan(UnreadBadge.Max); + await Assert.That(telnet.Sizes.Distinct().ToList()).IsEquivalentTo(before); + } + + // --- clearing --------------------------------------------------------------------------------- + + /// + /// The tab clears on exactly the condition the sidebar clears on, because it reads the same field. + /// Both render from WorkspaceWindow.Unread, so the interesting case is the one where a rule of + /// one's own would get it wrong: Workspace.ActivateWindow deliberately does not clear a + /// window the reader has scrolled back, because the unread lines are still below the viewport + /// (WorkspaceTests.ActivateWindow_DoesNotClearUnreadOfAScrolledBackWindow). Picking the tab + /// therefore leaves both badges up, and returning to the live tail takes both down together. + /// + [Test] + public async Task PickingTheTabOfAScrolledBackWindowClearsNeitherBadgeAndTheTailClearsBoth() + { + var (app, session) = Bound(); + AccrueOnScrolledBackMain(app, session, 7); + + await Assert.That(app.UnreadOf(Main)).IsEqualTo(7); + await Assert.That(MainTabLabel(app)).Contains("(7)"); + await Assert.That(RailShowsABadge(app)).IsTrue(); + + // Activating the window it is already on: the sidebar keeps its badge, so the tab must keep its own. + app.SimulateWindowChange(Main); + app.RenderNextFrame(); + + await Assert.That(app.UnreadOf(Main)).IsEqualTo(7); + await Assert.That(MainTabLabel(app)).Contains("(7)"); + await Assert.That(RailShowsABadge(app)).IsTrue(); + + // ⌃End is catching up, and it is the one gesture that clears — for both surfaces at once. + app.SimulateKey(Chord(ConsoleKey.End, ctrl: true)); + app.RenderNextFrame(); + + await Assert.That(app.UnreadOf(Main)).IsEqualTo(0); + await Assert.That(MainTabLabel(app)).DoesNotContain("("); + await Assert.That(MainTabLabel(app)).DoesNotContain(UnreadBadge.Tint); + await Assert.That(RailShowsABadge(app)).IsFalse(); + } + + /// + /// Whether any rail row is drawing an unread badge — the sidebar's half of the signal. Matched on the + /// field (a count right-aligned into ) and not merely on + /// the accent, because the rail paints its world spine and its connection dots in that colour too. + /// + private static bool RailShowsABadge(SharpMUTermApp app) => + app.RailLines.Any(l => Regex.IsMatch( + l, $@"\[{Regex.Escape(UnreadBadge.Tint)}\]\s*\d+\+?\[/\]")); + + // --- frame decoding --------------------------------------------------------------------------- + + private readonly record struct Cell(int Row, int Column, char Char, string? Foreground, string? Background); + + /// + /// Walks a frame into cells carrying both channels. Note the 38 as well as the + /// 48: this indicator lives in the foreground and the focus cue it must not be confused with + /// lives in the background, so a decoder that read only one of them could not tell them apart. + /// + private static Dictionary<(int Row, int Column), Cell> Cells(string ansi) + { + var cells = new Dictionary<(int, int), Cell>(); + var (row, column) = (0, 0); + string? fg = null, bg = null; + + foreach (Match token in Regex.Matches(ansi, @"\x1b\[([0-9;]*)([A-Za-z])|([^\x1b\r\n])|(\n)")) + { + if (token.Groups[4].Success) + { + row++; + column = 0; + continue; + } + + if (token.Groups[3].Success) + { + cells[(row, column)] = new Cell(row, column, token.Groups[3].Value[0], fg, bg); + column++; + continue; + } + + var parameters = token.Groups[1].Value; + switch (token.Groups[2].Value) + { + case "H": + var at = parameters.Split(';'); + row = at[0].Length > 0 ? int.Parse(at[0]) - 1 : 0; + column = at.Length > 1 && at[1].Length > 0 ? int.Parse(at[1]) - 1 : 0; + break; + case "m": + if (parameters.Length == 0 || parameters == "0") + { + fg = bg = null; + } + + fg = Truecolor(parameters, "38;2;") ?? fg; + bg = Truecolor(parameters, "48;2;") ?? bg; + break; + } + } + + return cells; + } + + /// Reads an r;g;b triple out of an SGR parameter list as #rrggbb, or null. + private static string? Truecolor(string parameters, string introducer) + { + var at = parameters.IndexOf(introducer, StringComparison.Ordinal); + if (at < 0) + { + return null; + } + + var parts = parameters[(at + introducer.Length)..].Split(';'); + return parts.Length < 3 ? null : Hex(byte.Parse(parts[0]), byte.Parse(parts[1]), byte.Parse(parts[2])); + } + + private static string Hex(int r, int g, int b) => $"#{r:x2}{g:x2}{b:x2}"; + + /// + /// The frame row a pane's tab strip is drawn on: the header sits immediately above the pane's output + /// rectangle, which is what PaneOutputRects reports. + /// + private static int StripRow(SharpMUTermApp app) => app.PaneOutputRects()[app.PaneIdOf(Main)!].Y - 1; + + /// + /// The tinted cells on one row, in column order and within that pane's own columns. + /// + /// Scoped rather than swept, because is the app accent and the + /// chrome already uses it elsewhere — the header's connected ●, the rail's world spine ▚ and its status + /// dots are all painted in it. That is fine where it is: those are fixed decorations in fixed places, + /// and inside a tab strip nothing else is accent-coloured, so on this row the tint is unambiguous. But + /// a test that swept the whole frame for the colour would be counting them too. + /// + /// + private static List Tinted( + Dictionary<(int Row, int Column), Cell> cells, int row, SharpMUTermApp app) + { + var rect = app.PaneOutputRects()[app.PaneIdOf(Main)!]; + return cells.Values + .Where(c => c.Row == row + && c.Column >= rect.X && c.Column < rect.X + rect.Width + && c.Foreground == UnreadBadge.Tint) + .OrderBy(c => c.Column) + .ToList(); + } + + private static string RowText(Dictionary<(int Row, int Column), Cell> cells, int row) + { + var onRow = cells.Values.Where(c => c.Row == row).ToList(); + return onRow.Count == 0 + ? string.Empty + : string.Concat(Enumerable.Range(0, onRow.Max(c => c.Column) + 1) + .Select(x => cells.TryGetValue((row, x), out var c) ? c.Char : ' ')); + } + + /// WCAG relative-contrast ratio between two #rrggbb colours. + private static double Contrast(string a, string b) + { + var (la, lb) = (Luminance(a), Luminance(b)); + return (Math.Max(la, lb) + 0.05) / (Math.Min(la, lb) + 0.05); + } + + private static double Luminance(string hex) + { + double Channel(int v) + { + var s = v / 255.0; + return s <= 0.03928 ? s / 12.92 : Math.Pow((s + 0.055) / 1.055, 2.4); + } + + var r = Convert.ToInt32(hex.Substring(1, 2), 16); + var g = Convert.ToInt32(hex.Substring(3, 2), 16); + var b = Convert.ToInt32(hex.Substring(5, 2), 16); + return (0.2126 * Channel(r)) + (0.7152 * Channel(g)) + (0.0722 * Channel(b)); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs b/tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs index 24024cc6..078c0908 100644 --- a/tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs @@ -1,3 +1,4 @@ +using SharpConsoleUI.Parsing; using SharpMUTerm.Core.Workspaces; using SharpMUTerm.Tui; @@ -5,6 +6,23 @@ namespace SharpMUTerm.Tui.Tests; public class TabTitlesTests { + /// + /// A background window carrying unread lines, accrued through the workspace's + /// own counter rather than written onto the window — WorkspaceWindow.Unread is settable only + /// inside Core, and going round it would be testing a number this code does not read. + /// + private static WorkspaceWindow Background(string title, int unread) + { + var ws = new Workspace(); + var window = ws.RouteSpawn(title); // opens in the background, so the first route already counts 1 + for (var i = 1; i < unread; i++) + { + ws.NoteActivity(window.Id); + } + + return window; + } + [Test] public async Task PlainWindow_IsJustItsTitle() { @@ -18,7 +36,53 @@ public async Task Unread_AppendsACountBadge() var ws = new Workspace(); ws.RouteSpawn("Chat"); var chat = ws.RouteSpawn("Chat"); // two background routes → unread 2 - await Assert.That(TabTitles.For(chat)).IsEqualTo("Chat (2)"); + await Assert.That(TabTitles.For(chat)).IsEqualTo($"[{UnreadBadge.Tint}]Chat (2)[/]"); + } + + /// + /// The count is capped exactly as the sidebar's badge is, from the same formatter. Uncapped it grew a + /// digit at a time from the wire — and the rail, which is capped, then read 99+ beside + /// a tab reading (150): two answers to one number. + /// + [Test] + [Arguments(99, "99")] + [Arguments(100, "99+")] + [Arguments(4127, "99+")] + public async Task Unread_IsCappedTheWayTheSidebarCapsIt(int unread, string badge) + { + var window = Background("Mannaz", unread); + await Assert.That(window.Unread).IsEqualTo(unread); + await Assert.That(TabTitles.For(window)).IsEqualTo($"[{UnreadBadge.Tint}]Mannaz ({badge})[/]"); + } + + /// + /// The tint covers the name and the count and nothing else. The ahead of it says which pane + /// holds the keyboard — an independent fact, true or false whatever the count is — so recolouring it + /// would make one signal look like the other, which is the confusion this indicator has to avoid. + /// + [Test] + public async Task TheFocusMarkerStaysOutsideTheActivityTint() + { + var window = Background("Mannaz", 36); + var label = TabTitles.For(window, focusedPane: true); + + await Assert.That(label).IsEqualTo($"{Glyphs.FocusedPane} [{UnreadBadge.Tint}]Mannaz (36)[/]"); + await Assert.That(label.StartsWith(Glyphs.FocusedPane, StringComparison.Ordinal)).IsTrue(); + } + + /// + /// A window title is world- and user-supplied text, and the label is markup — the framework parses it + /// and measures every width, including the click hit test, with StripLength. So a title + /// containing brackets has to survive as brackets rather than being eaten as a tag. + /// + [Test] + public async Task ATitleWithBracketsIsEscapedRatherThanParsedAsMarkup() + { + var window = new WorkspaceWindow("w1", "[Chat] room", WindowKind.Spawn) { OwnerLabel = "[Corvid]" }; + var label = TabTitles.For(window); + + await Assert.That(label).IsEqualTo("[[Corvid]] - [[Chat]] room"); + await Assert.That(MarkupParser.StripLength(label)).IsEqualTo("[Corvid] - [Chat] room".Length); } [Test] @@ -37,7 +101,7 @@ public async Task UnreadAndUnsent_ShowBoth() var ws = new Workspace(); var chat = ws.RouteSpawn("Chat"); // unread 1, background ws.SetUnsentInput(chat.Id, true); - await Assert.That(TabTitles.For(chat)).IsEqualTo($"Chat (1) {Glyphs.Draft}"); + await Assert.That(TabTitles.For(chat)).IsEqualTo($"[{UnreadBadge.Tint}]Chat (1)[/] {Glyphs.Draft}"); } [Test] @@ -65,7 +129,8 @@ public async Task ChildWindow_OwnerPrefixPrecedesBadges() var chat = ws.RouteSpawn("Chat"); // unread 1, background chat.OwnerLabel = "Corvid"; ws.SetUnsentInput(chat.Id, true); - await Assert.That(TabTitles.For(chat)).IsEqualTo($"Corvid - Chat (1) {Glyphs.Draft}"); + await Assert.That(TabTitles.For(chat)) + .IsEqualTo($"[{UnreadBadge.Tint}]Corvid - Chat (1)[/] {Glyphs.Draft}"); } [Test]