fix: clear focus when removing focused component subtree

- detect focused components inside removed runtime subtrees

- clear stale focus for dynamic composite removals

- add runtime regression tests for removed focused children
This commit is contained in:
chuan
2026-06-04 03:52:37 +08:00
Unverified
parent 7c254fd9d7
commit 1bcb8d0b5e
3 changed files with 118 additions and 6 deletions
+19 -1
View File
@@ -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;这些应在输入/编辑能力后续增量中处理
+40 -5
View File
@@ -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);
}
/// <summary>
/// 移除组件子树前清理位于该子树内的焦点 避免输入继续派发到已卸载组件
/// </summary>
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);
}
/// <summary>
/// 判断组件子树是否包含目标组件 递归复用复合组件公开的子组件枚举契约
/// </summary>
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;
}
/// <summary>
/// 统一写入组件焦点状态 组件自身只根据状态决定是否输出光标 marker
/// </summary>
@@ -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);
@@ -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()
{