From 7c254fd9d7fb2545b054e798e6cee70bfe75bbf2 Mon Sep 17 00:00:00 2001 From: chuan Date: Thu, 4 Jun 2026 03:47:00 +0800 Subject: [PATCH] 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 --- TODO.md | 24 +++++- src/TinyTUI/Components/Core/Container.cs | 53 ++++++++++--- .../Components/Core/ICompositeComponent.cs | 27 +++++++ src/TinyTUI/Runtime/TuiRuntime.cs | 24 +++--- .../Runtime/TuiRuntimeInputListenerTests.cs | 74 +++++++++++++++++++ 5 files changed, 177 insertions(+), 25 deletions(-) diff --git a/TODO.md b/TODO.md index b4d8b4a..6e47381 100644 --- a/TODO.md +++ b/TODO.md @@ -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;这些应在输入/编辑能力后续增量中处理 diff --git a/src/TinyTUI/Components/Core/Container.cs b/src/TinyTUI/Components/Core/Container.cs index e8a99e6..31ad53b 100644 --- a/src/TinyTUI/Components/Core/Container.cs +++ b/src/TinyTUI/Components/Core/Container.cs @@ -3,8 +3,11 @@ namespace TinyTUI.Components; /// /// 按添加顺序纵向渲染子组件的容器组件 /// -public class Container : ICompositeComponent +public class Container : IMutableCompositeComponent { + private event EventHandler? MutableChildAdded; + private event EventHandler? MutableChildRemoved; + /// /// 在子组件添加后触发 /// @@ -15,6 +18,20 @@ public class Container : ICompositeComponent /// public event EventHandler? ChildRemoved; + /// + event EventHandler? IMutableCompositeComponent.ChildAdded + { + add => MutableChildAdded += value; + remove => MutableChildAdded -= value; + } + + /// + event EventHandler? IMutableCompositeComponent.ChildRemoved + { + add => MutableChildRemoved += value; + remove => MutableChildRemoved -= value; + } + /// /// 获取当前子组件列表 /// @@ -26,7 +43,7 @@ public class Container : ICompositeComponent public void Add(IComponent component) { Children.Add(component); - ChildAdded?.Invoke(this, new ContainerChildChangedEventArgs(component)); + OnChildAdded(component); } /// @@ -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); } /// @@ -47,7 +64,7 @@ public class Container : ICompositeComponent Children.Clear(); foreach (var component in removed) - ChildRemoved?.Invoke(this, new ContainerChildChangedEventArgs(component)); + OnChildRemoved(component); } /// @@ -72,15 +89,29 @@ public class Container : ICompositeComponent child.Invalidate(); } } + + /// + /// 同时触发旧 Container 事件和通用可变复合组件事件 + /// + private void OnChildAdded(IComponent component) + { + var args = new ContainerChildChangedEventArgs(component); + ChildAdded?.Invoke(this, args); + MutableChildAdded?.Invoke(this, args); + } + + /// + /// 同时触发旧 Container 事件和通用可变复合组件事件 + /// + private void OnChildRemoved(IComponent component) + { + var args = new ContainerChildChangedEventArgs(component); + ChildRemoved?.Invoke(this, args); + MutableChildRemoved?.Invoke(this, args); + } } /// /// 表示容器子组件变化事件参数 /// -public sealed class ContainerChildChangedEventArgs(IComponent component) : EventArgs -{ - /// - /// 获取发生变化的子组件 - /// - public IComponent Component { get; } = component; -} +public sealed class ContainerChildChangedEventArgs(IComponent component) : CompositeComponentChildChangedEventArgs(component); diff --git a/src/TinyTUI/Components/Core/ICompositeComponent.cs b/src/TinyTUI/Components/Core/ICompositeComponent.cs index 9a26656..de17505 100644 --- a/src/TinyTUI/Components/Core/ICompositeComponent.cs +++ b/src/TinyTUI/Components/Core/ICompositeComponent.cs @@ -10,3 +10,30 @@ public interface ICompositeComponent : IComponent /// IEnumerable GetChildren(); } + +/// +/// 定义运行时可监听子组件动态变化的可变复合组件 +/// +public interface IMutableCompositeComponent : ICompositeComponent +{ + /// + /// 在子组件添加后触发 + /// + event EventHandler? ChildAdded; + + /// + /// 在子组件移除后触发 + /// + event EventHandler? ChildRemoved; +} + +/// +/// 表示复合组件子组件变化事件参数 +/// +public class CompositeComponentChildChangedEventArgs(IComponent component) : EventArgs +{ + /// + /// 获取发生变化的子组件 + /// + public IComponent Component { get; } = component; +} diff --git a/src/TinyTUI/Runtime/TuiRuntime.cs b/src/TinyTUI/Runtime/TuiRuntime.cs index 70e19e6..f4f5a2e 100644 --- a/src/TinyTUI/Runtime/TuiRuntime.cs +++ b/src/TinyTUI/Runtime/TuiRuntime.cs @@ -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 } /// - /// 处理已挂载容器的动态子组件 让新子树立即进入 Runtime 共享上下文 + /// 处理已挂载可变复合组件的动态子组件 让新子树立即进入 Runtime 共享上下文 /// - 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 } /// - /// 处理已挂载容器的动态子组件移除 避免卸载后的子树继续保留 Runtime 订阅 + /// 处理已挂载可变复合组件的动态子组件移除 避免卸载后的子树继续保留 Runtime 订阅 /// - 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 } /// - /// 获取事件来源容器当前 Runtime 挂载次数 + /// 获取事件来源组件当前 Runtime 挂载次数 /// private int GetRuntimeMountCount(object? sender) => sender is IComponent component && _runtimeComponentMountCounts.TryGetValue(component, out var mountCount) diff --git a/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs b/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs index b5eedb0..08034a5 100644 --- a/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs +++ b/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs @@ -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 GetChildren() => Children; } + private sealed class ThirdPartyMutableComposite : IMutableCompositeComponent + { + private readonly List _children = []; + + public event EventHandler? ChildAdded; + + public event EventHandler? ChildRemoved; + + public IReadOnlyList 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 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}> ";