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
This commit is contained in:
chuan
2026-06-04 03:32:19 +08:00
Unverified
parent d877612354
commit 49a234ba7c
6 changed files with 140 additions and 23 deletions
+21
View File
@@ -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;这些应在输入/编辑能力后续增量中处理
+7 -1
View File
@@ -5,7 +5,7 @@ namespace TinyTUI.Components;
/// <summary>
/// 为子组件渲染简单边框的容器组件
/// </summary>
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;
}
/// <inheritdoc />
public IEnumerable<IComponent> GetChildren()
{
yield return Child;
}
/// <inheritdoc />
public void Invalidate()
{
+4 -1
View File
@@ -3,7 +3,7 @@ namespace TinyTUI.Components;
/// <summary>
/// 按添加顺序纵向渲染子组件的容器组件
/// </summary>
public class Container : IComponent
public class Container : ICompositeComponent
{
/// <summary>
/// 在子组件添加后触发
@@ -61,6 +61,9 @@ public class Container : IComponent
return lines;
}
/// <inheritdoc />
public IEnumerable<IComponent> GetChildren() => Children;
/// <inheritdoc />
public virtual void Invalidate()
{
@@ -0,0 +1,12 @@
namespace TinyTUI.Components;
/// <summary>
/// 定义可以向 Runtime 暴露直接子组件的复合组件
/// </summary>
public interface ICompositeComponent : IComponent
{
/// <summary>
/// 枚举当前组件直接包含的子组件
/// </summary>
IEnumerable<IComponent> GetChildren();
}
+13 -21
View File
@@ -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);
}
/// <summary>
@@ -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);
}
/// <summary>
/// 通过复合组件契约枚举 Runtime 生命周期需要递归处理的直接子组件
/// </summary>
private static IEnumerable<IComponent> EnumerateRuntimeChildren(IComponent component)
=> component is ICompositeComponent compositeComponent
? compositeComponent.GetChildren()
: [];
/// <summary>
/// 将 Runtime 级共享快捷键注册表写入单个支持组件
/// </summary>
@@ -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<IComponent> Children { get; } = children;
public IReadOnlyList<string> Render(int width)
{
var lines = new List<string>();
foreach (var child in Children)
lines.AddRange(child.Render(width));
return lines;
}
public void Invalidate()
{
foreach (var child in Children)
child.Invalidate();
}
public IEnumerable<IComponent> GetChildren() => Children;
}
private sealed class TestTheme(string name) : ITuiTheme
{
public string Cursor => $"{name}> ";