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;