migrate the theme switcher onto ClideAnchoredOverlay + ClideMenu (T-286, D-88)

Drop the status item's hand-rolled OverlayEntry + bespoke popover rows for the
shared anchored-overlay (above/end, autoFlip) and ClideMenu. The High-contrast
toggle becomes a keepOpenOnSelect item; theme rows mark the active one. Nav,
focus capture, barrier and Esc now come from the primitives.

The popover tests move to anchoredHarness (a real sized overlay) so the
above-anchored panel hit-tests on-screen, which the shared canSizeOverlay
harness does not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 23:24:00 +02:00
co-authored by Claude Opus 4.8
parent bf4b6bec1e
commit 9f249f6dcc
3 changed files with 78 additions and 258 deletions
+5 -4
View File
@@ -153,10 +153,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Changed
- The Claude composer's **slash typeahead** and the team-chat **@-mention**
list now ride the shared `ClideAnchoredOverlay` + `ClideMenu` popover
primitive, alongside the menu bar. The @-mention list gains full keyboard
nav (arrows/Enter), and both narrow live as you type. (T-286, D-88)
- The Claude composer's **slash typeahead**, the team-chat **@-mention** list,
and the status-bar **theme switcher** now ride the shared
`ClideAnchoredOverlay` + `ClideMenu` popover primitive, alongside the menu
bar. The @-mention list gains full keyboard nav (arrows/Enter), and both
typeaheads narrow live as you type. (T-286, D-88)
- Ticket cards now show parentage as a small **tree** — the parent as a muted,
clickable breadcrumb above and the card's own ticket **bold** under a ``
connector — instead of the ambiguous inline `T-1 ← T-9` arrow. (T-281)
@@ -9,7 +9,6 @@ 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';
import 'package:flutter/services.dart' show KeyDownEvent, KeyRepeatEvent, LogicalKeyboardKey;
class ThemeSwitcherStatusItem extends StatefulWidget {
const ThemeSwitcherStatusItem({super.key});
@@ -21,104 +20,69 @@ class ThemeSwitcherStatusItem extends StatefulWidget {
}
class _ThemeSwitcherStatusItemState extends State<ThemeSwitcherStatusItem> {
OverlayEntry? _entry;
bool get _open => _entry != null;
final ClideOverlayController _overlay = ClideOverlayController();
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,
onClose: _close,
),
),
],
);
},
);
overlay.insert(_entry!);
setState(() {});
}
@override
void dispose() {
_entry?.remove();
_entry = null;
_overlay.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
// Listen to both the theme (label changes) and the overlay (open state).
return ListenableBuilder(
listenable: _controller,
listenable: Listenable.merge([_controller, _overlay]),
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: [
ClideIcon(PhosphorIcons.palette, size: 13, color: tokens.statusBarForeground),
const SizedBox(width: 6),
// 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),
],
final open = _overlay.isOpen;
return ClideAnchoredOverlay(
controller: _overlay,
// The status bar lives at the window bottom, so the popover opens
// upward, right-aligned to the control; autoFlip handles the rare
// case where it sits high enough to grow down (e.g. in tests).
side: ClideAnchorSide.above,
align: ClideAnchorAlign.end,
offset: const Offset(0, -4),
anchor: Semantics(
button: true,
expanded: open,
label: 'Theme: $label',
hint: 'Open the theme switcher',
excludeSemantics: true,
child: ClideTappable(
onTap: _overlay.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: [
ClideIcon(PhosphorIcons.palette, size: 13, color: tokens.statusBarForeground),
const SizedBox(width: 6),
// 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),
],
),
),
),
),
overlayBuilder: (ctx, ctrl) => _ThemePopover(controller: _controller, onClose: ctrl.close),
);
},
);
}
}
/// 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.
/// The popover body, rendered on the shared [ClideMenu] (D-88): a "High
/// contrast" toggle (a `keepOpenOnSelect` item that live-applies the `-hc`
/// sibling, T-237) then the base themes — the `-hc` variants collapse into the
/// toggle. ClideMenu owns the arrow / Enter / Esc nav + autofocus; this widget
/// just maps theme state to entries and re-applies on select.
class _ThemePopover extends StatefulWidget {
const _ThemePopover({required this.controller, required this.onClose});
@@ -130,9 +94,7 @@ class _ThemePopover extends StatefulWidget {
}
class _ThemePopoverState extends State<_ThemePopover> {
final _focus = FocusNode(debugLabel: 'ThemeSwitcher.popover');
late bool _hc;
int _index = 0;
List<ThemeDefinition> get _themes => baseThemes(widget.controller.available);
@@ -140,16 +102,6 @@ class _ThemePopoverState extends State<_ThemePopover> {
void initState() {
super.initState();
_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());
}
@override
void dispose() {
_focus.dispose();
super.dispose();
}
void _toggleHc() {
@@ -164,175 +116,33 @@ class _ThemePopoverState extends State<_ThemePopover> {
widget.onClose();
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
final count = _themes.length + 1; // +1 toggle
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowDown:
setState(() => _index = (_index + 1) % count);
return KeyEventResult.handled;
case LogicalKeyboardKey.arrowUp:
setState(() => _index = (_index - 1 + count) % count);
return KeyEventResult.handled;
case LogicalKeyboardKey.enter:
case LogicalKeyboardKey.numpadEnter:
if (_index == 0) {
_toggleHc();
} else {
_pick(_themes[_index - 1]);
}
return KeyEventResult.handled;
case LogicalKeyboardKey.escape:
widget.onClose();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final themes = _themes;
final currentBase = baseThemeName(widget.controller.currentName);
return Focus(
focusNode: _focus,
onKeyEvent: _onKey,
child: Semantics(
container: true,
label: 'Theme switcher',
explicitChildNodes: true,
child: ClideSurface(
width: 280,
color: tokens.modalSurfaceBackground,
border: tokens.modalSurfaceBorder,
padding: const EdgeInsets.all(4),
borderRadius: BorderRadius.circular(4),
child: ConstrainedBox(
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 == currentBase,
highlighted: _index == i + 1,
onEnter: () => setState(() => _index = i + 1),
onTap: () => _pick(themes[i]),
),
],
),
),
),
),
),
);
}
}
/// 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)),
],
),
container: true,
label: 'Theme switcher',
explicitChildNodes: true,
child: ClideMenu(
onClose: widget.onClose,
minWidth: 220,
maxWidth: 280,
maxHeight: 360,
entries: [
ClideMenuItem(
label: 'High contrast',
active: _hc,
keepOpenOnSelect: true,
onSelect: _toggleHc,
),
),
),
);
}
}
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, maxLines: 1, overflow: TextOverflow.ellipsis)),
],
const ClideMenuSeparator(),
for (final t in _themes)
ClideMenuItem(
label: t.displayName,
active: t.name == currentBase,
onSelect: () => _pick(t),
),
),
),
],
),
);
}
+12 -3
View File
@@ -145,7 +145,10 @@ void main() {
});
testWidgets('status switcher shows the active theme and opens a popover (T-234)', (tester) async {
await tester.pumpWidget(harness(f, const ThemeSwitcherStatusItem()));
// anchoredHarness gives a real 800×600 overlay with the control pinned at
// its real corner (bottom-right) so the above-anchored popover renders and
// hit-tests on-screen — the shared canSizeOverlay harness mispositions it.
await tester.pumpWidget(anchoredHarness(f, const ThemeSwitcherStatusItem(), alignment: Alignment.bottomRight));
await tester.pump();
// Controller starts on the first bundled theme.
expect(f.services.theme.currentName, 'summer-night');
@@ -158,7 +161,10 @@ void main() {
});
testWidgets('selecting in the popover applies live and closes (T-234)', (tester) async {
await tester.pumpWidget(harness(f, const ThemeSwitcherStatusItem()));
// anchoredHarness gives a real 800×600 overlay with the control pinned at
// its real corner (bottom-right) so the above-anchored popover renders and
// hit-tests on-screen — the shared canSizeOverlay harness mispositions it.
await tester.pumpWidget(anchoredHarness(f, const ThemeSwitcherStatusItem(), alignment: Alignment.bottomRight));
await tester.pump();
await tester.tap(find.bySemanticsLabel('Theme: summer-night'));
await tester.pumpAndSettle();
@@ -171,7 +177,10 @@ void main() {
});
testWidgets('Esc dismisses the popover without changing the theme (T-234)', (tester) async {
await tester.pumpWidget(harness(f, const ThemeSwitcherStatusItem()));
// anchoredHarness gives a real 800×600 overlay with the control pinned at
// its real corner (bottom-right) so the above-anchored popover renders and
// hit-tests on-screen — the shared canSizeOverlay harness mispositions it.
await tester.pumpWidget(anchoredHarness(f, const ThemeSwitcherStatusItem(), alignment: Alignment.bottomRight));
await tester.pump();
await tester.tap(find.bySemanticsLabel('Theme: summer-night'));
await tester.pumpAndSettle();