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:
@@ -879,3 +879,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
|
|||||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-275', 'status', 'ready', 'in_progress', NULL, '2026-06-08 15:45:13', '2026-06-08 15:45:13', '2026-06-08 15:45:13', NULL, '2745334e049523152af749102abe31e5', 1) ON CONFLICT(hash) DO NOTHING;
|
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-275', 'status', 'ready', 'in_progress', NULL, '2026-06-08 15:45:13', '2026-06-08 15:45:13', '2026-06-08 15:45:13', NULL, '2745334e049523152af749102abe31e5', 1) ON CONFLICT(hash) DO NOTHING;
|
||||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-275', 'parent_id', 'T-276', 'T-286', NULL, '2026-06-08 16:30:47', '2026-06-08 16:30:47', '2026-06-08 16:30:47', NULL, 'e0d912feca8121cdeb7a6e39b17e5eb0', 1) ON CONFLICT(hash) DO NOTHING;
|
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-275', 'parent_id', 'T-276', 'T-286', NULL, '2026-06-08 16:30:47', '2026-06-08 16:30:47', '2026-06-08 16:30:47', NULL, 'e0d912feca8121cdeb7a6e39b17e5eb0', 1) ON CONFLICT(hash) DO NOTHING;
|
||||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-275', 'status', 'in_progress', 'done', NULL, '2026-06-08 16:52:13', '2026-06-08 16:52:13', '2026-06-08 16:52:13', NULL, '6ac483536876a274c0f76a4f4bb5fc69', 1) ON CONFLICT(hash) DO NOTHING;
|
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-275', 'status', 'in_progress', 'done', NULL, '2026-06-08 16:52:13', '2026-06-08 16:52:13', '2026-06-08 16:52:13', NULL, '6ac483536876a274c0f76a4f4bb5fc69', 1) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-286', 'status', 'backlog', 'ready', NULL, '2026-06-08 17:34:29', '2026-06-08 17:34:29', '2026-06-08 17:34:29', NULL, '01a873c4191650aa6faa3e7f63926439', 1) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-286', 'status', 'ready', 'in_progress', NULL, '2026-06-08 17:34:31', '2026-06-08 17:34:31', '2026-06-08 17:34:31', NULL, 'b2ff3d5dbf5d72be72ba59b2d7e7c4d8', 1) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
|||||||
@@ -2309,3 +2309,5 @@ Impact: low — console noise at CRITICAL level; no observed functional breakage
|
|||||||
Investigation: run with G_DEBUG=fatal-warnings (or gdb break on g_log/g_logv) to capture the stack at the warning and identify the library/plugin frame; check whether a Flutter plugin (file_selector, path_provider, url_launcher) or GTK icon/mime loading is responsible. If upstream/GTK, document + suppress-from-our-side or pin; if a plugin, file upstream.
|
Investigation: run with G_DEBUG=fatal-warnings (or gdb break on g_log/g_logv) to capture the stack at the warning and identify the library/plugin frame; check whether a Flutter plugin (file_selector, path_provider, url_launcher) or GTK icon/mime loading is responsible. If upstream/GTK, document + suppress-from-our-side or pin; if a plugin, file upstream.
|
||||||
|
|
||||||
Env: Fedora, GTK Linux embedder, flutter run.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-08 17:12:49', '2026-06-08 17:12:49', NULL, 'ff6e04e755a889ef090b498e560b83f1', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
Env: Fedora, GTK Linux embedder, flutter run.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-08 17:12:49', '2026-06-08 17:12:49', NULL, 'ff6e04e755a889ef090b498e560b83f1', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||||
|
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-286', 'epic', 'T-276', 'Anchored popover + menu primitive; migrate anchored surfaces', 'Extract a clide-owned anchored-popover + menu widget primitive (lib/widgets/, no Material) and migrate every anchored surface onto it. Nine surfaces hand-roll the same anchored-overlay + row-list + barrier + keyboard-nav pattern. Two layers: ClideAnchoredOverlay (positioning/lifecycle: LayerLink/follower or centered, barrier, Esc, focus capture, autoFlip) + ClideMenu / ClideMenuListController (rows + reusable nav). Modal DialogRouter pickers (session/project/branch) stay modal. Built on T-275''s permission-mode picker first, then migrate menu bar, theme picker, @-mention, slash typeahead, quick-open. See decision (architecture domain) + plan. Children: primitive, T-275 picker, one per migration.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-08 16:30:27', '2026-06-08 17:34:29', NULL, 'ce3217282170e264b73c83eeb862d68d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||||
|
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-286', 'epic', 'T-276', 'Anchored popover + menu primitive; migrate anchored surfaces', 'Extract a clide-owned anchored-popover + menu widget primitive (lib/widgets/, no Material) and migrate every anchored surface onto it. Nine surfaces hand-roll the same anchored-overlay + row-list + barrier + keyboard-nav pattern. Two layers: ClideAnchoredOverlay (positioning/lifecycle: LayerLink/follower or centered, barrier, Esc, focus capture, autoFlip) + ClideMenu / ClideMenuListController (rows + reusable nav). Modal DialogRouter pickers (session/project/branch) stay modal. Built on T-275''s permission-mode picker first, then migrate menu bar, theme picker, @-mention, slash typeahead, quick-open. See decision (architecture domain) + plan. Children: primitive, T-275 picker, one per migration.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-08 16:30:27', '2026-06-08 17:34:31', NULL, '943af41d72d9e982968f0468c36a51d3', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'package:clide/widgets/widgets.dart';
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import 'extension.dart' show buildClideMenuTree;
|
import 'extension.dart' show buildClideMenuTree;
|
||||||
import 'menu_dropdown.dart';
|
|
||||||
import 'menu_model.dart';
|
import 'menu_model.dart';
|
||||||
|
|
||||||
/// Open/close state for the application menu bar (T-48). Owned by the root
|
/// 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();
|
State<_TopMenuButton> createState() => _TopMenuButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TopMenuButtonState extends State<_TopMenuButton> {
|
/// Adapts the shared [MenuBarController] (single-open across buttons) to the
|
||||||
final LayerLink _link = LayerLink();
|
/// per-button [ClideOverlayController] the primitive drives. Reads/writes go
|
||||||
OverlayEntry? _entry;
|
/// 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.
|
||||||
@override
|
class _MenuOverlayAdapter extends ClideOverlayController {
|
||||||
void initState() {
|
_MenuOverlayAdapter(this._bar, this._index) : _last = _bar.openIndex == _index {
|
||||||
super.initState();
|
_bar.addListener(_onBar);
|
||||||
widget.controller.addListener(_sync);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
final MenuBarController _bar;
|
||||||
void didUpdateWidget(_TopMenuButton old) {
|
final int _index;
|
||||||
super.didUpdateWidget(old);
|
bool _last;
|
||||||
if (!identical(old.controller, widget.controller)) {
|
|
||||||
old.controller.removeListener(_sync);
|
void _onBar() {
|
||||||
widget.controller.addListener(_sync);
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
widget.controller.removeListener(_sync);
|
_bar.removeListener(_onBar);
|
||||||
_entry?.remove();
|
|
||||||
_entry = null;
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _sync() {
|
class _TopMenuButtonState extends State<_TopMenuButton> {
|
||||||
final shouldOpen = widget.controller.openIndex == widget.index;
|
late final _MenuOverlayAdapter _overlay = _MenuOverlayAdapter(widget.controller, 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _activate(String commandId) {
|
@override
|
||||||
widget.controller.close();
|
void dispose() {
|
||||||
unawaited(widget.kernel.commands.execute(commandId));
|
_overlay.dispose();
|
||||||
}
|
super.dispose();
|
||||||
|
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
final open = widget.controller.openIndex == widget.index;
|
final open = widget.controller.openIndex == widget.index;
|
||||||
return CompositedTransformTarget(
|
return ClideAnchoredOverlay(
|
||||||
link: _link,
|
controller: _overlay,
|
||||||
child: MouseRegion(
|
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).
|
// Once a menu is open, hovering a sibling switches to it (Zed behavior).
|
||||||
onEnter: (_) {
|
onEnter: (_) {
|
||||||
if (widget.controller.isOpen) widget.controller.open(widget.index);
|
if (widget.controller.isOpen) widget.controller.open(widget.index);
|
||||||
|
|||||||
@@ -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),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user