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
parent d385348c62
commit 7103f8acc0
3 changed files with 183 additions and 5 deletions
+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;
}
}