diff --git a/src/Example/Program.cs b/src/Example/Program.cs index 4bc4a8d..3644caf 100644 --- a/src/Example/Program.cs +++ b/src/Example/Program.cs @@ -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 Render(int width) => _box.Render(width); +} + file sealed class SelectOverlay : IInputComponent { private readonly ITuiRuntime _runtime; diff --git a/src/TinyTUI/Overlay/IOverlayHandle.cs b/src/TinyTUI/Overlay/IOverlayHandle.cs index 8d91c90..b85e93a 100644 --- a/src/TinyTUI/Overlay/IOverlayHandle.cs +++ b/src/TinyTUI/Overlay/IOverlayHandle.cs @@ -14,4 +14,24 @@ public interface IOverlayHandle /// 临时切换 overlay 是否参与渲染和焦点处理 /// void SetHidden(bool hidden); + + /// + /// 获取 overlay 当前是否临时隐藏 + /// + bool IsHidden { get; } + + /// + /// 将焦点切换到当前 overlay 并提升到视觉最前 + /// + void Focus(); + + /// + /// 释放当前 overlay 的焦点并回到合适的组件 + /// + void Unfocus(); + + /// + /// 获取 overlay 当前是否拥有焦点 + /// + bool IsFocused { get; } } diff --git a/src/TinyTUI/Overlay/IOverlayManager.cs b/src/TinyTUI/Overlay/IOverlayManager.cs index bd4ff22..5fc9866 100644 --- a/src/TinyTUI/Overlay/IOverlayManager.cs +++ b/src/TinyTUI/Overlay/IOverlayManager.cs @@ -15,7 +15,7 @@ public interface IOverlayManager /// /// 显示 overlay 组件并返回控制句柄 /// - IOverlayHandle Show(IComponent component); + IOverlayHandle Show(IComponent component, OverlayOptions? options = null, OverlayHandleCallbacks? callbacks = null); /// /// 隐藏最上层 overlay diff --git a/src/TinyTUI/Overlay/OverlayAnchor.cs b/src/TinyTUI/Overlay/OverlayAnchor.cs new file mode 100644 index 0000000..c08ae91 --- /dev/null +++ b/src/TinyTUI/Overlay/OverlayAnchor.cs @@ -0,0 +1,17 @@ +namespace TinyTUI.Overlay; + +/// +/// 定义 overlay 相对终端窗口的默认停靠位置 +/// +public enum OverlayAnchor +{ + Center, + TopLeft, + TopRight, + BottomLeft, + BottomRight, + TopCenter, + BottomCenter, + LeftCenter, + RightCenter, +} diff --git a/src/TinyTUI/Overlay/OverlayHandleCallbacks.cs b/src/TinyTUI/Overlay/OverlayHandleCallbacks.cs new file mode 100644 index 0000000..1620d91 --- /dev/null +++ b/src/TinyTUI/Overlay/OverlayHandleCallbacks.cs @@ -0,0 +1,32 @@ +namespace TinyTUI.Overlay; + +/// +/// 连接 overlay 句柄操作和运行时焦点恢复逻辑 +/// +public sealed class OverlayHandleCallbacks +{ + /// + /// 在 overlay 永久移除前触发 + /// + public Action? Removing { get; init; } + + /// + /// 在 overlay 隐藏状态变化后触发 + /// + public Action? HiddenChanged { get; init; } + + /// + /// 请求将焦点切换到当前 overlay + /// + public Action? FocusRequested { get; init; } + + /// + /// 请求释放当前 overlay 的焦点 + /// + public Action? UnfocusRequested { get; init; } + + /// + /// 获取当前 overlay 是否拥有焦点 + /// + public Func? IsFocused { get; init; } +} diff --git a/src/TinyTUI/Overlay/OverlayManager.cs b/src/TinyTUI/Overlay/OverlayManager.cs index 5a3a348..f83cd6d 100644 --- a/src/TinyTUI/Overlay/OverlayManager.cs +++ b/src/TinyTUI/Overlay/OverlayManager.cs @@ -22,20 +22,39 @@ public sealed class OverlayManager(ITextMeasurer? textMeasurer = null) : IOverla /// /// 获取最上层可见 overlay 组件 /// - public IComponent? TopVisibleComponent => _entries.LastOrDefault(static entry => !entry.Hidden)?.Component; + public IComponent? TopVisibleComponent => TopVisibleEntry?.Component; + + /// + /// 获取最上层可接收焦点的 overlay 组件 + /// + 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; /// - 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); } /// 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 /// public IReadOnlyList Compose(IReadOnlyList 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; } /// - /// 将单个 overlay 组件居中覆盖到目标行集合 + /// 将单个 overlay 组件按布局配置覆盖到目标行集合 /// - private void ComposeOne(List target, IComponent component, TerminalSize size) + private void ComposeOne(List 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); } /// @@ -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); + } + + /// + /// 将 overlay 提升到视觉最前并通知 Runtime 切换焦点 + /// + private void Focus(OverlayEntry entry) + { + if (!_entries.Contains(entry) || !IsVisible(entry)) return; + + entry.FocusOrder = ++_focusOrderCounter; + entry.Callbacks.FocusRequested?.Invoke(); } /// @@ -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) + /// + /// 判断 overlay 是否应该参与当前帧渲染 + /// + private static bool IsVisible(OverlayEntry entry) + => !entry.Hidden && (entry.Options.Visible?.Invoke(entry.LastSize) ?? true); + + /// + /// 计算 overlay 的最终宽度 位置和高度限制 + /// + 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 hide, - Action setHidden) : IOverlayHandle + Action setHidden, + Action 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); } diff --git a/src/TinyTUI/Overlay/OverlayMargin.cs b/src/TinyTUI/Overlay/OverlayMargin.cs new file mode 100644 index 0000000..c0c9f22 --- /dev/null +++ b/src/TinyTUI/Overlay/OverlayMargin.cs @@ -0,0 +1,14 @@ +namespace TinyTUI.Overlay; + +/// +/// 定义 overlay 与终端边缘之间的安全距离 +/// +public readonly record struct OverlayMargin(int Top, int Right, int Bottom, int Left) +{ + /// + /// 创建四边相同的边距 + /// + public OverlayMargin(int all) : this(all, all, all, all) + { + } +} diff --git a/src/TinyTUI/Overlay/OverlayOptions.cs b/src/TinyTUI/Overlay/OverlayOptions.cs new file mode 100644 index 0000000..79b3721 --- /dev/null +++ b/src/TinyTUI/Overlay/OverlayOptions.cs @@ -0,0 +1,74 @@ +using TinyTUI.Components; + +namespace TinyTUI.Overlay; + +/// +/// 控制 overlay 的尺寸 位置 可见性和焦点策略 +/// +public sealed class OverlayOptions +{ + /// + /// 获取或设置 overlay 宽度 + /// + public OverlaySize? Width { get; set; } + + /// + /// 获取或设置 overlay 最小宽度 + /// + public int? MinWidth { get; set; } + + /// + /// 获取或设置 overlay 最大高度 + /// + public OverlaySize? MaxHeight { get; set; } + + /// + /// 获取或设置 overlay 停靠位置 + /// + public OverlayAnchor Anchor { get; set; } = OverlayAnchor.Center; + + /// + /// 获取或设置横向偏移 + /// + public int OffsetX { get; set; } + + /// + /// 获取或设置纵向偏移 + /// + public int OffsetY { get; set; } + + /// + /// 获取或设置绝对或百分比行位置 + /// + public OverlaySize? Row { get; set; } + + /// + /// 获取或设置绝对或百分比列位置 + /// + public OverlaySize? Column { get; set; } + + /// + /// 获取或设置终端边缘边距 + /// + public OverlayMargin Margin { get; set; } + + /// + /// 获取或设置当前 overlay 是否可见 + /// + public Func? Visible { get; set; } + + /// + /// 获取或设置 overlay 是否不接管键盘焦点 + /// + public bool NonCapturing { get; set; } + + /// + /// 创建用于帮助信息等非交互提示的 overlay 选项 + /// + public static OverlayOptions NonCapturingTooltip(OverlayAnchor anchor = OverlayAnchor.TopRight) => new() + { + Anchor = anchor, + Margin = new OverlayMargin(1), + NonCapturing = true, + }; +} diff --git a/src/TinyTUI/Overlay/OverlaySize.cs b/src/TinyTUI/Overlay/OverlaySize.cs new file mode 100644 index 0000000..54bde2c --- /dev/null +++ b/src/TinyTUI/Overlay/OverlaySize.cs @@ -0,0 +1,40 @@ +namespace TinyTUI.Overlay; + +/// +/// 表示 overlay 的绝对尺寸或百分比尺寸 +/// +public readonly record struct OverlaySize +{ + private OverlaySize(int value, bool isPercent) + { + Value = value; + IsPercent = isPercent; + } + + /// + /// 获取尺寸值 + /// + public int Value { get; } + + /// + /// 获取当前值是否按百分比解释 + /// + public bool IsPercent { get; } + + /// + /// 创建绝对列数或行数 + /// + public static OverlaySize Columns(int value) => new(value, false); + + /// + /// 创建百分比尺寸 + /// + public static OverlaySize Percent(int value) => new(Math.Clamp(value, 0, 100), true); + + /// + /// 根据参考尺寸解析为实际列数或行数 + /// + public int Resolve(int reference) => IsPercent ? reference * Value / 100 : Value; + + public static implicit operator OverlaySize(int value) => Columns(value); +} diff --git a/src/TinyTUI/Runtime/ITuiRuntime.cs b/src/TinyTUI/Runtime/ITuiRuntime.cs index 9317fd7..9cc231b 100644 --- a/src/TinyTUI/Runtime/ITuiRuntime.cs +++ b/src/TinyTUI/Runtime/ITuiRuntime.cs @@ -31,7 +31,7 @@ public interface ITuiRuntime : IDisposable /// /// 显示 overlay 组件并切换焦点 /// - IOverlayHandle ShowOverlay(IComponent component); + IOverlayHandle ShowOverlay(IComponent component, OverlayOptions? options = null); /// /// 隐藏最上层 overlay diff --git a/src/TinyTUI/Runtime/TuiRuntime.cs b/src/TinyTUI/Runtime/TuiRuntime.cs index 778f152..38ab493 100644 --- a/src/TinyTUI/Runtime/TuiRuntime.cs +++ b/src/TinyTUI/Runtime/TuiRuntime.cs @@ -69,12 +69,25 @@ public sealed class TuiRuntime : ITuiRuntime } /// - 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 /// 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(); } + + /// + /// 在 overlay 临时隐藏或恢复显示时同步焦点 + /// + private void OnOverlayHiddenChanged(IComponent component, OverlayOptions options, bool hidden) + { + if (hidden) + RestoreOverlayFocus(component); + else if (!options.NonCapturing) + SetOverlayFocus(component); + + RequestRender(); + } + + /// + /// 将焦点切到 overlay 并保留进入 overlay 前的恢复目标 + /// + private void SetOverlayFocus(IComponent component) + { + _overlayFocusRestore.TryAdd(component, _focusedComponent); + SetFocus(component); + } + + /// + /// 根据 overlay 栈和进入前焦点恢复输入目标 + /// + 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; + } }