add a status-bar theme switcher with live popover (T-234)

A far-right status-bar control shows the current theme and opens an
anchored popover (not the full-screen modal) to switch live: click or
keyboard (arrow/Enter to pick, Esc/tap-away to dismiss without change).
Reuses ThemeController.available/select; the theme.pick palette command
is unchanged (D-6 parity). Registered at priority 110 so it sits in the
status bar's right group.

Closes T-234 (under no parent; demo polish).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 17:04:39 +02:00
co-authored by Claude Opus 4.8
parent 3ff09dfb6e
commit b4bbbc8a62
6 changed files with 352 additions and 0 deletions
@@ -116,3 +116,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
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-234', 'status', 'ready', 'in_progress', NULL, '2026-06-03 14:27:26', '2026-06-03 14:27:26', '2026-06-03 14:27:26', NULL, '3db43a5f8b3855b2a78b4f6294dfeb97', 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-82', 'status', 'ready', 'in_progress', NULL, '2026-06-03 14:27:26', '2026-06-03 14:27:26', '2026-06-03 14:27:26', NULL, '7a7042a498a0f09a072d911fad8d33d7', 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', 'ready', 'in_progress', NULL, '2026-06-03 14:27:26', '2026-06-03 14:27:26', '2026-06-03 14:27:26', NULL, 'ec0e54053e51dea1fecb48eebc69e387', 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-82', 'status', 'in_progress', 'done', NULL, '2026-06-03 14:56:07', '2026-06-03 14:56:07', '2026-06-03 14:56:07', NULL, '9b160e380defb6a1ac78f200585745c8', 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-234', 'status', 'in_progress', 'done', NULL, '2026-06-03 15:04:07', '2026-06-03 15:04:07', '2026-06-03 15:04:07', NULL, 'a498a47a4c52685fb7a33cbf004b47f9', 1) ON CONFLICT(hash) DO NOTHING;
+27
View File
@@ -358,3 +358,30 @@ Acceptance:
3. Selecting a theme applies it live (ThemeController.select) and closes the popout.
4. Esc / outside-click dismisses without changing the theme; the control is keyboard-operable.
5. theme.pick (palette/command) still works unchanged.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-03 13:49:44', '2026-06-03 14:27:26', NULL, '9a5ac18d9e54e009f912b9c54584d796', 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-82', 'task', NULL, 'add Catppuccin theme to bundled themes', 'User request: ship a Catppuccin theme alongside the four bundled themes (clide, midnight, paper, terminal — D-44).
**Acceptance:**
- New theme YAML at `lib/kernel/src/theme/themes/catppuccin.yaml` (or split for the four Catppuccin flavours: latte / frappe / macchiato / mocha — pick one or all four; recommend Mocha as the dark default and Latte for the light variant).
- Tokens populated for every key in `SurfaceTokens` (lib/kernel/src/theme/tokens.dart) — chrome, panels, tabs, list items, status colours, syntax tokens (D-45).
- WCAG-AA contrast gate (D-22) passes against the theme.
- Listed in `assets/licenses.yaml` with the upstream license (MIT) and attribution.
- Theme picker (`builtin.theme-picker`) shows it in the live-switch menu.
**Source palettes:** https://github.com/catppuccin/catppuccin#-palette — official hex values, don`t paraphrase.
**Why now:** test user request; also a good exercise for any future user-contributed theme since Catppuccin is the most common one to ask for.', 'done', 'low', NULL, NULL, NULL, '2026-05-06 08:30:15', '2026-06-03 14:56:07', NULL, '9cb0ab9e2c94f944809ee5abffdf802b', 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-234', 'story', NULL, 'Theme-switcher popout in the status bar', 'Add a theme switcher to the far-right corner of the bottom status bar (the empty area to the right of the ''application ok'' / ipc-status item — see the user''s screenshot). Today switching themes is only reachable via the theme.pick command / palette (ThemePickerExtension opens a modal picker_view); there''s no always-visible affordance.
Add a compact status-bar control that opens a POPOUT (popover anchored to the control, not the full-screen modal) for quick theme switching:
- A small right-aligned status item — a palette/swatch icon (or the current theme name). Contribute via StatusItemContribution at Slots.statusbar with priority > 100 so it sits in the right group, to the right of the ipc/toolchain ''application ok'' item (priority 100).
- Click (or focus + Enter/Space) opens a popover listing ThemeController.available, the current (ThemeController.currentName) marked.
- Selecting an entry applies it immediately via ThemeController.select(name) and closes the popout.
- Esc / click-away dismisses with no change. Keyboard navigable (arrow + Enter), matching the palette/quick-open overlay pattern.
- Natural home: ThemePickerExtension (lib/builtin/theme_picker). Reuse the theme list + select logic from picker_view rather than duplicating; the modal theme.pick command stays as-is for parity (D-6).
Acceptance:
1. A theme control sits in the far-right of the status bar.
2. Clicking/activating it opens a popover of all available themes with the current one marked.
3. Selecting a theme applies it live (ThemeController.select) and closes the popout.
4. Esc / outside-click dismisses without changing the theme; the control is keyboard-operable.
5. theme.pick (palette/command) still works unchanged.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 13:49:44', '2026-06-03 15:04:07', NULL, '88cfe20315c172dfdb9ba3ed1c25564d', 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);
+3
View File
@@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- Theme switcher in the status bar: a far-right control showing the current
theme that opens a popover to switch live — click or keyboard (arrows/Enter,
Esc to dismiss). The `theme.pick` palette command is unchanged. (T-234)
- Catppuccin Mocha theme, plus a high-contrast `catppuccin-mocha-hc` sibling,
added to the bundled themes — switchable from the theme picker. Faithful to
the official palette (D-69). (T-82)
@@ -1,5 +1,6 @@
import 'package:clide/clide.dart';
import 'package:clide/builtin/theme_picker/src/picker_view.dart';
import 'package:clide/builtin/theme_picker/src/theme_status_item.dart';
import 'package:clide/extension/extension.dart';
class ThemePickerExtension extends ClideExtension {
@@ -26,6 +27,14 @@ class ThemePickerExtension extends ClideExtension {
defaultBinding: 'ctrl+k',
run: _pick,
),
// Always-visible switcher in the far-right status bar (T-234).
// priority >= 100 places it in the right group; registered after
// ipc-status so it sits to its right.
StatusItemContribution(
id: 'theme-picker.switcher',
priority: 110,
build: (_) => const ThemeSwitcherStatusItem(),
),
];
Future<IpcResponse> _pick(List<String> args) async {
@@ -0,0 +1,270 @@
/// Status-bar theme switcher (T-234). A compact right-aligned control
/// showing the current theme; activating it opens a popover (anchored
/// above the status bar, not a full-screen modal) listing every theme,
/// with live-apply on select. The modal `theme.pick` command is unchanged
/// (D-6 parity — both reach the same ThemeController).
library;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/services.dart' show KeyDownEvent, KeyRepeatEvent, LogicalKeyboardKey;
class ThemeSwitcherStatusItem extends StatefulWidget {
const ThemeSwitcherStatusItem({super.key});
static const ns = 'builtin.theme-picker';
@override
State<ThemeSwitcherStatusItem> createState() => _ThemeSwitcherStatusItemState();
}
class _ThemeSwitcherStatusItemState extends State<ThemeSwitcherStatusItem> {
OverlayEntry? _entry;
bool get _open => _entry != null;
ThemeController get _controller => ClideKernel.of(context).theme;
void _toggle() => _open ? _close() : _openPopover();
void _close() {
_entry?.remove();
_entry = null;
if (mounted) setState(() {});
}
void _openPopover() {
final overlay = Overlay.maybeOf(context);
final box = context.findRenderObject() as RenderBox?;
if (overlay == null || box == null) return;
final anchor = box.localToGlobal(Offset.zero);
final size = box.size;
final screen = MediaQuery.of(context).size;
_entry = OverlayEntry(
builder: (ctx) {
// Anchor the popover's bottom-right to the control's top-right so it
// grows upward (the status bar lives at the window bottom).
final right = (screen.width - (anchor.dx + size.width)).clamp(0.0, screen.width);
return Stack(
children: [
// Tap-away barrier — dismisses without changing the theme.
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _close,
),
),
Positioned(
right: right,
bottom: screen.height - anchor.dy + 4,
child: _ThemePopover(
controller: _controller,
onPick: (name) {
_controller.select(name);
_close();
},
onDismiss: _close,
),
),
],
);
},
);
overlay.insert(_entry!);
setState(() {});
}
@override
void dispose() {
_entry?.remove();
_entry = null;
super.dispose();
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final label = _controller.current.displayName;
return Semantics(
button: true,
expanded: _open,
label: 'Theme: $label',
hint: 'Open the theme switcher',
excludeSemantics: true,
child: ClideTappable(
onTap: _toggle,
builder: (context, hovered, focused) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
color: (hovered || focused || _open) ? tokens.listItemHoverBackground : null,
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),
),
const SizedBox(width: 6),
ClideText(label, fontSize: clideFontCaption, color: tokens.statusBarForeground),
],
),
),
),
);
},
);
}
}
/// The popover body: a keyboard-navigable list of themes. Autofocuses so
/// arrows/Enter/Esc work immediately; the current theme starts highlighted.
class _ThemePopover extends StatefulWidget {
const _ThemePopover({required this.controller, required this.onPick, required this.onDismiss});
final ThemeController controller;
final void Function(String name) onPick;
final VoidCallback onDismiss;
@override
State<_ThemePopover> createState() => _ThemePopoverState();
}
class _ThemePopoverState extends State<_ThemePopover> {
final _focus = FocusNode(debugLabel: 'ThemeSwitcher.popover');
late int _index;
@override
void initState() {
super.initState();
final themes = widget.controller.available;
_index = themes.indexWhere((t) => t.name == widget.controller.currentName);
if (_index < 0) _index = 0;
WidgetsBinding.instance.addPostFrameCallback((_) => _focus.requestFocus());
}
@override
void dispose() {
_focus.dispose();
super.dispose();
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
final themes = widget.controller.available;
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowDown:
setState(() => _index = (_index + 1) % themes.length);
return KeyEventResult.handled;
case LogicalKeyboardKey.arrowUp:
setState(() => _index = (_index - 1 + themes.length) % themes.length);
return KeyEventResult.handled;
case LogicalKeyboardKey.enter:
case LogicalKeyboardKey.numpadEnter:
widget.onPick(themes[_index].name);
return KeyEventResult.handled;
case LogicalKeyboardKey.escape:
widget.onDismiss();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final themes = widget.controller.available;
final currentName = widget.controller.currentName;
return Focus(
focusNode: _focus,
onKeyEvent: _onKey,
child: Semantics(
container: true,
label: 'Theme switcher',
explicitChildNodes: true,
child: ClideSurface(
width: 240,
color: tokens.modalSurfaceBackground,
border: tokens.modalSurfaceBorder,
padding: const EdgeInsets.all(4),
borderRadius: BorderRadius.circular(4),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
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),
),
],
),
),
),
),
),
);
}
}
class _PopoverRow extends StatelessWidget {
const _PopoverRow({
required this.displayName,
required this.selected,
required this.highlighted,
required this.onEnter,
required this.onTap,
});
final String displayName;
final bool selected;
final bool highlighted;
final VoidCallback onEnter;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final bg = selected ? tokens.listItemSelectedBackground : (highlighted ? tokens.listItemHoverBackground : tokens.listItemBackground);
final fg = selected ? tokens.listItemSelectedForeground : tokens.listItemForeground;
return Semantics(
button: true,
selected: selected,
label: displayName,
excludeSemantics: true,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => onEnter(),
child: GestureDetector(
onTap: onTap,
child: Container(
color: bg,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
if (selected)
Padding(
padding: const EdgeInsets.only(right: 6),
child: ClideIcon(const CheckIcon(), size: 11, color: fg),
)
else
const SizedBox(width: 17),
Expanded(child: ClideText(displayName, color: fg, fontSize: clideFontCaption)),
],
),
),
),
),
);
}
}
@@ -1,6 +1,8 @@
import 'package:clide/builtin/theme_picker/src/theme_status_item.dart';
import 'package:clide/builtin/theme_picker/theme_picker.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -112,6 +114,45 @@ void main() {
expect(dismissed, isNull);
});
testWidgets('status switcher shows the active theme and opens a popover (T-234)', (tester) async {
await tester.pumpWidget(harness(f, const ThemeSwitcherStatusItem()));
await tester.pump();
// Controller starts on the first bundled theme.
expect(f.services.theme.currentName, 'summer-night');
expect(find.text('summer-night'), findsOneWidget);
// Open the popover; it lists every theme (the other one appears).
await tester.tap(find.bySemanticsLabel('Theme: summer-night'));
await tester.pumpAndSettle();
expect(find.text('forest'), findsOneWidget);
});
testWidgets('selecting in the popover applies live and closes (T-234)', (tester) async {
await tester.pumpWidget(harness(f, const ThemeSwitcherStatusItem()));
await tester.pump();
await tester.tap(find.bySemanticsLabel('Theme: summer-night'));
await tester.pumpAndSettle();
await tester.tap(find.bySemanticsLabel('forest'));
await tester.pumpAndSettle();
expect(f.services.theme.currentName, 'forest'); // applied live
// Popover closed: the only 'forest' left is the trigger label.
expect(find.bySemanticsLabel('forest'), findsNothing);
});
testWidgets('Esc dismisses the popover without changing the theme (T-234)', (tester) async {
await tester.pumpWidget(harness(f, const ThemeSwitcherStatusItem()));
await tester.pump();
await tester.tap(find.bySemanticsLabel('Theme: summer-night'));
await tester.pumpAndSettle();
expect(find.text('forest'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.text('forest'), findsNothing); // popover gone
expect(f.services.theme.currentName, 'summer-night'); // unchanged
});
test('_pick before activate returns a not-activated error', () async {
// Reach the command's run callback without going through activate —
// _ctx is still null, so _pick hits the defensive error branch.