From 757d6f71fa76d228c23b1a31708c7bf3bd11293a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 17 Jun 2026 13:04:52 +0200 Subject: [PATCH] feat(settings): Appearance category + custom-control escape hatch (T-452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the one bespoke control the schema engine defers to. New SettingsControlContribution routes a WidgetBuilder into a kernel SettingsControlRegistry under a customId; a SettingsFieldKind.custom field names that id, and the renderer draws the registered widget full-width (label on top, no scope tag — the control owns its own apply + scope). The theme-picker extension uses it: an Appearance category whose theme field is custom, backed by AppearanceThemeControl — base-theme chips + a high-contrast toggle that apply live through ThemeController (persisted by theme_persistence). Reuses the shared theme_families helpers. Tests: control registry (register/dup/unregister), the renderer's custom-field path, and the Appearance contribution + live theme apply. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 2 + .pql/changelog/tickets/2026-06.sql | 2 + CHANGELOG.md | 5 + .../src/settings_category_view.dart | 48 +++++-- .../theme_picker/src/appearance_control.dart | 126 ++++++++++++++++++ lib/builtin/theme_picker/src/extension.dart | 28 ++++ lib/builtin/theme_picker/theme_picker.dart | 1 + lib/extension/src/contribution.dart | 12 ++ lib/kernel/kernel.dart | 1 + lib/kernel/src/extensions_manager.dart | 7 + lib/kernel/src/facade.dart | 8 ++ lib/kernel/src/settings_control_registry.dart | 26 ++++ lib/kernel/src/settings_schema.dart | 11 ++ .../settings_ui/category_view_test.dart | 19 +++ test/builtin/theme_picker/widget_test.dart | 16 +++ .../src/settings_control_registry_test.dart | 26 ++++ 16 files changed, 324 insertions(+), 14 deletions(-) create mode 100644 lib/builtin/theme_picker/src/appearance_control.dart create mode 100644 lib/kernel/src/settings_control_registry.dart create mode 100644 test/kernel/src/settings_control_registry_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 493eb6c4..9470e499 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -5909,3 +5909,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A6K9B60ADTFBZA6NBVJFM', 'status', 'in_progress', 'done', NULL, '2026-06-17 10:31:23', '2026-06-17 10:31:23.935', '2026-06-17 10:31:23.935', NULL, '9228abe9a3eb9b340c91717b4dade863', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'status', 'backlog', 'in_progress', NULL, '2026-06-17 10:44:25', '2026-06-17 10:44:25.018', '2026-06-17 10:44:25.018', NULL, '4626e79f524f63e003c0f93511e8cdce', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'status', 'in_progress', 'done', NULL, '2026-06-17 10:48:30', '2026-06-17 10:48:30.719', '2026-06-17 10:48:30.719', NULL, '3758ec372c9635bfd7756a8939f4b329', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A4MY8DF6NB0KNV96RQADR', 'status', 'backlog', 'in_progress', NULL, '2026-06-17 10:56:30', '2026-06-17 10:56:30.289', '2026-06-17 10:56:30.289', NULL, 'f4ca163e49ee68804c6d66bc059a6dfd', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A4MY8DF6NB0KNV96RQADR', 'status', 'in_progress', 'done', NULL, '2026-06-17 11:04:11', '2026-06-17 11:04:11.562', '2026-06-17 11:04:11.562', NULL, '10855e5b574013427ec9a6872c674d1d', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index aed3793c..36e21cb8 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -7177,3 +7177,5 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A6K9B60ADTFBZA6NBVJFM', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Activity settings category (fold level + display)', 'Register the Claude-activity settings schema: activity fold level (T-183) and related conversation display options, via the standard field renderer (T-448).', 'done', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:57', '2026-06-17 10:31:23.935', NULL, 'a7ddd9362097e6a2f7df764faf7cbf79', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Keymap settings category (preset select + options)', 'Register the keymap settings schema: preset enum select (default / vscode / jetbrains / vim — T-115, T-64, T-65, T-66) wired to KeymapService.setPreset, plus related options (e.g. sequence-timeout). Reuses the schema field renderer (T-448).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:11', '2026-06-17 10:44:25.018', NULL, 'c5d65c432709c0ffad00237e82b7a23c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Keymap settings category (preset select + options)', 'Register the keymap settings schema: preset enum select (default / vscode / jetbrains / vim — T-115, T-64, T-65, T-66) wired to KeymapService.setPreset, plus related options (e.g. sequence-timeout). Reuses the schema field renderer (T-448).', 'done', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:11', '2026-06-17 10:48:30.719', NULL, '4678d116aadfaea65526200caf8e0ece', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A4MY8DF6NB0KNV96RQADR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Appearance settings category — theme picker control', 'The one custom control beyond the four field patterns: a theme-picker swatch grid with live mini-previews of the bundled themes (D-44 / D-69), selected state, and import-custom-YAML; plus high-contrast toggle and text-size. Coordinate with / extend the existing builtin.theme-picker (T-8) and per-repo theme persistence (T-293). Design pinned in T-302. Wireframe: docs/design/wireframes/settings/settings-appearance.png.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:41', '2026-06-17 10:56:30.288', NULL, 'e7d925e921ee3c791398c6b15ebc00f5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A4MY8DF6NB0KNV96RQADR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Appearance settings category — theme picker control', 'The one custom control beyond the four field patterns: a theme-picker swatch grid with live mini-previews of the bundled themes (D-44 / D-69), selected state, and import-custom-YAML; plus high-contrast toggle and text-size. Coordinate with / extend the existing builtin.theme-picker (T-8) and per-repo theme persistence (T-293). Design pinned in T-302. Wireframe: docs/design/wireframes/settings/settings-appearance.png.', 'done', 'medium', NULL, NULL, NULL, '2026-06-16 11:19:41', '2026-06-17 11:04:11.562', NULL, 'e3bf96df622cc32d69465ac3ec06d269', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index 37141f51..afe14527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. JetBrains); picking one switches the active keymap live via the preset command. (T-451) +- **Settings → Appearance category.** A theme picker in the panel — base-theme + chips + a high-contrast toggle, applied live. Adds the engine's custom-control + escape hatch (`SettingsControlContribution` / `SettingsControlRegistry`) for + one-off controls the generic field kinds can't express. (T-452) + ### Changed - **Theme picker relabelled "Theme…".** The ⌘K theme picker's command title diff --git a/lib/builtin/settings_ui/src/settings_category_view.dart b/lib/builtin/settings_ui/src/settings_category_view.dart index 685ae11d..16889b59 100644 --- a/lib/builtin/settings_ui/src/settings_category_view.dart +++ b/lib/builtin/settings_ui/src/settings_category_view.dart @@ -159,27 +159,44 @@ class _FieldRow extends StatelessWidget { @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; + final label = 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), + ), + ], + ); + + // Custom controls own their layout, persistence, and scope — render them + // full-width under the label rather than in the narrow control slot. + if (field.kind == SettingsFieldKind.custom) { + final id = field.customId; + final builder = id == null ? null : ClideKernel.of(context).settingsControlRegistry.builderFor(id); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + label, + const SizedBox(height: 10), + builder?.call(context) ?? const SizedBox.shrink(), + ], + ), + ); + } + final raw = store.get(field.key); final effective = raw ?? 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), - ), - ], - ), - ), + Expanded(child: label), const SizedBox(width: 16), _Control(field: field, value: effective, store: store), const SizedBox(width: 10), @@ -225,6 +242,9 @@ class _Control extends StatelessWidget { return _EditControl(field: field, value: value?.toString() ?? '', numeric: true, onCommit: _set); case SettingsFieldKind.file: return _FileControl(field: field); + case SettingsFieldKind.custom: + // Custom fields are rendered full-width by _FieldRow; never here. + return const SizedBox.shrink(); } } } diff --git a/lib/builtin/theme_picker/src/appearance_control.dart b/lib/builtin/theme_picker/src/appearance_control.dart new file mode 100644 index 00000000..a3b1b8f5 --- /dev/null +++ b/lib/builtin/theme_picker/src/appearance_control.dart @@ -0,0 +1,126 @@ +import 'package:clide/builtin/theme_picker/src/theme_families.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +/// The Appearance category's custom theme control (T-452) — the one bespoke +/// settings control the schema engine defers to (registered as +/// `SettingsControlContribution('theme.picker')`). +/// +/// Base themes render as selectable chips; a High-contrast toggle maps to the +/// `-hc` sibling (D-69) via the shared [theme_families] helpers. Selecting +/// applies live through [ThemeController] (which persists via theme_persistence) +/// — so this control owns its own apply + scope, unlike schema-keyed fields. +class AppearanceThemeControl extends StatelessWidget { + const AppearanceThemeControl({super.key}); + + static const ns = 'builtin.theme-picker'; + + @override + Widget build(BuildContext context) { + final controller = ClideKernel.of(context).theme; + final i = ClideKernel.of(context).i18n; + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final themes = baseThemes(controller.available); + final currentBase = baseThemeName(controller.currentName); + final hc = isHcName(controller.currentName); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final t in themes) + _ThemeChip( + label: t.displayName, + selected: t.name == currentBase, + onTap: () => controller.select(resolveThemeName(controller.available, t.name, highContrast: hc)), + ), + ], + ), + const SizedBox(height: 12), + _HcToggle( + checked: hc, + label: i.string('toggle.highContrast', namespace: ns, placeholder: 'High contrast'), + onTap: () => controller.select(resolveThemeName(controller.available, currentBase, highContrast: !hc)), + ), + ], + ); + }, + ); + } +} + +class _ThemeChip extends StatelessWidget { + const _ThemeChip({required this.label, required this.selected, required this.onTap}); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Semantics( + button: true, + selected: selected, + label: label, + excludeSemantics: true, + child: ClideTappable( + cursor: SystemMouseCursors.click, + onTap: onTap, + builder: (ctx, hovered, _) => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: selected ? tokens.buttonActiveBackground : (hovered ? tokens.listItemHoverBackground : tokens.panelBackground), + border: Border.all(color: selected ? tokens.panelActiveBorder : tokens.dividerColor), + borderRadius: BorderRadius.circular(5), + ), + child: ClideText(label, color: selected ? tokens.globalBackground : tokens.globalForeground), + ), + ), + ); + } +} + +class _HcToggle extends StatelessWidget { + const _HcToggle({required this.checked, required this.label, required this.onTap}); + + final bool checked; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Semantics( + checked: checked, + label: label, + excludeSemantics: true, + child: ClideTappable( + cursor: SystemMouseCursors.click, + onTap: onTap, + builder: (ctx, hovered, _) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + 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, + ), + const SizedBox(width: 8), + ClideText(label, color: tokens.listItemForeground), + ], + ), + ), + ); + } +} diff --git a/lib/builtin/theme_picker/src/extension.dart b/lib/builtin/theme_picker/src/extension.dart index a2b4462f..69e4e79c 100644 --- a/lib/builtin/theme_picker/src/extension.dart +++ b/lib/builtin/theme_picker/src/extension.dart @@ -1,7 +1,9 @@ import 'package:clide/clide.dart'; +import 'package:clide/builtin/theme_picker/src/appearance_control.dart'; import 'package:clide/builtin/theme_picker/src/settings_view.dart'; import 'package:clide/builtin/theme_picker/src/theme_status_item.dart'; import 'package:clide/extension/extension.dart'; +import 'package:clide/kernel/kernel.dart'; class ThemePickerExtension extends ClideExtension { @override @@ -30,6 +32,32 @@ class ThemePickerExtension extends ClideExtension { // priority >= 100 places it in the right group; registered after // ipc-status so it sits to its right. StatusItemContribution(id: 'theme-picker.switcher', priority: 110, build: (_) => const ThemeSwitcherStatusItem()), + // Appearance settings category (T-452) — the theme picker as the schema + // engine's one custom control, registered against the control registry. + SettingsControlContribution(id: 'theme-picker.appearance-control', customId: 'theme.picker', builder: (_) => const AppearanceThemeControl()), + const SettingsCategoryContribution( + id: 'appearance', + category: SettingsCategory( + id: 'appearance', + title: 'Appearance', + iconName: 'palette', + priority: 10, + sections: [ + SettingsSection( + label: 'Theme', + fields: [ + SettingsField( + key: 'app.theme', + kind: SettingsFieldKind.custom, + label: 'Theme', + help: 'Color theme; high contrast switches to the accessible variant.', + customId: 'theme.picker', + ), + ], + ), + ], + ), + ), ]; Future _pick(List args) async { diff --git a/lib/builtin/theme_picker/theme_picker.dart b/lib/builtin/theme_picker/theme_picker.dart index a0ea0100..45a2da56 100644 --- a/lib/builtin/theme_picker/theme_picker.dart +++ b/lib/builtin/theme_picker/theme_picker.dart @@ -1,2 +1,3 @@ +export 'src/appearance_control.dart'; export 'src/extension.dart'; export 'src/settings_view.dart'; diff --git a/lib/extension/src/contribution.dart b/lib/extension/src/contribution.dart index 9fca481f..57b6b2f7 100644 --- a/lib/extension/src/contribution.dart +++ b/lib/extension/src/contribution.dart @@ -149,3 +149,15 @@ class SettingsCategoryContribution extends ContributionPoint { final SettingsCategory category; } + +/// A bespoke widget for a `SettingsFieldKind.custom` field (T-452). The kernel +/// routes it into the `SettingsControlRegistry` under [customId]; the settings +/// renderer draws it when a field names that [customId]. Lets a subsystem ship +/// a one-off control (e.g. the theme picker) without leaking widgets into the +/// pure-data schema. +class SettingsControlContribution extends ContributionPoint { + const SettingsControlContribution({required super.id, required this.customId, required this.builder}); + + final String customId; + final WidgetBuilder builder; +} diff --git a/lib/kernel/kernel.dart b/lib/kernel/kernel.dart index 9e30a22d..3741fc27 100644 --- a/lib/kernel/kernel.dart +++ b/lib/kernel/kernel.dart @@ -22,6 +22,7 @@ export 'src/watchdog.dart'; export 'src/settings.dart'; export 'src/settings_schema.dart'; export 'src/settings_registry.dart'; +export 'src/settings_control_registry.dart'; export 'src/facade.dart'; export 'src/clipboard.dart'; export 'src/commands/keybindings.dart'; diff --git a/lib/kernel/src/extensions_manager.dart b/lib/kernel/src/extensions_manager.dart index 0220390a..41ef72ee 100644 --- a/lib/kernel/src/extensions_manager.dart +++ b/lib/kernel/src/extensions_manager.dart @@ -26,6 +26,7 @@ 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/settings_control_registry.dart'; import 'package:clide/kernel/src/theme/controller.dart'; import 'package:clide/kernel/src/tray.dart'; import 'package:flutter/foundation.dart'; @@ -57,6 +58,7 @@ class ExtensionManager extends ChangeNotifier { required this.project, required this.ipc, required this.settingsRegistry, + required this.settingsControlRegistry, }); final Logger log; @@ -84,6 +86,7 @@ class ExtensionManager extends ChangeNotifier { final ProjectManager project; final DaemonClient ipc; final SettingsRegistry settingsRegistry; + final SettingsControlRegistry settingsControlRegistry; final Map _known = {}; final Set _activated = {}; @@ -262,6 +265,8 @@ class ExtensionManager extends ChangeNotifier { case SettingsCategoryContribution s: // register() throws on a duplicate id, rolling activation back. settingsRegistry.register(s.category); + case SettingsControlContribution s: + settingsControlRegistry.register(s.customId, s.builder); } } @@ -284,6 +289,8 @@ class ExtensionManager extends ChangeNotifier { break; case SettingsCategoryContribution s: settingsRegistry.unregister(s.category.id); + case SettingsControlContribution s: + settingsControlRegistry.unregister(s.customId); } } diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart index 210a6217..9a6debf5 100644 --- a/lib/kernel/src/facade.dart +++ b/lib/kernel/src/facade.dart @@ -35,6 +35,7 @@ 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/settings_control_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'; @@ -83,6 +84,7 @@ class KernelServices { required this.toast, required this.logRing, required this.settingsRegistry, + required this.settingsControlRegistry, }); final Logger log; @@ -90,6 +92,9 @@ class KernelServices { /// Categories registered for the Settings panel (T-444). final SettingsRegistry settingsRegistry; + + /// Bespoke widgets for custom settings fields (T-452). + final SettingsControlRegistry settingsControlRegistry; final DaemonBus events; final MessageBus messages; @@ -161,6 +166,7 @@ class KernelServices { final settings = SettingsStore(appDir: appDir, onError: (m) => log.warn('settings', m)); await settings.load(); final settingsRegistry = SettingsRegistry(); + final settingsControlRegistry = SettingsControlRegistry(); final i18n = I18n(loader: i18nLoader, log: log, defaultLocale: defaultLocale, initialLocale: initialLocale, availableLocales: availableLocales); for (final ns in preloadNamespaces) { @@ -245,6 +251,7 @@ class KernelServices { project: project, ipc: ipc, settingsRegistry: settingsRegistry, + settingsControlRegistry: settingsControlRegistry, ); if (autoStartDaemonClient) { @@ -256,6 +263,7 @@ class KernelServices { logRing: logRing, settings: settings, settingsRegistry: settingsRegistry, + settingsControlRegistry: settingsControlRegistry, events: events, messages: messages, filterStates: filterStates, diff --git a/lib/kernel/src/settings_control_registry.dart b/lib/kernel/src/settings_control_registry.dart new file mode 100644 index 00000000..a8df50b5 --- /dev/null +++ b/lib/kernel/src/settings_control_registry.dart @@ -0,0 +1,26 @@ +import 'package:flutter/widgets.dart'; + +/// Holds the bespoke widgets that draw [SettingsFieldKind.custom] fields +/// (T-452). A subsystem registers a builder under a `customId` (via +/// `SettingsControlContribution`, routed by the extension manager); the +/// settings renderer looks it up when it meets a custom field. +/// +/// Controls register at activation, before any settings modal opens, so this +/// is a plain registry — no change notification needed. +class SettingsControlRegistry { + final Map _byId = {}; + + /// Register the [builder] for [customId]. Throws on a duplicate id so a + /// collision rolls the contributing extension's activation back. + void register(String customId, WidgetBuilder builder) { + if (_byId.containsKey(customId)) { + throw StateError('duplicate settings control id: $customId'); + } + _byId[customId] = builder; + } + + void unregister(String customId) => _byId.remove(customId); + + /// The builder for [customId], or null when none is registered. + WidgetBuilder? builderFor(String customId) => _byId[customId]; +} diff --git a/lib/kernel/src/settings_schema.dart b/lib/kernel/src/settings_schema.dart index 05b23e1c..270c2c36 100644 --- a/lib/kernel/src/settings_schema.dart +++ b/lib/kernel/src/settings_schema.dart @@ -24,6 +24,12 @@ enum SettingsFieldKind { /// of editing a value inline — the action is a command id, keeping the /// schema widget-free. file, + + /// A bespoke control rendered by a widget the owning subsystem registers + /// (via `SettingsControlContribution`) under [SettingsField.customId]. Keeps + /// the schema widget-free while allowing the one-off controls the generic + /// kinds can't express (e.g. the Appearance theme picker). + custom, } /// One choice in a [SettingsFieldKind.select] field. @@ -53,6 +59,7 @@ class SettingsField { this.max, this.fileCommand, this.applyCommandPrefix, + this.customId, }); final String key; @@ -81,6 +88,10 @@ class SettingsField { /// persists). The current value is still read from [key], so the scope tag /// and selection still work. Example: `'keymap.preset.'` → `keymap.preset.vim`. final String? applyCommandPrefix; + + /// For [SettingsFieldKind.custom]: the id the renderer looks up in the + /// `SettingsControlRegistry` to find the widget that draws this field. + final String? customId; } /// A carded group of fields (surface.md "sectioned cards"). [label] is the diff --git a/test/builtin/settings_ui/category_view_test.dart b/test/builtin/settings_ui/category_view_test.dart index 9e67ec03..d9d7402f 100644 --- a/test/builtin/settings_ui/category_view_test.dart +++ b/test/builtin/settings_ui/category_view_test.dart @@ -149,6 +149,25 @@ void main() { }); }); + group('custom field (T-452)', () { + testWidgets('renders the control registered for its customId, full-width with its label', (tester) async { + f.services.settingsControlRegistry.register('probe', (_) => const ClideText('PROBE-CONTROL')); + const cat = SettingsCategory( + id: 'c', + title: 'C', + sections: [ + SettingsSection( + label: 'S', + fields: [SettingsField(key: 'app.c.x', kind: SettingsFieldKind.custom, label: 'Custom', customId: 'probe')], + ), + ], + ); + await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: cat)))); + expect(find.text('PROBE-CONTROL'), findsOneWidget); + expect(find.text('Custom'), findsOneWidget); + }); + }); + group('scope tag (T-449)', () { testWidgets('an unset field shows the Default scope tag', (tester) async { await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: _category)))); diff --git a/test/builtin/theme_picker/widget_test.dart b/test/builtin/theme_picker/widget_test.dart index 41f972d0..a19ed5ca 100644 --- a/test/builtin/theme_picker/widget_test.dart +++ b/test/builtin/theme_picker/widget_test.dart @@ -187,5 +187,21 @@ void main() { expect(resp.ok, isTrue); expect(resp.data['selected'], 'forest'); }); + + test('contributes an Appearance category + theme.picker control (T-452)', () { + final ext = ThemePickerExtension(); + final cat = ext.contributions.whereType().firstWhere((c) => c.id == 'appearance').category; + expect(cat.title, 'Appearance'); + expect(ext.contributions.whereType().any((c) => c.customId == 'theme.picker'), isTrue); + }); + + testWidgets('AppearanceThemeControl lists base themes and applies a pick (T-452)', (tester) async { + await tester.pumpWidget(harness(f, const SizedBox(width: 420, child: AppearanceThemeControl()))); + expect(find.text('summer-night'), findsOneWidget); + expect(find.text('forest'), findsOneWidget); + await tester.tap(find.text('forest')); + await tester.pump(); + expect(f.services.theme.currentName, 'forest'); + }); }); } diff --git a/test/kernel/src/settings_control_registry_test.dart b/test/kernel/src/settings_control_registry_test.dart new file mode 100644 index 00000000..0b608cac --- /dev/null +++ b/test/kernel/src/settings_control_registry_test.dart @@ -0,0 +1,26 @@ +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('SettingsControlRegistry', () { + Widget builder(BuildContext _) => const SizedBox.shrink(); + + test('register exposes a builder by id', () { + final r = SettingsControlRegistry()..register('theme.picker', builder); + expect(r.builderFor('theme.picker'), isNotNull); + expect(r.builderFor('missing'), isNull); + }); + + test('duplicate id throws', () { + final r = SettingsControlRegistry()..register('dup', builder); + expect(() => r.register('dup', builder), throwsStateError); + }); + + test('unregister removes the builder', () { + final r = SettingsControlRegistry()..register('x', builder); + r.unregister('x'); + expect(r.builderFor('x'), isNull); + }); + }); +}