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