From 7103f8acc068f3fcd914bb59c0c19f503f24e2ed Mon Sep 17 00:00:00 2001 From: chuan Date: Wed, 3 Jun 2026 22:14:35 +0800 Subject: [PATCH] feat: add terminal text measurer - measure terminal display width for text and escape sequences - truncate rendered lines to terminal columns - add width and truncation examples --- src/Example/Program.cs | 15 +- src/TinyTUI/Rendering/FullScreenRenderer.cs | 7 +- src/TinyTUI/Text/TerminalTextMeasurer.cs | 166 ++++++++++++++++++++ 3 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 src/TinyTUI/Text/TerminalTextMeasurer.cs diff --git a/src/Example/Program.cs b/src/Example/Program.cs index 551914f..ac8e5f4 100644 --- a/src/Example/Program.cs +++ b/src/Example/Program.cs @@ -5,14 +5,16 @@ using TinyTUI.Rendering; using TinyTUI.Runtime; using TinyTUI.Stdio; using TinyTUI.Stdout; +using TinyTUI.Text; using var terminalInput = new ConsoleTerminalInput(); var terminalOutput = new ConsoleTerminalOutput(); var parser = new DefaultInputParser(); -var renderer = new FullScreenRenderer(terminalOutput); +var textMeasurer = new TerminalTextMeasurer(); +var renderer = new FullScreenRenderer(terminalOutput, textMeasurer); using var runtime = new TuiRuntime(terminalInput, parser, renderer); -var component = new EchoComponent(runtime); +var component = new EchoComponent(runtime, textMeasurer); runtime.Add(component); runtime.SetFocus(component); @@ -34,7 +36,7 @@ finally /// /// 用于手动验证 Runtime 的示例组件 /// -file sealed class EchoComponent(ITuiRuntime runtime) : IInputComponent +file sealed class EchoComponent(ITuiRuntime runtime, ITextMeasurer textMeasurer) : IInputComponent { private readonly ManualResetEventSlim _done = new(); private readonly List _events = []; @@ -54,6 +56,13 @@ file sealed class EchoComponent(ITuiRuntime runtime) : IInputComponent "- 调整窗口大小 验证 Resize 事件会进入焦点组件并触发重渲染", "- 按 Esc 退出", string.Empty, + "宽度示例:", + $"字符串 \"abc\" 的终端宽度 = {textMeasurer.GetWidth("abc")}", + $"字符串 \"中文\" 的终端宽度 = {textMeasurer.GetWidth("中文")}", + $"字符串 \"😀\" 的终端宽度 = {textMeasurer.GetWidth("😀")}", + $"截断前 \"abc中文😀xyz\" 的终端宽度 = {textMeasurer.GetWidth("abc中文😀xyz")}", + $"截断到 8 列后的结果 = \"{textMeasurer.Truncate("abc中文😀xyz", 8)}\"", + string.Empty, "最近事件:", }; diff --git a/src/TinyTUI/Rendering/FullScreenRenderer.cs b/src/TinyTUI/Rendering/FullScreenRenderer.cs index 1950317..58d006a 100644 --- a/src/TinyTUI/Rendering/FullScreenRenderer.cs +++ b/src/TinyTUI/Rendering/FullScreenRenderer.cs @@ -1,12 +1,15 @@ using TinyTUI.Stdout; +using TinyTUI.Text; namespace TinyTUI.Rendering; /// /// 每次渲染都清屏并重绘全部内容的基础渲染器 /// -public sealed class FullScreenRenderer(ITerminalOutput output) : IRenderer +public sealed class FullScreenRenderer(ITerminalOutput output, ITextMeasurer? textMeasurer = null) : IRenderer { + private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer(); + /// public void Render(IReadOnlyList lines, TerminalSize size) { @@ -14,7 +17,7 @@ public sealed class FullScreenRenderer(ITerminalOutput output) : IRenderer foreach (var line in lines) { - output.Write(line); + output.Write(_textMeasurer.Truncate(line, size.Columns)); output.Write("\r\n"); } diff --git a/src/TinyTUI/Text/TerminalTextMeasurer.cs b/src/TinyTUI/Text/TerminalTextMeasurer.cs new file mode 100644 index 0000000..fe88c11 --- /dev/null +++ b/src/TinyTUI/Text/TerminalTextMeasurer.cs @@ -0,0 +1,166 @@ +using System.Buffers; +using System.Globalization; +using System.Text; + +namespace TinyTUI.Text; + +/// +/// 基于常见终端字符单元规则的文本测量器 +/// +public sealed class TerminalTextMeasurer : ITextMeasurer +{ + /// + public int GetWidth(string value) + { + var width = 0; + + for (var index = 0; index < value.Length;) + { + if (TryReadEscape(value, index, out var escapeLength)) + { + index += escapeLength; + continue; + } + + var status = Rune.DecodeFromUtf16(value.AsSpan(index), out var rune, out var charsConsumed); + if (status != OperationStatus.Done) + { + index++; + continue; + } + + width += GetRuneWidth(rune); + index += charsConsumed; + } + + return width; + } + + /// + public string Truncate(string value, int maxWidth) + { + if (maxWidth <= 0) + { + return string.Empty; + } + + var builder = new StringBuilder(); + var width = 0; + + for (var index = 0; index < value.Length;) + { + if (TryReadEscape(value, index, out var escapeLength)) + { + builder.Append(value.AsSpan(index, escapeLength)); + index += escapeLength; + continue; + } + + var status = Rune.DecodeFromUtf16(value.AsSpan(index), out var rune, out var charsConsumed); + if (status != OperationStatus.Done) + { + index++; + continue; + } + + var runeWidth = GetRuneWidth(rune); + if (width + runeWidth > maxWidth) + { + break; + } + + builder.Append(value.AsSpan(index, charsConsumed)); + width += runeWidth; + index += charsConsumed; + } + + return builder.ToString(); + } + + private static int GetRuneWidth(Rune rune) + { + if (Rune.IsControl(rune)) + return 0; + + var category = Rune.GetUnicodeCategory(rune); + if (category is UnicodeCategory.NonSpacingMark or UnicodeCategory.EnclosingMark or UnicodeCategory.Format) + return 0; + + return IsWide(rune.Value) ? 2 : 1; + } + + private static bool IsWide(int value) => + value is + >= 0x1100 and <= 0x115f or + >= 0x2329 and <= 0x232a or + >= 0x2e80 and <= 0xa4cf or + >= 0xac00 and <= 0xd7a3 or + >= 0xf900 and <= 0xfaff or + >= 0xfe10 and <= 0xfe19 or + >= 0xfe30 and <= 0xfe6f or + >= 0xff00 and <= 0xff60 or + >= 0xffe0 and <= 0xffe6 or + >= 0x1f000 and <= 0x1faff; + + private static bool TryReadEscape(string value, int start, out int length) + { + length = 0; + + if (value[start] != '\e') + return false; + + if (start + 1 >= value.Length) + { + length = 1; + return true; + } + + return value[start + 1] switch + { + '[' => TryReadCsi(value, start, out length), + ']' or '_' or 'P' => TryReadStringEscape(value, start, out length), + _ => ReadSimpleEscape(value, start, out length), + }; + } + + private static bool TryReadCsi(string value, int start, out int length) + { + for (var index = start + 2; index < value.Length; index++) + { + var ch = value[index]; + if (ch is >= '@' and <= '~') + { + length = index - start + 1; + return true; + } + } + + length = value.Length - start; + return true; + } + + private static bool TryReadStringEscape(string value, int start, out int length) + { + for (var index = start + 2; index < value.Length; index++) + { + switch (value[index]) + { + case '\a': + length = index - start + 1; + return true; + case '\e' when index + 1 < value.Length && value[index + 1] == '\\': + length = index - start + 2; + return true; + } + } + + length = value.Length - start; + return true; + } + + private static bool ReadSimpleEscape(string value, int start, out int length) + { + length = Math.Min(2, value.Length - start); + return true; + } +}