feat(settings): schema-driven settings engine (T-448)

The core of the settings panel (epic T-444). Subsystems describe a category
as data — a SettingsCategory of carded SettingsSections of SettingsFields
(toggle / select / text / number / opens-external-file), each bound to a
SettingsStore key with help text, a default, and reset-to-default.

Registration is declarative: a new SettingsCategoryContribution carries the
category; the extension manager routes it into a new kernel SettingsRegistry
(exposed on KernelServices), which the panel reads via ClideKernel. Adding a
category is now pure data + a contribution — no widget code.

SettingsCategoryView renders a category into carded sections per ui-design
surface.md: panelHeader card fill, dividerColor border, inputs receding to
panelBackground; select reuses the anchored-overlay menu, text/number commit
on Enter or blur (numeric clamps to bounds). The modal panel now shows the
selected/first registered category, falling back to the empty state.

Tests: registry (sort / dedup / notify), contribution routing on activation,
renderer (render + toggle/select write-through + reset), modal-with-category.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 12:07:51 +02:00
co-authored by Claude Opus 4.8
parent 643f40d7b2
commit 4bbb0ee4b3
15 changed files with 890 additions and 22 deletions
+1
View File
@@ -1,2 +1,3 @@
export 'src/extension.dart';
export 'src/settings_category_view.dart';
export 'src/settings_modal.dart';
@@ -0,0 +1,405 @@
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
/// The schema-driven field renderer (T-448, epic T-444): turns a
/// [SettingsCategory] into carded sections of field rows. Each field binds to a
/// `SettingsStore` key — read with `get` (falling back to the schema default),
/// written with `set` on edit. Rebuilds live as the store changes.
///
/// Per-field scope tags (T-449) and cross-category search (T-450) layer onto
/// the row in their own tickets; the trailing slot here is the reset control.
/// Carded layout follows ui-design `surface.md` ("sectioned cards").
class SettingsCategoryView extends StatelessWidget {
const SettingsCategoryView({super.key, required this.category});
final SettingsCategory category;
@override
Widget build(BuildContext context) {
final store = ClideKernel.of(context).settings;
// Re-read on every settings change so edits (and resets) reflect at once.
return ListenableBuilder(
listenable: store,
builder: (context, _) => SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final section in category.sections) _SectionCard(section: section, store: store),
],
),
),
);
}
}
/// One section: a small-caps header above an elevated card of field rows.
class _SectionCard extends StatelessWidget {
const _SectionCard({required this.section, required this.store});
final SettingsSection section;
final SettingsStore store;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(left: 2, bottom: 6),
child: ClideText(
section.label.toUpperCase(),
fontSize: clideFontCaption,
color: tokens.sidebarSectionHeader,
fontFamily: clideMonoFamily,
),
),
ClideSurface(
// Card surface (surface.md): panelHeader resolves to the `surface`
// palette key (the elevated card tone); inputs inside recede to
// panelBackground.
color: tokens.panelHeader,
border: tokens.dividerColor,
borderRadius: BorderRadius.circular(6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < section.fields.length; i++) ...[
if (i > 0) const ClideDivider(),
_FieldRow(field: section.fields[i], store: store),
],
],
),
),
],
),
);
}
}
/// A label/help block + the field's control + a reset affordance.
class _FieldRow extends StatelessWidget {
const _FieldRow({required this.field, required this.store});
final SettingsField field;
final SettingsStore store;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final raw = store.get<Object>(field.key);
final effective = raw ?? field.defaultValue;
final canReset = field.defaultValue != null && effective != field.defaultValue;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(field.label, color: tokens.globalForeground),
if (field.help != null && field.help!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: ClideText(field.help!, fontSize: clideFontCaption, color: tokens.globalTextMuted),
),
],
),
),
const SizedBox(width: 16),
_Control(field: field, value: effective, store: store),
SizedBox(
width: 24,
child: canReset
? _ResetButton(onTap: () => store.set<Object?>(field.key, field.defaultValue))
: const SizedBox.shrink(),
),
],
),
);
}
}
/// Dispatches to the control widget for the field's [SettingsFieldKind].
class _Control extends StatelessWidget {
const _Control({required this.field, required this.value, required this.store});
final SettingsField field;
final Object? value;
final SettingsStore store;
void _set(Object? v) => store.set<Object?>(field.key, v);
@override
Widget build(BuildContext context) {
switch (field.kind) {
case SettingsFieldKind.toggle:
return _ToggleControl(checked: value == true, onChanged: _set);
case SettingsFieldKind.select:
return _SelectControl(field: field, value: value?.toString(), onPick: _set);
case SettingsFieldKind.text:
return _EditControl(field: field, value: value?.toString() ?? '', numeric: false, onCommit: _set);
case SettingsFieldKind.number:
return _EditControl(field: field, value: value?.toString() ?? '', numeric: true, onCommit: _set);
case SettingsFieldKind.file:
return _FileControl(field: field);
}
}
}
/// Checkbox toggle (mirrors the theme-picker high-contrast box).
class _ToggleControl extends StatelessWidget {
const _ToggleControl({required this.checked, required this.onChanged});
final bool checked;
final void Function(bool value) onChanged;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Semantics(
checked: checked,
excludeSemantics: true,
child: ClideTappable(
cursor: SystemMouseCursors.click,
onTap: () => onChanged(!checked),
builder: (ctx, hovered, pressed) => Container(
width: 15,
height: 15,
decoration: BoxDecoration(
color: checked ? tokens.buttonBackground : (hovered ? tokens.listItemHoverBackground : null),
border: Border.all(color: checked ? tokens.buttonBackground : tokens.modalSurfaceBorder),
borderRadius: BorderRadius.circular(3),
),
child: checked ? ClideIcon(const CheckIcon(), size: 11, color: tokens.buttonForeground) : null,
),
),
);
}
}
/// Enum picker — current value on an anchored popover of options.
class _SelectControl extends StatefulWidget {
const _SelectControl({required this.field, required this.value, required this.onPick});
final SettingsField field;
final String? value;
final void Function(String value) onPick;
@override
State<_SelectControl> createState() => _SelectControlState();
}
class _SelectControlState extends State<_SelectControl> {
final ClideOverlayController _overlay = ClideOverlayController();
@override
void dispose() {
_overlay.dispose();
super.dispose();
}
String get _label {
for (final o in widget.field.options) {
if (o.value == widget.value) return o.label;
}
return widget.value ?? '';
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return ClideAnchoredOverlay(
controller: _overlay,
align: ClideAnchorAlign.end,
overlayBuilder: (ctx, c) => ClideMenu(
onClose: c.close,
entries: [
for (final o in widget.field.options)
ClideMenuItem(
label: o.label,
active: o.value == widget.value,
semanticLabel: '${widget.field.label}: ${o.label}',
onSelect: () => widget.onPick(o.value),
),
],
),
anchor: Semantics(
button: true,
label: '${widget.field.label}: $_label. Click to change.',
excludeSemantics: true,
onTap: _overlay.toggle,
child: ClideTappable(
cursor: SystemMouseCursors.click,
onTap: _overlay.toggle,
builder: (ctx, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: tokens.panelBackground,
border: Border.all(color: hovered ? tokens.panelActiveBorder : tokens.dividerColor),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideText(_label, color: tokens.globalForeground),
const SizedBox(width: 6),
ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: tokens.globalTextMuted),
],
),
),
),
),
);
}
}
/// Inline text / numeric editor. Commits on Enter and on focus loss; numeric
/// fields parse + clamp to the field's bounds, ignoring unparseable input.
class _EditControl extends StatefulWidget {
const _EditControl({required this.field, required this.value, required this.numeric, required this.onCommit});
final SettingsField field;
final String value;
final bool numeric;
final void Function(Object value) onCommit;
@override
State<_EditControl> createState() => _EditControlState();
}
class _EditControlState extends State<_EditControl> {
late final TextEditingController _controller = TextEditingController(text: widget.value);
late final FocusNode _focus = FocusNode(debugLabel: 'settings-${widget.field.key}');
@override
void initState() {
super.initState();
_focus.addListener(() {
if (!_focus.hasFocus) _commit();
});
}
@override
void didUpdateWidget(_EditControl old) {
super.didUpdateWidget(old);
// Reflect external changes (reset, scope flip) only when not being edited.
if (!_focus.hasFocus && widget.value != _controller.text) {
_controller.text = widget.value;
}
}
@override
void dispose() {
_controller.dispose();
_focus.dispose();
super.dispose();
}
void _commit() {
final text = _controller.text.trim();
if (widget.numeric) {
final n = num.tryParse(text);
if (n == null) {
_controller.text = widget.value; // revert unparseable input
return;
}
var clamped = n;
final min = widget.field.min;
final max = widget.field.max;
if (min != null && clamped < min) clamped = min;
if (max != null && clamped > max) clamped = max;
// Preserve int vs double per the parsed text.
final out = clamped == clamped.roundToDouble() && !text.contains('.') ? clamped.toInt() : clamped;
_controller.text = '$out';
widget.onCommit(out);
} else {
widget.onCommit(text);
}
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Semantics(
textField: true,
label: widget.field.label,
excludeSemantics: true,
child: Container(
width: widget.numeric ? 88 : 180,
height: 26,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: tokens.panelBackground,
border: Border.all(color: _focus.hasFocus ? tokens.panelActiveBorder : tokens.dividerColor),
borderRadius: BorderRadius.circular(4),
),
child: EditableText(
controller: _controller,
focusNode: _focus,
style: TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontMono, color: tokens.globalForeground),
cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalTextMuted,
maxLines: 1,
onSubmitted: (_) => _commit(),
),
),
);
}
}
/// "Opens external file" affordance — a button that runs the field's command.
class _FileControl extends StatelessWidget {
const _FileControl({required this.field});
final SettingsField field;
@override
Widget build(BuildContext context) {
final commands = ClideKernel.of(context).commands;
return ClideButton(
label: field.label,
variant: ClideButtonVariant.subtle,
onPressed: field.fileCommand == null ? null : () => commands.execute(field.fileCommand!),
);
}
}
/// Reset-to-default control — a circular-arrow icon shown when the value
/// differs from the schema default.
class _ResetButton extends StatelessWidget {
const _ResetButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Semantics(
button: true,
label: 'Reset to default',
excludeSemantics: true,
child: ClideTappable(
cursor: SystemMouseCursors.click,
tooltip: 'Reset to default',
onTap: onTap,
builder: (ctx, hovered, _) => Padding(
padding: const EdgeInsets.only(left: 6),
child: ClideIcon(
PhosphorIcons.byName('arrow-counter-clockwise'),
size: 14,
color: hovered ? tokens.globalForeground : tokens.globalTextMuted,
),
),
),
);
}
}
+61 -22
View File
@@ -1,3 +1,4 @@
import 'package:clide/builtin/settings_ui/src/settings_category_view.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart';
@@ -7,19 +8,19 @@ import 'package:flutter/widgets.dart';
///
/// 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:
/// Material). It frames the two regions 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).
/// - the **category rail** on the left (interactive navigation lands in
/// T-447; the list is data-driven from the registered schemas), and
/// - the **scrolling carded panel** on the right (the schema field renderer
/// is [SettingsCategoryView], 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]).
/// Categories come from the kernel [SettingsRegistry]; until one registers a
/// schema the panel shows its empty state. 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 {
class SettingsModal extends StatefulWidget {
const SettingsModal({super.key, required this.onDismiss});
/// Closes the modal. Wired to the dialog router's `dismiss` by the
@@ -34,17 +35,26 @@ class SettingsModal extends StatelessWidget {
static const double _height = 560;
static const double _railWidth = 196;
@override
State<SettingsModal> createState() => _SettingsModalState();
}
class _SettingsModalState extends State<SettingsModal> {
/// Selected category id; null falls back to the first registered category.
/// The rail sets this in T-447.
String? _selectedId;
@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');
final title = i.string('modal.title', namespace: SettingsModal.ns, placeholder: 'Settings');
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
onDismiss();
widget.onDismiss();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
@@ -54,23 +64,23 @@ class SettingsModal extends StatelessWidget {
label: title,
explicitChildNodes: true,
child: ClideSurface(
width: _width,
height: _height,
width: SettingsModal._width,
height: SettingsModal._height,
color: tokens.modalSurfaceBackground,
border: tokens.modalSurfaceBorder,
borderRadius: BorderRadius.circular(6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Header(title: title, onClose: onDismiss),
_Header(title: title, onClose: widget.onDismiss),
const ClideDivider(),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(width: _railWidth, child: const _CategoryRail()),
const SizedBox(width: SettingsModal._railWidth, child: _CategoryRail()),
const ClideDivider(axis: Axis.vertical),
const Expanded(child: _SettingsPanel()),
Expanded(child: _SettingsPanel(selectedId: _selectedId)),
],
),
),
@@ -128,7 +138,10 @@ class _CloseButton extends StatelessWidget {
onTap: onTap,
builder: (ctx, hovered, pressed) => Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
decoration: BoxDecoration(
color: hovered ? tokens.listItemHoverBackground : null,
borderRadius: BorderRadius.circular(4),
),
child: ClideIcon(const CloseIcon(), size: 16, color: tokens.globalForeground),
),
),
@@ -136,8 +149,8 @@ class _CloseButton extends StatelessWidget {
}
}
/// Left rail. The category list is populated from registered schemas in
/// T-447; for now it shows only its section header.
/// Left rail. The interactive category list lands in T-447; for now it shows
/// only its section header.
class _CategoryRail extends StatelessWidget {
const _CategoryRail();
@@ -162,10 +175,36 @@ class _CategoryRail extends StatelessWidget {
}
}
/// Right panel. The schema-driven field renderer fills this in T-448; with
/// no category registered yet it shows the empty state.
/// Right panel — renders the selected category's schema (or the first
/// registered one) via [SettingsCategoryView]; the empty state shows when no
/// category is registered. The region recedes to panelBackground so the
/// panelHeader cards pop (surface.md).
class _SettingsPanel extends StatelessWidget {
const _SettingsPanel();
const _SettingsPanel({required this.selectedId});
final String? selectedId;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final registry = ClideKernel.of(context).settingsRegistry;
return ColoredBox(
color: tokens.panelBackground,
child: ListenableBuilder(
listenable: registry,
builder: (context, _) {
final categories = registry.categories;
if (categories.isEmpty) return const _EmptyState();
final selected = (selectedId == null ? null : registry.byId(selectedId!)) ?? categories.first;
return SettingsCategoryView(category: selected);
},
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
+11
View File
@@ -1,5 +1,6 @@
import 'package:clide/clide.dart';
import 'package:clide/kernel/src/panels/slot_id.dart';
import 'package:clide/kernel/src/settings_schema.dart';
import 'package:flutter/widgets.dart';
/// One atom contributed by a [ClideExtension]. Extensions declare N of
@@ -138,3 +139,13 @@ class LayoutSlot {
final double? maxSize;
final bool visible;
}
/// A category in the Settings panel (epic T-444). The kernel routes it into the
/// `SettingsRegistry`; `builtin.settings-ui` renders its [SettingsCategory]
/// schema into carded field rows (T-448). Categories are data — register one to
/// surface a new settings tab.
class SettingsCategoryContribution extends ContributionPoint {
const SettingsCategoryContribution({required super.id, required this.category});
final SettingsCategory category;
}
+2
View File
@@ -20,6 +20,8 @@ export 'src/log.dart';
export 'src/file_log_sink.dart';
export 'src/watchdog.dart';
export 'src/settings.dart';
export 'src/settings_schema.dart';
export 'src/settings_registry.dart';
export 'src/facade.dart';
export 'src/clipboard.dart';
export 'src/commands/keybindings.dart';
+8
View File
@@ -25,6 +25,7 @@ import 'package:clide/kernel/src/panels/registry.dart';
import 'package:clide/kernel/src/project.dart';
import 'package:clide/kernel/src/secrets.dart';
import 'package:clide/kernel/src/settings.dart';
import 'package:clide/kernel/src/settings_registry.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/tray.dart';
import 'package:flutter/foundation.dart';
@@ -55,6 +56,7 @@ class ExtensionManager extends ChangeNotifier {
required this.focus,
required this.project,
required this.ipc,
required this.settingsRegistry,
});
final Logger log;
@@ -81,6 +83,7 @@ class ExtensionManager extends ChangeNotifier {
final FocusTracker focus;
final ProjectManager project;
final DaemonClient ipc;
final SettingsRegistry settingsRegistry;
final Map<String, ClideExtension> _known = {};
final Set<String> _activated = {};
@@ -256,6 +259,9 @@ class ExtensionManager extends ChangeNotifier {
// Presets are consumed by the default-layout extension in its
// own activate(); nothing for the kernel to do here.
break;
case SettingsCategoryContribution s:
// register() throws on a duplicate id, rolling activation back.
settingsRegistry.register(s.category);
}
}
@@ -276,6 +282,8 @@ class ExtensionManager extends ChangeNotifier {
tray.remove(t.id);
case LayoutPresetContribution _:
break;
case SettingsCategoryContribution s:
settingsRegistry.unregister(s.category.id);
}
}
+9
View File
@@ -34,6 +34,7 @@ import 'package:clide/kernel/src/recent_files.dart';
import 'package:clide/kernel/src/scheduler.dart';
import 'package:clide/kernel/src/secrets.dart';
import 'package:clide/kernel/src/settings.dart';
import 'package:clide/kernel/src/settings_registry.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/loader.dart';
import 'package:clide/kernel/src/toolchain.dart';
@@ -81,10 +82,14 @@ class KernelServices {
required this.textZoom,
required this.toast,
required this.logRing,
required this.settingsRegistry,
});
final Logger log;
final SettingsStore settings;
/// Categories registered for the Settings panel (T-444).
final SettingsRegistry settingsRegistry;
final DaemonBus events;
final MessageBus messages;
@@ -155,6 +160,7 @@ class KernelServices {
final settings = SettingsStore(appDir: appDir, onError: (m) => log.warn('settings', m));
await settings.load();
final settingsRegistry = SettingsRegistry();
final i18n = I18n(loader: i18nLoader, log: log, defaultLocale: defaultLocale, initialLocale: initialLocale, availableLocales: availableLocales);
for (final ns in preloadNamespaces) {
@@ -238,6 +244,7 @@ class KernelServices {
focus: focus,
project: project,
ipc: ipc,
settingsRegistry: settingsRegistry,
);
if (autoStartDaemonClient) {
@@ -248,6 +255,7 @@ class KernelServices {
log: log,
logRing: logRing,
settings: settings,
settingsRegistry: settingsRegistry,
events: events,
messages: messages,
filterStates: filterStates,
@@ -287,6 +295,7 @@ class KernelServices {
await ipc.stop();
ipc.dispose();
settings.dispose();
settingsRegistry.dispose();
theme.dispose();
panels.dispose();
arrangement.dispose();
+37
View File
@@ -0,0 +1,37 @@
import 'package:clide/kernel/src/settings_schema.dart';
import 'package:flutter/foundation.dart';
/// Holds the [SettingsCategory] schemas subsystems register against the kernel
/// (via `SettingsCategoryContribution`, routed by the extension manager). The
/// settings panel reads this to build its rail + panels (T-447/T-448) and
/// rebuilds when the set changes.
class SettingsRegistry extends ChangeNotifier {
final Map<String, SettingsCategory> _byId = <String, SettingsCategory>{};
/// Registered categories, sorted by (priority, then case-insensitive title).
List<SettingsCategory> get categories {
final list = _byId.values.toList()
..sort((a, b) {
final p = a.priority.compareTo(b.priority);
return p != 0 ? p : a.title.toLowerCase().compareTo(b.title.toLowerCase());
});
return List.unmodifiable(list);
}
SettingsCategory? byId(String id) => _byId[id];
/// Register a category. Throws on a duplicate id — a collision is a wiring
/// bug that should roll the contributing extension's activation back, the
/// same way duplicate command/slot ids do.
void register(SettingsCategory category) {
if (_byId.containsKey(category.id)) {
throw StateError('duplicate settings category id: ${category.id}');
}
_byId[category.id] = category;
notifyListeners();
}
void unregister(String id) {
if (_byId.remove(id) != null) notifyListeners();
}
}
+103
View File
@@ -0,0 +1,103 @@
/// Schema model for the settings panel (T-448, epic T-444).
///
/// Pure data — no Flutter imports — so any subsystem can declare a category
/// without depending on the widget layer. The settings-ui renderer turns a
/// [SettingsCategory] into carded sections of field rows; each field binds to
/// a `SettingsStore` key and is read/written through the store.
library;
/// The control a [SettingsField] renders as.
enum SettingsFieldKind {
/// On/off boolean.
toggle,
/// One value chosen from [SettingsField.options].
select,
/// Free-text input.
text,
/// Numeric input (optionally bounded by [SettingsField.min]/[max]).
number,
/// A row that opens an external file/editor (e.g. `.editorconfig`) instead
/// of editing a value inline — the action is a command id, keeping the
/// schema widget-free.
file,
}
/// One choice in a [SettingsFieldKind.select] field.
class SettingsOption {
const SettingsOption({required this.value, required this.label});
/// Stored value.
final String value;
/// Human label shown in the picker.
final String label;
}
/// One editable setting. [key] is a `SettingsStore` key — its `app.`/
/// `project.`/`ext.` prefix determines the scope (and the per-field scope tag,
/// T-449). The renderer reads the current value with `store.get`, falling back
/// to [defaultValue] when unset, and writes edits with `store.set`.
class SettingsField {
const SettingsField({
required this.key,
required this.kind,
required this.label,
this.help,
this.defaultValue,
this.options = const [],
this.min,
this.max,
this.fileCommand,
});
final String key;
final SettingsFieldKind kind;
final String label;
/// Optional one-line help shown under the label.
final String? help;
/// Value shown / restored when the key is unset (reset-to-default target).
final Object? defaultValue;
/// Choices for [SettingsFieldKind.select].
final List<SettingsOption> options;
/// Optional inclusive bounds for [SettingsFieldKind.number].
final num? min;
final num? max;
/// For [SettingsFieldKind.file]: the command id the row's button invokes.
final String? fileCommand;
}
/// A carded group of fields (surface.md "sectioned cards"). [label] is the
/// small-caps header rendered just above the card.
class SettingsSection {
const SettingsSection({required this.label, required this.fields});
final String label;
final List<SettingsField> fields;
}
/// One settings category — a rail entry (T-447) plus the sections its panel
/// shows. Subsystems register these via `SettingsCategoryContribution`; the
/// renderer draws them.
class SettingsCategory {
const SettingsCategory({required this.id, required this.title, required this.sections, this.iconName, this.priority = 0});
final String id;
final String title;
/// Phosphor glyph name, resolved via `PhosphorIcons.byName` at render (T-314).
final String? iconName;
/// Rail ordering — lower sorts first; ties broken by [title].
final int priority;
final List<SettingsSection> sections;
}