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
parent b22030c76d
commit 48fa673daf
5 changed files with 149 additions and 12 deletions
+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);