From 0cb9f70460701f31bcbdfed3af905847ee3c210b Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Mon, 28 Nov 2022 16:27:20 -0800 Subject: [PATCH] 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. --- dev/manual_tests/lib/menu_anchor.dart | 823 +++++++++--------- .../menu_anchor/menu_accelerator_label.0.dart | 116 +++ .../lib/material/menu_anchor/menu_bar.0.dart | 2 +- .../lib/widgets/shortcuts/shortcuts.0.dart | 6 +- .../menu_accelerator_label.0_test.dart | 54 ++ .../flutter/lib/src/material/menu_anchor.dart | 621 +++++++++++-- .../flutter/lib/src/widgets/shortcuts.dart | 135 ++- .../flutter/test/material/debug_test.dart | 2 +- .../test/material/menu_anchor_test.dart | 514 +++++++---- 9 files changed, 1598 insertions(+), 675 deletions(-) create mode 100644 examples/api/lib/material/menu_anchor/menu_accelerator_label.0.dart create mode 100644 examples/api/test/material/menu_anchor/menu_accelerator_label.0_test.dart diff --git a/dev/manual_tests/lib/menu_anchor.dart b/dev/manual_tests/lib/menu_anchor.dart index ff297e737d..ea3fb78e57 100644 --- a/dev/manual_tests/lib/menu_anchor.dart +++ b/dev/manual_tests/lib/menu_anchor.dart @@ -29,6 +29,7 @@ class _HomeState extends State { TextDirection _textDirection = TextDirection.ltr; double _extraPadding = 0; bool _addItem = false; + bool _accelerators = true; bool _transparent = false; bool _funkyTheme = false; @@ -99,6 +100,7 @@ class _HomeState extends State { children: [ _TestMenus( menuController: _controller, + accelerators: _accelerators, addItem: _addItem, ), Expanded( @@ -107,6 +109,7 @@ class _HomeState extends State { menuController: _controller, density: _density, addItem: _addItem, + accelerators: _accelerators, transparent: _transparent, funkyTheme: _funkyTheme, extraPadding: _extraPadding, @@ -131,6 +134,11 @@ class _HomeState extends State { _addItem = value; }); }, + onAcceleratorsChanged: (bool value) { + setState(() { + _accelerators = value; + }); + }, onTransparentChanged: (bool value) { setState(() { _transparent = value; @@ -159,12 +167,14 @@ class _Controls extends StatefulWidget { required this.textDirection, required this.extraPadding, this.addItem = false, + this.accelerators = true, this.transparent = false, this.funkyTheme = false, required this.onDensityChanged, required this.onTextDirectionChanged, required this.onExtraPaddingChanged, required this.onAddItemChanged, + required this.onAcceleratorsChanged, required this.onTransparentChanged, required this.onFunkyThemeChanged, required this.menuController, @@ -174,12 +184,14 @@ class _Controls extends StatefulWidget { final TextDirection textDirection; final double extraPadding; final bool addItem; + final bool accelerators; final bool transparent; final bool funkyTheme; final ValueChanged onDensityChanged; final ValueChanged onTextDirectionChanged; final ValueChanged onExtraPaddingChanged; final ValueChanged onAddItemChanged; + final ValueChanged onAcceleratorsChanged; final ValueChanged onTransparentChanged; final ValueChanged onFunkyThemeChanged; final MenuController menuController; @@ -199,165 +211,180 @@ class _ControlsState extends State<_Controls> { @override Widget build(BuildContext context) { - return Container( - color: Colors.lightBlueAccent, - alignment: Alignment.center, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - MenuAnchor( - childFocusNode: _focusNode, - style: const MenuStyle(alignment: AlignmentDirectional.topEnd), - alignmentOffset: const Offset(100, -8), - menuChildren: [ - MenuItemButton( - shortcut: TestMenu.standaloneMenu1.shortcut, - onPressed: () { - _itemSelected(TestMenu.standaloneMenu1); - }, - child: Text(TestMenu.standaloneMenu1.label), + return Center( + child: SingleChildScrollView( + child: Column( + children: [ + MenuAnchor( + childFocusNode: _focusNode, + style: const MenuStyle(alignment: AlignmentDirectional.topEnd), + alignmentOffset: const Offset(100, -8), + menuChildren: [ + MenuItemButton( + shortcut: TestMenu.standaloneMenu1.shortcut, + onPressed: () { + _itemSelected(TestMenu.standaloneMenu1); + }, + child: MenuAcceleratorLabel(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: [ + _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), - trailingIcon: const Icon(Icons.mail), - 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, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _ControlSlider( - label: 'Extra Padding: ${widget.extraPadding.toStringAsFixed(1)}', - value: widget.extraPadding, - max: 40, - divisions: 20, - onChanged: (double value) { - widget.onExtraPaddingChanged(value); - }, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + 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') + ], ), - _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, - ), - ); - }, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + value: widget.addItem, + onChanged: (bool? value) { + if (value ?? false) { + widget.onAddItemChanged(true); + } else { + widget.onAddItemChanged(false); + } + }, + ), + const Text('Add Item') + ], ), - _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, - ), - ); - }, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + value: widget.accelerators, + onChanged: (bool? value) { + if (value ?? false) { + widget.onAcceleratorsChanged(true); + } else { + widget.onAcceleratorsChanged(false); + } + }, + ), + const Text('Enable Accelerators') + ], + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + 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: [ + 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: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - 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: [ - 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: [ - 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: [ - 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({ required this.menuController, this.addItem = false, + this.accelerators = false, }); final MenuController menuController; final bool addItem; + final bool accelerators; @override State<_TestMenus> createState() => _TestMenusState(); @@ -439,8 +468,8 @@ class _TestMenusState extends State<_TestMenus> { debugPrint('App: Closed item ${item.label}'); } - void _setRadio(TestMenu item) { - debugPrint('App: Set Radio item ${item.label}'); + void _setRadio(TestMenu? item) { + debugPrint('App: Set Radio item ${item?.label}'); setState(() { radioValue = item; }); @@ -449,17 +478,17 @@ class _TestMenusState extends State<_TestMenus> { void _setCheck(TestMenu item) { debugPrint('App: Set Checkbox item ${item.label}'); setState(() { - switch (checkboxState) { - case false: - checkboxState = true; - break; - case true: - checkboxState = null; - break; - case null: - checkboxState = false; - break; - } + switch (checkboxState) { + case false: + checkboxState = true; + break; + case true: + checkboxState = null; + break; + case null: + checkboxState = false; + break; + } }); } @@ -469,9 +498,9 @@ class _TestMenusState extends State<_TestMenus> { _shortcutsEntry?.dispose(); final Map shortcuts = {}; for (final TestMenu item in TestMenu.values) { - if (item.shortcut == null) { + if (item.shortcut == null) { continue; - } + } switch (item) { case TestMenu.radioMenu1: case TestMenu.radioMenu2: @@ -519,219 +548,21 @@ class _TestMenusState extends State<_TestMenus> { Expanded( child: MenuBar( controller: widget.menuController, - children: [ - SubmenuButton( - onOpen: () { - _openItem(TestMenu.mainMenu1); - }, - onClose: () { - _closeItem(TestMenu.mainMenu1); - }, - menuChildren: [ - CheckboxMenuButton( - value: checkboxState, - tristate: true, - shortcut: TestMenu.subMenu1.shortcut, - trailingIcon: const Icon(Icons.assessment), - onChanged: (bool? value) { - setState(() { - checkboxState = value; - }); - _itemSelected(TestMenu.subMenu1); - }, - child: Text(TestMenu.subMenu1.label), - ), - RadioMenuButton( - 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( - 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( - 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: [ - 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: [ - 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: [ - Actions( - actions: >{ - ActivateIntent: CallbackAction( - 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: [ - 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: [ - 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), - ), - ], + children: createTestMenus( + onPressed: _itemSelected, + onOpen: _openItem, + onClose: _closeItem, + onCheckboxChanged: (TestMenu menu, bool? value) { + _setCheck(menu); + }, + onRadioChanged: _setRadio, + checkboxValue: checkboxState, + radioValue: radioValue, + menuController: widget.menuController, + textEditingController: textController, + includeExtraGroups: widget.addItem, + accelerators: widget.accelerators, + ), ), ), ], @@ -739,31 +570,223 @@ class _TestMenusState extends State<_TestMenus> { } } +List createTestMenus({ + void Function(TestMenu)? onPressed, + void Function(TestMenu, bool?)? onCheckboxChanged, + void Function(TestMenu?)? onRadioChanged, + void Function(TestMenu)? onOpen, + void Function(TestMenu)? onClose, + Map shortcuts = const {}, + bool? checkboxValue, + TestMenu? radioValue, + MenuController? menuController, + TextEditingController? textEditingController, + bool includeExtraGroups = false, + bool accelerators = false, +}) { + Widget submenuButton( + TestMenu menu, { + required List 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( + 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 result = [ + submenuButton( + TestMenu.mainMenu1, + menuChildren: [ + 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: [ + 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: [ + menuItemButton(TestMenu.subMenu8), + ], + ), + submenuButton( + TestMenu.mainMenu4, + menuChildren: [ + 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: [ + menuItemButton(TestMenu.subSubMenu1), + menuItemButton(TestMenu.subSubMenu2), + if (includeExtraGroups) + submenuButton( + TestMenu.subSubMenu3, + menuChildren: [ + menuItemButton(TestMenu.subSubSubMenu1), + ], + ), + ], + ), + menuItemButton(TestMenu.subMenu6, enabled: false), + menuItemButton(TestMenu.subMenu7), + menuItemButton(TestMenu.subMenu7), + menuItemButton(TestMenu.subMenu8), + ], + ), + ]; + return result; +} + enum TestMenu { mainMenu1('Menu 1'), - mainMenu2('Menu 2'), - mainMenu3('Menu 3'), - mainMenu4('Menu 4'), + mainMenu2('M&enu &2'), + mainMenu3('Me&nu &3'), + mainMenu4('Men&u &4'), radioMenu1('Radio Menu One', SingleActivator(LogicalKeyboardKey.digit1, control: true)), radioMenu2('Radio Menu Two', SingleActivator(LogicalKeyboardKey.digit2, control: true)), radioMenu3('Radio Menu Three', SingleActivator(LogicalKeyboardKey.digit3, control: true)), - subMenu1('Sub Menu 1', SingleActivator(LogicalKeyboardKey.keyB, control: true)), - subMenu2('Sub Menu 2'), - subMenu3('Sub Menu 3', SingleActivator(LogicalKeyboardKey.enter, control: true)), - subMenu4('Sub Menu 4'), - subMenu5('Sub Menu 5'), - subMenu6('Sub Menu 6', SingleActivator(LogicalKeyboardKey.tab, control: true)), - subMenu7('Sub Menu 7'), - subMenu8('Sub Menu 8'), - subSubMenu1('Sub Sub Menu 1', SingleActivator(LogicalKeyboardKey.f10, control: true)), - subSubMenu2('Sub Sub Menu 2'), - subSubMenu3('Sub Sub Menu 3'), - subSubSubMenu1('Sub Sub Sub Menu 1', SingleActivator(LogicalKeyboardKey.f11, control: true)), - testButton('TEST button'), - standaloneMenu1('Standalone Menu 1', SingleActivator(LogicalKeyboardKey.keyC, control: true)), - standaloneMenu2('Standalone Menu 2'); + subMenu1('Sub Menu &1', SingleActivator(LogicalKeyboardKey.keyB, control: true)), + subMenu2('Sub Menu &2'), + subMenu3('Sub Menu &3', SingleActivator(LogicalKeyboardKey.enter, control: true)), + subMenu4('Sub Menu &4'), + subMenu5('Sub Menu &5'), + subMenu6('Sub Menu &6', SingleActivator(LogicalKeyboardKey.tab, control: true)), + subMenu7('Sub Menu &7'), + subMenu8('Sub Menu &8'), + subSubMenu1('Sub Sub Menu &1', SingleActivator(LogicalKeyboardKey.f10, control: true)), + subSubMenu2('Sub Sub Menu &2'), + subSubMenu3('Sub Sub Menu &3'), + subSubSubMenu1('Sub Sub Sub Menu &1', SingleActivator(LogicalKeyboardKey.f11, control: true)), + testButton('&TEST && &&& Button &'), + standaloneMenu1('Standalone Menu &1', SingleActivator(LogicalKeyboardKey.keyC, control: true)), + standaloneMenu2('Standalone Menu &2'); - const TestMenu(this.label, [this.shortcut]); - final String label; + const TestMenu(this.acceleratorLabel, [this.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; + } } diff --git a/examples/api/lib/material/menu_anchor/menu_accelerator_label.0.dart b/examples/api/lib/material/menu_anchor/menu_accelerator_label.0.dart new file mode 100644 index 0000000000..096e600bbb --- /dev/null +++ b/examples/api/lib/material/menu_anchor/menu_accelerator_label.0.dart @@ -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: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: MenuBar( + children: [ + SubmenuButton( + menuChildren: [ + 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: [ + 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: { + const SingleActivator(LogicalKeyboardKey.keyT, control: true): VoidCallbackIntent(() { + debugDumpApp(); + }), + }, + child: const Scaffold(body: MyMenuBar()), + ), + ); + } +} diff --git a/examples/api/lib/material/menu_anchor/menu_bar.0.dart b/examples/api/lib/material/menu_anchor/menu_bar.0.dart index eb681211c1..f7677568ef 100644 --- a/examples/api/lib/material/menu_anchor/menu_bar.0.dart +++ b/examples/api/lib/material/menu_anchor/menu_bar.0.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -/// Flutter code sample for [MenuBar] +/// Flutter code sample for [MenuBar]. import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; diff --git a/examples/api/lib/widgets/shortcuts/shortcuts.0.dart b/examples/api/lib/widgets/shortcuts/shortcuts.0.dart index 2da2b3dee7..3efc1d96e4 100644 --- a/examples/api/lib/widgets/shortcuts/shortcuts.0.dart +++ b/examples/api/lib/widgets/shortcuts/shortcuts.0.dart @@ -49,9 +49,9 @@ class _MyStatefulWidgetState extends State { @override Widget build(BuildContext context) { return Shortcuts( - shortcuts: { - LogicalKeySet(LogicalKeyboardKey.arrowUp): const IncrementIntent(), - LogicalKeySet(LogicalKeyboardKey.arrowDown): const DecrementIntent(), + shortcuts: const { + SingleActivator(LogicalKeyboardKey.arrowUp): IncrementIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): DecrementIntent(), }, child: Actions( actions: >{ diff --git a/examples/api/test/material/menu_anchor/menu_accelerator_label.0_test.dart b/examples/api/test/material/menu_anchor/menu_accelerator_label.0_test.dart new file mode 100644 index 0000000000..d32700dbce --- /dev/null +++ b/examples/api/test/material/menu_anchor/menu_accelerator_label.0_test.dart @@ -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); + }); +} diff --git a/packages/flutter/lib/src/material/menu_anchor.dart b/packages/flutter/lib/src/material/menu_anchor.dart index fd8fe7989f..d551546c70 100644 --- a/packages/flutter/lib/src/material/menu_anchor.dart +++ b/packages/flutter/lib/src/material/menu_anchor.dart @@ -216,7 +216,7 @@ class MenuAnchor extends StatefulWidget { /// A list of children containing the menu items that are the contents of the /// menu surrounded by this [MenuAnchor]. /// - /// {@macro flutter.material.menu_bar.shortcuts_note} + /// {@macro flutter.material.MenuBar.shortcuts_note} final List menuChildren; /// The widget that this [MenuAnchor] surrounds. @@ -263,7 +263,6 @@ class _MenuAnchorState extends State { // view's edges. final GlobalKey _anchorKey = GlobalKey(debugLabel: kReleaseMode ? null : 'MenuAnchor'); _MenuAnchorState? _parent; - bool _childIsOpen = false; final FocusScopeNode _menuScopeNode = FocusScopeNode(debugLabel: kReleaseMode ? null : 'MenuAnchor sub menu'); MenuController? _internalMenuController; final List<_MenuAnchorState> _anchorChildren = <_MenuAnchorState>[]; @@ -357,6 +356,7 @@ class _MenuAnchorState extends State { return _MenuAnchorMarker( anchorKey: _anchorKey, anchor: this, + isOpen: _isOpen, child: child, ); } @@ -436,14 +436,12 @@ class _MenuAnchorState extends State { return handle; } - void _childChangedOpenState(bool value) { - if (_childIsOpen != value) { - _parent?._childChangedOpenState(_childIsOpen || _isOpen); - if (mounted) { - setState(() { - _childIsOpen = value; - }); - } + void _childChangedOpenState() { + if (mounted) { + _parent?._childChangedOpenState(); + setState(() { + // Mark dirty, but only if mounted. + }); } } @@ -483,13 +481,14 @@ class _MenuAnchorState extends State { // close it first. _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. assert(_overlayEntry == null); final BuildContext outerContext = context; + _parent?._childChangedOpenState(); setState(() { - _parent?._childChangedOpenState(true); _overlayEntry = OverlayEntry( builder: (BuildContext context) { final OverlayState overlay = Overlay.of(outerContext); @@ -509,6 +508,7 @@ class _MenuAnchorState extends State { // it. anchorKey: _anchorKey, anchor: this, + isOpen: _isOpen, child: _Submenu( anchor: this, menuStyle: widget.style, @@ -542,12 +542,10 @@ class _MenuAnchorState extends State { _closeChildren(inDispose: inDispose); _overlayEntry?.remove(); _overlayEntry = null; - if (!inDispose && mounted) { - setState(() { - // Notify that _isOpen may have changed state, but only if not currently - // disposing or unmounted. - _parent?._childChangedOpenState(false); - }); + if (!inDispose) { + // Notify that _childIsOpen changed state, but only if not + // currently disposing. + _parent?._childChangedOpenState(); } widget.onClose?.call(); } @@ -651,11 +649,11 @@ class MenuController { /// 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 /// hovering over other items will close the previous menu and open the newly -/// hovered one. When those open/close transitions occur, [SubmenuButton.onOpen], -/// and [SubmenuButton.onClose] are called on the corresponding [SubmenuButton] child -/// of the menu bar. +/// hovered one. When those open/close transitions occur, +/// [SubmenuButton.onOpen], and [SubmenuButton.onClose] are called on the +/// 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 /// [CharacterActivator] assigned to them as their [MenuItemButton.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 /// thing, it is recommended that they call the same callback. /// -/// {@tool dartpad} -/// This example shows a [MenuBar] that contains a single top level menu, -/// containing three items: "About", a checkbox menu item for showing a -/// message, and "Quit". The items are identified with an enum value, and the -/// shortcuts are registered globally with the [ShortcutRegistry]. +/// {@tool dartpad} This example shows a [MenuBar] that contains a single top +/// level menu, containing three items: "About", a checkbox menu item for +/// showing a message, and "Quit". The items are identified with an enum value, +/// and the shortcuts are registered globally with the [ShortcutRegistry]. /// /// ** See code in examples/api/lib/material/menu_anchor/menu_bar.0.dart ** /// {@end-tool} /// {@endtemplate} /// +/// {@macro flutter.material.MenuAcceleratorLabel.accelerator_sample} +/// /// See also: /// /// * [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 /// object must be provided. /// - /// {@macro flutter.material.menu_bar.shortcuts_note} + /// {@macro flutter.material.MenuBar.shortcuts_note} final List children; @override @@ -747,7 +746,7 @@ class MenuBar extends StatelessWidget { List debugDescribeChildren() { return [ ...children.map( - (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 /// created with a [MenuAnchor]. /// -/// {@macro flutter.material.menu_bar.shortcuts_note} +/// {@macro flutter.material.MenuBar.shortcuts_note} /// /// See also: /// @@ -829,7 +828,7 @@ class MenuItemButton extends StatefulWidget { /// The optional shortcut that selects this [MenuItemButton]. /// - /// {@macro flutter.material.menu_bar.shortcuts_note} + /// {@macro flutter.material.MenuBar.shortcuts_note} final MenuSerializableShortcut? shortcut; /// Customizes this button's appearance. @@ -1029,7 +1028,7 @@ class _MenuItemButtonState extends State { mergedStyle = widget.style!.merge(mergedStyle); } - return TextButton( + Widget child = TextButton( onPressed: widget.enabled ? _handleSelect : null, onHover: widget.enabled ? _handleHover : null, onFocusChange: widget.enabled ? widget.onFocusChange : null, @@ -1045,6 +1044,15 @@ class _MenuItemButtonState extends State { child: widget.child!, ), ); + + if (_platformSupportsAccelerators() && widget.enabled) { + child = MenuAcceleratorCallbackBinding( + onInvoke: _handleSelect, + child: child, + ); + } + + return child; } void _handleFocusChange() { @@ -1193,7 +1201,7 @@ class CheckboxMenuButton extends StatelessWidget { /// The optional shortcut that selects this [MenuItemButton]. /// - /// {@macro flutter.material.menu_bar.shortcuts_note} + /// {@macro flutter.material.MenuBar.shortcuts_note} final MenuSerializableShortcut? shortcut; /// Customizes this button's appearance. @@ -1390,7 +1398,7 @@ class RadioMenuButton extends StatelessWidget { /// The optional shortcut that selects this [MenuItemButton]. /// - /// {@macro flutter.material.menu_bar.shortcuts_note} + /// {@macro flutter.material.MenuBar.shortcuts_note} final MenuSerializableShortcut? shortcut; /// Customizes this button's appearance. @@ -1467,7 +1475,6 @@ class RadioMenuButton extends StatelessWidget { } } - /// A menu button that displays a cascading menu. /// /// It can be used as part of a [MenuBar], or as a standalone widget. @@ -1811,6 +1818,9 @@ class _SubmenuButtonState extends State { } void toggleShowMenu(BuildContext context) { + if (controller._anchor == null) { + return; + } if (controller.isOpen) { controller.close(); } else { @@ -1835,7 +1845,7 @@ class _SubmenuButtonState extends State { // 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 // it. - if (controller._anchor!._root._orientation == Axis.horizontal && !controller._anchor!._root._childIsOpen) { + if (controller._anchor!._root._orientation == Axis.horizontal && !controller._anchor!._root._isOpen) { return; } @@ -1845,7 +1855,7 @@ class _SubmenuButtonState extends State { } } - return TextButton( + child = TextButton( style: mergedStyle, focusNode: _buttonFocusNode, onHover: _enabled ? (bool hovering) => handleHover(hovering, context) : null, @@ -1858,6 +1868,15 @@ class _SubmenuButtonState extends State { child: child ?? const SizedBox(), ), ); + + if (_enabled && _platformSupportsAccelerators()) { + return MenuAcceleratorCallbackBinding( + onInvoke: () => toggleShowMenu(context), + hasSubmenu: true, + child: child, + ); + } + return child; }, menuChildren: widget.menuChildren, child: widget.child, @@ -1874,7 +1893,7 @@ class _SubmenuButtonState extends State { T? resolve(MaterialStateProperty? Function(MenuStyle? style) getProperty) { return effectiveValue( - (MenuStyle? style) { + (MenuStyle? style) { return getProperty(style)?.resolve(widget.statesController?.value ?? const {}); }, ); @@ -1882,10 +1901,7 @@ class _SubmenuButtonState extends State { return resolve( (MenuStyle? style) => style?.padding, - )?.resolve( - Directionality.of(context), - ) ?? - EdgeInsets.zero; + )?.resolve(Directionality.of(context)) ?? EdgeInsets.zero; } void _handleFocusChange() { @@ -2154,14 +2170,18 @@ class _MenuAnchorMarker extends InheritedWidget { required super.child, required this.anchorKey, required this.anchor, + required this.isOpen, }); final GlobalKey anchorKey; final _MenuAnchorState anchor; + final bool isOpen; @override 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 bool get _isOpen { // 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 @@ -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(); + } + + /// 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: [ + 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 '&' 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 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 { + 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.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( + { + 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 /// [SubmenuButton]. /// @@ -2857,25 +3303,25 @@ class _MenuPanelState extends State<_MenuPanel> { constrainedAxis: widget.orientation, clipBehavior: Clip.hardEdge, alignment: AlignmentDirectional.centerStart, - child: _intrinsicCrossSize( - child: Material( - elevation: elevation, - shape: shape, - color: backgroundColor, - shadowColor: shadowColor, - surfaceTintColor: surfaceTintColor, - type: backgroundColor == null ? MaterialType.transparency : MaterialType.canvas, + child: _intrinsicCrossSize( + child: Material( + elevation: elevation, + shape: shape, + color: backgroundColor, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + type: backgroundColor == null ? MaterialType.transparency : MaterialType.canvas, clipBehavior: Clip.hardEdge, - child: Padding( - padding: resolvedPadding, - child: SingleChildScrollView( - scrollDirection: widget.orientation, - child: Flex( - crossAxisAlignment: CrossAxisAlignment.start, - textDirection: Directionality.of(context), - direction: widget.orientation, - mainAxisSize: MainAxisSize.min, - children: widget.children, + child: Padding( + padding: resolvedPadding, + child: SingleChildScrollView( + scrollDirection: widget.orientation, + child: Flex( + crossAxisAlignment: CrossAxisAlignment.start, + textDirection: Directionality.of(context), + direction: widget.orientation, + mainAxisSize: MainAxisSize.min, + children: widget.children, ), ), ), @@ -3061,6 +3507,23 @@ bool _debugMenuInfo(String message, [Iterable? details]) { 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 // Do not edit by hand. The code between the "BEGIN GENERATED" and @@ -3072,13 +3535,13 @@ bool _debugMenuInfo(String message, [Iterable? details]) { class _MenuBarDefaultsM3 extends MenuStyle { _MenuBarDefaultsM3(this.context) - : super( - elevation: const MaterialStatePropertyAll(3.0), - shape: const MaterialStatePropertyAll(_defaultMenuBorder), - alignment: AlignmentDirectional.bottomStart, - ); + : super( + elevation: const MaterialStatePropertyAll(3.0), + shape: const MaterialStatePropertyAll(_defaultMenuBorder), + alignment: AlignmentDirectional.bottomStart, + ); static const RoundedRectangleBorder _defaultMenuBorder = - RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))); + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))); final BuildContext context; @@ -3114,11 +3577,11 @@ class _MenuBarDefaultsM3 extends MenuStyle { class _MenuButtonDefaultsM3 extends ButtonStyle { _MenuButtonDefaultsM3(this.context) - : super( - animationDuration: kThemeChangeDuration, - enableFeedback: true, - alignment: AlignmentDirectional.centerStart, - ); + : super( + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: AlignmentDirectional.centerStart, + ); final BuildContext context; late final ColorScheme _colors = Theme.of(context).colorScheme; @@ -3256,13 +3719,13 @@ class _MenuButtonDefaultsM3 extends ButtonStyle { class _MenuDefaultsM3 extends MenuStyle { _MenuDefaultsM3(this.context) - : super( - elevation: const MaterialStatePropertyAll(3.0), - shape: const MaterialStatePropertyAll(_defaultMenuBorder), - alignment: AlignmentDirectional.topEnd, - ); + : super( + elevation: const MaterialStatePropertyAll(3.0), + shape: const MaterialStatePropertyAll(_defaultMenuBorder), + alignment: AlignmentDirectional.topEnd, + ); static const RoundedRectangleBorder _defaultMenuBorder = - RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))); + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))); final BuildContext context; diff --git a/packages/flutter/lib/src/widgets/shortcuts.dart b/packages/flutter/lib/src/widgets/shortcuts.dart index fec74e836c..a8e923f030 100644 --- a/packages/flutter/lib/src/widgets/shortcuts.dart +++ b/packages/flutter/lib/src/widgets/shortcuts.dart @@ -5,6 +5,7 @@ import 'dart:collection'; import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.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 ** /// {@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: /// /// * [SingleActivator], an activator that represents a single key combined /// with modifiers, such as `Ctrl+C` or `Ctrl-Right Arrow`. class CharacterActivator with Diagnosticable, MenuSerializableShortcut implements ShortcutActivator { /// 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, { this.alt = false, this.control = false, @@ -602,36 +622,38 @@ class CharacterActivator with Diagnosticable, MenuSerializableShortcut implement 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. /// /// 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 - /// or both Alt keys must be pressed. - /// + /// one or both Alt keys must be pressed. + /// + /// {@macro flutter.widgets.shortcuts.CharacterActivator.alt} + /// /// See also: /// /// * [LogicalKeyboardKey.altLeft], [LogicalKeyboardKey.altRight]. 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. /// /// 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 - /// either or both Control keys must be pressed. + /// either one or both Control keys must be pressed. /// /// See also: /// /// * [LogicalKeyboardKey.controlLeft], [LogicalKeyboardKey.controlRight]. 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. /// /// 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 - /// either or both Meta keys must be pressed. + /// either one or both Meta keys must be pressed. /// /// See also: /// @@ -1150,7 +1172,7 @@ class ShortcutRegistryEntry { /// [ShortcutRegistryEntry] from the [registry]. @mustCallSuper void dispose() { - registry._disposeToken(this); + registry._disposeEntry(this); } } @@ -1160,11 +1182,22 @@ class ShortcutRegistryEntry { /// You can reach the nearest [ShortcutRegistry] using [of] and [maybeOf]. /// /// 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 { + bool _notificationScheduled = false; + bool _disposed = false; + + @override + void dispose() { + super.dispose(); + _disposed = true; + } + /// Gets the combined shortcut bindings from all contexts that are registered - /// with this [ShortcutRegistry], in addition to the bindings passed to - /// [ShortcutRegistry]. + /// with this [ShortcutRegistry]. /// /// Listeners will be notified when the value returned by this getter changes. /// @@ -1172,11 +1205,12 @@ class ShortcutRegistry with ChangeNotifier { Map get shortcuts { assert(ChangeNotifier.debugAssertNotDisposed(this)); return { - for (final MapEntry> entry in _tokenShortcuts.entries) + for (final MapEntry> entry in _registeredShortcuts.entries) ...entry.value, }; } - final Map> _tokenShortcuts = + + final Map> _registeredShortcuts = >{}; /// Adds all the given shortcut bindings to this [ShortcutRegistry], and @@ -1202,13 +1236,31 @@ class ShortcutRegistry with ChangeNotifier { /// shortcuts associated with a particular entry. ShortcutRegistryEntry addAll(Map value) { assert(ChangeNotifier.debugAssertNotDisposed(this)); + assert(value.isNotEmpty, 'Cannot register an empty map of shortcuts'); final ShortcutRegistryEntry entry = ShortcutRegistryEntry._(this); - _tokenShortcuts[entry] = value; + _registeredShortcuts[entry] = value; assert(_debugCheckForDuplicates()); - notifyListeners(); + _notifyListenersNextFrame(); 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] /// which most tightly encloses the given [BuildContext]. /// @@ -1270,23 +1322,24 @@ class ShortcutRegistry with ChangeNotifier { // registry. void _replaceAll(ShortcutRegistryEntry entry, Map value) { assert(ChangeNotifier.debugAssertNotDisposed(this)); - assert(_debugCheckTokenIsValid(entry)); - _tokenShortcuts[entry] = value; + assert(_debugCheckEntryIsValid(entry)); + _registeredShortcuts[entry] = value; assert(_debugCheckForDuplicates()); - notifyListeners(); + _notifyListenersNextFrame(); } // Removes all the shortcuts associated with the given entry from this // registry. - void _disposeToken(ShortcutRegistryEntry entry) { - assert(_debugCheckTokenIsValid(entry)); - if (_tokenShortcuts.remove(entry) != null) { - notifyListeners(); + void _disposeEntry(ShortcutRegistryEntry entry) { + assert(_debugCheckEntryIsValid(entry)); + final Map? removedShortcut = _registeredShortcuts.remove(entry); + if (removedShortcut != null) { + _notifyListenersNextFrame(); } } - bool _debugCheckTokenIsValid(ShortcutRegistryEntry entry) { - if (!_tokenShortcuts.containsKey(entry)) { + bool _debugCheckEntryIsValid(ShortcutRegistryEntry entry) { + if (!_registeredShortcuts.containsKey(entry)) { if (entry.registry == this) { throw FlutterError('entry ${describeIdentity(entry)} is invalid.\n' 'The entry has already been disposed of. Tokens are not valid after ' @@ -1303,7 +1356,7 @@ class ShortcutRegistry with ChangeNotifier { bool _debugCheckForDuplicates() { final Map previous = {}; - for (final MapEntry> tokenEntry in _tokenShortcuts.entries) { + for (final MapEntry> tokenEntry in _registeredShortcuts.entries) { for (final ShortcutActivator shortcut in tokenEntry.value.keys) { if (previous.containsKey(shortcut)) { throw FlutterError( @@ -1378,10 +1431,10 @@ class _ShortcutRegistrarState extends State { @override Widget build(BuildContext context) { - return Shortcuts.manager( - manager: manager, - child: _ShortcutRegistrarMarker( - registry: registry, + return _ShortcutRegistrarMarker( + registry: registry, + child: Shortcuts.manager( + manager: manager, child: widget.child, ), ); diff --git a/packages/flutter/test/material/debug_test.dart b/packages/flutter/test/material/debug_test.dart index 055298b909..330ab5e56c 100644 --- a/packages/flutter/test/material/debug_test.dart +++ b/packages/flutter/test/material/debug_test.dart @@ -191,11 +191,11 @@ void main() { ' Localizations\n' ' MediaQuery\n' ' _MediaQueryFromWindow\n' - ' _ShortcutRegistrarMarker\n' ' Semantics\n' ' _FocusMarker\n' ' Focus\n' ' Shortcuts\n' + ' _ShortcutRegistrarMarker\n' ' ShortcutRegistrar\n' ' TapRegionSurface\n' ' _FocusMarker\n' diff --git a/packages/flutter/test/material/menu_anchor_test.dart b/packages/flutter/test/material/menu_anchor_test.dart index b2c89d5428..645fa71179 100644 --- a/packages/flutter/test/material/menu_anchor_test.dart +++ b/packages/flutter/test/material/menu_anchor_test.dart @@ -31,7 +31,7 @@ void main() { } void handleFocusChange() { - focusedMenu = primaryFocus?.debugLabel ?? primaryFocus?.toString(); + focusedMenu = (primaryFocus?.debugLabel ?? primaryFocus).toString(); } setUpAll(() { @@ -392,6 +392,28 @@ void main() { ), 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: [ + 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 { @@ -448,13 +470,16 @@ void main() { await tester.pumpWidget( MaterialApp( home: Material( - child: Column( - children: [ - MenuBar( - children: createTestMenus(onPressed: onPressed), - ), - const Expanded(child: Placeholder()), - ], + child: Directionality( + textDirection: TextDirection.rtl, + child: Column( + children: [ + MenuBar( + children: createTestMenus(onPressed: onPressed), + ), + const Expanded(child: Placeholder()), + ], + ), ), ), ), @@ -463,7 +488,7 @@ void main() { expect( 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); 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 { @@ -1086,10 +1107,6 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); 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 { @@ -1235,6 +1252,243 @@ void main() { }); }); + group('Accelerators', () { + const Set apple = {TargetPlatform.macOS, TargetPlatform.iOS}; + final Set nonApple = TargetPlatform.values.toSet().difference(apple); + + test('Accelerator markers are stripped properly', () { + const Map expected = { + '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 expectedIndices = [-1, 0, 0, -1, 0, -1, 24, -1]; + const List expectedHasAccelerator = [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.mainMenu0])); + expect(closed, equals([TestMenu.mainMenu0])); + expect(selected, equals([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.mainMenu1, TestMenu.subMenu11])); + expect(closed, equals([TestMenu.subMenu11, TestMenu.mainMenu1])); + expect(selected, equals([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.mainMenu1, TestMenu.subMenu11])); + expect(closed, equals([TestMenu.subMenu11, TestMenu.mainMenu1])); + expect(selected, equals([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.mainMenu1, TestMenu.subMenu11])); + expect(closed, equals([TestMenu.subMenu11, TestMenu.mainMenu1])); + expect(selected, equals([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', () { testWidgets('Moving a controller to a new instance works', (WidgetTester tester) async { 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[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[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))); }); @@ -1647,7 +1901,7 @@ void main() { 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[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))); }); @@ -1687,7 +1941,7 @@ void main() { 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[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))); }); @@ -1727,7 +1981,7 @@ void main() { 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[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))); }); }); @@ -1929,164 +2183,124 @@ List createTestMenus({ void Function(TestMenu)? onOpen, void Function(TestMenu)? onClose, Map shortcuts = const {}, - bool includeStandard = false, bool includeExtraGroups = false, + bool accelerators = false, }) { + Widget submenuButton( + TestMenu menu, { + required List 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 result = [ - SubmenuButton( - onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu0) : null, - onClose: onClose != null ? () => onClose(TestMenu.mainMenu0) : null, + submenuButton( + TestMenu.mainMenu0, menuChildren: [ - MenuItemButton( - onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu00) : null, - shortcut: shortcuts[TestMenu.subMenu00], - 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), - ), + menuItemButton(TestMenu.subMenu00, leadingIcon: const Icon(Icons.add)), + menuItemButton(TestMenu.subMenu01), + menuItemButton(TestMenu.subMenu02), ], - child: Text(TestMenu.mainMenu0.label), ), - SubmenuButton( - onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu1) : null, - onClose: onClose != null ? () => onClose(TestMenu.mainMenu1) : null, + submenuButton( + TestMenu.mainMenu1, menuChildren: [ - MenuItemButton( - onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu10) : null, - shortcut: shortcuts[TestMenu.subMenu10], - child: Text(TestMenu.subMenu10.label), - ), - SubmenuButton( - onOpen: onOpen != null ? () => onOpen(TestMenu.subMenu11) : null, - onClose: onClose != null ? () => onClose(TestMenu.subMenu11) : null, + menuItemButton(TestMenu.subMenu10), + submenuButton( + TestMenu.subMenu11, menuChildren: [ - MenuItemButton( - key: UniqueKey(), - onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu110) : null, - shortcut: shortcuts[TestMenu.subSubMenu110], - 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), - ), + menuItemButton(TestMenu.subSubMenu110, key: UniqueKey()), + menuItemButton(TestMenu.subSubMenu111), + menuItemButton(TestMenu.subSubMenu112), + menuItemButton(TestMenu.subSubMenu113), ], - 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( - onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu2) : null, - onClose: onClose != null ? () => onClose(TestMenu.mainMenu2) : null, + submenuButton( + TestMenu.mainMenu2, menuChildren: [ - MenuItemButton( - // Always disabled. + menuItemButton( + TestMenu.subMenu20, leadingIcon: const Icon(Icons.ac_unit), - shortcut: shortcuts[TestMenu.subMenu20], - child: Text(TestMenu.subMenu20.label), + enabled: false, ), ], - child: Text(TestMenu.mainMenu2.label), ), if (includeExtraGroups) - SubmenuButton( - onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu3) : null, - onClose: onClose != null ? () => onClose(TestMenu.mainMenu3) : null, + submenuButton( + TestMenu.mainMenu3, menuChildren: [ - MenuItemButton( - // Always disabled. - shortcut: shortcuts[TestMenu.subMenu30], - // Always disabled. - child: Text(TestMenu.subMenu30.label), - ), + menuItemButton(TestMenu.subMenu30, enabled: false), ], - child: Text(TestMenu.mainMenu3.label), ), if (includeExtraGroups) - SubmenuButton( - onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu4) : null, - onClose: onClose != null ? () => onClose(TestMenu.mainMenu4) : null, + submenuButton( + TestMenu.mainMenu4, menuChildren: [ - MenuItemButton( - // Always disabled. - shortcut: shortcuts[TestMenu.subMenu40], - // 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), - ), + menuItemButton(TestMenu.subMenu40, enabled: false), + menuItemButton(TestMenu.subMenu41, enabled: false), + menuItemButton(TestMenu.subMenu42, enabled: false), ], - child: Text(TestMenu.mainMenu4.label), ), - SubmenuButton( - onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu5) : null, - onClose: onClose != null ? () => onClose(TestMenu.mainMenu5) : null, - menuChildren: const [], - child: Text(TestMenu.mainMenu5.label), - ), + submenuButton(TestMenu.mainMenu5, menuChildren: const []), ]; return result; } enum TestMenu { - mainMenu0('Menu 0'), - mainMenu1('Menu 1'), - mainMenu2('Menu 2'), - mainMenu3('Menu 3'), - mainMenu4('Menu 4'), - mainMenu5('Menu 5'), - subMenu00('Sub Menu 00'), - subMenu01('Sub Menu 01'), - subMenu02('Sub Menu 02'), - subMenu10('Sub Menu 10'), - subMenu11('Sub Menu 11'), - subMenu12('Sub Menu 12'), - subMenu20('Sub Menu 20'), - subMenu30('Sub Menu 30'), - subMenu40('Sub Menu 40'), - subMenu41('Sub Menu 41'), - subMenu42('Sub Menu 42'), - subSubMenu110('Sub Sub Menu 110'), - subSubMenu111('Sub Sub Menu 111'), - subSubMenu112('Sub Sub Menu 112'), - subSubMenu113('Sub Sub Menu 113'); + mainMenu0('&Menu 0'), + mainMenu1('M&enu &1'), + mainMenu2('Me&nu 2'), + mainMenu3('Men&u 3'), + mainMenu4('Menu &4'), + mainMenu5('Menu &5 && &6 &'), + subMenu00('Sub &Menu 0&0'), + subMenu01('Sub Menu 0&1'), + subMenu02('Sub Menu 0&2'), + subMenu10('Sub Menu 1&0'), + subMenu11('Sub Menu 1&1'), + subMenu12('Sub Menu 1&2'), + subMenu20('Sub Menu 2&0'), + subMenu30('Sub Menu 3&0'), + subMenu40('Sub Menu 4&0'), + subMenu41('Sub Menu 4&1'), + subMenu42('Sub Menu 4&2'), + subSubMenu110('Sub Sub Menu 11&0'), + subSubMenu111('Sub Sub Menu 11&1'), + subSubMenu112('Sub Sub Menu 11&2'), + subSubMenu113('Sub Sub Menu 11&3'); - const TestMenu(this.label); - final String label; + const TestMenu(this.acceleratorLabel); + 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; + } }