From 2d9ad2608666ffe900684cefc14996da6dfa9ada Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Mon, 4 Apr 2022 15:03:10 -0700 Subject: [PATCH] Implements a PlatformMenuBar widget and associated data structures (#100274) Implements a PlatformMenuBar widget and associated data structures for defining menu bars that use native APIs for rendering. This PR includes: A PlatformMenuBar class, which is a widget that menu bar data can be attached to for sending to the platform. A PlatformMenuDelegate base, which is the type taken by a new WidgetsBinding.platformMenuDelegate. An implementation of the above in DefaultPlatformMenuDelegate that talks to the built-in "flutter/menu" channel to talk to the built-in platform implementation. The delegate is so that a plugin could override with its own delegate and provide other platforms with native menu support using the same widgets to define the menus. This is the framework part of the implementation. The engine part will be in flutter/engine#32080 (and flutter/engine#32358) --- .../platform_menu_bar.0.dart | 138 +++ .../lib/src/rendering/layout_helper.dart | 4 +- .../lib/src/services/system_channels.dart | 49 + packages/flutter/lib/src/widgets/binding.dart | 9 + .../lib/src/widgets/platform_menu_bar.dart | 1003 +++++++++++++++++ .../flutter/lib/src/widgets/shortcuts.dart | 21 +- packages/flutter/lib/widgets.dart | 1 + .../test/widgets/platform_menu_bar_test.dart | 400 +++++++ 8 files changed, 1621 insertions(+), 4 deletions(-) create mode 100644 examples/api/lib/material/platform_menu_bar/platform_menu_bar.0.dart create mode 100644 packages/flutter/lib/src/widgets/platform_menu_bar.dart create mode 100644 packages/flutter/test/widgets/platform_menu_bar_test.dart diff --git a/examples/api/lib/material/platform_menu_bar/platform_menu_bar.0.dart b/examples/api/lib/material/platform_menu_bar/platform_menu_bar.0.dart new file mode 100644 index 00000000000..bc9951175dc --- /dev/null +++ b/examples/api/lib/material/platform_menu_bar/platform_menu_bar.0.dart @@ -0,0 +1,138 @@ +// 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 PlatformMenuBar + +//////////////////////////////////// +// THIS SAMPLE ONLY WORKS ON MACOS. +//////////////////////////////////// + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const SampleApp()); + +enum MenuSelection { + about, + showMessage, +} + +class SampleApp extends StatelessWidget { + const SampleApp({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold(body: MyMenuBarApp()), + ); + } +} + +class MyMenuBarApp extends StatefulWidget { + const MyMenuBarApp({Key? key}) : super(key: key); + + @override + State createState() => _MyMenuBarAppState(); +} + +class _MyMenuBarAppState extends State { + String _message = 'Hello'; + bool _showMessage = false; + + void _handleMenuSelection(MenuSelection value) { + switch (value) { + case MenuSelection.about: + showAboutDialog( + context: context, + applicationName: 'MenuBar Sample', + applicationVersion: '1.0.0', + ); + break; + case MenuSelection.showMessage: + setState(() { + _showMessage = !_showMessage; + }); + break; + } + } + + @override + Widget build(BuildContext context) { + //////////////////////////////////// + // THIS SAMPLE ONLY WORKS ON MACOS. + //////////////////////////////////// + + // This builds a menu hierarchy that looks like this: + // Flutter API Sample + // ├ About + // ├ ──────── (group divider) + // ├ Hide/Show Message + // ├ Messages + // │ ├ I am not throwing away my shot. + // │ └ There's a million things I haven't done, but just you wait. + // └ Quit + return PlatformMenuBar( + menus: [ + PlatformMenu( + label: 'Flutter API Sample', + menus: [ + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: 'About', + onSelected: () { + _handleMenuSelection(MenuSelection.about); + }, + ) + ], + ), + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + onSelected: () { + _handleMenuSelection(MenuSelection.showMessage); + }, + shortcut: const CharacterActivator('m'), + label: _showMessage ? 'Hide Message' : 'Show Message', + ), + PlatformMenu( + label: 'Messages', + menus: [ + PlatformMenuItem( + label: 'I am not throwing away my shot.', + shortcut: const SingleActivator(LogicalKeyboardKey.digit1, meta: true), + onSelected: () { + setState(() { + _message = 'I am not throwing away my shot.'; + }); + }, + ), + PlatformMenuItem( + label: "There's a million things I haven't done, but just you wait.", + shortcut: const SingleActivator(LogicalKeyboardKey.digit2, meta: true), + onSelected: () { + setState(() { + _message = "There's a million things I haven't done, but just you wait."; + }); + }, + ), + ], + ) + ], + ), + if (PlatformProvidedMenuItem.hasMenu(PlatformProvidedMenuItemType.quit)) + const PlatformProvidedMenuItem(type: PlatformProvidedMenuItemType.quit), + ], + ), + ], + body: Center( + child: Text(_showMessage + ? _message + : 'This space intentionally left blank.\n' + 'Show a message here using the menu.'), + ), + ); + } +} diff --git a/packages/flutter/lib/src/rendering/layout_helper.dart b/packages/flutter/lib/src/rendering/layout_helper.dart index cccc0f66be6..e4edebf231e 100644 --- a/packages/flutter/lib/src/rendering/layout_helper.dart +++ b/packages/flutter/lib/src/rendering/layout_helper.dart @@ -28,7 +28,7 @@ class ChildLayoutHelper { /// This method calls [RenderBox.getDryLayout] on the given [RenderBox]. /// /// This method should only be called by the parent of the provided - /// [RenderBox] child as it bounds parent and child together (if the child + /// [RenderBox] child as it binds parent and child together (if the child /// is marked as dirty, the child will also be marked as dirty). /// /// See also: @@ -46,7 +46,7 @@ class ChildLayoutHelper { /// `parentUsesSize` set to true to receive its [Size]. /// /// This method should only be called by the parent of the provided - /// [RenderBox] child as it bounds parent and child together (if the child + /// [RenderBox] child as it binds parent and child together (if the child /// is marked as dirty, the child will also be marked as dirty). /// /// See also: diff --git a/packages/flutter/lib/src/services/system_channels.dart b/packages/flutter/lib/src/services/system_channels.dart index a34f33a21dc..edb4b29565a 100644 --- a/packages/flutter/lib/src/services/system_channels.dart +++ b/packages/flutter/lib/src/services/system_channels.dart @@ -392,4 +392,53 @@ class SystemChannels { 'flutter/localization', JSONMethodCodec(), ); + + /// A [MethodChannel] for platform menu specification and control. + /// + /// The following outgoing method is defined for this channel (invoked using + /// [OptionalMethodChannel.invokeMethod]): + /// + /// * `Menu.setMenu`: sends the configuration of the platform menu, including + /// labels, enable/disable information, and unique integer identifiers for + /// each menu item. The configuration is sent as a `Map` + /// encoding the list of top level menu items in window "0", which each + /// have a hierarchy of `Map` containing the required + /// data, sent via a [StandardMessageCodec]. It is typically generated from + /// a list of [MenuItem]s, and ends up looking like this example: + /// + /// ```dart + /// List> menu = { + /// '0': >[ + /// { + /// 'id': 1, + /// 'label': 'First Menu Label', + /// 'enabled': true, + /// 'children': >[ + /// { + /// 'id': 2, + /// 'label': 'Sub Menu Label', + /// 'enabled': true, + /// }, + /// ], + /// }, + /// ], + /// }; + /// ``` + /// + /// The following incoming methods are defined for this channel (registered + /// using [MethodChannel.setMethodCallHandler]). + /// + /// * `Menu.selectedCallback`: Called when a menu item is selected, along + /// with the unique ID of the menu item selected. + /// + /// * `Menu.opened`: Called when a submenu is opened, along with the unique + /// ID of the submenu. + /// + /// * `Menu.closed`: Called when a submenu is closed, along with the unique + /// ID of the submenu. + /// + /// See also: + /// + /// * [DefaultPlatformMenuDelegate], which uses this channel. + static const MethodChannel menu = OptionalMethodChannel('flutter/menu'); } diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index 328315bd86e..8f0eedcc386 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -16,6 +16,7 @@ import 'app.dart'; import 'debug.dart'; import 'focus_manager.dart'; import 'framework.dart'; +import 'platform_menu_bar.dart'; import 'router.dart'; import 'widget_inspector.dart'; @@ -294,6 +295,7 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB FlutterErrorDetails.propertiesTransformers.add(debugTransformDebugCreator); return true; }()); + platformMenuDelegate = DefaultPlatformMenuDelegate(); } /// The current [WidgetsBinding], if one has been created. @@ -523,6 +525,13 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB /// See [FocusManager] for more details. FocusManager get focusManager => _buildOwner!.focusManager; + /// A delegate that communicates with a platform plugin for serializing and + /// managing platform-rendered menu bars created by [PlatformMenuBar]. + /// + /// This is set by default to a [DefaultPlatformMenuDelegate] instance in + /// [initInstances]. + late PlatformMenuDelegate platformMenuDelegate; + final List _observers = []; /// Registers the given object as a binding observer. Binding diff --git a/packages/flutter/lib/src/widgets/platform_menu_bar.dart b/packages/flutter/lib/src/widgets/platform_menu_bar.dart new file mode 100644 index 00000000000..5fd1f1d67d9 --- /dev/null +++ b/packages/flutter/lib/src/widgets/platform_menu_bar.dart @@ -0,0 +1,1003 @@ +// 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 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; + +import 'binding.dart'; +import 'framework.dart'; +import 'shortcuts.dart'; + +// "flutter/menu" Method channel methods. +const String _kMenuSetMethod = 'Menu.setMenu'; +const String _kMenuSelectedCallbackMethod = 'Menu.selectedCallback'; +const String _kMenuItemOpenedMethod = 'Menu.opened'; +const String _kMenuItemClosedMethod = 'Menu.closed'; + +// Keys for channel communication map. +const String _kIdKey = 'id'; +const String _kLabelKey = 'label'; +const String _kEnabledKey = 'enabled'; +const String _kChildrenKey = 'children'; +const String _kIsDividerKey = 'isDivider'; +const String _kPlatformDefaultMenuKey = 'platformProvidedMenu'; + +/// A class used by [MenuSerializableShortcut] to describe the shortcut for +/// serialization to send to the platform for rendering a [PlatformMenuBar]. +/// +/// See also: +/// +/// * [PlatformMenuBar], a widget that defines a menu bar for the platform to +/// render natively. +/// * [MenuSerializableShortcut], a mixin allowing a [ShortcutActivator] to +/// provide data for serialization of the shortcut for sending to the +/// platform. +class ShortcutSerialization { + /// Creates a [ShortcutSerialization] representing a single character. + /// + /// This is used by a [CharacterActivator] to serialize itself. + ShortcutSerialization.character(String character) + : _internal = {_shortcutCharacter: character}, + assert(character.length == 1); + + /// Creates a [ShortcutSerialization] representing a specific + /// [LogicalKeyboardKey] and modifiers. + /// + /// This is used by a [SingleActivator] to serialize itself. + ShortcutSerialization.modifier( + LogicalKeyboardKey trigger, { + bool control = false, + bool shift = false, + bool alt = false, + bool meta = false, + }) : assert(trigger != LogicalKeyboardKey.shift && + trigger != LogicalKeyboardKey.shiftLeft && + trigger != LogicalKeyboardKey.shiftRight && + trigger != LogicalKeyboardKey.alt && + trigger != LogicalKeyboardKey.altLeft && + trigger != LogicalKeyboardKey.altRight && + trigger != LogicalKeyboardKey.control && + trigger != LogicalKeyboardKey.controlLeft && + trigger != LogicalKeyboardKey.controlRight && + trigger != LogicalKeyboardKey.meta && + trigger != LogicalKeyboardKey.metaLeft && + trigger != LogicalKeyboardKey.metaRight, + 'Specifying a modifier key as a trigger is not allowed. ' + 'Use provided boolean parameters instead.'), + _internal = { + _shortcutTrigger: trigger.keyId, + _shortcutModifiers: (control ? _shortcutModifierControl : 0) | + (alt ? _shortcutModifierAlt : 0) | + (shift ? _shortcutModifierShift : 0) | + (meta ? _shortcutModifierMeta : 0), + }; + + final Map _internal; + + /// The bit mask for the [LogicalKeyboardKey.meta] key (or it's left/right + /// equivalents) being down. + static const int _shortcutModifierMeta = 1 << 0; + + /// The bit mask for the [LogicalKeyboardKey.shift] key (or it's left/right + /// equivalents) being down. + static const int _shortcutModifierShift = 1 << 1; + + /// The bit mask for the [LogicalKeyboardKey.alt] key (or it's left/right + /// equivalents) being down. + static const int _shortcutModifierAlt = 1 << 2; + + /// The bit mask for the [LogicalKeyboardKey.alt] key (or it's left/right + /// equivalents) being down. + static const int _shortcutModifierControl = 1 << 3; + + /// The key for a string map field returned from [serializeForMenu] containing + /// a string that represents the character that, when typed, will trigger the + /// shortcut. + /// + /// All platforms are limited to a single trigger key that can be represented, + /// so this string should only contain a character that can be typed with a + /// single keystroke. + static const String _shortcutCharacter = 'shortcutEquivalent'; + + /// The key for the integer map field returned from [serializeForMenu] + /// containing the logical key ID for the trigger key on this shortcut. + /// + /// All platforms are limited to a single trigger key that can be represented. + static const String _shortcutTrigger = 'shortcutTrigger'; + + /// The key for the integer map field returned from [serializeForMenu] + /// containing a bitfield combination of [shortcutModifierControl], + /// [shortcutModifierAlt], [shortcutModifierShift], and/or + /// [shortcutModifierMeta]. + /// + /// If the shortcut responds to one of those modifiers, it should be + /// represented in the bitfield tagged with this key. + static const String _shortcutModifiers = 'shortcutModifiers'; + + /// Converts the internal representation to the format needed for a [MenuItem] + /// to include it in its serialized form for sending to the platform. + Map toChannelRepresentation() => _internal; +} + +/// A mixin allowing a [ShortcutActivator] to provide data for serialization of +/// the shortcut when sending to the platform. +/// +/// This is meant for those who have written their own [ShortcutActivator] +/// subclass, and would like to have it work for menus in a [PlatformMenuBar] as +/// well. +/// +/// Keep in mind that there are limits to the capabilities of the platform APIs, +/// and not all kinds of [ShortcutActivator]s will work with them. +/// +/// See also: +/// +/// * [SingleActivator], a [ShortcutActivator] which implements this mixin. +/// * [CharacterActivator], another [ShortcutActivator] which implements this mixin. +mixin MenuSerializableShortcut { + /// Implement this in a [ShortcutActivator] subclass to allow it to be + /// serialized for use in a [PlatformMenuBar]. + ShortcutSerialization serializeForMenu(); +} + +/// An abstract class for describing cascading menu hierarchies that are part of +/// a [PlatformMenuBar]. +/// +/// This type is used by [PlatformMenuDelegate.setMenus] to accept the menu +/// hierarchy to be sent to the platform, and by [PlatformMenuBar] to define the +/// menu hierarchy. +/// +/// See also: +/// +/// * [PlatformMenuBar], a widget that renders menu items using platform APIs +/// instead of Flutter. +abstract class MenuItem with Diagnosticable { + /// Allows subclasses to have const constructors. + const MenuItem(); + + /// Converts the representation of this item into a map suitable for sending + /// over the default "flutter/menu" channel used by [DefaultPlatformMenuDelegate]. + /// + /// The `delegate` is the [PlatformMenuDelegate] that is requesting the + /// serialization. The `index` is the position of this menu item in the list + /// of children of the [PlatformMenu] it belongs to, and `count` is the number + /// of children in the [PlatformMenu] it belongs to. + /// + /// The `getId` parameter is a [MenuItemSerializableIdGenerator] function that + /// generates a unique ID for each menu item, which is to be returned in the + /// "id" field of the menu item data. + Iterable> toChannelRepresentation( + PlatformMenuDelegate delegate, { + required int index, + required int count, + required MenuItemSerializableIdGenerator getId, + }); + + /// Returns all descendant [MenuItem]s of this item. + /// + /// Returns an empty list if this type of menu item doesn't have + /// descendants. + List get descendants => const []; + + /// Returns a callback, if any, to be invoked if the platform menu receives a + /// "Menu.selectedCallback" method call from the platform for this item. + /// + /// Only items that do not have submenus will have this callback invoked. + /// + /// The default implementation returns null. + VoidCallback? get onSelected => null; + + /// Returns a callback, if any, to be invoked if the platform menu receives a + /// "Menu.opened" method call from the platform for this item. + /// + /// Only items that have submenus will have this callback invokes + /// + /// The default implementation returns null. + VoidCallback? get onOpen => null; + + /// Returns a callback, if any, to be invoked if the platform menu receives a + /// "Menu.opened" method call from the platform for this item. + /// + /// Only items that have submenus will have this callback invoked. + /// + /// The default implementation returns null. + VoidCallback? get onClose => null; +} + +/// An abstract delegate class that can be used to set +/// [WidgetsBinding.platformMenuDelegate] to provide for managing platform +/// menus. +/// +/// This can be subclassed to provide a different menu plugin than the default +/// system-provided plugin for managing [PlatformMenuBar] menus. +/// +/// The [setMenus] method allows for setting of the menu hierarchy when the +/// [PlatformMenuBar] menu hierarchy changes. +/// +/// This delegate doesn't handle the results of clicking on a menu item, which +/// is left to the implementor of subclasses of `PlatformMenuDelegate` to +/// handle for their implementation. +/// +/// This delegate typically knows how to serialize a [PlatformMenu] +/// hierarchy, send it over a channel, and register for calls from the channel +/// when a menu is invoked or a submenu is opened or closed. +/// +/// See [DefaultPlatformMenuDelegate] for an example of implementing one of +/// these. +/// +/// See also: +/// +/// * [PlatformMenuBar], the widget that adds a platform menu bar to an +/// application, and uses [setMenus] to send the menus to the platform. +/// * [PlatformMenu], the class that describes a menu item with children +/// that appear in a cascading menu. +/// * [PlatformMenuItem], the class that describes the leaves of a menu +/// hierarchy. +abstract class PlatformMenuDelegate { + /// A const constructor so that subclasses can have const constructors. + const PlatformMenuDelegate(); + + /// Sets the entire menu hierarchy for a platform-rendered menu bar. + /// + /// The `topLevelMenus` argument is the list of menus that appear in the menu + /// bar, which themselves can have children. + /// + /// To update the menu hierarchy or menu item state, call `setMenus` with the + /// modified hierarchy, and it will overwrite the previous menu state. + /// + /// See also: + /// + /// * [PlatformMenuBar], the widget that adds a platform menu bar to an + /// application. + /// * [PlatformMenu], the class that describes a menu item with children + /// that appear in a cascading menu. + /// * [PlatformMenuItem], the class that describes the leaves of a menu + /// hierarchy. + void setMenus(List topLevelMenus); + + /// Clears any existing platform-rendered menus and leaves the application + /// with no menus. + /// + /// It is not necessary to call this before updating the menu with [setMenus]. + void clearMenus(); + + /// This is called by [PlatformMenuBar] when it is initialized, to be sure that + /// only one is active at a time. + /// + /// The `debugLockDelegate` function should be called before the first call to + /// [setMenus]. + /// + /// If the lock is successfully acquired, `debugLockDelegate` will return + /// true. + /// + /// If your implementation of a [PlatformMenuDelegate] can have only limited + /// active instances, enforce it when you override this function. + /// + /// See also: + /// + /// * [debugUnlockDelegate], where the delegate is unlocked. + bool debugLockDelegate(BuildContext context); + + /// This is called by [PlatformMenuBar] when it is disposed, so that another + /// one can take over. + /// + /// If the `debugUnlockDelegate` successfully unlocks the delegate, it will + /// return true. + /// + /// See also: + /// + /// * [debugLockDelegate], where the delegate is locked. + bool debugUnlockDelegate(BuildContext context); +} + +/// The signature for a function that generates unique menu item IDs for +/// serialization of a [MenuItem]. +typedef MenuItemSerializableIdGenerator = int Function(MenuItem item); + +/// The platform menu delegate that handles the built-in macOS platform menu +/// generation using the 'flutter/menu' channel. +/// +/// An instance of this class is set on [WidgetsBinding.platformMenuDelegate] by +/// default when the [WidgetsBinding] is initialized. +/// +/// See also: +/// +/// * [PlatformMenuBar], the widget that adds a platform menu bar to an +/// application. +/// * [PlatformMenu], the class that describes a menu item with children +/// that appear in a cascading menu. +/// * [PlatformMenuItem], the class that describes the leaves of a menu +/// hierarchy. +class DefaultPlatformMenuDelegate extends PlatformMenuDelegate { + /// Creates a const [DefaultPlatformMenuDelegate]. + /// + /// The optional [channel] argument defines the channel used to communicate + /// with the platform. It defaults to [SystemChannels.menu] if not supplied. + DefaultPlatformMenuDelegate({MethodChannel? channel}) + : channel = channel ?? SystemChannels.menu, + _idMap = {} { + this.channel.setMethodCallHandler(_methodCallHandler); + } + + // Map of distributed IDs to menu items. + final Map _idMap; + // An ever increasing value used to dole out IDs. + int _serial = 0; + // The context used to "lock" this delegate to a specific instance of + // PlatformMenuBar to make sure there is only one. + BuildContext? _lockedContext; + + @override + void clearMenus() => setMenus([]); + + @override + void setMenus(List topLevelMenus) { + _idMap.clear(); + final List> representation = >[]; + if (topLevelMenus.isNotEmpty) { + int index = 0; + for (final MenuItem childItem in topLevelMenus) { + representation + .addAll(childItem.toChannelRepresentation(this, index: index, count: topLevelMenus.length, getId: _getId)); + index += 1; + } + } + // Currently there's only ever one window, but the channel's format allows + // more than one window's menu hierarchy to be defined. + final Map windowMenu = { + '0': representation, + }; + channel.invokeMethod(_kMenuSetMethod, windowMenu); + } + + /// Defines the channel that the [DefaultPlatformMenuDelegate] uses to + /// communicate with the platform. + /// + /// Defaults to [SystemChannels.menu]. + final MethodChannel channel; + + /// Get the next serialization ID. + /// + /// This is called by each DefaultPlatformMenuDelegateSerializer when + /// serializing a new object so that it has a unique ID. + int _getId(MenuItem item) { + _serial += 1; + _idMap[_serial] = item; + return _serial; + } + + @override + bool debugLockDelegate(BuildContext context) { + assert(() { + // It's OK to lock if the lock isn't set, but not OK if a different + // context is locking it. + if (_lockedContext != null && _lockedContext != context) { + return false; + } + _lockedContext = context; + return true; + }()); + return true; + } + + @override + bool debugUnlockDelegate(BuildContext context) { + assert(() { + // It's OK to unlock if the lock isn't set, but not OK if a different + // context is unlocking it. + if (_lockedContext != null && _lockedContext != context) { + return false; + } + _lockedContext = null; + return true; + }()); + return true; + } + + // Handles the method calls from the plugin to forward to selection and + // open/close callbacks. + Future _methodCallHandler(MethodCall call) async { + final int id = call.arguments as int; + assert( + _idMap.containsKey(id), + 'Received a menu ${call.method} for a menu item with an ID that was not recognized: $id', + ); + if (!_idMap.containsKey(id)) { + return; + } + final MenuItem item = _idMap[id]!; + if (call.method == _kMenuSelectedCallbackMethod) { + item.onSelected?.call(); + } else if (call.method == _kMenuItemOpenedMethod) { + item.onOpen?.call(); + } else if (call.method == _kMenuItemClosedMethod) { + item.onClose?.call(); + } + } +} + +/// A menu bar that uses the platform's native APIs to construct and render a +/// menu described by a [PlatformMenu]/[PlatformMenuItem] hierarchy. +/// +/// This widget is especially useful on macOS, where a system menu is a required +/// part of every application. Flutter only includes support for macOS out of +/// the box, but support for other platforms may be provided via plugins that +/// set [WidgetsBinding.platformMenuDelegate] in their initialization. +/// +/// The [menus] member contains [MenuItem]s. They will not be part of the +/// widget tree, since they are not required to be widgets (even if they happen +/// to be widgets that implement [MenuItem], they still won't be part of the +/// widget tree). They are provided to configure the properties of the menus on +/// the platform menu bar. +/// +/// As far as Flutter is concerned, this widget has no visual representation, +/// and intercepts no events: it just returns the [body] from its build +/// function. This is because all of the rendering, shortcuts, and event +/// handling for the menu is handled by the plugin on the host platform. +/// +/// There can only be one [PlatformMenuBar] at a time using the same +/// [PlatformMenuDelegate]. It will assert if more than one is detected. +/// +/// When calling [toStringDeep] on this widget, it will give a tree of +/// [MenuItem]s, not a tree of widgets. +/// +/// {@tool sample} +/// This example shows a [PlatformMenuBar] that contains a single top level +/// menu, containing three items for "About", a toggleable menu item for showing +/// a message, a cascading submenu with message choices, and "Quit". +/// +/// **This example will only work on macOS.** +/// +/// ** See code in examples/api/lib/material/platform_menu_bar/platform_menu_bar.0.dart ** +/// {@end-tool} +class PlatformMenuBar extends StatefulWidget with DiagnosticableTreeMixin { + /// Creates a const [PlatformMenuBar]. + /// + /// The [body] and [menus] attributes are required. + const PlatformMenuBar({ + Key? key, + required this.body, + required this.menus, + }) : super(key: key); + + /// The widget to be rendered in the Flutter window that these platform menus + /// are associated with. + /// + /// This is typically the body of the application's UI. + final Widget body; + + /// The list of menu items that are the top level children of the + /// [PlatformMenuBar]. + /// + /// The `menus` member contains [MenuItem]s. They will not be part + /// of the widget tree, since they are not widgets. They are provided to + /// configure the properties of the menus on the platform menu bar. + /// + /// Also, a Widget in Flutter is immutable, so directly modifying the + /// `menus` with `List` APIs such as + /// `somePlatformMenuBarWidget.menus.add(...)` will result in incorrect + /// behaviors. Whenever the menus list is modified, a new list object + /// should be provided. + final List menus; + + @override + State createState() => _PlatformMenuBarState(); + + @override + List debugDescribeChildren() { + return menus.map((MenuItem child) => child.toDiagnosticsNode()).toList(); + } +} + +class _PlatformMenuBarState extends State { + List descendants = []; + + @override + void initState() { + super.initState(); + assert( + WidgetsBinding.instance.platformMenuDelegate.debugLockDelegate(context), + 'More than one active $PlatformMenuBar detected. Only one active ' + 'platform-rendered menu bar is allowed at a time.'); + WidgetsBinding.instance.platformMenuDelegate.clearMenus(); + _updateMenu(); + } + + @override + void dispose() { + assert(WidgetsBinding.instance.platformMenuDelegate.debugUnlockDelegate(context), + 'tried to unlock the $DefaultPlatformMenuDelegate more than once with context $context.'); + WidgetsBinding.instance.platformMenuDelegate.clearMenus(); + super.dispose(); + } + + @override + void didUpdateWidget(PlatformMenuBar oldWidget) { + super.didUpdateWidget(oldWidget); + final List newDescendants = [ + for (final MenuItem item in widget.menus) ...[ + item, + ...item.descendants, + ], + ]; + if (!listEquals(newDescendants, descendants)) { + descendants = newDescendants; + _updateMenu(); + } + } + + // Updates the data structures for the menu and send them to the platform + // plugin. + void _updateMenu() { + WidgetsBinding.instance.platformMenuDelegate.setMenus(widget.menus); + } + + @override + Widget build(BuildContext context) { + // PlatformMenuBar is really about managing the platform menu bar, and + // doesn't do any rendering or event handling in Flutter. + return widget.body; + } +} + +/// A class for representing menu items that have child submenus. +/// +/// See also: +/// +/// * [PlatformMenuItem], a class representing a leaf menu item in a +/// [PlatformMenuBar]. +class PlatformMenu extends MenuItem with DiagnosticableTreeMixin { + /// Creates a const [PlatformMenu]. + /// + /// The [label] and [menus] fields are required. + const PlatformMenu({ + required this.label, + this.onOpen, + this.onClose, + required this.menus, + }); + + /// The label that will appear on the menu. + final String label; + + /// The callback that is called when this menu is opened. + @override + final VoidCallback? onOpen; + + /// The callback that is called when this menu is closed. + @override + final VoidCallback? onClose; + + /// The menu items in the submenu opened by this menu item. + /// + /// If this is an empty list, this [PlatformMenu] will be disabled. + final List menus; + + /// Returns all descendant [MenuItem]s of this item. + @override + List get descendants => getDescendants(this); + + /// Returns all descendants of the given item. + /// + /// This API is supplied so that implementers of [PlatformMenu] can share + /// this implementation. + static List getDescendants(PlatformMenu item) { + return [ + for (final MenuItem child in item.menus) ...[ + child, + ...child.descendants, + ], + ]; + } + + @override + Iterable> toChannelRepresentation( + PlatformMenuDelegate delegate, { + required int index, + required int count, + required MenuItemSerializableIdGenerator getId, + }) { + return >[serialize(this, delegate, getId)]; + } + + /// Converts the supplied object to the correct channel representation for the + /// 'flutter/menu' channel. + /// + /// This API is supplied so that implementers of [PlatformMenu] can share + /// this implementation. + static Map serialize( + PlatformMenu item, + PlatformMenuDelegate delegate, + MenuItemSerializableIdGenerator getId, + ) { + final List> result = >[]; + int index = 0; + for (final MenuItem childItem in item.menus) { + result.addAll(childItem.toChannelRepresentation( + delegate, + index: index, + count: item.menus.length, + getId: getId, + )); + index += 1; + } + return { + _kIdKey: getId(item), + _kLabelKey: item.label, + _kEnabledKey: item.menus.isNotEmpty, + _kChildrenKey: result, + }; + } + + @override + List debugDescribeChildren() { + return menus.map((MenuItem child) => child.toDiagnosticsNode()).toList(); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('label', label)); + properties.add(FlagProperty('enabled', value: menus.isNotEmpty, ifFalse: 'DISABLED')); + } +} + +/// A class that groups other menu items into sections delineated by dividers. +/// +/// Visual dividers will be added before and after this group if other menu +/// items appear in the [PlatformMenu], and the leading one omitted if it is +/// first and the trailing one omitted if it is last in the menu. +class PlatformMenuItemGroup extends MenuItem { + /// Creates a const [PlatformMenuItemGroup]. + /// + /// The [members] field is required. + const PlatformMenuItemGroup({required this.members}); + + /// The [MenuItem]s that are members of this menu item group. + /// + /// An assertion will be thrown if there isn't at least one member of the group. + final List members; + + @override + Iterable> toChannelRepresentation( + PlatformMenuDelegate delegate, { + required int index, + required int count, + required MenuItemSerializableIdGenerator getId, + }) { + assert(members.isNotEmpty, 'There must be at least one member in a PlatformMenuItemGroup'); + final List> result = >[]; + if (index != 0) { + result.add({ + _kIdKey: getId(this), + _kIsDividerKey: true, + }); + } + for (final MenuItem item in members) { + result.addAll(item.toChannelRepresentation( + delegate, + index: index, + count: count, + getId: getId, + )); + } + if (index != count - 1) { + result.add({ + _kIdKey: getId(this), + _kIsDividerKey: true, + }); + } + return result; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IterableProperty('members', members)); + } +} + +/// A class for [MenuItem]s that do not have submenus (as a [PlatformMenu] +/// would), but can be selected. +/// +/// These [MenuItem]s are the leaves of the menu item tree, and [onSelected] +/// will be called when they are selected by clicking on them, or via an +/// optional keyboard [shortcut]. +/// +/// See also: +/// +/// * [PlatformMenu], a menu item that opens a submenu. +class PlatformMenuItem extends MenuItem { + /// Creates a const [PlatformMenuItem]. + /// + /// The [label] attribute is required. + const PlatformMenuItem({ + required this.label, + this.shortcut, + this.onSelected, + }); + + /// The required label used for rendering the menu item. + final String label; + + /// The optional shortcut that selects this [PlatformMenuItem]. + /// + /// This shortcut is only enabled when [onSelected] is set. + final MenuSerializableShortcut? shortcut; + + /// An optional callback that is called when this [PlatformMenuItem] is + /// selected. + /// + /// If unset, this menu item will be disabled. + @override + final VoidCallback? onSelected; + + @override + Iterable> toChannelRepresentation( + PlatformMenuDelegate delegate, { + required int index, + required int count, + required MenuItemSerializableIdGenerator getId, + }) { + return >[PlatformMenuItem.serialize(this, delegate, getId)]; + } + + /// Converts the given [PlatformMenuItem] into a data structure accepted by + /// the 'flutter/menu' method channel method 'Menu.SetMenu'. + /// + /// This API is supplied so that implementers of [PlatformMenuItem] can share + /// this implementation. + static Map serialize( + PlatformMenuItem item, + PlatformMenuDelegate delegate, + MenuItemSerializableIdGenerator getId, + ) { + final MenuSerializableShortcut? shortcut = item.shortcut; + return { + _kIdKey: getId(item), + _kLabelKey: item.label, + _kEnabledKey: item.onSelected != null, + if (shortcut != null)...shortcut.serializeForMenu().toChannelRepresentation(), + }; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('label', label)); + properties.add(DiagnosticsProperty('shortcut', shortcut, defaultValue: null)); + properties.add(FlagProperty('enabled', value: onSelected != null, ifFalse: 'DISABLED')); + } +} + +/// A class that represents a menu item that is provided by the platform. +/// +/// This is used to add things like the "About" and "Quit" menu items to a +/// platform menu. +/// +/// The [type] enum determines which type of platform defined menu will be +/// added. +/// +/// This is most useful on a macOS platform where there are many different types +/// of platform provided menu items in the standard menu setup. +/// +/// In order to know if a [PlatformProvidedMenuItem] is available on a +/// particular platform, call [PlatformProvidedMenuItem.hasMenu]. +/// +/// If the platform does not support the given [type], then the menu item will +/// throw an [ArgumentError] when it is sent to the platform. +/// +/// See also: +/// +/// * [PlatformMenuBar] which takes these items for inclusion in a +/// platform-rendered menu bar. +class PlatformProvidedMenuItem extends PlatformMenuItem { + /// Creates a const [PlatformProvidedMenuItem] of the appropriate type. Throws if the + /// platform doesn't support the given default menu type. + /// + /// The [type] argument is required. + const PlatformProvidedMenuItem({ + required this.type, + this.enabled = true, + }) : super( + label: '', // The label is ignored for standard menus. + ); + + /// The type of default menu this is. + /// + /// See [PlatformProvidedMenuItemType] for the different types available. Not + /// all of the types will be available on every platform. Use [hasMenu] to + /// determine if the current platform has a given default menu item. + /// + /// If the platform does not support the given [type], then the menu item will + /// throw an [ArgumentError] in debug mode. + final PlatformProvidedMenuItemType type; + + /// True if this [PlatformProvidedMenuItem] should be enabled or not. + final bool enabled; + + /// Checks to see if the given default menu type is supported on this + /// platform. + static bool hasMenu(PlatformProvidedMenuItemType menu) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return false; + case TargetPlatform.macOS: + return const { + PlatformProvidedMenuItemType.about, + PlatformProvidedMenuItemType.quit, + PlatformProvidedMenuItemType.servicesSubmenu, + PlatformProvidedMenuItemType.hide, + PlatformProvidedMenuItemType.hideOtherApplications, + PlatformProvidedMenuItemType.showAllApplications, + PlatformProvidedMenuItemType.startSpeaking, + PlatformProvidedMenuItemType.stopSpeaking, + PlatformProvidedMenuItemType.toggleFullScreen, + PlatformProvidedMenuItemType.minimizeWindow, + PlatformProvidedMenuItemType.zoomWindow, + PlatformProvidedMenuItemType.arrangeWindowsInFront, + }.contains(menu); + } + } + + @override + Iterable> toChannelRepresentation( + PlatformMenuDelegate delegate, { + required int index, + required int count, + required MenuItemSerializableIdGenerator getId, + }) { + assert(() { + if (!hasMenu(type)) { + throw ArgumentError( + 'Platform ${defaultTargetPlatform.name} has no standard menu for ' + '$type. Call PlatformProvidedMenuItem.hasMenu to determine this before ' + 'instantiating one.', + ); + } + return true; + }()); + + return >[ + { + _kIdKey: getId(this), + _kEnabledKey: enabled, + _kPlatformDefaultMenuKey: type.index, + }, + ]; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagProperty('enabled', value: enabled, ifFalse: 'DISABLED')); + } +} + +/// The list of possible standard, prebuilt menus for use in a [PlatformMenuBar]. +/// +/// These are menus that the platform typically provides that cannot be +/// reproduced in Flutter without calling platform functions, but are standard +/// on the platform. +/// +/// Examples include things like the "Quit" or "Services" menu items on macOS. +/// Not all platforms support all menu item types. Use +/// [PlatformProvidedMenuItem.hasMenu] to know if a particular type is supported +/// on a the current platform. +/// +/// Add these to your [PlatformMenuBar] using the [PlatformProvidedMenuItem] +/// class. +/// +/// You can tell if the platform supports the given standard menu using the +/// [PlatformProvidedMenuItem.hasMenu] method. +// Must be kept in sync with the plugin code's enum of the same name. +enum PlatformProvidedMenuItemType { + /// The system provided "About" menu item. + /// + /// On macOS, this is the `orderFrontStandardAboutPanel` default menu. + about, + + /// The system provided "Quit" menu item. + /// + /// On macOS, this is the `terminate` default menu. + /// + /// This menu item will simply exit the application when activated. + quit, + + /// The system provided "Services" submenu. + /// + /// This submenu provides a list of system provided application services. + /// + /// This default menu is only supported on macOS. + servicesSubmenu, + + /// The system provided "Hide" menu item. + /// + /// This menu item hides the application window. + /// + /// On macOS, this is the `hide` default menu. + /// + /// This default menu is only supported on macOS. + hide, + + /// The system provided "Hide Others" menu item. + /// + /// This menu item hides other application windows. + /// + /// On macOS, this is the `hideOtherApplications` default menu. + /// + /// This default menu is only supported on macOS. + hideOtherApplications, + + /// The system provided "Show All" menu item. + /// + /// This menu item shows all hidden application windows. + /// + /// On macOS, this is the `unhideAllApplications` default menu. + /// + /// This default menu is only supported on macOS. + showAllApplications, + + /// The system provided "Start Dictation..." menu item. + /// + /// This menu item tells the system to start the screen reader. + /// + /// On macOS, this is the `startSpeaking` default menu. + /// + /// This default menu is currently only supported on macOS. + startSpeaking, + + /// The system provided "Stop Dictation..." menu item. + /// + /// This menu item tells the system to stop the screen reader. + /// + /// On macOS, this is the `stopSpeaking` default menu. + /// + /// This default menu is currently only supported on macOS. + stopSpeaking, + + /// The system provided "Enter Full Screen" menu item. + /// + /// This menu item tells the system to toggle full screen mode for the window. + /// + /// On macOS, this is the `toggleFullScreen` default menu. + /// + /// This default menu is currently only supported on macOS. + toggleFullScreen, + + /// The system provided "Minimize" menu item. + /// + /// This menu item tells the system to minimize the window. + /// + /// On macOS, this is the `performMiniaturize` default menu. + /// + /// This default menu is currently only supported on macOS. + minimizeWindow, + + /// The system provided "Zoom" menu item. + /// + /// This menu item tells the system to expand the window size. + /// + /// On macOS, this is the `performZoom` default menu. + /// + /// This default menu is currently only supported on macOS. + zoomWindow, + + /// The system provided "Bring To Front" menu item. + /// + /// This menu item tells the system to stack the window above other windows. + /// + /// On macOS, this is the `arrangeInFront` default menu. + /// + /// This default menu is currently only supported on macOS. + arrangeWindowsInFront, +} diff --git a/packages/flutter/lib/src/widgets/shortcuts.dart b/packages/flutter/lib/src/widgets/shortcuts.dart index a40263c0205..df016e3a845 100644 --- a/packages/flutter/lib/src/widgets/shortcuts.dart +++ b/packages/flutter/lib/src/widgets/shortcuts.dart @@ -12,6 +12,7 @@ import 'focus_manager.dart'; import 'focus_scope.dart'; import 'framework.dart'; import 'inherited_notifier.dart'; +import 'platform_menu_bar.dart'; /// A set of [KeyboardKey]s that can be used as the keys in a [Map]. /// @@ -397,7 +398,7 @@ class ShortcutMapProperty extends DiagnosticsProperty activated = []; + final List opened = []; + final List closed = []; + + void onActivate(String item) { + activated.add(item); + } + + void onOpen(String item) { + opened.add(item); + } + + void onClose(String item) { + closed.add(item); + } + + setUp(() { + fakeMenuChannel = FakeMenuChannel((MethodCall call) async {}); + delegate = DefaultPlatformMenuDelegate(channel: fakeMenuChannel); + originalDelegate = WidgetsBinding.instance.platformMenuDelegate; + WidgetsBinding.instance.platformMenuDelegate = delegate; + activated.clear(); + opened.clear(); + closed.clear(); + }); + + tearDown(() { + WidgetsBinding.instance.platformMenuDelegate = originalDelegate; + }); + + group('PlatformMenuBar', () { + testWidgets('basic menu structure is transmitted to platform', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PlatformMenuBar( + body: const Center(child: Text('Body')), + menus: createTestMenus( + onActivate: onActivate, + onOpen: onOpen, + onClose: onClose, + shortcuts: { + subSubMenu10[0]: const SingleActivator(LogicalKeyboardKey.keyA, control: true), + subSubMenu10[1]: const SingleActivator(LogicalKeyboardKey.keyB, shift: true), + subSubMenu10[2]: const SingleActivator(LogicalKeyboardKey.keyC, alt: true), + subSubMenu10[3]: const SingleActivator(LogicalKeyboardKey.keyD, meta: true), + }, + ), + ), + ), + ), + ); + + expect(fakeMenuChannel.outgoingCalls.last.method, equals('Menu.setMenu')); + expect( + fakeMenuChannel.outgoingCalls.last.arguments, + equals( + { + '0': >[ + { + 'id': 2, + 'label': 'Menu 0', + 'enabled': true, + 'children': >[ + { + 'id': 1, + 'label': 'Sub Menu 00', + 'enabled': true, + } + ] + }, + { + 'id': 12, + 'label': 'Menu 1', + 'enabled': true, + 'children': >[ + { + 'id': 3, + 'label': 'Sub Menu 10', + 'enabled': true, + }, + { + 'id': 4, + 'isDivider': true, + }, + { + 'id': 10, + 'label': 'Sub Menu 11', + 'enabled': true, + 'children': >[ + { + 'id': 5, + 'label': 'Sub Sub Menu 100', + 'enabled': true, + 'shortcutTrigger': 97, + 'shortcutModifiers': 8 + }, + { + 'id': 6, + 'isDivider': true, + }, + { + 'id': 7, + 'label': 'Sub Sub Menu 101', + 'enabled': true, + 'shortcutTrigger': 98, + 'shortcutModifiers': 2 + }, + { + 'id': 8, + 'label': 'Sub Sub Menu 102', + 'enabled': true, + 'shortcutTrigger': 99, + 'shortcutModifiers': 4 + }, + { + 'id': 9, + 'label': 'Sub Sub Menu 103', + 'enabled': true, + 'shortcutTrigger': 100, + 'shortcutModifiers': 1 + } + ] + }, + { + 'id': 11, + 'label': 'Sub Menu 12', + 'enabled': true, + } + ] + }, + { + 'id': 14, + 'label': 'Menu 2', + 'enabled': true, + 'children': >[ + { + 'id': 13, + 'label': 'Sub Menu 20', + 'enabled': false, + } + ] + }, + { + 'id': 15, + 'label': 'Menu 3', + 'enabled': false, + 'children': >[], + }, + ], + }, + ), + ); + }); + testWidgets('asserts when more than one has locked the delegate', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: PlatformMenuBar( + body: PlatformMenuBar( + body: SizedBox(), + menus: [], + ), + menus: [], + ), + ), + ), + ); + expect(tester.takeException(), isA()); + }); + testWidgets('diagnostics', (WidgetTester tester) async { + const PlatformMenuItem item = PlatformMenuItem( + label: 'label2', + shortcut: SingleActivator(LogicalKeyboardKey.keyA), + ); + const PlatformMenuBar menuBar = PlatformMenuBar( + body: SizedBox(), + menus: [item], + ); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: menuBar, + ), + ), + ); + await tester.pump(); + + expect( + menuBar.toStringDeep(), + equalsIgnoringHashCodes( + 'PlatformMenuBar#00000\n' + ' └─PlatformMenuItem#00000\n' + ' label: "label2"\n' + ' shortcut: SingleActivator#00000(keys: Key A)\n' + ' DISABLED\n', + ), + ); + }); + }); + group('PlatformMenuBarItem', () { + testWidgets('diagnostics', (WidgetTester tester) async { + const PlatformMenuItem childItem = PlatformMenuItem( + label: 'label', + ); + const PlatformMenu item = PlatformMenu( + label: 'label', + menus: [childItem], + ); + + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + item.debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, [ + 'label: "label"', + ]); + }); + }); +} + +const List mainMenu = [ + 'Menu 0', + 'Menu 1', + 'Menu 2', + 'Menu 3', +]; + +const List subMenu0 = [ + 'Sub Menu 00', +]; + +const List subMenu1 = [ + 'Sub Menu 10', + 'Sub Menu 11', + 'Sub Menu 12', +]; + +const List subSubMenu10 = [ + 'Sub Sub Menu 100', + 'Sub Sub Menu 101', + 'Sub Sub Menu 102', + 'Sub Sub Menu 103', +]; + +const List subMenu2 = [ + 'Sub Menu 20', +]; + +List createTestMenus({ + void Function(String)? onActivate, + void Function(String)? onOpen, + void Function(String)? onClose, + Map shortcuts = const {}, + bool includeStandard = false, +}) { + final List result = [ + PlatformMenu( + label: mainMenu[0], + onOpen: onOpen != null ? () => onOpen(mainMenu[0]) : null, + onClose: onClose != null ? () => onClose(mainMenu[0]) : null, + menus: [ + PlatformMenuItem( + label: subMenu0[0], + onSelected: onActivate != null ? () => onActivate(subMenu0[0]) : null, + shortcut: shortcuts[subMenu0[0]], + ), + ], + ), + PlatformMenu( + label: mainMenu[1], + onOpen: onOpen != null ? () => onOpen(mainMenu[1]) : null, + onClose: onClose != null ? () => onClose(mainMenu[1]) : null, + menus: [ + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: subMenu1[0], + onSelected: onActivate != null ? () => onActivate(subMenu1[0]) : null, + shortcut: shortcuts[subMenu1[0]], + ), + ], + ), + PlatformMenu( + label: subMenu1[1], + onOpen: onOpen != null ? () => onOpen(subMenu1[1]) : null, + onClose: onClose != null ? () => onClose(subMenu1[1]) : null, + menus: [ + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: subSubMenu10[0], + onSelected: onActivate != null ? () => onActivate(subSubMenu10[0]) : null, + shortcut: shortcuts[subSubMenu10[0]], + ), + ], + ), + PlatformMenuItem( + label: subSubMenu10[1], + onSelected: onActivate != null ? () => onActivate(subSubMenu10[1]) : null, + shortcut: shortcuts[subSubMenu10[1]], + ), + PlatformMenuItem( + label: subSubMenu10[2], + onSelected: onActivate != null ? () => onActivate(subSubMenu10[2]) : null, + shortcut: shortcuts[subSubMenu10[2]], + ), + PlatformMenuItem( + label: subSubMenu10[3], + onSelected: onActivate != null ? () => onActivate(subSubMenu10[3]) : null, + shortcut: shortcuts[subSubMenu10[3]], + ), + ], + ), + PlatformMenuItem( + label: subMenu1[2], + onSelected: onActivate != null ? () => onActivate(subMenu1[2]) : null, + shortcut: shortcuts[subMenu1[2]], + ), + ], + ), + PlatformMenu( + label: mainMenu[2], + onOpen: onOpen != null ? () => onOpen(mainMenu[2]) : null, + onClose: onClose != null ? () => onClose(mainMenu[2]) : null, + menus: [ + PlatformMenuItem( + // Always disabled. + label: subMenu2[0], + shortcut: shortcuts[subMenu2[0]], + ), + ], + ), + // Disabled menu + PlatformMenu( + label: mainMenu[3], + onOpen: onOpen != null ? () => onOpen(mainMenu[2]) : null, + onClose: onClose != null ? () => onClose(mainMenu[2]) : null, + menus: [], + ), + ]; + return result; +} + +class FakeMenuChannel implements MethodChannel { + FakeMenuChannel(this.outgoing) : assert(outgoing != null); + + Future Function(MethodCall) outgoing; + Future Function(MethodCall)? incoming; + + List outgoingCalls = []; + + @override + BinaryMessenger get binaryMessenger => throw UnimplementedError(); + + @override + MethodCodec get codec => const StandardMethodCodec(); + + @override + Future> invokeListMethod(String method, [dynamic arguments]) => throw UnimplementedError(); + + @override + Future> invokeMapMethod(String method, [dynamic arguments]) => throw UnimplementedError(); + + @override + Future invokeMethod(String method, [dynamic arguments]) async { + final MethodCall call = MethodCall(method, arguments); + outgoingCalls.add(call); + return await outgoing(call) as T; + } + + @override + String get name => 'flutter/menu'; + + @override + void setMethodCallHandler(Future Function(MethodCall call)? handler) => incoming = handler; +}