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
This commit is contained in:
chuan
2026-06-03 22:14:35 +08:00
Unverified
parent d385348c62
commit 7103f8acc0
3 changed files with 183 additions and 5 deletions
+12 -3
View File
@@ -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
/// <summary>
/// 用于手动验证 Runtime 的示例组件
/// </summary>
file sealed class EchoComponent(ITuiRuntime runtime) : IInputComponent
file sealed class EchoComponent(ITuiRuntime runtime, ITextMeasurer textMeasurer) : IInputComponent
{
private readonly ManualResetEventSlim _done = new();
private readonly List<string> _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,
"最近事件:",
};
+5 -2
View File
@@ -1,12 +1,15 @@
using TinyTUI.Stdout;
using TinyTUI.Text;
namespace TinyTUI.Rendering;
/// <summary>
/// 每次渲染都清屏并重绘全部内容的基础渲染器
/// </summary>
public sealed class FullScreenRenderer(ITerminalOutput output) : IRenderer
public sealed class FullScreenRenderer(ITerminalOutput output, ITextMeasurer? textMeasurer = null) : IRenderer
{
private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer();
/// <inheritdoc />
public void Render(IReadOnlyList<string> 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");
}
+166
View File
@@ -0,0 +1,166 @@
using System.Buffers;
using System.Globalization;
using System.Text;
namespace TinyTUI.Text;
/// <summary>
/// 基于常见终端字符单元规则的文本测量器
/// </summary>
public sealed class TerminalTextMeasurer : ITextMeasurer
{
/// <inheritdoc />
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;
}
/// <inheritdoc />
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;
}
}