Introduce ClideSettings, a single widget-facing facade for the app's live user preferences, namespaced by concern: ClideSettings.fonts.monoOf(context) / .fonts.uiOf(context), ClideSettings.theme.of(context), ClideSettings.i18n.of(context). "Plumb once, use many." Fonts are carried by a new root-provided ClideSettingsScope (resolved from the font settings in root_shell, rebuilt on change); theme and i18n delegate to their existing live providers (ClideTheme / the I18n service) so there's one source of truth and their many consumers migrate incrementally rather than in a big-bang. Reads outside a scope fall back to the bundled font defaults, so a widget renders without a provider (isolated tests). Migrate ~93 monospace-font call sites across 33 files from the clideMonoFamily const to ClideSettings.fonts.monoOf(context) — pure refactor, identical family when no override is set. 11 context-less helper sites (markdown static spans, a few top-level/static builders) keep the const for now and are tracked in T-472. Records D-101; updates the ui-design skill's font-family rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
328 lines
12 KiB
Dart
328 lines
12 KiB
Dart
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';
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
/// The schema-driven Settings panel shell (T-445, epic T-444).
|
|
///
|
|
/// 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 epic fills in:
|
|
///
|
|
/// - 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).
|
|
///
|
|
/// 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 StatefulWidget {
|
|
const SettingsModal({super.key, required this.onDismiss});
|
|
|
|
/// Closes the modal. Wired to the dialog router's `dismiss` by the
|
|
/// opener (see `SettingsUiExtension`).
|
|
final VoidCallback onDismiss;
|
|
|
|
static const ns = 'builtin.settings-ui';
|
|
|
|
// The modal is a fixed-size desktop surface (the app is a desktop host);
|
|
// the right panel scrolls when its cards exceed the height (surface.md).
|
|
static const double _width = 760;
|
|
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;
|
|
|
|
/// Cross-category search query (T-450); non-empty swaps the panel to results.
|
|
String _query = '';
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final i = ClideKernel.of(context).i18n;
|
|
final tokens = ClideTheme.of(context).surface;
|
|
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) {
|
|
widget.onDismiss();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
},
|
|
child: Semantics(
|
|
container: true,
|
|
label: title,
|
|
explicitChildNodes: true,
|
|
child: ClideSurface(
|
|
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: widget.onDismiss),
|
|
const ClideDivider(),
|
|
Expanded(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
SizedBox(
|
|
width: SettingsModal._railWidth,
|
|
child: _CategoryRail(
|
|
selectedId: _selectedId,
|
|
query: _query,
|
|
onSelect: (id) => setState(() => _selectedId = id),
|
|
onQueryChanged: (q) => setState(() => _query = q),
|
|
),
|
|
),
|
|
const ClideDivider(axis: Axis.vertical),
|
|
Expanded(
|
|
child: ColoredBox(
|
|
color: tokens.panelBackground,
|
|
child: _query.trim().isEmpty ? _SettingsPanel(selectedId: _selectedId) : SettingsSearchResults(query: _query),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Title bar: gear glyph + "Settings" on the left, close ✕ on the right.
|
|
class _Header extends StatelessWidget {
|
|
const _Header({required this.title, required this.onClose});
|
|
|
|
final String title;
|
|
final VoidCallback onClose;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = ClideTheme.of(context).surface;
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
|
|
child: Row(
|
|
children: [
|
|
ClideIcon(const GearIcon(), size: 16, color: tokens.globalForeground),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: ClideText(title, fontSize: 15, fontWeight: FontWeight.w600)),
|
|
_CloseButton(onTap: onClose),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CloseButton extends StatelessWidget {
|
|
const _CloseButton({required this.onTap});
|
|
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = ClideTheme.of(context).surface;
|
|
final i = ClideKernel.of(context).i18n;
|
|
final label = i.string('modal.close', namespace: SettingsModal.ns, placeholder: 'Close');
|
|
final hint = i.string('modal.close.hint', namespace: SettingsModal.ns, placeholder: 'Close settings');
|
|
return Semantics(
|
|
button: true,
|
|
label: label,
|
|
hint: hint,
|
|
onTap: onTap,
|
|
excludeSemantics: true,
|
|
child: ClideTappable(
|
|
cursor: SystemMouseCursors.click,
|
|
onTap: onTap,
|
|
builder: (ctx, hovered, pressed) => Container(
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
|
|
child: ClideIcon(const CloseIcon(), size: 16, color: tokens.globalForeground),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Left rail — a cross-category search box atop the registered categories,
|
|
/// data-driven from the [SettingsRegistry]. Selecting one swaps the panel
|
|
/// (T-447); while searching, each row shows its match count and zero-match
|
|
/// rows dim (T-450).
|
|
class _CategoryRail extends StatelessWidget {
|
|
const _CategoryRail({required this.selectedId, required this.query, required this.onSelect, required this.onQueryChanged});
|
|
|
|
/// The modal's chosen category id (null → the first category).
|
|
final String? selectedId;
|
|
final String query;
|
|
final void Function(String id) onSelect;
|
|
final ValueChanged<String> onQueryChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = ClideTheme.of(context).surface;
|
|
final i = ClideKernel.of(context).i18n;
|
|
final registry = ClideKernel.of(context).settingsRegistry;
|
|
final searching = query.trim().isNotEmpty;
|
|
return ListenableBuilder(
|
|
listenable: registry,
|
|
builder: (context, _) {
|
|
final categories = registry.categories;
|
|
final effectiveId = selectedId ?? (categories.isEmpty ? null : categories.first.id);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
|
|
child: ClideFilterBox(
|
|
hint: i.string('search.hint', namespace: SettingsModal.ns, placeholder: 'Search settings…'),
|
|
onChanged: onQueryChanged,
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 2, 12, 6),
|
|
child: ClideText(
|
|
i.string('rail.header', namespace: SettingsModal.ns, placeholder: 'Categories'),
|
|
fontSize: clideFontCaption,
|
|
color: tokens.sidebarSectionHeader,
|
|
fontFamily: ClideSettings.fonts.monoOf(context),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
for (final c in categories)
|
|
_RailRow(
|
|
category: c,
|
|
selected: !searching && c.id == effectiveId,
|
|
matchCount: searching ? settingsMatchCount(c, query) : null,
|
|
onTap: () => onSelect(c.id),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// One category row: optional glyph + title, accent left-stripe + surfaceHi
|
|
/// fill when selected (surface.md side panels). When [matchCount] is non-null
|
|
/// (searching) it shows the count and dims to muted on zero matches (T-450).
|
|
class _RailRow extends StatelessWidget {
|
|
const _RailRow({required this.category, required this.selected, required this.matchCount, required this.onTap});
|
|
|
|
final SettingsCategory category;
|
|
final bool selected;
|
|
final int? matchCount;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = ClideTheme.of(context).surface;
|
|
final dim = matchCount == 0;
|
|
final fg = dim ? tokens.globalTextMuted : (selected ? tokens.globalForeground : tokens.sidebarForeground);
|
|
return Semantics(
|
|
button: true,
|
|
selected: selected,
|
|
label: category.title,
|
|
excludeSemantics: true,
|
|
child: ClideTappable(
|
|
cursor: SystemMouseCursors.click,
|
|
onTap: onTap,
|
|
builder: (ctx, hovered, _) => Container(
|
|
// color: null => no paint (shows the modal surface behind the rail).
|
|
color: selected ? tokens.sidebarItemSelected : (hovered ? tokens.sidebarItemHover : null),
|
|
child: Row(
|
|
children: [
|
|
// Accent left-stripe; the 2px slot is reserved either way so the
|
|
// row never shifts. Painted only when selected — no color literal.
|
|
SizedBox(width: 2, child: selected ? ColoredBox(color: tokens.panelActiveBorder) : null),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(10, 7, 12, 7),
|
|
child: Row(
|
|
children: [
|
|
if (category.iconName != null) ...[ClideIcon(PhosphorIcons.byName(category.iconName!), size: 15, color: fg), const SizedBox(width: 8)],
|
|
Expanded(
|
|
child: ClideText(category.title, color: fg, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
),
|
|
if (matchCount != null) ClideText('$matchCount', fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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 (set by the
|
|
/// modal) so the panelHeader cards pop (surface.md).
|
|
class _SettingsPanel extends StatelessWidget {
|
|
const _SettingsPanel({required this.selectedId});
|
|
|
|
final String? selectedId;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final registry = ClideKernel.of(context).settingsRegistry;
|
|
return 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) {
|
|
final tokens = ClideTheme.of(context).surface;
|
|
final i = ClideKernel.of(context).i18n;
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: ClideText(
|
|
i.string('panel.empty', namespace: SettingsModal.ns, placeholder: 'No settings categories are registered yet.'),
|
|
color: tokens.globalTextMuted,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|