implement collapse spine, focus mode, keyboard shortcuts, context rail

Collapsed side panels render as a 12px vertical spine with rotated
label, hover highlight, and badge dot (D-051). Focus mode snapshots
and restores the full layout arrangement (D-052). Fourteen new
commands in the default-layout extension: collapse toggles
(Ctrl+Shift+1/3), panel focus (Ctrl+1/2/3), focus mode (Ctrl+.),
sidebar section switching (Alt+1-5), Escape dismiss (D-054). Right
panel gets a bottom icon rail matching the sidebar pattern (D-047).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 22:47:45 +02:00
co-authored by Claude Opus 4.6
parent 9e3a13e7f7
commit 11eb91effe
7 changed files with 456 additions and 46 deletions
+22
View File
@@ -18,6 +18,28 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
### Added
- Interaction model from Wireframe Flows v3: eight new D-records
(D-047 through D-054) and five Q-records (Q-026 through Q-030)
codifying layout invariants, chrome budget, editor mode, context
auto-behavior, collapse spine, focus mode, state persistence, and
the canonical keyboard map.
- Panel collapse spine — collapsed side panels render as a 12px
vertical spine with rotated label, hover highlight, and badge dot
for pending context (D-051, T-030).
- Focus mode — `Ctrl+.` takes the active panel full-window;
`Escape` restores the prior layout with collapse states and
divider positions intact (D-052, T-031).
- Canonical keyboard shortcuts from the interaction model: collapse
toggles (`Ctrl+Shift+1/3`), panel focus (`Ctrl+1/2/3`), sidebar
section switching (`Alt+15`), focus mode, and `Escape` dismiss
(D-054, T-033).
- Right panel (context) icon rail — bottom section switcher matching
the left sidebar rail pattern (D-047, T-034).
- Syntax highlighting via tree-sitter (dart:ffi to vendored
libtree-sitter.so with embedded wasmtime). 48 grammar WASM files,
48 highlight queries. Colors map to theme syntax tokens.
+86 -10
View File
@@ -30,8 +30,7 @@ class _AppRoot extends StatelessWidget {
return WidgetsApp(
title: 'clide',
color: const Color(0xFF000000),
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) =>
PageRouteBuilder<T>(
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) => PageRouteBuilder<T>(
settings: settings,
pageBuilder: (ctx, _, __) => builder(ctx),
),
@@ -117,7 +116,9 @@ class RootLayout extends StatelessWidget {
builder: (ctx, _) {
final a = kernel.arrangement;
final sidebarVisible = a.isVisible(Slots.sidebar);
final sidebarCollapsed = a.isCollapsed(Slots.sidebar);
final contextVisible = a.isVisible(Slots.contextPanel);
final contextCollapsed = a.isCollapsed(Slots.contextPanel);
final statusVisible = a.isVisible(Slots.statusbar);
final sidebarSize = a.sizeOf(Slots.sidebar) ?? 240;
final contextSize = a.sizeOf(Slots.contextPanel) ?? 280;
@@ -128,7 +129,13 @@ class RootLayout extends StatelessWidget {
Expanded(
child: Row(
children: [
if (sidebarVisible) ...[
if (sidebarVisible && sidebarCollapsed)
ClideSpine(
label: _sidebarSpineLabel(kernel),
side: SpineSide.left,
onExpand: () => a.setCollapsed(Slots.sidebar, false),
)
else if (sidebarVisible) ...[
SizedBox(
width: sidebarSize,
child: SlotHost(slot: Slots.sidebar),
@@ -140,7 +147,13 @@ class RootLayout extends StatelessWidget {
),
],
const Expanded(child: SlotHost(slot: Slots.workspace)),
if (contextVisible) ...[
if (contextVisible && contextCollapsed)
ClideSpine(
label: 'context',
side: SpineSide.right,
onExpand: () => a.setCollapsed(Slots.contextPanel, false),
)
else if (contextVisible) ...[
DragResizeHandle(
arrangement: a,
slot: Slots.contextPanel,
@@ -164,6 +177,16 @@ class RootLayout extends StatelessWidget {
},
);
}
static String _sidebarSpineLabel(KernelServices kernel) {
final activeTab = kernel.panels.activeTabIn(Slots.sidebar);
if (activeTab == null) return 'overview';
final tabs = kernel.panels.tabsFor(Slots.sidebar);
for (final t in tabs) {
if (t.id == activeTab) return t.title.toLowerCase();
}
return 'overview';
}
}
class SlotHost extends StatelessWidget {
@@ -196,14 +219,22 @@ class SlotHost extends StatelessWidget {
);
}
if (slot == Slots.contextPanel) {
return _ContextSlot(
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)),
for (final t in tabs) ClideTabItem(id: t.id, title: _resolveTitle(ctx, t)),
],
activeId: active.id,
onSelect: (id) => kernel.panels.activateTab(slot, id),
@@ -279,6 +310,54 @@ class _SidebarSlot extends StatelessWidget {
}
}
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 = ClideTheme.of(context).surface;
return Container(
color: tokens.panelBackground,
child: Column(
children: [
Expanded(child: active.build(context)),
ClideIconRail(
items: [
for (final t in tabs)
ClideIconRailItem(
id: t.id,
icon: _iconFor(t),
tooltip: SlotHost._resolveTitle(context, t),
),
],
activeId: activeId,
onSelect: onSelect,
),
],
),
);
}
static ClideIconPainter _iconFor(TabContribution t) {
if (t.icon is ClideIconPainter) return t.icon as ClideIconPainter;
return switch (t.id) {
'markdown.viewer' => const DotIcon(),
'graph.view' => const SearchIcon(),
_ => const DotIcon(),
};
}
}
class StatusbarHost extends StatelessWidget {
const StatusbarHost({super.key});
@@ -289,10 +368,7 @@ class StatusbarHost extends StatelessWidget {
return ListenableBuilder(
listenable: kernel.panels,
builder: (ctx, _) {
final items = kernel.panels
.contributionsFor(Slots.statusbar)
.whereType<StatusItemContribution>()
.toList();
final items = kernel.panels.contributionsFor(Slots.statusbar).whereType<StatusItemContribution>().toList();
final left = items.where((i) => i.priority < 100).toList();
final right = items.where((i) => i.priority >= 100).toList();
return Container(
+139 -22
View File
@@ -8,7 +8,7 @@ class DefaultLayoutExtension extends ClideExtension {
@override
String get title => 'Default layout';
@override
String get version => '0.1.0';
String get version => '0.2.0';
LayoutPresetContribution? _preset;
ClideExtensionContext? _ctx;
@@ -29,20 +29,67 @@ class DefaultLayoutExtension extends ClideExtension {
defaultBinding: 'ctrl+shift+p',
run: _togglePalette,
),
// Collapse toggles (D-051, D-054)
CommandContribution(
id: 'sidebar.toggle',
command: 'sidebar.toggle',
title: 'Toggle Sidebar',
defaultBinding: 'ctrl+b',
run: _toggleSidebar,
id: 'sidebar.collapse',
command: 'sidebar.collapse',
title: 'Toggle Sidebar Collapse',
defaultBinding: 'ctrl+shift+1',
run: _collapseSidebar,
),
CommandContribution(
id: 'context.toggle',
command: 'context.toggle',
title: 'Toggle Context Panel',
defaultBinding: 'ctrl+j',
run: _toggleContext,
id: 'context.collapse',
command: 'context.collapse',
title: 'Toggle Context Panel Collapse',
defaultBinding: 'ctrl+shift+3',
run: _collapseContext,
),
// Panel focus (D-054)
CommandContribution(
id: 'panel.focus.left',
command: 'panel.focus.left',
title: 'Focus Left Panel',
defaultBinding: 'ctrl+1',
run: _focusLeft,
),
CommandContribution(
id: 'panel.focus.middle',
command: 'panel.focus.middle',
title: 'Focus Middle Panel',
defaultBinding: 'ctrl+2',
run: _focusMiddle,
),
CommandContribution(
id: 'panel.focus.right',
command: 'panel.focus.right',
title: 'Focus Right Panel',
defaultBinding: 'ctrl+3',
run: _focusRight,
),
// Focus mode (D-052, D-054)
CommandContribution(
id: 'panel.focusMode',
command: 'panel.focusMode',
title: 'Toggle Focus Mode',
defaultBinding: 'ctrl+.',
run: _toggleFocusMode,
),
CommandContribution(
id: 'panel.focusMode.exit',
command: 'panel.focusMode.exit',
title: 'Exit Focus Mode',
defaultBinding: 'escape',
run: _exitFocusMode,
),
// Sidebar section switching (D-054): alt+1 through alt+5
for (var i = 0; i < 5; i++)
CommandContribution(
id: 'sidebar.section.${i + 1}',
command: 'sidebar.section.${i + 1}',
title: 'Sidebar: Section ${i + 1}',
defaultBinding: 'alt+${i + 1}',
run: (args) => _switchSidebarSection(i),
),
];
@override
@@ -56,9 +103,7 @@ class DefaultLayoutExtension extends ClideExtension {
Future<IpcResponse> _reset(List<String> args) async {
final preset = _preset;
final ctx = _ctx;
if (preset == null || ctx == null) {
return _notActivated();
}
if (preset == null || ctx == null) return _notActivated();
ctx.arrangement.applyPreset(preset);
return IpcResponse.ok(id: '', data: {'preset': preset.id});
}
@@ -70,20 +115,92 @@ class DefaultLayoutExtension extends ClideExtension {
return IpcResponse.ok(id: '', data: {'open': ctx.palette.isOpen});
}
Future<IpcResponse> _toggleSidebar(List<String> args) async {
Future<IpcResponse> _collapseSidebar(List<String> args) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
final visible = ctx.arrangement.isVisible(Slots.sidebar);
ctx.arrangement.setVisible(Slots.sidebar, !visible);
return IpcResponse.ok(id: '', data: {'visible': !visible});
ctx.arrangement.toggleCollapsed(Slots.sidebar);
final collapsed = ctx.arrangement.isCollapsed(Slots.sidebar);
return IpcResponse.ok(id: '', data: {'collapsed': collapsed});
}
Future<IpcResponse> _toggleContext(List<String> args) async {
Future<IpcResponse> _collapseContext(List<String> args) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
final visible = ctx.arrangement.isVisible(Slots.contextPanel);
ctx.arrangement.setVisible(Slots.contextPanel, !visible);
return IpcResponse.ok(id: '', data: {'visible': !visible});
ctx.arrangement.toggleCollapsed(Slots.contextPanel);
final collapsed = ctx.arrangement.isCollapsed(Slots.contextPanel);
return IpcResponse.ok(id: '', data: {'collapsed': collapsed});
}
Future<IpcResponse> _focusLeft(List<String> args) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
if (ctx.arrangement.isCollapsed(Slots.sidebar)) {
ctx.arrangement.setCollapsed(Slots.sidebar, false);
}
final active = ctx.panels.activeTabIn(Slots.sidebar);
if (active != null) {
ctx.focus.setActive(slot: Slots.sidebar, contributionId: active);
}
return IpcResponse.ok(id: '', data: {'focused': 'sidebar'});
}
Future<IpcResponse> _focusMiddle(List<String> args) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
final active = ctx.panels.activeTabIn(Slots.workspace);
if (active != null) {
ctx.focus.setActive(slot: Slots.workspace, contributionId: active);
}
return IpcResponse.ok(id: '', data: {'focused': 'workspace'});
}
Future<IpcResponse> _focusRight(List<String> args) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
if (ctx.arrangement.isCollapsed(Slots.contextPanel)) {
ctx.arrangement.setCollapsed(Slots.contextPanel, false);
}
final active = ctx.panels.activeTabIn(Slots.contextPanel);
if (active != null) {
ctx.focus.setActive(slot: Slots.contextPanel, contributionId: active);
}
return IpcResponse.ok(id: '', data: {'focused': 'context'});
}
Future<IpcResponse> _toggleFocusMode(List<String> args) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
final activeSlot = ctx.focus.activeSlot ?? Slots.workspace;
ctx.arrangement.toggleFocusMode(activeSlot);
return IpcResponse.ok(id: '', data: {'focusMode': ctx.arrangement.isInFocusMode});
}
Future<IpcResponse> _exitFocusMode(List<String> args) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
if (ctx.arrangement.isInFocusMode) {
ctx.arrangement.exitFocusMode();
return IpcResponse.ok(id: '', data: {'focusMode': false});
}
if (ctx.palette.isOpen) {
ctx.palette.toggle();
return IpcResponse.ok(id: '', data: {'palette': false});
}
return IpcResponse.ok(id: '', data: {});
}
Future<IpcResponse> _switchSidebarSection(int index) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
if (ctx.arrangement.isCollapsed(Slots.sidebar)) {
ctx.arrangement.setCollapsed(Slots.sidebar, false);
}
final tabs = ctx.panels.tabsFor(Slots.sidebar);
if (index < tabs.length) {
ctx.panels.activateTab(Slots.sidebar, tabs[index].id);
return IpcResponse.ok(id: '', data: {'section': tabs[index].id});
}
return IpcResponse.ok(id: '', data: {});
}
static IpcResponse _notActivated() => IpcResponse.err(
+60 -14
View File
@@ -3,19 +3,18 @@ import 'package:clide_app/kernel/src/panels/registry.dart';
import 'package:clide_app/kernel/src/panels/slot_id.dart';
import 'package:flutter/foundation.dart';
/// Current, runtime layout state: which slots are visible, at what size,
/// and in what positions. Persisted through `settings`.
///
/// The registry knows which slots *exist* and which contributions are
/// mounted. The arrangement knows which slots are *currently* shown and
/// how big they are — user-modifiable via drag-resize.
class LayoutArrangement extends ChangeNotifier {
LayoutArrangement();
final Map<SlotId, _SlotState> _state = {};
Map<SlotId, _SlotState>? _focusModeSnapshot;
SlotId? _focusModeSlot;
void applyPreset(LayoutPresetContribution preset) {
_state.clear();
_focusModeSnapshot = null;
_focusModeSlot = null;
for (final slot in preset.slots) {
_state[slot.slot] = _SlotState(
position: slot.position,
@@ -35,12 +34,14 @@ class LayoutArrangement extends ChangeNotifier {
double? minSizeOf(SlotId id) => _state[id]?.minSize;
double? maxSizeOf(SlotId id) => _state[id]?.maxSize;
bool isVisible(SlotId id) => _state[id]?.visible ?? false;
bool isCollapsed(SlotId id) => _state[id]?.collapsed ?? false;
bool get isInFocusMode => _focusModeSlot != null;
SlotId? get focusModeSlot => _focusModeSlot;
void setSize(SlotId id, double size) {
final s = _state[id];
if (s == null) return;
final clamped =
size.clamp(s.minSize ?? 0, s.maxSize ?? double.infinity).toDouble();
final clamped = size.clamp(s.minSize ?? 0, s.maxSize ?? double.infinity).toDouble();
if (s.size == clamped) return;
_state[id] = s.copyWith(size: clamped);
notifyListeners();
@@ -53,12 +54,53 @@ class LayoutArrangement extends ChangeNotifier {
notifyListeners();
}
/// Convenience for the default-layout extension: register all slots
/// it provides into a [PanelRegistry] with defaults from this preset.
void registerSlotsInto(
PanelRegistry registry,
LayoutPresetContribution preset,
) {
void setCollapsed(SlotId id, bool collapsed) {
final s = _state[id];
if (s == null || s.collapsed == collapsed) return;
_state[id] = s.copyWith(collapsed: collapsed);
notifyListeners();
}
void toggleCollapsed(SlotId id) {
final s = _state[id];
if (s == null) return;
_state[id] = s.copyWith(collapsed: !s.collapsed);
notifyListeners();
}
void enterFocusMode(SlotId slot) {
if (_focusModeSlot != null) return;
_focusModeSnapshot = {for (final e in _state.entries) e.key: e.value};
_focusModeSlot = slot;
for (final id in _state.keys) {
if (id == slot) {
_state[id] = _state[id]!.copyWith(visible: true, collapsed: false);
} else {
_state[id] = _state[id]!.copyWith(visible: false);
}
}
notifyListeners();
}
void exitFocusMode() {
final snap = _focusModeSnapshot;
if (snap == null) return;
_state.clear();
_state.addAll(snap);
_focusModeSnapshot = null;
_focusModeSlot = null;
notifyListeners();
}
void toggleFocusMode(SlotId slot) {
if (_focusModeSlot != null) {
exitFocusMode();
} else {
enterFocusMode(slot);
}
}
void registerSlotsInto(PanelRegistry registry, LayoutPresetContribution preset) {
for (final slot in preset.slots) {
registry.registerSlot(SlotDefinition(
id: slot.slot,
@@ -78,6 +120,7 @@ class _SlotState {
this.minSize,
this.maxSize,
this.visible = true,
this.collapsed = false,
});
final SlotPosition position;
@@ -85,6 +128,7 @@ class _SlotState {
final double? minSize;
final double? maxSize;
final bool visible;
final bool collapsed;
_SlotState copyWith({
SlotPosition? position,
@@ -92,6 +136,7 @@ class _SlotState {
double? minSize,
double? maxSize,
bool? visible,
bool? collapsed,
}) {
return _SlotState(
position: position ?? this.position,
@@ -99,6 +144,7 @@ class _SlotState {
minSize: minSize ?? this.minSize,
maxSize: maxSize ?? this.maxSize,
visible: visible ?? this.visible,
collapsed: collapsed ?? this.collapsed,
);
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'dart:math' as math;
import 'package:clide_app/kernel/src/theme/controller.dart';
import 'package:flutter/widgets.dart';
class ClideSpine extends StatefulWidget {
const ClideSpine({
super.key,
required this.label,
required this.onExpand,
this.side = SpineSide.left,
this.badgeCount = 0,
});
final String label;
final VoidCallback onExpand;
final SpineSide side;
final int badgeCount;
static const double width = 12;
@override
State<ClideSpine> createState() => _ClideSpineState();
}
enum SpineSide { left, right }
class _ClideSpineState extends State<ClideSpine> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final borderSide = BorderSide(color: tokens.dividerColor);
return Semantics(
button: true,
label: '${widget.label} — click to expand',
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onExpand,
child: Container(
width: ClideSpine.width,
decoration: BoxDecoration(
color: _hovered ? tokens.sidebarItemHover : tokens.sidebarBackground,
border: Border(
left: widget.side == SpineSide.right ? borderSide : BorderSide.none,
right: widget.side == SpineSide.left ? borderSide : BorderSide.none,
),
),
child: Stack(
children: [
Center(
child: Transform.rotate(
angle: widget.side == SpineSide.left ? -math.pi / 2 : math.pi / 2,
child: Text(
widget.label,
style: TextStyle(
fontSize: 9,
color: tokens.globalTextMuted,
letterSpacing: 0.5,
),
maxLines: 1,
overflow: TextOverflow.clip,
),
),
),
if (widget.badgeCount > 0)
Positioned(
top: 4,
left: 0,
right: 0,
child: Center(
child: Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: tokens.statusInfo,
shape: BoxShape.circle,
),
),
),
),
],
),
),
),
),
);
}
}
+1
View File
@@ -13,6 +13,7 @@ export 'src/clide_palette.dart';
export 'src/clide_pane_chrome.dart';
export 'src/clide_pty_view.dart';
export 'src/clide_scrollbar.dart';
export 'src/clide_spine.dart';
export 'src/clide_surface.dart';
export 'src/clide_tab_bar.dart';
export 'src/clide_text.dart';
@@ -46,5 +46,59 @@ void main() {
expect(r.definitionFor(Slots.sidebar), isNotNull);
expect(r.definitionFor(Slots.statusbar), isNotNull);
});
test('setCollapsed toggles collapsed state', () {
final a = LayoutArrangement()..applyPreset(classicPreset());
expect(a.isCollapsed(Slots.sidebar), false);
a.setCollapsed(Slots.sidebar, true);
expect(a.isCollapsed(Slots.sidebar), true);
expect(a.isVisible(Slots.sidebar), true);
});
test('toggleCollapsed flips collapsed flag', () {
final a = LayoutArrangement()..applyPreset(classicPreset());
a.toggleCollapsed(Slots.sidebar);
expect(a.isCollapsed(Slots.sidebar), true);
a.toggleCollapsed(Slots.sidebar);
expect(a.isCollapsed(Slots.sidebar), false);
});
test('enterFocusMode hides all slots except the focused one', () {
final a = LayoutArrangement()..applyPreset(classicPreset());
a.enterFocusMode(Slots.workspace);
expect(a.isInFocusMode, true);
expect(a.focusModeSlot, Slots.workspace);
expect(a.isVisible(Slots.workspace), true);
expect(a.isVisible(Slots.sidebar), false);
expect(a.isVisible(Slots.contextPanel), false);
});
test('exitFocusMode restores prior layout', () {
final a = LayoutArrangement()..applyPreset(classicPreset());
a.setCollapsed(Slots.sidebar, true);
a.setSize(Slots.contextPanel, 300);
a.enterFocusMode(Slots.workspace);
a.exitFocusMode();
expect(a.isInFocusMode, false);
expect(a.isVisible(Slots.sidebar), true);
expect(a.isCollapsed(Slots.sidebar), true);
expect(a.sizeOf(Slots.contextPanel), 300);
});
test('toggleFocusMode enters then exits', () {
final a = LayoutArrangement()..applyPreset(classicPreset());
a.toggleFocusMode(Slots.workspace);
expect(a.isInFocusMode, true);
a.toggleFocusMode(Slots.workspace);
expect(a.isInFocusMode, false);
expect(a.isVisible(Slots.sidebar), true);
});
test('applyPreset clears focus mode', () {
final a = LayoutArrangement()..applyPreset(classicPreset());
a.enterFocusMode(Slots.workspace);
a.applyPreset(classicPreset());
expect(a.isInFocusMode, false);
});
});
}