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