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.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..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. @@ -7923,8 +7969,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 @@ -8101,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); } @@ -9923,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.Core.Tests/Workspace/RailModelTests.cs b/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs index 7586bf8..6b35626 100644 --- a/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs @@ -209,6 +209,43 @@ 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 + + // 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[] { new RailCharacter("Corvid", "Aetherfall.Corvid", Connected: true, Active: true, Unread: 3, new[] diff --git a/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs index 29dbe2e..a9bf1d8 100644 --- a/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/PaneTintTests.cs @@ -132,6 +132,85 @@ 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); + } + + /// + /// 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/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..5e2331f 100644 --- a/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/RailWindowRowTests.cs @@ -241,6 +241,32 @@ 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); + + // 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); + } + /// /// 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 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() {