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:
@@ -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 +1,2 @@
|
||||
export 'src/extension.dart';
|
||||
export 'src/settings_modal.dart';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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." }
|
||||
}
|
||||
Reference in New Issue
Block a user