Skip to content

Commit 042d43a

Browse files
committed
fix(desktop): refine focus reclaim logic to not steal from dialogs/routes
- Only reclaim focus when primary focus is truly idle: null, root scope, empty FocusScopeNode (no focused children), or a descendant of our scope. - Add _isEmptyFocusScope() to check if a FocusScopeNode has no focused children. - Add _isDescendantOfScopeFocus() to distinguish internal idle scopes from external scopes (dialogs, routes) that should retain focus. - Fixes issue where global shortcuts would steal focus from open dialogs/routes.
1 parent 779ba89 commit 042d43a

2 files changed

Lines changed: 39 additions & 5 deletions

File tree

app/lib/desktop/chat/keymap_scope.dart

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,51 @@ class _DesktopKeymapScopeState extends ConsumerState<DesktopKeymapScope> {
5959

6060
/// Re-grabs focus for [_scopeFocus] when no concrete widget holds it. A real
6161
/// focusable (text field, button) is a leaf [FocusNode]; when focus instead
62-
/// rests on a [FocusScopeNode] (or is null) nothing is truly focused, so we
63-
/// pull it back into the scope without stealing from any active widget.
62+
/// rests on a [FocusScopeNode] with no focused children (or is null) nothing
63+
/// is truly focused, so we pull it back into the scope without stealing from
64+
/// any active widget.
65+
///
66+
/// Focus is considered "idle" only when:
67+
/// 1. No widget has focus (primaryFocus is null)
68+
/// 2. Focus is on the framework root scope (empty focus)
69+
/// 3. Focus is on a [FocusScopeNode] that has no focused children (empty scope)
70+
/// 4. Focus is on a [FocusScopeNode] that is a descendant of our scope
71+
/// (internal idle scope like an empty [FocusScope] wrapper in our subtree)
72+
///
73+
/// We do NOT reclaim focus when a dialog, settings route, or other overlay
74+
/// holds focus, since those create their own focus scopes WITH focused children
75+
/// outside our subtree.
6476
void _reclaimFocusWhenIdle() {
6577
if (!mounted || !_scopeFocus.canRequestFocus) return;
6678
final primary = FocusManager.instance.primaryFocus;
67-
final idle = primary == null || primary is FocusScopeNode;
68-
if (idle && !_scopeFocus.hasPrimaryFocus) {
79+
final isIdle = primary == null ||
80+
primary == FocusManager.instance.rootScope ||
81+
_isEmptyFocusScope(primary) ||
82+
_isDescendantOfScopeFocus(primary);
83+
if (isIdle && !_scopeFocus.hasPrimaryFocus) {
6984
_scopeFocus.requestFocus();
7085
}
7186
}
7287

88+
/// Returns true if [node] is a [FocusScopeNode] with no focused children,
89+
/// indicating an empty focus scope (idle focus).
90+
bool _isEmptyFocusScope(FocusNode node) {
91+
if (node is! FocusScopeNode) return false;
92+
return node.focusedChild == null;
93+
}
94+
95+
/// Returns true if [node] is [_scopeFocus] or a descendant of it in the
96+
/// focus tree. Used to distinguish internal idle scopes from external scopes
97+
/// (dialogs, routes) that should not have focus stolen.
98+
bool _isDescendantOfScopeFocus(FocusNode node) {
99+
FocusNode? current = node;
100+
while (current != null) {
101+
if (current == _scopeFocus) return true;
102+
current = current.parent;
103+
}
104+
return false;
105+
}
106+
73107
@override
74108
Widget build(BuildContext context) {
75109
final keymap = ref.watch(keymapProvider);

app/test/desktop/keymap_scope_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,4 @@ void main() {
134134
await pressCtrl(tester, LogicalKeyboardKey.keyP);
135135
expect(opened, 1);
136136
});
137-
}
137+
}

0 commit comments

Comments
 (0)