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