feat(settings): cross-category settings search (T-450)

A search box (ClideFilterBox) atop the rail filters fields across every
registered category. While searching, the panel swaps to a results view that
groups the matching fields under category subheaders — rendered with the same
carded rows and editable inline — and each rail row shows its match count with
zero-match categories dimmed. Completes the settings-UI infra spine (T-444):
shell, engine, rail, scope tags, search.

Tests: search filters across categories, hides non-matches, and surfaces the
per-category rail count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 12:24:24 +02:00
co-authored by Claude Opus 4.8
parent f56ad88439
commit f643d6e747
12 changed files with 184 additions and 68 deletions
@@ -10,8 +10,8 @@ const _settingsNs = 'builtin.settings-ui';
/// `SettingsStore` key — read with `get` (falling back to the schema default),
/// written with `set` on edit. Rebuilds live as the store changes.
///
/// Per-field scope tags (T-449) and cross-category search (T-450) layer onto
/// the row in their own tickets; the trailing slot here is the reset control.
/// Each row ends in a per-field scope tag (T-449) whose menu also resets the
/// value; cross-category search lives in [SettingsSearchResults] (T-450).
/// Carded layout follows ui-design `surface.md` ("sectioned cards").
class SettingsCategoryView extends StatelessWidget {
const SettingsCategoryView({super.key, required this.category});
@@ -28,15 +28,88 @@ class SettingsCategoryView extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final section in category.sections) _SectionCard(section: section, store: store),
],
children: [for (final section in category.sections) _SectionCard(section: section, store: store)],
),
),
);
}
}
/// True when [field] matches [query] (case-insensitive; label or help). An
/// empty query matches everything.
bool settingsFieldMatches(SettingsField field, String query) {
final q = query.trim().toLowerCase();
if (q.isEmpty) return true;
return field.label.toLowerCase().contains(q) || (field.help?.toLowerCase().contains(q) ?? false);
}
/// [category]'s sections holding only the fields matching [query]; empty
/// sections are dropped.
List<SettingsSection> settingsMatchingSections(SettingsCategory category, String query) {
final out = <SettingsSection>[];
for (final s in category.sections) {
final fields = s.fields.where((f) => settingsFieldMatches(f, query)).toList();
if (fields.isNotEmpty) out.add(SettingsSection(label: s.label, fields: fields));
}
return out;
}
/// How many fields in [category] match [query] (the rail's per-category count).
int settingsMatchCount(SettingsCategory category, String query) =>
category.sections.fold(0, (n, s) => n + s.fields.where((f) => settingsFieldMatches(f, query)).length);
/// Cross-category search results (T-450): every matching field across ALL
/// registered categories, grouped under a category subheader, rendered with the
/// same carded field rows and editable inline.
class SettingsSearchResults extends StatelessWidget {
const SettingsSearchResults({super.key, required this.query});
final String query;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final i = ClideKernel.of(context).i18n;
final store = ClideKernel.of(context).settings;
final registry = ClideKernel.of(context).settingsRegistry;
return ListenableBuilder(
listenable: store,
builder: (context, _) {
final blocks = <Widget>[];
for (final c in registry.categories) {
final sections = settingsMatchingSections(c, query);
if (sections.isEmpty) continue;
blocks.add(
Padding(
padding: EdgeInsets.only(top: blocks.isEmpty ? 0 : 14, bottom: 8),
child: ClideText(c.title, fontSize: 13, fontWeight: FontWeight.w600, color: tokens.globalForeground),
),
);
for (final s in sections) {
blocks.add(_SectionCard(section: s, store: store));
}
}
if (blocks.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: ClideText(
i.string('search.empty', namespace: _settingsNs, placeholder: 'No settings match your search.'),
color: tokens.globalTextMuted,
textAlign: TextAlign.center,
),
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: blocks),
);
},
);
}
}
/// One section: a small-caps header above an elevated card of field rows.
class _SectionCard extends StatelessWidget {
const _SectionCard({required this.section, required this.store});
@@ -54,12 +127,7 @@ class _SectionCard extends StatelessWidget {
children: [
Padding(
padding: const EdgeInsets.only(left: 2, bottom: 6),
child: ClideText(
section.label.toUpperCase(),
fontSize: clideFontCaption,
color: tokens.sidebarSectionHeader,
fontFamily: clideMonoFamily,
),
child: ClideText(section.label.toUpperCase(), fontSize: clideFontCaption, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
),
ClideSurface(
// Card surface (surface.md): panelHeader resolves to the `surface`
@@ -71,10 +139,7 @@ class _SectionCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < section.fields.length; i++) ...[
if (i > 0) const ClideDivider(),
_FieldRow(field: section.fields[i], store: store),
],
for (var i = 0; i < section.fields.length; i++) ...[if (i > 0) const ClideDivider(), _FieldRow(field: section.fields[i], store: store)],
],
),
),
@@ -399,8 +464,7 @@ class _ScopeTagState extends State<_ScopeTag> {
super.dispose();
}
String _ns(String key, String fallback) =>
ClideKernel.of(context).i18n.string(key, namespace: _settingsNs, placeholder: fallback);
String _ns(String key, String fallback) => ClideKernel.of(context).i18n.string(key, namespace: _settingsNs, placeholder: fallback);
({String glyph, Color color, String tip, String label}) _appearance(SettingsScope? layer) {
final tokens = ClideTheme.of(context).surface;
@@ -452,12 +516,7 @@ class _ScopeTagState extends State<_ScopeTag> {
overlayBuilder: (ctx, c) => ClideMenu(
onClose: c.close,
entries: [
for (final layer in layers)
ClideMenuItem(
label: _appearance(layer).label,
active: layer == current,
onSelect: () => _moveTo(layer),
),
for (final layer in layers) ClideMenuItem(label: _appearance(layer).label, active: layer == current, onSelect: () => _moveTo(layer)),
const ClideMenuSeparator(),
ClideMenuItem(label: _ns('scope.reset', 'Reset to default'), active: false, enabled: current != null, onSelect: _reset),
],
+55 -33
View File
@@ -44,6 +44,9 @@ class _SettingsModalState extends State<SettingsModal> {
/// 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;
@@ -82,11 +85,18 @@ class _SettingsModalState extends State<SettingsModal> {
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: _SettingsPanel(selectedId: _selectedId)),
Expanded(
child: ColoredBox(
color: tokens.panelBackground,
child: _query.trim().isEmpty ? _SettingsPanel(selectedId: _selectedId) : SettingsSearchResults(query: _query),
),
),
],
),
),
@@ -144,10 +154,7 @@ class _CloseButton extends StatelessWidget {
onTap: onTap,
builder: (ctx, hovered, pressed) => Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: hovered ? tokens.listItemHoverBackground : null,
borderRadius: BorderRadius.circular(4),
),
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
child: ClideIcon(const CloseIcon(), size: 16, color: tokens.globalForeground),
),
),
@@ -155,21 +162,25 @@ class _CloseButton extends StatelessWidget {
}
}
/// Left rail — the registered categories, data-driven from the
/// [SettingsRegistry]. Selecting one swaps the panel (T-447). The cross-category
/// search box sits atop the rail in T-450.
/// 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.onSelect});
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, _) {
@@ -179,7 +190,14 @@ class _CategoryRail extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 12, 6),
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,
@@ -193,7 +211,12 @@ class _CategoryRail extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final c in categories)
_RailRow(category: c, selected: c.id == effectiveId, onTap: () => onSelect(c.id)),
_RailRow(
category: c,
selected: !searching && c.id == effectiveId,
matchCount: searching ? settingsMatchCount(c, query) : null,
onTap: () => onSelect(c.id),
),
],
),
),
@@ -206,18 +229,21 @@ class _CategoryRail extends StatelessWidget {
}
/// One category row: optional glyph + title, accent left-stripe + surfaceHi
/// fill when selected (surface.md side panels).
/// 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.onTap});
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 fg = selected ? tokens.globalForeground : tokens.sidebarForeground;
final dim = matchCount == 0;
final fg = dim ? tokens.globalTextMuted : (selected ? tokens.globalForeground : tokens.sidebarForeground);
return Semantics(
button: true,
selected: selected,
@@ -239,11 +265,11 @@ class _RailRow extends StatelessWidget {
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 (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),
],
),
),
@@ -258,8 +284,8 @@ class _RailRow extends StatelessWidget {
/// 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 so the
/// panelHeader cards pop (surface.md).
/// 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});
@@ -267,19 +293,15 @@ class _SettingsPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final registry = ClideKernel.of(context).settingsRegistry;
return ColoredBox(
color: tokens.panelBackground,
child: 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);
},
),
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);
},
);
}
}
@@ -4,6 +4,8 @@
"modal.close.hint": { "translation": "Close settings without changing anything" },
"rail.header": { "translation": "Categories" },
"panel.empty": { "translation": "No settings categories are registered yet." },
"search.hint": { "translation": "Search settings…" },
"search.empty": { "translation": "No settings match your search." },
"scope.project": { "translation": "This project" },
"scope.always": { "translation": "All clide" },
"scope.default": { "translation": "Default" },