From da606854ad9453df253850037276eef1a99ec4d1 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Fri, 14 Aug 2026 10:36:14 -0500 Subject: [PATCH] feat(tabs): a tab strip shrinks its labels to fit its pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 stops drawing — the tabs past the edge are gone, the selected one included, and so are the ×, the separator and the framework's own ← → hint, which only draws when there is slack for it and the strip has keyboard focus (never, here: focus is pinned to the armed command line). Five captures in a split pane drew two tabs and half of a third, and said nothing about the rest. The titles are ours, so the titles shrink. TabStripFit drops the repeated owner prefix across the whole strip first — a pane of one character's captures repeats "Corvid - " on every tab, and the chip behind the tab already says whose it is — then water-fills what is left, so the long name gives up cells and the short one keeps them all. The tab the pane is showing is spared while any other tab can still pay, and keeps a floor of its own when it cannot be. A tab's fixed cost is measured off a rendered title rather than derived a second time, so the badge, pen, ⌁ and focus ▌ keep one definition; the width is synced on PostBufferPaint, gated on having moved, and through RetitlePane rather than RefreshTabTitles so it does not drag the rail — which resizes the sidebar column, which moves the very widths that triggered it. TabControl.CalculateSize returns the constraint's width and never its content's, so there is no loop to guard against beyond that. Past the floors the tabs do overflow and that is left alone: eight tabs cannot be drawn in forty cells however they are labelled. Making the last tab reachable is a scroll offset, which is upstream work. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 42 ++++- src/SharpMUTerm.Tui/DemoScene.cs | 11 ++ src/SharpMUTerm.Tui/SharpMUTermApp.cs | 171 ++++++++++++++++-- src/SharpMUTerm.Tui/TabStripFit.cs | 147 +++++++++++++++ src/SharpMUTerm.Tui/TabTitles.cs | 56 +++++- .../TabStripElisionTests.cs | 122 +++++++++++++ .../SharpMUTerm.Tui.Tests/TabStripFitTests.cs | 147 +++++++++++++++ 7 files changed, 674 insertions(+), 22 deletions(-) create mode 100644 src/SharpMUTerm.Tui/TabStripFit.cs create mode 100644 tests/SharpMUTerm.Tui.Tests/TabStripElisionTests.cs create mode 100644 tests/SharpMUTerm.Tui.Tests/TabStripFitTests.cs 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); + } + } + } +}