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;
|
||||
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).
|
||||
|
||||
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).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/theme_picker/src/theme_families.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -60,11 +61,7 @@ class _ThemeSwitcherStatusItemState extends State<ThemeSwitcherStatusItem> {
|
||||
bottom: screen.height - anchor.dy + 4,
|
||||
child: _ThemePopover(
|
||||
controller: _controller,
|
||||
onPick: (name) {
|
||||
_controller.select(name);
|
||||
_close();
|
||||
},
|
||||
onDismiss: _close,
|
||||
onClose: _close,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -103,14 +100,11 @@ class _ThemeSwitcherStatusItemState extends State<ThemeSwitcherStatusItem> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// A swatch dot in the theme's accent — the "what theme" cue.
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: tokens.buttonBackground, shape: BoxShape.circle),
|
||||
),
|
||||
ClideIcon(PhosphorIcons.palette, size: 13, color: tokens.statusBarForeground),
|
||||
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
|
||||
/// arrows/Enter/Esc work immediately; the current theme starts highlighted.
|
||||
/// The popover body: a "High contrast" toggle then the base themes (the `-hc`
|
||||
/// 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 {
|
||||
const _ThemePopover({required this.controller, required this.onPick, required this.onDismiss});
|
||||
const _ThemePopover({required this.controller, required this.onClose});
|
||||
|
||||
final ThemeController controller;
|
||||
final void Function(String name) onPick;
|
||||
final VoidCallback onDismiss;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
State<_ThemePopover> createState() => _ThemePopoverState();
|
||||
@@ -136,14 +131,18 @@ class _ThemePopover extends StatefulWidget {
|
||||
|
||||
class _ThemePopoverState extends State<_ThemePopover> {
|
||||
final _focus = FocusNode(debugLabel: 'ThemeSwitcher.popover');
|
||||
late int _index;
|
||||
late bool _hc;
|
||||
int _index = 0;
|
||||
|
||||
List<ThemeDefinition> get _themes => baseThemes(widget.controller.available);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final themes = widget.controller.available;
|
||||
_index = themes.indexWhere((t) => t.name == widget.controller.currentName);
|
||||
if (_index < 0) _index = 0;
|
||||
_hc = isHcName(widget.controller.currentName);
|
||||
final currentBase = baseThemeName(widget.controller.currentName);
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -153,22 +152,38 @@ class _ThemePopoverState extends State<_ThemePopover> {
|
||||
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) {
|
||||
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) {
|
||||
case LogicalKeyboardKey.arrowDown:
|
||||
setState(() => _index = (_index + 1) % themes.length);
|
||||
setState(() => _index = (_index + 1) % count);
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.arrowUp:
|
||||
setState(() => _index = (_index - 1 + themes.length) % themes.length);
|
||||
setState(() => _index = (_index - 1 + count) % count);
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.enter:
|
||||
case LogicalKeyboardKey.numpadEnter:
|
||||
widget.onPick(themes[_index].name);
|
||||
if (_index == 0) {
|
||||
_toggleHc();
|
||||
} else {
|
||||
_pick(_themes[_index - 1]);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.escape:
|
||||
widget.onDismiss();
|
||||
widget.onClose();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
@@ -177,8 +192,8 @@ class _ThemePopoverState extends State<_ThemePopover> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final themes = widget.controller.available;
|
||||
final currentName = widget.controller.currentName;
|
||||
final themes = _themes;
|
||||
final currentBase = baseThemeName(widget.controller.currentName);
|
||||
return Focus(
|
||||
focusNode: _focus,
|
||||
onKeyEvent: _onKey,
|
||||
@@ -187,25 +202,32 @@ class _ThemePopoverState extends State<_ThemePopover> {
|
||||
label: 'Theme switcher',
|
||||
explicitChildNodes: true,
|
||||
child: ClideSurface(
|
||||
width: 240,
|
||||
width: 280,
|
||||
color: tokens.modalSurfaceBackground,
|
||||
border: tokens.modalSurfaceBorder,
|
||||
padding: const EdgeInsets.all(4),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 320),
|
||||
constraints: const BoxConstraints(maxHeight: 360),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_HighContrastToggle(
|
||||
checked: _hc,
|
||||
highlighted: _index == 0,
|
||||
onEnter: () => setState(() => _index = 0),
|
||||
onTap: _toggleHc,
|
||||
),
|
||||
ClideDivider(),
|
||||
for (var i = 0; i < themes.length; i++)
|
||||
_PopoverRow(
|
||||
displayName: themes[i].displayName,
|
||||
selected: themes[i].name == currentName,
|
||||
highlighted: i == _index,
|
||||
onEnter: () => setState(() => _index = i),
|
||||
onTap: () => widget.onPick(themes[i].name),
|
||||
selected: themes[i].name == currentBase,
|
||||
highlighted: _index == i + 1,
|
||||
onEnter: () => setState(() => _index = i + 1),
|
||||
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 {
|
||||
const _PopoverRow({
|
||||
required this.displayName,
|
||||
@@ -259,7 +328,7 @@ class _PopoverRow extends StatelessWidget {
|
||||
)
|
||||
else
|
||||
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);
|
||||
});
|
||||
|
||||
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 {
|
||||
await tester.pumpWidget(harness(f, const ThemeSwitcherStatusItem()));
|
||||
await tester.pump();
|
||||
|
||||
Reference in New Issue
Block a user