From 0be34a9c1624a80fa68dc1753a2b20eac7f740a2 Mon Sep 17 00:00:00 2001 From: chuan Date: Thu, 4 Jun 2026 02:10:46 +0800 Subject: [PATCH] feat: improve ansi text utilities - measure text with grapheme-aware terminal widths - add ansi-aware truncate slice and wrap checks --- TODO.md | 30 + src/TinyTUI/Text/ITextMeasurer.cs | 55 ++ src/TinyTUI/Text/TerminalTextMeasurer.cs | 741 ++++++++++++++++-- test/TinyTUI.TextChecks/Program.cs | 69 ++ .../TinyTUI.TextChecks.csproj | 14 + ttui.slnx | 1 + 6 files changed, 843 insertions(+), 67 deletions(-) create mode 100644 test/TinyTUI.TextChecks/Program.cs create mode 100644 test/TinyTUI.TextChecks/TinyTUI.TextChecks.csproj 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 @@ +