feat: add component infrastructure
- add focusable runtime state and invalidate entry - add theme primitives and reusable components - add component checks for focus and cancellation
This commit is contained in:
@@ -225,6 +225,38 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入
|
||||
|
||||
参考:`tmp/tui/src/components/*`
|
||||
|
||||
本次推进:
|
||||
|
||||
- 新增 `IFocusableComponent`,把焦点状态从组件内部自管改成由 `TuiRuntime.SetFocus` 统一写入
|
||||
- `Input`、`SelectList`、`Editor` 改为只在 `Focused` 为 true 时输出 `CursorMarker`,避免多个可输入组件同时影响硬件光标定位
|
||||
- `TuiRuntime` 在添加、移除、焦点切换、resize 和显式 `Invalidate()` 时递归清理组件缓存,为主题变化和复杂组件缓存失效提供框架入口
|
||||
- `RenderPipelineOptions` 新增 `ShowHardwareCursor`,renderer 始终能把硬件光标定位到 marker,是否显示光标由配置或 `TINYTUI_HARDWARE_CURSOR` 控制
|
||||
- 新增 `ITuiTheme` 和默认 `TuiTheme`,先提供 hint、dim、输入光标、设置项标签和值、选择 cursor 等跨组件样式入口
|
||||
- 新增通用组件 `Spacer`、`TruncatedText`、`CancellableLoader`,覆盖空行间隔、ANSI 感知单行截断和 Escape 取消加载流程
|
||||
- 新增 `TinyTUI.ComponentChecks` 最小检查项目,验证 focusable marker、通用组件渲染和 cancellable loader 取消行为
|
||||
|
||||
为什么先做:
|
||||
|
||||
- `tmp/tui/src/tui.ts` 的 `Focusable.focused` 是组件基础设施的关键分界:运行时决定谁有焦点,组件只根据状态决定是否发出硬件光标 marker;先补这一层能减少后续 overlay、autocomplete、settings 子菜单抢焦点时的耦合
|
||||
- 主题和通用组件先提供薄接口和默认实现,不直接重写所有现有组件样式,避免一次提交同时改变大量视觉输出
|
||||
- `Invalidate()` 先接入 runtime 生命周期和 resize,不提前实现完整缓存系统,但给 Markdown、SettingsList、Autocomplete 这类后续复杂组件留下统一失效入口
|
||||
|
||||
当前更好的点:
|
||||
|
||||
- C# 侧通过 `IFocusableComponent` 明确焦点状态契约,运行时切换焦点时会同时 invalidate 前后组件,后续带缓存组件不需要自己猜测焦点变化
|
||||
- 硬件光标显示配置放在渲染管线选项里,组件只负责 marker,避免组件直接依赖终端输出细节
|
||||
- `CancellableLoader` 暴露标准 `CancellationToken`,比只暴露回调更容易接入 C# async 工作流
|
||||
- `TruncatedText` 直接复用 `ITextMeasurer`,能继承第 5 项已经补过的 ANSI、OSC 和 grapheme 宽度处理
|
||||
|
||||
后续仍需补齐:
|
||||
|
||||
- `SettingsList` 还没有移植,参考实现里的搜索、描述换行、值循环、submenu 委托和主题分区仍需单独推进
|
||||
- `ITuiTheme` 当前只是轻量样式接口,还没有 runtime 级主题持有、主题切换事件和组件缓存 theme version
|
||||
- `Input` 目前仍是尾部输入模型,还没有参考实现里的水平滚动、按 grapheme 移动、kill ring 和 undo;这些应在输入/编辑能力后续增量中处理
|
||||
- 组件缓存规范还只具备 invalidate 入口,没有统一的 width/content/theme cache key,也没有 Markdown AST 或复杂布局缓存基类
|
||||
- 焦点恢复策略仍比 `tmp/tui/src/tui.ts` 简化,overlay 被临时隐藏、非 overlay 组件短暂抢焦点和多层 submenu 的 blocked restore 还需要继续补强
|
||||
- 硬件光标定位已可配置显示隐藏,但还没有维护 renderer 级 `hardwareCursorRow` 相对移动模型,IME 在滚动和 resize 场景仍需要真实终端验证
|
||||
|
||||
### 7. 自动补全和命令交互基础
|
||||
|
||||
目标:把自动补全从 Editor 局部功能升级为可复用的交互能力
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using TinyTUI.Input;
|
||||
|
||||
namespace TinyTUI.Components;
|
||||
|
||||
/// <summary>
|
||||
/// 支持 Escape 取消并暴露 CancellationToken 的加载组件
|
||||
/// </summary>
|
||||
public sealed class CancellableLoader : IInputComponent, IDisposable
|
||||
{
|
||||
private readonly KeybindingRegistry _keybindings;
|
||||
private readonly CancellationTokenSource _cancellation = new();
|
||||
private readonly Loader _loader;
|
||||
|
||||
/// <summary>
|
||||
/// 创建可取消加载组件
|
||||
/// </summary>
|
||||
public CancellableLoader(Loader? loader = null, KeybindingRegistry? keybindings = null)
|
||||
{
|
||||
_loader = loader ?? new Loader();
|
||||
_keybindings = keybindings ?? KeybindingRegistry.CreateDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取底层加载组件
|
||||
/// </summary>
|
||||
public Loader Loader => _loader;
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于传递给异步操作的取消令牌
|
||||
/// </summary>
|
||||
public CancellationToken Token => _cancellation.Token;
|
||||
|
||||
/// <summary>
|
||||
/// 获取是否已经取消
|
||||
/// </summary>
|
||||
public bool IsCanceled => _cancellation.IsCancellationRequested;
|
||||
|
||||
/// <summary>
|
||||
/// 在用户取消加载时触发
|
||||
/// </summary>
|
||||
public Action? OnCanceled { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Render(int width) => _loader.Render(width);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Invalidate() => ((IComponent)_loader).Invalidate();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void HandleInput(TuiInputEvent input)
|
||||
{
|
||||
if (input.Kind != TuiInputEventKind.Key || !_keybindings.Matches(input, TuiKeybindings.SelectCancel))
|
||||
return;
|
||||
|
||||
Cancel();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动取消加载流程
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
if (IsCanceled)
|
||||
return;
|
||||
|
||||
_cancellation.Cancel();
|
||||
OnCanceled?.Invoke();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _cancellation.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TinyTUI.Components;
|
||||
|
||||
/// <summary>
|
||||
/// 定义可由运行时统一设置焦点状态的组件
|
||||
/// </summary>
|
||||
public interface IFocusableComponent : IComponent
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置组件当前是否拥有输入焦点
|
||||
/// </summary>
|
||||
bool Focused { get; set; }
|
||||
}
|
||||
@@ -101,13 +101,16 @@ public sealed partial class Editor
|
||||
private string RenderVisualRow(EditorVisualRow row, bool isLastRow)
|
||||
{
|
||||
if (IsEmpty && row.LogicalRow == _cursorRow && Placeholder.Length > 0)
|
||||
return CursorMarker.Marker + _textMeasurer.Truncate(Placeholder, _lastRenderWidth);
|
||||
{
|
||||
var marker = Focused ? CursorMarker.Marker : string.Empty;
|
||||
return marker + _textMeasurer.Truncate(Placeholder, _lastRenderWidth);
|
||||
}
|
||||
|
||||
if (row.LogicalRow != _cursorRow || !IsCursorInVisualRow(row, isLastRow))
|
||||
return row.Text;
|
||||
|
||||
// Renderer 会提取这个 marker 并移动硬件光标 所以这里不渲染可见光标字符
|
||||
return InsertCursorMarker(row.Text, _cursorColumn - row.StartColumn);
|
||||
return Focused ? InsertCursorMarker(row.Text, _cursorColumn - row.StartColumn) : row.Text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace TinyTUI.Components;
|
||||
/// <summary>
|
||||
/// 支持多行文本编辑的基础编辑器组件
|
||||
/// </summary>
|
||||
public sealed partial class Editor(ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null) : IInputComponent
|
||||
public sealed partial class Editor(ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null) : IInputComponent, IFocusableComponent
|
||||
{
|
||||
private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer();
|
||||
private readonly KeybindingRegistry _keybindings = keybindings ?? KeybindingRegistry.CreateDefault();
|
||||
@@ -38,6 +38,9 @@ public sealed partial class Editor(ITextMeasurer? textMeasurer = null, Keybindin
|
||||
/// </summary>
|
||||
public string Placeholder { get; set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Focused { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前编辑器文本
|
||||
/// </summary>
|
||||
|
||||
@@ -2,16 +2,21 @@ using System.Text;
|
||||
using TinyTUI.Input;
|
||||
using TinyTUI.Rendering;
|
||||
using TinyTUI.Text;
|
||||
using TinyTUI.Theme;
|
||||
|
||||
namespace TinyTUI.Components;
|
||||
|
||||
/// <summary>
|
||||
/// 支持文本输入 提交和取消的单行输入组件
|
||||
/// </summary>
|
||||
public sealed class Input(ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null) : IInputComponent
|
||||
public sealed class Input(
|
||||
ITextMeasurer? textMeasurer = null,
|
||||
KeybindingRegistry? keybindings = null,
|
||||
ITuiTheme? theme = null) : IInputComponent, IFocusableComponent
|
||||
{
|
||||
private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer();
|
||||
private readonly KeybindingRegistry _keybindings = keybindings ?? KeybindingRegistry.CreateDefault();
|
||||
private readonly ITuiTheme _theme = theme ?? TuiTheme.Default;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置输入框前缀
|
||||
@@ -23,6 +28,9 @@ public sealed class Input(ITextMeasurer? textMeasurer = null, KeybindingRegistry
|
||||
/// </summary>
|
||||
public string Value { get; private set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Focused { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 在输入值变化时触发
|
||||
/// </summary>
|
||||
@@ -41,8 +49,10 @@ public sealed class Input(ITextMeasurer? textMeasurer = null, KeybindingRegistry
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Render(int width)
|
||||
{
|
||||
// 光标不渲染成可见字符 由 renderer 提取 marker 后移动真实终端光标
|
||||
var visible = $"{Prompt}{Value}{CursorMarker.Marker}";
|
||||
// 只有运行时确认获得焦点后才发出 marker 避免多个输入组件争抢硬件光标
|
||||
var marker = Focused ? CursorMarker.Marker : string.Empty;
|
||||
var cursor = Focused ? _theme.InputCursor(" ") : string.Empty;
|
||||
var visible = $"{Prompt}{Value}{marker}{cursor}";
|
||||
return [_textMeasurer.Truncate(visible, width)];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
using TinyTUI.Input;
|
||||
using TinyTUI.Rendering;
|
||||
using TinyTUI.Text;
|
||||
using TinyTUI.Theme;
|
||||
|
||||
namespace TinyTUI.Components;
|
||||
|
||||
/// <summary>
|
||||
/// 支持方向键选择和回车确认的列表组件
|
||||
/// </summary>
|
||||
public sealed class SelectList(ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null) : IInputComponent
|
||||
public sealed class SelectList(
|
||||
ITextMeasurer? textMeasurer = null,
|
||||
KeybindingRegistry? keybindings = null,
|
||||
ITuiTheme? theme = null) : IInputComponent, IFocusableComponent
|
||||
{
|
||||
private const int PrimaryColumnGap = 2;
|
||||
private const int MinDescriptionWidth = 10;
|
||||
|
||||
private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer();
|
||||
private readonly KeybindingRegistry _keybindings = keybindings ?? KeybindingRegistry.CreateDefault();
|
||||
private readonly ITuiTheme _theme = theme ?? TuiTheme.Default;
|
||||
private readonly List<SelectItem> _items = [];
|
||||
private readonly List<SelectItem> _filteredItems = [];
|
||||
|
||||
@@ -29,6 +34,9 @@ public sealed class SelectList(ITextMeasurer? textMeasurer = null, KeybindingReg
|
||||
/// </summary>
|
||||
public int SelectedIndex { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Focused { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前选中项文本
|
||||
/// </summary>
|
||||
@@ -114,8 +122,9 @@ public sealed class SelectList(ITextMeasurer? textMeasurer = null, KeybindingReg
|
||||
public IReadOnlyList<string> Render(int width)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var marker = Focused ? CursorMarker.Marker : string.Empty;
|
||||
|
||||
lines.Add(_textMeasurer.Truncate($"Filter: {_filter}{CursorMarker.Marker}", width));
|
||||
lines.Add(_textMeasurer.Truncate($"Filter: {_filter}{marker}", width));
|
||||
|
||||
if (_filteredItems.Count == 0)
|
||||
{
|
||||
@@ -244,7 +253,7 @@ public sealed class SelectList(ITextMeasurer? textMeasurer = null, KeybindingReg
|
||||
/// </summary>
|
||||
private string RenderItem(SelectItem item, bool selected, int width, int primaryWidth)
|
||||
{
|
||||
var prefix = selected ? "> " : " ";
|
||||
var prefix = selected ? _theme.Cursor : " ";
|
||||
var label = NormalizeSingleLine(item.Label.Length == 0 ? item.Value : item.Label);
|
||||
var description = NormalizeSingleLine(item.Description ?? string.Empty);
|
||||
var prefixWidth = _textMeasurer.GetWidth(prefix);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace TinyTUI.Components;
|
||||
|
||||
/// <summary>
|
||||
/// 渲染固定数量空行的间隔组件
|
||||
/// </summary>
|
||||
public sealed class Spacer(int lines = 1) : IComponent
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取或设置空行数量
|
||||
/// </summary>
|
||||
public int Lines { get; set; } = Math.Max(0, lines);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Render(int width)
|
||||
{
|
||||
var count = Math.Max(0, Lines);
|
||||
return count == 0 ? [] : Enumerable.Repeat(string.Empty, count).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using TinyTUI.Text;
|
||||
|
||||
namespace TinyTUI.Components;
|
||||
|
||||
/// <summary>
|
||||
/// 渲染单行并按终端宽度截断和填充的文本组件
|
||||
/// </summary>
|
||||
public sealed class TruncatedText(string value = "", ITextMeasurer? textMeasurer = null) : IComponent
|
||||
{
|
||||
private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer();
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置要显示的文本
|
||||
/// </summary>
|
||||
public string Value { get; set; } = value;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置左右内边距
|
||||
/// </summary>
|
||||
public int PaddingX { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置上下空行内边距
|
||||
/// </summary>
|
||||
public int PaddingY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置是否补齐到完整宽度
|
||||
/// </summary>
|
||||
public bool PadToWidth { get; set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Render(int width)
|
||||
{
|
||||
var safeWidth = Math.Max(0, width);
|
||||
var lines = new List<string>();
|
||||
var emptyLine = PadToWidth ? new string(' ', safeWidth) : string.Empty;
|
||||
var paddingY = Math.Max(0, PaddingY);
|
||||
|
||||
for (var index = 0; index < paddingY; index++)
|
||||
lines.Add(emptyLine);
|
||||
|
||||
lines.Add(RenderContentLine(safeWidth));
|
||||
|
||||
for (var index = 0; index < paddingY; index++)
|
||||
lines.Add(emptyLine);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 渲染单行内容并按可用宽度处理左右内边距
|
||||
/// </summary>
|
||||
private string RenderContentLine(int width)
|
||||
{
|
||||
var paddingX = Math.Max(0, PaddingX);
|
||||
var availableWidth = Math.Max(0, width - paddingX * 2);
|
||||
var singleLine = Value.Replace("\r\n", "\n").Split('\n')[0];
|
||||
var content = availableWidth == 0 ? string.Empty : _textMeasurer.Truncate(singleLine, availableWidth);
|
||||
var rendered = $"{new string(' ', paddingX)}{content}{new string(' ', paddingX)}";
|
||||
|
||||
if (!PadToWidth)
|
||||
return rendered;
|
||||
|
||||
var padding = Math.Max(0, width - _textMeasurer.GetWidth(rendered));
|
||||
return rendered + new string(' ', padding);
|
||||
}
|
||||
}
|
||||
@@ -252,14 +252,24 @@ public sealed class DifferentialRenderer : IRenderer
|
||||
private void MoveCursor(RenderedFrame frame)
|
||||
{
|
||||
if (frame.Cursor is not { } position)
|
||||
{
|
||||
_output.HideCursor();
|
||||
return;
|
||||
}
|
||||
|
||||
var visibleRow = position.Row - frame.Viewport.Top;
|
||||
if (visibleRow is < 1 || visibleRow > frame.Viewport.Height)
|
||||
{
|
||||
_output.HideCursor();
|
||||
return;
|
||||
}
|
||||
|
||||
_output.ShowCursor();
|
||||
_output.MoveCursorTo(visibleRow, position.Column);
|
||||
|
||||
if (_options.ShowHardwareCursor)
|
||||
_output.ShowCursor();
|
||||
else
|
||||
_output.HideCursor();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -77,13 +77,23 @@ public sealed class FullScreenRenderer : IRenderer
|
||||
private void MoveCursor(RenderedFrame frame)
|
||||
{
|
||||
if (frame.Cursor is not { } position)
|
||||
{
|
||||
_output.HideCursor();
|
||||
return;
|
||||
}
|
||||
|
||||
var visibleRow = position.Row - frame.Viewport.Top;
|
||||
if (visibleRow is < 1 || visibleRow > frame.Viewport.Height)
|
||||
{
|
||||
_output.HideCursor();
|
||||
return;
|
||||
}
|
||||
|
||||
_output.ShowCursor();
|
||||
_output.MoveCursorTo(visibleRow, position.Column);
|
||||
|
||||
if (_options.ShowHardwareCursor)
|
||||
_output.ShowCursor();
|
||||
else
|
||||
_output.HideCursor();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ public sealed class RenderPipelineOptions
|
||||
/// </summary>
|
||||
public bool ThrowOnWidthOverflow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置定位到光标标记后是否显示硬件光标
|
||||
/// </summary>
|
||||
public bool ShowHardwareCursor { get; init; } = Environment.GetEnvironmentVariable("TINYTUI_HARDWARE_CURSOR") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置是否写出每次差分渲染调试日志
|
||||
/// </summary>
|
||||
|
||||
@@ -23,6 +23,11 @@ public interface ITuiRuntime : IDisposable
|
||||
/// </summary>
|
||||
void SetFocus(IComponent? component);
|
||||
|
||||
/// <summary>
|
||||
/// 递归清除组件缓存并请求重新渲染
|
||||
/// </summary>
|
||||
void Invalidate();
|
||||
|
||||
/// <summary>
|
||||
/// 请求一次渲染
|
||||
/// </summary>
|
||||
|
||||
@@ -46,6 +46,7 @@ public sealed class TuiRuntime : ITuiRuntime
|
||||
public void Add(IComponent component)
|
||||
{
|
||||
_root.Add(component);
|
||||
component.Invalidate();
|
||||
RequestRender();
|
||||
}
|
||||
|
||||
@@ -54,13 +55,37 @@ public sealed class TuiRuntime : ITuiRuntime
|
||||
{
|
||||
_root.Remove(component);
|
||||
|
||||
if (_focusedComponent == component) _focusedComponent = null;
|
||||
if (_focusedComponent == component)
|
||||
SetFocus(null);
|
||||
else
|
||||
component.Invalidate();
|
||||
|
||||
RequestRender();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetFocus(IComponent? component) => _focusedComponent = component;
|
||||
public void SetFocus(IComponent? component)
|
||||
{
|
||||
if (ReferenceEquals(_focusedComponent, component))
|
||||
return;
|
||||
|
||||
SetFocusableState(_focusedComponent, focused: false);
|
||||
_focusedComponent?.Invalidate();
|
||||
|
||||
_focusedComponent = component;
|
||||
|
||||
SetFocusableState(_focusedComponent, focused: true);
|
||||
_focusedComponent?.Invalidate();
|
||||
|
||||
RequestRender();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Invalidate()
|
||||
{
|
||||
_root.Invalidate();
|
||||
RequestRender();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RequestRender()
|
||||
@@ -142,7 +167,10 @@ public sealed class TuiRuntime : ITuiRuntime
|
||||
}
|
||||
|
||||
private void OnResized(object? sender, TerminalSize size)
|
||||
=> Dispatch(new TuiInputEvent(TuiInputEventKind.Resize, $"{size.Columns}x{size.Rows}"));
|
||||
{
|
||||
_root.Invalidate();
|
||||
Dispatch(new TuiInputEvent(TuiInputEventKind.Resize, $"{size.Columns}x{size.Rows}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将输入事件发送给当前焦点组件并请求重渲染
|
||||
@@ -200,7 +228,16 @@ public sealed class TuiRuntime : ITuiRuntime
|
||||
_overlayFocusRestore.TryGetValue(component, out restoreFocus);
|
||||
|
||||
if (_focusedComponent == component)
|
||||
_focusedComponent = _overlayManager.TopFocusableComponent ?? restoreFocus;
|
||||
SetFocus(_overlayManager.TopFocusableComponent ?? restoreFocus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 统一写入组件焦点状态 组件自身只根据状态决定是否输出光标 marker
|
||||
/// </summary>
|
||||
private static void SetFocusableState(IComponent? component, bool focused)
|
||||
{
|
||||
if (component is IFocusableComponent focusable)
|
||||
focusable.Focused = focused;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace TinyTUI.Theme;
|
||||
|
||||
/// <summary>
|
||||
/// 定义组件渲染时可复用的终端样式入口
|
||||
/// </summary>
|
||||
public interface ITuiTheme
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取普通提示文本样式
|
||||
/// </summary>
|
||||
string Hint(string value);
|
||||
|
||||
/// <summary>
|
||||
/// 获取弱化文本样式
|
||||
/// </summary>
|
||||
string Dim(string value);
|
||||
|
||||
/// <summary>
|
||||
/// 获取选中行前缀
|
||||
/// </summary>
|
||||
string Cursor { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取输入光标所在字符样式
|
||||
/// </summary>
|
||||
string InputCursor(string value);
|
||||
|
||||
/// <summary>
|
||||
/// 获取设置项标签样式
|
||||
/// </summary>
|
||||
string SettingLabel(string value, bool selected);
|
||||
|
||||
/// <summary>
|
||||
/// 获取设置项值样式
|
||||
/// </summary>
|
||||
string SettingValue(string value, bool selected);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace TinyTUI.Theme;
|
||||
|
||||
/// <summary>
|
||||
/// 提供 TinyTUI 默认终端主题
|
||||
/// </summary>
|
||||
public sealed class TuiTheme : ITuiTheme
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取默认主题实例
|
||||
/// </summary>
|
||||
public static TuiTheme Default { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Cursor { get; init; } = "> ";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Hint(string value) => $"\e[2m{value}\e[22m";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Dim(string value) => $"\e[2m{value}\e[22m";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string InputCursor(string value) => $"\e[7m{value}\e[27m";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SettingLabel(string value, bool selected) => selected ? $"\e[1m{value}\e[22m" : value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SettingValue(string value, bool selected) => selected ? $"\e[36m{value}\e[39m" : value;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using TinyTUI.Components;
|
||||
using TinyTUI.Input;
|
||||
using TinyTUI.Rendering;
|
||||
using TinyTUI.Text;
|
||||
|
||||
var measurer = new TerminalTextMeasurer();
|
||||
|
||||
var input = new Input(measurer) { Prompt = "> " };
|
||||
AssertFalse(input.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "unfocused input marker");
|
||||
input.Focused = true;
|
||||
AssertTrue(input.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "focused input marker");
|
||||
|
||||
var editor = new Editor(measurer) { Placeholder = "type here" };
|
||||
AssertFalse(editor.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "unfocused editor marker");
|
||||
editor.Focused = true;
|
||||
AssertTrue(editor.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "focused editor marker");
|
||||
|
||||
var list = new SelectList(measurer);
|
||||
list.SetItems(["alpha", "beta"]);
|
||||
AssertFalse(list.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "unfocused select marker");
|
||||
list.Focused = true;
|
||||
AssertTrue(list.Render(20)[0].Contains(CursorMarker.Marker, StringComparison.Ordinal), "focused select marker");
|
||||
|
||||
AssertEqual(3, new Spacer(3).Render(10).Count, "spacer line count");
|
||||
AssertEqual(0, new Spacer(-1).Render(10).Count, "spacer clamps negative lines");
|
||||
|
||||
var truncated = new TruncatedText("abcdef\nignored", measurer)
|
||||
{
|
||||
PaddingX = 1,
|
||||
PaddingY = 1,
|
||||
};
|
||||
var truncatedLines = truncated.Render(6);
|
||||
AssertEqual(3, truncatedLines.Count, "truncated text vertical padding");
|
||||
AssertEqual(6, measurer.GetWidth(truncatedLines[1]), "truncated text padded width");
|
||||
AssertTrue(truncatedLines[1].Contains("abc", StringComparison.Ordinal), "truncated text first line only");
|
||||
|
||||
var loader = new CancellableLoader(keybindings: KeybindingRegistry.CreateDefault());
|
||||
var canceled = false;
|
||||
loader.OnCanceled = () => canceled = true;
|
||||
loader.HandleInput(new TuiInputEvent(TuiInputEventKind.Key, KeyNames.Escape));
|
||||
AssertTrue(loader.IsCanceled, "cancellable loader token");
|
||||
AssertTrue(canceled, "cancellable loader callback");
|
||||
|
||||
Console.WriteLine("TinyTUI component checks passed");
|
||||
|
||||
static void AssertEqual<T>(T expected, T actual, string name)
|
||||
{
|
||||
if (!EqualityComparer<T>.Default.Equals(expected, actual))
|
||||
throw new InvalidOperationException($"{name}: expected '{expected}', actual '{actual}'");
|
||||
}
|
||||
|
||||
static void AssertTrue(bool condition, string name)
|
||||
{
|
||||
if (!condition)
|
||||
throw new InvalidOperationException($"{name}: assertion failed");
|
||||
}
|
||||
|
||||
static void AssertFalse(bool condition, string name) => AssertTrue(!condition, name);
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\TinyTUI\TinyTUI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -9,5 +9,6 @@
|
||||
</Folder>
|
||||
<Project Path="src/Example/Example.csproj" Id="6656c1ec-b220-4e7a-8875-5a97a85afbc1" />
|
||||
<Project Path="src/TinyTUI/TinyTUI.csproj" Id="f39199aa-6947-4951-b952-a1588045505e" />
|
||||
<Project Path="test/TinyTUI.ComponentChecks/TinyTUI.ComponentChecks.csproj" Id="95c72f97-61f0-4806-8a90-28d70a89cd87" />
|
||||
<Project Path="test/TinyTUI.TextChecks/TinyTUI.TextChecks.csproj" Id="d827983c-78a1-4f04-85f3-874ccfe2e735" />
|
||||
</Solution>
|
||||
|
||||
Reference in New Issue
Block a user