From 63c4b56069e02a971979b0b5c5afb5980c1a892d Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 9 Oct 2026 12:33:46 -0400 Subject: [PATCH 1/3] Measure TUI chrome text in terminal cells, not UTF-16 chars --- src/UniGetUI.Tui.Tests/TuiFunctionBarTests.cs | 16 ++++++++++++++++ src/UniGetUI.Tui/Infrastructure/TuiChrome.cs | 3 +++ .../Views/Controls/TuiFunctionBar.cs | 6 +++--- src/UniGetUI.Tui/Views/MainWindow.cs | 2 +- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/UniGetUI.Tui.Tests/TuiFunctionBarTests.cs b/src/UniGetUI.Tui.Tests/TuiFunctionBarTests.cs index 8d8bac4566..2c1ea20e8d 100644 --- a/src/UniGetUI.Tui.Tests/TuiFunctionBarTests.cs +++ b/src/UniGetUI.Tui.Tests/TuiFunctionBarTests.cs @@ -58,4 +58,20 @@ public void NarrowBarsDropWholeKeysByPriorityThenShortenTheNotification() NAssert.That(keys, Is.EqualTo(new[] { 0, 1, 2, 3, 4 })); NAssert.That(variant, Is.EqualTo(-1)); } + + [Test] + public void DoubleWidthLabelsAreMeasuredInTerminalCells() + { + NAssert.That(TuiChrome.Cells("帮助"), Is.EqualTo(4)); + NAssert.That(TuiChrome.Cells("Help"), Is.EqualTo(4)); + + TuiKeyHint[] keys = [new("F1", "帮助", 0), new("F5", "重新加载", 4)]; + (List shown, _) = TuiFunctionBar.Fit(keys, null, [""], 20); + NAssert.That(shown, Is.EqualTo(new[] { 0 })); + (shown, _) = TuiFunctionBar.Fit(keys, null, [""], 24); + NAssert.That(shown, Is.EqualTo(new[] { 0, 1 })); + + (_, int variant) = TuiFunctionBar.Fit([], "INFO", ["已更新 3 个软件包", ""], 25); + NAssert.That(variant, Is.EqualTo(1)); + } } diff --git a/src/UniGetUI.Tui/Infrastructure/TuiChrome.cs b/src/UniGetUI.Tui/Infrastructure/TuiChrome.cs index f74d2674e0..26720bf024 100644 --- a/src/UniGetUI.Tui/Infrastructure/TuiChrome.cs +++ b/src/UniGetUI.Tui/Infrastructure/TuiChrome.cs @@ -2,6 +2,7 @@ using Avalonia.Controls; using UniGetUI.Core.Tools; using UniGetUI.Tui.Views.Controls; +using Wcwidth; namespace UniGetUI.Tui.Infrastructure; @@ -17,4 +18,6 @@ public static TuiPanel PageTitle(string title, Control? summary = null) /// Key hints with translated labels, most important first, for . public static IReadOnlyList Hints(params (string Key, string Label)[] hints) => hints.Select((h, i) => new TuiKeyHint(h.Key, CoreTools.Translate(h.Label), i)).ToList(); + + public static int Cells(string text) => text.EnumerateRunes().Sum(r => Math.Max(0, UnicodeCalculator.GetWidth(r))); } diff --git a/src/UniGetUI.Tui/Views/Controls/TuiFunctionBar.cs b/src/UniGetUI.Tui/Views/Controls/TuiFunctionBar.cs index c17d3e8252..af8da7a407 100644 --- a/src/UniGetUI.Tui/Views/Controls/TuiFunctionBar.cs +++ b/src/UniGetUI.Tui/Views/Controls/TuiFunctionBar.cs @@ -119,7 +119,7 @@ internal static IReadOnlyList NotificationVariants(string title, string /// internal static (List Keys, int Variant) Fit(IReadOnlyList keys, string? badge, IReadOnlyList variants, double width) { - int NotificationWidth(int variant) => Gap + badge!.Length + 2 + (variants[variant].Length > 0 ? variants[variant].Length + 2 : 0); + int NotificationWidth(int variant) => Gap + TuiChrome.Cells(badge!) + 2 + (variants[variant].Length > 0 ? TuiChrome.Cells(variants[variant]) + 2 : 0); var byPriority = Enumerable.Range(0, keys.Count).OrderBy(i => keys[i].Priority).ToList(); var shown = new List(); @@ -152,13 +152,13 @@ void Place(IEnumerable candidates) return (shown, chosen); } - private static int CellWidth(TuiKeyHint hint) => hint.Key.Length + 2 + 1 + hint.Label.Length; + private static int CellWidth(TuiKeyHint hint) => TuiChrome.Cells(hint.Key) + 2 + 1 + TuiChrome.Cells(hint.Label); private void Layout() { double width = Bounds.Width - Padding.Left - Padding.Right; if (width <= 0) return; - double badges = _badgeTexts.Sum(b => b.Length + 2) + Math.Max(0, _badgeTexts.Count - 1) + (_badgeTexts.Count > 0 ? 1 : 0); + double badges = _badgeTexts.Sum(b => TuiChrome.Cells(b) + 2) + Math.Max(0, _badgeTexts.Count - 1) + (_badgeTexts.Count > 0 ? 1 : 0); IReadOnlyList variants = NotificationVariants(_title, _message); string? badge = _notificationActive ? _notificationBadge.Text?.Trim() : null; (List keys, int variant) = Fit(_hints, badge, variants, width - badges); diff --git a/src/UniGetUI.Tui/Views/MainWindow.cs b/src/UniGetUI.Tui/Views/MainWindow.cs index a2d4555d63..b65215b249 100644 --- a/src/UniGetUI.Tui/Views/MainWindow.cs +++ b/src/UniGetUI.Tui/Views/MainWindow.cs @@ -351,7 +351,7 @@ private void RefreshTabs() foreach (TabDensity candidate in Enum.GetValues()) { density = candidate; - int width = tabs.Sum(t => TabLabel(t.Id, t.Index, t.Id == _currentPageId ? TabDensity.Full : candidate).Length + 2); + int width = tabs.Sum(t => TuiChrome.Cells(TabLabel(t.Id, t.Index, t.Id == _currentPageId ? TabDensity.Full : candidate)) + 2); if (available <= 0 || width <= available) break; } From ea61718b0901a160b2f31b5e62088936aaf81489 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 9 Oct 2026 13:23:57 -0400 Subject: [PATCH 2/3] Keep the TUI tab strip clear of the right edge on narrow terminals --- src/UniGetUI.Tui.Tests/TuiTabStripTests.cs | 40 ++++++++++++++++++++++ src/UniGetUI.Tui/Views/MainWindow.cs | 24 ++++++++----- 2 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 src/UniGetUI.Tui.Tests/TuiTabStripTests.cs diff --git a/src/UniGetUI.Tui.Tests/TuiTabStripTests.cs b/src/UniGetUI.Tui.Tests/TuiTabStripTests.cs new file mode 100644 index 0000000000..c5077f43e6 --- /dev/null +++ b/src/UniGetUI.Tui.Tests/TuiTabStripTests.cs @@ -0,0 +1,40 @@ +using NUnit.Framework; +using UniGetUI.Tui.Views; +using static UniGetUI.Tui.Views.MainWindow; +using NAssert = NUnit.Framework.Assert; + +namespace UniGetUI.Tui.Tests; + +[TestFixture] +internal sealed class TuiTabStripTests +{ + private static readonly string[] Names = ["Discover", "Updates", "操作", "设置", "历史记录"]; + + private static string Label(int i, TabDensity density) => density switch + { + TabDensity.Full => $"{i + 1} {Names[i]}", + TabDensity.Short => $"{i + 1} {(Names[i].Length > 4 ? Names[i][..4] : Names[i])}", + TabDensity.Shorter => $"{i + 1} {(Names[i].Length > 3 ? Names[i][..3] : Names[i])}", + _ => $"{i + 1}", + }; + + [Test] + public void DoubleWidthTabLabelsAreMeasuredInTerminalCells() + { + NAssert.That(MainWindow.FitTabs(Names.Length, 0, Label, 45), Is.EqualTo((TabDensity.Shorter, TabDensity.Full))); + NAssert.That(MainWindow.FitTabs(Names.Length, 0, Label, 51), Is.EqualTo((TabDensity.Full, TabDensity.Full))); + } + + [Test] + public void WhenNothingFitsTheSelectedTabDropsToItsNumberToo() + { + NAssert.That(MainWindow.FitTabs(Names.Length, 4, Label, 24), Is.EqualTo((TabDensity.NumberOnly, TabDensity.Full))); + NAssert.That(MainWindow.FitTabs(Names.Length, 4, Label, 20), Is.EqualTo((TabDensity.NumberOnly, TabDensity.NumberOnly))); + } + + [Test] + public void AnUnmeasuredStripKeepsEveryTabFull() + { + NAssert.That(MainWindow.FitTabs(Names.Length, 0, Label, 0), Is.EqualTo((TabDensity.Full, TabDensity.Full))); + } +} diff --git a/src/UniGetUI.Tui/Views/MainWindow.cs b/src/UniGetUI.Tui/Views/MainWindow.cs index b65215b249..966b4bfc6f 100644 --- a/src/UniGetUI.Tui/Views/MainWindow.cs +++ b/src/UniGetUI.Tui/Views/MainWindow.cs @@ -312,7 +312,7 @@ private Control GetPage(string id) /// The pages, each with a numbered tab (Help and About are dialogs, opened from the Help menu and F1). private static readonly string[] TabPages = TuiPageIds.All.Where(id => id is not (TuiPageIds.Help or TuiPageIds.About)).ToArray(); - private enum TabDensity + internal enum TabDensity { Full, Short, @@ -342,18 +342,24 @@ private static string TabLabel(string id, int index, TabDensity density) }; } + internal static (TabDensity Others, TabDensity Active) FitTabs(int count, int active, Func label, double available) + { + foreach (TabDensity candidate in Enum.GetValues()) + { + int width = Enumerable.Range(0, count).Sum(i => TuiChrome.Cells(label(i, i == active ? TabDensity.Full : candidate)) + 2); + if (available <= 0 || width <= available) return (candidate, TabDensity.Full); + } + + return (TabDensity.NumberOnly, TabDensity.NumberOnly); + } + private void RefreshTabs() { List<(string Id, int Index)> tabs = TabPages.Select((id, i) => (id, i)).ToList(); double available = (_tabStrip.Parent as Control)?.Bounds.Width ?? 0; - TabDensity density = TabDensity.Full; - foreach (TabDensity candidate in Enum.GetValues()) - { - density = candidate; - int width = tabs.Sum(t => TuiChrome.Cells(TabLabel(t.Id, t.Index, t.Id == _currentPageId ? TabDensity.Full : candidate)) + 2); - if (available <= 0 || width <= available) break; - } + int current = tabs.FindIndex(t => t.Id == _currentPageId); + (TabDensity density, TabDensity activeDensity) = FitTabs(tabs.Count, current, (i, d) => TabLabel(tabs[i].Id, tabs[i].Index, d), available); while (_tabStrip.Children.Count > tabs.Count) _tabStrip.Children.RemoveAt(_tabStrip.Children.Count - 1); for (int i = 0; i < tabs.Count; i++) @@ -377,7 +383,7 @@ private void RefreshTabs() var text = (TextBlock)border.Child!; border.Tag = id; border.Background = active ? TuiPalette.Focus : Brushes.Transparent; - text.Text = " " + TabLabel(id, index, active ? TabDensity.Full : density) + " "; + text.Text = " " + TabLabel(id, index, active ? activeDensity : density) + " "; text.Foreground = active ? TuiPalette.FocusText : TuiPalette.TextMuted; text.FontWeight = active ? FontWeight.Bold : FontWeight.Normal; } From f886c53234a5d23c1974007d0c5765bbaec3d7a2 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 9 Oct 2026 13:44:51 -0400 Subject: [PATCH 3/3] Measure TUI chrome text with Consolonia's own glyph widths --- src/UniGetUI.Tui.Tests/TuiChromeCellsTests.cs | 23 +++++++++++++++++++ src/UniGetUI.Tui/Infrastructure/TuiChrome.cs | 5 +++- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 src/UniGetUI.Tui.Tests/TuiChromeCellsTests.cs diff --git a/src/UniGetUI.Tui.Tests/TuiChromeCellsTests.cs b/src/UniGetUI.Tui.Tests/TuiChromeCellsTests.cs new file mode 100644 index 0000000000..e33b7ceba2 --- /dev/null +++ b/src/UniGetUI.Tui.Tests/TuiChromeCellsTests.cs @@ -0,0 +1,23 @@ +using NUnit.Framework; +using UniGetUI.Tui.Infrastructure; +using UniGetUI.Tui.Views.Controls; +using NAssert = NUnit.Framework.Assert; + +namespace UniGetUI.Tui.Tests; + +[TestFixture, NonParallelizable] +internal sealed class TuiChromeCellsTests : TuiE2ETestBase +{ + [Test] + public async Task EmojiAreMeasuredTheWayTheConsoleDrawsThem() + { + await OnUi(() => + { + NAssert.That(TuiChrome.Cells("⚙"), Is.EqualTo(2)); + NAssert.That(TuiChrome.Cells("⚙ 设置"), Is.EqualTo(7)); + + (_, int variant) = TuiFunctionBar.Fit([], "INFO", ["⚙⚙⚙⚙", ""], 15); + NAssert.That(variant, Is.EqualTo(1)); + }); + } +} diff --git a/src/UniGetUI.Tui/Infrastructure/TuiChrome.cs b/src/UniGetUI.Tui/Infrastructure/TuiChrome.cs index 26720bf024..9bc08fdd95 100644 --- a/src/UniGetUI.Tui/Infrastructure/TuiChrome.cs +++ b/src/UniGetUI.Tui/Infrastructure/TuiChrome.cs @@ -19,5 +19,8 @@ public static TuiPanel PageTitle(string title, Control? summary = null) public static IReadOnlyList Hints(params (string Key, string Label)[] hints) => hints.Select((h, i) => new TuiKeyHint(h.Key, CoreTools.Translate(h.Label), i)).ToList(); - public static int Cells(string text) => text.EnumerateRunes().Sum(r => Math.Max(0, UnicodeCalculator.GetWidth(r))); + public static int Cells(string text) + => Application.Current is null + ? text.EnumerateRunes().Sum(r => Math.Max(0, UnicodeCalculator.GetWidth(r))) + : Consolonia.Controls.ControlUtils.MeasureText(text); }