Menu bar accelerators (#114852)

* Add MenuMenuAcceleratorLabel to support accelerators.

* Review Changes

* Review Changed

* Fix default label builder to use characters

* Remove golden test that shouldn't have been there.
This commit is contained in:
Greg Spencer 2022-11-28 16:27:20 -08:00 committed by GitHub
parent db631f1496
commit 0cb9f70460
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 1598 additions and 675 deletions

View File

@ -29,6 +29,7 @@ class _HomeState extends State<Home> {
TextDirection _textDirection = TextDirection.ltr; TextDirection _textDirection = TextDirection.ltr;
double _extraPadding = 0; double _extraPadding = 0;
bool _addItem = false; bool _addItem = false;
bool _accelerators = true;
bool _transparent = false; bool _transparent = false;
bool _funkyTheme = false; bool _funkyTheme = false;
@ -99,6 +100,7 @@ class _HomeState extends State<Home> {
children: <Widget>[ children: <Widget>[
_TestMenus( _TestMenus(
menuController: _controller, menuController: _controller,
accelerators: _accelerators,
addItem: _addItem, addItem: _addItem,
), ),
Expanded( Expanded(
@ -107,6 +109,7 @@ class _HomeState extends State<Home> {
menuController: _controller, menuController: _controller,
density: _density, density: _density,
addItem: _addItem, addItem: _addItem,
accelerators: _accelerators,
transparent: _transparent, transparent: _transparent,
funkyTheme: _funkyTheme, funkyTheme: _funkyTheme,
extraPadding: _extraPadding, extraPadding: _extraPadding,
@ -131,6 +134,11 @@ class _HomeState extends State<Home> {
_addItem = value; _addItem = value;
}); });
}, },
onAcceleratorsChanged: (bool value) {
setState(() {
_accelerators = value;
});
},
onTransparentChanged: (bool value) { onTransparentChanged: (bool value) {
setState(() { setState(() {
_transparent = value; _transparent = value;
@ -159,12 +167,14 @@ class _Controls extends StatefulWidget {
required this.textDirection, required this.textDirection,
required this.extraPadding, required this.extraPadding,
this.addItem = false, this.addItem = false,
this.accelerators = true,
this.transparent = false, this.transparent = false,
this.funkyTheme = false, this.funkyTheme = false,
required this.onDensityChanged, required this.onDensityChanged,
required this.onTextDirectionChanged, required this.onTextDirectionChanged,
required this.onExtraPaddingChanged, required this.onExtraPaddingChanged,
required this.onAddItemChanged, required this.onAddItemChanged,
required this.onAcceleratorsChanged,
required this.onTransparentChanged, required this.onTransparentChanged,
required this.onFunkyThemeChanged, required this.onFunkyThemeChanged,
required this.menuController, required this.menuController,
@ -174,12 +184,14 @@ class _Controls extends StatefulWidget {
final TextDirection textDirection; final TextDirection textDirection;
final double extraPadding; final double extraPadding;
final bool addItem; final bool addItem;
final bool accelerators;
final bool transparent; final bool transparent;
final bool funkyTheme; final bool funkyTheme;
final ValueChanged<VisualDensity> onDensityChanged; final ValueChanged<VisualDensity> onDensityChanged;
final ValueChanged<TextDirection> onTextDirectionChanged; final ValueChanged<TextDirection> onTextDirectionChanged;
final ValueChanged<double> onExtraPaddingChanged; final ValueChanged<double> onExtraPaddingChanged;
final ValueChanged<bool> onAddItemChanged; final ValueChanged<bool> onAddItemChanged;
final ValueChanged<bool> onAcceleratorsChanged;
final ValueChanged<bool> onTransparentChanged; final ValueChanged<bool> onTransparentChanged;
final ValueChanged<bool> onFunkyThemeChanged; final ValueChanged<bool> onFunkyThemeChanged;
final MenuController menuController; final MenuController menuController;
@ -199,165 +211,180 @@ class _ControlsState extends State<_Controls> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Center(
color: Colors.lightBlueAccent, child: SingleChildScrollView(
alignment: Alignment.center, child: Column(
child: Column( children: <Widget>[
mainAxisAlignment: MainAxisAlignment.center, MenuAnchor(
children: <Widget>[ childFocusNode: _focusNode,
MenuAnchor( style: const MenuStyle(alignment: AlignmentDirectional.topEnd),
childFocusNode: _focusNode, alignmentOffset: const Offset(100, -8),
style: const MenuStyle(alignment: AlignmentDirectional.topEnd), menuChildren: <Widget>[
alignmentOffset: const Offset(100, -8), MenuItemButton(
menuChildren: <Widget>[ shortcut: TestMenu.standaloneMenu1.shortcut,
MenuItemButton( onPressed: () {
shortcut: TestMenu.standaloneMenu1.shortcut, _itemSelected(TestMenu.standaloneMenu1);
onPressed: () { },
_itemSelected(TestMenu.standaloneMenu1); child: MenuAcceleratorLabel(TestMenu.standaloneMenu1.label),
}, ),
child: Text(TestMenu.standaloneMenu1.label), MenuItemButton(
leadingIcon: const Icon(Icons.send),
trailingIcon: const Icon(Icons.mail),
onPressed: () {
_itemSelected(TestMenu.standaloneMenu2);
},
child: MenuAcceleratorLabel(TestMenu.standaloneMenu2.label),
),
],
builder: (BuildContext context, MenuController controller, Widget? child) {
return TextButton(
focusNode: _focusNode,
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: child!,
);
},
child: const MenuAcceleratorLabel('Open Menu'),
),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_ControlSlider(
label: 'Extra Padding: ${widget.extraPadding.toStringAsFixed(1)}',
value: widget.extraPadding,
max: 40,
divisions: 20,
onChanged: (double value) {
widget.onExtraPaddingChanged(value);
},
),
_ControlSlider(
label: 'Horizontal Density: ${widget.density.horizontal.toStringAsFixed(1)}',
value: widget.density.horizontal,
max: 4,
min: -4,
divisions: 12,
onChanged: (double value) {
widget.onDensityChanged(
VisualDensity(
horizontal: value,
vertical: widget.density.vertical,
),
);
},
),
_ControlSlider(
label: 'Vertical Density: ${widget.density.vertical.toStringAsFixed(1)}',
value: widget.density.vertical,
max: 4,
min: -4,
divisions: 12,
onChanged: (double value) {
widget.onDensityChanged(
VisualDensity(
horizontal: widget.density.horizontal,
vertical: value,
),
);
},
),
],
), ),
MenuItemButton( ),
leadingIcon: const Icon(Icons.send), Column(
trailingIcon: const Icon(Icons.mail), crossAxisAlignment: CrossAxisAlignment.start,
onPressed: () {
_itemSelected(TestMenu.standaloneMenu2);
},
child: Text(TestMenu.standaloneMenu2.label),
),
],
builder: (BuildContext context, MenuController controller, Widget? child) {
return TextButton(
focusNode: _focusNode,
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: child!,
);
},
child: const Text('Open Menu'),
),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[ children: <Widget>[
_ControlSlider( Row(
label: 'Extra Padding: ${widget.extraPadding.toStringAsFixed(1)}', mainAxisSize: MainAxisSize.min,
value: widget.extraPadding, children: <Widget>[
max: 40, Checkbox(
divisions: 20, value: widget.textDirection == TextDirection.rtl,
onChanged: (double value) { onChanged: (bool? value) {
widget.onExtraPaddingChanged(value); if (value ?? false) {
}, widget.onTextDirectionChanged(TextDirection.rtl);
} else {
widget.onTextDirectionChanged(TextDirection.ltr);
}
},
),
const Text('RTL Text')
],
), ),
_ControlSlider( Row(
label: 'Horizontal Density: ${widget.density.horizontal.toStringAsFixed(1)}', mainAxisSize: MainAxisSize.min,
value: widget.density.horizontal, children: <Widget>[
max: 4, Checkbox(
min: -4, value: widget.addItem,
divisions: 12, onChanged: (bool? value) {
onChanged: (double value) { if (value ?? false) {
widget.onDensityChanged( widget.onAddItemChanged(true);
VisualDensity( } else {
horizontal: value, widget.onAddItemChanged(false);
vertical: widget.density.vertical, }
), },
); ),
}, const Text('Add Item')
],
), ),
_ControlSlider( Row(
label: 'Vertical Density: ${widget.density.vertical.toStringAsFixed(1)}', mainAxisSize: MainAxisSize.min,
value: widget.density.vertical, children: <Widget>[
max: 4, Checkbox(
min: -4, value: widget.accelerators,
divisions: 12, onChanged: (bool? value) {
onChanged: (double value) { if (value ?? false) {
widget.onDensityChanged( widget.onAcceleratorsChanged(true);
VisualDensity( } else {
horizontal: widget.density.horizontal, widget.onAcceleratorsChanged(false);
vertical: value, }
), },
); ),
}, const Text('Enable Accelerators')
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Checkbox(
value: widget.transparent,
onChanged: (bool? value) {
if (value ?? false) {
widget.onTransparentChanged(true);
} else {
widget.onTransparentChanged(false);
}
},
),
const Text('Transparent')
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Checkbox(
value: widget.funkyTheme,
onChanged: (bool? value) {
if (value ?? false) {
widget.onFunkyThemeChanged(true);
} else {
widget.onFunkyThemeChanged(false);
}
},
),
const Text('Funky Theme')
],
), ),
], ],
), ),
), ],
Column( ),
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Checkbox(
value: widget.textDirection == TextDirection.rtl,
onChanged: (bool? value) {
if (value ?? false) {
widget.onTextDirectionChanged(TextDirection.rtl);
} else {
widget.onTextDirectionChanged(TextDirection.ltr);
}
},
),
const Text('RTL Text')
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Checkbox(
value: widget.addItem,
onChanged: (bool? value) {
if (value ?? false) {
widget.onAddItemChanged(true);
} else {
widget.onAddItemChanged(false);
}
},
),
const Text('Add Item')
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Checkbox(
value: widget.transparent,
onChanged: (bool? value) {
if (value ?? false) {
widget.onTransparentChanged(true);
} else {
widget.onTransparentChanged(false);
}
},
),
const Text('Transparent')
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Checkbox(
value: widget.funkyTheme,
onChanged: (bool? value) {
if (value ?? false) {
widget.onFunkyThemeChanged(true);
} else {
widget.onFunkyThemeChanged(false);
}
},
),
const Text('Funky Theme')
],
),
],
),
],
), ),
); );
} }
@ -412,10 +439,12 @@ class _TestMenus extends StatefulWidget {
const _TestMenus({ const _TestMenus({
required this.menuController, required this.menuController,
this.addItem = false, this.addItem = false,
this.accelerators = false,
}); });
final MenuController menuController; final MenuController menuController;
final bool addItem; final bool addItem;
final bool accelerators;
@override @override
State<_TestMenus> createState() => _TestMenusState(); State<_TestMenus> createState() => _TestMenusState();
@ -439,8 +468,8 @@ class _TestMenusState extends State<_TestMenus> {
debugPrint('App: Closed item ${item.label}'); debugPrint('App: Closed item ${item.label}');
} }
void _setRadio(TestMenu item) { void _setRadio(TestMenu? item) {
debugPrint('App: Set Radio item ${item.label}'); debugPrint('App: Set Radio item ${item?.label}');
setState(() { setState(() {
radioValue = item; radioValue = item;
}); });
@ -449,17 +478,17 @@ class _TestMenusState extends State<_TestMenus> {
void _setCheck(TestMenu item) { void _setCheck(TestMenu item) {
debugPrint('App: Set Checkbox item ${item.label}'); debugPrint('App: Set Checkbox item ${item.label}');
setState(() { setState(() {
switch (checkboxState) { switch (checkboxState) {
case false: case false:
checkboxState = true; checkboxState = true;
break; break;
case true: case true:
checkboxState = null; checkboxState = null;
break; break;
case null: case null:
checkboxState = false; checkboxState = false;
break; break;
} }
}); });
} }
@ -469,9 +498,9 @@ class _TestMenusState extends State<_TestMenus> {
_shortcutsEntry?.dispose(); _shortcutsEntry?.dispose();
final Map<ShortcutActivator, Intent> shortcuts = <ShortcutActivator, Intent>{}; final Map<ShortcutActivator, Intent> shortcuts = <ShortcutActivator, Intent>{};
for (final TestMenu item in TestMenu.values) { for (final TestMenu item in TestMenu.values) {
if (item.shortcut == null) { if (item.shortcut == null) {
continue; continue;
} }
switch (item) { switch (item) {
case TestMenu.radioMenu1: case TestMenu.radioMenu1:
case TestMenu.radioMenu2: case TestMenu.radioMenu2:
@ -519,219 +548,21 @@ class _TestMenusState extends State<_TestMenus> {
Expanded( Expanded(
child: MenuBar( child: MenuBar(
controller: widget.menuController, controller: widget.menuController,
children: <Widget>[ children: createTestMenus(
SubmenuButton( onPressed: _itemSelected,
onOpen: () { onOpen: _openItem,
_openItem(TestMenu.mainMenu1); onClose: _closeItem,
}, onCheckboxChanged: (TestMenu menu, bool? value) {
onClose: () { _setCheck(menu);
_closeItem(TestMenu.mainMenu1); },
}, onRadioChanged: _setRadio,
menuChildren: <Widget>[ checkboxValue: checkboxState,
CheckboxMenuButton( radioValue: radioValue,
value: checkboxState, menuController: widget.menuController,
tristate: true, textEditingController: textController,
shortcut: TestMenu.subMenu1.shortcut, includeExtraGroups: widget.addItem,
trailingIcon: const Icon(Icons.assessment), accelerators: widget.accelerators,
onChanged: (bool? value) { ),
setState(() {
checkboxState = value;
});
_itemSelected(TestMenu.subMenu1);
},
child: Text(TestMenu.subMenu1.label),
),
RadioMenuButton<TestMenu>(
value: TestMenu.radioMenu1,
groupValue: radioValue,
toggleable: true,
shortcut: TestMenu.radioMenu1.shortcut,
trailingIcon: const Icon(Icons.assessment),
onChanged: (TestMenu? value) {
setState(() {
radioValue = value;
});
_itemSelected(TestMenu.radioMenu1);
},
child: Text(TestMenu.radioMenu1.label),
),
RadioMenuButton<TestMenu>(
value: TestMenu.radioMenu2,
groupValue: radioValue,
toggleable: true,
shortcut: TestMenu.radioMenu2.shortcut,
trailingIcon: const Icon(Icons.assessment),
onChanged: (TestMenu? value) {
setState(() {
radioValue = value;
});
_itemSelected(TestMenu.radioMenu2);
},
child: Text(TestMenu.radioMenu2.label),
),
RadioMenuButton<TestMenu>(
value: TestMenu.radioMenu3,
groupValue: radioValue,
toggleable: true,
shortcut: TestMenu.radioMenu3.shortcut,
trailingIcon: const Icon(Icons.assessment),
onChanged: (TestMenu? value) {
setState(() {
radioValue = value;
});
_itemSelected(TestMenu.radioMenu3);
},
child: Text(TestMenu.radioMenu3.label),
),
MenuItemButton(
leadingIcon: const Icon(Icons.send),
trailingIcon: const Icon(Icons.mail),
onPressed: () {
_itemSelected(TestMenu.subMenu2);
},
child: Text(TestMenu.subMenu2.label),
),
],
child: Text(TestMenu.mainMenu1.label),
),
SubmenuButton(
onOpen: () {
_openItem(TestMenu.mainMenu2);
},
onClose: () {
_closeItem(TestMenu.mainMenu2);
},
menuChildren: <Widget>[
TextButton(
child: const Text('TEST'),
onPressed: () {
_itemSelected(TestMenu.testButton);
widget.menuController.close();
},
),
MenuItemButton(
shortcut: TestMenu.subMenu3.shortcut,
onPressed: () {
_itemSelected(TestMenu.subMenu3);
},
child: Text(TestMenu.subMenu3.label),
),
],
child: Text(TestMenu.mainMenu2.label),
),
SubmenuButton(
onOpen: () {
_openItem(TestMenu.mainMenu3);
},
onClose: () {
_closeItem(TestMenu.mainMenu3);
},
menuChildren: <Widget>[
MenuItemButton(
child: Text(TestMenu.subMenu8.label),
onPressed: () {
_itemSelected(TestMenu.subMenu8);
},
),
],
child: Text(TestMenu.mainMenu3.label),
),
SubmenuButton(
onOpen: () {
_openItem(TestMenu.mainMenu4);
},
onClose: () {
_closeItem(TestMenu.mainMenu4);
},
menuChildren: <Widget>[
Actions(
actions: <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (ActivateIntent? intent) {
debugPrint('Activated!');
return;
},
)
},
child: MenuItemButton(
onPressed: () {
debugPrint('Activated text input item with ${textController.text} as a value.');
},
child: SizedBox(
width: 200,
child: TextField(
controller: textController,
onSubmitted: (String value) {
debugPrint('String $value submitted.');
},
),
),
),
),
SubmenuButton(
onOpen: () {
_openItem(TestMenu.subMenu5);
},
onClose: () {
_closeItem(TestMenu.subMenu5);
},
menuChildren: <Widget>[
MenuItemButton(
shortcut: TestMenu.subSubMenu1.shortcut,
onPressed: () {
_itemSelected(TestMenu.subSubMenu1);
},
child: Text(TestMenu.subSubMenu1.label),
),
MenuItemButton(
child: Text(TestMenu.subSubMenu2.label),
onPressed: () {
_itemSelected(TestMenu.subSubMenu2);
},
),
if (widget.addItem)
SubmenuButton(
menuChildren: <Widget>[
MenuItemButton(
shortcut: TestMenu.subSubSubMenu1.shortcut,
onPressed: () {
_itemSelected(TestMenu.subSubSubMenu1);
},
child: Text(TestMenu.subSubSubMenu1.label),
),
],
child: Text(TestMenu.subSubMenu3.label),
),
],
child: Text(TestMenu.subMenu5.label),
),
MenuItemButton(
// Disabled button
shortcut: TestMenu.subMenu6.shortcut,
child: Text(TestMenu.subMenu6.label),
),
MenuItemButton(
child: Text(TestMenu.subMenu7.label),
onPressed: () {
_itemSelected(TestMenu.subMenu7);
},
),
MenuItemButton(
child: Text(TestMenu.subMenu7.label),
onPressed: () {
_itemSelected(TestMenu.subMenu7);
},
),
MenuItemButton(
child: Text(TestMenu.subMenu8.label),
onPressed: () {
_itemSelected(TestMenu.subMenu8);
},
),
],
child: Text(TestMenu.mainMenu4.label),
),
],
), ),
), ),
], ],
@ -739,31 +570,223 @@ class _TestMenusState extends State<_TestMenus> {
} }
} }
List<Widget> createTestMenus({
void Function(TestMenu)? onPressed,
void Function(TestMenu, bool?)? onCheckboxChanged,
void Function(TestMenu?)? onRadioChanged,
void Function(TestMenu)? onOpen,
void Function(TestMenu)? onClose,
Map<TestMenu, MenuSerializableShortcut> shortcuts = const <TestMenu, MenuSerializableShortcut>{},
bool? checkboxValue,
TestMenu? radioValue,
MenuController? menuController,
TextEditingController? textEditingController,
bool includeExtraGroups = false,
bool accelerators = false,
}) {
Widget submenuButton(
TestMenu menu, {
required List<Widget> menuChildren,
}) {
return SubmenuButton(
onOpen: onOpen != null ? () => onOpen(menu) : null,
onClose: onClose != null ? () => onClose(menu) : null,
menuChildren: menuChildren,
child: accelerators ? MenuAcceleratorLabel(menu.acceleratorLabel) : Text(menu.label),
);
}
Widget menuItemButton(
TestMenu menu, {
bool enabled = true,
Widget? leadingIcon,
Widget? trailingIcon,
Key? key,
}) {
return MenuItemButton(
key: key,
onPressed: enabled && onPressed != null ? () => onPressed(menu) : null,
shortcut: shortcuts[menu],
leadingIcon: leadingIcon,
trailingIcon: trailingIcon,
child: accelerators ? MenuAcceleratorLabel(menu.acceleratorLabel) : Text(menu.label),
);
}
Widget checkboxMenuButton(
TestMenu menu, {
bool enabled = true,
bool tristate = false,
Widget? leadingIcon,
Widget? trailingIcon,
Key? key,
}) {
return CheckboxMenuButton(
key: key,
value: checkboxValue,
tristate: tristate,
onChanged: enabled && onCheckboxChanged != null ? (bool? value) => onCheckboxChanged(menu, value) : null,
shortcut: menu.shortcut,
trailingIcon: trailingIcon,
child: accelerators ? MenuAcceleratorLabel(menu.acceleratorLabel) : Text(menu.label),
);
}
Widget radioMenuButton(
TestMenu menu, {
bool enabled = true,
bool toggleable = false,
Widget? leadingIcon,
Widget? trailingIcon,
Key? key,
}) {
return RadioMenuButton<TestMenu>(
key: key,
groupValue: radioValue,
value: menu,
toggleable: toggleable,
onChanged: enabled && onRadioChanged != null ? onRadioChanged : null,
shortcut: menu.shortcut,
trailingIcon: trailingIcon,
child: accelerators ? MenuAcceleratorLabel(menu.acceleratorLabel) : Text(menu.label),
);
}
final List<Widget> result = <Widget>[
submenuButton(
TestMenu.mainMenu1,
menuChildren: <Widget>[
checkboxMenuButton(
TestMenu.subMenu1,
tristate: true,
trailingIcon: const Icon(Icons.assessment),
),
radioMenuButton(
TestMenu.radioMenu1,
toggleable: true,
trailingIcon: const Icon(Icons.assessment),
),
radioMenuButton(
TestMenu.radioMenu2,
toggleable: true,
trailingIcon: const Icon(Icons.assessment),
),
radioMenuButton(
TestMenu.radioMenu3,
toggleable: true,
trailingIcon: const Icon(Icons.assessment),
),
menuItemButton(
TestMenu.subMenu2,
leadingIcon: const Icon(Icons.send),
trailingIcon: const Icon(Icons.mail),
),
],
),
submenuButton(
TestMenu.mainMenu2,
menuChildren: <Widget>[
MenuAcceleratorCallbackBinding(
onInvoke: onPressed != null
? () {
onPressed.call(TestMenu.testButton);
menuController?.close();
}
: null,
child: TextButton(
onPressed: onPressed != null
? () {
onPressed.call(TestMenu.testButton);
menuController?.close();
}
: null,
child: accelerators
? MenuAcceleratorLabel(TestMenu.testButton.acceleratorLabel)
: Text(TestMenu.testButton.label),
),
),
menuItemButton(TestMenu.subMenu3),
],
),
submenuButton(
TestMenu.mainMenu3,
menuChildren: <Widget>[
menuItemButton(TestMenu.subMenu8),
],
),
submenuButton(
TestMenu.mainMenu4,
menuChildren: <Widget>[
MenuItemButton(
onPressed: () {
debugPrint('Activated text input item with ${textEditingController?.text} as a value.');
},
child: SizedBox(
width: 200,
child: TextField(
controller: textEditingController,
onSubmitted: (String value) {
debugPrint('String $value submitted.');
},
),
),
),
submenuButton(
TestMenu.subMenu5,
menuChildren: <Widget>[
menuItemButton(TestMenu.subSubMenu1),
menuItemButton(TestMenu.subSubMenu2),
if (includeExtraGroups)
submenuButton(
TestMenu.subSubMenu3,
menuChildren: <Widget>[
menuItemButton(TestMenu.subSubSubMenu1),
],
),
],
),
menuItemButton(TestMenu.subMenu6, enabled: false),
menuItemButton(TestMenu.subMenu7),
menuItemButton(TestMenu.subMenu7),
menuItemButton(TestMenu.subMenu8),
],
),
];
return result;
}
enum TestMenu { enum TestMenu {
mainMenu1('Menu 1'), mainMenu1('Menu 1'),
mainMenu2('Menu 2'), mainMenu2('M&enu &2'),
mainMenu3('Menu 3'), mainMenu3('Me&nu &3'),
mainMenu4('Menu 4'), mainMenu4('Men&u &4'),
radioMenu1('Radio Menu One', SingleActivator(LogicalKeyboardKey.digit1, control: true)), radioMenu1('Radio Menu One', SingleActivator(LogicalKeyboardKey.digit1, control: true)),
radioMenu2('Radio Menu Two', SingleActivator(LogicalKeyboardKey.digit2, control: true)), radioMenu2('Radio Menu Two', SingleActivator(LogicalKeyboardKey.digit2, control: true)),
radioMenu3('Radio Menu Three', SingleActivator(LogicalKeyboardKey.digit3, control: true)), radioMenu3('Radio Menu Three', SingleActivator(LogicalKeyboardKey.digit3, control: true)),
subMenu1('Sub Menu 1', SingleActivator(LogicalKeyboardKey.keyB, control: true)), subMenu1('Sub Menu &1', SingleActivator(LogicalKeyboardKey.keyB, control: true)),
subMenu2('Sub Menu 2'), subMenu2('Sub Menu &2'),
subMenu3('Sub Menu 3', SingleActivator(LogicalKeyboardKey.enter, control: true)), subMenu3('Sub Menu &3', SingleActivator(LogicalKeyboardKey.enter, control: true)),
subMenu4('Sub Menu 4'), subMenu4('Sub Menu &4'),
subMenu5('Sub Menu 5'), subMenu5('Sub Menu &5'),
subMenu6('Sub Menu 6', SingleActivator(LogicalKeyboardKey.tab, control: true)), subMenu6('Sub Menu &6', SingleActivator(LogicalKeyboardKey.tab, control: true)),
subMenu7('Sub Menu 7'), subMenu7('Sub Menu &7'),
subMenu8('Sub Menu 8'), subMenu8('Sub Menu &8'),
subSubMenu1('Sub Sub Menu 1', SingleActivator(LogicalKeyboardKey.f10, control: true)), subSubMenu1('Sub Sub Menu &1', SingleActivator(LogicalKeyboardKey.f10, control: true)),
subSubMenu2('Sub Sub Menu 2'), subSubMenu2('Sub Sub Menu &2'),
subSubMenu3('Sub Sub Menu 3'), subSubMenu3('Sub Sub Menu &3'),
subSubSubMenu1('Sub Sub Sub Menu 1', SingleActivator(LogicalKeyboardKey.f11, control: true)), subSubSubMenu1('Sub Sub Sub Menu &1', SingleActivator(LogicalKeyboardKey.f11, control: true)),
testButton('TEST button'), testButton('&TEST && &&& Button &'),
standaloneMenu1('Standalone Menu 1', SingleActivator(LogicalKeyboardKey.keyC, control: true)), standaloneMenu1('Standalone Menu &1', SingleActivator(LogicalKeyboardKey.keyC, control: true)),
standaloneMenu2('Standalone Menu 2'); standaloneMenu2('Standalone Menu &2');
const TestMenu(this.label, [this.shortcut]); const TestMenu(this.acceleratorLabel, [this.shortcut]);
final String label;
final MenuSerializableShortcut? shortcut; final MenuSerializableShortcut? shortcut;
final String acceleratorLabel;
// Strip the accelerator markers.
String get label => MenuAcceleratorLabel.stripAcceleratorMarkers(acceleratorLabel);
int get acceleratorIndex {
int index = -1;
MenuAcceleratorLabel.stripAcceleratorMarkers(acceleratorLabel, setIndex: (int i) => index = i);
return index;
}
} }

View File

@ -0,0 +1,116 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/// Flutter code sample for [MenuAcceleratorLabel].
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() => runApp(const MenuAcceleratorApp());
class MyMenuBar extends StatelessWidget {
const MyMenuBar({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Expanded(
child: MenuBar(
children: <Widget>[
SubmenuButton(
menuChildren: <Widget>[
MenuItemButton(
onPressed: () {
showAboutDialog(
context: context,
applicationName: 'MenuBar Sample',
applicationVersion: '1.0.0',
);
},
child: const MenuAcceleratorLabel('&About'),
),
MenuItemButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Saved!'),
),
);
},
child: const MenuAcceleratorLabel('&Save'),
),
MenuItemButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Quit!'),
),
);
},
child: const MenuAcceleratorLabel('&Quit'),
),
],
child: const MenuAcceleratorLabel('&File'),
),
SubmenuButton(
menuChildren: <Widget>[
MenuItemButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Magnify!'),
),
);
},
child: const MenuAcceleratorLabel('&Magnify'),
),
MenuItemButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Minify!'),
),
);
},
child: const MenuAcceleratorLabel('Mi&nify'),
),
],
child: const MenuAcceleratorLabel('&View'),
),
],
),
),
],
),
Expanded(
child: FlutterLogo(
size: MediaQuery.of(context).size.shortestSide * 0.5,
),
),
],
);
}
}
class MenuAcceleratorApp extends StatelessWidget {
const MenuAcceleratorApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
const SingleActivator(LogicalKeyboardKey.keyT, control: true): VoidCallbackIntent(() {
debugDumpApp();
}),
},
child: const Scaffold(body: MyMenuBar()),
),
);
}
}

View File

@ -2,7 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
/// Flutter code sample for [MenuBar] /// Flutter code sample for [MenuBar].
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';

View File

@ -49,9 +49,9 @@ class _MyStatefulWidgetState extends State<MyStatefulWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Shortcuts( return Shortcuts(
shortcuts: <ShortcutActivator, Intent>{ shortcuts: const <ShortcutActivator, Intent>{
LogicalKeySet(LogicalKeyboardKey.arrowUp): const IncrementIntent(), SingleActivator(LogicalKeyboardKey.arrowUp): IncrementIntent(),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const DecrementIntent(), SingleActivator(LogicalKeyboardKey.arrowDown): DecrementIntent(),
}, },
child: Actions( child: Actions(
actions: <Type, Action<Intent>>{ actions: <Type, Action<Intent>>{

View File

@ -0,0 +1,54 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_api_samples/material/menu_anchor/menu_accelerator_label.0.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('Can open menu', (WidgetTester tester) async {
Finder findMenu(String label) {
return find
.ancestor(
of: find.text(label, findRichText: true),
matching: find.byType(FocusScope),
)
.first;
}
await tester.pumpWidget(const example.MenuAcceleratorApp());
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyF, character: 'f');
await tester.pumpAndSettle();
await tester.pump();
expect(find.text('About', findRichText: true), findsOneWidget);
expect(
tester.getRect(findMenu('About')),
equals(const Rect.fromLTRB(4.0, 48.0, 98.0, 208.0)),
);
expect(find.text('Save', findRichText: true), findsOneWidget);
expect(find.text('Quit', findRichText: true), findsOneWidget);
expect(find.text('Magnify', findRichText: true), findsNothing);
expect(find.text('Minify', findRichText: true), findsNothing);
// Open the About dialog.
await tester.sendKeyEvent(LogicalKeyboardKey.keyA, character: 'a');
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
await tester.pumpAndSettle();
expect(find.text('Save', findRichText: true), findsNothing);
expect(find.text('Quit', findRichText: true), findsNothing);
expect(find.text('Magnify', findRichText: true), findsNothing);
expect(find.text('Minify', findRichText: true), findsNothing);
expect(find.text('CLOSE'), findsOneWidget);
await tester.tap(find.text('CLOSE'));
await tester.pumpAndSettle();
expect(find.text('CLOSE'), findsNothing);
});
}

View File

@ -216,7 +216,7 @@ class MenuAnchor extends StatefulWidget {
/// A list of children containing the menu items that are the contents of the /// A list of children containing the menu items that are the contents of the
/// menu surrounded by this [MenuAnchor]. /// menu surrounded by this [MenuAnchor].
/// ///
/// {@macro flutter.material.menu_bar.shortcuts_note} /// {@macro flutter.material.MenuBar.shortcuts_note}
final List<Widget> menuChildren; final List<Widget> menuChildren;
/// The widget that this [MenuAnchor] surrounds. /// The widget that this [MenuAnchor] surrounds.
@ -263,7 +263,6 @@ class _MenuAnchorState extends State<MenuAnchor> {
// view's edges. // view's edges.
final GlobalKey _anchorKey = GlobalKey(debugLabel: kReleaseMode ? null : 'MenuAnchor'); final GlobalKey _anchorKey = GlobalKey(debugLabel: kReleaseMode ? null : 'MenuAnchor');
_MenuAnchorState? _parent; _MenuAnchorState? _parent;
bool _childIsOpen = false;
final FocusScopeNode _menuScopeNode = FocusScopeNode(debugLabel: kReleaseMode ? null : 'MenuAnchor sub menu'); final FocusScopeNode _menuScopeNode = FocusScopeNode(debugLabel: kReleaseMode ? null : 'MenuAnchor sub menu');
MenuController? _internalMenuController; MenuController? _internalMenuController;
final List<_MenuAnchorState> _anchorChildren = <_MenuAnchorState>[]; final List<_MenuAnchorState> _anchorChildren = <_MenuAnchorState>[];
@ -357,6 +356,7 @@ class _MenuAnchorState extends State<MenuAnchor> {
return _MenuAnchorMarker( return _MenuAnchorMarker(
anchorKey: _anchorKey, anchorKey: _anchorKey,
anchor: this, anchor: this,
isOpen: _isOpen,
child: child, child: child,
); );
} }
@ -436,14 +436,12 @@ class _MenuAnchorState extends State<MenuAnchor> {
return handle; return handle;
} }
void _childChangedOpenState(bool value) { void _childChangedOpenState() {
if (_childIsOpen != value) { if (mounted) {
_parent?._childChangedOpenState(_childIsOpen || _isOpen); _parent?._childChangedOpenState();
if (mounted) { setState(() {
setState(() { // Mark dirty, but only if mounted.
_childIsOpen = value; });
});
}
} }
} }
@ -483,13 +481,14 @@ class _MenuAnchorState extends State<MenuAnchor> {
// close it first. // close it first.
_close(); _close();
} }
assert(_debugMenuInfo('Opening $this at ${position ?? Offset.zero} with alignment offset ${widget.alignmentOffset ?? Offset.zero}')); assert(_debugMenuInfo(
'Opening $this at ${position ?? Offset.zero} with alignment offset ${widget.alignmentOffset ?? Offset.zero}'));
_parent?._closeChildren(); // Close all siblings. _parent?._closeChildren(); // Close all siblings.
assert(_overlayEntry == null); assert(_overlayEntry == null);
final BuildContext outerContext = context; final BuildContext outerContext = context;
_parent?._childChangedOpenState();
setState(() { setState(() {
_parent?._childChangedOpenState(true);
_overlayEntry = OverlayEntry( _overlayEntry = OverlayEntry(
builder: (BuildContext context) { builder: (BuildContext context) {
final OverlayState overlay = Overlay.of(outerContext); final OverlayState overlay = Overlay.of(outerContext);
@ -509,6 +508,7 @@ class _MenuAnchorState extends State<MenuAnchor> {
// it. // it.
anchorKey: _anchorKey, anchorKey: _anchorKey,
anchor: this, anchor: this,
isOpen: _isOpen,
child: _Submenu( child: _Submenu(
anchor: this, anchor: this,
menuStyle: widget.style, menuStyle: widget.style,
@ -542,12 +542,10 @@ class _MenuAnchorState extends State<MenuAnchor> {
_closeChildren(inDispose: inDispose); _closeChildren(inDispose: inDispose);
_overlayEntry?.remove(); _overlayEntry?.remove();
_overlayEntry = null; _overlayEntry = null;
if (!inDispose && mounted) { if (!inDispose) {
setState(() { // Notify that _childIsOpen changed state, but only if not
// Notify that _isOpen may have changed state, but only if not currently // currently disposing.
// disposing or unmounted. _parent?._childChangedOpenState();
_parent?._childChangedOpenState(false);
});
} }
widget.onClose?.call(); widget.onClose?.call();
} }
@ -651,11 +649,11 @@ class MenuController {
/// When a menu item with a submenu is clicked on, it toggles the visibility of /// When a menu item with a submenu is clicked on, it toggles the visibility of
/// the submenu. When the menu item is hovered over, the submenu will open, and /// the submenu. When the menu item is hovered over, the submenu will open, and
/// hovering over other items will close the previous menu and open the newly /// hovering over other items will close the previous menu and open the newly
/// hovered one. When those open/close transitions occur, [SubmenuButton.onOpen], /// hovered one. When those open/close transitions occur,
/// and [SubmenuButton.onClose] are called on the corresponding [SubmenuButton] child /// [SubmenuButton.onOpen], and [SubmenuButton.onClose] are called on the
/// of the menu bar. /// corresponding [SubmenuButton] child of the menu bar.
/// ///
/// {@template flutter.material.menu_bar.shortcuts_note} /// {@template flutter.material.MenuBar.shortcuts_note}
/// Menus using [MenuItemButton] can have a [SingleActivator] or /// Menus using [MenuItemButton] can have a [SingleActivator] or
/// [CharacterActivator] assigned to them as their [MenuItemButton.shortcut], /// [CharacterActivator] assigned to them as their [MenuItemButton.shortcut],
/// which will display an appropriate shortcut hint. Even though the shortcut /// which will display an appropriate shortcut hint. Even though the shortcut
@ -670,16 +668,17 @@ class MenuController {
/// sure that selecting a menu item and triggering the shortcut do the same /// sure that selecting a menu item and triggering the shortcut do the same
/// thing, it is recommended that they call the same callback. /// thing, it is recommended that they call the same callback.
/// ///
/// {@tool dartpad} /// {@tool dartpad} This example shows a [MenuBar] that contains a single top
/// This example shows a [MenuBar] that contains a single top level menu, /// level menu, containing three items: "About", a checkbox menu item for
/// containing three items: "About", a checkbox menu item for showing a /// showing a message, and "Quit". The items are identified with an enum value,
/// message, and "Quit". The items are identified with an enum value, and the /// and the shortcuts are registered globally with the [ShortcutRegistry].
/// shortcuts are registered globally with the [ShortcutRegistry].
/// ///
/// ** See code in examples/api/lib/material/menu_anchor/menu_bar.0.dart ** /// ** See code in examples/api/lib/material/menu_anchor/menu_bar.0.dart **
/// {@end-tool} /// {@end-tool}
/// {@endtemplate} /// {@endtemplate}
/// ///
/// {@macro flutter.material.MenuAcceleratorLabel.accelerator_sample}
///
/// See also: /// See also:
/// ///
/// * [MenuAnchor], a widget that creates a region with a submenu and shows it /// * [MenuAnchor], a widget that creates a region with a submenu and shows it
@ -729,7 +728,7 @@ class MenuBar extends StatelessWidget {
/// incorrect behaviors. Whenever the menus list is modified, a new list /// incorrect behaviors. Whenever the menus list is modified, a new list
/// object must be provided. /// object must be provided.
/// ///
/// {@macro flutter.material.menu_bar.shortcuts_note} /// {@macro flutter.material.MenuBar.shortcuts_note}
final List<Widget> children; final List<Widget> children;
@override @override
@ -747,7 +746,7 @@ class MenuBar extends StatelessWidget {
List<DiagnosticsNode> debugDescribeChildren() { List<DiagnosticsNode> debugDescribeChildren() {
return <DiagnosticsNode>[ return <DiagnosticsNode>[
...children.map<DiagnosticsNode>( ...children.map<DiagnosticsNode>(
(Widget item) => item.toDiagnosticsNode(), (Widget item) => item.toDiagnosticsNode(),
), ),
]; ];
} }
@ -767,7 +766,7 @@ class MenuBar extends StatelessWidget {
/// part of a [MenuBar], but may be used independently, or as part of a menu /// part of a [MenuBar], but may be used independently, or as part of a menu
/// created with a [MenuAnchor]. /// created with a [MenuAnchor].
/// ///
/// {@macro flutter.material.menu_bar.shortcuts_note} /// {@macro flutter.material.MenuBar.shortcuts_note}
/// ///
/// See also: /// See also:
/// ///
@ -829,7 +828,7 @@ class MenuItemButton extends StatefulWidget {
/// The optional shortcut that selects this [MenuItemButton]. /// The optional shortcut that selects this [MenuItemButton].
/// ///
/// {@macro flutter.material.menu_bar.shortcuts_note} /// {@macro flutter.material.MenuBar.shortcuts_note}
final MenuSerializableShortcut? shortcut; final MenuSerializableShortcut? shortcut;
/// Customizes this button's appearance. /// Customizes this button's appearance.
@ -1029,7 +1028,7 @@ class _MenuItemButtonState extends State<MenuItemButton> {
mergedStyle = widget.style!.merge(mergedStyle); mergedStyle = widget.style!.merge(mergedStyle);
} }
return TextButton( Widget child = TextButton(
onPressed: widget.enabled ? _handleSelect : null, onPressed: widget.enabled ? _handleSelect : null,
onHover: widget.enabled ? _handleHover : null, onHover: widget.enabled ? _handleHover : null,
onFocusChange: widget.enabled ? widget.onFocusChange : null, onFocusChange: widget.enabled ? widget.onFocusChange : null,
@ -1045,6 +1044,15 @@ class _MenuItemButtonState extends State<MenuItemButton> {
child: widget.child!, child: widget.child!,
), ),
); );
if (_platformSupportsAccelerators() && widget.enabled) {
child = MenuAcceleratorCallbackBinding(
onInvoke: _handleSelect,
child: child,
);
}
return child;
} }
void _handleFocusChange() { void _handleFocusChange() {
@ -1193,7 +1201,7 @@ class CheckboxMenuButton extends StatelessWidget {
/// The optional shortcut that selects this [MenuItemButton]. /// The optional shortcut that selects this [MenuItemButton].
/// ///
/// {@macro flutter.material.menu_bar.shortcuts_note} /// {@macro flutter.material.MenuBar.shortcuts_note}
final MenuSerializableShortcut? shortcut; final MenuSerializableShortcut? shortcut;
/// Customizes this button's appearance. /// Customizes this button's appearance.
@ -1390,7 +1398,7 @@ class RadioMenuButton<T> extends StatelessWidget {
/// The optional shortcut that selects this [MenuItemButton]. /// The optional shortcut that selects this [MenuItemButton].
/// ///
/// {@macro flutter.material.menu_bar.shortcuts_note} /// {@macro flutter.material.MenuBar.shortcuts_note}
final MenuSerializableShortcut? shortcut; final MenuSerializableShortcut? shortcut;
/// Customizes this button's appearance. /// Customizes this button's appearance.
@ -1467,7 +1475,6 @@ class RadioMenuButton<T> extends StatelessWidget {
} }
} }
/// A menu button that displays a cascading menu. /// A menu button that displays a cascading menu.
/// ///
/// It can be used as part of a [MenuBar], or as a standalone widget. /// It can be used as part of a [MenuBar], or as a standalone widget.
@ -1811,6 +1818,9 @@ class _SubmenuButtonState extends State<SubmenuButton> {
} }
void toggleShowMenu(BuildContext context) { void toggleShowMenu(BuildContext context) {
if (controller._anchor == null) {
return;
}
if (controller.isOpen) { if (controller.isOpen) {
controller.close(); controller.close();
} else { } else {
@ -1835,7 +1845,7 @@ class _SubmenuButtonState extends State<SubmenuButton> {
// is already open. This means that the user has to first click to // is already open. This means that the user has to first click to
// open a menu on the menu bar before hovering allows them to traverse // open a menu on the menu bar before hovering allows them to traverse
// it. // it.
if (controller._anchor!._root._orientation == Axis.horizontal && !controller._anchor!._root._childIsOpen) { if (controller._anchor!._root._orientation == Axis.horizontal && !controller._anchor!._root._isOpen) {
return; return;
} }
@ -1845,7 +1855,7 @@ class _SubmenuButtonState extends State<SubmenuButton> {
} }
} }
return TextButton( child = TextButton(
style: mergedStyle, style: mergedStyle,
focusNode: _buttonFocusNode, focusNode: _buttonFocusNode,
onHover: _enabled ? (bool hovering) => handleHover(hovering, context) : null, onHover: _enabled ? (bool hovering) => handleHover(hovering, context) : null,
@ -1858,6 +1868,15 @@ class _SubmenuButtonState extends State<SubmenuButton> {
child: child ?? const SizedBox(), child: child ?? const SizedBox(),
), ),
); );
if (_enabled && _platformSupportsAccelerators()) {
return MenuAcceleratorCallbackBinding(
onInvoke: () => toggleShowMenu(context),
hasSubmenu: true,
child: child,
);
}
return child;
}, },
menuChildren: widget.menuChildren, menuChildren: widget.menuChildren,
child: widget.child, child: widget.child,
@ -1874,7 +1893,7 @@ class _SubmenuButtonState extends State<SubmenuButton> {
T? resolve<T>(MaterialStateProperty<T>? Function(MenuStyle? style) getProperty) { T? resolve<T>(MaterialStateProperty<T>? Function(MenuStyle? style) getProperty) {
return effectiveValue( return effectiveValue(
(MenuStyle? style) { (MenuStyle? style) {
return getProperty(style)?.resolve(widget.statesController?.value ?? const <MaterialState>{}); return getProperty(style)?.resolve(widget.statesController?.value ?? const <MaterialState>{});
}, },
); );
@ -1882,10 +1901,7 @@ class _SubmenuButtonState extends State<SubmenuButton> {
return resolve<EdgeInsetsGeometry?>( return resolve<EdgeInsetsGeometry?>(
(MenuStyle? style) => style?.padding, (MenuStyle? style) => style?.padding,
)?.resolve( )?.resolve(Directionality.of(context)) ?? EdgeInsets.zero;
Directionality.of(context),
) ??
EdgeInsets.zero;
} }
void _handleFocusChange() { void _handleFocusChange() {
@ -2154,14 +2170,18 @@ class _MenuAnchorMarker extends InheritedWidget {
required super.child, required super.child,
required this.anchorKey, required this.anchorKey,
required this.anchor, required this.anchor,
required this.isOpen,
}); });
final GlobalKey anchorKey; final GlobalKey anchorKey;
final _MenuAnchorState anchor; final _MenuAnchorState anchor;
final bool isOpen;
@override @override
bool updateShouldNotify(_MenuAnchorMarker oldWidget) { bool updateShouldNotify(_MenuAnchorMarker oldWidget) {
return anchorKey != oldWidget.anchorKey || anchor != anchor; return anchorKey != oldWidget.anchorKey
|| anchor != oldWidget.anchor
|| isOpen != oldWidget.isOpen;
} }
} }
@ -2183,7 +2203,12 @@ class _MenuBarAnchorState extends _MenuAnchorState {
@override @override
bool get _isOpen { bool get _isOpen {
// If it's a bar, then it's "open" if any of its children are open. // If it's a bar, then it's "open" if any of its children are open.
return _childIsOpen; for (final _MenuAnchorState child in _anchorChildren) {
if (child._isOpen) {
return true;
}
}
return false;
} }
@override @override
@ -2464,6 +2489,427 @@ class _MenuDirectionalFocusAction extends DirectionalFocusAction {
} }
} }
/// An [InheritedWidget] that provides a descendant [MenuAcceleratorLabel] with
/// the function to invoke when the accelerator is pressed.
///
/// This is used when creating your own custom menu item for use with
/// [MenuAnchor] or [MenuBar]. Provided menu items such as [MenuItemButton] and
/// [SubmenuButton] already supply this wrapper internally.
class MenuAcceleratorCallbackBinding extends InheritedWidget {
/// Create a const [MenuAcceleratorCallbackBinding].
///
/// The [child] parameter is required.
const MenuAcceleratorCallbackBinding({
super.key,
this.onInvoke,
this.hasSubmenu = false,
required super.child,
});
/// The function that pressing the accelerator defined in a descendant
/// [MenuAcceleratorLabel] will invoke.
///
/// If set to null, then the accelerator won't be enabled.
final VoidCallback? onInvoke;
/// Whether or not the associated label will host its own submenu or not.
///
/// This setting determines when accelerators are active, since accelerators
/// for menu items that open submenus shouldn't be active when the submenu is
/// open.
final bool hasSubmenu;
@override
bool updateShouldNotify(MenuAcceleratorCallbackBinding oldWidget) {
return onInvoke != oldWidget.onInvoke || hasSubmenu != oldWidget.hasSubmenu;
}
/// Returns the active [MenuAcceleratorCallbackBinding] in the given context, if any,
/// and creates a dependency relationship that will rebuild the context when
/// [onInvoke] changes.
///
/// If no [MenuAcceleratorCallbackBinding] is found, returns null.
///
/// See also:
///
/// * [of], which is similar, but asserts if no [MenuAcceleratorCallbackBinding]
/// is found.
static MenuAcceleratorCallbackBinding? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<MenuAcceleratorCallbackBinding>();
}
/// Returns the active [MenuAcceleratorCallbackBinding] in the given context, and
/// creates a dependency relationship that will rebuild the context when
/// [onInvoke] changes.
///
/// If no [MenuAcceleratorCallbackBinding] is found, returns will assert in debug mode
/// and throw an exception in release mode.
///
/// See also:
///
/// * [maybeOf], which is similar, but returns null if no
/// [MenuAcceleratorCallbackBinding] is found.
static MenuAcceleratorCallbackBinding of(BuildContext context) {
final MenuAcceleratorCallbackBinding? result = maybeOf(context);
assert(() {
if (result == null) {
throw FlutterError(
'MenuAcceleratorWrapper.of() was called with a context that does not '
'contain a MenuAcceleratorWrapper in the given context.\n'
'No MenuAcceleratorWrapper ancestor could be found in the context that '
'was passed to MenuAcceleratorWrapper.of(). This can happen because '
'you are using a widget that looks for a MenuAcceleratorWrapper '
'ancestor, and do not have a MenuAcceleratorWrapper widget ancestor.\n'
'The context used was:\n'
' $context',
);
}
return true;
}());
return result!;
}
}
/// The type of builder function used for building a [MenuAcceleratorLabel]'s
/// [MenuAcceleratorLabel.builder] function.
///
/// {@template flutter.material.menu_anchor.menu_accelerator_child_builder.args}
/// The arguments to the function are as follows:
///
/// * The `context` supplies the [BuildContext] to use.
/// * The `label` is the [MenuAcceleratorLabel.label] attribute for the relevant
/// [MenuAcceleratorLabel] with the accelerator markers stripped out of it.
/// * The `index` is the index of the accelerator character within the
/// `label.characters` that applies to this accelerator. If it is -1, then the
/// accelerator should not be highlighted. Otherwise, the given character
/// should be highlighted somehow in the rendered label (typically with an
/// underscore). Importantly, `index` is not an index into the [String]
/// `label`, it is an index into the [Characters] iterable returned by
/// `label.characters`, so that it is in terms of user-visible characters
/// (a.k.a. grapheme clusters), not Unicode code points.
/// {@endtemplate}
///
/// See also:
///
/// * [MenuAcceleratorLabel.defaultLabelBuilder], which is the implementation
/// used as the default value for [MenuAcceleratorLabel.builder].
typedef MenuAcceleratorChildBuilder = Widget Function(
BuildContext context,
String label,
int index,
);
/// A widget that draws the label text for a menu item (typically a
/// [MenuItemButton] or [SubmenuButton]) and renders its child with information
/// about the currently active keyboard accelerator.
///
/// On platforms other than macOS and iOS, this widget listens for the Alt key
/// to be pressed, and when it is down, will update the label by calling the
/// builder again with the position of the accelerator in the label string.
/// While the Alt key is pressed, it registers a shortcut with the
/// [ShortcutRegistry] mapped to a [VoidCallbackIntent] containing the callback
/// defined by the nearest [MenuAcceleratorCallbackBinding].
///
/// Because the accelerators are registered with the [ShortcutRegistry], any
/// other shortcuts in the widget tree between the [primaryFocus] and the
/// [ShortcutRegistry] that define Alt-based shortcuts using the same keys will
/// take precedence over the accelerators.
///
/// Because accelerators aren't used on macOS and iOS, the label ignores the Alt
/// key on those platforms, and the [builder] is always given -1 as an
/// accelerator index. Accelerator labels are still stripped of their
/// accelerator markers.
///
/// The built-in menu items [MenuItemButton] and [SubmenuButton] already provide
/// the appropriate [MenuAcceleratorCallbackBinding], so unless you are creating
/// your own custom menu item type that takes a [MenuAcceleratorLabel], it is
/// not necessary to provide one.
///
/// {@template flutter.material.MenuAcceleratorLabel.accelerator_sample}
/// {@tool dartpad} This example shows a [MenuBar] that handles keyboard
/// accelerators using [MenuAcceleratorLabel]. To use the accelerators, press
/// the Alt key to see which letters are underlined in the menu bar, and then
/// press the appropriate letter. Accelerators are not supported on macOS or iOS
/// since those platforms don't support them natively, so this demo will only
/// show a regular Material menu bar on those platforms.
///
/// ** See code in examples/api/lib/material/menu_anchor/menu_accelerator_label.0.dart **
/// {@end-tool}
/// {@endtemplate}
class MenuAcceleratorLabel extends StatefulWidget {
/// Creates a const [MenuAcceleratorLabel].
///
/// The [label] parameter is required.
const MenuAcceleratorLabel(
this.label, {
super.key,
this.builder = defaultLabelBuilder,
});
/// The label string that should be displayed.
///
/// The label string provides the label text, as well as the possible
/// characters which could be used as accelerators in the menu system.
///
/// {@template flutter.material.menu_anchor.menu_accelerator_label.label}
/// To indicate which letters in the label are to be used as accelerators, add
/// an "&" character before the character in the string. If more than one
/// character has an "&" in front of it, then the characters appearing earlier
/// in the string are preferred. To represent a literal "&", insert "&&" into
/// the string. All other ampersands will be removed from the string before
/// calling [MenuAcceleratorLabel.builder]. Bare ampersands at the end of the
/// string or before whitespace are stripped and ignored.
/// {@endtemplate}
///
/// See also:
///
/// * [displayLabel], which returns the [label] with all of the ampersands
/// stripped out of it, and double ampersands converted to ampersands.
/// * [stripAcceleratorMarkers], which returns the supplied string with all of
/// the ampersands stripped out of it, and double ampersands converted to
/// ampersands, and optionally calls a callback with the index of the
/// accelerator character found.
final String label;
/// Returns the [label] with any accelerator markers removed.
///
/// This getter just calls [stripAcceleratorMarkers] with the [label].
String get displayLabel => stripAcceleratorMarkers(label);
/// The optional [MenuAcceleratorChildBuilder] which is used to build the
/// widget that displays the label itself.
///
/// The [defaultLabelBuilder] function serves as the default value for
/// [builder], rendering the label as a [RichText] widget with appropriate
/// [TextSpan]s for rendering the label with an underscore under the selected
/// accelerator for the label when accelerators have been activated.
///
/// {@macro flutter.material.menu_anchor.menu_accelerator_child_builder.args}
///
/// When writing the builder function, it's not necessary to take the current
/// platform into account. On platforms which don't support accelerators (e.g.
/// macOS and iOS), the passed accelerator index will always be -1, and the
/// accelerator markers will already be stripped.
final MenuAcceleratorChildBuilder builder;
/// Whether [label] contains an accelerator definition.
///
/// {@macro flutter.material.menu_anchor.menu_accelerator_label.label}
bool get hasAccelerator => RegExp(r'&(?!([&\s]|$))').hasMatch(label);
/// Serves as the default value for [builder], rendering the label as a
/// [RichText] widget with appropriate [TextSpan]s for rendering the label
/// with an underscore under the selected accelerator for the label when the
/// [index] is non-negative, and a [Text] widget when the [index] is negative.
///
/// {@macro flutter.material.menu_anchor.menu_accelerator_child_builder.args}
static Widget defaultLabelBuilder(
BuildContext context,
String label,
int index,
) {
if (index < 0) {
return Text(label);
}
final TextStyle defaultStyle = DefaultTextStyle.of(context).style;
final Characters characters = label.characters;
return RichText(
text: TextSpan(
children: <TextSpan>[
if (index > 0)
TextSpan(text: characters.getRange(0, index).toString(), style: defaultStyle),
TextSpan(
text: characters.getRange(index, index + 1).toString(),
style: defaultStyle.copyWith(decoration: TextDecoration.underline),
),
if (index < characters.length - 1)
TextSpan(text: characters.getRange(index + 1).toString(), style: defaultStyle),
],
),
);
}
/// Strips out any accelerator markers from the given [label], and unescapes
/// any escaped ampersands.
///
/// If [setIndex] is supplied, it will be called before this function returns
/// with the index in the returned string of the accelerator character.
///
/// {@macro flutter.material.menu_anchor.menu_accelerator_label.label}
static String stripAcceleratorMarkers(String label, {void Function(int index)? setIndex}) {
int quotedAmpersands = 0;
final StringBuffer displayLabel = StringBuffer();
int acceleratorIndex = -1;
// Use characters so that we don't split up surrogate pairs and interpret
// them incorrectly.
final Characters labelChars = label.characters;
final Characters ampersand = '&'.characters;
bool lastWasAmpersand = false;
for (int i = 0; i < labelChars.length; i += 1) {
// Stop looking one before the end, since a single ampersand at the end is
// just treated as a quoted ampersand.
final Characters character = labelChars.characterAt(i);
if (lastWasAmpersand) {
lastWasAmpersand = false;
displayLabel.write(character);
continue;
}
if (character != ampersand) {
displayLabel.write(character);
continue;
}
if (i == labelChars.length - 1) {
// Strip bare ampersands at the end of a string.
break;
}
lastWasAmpersand = true;
final Characters acceleratorCharacter = labelChars.characterAt(i + 1);
if (acceleratorIndex == -1 && acceleratorCharacter != ampersand &&
acceleratorCharacter.toString().trim().isNotEmpty) {
// Don't set the accelerator index if the character is an ampersand,
// or whitespace.
acceleratorIndex = i - quotedAmpersands;
}
// As we encounter '&<character>' pairs, the following indices must be
// adjusted so that they correspond with indices in the stripped string.
quotedAmpersands += 1;
}
setIndex?.call(acceleratorIndex);
return displayLabel.toString();
}
@override
State<MenuAcceleratorLabel> createState() => _MenuAcceleratorLabelState();
@override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
return '$MenuAcceleratorLabel("$label")';
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('label', label));
}
}
class _MenuAcceleratorLabelState extends State<MenuAcceleratorLabel> {
late String _displayLabel;
int _acceleratorIndex = -1;
MenuAcceleratorCallbackBinding? _binding;
_MenuAnchorState? _anchor;
ShortcutRegistry? _shortcutRegistry;
ShortcutRegistryEntry? _shortcutRegistryEntry;
bool _showAccelerators = false;
@override
void initState() {
super.initState();
if (_platformSupportsAccelerators()) {
_showAccelerators = _altIsPressed();
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
}
_updateDisplayLabel();
}
@override
void dispose() {
assert(_platformSupportsAccelerators() || _shortcutRegistryEntry == null);
_displayLabel = '';
if (_platformSupportsAccelerators()) {
_shortcutRegistryEntry?.dispose();
_shortcutRegistryEntry = null;
_shortcutRegistry = null;
_anchor = null;
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
}
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_platformSupportsAccelerators()) {
return;
}
_binding = MenuAcceleratorCallbackBinding.maybeOf(context);
_anchor = _MenuAnchorState._maybeOf(context);
_shortcutRegistry = ShortcutRegistry.maybeOf(context);
_updateAcceleratorShortcut();
}
@override
void didUpdateWidget(MenuAcceleratorLabel oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.label != oldWidget.label) {
_updateDisplayLabel();
}
}
static bool _altIsPressed() {
return HardwareKeyboard.instance.logicalKeysPressed.intersection(
<LogicalKeyboardKey>{
LogicalKeyboardKey.altLeft,
LogicalKeyboardKey.altRight,
LogicalKeyboardKey.alt,
},
).isNotEmpty;
}
bool _handleKeyEvent(KeyEvent event) {
assert(_platformSupportsAccelerators());
final bool altIsPressed = _altIsPressed();
if (altIsPressed != _showAccelerators) {
setState(() {
_showAccelerators = altIsPressed;
_updateAcceleratorShortcut();
});
}
// Just listening, does't ever handle a key.
return false;
}
void _updateAcceleratorShortcut() {
assert(_platformSupportsAccelerators());
_shortcutRegistryEntry?.dispose();
_shortcutRegistryEntry = null;
// Before registering an accelerator as a shortcut it should meet these
// conditions:
//
// 1) Is showing accelerators (i.e. Alt key is down).
// 2) Has an accelerator marker in the label.
// 3) Has an associated action callback for the label (from the
// MenuAcceleratorCallbackBinding).
// 4) Is part of an anchor that either doesn't have a submenu, or doesn't
// have any submenus currently open (only the "deepest" open menu should
// have accelerator shortcuts registered).
assert(_displayLabel != null);
if (_showAccelerators && _acceleratorIndex != -1 && _binding?.onInvoke != null && !(_binding!.hasSubmenu && (_anchor?._isOpen ?? false))) {
final String acceleratorCharacter = _displayLabel[_acceleratorIndex].toLowerCase();
_shortcutRegistryEntry = _shortcutRegistry?.addAll(
<ShortcutActivator, Intent>{
CharacterActivator(acceleratorCharacter, alt: true): VoidCallbackIntent(_binding!.onInvoke!),
},
);
}
}
void _updateDisplayLabel() {
_displayLabel = MenuAcceleratorLabel.stripAcceleratorMarkers(
widget.label,
setIndex: (int index) {
_acceleratorIndex = index;
},
);
}
@override
Widget build(BuildContext context) {
final int index = _showAccelerators ? _acceleratorIndex : -1;
return widget.builder(context, _displayLabel, index);
}
}
/// A label widget that is used as the label for a [MenuItemButton] or /// A label widget that is used as the label for a [MenuItemButton] or
/// [SubmenuButton]. /// [SubmenuButton].
/// ///
@ -2857,25 +3303,25 @@ class _MenuPanelState extends State<_MenuPanel> {
constrainedAxis: widget.orientation, constrainedAxis: widget.orientation,
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
child: _intrinsicCrossSize( child: _intrinsicCrossSize(
child: Material( child: Material(
elevation: elevation, elevation: elevation,
shape: shape, shape: shape,
color: backgroundColor, color: backgroundColor,
shadowColor: shadowColor, shadowColor: shadowColor,
surfaceTintColor: surfaceTintColor, surfaceTintColor: surfaceTintColor,
type: backgroundColor == null ? MaterialType.transparency : MaterialType.canvas, type: backgroundColor == null ? MaterialType.transparency : MaterialType.canvas,
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
child: Padding( child: Padding(
padding: resolvedPadding, padding: resolvedPadding,
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: widget.orientation, scrollDirection: widget.orientation,
child: Flex( child: Flex(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
textDirection: Directionality.of(context), textDirection: Directionality.of(context),
direction: widget.orientation, direction: widget.orientation,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: widget.children, children: widget.children,
), ),
), ),
), ),
@ -3061,6 +3507,23 @@ bool _debugMenuInfo(String message, [Iterable<String>? details]) {
return true; return true;
} }
bool _platformSupportsAccelerators() {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return true;
case TargetPlatform.iOS:
case TargetPlatform.macOS:
// On iOS and macOS, pressing the Option key (a.k.a. the Alt key) causes a
// different set of characters to be generated, and the native menus don't
// support accelerators anyhow, so we just disable accelerators on these
// platforms.
return false;
}
}
// BEGIN GENERATED TOKEN PROPERTIES - Menu // BEGIN GENERATED TOKEN PROPERTIES - Menu
// Do not edit by hand. The code between the "BEGIN GENERATED" and // Do not edit by hand. The code between the "BEGIN GENERATED" and
@ -3072,13 +3535,13 @@ bool _debugMenuInfo(String message, [Iterable<String>? details]) {
class _MenuBarDefaultsM3 extends MenuStyle { class _MenuBarDefaultsM3 extends MenuStyle {
_MenuBarDefaultsM3(this.context) _MenuBarDefaultsM3(this.context)
: super( : super(
elevation: const MaterialStatePropertyAll<double?>(3.0), elevation: const MaterialStatePropertyAll<double?>(3.0),
shape: const MaterialStatePropertyAll<OutlinedBorder>(_defaultMenuBorder), shape: const MaterialStatePropertyAll<OutlinedBorder>(_defaultMenuBorder),
alignment: AlignmentDirectional.bottomStart, alignment: AlignmentDirectional.bottomStart,
); );
static const RoundedRectangleBorder _defaultMenuBorder = static const RoundedRectangleBorder _defaultMenuBorder =
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))); RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)));
final BuildContext context; final BuildContext context;
@ -3114,11 +3577,11 @@ class _MenuBarDefaultsM3 extends MenuStyle {
class _MenuButtonDefaultsM3 extends ButtonStyle { class _MenuButtonDefaultsM3 extends ButtonStyle {
_MenuButtonDefaultsM3(this.context) _MenuButtonDefaultsM3(this.context)
: super( : super(
animationDuration: kThemeChangeDuration, animationDuration: kThemeChangeDuration,
enableFeedback: true, enableFeedback: true,
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
); );
final BuildContext context; final BuildContext context;
late final ColorScheme _colors = Theme.of(context).colorScheme; late final ColorScheme _colors = Theme.of(context).colorScheme;
@ -3256,13 +3719,13 @@ class _MenuButtonDefaultsM3 extends ButtonStyle {
class _MenuDefaultsM3 extends MenuStyle { class _MenuDefaultsM3 extends MenuStyle {
_MenuDefaultsM3(this.context) _MenuDefaultsM3(this.context)
: super( : super(
elevation: const MaterialStatePropertyAll<double?>(3.0), elevation: const MaterialStatePropertyAll<double?>(3.0),
shape: const MaterialStatePropertyAll<OutlinedBorder>(_defaultMenuBorder), shape: const MaterialStatePropertyAll<OutlinedBorder>(_defaultMenuBorder),
alignment: AlignmentDirectional.topEnd, alignment: AlignmentDirectional.topEnd,
); );
static const RoundedRectangleBorder _defaultMenuBorder = static const RoundedRectangleBorder _defaultMenuBorder =
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))); RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)));
final BuildContext context; final BuildContext context;

View File

@ -5,6 +5,7 @@
import 'dart:collection'; import 'dart:collection';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'actions.dart'; import 'actions.dart';
@ -577,24 +578,43 @@ class SingleActivator with Diagnosticable, MenuSerializableShortcut implements S
/// ** See code in examples/api/lib/widgets/shortcuts/character_activator.0.dart ** /// ** See code in examples/api/lib/widgets/shortcuts/character_activator.0.dart **
/// {@end-tool} /// {@end-tool}
/// ///
/// The [alt], [control], and [meta] flags represent whether the respective
/// modifier keys should be held (true) or released (false). They default to
/// false. [CharacterActivator] cannot check shifted keys, since the Shift key
/// affects the resulting character, and will accept whether either of the
/// Shift keys are pressed or not, as long as the key event produces the
/// correct character.
///
/// By default, the activator is checked on all [RawKeyDownEvent] events for
/// the [character] in combination with the requested modifier keys. If
/// `includeRepeats` is false, only the [character] events with a false
/// [RawKeyDownEvent.repeat] attribute will be considered.
///
/// {@template flutter.widgets.shortcuts.CharacterActivator.alt}
/// On macOS and iOS, the [alt] flag indicates that the Option key () is
/// pressed. Because the Option key affects the character generated on these
/// platforms, it can be unintuitive to define [CharacterActivator]s for them.
///
/// For instance, if you want the shortcut to trigger when Option+s (-s) is
/// pressed, and what you intend is to trigger whenever the character 'ß' is
/// produced, you would use `CharacterActivator('ß')` or
/// `CharacterActivator('ß', alt: true)` instead of `CharacterActivator('s',
/// alt: true)`. This is because `CharacterActivator('s', alt: true)` will
/// never trigger, since the 's' character can't be produced when the Option
/// key is held down.
///
/// If what is intended is that the shortcut is triggered when Option+s (-s)
/// is pressed, regardless of which character is produced, it is better to use
/// [SingleActivator], as in `SingleActivator(LogicalKeyboardKey.keyS, alt:
/// true)`.
/// {@endtemplate}
///
/// See also: /// See also:
/// ///
/// * [SingleActivator], an activator that represents a single key combined /// * [SingleActivator], an activator that represents a single key combined
/// with modifiers, such as `Ctrl+C` or `Ctrl-Right Arrow`. /// with modifiers, such as `Ctrl+C` or `Ctrl-Right Arrow`.
class CharacterActivator with Diagnosticable, MenuSerializableShortcut implements ShortcutActivator { class CharacterActivator with Diagnosticable, MenuSerializableShortcut implements ShortcutActivator {
/// Triggered when the key event yields the given character. /// Triggered when the key event yields the given character.
///
/// The [alt], [control], and [meta] flags represent whether the respective
/// modifier keys should be held (true) or released (false). They default to
/// false. [CharacterActivator] cannot check Shift keys, since the shift key
/// affects the resulting character, and will accept whether either of the
/// Shift keys are pressed or not, as long as the key event produces the
/// correct character.
///
/// By default, the activator is checked on all [RawKeyDownEvent] events for
/// the [character] in combination with the requested modifier keys. If
/// `includeRepeats` is false, only the [character] events with a false
/// [RawKeyDownEvent.repeat] attribute will be considered.
const CharacterActivator(this.character, { const CharacterActivator(this.character, {
this.alt = false, this.alt = false,
this.control = false, this.control = false,
@ -602,36 +622,38 @@ class CharacterActivator with Diagnosticable, MenuSerializableShortcut implement
this.includeRepeats = true, this.includeRepeats = true,
}); });
/// Whether either (or both) alt keys should be held for the [character] to /// Whether either (or both) Alt keys should be held for the [character] to
/// activate the shortcut. /// activate the shortcut.
/// ///
/// It defaults to false, meaning all Alt keys must be released when the event /// It defaults to false, meaning all Alt keys must be released when the event
/// is received in order to activate the shortcut. If it's true, then either /// is received in order to activate the shortcut. If it's true, then either
/// or both Alt keys must be pressed. /// one or both Alt keys must be pressed.
/// ///
/// {@macro flutter.widgets.shortcuts.CharacterActivator.alt}
///
/// See also: /// See also:
/// ///
/// * [LogicalKeyboardKey.altLeft], [LogicalKeyboardKey.altRight]. /// * [LogicalKeyboardKey.altLeft], [LogicalKeyboardKey.altRight].
final bool alt; final bool alt;
/// Whether either (or both) control keys should be held for the [character] /// Whether either (or both) Control keys should be held for the [character]
/// to activate the shortcut. /// to activate the shortcut.
/// ///
/// It defaults to false, meaning all Control keys must be released when the /// It defaults to false, meaning all Control keys must be released when the
/// event is received in order to activate the shortcut. If it's true, then /// event is received in order to activate the shortcut. If it's true, then
/// either or both Control keys must be pressed. /// either one or both Control keys must be pressed.
/// ///
/// See also: /// See also:
/// ///
/// * [LogicalKeyboardKey.controlLeft], [LogicalKeyboardKey.controlRight]. /// * [LogicalKeyboardKey.controlLeft], [LogicalKeyboardKey.controlRight].
final bool control; final bool control;
/// Whether either (or both) meta keys should be held for the [character] to /// Whether either (or both) Meta keys should be held for the [character] to
/// activate the shortcut. /// activate the shortcut.
/// ///
/// It defaults to false, meaning all Meta keys must be released when the /// It defaults to false, meaning all Meta keys must be released when the
/// event is received in order to activate the shortcut. If it's true, then /// event is received in order to activate the shortcut. If it's true, then
/// either or both Meta keys must be pressed. /// either one or both Meta keys must be pressed.
/// ///
/// See also: /// See also:
/// ///
@ -1150,7 +1172,7 @@ class ShortcutRegistryEntry {
/// [ShortcutRegistryEntry] from the [registry]. /// [ShortcutRegistryEntry] from the [registry].
@mustCallSuper @mustCallSuper
void dispose() { void dispose() {
registry._disposeToken(this); registry._disposeEntry(this);
} }
} }
@ -1160,11 +1182,22 @@ class ShortcutRegistryEntry {
/// You can reach the nearest [ShortcutRegistry] using [of] and [maybeOf]. /// You can reach the nearest [ShortcutRegistry] using [of] and [maybeOf].
/// ///
/// The registry may be listened to (with [addListener]/[removeListener]) for /// The registry may be listened to (with [addListener]/[removeListener]) for
/// change notifications when the registered shortcuts change. /// change notifications when the registered shortcuts change. Change
/// notifications take place after the the current frame is drawn, so that
/// widgets that are not descendants of the registry can listen to it (e.g. in
/// overlays).
class ShortcutRegistry with ChangeNotifier { class ShortcutRegistry with ChangeNotifier {
bool _notificationScheduled = false;
bool _disposed = false;
@override
void dispose() {
super.dispose();
_disposed = true;
}
/// Gets the combined shortcut bindings from all contexts that are registered /// Gets the combined shortcut bindings from all contexts that are registered
/// with this [ShortcutRegistry], in addition to the bindings passed to /// with this [ShortcutRegistry].
/// [ShortcutRegistry].
/// ///
/// Listeners will be notified when the value returned by this getter changes. /// Listeners will be notified when the value returned by this getter changes.
/// ///
@ -1172,11 +1205,12 @@ class ShortcutRegistry with ChangeNotifier {
Map<ShortcutActivator, Intent> get shortcuts { Map<ShortcutActivator, Intent> get shortcuts {
assert(ChangeNotifier.debugAssertNotDisposed(this)); assert(ChangeNotifier.debugAssertNotDisposed(this));
return <ShortcutActivator, Intent>{ return <ShortcutActivator, Intent>{
for (final MapEntry<ShortcutRegistryEntry, Map<ShortcutActivator, Intent>> entry in _tokenShortcuts.entries) for (final MapEntry<ShortcutRegistryEntry, Map<ShortcutActivator, Intent>> entry in _registeredShortcuts.entries)
...entry.value, ...entry.value,
}; };
} }
final Map<ShortcutRegistryEntry, Map<ShortcutActivator, Intent>> _tokenShortcuts =
final Map<ShortcutRegistryEntry, Map<ShortcutActivator, Intent>> _registeredShortcuts =
<ShortcutRegistryEntry, Map<ShortcutActivator, Intent>>{}; <ShortcutRegistryEntry, Map<ShortcutActivator, Intent>>{};
/// Adds all the given shortcut bindings to this [ShortcutRegistry], and /// Adds all the given shortcut bindings to this [ShortcutRegistry], and
@ -1202,13 +1236,31 @@ class ShortcutRegistry with ChangeNotifier {
/// shortcuts associated with a particular entry. /// shortcuts associated with a particular entry.
ShortcutRegistryEntry addAll(Map<ShortcutActivator, Intent> value) { ShortcutRegistryEntry addAll(Map<ShortcutActivator, Intent> value) {
assert(ChangeNotifier.debugAssertNotDisposed(this)); assert(ChangeNotifier.debugAssertNotDisposed(this));
assert(value.isNotEmpty, 'Cannot register an empty map of shortcuts');
final ShortcutRegistryEntry entry = ShortcutRegistryEntry._(this); final ShortcutRegistryEntry entry = ShortcutRegistryEntry._(this);
_tokenShortcuts[entry] = value; _registeredShortcuts[entry] = value;
assert(_debugCheckForDuplicates()); assert(_debugCheckForDuplicates());
notifyListeners(); _notifyListenersNextFrame();
return entry; return entry;
} }
// Subscriber notification has to happen in the next frame because shortcuts
// are often registered that affect things in the overlay or different parts
// of the tree, and so can cause build ordering issues if notifications happen
// during the build. The _notificationScheduled check makes sure we only
// notify once per frame.
void _notifyListenersNextFrame() {
if (!_notificationScheduled) {
SchedulerBinding.instance.addPostFrameCallback((Duration _) {
_notificationScheduled = false;
if (!_disposed) {
notifyListeners();
}
});
_notificationScheduled = true;
}
}
/// Returns the [ShortcutRegistry] that belongs to the [ShortcutRegistrar] /// Returns the [ShortcutRegistry] that belongs to the [ShortcutRegistrar]
/// which most tightly encloses the given [BuildContext]. /// which most tightly encloses the given [BuildContext].
/// ///
@ -1270,23 +1322,24 @@ class ShortcutRegistry with ChangeNotifier {
// registry. // registry.
void _replaceAll(ShortcutRegistryEntry entry, Map<ShortcutActivator, Intent> value) { void _replaceAll(ShortcutRegistryEntry entry, Map<ShortcutActivator, Intent> value) {
assert(ChangeNotifier.debugAssertNotDisposed(this)); assert(ChangeNotifier.debugAssertNotDisposed(this));
assert(_debugCheckTokenIsValid(entry)); assert(_debugCheckEntryIsValid(entry));
_tokenShortcuts[entry] = value; _registeredShortcuts[entry] = value;
assert(_debugCheckForDuplicates()); assert(_debugCheckForDuplicates());
notifyListeners(); _notifyListenersNextFrame();
} }
// Removes all the shortcuts associated with the given entry from this // Removes all the shortcuts associated with the given entry from this
// registry. // registry.
void _disposeToken(ShortcutRegistryEntry entry) { void _disposeEntry(ShortcutRegistryEntry entry) {
assert(_debugCheckTokenIsValid(entry)); assert(_debugCheckEntryIsValid(entry));
if (_tokenShortcuts.remove(entry) != null) { final Map<ShortcutActivator, Intent>? removedShortcut = _registeredShortcuts.remove(entry);
notifyListeners(); if (removedShortcut != null) {
_notifyListenersNextFrame();
} }
} }
bool _debugCheckTokenIsValid(ShortcutRegistryEntry entry) { bool _debugCheckEntryIsValid(ShortcutRegistryEntry entry) {
if (!_tokenShortcuts.containsKey(entry)) { if (!_registeredShortcuts.containsKey(entry)) {
if (entry.registry == this) { if (entry.registry == this) {
throw FlutterError('entry ${describeIdentity(entry)} is invalid.\n' throw FlutterError('entry ${describeIdentity(entry)} is invalid.\n'
'The entry has already been disposed of. Tokens are not valid after ' 'The entry has already been disposed of. Tokens are not valid after '
@ -1303,7 +1356,7 @@ class ShortcutRegistry with ChangeNotifier {
bool _debugCheckForDuplicates() { bool _debugCheckForDuplicates() {
final Map<ShortcutActivator, ShortcutRegistryEntry?> previous = <ShortcutActivator, ShortcutRegistryEntry?>{}; final Map<ShortcutActivator, ShortcutRegistryEntry?> previous = <ShortcutActivator, ShortcutRegistryEntry?>{};
for (final MapEntry<ShortcutRegistryEntry, Map<ShortcutActivator, Intent>> tokenEntry in _tokenShortcuts.entries) { for (final MapEntry<ShortcutRegistryEntry, Map<ShortcutActivator, Intent>> tokenEntry in _registeredShortcuts.entries) {
for (final ShortcutActivator shortcut in tokenEntry.value.keys) { for (final ShortcutActivator shortcut in tokenEntry.value.keys) {
if (previous.containsKey(shortcut)) { if (previous.containsKey(shortcut)) {
throw FlutterError( throw FlutterError(
@ -1378,10 +1431,10 @@ class _ShortcutRegistrarState extends State<ShortcutRegistrar> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Shortcuts.manager( return _ShortcutRegistrarMarker(
manager: manager, registry: registry,
child: _ShortcutRegistrarMarker( child: Shortcuts.manager(
registry: registry, manager: manager,
child: widget.child, child: widget.child,
), ),
); );

View File

@ -191,11 +191,11 @@ void main() {
' Localizations\n' ' Localizations\n'
' MediaQuery\n' ' MediaQuery\n'
' _MediaQueryFromWindow\n' ' _MediaQueryFromWindow\n'
' _ShortcutRegistrarMarker\n'
' Semantics\n' ' Semantics\n'
' _FocusMarker\n' ' _FocusMarker\n'
' Focus\n' ' Focus\n'
' Shortcuts\n' ' Shortcuts\n'
' _ShortcutRegistrarMarker\n'
' ShortcutRegistrar\n' ' ShortcutRegistrar\n'
' TapRegionSurface\n' ' TapRegionSurface\n'
' _FocusMarker\n' ' _FocusMarker\n'

View File

@ -31,7 +31,7 @@ void main() {
} }
void handleFocusChange() { void handleFocusChange() {
focusedMenu = primaryFocus?.debugLabel ?? primaryFocus?.toString(); focusedMenu = (primaryFocus?.debugLabel ?? primaryFocus).toString();
} }
setUpAll(() { setUpAll(() {
@ -392,6 +392,28 @@ void main() {
), ),
equals(const Rect.fromLTRB(112.0, 48.0, 326.0, 208.0)), equals(const Rect.fromLTRB(112.0, 48.0, 326.0, 208.0)),
); );
// Test menu bar size when not expanded.
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Column(
children: <Widget>[
MenuBar(
children: createTestMenus(onPressed: onPressed),
),
const Expanded(child: Placeholder()),
],
),
),
),
);
await tester.pump();
expect(
tester.getRect(find.byType(MenuBar)),
equals(const Rect.fromLTRB(145.0, 0.0, 655.0, 48.0)),
);
}); });
testWidgets('geometry with RTL direction', (WidgetTester tester) async { testWidgets('geometry with RTL direction', (WidgetTester tester) async {
@ -448,13 +470,16 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Material( home: Material(
child: Column( child: Directionality(
children: <Widget>[ textDirection: TextDirection.rtl,
MenuBar( child: Column(
children: createTestMenus(onPressed: onPressed), children: <Widget>[
), MenuBar(
const Expanded(child: Placeholder()), children: createTestMenus(onPressed: onPressed),
], ),
const Expanded(child: Placeholder()),
],
),
), ),
), ),
), ),
@ -463,7 +488,7 @@ void main() {
expect( expect(
tester.getRect(find.byType(MenuBar)), tester.getRect(find.byType(MenuBar)),
equals(const Rect.fromLTRB(180.0, 0.0, 620.0, 48.0)), equals(const Rect.fromLTRB(145.0, 0.0, 655.0, 48.0)),
); );
}); });
@ -996,10 +1021,6 @@ void main() {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 113"))')); expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 113"))'));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(focusedMenu, equals('SubmenuButton(Text("Menu 2"))'));
}); });
testWidgets('keyboard directional traversal works in RTL mode', (WidgetTester tester) async { testWidgets('keyboard directional traversal works in RTL mode', (WidgetTester tester) async {
@ -1086,10 +1107,6 @@ void main() {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 113"))')); expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 113"))'));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(focusedMenu, equals('SubmenuButton(Text("Menu 2"))'));
}); });
testWidgets('hover traversal works', (WidgetTester tester) async { testWidgets('hover traversal works', (WidgetTester tester) async {
@ -1235,6 +1252,243 @@ void main() {
}); });
}); });
group('Accelerators', () {
const Set<TargetPlatform> apple = <TargetPlatform>{TargetPlatform.macOS, TargetPlatform.iOS};
final Set<TargetPlatform> nonApple = TargetPlatform.values.toSet().difference(apple);
test('Accelerator markers are stripped properly', () {
const Map<String, String> expected = <String, String>{
'Plain String': 'Plain String',
'&Simple Accelerator': 'Simple Accelerator',
'&Multiple &Accelerators': 'Multiple Accelerators',
'Whitespace & Accelerators': 'Whitespace Accelerators',
'&Quoted && Ampersand': 'Quoted & Ampersand',
'Ampersand at End &': 'Ampersand at End ',
'&&Multiple Ampersands &&& &&&A &&&&B &&&&': '&Multiple Ampersands & &A &&B &&',
'Bohrium 𨨏 Code point U+28A0F': 'Bohrium 𨨏 Code point U+28A0F',
};
const List<int> expectedIndices = <int>[-1, 0, 0, -1, 0, -1, 24, -1];
const List<bool> expectedHasAccelerator = <bool>[false, true, true, false, true, false, true, false];
int acceleratorIndex = -1;
int count = 0;
for (final String key in expected.keys) {
expect(MenuAcceleratorLabel.stripAcceleratorMarkers(key, setIndex: (int index) {
acceleratorIndex = index;
}), equals(expected[key]),
reason: "'$key' label doesn't match ${expected[key]}");
expect(acceleratorIndex, equals(expectedIndices[count]),
reason: "'$key' index doesn't match ${expectedIndices[count]}");
expect(MenuAcceleratorLabel(key).hasAccelerator, equals(expectedHasAccelerator[count]),
reason: "'$key' hasAccelerator isn't ${expectedHasAccelerator[count]}");
count += 1;
}
});
testWidgets('can invoke menu items', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Material(
child: MenuBar(
key: UniqueKey(),
controller: controller,
children: createTestMenus(
onPressed: onPressed,
onOpen: onOpen,
onClose: onClose,
accelerators: true,
),
),
),
),
);
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'm');
await tester.pump();
// Makes sure that identical accelerators in parent menu items don't
// shadow the ones in the children.
await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'm');
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
await tester.pump();
expect(opened, equals(<TestMenu>[TestMenu.mainMenu0]));
expect(closed, equals(<TestMenu>[TestMenu.mainMenu0]));
expect(selected, equals(<TestMenu>[TestMenu.subMenu00]));
// Selecting a non-submenu item should close all the menus.
expect(find.text(TestMenu.subMenu00.label), findsNothing);
opened.clear();
closed.clear();
selected.clear();
// Invoking several levels deep.
await tester.sendKeyDownEvent(LogicalKeyboardKey.altRight);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'e');
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: '1');
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: '1');
await tester.sendKeyUpEvent(LogicalKeyboardKey.altRight);
await tester.pump();
expect(opened, equals(<TestMenu>[TestMenu.mainMenu1, TestMenu.subMenu11]));
expect(closed, equals(<TestMenu>[TestMenu.subMenu11, TestMenu.mainMenu1]));
expect(selected, equals(<TestMenu>[TestMenu.subSubMenu111]));
opened.clear();
closed.clear();
selected.clear();
}, variant: TargetPlatformVariant(nonApple));
testWidgets('can combine with regular keyboard navigation', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Material(
child: MenuBar(
key: UniqueKey(),
controller: controller,
children: createTestMenus(
onPressed: onPressed,
onOpen: onOpen,
onClose: onClose,
accelerators: true,
),
),
),
),
);
// Combining accelerators and regular keyboard navigation works.
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'e');
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: '1');
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(opened, equals(<TestMenu>[TestMenu.mainMenu1, TestMenu.subMenu11]));
expect(closed, equals(<TestMenu>[TestMenu.subMenu11, TestMenu.mainMenu1]));
expect(selected, equals(<TestMenu>[TestMenu.subSubMenu110]));
}, variant: TargetPlatformVariant(nonApple));
testWidgets('can combine with mouse', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Material(
child: MenuBar(
key: UniqueKey(),
controller: controller,
children: createTestMenus(
onPressed: onPressed,
onOpen: onOpen,
onClose: onClose,
accelerators: true,
),
),
),
),
);
// Combining accelerators and regular keyboard navigation works.
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'e');
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: '1');
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
await tester.pump();
await tester.tap(find.text(TestMenu.subSubMenu112.label));
await tester.pump();
expect(opened, equals(<TestMenu>[TestMenu.mainMenu1, TestMenu.subMenu11]));
expect(closed, equals(<TestMenu>[TestMenu.subMenu11, TestMenu.mainMenu1]));
expect(selected, equals(<TestMenu>[TestMenu.subSubMenu112]));
}, variant: TargetPlatformVariant(nonApple));
testWidgets("disabled items don't respond to accelerators", (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Material(
child: MenuBar(
key: UniqueKey(),
controller: controller,
children: createTestMenus(
onPressed: onPressed,
onOpen: onOpen,
onClose: onClose,
accelerators: true,
),
),
),
),
);
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: '5');
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
await tester.pump();
expect(opened, isEmpty);
expect(closed, isEmpty);
expect(selected, isEmpty);
// Selecting a non-submenu item should close all the menus.
expect(find.text(TestMenu.subMenu00.label), findsNothing);
}, variant: TargetPlatformVariant(nonApple));
testWidgets("Apple platforms don't react to accelerators", (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Material(
child: MenuBar(
key: UniqueKey(),
controller: controller,
children: createTestMenus(
onPressed: onPressed,
onOpen: onOpen,
onClose: onClose,
accelerators: true,
),
),
),
),
);
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'm');
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'm');
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
await tester.pump();
expect(opened, isEmpty);
expect(closed, isEmpty);
expect(selected, isEmpty);
// Or with the option key equivalents.
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'µ');
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'µ');
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
await tester.pump();
expect(opened, isEmpty);
expect(closed, isEmpty);
expect(selected, isEmpty);
}, variant: const TargetPlatformVariant(apple));
});
group('MenuController', () { group('MenuController', () {
testWidgets('Moving a controller to a new instance works', (WidgetTester tester) async { testWidgets('Moving a controller to a new instance works', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
@ -1605,7 +1859,7 @@ void main() {
expect(menuRects[0], equals(const Rect.fromLTRB(4.0, 0.0, 112.0, 48.0))); expect(menuRects[0], equals(const Rect.fromLTRB(4.0, 0.0, 112.0, 48.0)));
expect(menuRects[1], equals(const Rect.fromLTRB(112.0, 0.0, 220.0, 48.0))); expect(menuRects[1], equals(const Rect.fromLTRB(112.0, 0.0, 220.0, 48.0)));
expect(menuRects[2], equals(const Rect.fromLTRB(220.0, 0.0, 328.0, 48.0))); expect(menuRects[2], equals(const Rect.fromLTRB(220.0, 0.0, 328.0, 48.0)));
expect(menuRects[3], equals(const Rect.fromLTRB(328.0, 0.0, 436.0, 48.0))); expect(menuRects[3], equals(const Rect.fromLTRB(328.0, 0.0, 506.0, 48.0)));
expect(menuRects[4], equals(const Rect.fromLTRB(112.0, 104.0, 326.0, 152.0))); expect(menuRects[4], equals(const Rect.fromLTRB(112.0, 104.0, 326.0, 152.0)));
}); });
@ -1647,7 +1901,7 @@ void main() {
expect(menuRects[0], equals(const Rect.fromLTRB(688.0, 0.0, 796.0, 48.0))); expect(menuRects[0], equals(const Rect.fromLTRB(688.0, 0.0, 796.0, 48.0)));
expect(menuRects[1], equals(const Rect.fromLTRB(580.0, 0.0, 688.0, 48.0))); expect(menuRects[1], equals(const Rect.fromLTRB(580.0, 0.0, 688.0, 48.0)));
expect(menuRects[2], equals(const Rect.fromLTRB(472.0, 0.0, 580.0, 48.0))); expect(menuRects[2], equals(const Rect.fromLTRB(472.0, 0.0, 580.0, 48.0)));
expect(menuRects[3], equals(const Rect.fromLTRB(364.0, 0.0, 472.0, 48.0))); expect(menuRects[3], equals(const Rect.fromLTRB(294.0, 0.0, 472.0, 48.0)));
expect(menuRects[4], equals(const Rect.fromLTRB(474.0, 104.0, 688.0, 152.0))); expect(menuRects[4], equals(const Rect.fromLTRB(474.0, 104.0, 688.0, 152.0)));
}); });
@ -1687,7 +1941,7 @@ void main() {
expect(menuRects[0], equals(const Rect.fromLTRB(4.0, 0.0, 112.0, 48.0))); expect(menuRects[0], equals(const Rect.fromLTRB(4.0, 0.0, 112.0, 48.0)));
expect(menuRects[1], equals(const Rect.fromLTRB(112.0, 0.0, 220.0, 48.0))); expect(menuRects[1], equals(const Rect.fromLTRB(112.0, 0.0, 220.0, 48.0)));
expect(menuRects[2], equals(const Rect.fromLTRB(220.0, 0.0, 328.0, 48.0))); expect(menuRects[2], equals(const Rect.fromLTRB(220.0, 0.0, 328.0, 48.0)));
expect(menuRects[3], equals(const Rect.fromLTRB(328.0, 0.0, 436.0, 48.0))); expect(menuRects[3], equals(const Rect.fromLTRB(328.0, 0.0, 506.0, 48.0)));
expect(menuRects[4], equals(const Rect.fromLTRB(86.0, 104.0, 300.0, 152.0))); expect(menuRects[4], equals(const Rect.fromLTRB(86.0, 104.0, 300.0, 152.0)));
}); });
@ -1727,7 +1981,7 @@ void main() {
expect(menuRects[0], equals(const Rect.fromLTRB(188.0, 0.0, 296.0, 48.0))); expect(menuRects[0], equals(const Rect.fromLTRB(188.0, 0.0, 296.0, 48.0)));
expect(menuRects[1], equals(const Rect.fromLTRB(80.0, 0.0, 188.0, 48.0))); expect(menuRects[1], equals(const Rect.fromLTRB(80.0, 0.0, 188.0, 48.0)));
expect(menuRects[2], equals(const Rect.fromLTRB(-28.0, 0.0, 80.0, 48.0))); expect(menuRects[2], equals(const Rect.fromLTRB(-28.0, 0.0, 80.0, 48.0)));
expect(menuRects[3], equals(const Rect.fromLTRB(-136.0, 0.0, -28.0, 48.0))); expect(menuRects[3], equals(const Rect.fromLTRB(-206.0, 0.0, -28.0, 48.0)));
expect(menuRects[4], equals(const Rect.fromLTRB(0.0, 104.0, 214.0, 152.0))); expect(menuRects[4], equals(const Rect.fromLTRB(0.0, 104.0, 214.0, 152.0)));
}); });
}); });
@ -1929,164 +2183,124 @@ List<Widget> createTestMenus({
void Function(TestMenu)? onOpen, void Function(TestMenu)? onOpen,
void Function(TestMenu)? onClose, void Function(TestMenu)? onClose,
Map<TestMenu, MenuSerializableShortcut> shortcuts = const <TestMenu, MenuSerializableShortcut>{}, Map<TestMenu, MenuSerializableShortcut> shortcuts = const <TestMenu, MenuSerializableShortcut>{},
bool includeStandard = false,
bool includeExtraGroups = false, bool includeExtraGroups = false,
bool accelerators = false,
}) { }) {
Widget submenuButton(
TestMenu menu, {
required List<Widget> menuChildren,
}) {
return SubmenuButton(
onOpen: onOpen != null ? () => onOpen(menu) : null,
onClose: onClose != null ? () => onClose(menu) : null,
menuChildren: menuChildren,
child: accelerators ? MenuAcceleratorLabel(menu.acceleratorLabel) : Text(menu.label),
);
}
Widget menuItemButton(
TestMenu menu, {
bool enabled = true,
Widget? leadingIcon,
Widget? trailingIcon,
Key? key,
}) {
return MenuItemButton(
key: key,
onPressed: enabled && onPressed != null ? () => onPressed(menu) : null,
shortcut: shortcuts[menu],
leadingIcon: leadingIcon,
trailingIcon: trailingIcon,
child: accelerators ? MenuAcceleratorLabel(menu.acceleratorLabel) : Text(menu.label),
);
}
final List<Widget> result = <Widget>[ final List<Widget> result = <Widget>[
SubmenuButton( submenuButton(
onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu0) : null, TestMenu.mainMenu0,
onClose: onClose != null ? () => onClose(TestMenu.mainMenu0) : null,
menuChildren: <Widget>[ menuChildren: <Widget>[
MenuItemButton( menuItemButton(TestMenu.subMenu00, leadingIcon: const Icon(Icons.add)),
onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu00) : null, menuItemButton(TestMenu.subMenu01),
shortcut: shortcuts[TestMenu.subMenu00], menuItemButton(TestMenu.subMenu02),
leadingIcon: const Icon(Icons.add),
child: Text(TestMenu.subMenu00.label),
),
MenuItemButton(
onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu01) : null,
shortcut: shortcuts[TestMenu.subMenu01],
child: Text(TestMenu.subMenu01.label),
),
MenuItemButton(
onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu02) : null,
shortcut: shortcuts[TestMenu.subMenu02],
child: Text(TestMenu.subMenu02.label),
),
], ],
child: Text(TestMenu.mainMenu0.label),
), ),
SubmenuButton( submenuButton(
onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu1) : null, TestMenu.mainMenu1,
onClose: onClose != null ? () => onClose(TestMenu.mainMenu1) : null,
menuChildren: <Widget>[ menuChildren: <Widget>[
MenuItemButton( menuItemButton(TestMenu.subMenu10),
onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu10) : null, submenuButton(
shortcut: shortcuts[TestMenu.subMenu10], TestMenu.subMenu11,
child: Text(TestMenu.subMenu10.label),
),
SubmenuButton(
onOpen: onOpen != null ? () => onOpen(TestMenu.subMenu11) : null,
onClose: onClose != null ? () => onClose(TestMenu.subMenu11) : null,
menuChildren: <Widget>[ menuChildren: <Widget>[
MenuItemButton( menuItemButton(TestMenu.subSubMenu110, key: UniqueKey()),
key: UniqueKey(), menuItemButton(TestMenu.subSubMenu111),
onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu110) : null, menuItemButton(TestMenu.subSubMenu112),
shortcut: shortcuts[TestMenu.subSubMenu110], menuItemButton(TestMenu.subSubMenu113),
child: Text(TestMenu.subSubMenu110.label),
),
MenuItemButton(
onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu111) : null,
shortcut: shortcuts[TestMenu.subSubMenu111],
child: Text(TestMenu.subSubMenu111.label),
),
MenuItemButton(
onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu112) : null,
shortcut: shortcuts[TestMenu.subSubMenu112],
child: Text(TestMenu.subSubMenu112.label),
),
MenuItemButton(
onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu113) : null,
shortcut: shortcuts[TestMenu.subSubMenu113],
child: Text(TestMenu.subSubMenu113.label),
),
], ],
child: Text(TestMenu.subMenu11.label),
),
MenuItemButton(
onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu12) : null,
shortcut: shortcuts[TestMenu.subMenu12],
child: Text(TestMenu.subMenu12.label),
), ),
menuItemButton(TestMenu.subMenu12),
], ],
child: Text(TestMenu.mainMenu1.label),
), ),
SubmenuButton( submenuButton(
onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu2) : null, TestMenu.mainMenu2,
onClose: onClose != null ? () => onClose(TestMenu.mainMenu2) : null,
menuChildren: <Widget>[ menuChildren: <Widget>[
MenuItemButton( menuItemButton(
// Always disabled. TestMenu.subMenu20,
leadingIcon: const Icon(Icons.ac_unit), leadingIcon: const Icon(Icons.ac_unit),
shortcut: shortcuts[TestMenu.subMenu20], enabled: false,
child: Text(TestMenu.subMenu20.label),
), ),
], ],
child: Text(TestMenu.mainMenu2.label),
), ),
if (includeExtraGroups) if (includeExtraGroups)
SubmenuButton( submenuButton(
onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu3) : null, TestMenu.mainMenu3,
onClose: onClose != null ? () => onClose(TestMenu.mainMenu3) : null,
menuChildren: <Widget>[ menuChildren: <Widget>[
MenuItemButton( menuItemButton(TestMenu.subMenu30, enabled: false),
// Always disabled.
shortcut: shortcuts[TestMenu.subMenu30],
// Always disabled.
child: Text(TestMenu.subMenu30.label),
),
], ],
child: Text(TestMenu.mainMenu3.label),
), ),
if (includeExtraGroups) if (includeExtraGroups)
SubmenuButton( submenuButton(
onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu4) : null, TestMenu.mainMenu4,
onClose: onClose != null ? () => onClose(TestMenu.mainMenu4) : null,
menuChildren: <Widget>[ menuChildren: <Widget>[
MenuItemButton( menuItemButton(TestMenu.subMenu40, enabled: false),
// Always disabled. menuItemButton(TestMenu.subMenu41, enabled: false),
shortcut: shortcuts[TestMenu.subMenu40], menuItemButton(TestMenu.subMenu42, enabled: false),
// Always disabled.
child: Text(TestMenu.subMenu40.label),
),
MenuItemButton(
// Always disabled.
shortcut: shortcuts[TestMenu.subMenu41],
// Always disabled.
child: Text(TestMenu.subMenu41.label),
),
MenuItemButton(
// Always disabled.
shortcut: shortcuts[TestMenu.subMenu42],
// Always disabled.
child: Text(TestMenu.subMenu42.label),
),
], ],
child: Text(TestMenu.mainMenu4.label),
), ),
SubmenuButton( submenuButton(TestMenu.mainMenu5, menuChildren: const <Widget>[]),
onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu5) : null,
onClose: onClose != null ? () => onClose(TestMenu.mainMenu5) : null,
menuChildren: const <Widget>[],
child: Text(TestMenu.mainMenu5.label),
),
]; ];
return result; return result;
} }
enum TestMenu { enum TestMenu {
mainMenu0('Menu 0'), mainMenu0('&Menu 0'),
mainMenu1('Menu 1'), mainMenu1('M&enu &1'),
mainMenu2('Menu 2'), mainMenu2('Me&nu 2'),
mainMenu3('Menu 3'), mainMenu3('Men&u 3'),
mainMenu4('Menu 4'), mainMenu4('Menu &4'),
mainMenu5('Menu 5'), mainMenu5('Menu &5 && &6 &'),
subMenu00('Sub Menu 00'), subMenu00('Sub &Menu 0&0'),
subMenu01('Sub Menu 01'), subMenu01('Sub Menu 0&1'),
subMenu02('Sub Menu 02'), subMenu02('Sub Menu 0&2'),
subMenu10('Sub Menu 10'), subMenu10('Sub Menu 1&0'),
subMenu11('Sub Menu 11'), subMenu11('Sub Menu 1&1'),
subMenu12('Sub Menu 12'), subMenu12('Sub Menu 1&2'),
subMenu20('Sub Menu 20'), subMenu20('Sub Menu 2&0'),
subMenu30('Sub Menu 30'), subMenu30('Sub Menu 3&0'),
subMenu40('Sub Menu 40'), subMenu40('Sub Menu 4&0'),
subMenu41('Sub Menu 41'), subMenu41('Sub Menu 4&1'),
subMenu42('Sub Menu 42'), subMenu42('Sub Menu 4&2'),
subSubMenu110('Sub Sub Menu 110'), subSubMenu110('Sub Sub Menu 11&0'),
subSubMenu111('Sub Sub Menu 111'), subSubMenu111('Sub Sub Menu 11&1'),
subSubMenu112('Sub Sub Menu 112'), subSubMenu112('Sub Sub Menu 11&2'),
subSubMenu113('Sub Sub Menu 113'); subSubMenu113('Sub Sub Menu 11&3');
const TestMenu(this.label); const TestMenu(this.acceleratorLabel);
final String label; final String acceleratorLabel;
// Strip the accelerator markers.
String get label => MenuAcceleratorLabel.stripAcceleratorMarkers(acceleratorLabel);
int get acceleratorIndex {
int index = -1;
MenuAcceleratorLabel.stripAcceleratorMarkers(acceleratorLabel, setIndex: (int i) => index = i);
return index;
}
} }