migrate the menu bar onto ClideAnchoredOverlay + ClideMenu (T-286)

Replace the per-button hand-rolled LayerLink/OverlayEntry/barrier and the
bespoke MenuDropdown + MenuItemRow with the shared popover primitive (D-88).
A small _MenuOverlayAdapter bridges the single-open MenuBarController to each
button's ClideOverlayController, so one source of truth drives open/close,
hover-switch, Alt mnemonics, and Left/Right menu switching (onArrowLeft/Right).
menu_dropdown.dart and menu_item_row.dart are deleted (absorbed by ClideMenu).

Behaviour and a11y are unchanged; menu_bar_test + app_test stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 19:37:10 +02:00
co-authored by Claude Opus 4.8
parent cb4598b048
commit e1d51eba7d
5 changed files with 64 additions and 257 deletions
+60 -65
View File
@@ -5,7 +5,6 @@ import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'extension.dart' show buildClideMenuTree;
import 'menu_dropdown.dart';
import 'menu_model.dart';
/// Open/close state for the application menu bar (T-48). Owned by the root
@@ -102,87 +101,83 @@ class _TopMenuButton extends StatefulWidget {
State<_TopMenuButton> createState() => _TopMenuButtonState();
}
class _TopMenuButtonState extends State<_TopMenuButton> {
final LayerLink _link = LayerLink();
OverlayEntry? _entry;
@override
void initState() {
super.initState();
widget.controller.addListener(_sync);
/// Adapts the shared [MenuBarController] (single-open across buttons) to the
/// per-button [ClideOverlayController] the primitive drives. Reads/writes go
/// straight through to the bar so there's one source of truth; it notifies when
/// this button's open state flips so the anchored overlay opens/closes in step.
class _MenuOverlayAdapter extends ClideOverlayController {
_MenuOverlayAdapter(this._bar, this._index) : _last = _bar.openIndex == _index {
_bar.addListener(_onBar);
}
@override
void didUpdateWidget(_TopMenuButton old) {
super.didUpdateWidget(old);
if (!identical(old.controller, widget.controller)) {
old.controller.removeListener(_sync);
widget.controller.addListener(_sync);
final MenuBarController _bar;
final int _index;
bool _last;
void _onBar() {
final now = _bar.openIndex == _index;
if (now != _last) {
_last = now;
notifyListeners();
}
// NB: do NOT markNeedsBuild the open entry here — didUpdateWidget runs
// during the parent's build, and marking an overlay entry mid-build is
// illegal. The menu set doesn't change while a menu is open in practice;
// the panel is rebuilt fresh on the next open.
}
@override
bool get isOpen => _bar.openIndex == _index;
@override
void open() => _bar.open(_index);
@override
void close() => _bar.close();
@override
void toggle() => _bar.toggle(_index);
@override
void dispose() {
widget.controller.removeListener(_sync);
_entry?.remove();
_entry = null;
_bar.removeListener(_onBar);
super.dispose();
}
}
void _sync() {
final shouldOpen = widget.controller.openIndex == widget.index;
if (shouldOpen && _entry == null) {
_entry = OverlayEntry(builder: _buildOverlay);
Overlay.of(context, rootOverlay: true).insert(_entry!);
} else if (!shouldOpen && _entry != null) {
_entry!.remove();
_entry = null;
}
}
class _TopMenuButtonState extends State<_TopMenuButton> {
late final _MenuOverlayAdapter _overlay = _MenuOverlayAdapter(widget.controller, widget.index);
void _activate(String commandId) {
widget.controller.close();
unawaited(widget.kernel.commands.execute(commandId));
}
Widget _buildOverlay(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: GestureDetector(behavior: HitTestBehavior.opaque, onTap: widget.controller.close),
),
CompositedTransformFollower(
link: _link,
showWhenUnlinked: false,
targetAnchor: Alignment.bottomLeft,
followerAnchor: Alignment.topLeft,
offset: const Offset(0, 2),
child: Align(
alignment: Alignment.topLeft,
child: MenuDropdown(
menu: widget.menu,
onActivate: _activate,
onClose: widget.controller.close,
onPrevMenu: widget.controller.openPrev,
onNextMenu: widget.controller.openNext,
),
),
),
],
);
@override
void dispose() {
_overlay.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final open = widget.controller.openIndex == widget.index;
return CompositedTransformTarget(
link: _link,
child: MouseRegion(
return ClideAnchoredOverlay(
controller: _overlay,
side: ClideAnchorSide.below,
align: ClideAnchorAlign.start,
offset: const Offset(0, 2),
autoFlip: false, // the bar sits at the top; menus always open downward
rootOverlay: true,
overlayBuilder: (ctx, ctrl) => ClideMenu(
onClose: ctrl.close,
onArrowLeft: widget.controller.openPrev,
onArrowRight: widget.controller.openNext,
entries: [
for (final node in widget.menu.items)
if (node is ResolvedItem)
ClideMenuItem(
label: node.title,
enabled: node.enabled,
trailing: node.keybinding != null
? ClideText(node.keybinding!, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: ClideTheme.of(ctx).surface.globalTextMuted)
: null,
onSelect: () => unawaited(widget.kernel.commands.execute(node.commandId)),
)
else
const ClideMenuSeparator(),
],
),
anchor: MouseRegion(
// Once a menu is open, hovering a sibling switches to it (Zed behavior).
onEnter: (_) {
if (widget.controller.isOpen) widget.controller.open(widget.index);
-141
View File
@@ -1,141 +0,0 @@
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'menu_item_row.dart';
import 'menu_model.dart';
/// The open-menu panel (T-48): a `dropdown`-token surface listing the resolved
/// items, with full keyboard navigation (Up/Down skip disabled rows +
/// separators, Enter activates, Esc closes, Left/Right switch top menus).
class MenuDropdown extends StatefulWidget {
const MenuDropdown({
super.key,
required this.menu,
required this.onActivate,
required this.onClose,
required this.onPrevMenu,
required this.onNextMenu,
});
final ResolvedMenu menu;
final void Function(String commandId) onActivate;
final VoidCallback onClose;
final VoidCallback onPrevMenu;
final VoidCallback onNextMenu;
@override
State<MenuDropdown> createState() => _MenuDropdownState();
}
class _MenuDropdownState extends State<MenuDropdown> {
final FocusNode _focus = FocusNode(debugLabel: 'menu-dropdown');
int _highlight = -1;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focus.requestFocus();
});
}
@override
void dispose() {
_focus.dispose();
super.dispose();
}
/// Indices into `menu.items` that are enabled command rows (navigable).
List<int> get _navigable {
final out = <int>[];
for (var i = 0; i < widget.menu.items.length; i++) {
final it = widget.menu.items[i];
if (it is ResolvedItem && it.enabled) out.add(i);
}
return out;
}
void _move(int dir) {
final nav = _navigable;
if (nav.isEmpty) return;
final pos = nav.indexOf(_highlight);
final next = pos < 0 ? (dir > 0 ? 0 : nav.length - 1) : (pos + dir) % nav.length;
setState(() => _highlight = nav[(next + nav.length) % nav.length]);
}
void _activateHighlighted() {
if (_highlight < 0) return;
final it = widget.menu.items[_highlight];
if (it is ResolvedItem && it.enabled) widget.onActivate(it.commandId);
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowDown:
_move(1);
return KeyEventResult.handled;
case LogicalKeyboardKey.arrowUp:
_move(-1);
return KeyEventResult.handled;
case LogicalKeyboardKey.enter:
case LogicalKeyboardKey.numpadEnter:
case LogicalKeyboardKey.space:
_activateHighlighted();
return KeyEventResult.handled;
case LogicalKeyboardKey.escape:
widget.onClose();
return KeyEventResult.handled;
case LogicalKeyboardKey.arrowLeft:
widget.onPrevMenu();
return KeyEventResult.handled;
case LogicalKeyboardKey.arrowRight:
widget.onNextMenu();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Focus(
focusNode: _focus,
onKeyEvent: _onKey,
child: IntrinsicWidth(
child: Container(
constraints: const BoxConstraints(minWidth: 220, maxWidth: 420),
decoration: BoxDecoration(
color: tokens.dropdownBackground,
border: Border.all(color: tokens.dropdownBorder),
borderRadius: BorderRadius.circular(6),
boxShadow: [BoxShadow(color: tokens.shadowAmbient, blurRadius: 12, offset: const Offset(0, 4))],
),
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < widget.menu.items.length; i++) _row(i, widget.menu.items[i], tokens),
],
),
),
),
);
}
Widget _row(int index, ResolvedNode node, SurfaceTokens tokens) {
return switch (node) {
ResolvedSeparator() => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Container(height: 1, color: tokens.dividerColor),
),
ResolvedItem() => MenuItemRow(
item: node,
highlighted: index == _highlight,
onActivate: () => widget.onActivate(node.commandId),
),
};
}
}
@@ -1,51 +0,0 @@
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'menu_model.dart';
/// One command row in an open menu (T-48): label on the left, inline keybinding
/// on the right (two-column control pattern). Disabled items render greyed and
/// inert; the keyboard-highlighted row uses the hover background.
class MenuItemRow extends StatelessWidget {
const MenuItemRow({super.key, required this.item, required this.highlighted, required this.onActivate});
final ResolvedItem item;
final bool highlighted;
final VoidCallback onActivate;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final enabled = item.enabled;
return Semantics(
button: true,
enabled: enabled,
label: item.title,
child: ClideTappable(
onTap: enabled ? onActivate : null,
builder: (context, hovered, _) => Container(
color: enabled && (highlighted || hovered) ? tokens.listItemHoverBackground : null,
padding: const EdgeInsets.symmetric(horizontal: clideInsetText, vertical: 6),
child: Row(
children: [
Expanded(
child: ClideText(
item.title,
fontSize: clideFontCaption,
color: enabled ? tokens.dropdownForeground : tokens.globalTextMuted,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (item.keybinding != null) ...[
const SizedBox(width: clideGapSection),
ClideText(item.keybinding!, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
],
],
),
),
),
);
}
}