feat(settings): settings panel shell + settings.open command (T-445)

Lay the foundation for the schema-driven settings UI (epic T-444). A new
`settings.open` command (⌘`,`, plus a File-menu and command-palette entry)
opens a centered Settings modal over the dimmed app via the dialog router,
built from the modalSurface* tokens (D-7, no Material). The shell frames the
two regions later tickets fill in — the category rail (T-447) and the
scrolling carded panel (T-448) — and dismisses on ✕, Esc, or barrier tap.
With no category registered yet it shows its empty state, which is the
correct runtime state.

Flesh out the `builtin.settings-ui` stub (was 0.0.0-stub) into a real
extension; ship its en-US i18n catalog. Relabel the theme picker's
`theme.pick` command title from "Settings…" to "Theme…" so the two no
longer collide in the palette (the picker folds into the new panel's
Appearance category in T-452).

Tests: command + ⌘`,` binding registered, shell renders, Esc and close
both dismiss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 11:41:20 +02:00
co-authored by Claude Opus 4.8
parent 50d10c792a
commit 643f40d7b2
10 changed files with 317 additions and 9 deletions
+2
View File
@@ -75,6 +75,8 @@ List<TopMenu> buildClideMenuTree() => [
const MenuCommandItem('file.openFolder', fallbackTitle: 'Open Folder…'),
const MenuCommandItem('file.newWindow', fallbackTitle: 'New Window'),
const MenuSeparator(),
const MenuCommandItem('settings.open', fallbackTitle: 'Settings…'),
const MenuSeparator(),
MenuCommandItem('file.closeWorkspace', fallbackTitle: 'Close Project', enabledWhen: (s) => s.project.isOpen),
],
),
+1
View File
@@ -1 +1,2 @@
export 'src/extension.dart';
export 'src/settings_modal.dart';
+32 -5
View File
@@ -1,17 +1,44 @@
import 'package:clide/builtin/settings_ui/src/settings_modal.dart';
import 'package:clide/clide.dart';
import 'package:clide/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
/// Schema-driven Settings UI (epic T-444). T-445 lands the foundation: the
/// `settings.open` command and the modal shell it opens. The category rail
/// (T-447), schema field renderer (T-448), scope tags (T-449), search
/// (T-450) and the per-subsystem categories fill the shell in later tickets.
class SettingsUiExtension extends ClideExtension {
@override
String get id => 'builtin.settings-ui';
@override
String get title => 'Settings UI';
@override
String get version => '0.0.0-stub';
String get version => '0.1.0';
@override
List<String> get dependsOn => const [];
ClideExtensionContext? _ctx;
@override
List<ContributionPoint> get contributions => const [];
Future<void> activate(ClideExtensionContext ctx) async {
_ctx = ctx;
}
@override
List<ContributionPoint> get contributions => [
// Opens the Settings panel. Palette + File-menu entry come for free off
// the title; ctrl+, is the conventional settings shortcut.
CommandContribution(id: 'settings.open', command: 'settings.open', title: 'Settings…', defaultBinding: 'ctrl+,', run: _open),
];
Future<IpcResponse> _open(List<String> args) async {
final ctx = _ctx;
if (ctx == null) {
return IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'settings-ui not activated'),
);
}
await ctx.dialog.show<Object>((context, dismiss) => SettingsModal(onDismiss: () => dismiss()));
return IpcResponse.ok(id: '', data: const {});
}
}
@@ -0,0 +1,185 @@
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// The schema-driven Settings panel shell (T-445, epic T-444).
///
/// A centered modal over the dimmed app (hosted by [DialogHost] via
/// `ctx.dialog.show`), built from the `modalSurface*` tokens (D-7, no
/// Material). It frames the two regions the rest of the epic fills in:
///
/// - the **category rail** on the left (navigation lands in T-447; the
/// list is data-driven from the schemas each subsystem registers), and
/// - the **scrolling carded panel** on the right (the schema field
/// renderer is T-448).
///
/// Until any category registers a schema the panel shows its empty state —
/// that is the correct runtime state, not a placeholder. Dismiss with the
/// close button, Esc, or a barrier tap (the last handled by [DialogHost]).
///
/// Wireframe: `docs/design/wireframes/settings/settings-screen.png`.
class SettingsModal extends StatelessWidget {
const SettingsModal({super.key, required this.onDismiss});
/// Closes the modal. Wired to the dialog router's `dismiss` by the
/// opener (see `SettingsUiExtension`).
final VoidCallback onDismiss;
static const ns = 'builtin.settings-ui';
// The modal is a fixed-size desktop surface (the app is a desktop host);
// the right panel scrolls when its cards exceed the height (surface.md).
static const double _width = 760;
static const double _height = 560;
static const double _railWidth = 196;
@override
Widget build(BuildContext context) {
final i = ClideKernel.of(context).i18n;
final tokens = ClideTheme.of(context).surface;
final title = i.string('modal.title', namespace: ns, placeholder: 'Settings');
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
onDismiss();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Semantics(
container: true,
label: title,
explicitChildNodes: true,
child: ClideSurface(
width: _width,
height: _height,
color: tokens.modalSurfaceBackground,
border: tokens.modalSurfaceBorder,
borderRadius: BorderRadius.circular(6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Header(title: title, onClose: onDismiss),
const ClideDivider(),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(width: _railWidth, child: const _CategoryRail()),
const ClideDivider(axis: Axis.vertical),
const Expanded(child: _SettingsPanel()),
],
),
),
],
),
),
),
);
}
}
/// Title bar: gear glyph + "Settings" on the left, close ✕ on the right.
class _Header extends StatelessWidget {
const _Header({required this.title, required this.onClose});
final String title;
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
child: Row(
children: [
ClideIcon(const GearIcon(), size: 16, color: tokens.globalForeground),
const SizedBox(width: 8),
Expanded(child: ClideText(title, fontSize: 15, fontWeight: FontWeight.w600)),
_CloseButton(onTap: onClose),
],
),
);
}
}
class _CloseButton extends StatelessWidget {
const _CloseButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final i = ClideKernel.of(context).i18n;
final label = i.string('modal.close', namespace: SettingsModal.ns, placeholder: 'Close');
final hint = i.string('modal.close.hint', namespace: SettingsModal.ns, placeholder: 'Close settings');
return Semantics(
button: true,
label: label,
hint: hint,
onTap: onTap,
excludeSemantics: true,
child: ClideTappable(
cursor: SystemMouseCursors.click,
onTap: onTap,
builder: (ctx, hovered, pressed) => Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
child: ClideIcon(const CloseIcon(), size: 16, color: tokens.globalForeground),
),
),
);
}
}
/// Left rail. The category list is populated from registered schemas in
/// T-447; for now it shows only its section header.
class _CategoryRail extends StatelessWidget {
const _CategoryRail();
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final i = ClideKernel.of(context).i18n;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(
i.string('rail.header', namespace: SettingsModal.ns, placeholder: 'Categories'),
fontSize: clideFontCaption,
color: tokens.sidebarSectionHeader,
fontFamily: clideMonoFamily,
),
],
),
);
}
}
/// Right panel. The schema-driven field renderer fills this in T-448; with
/// no category registered yet it shows the empty state.
class _SettingsPanel extends StatelessWidget {
const _SettingsPanel();
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final i = ClideKernel.of(context).i18n;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: ClideText(
i.string('panel.empty', namespace: SettingsModal.ns, placeholder: 'No settings categories are registered yet.'),
color: tokens.globalTextMuted,
textAlign: TextAlign.center,
),
),
);
}
}
+6 -4
View File
@@ -20,10 +20,12 @@ class ThemePickerExtension extends ClideExtension {
@override
List<ContributionPoint> get contributions => [
// Opens the settings modal (T-238). Command id kept as `theme.pick`
// (the welcome theme-link and other callers reference it); ⌘K opens
// Settings, whose only section today is the theme picker.
CommandContribution(id: 'theme.pick', command: 'theme.pick', title: 'Settings…', defaultBinding: 'ctrl+k', run: _pick),
// Opens the theme picker modal (T-238). Command id kept as `theme.pick`
// (the welcome theme-link and other callers reference it). Titled
// "Theme…" to disambiguate from the schema-driven Settings panel's
// `settings.open` (T-444); the theme picker folds into that panel's
// Appearance category in T-452.
CommandContribution(id: 'theme.pick', command: 'theme.pick', title: 'Theme…', defaultBinding: 'ctrl+k', run: _pick),
// Always-visible switcher in the far-right status bar (T-234).
// priority >= 100 places it in the right group; registered after
// ipc-status so it sits to its right.
@@ -0,0 +1,7 @@
{
"modal.title": { "translation": "Settings" },
"modal.close": { "translation": "Close" },
"modal.close.hint": { "translation": "Close settings without changing anything" },
"rail.header": { "translation": "Categories" },
"panel.empty": { "translation": "No settings categories are registered yet." }
}