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:
+12
-3
@@ -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,
|
||||
"最近事件:",
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user