diff --git a/TODO.md b/TODO.md
index e9ee9af..318dc46 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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 局部功能升级为可复用的交互能力
diff --git a/src/TinyTUI/Components/CancellableLoader.cs b/src/TinyTUI/Components/CancellableLoader.cs
new file mode 100644
index 0000000..568ffa0
--- /dev/null
+++ b/src/TinyTUI/Components/CancellableLoader.cs
@@ -0,0 +1,72 @@
+using TinyTUI.Input;
+
+namespace TinyTUI.Components;
+
+///
+/// 支持 Escape 取消并暴露 CancellationToken 的加载组件
+///
+public sealed class CancellableLoader : IInputComponent, IDisposable
+{
+ private readonly KeybindingRegistry _keybindings;
+ private readonly CancellationTokenSource _cancellation = new();
+ private readonly Loader _loader;
+
+ ///
+ /// 创建可取消加载组件
+ ///
+ public CancellableLoader(Loader? loader = null, KeybindingRegistry? keybindings = null)
+ {
+ _loader = loader ?? new Loader();
+ _keybindings = keybindings ?? KeybindingRegistry.CreateDefault();
+ }
+
+ ///
+ /// 获取底层加载组件
+ ///
+ public Loader Loader => _loader;
+
+ ///
+ /// 获取用于传递给异步操作的取消令牌
+ ///
+ public CancellationToken Token => _cancellation.Token;
+
+ ///
+ /// 获取是否已经取消
+ ///
+ public bool IsCanceled => _cancellation.IsCancellationRequested;
+
+ ///
+ /// 在用户取消加载时触发
+ ///
+ public Action? OnCanceled { get; set; }
+
+ ///
+ public IReadOnlyList Render(int width) => _loader.Render(width);
+
+ ///
+ public void Invalidate() => ((IComponent)_loader).Invalidate();
+
+ ///
+ public void HandleInput(TuiInputEvent input)
+ {
+ if (input.Kind != TuiInputEventKind.Key || !_keybindings.Matches(input, TuiKeybindings.SelectCancel))
+ return;
+
+ Cancel();
+ }
+
+ ///
+ /// 手动取消加载流程
+ ///
+ public void Cancel()
+ {
+ if (IsCanceled)
+ return;
+
+ _cancellation.Cancel();
+ OnCanceled?.Invoke();
+ }
+
+ ///
+ public void Dispose() => _cancellation.Dispose();
+}
diff --git a/src/TinyTUI/Components/Core/IFocusableComponent.cs b/src/TinyTUI/Components/Core/IFocusableComponent.cs
new file mode 100644
index 0000000..3684802
--- /dev/null
+++ b/src/TinyTUI/Components/Core/IFocusableComponent.cs
@@ -0,0 +1,12 @@
+namespace TinyTUI.Components;
+
+///
+/// 定义可由运行时统一设置焦点状态的组件
+///
+public interface IFocusableComponent : IComponent
+{
+ ///
+ /// 获取或设置组件当前是否拥有输入焦点
+ ///
+ bool Focused { get; set; }
+}
diff --git a/src/TinyTUI/Components/Editor/Editor.Rendering.cs b/src/TinyTUI/Components/Editor/Editor.Rendering.cs
index d6e06c5..09577ed 100644
--- a/src/TinyTUI/Components/Editor/Editor.Rendering.cs
+++ b/src/TinyTUI/Components/Editor/Editor.Rendering.cs
@@ -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;
}
///
diff --git a/src/TinyTUI/Components/Editor/Editor.cs b/src/TinyTUI/Components/Editor/Editor.cs
index 17cf9ad..0c481bc 100644
--- a/src/TinyTUI/Components/Editor/Editor.cs
+++ b/src/TinyTUI/Components/Editor/Editor.cs
@@ -6,7 +6,7 @@ namespace TinyTUI.Components;
///
/// 支持多行文本编辑的基础编辑器组件
///
-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
///
public string Placeholder { get; set; } = string.Empty;
+ ///
+ public bool Focused { get; set; }
+
///
/// 获取当前编辑器文本
///
diff --git a/src/TinyTUI/Components/Input.cs b/src/TinyTUI/Components/Input.cs
index 933f297..59599af 100644
--- a/src/TinyTUI/Components/Input.cs
+++ b/src/TinyTUI/Components/Input.cs
@@ -2,16 +2,21 @@ using System.Text;
using TinyTUI.Input;
using TinyTUI.Rendering;
using TinyTUI.Text;
+using TinyTUI.Theme;
namespace TinyTUI.Components;
///
/// 支持文本输入 提交和取消的单行输入组件
///
-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;
///
/// 获取或设置输入框前缀
@@ -23,6 +28,9 @@ public sealed class Input(ITextMeasurer? textMeasurer = null, KeybindingRegistry
///
public string Value { get; private set; } = string.Empty;
+ ///
+ public bool Focused { get; set; }
+
///
/// 在输入值变化时触发
///
@@ -41,8 +49,10 @@ public sealed class Input(ITextMeasurer? textMeasurer = null, KeybindingRegistry
///
public IReadOnlyList 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)];
}
diff --git a/src/TinyTUI/Components/SelectList.cs b/src/TinyTUI/Components/SelectList.cs
index e1572c6..9059315 100644
--- a/src/TinyTUI/Components/SelectList.cs
+++ b/src/TinyTUI/Components/SelectList.cs
@@ -1,19 +1,24 @@
using TinyTUI.Input;
using TinyTUI.Rendering;
using TinyTUI.Text;
+using TinyTUI.Theme;
namespace TinyTUI.Components;
///
/// 支持方向键选择和回车确认的列表组件
///
-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 _items = [];
private readonly List _filteredItems = [];
@@ -29,6 +34,9 @@ public sealed class SelectList(ITextMeasurer? textMeasurer = null, KeybindingReg
///
public int SelectedIndex { get; private set; }
+ ///
+ public bool Focused { get; set; }
+
///
/// 获取当前选中项文本
///
@@ -114,8 +122,9 @@ public sealed class SelectList(ITextMeasurer? textMeasurer = null, KeybindingReg
public IReadOnlyList Render(int width)
{
var lines = new List();
+ 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
///
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);
diff --git a/src/TinyTUI/Components/Spacer.cs b/src/TinyTUI/Components/Spacer.cs
new file mode 100644
index 0000000..b6d34d5
--- /dev/null
+++ b/src/TinyTUI/Components/Spacer.cs
@@ -0,0 +1,19 @@
+namespace TinyTUI.Components;
+
+///
+/// 渲染固定数量空行的间隔组件
+///
+public sealed class Spacer(int lines = 1) : IComponent
+{
+ ///
+ /// 获取或设置空行数量
+ ///
+ public int Lines { get; set; } = Math.Max(0, lines);
+
+ ///
+ public IReadOnlyList Render(int width)
+ {
+ var count = Math.Max(0, Lines);
+ return count == 0 ? [] : Enumerable.Repeat(string.Empty, count).ToArray();
+ }
+}
diff --git a/src/TinyTUI/Components/TruncatedText.cs b/src/TinyTUI/Components/TruncatedText.cs
new file mode 100644
index 0000000..8e26174
--- /dev/null
+++ b/src/TinyTUI/Components/TruncatedText.cs
@@ -0,0 +1,68 @@
+using TinyTUI.Text;
+
+namespace TinyTUI.Components;
+
+///
+/// 渲染单行并按终端宽度截断和填充的文本组件
+///
+public sealed class TruncatedText(string value = "", ITextMeasurer? textMeasurer = null) : IComponent
+{
+ private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer();
+
+ ///
+ /// 获取或设置要显示的文本
+ ///
+ public string Value { get; set; } = value;
+
+ ///
+ /// 获取或设置左右内边距
+ ///
+ public int PaddingX { get; set; }
+
+ ///
+ /// 获取或设置上下空行内边距
+ ///
+ public int PaddingY { get; set; }
+
+ ///
+ /// 获取或设置是否补齐到完整宽度
+ ///
+ public bool PadToWidth { get; set; } = true;
+
+ ///
+ public IReadOnlyList Render(int width)
+ {
+ var safeWidth = Math.Max(0, width);
+ var lines = new List();
+ 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;
+ }
+
+ ///
+ /// 渲染单行内容并按可用宽度处理左右内边距
+ ///
+ 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);
+ }
+}
diff --git a/src/TinyTUI/Rendering/DifferentialRenderer.cs b/src/TinyTUI/Rendering/DifferentialRenderer.cs
index a5fa530..c632dd6 100644
--- a/src/TinyTUI/Rendering/DifferentialRenderer.cs
+++ b/src/TinyTUI/Rendering/DifferentialRenderer.cs
@@ -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();
}
///
diff --git a/src/TinyTUI/Rendering/FullScreenRenderer.cs b/src/TinyTUI/Rendering/FullScreenRenderer.cs
index 462e062..9c10cf2 100644
--- a/src/TinyTUI/Rendering/FullScreenRenderer.cs
+++ b/src/TinyTUI/Rendering/FullScreenRenderer.cs
@@ -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();
}
}
diff --git a/src/TinyTUI/Rendering/RenderPipelineOptions.cs b/src/TinyTUI/Rendering/RenderPipelineOptions.cs
index f37be1b..9a38a4a 100644
--- a/src/TinyTUI/Rendering/RenderPipelineOptions.cs
+++ b/src/TinyTUI/Rendering/RenderPipelineOptions.cs
@@ -30,6 +30,11 @@ public sealed class RenderPipelineOptions
///
public bool ThrowOnWidthOverflow { get; init; }
+ ///
+ /// 获取或设置定位到光标标记后是否显示硬件光标
+ ///
+ public bool ShowHardwareCursor { get; init; } = Environment.GetEnvironmentVariable("TINYTUI_HARDWARE_CURSOR") == "1";
+
///
/// 获取或设置是否写出每次差分渲染调试日志
///
diff --git a/src/TinyTUI/Runtime/ITuiRuntime.cs b/src/TinyTUI/Runtime/ITuiRuntime.cs
index 9cc231b..06139a4 100644
--- a/src/TinyTUI/Runtime/ITuiRuntime.cs
+++ b/src/TinyTUI/Runtime/ITuiRuntime.cs
@@ -23,6 +23,11 @@ public interface ITuiRuntime : IDisposable
///
void SetFocus(IComponent? component);
+ ///
+ /// 递归清除组件缓存并请求重新渲染
+ ///
+ void Invalidate();
+
///
/// 请求一次渲染
///
diff --git a/src/TinyTUI/Runtime/TuiRuntime.cs b/src/TinyTUI/Runtime/TuiRuntime.cs
index 4e9f53b..b19e52f 100644
--- a/src/TinyTUI/Runtime/TuiRuntime.cs
+++ b/src/TinyTUI/Runtime/TuiRuntime.cs
@@ -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();
}
///
- 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();
+ }
+
+ ///
+ public void Invalidate()
+ {
+ _root.Invalidate();
+ RequestRender();
+ }
///
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}"));
+ }
///
/// 将输入事件发送给当前焦点组件并请求重渲染
@@ -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);
+ }
+
+ ///
+ /// 统一写入组件焦点状态 组件自身只根据状态决定是否输出光标 marker
+ ///
+ private static void SetFocusableState(IComponent? component, bool focused)
+ {
+ if (component is IFocusableComponent focusable)
+ focusable.Focused = focused;
}
///
diff --git a/src/TinyTUI/Theme/ITuiTheme.cs b/src/TinyTUI/Theme/ITuiTheme.cs
new file mode 100644
index 0000000..62ea4be
--- /dev/null
+++ b/src/TinyTUI/Theme/ITuiTheme.cs
@@ -0,0 +1,37 @@
+namespace TinyTUI.Theme;
+
+///
+/// 定义组件渲染时可复用的终端样式入口
+///
+public interface ITuiTheme
+{
+ ///
+ /// 获取普通提示文本样式
+ ///
+ string Hint(string value);
+
+ ///
+ /// 获取弱化文本样式
+ ///
+ string Dim(string value);
+
+ ///
+ /// 获取选中行前缀
+ ///
+ string Cursor { get; }
+
+ ///
+ /// 获取输入光标所在字符样式
+ ///
+ string InputCursor(string value);
+
+ ///
+ /// 获取设置项标签样式
+ ///
+ string SettingLabel(string value, bool selected);
+
+ ///
+ /// 获取设置项值样式
+ ///
+ string SettingValue(string value, bool selected);
+}
diff --git a/src/TinyTUI/Theme/TuiTheme.cs b/src/TinyTUI/Theme/TuiTheme.cs
new file mode 100644
index 0000000..bb323b2
--- /dev/null
+++ b/src/TinyTUI/Theme/TuiTheme.cs
@@ -0,0 +1,30 @@
+namespace TinyTUI.Theme;
+
+///
+/// 提供 TinyTUI 默认终端主题
+///
+public sealed class TuiTheme : ITuiTheme
+{
+ ///
+ /// 获取默认主题实例
+ ///
+ public static TuiTheme Default { get; } = new();
+
+ ///
+ public string Cursor { get; init; } = "> ";
+
+ ///
+ public string Hint(string value) => $"\e[2m{value}\e[22m";
+
+ ///
+ public string Dim(string value) => $"\e[2m{value}\e[22m";
+
+ ///
+ public string InputCursor(string value) => $"\e[7m{value}\e[27m";
+
+ ///
+ public string SettingLabel(string value, bool selected) => selected ? $"\e[1m{value}\e[22m" : value;
+
+ ///
+ public string SettingValue(string value, bool selected) => selected ? $"\e[36m{value}\e[39m" : value;
+}
diff --git a/test/TinyTUI.ComponentChecks/Program.cs b/test/TinyTUI.ComponentChecks/Program.cs
new file mode 100644
index 0000000..41987d6
--- /dev/null
+++ b/test/TinyTUI.ComponentChecks/Program.cs
@@ -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 expected, T actual, string name)
+{
+ if (!EqualityComparer.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);
diff --git a/test/TinyTUI.ComponentChecks/TinyTUI.ComponentChecks.csproj b/test/TinyTUI.ComponentChecks/TinyTUI.ComponentChecks.csproj
new file mode 100644
index 0000000..12a074a
--- /dev/null
+++ b/test/TinyTUI.ComponentChecks/TinyTUI.ComponentChecks.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/ttui.slnx b/ttui.slnx
index 6184d0a..f504e04 100644
--- a/ttui.slnx
+++ b/ttui.slnx
@@ -9,5 +9,6 @@
+