// GarageUI — hand rolled replacement for shadcn's menu family. // // Covers the in-app menu bar (Menubar), the dropdown / context menu surface // (MenuPopup + MenuGroup) and all the item types the app actually uses: // MenuButton, MenuCheckbox, MenuDivider, MenuLabel, MenuShortcut, MenuGap. // // Styling started from shadcn 0.0.52, then got tightened around GarageUI's shared // desktop density/type tokens. The overlay / focus machinery is our own, built // on flutter's Overlay so we don't drag in the shadcn popover system. import "package:flutter/widgets.dart"; import "package:flutter/services.dart"; import "package:garage_ui/popover_placement.dart"; import "package:garage_ui/theme/garage_theme.dart"; import "package:flutter_lucide/flutter_lucide.dart"; import "package:garage_ui/button.dart"; typedef ContextedCallback = void Function(BuildContext context); typedef ContextedValueChanged = void Function(BuildContext context, T value); // Menu rows have their own height target, seperate from the control height - // the vertical inset is whatever menuRowHeight leaves around the line box. EdgeInsets _menuPadding(ControlDensity density, ThemeData theme) { final tokens = density.tokens(theme); final vertical = tokens.menuRowPaddingY; return density.resolve(theme).copyWith(top: vertical, bottom: vertical); } // --------------------------------------------------------------------------- // popover controller (compat shim) // // menu_bar.dart hands one of these to each top level MenuButton and watches // hasOpenPopover to know when a dropdown is showing. We drive the open flag // from the Menubar so that keeps working. // --------------------------------------------------------------------------- // leading icon slot width, same as the explorer's icon boxes. const double _kMenuIconSlot = 18.0; const double _kMenuBarPopupMinWidth = 240.0; class PopoverController extends ChangeNotifier { bool _open = false; bool get hasOpenPopover => _open; void _setOpen(bool value) { if (value == _open) return; _open = value; notifyListeners(); } void close() => _setOpen(false); } // --------------------------------------------------------------------------- // item interface // --------------------------------------------------------------------------- abstract class MenuItem extends Widget { const MenuItem({super.key}); bool get hasLeading; } // --------------------------------------------------------------------------- // internal inherited state // --------------------------------------------------------------------------- class _MenuScope extends InheritedWidget { const _MenuScope({ required this.direction, required this.hasLeading, required this.itemPadding, required this.isMenubar, required this.popoverOffset, required this.density, required this.closeAll, required this.openChild, required this.hoveredChild, required this.rootGroupId, required super.child, }); final Axis direction; final bool hasLeading; final EdgeInsets itemPadding; final bool isMenubar; final Offset popoverOffset; final ControlDensity density; // closes the entire menu tree, shared from the root down every submenu level final VoidCallback closeAll; // which sibling (by index) currently has its submenu open, this level only final ValueNotifier openChild; // which sibling (by index) is currently hovered, this level only. tracked // centrally so only one item can be lit - moving onto a new item clears the // last even if flutter dropped its onExit during a fast mouse move. final ValueNotifier hoveredChild; final Object rootGroupId; static _MenuScope? of(BuildContext context) { return context.dependOnInheritedWidgetOfExactType<_MenuScope>(); } @override bool updateShouldNotify(_MenuScope old) { return direction != old.direction || hasLeading != old.hasLeading || itemPadding != old.itemPadding || isMenubar != old.isMenubar || popoverOffset != old.popoverOffset || density != old.density || openChild != old.openChild; } } class _MenuIndex extends InheritedWidget { const _MenuIndex({required this.index, required super.child}); final int index; static int indexOf(BuildContext context) { final scope = context.dependOnInheritedWidgetOfExactType<_MenuIndex>(); return scope?.index ?? 0; } @override bool updateShouldNotify(_MenuIndex old) => index != old.index; } // --------------------------------------------------------------------------- // MenuPopup — the surface. border + popover fill + radius, no shadow (shadcn's // popover surface doesn't draw one either). // --------------------------------------------------------------------------- class MenuPopup extends StatelessWidget { const MenuPopup({ super.key, this.surfaceOpacity, this.surfaceBlur, this.padding, this.fillColor, this.borderColor, this.borderRadius, required this.children, }); // kept for call site compatibility, we render opaque so these are unused final double? surfaceOpacity; final double? surfaceBlur; final EdgeInsetsGeometry? padding; final Color? fillColor; final Color? borderColor; final BorderRadiusGeometry? borderRadius; final List children; @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final cs = theme.colorScheme; final scope = _MenuScope.of(context); final direction = scope?.direction ?? Axis.vertical; final pad = padding ?? const EdgeInsets.all(2); final radius = borderRadius ?? BorderRadius.circular(theme.radiusMd); Widget content = Flex( direction: direction, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: children, ); // intrinsic sizing so the rows get a bounded main-axis size (needed for the // Expanded that right-aligns trailing shortcuts). content = direction == Axis.vertical ? IntrinsicWidth(child: content) : IntrinsicHeight(child: content); final surface = Container( padding: pad, decoration: BoxDecoration( color: fillColor ?? cs.popover, borderRadius: radius, border: Border.all(color: borderColor ?? cs.popoverBorder, width: 1), ), child: SingleChildScrollView(scrollDirection: direction, child: content), ); // popup ambient text weight is normal (w400); items override size/colour return Semantics( scopesRoute: true, explicitChildNodes: true, child: DefaultTextStyle.merge( style: GarageTheme.of(context).typography.normal, child: surface, ), ); } } // --------------------------------------------------------------------------- // MenuGroup — lays out a set of items via a builder and wires up the shared // group state. Used by the context menu path. // --------------------------------------------------------------------------- class MenuGroup extends StatefulWidget { const MenuGroup({ super.key, required this.children, required this.builder, required this.direction, this.parent, this.subMenuOffset, this.onDismissed, this.regionGroupId, this.itemPadding, this.density, this.autofocus = true, this.focusNode, }); final List children; final Widget Function(BuildContext context, List children) builder; final Axis direction; // accepted for api parity, not all are meaningful in our impl final Object? parent; final Offset? subMenuOffset; final VoidCallback? onDismissed; final Object? regionGroupId; final EdgeInsets? itemPadding; final ControlDensity? density; final bool autofocus; final FocusNode? focusNode; @override State createState() => _MenuGroupState(); } class _MenuGroupState extends State { final ValueNotifier _openChild = ValueNotifier(null); final ValueNotifier _hoveredChild = ValueNotifier(null); @override void dispose() { _openChild.dispose(); _hoveredChild.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); // inherit the root close + group id if we're a nested group, otherwise be // the root ourselves. final parentScope = _MenuScope.of(context); final closeAll = parentScope?.closeAll ?? widget.onDismissed ?? () {}; final rootGroupId = parentScope?.rootGroupId ?? widget.regionGroupId ?? this; bool hasLeading = false; for (final c in widget.children) { if (c.hasLeading) hasLeading = true; } final children = [ for (var i = 0; i < widget.children.length; i++) _MenuIndex(index: i, child: widget.children[i]), ]; return _MenuScope( direction: widget.direction, hasLeading: hasLeading, itemPadding: widget.itemPadding ?? EdgeInsets.zero, isMenubar: false, popoverOffset: Offset.zero, density: widget.density ?? ControlDensity.of(theme), closeAll: closeAll, openChild: _openChild, hoveredChild: _hoveredChild, rootGroupId: rootGroupId, child: Builder(builder: (context) => widget.builder(context, children)), ); } } // --------------------------------------------------------------------------- // Menubar — the horizontal top level bar. // --------------------------------------------------------------------------- class Menubar extends StatefulWidget { const Menubar({ super.key, this.popoverOffset, this.border = true, this.density, required this.children, }); final Offset? popoverOffset; final bool border; final ControlDensity? density; final List children; @override State createState() => _MenubarState(); } class _MenubarState extends State { final ValueNotifier _openChild = ValueNotifier(null); final ValueNotifier _hoveredChild = ValueNotifier(null); final Object _rootGroupId = Object(); @override void dispose() { _openChild.dispose(); _hoveredChild.dispose(); super.dispose(); } void _closeAll() { _openChild.value = null; _hoveredChild.value = null; } @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final cs = theme.colorScheme; final gap = theme.density.containerGap; bool hasLeading = false; for (final c in widget.children) { if (c.hasLeading) hasLeading = true; } final children = [ for (var i = 0; i < widget.children.length; i++) ...[ if (i > 0) const SizedBox(width: 2), _MenuIndex(index: i, child: widget.children[i]), ], ]; Widget row = DefaultTextStyle.merge( style: GarageTheme.of(context).typography.normal, child: IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: children, ), ), ); row = _MenuScope( direction: Axis.horizontal, hasLeading: hasLeading, itemPadding: EdgeInsets.zero, isMenubar: true, popoverOffset: widget.popoverOffset ?? const Offset(0, 4), density: widget.density ?? ControlDensity.of(theme), closeAll: _closeAll, openChild: _openChild, hoveredChild: _hoveredChild, rootGroupId: _rootGroupId, // outside taps anywhere that isn't part of this menu close the dropdown child: TapRegion( groupId: _rootGroupId, onTapOutside: (_) => _closeAll(), child: row, ), ); if (!widget.border) { return row; } return Container( padding: EdgeInsets.all(gap * 0.5), decoration: BoxDecoration( color: cs.background, borderRadius: BorderRadius.circular(theme.radiusMd), border: Border.all(color: cs.border, width: 1), ), child: row, ); } } // --------------------------------------------------------------------------- // MenuButton — the workhorse. clickable item, optional submenu. // --------------------------------------------------------------------------- class MenuButton extends StatefulWidget implements MenuItem { const MenuButton({ super.key, required this.child, this.subMenu, this.onPressed, this.trailing, this.leading, this.enabled = true, this.focusNode, this.autoClose = true, this.popoverController, this.semanticChecked, }); /// when non-null the row announces as a checkable item in this state. /// MenuCheckbox sets it — the tick it draws in the leading slot is pure /// paint and reads as nothing on its own. final bool? semanticChecked; final Widget child; final List? subMenu; final ContextedCallback? onPressed; final Widget? trailing; final Widget? leading; final bool enabled; final FocusNode? focusNode; final bool autoClose; final PopoverController? popoverController; @override bool get hasLeading => leading != null; @override State createState() => _MenuButtonState(); } class _MenuButtonState extends State { final OverlayPortalController _portal = OverlayPortalController(); final LayerLink _link = LayerLink(); _MenuScope? _scope; int _index = 0; bool _hovered = false; bool get _hasSub => (widget.subMenu?.isNotEmpty ?? false); bool get _isOpen => _scope?.openChild.value == _index; @override void didChangeDependencies() { super.didChangeDependencies(); final scope = _MenuScope.of(context); if (scope?.openChild != _scope?.openChild) { _scope?.openChild.removeListener(_syncPortal); scope?.openChild.addListener(_syncPortal); } if (scope?.hoveredChild != _scope?.hoveredChild) { _scope?.hoveredChild.removeListener(_onHoverChanged); scope?.hoveredChild.addListener(_onHoverChanged); } _scope = scope; _index = _MenuIndex.indexOf(context); } void _onHoverChanged() { if (mounted) setState(() {}); } @override void dispose() { _scope?.openChild.removeListener(_syncPortal); _scope?.hoveredChild.removeListener(_onHoverChanged); widget.popoverController?._setOpen(false); super.dispose(); } void _syncPortal() { if (!mounted) return; final shouldOpen = _isOpen && _hasSub; if (shouldOpen && !_portal.isShowing) { _portal.show(); widget.popoverController?._setOpen(true); } else if (!shouldOpen && _portal.isShowing) { _portal.hide(); widget.popoverController?._setOpen(false); } } void _open() { _scope?.openChild.value = _index; } void _handleTap() { widget.onPressed?.call(context); if (_hasSub) { if (_isOpen) { _scope?.openChild.value = null; } else { _open(); } } else { if (widget.autoClose) { _scope?.closeAll(); } } } void _handleHover(bool entered) { final scope = _scope; // centralised hover: only one sibling can be lit. setting it on enter // clears whoever was lit before, so a dropped onExit cant leave items stuck. if (scope != null) { if (entered) { scope.hoveredChild.value = _index; } else if (scope.hoveredChild.value == _index) { scope.hoveredChild.value = null; } } else { setState(() => _hovered = entered); } if (!entered) return; if (scope == null) return; if (_hasSub) { // inside a popup we open on hover; on the menubar only when a dropdown is // already showing (classic "tracking" behaviour). if (!scope.isMenubar || scope.openChild.value != null) { _open(); } } else { // leaf item — make sure a sibling's submenu isn't left hanging open scope.openChild.value = null; } } @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final cs = theme.colorScheme; final gap = theme.density.containerGap; final scope = _scope; final isMenubar = scope?.isMenubar ?? false; final groupHasLeading = scope?.hasLeading ?? false; final itemPadding = scope?.itemPadding ?? EdgeInsets.zero; final density = scope?.density ?? ControlDensity.of(theme); final bool hovered = _scope != null ? _scope!.hoveredChild.value == _index : _hovered; final highlight = widget.enabled && (hovered || _isOpen); // Menu triggers and rows share the button density tokens. Their height is // therefore intrinsic: 11px content plus the token padding, never a fixed // per-row constraint. final EdgeInsets padding = _menuPadding(density, theme) + itemPadding; // foreground, the same slot every other control's text reads. This was // cs.menuItemText, which every scheme authored eight values of grey away // from foreground - a difference nobody could see and everybody had to // maintain. final Color baseFg = cs.foreground; final Color fg = widget.enabled ? baseFg : baseFg.withValues(alpha: 0.5); final Color baseIcon = cs.mutedForeground; final Color iconColor = widget.enabled ? baseIcon : baseIcon.withValues(alpha: 0.5); final double leadSlot = isMenubar ? gap * 2 : _kMenuIconSlot; final double leadIcon = theme.iconTheme.small.size ?? 11; final double leadGap = isMenubar ? gap : 4; final double leadIconOffsetX = !isMenubar ? (leadGap - padding.left) / 2 : 0; final double iconOffsetY = density.isCompact ? 0.5 : 0; final double textOffsetY = 0; // leading slot Widget? leading; if (widget.leading != null) { leading = SizedBox( width: leadSlot, child: Center( child: Transform.translate( offset: Offset(leadIconOffsetX, iconOffsetY), child: IconTheme.merge( data: IconThemeData(size: leadIcon, color: iconColor), child: widget.leading!, ), ), ), ); } else if (groupHasLeading && !isMenubar) { leading = SizedBox(width: leadSlot); } // trailing slot (shortcut / chevron) Widget? trailing; if (isMenubar) { trailing = widget.trailing; } else if (widget.trailing != null || _hasSub) { trailing = Row( mainAxisSize: MainAxisSize.min, children: [ if (widget.trailing != null) Transform.translate( offset: widget.trailing is Icon ? Offset(0, iconOffsetY) : Offset.zero, child: IconTheme.merge( data: IconThemeData(size: leadIcon, color: iconColor), child: widget.trailing!, ), ), if (widget.trailing != null && _hasSub) SizedBox(width: gap), if (_hasSub) Transform.translate( offset: Offset(0, iconOffsetY), child: Icon( LucideIcons.chevron_right, size: leadIcon, color: iconColor, ), ), ], ); } final content = Transform.translate( offset: Offset(0, textOffsetY), child: DefaultTextStyle.merge( // control type, same as Button and Select. This used to set only the // colour and inherit size and weight from the popup, so a menu row // rendered a pixel larger and a weight lighter than the buttons it // sits beside - and than the MenuShortcut in its own row. style: theme.typography.small.copyWith(color: fg), maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis, child: IconTheme.merge( data: IconThemeData(color: iconColor, size: leadIcon), child: widget.child, ), ), ); // row layout differs between the horizontal bar and vertical items final Widget rowChild = isMenubar ? Row( mainAxisSize: MainAxisSize.min, children: [ if (leading != null) ...[leading, SizedBox(width: leadGap)], content, if (trailing != null) ...[SizedBox(width: gap), trailing], ], ) : Row( children: [ if (leading != null) ...[leading, SizedBox(width: leadGap)], Expanded(child: content), if (trailing != null) ...[SizedBox(width: gap), trailing], ], ); Widget button = MouseRegion( cursor: widget.enabled ? SystemMouseCursors.click : SystemMouseCursors.basic, onEnter: (_) => _handleHover(true), onExit: (_) => _handleHover(false), child: GestureDetector( behavior: HitTestBehavior.opaque, excludeFromSemantics: true, onTap: widget.enabled ? _handleTap : null, child: Container( padding: padding, decoration: BoxDecoration( color: highlight ? cs.rowHovered : null, borderRadius: BorderRadius.circular(isMenubar ? theme.radiusMd : 4), ), alignment: isMenubar ? Alignment.center : null, child: rowChild, ), ), ); // an item that opens a submenu is an expandable, everything else is a // plain button. The label comes from the merged row (title + shortcut), // which is what you want read out - the shortcut is genuinely part of // what the item is. button = MergeSemantics( child: Semantics( container: true, button: widget.semanticChecked == null, checked: widget.semanticChecked, enabled: widget.enabled, expanded: _hasSub ? _isOpen : null, onTap: widget.enabled ? _handleTap : null, child: button, ), ); // wire up the submenu overlay if there is one if (_hasSub) { button = CompositedTransformTarget( link: _link, child: OverlayPortal( controller: _portal, overlayChildBuilder: _buildSubMenu, child: button, ), ); } return button; } Widget _buildSubMenu(BuildContext overlayContext) { final theme = GarageTheme.of(context); final scaling = theme.scaling; final gap = theme.density.containerGap; final scope = _scope!; // anchor: menubar drops down below-left, a nested submenu flies out to the // right. offsets copied from shadcn's menu/menubar defaults. // // both directions flip when they'd run off the viewport - a menubar menu // opens upward near the bottom, a submenu flies left near the right edge. // the popup shrink-wraps so its real size isnt known here; the min width // is the best estimate available, and the flip falls back to a plain // which-side-has-more-room comparison when that doesnt fit either. final bool isMenubar = scope.isMenubar; final target = context.findRenderObject() as RenderBox?; final overlayBox = Overlay.of(overlayContext).context.findRenderObject() as RenderBox?; final placement = (target != null && overlayBox != null && target.hasSize) ? resolvePopoverPlacement( target: target, overlay: overlayBox, preferredSize: Size(_kMenuBarPopupMinWidth * scaling, 0), axis: isMenubar ? PopoverAxis.vertical : PopoverAxis.horizontal, gap: gap, ) : null; final Alignment targetAnchor = placement?.targetAnchor ?? (isMenubar ? Alignment.bottomLeft : Alignment.topRight); final Alignment followerAnchor = placement?.followerAnchor ?? Alignment.topLeft; // the gap has to point away from the trigger on whichever side we landed, // or a flipped menu overlaps the thing that opened it final bool below = placement?.openBelow ?? true; final bool toRight = placement?.openToRight ?? true; final Offset offset = isMenubar ? (widget.key != null ? Offset.zero : Offset(-gap * 0.5, below ? gap : -gap)) + scope.popoverOffset : Offset(toRight ? gap : -gap, below ? -gap * 0.625 : gap * 0.625); final subChildren = widget.subMenu ?? const []; Widget popup = ConstrainedBox( constraints: BoxConstraints(minWidth: _kMenuBarPopupMinWidth * scaling), child: MenuGroup( direction: Axis.vertical, onDismissed: scope.closeAll, regionGroupId: scope.rootGroupId, // The header trigger may be compact, but popup rows should follow the // active theme density. density: ControlDensity.of(theme), builder: (context, children) => MenuPopup(children: children), children: subChildren, ), ); // keep the submenu inside the same tap region so hovering back to the // parent doesn't dismiss, and let escape bubble up. popup = TapRegion(groupId: scope.rootGroupId, child: popup); return Positioned.fill( child: Stack( children: [ CompositedTransformFollower( link: _link, targetAnchor: targetAnchor, followerAnchor: followerAnchor, offset: offset, child: Align(alignment: Alignment.topLeft, child: popup), ), ], ), ); } } // --------------------------------------------------------------------------- // MenuCheckbox — a MenuButton with a check glyph in the leading slot. // --------------------------------------------------------------------------- class MenuCheckbox extends StatelessWidget implements MenuItem { const MenuCheckbox({ super.key, this.value = false, this.onChanged, required this.child, this.trailing, this.enabled = true, this.autoClose = true, }); final bool value; final ContextedValueChanged? onChanged; final Widget child; final Widget? trailing; final bool enabled; final bool autoClose; @override bool get hasLeading => true; @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final iconSize = theme.iconTheme.small.size ?? 11; return MenuButton( leading: value ? SizedBox( width: iconSize, height: iconSize, child: Icon(LucideIcons.check, size: iconSize), ) : SizedBox(width: iconSize), onPressed: (context) => onChanged?.call(context, !value), enabled: enabled, autoClose: autoClose, trailing: trailing, semanticChecked: value, child: child, ); } } // --------------------------------------------------------------------------- // MenuLabel — non interactive header row. // --------------------------------------------------------------------------- class MenuLabel extends StatelessWidget implements MenuItem { const MenuLabel({ super.key, required this.child, this.trailing, this.leading, }); final Widget child; final Widget? trailing; final Widget? leading; @override bool get hasLeading => leading != null; @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final cs = theme.colorScheme; final gap = theme.density.containerGap; final scope = _MenuScope.of(context); final density = scope?.density ?? ControlDensity.of(theme); final padding = _menuPadding(density, theme) + (scope?.itemPadding ?? EdgeInsets.zero); final leadIconOffsetX = (gap - padding.left) / 2; final double iconOffsetY = density.isCompact ? 0.5 : 0; final textOffsetY = 0.0; Widget? lead; if (leading != null) { lead = SizedBox( width: _kMenuIconSlot, child: Center( child: Transform.translate( offset: Offset(leadIconOffsetX, iconOffsetY), child: IconTheme.merge( data: IconThemeData( size: theme.iconTheme.small.size, color: cs.mutedForeground, ), child: leading!, ), ), ), ); } else if (scope?.hasLeading ?? false) { lead = SizedBox(width: _kMenuIconSlot); } return MergeSemantics( child: Semantics( container: true, header: true, child: Padding( padding: padding, child: Row( children: [ if (lead != null) ...[lead, SizedBox(width: gap)], Expanded( child: Transform.translate( offset: Offset(0, textOffsetY), child: DefaultTextStyle.merge( style: theme.typography.small.merge( theme.typography.semiBold, ), child: child, ), ), ), if (trailing != null) ...[SizedBox(width: gap), trailing!], ], ), ), ), ); } } // --------------------------------------------------------------------------- // MenuDivider — thin line between items. // --------------------------------------------------------------------------- class MenuDivider extends StatelessWidget implements MenuItem { const MenuDivider({super.key}); @override bool get hasLeading => false; @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final cs = theme.colorScheme; final scaling = theme.scaling; final scope = _MenuScope.of(context); final vertical = scope == null || scope.direction == Axis.vertical; if (vertical) { return Padding( padding: EdgeInsets.symmetric(vertical: 4 * scaling), child: Container(height: 1 * scaling, color: cs.divider), ); } return Padding( padding: EdgeInsets.symmetric(horizontal: 4 * scaling), child: Container(width: 1 * scaling, color: cs.divider), ); } } // --------------------------------------------------------------------------- // MenuGap — empty spacing item. // --------------------------------------------------------------------------- class MenuGap extends StatelessWidget implements MenuItem { const MenuGap(this.size, {super.key}); final double size; @override bool get hasLeading => false; @override Widget build(BuildContext context) => SizedBox(width: size, height: size); } // --------------------------------------------------------------------------- // MenuShortcut — renders a shortcut activator as muted key glyphs. // --------------------------------------------------------------------------- class MenuShortcut extends StatelessWidget { const MenuShortcut({super.key, required this.activator, this.combiner}); final ShortcutActivator activator; final Widget? combiner; @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final cs = theme.colorScheme; final combine = combiner ?? const Text(" + "); final keys = _shortcutActivatorToKeySet(activator); final children = []; for (var i = 0; i < keys.length; i++) { if (i > 0) children.add(combine); children.add(Text(_keyGlyph(keys[i]))); } return DefaultTextStyle.merge( style: theme.typography.small.copyWith(color: cs.mutedForeground), child: Row(mainAxisSize: MainAxisSize.min, children: children), ); } } // same glyph mapping shadcn's default keyboard display uses String _keyGlyph(LogicalKeyboardKey key) { switch (key) { case LogicalKeyboardKey.control: case LogicalKeyboardKey.controlLeft: case LogicalKeyboardKey.controlRight: return "Ctrl"; case LogicalKeyboardKey.shift: case LogicalKeyboardKey.shiftLeft: case LogicalKeyboardKey.shiftRight: return "Shift"; case LogicalKeyboardKey.alt: case LogicalKeyboardKey.altLeft: case LogicalKeyboardKey.altRight: return "Alt"; case LogicalKeyboardKey.meta: case LogicalKeyboardKey.metaLeft: case LogicalKeyboardKey.metaRight: return "⌘"; // ⌘ case LogicalKeyboardKey.enter: return "↵"; // ↵ case LogicalKeyboardKey.arrowLeft: return "←"; case LogicalKeyboardKey.arrowRight: return "→"; case LogicalKeyboardKey.arrowUp: return "↑"; case LogicalKeyboardKey.arrowDown: return "↓"; default: return key.keyLabel; } } List _shortcutActivatorToKeySet( ShortcutActivator activator, ) { final keys = []; if (activator is CharacterActivator) { if (activator.control) keys.add(LogicalKeyboardKey.control); if (activator.alt) keys.add(LogicalKeyboardKey.alt); if (activator.meta) keys.add(LogicalKeyboardKey.meta); keys.add(LogicalKeyboardKey(activator.character.codeUnitAt(0))); } else if (activator is SingleActivator) { if (activator.control) keys.add(LogicalKeyboardKey.control); if (activator.alt) keys.add(LogicalKeyboardKey.alt); if (activator.meta) keys.add(LogicalKeyboardKey.meta); if (activator.shift) keys.add(LogicalKeyboardKey.shift); keys.add(activator.trigger); } else if (activator is LogicalKeySet) { keys.addAll(activator.triggers); } return keys; }