diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e8cc660..6504b45a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/lib/builtin/theme_picker/src/theme_status_item.dart b/lib/builtin/theme_picker/src/theme_status_item.dart index 8c80529e..85973c56 100644 --- a/lib/builtin/theme_picker/src/theme_status_item.dart +++ b/lib/builtin/theme_picker/src/theme_status_item.dart @@ -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 { - 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 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), ), - ), - ), + ], ), ); } diff --git a/test/builtin/theme_picker/widget_test.dart b/test/builtin/theme_picker/widget_test.dart index fdf446e5..d5a8dea5 100644 --- a/test/builtin/theme_picker/widget_test.dart +++ b/test/builtin/theme_picker/widget_test.dart @@ -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();