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
+59 -5
View File
@@ -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]);
}
}
+15
View File
@@ -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(),