From c9aa311c09f8b550f859a0fe9ef25b1e6ab4ab7b Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Wed, 12 Aug 2026 15:36:48 -0500 Subject: [PATCH 1/3] fix(rail): the sidebar stops spending columns on nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connection rail was 22 columns wide in the demo's `connections` frame while its widest visible row ended at 20 and most ended at 15. Two of those columns named nothing. The indent ladder skipped a level: worlds at 0, characters straight to 2, windows at 3, so every window row carried two cells of indent for a depth no row is ever drawn at. It is one level per depth now — world 0, character 1, window 2. `RailMargin` goes 2 → 1. A divider column and a one-cell spacer already stand between the rail's last cell and the first pane, so the second margin cell separated nothing from nothing. The rail's width comes out of the pane area and every connected session is told its pane's size over NAWS, so both are columns handed back to the game. Demo `connections` rail: 22 → 19. The reserved fields are untouched — the unsent pen, the unread badge and the chord column still cost their cells whether or not they have anything to say, because a field that appears out of nothing resizes the sidebar from the wire. Two pins: `TheIndentLadderSkipsNoLevel` (no row is more than one level deeper than the row before it) and `TheRailSpendsAtMostOneBlankColumnPast ItsWidestRow`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- src/SharpMUTerm.Core/Workspaces/RailModel.cs | 12 +++++++--- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 8 +++++-- .../Workspace/RailModelTests.cs | 22 +++++++++++++++++++ .../RailRendererTests.cs | 2 +- .../RailWindowRowTests.cs | 19 ++++++++++++++++ 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/SharpMUTerm.Core/Workspaces/RailModel.cs b/src/SharpMUTerm.Core/Workspaces/RailModel.cs index 14901ad..c1f128f 100644 --- a/src/SharpMUTerm.Core/Workspaces/RailModel.cs +++ b/src/SharpMUTerm.Core/Workspaces/RailModel.cs @@ -107,6 +107,12 @@ public sealed record RailWindow(string Title, string Id, string? Chord, int Unre /// filter exists. /// /// +/// Indent is one level per depth — world 0, character 1, window 2 — and the view spends two cells +/// on each. Characters used to sit at 2, reserving a level nothing was ever drawn at and pushing every +/// window row two cells right; the sidebar's width is its widest row and comes out of the panes, which +/// every connected session is told over NAWS, so a skipped level is columns spent saying nothing. +/// +/// /// 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. @@ -137,7 +143,7 @@ public static IReadOnlyList Build(IReadOnlyList worlds) if (world.Characters.Count == 0) { rows.Add(new RailRow( - RailRowKind.Empty, 2, "no characters", Target: NoCharactersTarget(world.Name))); + RailRowKind.Empty, 1, "no characters", Target: NoCharactersTarget(world.Name))); continue; } @@ -145,7 +151,7 @@ public static IReadOnlyList Build(IReadOnlyList worlds) { rows.Add(new RailRow( RailRowKind.Character, - 2, + 1, character.Name, Accent: world.Accent, Active: character.Active, @@ -163,7 +169,7 @@ public static IReadOnlyList Build(IReadOnlyList worlds) { rows.Add(new RailRow( RailRowKind.Window, - 3, + 2, window.Title, Accent: world.Accent, Unsent: window.HasUnsent, diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index a9ac5b6..ae07124 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -7923,8 +7923,12 @@ private List RenderRailLines() /// The widest it goes, so one long world or window name cannot run away with the layout. private const int RailMaxWidth = 44; - /// Breathing room between the widest row and the divider beside it. - private const int RailMargin = 2; + /// + /// Breathing room between the widest row and the divider beside it. One cell, not two: the divider + /// column and the _railSpacer beside it (see ) already separate + /// the rail from the panes, so a second margin cell is a column taken off every pane to no effect. + /// + private const int RailMargin = 1; /// /// The rail column width: the widest row's visible width plus a small margin, clamped. Collapsed, it diff --git a/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs b/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs index 7586bf8..1900bd1 100644 --- a/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs @@ -209,6 +209,28 @@ await Assert.That(row.Target is null).IsEqualTo(expected) } } + /// + /// The indent ladder skips no level. Every cell of indent is width the sidebar takes out of the + /// panes — and the sidebar's width is announced to every connected server over NAWS — so a depth + /// nothing is ever drawn at is columns spent saying nothing. Characters sat at 2 under a world at 0, + /// which reserved a level 1 that no row has ever used and pushed every window row two cells right. + /// + [Test] + public async Task TheIndentLadderSkipsNoLevel() + { + var rows = RailModel.Build(new[] { TwoCharacterWorld(), new RailWorld("Empties", "h", 1, Accent, Array.Empty()) }); + + var previous = 0; + foreach (var row in rows) + { + await Assert.That(row.Indent).IsLessThanOrEqualTo(previous + 1) + .Because($"a {row.Kind} row ('{row.Label}') at indent {row.Indent} follows one at {previous}"); + previous = row.Indent; + } + + await Assert.That(rows.Max(r => r.Indent)).IsEqualTo(2); // world → character → window, and no more + } + private static RailWorld TwoCharacterWorld() => new("Aetherfall", "aetherfall.mux", 4201, Accent, new[] { new RailCharacter("Corvid", "Aetherfall.Corvid", Connected: true, Active: true, Unread: 3, new[] diff --git a/tests/SharpMUTerm.Tui.Tests/RailRendererTests.cs b/tests/SharpMUTerm.Tui.Tests/RailRendererTests.cs index d4e62cf..93d2611 100644 --- a/tests/SharpMUTerm.Tui.Tests/RailRendererTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/RailRendererTests.cs @@ -185,7 +185,7 @@ public async Task Render_EscapesBracketsInsideTheLinkTarget() var line = RailRenderer.Render(rows).Single(l => l.Contains("no characters")); await Assert.That(line).Contains("[link=rail:no-characters:Od%5Dd]"); - await Assert.That(SharpMUTermApp.MarkupWidth(line)).IsEqualTo(" no characters".Length); + await Assert.That(SharpMUTermApp.MarkupWidth(line)).IsEqualTo(" no characters".Length); } // --- the width trap: nothing volatile may cost a cell ------------------------------------------ diff --git a/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs b/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs index c870098..fe1ac24 100644 --- a/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs @@ -241,6 +241,25 @@ public async Task ALabelLongerThanTheRailIsElidedRatherThanWrapped() } } + /// + /// The sidebar spends at most one blank column past its widest row. The other half of the width + /// invariant: the rail must be wide enough for its rows (above) and no wider, because everything it + /// keeps is taken off the panes. One column and not two — a divider and a one-cell spacer already stand + /// between the rail's last cell and the first pane, so a second margin cell separates nothing from + /// nothing. Asserted on the demo, whose widest row is comfortably past RailMinWidth, so what is + /// being read is the margin rather than the floor. + /// + [Test] + public async Task TheRailSpendsAtMostOneBlankColumnPastItsWidestRow() + { + var app = App(); + app.RenderSnapshot(); + + var widest = Rail(app).Max(SharpMUTermApp.MarkupWidth); + await Assert.That(widest).IsGreaterThan(16); // else the floor is what is being measured + await Assert.That(app.RailColumnWidth).IsEqualTo(widest + 1); + } + /// /// Shortening the rows narrows the sidebar again, rather than leaving it as wide as the widest thing it /// ever held: the columns the rail gives back are columns the panes get, and per-pane NAWS is derived From 6a3275667610179a0dc7b566a3128eb5cec7b846 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Wed, 12 Aug 2026 15:52:48 -0500 Subject: [PATCH 2/3] feat(tabs): a background tab wears its own character's colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pane can host several characters' windows as tabs and paints one rectangle, so the plane behind a strip can only ever answer for the window in front. Every other tab in that strip was drawn in that same hue — naming the wrong character, which is the one thing the pane tint exists to prevent. `TabControl`'s four chip colours belong to the control, so this cannot come from `PaintTabChips`. It goes where the per-tab channel is: the title, which is markup, where a tag costs no cells and moves no hit test. `TabChip` carries the plane and its ink, and `TabTitles.For` emits `[{ink} on {plane}]` around the same span the bold and the activity tint already cover. `ChipFor` runs the same pipeline as the plane behind it — `SurfaceToneIn` (the character's tint, plus the focus lift when the pane holds focus) then `Recessed` — so a strip whose tabs share an owner comes out byte for byte what it was, and only a mixed pane changes. `PaneSurfaceTone` is now one line of that function rather than a second arithmetic beside it. The selected tab is never chipped: the strip already paints it in its page's plane. Unread stays a foreground over the chip, so "whose" and "something new" remain two channels. A window nobody owns and a character who has chosen no colour come out on the plain surface. New `tint-tabs` view: the two tinted characters of `tint` with no split, so one pane holds both their windows. It is the only geometry where an idle chip can be seen wearing a colour the pane behind it is not. `PaneTintTests.ABackgroundTabWearsItsOwnCharactersColour` reads the painted cells — the old code was internally consistent while the screen was wrong, so nothing built on the expression would have caught it. `TabTitlesTests` pins the markup's shape and that a chip costs no cells. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- CLAUDE.md | 21 +++++++ src/SharpMUTerm.Tui/SharpMUTermApp.cs | 58 +++++++++++++++++-- src/SharpMUTerm.Tui/TabTitles.cs | 24 +++++++- tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs | 41 +++++++++++++ tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs | 41 +++++++++++++ 5 files changed, 179 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 20ee8ad..9e549e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -511,6 +511,9 @@ python3 tools/ansi_frame_to_image.py frame.ansi frame.html # or .svg wearing it, an armed tinted band over an idle tinted one, and the colour travelling with the focus. The moved frame is also the one that shows a bar wearing a character's hue while its prompt reads `no connection ›`, which is the composition rule stated in paint: hue says whose, not whether), + `tint-tabs` (the same two tinted characters with **no split**, so one pane holds both their windows as + tabs — the only geometry where an idle chip can be seen wearing a colour the pane behind it is not, + which is the whole of the per-tab chip), `deletions`, `compose`/`compose-literal` (the F1 composer in each of its two escaping modes — the pair exists because ⌥L changes what is *sent* and only the header says which way it is set; the demo has no @@ -877,6 +880,24 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] **Not fixed here**: the `│` between chips and the `─` filling the rest of the strip are hardcoded `Color.Grey` in `TabControl.Rendering.cs`, unthemeable in all three header styles. That is an upstream change. +- **An idle chip wears its *own* character's colour, and that has to be said in the title** (`TabChip`, + `SharpMUTermApp.ChipFor`). `TabControl`'s four chip properties belong to the **control**, so every + unselected chip in a strip is one colour — and a pane can host several characters' windows as tabs while + painting one rectangle, so that one colour is necessarily the *front* window's. The other characters' + tabs were therefore drawn in a hue that named the wrong person, which is the one thing the tint exists + to prevent. The per-tab channel is the title, because a tag there costs no cells (the entry above), so + the chip goes out as `[{ink} on {plane}]` around the same span the bold and the activity tint already + cover. Three things hold it together. It runs the **same pipeline as the plane behind it** — + `SurfaceToneIn` (tint, then the focus lift if the pane holds focus) then `Recessed` — so a strip whose + tabs share an owner is byte-identical to what it was before this existed and only the mixed pane + changes; `PaneSurfaceTone` is now one line of that same function, rather than a second arithmetic that + would drift. The **selected** tab is never chipped: it is the strip's own colour, which is already its + page's plane. And unread stays a **foreground** over the chip, so "whose" and "something new" remain the + two channels they are everywhere else. The framework pads each title with one space either side + (`" {Title} "`, outside our markup), so the colour hugs the text and the pad keeps the strip's tone. + `PaneTintTests.ABackgroundTabWearsItsOwnCharactersColour` is the pin and the `tint-tabs` view is the + frame — the old code was internally consistent and the screen was still wrong, so only a painted cell + answers this. - **One unread count, one spelling: `UnreadBadge`.** The sidebar and the tab strip are two views of `WorkspaceWindow.Unread`, and they had two formatters — the rail capped at `99+`, the tab printed the raw integer, so a busy channel read `99+` in one place and `(4127)` in the other. Cap, field width and diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index ae07124..203d373 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -1418,7 +1418,22 @@ public string RenderSnapshot(string? view = null) SwitchToCharacter($"{tinted[1].Name}.{tinted[1].Characters[0].Name}"); } - PaneCommands.Apply(_workspace.Layout, PaneCommand.SplitRight); + // `tint-tabs` is the same two characters with no split — the one geometry where a pane holds + // two characters' windows, which is the only place an idle chip can be seen wearing a colour + // the pane behind it is not. Switching back brings the first character's window to the front, + // so the frame carries a selected chip beside an idle one in the other character's hue. + if (string.Equals(view, "tint-tabs", StringComparison.OrdinalIgnoreCase)) + { + if (tinted.Count > 0) + { + SwitchToCharacter($"{tinted[0].Name}.{tinted[0].Characters[0].Name}"); + } + } + else + { + PaneCommands.Apply(_workspace.Layout, PaneCommand.SplitRight); + } + RebuildPaneArea(); if (view.StartsWith("tint-input", StringComparison.OrdinalIgnoreCase)) @@ -7841,12 +7856,43 @@ private IWindowControl OnSurface(IWindowControl content, string? paneId = null) /// tinted pane is exactly as visibly focused as an untinted one and a focused pane still says whose /// it is. See . /// - private Rgb PaneSurfaceTone(string? paneId) + private Rgb PaneSurfaceTone(string? paneId) => SurfaceToneIn(paneId, PaneTintOf(paneId)); + + /// + /// The tone comes out as in — the character's + /// plane, lit if that pane holds the focus. Split out from so a tab + /// chip can ask the question for a character who is not the pane's occupant and get an + /// answer on the same two channels; a second arithmetic would drift from the plane beside it. + /// + private Rgb SurfaceToneIn(string? paneId, PaneTint tint) { - var plane = PanePlane(paneId); + var plane = WorkspacePalette.Tint(_theme, tint); return paneId is not null && IsFocusedPane(paneId) ? WorkspacePalette.Focus(plane) : plane; } + /// + /// The chip an unselected tab is painted on: its own character's plane, recessed the same + /// step every idle chip has always been recessed by. + /// + /// Per tab, because the framework cannot say it. TabControl's four colour properties + /// belong to the control, so every unselected chip in a strip is one colour — the front window's, + /// recessed — and a pane holding two characters' windows painted the other one's tab in the wrong + /// character's hue. The per-tab channel is the title, which is markup and costs no cells. + /// + /// + /// It runs the same pipeline as the plane behind it ( then + /// ), so a strip whose tabs share an owner comes out byte for + /// byte what it was before this existed, and only the mixed pane changes. A window nobody owns — the + /// web view — and a character who has chosen no colour both come out on the plain surface, which is + /// the honest answer rather than borrowing the neighbour's hue. + /// + /// + private TabChip ChipFor(string paneId, WorkspaceWindow window) + { + var plane = WorkspacePalette.Recessed(SurfaceToneIn(paneId, TintOf(window.SessionKey))); + return new TabChip(plane.ToHex(), ChromeInk.On(WorkspacePalette.IdleInk(_theme), plane)); + } + /// /// A pane's unfocused plane — the tint of the character whose window is in front of it, or /// the plain surface when that window has no owner or its character has chosen no colour. @@ -8105,7 +8151,8 @@ private IWindowControl BuildPaneTabs(PaneNode pane) // terminal, where a luminance step does not. var selected = string.Equals(pane.ActiveTab, windowId, StringComparison.Ordinal); builder.AddTab( - TabTitles.For(window, ActiveCharacterKey(), focused && selected, selected, _ink), + TabTitles.For( + window, ActiveCharacterKey(), focused && selected, selected, _ink, ChipFor(pane.Id, window)), BuildTabContent(pane, windowId, window)); ids.Add(windowId); } @@ -9927,7 +9974,8 @@ private void RefreshTabTitles() { var selected = string.Equals(activeTab, id, StringComparison.Ordinal); page.Title = TabTitles.For( - window, focusedCharacter, IsFocusedPane(paneId) && selected, selected, _ink); + window, focusedCharacter, IsFocusedPane(paneId) && selected, selected, _ink, + ChipFor(paneId, window)); // The × follows the active tab, so keep it in step with every title refresh. page.IsClosable = CanCloseTab(id, activeTab); } diff --git a/src/SharpMUTerm.Tui/TabTitles.cs b/src/SharpMUTerm.Tui/TabTitles.cs index 3bfa1a3..1047096 100644 --- a/src/SharpMUTerm.Tui/TabTitles.cs +++ b/src/SharpMUTerm.Tui/TabTitles.cs @@ -17,6 +17,14 @@ namespace SharpMUTerm.Tui; /// The close affordance is not here. A in the label is just text the hit test /// reads as part of the title; the real button is TabPage.IsClosable. /// +/// +/// The plane an idle tab's chip is painted on and the ink that lands on it, as #rrggbb markup +/// tokens. It exists because TabControl's four chip colours are properties of the control +/// — one answer for every unselected tab in a strip — so a tab that wants to say whose window it is has +/// to say it in the only per-tab channel there is: its title, which is markup. +/// +internal readonly record struct TabChip(string Plane, string Ink); + internal static class TabTitles { /// The window the tab stands for. @@ -32,12 +40,18 @@ internal static class TabTitles /// survives a flattened palette. Independent of : an unfocused pane /// still has a tab in front of it. /// + /// + /// The plane this tab is drawn on when it is not the one its pane is showing, so a pane + /// holding two characters' windows says whose each background tab is. Ignored on the selected tab, + /// which the strip paints in its page's own plane. + /// public static string For( WorkspaceWindow window, string? focusedCharacterKey = null, bool focusedPane = false, bool selected = false, - ChromeInk? ink = null) + ChromeInk? ink = null, + TabChip? chip = null) { ArgumentNullException.ThrowIfNull(window); @@ -71,6 +85,14 @@ public static string For( (false, true) => UnreadBadge.TintFor(ink), _ => null, }; + + // The chip is the tab's own plane, which only an unselected tab carries — the selected one is + // painted by the strip, in the plane its page is on. Foreground first so an unread tab keeps its + // accent: the plane says whose window this is, the accent says it has something new. + if (chip is { } tile && !selected) + { + style = $"{style ?? tile.Ink} on {tile.Plane}"; + } var body = style is null ? named : $"[{style}]{named}[/]"; return focus + body + pen + cross; diff --git a/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs index 29dbe2e..e60bdea 100644 --- a/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs @@ -132,6 +132,47 @@ await Assert.That(FrameGrid.CellsPaintedIn(ansi, rects[paneId], Colour(expected) await Assert.That(app.PaneSurfaceColors.Values.Distinct().Count()).IsEqualTo(2); } + /// + /// A background tab wears the colour of the character whose window it is, not of the pane it sits + /// in. A pane can host several characters' windows and paints one rectangle, so the plane behind + /// the strip can only ever answer for the window in front; the chips are where the rest of them get + /// to say whose they are. + /// + /// It has to be a frame test. TabControl's four chip colours belong to the control, so the + /// old code was internally consistent — every unselected chip really was the colour it had been + /// told to be — and the screen was still wrong. The per-tab colour rides the title's markup, which + /// only a painted cell can confirm arrived. + /// + /// + [Test] + public async Task ABackgroundTabWearsItsOwnCharactersColour() + { + var config = DemoScene.Build(); + var app = App(config); + + // `tint-tabs` is the one geometry that can ask this: the same two tinted characters as `tint`, + // with no split, so both their windows are tabs of a single pane. + var ansi = app.RenderSnapshot("tint-tabs"); + var cells = FrameGrid.Cells(ansi, 120, 34); + var rows = FrameGrid.Decode(ansi, 120, 34); + + // The pane is the focused one (it is the only one), so both chips are recessed from a lit plane. + var lit = (PaneTint tint) => + WorkspacePalette.Recessed(WorkspacePalette.Focus(WorkspacePalette.Tint(config.Theme, tint))); + var other = config.Worlds[1].Characters[0].Name; // Moss, and the second world's own character + + var strip = rows.Select((text, y) => (text, y)).First(r => r.text.Contains(other, StringComparison.Ordinal)); + var mine = strip.text.IndexOf("Chat", StringComparison.Ordinal); // Corvid's spawn window, idle + var theirs = strip.text.IndexOf(other, StringComparison.Ordinal); // the other character's, idle + await Assert.That(mine).IsGreaterThan(0); + + await Assert.That(cells[(strip.y, theirs)].Background).IsEqualTo(lit(PaneTint.Moss)); + await Assert.That(cells[(strip.y, mine)].Background).IsEqualTo(lit(PaneTint.Slate)); + + // And they are genuinely two colours, or the frame proves nothing. + await Assert.That(cells[(strip.y, theirs)].Background).IsNotEqualTo(cells[(strip.y, mine)].Background); + } + // --- composing with focus --------------------------------------------------------------------- /// diff --git a/tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs b/tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs index 1094998..25ca6b2 100644 --- a/tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/TabTitlesTests.cs @@ -151,6 +151,47 @@ public async Task DifferentCharacter_AppendsACrossMarker() await Assert.That(label).IsEqualTo("pages ⌁"); } + // ---- the idle chip ----------------------------------------------------------------------- + // + // A strip's unselected chips are one colour to TabControl, so a tab that wants to name its own + // character has to do it in its title. These pin the shape of that markup and, more importantly, + // that it stays free: the title is measured by MarkupParser.StripLength everywhere the strip is + // laid out or hit-tested, so a tag here must move nothing. + + private static readonly TabChip Chip = new("#101418", "#9aa5b1"); + + [Test] + public async Task IdleTab_WearsTheChipItIsHanded() + { + var window = new WorkspaceWindow("w1", "Chat", WindowKind.Spawn, sessionKey: "Aetherfall.Rookery"); + await Assert.That(TabTitles.For(window, chip: Chip)).IsEqualTo("[#9aa5b1 on #101418]Chat[/]"); + } + + /// + /// Unread stays a foreground on a chipped tab: the plane says whose window it is and the + /// accent says it has something new, which are two facts on two channels. Writing the plane over the + /// activity tint would have made a background tab's colour mean either. + /// + [Test] + public async Task AnUnreadIdleTabKeepsItsActivityTintOverTheChip() + { + var chat = Background("Chat", 2); + await Assert.That(TabTitles.For(chat, chip: Chip)) + .IsEqualTo($"[{UnreadBadge.TintFor(null)} on #101418]Chat (2)[/]"); + } + + /// + /// The invariant the strip's geometry rests on. Every width a tab is measured by is + /// StripLength, so a chip costs no cells and moves no click target. + /// + [Test] + public async Task AChipCostsNoCells() + { + var chat = Background("Chat", 3); + await Assert.That(MarkupParser.StripLength(TabTitles.For(chat, chip: Chip))) + .IsEqualTo(MarkupParser.StripLength(TabTitles.For(chat))); + } + [Test] public async Task SameCharacter_HasNoCrossMarker() { From 8a62a06d860c99f49a04a1b33cefe5ecd9f42915 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Wed, 12 Aug 2026 16:05:25 -0500 Subject: [PATCH 3/3] test: close three gaps CodeRabbit found in the new pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three were the same shape — a test that agreed with the code it was watching rather than with the screen or the model. `TheIndentLadderSkipsNoLevel` proved the ladder was monotonic and capped at 2, which an Empty row at indent 0 satisfies. Each kind is now held to its own depth. `TheRailSpendsAtMostOneBlankColumnPastItsWidestRow` was arithmetic over the rail's own rows and would have passed had nothing applied the answer. It closes against the arranged pane rectangle now: a pane starts past the rail, its divider and the spacer — the same geometry per-pane NAWS is derived from. `AnUnownedBackgroundTabBorrowsNobodysColour` is new: the other half of the chip rule, that a tab nobody owns stays on the plain surface rather than borrowing the hue of whatever is in front of it. The web view is the reachable case and the one that would make the cue name a character whose window it is not. It asserts the pane really is tinted too, or the frame asks nothing; confirmed it fails against the old per-pane chip with `expected Rgb(41,41,46), found Rgb(17,34,71)`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- .../Workspace/RailModelTests.cs | 15 ++++++++ tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs | 38 +++++++++++++++++++ .../RailWindowRowTests.cs | 7 ++++ 3 files changed, 60 insertions(+) diff --git a/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs b/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs index 1900bd1..6b35626 100644 --- a/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs @@ -229,6 +229,21 @@ await Assert.That(row.Indent).IsLessThanOrEqualTo(previous + 1) } await Assert.That(rows.Max(r => r.Indent)).IsEqualTo(2); // world → character → window, and no more + + // The ladder above is monotonic and could still be nonsense — an empty row at 0 satisfies it — so + // each kind is held to its own depth. An Empty row stands where a character would, and takes a + // character's indent. + foreach (var row in rows) + { + var depth = row.Kind switch + { + RailRowKind.Header or RailRowKind.World => 0, + RailRowKind.Character or RailRowKind.Empty or RailRowKind.Host => 1, + _ => 2, + }; + + await Assert.That(row.Indent).IsEqualTo(depth).Because($"{row.Kind} ('{row.Label}')"); + } } private static RailWorld TwoCharacterWorld() => new("Aetherfall", "aetherfall.mux", 4201, Accent, new[] diff --git a/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs index e60bdea..a9bf1d8 100644 --- a/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs @@ -173,6 +173,44 @@ public async Task ABackgroundTabWearsItsOwnCharactersColour() await Assert.That(cells[(strip.y, theirs)].Background).IsNotEqualTo(cells[(strip.y, mine)].Background); } + /// + /// The other half of the chip's rule: a tab nobody owns stays on the plain surface rather than + /// borrowing the hue of whatever is in front of it. The web view is the reachable case — it belongs to + /// no character — and it is the one that would make the cue lie, since a colour there would name a + /// character whose window it is not. + /// + [Test] + public async Task AnUnownedBackgroundTabBorrowsNobodysColour() + { + var config = DemoScene.Build(); + Corvid(config).Tint = PaneTint.Slate; + var app = App(config); + + // The `web` view opens the web window into the pane and brings it forward; putting the character's + // own window back in front leaves the unowned one as the background tab this is about. The page is + // retitled first only so the tab carries a token nothing else on the frame does. + app.RenderSnapshot("web"); + app.SimulateWebPageTitled("Atlas"); + await Assert.That(app.DispatchCommand("win:main")).IsTrue(); + var ansi = app.RenderWholeFrame(); // a second render over a populated buffer emits only a delta + + var rows = FrameGrid.Decode(ansi, 120, 34); + var cells = FrameGrid.Cells(ansi, 120, 34); + + // Past the sidebar, so the rail's own row for that window cannot be mistaken for the tab. + var strip = rows.Select((text, y) => (text, y)) + .First(r => r.text.IndexOf("Atlas", StringComparison.Ordinal) > app.RailColumnWidth); + + var untinted = WorkspacePalette.Recessed(WorkspacePalette.Focus(WorkspacePalette.Surface(config.Theme))); + var web = strip.text.IndexOf("Atlas", StringComparison.Ordinal); + + await Assert.That(cells[(strip.y, web)].Background).IsEqualTo(untinted); + + // And the pane behind that chip really is wearing a colour, or the frame asks nothing at all. + await Assert.That(app.PaneSurfaceColors[app.FocusedPaneId]) + .IsEqualTo(Colour(WorkspacePalette.Focus(WorkspacePalette.Tint(config.Theme, PaneTint.Slate)))); + } + // --- composing with focus --------------------------------------------------------------------- /// diff --git a/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs b/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs index fe1ac24..5e2331f 100644 --- a/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs @@ -258,6 +258,13 @@ public async Task TheRailSpendsAtMostOneBlankColumnPastItsWidestRow() var widest = Rail(app).Max(SharpMUTermApp.MarkupWidth); await Assert.That(widest).IsGreaterThan(16); // else the floor is what is being measured await Assert.That(app.RailColumnWidth).IsEqualTo(widest + 1); + + // And the layout spent exactly that. The line above is arithmetic over the rows and would still + // pass if nothing applied the answer, so the claim is closed against the arranged pane rectangle: + // a pane starts past the rail, its divider and the one-cell spacer beside it + // (SharpMUTermApp.BuildWorkspaceRow), which is also the geometry per-pane NAWS is derived from. + var rect = app.PaneOutputRects()[app.FocusedPaneId]; + await Assert.That(rect.X).IsEqualTo(app.RailColumnWidth + 2); } ///