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:
@@ -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+1–5`), 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
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user