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 @@ +