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:
@@ -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 感知工具
|
||||
|
||||
目标:把文本宽度、截断、切片、换行作为框架底座,而不是散落在组件里的局部逻辑
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace TinyTUI.Text;
|
||||
|
||||
/// <summary>
|
||||
/// 表示按终端列切片后的文本和实际可见宽度
|
||||
/// </summary>
|
||||
public readonly record struct TextSlice(string Text, int Width);
|
||||
Reference in New Issue
Block a user