panel-to-panel focus traversal via F6 / Shift+F6 (T-105)

Each SlotHost now owns a FocusScopeNode and registers it with
FocusTracker on mount. The render is wrapped in
FocusScope + FocusTraversalGroup so Tab stays within a panel and
slot-level focus is observable.

When a slot's scope gains focus, SlotHost pushes
(slot, activeContributionId) to FocusTracker — this collapses the
parallel-tracker model the consultant flagged. FocusTracker keeps
its setActive surface for explicit callers (palette, etc.) but
slot-scoped tab activation feeds it automatically.

Two new intents, two new bindings:
  FocusNextPanelIntent     → F6
  FocusPreviousPanelIntent → Shift+F6
(VS Code convention; preset YAML.)

The cycle skips slots without a registered scope, so a layout that
hides the context panel doesn't strand focus on a missing target.
Fewer than two registered → no-op.

SlotHost split into a stateful outer (scope + registry) and a
stateless `_SlotBody` (the existing slot-specific rendering),
keeping the build straightforward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 21:56:35 +02:00
co-authored by Claude Opus 4.7
parent 12e0509fa3
commit 7937da1734
8 changed files with 278 additions and 53 deletions
+116 -48
View File
@@ -119,6 +119,18 @@ class _RootShellState extends State<_RootShell> {
return null;
},
),
FocusNextPanelIntent: CallbackAction<FocusNextPanelIntent>(
onInvoke: (_) {
widget.services.focus.focusNextSlot();
return null;
},
),
FocusPreviousPanelIntent: CallbackAction<FocusPreviousPanelIntent>(
onInvoke: (_) {
widget.services.focus.focusPreviousSlot();
return null;
},
),
},
child: KeyboardListener(
focusNode: _keyFocus,
@@ -736,66 +748,122 @@ class _NotARepoDialog extends StatelessWidget {
}
}
class SlotHost extends StatelessWidget {
class SlotHost extends StatefulWidget {
const SlotHost({super.key, required this.slot});
final SlotId slot;
@override
State<SlotHost> createState() => _SlotHostState();
}
class _SlotHostState extends State<SlotHost> {
late final FocusScopeNode _scope = FocusScopeNode(debugLabel: 'SlotScope:${widget.slot.value}');
FocusTracker? _tracker;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final kernel = ClideKernel.of(context);
if (!identical(_tracker, kernel.focus)) {
_tracker?.unregisterSlotScope(widget.slot, _scope);
_tracker = kernel.focus;
_tracker!.registerSlotScope(widget.slot, _scope);
}
}
@override
void dispose() {
_tracker?.unregisterSlotScope(widget.slot, _scope);
_scope.dispose();
super.dispose();
}
void _onFocusChange(bool hasFocus) {
if (!hasFocus || _tracker == null) return;
final kernel = ClideKernel.of(context);
final activeId = kernel.panels.activeTabIn(widget.slot);
if (activeId != null) {
_tracker!.setActive(slot: widget.slot, contributionId: activeId);
}
}
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: Listenable.merge([kernel.panels, kernel.i18n]),
builder: (ctx, _) {
final tabs = kernel.panels.tabsFor(slot);
if (tabs.isEmpty) {
return Container(color: tokens.panelBackground);
}
final activeId = kernel.panels.activeTabIn(slot) ?? tabs.first.id;
final active = tabs.firstWhere(
(t) => t.id == activeId,
orElse: () => tabs.first,
);
return FocusScope(
node: _scope,
onFocusChange: _onFocusChange,
child: FocusTraversalGroup(
child: ListenableBuilder(
listenable: Listenable.merge([kernel.panels, kernel.i18n]),
builder: (ctx, _) {
final tabs = kernel.panels.tabsFor(widget.slot);
if (tabs.isEmpty) {
return Container(color: tokens.panelBackground);
}
final activeId = kernel.panels.activeTabIn(widget.slot) ?? tabs.first.id;
final active = tabs.firstWhere(
(t) => t.id == activeId,
orElse: () => tabs.first,
);
return _SlotBody(slot: widget.slot, tabs: tabs, active: active, activeId: activeId);
},
),
),
);
}
}
if (slot == Slots.sidebar) {
return _SidebarSlot(
tabs: tabs,
active: active,
activeId: activeId,
onSelect: (id) => kernel.panels.activateTab(slot, id),
);
}
class _SlotBody extends StatelessWidget {
const _SlotBody({required this.slot, required this.tabs, required this.active, required this.activeId});
final SlotId slot;
final List<TabContribution> tabs;
final TabContribution active;
final String activeId;
if (slot == Slots.contextPanel) {
return _ContextSlot(
tabs: tabs,
active: active,
activeId: activeId,
onSelect: (id) => kernel.panels.activateTab(slot, id),
);
}
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
if (slot == Slots.workspace) {
return _WorkspaceSlot(tabs: tabs, active: active);
}
if (slot == Slots.sidebar) {
return _SidebarSlot(
tabs: tabs,
active: active,
activeId: activeId,
onSelect: (id) => kernel.panels.activateTab(slot, id),
);
}
return Container(
color: tokens.panelBackground,
child: Column(
children: [
ClideTabBar(
items: [
for (final t in tabs) ClideTabItem(id: t.id, title: _resolveTitle(ctx, t)),
],
activeId: active.id,
onSelect: (id) => kernel.panels.activateTab(slot, id),
),
ClideDivider(),
Expanded(child: active.build(ctx)),
if (slot == Slots.contextPanel) {
return _ContextSlot(
tabs: tabs,
active: active,
activeId: activeId,
onSelect: (id) => kernel.panels.activateTab(slot, id),
);
}
if (slot == Slots.workspace) {
return _WorkspaceSlot(tabs: tabs, active: active);
}
return Container(
color: tokens.panelBackground,
child: Column(
children: [
ClideTabBar(
items: [
for (final t in tabs) ClideTabItem(id: t.id, title: _resolveTitle(context, t)),
],
activeId: active.id,
onSelect: (id) => kernel.panels.activateTab(slot, id),
),
);
},
ClideDivider(),
Expanded(child: active.build(context)),
],
),
);
}
@@ -975,7 +1043,7 @@ class _BottomRail extends StatelessWidget {
ClideIconRailItem(
id: t.id,
icon: _iconFor(slot, t),
tooltip: SlotHost._resolveTitle(ctx, t),
tooltip: _SlotBody._resolveTitle(ctx, t),
),
],
activeId: activeId,