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:
@@ -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