feat: add mutable composite component lifecycle contract

- add generic child change events for dynamic composite components

- update runtime mount tracking to subscribe through the common contract

- cover third-party dynamic composite keybinding theme and context injection
This commit is contained in:
chuan
2026-06-04 03:47:00 +08:00
Unverified
parent 3154cc4ebf
commit 7c254fd9d7
5 changed files with 177 additions and 25 deletions
+22 -2
View File
@@ -448,11 +448,31 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入
- 上下文是 Runtime 实例级快照,多 Runtime 测试和多终端实例可以各自隔离 keybinding、theme、终端会话和取消令牌
- 重复挂载计数让同一组件可以同时出现在根树和 overlay,避免一次隐藏 overlay 就提前触发卸载或取消仍在根树中的组件
本次可变复合组件动态契约推进:
- 新增 `IMutableCompositeComponent``CompositeComponentChildChangedEventArgs`,把动态子组件 `ChildAdded` / `ChildRemoved``Container` 专属能力抽成通用复合组件契约
- `Container` 实现通用可变复合组件契约,同时保留原有 `ContainerChildChangedEventArgs` 和公开事件类型,避免旧调用方因为事件参数类型变化断裂
- `TuiRuntime` 的挂载上下文不再特判 `Container`,改为订阅所有 `IMutableCompositeComponent`,动态添加的第三方子树会自动获得 keybinding、theme 和 runtime context 注入
- 动态移除第三方可变复合组件子树时会复用同一条 `UnmountRuntimeComponentTree` 路径,触发 `IRuntimeContextComponent.OnUnmounted` 并解除子树内动态事件订阅
- 新增 xUnit 回归测试,用自定义第三方可变复合组件覆盖动态添加后快捷键提交、主题注入、Runtime 上下文挂载,以及动态移除后的 unmount 通知
本次为什么这样做:
- 对比 `tmp/tui/src/tui.ts`,参考实现的 `TUI extends Container` 让根容器动态增删天然影响渲染,但它没有给第三方可变复合组件提供独立生命周期事件;C# 侧 Runtime 是组合式根树,需要显式事件契约才能把 Runtime 级上下文传播到任意动态子树
- `ICompositeComponent` 继续只负责静态子树枚举,`IMutableCompositeComponent` 只给会动态增删子组件的类型实现,避免让固定结构组件如 `Box` 承担不需要的事件协议
- `Container` 使用显式接口事件接入通用契约,是为了让 Runtime 看到统一事件类型,同时保留原公开 API 的兼容性
本次当前更好的点:
- 第三方可变复合组件只要实现 `IMutableCompositeComponent`,无需继承 `Container` 或让 Runtime 认识具体类型,也能获得完整挂载上下文
- 动态添加和动态移除都走同一套 Runtime 挂载计数,根树和 overlay 多路径挂载时仍能避免重复 mount 或提前 unmount
- C# 侧把“可枚举子树”和“可变子树事件”拆成两个接口,比参考实现依赖 `instanceof Container` 的递归判断更适合第三方组件扩展
后续仍需补齐:
- `ICompositeComponent` 目前只定义直接子组件枚举,还没有定义子组件变化事件;除 `Container` 外的可变复合组件如果后续出现,需要新增通用动态子树变更契约
- Runtime 目前不会自动 `Dispose` 实现 `IDisposable` 的用户组件;后续需要明确所有权策略,比如仅 Dispose Runtime 自己创建的组件,或提供可选自动释放策略
- `Container` 外的可变复合组件仍没有通用子组件变化事件,后续可以把 `ChildAdded` / `ChildRemoved` 抽成接口并复用同一生命周期挂载上下文
- `IMutableCompositeComponent` 目前只表达直接子组件增删,没有批量变更、移动、替换或变更事务;如果后续出现高频动态列表,需要补批量事件或挂载 diff 策略减少重复注入
- Runtime 对动态移除的已聚焦子组件暂未自动清焦点或重定向焦点;当前保持现有显式 `SetFocus` 语义,后续可结合 overlay 焦点恢复策略统一处理
- `SettingsList` 还没有移植,参考实现里的搜索、描述换行、值循环、submenu 委托和主题分区仍需单独推进
- `ITuiTheme` 当前仍是轻量样式接口,还没有 theme version、分区主题、Markdown 完整主题或控件级样式覆盖策略
- `Input` 目前仍是尾部输入模型,还没有参考实现里的水平滚动、按 grapheme 移动、kill ring 和 undo;这些应在输入/编辑能力后续增量中处理
+42 -11
View File
@@ -3,8 +3,11 @@ namespace TinyTUI.Components;
/// <summary>
/// 按添加顺序纵向渲染子组件的容器组件
/// </summary>
public class Container : ICompositeComponent
public class Container : IMutableCompositeComponent
{
private event EventHandler<CompositeComponentChildChangedEventArgs>? MutableChildAdded;
private event EventHandler<CompositeComponentChildChangedEventArgs>? MutableChildRemoved;
/// <summary>
/// 在子组件添加后触发
/// </summary>
@@ -15,6 +18,20 @@ public class Container : ICompositeComponent
/// </summary>
public event EventHandler<ContainerChildChangedEventArgs>? ChildRemoved;
/// <inheritdoc />
event EventHandler<CompositeComponentChildChangedEventArgs>? IMutableCompositeComponent.ChildAdded
{
add => MutableChildAdded += value;
remove => MutableChildAdded -= value;
}
/// <inheritdoc />
event EventHandler<CompositeComponentChildChangedEventArgs>? IMutableCompositeComponent.ChildRemoved
{
add => MutableChildRemoved += value;
remove => MutableChildRemoved -= value;
}
/// <summary>
/// 获取当前子组件列表
/// </summary>
@@ -26,7 +43,7 @@ public class Container : ICompositeComponent
public void Add(IComponent component)
{
Children.Add(component);
ChildAdded?.Invoke(this, new ContainerChildChangedEventArgs(component));
OnChildAdded(component);
}
/// <summary>
@@ -35,7 +52,7 @@ public class Container : ICompositeComponent
public void Remove(IComponent component)
{
if (Children.Remove(component))
ChildRemoved?.Invoke(this, new ContainerChildChangedEventArgs(component));
OnChildRemoved(component);
}
/// <summary>
@@ -47,7 +64,7 @@ public class Container : ICompositeComponent
Children.Clear();
foreach (var component in removed)
ChildRemoved?.Invoke(this, new ContainerChildChangedEventArgs(component));
OnChildRemoved(component);
}
/// <inheritdoc />
@@ -72,15 +89,29 @@ public class Container : ICompositeComponent
child.Invalidate();
}
}
/// <summary>
/// 同时触发旧 Container 事件和通用可变复合组件事件
/// </summary>
private void OnChildAdded(IComponent component)
{
var args = new ContainerChildChangedEventArgs(component);
ChildAdded?.Invoke(this, args);
MutableChildAdded?.Invoke(this, args);
}
/// <summary>
/// 同时触发旧 Container 事件和通用可变复合组件事件
/// </summary>
private void OnChildRemoved(IComponent component)
{
var args = new ContainerChildChangedEventArgs(component);
ChildRemoved?.Invoke(this, args);
MutableChildRemoved?.Invoke(this, args);
}
}
/// <summary>
/// 表示容器子组件变化事件参数
/// </summary>
public sealed class ContainerChildChangedEventArgs(IComponent component) : EventArgs
{
/// <summary>
/// 获取发生变化的子组件
/// </summary>
public IComponent Component { get; } = component;
}
public sealed class ContainerChildChangedEventArgs(IComponent component) : CompositeComponentChildChangedEventArgs(component);
@@ -10,3 +10,30 @@ public interface ICompositeComponent : IComponent
/// </summary>
IEnumerable<IComponent> GetChildren();
}
/// <summary>
/// 定义运行时可监听子组件动态变化的可变复合组件
/// </summary>
public interface IMutableCompositeComponent : ICompositeComponent
{
/// <summary>
/// 在子组件添加后触发
/// </summary>
event EventHandler<CompositeComponentChildChangedEventArgs>? ChildAdded;
/// <summary>
/// 在子组件移除后触发
/// </summary>
event EventHandler<CompositeComponentChildChangedEventArgs>? ChildRemoved;
}
/// <summary>
/// 表示复合组件子组件变化事件参数
/// </summary>
public class CompositeComponentChildChangedEventArgs(IComponent component) : EventArgs
{
/// <summary>
/// 获取发生变化的子组件
/// </summary>
public IComponent Component { get; } = component;
}
+12 -12
View File
@@ -356,11 +356,11 @@ public sealed class TuiRuntime : ITuiRuntime
_runtimeComponentMountCounts[component] = isFirstMount ? 1 : mountCount + 1;
ApplyRuntimeContext(component);
if (isFirstMount && component is Container mountedContainer)
if (isFirstMount && component is IMutableCompositeComponent mutableCompositeComponent)
{
// 只有首次进入 Runtime 时订阅事件 但下面仍会按每条挂载路径递归计数子树
mountedContainer.ChildAdded += OnRuntimeContainerChildAdded;
mountedContainer.ChildRemoved += OnRuntimeContainerChildRemoved;
mutableCompositeComponent.ChildAdded += OnRuntimeCompositeChildAdded;
mutableCompositeComponent.ChildRemoved += OnRuntimeCompositeChildRemoved;
}
if (isFirstMount && component is IRuntimeContextComponent runtimeContextComponent)
@@ -384,10 +384,10 @@ public sealed class TuiRuntime : ITuiRuntime
{
_runtimeComponentMountCounts.Remove(component);
if (component is Container mountedContainer)
if (component is IMutableCompositeComponent mutableCompositeComponent)
{
mountedContainer.ChildAdded -= OnRuntimeContainerChildAdded;
mountedContainer.ChildRemoved -= OnRuntimeContainerChildRemoved;
mutableCompositeComponent.ChildAdded -= OnRuntimeCompositeChildAdded;
mutableCompositeComponent.ChildRemoved -= OnRuntimeCompositeChildRemoved;
}
if (component is IRuntimeContextComponent runtimeContextComponent)
@@ -462,13 +462,13 @@ public sealed class TuiRuntime : ITuiRuntime
}
/// <summary>
/// 处理已挂载容器的动态子组件 让新子树立即进入 Runtime 共享上下文
/// 处理已挂载可变复合组件的动态子组件 让新子树立即进入 Runtime 共享上下文
/// </summary>
private void OnRuntimeContainerChildAdded(object? sender, ContainerChildChangedEventArgs args)
private void OnRuntimeCompositeChildAdded(object? sender, CompositeComponentChildChangedEventArgs args)
{
var parentMountCount = GetRuntimeMountCount(sender);
// 同一个 Container 可能同时出现在根树和 overlay 中 动态子树需要继承父容器的挂载次数
// 同一个可变复合组件可能同时出现在根树和 overlay 中 动态子树需要继承父组件的挂载次数
for (var index = 0; index < parentMountCount; index++)
MountRuntimeComponentTree(args.Component);
@@ -477,9 +477,9 @@ public sealed class TuiRuntime : ITuiRuntime
}
/// <summary>
/// 处理已挂载容器的动态子组件移除 避免卸载后的子树继续保留 Runtime 订阅
/// 处理已挂载可变复合组件的动态子组件移除 避免卸载后的子树继续保留 Runtime 订阅
/// </summary>
private void OnRuntimeContainerChildRemoved(object? sender, ContainerChildChangedEventArgs args)
private void OnRuntimeCompositeChildRemoved(object? sender, CompositeComponentChildChangedEventArgs args)
{
var parentMountCount = GetRuntimeMountCount(sender);
@@ -491,7 +491,7 @@ public sealed class TuiRuntime : ITuiRuntime
}
/// <summary>
/// 获取事件来源容器当前 Runtime 挂载次数
/// 获取事件来源组件当前 Runtime 挂载次数
/// </summary>
private int GetRuntimeMountCount(object? sender)
=> sender is IComponent component && _runtimeComponentMountCounts.TryGetValue(component, out var mountCount)
@@ -287,6 +287,39 @@ public sealed class TuiRuntimeInputListenerTests
Assert.Equal(1, component.SetThemeCount);
}
[Fact]
public void RuntimeInjectsSharedContextIntoDynamicMutableCompositeChildren()
{
var terminal = new TestTerminalSession();
var theme = new TestTheme("third-party-mutable");
var keybindings = KeybindingRegistry.CreateDefault();
keybindings.SetUserBinding(TuiKeybindings.InputSubmit, KeyNames.Tab);
using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer(), keybindings, theme);
var composite = new ThirdPartyMutableComposite();
var input = new TinyTUI.Components.Input();
var themeComponent = new RecordingThemeComponent();
var contextComponent = new RecordingRuntimeContextComponent();
var submitted = false;
input.OnSubmitted = _ => submitted = true;
runtime.Add(composite);
runtime.SetFocus(input);
runtime.Start();
composite.Add(new ThirdPartyComposite(new Box(input), themeComponent, contextComponent));
terminal.Receive("\t");
composite.Remove(composite.Children[0]);
Assert.True(submitted);
Assert.Same(theme, themeComponent.Theme);
Assert.Equal(1, themeComponent.SetThemeCount);
Assert.Equal(1, contextComponent.MountedCount);
Assert.Equal(1, contextComponent.UnmountedCount);
Assert.Same(runtime, contextComponent.LastMountedContext?.Runtime);
Assert.Same(theme, contextComponent.LastMountedContext?.Theme);
Assert.Same(keybindings, contextComponent.LastMountedContext?.Keybindings);
}
[Fact]
public void RuntimeInjectedEditorKeybindingsReachAutocompleteList()
{
@@ -618,6 +651,47 @@ public sealed class TuiRuntimeInputListenerTests
public IEnumerable<IComponent> GetChildren() => Children;
}
private sealed class ThirdPartyMutableComposite : IMutableCompositeComponent
{
private readonly List<IComponent> _children = [];
public event EventHandler<CompositeComponentChildChangedEventArgs>? ChildAdded;
public event EventHandler<CompositeComponentChildChangedEventArgs>? ChildRemoved;
public IReadOnlyList<IComponent> Children => _children;
public void Add(IComponent component)
{
_children.Add(component);
ChildAdded?.Invoke(this, new CompositeComponentChildChangedEventArgs(component));
}
public void Remove(IComponent component)
{
if (_children.Remove(component))
ChildRemoved?.Invoke(this, new CompositeComponentChildChangedEventArgs(component));
}
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}> ";