feat(settings): Appearance category + custom-control escape hatch (T-452)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -159,16 +159,7 @@ class _FieldRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final raw = store.get<Object>(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(
|
||||
final label = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(field.label, color: tokens.globalForeground),
|
||||
@@ -178,8 +169,34 @@ class _FieldRow extends StatelessWidget {
|
||||
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<Object>(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: 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<IpcResponse> _pick(List<String> args) async {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export 'src/appearance_control.dart';
|
||||
export 'src/extension.dart';
|
||||
export 'src/settings_view.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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<String, ClideExtension> _known = {};
|
||||
final Set<String> _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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String, WidgetBuilder> _byId = <String, WidgetBuilder>{};
|
||||
|
||||
/// 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];
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))));
|
||||
|
||||
@@ -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<SettingsCategoryContribution>().firstWhere((c) => c.id == 'appearance').category;
|
||||
expect(cat.title, 'Appearance');
|
||||
expect(ext.contributions.whereType<SettingsControlContribution>().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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user