diff --git a/TODO.md b/TODO.md index c07ccb2..9a5bafa 100644 --- a/TODO.md +++ b/TODO.md @@ -386,10 +386,31 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入 - `CancellableLoader` 暴露标准 `CancellationToken`,比只暴露回调更容易接入 C# async 工作流 - `TruncatedText` 直接复用 `ITextMeasurer`,能继承第 5 项已经补过的 ANSI、OSC 和 grapheme 宽度处理 +本次 Runtime 级主题上下文推进: + +- 新增 `IThemeComponent`,让组件可以显式接收 Runtime 级共享 `ITuiTheme` +- `TuiRuntime` 现在持有 `Theme`,构造函数支持传入初始主题,默认仍使用 `TuiTheme.Default` +- `ITuiRuntime` 增加 `ThemeChanged` 和 `SetTheme(ITuiTheme theme)`,主题切换时会更新已挂载根组件和 overlay 组件并请求重渲染 +- `MountRuntimeComponentTree` 复用现有 Runtime 挂载上下文,在根组件、overlay、动态 `Container.Add` 和内置 `Box.Child` 路径同时注入共享主题和共享 keybinding +- `Input`、`SelectList`、`Image`、`CancellableLoader`、`Editor` 接入主题注入,其中 `Editor` 会把主题转发给内部 `AutocompleteList` +- 新增 xUnit 回归测试,覆盖根组件主题注入、overlay 主题注入、动态容器子组件主题注入、`SetTheme` 刷新已挂载组件且不破坏 keybinding 注入 + +本次为什么这样做: + +- 对比 `tmp/tui/src/tui.ts`,参考实现的关键生命周期是 `TUI.invalidate()` 会递归根组件和 overlay;C# 侧已有 Runtime 挂载计数,因此把主题注入接到同一条生命周期能避免维护两套递归逻辑 +- 主题切换只负责更新共享主题、触发组件失效和重渲染,不扩展控件样式细节,避免把本轮任务扩大成完整主题系统 +- `IThemeComponent` 与 `IKeybindingComponent` 对称,第三方组件可以选择接入并自行向内部子组件转发,Runtime 不需要用反射猜测组件结构 + +本次当前更好的点: + +- C# 侧主题是 Runtime 实例级状态,不是模块级全局对象,多 Runtime 和测试场景可以隔离不同主题 +- 已挂载 overlay 和动态添加的容器子树会收到同一个主题对象,避免浮层和后续加入组件继续使用默认主题 +- 主题切换路径不会重建 keybinding registry,测试覆盖了切换主题后共享快捷键仍然生效 + 后续仍需补齐: - `SettingsList` 还没有移植,参考实现里的搜索、描述换行、值循环、submenu 委托和主题分区仍需单独推进 -- `ITuiTheme` 当前只是轻量样式接口,还没有 runtime 级主题持有、主题切换事件和组件缓存 theme version +- `ITuiTheme` 当前仍是轻量样式接口,还没有 theme version、分区主题、Markdown 完整主题或控件级样式覆盖策略 - `Input` 目前仍是尾部输入模型,还没有参考实现里的水平滚动、按 grapheme 移动、kill ring 和 undo;这些应在输入/编辑能力后续增量中处理 - 组件缓存规范还只具备 invalidate 入口,没有统一的 width/content/theme cache key,也没有 Markdown AST 或复杂布局缓存基类 - 焦点恢复策略仍比 `tmp/tui/src/tui.ts` 简化,overlay 被临时隐藏、非 overlay 组件短暂抢焦点和多层 submenu 的 blocked restore 还需要继续补强 diff --git a/src/TinyTUI/Components/CancellableLoader.cs b/src/TinyTUI/Components/CancellableLoader.cs index fcca661..c4bfede 100644 --- a/src/TinyTUI/Components/CancellableLoader.cs +++ b/src/TinyTUI/Components/CancellableLoader.cs @@ -1,11 +1,12 @@ using TinyTUI.Input; +using TinyTUI.Theme; namespace TinyTUI.Components; /// /// 支持 Escape 取消并暴露 CancellationToken 的加载组件 /// -public sealed class CancellableLoader : IInputComponent, IKeybindingComponent, IDisposable +public sealed class CancellableLoader : IInputComponent, IKeybindingComponent, IThemeComponent, IDisposable { private readonly CancellationTokenSource _cancellation = new(); private readonly Loader _loader; @@ -50,6 +51,14 @@ public sealed class CancellableLoader : IInputComponent, IKeybindingComponent, I public void SetKeybindings(KeybindingRegistry keybindings) => _keybindings = keybindings ?? throw new ArgumentNullException(nameof(keybindings)); + /// + public void SetTheme(ITuiTheme theme) + { + ArgumentNullException.ThrowIfNull(theme); + + // 当前 Loader 本身没有样式入口 这里仅接收 Runtime 主题以保持生命周期契约一致 + } + /// public void HandleInput(TuiInputEvent input) { diff --git a/src/TinyTUI/Components/Core/IThemeComponent.cs b/src/TinyTUI/Components/Core/IThemeComponent.cs new file mode 100644 index 0000000..9796ca9 --- /dev/null +++ b/src/TinyTUI/Components/Core/IThemeComponent.cs @@ -0,0 +1,14 @@ +using TinyTUI.Theme; + +namespace TinyTUI.Components; + +/// +/// 定义可以接收 Runtime 级共享主题的组件 +/// +public interface IThemeComponent : IComponent +{ + /// + /// 设置组件后续渲染时使用的主题 + /// + void SetTheme(ITuiTheme theme); +} diff --git a/src/TinyTUI/Components/Editor/Editor.cs b/src/TinyTUI/Components/Editor/Editor.cs index 63a1f34..bcdc828 100644 --- a/src/TinyTUI/Components/Editor/Editor.cs +++ b/src/TinyTUI/Components/Editor/Editor.cs @@ -1,12 +1,13 @@ using TinyTUI.Input; using TinyTUI.Text; +using TinyTUI.Theme; namespace TinyTUI.Components; /// /// 支持多行文本编辑的基础编辑器组件 /// -public sealed partial class Editor : IInputComponent, IFocusableComponent, IKeybindingComponent +public sealed partial class Editor : IInputComponent, IFocusableComponent, IKeybindingComponent, IThemeComponent { private readonly ITextMeasurer _textMeasurer; private readonly List _lines = [string.Empty]; @@ -15,6 +16,7 @@ public sealed partial class Editor : IInputComponent, IFocusableComponent, IKeyb private readonly List _history = []; private KeybindingRegistry _keybindings; + private ITuiTheme _theme; private int _cursorRow; private int _cursorColumn; private int _historyIndex = -1; @@ -26,11 +28,12 @@ public sealed partial class Editor : IInputComponent, IFocusableComponent, IKeyb /// /// 创建多行编辑器组件 /// - public Editor(ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null) + public Editor(ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null, ITuiTheme? theme = null) { _textMeasurer = textMeasurer ?? new TerminalTextMeasurer(); _keybindings = keybindings ?? KeybindingRegistry.CreateDefault(); - AutocompleteList = new SelectList(_textMeasurer, _keybindings) + _theme = theme ?? TuiTheme.Default; + AutocompleteList = new SelectList(_textMeasurer, _keybindings, _theme) { Height = 5, Focused = true, @@ -93,6 +96,15 @@ public sealed partial class Editor : IInputComponent, IFocusableComponent, IKeyb AutocompleteList.SetKeybindings(_keybindings); } + /// + public void SetTheme(ITuiTheme theme) + { + _theme = theme ?? throw new ArgumentNullException(nameof(theme)); + + // 补全列表是 Editor 内部子组件 Runtime 无法直接遍历到 需要由 Editor 转发共享主题 + AutocompleteList.SetTheme(_theme); + } + /// public void HandleInput(TuiInputEvent input) { diff --git a/src/TinyTUI/Components/Image.cs b/src/TinyTUI/Components/Image.cs index 140bbe3..92d5715 100644 --- a/src/TinyTUI/Components/Image.cs +++ b/src/TinyTUI/Components/Image.cs @@ -6,14 +6,14 @@ namespace TinyTUI.Components; /// /// 渲染终端内联图像并在不支持时输出文本 fallback /// -public sealed class Image : IComponent +public sealed class Image : IThemeComponent { private readonly byte[] _data; private readonly string _base64Data; private readonly ITerminalImageService _imageService; - private readonly ITuiTheme _theme; private IReadOnlyList? _cachedLines; private int? _cachedWidth; + private ITuiTheme _theme; /// /// 创建终端图像组件 @@ -72,6 +72,13 @@ public sealed class Image : IComponent _cachedWidth = null; } + /// + public void SetTheme(ITuiTheme theme) + { + _theme = theme ?? throw new ArgumentNullException(nameof(theme)); + Invalidate(); + } + /// public IReadOnlyList Render(int width) { diff --git a/src/TinyTUI/Components/Input.cs b/src/TinyTUI/Components/Input.cs index c50fec0..b1c350b 100644 --- a/src/TinyTUI/Components/Input.cs +++ b/src/TinyTUI/Components/Input.cs @@ -12,11 +12,11 @@ namespace TinyTUI.Components; public sealed class Input( ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null, - ITuiTheme? theme = null) : IInputComponent, IFocusableComponent, IKeybindingComponent + ITuiTheme? theme = null) : IInputComponent, IFocusableComponent, IKeybindingComponent, IThemeComponent { private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer(); - private readonly ITuiTheme _theme = theme ?? TuiTheme.Default; private KeybindingRegistry _keybindings = keybindings ?? KeybindingRegistry.CreateDefault(); + private ITuiTheme _theme = theme ?? TuiTheme.Default; /// /// 获取或设置输入框前缀 @@ -60,6 +60,10 @@ public sealed class Input( public void SetKeybindings(KeybindingRegistry keybindings) => _keybindings = keybindings ?? throw new ArgumentNullException(nameof(keybindings)); + /// + public void SetTheme(ITuiTheme theme) + => _theme = theme ?? throw new ArgumentNullException(nameof(theme)); + /// public void HandleInput(TuiInputEvent input) { diff --git a/src/TinyTUI/Components/SelectList.cs b/src/TinyTUI/Components/SelectList.cs index ce939e5..8efe8c2 100644 --- a/src/TinyTUI/Components/SelectList.cs +++ b/src/TinyTUI/Components/SelectList.cs @@ -11,17 +11,17 @@ namespace TinyTUI.Components; public sealed class SelectList( ITextMeasurer? textMeasurer = null, KeybindingRegistry? keybindings = null, - ITuiTheme? theme = null) : IInputComponent, IFocusableComponent, IKeybindingComponent + ITuiTheme? theme = null) : IInputComponent, IFocusableComponent, IKeybindingComponent, IThemeComponent { private const int PrimaryColumnGap = 2; private const int MinDescriptionWidth = 10; private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer(); - private readonly ITuiTheme _theme = theme ?? TuiTheme.Default; private readonly List _items = []; private readonly List _filteredItems = []; private KeybindingRegistry _keybindings = keybindings ?? KeybindingRegistry.CreateDefault(); + private ITuiTheme _theme = theme ?? TuiTheme.Default; private string _filter = string.Empty; /// @@ -122,6 +122,10 @@ public sealed class SelectList( public void SetKeybindings(KeybindingRegistry keybindings) => _keybindings = keybindings ?? throw new ArgumentNullException(nameof(keybindings)); + /// + public void SetTheme(ITuiTheme theme) + => _theme = theme ?? throw new ArgumentNullException(nameof(theme)); + /// public IReadOnlyList Render(int width) { diff --git a/src/TinyTUI/Runtime/ITuiRuntime.cs b/src/TinyTUI/Runtime/ITuiRuntime.cs index d22e5a3..a1e8367 100644 --- a/src/TinyTUI/Runtime/ITuiRuntime.cs +++ b/src/TinyTUI/Runtime/ITuiRuntime.cs @@ -1,6 +1,7 @@ using TinyTUI.Components; using TinyTUI.Input; using TinyTUI.Overlay; +using TinyTUI.Theme; namespace TinyTUI.Runtime; @@ -14,6 +15,21 @@ public interface ITuiRuntime : IDisposable /// KeybindingRegistry Keybindings { get; } + /// + /// 获取 Runtime 级共享主题 + /// + ITuiTheme Theme { get; } + + /// + /// 在 Runtime 级共享主题切换后触发 + /// + event EventHandler? ThemeChanged; + + /// + /// 切换 Runtime 级共享主题并刷新已挂载组件 + /// + void SetTheme(ITuiTheme theme); + /// /// 向根组件树添加组件 /// diff --git a/src/TinyTUI/Runtime/TuiRuntime.cs b/src/TinyTUI/Runtime/TuiRuntime.cs index d8f2a1d..211c970 100644 --- a/src/TinyTUI/Runtime/TuiRuntime.cs +++ b/src/TinyTUI/Runtime/TuiRuntime.cs @@ -4,6 +4,7 @@ using TinyTUI.Overlay; using TinyTUI.Rendering; using TinyTUI.Stdio; using TinyTUI.Terminal; +using TinyTUI.Theme; namespace TinyTUI.Runtime; @@ -33,12 +34,14 @@ public sealed class TuiRuntime : ITuiRuntime ITerminalSession terminal, IInputParser inputParser, IRenderer renderer, - KeybindingRegistry? keybindings = null) + KeybindingRegistry? keybindings = null, + ITuiTheme? theme = null) { _terminal = terminal; _inputParser = inputParser; _renderer = renderer; Keybindings = keybindings ?? KeybindingRegistry.CreateDefault(); + Theme = theme ?? TuiTheme.Default; _overlayManager.Removed += OnOverlayRemoved; } @@ -49,14 +52,41 @@ public sealed class TuiRuntime : ITuiRuntime ITerminalInput terminalInput, IInputParser inputParser, IRenderer renderer, - KeybindingRegistry? keybindings = null) - : this(new TerminalInputSessionAdapter(terminalInput), inputParser, renderer, keybindings) + KeybindingRegistry? keybindings = null, + ITuiTheme? theme = null) + : this(new TerminalInputSessionAdapter(terminalInput), inputParser, renderer, keybindings, theme) { } /// public KeybindingRegistry Keybindings { get; } + /// + public ITuiTheme Theme { get; private set; } + + /// + public event EventHandler? ThemeChanged; + + /// + public void SetTheme(ITuiTheme theme) + { + ArgumentNullException.ThrowIfNull(theme); + + if (ReferenceEquals(Theme, theme)) + return; + + Theme = theme; + + foreach (var component in _runtimeComponentMountCounts.Keys) + { + ApplyRuntimeTheme(component); + component.Invalidate(); + } + + ThemeChanged?.Invoke(this, Theme); + RequestRender(); + } + /// public void Add(IComponent component) { @@ -302,6 +332,7 @@ public sealed class TuiRuntime : ITuiRuntime private void MountRuntimeComponentTree(IComponent component) { ApplyRuntimeKeybindings(component); + ApplyRuntimeTheme(component); var isFirstMount = !_runtimeComponentMountCounts.TryGetValue(component, out var mountCount); _runtimeComponentMountCounts[component] = isFirstMount ? 1 : mountCount + 1; @@ -372,6 +403,15 @@ public sealed class TuiRuntime : ITuiRuntime keybindingComponent.SetKeybindings(Keybindings); } + /// + /// 将 Runtime 级共享主题写入单个支持组件 + /// + private void ApplyRuntimeTheme(IComponent component) + { + if (component is IThemeComponent themeComponent) + themeComponent.SetTheme(Theme); + } + /// /// 处理已挂载容器的动态子组件 让新子树立即进入 Runtime 共享上下文 /// diff --git a/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs b/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs index 76a203b..9a06ae3 100644 --- a/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs +++ b/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs @@ -1,9 +1,11 @@ using TinyTUI.Autocomplete; using TinyTUI.Components; using TinyTUI.Input; +using TinyTUI.Overlay; using TinyTUI.Rendering; using TinyTUI.Runtime; using TinyTUI.Terminal; +using TinyTUI.Theme; namespace TinyTUI.Tests.Runtime; @@ -252,6 +254,95 @@ public sealed class TuiRuntimeInputListenerTests Assert.Equal("/history ", editor.Value); } + [Fact] + public void RuntimeInjectsSharedThemeIntoAddedThemeComponent() + { + var terminal = new TestTerminalSession(); + var theme = new TestTheme("runtime"); + using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer(), theme: theme); + var component = new RecordingThemeComponent(); + + runtime.Add(component); + + Assert.Same(theme, runtime.Theme); + Assert.Same(theme, component.Theme); + Assert.Equal(1, component.SetThemeCount); + } + + [Fact] + public void RuntimeInjectsSharedThemeIntoOverlayComponent() + { + var terminal = new TestTerminalSession(); + var theme = new TestTheme("overlay"); + using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer(), theme: theme); + var component = new RecordingThemeComponent(); + + runtime.ShowOverlay(component); + + Assert.Same(theme, component.Theme); + Assert.Equal(1, component.SetThemeCount); + } + + [Fact] + public void RuntimeInjectsSharedThemeIntoDynamicContainerChild() + { + var terminal = new TestTerminalSession(); + var theme = new TestTheme("dynamic"); + using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer(), theme: theme); + var container = new Container(); + var component = new RecordingThemeComponent(); + + runtime.Add(container); + container.Add(new Box(component)); + + Assert.Same(theme, component.Theme); + Assert.Equal(1, component.SetThemeCount); + } + + [Fact] + public void SetThemeRefreshesMountedComponentsAndPreservesKeybindingInjection() + { + var terminal = new TestTerminalSession(); + var renderer = new CountingRenderer(); + var keybindings = KeybindingRegistry.CreateDefault(); + keybindings.SetUserBinding(TuiKeybindings.InputSubmit, KeyNames.Tab); + var initialTheme = new TestTheme("initial"); + var nextTheme = new TestTheme("next"); + using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), renderer, keybindings, initialTheme); + var themeComponent = new RecordingThemeComponent(); + var overlayThemeComponent = new RecordingThemeComponent(); + var input = new TinyTUI.Components.Input(); + var submitted = false; + var changedThemes = new List(); + + input.OnSubmitted = _ => submitted = true; + runtime.ThemeChanged += (_, theme) => changedThemes.Add(theme); + runtime.Add(new Container + { + Children = + { + themeComponent, + input, + }, + }); + runtime.SetFocus(input); + runtime.ShowOverlay(overlayThemeComponent, new OverlayOptions { NonCapturing = true }); + runtime.Start(); + + var renderCountBeforeThemeChange = renderer.RenderCount; + runtime.SetTheme(nextTheme); + terminal.Receive("\t"); + + Assert.Same(nextTheme, runtime.Theme); + Assert.Same(nextTheme, themeComponent.Theme); + Assert.Same(nextTheme, overlayThemeComponent.Theme); + Assert.Equal([nextTheme], changedThemes); + Assert.True(themeComponent.InvalidateCount > 0); + Assert.True(overlayThemeComponent.InvalidateCount > 0); + Assert.True(renderer.RenderCount > renderCountBeforeThemeChange); + Assert.True(submitted); + } + [Fact] public void DisposingListenerRegistrationRemovesListener() { @@ -281,6 +372,40 @@ public sealed class TuiRuntimeInputListenerTests public void HandleInput(TuiInputEvent input) => Inputs.Add(input); } + private sealed class RecordingThemeComponent : IThemeComponent + { + public ITuiTheme? Theme { get; private set; } + + public int SetThemeCount { get; private set; } + + public int InvalidateCount { get; private set; } + + public IReadOnlyList Render(int width) => []; + + public void Invalidate() => InvalidateCount++; + + public void SetTheme(ITuiTheme theme) + { + Theme = theme; + SetThemeCount++; + } + } + + private sealed class TestTheme(string name) : ITuiTheme + { + public string Cursor => $"{name}> "; + + public string Hint(string value) => $"{name}:hint:{value}"; + + public string Dim(string value) => $"{name}:dim:{value}"; + + public string InputCursor(string value) => $"{name}:cursor:{value}"; + + public string SettingLabel(string value, bool selected) => $"{name}:label:{selected}:{value}"; + + public string SettingValue(string value, bool selected) => $"{name}:value:{selected}:{value}"; + } + private sealed class CountingRenderer : IRenderer { public int RenderCount { get; private set; }