Unverified Commit 24e195d9 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Mac context menu (#73882)

A very minimal right-click menu for Mac desktop.
parent c66596e5
......@@ -32,6 +32,7 @@ export 'src/cupertino/constants.dart';
export 'src/cupertino/context_menu.dart';
export 'src/cupertino/context_menu_action.dart';
export 'src/cupertino/date_picker.dart';
export 'src/cupertino/desktop_text_selection.dart';
export 'src/cupertino/dialog.dart';
export 'src/cupertino/form_row.dart';
export 'src/cupertino/form_section.dart';
......
This diff is collapsed.
......@@ -4,12 +4,14 @@
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle;
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
import 'desktop_text_selection.dart';
import 'icons.dart';
import 'text_selection.dart';
import 'theme.dart';
......@@ -1064,7 +1066,22 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
super.build(context); // See AutomaticKeepAliveClientMixin.
assert(debugCheckHasDirectionality(context));
final TextEditingController controller = _effectiveController;
final TextSelectionControls textSelectionControls = widget.selectionControls ?? cupertinoTextSelectionControls;
TextSelectionControls? textSelectionControls = widget.selectionControls;
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
textSelectionControls ??= cupertinoTextSelectionControls;
break;
case TargetPlatform.macOS:
textSelectionControls ??= cupertinoDesktopTextSelectionControls;
break;
}
final bool enabled = widget.enabled ?? true;
final Offset cursorOffset = Offset(_iOSHorizontalCursorOffsetPixels / MediaQuery.of(context).devicePixelRatio, 0);
final List<TextInputFormatter> formatters = <TextInputFormatter>[
......
......@@ -52,7 +52,7 @@ class _CupertinoTextSelectionControlsToolbar extends StatefulWidget {
}
class _CupertinoTextSelectionControlsToolbarState extends State<_CupertinoTextSelectionControlsToolbar> {
late ClipboardStatusNotifier _clipboardStatus;
ClipboardStatusNotifier? _clipboardStatus;
void _onChangedClipboardStatus() {
setState(() {
......@@ -63,31 +63,26 @@ class _CupertinoTextSelectionControlsToolbarState extends State<_CupertinoTextSe
@override
void initState() {
super.initState();
_clipboardStatus = widget.clipboardStatus ?? ClipboardStatusNotifier();
_clipboardStatus.addListener(_onChangedClipboardStatus);
_clipboardStatus.update();
if (widget.handlePaste != null) {
_clipboardStatus = widget.clipboardStatus ?? ClipboardStatusNotifier();
_clipboardStatus!.addListener(_onChangedClipboardStatus);
_clipboardStatus!.update();
}
}
@override
void didUpdateWidget(_CupertinoTextSelectionControlsToolbar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.clipboardStatus == null && widget.clipboardStatus != null) {
_clipboardStatus.removeListener(_onChangedClipboardStatus);
_clipboardStatus.dispose();
_clipboardStatus = widget.clipboardStatus!;
} else if (oldWidget.clipboardStatus != null) {
if (widget.clipboardStatus == null) {
_clipboardStatus = ClipboardStatusNotifier();
_clipboardStatus.addListener(_onChangedClipboardStatus);
oldWidget.clipboardStatus!.removeListener(_onChangedClipboardStatus);
} else if (widget.clipboardStatus != oldWidget.clipboardStatus) {
_clipboardStatus = widget.clipboardStatus!;
_clipboardStatus.addListener(_onChangedClipboardStatus);
oldWidget.clipboardStatus!.removeListener(_onChangedClipboardStatus);
if (oldWidget.clipboardStatus != widget.clipboardStatus) {
if (_clipboardStatus != null) {
_clipboardStatus!.removeListener(_onChangedClipboardStatus);
_clipboardStatus!.dispose();
}
_clipboardStatus = widget.clipboardStatus ?? ClipboardStatusNotifier();
_clipboardStatus!.addListener(_onChangedClipboardStatus);
if (widget.handlePaste != null) {
_clipboardStatus!.update();
}
}
if (widget.handlePaste != null) {
_clipboardStatus.update();
}
}
......@@ -96,10 +91,10 @@ class _CupertinoTextSelectionControlsToolbarState extends State<_CupertinoTextSe
super.dispose();
// When used in an Overlay, this can be disposed after its creator has
// already disposed _clipboardStatus.
if (!_clipboardStatus.disposed) {
_clipboardStatus.removeListener(_onChangedClipboardStatus);
if (_clipboardStatus != null && !_clipboardStatus!.disposed) {
_clipboardStatus!.removeListener(_onChangedClipboardStatus);
if (widget.clipboardStatus == null) {
_clipboardStatus.dispose();
_clipboardStatus!.dispose();
}
}
}
......@@ -108,7 +103,7 @@ class _CupertinoTextSelectionControlsToolbarState extends State<_CupertinoTextSe
Widget build(BuildContext context) {
// Don't render the menu until the state of the clipboard is known.
if (widget.handlePaste != null
&& _clipboardStatus.value == ClipboardStatus.unknown) {
&& _clipboardStatus!.value == ClipboardStatus.unknown) {
return const SizedBox(width: 0.0, height: 0.0);
}
......@@ -162,7 +157,7 @@ class _CupertinoTextSelectionControlsToolbarState extends State<_CupertinoTextSe
addToolbarButton(localizations.copyButtonLabel, widget.handleCopy!);
}
if (widget.handlePaste != null
&& _clipboardStatus.value == ClipboardStatus.pasteable) {
&& _clipboardStatus!.value == ClipboardStatus.pasteable) {
addToolbarButton(localizations.pasteButtonLabel, widget.handlePaste!);
}
if (widget.handleSelectAll != null) {
......@@ -235,6 +230,7 @@ class CupertinoTextSelectionControls extends TextSelectionControls {
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ClipboardStatusNotifier clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
return _CupertinoTextSelectionControlsToolbar(
clipboardStatus: clipboardStatus,
......
......@@ -45,12 +45,16 @@ class CupertinoTextSelectionToolbarButton extends StatelessWidget {
),
super(key: key);
/// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.child}
/// The child of this button.
///
/// Usually a [Text] or an [Icon].
/// {@endtemplate}
final Widget child;
/// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
/// Called when this button is pressed.
/// {@endtemplate}
final VoidCallback? onPressed;
@override
......
......@@ -581,7 +581,6 @@ class _SelectableTextState extends State<SelectableText> with AutomaticKeepAlive
switch (theme.platform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
forcePressEnabled = true;
textSelectionControls ??= cupertinoTextSelectionControls;
......@@ -593,6 +592,18 @@ class _SelectableTextState extends State<SelectableText> with AutomaticKeepAlive
cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
break;
case TargetPlatform.macOS:
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
forcePressEnabled = false;
textSelectionControls ??= cupertinoDesktopTextSelectionControls;
paintCursorAboveText = true;
cursorOpacityAnimates = true;
cursorColor ??= selectionTheme.cursorColor ?? cupertinoTheme.primaryColor;
selectionColor = selectionTheme.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40);
cursorRadius ??= const Radius.circular(2.0);
cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
......
......@@ -1143,7 +1143,6 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
switch (theme.platform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
forcePressEnabled = true;
textSelectionControls ??= cupertinoTextSelectionControls;
......@@ -1156,6 +1155,18 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
autocorrectionTextRectColor = selectionColor;
break;
case TargetPlatform.macOS:
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
forcePressEnabled = false;
textSelectionControls ??= cupertinoDesktopTextSelectionControls;
paintCursorAboveText = true;
cursorOpacityAnimates = true;
cursorColor ??= selectionTheme.cursorColor ?? cupertinoTheme.primaryColor;
selectionColor = selectionTheme.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40);
cursorRadius ??= const Radius.circular(2.0);
cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
......
......@@ -38,6 +38,7 @@ class MaterialTextSelectionControls extends TextSelectionControls {
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ClipboardStatusNotifier clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
return _TextSelectionControlsToolbar(
globalEditableRegion: globalEditableRegion,
......
......@@ -19,16 +19,6 @@ import 'material_localizations.dart';
const double _kToolbarScreenPadding = 8.0;
const double _kToolbarHeight = 44.0;
/// The type for a Function that builds a toolbar's container with the given
/// child.
///
/// See also:
///
/// * [TextSelectionToolbar.toolbarBuilder], which is of this type.
/// * [CupertinoTextSelectionToolbar.toolbarBuilder], which is similar, but
/// for a Cupertino-style toolbar.
typedef ToolbarBuilder = Widget Function(BuildContext context, Widget child);
/// A fully-functional Material-style text selection toolbar.
///
/// Tries to position itself above [anchorAbove], but if it doesn't fit, then
......
......@@ -1783,6 +1783,20 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
}
Offset? _lastTapDownPosition;
Offset? _lastSecondaryTapDownPosition;
/// The position of the most recent secondary tap down event on this text
/// input.
Offset? get lastSecondaryTapDownPosition => _lastSecondaryTapDownPosition;
/// Tracks the position of a secondary tap event.
///
/// Should be called before attempting to change the selection based on the
/// position of a secondary tap.
void handleSecondaryTapDown(TapDownDetails details) {
_lastTapDownPosition = details.globalPosition;
_lastSecondaryTapDownPosition = details.globalPosition;
}
/// If [ignorePointer] is false (the default) then this method is called by
/// the internal gesture recognizer's [TapGestureRecognizer.onTapDown]
......
......@@ -78,6 +78,17 @@ enum _TextSelectionHandlePosition { start, end }
/// having to store the start position.
typedef DragSelectionUpdateCallback = void Function(DragStartDetails startDetails, DragUpdateDetails updateDetails);
/// The type for a Function that builds a toolbar's container with the given
/// child.
///
/// See also:
///
/// * [TextSelectionToolbar.toolbarBuilder], which is of this type.
/// type.
/// * [CupertinoTextSelectionToolbar.toolbarBuilder], which is similar, but
/// for a Cupertino-style toolbar.
typedef ToolbarBuilder = Widget Function(BuildContext context, Widget child);
/// ParentData that determines whether or not to paint the corresponding child.
///
/// Used in the layout of the Cupertino and Material text selection menus, which
......@@ -131,6 +142,7 @@ abstract class TextSelectionControls {
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ClipboardStatusNotifier clipboardStatus,
Offset? lastSecondaryTapDownPosition,
);
/// Returns the size of the selection handle.
......@@ -585,6 +597,7 @@ class TextSelectionOverlay {
endpoints,
selectionDelegate!,
clipboardStatus!,
renderObject.lastSecondaryTapDownPosition,
);
},
),
......@@ -894,6 +907,21 @@ class TextSelectionGestureDetectorBuilder {
@protected
final TextSelectionGestureDetectorBuilderDelegate delegate;
/// Returns true iff lastSecondaryTapDownPosition was on selection.
bool get _lastSecondaryTapWasOnSelection {
assert(renderEditable.lastSecondaryTapDownPosition != null);
if (renderEditable.selection == null) {
return false;
}
final TextPosition textPosition = renderEditable.getPositionForPoint(
renderEditable.lastSecondaryTapDownPosition!,
);
return renderEditable.selection!.base.offset <= textPosition.offset
&& renderEditable.selection!.extent.offset >= textPosition.offset;
}
/// Whether to show the selection toolbar.
///
/// It is based on the signal source when a [onTapDown] is called. This getter
......@@ -1056,6 +1084,35 @@ class TextSelectionGestureDetectorBuilder {
editableText.showToolbar();
}
/// Handler for [TextSelectionGestureDetector.onSecondaryTap].
///
/// By default, selects the word if possible and shows the toolbar.
@protected
void onSecondaryTap() {
if (delegate.selectionEnabled) {
if (!_lastSecondaryTapWasOnSelection) {
renderEditable.selectWord(cause: SelectionChangedCause.tap);
}
if (shouldShowSelectionToolbar) {
editableText.hideToolbar();
editableText.showToolbar();
}
}
}
/// Handler for [TextSelectionGestureDetector.onSecondaryTapDown].
///
/// See also:
///
/// * [TextSelectionGestureDetector.onSecondaryTapDown], which triggers this
/// callback.
/// * [onSecondaryTap], which is typically called after this.
@protected
void onSecondaryTapDown(TapDownDetails details) {
renderEditable.handleSecondaryTapDown(details);
_shouldShowSelectionToolbar = true;
}
/// Handler for [TextSelectionGestureDetector.onDoubleTapDown].
///
/// By default, it selects a word through [RenderEditable.selectWord] if
......@@ -1142,6 +1199,8 @@ class TextSelectionGestureDetectorBuilder {
onTapDown: onTapDown,
onForcePressStart: delegate.forcePressEnabled ? onForcePressStart : null,
onForcePressEnd: delegate.forcePressEnabled ? onForcePressEnd : null,
onSecondaryTap: onSecondaryTap,
onSecondaryTapDown: onSecondaryTapDown,
onSingleTapUp: onSingleTapUp,
onSingleTapCancel: onSingleTapCancel,
onSingleLongTapStart: onSingleLongTapStart,
......@@ -1179,6 +1238,8 @@ class TextSelectionGestureDetector extends StatefulWidget {
this.onTapDown,
this.onForcePressStart,
this.onForcePressEnd,
this.onSecondaryTap,
this.onSecondaryTapDown,
this.onSingleTapUp,
this.onSingleTapCancel,
this.onSingleLongTapStart,
......@@ -1206,6 +1267,12 @@ class TextSelectionGestureDetector extends StatefulWidget {
/// lifted off the screen.
final GestureForcePressEndCallback? onForcePressEnd;
/// Called for a tap event with the secondary mouse button.
final GestureTapCallback? onSecondaryTap;
/// Called for a tap down event with the secondary mouse button.
final GestureTapDownCallback? onSecondaryTapDown;
/// Called for each distinct tap except for every second tap of a double tap.
/// For example, if the detector was configured with [onTapDown] and
/// [onDoubleTapDown], three quick taps would be recognized as a single tap
......@@ -1419,6 +1486,8 @@ class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetec
() => _TransparentTapGestureRecognizer(debugOwner: this),
(_TransparentTapGestureRecognizer instance) {
instance
..onSecondaryTap = widget.onSecondaryTap
..onSecondaryTapDown = widget.onSecondaryTapDown
..onTapDown = _handleTapDown
..onTapUp = _handleTapUp
..onTapCancel = _handleTapCancel;
......
......@@ -9,7 +9,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind;
import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind, kSecondaryMouseButton;
import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart';
......@@ -40,13 +40,15 @@ class MockTextSelectionControls extends TextSelectionControls {
@override
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
double textLineHeight,
Offset position,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ClipboardStatusNotifier clipboardStatus) {
BuildContext context,
Rect globalEditableRegion,
double textLineHeight,
Offset position,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ClipboardStatusNotifier clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
throw UnimplementedError();
}
......@@ -209,6 +211,79 @@ void main() {
await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
});
testWidgets('can use the desktop cut/copy/paste buttons on Mac', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'blah1 blah2',
);
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: ConstrainedBox(
constraints: BoxConstraints.loose(const Size(400, 200)),
child: CupertinoTextField(controller: controller),
),
),
),
);
// Initially, the menu is not shown and there is no selection.
expect(find.byType(CupertinoButton), findsNothing);
expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1));
final Offset midBlah1 = textOffsetToPosition(tester, 2);
// Right clicking shows the menu.
final TestGesture gesture = await tester.startGesture(
midBlah1,
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Cut'), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
// Copy the first word.
await tester.tap(find.text('Copy'));
await tester.pumpAndSettle();
expect(controller.text, 'blah1 blah2');
expect(controller.selection, const TextSelection(baseOffset: 5, extentOffset: 5));
expect(find.byType(CupertinoButton), findsNothing);
// Paste it at the end.
await gesture.down(textOffsetToPosition(tester, controller.text.length));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 11, affinity: TextAffinity.upstream));
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsOneWidget);
await tester.tap(find.text('Paste'));
await tester.pumpAndSettle();
expect(controller.text, 'blah1 blah2blah1');
expect(controller.selection, const TextSelection(baseOffset: 16, extentOffset: 16));
// Cut the first word.
await gesture.down(midBlah1);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(find.text('Cut'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
await tester.tap(find.text('Cut'));
await tester.pumpAndSettle();
expect(controller.text, ' blah2blah1');
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 0));
expect(find.byType(CupertinoButton), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.macOS }), skip: kIsWeb);
testWidgets(
'takes available space horizontally and takes intrinsic space vertically no-strut',
(WidgetTester tester) async {
......@@ -1861,6 +1936,74 @@ void main() {
expect(controller.value.selection.extentOffset, 1);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.macOS }));
testWidgets('double clicking a space selects the space on Mac', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: ' blah blah',
);
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextField(
controller: controller,
),
),
),
);
expect(controller.value.selection, isNotNull);
expect(controller.value.selection.baseOffset, -1);
expect(controller.value.selection.extentOffset, -1);
// Put the cursor at the end of the field.
final TestGesture gesture = await tester.startGesture(
textOffsetToPosition(tester, 10),
pointer: 7,
kind: PointerDeviceKind.mouse,
);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
expect(controller.value.selection, isNotNull);
expect(controller.value.selection.baseOffset, 10);
expect(controller.value.selection.extentOffset, 10);
// Double tapping the second space selects it.
await tester.pump(const Duration(milliseconds: 500));
await gesture.down(textOffsetToPosition(tester, 5));
await tester.pump();
await gesture.up();
await tester.pump(const Duration(milliseconds: 50));
await gesture.down(textOffsetToPosition(tester, 5));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(controller.value.selection, isNotNull);
expect(controller.value.selection.baseOffset, 5);
expect(controller.value.selection.extentOffset, 6);
// Put the cursor at the end of the field.
await gesture.down(textOffsetToPosition(tester, 10));
await tester.pump();
await gesture.up();
expect(controller.value.selection, isNotNull);
expect(controller.value.selection.baseOffset, 10);
expect(controller.value.selection.extentOffset, 10);
// Double tapping the first space selects it.
await tester.pump(const Duration(milliseconds: 500));
await gesture.down(textOffsetToPosition(tester, 0));
await tester.pump();
await gesture.up();
await tester.pump(const Duration(milliseconds: 50));
await gesture.down(textOffsetToPosition(tester, 0));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(controller.value.selection, isNotNull);
expect(controller.value.selection.baseOffset, 0);
expect(controller.value.selection.extentOffset, 1);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.macOS }));
testWidgets(
'An obscured CupertinoTextField is not selectable when disabled',
(WidgetTester tester) async {
......
......@@ -9,7 +9,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../widgets/text.dart' show textOffsetToPosition;
import '../widgets/editable_text_utils.dart' show textOffsetToPosition;
class MockClipboard {
Object _clipboardData = <String, dynamic>{
......
......@@ -8,7 +8,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import '../widgets/text.dart' show textOffsetToPosition;
import '../widgets/editable_text_utils.dart' show textOffsetToPosition;
// These constants are copied from cupertino/text_selection_toolbar.dart.
const double _kArrowScreenPadding = 26.0;
......@@ -26,6 +26,7 @@ class _CustomCupertinoTextSelectionControls extends CupertinoTextSelectionContro
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ClipboardStatusNotifier clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
final MediaQueryData mediaQuery = MediaQuery.of(context);
final double anchorX = (selectionMidpoint.dx + globalEditableRegion.left).clamp(
......
......@@ -2,14 +2,118 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/rendering.dart';
import '../rendering/mock_canvas.dart';
import '../widgets/editable_text_utils.dart';
class MockClipboard {
Object _clipboardData = <String, dynamic>{
'text': null,
};
Future<dynamic> handleMethodCall(MethodCall methodCall) async {
switch (methodCall.method) {
case 'Clipboard.getData':
return _clipboardData;
case 'Clipboard.setData':
_clipboardData = methodCall.arguments as Object;
break;
}
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final MockClipboard mockClipboard = MockClipboard();
SystemChannels.platform.setMockMethodCallHandler(mockClipboard.handleMethodCall);
setUp(() async {
// Fill the clipboard so that the Paste option is available in the text
// selection menu.
await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
});
testWidgets('can use the desktop cut/copy/paste buttons on Mac', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'blah1 blah2',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextFormField(
controller: controller,
),
),
),
),
);
// Initially, the menu is not shown and there is no selection.
expect(find.byType(CupertinoButton), findsNothing);
expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1));
final Offset midBlah1 = textOffsetToPosition(tester, 2);
// Right clicking shows the menu.
final TestGesture gesture = await tester.startGesture(
midBlah1,
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Cut'), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
// Copy the first word.
await tester.tap(find.text('Copy'));
await tester.pumpAndSettle();
expect(controller.text, 'blah1 blah2');
expect(controller.selection, const TextSelection(baseOffset: 5, extentOffset: 5));
expect(find.byType(CupertinoButton), findsNothing);
// Paste it at the end.
await gesture.down(textOffsetToPosition(tester, controller.text.length));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 11, affinity: TextAffinity.upstream));
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsOneWidget);
await tester.tap(find.text('Paste'));
await tester.pumpAndSettle();
expect(controller.text, 'blah1 blah2blah1');
expect(controller.selection, const TextSelection(baseOffset: 16, extentOffset: 16));
// Cut the first word.
await gesture.down(midBlah1);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(find.text('Cut'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
await tester.tap(find.text('Cut'));
await tester.pumpAndSettle();
expect(controller.text, ' blah2blah1');
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 0));
expect(find.byType(CupertinoButton), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.macOS }), skip: kIsWeb);
testWidgets('Passes textAlign to underlying TextField', (WidgetTester tester) async {
const TextAlign alignment = TextAlign.center;
......
......@@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../widgets/text.dart' show findRenderEditable, globalize, textOffsetToPosition;
import '../widgets/editable_text_utils.dart' show findRenderEditable, globalize, textOffsetToPosition;
class MockClipboard {
Object _clipboardData = <String, dynamic>{
......
......@@ -8,7 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import '../widgets/text.dart' show textOffsetToPosition;
import '../widgets/editable_text_utils.dart' show textOffsetToPosition;
// A custom text selection menu that just displays a single custom button.
class _CustomMaterialTextSelectionControls extends MaterialTextSelectionControls {
......@@ -24,6 +24,7 @@ class _CustomMaterialTextSelectionControls extends MaterialTextSelectionControls
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ClipboardStatusNotifier clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
final TextSelectionPoint startTextSelectionPoint = endpoints[0];
final TextSelectionPoint endTextSelectionPoint = endpoints.length > 1
......
......@@ -6984,7 +6984,7 @@ class MockTextFormatter extends TextInputFormatter {
class MockTextSelectionControls extends Fake implements TextSelectionControls {
@override
Widget buildToolbar(BuildContext context, Rect globalEditableRegion, double textLineHeight, Offset position, List<TextSelectionPoint> endpoints, TextSelectionDelegate delegate, ClipboardStatusNotifier clipboardStatus) {
Widget buildToolbar(BuildContext context, Rect globalEditableRegion, double textLineHeight, Offset position, List<TextSelectionPoint> endpoints, TextSelectionDelegate delegate, ClipboardStatusNotifier clipboardStatus, Offset? lastSecondaryTapDownPosition) {
return Container();
}
......
......@@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
// Returns the first RenderEditable.
RenderEditable findRenderEditable(WidgetTester tester) {
final RenderObject root = tester.renderObject(find.byType(EditableText));
expect(root, isNotNull);
......@@ -23,3 +24,24 @@ RenderEditable findRenderEditable(WidgetTester tester) {
expect(renderEditable, isNotNull);
return renderEditable;
}
List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) {
return points.map<TextSelectionPoint>((TextSelectionPoint point) {
return TextSelectionPoint(
box.localToGlobal(point.point),
point.direction,
);
}).toList();
}
Offset textOffsetToPosition(WidgetTester tester, int offset) {
final RenderEditable renderEditable = findRenderEditable(tester);
final List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(
TextSelection.collapsed(offset: offset),
),
renderEditable,
);
expect(endpoints.length, 1);
return endpoints[0].point + const Offset(0.0, -2.0);
}
......@@ -11,8 +11,8 @@ import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import '../widgets/editable_text_utils.dart' show textOffsetToPosition;
import '../widgets/semantics_tester.dart';
import '../widgets/text.dart' show textOffsetToPosition;
class MockClipboard {
dynamic _clipboardData = <String, dynamic>{
......@@ -161,8 +161,11 @@ void main() {
}).toList();
}
setUp(() {
setUp(() async {
debugResetSemanticsIdCounter();
// Fill the clipboard so that the Paste option is available in the text
// selection menu.
await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
});
Widget selectableTextBuilder({
......@@ -180,6 +183,80 @@ void main() {
);
}
testWidgets('can use the desktop cut/copy/paste buttons on Mac', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'blah1 blah2',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextFormField(
controller: controller,
),
),
),
),
);
// Initially, the menu is not shown and there is no selection.
expect(find.byType(CupertinoButton), findsNothing);
expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1));
final Offset midBlah1 = textOffsetToPosition(tester, 2);
// Right clicking shows the menu.
final TestGesture gesture = await tester.startGesture(
midBlah1,
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Cut'), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
// Copy the first word.
await tester.tap(find.text('Copy'));
await tester.pumpAndSettle();
expect(controller.text, 'blah1 blah2');
expect(controller.selection, const TextSelection(baseOffset: 5, extentOffset: 5));
expect(find.byType(CupertinoButton), findsNothing);
// Paste it at the end.
await gesture.down(textOffsetToPosition(tester, controller.text.length));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 11, affinity: TextAffinity.upstream));
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsOneWidget);
await tester.tap(find.text('Paste'));
await tester.pumpAndSettle();
expect(controller.text, 'blah1 blah2blah1');
expect(controller.selection, const TextSelection(baseOffset: 16, extentOffset: 16));
// Cut the first word.
await gesture.down(midBlah1);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(find.text('Cut'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
await tester.tap(find.text('Cut'));
await tester.pumpAndSettle();
expect(controller.text, ' blah2blah1');
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 0));
expect(find.byType(CupertinoButton), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.macOS }), skip: kIsWeb);
testWidgets('has expected defaults', (WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
......@@ -3504,7 +3581,7 @@ void main() {
await gesture.up();
await tester.pump();
expect(find.byType(CupertinoButton), findsNWidgets(1));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }));
testWidgets('tap on non-force-press-supported devices work', (WidgetTester tester) async {
await tester.pumpWidget(
......
// 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_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
// Returns the first RenderEditable.
RenderEditable findRenderEditable(WidgetTester tester) {
final RenderObject root = tester.renderObject(find.byType(EditableText));
expect(root, isNotNull);
late RenderEditable renderEditable;
void recursiveFinder(RenderObject child) {
if (child is RenderEditable) {
renderEditable = child;
return;
}
child.visitChildren(recursiveFinder);
}
root.visitChildren(recursiveFinder);
expect(renderEditable, isNotNull);
return renderEditable;
}
List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) {
return points.map<TextSelectionPoint>((TextSelectionPoint point) {
return TextSelectionPoint(
box.localToGlobal(point.point),
point.direction,
);
}).toList();
}
Offset textOffsetToPosition(WidgetTester tester, int offset) {
final RenderEditable renderEditable = findRenderEditable(tester);
final List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(
TextSelection.collapsed(offset: offset),
),
renderEditable,
);
expect(endpoints.length, 1);
return endpoints[0].point + const Offset(0.0, -2.0);
}
......@@ -672,7 +672,7 @@ void main() {
expect(hitRect.size.width, lessThan(textFieldRect.size.width));
expect(hitRect.size.height, lessThan(textFieldRect.size.height));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }));
group('ClipboardStatusNotifier', () {
group('when Clipboard fails', () {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment