Files
ImBenjiandClaude Opus 5.5 b269201919 The Garage SDKs, in the open
garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of
Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme,
docs under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
2026-09-23 18:49:21 +01:00

1057 lines
33 KiB
Dart

// 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<T> = 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<int?> 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<int?> 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<Widget> 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<MenuItem> children;
final Widget Function(BuildContext context, List<Widget> 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<MenuGroup> createState() => _MenuGroupState();
}
class _MenuGroupState extends State<MenuGroup> {
final ValueNotifier<int?> _openChild = ValueNotifier(null);
final ValueNotifier<int?> _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 = <Widget>[
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<MenuItem> children;
@override
State<Menubar> createState() => _MenubarState();
}
class _MenubarState extends State<Menubar> {
final ValueNotifier<int?> _openChild = ValueNotifier(null);
final ValueNotifier<int?> _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 = <Widget>[
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<MenuItem>? 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<MenuButton> createState() => _MenuButtonState();
}
class _MenuButtonState extends State<MenuButton> {
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 <MenuItem>[];
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<bool>? 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 = <Widget>[];
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<LogicalKeyboardKey> _shortcutActivatorToKeySet(
ShortcutActivator activator,
) {
final keys = <LogicalKeyboardKey>[];
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;
}