feat: add overlay layout options

- add configurable overlay sizing and anchors

- support non-capturing overlays and focus handles

- demonstrate overlay options in Example
This commit is contained in:
chuan
2026-06-04 00:44:28 +08:00
Unverified
parent 2ab090d724
commit 8a71a27944
11 changed files with 450 additions and 31 deletions
+51 -3
View File
@@ -21,6 +21,7 @@ var statusText = new Text();
var loader = new Loader(textMeasurer) { Message = "运行中" };
using var loaderCancellation = new CancellationTokenSource();
var input = new TinyTUI.Components.Input(textMeasurer) { Prompt = "请输入: " };
IOverlayHandle? tipHandle = null;
input.OnChanged = value => UpdateStatus(statusText, value, textMeasurer, renderer);
input.OnSubmitted = value =>
@@ -29,15 +30,46 @@ input.OnSubmitted = value =>
{
case "/help":
AddEvent(eventsText, "Overlay: help opened");
runtime.ShowOverlay(new HelpOverlay(runtime, textMeasurer));
runtime.ShowOverlay(
new HelpOverlay(runtime, textMeasurer),
new OverlayOptions
{
Width = OverlaySize.Percent(45),
MinWidth = 42,
Anchor = OverlayAnchor.TopRight,
Margin = new OverlayMargin(1),
});
break;
case "/tip":
tipHandle?.Hide();
tipHandle = runtime.ShowOverlay(
new TipOverlay(textMeasurer),
OverlayOptions.NonCapturingTooltip());
AddEvent(eventsText, "Overlay: non-capturing tip opened");
break;
case "/select":
AddEvent(eventsText, "Overlay: select opened");
runtime.ShowOverlay(new SelectOverlay(runtime, message => AddEvent(eventsText, message), textMeasurer));
runtime.ShowOverlay(
new SelectOverlay(runtime, message => AddEvent(eventsText, message), textMeasurer),
new OverlayOptions
{
Width = 34,
Anchor = OverlayAnchor.LeftCenter,
Margin = new OverlayMargin(1),
MaxHeight = 9,
});
break;
case "/edit":
AddEvent(eventsText, "Overlay: editor opened");
runtime.ShowOverlay(new EditorOverlay(runtime, message => AddEvent(eventsText, message), textMeasurer));
runtime.ShowOverlay(
new EditorOverlay(runtime, message => AddEvent(eventsText, message), textMeasurer),
new OverlayOptions
{
Width = OverlaySize.Percent(70),
MinWidth = 52,
Anchor = OverlayAnchor.BottomCenter,
Margin = new OverlayMargin(1),
});
break;
case "":
AddEvent(eventsText, "Submitted empty input");
@@ -62,6 +94,7 @@ AddAll(
"操作说明\n" +
"- 输入文本后 Enter 提交\n" +
"- /help 打开帮助弹层\n" +
"- /tip 打开不抢焦点的右上角提示\n" +
"- /select 打开可交互选择列表\n" +
"- /edit 打开需要焦点的多行编辑器\n" +
"- Esc 退出"),
@@ -169,6 +202,21 @@ file sealed class HelpOverlay(ITuiRuntime runtime, ITextMeasurer textMeasurer) :
}
}
file sealed class TipOverlay(ITextMeasurer textMeasurer) : IComponent
{
private readonly Box _box = new(
new Text(
"这是 non-capturing overlay\n" +
"打开后输入框仍然接收键盘输入\n" +
"再次输入 /tip 会替换当前提示"),
textMeasurer)
{
Title = "Tip",
};
public IReadOnlyList<string> Render(int width) => _box.Render(width);
}
file sealed class SelectOverlay : IInputComponent
{
private readonly ITuiRuntime _runtime;
+20
View File
@@ -14,4 +14,24 @@ public interface IOverlayHandle
/// 临时切换 overlay 是否参与渲染和焦点处理
/// </summary>
void SetHidden(bool hidden);
/// <summary>
/// 获取 overlay 当前是否临时隐藏
/// </summary>
bool IsHidden { get; }
/// <summary>
/// 将焦点切换到当前 overlay 并提升到视觉最前
/// </summary>
void Focus();
/// <summary>
/// 释放当前 overlay 的焦点并回到合适的组件
/// </summary>
void Unfocus();
/// <summary>
/// 获取 overlay 当前是否拥有焦点
/// </summary>
bool IsFocused { get; }
}
+1 -1
View File
@@ -15,7 +15,7 @@ public interface IOverlayManager
/// <summary>
/// 显示 overlay 组件并返回控制句柄
/// </summary>
IOverlayHandle Show(IComponent component);
IOverlayHandle Show(IComponent component, OverlayOptions? options = null, OverlayHandleCallbacks? callbacks = null);
/// <summary>
/// 隐藏最上层 overlay
+17
View File
@@ -0,0 +1,17 @@
namespace TinyTUI.Overlay;
/// <summary>
/// 定义 overlay 相对终端窗口的默认停靠位置
/// </summary>
public enum OverlayAnchor
{
Center,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
TopCenter,
BottomCenter,
LeftCenter,
RightCenter,
}
@@ -0,0 +1,32 @@
namespace TinyTUI.Overlay;
/// <summary>
/// 连接 overlay 句柄操作和运行时焦点恢复逻辑
/// </summary>
public sealed class OverlayHandleCallbacks
{
/// <summary>
/// 在 overlay 永久移除前触发
/// </summary>
public Action? Removing { get; init; }
/// <summary>
/// 在 overlay 隐藏状态变化后触发
/// </summary>
public Action<bool>? HiddenChanged { get; init; }
/// <summary>
/// 请求将焦点切换到当前 overlay
/// </summary>
public Action? FocusRequested { get; init; }
/// <summary>
/// 请求释放当前 overlay 的焦点
/// </summary>
public Action? UnfocusRequested { get; init; }
/// <summary>
/// 获取当前 overlay 是否拥有焦点
/// </summary>
public Func<bool>? IsFocused { get; init; }
}
+145 -19
View File
@@ -22,20 +22,39 @@ public sealed class OverlayManager(ITextMeasurer? textMeasurer = null) : IOverla
/// <summary>
/// 获取最上层可见 overlay 组件
/// </summary>
public IComponent? TopVisibleComponent => _entries.LastOrDefault(static entry => !entry.Hidden)?.Component;
public IComponent? TopVisibleComponent => TopVisibleEntry?.Component;
/// <summary>
/// 获取最上层可接收焦点的 overlay 组件
/// </summary>
public IComponent? TopFocusableComponent => TopFocusableEntry?.Component;
private OverlayEntry? TopVisibleEntry => _entries
.Where(IsVisible)
.MaxBy(static entry => entry.FocusOrder);
private OverlayEntry? TopFocusableEntry => _entries
.Where(entry => IsVisible(entry) && !entry.Options.NonCapturing)
.MaxBy(static entry => entry.FocusOrder);
private long _focusOrderCounter;
/// <inheritdoc />
public IOverlayHandle Show(IComponent component)
public IOverlayHandle Show(IComponent component, OverlayOptions? options = null, OverlayHandleCallbacks? callbacks = null)
{
var entry = new OverlayEntry(component);
var entry = new OverlayEntry(component, options ?? new OverlayOptions())
{
Callbacks = callbacks ?? new OverlayHandleCallbacks(),
FocusOrder = ++_focusOrderCounter,
};
_entries.Add(entry);
return new OverlayHandle(entry, Remove, SetHidden);
return new OverlayHandle(entry, Remove, SetHidden, Focus);
}
/// <inheritdoc />
public void HideTop()
{
var top = _entries.LastOrDefault(static entry => !entry.Hidden);
var top = TopVisibleEntry;
if (top is not null) Remove(top);
}
@@ -44,7 +63,10 @@ public sealed class OverlayManager(ITextMeasurer? textMeasurer = null) : IOverla
/// </summary>
public IReadOnlyList<string> Compose(IReadOnlyList<string> baseLines, TerminalSize size)
{
var visibleEntries = _entries.Where(static entry => !entry.Hidden).ToArray();
foreach (var entry in _entries)
entry.LastSize = size;
var visibleEntries = _entries.Where(IsVisible).OrderBy(static entry => entry.FocusOrder).ToArray();
if (visibleEntries.Length == 0)
return baseLines;
@@ -53,28 +75,28 @@ public sealed class OverlayManager(ITextMeasurer? textMeasurer = null) : IOverla
result.Add(string.Empty);
foreach (var entry in visibleEntries)
ComposeOne(result, entry.Component, size);
ComposeOne(result, entry, size);
return result;
}
/// <summary>
/// 将单个 overlay 组件居中覆盖到目标行集合
/// 将单个 overlay 组件按布局配置覆盖到目标行集合
/// </summary>
private void ComposeOne(List<string> target, IComponent component, TerminalSize size)
private void ComposeOne(List<string> target, OverlayEntry entry, TerminalSize size)
{
// 当前基础版本固定居中显示 后续可以把宽度和位置抽成 OverlayOptions
var overlayWidth = Math.Clamp(Math.Min(60, size.Columns - 4), 1, Math.Max(1, size.Columns));
var overlayLines = component.Render(overlayWidth).Select(line => _textMeasurer.Truncate(line, overlayWidth)).ToArray();
var overlayHeight = overlayLines.Length;
var row = Math.Max(0, (size.Rows - overlayHeight) / 2);
var column = Math.Max(0, (size.Columns - overlayWidth) / 2);
var initialLayout = ResolveLayout(entry.Options, 0, size);
var overlayLines = entry.Component.Render(initialLayout.Width)
.Take(initialLayout.MaxHeight ?? int.MaxValue)
.Select(line => _textMeasurer.Truncate(line, initialLayout.Width))
.ToArray();
var layout = ResolveLayout(entry.Options, overlayLines.Length, size);
while (target.Count < row + overlayHeight)
while (target.Count < layout.Row + overlayLines.Length)
target.Add(string.Empty);
for (var index = 0; index < overlayLines.Length; index++)
target[row + index] = ComposeLine(target[row + index], overlayLines[index], column, overlayWidth, size.Columns);
target[layout.Row + index] = ComposeLine(target[layout.Row + index], overlayLines[index], layout.Column, layout.Width, size.Columns);
}
/// <summary>
@@ -98,8 +120,21 @@ public sealed class OverlayManager(ITextMeasurer? textMeasurer = null) : IOverla
private void SetHidden(OverlayEntry entry, bool hidden)
{
if (!_entries.Contains(entry)) return;
if (entry.Hidden == hidden) return;
entry.Hidden = hidden;
entry.Callbacks.HiddenChanged?.Invoke(hidden);
}
/// <summary>
/// 将 overlay 提升到视觉最前并通知 Runtime 切换焦点
/// </summary>
private void Focus(OverlayEntry entry)
{
if (!_entries.Contains(entry) || !IsVisible(entry)) return;
entry.FocusOrder = ++_focusOrderCounter;
entry.Callbacks.FocusRequested?.Invoke();
}
/// <summary>
@@ -109,23 +144,114 @@ public sealed class OverlayManager(ITextMeasurer? textMeasurer = null) : IOverla
{
if (!_entries.Remove(entry)) return;
entry.Callbacks.Removing?.Invoke();
Removed?.Invoke(this, entry.Component);
}
private sealed class OverlayEntry(IComponent component)
/// <summary>
/// 判断 overlay 是否应该参与当前帧渲染
/// </summary>
private static bool IsVisible(OverlayEntry entry)
=> !entry.Hidden && (entry.Options.Visible?.Invoke(entry.LastSize) ?? true);
/// <summary>
/// 计算 overlay 的最终宽度 位置和高度限制
/// </summary>
private static OverlayLayout ResolveLayout(OverlayOptions options, int overlayHeight, TerminalSize size)
{
var margin = NormalizeMargin(options.Margin);
var availableWidth = Math.Max(1, size.Columns - margin.Left - margin.Right);
var availableHeight = Math.Max(1, size.Rows - margin.Top - margin.Bottom);
var width = options.Width?.Resolve(size.Columns) ?? Math.Min(60, availableWidth);
if (options.MinWidth is { } minWidth)
width = Math.Max(width, minWidth);
width = Math.Clamp(width, 1, availableWidth);
var maxHeight = options.MaxHeight?.Resolve(size.Rows);
if (maxHeight is not null)
maxHeight = Math.Clamp(maxHeight.Value, 1, availableHeight);
var effectiveHeight = maxHeight is { } limit ? Math.Min(overlayHeight, limit) : overlayHeight;
var row = options.Row is { } rowOption
? ResolvePosition(rowOption, availableHeight, effectiveHeight, margin.Top)
: ResolveAnchorRow(options.Anchor, effectiveHeight, availableHeight, margin.Top);
var column = options.Column is { } columnOption
? ResolvePosition(columnOption, availableWidth, width, margin.Left)
: ResolveAnchorColumn(options.Anchor, width, availableWidth, margin.Left);
row += options.OffsetY;
column += options.OffsetX;
row = Math.Clamp(row, margin.Top, Math.Max(margin.Top, size.Rows - margin.Bottom - effectiveHeight));
column = Math.Clamp(column, margin.Left, Math.Max(margin.Left, size.Columns - margin.Right - width));
return new OverlayLayout(width, row, column, maxHeight);
}
private static OverlayMargin NormalizeMargin(OverlayMargin margin) => new(
Math.Max(0, margin.Top),
Math.Max(0, margin.Right),
Math.Max(0, margin.Bottom),
Math.Max(0, margin.Left));
private static int ResolvePosition(OverlaySize option, int available, int overlayLength, int marginStart)
{
if (!option.IsPercent)
return option.Value;
var maxStart = Math.Max(0, available - overlayLength);
return marginStart + maxStart * option.Value / 100;
}
private static int ResolveAnchorRow(OverlayAnchor anchor, int height, int availableHeight, int marginTop) => anchor switch
{
OverlayAnchor.TopLeft or OverlayAnchor.TopCenter or OverlayAnchor.TopRight => marginTop,
OverlayAnchor.BottomLeft or OverlayAnchor.BottomCenter or OverlayAnchor.BottomRight => marginTop + availableHeight - height,
_ => marginTop + Math.Max(0, availableHeight - height) / 2,
};
private static int ResolveAnchorColumn(OverlayAnchor anchor, int width, int availableWidth, int marginLeft) => anchor switch
{
OverlayAnchor.TopLeft or OverlayAnchor.LeftCenter or OverlayAnchor.BottomLeft => marginLeft,
OverlayAnchor.TopRight or OverlayAnchor.RightCenter or OverlayAnchor.BottomRight => marginLeft + availableWidth - width,
_ => marginLeft + Math.Max(0, availableWidth - width) / 2,
};
private sealed class OverlayEntry(IComponent component, OverlayOptions options)
{
public IComponent Component { get; } = component;
public OverlayOptions Options { get; } = options;
public OverlayHandleCallbacks Callbacks { get; set; } = new();
public TerminalSize LastSize { get; set; } = new(80, 24);
public bool Hidden { get; set; }
public long FocusOrder { get; set; }
}
private sealed class OverlayHandle(
OverlayEntry entry,
Action<OverlayEntry> hide,
Action<OverlayEntry, bool> setHidden) : IOverlayHandle
Action<OverlayEntry, bool> setHidden,
Action<OverlayEntry> focus) : IOverlayHandle
{
public bool IsHidden => entry.Hidden;
public bool IsFocused => entry.Callbacks.IsFocused?.Invoke() ?? false;
public void Hide() => hide(entry);
public void SetHidden(bool hidden) => setHidden(entry, hidden);
public void Focus() => focus(entry);
public void Unfocus() => entry.Callbacks.UnfocusRequested?.Invoke();
}
private readonly record struct OverlayLayout(int Width, int Row, int Column, int? MaxHeight);
}
+14
View File
@@ -0,0 +1,14 @@
namespace TinyTUI.Overlay;
/// <summary>
/// 定义 overlay 与终端边缘之间的安全距离
/// </summary>
public readonly record struct OverlayMargin(int Top, int Right, int Bottom, int Left)
{
/// <summary>
/// 创建四边相同的边距
/// </summary>
public OverlayMargin(int all) : this(all, all, all, all)
{
}
}
+74
View File
@@ -0,0 +1,74 @@
using TinyTUI.Components;
namespace TinyTUI.Overlay;
/// <summary>
/// 控制 overlay 的尺寸 位置 可见性和焦点策略
/// </summary>
public sealed class OverlayOptions
{
/// <summary>
/// 获取或设置 overlay 宽度
/// </summary>
public OverlaySize? Width { get; set; }
/// <summary>
/// 获取或设置 overlay 最小宽度
/// </summary>
public int? MinWidth { get; set; }
/// <summary>
/// 获取或设置 overlay 最大高度
/// </summary>
public OverlaySize? MaxHeight { get; set; }
/// <summary>
/// 获取或设置 overlay 停靠位置
/// </summary>
public OverlayAnchor Anchor { get; set; } = OverlayAnchor.Center;
/// <summary>
/// 获取或设置横向偏移
/// </summary>
public int OffsetX { get; set; }
/// <summary>
/// 获取或设置纵向偏移
/// </summary>
public int OffsetY { get; set; }
/// <summary>
/// 获取或设置绝对或百分比行位置
/// </summary>
public OverlaySize? Row { get; set; }
/// <summary>
/// 获取或设置绝对或百分比列位置
/// </summary>
public OverlaySize? Column { get; set; }
/// <summary>
/// 获取或设置终端边缘边距
/// </summary>
public OverlayMargin Margin { get; set; }
/// <summary>
/// 获取或设置当前 overlay 是否可见
/// </summary>
public Func<TerminalSize, bool>? Visible { get; set; }
/// <summary>
/// 获取或设置 overlay 是否不接管键盘焦点
/// </summary>
public bool NonCapturing { get; set; }
/// <summary>
/// 创建用于帮助信息等非交互提示的 overlay 选项
/// </summary>
public static OverlayOptions NonCapturingTooltip(OverlayAnchor anchor = OverlayAnchor.TopRight) => new()
{
Anchor = anchor,
Margin = new OverlayMargin(1),
NonCapturing = true,
};
}
+40
View File
@@ -0,0 +1,40 @@
namespace TinyTUI.Overlay;
/// <summary>
/// 表示 overlay 的绝对尺寸或百分比尺寸
/// </summary>
public readonly record struct OverlaySize
{
private OverlaySize(int value, bool isPercent)
{
Value = value;
IsPercent = isPercent;
}
/// <summary>
/// 获取尺寸值
/// </summary>
public int Value { get; }
/// <summary>
/// 获取当前值是否按百分比解释
/// </summary>
public bool IsPercent { get; }
/// <summary>
/// 创建绝对列数或行数
/// </summary>
public static OverlaySize Columns(int value) => new(value, false);
/// <summary>
/// 创建百分比尺寸
/// </summary>
public static OverlaySize Percent(int value) => new(Math.Clamp(value, 0, 100), true);
/// <summary>
/// 根据参考尺寸解析为实际列数或行数
/// </summary>
public int Resolve(int reference) => IsPercent ? reference * Value / 100 : Value;
public static implicit operator OverlaySize(int value) => Columns(value);
}
+1 -1
View File
@@ -31,7 +31,7 @@ public interface ITuiRuntime : IDisposable
/// <summary>
/// 显示 overlay 组件并切换焦点
/// </summary>
IOverlayHandle ShowOverlay(IComponent component);
IOverlayHandle ShowOverlay(IComponent component, OverlayOptions? options = null);
/// <summary>
/// 隐藏最上层 overlay
+55 -7
View File
@@ -69,12 +69,25 @@ public sealed class TuiRuntime : ITuiRuntime
}
/// <inheritdoc />
public IOverlayHandle ShowOverlay(IComponent component)
public IOverlayHandle ShowOverlay(IComponent component, OverlayOptions? options = null)
{
// 记录显示 overlay 前的焦点 关闭 overlay 时恢复用户原来的输入位置
_overlayFocusRestore[component] = _focusedComponent;
var handle = _overlayManager.Show(component);
SetFocus(component);
var overlayOptions = options ?? new OverlayOptions();
var handle = _overlayManager.Show(
component,
overlayOptions,
new OverlayHandleCallbacks
{
HiddenChanged = hidden => OnOverlayHiddenChanged(component, overlayOptions, hidden),
FocusRequested = () => SetOverlayFocus(component),
UnfocusRequested = () => RestoreOverlayFocus(component),
IsFocused = () => _focusedComponent == component,
});
if (!overlayOptions.NonCapturing)
SetOverlayFocus(component);
RequestRender();
return handle;
}
@@ -138,11 +151,46 @@ public sealed class TuiRuntime : ITuiRuntime
/// </summary>
private void OnOverlayRemoved(object? sender, IComponent component)
{
_overlayFocusRestore.Remove(component, out var restoreFocus);
if (_focusedComponent == component)
_focusedComponent = _overlayManager.TopVisibleComponent ?? restoreFocus;
RestoreOverlayFocus(component, removeRestoreEntry: true);
RequestRender();
}
/// <summary>
/// 在 overlay 临时隐藏或恢复显示时同步焦点
/// </summary>
private void OnOverlayHiddenChanged(IComponent component, OverlayOptions options, bool hidden)
{
if (hidden)
RestoreOverlayFocus(component);
else if (!options.NonCapturing)
SetOverlayFocus(component);
RequestRender();
}
/// <summary>
/// 将焦点切到 overlay 并保留进入 overlay 前的恢复目标
/// </summary>
private void SetOverlayFocus(IComponent component)
{
_overlayFocusRestore.TryAdd(component, _focusedComponent);
SetFocus(component);
}
/// <summary>
/// 根据 overlay 栈和进入前焦点恢复输入目标
/// </summary>
private void RestoreOverlayFocus(IComponent component, bool removeRestoreEntry = false)
{
IComponent? restoreFocus;
if (removeRestoreEntry)
_overlayFocusRestore.Remove(component, out restoreFocus);
else
_overlayFocusRestore.TryGetValue(component, out restoreFocus);
if (_focusedComponent == component)
_focusedComponent = _overlayManager.TopFocusableComponent ?? restoreFocus;
}
}