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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user