From 6592a88695fb96c622129aa575bc6fc6dbb535ae Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Apr 2026 17:30:21 +0200 Subject: [PATCH] add command palette widget + layout keybindings ClidePalette renders as a floating overlay with fuzzy-filtered command list. Ctrl+Shift+P toggles it. Ctrl+B toggles sidebar, Ctrl+J toggles context panel. All wired through CommandRegistry via the default layout extension. Co-Authored-By: Claude --- .../builtin/default_layout/src/extension.dart | 64 +++++-- app/lib/widgets/src/clide_palette.dart | 166 ++++++++++++++++++ 2 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 app/lib/widgets/src/clide_palette.dart diff --git a/app/lib/builtin/default_layout/src/extension.dart b/app/lib/builtin/default_layout/src/extension.dart index b09668a3..44947bb1 100644 --- a/app/lib/builtin/default_layout/src/extension.dart +++ b/app/lib/builtin/default_layout/src/extension.dart @@ -22,14 +22,33 @@ class DefaultLayoutExtension extends ClideExtension { title: 'Layout: Reset to Classic', run: _reset, ), + CommandContribution( + id: 'palette.toggle', + command: 'palette.toggle', + title: 'Command Palette', + defaultBinding: 'ctrl+shift+p', + run: _togglePalette, + ), + CommandContribution( + id: 'sidebar.toggle', + command: 'sidebar.toggle', + title: 'Toggle Sidebar', + defaultBinding: 'ctrl+b', + run: _toggleSidebar, + ), + CommandContribution( + id: 'context.toggle', + command: 'context.toggle', + title: 'Toggle Context Panel', + defaultBinding: 'ctrl+j', + run: _toggleContext, + ), ]; @override Future activate(ClideExtensionContext ctx) async { _ctx = ctx; _preset = classicPreset(); - // Register the preset's slots into the panel registry and apply - // the arrangement so every SlotHost has something to render. ctx.arrangement.registerSlotsInto(ctx.panels, _preset!); ctx.arrangement.applyPreset(_preset!); } @@ -38,16 +57,41 @@ class DefaultLayoutExtension extends ClideExtension { final preset = _preset; final ctx = _ctx; if (preset == null || ctx == null) { - return IpcResponse.err( - id: '', - error: IpcError( - code: IpcExitCode.toolError, - kind: IpcErrorKind.toolError, - message: 'default-layout not activated', - ), - ); + return _notActivated(); } ctx.arrangement.applyPreset(preset); return IpcResponse.ok(id: '', data: {'preset': preset.id}); } + + Future _togglePalette(List args) async { + final ctx = _ctx; + if (ctx == null) return _notActivated(); + ctx.palette.toggle(); + return IpcResponse.ok(id: '', data: {'open': ctx.palette.isOpen}); + } + + Future _toggleSidebar(List 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}); + } + + Future _toggleContext(List 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}); + } + + static IpcResponse _notActivated() => IpcResponse.err( + id: '', + error: IpcError( + code: IpcExitCode.toolError, + kind: IpcErrorKind.toolError, + message: 'not activated', + ), + ); } diff --git a/app/lib/widgets/src/clide_palette.dart b/app/lib/widgets/src/clide_palette.dart new file mode 100644 index 00000000..fe251f9a --- /dev/null +++ b/app/lib/widgets/src/clide_palette.dart @@ -0,0 +1,166 @@ +import 'package:clide_app/kernel/kernel.dart'; +import 'package:clide_app/widgets/src/clide_text.dart'; +import 'package:clide_app/widgets/src/typography.dart'; +import 'package:flutter/widgets.dart'; + +class ClidePalette extends StatefulWidget { + const ClidePalette({super.key}); + + @override + State createState() => _ClidePaletteState(); +} + +class _ClidePaletteState extends State { + final _input = TextEditingController(); + final _focus = FocusNode(); + + @override + void initState() { + super.initState(); + _focus.requestFocus(); + } + + @override + void dispose() { + _input.dispose(); + _focus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final kernel = ClideKernel.of(context); + final tokens = ClideTheme.of(context).surface; + return ListenableBuilder( + listenable: kernel.palette, + builder: (ctx, _) { + if (!kernel.palette.isOpen) return const SizedBox.shrink(); + final filtered = kernel.palette.filtered(); + return Positioned( + top: 60, + left: 0, + right: 0, + child: Center( + child: Container( + width: 480, + constraints: const BoxConstraints(maxHeight: 360), + decoration: BoxDecoration( + color: tokens.dropdownBackground, + border: Border.all(color: tokens.dropdownBorder), + borderRadius: BorderRadius.circular(6), + boxShadow: const [ + BoxShadow( + color: Color(0x40000000), + blurRadius: 12, + offset: Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: EditableText( + controller: _input, + focusNode: _focus, + style: TextStyle( + fontFamily: clideMonoFamily, + fontSize: clideFontMono, + color: tokens.dropdownForeground, + ), + cursorColor: tokens.globalFocus, + backgroundCursorColor: tokens.globalFocus, + maxLines: 1, + onChanged: (v) => kernel.palette.setFilter(v), + onSubmitted: (_) { + if (filtered.isNotEmpty) { + kernel.palette.invoke(filtered.first.command); + _input.clear(); + } + }, + ), + ), + Flexible( + child: ListView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: filtered.length, + itemBuilder: (ctx, i) { + final cmd = filtered[i]; + return _PaletteItem( + title: cmd.title ?? cmd.command, + command: cmd.command, + binding: cmd.defaultBinding, + onTap: () { + kernel.palette.invoke(cmd.command); + _input.clear(); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _PaletteItem extends StatefulWidget { + const _PaletteItem({ + required this.title, + required this.command, + required this.onTap, + this.binding, + }); + + final String title; + final String command; + final String? binding; + final VoidCallback onTap; + + @override + State<_PaletteItem> createState() => _PaletteItemState(); +} + +class _PaletteItemState extends State<_PaletteItem> { + bool _hover = false; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: GestureDetector( + onTap: widget.onTap, + child: Container( + color: _hover ? tokens.listItemHoverBackground : null, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + children: [ + Expanded( + child: ClideText( + widget.title, + color: tokens.listItemForeground, + ), + ), + if (widget.binding != null) + ClideText( + widget.binding!, + fontSize: clideFontCaption, + fontFamily: clideMonoFamily, + color: tokens.globalTextMuted, + ), + ], + ), + ), + ), + ); + } +}