diff --git a/TODO.md b/TODO.md
index 8532664..e9ee9af 100644
--- a/TODO.md
+++ b/TODO.md
@@ -182,6 +182,36 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入
参考:`tmp/tui/src/utils.ts`
+本次推进:
+
+- `TerminalTextMeasurer` 从 Rune 扫描升级为 ANSI / OSC 感知的 grapheme cluster token 扫描,统一用于可见宽度、截断和按列切片
+- 支持可配置 tab 宽度,默认按 `tmp/tui` 的 3 列处理,避免 tab 在切片、overlay 和渲染宽度中计算不一致
+- 对 ZWJ emoji、variation selector、regional indicator 和常见 emoji 区间按终端双宽处理,降低流式输出中 emoji 中间态导致的差分渲染漂移
+- 新增带 ellipsis 和 pad 参数的 ANSI 感知截断,截断时保留样式前缀,并在省略符前后插入 SGR reset,避免样式污染省略符和后续内容
+- 新增 `ITextMeasurer.Wrap` 和 `TerminalTextMeasurer.Wrap`,提供 ANSI / OSC 8 感知换行,支持长词按 grapheme 切分、续行恢复活动 SGR 样式、行末临时关闭 underline 和 OSC 8 超链接
+- 新增 `test/TinyTUI.TextChecks` 最小文本检查项目,覆盖 tab、regional indicator、ZWJ emoji、variation selector、ANSI 截断、宽省略符、ANSI 换行和 OSC 8 BEL 超链接续行
+
+为什么先做:
+
+- 第 3 项渲染管线和第 4 项 overlay 合成都已经依赖 `ITextMeasurer` 的宽度、截断和切片;先把文本底座升级后,可以减少组件、overlay、renderer 各自实现宽度规则导致的错位
+- `tmp/tui/src/utils.ts` 的核心能力是同一套 grapheme/ANSI 工具支撑 `visibleWidth`、`truncateToWidth`、`sliceWithWidth` 和 `wrapTextWithAnsi`,本次选择在 C# 侧先收敛到 `TerminalTextMeasurer`,保持现有公开入口简单
+- 换行先处理 ANSI SGR 与 OSC 8,是因为 Markdown、Editor、SelectList 后续都需要长链接、带颜色文本和 underline 内容稳定换行
+
+当前更好的点:
+
+- C# 侧把 `Wrap` 挂到 `ITextMeasurer`,调用方可以先依赖抽象逐步迁移组件,而不是直接绑定某个静态工具函数
+- `TerminalTextMeasurer` 的 escape 识别覆盖 CSI、OSC、APC 和 DCS,切片与宽度计算共用同一次 token 化逻辑,比组件内手写字符串长度更稳定
+- 换行时 OSC 8 会保留原始 BEL 或 ST 终止符,避免 OAuth 等 BEL 终止超链接在续行中被改写成另一种终止形式
+- 新增检查项目只验证文本工具,不引入完整虚拟终端测试基础设施,避免提前扩大第 9 项范围
+
+后续仍需补齐:
+
+- C# 当前使用 `StringInfo.GetNextTextElementLength` 和 emoji 区间启发式,不等价于 `tmp/tui` 的 `Intl.Segmenter` + `RGI_Emoji` 精确判断,未来可引入更完整的 Unicode/RGI 数据或专门测试集
+- `Wrap` 目前是 word wrap + 长词切分,还没有实现 `tmp/tui` 中完整的 punctuation/word segmenter 行为,CJK 断行、标点避头尾和复杂 Markdown token 换行仍需细化
+- SGR tracker 已覆盖常见样式、标准色、256 色和 RGB 色,但还没有处理所有终端私有样式参数和嵌套 hyperlink 的边界测试
+- 按列切片仍没有像 `extractSegments` 那样恢复切片前的活动样式,overlay 右侧继承样式丢失问题需要在后续文本 segment 模型中解决
+- 还缺正式测试框架和虚拟终端断言;当前 `TinyTUI.TextChecks` 是最小可执行检查,后续第 9 项应把这些用例迁移到统一测试项目
+
### 6. 组件基础设施
目标:补齐组件体系的横向能力,而不是继续只补单个组件的小功能
diff --git a/src/TinyTUI/Text/ITextMeasurer.cs b/src/TinyTUI/Text/ITextMeasurer.cs
index 18ef310..2b5e588 100644
--- a/src/TinyTUI/Text/ITextMeasurer.cs
+++ b/src/TinyTUI/Text/ITextMeasurer.cs
@@ -15,6 +15,34 @@ public interface ITextMeasurer
///
string Truncate(string value, int maxWidth);
+ ///
+ /// 裁剪字符串并在确实发生裁剪时追加省略符
+ ///
+ string Truncate(string value, int maxWidth, string ellipsis, bool pad = false)
+ {
+ if (maxWidth <= 0)
+ return string.Empty;
+
+ var width = GetWidth(value);
+ if (width <= maxWidth)
+ return pad ? value + new string(' ', maxWidth - width) : value;
+
+ var ellipsisWidth = GetWidth(ellipsis);
+ if (ellipsisWidth >= maxWidth)
+ {
+ var ellipsisSlice = Slice(ellipsis, 0, maxWidth, strict: true);
+ var clipped = ellipsisSlice.Text.Length == 0 ? string.Empty : $"\e[0m{ellipsisSlice.Text}\e[0m";
+ return pad ? clipped + new string(' ', Math.Max(0, maxWidth - ellipsisSlice.Width)) : clipped;
+ }
+
+ var prefix = Slice(value, 0, maxWidth - ellipsisWidth, strict: true);
+ var result = ellipsis.Length == 0
+ ? $"{prefix.Text}\e[0m"
+ : $"{prefix.Text}\e[0m{ellipsis}\e[0m";
+
+ return pad ? result + new string(' ', Math.Max(0, maxWidth - prefix.Width - ellipsisWidth)) : result;
+ }
+
///
/// 按可见列切出字符串片段并返回片段实际宽度
///
@@ -31,4 +59,31 @@ public interface ITextMeasurer
var sliced = Truncate(remainder, maxWidth);
return new TextSlice(sliced, GetWidth(sliced));
}
+
+ ///
+ /// 按可见宽度换行并尽量保留终端样式状态
+ ///
+ IReadOnlyList Wrap(string value, int width)
+ {
+ if (value.Length == 0)
+ return [string.Empty];
+
+ if (width <= 0)
+ return value.Split('\n').Select(_ => string.Empty).ToArray();
+
+ var lines = new List();
+ foreach (var line in value.Split('\n'))
+ {
+ if (GetWidth(line) <= width)
+ {
+ lines.Add(line);
+ continue;
+ }
+
+ for (var column = 0; column < GetWidth(line); column += width)
+ lines.Add(Slice(line, column, width, strict: true).Text);
+ }
+
+ return lines.Count == 0 ? [string.Empty] : lines;
+ }
}
diff --git a/src/TinyTUI/Text/TerminalTextMeasurer.cs b/src/TinyTUI/Text/TerminalTextMeasurer.cs
index 049c37c..6826587 100644
--- a/src/TinyTUI/Text/TerminalTextMeasurer.cs
+++ b/src/TinyTUI/Text/TerminalTextMeasurer.cs
@@ -7,32 +7,24 @@ namespace TinyTUI.Text;
///
/// 基于常见终端字符单元规则的文本测量器
///
-public sealed class TerminalTextMeasurer : ITextMeasurer
+public sealed class TerminalTextMeasurer(int tabWidth = 3) : ITextMeasurer
{
+ ///
+ /// 获取 tab 字符占用的终端字符单元数量
+ ///
+ public int TabWidth { get; } = Math.Max(0, tabWidth);
+
///
public int GetWidth(string value)
{
var width = 0;
- for (var index = 0; index < value.Length;)
+ foreach (var token in EnumerateTokens(value))
{
- if (TryReadEscape(value, index, out var escapeLength))
- {
- // ANSI 和 OSC 等控制序列改变终端状态但不占显示列
- index += escapeLength;
+ if (token.IsEscape)
continue;
- }
- var status = Rune.DecodeFromUtf16(value.AsSpan(index), out var rune, out var charsConsumed);
- if (status != OperationStatus.Done)
- {
- // 遇到非法 UTF-16 片段时跳过一个 char 避免测量器卡住
- index++;
- continue;
- }
-
- width += GetRuneWidth(rune);
- index += charsConsumed;
+ width += GetTextElementWidth(token.Text);
}
return width;
@@ -47,37 +39,59 @@ public sealed class TerminalTextMeasurer : ITextMeasurer
var builder = new StringBuilder();
var width = 0;
- for (var index = 0; index < value.Length;)
+ foreach (var token in EnumerateTokens(value))
{
- if (TryReadEscape(value, index, out var escapeLength))
+ if (token.IsEscape)
{
// 截断时保留样式控制序列 否则被截断文本可能丢失颜色或重置符
- builder.Append(value.AsSpan(index, escapeLength));
- index += escapeLength;
+ builder.Append(token.Text);
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)
+ var tokenWidth = GetTextElementWidth(token.Text);
+ if (width + tokenWidth > maxWidth)
{
break;
}
- builder.Append(value.AsSpan(index, charsConsumed));
- width += runeWidth;
- index += charsConsumed;
+ builder.Append(token.Text);
+ width += tokenWidth;
}
return builder.ToString();
}
+ ///
+ /// 截断字符串并在确实发生截断时追加省略符
+ ///
+ public string Truncate(string value, int maxWidth, string ellipsis, bool pad = false)
+ {
+ if (maxWidth <= 0)
+ return string.Empty;
+
+ if (value.Length == 0)
+ return pad ? new string(' ', maxWidth) : string.Empty;
+
+ var textWidth = GetWidth(value);
+ if (textWidth <= maxWidth)
+ return pad ? value + new string(' ', maxWidth - textWidth) : value;
+
+ var ellipsisWidth = GetWidth(ellipsis);
+ if (ellipsisWidth >= maxWidth)
+ {
+ var ellipsisSlice = Slice(ellipsis, 0, maxWidth, strict: true);
+ var clipped = ellipsisSlice.Text.Length == 0 ? string.Empty : $"\e[0m{ellipsisSlice.Text}\e[0m";
+ return pad ? clipped + new string(' ', Math.Max(0, maxWidth - ellipsisSlice.Width)) : clipped;
+ }
+
+ var prefix = Slice(value, 0, maxWidth - ellipsisWidth, strict: true);
+ var result = ellipsis.Length == 0
+ ? $"{prefix.Text}\e[0m"
+ : $"{prefix.Text}\e[0m{ellipsis}\e[0m";
+
+ return pad ? result + new string(' ', Math.Max(0, maxWidth - prefix.Width - ellipsisWidth)) : result;
+ }
+
///
public TextSlice Slice(string value, int startColumn, int maxWidth, bool strict = true)
{
@@ -89,70 +103,347 @@ public sealed class TerminalTextMeasurer : ITextMeasurer
var sliceWidth = 0;
var endColumn = startColumn + maxWidth;
- for (var index = 0; index < value.Length;)
+ foreach (var token in EnumerateTokens(value))
{
- if (TryReadEscape(value, index, out var escapeLength))
+ if (token.IsEscape)
{
// 只有进入切片后才保留控制序列 避免把切片前的样式状态带入 overlay 边界
if (currentColumn >= startColumn && currentColumn < endColumn)
- builder.Append(value.AsSpan(index, escapeLength));
+ builder.Append(token.Text);
- index += escapeLength;
continue;
}
- var status = Rune.DecodeFromUtf16(value.AsSpan(index), out var rune, out var charsConsumed);
- if (status != OperationStatus.Done)
- {
- index++;
+ var tokenWidth = GetTextElementWidth(token.Text);
+ var tokenStart = currentColumn;
+ var tokenEnd = currentColumn + tokenWidth;
+ currentColumn = tokenEnd;
+
+ if (tokenEnd <= startColumn)
continue;
- }
- var runeWidth = GetRuneWidth(rune);
- var runeStart = currentColumn;
- var runeEnd = currentColumn + runeWidth;
- currentColumn = runeEnd;
-
- if (runeEnd <= startColumn)
- {
- index += charsConsumed;
- continue;
- }
-
- if (runeStart >= endColumn)
+ if (tokenStart >= endColumn)
break;
- if (strict && (runeStart < startColumn || runeEnd > endColumn))
+ if (strict && (tokenStart < startColumn || tokenEnd > endColumn))
{
// 双宽字符压到边界时直接跳过 避免终端用占位符或半个 emoji 污染相邻区域
- index += charsConsumed;
continue;
}
- if (!strict && runeEnd > endColumn)
+ if (!strict && tokenEnd > endColumn)
break;
- builder.Append(value.AsSpan(index, charsConsumed));
- sliceWidth += runeWidth;
- index += charsConsumed;
+ builder.Append(token.Text);
+ sliceWidth += tokenWidth;
}
return new TextSlice(builder.ToString(), sliceWidth);
}
///
- /// 计算单个 Unicode Rune 的终端宽度
+ /// 按可见宽度换行并在续行恢复必要的 ANSI 和 OSC 8 样式
///
- private static int GetRuneWidth(Rune rune)
+ public IReadOnlyList Wrap(string value, int width)
{
- if (Rune.IsControl(rune))
+ if (value.Length == 0)
+ return [string.Empty];
+
+ if (width <= 0)
+ return value.Split('\n').Select(_ => string.Empty).ToArray();
+
+ var lines = new List();
+ var literalLineTracker = new AnsiStyleTracker();
+
+ foreach (var inputLine in value.Split('\n'))
+ {
+ var line = lines.Count == 0 ? inputLine : literalLineTracker.GetActiveCodes() + inputLine;
+ lines.AddRange(WrapSingleLine(line, width));
+ UpdateTrackerFromText(inputLine, literalLineTracker);
+ }
+
+ return lines.Count == 0 ? [string.Empty] : lines;
+ }
+
+ ///
+ /// 换行单个逻辑行
+ ///
+ private IReadOnlyList WrapSingleLine(string line, int width)
+ {
+ if (line.Length == 0)
+ return [string.Empty];
+
+ if (GetWidth(line) <= width)
+ return [line];
+
+ var wrapped = new List();
+ var tracker = new AnsiStyleTracker();
+ var currentLine = new StringBuilder();
+ var currentWidth = 0;
+
+ foreach (var token in SplitWrapTokens(line))
+ {
+ var tokenWidth = GetWidth(token);
+ var isWhitespace = IsVisibleWhitespaceToken(token);
+
+ if (tokenWidth > width && !isWhitespace)
+ {
+ if (currentLine.Length > 0)
+ {
+ wrapped.Add(TrimTrailingWhitespace(currentLine.ToString()) + tracker.GetLineEndReset());
+ currentLine.Clear();
+ currentWidth = 0;
+ }
+
+ var broken = BreakLongToken(token, width, tracker);
+ for (var index = 0; index < broken.Count - 1; index++)
+ wrapped.Add(broken[index]);
+
+ currentLine.Append(broken[^1]);
+ currentWidth = GetWidth(broken[^1]);
+ continue;
+ }
+
+ if (currentWidth > 0 && currentWidth + tokenWidth > width)
+ {
+ wrapped.Add(TrimTrailingWhitespace(currentLine.ToString()) + tracker.GetLineEndReset());
+ currentLine.Clear();
+
+ if (isWhitespace)
+ {
+ // 换行后不保留行首空白 但要恢复仍然处于激活状态的样式
+ currentLine.Append(tracker.GetActiveCodes());
+ currentWidth = 0;
+ }
+ else
+ {
+ currentLine.Append(tracker.GetActiveCodes());
+ currentLine.Append(token);
+ currentWidth = tokenWidth;
+ }
+ }
+ else
+ {
+ currentLine.Append(token);
+ currentWidth += tokenWidth;
+ }
+
+ UpdateTrackerFromText(token, tracker);
+ }
+
+ if (currentLine.Length > 0)
+ wrapped.Add(TrimTrailingWhitespace(currentLine.ToString()));
+
+ return wrapped.Count == 0 ? [string.Empty] : wrapped;
+ }
+
+ ///
+ /// 把长词按 grapheme cluster 切成多个可见宽度受限的行
+ ///
+ private IReadOnlyList BreakLongToken(string token, int width, AnsiStyleTracker tracker)
+ {
+ var lines = new List();
+ var currentLine = new StringBuilder(tracker.GetActiveCodes());
+ var currentWidth = 0;
+
+ foreach (var part in EnumerateTokens(token))
+ {
+ if (part.IsEscape)
+ {
+ currentLine.Append(part.Text);
+ tracker.Process(part.Text);
+ continue;
+ }
+
+ var partWidth = GetTextElementWidth(part.Text);
+ if (currentWidth > 0 && currentWidth + partWidth > width)
+ {
+ currentLine.Append(tracker.GetLineEndReset());
+ lines.Add(currentLine.ToString());
+ currentLine.Clear();
+ currentLine.Append(tracker.GetActiveCodes());
+ currentWidth = 0;
+ }
+
+ if (partWidth > width)
+ continue;
+
+ currentLine.Append(part.Text);
+ currentWidth += partWidth;
+ }
+
+ if (currentLine.Length > 0)
+ lines.Add(currentLine.ToString());
+
+ return lines.Count == 0 ? [string.Empty] : lines;
+ }
+
+ ///
+ /// 按空白和非空白拆分换行词元 并把 ANSI 序列延后绑定到下一个可见词元
+ ///
+ private static IReadOnlyList SplitWrapTokens(string line)
+ {
+ var tokens = new List();
+ var current = new StringBuilder();
+ var pendingEscapes = new StringBuilder();
+ bool? currentIsWhitespace = null;
+
+ foreach (var token in EnumerateTokens(line))
+ {
+ if (token.IsEscape)
+ {
+ pendingEscapes.Append(token.Text);
+ continue;
+ }
+
+ var isWhitespace = IsWhitespaceTextElement(token.Text);
+ if (current.Length > 0 && currentIsWhitespace != isWhitespace)
+ {
+ tokens.Add(current.ToString());
+ current.Clear();
+ }
+
+ if (pendingEscapes.Length > 0)
+ {
+ current.Append(pendingEscapes);
+ pendingEscapes.Clear();
+ }
+
+ current.Append(token.Text);
+ currentIsWhitespace = isWhitespace;
+ }
+
+ if (pendingEscapes.Length > 0)
+ current.Append(pendingEscapes);
+
+ if (current.Length > 0)
+ tokens.Add(current.ToString());
+
+ return tokens;
+ }
+
+ ///
+ /// 更新样式追踪器
+ ///
+ private static void UpdateTrackerFromText(string text, AnsiStyleTracker tracker)
+ {
+ foreach (var token in EnumerateTokens(text))
+ {
+ if (token.IsEscape)
+ tracker.Process(token.Text);
+ }
+ }
+
+ ///
+ /// 判断词元的可见内容是否全部为空白
+ ///
+ private static bool IsVisibleWhitespaceToken(string token)
+ {
+ var hasVisibleText = false;
+
+ foreach (var part in EnumerateTokens(token))
+ {
+ if (part.IsEscape)
+ continue;
+
+ hasVisibleText = true;
+ if (!IsWhitespaceTextElement(part.Text))
+ return false;
+ }
+
+ return hasVisibleText;
+ }
+
+ ///
+ /// 判断文本单元是否为空白
+ ///
+ private static bool IsWhitespaceTextElement(string text) => text.EnumerateRunes().All(rune => Rune.IsWhiteSpace(rune));
+
+ ///
+ /// 去掉行尾可见空白 保留最后的终端控制序列由调用方重新补齐
+ ///
+ private static string TrimTrailingWhitespace(string value) => value.TrimEnd();
+
+ ///
+ /// 枚举文本中的不可见终端控制序列和可见 grapheme cluster
+ ///
+ private static IEnumerable EnumerateTokens(string value)
+ {
+ for (var index = 0; index < value.Length;)
+ {
+ if (TryReadEscape(value, index, out var escapeLength))
+ {
+ // ANSI OSC APC DCS 等序列是终端状态而不是可见文本
+ yield return new TextToken(value.Substring(index, escapeLength), IsEscape: true);
+ index += escapeLength;
+ continue;
+ }
+
+ var elementLength = StringInfo.GetNextTextElementLength(value, index);
+ if (elementLength <= 0)
+ elementLength = 1;
+
+ yield return new TextToken(value.Substring(index, elementLength), IsEscape: false);
+ index += elementLength;
+ }
+ }
+
+ ///
+ /// 计算单个 grapheme cluster 的终端宽度
+ ///
+ private int GetTextElementWidth(string text)
+ {
+ if (text == "\t")
+ return TabWidth;
+
+ var width = 0;
+ var hasPrintableRune = false;
+
+ foreach (var rune in text.EnumerateRunes())
+ {
+ if (Rune.IsControl(rune))
+ continue;
+
+ var category = Rune.GetUnicodeCategory(rune);
+ if (category is UnicodeCategory.NonSpacingMark or UnicodeCategory.EnclosingMark or UnicodeCategory.Format)
+ continue;
+
+ hasPrintableRune = true;
+ width += IsWide(rune.Value) ? 2 : 1;
+ }
+
+ if (!hasPrintableRune)
return 0;
- var category = Rune.GetUnicodeCategory(rune);
- if (category is UnicodeCategory.NonSpacingMark or UnicodeCategory.EnclosingMark or UnicodeCategory.Format)
- return 0;
+ // 终端通常把 emoji grapheme 作为 2 列渲染 组合序列不能按每个码点累加
+ if (CouldBeEmoji(text))
+ return 2;
- return IsWide(rune.Value) ? 2 : 1;
+ return width;
+ }
+
+ ///
+ /// 判断 grapheme cluster 是否应按 emoji 宽度处理
+ ///
+ private static bool CouldBeEmoji(string text)
+ {
+ var hasJoinerOrSelector = false;
+ var startsWithRegionalIndicator = false;
+
+ foreach (var (rune, index) in EnumerateRunesWithIndex(text))
+ {
+ var value = rune.Value;
+ if (index == 0 && value is >= 0x1f1e6 and <= 0x1f1ff)
+ startsWithRegionalIndicator = true;
+
+ if (value is 0x200d or 0xfe0f)
+ hasJoinerOrSelector = true;
+
+ if (IsEmojiRange(value))
+ return true;
+ }
+
+ return startsWithRegionalIndicator || hasJoinerOrSelector;
}
///
@@ -171,6 +462,26 @@ public sealed class TerminalTextMeasurer : ITextMeasurer
>= 0xffe0 and <= 0xffe6 or
>= 0x1f000 and <= 0x1faff;
+ ///
+ /// 判断码点是否位于常见 emoji 相关区间
+ ///
+ private static bool IsEmojiRange(int value) =>
+ value is
+ >= 0x1f000 and <= 0x1faff or
+ >= 0x2300 and <= 0x23ff or
+ >= 0x2600 and <= 0x27bf or
+ >= 0x2b50 and <= 0x2b55;
+
+ ///
+ /// 枚举 Rune 并提供 grapheme 内的序号
+ ///
+ private static IEnumerable<(Rune Rune, int Index)> EnumerateRunesWithIndex(string text)
+ {
+ var index = 0;
+ foreach (var rune in text.EnumerateRunes())
+ yield return (rune, index++);
+ }
+
///
/// 尝试读取不占显示宽度的终端 escape 序列
///
@@ -244,4 +555,300 @@ public sealed class TerminalTextMeasurer : ITextMeasurer
length = Math.Min(2, value.Length - start);
return true;
}
+
+ ///
+ /// 追踪换行过程中需要跨行恢复的 SGR 和 OSC 8 状态
+ ///
+ private sealed class AnsiStyleTracker
+ {
+ private bool _bold;
+ private bool _dim;
+ private bool _italic;
+ private bool _underline;
+ private bool _blink;
+ private bool _inverse;
+ private bool _hidden;
+ private bool _strikethrough;
+ private string? _foreground;
+ private string? _background;
+ private ActiveHyperlink? _activeHyperlink;
+
+ ///
+ /// 处理单个终端控制序列
+ ///
+ public void Process(string escape)
+ {
+ if (TryParseOsc8Hyperlink(escape, out var hyperlink))
+ {
+ _activeHyperlink = hyperlink;
+ return;
+ }
+
+ if (!escape.EndsWith('m'))
+ return;
+
+ if (!TryReadSgrParameters(escape, out var parameters))
+ return;
+
+ if (parameters.Count == 0)
+ {
+ ResetSgr();
+ return;
+ }
+
+ for (var index = 0; index < parameters.Count;)
+ {
+ var code = parameters[index];
+ if (code is 38 or 48)
+ {
+ var color = TryReadColorParameter(parameters, index, out var consumed);
+ if (color is not null)
+ {
+ if (code == 38)
+ _foreground = color;
+ else
+ _background = color;
+
+ index += consumed;
+ continue;
+ }
+ }
+
+ ApplySgrCode(code);
+ index++;
+ }
+ }
+
+ ///
+ /// 获取续行开头需要恢复的控制序列
+ ///
+ public string GetActiveCodes()
+ {
+ var codes = new List();
+ if (_bold)
+ codes.Add("1");
+ if (_dim)
+ codes.Add("2");
+ if (_italic)
+ codes.Add("3");
+ if (_underline)
+ codes.Add("4");
+ if (_blink)
+ codes.Add("5");
+ if (_inverse)
+ codes.Add("7");
+ if (_hidden)
+ codes.Add("8");
+ if (_strikethrough)
+ codes.Add("9");
+ if (_foreground is not null)
+ codes.Add(_foreground);
+ if (_background is not null)
+ codes.Add(_background);
+
+ var builder = new StringBuilder();
+ if (codes.Count > 0)
+ builder.Append($"\e[{string.Join(';', codes)}m");
+
+ if (_activeHyperlink is not null)
+ builder.Append(_activeHyperlink.Value.OpenSequence);
+
+ return builder.ToString();
+ }
+
+ ///
+ /// 获取物理行末需要临时关闭的控制序列
+ ///
+ public string GetLineEndReset()
+ {
+ var builder = new StringBuilder();
+ if (_underline)
+ builder.Append("\e[24m");
+
+ if (_activeHyperlink is not null)
+ builder.Append(_activeHyperlink.Value.CloseSequence);
+
+ return builder.ToString();
+ }
+
+ ///
+ /// 应用单个 SGR 参数
+ ///
+ private void ApplySgrCode(int code)
+ {
+ switch (code)
+ {
+ case 0:
+ ResetSgr();
+ break;
+ case 1:
+ _bold = true;
+ break;
+ case 2:
+ _dim = true;
+ break;
+ case 3:
+ _italic = true;
+ break;
+ case 4:
+ _underline = true;
+ break;
+ case 5:
+ _blink = true;
+ break;
+ case 7:
+ _inverse = true;
+ break;
+ case 8:
+ _hidden = true;
+ break;
+ case 9:
+ _strikethrough = true;
+ break;
+ case 21:
+ _bold = false;
+ break;
+ case 22:
+ _bold = false;
+ _dim = false;
+ break;
+ case 23:
+ _italic = false;
+ break;
+ case 24:
+ _underline = false;
+ break;
+ case 25:
+ _blink = false;
+ break;
+ case 27:
+ _inverse = false;
+ break;
+ case 28:
+ _hidden = false;
+ break;
+ case 29:
+ _strikethrough = false;
+ break;
+ case 39:
+ _foreground = null;
+ break;
+ case 49:
+ _background = null;
+ break;
+ case >= 30 and <= 37 or >= 90 and <= 97:
+ _foreground = code.ToString(CultureInfo.InvariantCulture);
+ break;
+ case >= 40 and <= 47 or >= 100 and <= 107:
+ _background = code.ToString(CultureInfo.InvariantCulture);
+ break;
+ }
+ }
+
+ ///
+ /// 重置 SGR 样式但保留 OSC 8 超链接状态
+ ///
+ private void ResetSgr()
+ {
+ _bold = false;
+ _dim = false;
+ _italic = false;
+ _underline = false;
+ _blink = false;
+ _inverse = false;
+ _hidden = false;
+ _strikethrough = false;
+ _foreground = null;
+ _background = null;
+ }
+
+ ///
+ /// 读取 SGR 参数
+ ///
+ private static bool TryReadSgrParameters(string escape, out List parameters)
+ {
+ parameters = [];
+ if (!escape.StartsWith("\e[", StringComparison.Ordinal) || !escape.EndsWith('m'))
+ return false;
+
+ var body = escape[2..^1];
+ if (body.Length == 0)
+ return true;
+
+ foreach (var part in body.Split(';'))
+ {
+ if (!int.TryParse(part, CultureInfo.InvariantCulture, out var value))
+ value = 0;
+
+ parameters.Add(value);
+ }
+
+ return true;
+ }
+
+ ///
+ /// 读取 256 色或 RGB 色彩参数
+ ///
+ private static string? TryReadColorParameter(IReadOnlyList parameters, int start, out int consumed)
+ {
+ consumed = 1;
+ if (start + 2 < parameters.Count && parameters[start + 1] == 5)
+ {
+ consumed = 3;
+ return string.Join(';', parameters.Skip(start).Take(consumed));
+ }
+
+ if (start + 4 < parameters.Count && parameters[start + 1] == 2)
+ {
+ consumed = 5;
+ return string.Join(';', parameters.Skip(start).Take(consumed));
+ }
+
+ return null;
+ }
+
+ ///
+ /// 解析 OSC 8 超链接打开和关闭序列
+ ///
+ private static bool TryParseOsc8Hyperlink(string escape, out ActiveHyperlink? hyperlink)
+ {
+ hyperlink = null;
+ if (!escape.StartsWith("\e]8;", StringComparison.Ordinal))
+ return false;
+
+ var terminator = escape.EndsWith('\a') ? "\a" : "\e\\";
+ var body = escape[4..^terminator.Length];
+ var separatorIndex = body.IndexOf(';', StringComparison.Ordinal);
+ if (separatorIndex < 0)
+ return false;
+
+ var parameters = body[..separatorIndex];
+ var url = body[(separatorIndex + 1)..];
+ hyperlink = url.Length == 0
+ ? null
+ : new ActiveHyperlink(parameters, url, terminator);
+ return true;
+ }
+ }
+
+ ///
+ /// 表示当前激活的 OSC 8 超链接
+ ///
+ private readonly record struct ActiveHyperlink(string Parameters, string Url, string Terminator)
+ {
+ ///
+ /// 获取打开超链接的 OSC 8 序列
+ ///
+ public string OpenSequence => $"\e]8;{Parameters};{Url}{Terminator}";
+
+ ///
+ /// 获取关闭超链接的 OSC 8 序列
+ ///
+ public string CloseSequence => $"\e]8;;{Terminator}";
+ }
+
+ ///
+ /// 表示文本扫描时得到的一个终端控制序列或可见文本单元
+ ///
+ private readonly record struct TextToken(string Text, bool IsEscape);
}
diff --git a/test/TinyTUI.TextChecks/Program.cs b/test/TinyTUI.TextChecks/Program.cs
new file mode 100644
index 0000000..125bda2
--- /dev/null
+++ b/test/TinyTUI.TextChecks/Program.cs
@@ -0,0 +1,69 @@
+using TinyTUI.Text;
+
+var measurer = new TerminalTextMeasurer();
+
+AssertEqual(5, measurer.GetWidth("\t\e[31m界\e[0m"), "tab and ansi width");
+AssertEqual(2, measurer.GetWidth("🇨"), "single regional indicator width");
+AssertEqual(2, measurer.GetWidth("🇨🇳"), "regional indicator pair width");
+AssertEqual(2, measurer.GetWidth("👨💻"), "zwj emoji width");
+AssertEqual(2, measurer.GetWidth("⚡️"), "variation selector emoji width");
+
+var strictTabSlice = measurer.Slice("out 192M\t.pi/skill-tests/results-ha", 0, 10, strict: true);
+AssertEqual("out 192M", strictTabSlice.Text, "strict tab slice text");
+AssertEqual(8, strictTabSlice.Width, "strict tab slice width");
+AssertEqual(measurer.GetWidth(strictTabSlice.Text), strictTabSlice.Width, "strict tab slice measured width");
+
+var afterTabSlice = measurer.Slice("out 192M\t.pi/skill-tests/results-ha", 13, 10, strict: true);
+AssertEqual("i/skill-te", afterTabSlice.Text, "slice after tab text");
+AssertEqual(measurer.GetWidth(afterTabSlice.Text), afterTabSlice.Width, "slice after tab measured width");
+
+var ansiText = $"\e[31m{"hello ".Repeat(100)}\e[0m";
+var truncated = measurer.Truncate(ansiText, 20, "…");
+AssertTrue(measurer.GetWidth(truncated) <= 20, "ansi truncate width");
+AssertTrue(truncated.Contains("\e[31m", StringComparison.Ordinal), "ansi truncate keeps style prefix");
+AssertTrue(truncated.EndsWith("\e[0m…\e[0m", StringComparison.Ordinal), "ansi truncate brackets ellipsis");
+
+var wideEllipsis = measurer.Truncate("abcdef", 2, "🙂");
+AssertEqual("\e[0m🙂\e[0m", wideEllipsis, "wide ellipsis clipping");
+AssertEqual(string.Empty, measurer.Truncate("abcdef", 1, "🙂"), "wide ellipsis too narrow");
+
+var plainWrapped = measurer.Wrap("hello world this is a test", 10);
+AssertTrue(plainWrapped.Count > 1, "plain wrap line count");
+AssertTrue(plainWrapped.All(line => measurer.GetWidth(line) <= 10), "plain wrap width");
+
+var redWrapped = measurer.Wrap($"\e[31mhello world this is red\e[0m", 10);
+AssertTrue(redWrapped.Skip(1).All(line => line.StartsWith("\e[31m", StringComparison.Ordinal)), "ansi wrap restores red");
+AssertTrue(redWrapped.Take(redWrapped.Count - 1).All(line => !line.EndsWith("\e[0m", StringComparison.Ordinal)), "ansi wrap avoids full reset before final");
+
+var underlinedUrl = measurer.Wrap($"read this thread \e[4mhttps://example.com/very/long/path/that/will/definitely/wrap\e[24m", 40);
+AssertEqual("read this thread", underlinedUrl[0], "underline wrap keeps prefix unstyled");
+AssertTrue(underlinedUrl[1].StartsWith("\e[4m", StringComparison.Ordinal), "underline wrap starts style on url line");
+AssertTrue(underlinedUrl.Take(underlinedUrl.Count - 1).Any(line => line.EndsWith("\e[24m", StringComparison.Ordinal)), "underline wrap closes line");
+
+var hyperlink = "\e]8;;https://example.com\a0123456789\e]8;;\a";
+var hyperlinkWrapped = measurer.Wrap(hyperlink, 6);
+AssertTrue(hyperlinkWrapped.Count > 1, "osc8 wrap line count");
+AssertTrue(hyperlinkWrapped.All(line => line.Contains("\e]8;;https://example.com\a", StringComparison.Ordinal)), "osc8 wrap reopens hyperlink with bel");
+AssertTrue(hyperlinkWrapped.Take(hyperlinkWrapped.Count - 1).All(line => line.EndsWith("\e]8;;\a", StringComparison.Ordinal)), "osc8 wrap closes hyperlink with bel");
+
+Console.WriteLine("TinyTUI text checks passed");
+
+static void AssertEqual(T expected, T actual, string name)
+{
+ if (!EqualityComparer.Default.Equals(expected, actual))
+ throw new InvalidOperationException($"{name}: expected '{expected}', actual '{actual}'");
+}
+
+static void AssertTrue(bool condition, string name)
+{
+ if (!condition)
+ throw new InvalidOperationException($"{name}: assertion failed");
+}
+
+internal static class StringExtensions
+{
+ ///
+ /// 重复字符串用于构造长文本测试输入
+ ///
+ public static string Repeat(this string value, int count) => string.Concat(Enumerable.Repeat(value, count));
+}
diff --git a/test/TinyTUI.TextChecks/TinyTUI.TextChecks.csproj b/test/TinyTUI.TextChecks/TinyTUI.TextChecks.csproj
new file mode 100644
index 0000000..12a074a
--- /dev/null
+++ b/test/TinyTUI.TextChecks/TinyTUI.TextChecks.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/ttui.slnx b/ttui.slnx
index 358266e..6184d0a 100644
--- a/ttui.slnx
+++ b/ttui.slnx
@@ -9,4 +9,5 @@
+