From 49a234ba7c1aba8d5077fddc1d71f4536edeb9ca Mon Sep 17 00:00:00 2001 From: chuan Date: Thu, 4 Jun 2026 03:32:19 +0800 Subject: [PATCH] feat: add generic component child traversal - add a component child enumeration contract for composite components - make runtime mount context recurse through the shared contract - cover third-party composite keybinding and theme injection --- TODO.md | 21 +++++ src/TinyTUI/Components/Box.cs | 8 +- src/TinyTUI/Components/Core/Container.cs | 5 +- .../Components/Core/ICompositeComponent.cs | 12 +++ src/TinyTUI/Runtime/TuiRuntime.cs | 34 +++----- .../Runtime/TuiRuntimeInputListenerTests.cs | 83 +++++++++++++++++++ 6 files changed, 140 insertions(+), 23 deletions(-) create mode 100644 src/TinyTUI/Components/Core/ICompositeComponent.cs diff --git a/TODO.md b/TODO.md index 9a5bafa..7916c12 100644 --- a/TODO.md +++ b/TODO.md @@ -407,8 +407,29 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入 - 已挂载 overlay 和动态添加的容器子树会收到同一个主题对象,避免浮层和后续加入组件继续使用默认主题 - 主题切换路径不会重建 keybinding registry,测试覆盖了切换主题后共享快捷键仍然生效 +本次通用复合组件子树枚举推进: + +- 新增 `ICompositeComponent`,让复合组件可以通过 `GetChildren()` 暴露直接子组件,Runtime 不再需要硬编码认识每一种内置复合组件 +- `Container` 和 `Box` 实现统一子组件枚举契约,保留现有 `Container.ChildAdded` / `ChildRemoved` 动态事件作为可变容器专属能力 +- `TuiRuntime.MountRuntimeComponentTree` 和 `UnmountRuntimeComponentTree` 改为通过 `ICompositeComponent` 递归,keybinding、theme、invalidate 和动态挂载上下文共用同一条子树遍历路径 +- 新增第三方复合组件回归测试,覆盖 Runtime 根组件添加、overlay 添加、已挂载 `Container` 动态添加后,内部 `Input` 和主题组件都能收到 Runtime 共享上下文 + +本次为什么这样做: + +- 对比 `tmp/tui/src/tui.ts` 和 `tmp/tui/src/components/box.ts`,参考实现的 `Container` / `Box` 都以 `children` 作为复合组件结构来源;C# 侧无法依赖 TS 的公开字段约定,因此用显式接口把这个能力稳定成组件契约 +- 动态增删事件仍只保留在 `Container`,是因为本轮目标是统一“如何枚举子树”,不是扩展所有复合组件的可变生命周期协议,避免把 `Box` 或第三方组件强行要求成可变容器 +- Runtime 挂载上下文本来已经负责 keybinding 和 theme 注入,本轮只替换递归来源,保持现有根树、overlay 和动态容器行为不退化 + +本次当前更好的点: + +- 第三方复合组件只要实现 `ICompositeComponent`,内部子树就能自动拿到 Runtime 级 keybinding 和 theme,不再需要自己实现 `IKeybindingComponent` 或 `IThemeComponent` 转发 +- Runtime 的子树遍历入口收敛为一个 helper,后续 invalidate、生命周期、dispose 或更多上下文注入可以复用同一个契约,减少继续追加 `Container` / `Box` 分支的风险 +- C# 侧把“可枚举子树”和“动态子组件事件”拆开,静态复合组件可以轻量接入,动态容器仍能保留已有挂载计数和事件订阅语义 + 后续仍需补齐: +- `ICompositeComponent` 目前只定义直接子组件枚举,还没有定义子组件变化事件;除 `Container` 外的可变复合组件如果后续出现,需要新增通用动态子树变更契约 +- `SetTheme` 仍通过已挂载组件集合逐个刷新,再由组件自身 `Invalidate()` 递归;后续如果引入更复杂生命周期,可以考虑统一成显式 `OnMounted` / `OnUnmounted` / `OnRuntimeContextChanged` - `SettingsList` 还没有移植,参考实现里的搜索、描述换行、值循环、submenu 委托和主题分区仍需单独推进 - `ITuiTheme` 当前仍是轻量样式接口,还没有 theme version、分区主题、Markdown 完整主题或控件级样式覆盖策略 - `Input` 目前仍是尾部输入模型,还没有参考实现里的水平滚动、按 grapheme 移动、kill ring 和 undo;这些应在输入/编辑能力后续增量中处理 diff --git a/src/TinyTUI/Components/Box.cs b/src/TinyTUI/Components/Box.cs index e2b5b4f..f5a6094 100644 --- a/src/TinyTUI/Components/Box.cs +++ b/src/TinyTUI/Components/Box.cs @@ -5,7 +5,7 @@ namespace TinyTUI.Components; /// /// 为子组件渲染简单边框的容器组件 /// -public sealed class Box(IComponent child, ITextMeasurer? textMeasurer = null) : IComponent +public sealed class Box(IComponent child, ITextMeasurer? textMeasurer = null) : ICompositeComponent { private readonly ITextMeasurer _textMeasurer = textMeasurer ?? new TerminalTextMeasurer(); @@ -40,6 +40,12 @@ public sealed class Box(IComponent child, ITextMeasurer? textMeasurer = null) : return lines; } + /// + public IEnumerable GetChildren() + { + yield return Child; + } + /// public void Invalidate() { diff --git a/src/TinyTUI/Components/Core/Container.cs b/src/TinyTUI/Components/Core/Container.cs index c15ad05..e8a99e6 100644 --- a/src/TinyTUI/Components/Core/Container.cs +++ b/src/TinyTUI/Components/Core/Container.cs @@ -3,7 +3,7 @@ namespace TinyTUI.Components; /// /// 按添加顺序纵向渲染子组件的容器组件 /// -public class Container : IComponent +public class Container : ICompositeComponent { /// /// 在子组件添加后触发 @@ -61,6 +61,9 @@ public class Container : IComponent return lines; } + /// + public IEnumerable GetChildren() => Children; + /// public virtual void Invalidate() { diff --git a/src/TinyTUI/Components/Core/ICompositeComponent.cs b/src/TinyTUI/Components/Core/ICompositeComponent.cs new file mode 100644 index 0000000..9a26656 --- /dev/null +++ b/src/TinyTUI/Components/Core/ICompositeComponent.cs @@ -0,0 +1,12 @@ +namespace TinyTUI.Components; + +/// +/// 定义可以向 Runtime 暴露直接子组件的复合组件 +/// +public interface ICompositeComponent : IComponent +{ + /// + /// 枚举当前组件直接包含的子组件 + /// + IEnumerable GetChildren(); +} diff --git a/src/TinyTUI/Runtime/TuiRuntime.cs b/src/TinyTUI/Runtime/TuiRuntime.cs index 211c970..bccf12e 100644 --- a/src/TinyTUI/Runtime/TuiRuntime.cs +++ b/src/TinyTUI/Runtime/TuiRuntime.cs @@ -344,17 +344,9 @@ public sealed class TuiRuntime : ITuiRuntime mountedContainer.ChildRemoved += OnRuntimeContainerChildRemoved; } - // 内置容器会在添加到 Runtime 前组装子树 这里递归注入避免子组件继续使用各自默认 registry - switch (component) - { - case Container childContainer: - foreach (var child in childContainer.Children) - MountRuntimeComponentTree(child); - break; - case Box box: - MountRuntimeComponentTree(box.Child); - break; - } + // 只依赖复合组件契约递归 不再把 Runtime 绑定到具体内置组件类型 + foreach (var child in EnumerateRuntimeChildren(component)) + MountRuntimeComponentTree(child); } /// @@ -382,18 +374,18 @@ public sealed class TuiRuntime : ITuiRuntime } // 即使当前组件仍被其他路径挂载 子树也要同步减少本次卸载路径的引用计数 - switch (component) - { - case Container childContainer: - foreach (var child in childContainer.Children) - UnmountRuntimeComponentTree(child); - break; - case Box box: - UnmountRuntimeComponentTree(box.Child); - break; - } + foreach (var child in EnumerateRuntimeChildren(component)) + UnmountRuntimeComponentTree(child); } + /// + /// 通过复合组件契约枚举 Runtime 生命周期需要递归处理的直接子组件 + /// + private static IEnumerable EnumerateRuntimeChildren(IComponent component) + => component is ICompositeComponent compositeComponent + ? compositeComponent.GetChildren() + : []; + /// /// 将 Runtime 级共享快捷键注册表写入单个支持组件 /// diff --git a/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs b/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs index 9a06ae3..7841e87 100644 --- a/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs +++ b/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs @@ -227,6 +227,66 @@ public sealed class TuiRuntimeInputListenerTests Assert.Equal(1, list.SelectedIndex); } + [Fact] + public void RuntimeInjectsSharedKeybindingsIntoThirdPartyCompositeChildren() + { + var terminal = new TestTerminalSession(); + var keybindings = KeybindingRegistry.CreateDefault(); + keybindings.SetUserBinding(TuiKeybindings.InputSubmit, KeyNames.Tab); + using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer(), keybindings); + var input = new TinyTUI.Components.Input(); + var submitted = false; + + input.OnSubmitted = _ => submitted = true; + runtime.Add(new ThirdPartyComposite(input)); + runtime.SetFocus(input); + + runtime.Start(); + terminal.Receive("\t"); + + Assert.True(submitted); + } + + [Fact] + public void RuntimeInjectsSharedThemeIntoThirdPartyOverlayCompositeChildren() + { + var terminal = new TestTerminalSession(); + var theme = new TestTheme("third-party-overlay"); + using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer(), theme: theme); + var component = new RecordingThemeComponent(); + + runtime.ShowOverlay(new ThirdPartyComposite(new Box(component))); + + Assert.Same(theme, component.Theme); + Assert.Equal(1, component.SetThemeCount); + } + + [Fact] + public void RuntimeInjectsSharedContextIntoDynamicThirdPartyCompositeChildren() + { + var terminal = new TestTerminalSession(); + var theme = new TestTheme("third-party-dynamic"); + var keybindings = KeybindingRegistry.CreateDefault(); + keybindings.SetUserBinding(TuiKeybindings.InputSubmit, KeyNames.Tab); + using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer(), keybindings, theme); + var container = new Container(); + var input = new TinyTUI.Components.Input(); + var component = new RecordingThemeComponent(); + var submitted = false; + + input.OnSubmitted = _ => submitted = true; + runtime.Add(container); + runtime.SetFocus(input); + + runtime.Start(); + container.Add(new ThirdPartyComposite(new Box(input), component)); + terminal.Receive("\t"); + + Assert.True(submitted); + Assert.Same(theme, component.Theme); + Assert.Equal(1, component.SetThemeCount); + } + [Fact] public void RuntimeInjectedEditorKeybindingsReachAutocompleteList() { @@ -391,6 +451,29 @@ public sealed class TuiRuntimeInputListenerTests } } + private sealed class ThirdPartyComposite(params IComponent[] children) : ICompositeComponent + { + public IReadOnlyList Children { get; } = children; + + public IReadOnlyList Render(int width) + { + var lines = new List(); + + foreach (var child in Children) + lines.AddRange(child.Render(width)); + + return lines; + } + + public void Invalidate() + { + foreach (var child in Children) + child.Invalidate(); + } + + public IEnumerable GetChildren() => Children; + } + private sealed class TestTheme(string name) : ITuiTheme { public string Cursor => $"{name}> ";