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:
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user