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:
+116
-48
@@ -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,
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import 'package:clide/kernel/src/panels/slot_id.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Tracks the currently focused contribution (tab id + slot). Backs
|
||||
/// `clide active`; extensions that need "which tab does the user care
|
||||
/// about right now?" read from here instead of poking Flutter's
|
||||
/// FocusScope directly.
|
||||
/// Tracks the currently focused contribution (tab id + slot) and
|
||||
/// hosts a registry of per-slot [FocusScopeNode]s for panel-to-panel
|
||||
/// focus traversal.
|
||||
///
|
||||
/// Backs `clide active` (extensions read activeSlot / activeContributionId)
|
||||
/// and the `FocusNextPanelIntent` / `FocusPreviousPanelIntent` actions
|
||||
/// the keymap dispatches on F6 / Shift+F6.
|
||||
class FocusTracker extends ChangeNotifier {
|
||||
SlotId? _slot;
|
||||
String? _contributionId;
|
||||
final Map<SlotId, FocusScopeNode> _scopes = {};
|
||||
|
||||
/// Static order panels cycle through. Matches the visual left-to-right
|
||||
/// of the three-column layout (sidebar → workspace → context); the
|
||||
/// statusbar / toolbar aren't included because they don't host
|
||||
/// keyboard-active content.
|
||||
static const List<SlotId> traversalOrder = [
|
||||
Slots.sidebar,
|
||||
Slots.workspace,
|
||||
Slots.contextPanel,
|
||||
];
|
||||
|
||||
SlotId? get activeSlot => _slot;
|
||||
String? get activeContributionId => _contributionId;
|
||||
@@ -25,4 +39,44 @@ class FocusTracker extends ChangeNotifier {
|
||||
_contributionId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// SlotHost calls this in initState. Replaces any prior registration
|
||||
/// for the same slot (handles hot-reload + slot rebuild).
|
||||
void registerSlotScope(SlotId slot, FocusScopeNode scope) {
|
||||
_scopes[slot] = scope;
|
||||
}
|
||||
|
||||
/// SlotHost calls this in dispose. No-op if a newer scope already
|
||||
/// took the slot.
|
||||
void unregisterSlotScope(SlotId slot, FocusScopeNode scope) {
|
||||
if (identical(_scopes[slot], scope)) {
|
||||
_scopes.remove(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/// Request focus on [slot]'s registered scope. No-op when the slot
|
||||
/// has no registered scope (panel not mounted, layout doesn't
|
||||
/// include it, etc.).
|
||||
void focusSlot(SlotId slot) {
|
||||
final scope = _scopes[slot];
|
||||
if (scope == null) return;
|
||||
scope.requestFocus();
|
||||
}
|
||||
|
||||
/// Move focus to the next slot in [traversalOrder], wrapping at the
|
||||
/// end. No-op when fewer than two slots are registered.
|
||||
void focusNextSlot() => _cycleSlot(1);
|
||||
|
||||
/// Move focus to the previous slot, wrapping at the start.
|
||||
void focusPreviousSlot() => _cycleSlot(-1);
|
||||
|
||||
void _cycleSlot(int delta) {
|
||||
final registered = traversalOrder.where(_scopes.containsKey).toList();
|
||||
if (registered.length < 2) return;
|
||||
final from = registered.indexOf(_slot ?? registered.first);
|
||||
final start = from < 0 ? 0 : from;
|
||||
final next = (start + delta) % registered.length;
|
||||
final wrapped = next < 0 ? next + registered.length : next;
|
||||
focusSlot(registered[wrapped]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,19 @@ library;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
// -- Panel-to-panel focus traversal -----------------------------------------
|
||||
|
||||
/// Move focus to the next panel in `FocusTracker.traversalOrder`
|
||||
/// (sidebar → workspace → context). Bound to F6 by default.
|
||||
class FocusNextPanelIntent extends Intent {
|
||||
const FocusNextPanelIntent();
|
||||
}
|
||||
|
||||
/// Move focus to the previous panel. Bound to Shift+F6 by default.
|
||||
class FocusPreviousPanelIntent extends Intent {
|
||||
const FocusPreviousPanelIntent();
|
||||
}
|
||||
|
||||
// -- Command palette --------------------------------------------------------
|
||||
|
||||
/// Open the command palette.
|
||||
@@ -76,6 +89,8 @@ class InvokeCommandIntent extends Intent {
|
||||
final Map<String, Intent Function()> builtinIntents = {
|
||||
'activate': () => const ActivateIntent(),
|
||||
'dismiss': () => const DismissIntent(),
|
||||
'focus.nextPanel': () => const FocusNextPanelIntent(),
|
||||
'focus.previousPanel': () => const FocusPreviousPanelIntent(),
|
||||
'palette.open': () => const PaletteOpenIntent(),
|
||||
'palette.selectNext': () => const PaletteSelectNextIntent(),
|
||||
'palette.selectPrevious': () => const PaletteSelectPreviousIntent(),
|
||||
|
||||
Reference in New Issue
Block a user