feat: improve overlay composition

- preserve base content around overlay regions

- add ANSI-aware column slicing for overlay segments

- align overlay rows with bottom viewport
This commit is contained in:
chuan
2026-06-04 01:55:13 +08:00
Unverified
parent b22030c76d
commit 48fa673daf
5 changed files with 149 additions and 12 deletions
+28
View File
@@ -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 lineoverlay 合成还不会像参考实现一样跳过图片行或管理已显示图片 id
- `ITextMeasurer` 的默认 `Slice` 是兼容 fallback,遇到带 ANSI 的非 `TerminalTextMeasurer` 实现时不如专用实现可靠,后续文本模型稳定后应收敛为显式能力
- 还缺 C# 虚拟终端测试,无法自动覆盖 overlay 短内容保留右侧、样式泄漏、宽字符边界和底部 viewport 对齐这些回归
### 5. 文本模型和 ANSI 感知工具
目标:把文本宽度、截断、切片、换行作为框架底座,而不是散落在组件里的局部逻辑
+36 -12
View File
@@ -9,6 +9,8 @@ namespace TinyTUI.Overlay;
/// </summary>
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<OverlayEntry> _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
/// <summary>
/// 将单个 overlay 组件按布局配置覆盖到目标行集合
/// </summary>
private void ComposeOne(List<string> target, OverlayEntry entry, TerminalSize size)
private void ComposeOne(List<string> 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);
}
}
/// <summary>
@@ -108,13 +116,29 @@ public sealed class OverlayManager(ITextMeasurer? textMeasurer = null) : IOverla
/// </summary>
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);
}
+17
View File
@@ -14,4 +14,21 @@ public interface ITextMeasurer
/// 裁剪字符串使其渲染宽度不超过给定字符单元宽度
/// </summary>
string Truncate(string value, int maxWidth);
/// <summary>
/// 按可见列切出字符串片段并返回片段实际宽度
/// </summary>
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));
}
}
+62
View File
@@ -78,6 +78,68 @@ public sealed class TerminalTextMeasurer : ITextMeasurer
return builder.ToString();
}
/// <inheritdoc />
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);
}
/// <summary>
/// 计算单个 Unicode Rune 的终端宽度
/// </summary>
+6
View File
@@ -0,0 +1,6 @@
namespace TinyTUI.Text;
/// <summary>
/// 表示按终端列切片后的文本和实际可见宽度
/// </summary>
public readonly record struct TextSlice(string Text, int Width);