diff --git a/TODO.md b/TODO.md index 6e47381..0c44b5a 100644 --- a/TODO.md +++ b/TODO.md @@ -468,11 +468,29 @@ TinyTUI 现在已经具备最小可运行的 C# TUI 框架骨架:终端输入 - 动态添加和动态移除都走同一套 Runtime 挂载计数,根树和 overlay 多路径挂载时仍能避免重复 mount 或提前 unmount - C# 侧把“可枚举子树”和“可变子树事件”拆成两个接口,比参考实现依赖 `instanceof Container` 的递归判断更适合第三方组件扩展 +本次焦点生命周期修复推进: + +- `TuiRuntime.Remove` 和动态 `IMutableCompositeComponent.ChildRemoved` 路径会在卸载子树前递归检查当前焦点是否位于被移除子树内 +- 如果焦点命中被移除子树,Runtime 会优先切到当前最上层可聚焦 overlay;普通根树或动态容器移除则保守清空焦点 +- 子树检测复用 `ICompositeComponent.GetChildren()`,因此 `Container`、`Box` 和第三方复合组件都走同一套焦点清理逻辑 +- 新增 xUnit 回归测试,覆盖动态 `Container.Remove(focusedInput)`、`Container.Remove(boxContainingFocusedInput)` 和第三方 `IMutableCompositeComponent.Remove(subtree)` 后旧输入组件不再收到后续输入 + +本次为什么这样做: + +- 对比 `tmp/tui/src/tui.ts`,参考实现通过 `containsComponent` / `isComponentMounted` 辅助判断焦点目标是否仍处于组件树内;C# 侧已有通用复合组件枚举契约,因此把判断落在 Runtime 子树移除边界即可避免 stale focus +- 清理发生在 `UnmountRuntimeComponentTree` 前,是因为此时被移除子树仍可通过 `GetChildren()` 完整遍历,能可靠判断深层已聚焦子组件 +- 本轮不扩展完整 overlay blocked restore 机制,只在 overlay 场景使用现有 `_overlayManager.TopFocusableComponent` 作为安全 fallback,避免把焦点生命周期修复扩大成多层 overlay 重构 + +本次当前更好的点: + +- C# 侧焦点清理支持第三方 `ICompositeComponent` / `IMutableCompositeComponent`,不依赖参考实现中的 `instanceof Container` +- Runtime 统一在根移除和动态复合移除两条路径处理焦点,后续输入派发入口不需要每次再判断焦点组件是否仍挂载 +- 保守清空焦点能确保已卸载组件不会再接收输入,比继续保留显式 `SetFocus` 语义更安全 + 后续仍需补齐: - Runtime 目前不会自动 `Dispose` 实现 `IDisposable` 的用户组件;后续需要明确所有权策略,比如仅 Dispose Runtime 自己创建的组件,或提供可选自动释放策略 - `IMutableCompositeComponent` 目前只表达直接子组件增删,没有批量变更、移动、替换或变更事务;如果后续出现高频动态列表,需要补批量事件或挂载 diff 策略减少重复注入 -- Runtime 对动态移除的已聚焦子组件暂未自动清焦点或重定向焦点;当前保持现有显式 `SetFocus` 语义,后续可结合 overlay 焦点恢复策略统一处理 - `SettingsList` 还没有移植,参考实现里的搜索、描述换行、值循环、submenu 委托和主题分区仍需单独推进 - `ITuiTheme` 当前仍是轻量样式接口,还没有 theme version、分区主题、Markdown 完整主题或控件级样式覆盖策略 - `Input` 目前仍是尾部输入模型,还没有参考实现里的水平滚动、按 grapheme 移动、kill ring 和 undo;这些应在输入/编辑能力后续增量中处理 diff --git a/src/TinyTUI/Runtime/TuiRuntime.cs b/src/TinyTUI/Runtime/TuiRuntime.cs index f4f5a2e..7b243eb 100644 --- a/src/TinyTUI/Runtime/TuiRuntime.cs +++ b/src/TinyTUI/Runtime/TuiRuntime.cs @@ -104,14 +104,11 @@ public sealed class TuiRuntime : ITuiRuntime if (!_root.Children.Contains(component)) return; + ClearFocusIfRemovedSubtreeContainsFocus(component); UnmountRuntimeComponentTree(component); _root.Remove(component); - if (_focusedComponent == component) - SetFocus(null); - else - component.Invalidate(); - + component.Invalidate(); RequestRender(); } @@ -338,6 +335,42 @@ public sealed class TuiRuntime : ITuiRuntime SetFocus(_overlayManager.TopFocusableComponent ?? restoreFocus); } + /// + /// 移除组件子树前清理位于该子树内的焦点 避免输入继续派发到已卸载组件 + /// + private void ClearFocusIfRemovedSubtreeContainsFocus(IComponent removedSubtree) + { + if (_focusedComponent is null || !ComponentTreeContains(removedSubtree, _focusedComponent)) + return; + + // overlay 场景优先交还给当前最上层可聚焦 overlay 其余普通动态移除保守清空焦点 + var nextFocus = _overlayManager.TopFocusableComponent; + if (ReferenceEquals(nextFocus, _focusedComponent) || ComponentTreeContains(removedSubtree, nextFocus)) + nextFocus = null; + + SetFocus(nextFocus); + } + + /// + /// 判断组件子树是否包含目标组件 递归复用复合组件公开的子组件枚举契约 + /// + private static bool ComponentTreeContains(IComponent root, IComponent? target) + { + if (target is null) + return false; + + if (ReferenceEquals(root, target)) + return true; + + foreach (var child in EnumerateRuntimeChildren(root)) + { + if (ComponentTreeContains(child, target)) + return true; + } + + return false; + } + /// /// 统一写入组件焦点状态 组件自身只根据状态决定是否输出光标 marker /// @@ -483,6 +516,8 @@ public sealed class TuiRuntime : ITuiRuntime { var parentMountCount = GetRuntimeMountCount(sender); + ClearFocusIfRemovedSubtreeContainsFocus(args.Component); + for (var index = 0; index < parentMountCount; index++) UnmountRuntimeComponentTree(args.Component); diff --git a/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs b/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs index 08034a5..3a1d10b 100644 --- a/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs +++ b/test/TinyTUI.Tests/Runtime/TuiRuntimeInputListenerTests.cs @@ -320,6 +320,65 @@ public sealed class TuiRuntimeInputListenerTests Assert.Same(keybindings, contextComponent.LastMountedContext?.Keybindings); } + [Fact] + public void RemovingFocusedDynamicContainerChildClearsFocus() + { + var terminal = new TestTerminalSession(); + using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer()); + var container = new Container(); + var input = new RecordingInputComponent(); + + runtime.Add(container); + container.Add(input); + runtime.SetFocus(input); + + runtime.Start(); + container.Remove(input); + terminal.Receive("a"); + + Assert.Empty(input.Inputs); + } + + [Fact] + public void RemovingBoxContainingFocusedChildClearsFocus() + { + var terminal = new TestTerminalSession(); + using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer()); + var container = new Container(); + var input = new RecordingInputComponent(); + var box = new Box(input); + + runtime.Add(container); + container.Add(box); + runtime.SetFocus(input); + + runtime.Start(); + container.Remove(box); + terminal.Receive("a"); + + Assert.Empty(input.Inputs); + } + + [Fact] + public void RemovingThirdPartyMutableCompositeSubtreeContainingFocusedChildClearsFocus() + { + var terminal = new TestTerminalSession(); + using var runtime = new TuiRuntime(terminal, new DefaultInputParser(), new CountingRenderer()); + var composite = new ThirdPartyMutableComposite(); + var input = new RecordingInputComponent(); + var subtree = new ThirdPartyComposite(new Box(input)); + + runtime.Add(composite); + composite.Add(subtree); + runtime.SetFocus(input); + + runtime.Start(); + composite.Remove(subtree); + terminal.Receive("a"); + + Assert.Empty(input.Inputs); + } + [Fact] public void RuntimeInjectedEditorKeybindingsReachAutocompleteList() {