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