diff --git a/CLAUDE.md b/CLAUDE.md
index 9f9200c..d578dc9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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`,
@@ -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
diff --git a/src/SharpMUTerm.Tui/DemoScene.cs b/src/SharpMUTerm.Tui/DemoScene.cs
index 4ee012e..99037f7 100644
--- a/src/SharpMUTerm.Tui/DemoScene.cs
+++ b/src/SharpMUTerm.Tui/DemoScene.cs
@@ -42,6 +42,17 @@ internal static class DemoScene
///
public static string ScenesWindowId => Workspace.SpawnWindowId(ActiveSessionKey, "Scenes");
+ ///
+ /// The channels the tabs-many 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 for 's reason — seven more rows
+ /// on every frame's rail would move the whole gallery to answer one view's question.
+ ///
+ public static readonly string[] CrowdedChannels =
+ {
+ "Public", "O-Gatecrashers", "Radio Umi", "Tells",
+ };
+
public static AppConfiguration Build()
{
var config = new AppConfiguration();
diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs
index b2ab11f..a03ddb3 100644
--- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs
+++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs
@@ -169,6 +169,13 @@ private sealed class SizeReport
private readonly MarkupControl _railSpacer = new(new List());
private readonly Dictionary _paneTabs = new(StringComparer.Ordinal);
+ ///
+ /// 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 reads that as "draw
+ /// the names whole" rather than as "no room".
+ ///
+ private readonly Dictionary _stripWidths = new(StringComparer.Ordinal);
+
///
/// Guards . 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
@@ -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
@@ -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
@@ -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();
}
+ ///
+ /// One strip's labels, elided to the pane it has to fit in (). Split out of
+ /// because the width sync calls it per pane and must not take
+ /// the rail with it: 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.
+ ///
+ /// A tab's fixed cost is measured off its rendered title (VisibleLength less the name) rather
+ /// than re-derived from the window, so the badge, the pen, the ⌁ and the focus ▌ keep
+ /// exactly one definition — 's. Added to it are the framework's own cells:
+ /// the space either side of every title, the × a closable tab draws, and the │ between
+ /// one tab and the next (TabControl.Rendering.cs).
+ ///
+ ///
+ private void RetitlePane(string paneId, TabControl tabs, string? focusedCharacter)
+ {
+ var activeTab = _workspace.Layout.FindPane(paneId)?.ActiveTab;
+ var pages = tabs.TabPages;
+ var costs = new List(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]);
+ }
+ }
+
+ ///
+ /// Re-measures each strip against the pane it was just arranged into, and re-titles the ones that
+ /// moved. It rides PostBufferPaint beside and for that
+ /// entry's reason: pane rectangles exist only while an arranged layout does, and every change that
+ /// could move one repaints.
+ ///
+ /// There is no loop in this, and that is a property of the framework rather than luck.
+ /// TabControl.CalculateSize 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.
+ ///
+ ///
+ private void SyncTabStripWidths()
+ {
+ List? 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()).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);
+ }
+ }
+ }
+
///
/// 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
diff --git a/src/SharpMUTerm.Tui/TabStripFit.cs b/src/SharpMUTerm.Tui/TabStripFit.cs
new file mode 100644
index 0000000..778c9a1
--- /dev/null
+++ b/src/SharpMUTerm.Tui/TabStripFit.cs
@@ -0,0 +1,147 @@
+namespace SharpMUTerm.Tui;
+
+///
+/// What one tab costs its strip: the cells its name wants, and the cells it spends on everything else.
+///
+///
+/// The visible width of the tab's whole name — the owner prefix a child window carries, and its own
+/// title.
+///
+///
+/// The same name without that prefix. It is a separate number because dropping the prefix is the first
+/// thing a crowded strip does and the last thing it can do without losing a letter of anybody's name.
+///
+///
+/// Everything else the tab occupies: the framework's one-space pad either side, the focus marker, the
+/// unread badge, the draft pen, the other-character mark, its ×, and the │ that follows it.
+/// Computed by measuring a rendered title rather than re-deriving it, so the two cannot drift.
+///
+/// Whether this is the tab its pane is showing.
+internal readonly record struct TabCost(int NameLength, int TitleLength, int Fixed, bool Selected);
+
+///
+/// How much of each tab's name a strip can afford to draw. Pure, and separate from
+/// for the reason every other renderer here is split that way: the rule is the
+/// part worth pinning, and it needs no terminal to state.
+///
+/// It exists because the framework clips and does not scroll. TabControl draws its tabs
+/// left to right from index 0 and writes each one 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, with nothing said. The tabs past the edge are gone, and so is the ×, the separator,
+/// and the framework's own ← → hint, which is only drawn when there is slack left to draw it in.
+/// None of that is reachable from here; what is ours is the titles, so the titles shrink.
+///
+///
+/// The owner prefix goes first, and goes for everybody. A pane full of one character's captures
+/// repeats Corvid - on every tab, which is the cheapest thing in the strip to lose and the most
+/// there is of it — dropping it is what turns eight tabs that do not fit into eight tabs that nearly do.
+/// It is dropped across the whole strip rather than per tab, so the tabs stay comparable, 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.
+///
+///
+/// Then names share by water-filling, not by proportion. A cap is lowered until the strip fits,
+/// and every name longer than the cap 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. Proportional
+/// shrinking would take a slice off Chat too, for no gain.
+///
+///
+/// The selected tab is spared, and keeps a floor of its own when it cannot be. It is the one the
+/// reader is looking at and the one the strip exists to name, so it holds its full title while any other
+/// tab still has cells to give — and even in a strip that cannot fit its tabs at all, it keeps enough to
+/// be read. A strip where every label including the selected one is three letters and an ellipsis
+/// answers none of the questions a strip is for.
+///
+///
+/// Only the name shrinks. The badge, the pen, the ⌁ and the focus ▌ are facts about
+/// the window rather than decoration, and each is one or two cells against a name that is routinely
+/// twenty — taking them would save little and cost the strip its meaning.
+///
+///
+internal static class TabStripFit
+{
+ ///
+ /// The fewest cells of name a background tab is cut to. Below three a name is an initial and an
+ /// ellipsis, which identifies nothing; at three there is still a syllable to recognise. It is a floor
+ /// rather than a guarantee — a strip narrower than its tabs at the floor is one the framework will
+ /// clip, and this cannot prevent that, only postpone it.
+ ///
+ internal const int MinimumName = 3;
+
+ ///
+ /// The fewest for the tab the pane is showing. Higher than on purpose: it
+ /// costs at most a few cells — one background tab's worth across the whole strip — and it buys the
+ /// one thing the strip must never stop saying.
+ ///
+ internal const int MinimumSelectedName = 8;
+
+ ///
+ /// How many cells of name each tab may draw, in the order it was given them. A tab whose budget
+ /// equals its is not being elided at all, which is the answer for
+ /// every strip that already fits; one whose budget is its is losing
+ /// its owner prefix and nothing else.
+ ///
+ /// The strip's tabs, in the order they are drawn.
+ /// The cells the strip has, or zero when it is not yet known.
+ internal static IReadOnlyList Budgets(IReadOnlyList tabs, int stripWidth)
+ {
+ ArgumentNullException.ThrowIfNull(tabs);
+
+ var full = tabs.Select(t => t.NameLength).ToArray();
+
+ // A width of zero is a strip that has not been arranged yet, not a strip with no room: eliding
+ // against it would cut every title on the first frame and undo it on the second.
+ if (tabs.Count == 0 || stripWidth <= 0 || Width(tabs, full) <= stripWidth)
+ {
+ return full;
+ }
+
+ var titles = tabs.Select(t => t.TitleLength).ToArray();
+ if (Width(tabs, titles) <= stripWidth)
+ {
+ return titles;
+ }
+
+ return Fill(tabs, stripWidth, spareSelected: true)
+ ?? Fill(tabs, stripWidth, spareSelected: false)
+ ?? tabs.Select(Floor).ToArray();
+ }
+
+ ///
+ /// The largest cap that fits, or null when even the floors do not. Walked down from the longest title
+ /// rather than solved for: both the tab count and a name's length are small, and the loop is the
+ /// version a reader can check against the rule it implements.
+ ///
+ private static int[]? Fill(IReadOnlyList tabs, int stripWidth, bool spareSelected)
+ {
+ for (var cap = tabs.Max(t => t.TitleLength); cap >= MinimumName; cap--)
+ {
+ var budgets = tabs
+ .Select(t => t.Selected
+ ? (spareSelected ? t.TitleLength : Math.Max(Floor(t), Math.Min(t.TitleLength, cap)))
+ : Math.Min(t.TitleLength, cap))
+ .ToArray();
+
+ if (Width(tabs, budgets) <= stripWidth)
+ {
+ return budgets;
+ }
+ }
+
+ return null;
+ }
+
+ private static int Floor(TabCost tab) =>
+ Math.Min(tab.TitleLength, tab.Selected ? MinimumSelectedName : MinimumName);
+
+ private static int Width(IReadOnlyList tabs, IReadOnlyList budgets)
+ {
+ var width = 0;
+ for (var i = 0; i < tabs.Count; i++)
+ {
+ width += tabs[i].Fixed + budgets[i];
+ }
+
+ return width;
+ }
+}
diff --git a/src/SharpMUTerm.Tui/TabTitles.cs b/src/SharpMUTerm.Tui/TabTitles.cs
index 1047096..e784020 100644
--- a/src/SharpMUTerm.Tui/TabTitles.cs
+++ b/src/SharpMUTerm.Tui/TabTitles.cs
@@ -45,21 +45,23 @@ internal static class TabTitles
/// 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.
///
+ ///
+ /// How many cells the name may spend, from ; zero or more than it wants
+ /// means draw it whole. Only the name is ever cut — see that class for why, and
+ /// for what counts as one.
+ ///
public static string For(
WorkspaceWindow window,
string? focusedCharacterKey = null,
bool focusedPane = false,
bool selected = false,
ChromeInk? ink = null,
- TabChip? chip = null)
+ TabChip? chip = null,
+ int nameBudget = 0)
{
ArgumentNullException.ThrowIfNull(window);
- // A child window carries its owner as a prefix so it stays traceable once dragged into another
- // pane; a character's own main window needs none, the focused-character context names it.
- var owner = window.Kind != WindowKind.Main && !string.IsNullOrEmpty(window.OwnerLabel)
- ? MarkupText.Escape(window.OwnerLabel) + " - "
- : string.Empty;
+ var name = MarkupText.Escape(Elide(window, nameBudget));
// Capped through the sidebar's own formatter, so the two surfaces reading one number cannot print
// different answers and a count from the wire cannot push a narrow strip's later tabs off the end.
@@ -77,7 +79,7 @@ public static string For(
// Tint and weight cover the name and count only: the ▌ ahead and the ✎ / ⌁ behind are other
// facts. One tag rather than two nested, because a selected tab can also be unread.
- var named = owner + MarkupText.Escape(window.Title) + unread;
+ var named = name + unread;
var style = (selected, window.Unread > 0) switch
{
(true, true) => $"bold {UnreadBadge.TintFor(ink)}",
@@ -97,4 +99,44 @@ public static string For(
return focus + body + pen + cross;
}
+
+ ///
+ /// The part of a tab's label that is its name — the owner prefix a child window carries so
+ /// it stays traceable once dragged into another pane, and the window's own title. Unescaped, because
+ /// this is what is measured and cut: an escaped bracket is two characters standing for one cell, and
+ /// a budget spent in the wrong units cuts the wrong number of them.
+ ///
+ /// Public so SharpMUTermApp can cost a tab without re-deriving the rule. What the strip
+ /// spends on everything else is measured off a rendered title instead, so the decorations
+ /// have exactly one definition — this one's counterpart would be a second.
+ ///
+ ///
+ public static string Name(WorkspaceWindow window)
+ {
+ ArgumentNullException.ThrowIfNull(window);
+
+ var owner = window.Kind != WindowKind.Main && !string.IsNullOrEmpty(window.OwnerLabel)
+ ? window.OwnerLabel + " - "
+ : string.Empty;
+
+ return owner + window.Title;
+ }
+
+ ///
+ /// A name cut to its budget. The owner prefix goes before the window's own name is touched:
+ /// the chip behind the tab already says whose window this is, and cutting from the front would spend
+ /// the surviving cells on Corvid… — the half that every other tab in the strip repeats.
+ ///
+ private static string Elide(WorkspaceWindow window, int budget)
+ {
+ var name = Name(window);
+ if (budget <= 0 || name.Length <= budget)
+ {
+ return name;
+ }
+
+ return window.Title.Length <= budget
+ ? window.Title
+ : window.Title[..Math.Max(1, budget - 1)] + "…";
+ }
}
diff --git a/tests/SharpMUTerm.Tui.Tests/TabStripElisionTests.cs b/tests/SharpMUTerm.Tui.Tests/TabStripElisionTests.cs
new file mode 100644
index 0000000..2c2114c
--- /dev/null
+++ b/tests/SharpMUTerm.Tui.Tests/TabStripElisionTests.cs
@@ -0,0 +1,122 @@
+using SharpConsoleUI.Drivers;
+using SharpConsoleUI.Parsing;
+using SharpMUTerm.Graphics;
+using SharpMUTerm.Tui;
+
+namespace SharpMUTerm.Tui.Tests;
+
+///
+/// The tab strip against the pane it has to fit in, driven through a real layout and read off a real
+/// frame. pins the rule; this pins that the cells it is counting are the
+/// cells the framework actually spends — the pad either side of a title, the × on the active tab
+/// and the │ between tabs are all the framework's, and an arithmetic that drifted from them would
+/// still satisfy a pure test while overflowing the screen.
+///
+///
+/// Serialised with the other suites that render: a frame redirects the process-global Console.Out.
+///
+[NotInParallel]
+public class TabStripElisionTests
+{
+ private const int Width = 120;
+ private const int Height = 32;
+
+ private static readonly TerminalCapabilities Headless =
+ new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false);
+
+ private static SharpMUTermApp Crowded()
+ {
+ Console.SetIn(TextReader.Null);
+ var app = new SharpMUTermApp(
+ DemoScene.Build(), Headless, new HeadlessConsoleDriver(Width, Height));
+ app.RenderSnapshot("tabs-many");
+ return app;
+ }
+
+ ///
+ /// What the framework spends on one tab besides its title: a space either side, the × a
+ /// closable tab draws, and the │ that follows every tab but the last
+ /// (TabControl.Rendering.cs).
+ ///
+ private static int StripWidth(IReadOnlyList<(string WindowId, string Title, bool Closable)> strip) =>
+ strip.Select((t, i) =>
+ MarkupParser.StripLength(t.Title) + 2 + (t.Closable ? 1 : 0) + (i < strip.Count - 1 ? 1 : 0)).Sum();
+
+ ///
+ /// The headline: every tab in a crowded pane is drawn, where before the strip ran off the edge and
+ /// the tabs past it were simply not there.
+ ///
+ [Test]
+ public async Task EveryTabInACrowdedPaneFitsInsideItsOwnPane()
+ {
+ var app = Crowded();
+ var rects = app.PaneOutputRects();
+
+ foreach (var (paneId, titles) in app.PaneTabTitles)
+ {
+ var drawn = StripWidth(app.PaneTabStrip(paneId));
+
+ await Assert.That(drawn).IsLessThanOrEqualTo(rects[paneId].Width)
+ .Because($"{paneId} draws {titles.Count} tabs in {rects[paneId].Width} cells");
+ }
+ }
+
+ ///
+ /// And it is the crowded case that is being asserted: a pane holding one tab proves nothing about a
+ /// strip that has to shed cells.
+ ///
+ [Test]
+ public async Task TheCrowdedViewReallyIsCrowded()
+ {
+ var app = Crowded();
+ var busiest = app.PaneTabTitles.Values.Max(t => t.Count);
+
+ await Assert.That(busiest).IsGreaterThanOrEqualTo(5);
+ }
+
+ ///
+ /// The repeated owner prefix is what goes, and it goes before anybody loses a letter of their own
+ /// name — every one of those tabs belongs to the same character, so the prefix says nothing the chip
+ /// behind the tab does not.
+ ///
+ [Test]
+ public async Task TheRepeatedOwnerPrefixIsWhatTheStripGivesUp()
+ {
+ var app = Crowded();
+ var strip = app.PaneTabTitles.Values.MaxBy(t => t.Count)!;
+
+ await Assert.That(strip.Any(t => t.Contains(DemoScene.MainCharacterName, StringComparison.Ordinal)))
+ .IsFalse();
+ }
+
+ ///
+ /// The tab the pane is showing keeps a name worth reading. A strip whose every label is three letters
+ /// and an ellipsis — the selected one included — has stopped answering the question it is there for.
+ ///
+ [Test]
+ public async Task TheTabThePaneIsShowingKeepsAReadableName()
+ {
+ var app = Crowded();
+ var strip = app.PaneTabTitles.Values.MaxBy(t => t.Count)!;
+ var selected = strip.Single(t => t.Contains("bold", StringComparison.Ordinal));
+
+ await Assert.That(MarkupParser.StripLength(selected)).IsGreaterThanOrEqualTo(4);
+ await Assert.That(selected).Contains("Chat");
+ }
+
+ ///
+ /// A strip with room is untouched, which is the answer for nearly every pane this client ever draws:
+ /// the default workspace holds two tabs in a wide pane and neither of them is elided.
+ ///
+ [Test]
+ public async Task AStripWithRoomIsLeftExactlyAsItWas()
+ {
+ Console.SetIn(TextReader.Null);
+ var app = new SharpMUTermApp(
+ DemoScene.Build(), Headless, new HeadlessConsoleDriver(Width, Height));
+ app.RenderSnapshot();
+
+ await Assert.That(app.PaneTabTitles.Values.SelectMany(t => t).Any(t => t.Contains('…')))
+ .IsFalse();
+ }
+}
diff --git a/tests/SharpMUTerm.Tui.Tests/TabStripFitTests.cs b/tests/SharpMUTerm.Tui.Tests/TabStripFitTests.cs
new file mode 100644
index 0000000..86bb9aa
--- /dev/null
+++ b/tests/SharpMUTerm.Tui.Tests/TabStripFitTests.cs
@@ -0,0 +1,147 @@
+using SharpMUTerm.Tui;
+
+namespace SharpMUTerm.Tui.Tests;
+
+///
+/// How much of each tab's name a strip can afford. The rule only —
+/// drives it through a real strip in a real pane, which is where the cell counting is checked against a
+/// frame rather than against this file's arithmetic.
+///
+public class TabStripFitTests
+{
+ ///
+ /// One tab: is what it would draw whole, and the part after the last
+ /// " - " is its title — the same split TabTitles.Name makes.
+ /// is what the tab spends besides its name; three is the ordinary case (a space either side, and the
+ /// separator that follows it).
+ ///
+ private static TabCost Tab(string name, bool selected = false, int extra = 3)
+ {
+ var at = name.LastIndexOf(" - ", StringComparison.Ordinal);
+ return new TabCost(name.Length, at >= 0 ? name.Length - at - 3 : name.Length, extra, selected);
+ }
+
+ private static IReadOnlyList Budgets(int width, params TabCost[] tabs) =>
+ TabStripFit.Budgets(tabs, width);
+
+ /// A strip with room is left exactly alone — every budget is the whole name.
+ [Test]
+ public async Task AStripThatFitsIsNotElidedAtAll()
+ {
+ var tabs = new[] { Tab("Corvid - Chat"), Tab("Corvid - Public", selected: true) };
+
+ await Assert.That(Budgets(100, tabs)).IsEquivalentTo(new[] { 13, 15 });
+ }
+
+ ///
+ /// A strip that has not been arranged yet reports no width, and that is not the same as having no
+ /// room: eliding against it would cut every title on the first frame and undo it on the second.
+ ///
+ [Test]
+ public async Task AnUnarrangedStripElidesNothing()
+ {
+ var tabs = new[] { Tab("Corvid - Chat"), Tab("Corvid - O-Gatecrashers", selected: true) };
+
+ await Assert.That(Budgets(0, tabs)).IsEquivalentTo(new[] { 13, 23 });
+ }
+
+ ///
+ /// The first thing a crowded strip drops, and it drops it for everybody: a pane full of one
+ /// character's captures repeats the same prefix on every tab, which is the cheapest thing there is to
+ /// lose and the most there is of it. Nobody loses a letter of their own name to it.
+ ///
+ [Test]
+ public async Task TheOwnerPrefixGoesBeforeAnyNameIsCut()
+ {
+ var tabs = new[]
+ {
+ Tab("Corvid - Chat", selected: true),
+ Tab("Corvid - Public"),
+ Tab("Corvid - Tells"),
+ };
+
+ // 4 + 6 + 5 names, 9 of frame: the titles fit exactly where the prefixed names could not.
+ await Assert.That(Budgets(24, tabs)).IsEquivalentTo(new[] { 4, 6, 5 });
+ }
+
+ ///
+ /// Cells come off the long name and leave the short one whole. Shrinking in proportion would take a
+ /// slice off Chat as well, which buys the strip nothing and costs it a word.
+ ///
+ [Test]
+ public async Task TheLongNameGivesUpCellsAndTheShortOneKeepsThemAll()
+ {
+ var tabs = new[] { Tab("Chat"), Tab("O-Gatecrashers"), Tab("Announcements") };
+
+ var budgets = Budgets(30, tabs);
+
+ await Assert.That(budgets[0]).IsEqualTo(4);
+ await Assert.That(budgets[1]).IsEqualTo(budgets[2]);
+ await Assert.That(budgets[1]).IsLessThan(14);
+ await Assert.That(budgets.Sum() + 9).IsLessThanOrEqualTo(30);
+ }
+
+ ///
+ /// The tab the pane is showing keeps its whole title while any other tab still has cells to give. It
+ /// is the one the reader is looking at and the one the strip exists to name.
+ ///
+ [Test]
+ public async Task TheSelectedTabIsSparedWhileTheOthersCanStillPay()
+ {
+ var tabs = new[]
+ {
+ Tab("Announcements"),
+ Tab("O-Gatecrashers", selected: true),
+ Tab("Radio Umi"),
+ };
+
+ var budgets = Budgets(34, tabs);
+
+ await Assert.That(budgets[1]).IsEqualTo(14);
+ await Assert.That(budgets[0]).IsLessThan(13);
+ }
+
+ ///
+ /// And when it cannot be spared it still keeps enough to be read. A strip where every label including
+ /// the selected one is three letters and an ellipsis answers none of the questions a strip is for.
+ ///
+ /// The floors are where this stops, and past them the tabs really do overflow. Eight tabs
+ /// cannot be drawn in forty cells however they are labelled, so the answer is the floors and the
+ /// framework clips whatever is left over — which is the state that existed before any of this and is
+ /// simply reached later now. What is bought is the tabs that do fit: at the floors, six of these
+ /// eight are drawn where two of them were before.
+ ///
+ ///
+ [Test]
+ public async Task EvenAStripThatCannotFitKeepsTheSelectedTabReadable()
+ {
+ var tabs = Enumerable.Range(0, 8)
+ .Select(i => Tab($"Channel number {i}", selected: i == 3))
+ .ToArray();
+
+ var budgets = Budgets(40, tabs);
+
+ await Assert.That(budgets[3]).IsEqualTo(TabStripFit.MinimumSelectedName);
+ await Assert.That(budgets.Where((_, i) => i != 3).Max()).IsEqualTo(TabStripFit.MinimumName);
+ }
+
+ ///
+ /// No budget is ever negative or past the name it is for — the two ways an elision this feeds could
+ /// throw rather than merely look wrong.
+ ///
+ [Test]
+ public async Task NoBudgetIsNegativeOrLongerThanTheNameItIsFor()
+ {
+ var tabs = new[] { Tab("A"), Tab("Corvid - B", selected: true), Tab("Corvid - Announcements") };
+
+ foreach (var width in new[] { 0, 1, 5, 12, 30, 400 })
+ {
+ var budgets = Budgets(width, tabs);
+ for (var i = 0; i < tabs.Length; i++)
+ {
+ await Assert.That(budgets[i]).IsGreaterThanOrEqualTo(1);
+ await Assert.That(budgets[i]).IsLessThanOrEqualTo(tabs[i].NameLength);
+ }
+ }
+ }
+}