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