Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions src/SharpMUTerm.Core/Workspaces/RailModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ public sealed record RailWindow(string Title, string Id, string? Chord, int Unre
/// filter exists.
/// </para>
/// <para>
/// <b>Indent is one level per depth</b> — 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.
/// </para>
/// <para>
/// Every row that names somewhere you can go also carries the <see cref="RailRow.Target"/> a click
/// dispatches. The ids are the shell's own (<see cref="CommandIds"/>), so the rail is a second door
/// onto the ⌃P surface's actions rather than a second implementation of switching.
Expand Down Expand Up @@ -137,15 +143,15 @@ public static IReadOnlyList<RailRow> Build(IReadOnlyList<RailWorld> 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;
}

foreach (var character in world.Characters)
{
rows.Add(new RailRow(
RailRowKind.Character,
2,
1,
character.Name,
Accent: world.Accent,
Active: character.Active,
Expand All @@ -163,7 +169,7 @@ public static IReadOnlyList<RailRow> Build(IReadOnlyList<RailWorld> worlds)
{
rows.Add(new RailRow(
RailRowKind.Window,
3,
2,
window.Title,
Accent: world.Accent,
Unsent: window.HasUnsent,
Expand Down
66 changes: 59 additions & 7 deletions src/SharpMUTerm.Tui/SharpMUTermApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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 <see cref="WorkspacePalette.Tint"/>.
/// </summary>
private Rgb PaneSurfaceTone(string? paneId)
private Rgb PaneSurfaceTone(string? paneId) => SurfaceToneIn(paneId, PaneTintOf(paneId));

/// <summary>
/// The tone <paramref name="tint"/> comes out as in <paramref name="paneId"/> — the character's
/// plane, lit if that pane holds the focus. Split out from <see cref="PaneSurfaceTone"/> so a tab
/// chip can ask the question for a character who is <em>not</em> the pane's occupant and get an
/// answer on the same two channels; a second arithmetic would drift from the plane beside it.
/// </summary>
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;
}

/// <summary>
/// The chip an <em>unselected</em> tab is painted on: its own character's plane, recessed the same
/// step every idle chip has always been recessed by.
/// <para>
/// <b>Per tab, because the framework cannot say it.</b> <c>TabControl</c>'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.
/// </para>
/// <para>
/// It runs the same pipeline as the plane behind it (<see cref="SurfaceToneIn"/> then
/// <see cref="WorkspacePalette.Recessed"/>), 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.
/// </para>
/// </summary>
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));
}

/// <summary>
/// A pane's <em>unfocused</em> 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.
Expand Down Expand Up @@ -7923,8 +7969,12 @@ private List<string> RenderRailLines()
/// <summary>The widest it goes, so one long world or window name cannot run away with the layout.</summary>
private const int RailMaxWidth = 44;

/// <summary>Breathing room between the widest row and the divider beside it.</summary>
private const int RailMargin = 2;
/// <summary>
/// Breathing room between the widest row and the divider beside it. One cell, not two: the divider
/// column and the <c>_railSpacer</c> beside it (see <see cref="BuildWorkspaceRow"/>) already separate
/// the rail from the panes, so a second margin cell is a column taken off every pane to no effect.
/// </summary>
private const int RailMargin = 1;

/// <summary>
/// The rail column width: the widest row's visible width plus a small margin, clamped. Collapsed, it
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down
24 changes: 23 additions & 1 deletion src/SharpMUTerm.Tui/TabTitles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ namespace SharpMUTerm.Tui;
/// <para>The close affordance is <em>not</em> here. A <c>✕</c> in the label is just text the hit test
/// reads as part of the title; the real button is <c>TabPage.IsClosable</c>.</para>
/// </remarks>
/// <summary>
/// The plane an idle tab's chip is painted on and the ink that lands on it, as <c>#rrggbb</c> markup
/// tokens. It exists because <c>TabControl</c>'s four chip colours are properties of the <em>control</em>
/// — 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.
/// </summary>
internal readonly record struct TabChip(string Plane, string Ink);

internal static class TabTitles
{
/// <param name="window">The window the tab stands for.</param>
Expand All @@ -32,12 +40,18 @@ internal static class TabTitles
/// survives a flattened palette. Independent of <paramref name="focusedPane"/>: an unfocused pane
/// still has a tab in front of it.
/// </param>
/// <param name="chip">
/// The plane this tab is drawn on when it is <em>not</em> 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.
/// </param>
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);

Expand Down Expand Up @@ -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;
Expand Down
37 changes: 37 additions & 0 deletions tests/SharpMUTerm.Core.Tests/Workspace/RailModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,43 @@ await Assert.That(row.Target is null).IsEqualTo(expected)
}
}

/// <summary>
/// <b>The indent ladder skips no level.</b> 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.
/// </summary>
[Test]
public async Task TheIndentLadderSkipsNoLevel()
{
var rows = RailModel.Build(new[] { TwoCharacterWorld(), new RailWorld("Empties", "h", 1, Accent, Array.Empty<RailCharacter>()) });

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}')");
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
private static RailWorld TwoCharacterWorld() => new("Aetherfall", "aetherfall.mux", 4201, Accent, new[]
{
new RailCharacter("Corvid", "Aetherfall.Corvid", Connected: true, Active: true, Unread: 3, new[]
Expand Down
Loading
Loading