diff --git a/src/Example/Program.cs b/src/Example/Program.cs index ff453df..cb166b3 100644 --- a/src/Example/Program.cs +++ b/src/Example/Program.cs @@ -1,6 +1,7 @@ using TinyTUI; using TinyTUI.Components; using TinyTUI.Input; +using TinyTUI.Overlay; using TinyTUI.Rendering; using TinyTUI.Runtime; using TinyTUI.Stdio; @@ -22,7 +23,16 @@ var input = new TinyTUI.Components.Input(textMeasurer) { Prompt = "请输入: " input.OnChanged = value => UpdateStatus(statusText, value, textMeasurer, renderer); input.OnSubmitted = value => { - AddEvent(eventsText, $"Submitted: {value}"); + if (value.Trim() == "/help") + { + AddEvent(eventsText, "Overlay: help opened"); + runtime.ShowOverlay(new HelpOverlay(runtime, textMeasurer)); + } + else + { + AddEvent(eventsText, $"Submitted: {value}"); + } + input.Clear(); }; input.OnCanceled = () => done.Set(); @@ -32,7 +42,7 @@ UpdateStatus(statusText, input.Value, textMeasurer, renderer); var page = new Container(); page.Add(new Text("TinyTUI 基础组件示例")); page.Add(new Text("======================")); -page.Add(new Text("测试方式: 输入文本 Backspace 删除 Enter 提交 Esc 退出")); +page.Add(new Text("测试方式: 输入文本 Backspace 删除 Enter 提交 /help 打开弹层 Esc 退出")); page.Add(new Text(string.Empty)); page.Add(new Box(statusText, textMeasurer) { Title = "状态" }); page.Add(new Text(string.Empty)); @@ -78,3 +88,39 @@ static void AddEvent(Text eventsText, string message) var lines = eventsText.Value.Split('\n').Skip(1).Append($"- {message}").TakeLast(8); eventsText.Value = "最近事件:\n" + string.Join('\n', lines); } + +/// +/// 用于手动验证 overlay 的帮助弹层 +/// +file sealed class HelpOverlay(ITuiRuntime runtime, ITextMeasurer textMeasurer) : IInputComponent +{ + private readonly Box _box = new( + new Text( + "Overlay 示例\n" + + "\n" + + "这个弹层由 Runtime 显示\n" + + "OverlayManager 会把它合成到基础页面上\n" + + "显示时焦点会切到弹层\n" + + "关闭后焦点会恢复到输入框\n" + + "\n" + + "按 Enter 或 Esc 关闭"), + textMeasurer) + { + Title = "Help", + }; + + /// + public IReadOnlyList Render(int width) + { + return _box.Render(width); + } + + /// + public void HandleInput(TuiInputEvent input) + { + if (input is { Kind: TuiInputEventKind.Key, Value: KeyNames.Enter or KeyNames.Escape }) + { + runtime.HideOverlay(); + } + } +} diff --git a/src/TinyTUI/Components/Container.cs b/src/TinyTUI/Components/Container.cs index 379737c..399c2ae 100644 --- a/src/TinyTUI/Components/Container.cs +++ b/src/TinyTUI/Components/Container.cs @@ -40,9 +40,7 @@ public class Container : IComponent var lines = new List(); foreach (var child in Children) - { lines.AddRange(child.Render(width)); - } return lines; } diff --git a/src/TinyTUI/Input/DefaultInputParser.cs b/src/TinyTUI/Input/DefaultInputParser.cs index 207fb13..385b790 100644 --- a/src/TinyTUI/Input/DefaultInputParser.cs +++ b/src/TinyTUI/Input/DefaultInputParser.cs @@ -93,9 +93,7 @@ public sealed class DefaultInputParser : IInputParser private void ParseRegular(string data, List events) { if (data.Length == 0) - { return; - } if (_keyMap.TryGetValue(data, out var keyName)) { diff --git a/src/TinyTUI/Overlay/OverlayManager.cs b/src/TinyTUI/Overlay/OverlayManager.cs new file mode 100644 index 0000000..7f554de --- /dev/null +++ b/src/TinyTUI/Overlay/OverlayManager.cs @@ -0,0 +1,117 @@ +using TinyTUI.Components; +using TinyTUI.Text; + +namespace TinyTUI.Overlay; + +/// +/// 默认 overlay 管理器 +/// +public sealed class OverlayManager(ITextMeasurer? textMeasurer = null) : IOverlayManager +{ + private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer(); + private readonly List _entries = []; + + /// + /// 在 overlay 被移除时触发 + /// + public event EventHandler? Removed; + + /// + public bool HasOverlay => _entries.Any(static entry => !entry.Hidden); + + /// + /// 获取最上层可见 overlay 组件 + /// + public IComponent? TopVisibleComponent => _entries.LastOrDefault(static entry => !entry.Hidden)?.Component; + + /// + public IOverlayHandle Show(IComponent component) + { + var entry = new OverlayEntry(component); + _entries.Add(entry); + return new OverlayHandle(entry, Remove, SetHidden); + } + + /// + public void HideTop() + { + var top = _entries.LastOrDefault(static entry => !entry.Hidden); + if (top is not null) Remove(top); + } + + /// + /// 将 overlay 合成到基础渲染行 + /// + public IReadOnlyList Compose(IReadOnlyList baseLines, TerminalSize size) + { + var visibleEntries = _entries.Where(static entry => !entry.Hidden).ToArray(); + if (visibleEntries.Length == 0) + return baseLines; + + var result = baseLines.ToList(); + while (result.Count < size.Rows) + result.Add(string.Empty); + + foreach (var entry in visibleEntries) + ComposeOne(result, entry.Component, size); + + return result; + } + + private void ComposeOne(List target, IComponent component, TerminalSize size) + { + 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); + + while (target.Count < row + overlayHeight) + 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); + } + + 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)); + var afterStartWidth = column + overlayWidth; + var after = _textMeasurer.GetWidth(baseLine) > afterStartWidth ? string.Empty : string.Empty; + var merged = before + new string(' ', beforePadding) + overlayLine + new string(' ', overlayPadding) + after; + return _textMeasurer.Truncate(merged, totalWidth); + } + + private void SetHidden(OverlayEntry entry, bool hidden) + { + if (!_entries.Contains(entry)) return; + + entry.Hidden = hidden; + } + + private void Remove(OverlayEntry entry) + { + if (!_entries.Remove(entry)) return; + + Removed?.Invoke(this, entry.Component); + } + + private sealed class OverlayEntry(IComponent component) + { + public IComponent Component { get; } = component; + + public bool Hidden { get; set; } + } + + private sealed class OverlayHandle( + OverlayEntry entry, + Action hide, + Action setHidden) : IOverlayHandle + { + public void Hide() => hide(entry); + + public void SetHidden(bool hidden) => setHidden(entry, hidden); + } +} diff --git a/src/TinyTUI/Rendering/DifferentialRenderer.cs b/src/TinyTUI/Rendering/DifferentialRenderer.cs index 296dde1..03259a7 100644 --- a/src/TinyTUI/Rendering/DifferentialRenderer.cs +++ b/src/TinyTUI/Rendering/DifferentialRenderer.cs @@ -73,32 +73,20 @@ public sealed class DifferentialRenderer(ITerminalOutput output, ITextMeasurer? var previous = index < _previousLines.Count ? _previousLines[index] : string.Empty; var next = index < lines.Count ? lines[index] : string.Empty; - if (previous == next) - { - continue; - } + if (previous == next) continue; changed = true; output.Write($"\e[{index + 1};1H"); output.Write("\e[2K"); - if (next.Length > 0) - { - output.Write(next); - } + if (next.Length > 0) output.Write(next); } - if (cursor is not null) - { - MoveCursor(cursor); - } + if (cursor is not null) MoveCursor(cursor); if (changed || cursor is not null) { - if (changed) - { - DifferentialRedrawCount++; - } + if (changed) DifferentialRedrawCount++; output.Flush(); } @@ -108,15 +96,10 @@ public sealed class DifferentialRenderer(ITerminalOutput output, ITextMeasurer? } private List NormalizeLines(IReadOnlyList lines, int width) - { - return [.. lines.Select(line => _textMeasurer.Truncate(line, width))]; - } + => [.. lines.Select(line => _textMeasurer.Truncate(line, width))]; private void MoveCursor(CursorPosition? cursor) { - if (cursor is { } position) - { - output.MoveCursorTo(position.Row, position.Column); - } + if (cursor is { } position) output.MoveCursorTo(position.Row, position.Column); } } diff --git a/src/TinyTUI/Rendering/FullScreenRenderer.cs b/src/TinyTUI/Rendering/FullScreenRenderer.cs index 2431a62..0d6dac1 100644 --- a/src/TinyTUI/Rendering/FullScreenRenderer.cs +++ b/src/TinyTUI/Rendering/FullScreenRenderer.cs @@ -36,9 +36,7 @@ public sealed class FullScreenRenderer(ITerminalOutput output, ITextMeasurer? te } private List NormalizeLines(IReadOnlyList lines, int width) - { - return [.. lines.Select(line => _textMeasurer.Truncate(line, width))]; - } + => [.. lines.Select(line => _textMeasurer.Truncate(line, width))]; private void MoveCursor(CursorPosition? cursor) { diff --git a/src/TinyTUI/Runtime/ITuiRuntime.cs b/src/TinyTUI/Runtime/ITuiRuntime.cs index 1f6fa3c..9317fd7 100644 --- a/src/TinyTUI/Runtime/ITuiRuntime.cs +++ b/src/TinyTUI/Runtime/ITuiRuntime.cs @@ -1,4 +1,5 @@ using TinyTUI.Components; +using TinyTUI.Overlay; namespace TinyTUI.Runtime; @@ -27,6 +28,16 @@ public interface ITuiRuntime : IDisposable /// void RequestRender(); + /// + /// 显示 overlay 组件并切换焦点 + /// + IOverlayHandle ShowOverlay(IComponent component); + + /// + /// 隐藏最上层 overlay + /// + void HideOverlay(); + /// /// 启动 TUI 运行时 /// diff --git a/src/TinyTUI/Runtime/TuiRuntime.cs b/src/TinyTUI/Runtime/TuiRuntime.cs index 3dc8261..7e6529a 100644 --- a/src/TinyTUI/Runtime/TuiRuntime.cs +++ b/src/TinyTUI/Runtime/TuiRuntime.cs @@ -1,5 +1,6 @@ using TinyTUI.Components; using TinyTUI.Input; +using TinyTUI.Overlay; using TinyTUI.Rendering; using TinyTUI.Stdio; @@ -13,8 +14,10 @@ public sealed class TuiRuntime : ITuiRuntime private readonly ITerminalInput _terminalInput; private readonly IInputParser _inputParser; private readonly IRenderer _renderer; + private readonly OverlayManager _overlayManager = new(); private readonly Container _root = new(); private readonly Lock _renderLock = new(); + private readonly Dictionary _overlayFocusRestore = []; private IComponent? _focusedComponent; private bool _started; @@ -27,6 +30,7 @@ public sealed class TuiRuntime : ITuiRuntime _terminalInput = terminalInput; _inputParser = inputParser; _renderer = renderer; + _overlayManager.Removed += OnOverlayRemoved; } /// @@ -41,43 +45,45 @@ public sealed class TuiRuntime : ITuiRuntime { _root.Remove(component); - if (_focusedComponent == component) - { - _focusedComponent = null; - } + if (_focusedComponent == component) _focusedComponent = null; RequestRender(); } /// - public void SetFocus(IComponent? component) - { - _focusedComponent = component; - } + public void SetFocus(IComponent? component) => _focusedComponent = component; /// public void RequestRender() { - if (!_started) - { - return; - } + if (!_started) return; lock (_renderLock) { var size = _terminalInput.CurrentSize; var lines = _root.Render(size.Columns); + lines = [.. _overlayManager.Compose(lines, size)]; _renderer.Render(lines, size); } } + /// + public IOverlayHandle ShowOverlay(IComponent component) + { + _overlayFocusRestore[component] = _focusedComponent; + var handle = _overlayManager.Show(component); + SetFocus(component); + RequestRender(); + return handle; + } + + /// + public void HideOverlay() => _overlayManager.HideTop(); + /// public void Start() { - if (_started) - { - return; - } + if (_started) return; _started = true; _terminalInput.DataReceived += OnDataReceived; @@ -90,10 +96,7 @@ public sealed class TuiRuntime : ITuiRuntime /// public void Stop() { - if (!_started) - { - return; - } + if (!_started) return; _started = false; _terminalInput.DataReceived -= OnDataReceived; @@ -111,22 +114,26 @@ public sealed class TuiRuntime : ITuiRuntime private void OnDataReceived(object? sender, string data) { foreach (var inputEvent in _inputParser.Parse(data)) - { Dispatch(inputEvent); - } } private void OnResized(object? sender, TerminalSize size) - { - Dispatch(new TuiInputEvent(TuiInputEventKind.Resize, $"{size.Columns}x{size.Rows}")); - } + => Dispatch(new TuiInputEvent(TuiInputEventKind.Resize, $"{size.Columns}x{size.Rows}")); private void Dispatch(TuiInputEvent inputEvent) { if (_focusedComponent is IInputComponent inputComponent) - { inputComponent.HandleInput(inputEvent); - } + + RequestRender(); + } + + private void OnOverlayRemoved(object? sender, IComponent component) + { + _overlayFocusRestore.Remove(component, out var restoreFocus); + + if (_focusedComponent == component) + _focusedComponent = _overlayManager.TopVisibleComponent ?? restoreFocus; RequestRender(); } diff --git a/src/TinyTUI/Stdio/ConsoleTerminalInput.cs b/src/TinyTUI/Stdio/ConsoleTerminalInput.cs index d1acc72..6f43b6c 100644 --- a/src/TinyTUI/Stdio/ConsoleTerminalInput.cs +++ b/src/TinyTUI/Stdio/ConsoleTerminalInput.cs @@ -24,10 +24,7 @@ public sealed class ConsoleTerminalInput : ITerminalInput /// public void Start() { - if (_cancellation is not null) - { - return; - } + if (_cancellation is not null) return; _previousTreatControlCAsInput = Console.TreatControlCAsInput; Console.TreatControlCAsInput = true; @@ -41,10 +38,7 @@ public sealed class ConsoleTerminalInput : ITerminalInput public void Stop() { var cancellation = _cancellation; - if (cancellation is null) - { - return; - } + if (cancellation is null) return; cancellation.Cancel(); diff --git a/src/TinyTUI/Stdout/ConsoleTerminalOutput.cs b/src/TinyTUI/Stdout/ConsoleTerminalOutput.cs index 13d4262..6a1caf0 100644 --- a/src/TinyTUI/Stdout/ConsoleTerminalOutput.cs +++ b/src/TinyTUI/Stdout/ConsoleTerminalOutput.cs @@ -17,53 +17,30 @@ public sealed class ConsoleTerminalOutput : ITerminalOutput /// /// 创建使用指定 TextWriter 的终端输出实现 /// - public ConsoleTerminalOutput(TextWriter writer) - { - _writer = writer; - } + public ConsoleTerminalOutput(TextWriter writer) => _writer = writer; /// - public void Write(string value) - { - _writer.Write(value); - } + public void Write(string value) => _writer.Write(value); /// - public void Flush() - { - _writer.Flush(); - } + public void Flush() => _writer.Flush(); /// - public void ClearScreen() - { - Write("\x1b[2J\x1b[H"); - } + public void ClearScreen() => Write("\e[2J\e[H"); /// - public void HideCursor() - { - Write("\x1b[?25l"); - } + public void HideCursor() => Write("\e[?25l"); /// - public void ShowCursor() - { - Write("\x1b[?25h"); - } + public void ShowCursor() => Write("\e[?25h"); /// - public void MoveCursorTo(int row, int column) - { - Write($"\e[{row};{column}H"); - } + public void MoveCursorTo(int row, int column) => Write($"\e[{row};{column}H"); private static TextWriter CreateConsoleWriter() { if (!Console.IsOutputRedirected) - { Console.OutputEncoding = Encoding.UTF8; - } return Console.Out; } diff --git a/src/TinyTUI/Text/TerminalTextMeasurer.cs b/src/TinyTUI/Text/TerminalTextMeasurer.cs index fe88c11..507de52 100644 --- a/src/TinyTUI/Text/TerminalTextMeasurer.cs +++ b/src/TinyTUI/Text/TerminalTextMeasurer.cs @@ -40,9 +40,7 @@ public sealed class TerminalTextMeasurer : ITextMeasurer public string Truncate(string value, int maxWidth) { if (maxWidth <= 0) - { return string.Empty; - } var builder = new StringBuilder(); var width = 0;