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); + } + } + } +}