diff --git a/TODO.md b/TODO.md index fe96420..8532664 100644 --- a/TODO.md +++ b/TODO.md @@ -141,6 +141,34 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入 参考:`tmp/tui/src/tui.ts`、`tmp/tui/test/tui-overlay-style-leak.test.ts`、`tmp/tui/test/overlay-short-content.test.ts` +本次推进: + +- 新增 `TextSlice` 和 `ITextMeasurer.Slice`,让文本工具可以按终端可见列切出片段并返回实际宽度 +- `TerminalTextMeasurer.Slice` 使用单次扫描处理 ANSI、OSC、APC、DCS 等不可见序列,并在 strict 模式下跳过压到边界的双宽字符 +- `OverlayManager.ComposeLine` 改为拼接底层左侧、overlay 区域和底层右侧,不再因为 overlay 覆盖一段内容就丢弃整行右侧内容 +- overlay 段前后插入 SGR reset 和 OSC 8 reset,降低底层样式污染 overlay、overlay 样式污染右侧底层内容的风险 +- overlay 行坐标改为映射到底部 viewport 的逻辑行,内容超过终端高度时 overlay 仍按当前屏幕可见区域定位 + +为什么先做: + +- `tmp/tui/src/tui.ts` 的 `compositeLineAt` 首先解决的是 overlay 短内容覆盖长底层行时的残留和截断问题;C# 侧原实现明确丢弃了右侧底层内容,这是菜单、补全列表和浮层提示最容易出现的视觉回归 +- 按列切片必须下沉到文本工具,否则 overlay、渲染管线和后续组件会继续各自用字符串长度或局部截断逻辑处理 ANSI 内容 +- 本次只引入轻量 `TextSlice`,不直接引入完整 grapheme / style tracker,避免把 TODO 第 5 项文本模型重构提前扩大 + +当前更好的点: + +- C# 侧把按列切片挂到 `ITextMeasurer`,后续组件可以先复用同一入口,不需要等完整文本模型落地 +- overlay 合成现在和第 3 项的 bottom viewport 模型对齐,短内容和超高内容都使用同一套屏幕坐标逻辑 +- reset 隔离在 overlay 合成层执行,即使 overlay 组件输出缺少结尾 reset,也能降低样式泄漏到右侧底层内容和后续行的概率 + +后续仍需补齐: + +- `TerminalTextMeasurer.Slice` 仍按 Rune 处理,不是 `tmp/tui/src/utils.ts` 那样的 grapheme cluster 切片;ZWJ emoji、肤色、regional indicator 等复杂组合还可能被切开 +- overlay 右侧底层内容现在会被 reset 隔离,但还没有像参考实现的 `extractSegments` 那样追踪并恢复切片前的活动 SGR 样式,因此右侧内容的继承样式可能丢失 +- 当前没有识别 Kitty image line,overlay 合成还不会像参考实现一样跳过图片行或管理已显示图片 id +- `ITextMeasurer` 的默认 `Slice` 是兼容 fallback,遇到带 ANSI 的非 `TerminalTextMeasurer` 实现时不如专用实现可靠,后续文本模型稳定后应收敛为显式能力 +- 还缺 C# 虚拟终端测试,无法自动覆盖 overlay 短内容保留右侧、样式泄漏、宽字符边界和底部 viewport 对齐这些回归 + ### 5. 文本模型和 ANSI 感知工具 目标:把文本宽度、截断、切片、换行作为框架底座,而不是散落在组件里的局部逻辑 diff --git a/src/TinyTUI/Overlay/OverlayManager.cs b/src/TinyTUI/Overlay/OverlayManager.cs index 0114d1a..0316c7c 100644 --- a/src/TinyTUI/Overlay/OverlayManager.cs +++ b/src/TinyTUI/Overlay/OverlayManager.cs @@ -9,6 +9,8 @@ namespace TinyTUI.Overlay; /// public sealed class OverlayManager(ITextMeasurer? textMeasurer = null) : IOverlayManager { + private const string SegmentReset = "\e[0m\e]8;;\a"; + private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer(); private readonly List _entries = []; @@ -75,11 +77,13 @@ public sealed class OverlayManager(ITextMeasurer? textMeasurer = null) : IOverla while (result.Count < size.Rows) result.Add(string.Empty); + var viewportStart = Math.Max(0, result.Count - size.Rows); + if (visibleEntries.Any(static entry => !entry.Options.NonCapturing)) RemoveBaseCursorMarkers(result); foreach (var entry in visibleEntries) - ComposeOne(result, entry, size); + ComposeOne(result, entry, size, viewportStart); return result; } @@ -87,7 +91,7 @@ public sealed class OverlayManager(ITextMeasurer? textMeasurer = null) : IOverla /// /// 将单个 overlay 组件按布局配置覆盖到目标行集合 /// - private void ComposeOne(List target, OverlayEntry entry, TerminalSize size) + private void ComposeOne(List target, OverlayEntry entry, TerminalSize size, int viewportStart) { var initialLayout = ResolveLayout(entry.Options, 0, size); var overlayLines = entry.Component.Render(initialLayout.Width) @@ -96,11 +100,15 @@ public sealed class OverlayManager(ITextMeasurer? textMeasurer = null) : IOverla .ToArray(); var layout = ResolveLayout(entry.Options, overlayLines.Length, size); - while (target.Count < layout.Row + overlayLines.Length) - target.Add(string.Empty); - for (var index = 0; index < overlayLines.Length; index++) - target[layout.Row + index] = ComposeLine(target[layout.Row + index], overlayLines[index], layout.Column, layout.Width, size.Columns); + { + // overlay 的 row 是终端视口内坐标 需要映射到底部视口对应的逻辑行 + var targetRow = viewportStart + layout.Row + index; + while (target.Count <= targetRow) + target.Add(string.Empty); + + target[targetRow] = ComposeLine(target[targetRow], overlayLines[index], layout.Column, layout.Width, size.Columns); + } } /// @@ -108,13 +116,29 @@ public sealed class OverlayManager(ITextMeasurer? textMeasurer = null) : IOverla /// private string ComposeLine(string baseLine, string overlayLine, int column, int overlayWidth, int totalWidth) { - var before = _textMeasurer.Truncate(baseLine, column); - var beforePadding = Math.Max(0, column - _textMeasurer.GetWidth(before)); - var overlayPadding = Math.Max(0, overlayWidth - _textMeasurer.GetWidth(overlayLine)); + if (totalWidth <= 0) + return string.Empty; + + var startColumn = Math.Clamp(column, 0, totalWidth); + var visibleOverlayWidth = Math.Clamp(overlayWidth, 0, totalWidth - startColumn); + var afterStart = startColumn + visibleOverlayWidth; + var before = _textMeasurer.Slice(baseLine, 0, startColumn, strict: true); + var overlay = _textMeasurer.Slice(overlayLine, 0, visibleOverlayWidth, strict: true); + var after = _textMeasurer.Slice(baseLine, afterStart, Math.Max(0, totalWidth - afterStart), strict: true); + var beforePadding = Math.Max(0, startColumn - before.Width); + var overlayPadding = Math.Max(0, visibleOverlayWidth - overlay.Width); + var afterPadding = Math.Max(0, totalWidth - startColumn - visibleOverlayWidth - after.Width); + + // reset 包住 overlay 段 避免底层样式污染弹层或弹层样式继续影响右侧底层内容 + var merged = before.Text + + new string(' ', beforePadding) + + SegmentReset + + overlay.Text + + new string(' ', overlayPadding) + + SegmentReset + + after.Text + + new string(' ', afterPadding); - // 先不保留 overlay 右侧原内容 这样弹层区域是稳定的实心覆盖 不会混入底层残留 - var after = string.Empty; - var merged = before + new string(' ', beforePadding) + overlayLine + new string(' ', overlayPadding) + after; return _textMeasurer.Truncate(merged, totalWidth); } diff --git a/src/TinyTUI/Text/ITextMeasurer.cs b/src/TinyTUI/Text/ITextMeasurer.cs index bc1a11d..18ef310 100644 --- a/src/TinyTUI/Text/ITextMeasurer.cs +++ b/src/TinyTUI/Text/ITextMeasurer.cs @@ -14,4 +14,21 @@ public interface ITextMeasurer /// 裁剪字符串使其渲染宽度不超过给定字符单元宽度 /// string Truncate(string value, int maxWidth); + + /// + /// 按可见列切出字符串片段并返回片段实际宽度 + /// + TextSlice Slice(string value, int startColumn, int maxWidth, bool strict = true) + { + if (startColumn <= 0) + { + var text = Truncate(value, maxWidth); + return new TextSlice(text, GetWidth(text)); + } + + var prefix = Truncate(value, startColumn); + var remainder = value[prefix.Length..]; + var sliced = Truncate(remainder, maxWidth); + return new TextSlice(sliced, GetWidth(sliced)); + } } diff --git a/src/TinyTUI/Text/TerminalTextMeasurer.cs b/src/TinyTUI/Text/TerminalTextMeasurer.cs index ca0c1d3..049c37c 100644 --- a/src/TinyTUI/Text/TerminalTextMeasurer.cs +++ b/src/TinyTUI/Text/TerminalTextMeasurer.cs @@ -78,6 +78,68 @@ public sealed class TerminalTextMeasurer : ITextMeasurer return builder.ToString(); } + /// + public TextSlice Slice(string value, int startColumn, int maxWidth, bool strict = true) + { + if (maxWidth <= 0) + return new TextSlice(string.Empty, 0); + + var builder = new StringBuilder(); + var currentColumn = 0; + var sliceWidth = 0; + var endColumn = startColumn + maxWidth; + + for (var index = 0; index < value.Length;) + { + if (TryReadEscape(value, index, out var escapeLength)) + { + // 只有进入切片后才保留控制序列 避免把切片前的样式状态带入 overlay 边界 + if (currentColumn >= startColumn && currentColumn < endColumn) + 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); + var runeStart = currentColumn; + var runeEnd = currentColumn + runeWidth; + currentColumn = runeEnd; + + if (runeEnd <= startColumn) + { + index += charsConsumed; + continue; + } + + if (runeStart >= endColumn) + break; + + if (strict && (runeStart < startColumn || runeEnd > endColumn)) + { + // 双宽字符压到边界时直接跳过 避免终端用占位符或半个 emoji 污染相邻区域 + index += charsConsumed; + continue; + } + + if (!strict && runeEnd > endColumn) + break; + + builder.Append(value.AsSpan(index, charsConsumed)); + sliceWidth += runeWidth; + index += charsConsumed; + } + + return new TextSlice(builder.ToString(), sliceWidth); + } + /// /// 计算单个 Unicode Rune 的终端宽度 /// diff --git a/src/TinyTUI/Text/TextSlice.cs b/src/TinyTUI/Text/TextSlice.cs new file mode 100644 index 0000000..8214016 --- /dev/null +++ b/src/TinyTUI/Text/TextSlice.cs @@ -0,0 +1,6 @@ +namespace TinyTUI.Text; + +/// +/// 表示按终端列切片后的文本和实际可见宽度 +/// +public readonly record struct TextSlice(string Text, int Width);