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:
@@ -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;这些应在输入/编辑能力后续增量中处理
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}> ";
|
||||
|
||||
Reference in New Issue
Block a user