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
42 changes: 41 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,10 @@ python3 tools/ansi_frame_to_image.py frame.ansi frame.html # or .svg
`tabs` (**two tabs in the pane that does *not* hold the focus** — the one geometry that can show a
strip saying which tab is in front, since every other view has at most one tab in an unfocused pane.
It opens a third window for itself alone, and re-activates the main window before splitting, because
opening brings a window to the front and a split carries the tabs that are not), plus the
opening brings a window to the front and a split carries the tabs that are not),
`tabs-many` (**more tabs than the strip can hold** — five of one character's captures in a split pane,
which is the state every other tab view is too small to reach. Without eliding, that pane draws two
tabs and half of a third and says nothing about the rest), plus the
default workspace
(no `--view`). Any settings screen also takes a `-edit` suffix, which opens it and drives real
keys in so the frame shows a field mid-edit. State toggles: `collapsed`, `prefix`, `timestamps`,
Expand Down Expand Up @@ -996,6 +999,43 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/]
`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.
- **A tab strip shrinks its labels to fit its pane, because the framework clips and does not scroll**
(`TabStripFit`; `SharpMUTermApp.RetitlePane`/`SyncTabStripWidths`; the `tabs-many` view).
`TabControl` draws its tabs left to right from index 0 and writes each clipped to the header rectangle
— there is **no first-visible-tab offset anywhere in the control** — so a strip narrower than its tabs
simply stops drawing. The tabs past the edge are gone, **the selected one included**, and with them the
`×`, the separator and the framework's own `← →` hint, which is drawn only when there is slack left to
draw it in *and* the strip has keyboard focus (which it never has here: focus is pinned to the armed
command line). Measured: five captures in a split pane drew two tabs and half of a third.
- **The owner prefix goes first, and goes for the whole strip.** A pane full of one character's
captures repeats `Corvid - ` on every tab; it is the cheapest thing there is to lose and the most
there is of it, and what it said is still said twice over — by the chip the tab is painted on, and by
the `⌁` a window belonging to another character wears. Dropping it alone is often the whole fix.
- **Then names share by water-filling, not in proportion.** A cap is lowered until the strip fits and
every name longer than it is cut to it, so a strip holding `Chat` beside `O-Gatecrashers` takes the
cells from the long one and leaves the short one whole.
- **The selected tab is spared, with a floor of its own (`MinimumSelectedName`) when it cannot be.** It
is the tab the strip exists to name. A strip whose every label including that one is three letters
and an ellipsis has stopped answering the question it is for.
- **Only the name shrinks.** The badge, the pen, the `⌁` and the focus `▌` are facts about the window,
each one or two cells against a name that is routinely twenty.
- **Past the floors the tabs really do overflow, and that is left alone.** Eight tabs cannot be drawn
in forty cells however they are labelled; the answer is the floors and the framework clips the
remainder — the state that existed before this, reached later. Making the *last* tab visible is a
scroll offset, which is upstream work.
- **A tab's fixed cost is measured off a rendered title, never re-derived.** `VisibleLength(title)`
less the name, plus the framework's own cells (the space either side, the `×`, the `│`). A second
arithmetic for the decorations would drift from `TabTitles.For` and overflow the pane while every
pure test still passed — which is why `TabStripElisionTests` asserts the sum against a real
`PaneOutputRects` width and not against its own model.
- **The width is synced on `PostBufferPaint` and there is no loop in it.** `TabControl.CalculateSize`
returns the *constraint's* width, never its content's, so a title cannot widen or narrow the pane it
is drawn in — the dependency runs one way. Re-titling is gated on the width having actually moved,
and goes through `RetitlePane` rather than `RefreshTabTitles` so it does not drag `RefreshRail` with
it: the rail resizes the sidebar's column, which changes the pane widths, which is the very thing
that triggered the sync.
- **A width of zero means "not arranged yet", not "no room".** Eliding against it would cut every title
on the first frame and undo it on the second.
- **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
11 changes: 11 additions & 0 deletions src/SharpMUTerm.Tui/DemoScene.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ internal static class DemoScene
/// </summary>
public static string ScenesWindowId => Workspace.SpawnWindowId(ActiveSessionKey, "Scenes");

/// <summary>
/// The channels the <c>tabs-many</c> view opens, and the reason that view exists: named the length a
/// real client's captures are named, so the strip wants far more cells than a split pane has. Kept
/// out of <see cref="BuildLastSession"/> for <see cref="ScenesWindowId"/>'s reason — seven more rows
/// on every frame's rail would move the whole gallery to answer one view's question.
/// </summary>
public static readonly string[] CrowdedChannels =
{
"Public", "O-Gatecrashers", "Radio Umi", "Tells",
};

public static AppConfiguration Build()
{
var config = new AppConfiguration();
Expand Down
171 changes: 157 additions & 14 deletions src/SharpMUTerm.Tui/SharpMUTermApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,13 @@ private sealed class SizeReport
private readonly MarkupControl _railSpacer = new(new List<string>());
private readonly Dictionary<string, TabControl> _paneTabs = new(StringComparer.Ordinal);

/// <summary>
/// The cells each pane's tab strip was last arranged with, which is what the labels are elided to.
/// A pane with no entry has not been laid out yet, and <see cref="TabStripFit"/> reads that as "draw
/// the names whole" rather than as "no room".
/// </summary>
private readonly Dictionary<string, int> _stripWidths = new(StringComparer.Ordinal);

/// <summary>
/// Guards <see cref="_paneTabs"/>. Everything else touches it on the UI thread, but a mouse frame
/// arrives on the driver's input thread and has to read it to locate the panes — enumerating it
Expand Down Expand Up @@ -774,7 +781,11 @@ public SharpMUTermApp(
// repaints. One hook therefore covers the lot, and none of them can be forgotten later.
// (The event's adder is a silent no-op while a window has no renderer; this one has had one
// since its constructor ran, and NawsPaneReportTests fails loudly if that ever stops holding.)
_window.PostBufferPaint += (_, _, _) => ReportPaneSizes();
_window.PostBufferPaint += (_, _, _) =>
{
ReportPaneSizes();
SyncTabStripWidths();
};
// Pane drag-and-drop listens at the driver, not at a control: SharpConsoleUI delivers mouse
// frames to the control that was pressed (it captures on Button1Pressed), so a control-level
// handler would only ever see the *source* pane. The driver stream carries every frame in
Expand Down Expand Up @@ -963,6 +974,36 @@ public string RenderSnapshot(string? view = null)
RebuildPaneArea();
}

// More tabs than the strip can hold, which is the state every other tab view is too small to
// show. The channels are named the way a real client's are — a character's captures, carrying
// their owner as a prefix — and there are seven of them in a split pane, so the strip wants far
// more cells than the pane has. Without eliding, TabControl draws them left to right and stops:
// the later tabs are simply not there, and neither is any word about them.
if (string.Equals(view, "tabs-many", StringComparison.OrdinalIgnoreCase))
{
foreach (var channel in DemoScene.CrowdedChannels)
{
var window = _workspace.OpenWindow(
Workspace.SpawnWindowId(DemoScene.ActiveSessionKey, channel),
channel,
WindowKind.Spawn,
DemoScene.ActiveSessionKey);
window.OwnerLabel = DemoScene.MainCharacterName;
AppendWindowLine(window.Id, MarkupText.Escape($"<{channel}> Rivane: the road is quiet tonight"));
}

// The character's own window back in front, so the frame shows a *selected* tab keeping its
// name while its siblings give theirs up — which is the rule, and the half a strip of
// uniformly stubbed labels would not show. The split is what makes the pane narrower than
// the terminal, which is where this bites in a real client.
_workspace.ActivateWindow(MainWindowId);
PaneCommands.Apply(_workspace.Layout, PaneCommand.SplitRight);
_workspace.NoteActivity(Workspace.SpawnWindowId(DemoScene.ActiveSessionKey, DemoScene.CrowdedChannels[1]));

RebuildPaneArea();
RefreshRail();
}

// The tab strip's own question — *which tab am I looking at* — in the one geometry that can
// answer it: a pane that does not hold the focus, holding two tabs. Every other view has at most
// one tab in an unfocused pane, which is exactly why no frame ever caught an unfocused pane
Expand Down Expand Up @@ -10218,25 +10259,127 @@ private void RefreshTabTitles()
var focusedCharacter = ActiveCharacterKey();
foreach (var (paneId, tabs) in _paneTabs)
{
var activeTab = _workspace.Layout.FindPane(paneId)?.ActiveTab;
foreach (var page in tabs.TabPages)
{
if (page.Tag is string id && _workspace.FindWindow(id) is { } window)
{
var selected = string.Equals(activeTab, id, StringComparison.Ordinal);
page.Title = TabTitles.For(
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);
}
}
RetitlePane(paneId, tabs, focusedCharacter);
}

RefreshRail();
UpdateInputChrome();
}

/// <summary>
/// One strip's labels, elided to the pane it has to fit in (<see cref="TabStripFit"/>). Split out of
/// <see cref="RefreshTabTitles"/> because the width sync calls it per pane and must <em>not</em> take
/// the rail with it: <see cref="RefreshRail"/> resizes the sidebar's own column, which changes the
/// pane widths, which is the very thing that triggered the sync — a loop that would settle, but only
/// after repainting its way there.
/// <para>
/// A tab's fixed cost is measured off its rendered title (<c>VisibleLength</c> less the name) rather
/// than re-derived from the window, so the badge, the pen, the <c>⌁</c> and the focus <c>▌</c> keep
/// exactly one definition — <see cref="TabTitles.For"/>'s. Added to it are the framework's own cells:
/// the space either side of every title, the <c>×</c> a closable tab draws, and the <c>│</c> between
/// one tab and the next (<c>TabControl.Rendering.cs</c>).
/// </para>
/// </summary>
private void RetitlePane(string paneId, TabControl tabs, string? focusedCharacter)
{
var activeTab = _workspace.Layout.FindPane(paneId)?.ActiveTab;
var pages = tabs.TabPages;
var costs = new List<TabCost>(pages.Count);
var windows = new WorkspaceWindow?[pages.Count];
var full = new string?[pages.Count];

for (var i = 0; i < pages.Count; i++)
{
var page = pages[i];
var id = page.Tag as string;
var window = id is not null ? _workspace.FindWindow(id) : null;
var selected = id is not null && string.Equals(activeTab, id, StringComparison.Ordinal);

// The × follows the active tab, so keep it in step with every title refresh — and it has to
// be settled before the strip is costed, because it is one of the cells being counted.
if (id is not null && window is not null)
{
page.IsClosable = CanCloseTab(id, activeTab);
}

var frame = 2 + (page.IsClosable ? 1 : 0) + (i < pages.Count - 1 ? 1 : 0);
if (window is null)
{
costs.Add(new TabCost(0, 0, MarkupText.VisibleLength(page.Title) + frame, selected));
continue;
}

var title = TabTitles.For(
window, focusedCharacter, IsFocusedPane(paneId) && selected, selected, _ink,
ChipFor(paneId, window));
var name = TabTitles.Name(window).Length;

windows[i] = window;
full[i] = title;
costs.Add(new TabCost(
name, window.Title.Length, MarkupText.VisibleLength(title) - name + frame, selected));
}

var budgets = TabStripFit.Budgets(costs, _stripWidths.GetValueOrDefault(paneId));
for (var i = 0; i < pages.Count; i++)
{
if (windows[i] is not { } window)
{
continue;
}

var selected = costs[i].Selected;
pages[i].Title = budgets[i] >= costs[i].NameLength
? full[i]!
: TabTitles.For(
window, focusedCharacter, IsFocusedPane(paneId) && selected, selected, _ink,
ChipFor(paneId, window), budgets[i]);
}
}

/// <summary>
/// Re-measures each strip against the pane it was just arranged into, and re-titles the ones that
/// moved. It rides <c>PostBufferPaint</c> beside <see cref="ReportPaneSizes"/> and for that
/// entry's reason: pane rectangles exist only while an arranged layout does, and every change that
/// could move one repaints.
/// <para>
/// <b>There is no loop in this, and that is a property of the framework rather than luck.</b>
/// <c>TabControl.CalculateSize</c> returns the constraint's own width, never its content's, so a
/// title cannot widen or narrow the pane it is drawn in — the dependency runs one way. Re-titling is
/// gated on the width having actually changed regardless, so a steady layout writes nothing per
/// frame.
/// </para>
/// </summary>
private void SyncTabStripWidths()
{
List<string>? moved = null;
foreach (var (paneId, tabs, rect) in RealisedPanes())
{
var width = Math.Max(0, rect.Width - tabs.Margin.Left - tabs.Margin.Right);
if (_stripWidths.TryGetValue(paneId, out var was) && was == width)
{
continue;
}

_stripWidths[paneId] = width;
(moved ??= new List<string>()).Add(paneId);
}

if (moved is null)
{
return;
}

var focusedCharacter = ActiveCharacterKey();
foreach (var paneId in moved)
{
if (_paneTabs.TryGetValue(paneId, out var tabs))
{
RetitlePane(paneId, tabs, focusedCharacter);
}
}
}

/// <summary>
/// Tells every connected session, over NAWS, how big its own output area is — the pane its window
/// lives in, less that pane's tab strip. Not the terminal: this client is built around splits, so
Expand Down
Loading
Loading