tidy the theme switcher popover (T-237)

Per demo feedback on the T-234 status-bar popover: collapse the -hc
theme rows into a single 'High contrast' toggle at the top (applies the
chosen base theme's -hc sibling live, falling back to the base when none
exists); list base themes only, sorted by display name; widen 240->280
and ellipsize rows so 'Catppuccin Mocha' no longer wraps; swap the swatch
dot for the Phosphor palette icon; lowercase the bar label to match the
all-lowercase status bar (proper case kept in the a11y label).

New pure theme_families helpers (base/sibling/resolve), unit-tested.
Modal picker_view consistency + the status-bar right-alignment remain on
T-237.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 22:46:17 +02:00
co-authored by Claude Opus 4.8
parent fb29254851
commit 5fcc42bd8c
6 changed files with 214 additions and 35 deletions
@@ -176,3 +176,4 @@ Acceptance:
Implemented (2026-06-03): pure grouping in activity_cluster.dart (groupConversation + FoldLevel none/tools/thinking/everything + RenderGroup StickyItem/FoldedCluster), fully unit-tested. Activity card + grouping wired into conversation_view.dart (collapsed live ticker of the latest step + step count; click/Enter expands to the folded steps in order; Semantics announces count + expanded/collapsed). Default level = L1 (tools): tool calls+results fold; user/Claude prose, FAILED results (sticky, surfaced), diffs (Edit/Write), and thinking stay first-class. Added FoldLevel.none (no folding) used by the existing item-level renderer tests. DEFERRED (acceptance 4''s persistence): the fold level is a switchable ConversationView parameter (proven by tests; none/L1/L2/L3 re-group) but is NOT yet wired to a persisted user setting + a UI control — the live pane uses the L1 default. Follow-up: read it from settings (ctx.settings) + a control to change it. Did NOT match the wireframe pixel-for-pixel; functional shape per the refinement decisions.', NULL, '2026-06-03 15:17:10', '2026-06-03 15:17:10', '2026-06-03 15:17:10', NULL, 'be73ea1e861930fc12fd9d6050cc6841', 1) ON CONFLICT(hash) DO NOTHING; Implemented (2026-06-03): pure grouping in activity_cluster.dart (groupConversation + FoldLevel none/tools/thinking/everything + RenderGroup StickyItem/FoldedCluster), fully unit-tested. Activity card + grouping wired into conversation_view.dart (collapsed live ticker of the latest step + step count; click/Enter expands to the folded steps in order; Semantics announces count + expanded/collapsed). Default level = L1 (tools): tool calls+results fold; user/Claude prose, FAILED results (sticky, surfaced), diffs (Edit/Write), and thinking stay first-class. Added FoldLevel.none (no folding) used by the existing item-level renderer tests. DEFERRED (acceptance 4''s persistence): the fold level is a switchable ConversationView parameter (proven by tests; none/L1/L2/L3 re-group) but is NOT yet wired to a persisted user setting + a UI control — the live pane uses the L1 default. Follow-up: read it from settings (ctx.settings) + a control to change it. Did NOT match the wireframe pixel-for-pixel; functional shape per the refinement decisions.', NULL, '2026-06-03 15:17:10', '2026-06-03 15:17:10', '2026-06-03 15:17:10', NULL, 'be73ea1e861930fc12fd9d6050cc6841', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-230', 'status', 'in_progress', 'done', NULL, '2026-06-03 15:17:33', '2026-06-03 15:17:33', '2026-06-03 15:17:33', NULL, '069a613ac7439a5d19da505712da8341', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-230', 'status', 'in_progress', 'done', NULL, '2026-06-03 15:17:33', '2026-06-03 15:17:33', '2026-06-03 15:17:33', NULL, '069a613ac7439a5d19da505712da8341', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-237', 'status', 'backlog', 'in_progress', NULL, '2026-06-03 15:43:55', '2026-06-03 15:43:55', '2026-06-03 15:43:55', NULL, '8505c7a515c60be219fdaafd5c0c4acc', 1) ON CONFLICT(hash) DO NOTHING;
+20
View File
@@ -446,3 +446,23 @@ Acceptance:
6. Keyboard + screen-reader accessible (expand/collapse, step count announced). 6. Keyboard + screen-reader accessible (expand/collapse, step count announced).
Implemented (2026-06-03): pure grouping in activity_cluster.dart (groupConversation + FoldLevel none/tools/thinking/everything + RenderGroup StickyItem/FoldedCluster), fully unit-tested. Activity card + grouping wired into conversation_view.dart (collapsed live ticker of the latest step + step count; click/Enter expands to the folded steps in order; Semantics announces count + expanded/collapsed). Default level = L1 (tools): tool calls+results fold; user/Claude prose, FAILED results (sticky, surfaced), diffs (Edit/Write), and thinking stay first-class. Added FoldLevel.none (no folding) used by the existing item-level renderer tests. DEFERRED (acceptance 4''s persistence): the fold level is a switchable ConversationView parameter (proven by tests; none/L1/L2/L3 re-group) but is NOT yet wired to a persisted user setting + a UI control — the live pane uses the L1 default. Follow-up: read it from settings (ctx.settings) + a control to change it. Did NOT match the wireframe pixel-for-pixel; functional shape per the refinement decisions.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 11:15:39', '2026-06-03 15:17:33', NULL, '2f9702465f4d0079bca7f4f4e7b60095', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); Implemented (2026-06-03): pure grouping in activity_cluster.dart (groupConversation + FoldLevel none/tools/thinking/everything + RenderGroup StickyItem/FoldedCluster), fully unit-tested. Activity card + grouping wired into conversation_view.dart (collapsed live ticker of the latest step + step count; click/Enter expands to the folded steps in order; Semantics announces count + expanded/collapsed). Default level = L1 (tools): tool calls+results fold; user/Claude prose, FAILED results (sticky, surfaced), diffs (Edit/Write), and thinking stay first-class. Added FoldLevel.none (no folding) used by the existing item-level renderer tests. DEFERRED (acceptance 4''s persistence): the fold level is a switchable ConversationView parameter (proven by tests; none/L1/L2/L3 re-group) but is NOT yet wired to a persisted user setting + a UI control — the live pane uses the L1 default. Follow-up: read it from settings (ctx.settings) + a control to change it. Did NOT match the wireframe pixel-for-pixel; functional shape per the refinement decisions.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 11:15:39', '2026-06-03 15:17:33', NULL, '2f9702465f4d0079bca7f4f4e7b60095', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-236', 'story', 'T-132', 'Render pasted-image @path tokens as thumbnails in the message log', 'When a user message contains a pasted-screenshot reference — an @<path> token pointing at an image (the composer''s ComposerAttachment.pathToken format, e.g. @/home/<user>/.cache/clide/pasted/paste-<ts>.png, see screenshot) — the conversation log renders it as the raw path string. Show it as an inline THUMBNAIL instead.
The composer already renders pre-send attachments as Image.file thumbnail chips (clipboard_paste.dart + claude_composer _chip); this carries that into the post-send message log (UserMessage rendering in conversation_view/conversation_card).
Scope:
- When rendering UserMessage text, detect @<path> tokens; for image paths (reuse the extension check behind _looksLikeImage: .png/.jpg/.jpeg/.gif/.webp/.bmp), render a bounded Image.file thumbnail in place of the bare token. Surrounding prose still renders normally; a message can carry multiple tokens.
- Click/activate the thumbnail to open the full-size image (a preview dialog). Keyboard-operable + a11y label (filename).
- Graceful fallback: a missing/unreadable file (pasted temp files can be cleaned up) degrades to a small placeholder or the path text via Image.file errorBuilder — never a crash/exception.
- Non-image @path tokens MAY render as a file chip (filename + icon) like the composer, but image thumbnails are the focus.
- Note: Image.file reads the path directly via dart:io, so this is NOT gated by the files.read allow-list (D-80) — the cache dir is outside the workspace and that''s fine for display.
- Display-only: the text actually sent to Claude is unchanged; this only affects rendering.
Acceptance:
1. A user message with an @<path> image token shows an inline thumbnail in the log (not the raw path).
2. Clicking/activating the thumbnail opens the full image.
3. A missing/unreadable referenced file degrades to a placeholder (or the path text), no exception.
4. Prose and any non-image @path tokens around it still render readably; multiple tokens in one message all resolve.
5. The message content delivered to Claude is unchanged (render-only). Relates to T-142 (paste attachments).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-03 15:31:01', '2026-06-03 15:31:01', NULL, '6cf569924ec0dbc31a4b2711aa420be2', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-237', 'task', NULL, 'Theme picker polish: collapse -hc into a checkbox, sort, widen, palette icon', 'User feedback on the T-234 status-bar theme popover (screenshot 2026-06-03): (1) the menu order looks random — the -hc variants are interleaved. (2) Hide all the high-contrast theme entries and replace them with a single ''[ ] High contrast'' checkbox at the top; toggling it applies the -hc sibling of the chosen base theme. (3) Widen the popover so long names (''Catppuccin Mocha'') don''t wrap; rows ellipsize. (4) The status-bar items (''application ok'' + theme switcher) float in the middle (boxed into the workspace column between the per-column bottom rails) instead of hugging the window''s right edge — align them right. (5) Replace the swatch dot on the trigger with a Phosphor icon (palette, 0xe6c8, already in the registry — paint-roller has no registered codepoint). Apply the -hc-as-checkbox + sort to the modal picker_view too for consistency. Helpers: base = name without -hc/-cb; show base themes only; resolve(base, hc) -> base+''-hc'' if it exists.', 'backlog', 'medium', NULL, NULL, 'D-6', '2026-06-03 15:43:45', '2026-06-03 15:43:45', NULL, '411a82da35f5bad2611cf4f4ff38aa13', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-237', 'task', NULL, 'Theme picker polish: collapse -hc into a checkbox, sort, widen, palette icon', 'User feedback on the T-234 status-bar theme popover (screenshot 2026-06-03): (1) the menu order looks random — the -hc variants are interleaved. (2) Hide all the high-contrast theme entries and replace them with a single ''[ ] High contrast'' checkbox at the top; toggling it applies the -hc sibling of the chosen base theme. (3) Widen the popover so long names (''Catppuccin Mocha'') don''t wrap; rows ellipsize. (4) The status-bar items (''application ok'' + theme switcher) float in the middle (boxed into the workspace column between the per-column bottom rails) instead of hugging the window''s right edge — align them right. (5) Replace the swatch dot on the trigger with a Phosphor icon (palette, 0xe6c8, already in the registry — paint-roller has no registered codepoint). Apply the -hc-as-checkbox + sort to the modal picker_view too for consistency. Helpers: base = name without -hc/-cb; show base themes only; resolve(base, hc) -> base+''-hc'' if it exists.', 'in_progress', 'medium', NULL, NULL, 'D-6', '2026-06-03 15:43:45', '2026-06-03 15:43:55', NULL, 'fc2bc3d466dbae36975e79cabcf289ee', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
@@ -0,0 +1,29 @@
/// Theme-family helpers for the picker surfaces (T-237).
///
/// Bundled themes ship as a base + a high-contrast (`-hc`) / colour-blind
/// (`-cb`) sibling (D-69). The pickers don't list the siblings as separate
/// rows — they show base themes only and expose the variant as a "High
/// contrast" toggle. These pure helpers do the base/sibling bookkeeping so
/// the popover and the modal share one source of truth.
library;
import 'package:clide/kernel/kernel.dart' show ThemeDefinition;
bool isHcName(String name) => name.endsWith('-hc');
bool isCbName(String name) => name.endsWith('-cb');
/// The base theme name for any theme (strips a `-hc`/`-cb` suffix).
String baseThemeName(String name) => (isHcName(name) || isCbName(name)) ? name.substring(0, name.length - 3) : name;
/// Base themes only (no `-hc`/`-cb`), sorted by display name so the list is
/// stable and not "randomly" ordered.
List<ThemeDefinition> baseThemes(List<ThemeDefinition> available) =>
available.where((t) => !isHcName(t.name) && !isCbName(t.name)).toList()..sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()));
/// Whether [base] has a high-contrast sibling among [available].
bool hasHcSibling(List<ThemeDefinition> available, String base) => available.any((t) => t.name == '$base-hc');
/// The theme name to actually apply for a chosen [base] given the high-contrast
/// toggle: the `-hc` sibling when requested and present, else the base itself.
String resolveThemeName(List<ThemeDefinition> available, String base, {required bool highContrast}) =>
(highContrast && hasHcSibling(available, base)) ? '$base-hc' : base;
@@ -5,6 +5,7 @@
/// (D-6 parity — both reach the same ThemeController). /// (D-6 parity — both reach the same ThemeController).
library; library;
import 'package:clide/builtin/theme_picker/src/theme_families.dart';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart'; import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -60,11 +61,7 @@ class _ThemeSwitcherStatusItemState extends State<ThemeSwitcherStatusItem> {
bottom: screen.height - anchor.dy + 4, bottom: screen.height - anchor.dy + 4,
child: _ThemePopover( child: _ThemePopover(
controller: _controller, controller: _controller,
onPick: (name) { onClose: _close,
_controller.select(name);
_close();
},
onDismiss: _close,
), ),
), ),
], ],
@@ -103,14 +100,11 @@ class _ThemeSwitcherStatusItemState extends State<ThemeSwitcherStatusItem> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// A swatch dot in the theme's accent — the "what theme" cue. ClideIcon(PhosphorIcons.palette, size: 13, color: tokens.statusBarForeground),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: tokens.buttonBackground, shape: BoxShape.circle),
),
const SizedBox(width: 6), const SizedBox(width: 6),
ClideText(label, fontSize: clideFontCaption, color: tokens.statusBarForeground), // The status bar is all-lowercase; the proper-case name stays
// in the Semantics label for screen readers.
ClideText(label.toLowerCase(), fontSize: clideFontCaption, color: tokens.statusBarForeground),
], ],
), ),
), ),
@@ -121,14 +115,15 @@ class _ThemeSwitcherStatusItemState extends State<ThemeSwitcherStatusItem> {
} }
} }
/// The popover body: a keyboard-navigable list of themes. Autofocuses so /// The popover body: a "High contrast" toggle then the base themes (the `-hc`
/// arrows/Enter/Esc work immediately; the current theme starts highlighted. /// siblings collapse into the toggle, T-237). Keyboard-navigable as one list —
/// index 0 is the toggle, 1..N the themes; arrows move, Enter activates, Esc
/// dismisses. Autofocuses on open.
class _ThemePopover extends StatefulWidget { class _ThemePopover extends StatefulWidget {
const _ThemePopover({required this.controller, required this.onPick, required this.onDismiss}); const _ThemePopover({required this.controller, required this.onClose});
final ThemeController controller; final ThemeController controller;
final void Function(String name) onPick; final VoidCallback onClose;
final VoidCallback onDismiss;
@override @override
State<_ThemePopover> createState() => _ThemePopoverState(); State<_ThemePopover> createState() => _ThemePopoverState();
@@ -136,14 +131,18 @@ class _ThemePopover extends StatefulWidget {
class _ThemePopoverState extends State<_ThemePopover> { class _ThemePopoverState extends State<_ThemePopover> {
final _focus = FocusNode(debugLabel: 'ThemeSwitcher.popover'); final _focus = FocusNode(debugLabel: 'ThemeSwitcher.popover');
late int _index; late bool _hc;
int _index = 0;
List<ThemeDefinition> get _themes => baseThemes(widget.controller.available);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final themes = widget.controller.available; _hc = isHcName(widget.controller.currentName);
_index = themes.indexWhere((t) => t.name == widget.controller.currentName); final currentBase = baseThemeName(widget.controller.currentName);
if (_index < 0) _index = 0; final at = _themes.indexWhere((t) => t.name == currentBase);
_index = at < 0 ? 0 : at + 1; // +1: row 0 is the toggle
WidgetsBinding.instance.addPostFrameCallback((_) => _focus.requestFocus()); WidgetsBinding.instance.addPostFrameCallback((_) => _focus.requestFocus());
} }
@@ -153,22 +152,38 @@ class _ThemePopoverState extends State<_ThemePopover> {
super.dispose(); super.dispose();
} }
void _toggleHc() {
setState(() => _hc = !_hc);
// Re-apply the current base with the new variant, live; keep the popover open.
final base = baseThemeName(widget.controller.currentName);
widget.controller.select(resolveThemeName(widget.controller.available, base, highContrast: _hc));
}
void _pick(ThemeDefinition base) {
widget.controller.select(resolveThemeName(widget.controller.available, base.name, highContrast: _hc));
widget.onClose();
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) { KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored; if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
final themes = widget.controller.available; final count = _themes.length + 1; // +1 toggle
switch (event.logicalKey) { switch (event.logicalKey) {
case LogicalKeyboardKey.arrowDown: case LogicalKeyboardKey.arrowDown:
setState(() => _index = (_index + 1) % themes.length); setState(() => _index = (_index + 1) % count);
return KeyEventResult.handled; return KeyEventResult.handled;
case LogicalKeyboardKey.arrowUp: case LogicalKeyboardKey.arrowUp:
setState(() => _index = (_index - 1 + themes.length) % themes.length); setState(() => _index = (_index - 1 + count) % count);
return KeyEventResult.handled; return KeyEventResult.handled;
case LogicalKeyboardKey.enter: case LogicalKeyboardKey.enter:
case LogicalKeyboardKey.numpadEnter: case LogicalKeyboardKey.numpadEnter:
widget.onPick(themes[_index].name); if (_index == 0) {
_toggleHc();
} else {
_pick(_themes[_index - 1]);
}
return KeyEventResult.handled; return KeyEventResult.handled;
case LogicalKeyboardKey.escape: case LogicalKeyboardKey.escape:
widget.onDismiss(); widget.onClose();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
return KeyEventResult.ignored; return KeyEventResult.ignored;
@@ -177,8 +192,8 @@ class _ThemePopoverState extends State<_ThemePopover> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
final themes = widget.controller.available; final themes = _themes;
final currentName = widget.controller.currentName; final currentBase = baseThemeName(widget.controller.currentName);
return Focus( return Focus(
focusNode: _focus, focusNode: _focus,
onKeyEvent: _onKey, onKeyEvent: _onKey,
@@ -187,25 +202,32 @@ class _ThemePopoverState extends State<_ThemePopover> {
label: 'Theme switcher', label: 'Theme switcher',
explicitChildNodes: true, explicitChildNodes: true,
child: ClideSurface( child: ClideSurface(
width: 240, width: 280,
color: tokens.modalSurfaceBackground, color: tokens.modalSurfaceBackground,
border: tokens.modalSurfaceBorder, border: tokens.modalSurfaceBorder,
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320), constraints: const BoxConstraints(maxHeight: 360),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_HighContrastToggle(
checked: _hc,
highlighted: _index == 0,
onEnter: () => setState(() => _index = 0),
onTap: _toggleHc,
),
ClideDivider(),
for (var i = 0; i < themes.length; i++) for (var i = 0; i < themes.length; i++)
_PopoverRow( _PopoverRow(
displayName: themes[i].displayName, displayName: themes[i].displayName,
selected: themes[i].name == currentName, selected: themes[i].name == currentBase,
highlighted: i == _index, highlighted: _index == i + 1,
onEnter: () => setState(() => _index = i), onEnter: () => setState(() => _index = i + 1),
onTap: () => widget.onPick(themes[i].name), onTap: () => _pick(themes[i]),
), ),
], ],
), ),
@@ -217,6 +239,53 @@ class _ThemePopoverState extends State<_ThemePopover> {
} }
} }
/// The "High contrast" checkbox row at the top of the popover.
class _HighContrastToggle extends StatelessWidget {
const _HighContrastToggle({required this.checked, required this.highlighted, required this.onEnter, required this.onTap});
final bool checked;
final bool highlighted;
final VoidCallback onEnter;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Semantics(
checked: checked,
label: 'High contrast',
excludeSemantics: true,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => onEnter(),
child: GestureDetector(
onTap: onTap,
child: Container(
color: highlighted ? tokens.listItemHoverBackground : tokens.listItemBackground,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
Container(
width: 13,
height: 13,
decoration: BoxDecoration(
color: checked ? tokens.buttonBackground : null,
border: Border.all(color: checked ? tokens.buttonBackground : tokens.modalSurfaceBorder),
borderRadius: BorderRadius.circular(3),
),
child: checked ? ClideIcon(const CheckIcon(), size: 9, color: tokens.buttonForeground) : null,
),
const SizedBox(width: 8),
Expanded(child: ClideText('High contrast', color: tokens.listItemForeground, fontSize: clideFontCaption)),
],
),
),
),
),
);
}
}
class _PopoverRow extends StatelessWidget { class _PopoverRow extends StatelessWidget {
const _PopoverRow({ const _PopoverRow({
required this.displayName, required this.displayName,
@@ -259,7 +328,7 @@ class _PopoverRow extends StatelessWidget {
) )
else else
const SizedBox(width: 17), const SizedBox(width: 17),
Expanded(child: ClideText(displayName, color: fg, fontSize: clideFontCaption)), Expanded(child: ClideText(displayName, color: fg, fontSize: clideFontCaption, maxLines: 1, overflow: TextOverflow.ellipsis)),
], ],
), ),
), ),
@@ -0,0 +1,43 @@
/// Tests for the theme-family helpers (T-237).
library;
import 'package:clide/builtin/theme_picker/src/theme_families.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:test/test.dart';
ThemeDefinition _def(String name, String display) => ThemeDefinition(name: name, displayName: display, dark: true, palette: Palette(const {}));
void main() {
final all = [
_def('clide', 'Clide'),
_def('clide-hc', 'Clide (high contrast)'),
_def('midnight', 'Midnight'),
_def('midnight-hc', 'Midnight (high contrast)'),
_def('catppuccin-mocha', 'Catppuccin Mocha'),
_def('catppuccin-mocha-hc', 'Catppuccin Mocha (high contrast)'),
_def('paper', 'Paper'), // no -hc sibling in this fixture
];
test('isHcName / baseThemeName', () {
expect(isHcName('midnight-hc'), isTrue);
expect(isHcName('midnight'), isFalse);
expect(baseThemeName('midnight-hc'), 'midnight');
expect(baseThemeName('catppuccin-mocha-hc'), 'catppuccin-mocha');
expect(baseThemeName('clide'), 'clide');
});
test('baseThemes drops -hc/-cb and sorts by display name', () {
final bases = baseThemes(all).map((t) => t.name).toList();
expect(bases, ['catppuccin-mocha', 'clide', 'midnight', 'paper']); // alphabetical by display
expect(bases.any(isHcName), isFalse);
});
test('hasHcSibling + resolveThemeName', () {
expect(hasHcSibling(all, 'midnight'), isTrue);
expect(hasHcSibling(all, 'paper'), isFalse);
expect(resolveThemeName(all, 'midnight', highContrast: true), 'midnight-hc');
expect(resolveThemeName(all, 'midnight', highContrast: false), 'midnight');
// No sibling → falls back to the base even when high contrast is on.
expect(resolveThemeName(all, 'paper', highContrast: true), 'paper');
});
}
@@ -140,6 +140,23 @@ void main() {
expect(find.bySemanticsLabel('forest'), findsNothing); expect(find.bySemanticsLabel('forest'), findsNothing);
}); });
testWidgets('popover hides -hc rows; the High contrast toggle applies the sibling (T-237)', (tester) async {
final f2 = await KernelFixture.create(bundledThemes: [_def('midnight'), _def('midnight-hc')]);
addTearDown(f2.dispose);
await tester.pumpWidget(harness(f2, const ThemeSwitcherStatusItem()));
await tester.pump();
await tester.tap(find.bySemanticsLabel('Theme: midnight'));
await tester.pumpAndSettle();
// Base shown, -hc row hidden, toggle present.
expect(find.text('midnight'), findsOneWidget);
expect(find.text('midnight-hc'), findsNothing);
expect(find.text('High contrast'), findsOneWidget);
// Toggling high contrast applies the -hc sibling live.
await tester.tap(find.bySemanticsLabel('High contrast'));
await tester.pumpAndSettle();
expect(f2.services.theme.currentName, 'midnight-hc');
});
testWidgets('Esc dismisses the popover without changing the theme (T-234)', (tester) async { testWidgets('Esc dismisses the popover without changing the theme (T-234)', (tester) async {
await tester.pumpWidget(harness(f, const ThemeSwitcherStatusItem())); await tester.pumpWidget(harness(f, const ThemeSwitcherStatusItem()));
await tester.pump(); await tester.pump();