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
⏎ commitEsc 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. PORT80234201 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-key — ESC+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("<[31m> 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
+ /// a–j 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]