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; }