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
1057 lines
33 KiB
Dart
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;
|
|
}
|