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