diff --git a/lib/home/home_menu.dart b/lib/home/home_menu.dart index b09ed708..4fa86b92 100644 --- a/lib/home/home_menu.dart +++ b/lib/home/home_menu.dart @@ -1,3 +1,4 @@ +import 'package:context_menu/context_menu.dart'; import 'package:flutter/material.dart'; import 'package:terminal_view/terminal_view.dart'; @@ -9,27 +10,27 @@ List buildContextMenu({ required VoidCallback? onCloseTab, }) { return [ - PopupMenuItem( + ContextMenuItem( onTap: onNewTab, child: const Text('New Tab'), ), - PopupMenuItem( + ContextMenuItem( onTap: onCloseTab, enabled: tabCount > 1, child: const Text('Close Tab'), ), const PopupMenuDivider(), - PopupMenuItem( + ContextMenuItem( onTap: current?.copy, enabled: current?.selectedText?.isNotEmpty == true, child: const Text('Copy'), ), - PopupMenuItem( + ContextMenuItem( onTap: current?.paste, enabled: current != null, child: const Text('Paste'), ), - PopupMenuItem( + ContextMenuItem( onTap: current?.selectAll, enabled: current != null, child: const Text('Select All'), diff --git a/lib/home/home_page.dart b/lib/home/home_page.dart index 9f915802..717c7fbd 100644 --- a/lib/home/home_page.dart +++ b/lib/home/home_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:context_menu/context_menu.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lxd_service/lxd_service.dart'; @@ -10,7 +11,6 @@ import 'package:ubuntu_service/ubuntu_service.dart'; import '../instances/instance_view.dart'; import '../launcher/launcher_wizard.dart'; import '../terminal/terminal_page.dart'; -import '../widgets/context_menu.dart'; import 'home_menu.dart'; import 'home_model.dart'; diff --git a/lib/widgets/context_menu.dart b/lib/widgets/context_menu.dart deleted file mode 100644 index c58c001f..00000000 --- a/lib/widgets/context_menu.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter/material.dart'; - -class ContextMenuArea extends StatelessWidget { - const ContextMenuArea({ - super.key, - this.builder, - this.child, - }); - - final Widget? child; - final List Function(BuildContext context, Offset position)? - builder; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onSecondaryTapDown: (details) { - final items = builder?.call(context, details.globalPosition); - if (items != null) { - showContextMenu( - context: context, - position: details.globalPosition, - items: items, - ); - } - }, - child: child, - ); - } -} - -Future showContextMenu({ - required BuildContext context, - required Offset position, - required List items, -}) { - return showMenu( - context: context, - position: RelativeRect.fromSize( - position & Size.zero, - MediaQuery.of(context).size, - ), - items: items, - ); -} diff --git a/packages/context_menu/.gitignore b/packages/context_menu/.gitignore new file mode 100644 index 00000000..96486fd9 --- /dev/null +++ b/packages/context_menu/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/packages/context_menu/.metadata b/packages/context_menu/.metadata new file mode 100644 index 00000000..7b724eb7 --- /dev/null +++ b/packages/context_menu/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 + channel: stable + +project_type: package diff --git a/packages/context_menu/LICENSE b/packages/context_menu/LICENSE new file mode 100644 index 00000000..aea51a33 --- /dev/null +++ b/packages/context_menu/LICENSE @@ -0,0 +1,25 @@ +Copyright 2014 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/context_menu/lib/context_menu.dart b/packages/context_menu/lib/context_menu.dart new file mode 100644 index 00000000..ee8490a2 --- /dev/null +++ b/packages/context_menu/lib/context_menu.dart @@ -0,0 +1,3 @@ +library context_menu; + +export 'src/context_menu.dart'; diff --git a/packages/context_menu/lib/src/context_menu.dart b/packages/context_menu/lib/src/context_menu.dart new file mode 100644 index 00000000..63bc2df6 --- /dev/null +++ b/packages/context_menu/lib/src/context_menu.dart @@ -0,0 +1,482 @@ +// Based on flutter/material/popup_menu.dart +// +// 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. + +// ignore_for_file: unnecessary_null_comparison, curly_braces_in_flow_control_structures, avoid_types_on_closure_parameters, omit_local_variable_types, use_super_parameters + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +// Examples can assume: +// enum Commands { heroAndScholar, hurricaneCame } +// late bool _heroAndScholar; +// late dynamic _selection; +// late BuildContext context; +// void setState(VoidCallback fn) { } +// enum Menu { itemOne, itemTwo, itemThree, itemFour } + +const Duration _kMenuDuration = Duration.zero; +const double _kMenuMaxWidth = 5.0 * _kMenuWidthStep; +const double _kMenuMinWidth = 2.0 * _kMenuWidthStep; +const double _kMenuVerticalPadding = 8.0; +const double _kMenuWidthStep = 56.0; +const double _kMenuScreenPadding = 8.0; +const double _kMenuItemHeight = 36.0; + +// This widget only exists to enable _PopupMenuRoute to save the sizes of +// each menu item. The sizes are used by _PopupMenuRouteLayout to compute the +// y coordinate of the menu's origin so that the center of selected menu +// item lines up with the center of its PopupMenuButton. +class _ContextMenuItem extends SingleChildRenderObjectWidget { + const _ContextMenuItem({ + Key? key, + required this.onLayout, + required Widget? child, + }) : assert(onLayout != null), + super(key: key, child: child); + + final ValueChanged onLayout; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderContextMenuItem(onLayout); + } + + @override + void updateRenderObject( + BuildContext context, covariant _RenderContextMenuItem renderObject) { + renderObject.onLayout = onLayout; + } +} + +class _RenderContextMenuItem extends RenderShiftedBox { + _RenderContextMenuItem(this.onLayout, [RenderBox? child]) + : assert(onLayout != null), + super(child); + + ValueChanged onLayout; + + @override + Size computeDryLayout(BoxConstraints constraints) { + if (child == null) { + return Size.zero; + } + return child!.getDryLayout(constraints); + } + + @override + void performLayout() { + if (child == null) { + size = Size.zero; + } else { + child!.layout(constraints, parentUsesSize: true); + size = constraints.constrain(child!.size); + final BoxParentData childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Offset.zero; + } + onLayout(size); + } +} + +class _ContextMenu extends StatelessWidget { + const _ContextMenu({ + Key? key, + required this.route, + required this.semanticLabel, + this.constraints, + }) : super(key: key); + + final _ContextMenuRoute route; + final String? semanticLabel; + final BoxConstraints? constraints; + + @override + Widget build(BuildContext context) { + final double unit = 1.0 / + (route.items.length + + 1.5); // 1.0 for the width and 0.5 for the last item's fade. + final List children = []; + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + + for (int i = 0; i < route.items.length; i += 1) { + final double start = (i + 1) * unit; + final double end = (start + 1.5 * unit).clamp(0.0, 1.0); + final CurvedAnimation opacity = CurvedAnimation( + parent: route.animation!, + curve: Interval(start, end), + ); + Widget item = route.items[i]; + if (route.initialValue != null && + route.items[i].represents(route.initialValue)) { + item = Container( + color: Theme.of(context).highlightColor, + child: item, + ); + } + children.add( + _ContextMenuItem( + onLayout: (Size size) { + route.itemSizes[i] = size; + }, + child: FadeTransition( + opacity: opacity, + child: item, + ), + ), + ); + } + + final CurveTween opacity = + CurveTween(curve: const Interval(0.0, 1.0 / 3.0)); + final CurveTween width = CurveTween(curve: Interval(0.0, unit)); + final CurveTween height = + CurveTween(curve: Interval(0.0, unit * route.items.length)); + + final Widget child = ConstrainedBox( + constraints: constraints ?? + const BoxConstraints( + minWidth: _kMenuMinWidth, + maxWidth: _kMenuMaxWidth, + ), + child: IntrinsicWidth( + stepWidth: _kMenuWidthStep, + child: Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: semanticLabel, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + vertical: _kMenuVerticalPadding, + ), + child: ListBody(children: children), + ), + ), + ), + ); + + return AnimatedBuilder( + animation: route.animation!, + builder: (BuildContext context, Widget? child) { + return FadeTransition( + opacity: opacity.animate(route.animation!), + child: Material( + shape: route.shape ?? popupMenuTheme.shape, + color: route.color ?? popupMenuTheme.color, + type: MaterialType.card, + elevation: route.elevation ?? popupMenuTheme.elevation ?? 8.0, + child: Align( + alignment: AlignmentDirectional.topEnd, + widthFactor: width.evaluate(route.animation!), + heightFactor: height.evaluate(route.animation!), + child: child, + ), + ), + ); + }, + child: child, + ); + } +} + +// Positioning of the menu on the screen. +class _ContextMenuRouteLayout extends SingleChildLayoutDelegate { + _ContextMenuRouteLayout( + this.position, + this.itemSizes, + this.selectedItemIndex, + this.textDirection, + this.padding, + this.avoidBounds, + ); + + // Rectangle of underlying button, relative to the overlay's dimensions. + final RelativeRect position; + + // The sizes of each item are computed when the menu is laid out, and before + // the route is laid out. + List itemSizes; + + // The index of the selected item, or null if PopupMenuButton.initialValue + // was not specified. + final int? selectedItemIndex; + + // Whether to prefer going to the left or to the right. + final TextDirection textDirection; + + // The padding of unsafe area. + EdgeInsets padding; + + // List of rectangles that we should avoid overlapping. Unusable screen area. + final Set avoidBounds; + + // We put the child wherever position specifies, so long as it will fit within + // the specified parent size padded (inset) by 8. If necessary, we adjust the + // child's position so that it fits. + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + // The menu can be at most the size of the overlay minus 8.0 pixels in each + // direction. + return BoxConstraints.loose(constraints.biggest).deflate( + const EdgeInsets.all(_kMenuScreenPadding) + padding, + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + // size: The size of the overlay. + // childSize: The size of the menu, when fully open, as determined by + // getConstraintsForChild. + + final double buttonHeight = size.height - position.top - position.bottom; + // Find the ideal vertical position. + double y = position.top; + if (selectedItemIndex != null && itemSizes != null) { + double selectedItemOffset = _kMenuVerticalPadding; + for (int index = 0; index < selectedItemIndex!; index += 1) + selectedItemOffset += itemSizes[index]!.height; + selectedItemOffset += itemSizes[selectedItemIndex!]!.height / 2; + y = y + buttonHeight / 2.0 - selectedItemOffset; + } + + // Find the ideal horizontal position. + double x; + assert(textDirection != null); + switch (textDirection) { + case TextDirection.rtl: + x = size.width - position.right - childSize.width; + break; + case TextDirection.ltr: + x = position.left; + break; + } + final Offset wantedPosition = Offset(x, y); + final Offset originCenter = position.toRect(Offset.zero & size).center; + final Iterable subScreens = + DisplayFeatureSubScreen.subScreensInBounds( + Offset.zero & size, avoidBounds); + final Rect subScreen = _closestScreen(subScreens, originCenter); + return _fitInsideScreen(subScreen, childSize, wantedPosition); + } + + Rect _closestScreen(Iterable screens, Offset point) { + Rect closest = screens.first; + for (final Rect screen in screens) { + if ((screen.center - point).distance < + (closest.center - point).distance) { + closest = screen; + } + } + return closest; + } + + Offset _fitInsideScreen(Rect screen, Size childSize, Offset wantedPosition) { + double x = wantedPosition.dx; + double y = wantedPosition.dy; + // Avoid going outside an area defined as the rectangle 8.0 pixels from the + // edge of the screen in every direction. + if (x < screen.left + _kMenuScreenPadding + padding.left) + x = screen.left + _kMenuScreenPadding + padding.left; + else if (x + childSize.width > + screen.right - _kMenuScreenPadding - padding.right) + x = screen.right - childSize.width - _kMenuScreenPadding - padding.right; + if (y < screen.top + _kMenuScreenPadding + padding.top) + y = _kMenuScreenPadding + padding.top; + else if (y + childSize.height > + screen.bottom - _kMenuScreenPadding - padding.bottom) + y = screen.bottom - + childSize.height - + _kMenuScreenPadding - + padding.bottom; + + return Offset(x, y); + } + + @override + bool shouldRelayout(_ContextMenuRouteLayout oldDelegate) { + // If called when the old and new itemSizes have been initialized then + // we expect them to have the same length because there's no practical + // way to change length of the items list once the menu has been shown. + assert(itemSizes.length == oldDelegate.itemSizes.length); + + return position != oldDelegate.position || + selectedItemIndex != oldDelegate.selectedItemIndex || + textDirection != oldDelegate.textDirection || + !listEquals(itemSizes, oldDelegate.itemSizes) || + padding != oldDelegate.padding || + !setEquals(avoidBounds, oldDelegate.avoidBounds); + } +} + +class _ContextMenuRoute extends PopupRoute { + _ContextMenuRoute({ + required this.position, + required this.items, + this.initialValue, + this.elevation, + required this.barrierLabel, + this.semanticLabel, + this.shape, + this.color, + required this.capturedThemes, + this.constraints, + }) : itemSizes = List.filled(items.length, null); + + final RelativeRect position; + final List> items; + final List itemSizes; + final T? initialValue; + final double? elevation; + final String? semanticLabel; + final ShapeBorder? shape; + final Color? color; + final CapturedThemes capturedThemes; + final BoxConstraints? constraints; + + @override + Duration get transitionDuration => _kMenuDuration; + + @override + bool get barrierDismissible => true; + + @override + Color? get barrierColor => null; + + @override + final String barrierLabel; + + @override + Widget buildPage(BuildContext context, Animation animation, + Animation secondaryAnimation) { + int? selectedItemIndex; + if (initialValue != null) { + for (int index = 0; + selectedItemIndex == null && index < items.length; + index += 1) { + if (items[index].represents(initialValue)) selectedItemIndex = index; + } + } + + final Widget menu = _ContextMenu( + route: this, + semanticLabel: semanticLabel, + constraints: constraints, + ); + final MediaQueryData mediaQuery = MediaQuery.of(context); + return MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + removeLeft: true, + removeRight: true, + child: Builder( + builder: (BuildContext context) { + return CustomSingleChildLayout( + delegate: _ContextMenuRouteLayout( + position, + itemSizes, + selectedItemIndex, + Directionality.of(context), + mediaQuery.padding, + _avoidBounds(mediaQuery), + ), + child: capturedThemes.wrap(menu), + ); + }, + ), + ); + } + + Set _avoidBounds(MediaQueryData mediaQuery) { + return DisplayFeatureSubScreen.avoidBounds(mediaQuery).toSet(); + } +} + +class ContextMenuItem extends PopupMenuItem { + const ContextMenuItem({ + super.key, + super.value, + super.onTap, + super.enabled, + required super.child, + }) : super(height: _kMenuItemHeight); +} + +class ContextMenuArea extends StatelessWidget { + const ContextMenuArea({ + super.key, + this.builder, + this.child, + }); + + final Widget? child; + final List Function(BuildContext context, Offset position)? + builder; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onSecondaryTapDown: (details) { + final items = builder?.call(context, details.globalPosition); + if (items != null) { + showContextMenu( + context: context, + position: details.globalPosition, + items: items, + ); + } + }, + child: child, + ); + } +} + +Future showContextMenu({ + required BuildContext context, + required Offset position, + required List> items, + T? initialValue, + double? elevation, + String? semanticLabel, + ShapeBorder? shape, + Color? color, + bool useRootNavigator = false, + BoxConstraints? constraints, +}) { + assert(items.isNotEmpty); + assert(debugCheckHasMaterialLocalizations(context)); + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + semanticLabel ??= MaterialLocalizations.of(context).popupMenuLabel; + } + + final NavigatorState navigator = + Navigator.of(context, rootNavigator: useRootNavigator); + return navigator.push(_ContextMenuRoute( + position: RelativeRect.fromSize( + position & Size.zero, + MediaQuery.of(context).size, + ), + items: items, + initialValue: initialValue, + elevation: elevation, + semanticLabel: semanticLabel, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + shape: shape, + color: color, + capturedThemes: + InheritedTheme.capture(from: context, to: navigator.context), + constraints: constraints, + )); +} diff --git a/packages/context_menu/pubspec.yaml b/packages/context_menu/pubspec.yaml new file mode 100644 index 00000000..0564ccbd --- /dev/null +++ b/packages/context_menu/pubspec.yaml @@ -0,0 +1,16 @@ +name: context_menu +description: Context menu. +publish_to: 'none' + +environment: + sdk: ">=2.17.0 <3.0.0" + flutter: ">=3.0.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_lints: ^2.0.0 + flutter_test: + sdk: flutter diff --git a/packages/context_menu/test/context_menu_test.dart b/packages/context_menu/test/context_menu_test.dart new file mode 100644 index 00000000..096c2278 --- /dev/null +++ b/packages/context_menu/test/context_menu_test.dart @@ -0,0 +1,159 @@ +import 'package:context_menu/context_menu.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const kWindowSize = Size(600, 400); + +void main() { + testWidgets('items', (tester) async { + await tester.pumpContextMenu(); + + expect(find.text('Foo'), findsNothing); + expect(find.text('Bar'), findsNothing); + expect(find.text('Baz'), findsNothing); + + await tester.rightClick(); + await tester.pumpAndSettle(); + + expect(find.text('Foo'), findsOneWidget); + expect(find.text('Bar'), findsOneWidget); + expect(find.text('Baz'), findsOneWidget); + }); + + testWidgets('center', (tester) async { + await tester.pumpContextMenu(); + + await tester.rightClick(kWindowSize.center(Offset.zero)); + await tester.pumpAndSettle(); + + final foo = tester.getRect(find.text('Foo')); + expect(foo.top, greaterThan(kWindowSize.height / 2)); + expect(foo.left, greaterThan(kWindowSize.width / 2)); + expect(foo.right, lessThan(kWindowSize.height)); + expect(foo.bottom, lessThan(kWindowSize.width)); + + final bar = tester.getRect(find.text('Bar')); + expect(bar.left, equals(foo.left)); + expect(bar.top, greaterThan(foo.bottom)); + expect(bar.right, equals(foo.right)); + + final baz = tester.getRect(find.text('Baz')); + expect(baz.left, equals(bar.left)); + expect(baz.top, greaterThan(bar.bottom)); + expect(baz.right, equals(bar.right)); + }); + + testWidgets('top-left', (tester) async { + await tester.pumpContextMenu(); + + await tester.rightClick(kWindowSize.topLeft(Offset.zero)); + await tester.pumpAndSettle(); + + final foo = tester.getRect(find.text('Foo')); + expect(foo.left, greaterThan(0)); + expect(foo.top, greaterThan(0)); + expect(foo.right, lessThan(kWindowSize.width / 2)); + expect(foo.bottom, lessThan(kWindowSize.height / 2)); + + final bar = tester.getRect(find.text('Bar')); + expect(bar.left, equals(foo.left)); + expect(bar.top, greaterThan(foo.bottom)); + expect(bar.right, equals(foo.right)); + + final baz = tester.getRect(find.text('Baz')); + expect(baz.left, equals(bar.left)); + expect(baz.top, greaterThan(bar.bottom)); + expect(baz.right, equals(bar.right)); + }); + + testWidgets('top-right', (tester) async { + await tester.pumpContextMenu(); + + await tester.rightClick(kWindowSize.topRight(const Offset(-1, 0))); + await tester.pumpAndSettle(); + + final foo = tester.getRect(find.text('Foo')); + expect(foo.left, greaterThan(kWindowSize.width / 2)); + expect(foo.top, greaterThan(0)); + expect(foo.right, lessThan(kWindowSize.width)); + expect(foo.bottom, lessThan(kWindowSize.height / 2)); + + final bar = tester.getRect(find.text('Bar')); + expect(bar.left, equals(foo.left)); + expect(bar.top, greaterThan(foo.bottom)); + expect(bar.right, equals(foo.right)); + + final baz = tester.getRect(find.text('Baz')); + expect(baz.left, equals(bar.left)); + expect(baz.top, greaterThan(bar.bottom)); + expect(baz.right, equals(bar.right)); + }); + + testWidgets('bottom-left', (tester) async { + await tester.pumpContextMenu(); + + await tester.rightClick(kWindowSize.bottomLeft(const Offset(0, -1))); + await tester.pumpAndSettle(); + + final foo = tester.getRect(find.text('Foo')); + expect(foo.left, greaterThan(0)); + expect(foo.top, greaterThan(kWindowSize.height / 2)); + expect(foo.right, lessThan(kWindowSize.width / 2)); + expect(foo.bottom, lessThan(kWindowSize.height)); + + final bar = tester.getRect(find.text('Bar')); + expect(bar.left, equals(foo.left)); + expect(bar.top, greaterThan(foo.bottom)); + expect(bar.right, equals(foo.right)); + + final baz = tester.getRect(find.text('Baz')); + expect(baz.left, equals(bar.left)); + expect(baz.top, greaterThan(bar.bottom)); + expect(baz.right, equals(bar.right)); + }); + + testWidgets('bottom-right', (tester) async { + await tester.pumpContextMenu(); + + await tester.rightClick(kWindowSize.bottomRight(const Offset(-1, -1))); + await tester.pumpAndSettle(); + + final foo = tester.getRect(find.text('Foo')); + expect(foo.left, greaterThan(kWindowSize.width / 2)); + expect(foo.top, greaterThan(kWindowSize.height / 2)); + expect(foo.right, lessThan(kWindowSize.width)); + expect(foo.bottom, lessThan(kWindowSize.height)); + + final bar = tester.getRect(find.text('Bar')); + expect(bar.left, equals(foo.left)); + expect(bar.top, greaterThan(foo.bottom)); + expect(bar.right, equals(foo.right)); + + final baz = tester.getRect(find.text('Baz')); + expect(baz.left, equals(bar.left)); + expect(baz.top, greaterThan(bar.bottom)); + expect(baz.right, equals(bar.right)); + }); +} + +extension ContextMenuTester on WidgetTester { + Future pumpContextMenu() { + binding.window.physicalSizeTestValue = kWindowSize; + binding.window.devicePixelRatioTestValue = 1.0; + return pumpWidget(MaterialApp( + home: Scaffold( + body: ContextMenuArea( + builder: (context, position) => ['Foo', 'Bar', 'Baz'] + .map((item) => ContextMenuItem(child: Text(item))) + .toList(), + ), + ), + )); + } + + Future rightClick([Offset? position]) async { + position ??= kWindowSize.center(Offset.zero); + return tapAt(position, buttons: kSecondaryMouseButton); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 1a4aff39..b3ebdebe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,8 @@ dependencies: async_value: path: packages/async_value collection: ^1.15.0 + context_menu: + path: packages/context_menu data_size: ^0.2.0 flutter: sdk: flutter