Files
clide/lib/src/shell/slot_host.dart
T
jpmschweitzerandClaude Opus 4.8 18cd945380 refactor(theme): route consumer reads through ClideSettings.theme (T-473)
Migrate ClideTheme.of(context) reads across the widget, feature, and shell
layers to the unified ClideSettings.theme.of(context) facade (D-101), so
theme/i18n/fonts/settings share one widget-facing entry. The facade delegates
straight to ClideTheme, so behaviour is unchanged — goldens are unmoved.

The low-level theme provider keeps its direct ClideTheme.of: the facade is
built on it, and the two kernel sites (ClideTheme's own definition + the
panels drag-resize widget) stay direct to avoid a widgets→kernel import cycle.
Dead controller.dart/kernel.dart imports left by the sweep removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:47:15 +02:00

373 lines
13 KiB
Dart

/// Slot hosting: mounts a slot's tab contributions, integrates focus
/// scopes, and renders the slot-specific bodies (sidebar / workspace
/// split incl. the editor drag handle / context). Split out of
/// app.dart (T-394).
library;
import 'package:clide/extension/src/contribution.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
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 = ClideSettings.theme.of(context).surface;
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);
},
),
),
);
}
}
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;
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideSettings.theme.of(context).surface;
if (slot == Slots.sidebar) {
return _SidebarSlot(tabs: tabs, active: active, activeId: activeId, onSelect: (id) => kernel.panels.activateTab(slot, id));
}
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: resolveTabTitle(context, t))],
activeId: active.id,
onSelect: (id) => kernel.panels.activateTab(slot, id),
),
ClideDivider(),
Expanded(child: active.build(context)),
],
),
);
}
}
class _SidebarSlot extends StatelessWidget {
const _SidebarSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect});
final List<TabContribution> tabs;
final TabContribution active;
final String activeId;
final ValueChanged<String> onSelect;
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
return Container(
color: tokens.chromeBackground,
alignment: Alignment.topLeft,
padding: const EdgeInsets.fromLTRB(2, 2, 0, 0),
child: active.build(context),
);
}
}
// Stable identity for the workspace's primary pane (Claude). Opening the
// editor reparents it from a direct child into a Column/Expanded; without a
// stable key Flutter disposes + rebuilds the subtree, and the Claude
// conversation's SelectableRegion then runs a pending selection update
// against now-inactive elements ("selectable not in this registrar" /
// "renderObject of inactive element"). The GlobalKey makes Flutter MOVE the
// element instead, preserving the selection subtree.
final GlobalKey _kWorkspacePrimary = GlobalKey(debugLabel: 'workspace.primary');
class _WorkspaceSlot extends StatelessWidget {
const _WorkspaceSlot({required this.tabs, required this.active});
final List<TabContribution> tabs;
final TabContribution active;
static const _editorTabId = 'editor.active';
static const _claudeTabId = 'claude.primary';
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideSettings.theme.of(context).surface;
return ListenableBuilder(
listenable: kernel.arrangement,
builder: (ctx, _) {
final editorOpen = kernel.arrangement.editorOpen;
final editorTab = tabs.where((t) => t.id == _editorTabId).firstOrNull;
final claude = tabs.where((t) => t.id == _claudeTabId).firstOrNull;
final primaryPane = KeyedSubtree(key: _kWorkspacePrimary, child: (claude ?? active).build(ctx));
// A non-Claude, non-editor workspace tab being the active one (e.g.
// diff.view revealed by `clide ui open diff`, T-233) shows in the split
// region above Claude — "review alongside the conversation" — with a
// close affordance back to full-Claude. Only when Claude exists below
// it; with no Claude pane the active tab just takes the whole slot, as
// before. The editor keeps its own editorOpen-gated split.
final reveal = (claude != null && active.id != _claudeTabId && active.id != _editorTabId) ? active : null;
final topTab = reveal ?? (editorOpen ? editorTab : null);
if (topTab == null) {
return Container(color: tokens.panelBackground, child: primaryPane);
}
final ratio = kernel.arrangement.editorRatio;
return Container(
color: tokens.panelBackground,
child: LayoutBuilder(
builder: (ctx, constraints) {
final totalHeight = constraints.maxHeight;
final topHeight = (totalHeight * ratio).clamp(60.0, totalHeight - 60.0);
return Column(
children: [
SizedBox(
height: topHeight,
child: reveal != null
? _RevealedTab(tab: reveal, onClose: () => kernel.panels.activateTab(Slots.workspace, _claudeTabId))
: topTab.build(ctx),
),
_EditorDragHandle(arrangement: kernel.arrangement, totalHeight: totalHeight),
Expanded(child: primaryPane),
],
);
},
),
);
},
);
}
}
/// A non-Claude workspace tab revealed in the split region above Claude
/// (T-233): a thin chrome header (title + close) over the tab's body, so the
/// user can review it alongside the conversation and dismiss it back to
/// full-Claude. The editor uses its own split path and never renders here.
class _RevealedTab extends StatelessWidget {
const _RevealedTab({required this.tab, required this.onClose});
final TabContribution tab;
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
return Column(
children: [
Container(
height: 28,
padding: const EdgeInsets.only(left: 10, right: 4),
color: tokens.panelHeader,
child: Row(
children: [
Expanded(
child: ClideText(resolveTabTitle(context, tab), fontSize: clideFontCaption, color: tokens.panelHeaderForeground, maxLines: 1),
),
Semantics(
button: true,
label: 'Close',
excludeSemantics: true,
onTap: onClose,
child: ClideTappable(
onTap: onClose,
tooltip: 'Close',
builder: (_, hovered, _) => Padding(
padding: const EdgeInsets.all(6),
child: ClideIcon(PhosphorIcons.byName('x'), size: 12, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
),
),
),
],
),
),
Expanded(child: tab.build(context)),
],
);
}
}
class _EditorDragHandle extends StatefulWidget {
const _EditorDragHandle({required this.arrangement, required this.totalHeight});
final LayoutArrangement arrangement;
final double totalHeight;
@override
State<_EditorDragHandle> createState() => _EditorDragHandleState();
}
class _EditorDragHandleState extends State<_EditorDragHandle> {
bool _hovered = false;
bool _focused = false;
double? _dragStartRatio;
double? _dragStartY;
// Editor split is a 0..1 fraction; the kernel clamps to 0.15..0.70.
// 2% per fine step, 10% per Shift step keeps keyboard feel close to
// the pixel-based DragResizeHandle.
static const double _stepFine = 0.02;
static const double _stepCoarse = 0.10;
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
final lineColor = (_hovered || _focused) ? tokens.panelActiveBorder : tokens.panelBorder;
final ratio = widget.arrangement.editorRatio;
String pct(double r) => '${(r.clamp(0.15, 0.70) * 100).round()}%';
return Semantics(
container: true,
slider: true,
label: 'Editor split',
value: pct(ratio),
// increase/decrease actions require matching increased/decreased
// values, or Flutter asserts on every semantics flush.
increasedValue: pct(ratio + _stepFine),
decreasedValue: pct(ratio - _stepFine),
onIncrease: () => _bump(_stepFine),
onDecrease: () => _bump(-_stepFine),
child: FocusableActionDetector(
onShowFocusHighlight: (v) => setState(() => _focused = v),
shortcuts: const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.arrowUp): _EditorBumpIntent(-_stepFine),
SingleActivator(LogicalKeyboardKey.arrowDown): _EditorBumpIntent(_stepFine),
SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): _EditorBumpIntent(-_stepCoarse),
SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): _EditorBumpIntent(_stepCoarse),
},
actions: <Type, Action<Intent>>{
_EditorBumpIntent: CallbackAction<_EditorBumpIntent>(
onInvoke: (intent) {
_bump(intent.delta);
return null;
},
),
},
child: MouseRegion(
cursor: SystemMouseCursors.resizeRow,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Listener(
onPointerDown: (e) {
_dragStartRatio = widget.arrangement.editorRatio;
_dragStartY = e.position.dy;
},
onPointerMove: (e) {
final startR = _dragStartRatio;
final startY = _dragStartY;
if (startR == null || startY == null || widget.totalHeight <= 0) return;
final deltaRatio = (e.position.dy - startY) / widget.totalHeight;
widget.arrangement.setEditorRatio(startR + deltaRatio);
},
onPointerUp: (_) {
_dragStartRatio = null;
_dragStartY = null;
},
child: Container(height: 4, color: lineColor),
),
),
),
);
}
void _bump(double delta) {
widget.arrangement.setEditorRatio(widget.arrangement.editorRatio + delta);
}
}
class _EditorBumpIntent extends Intent {
const _EditorBumpIntent(this.delta);
final double delta;
}
class _ContextSlot extends StatelessWidget {
const _ContextSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect});
final List<TabContribution> tabs;
final TabContribution active;
final String activeId;
final ValueChanged<String> onSelect;
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
return Container(color: tokens.panelBackground, alignment: Alignment.topLeft, padding: const EdgeInsets.only(right: 2), child: active.build(context));
}
}
/// Resolve a tab's display title through i18n when it carries a key +
/// namespace, else its static title. Shared by the slot bodies, the
/// revealed-tab header, and the bottom icon rails.
String resolveTabTitle(BuildContext context, TabContribution t) {
final key = t.titleKey;
final ns = t.i18nNamespace;
if (key == null || ns == null) return t.title;
return ClideSettings.i18n.of(context).string(key, namespace: ns, placeholder: t.title);
}