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:
2026-06-17 13:04:52 +02:00
co-authored by Claude Opus 4.8
parent 727fe8fdd4
commit 757d6f71fa
16 changed files with 324 additions and 14 deletions
@@ -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 {