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:
@@ -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