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';
......
// 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/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'button.dart';
import 'colors.dart';
import 'localizations.dart';
import 'theme.dart';
// Minimal padding from all edges of the selection toolbar to all edges of the
// screen.
const double _kToolbarScreenPadding = 8.0;
// These values were measured from a screenshot of TextEdit on MacOS 10.15.7 on
// a Macbook Pro.
const double _kToolbarWidth = 222.0;
const Radius _kToolbarBorderRadius = Radius.circular(4.0);
// These values were measured from a screenshot of TextEdit on MacOS 10.16 on a
// Macbook Pro.
const CupertinoDynamicColor _kToolbarBorderColor = CupertinoDynamicColor.withBrightness(
color: Color(0xFFBBBBBB),
darkColor: Color(0xFF505152),
);
const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness(
color: Color(0xffECE8E6),
darkColor: Color(0xff302928),
);
class _CupertinoDesktopTextSelectionControls extends TextSelectionControls {
/// Desktop has no text selection handles.
@override
Size getHandleSize(double textLineHeight) {
return Size.zero;
}
/// Builder for the Mac-style copy/paste text selection toolbar.
@override
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
double textLineHeight,
Offset selectionMidpoint,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ClipboardStatusNotifier clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
return _CupertinoDesktopTextSelectionControlsToolbar(
clipboardStatus: clipboardStatus,
endpoints: endpoints,
globalEditableRegion: globalEditableRegion,
handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
handleCopy: canCopy(delegate) ? () => handleCopy(delegate, clipboardStatus) : null,
handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null,
handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null,
selectionMidpoint: selectionMidpoint,
lastSecondaryTapDownPosition: lastSecondaryTapDownPosition,
textLineHeight: textLineHeight,
);
}
/// Builds the text selection handles, but desktop has none.
@override
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight) {
return const SizedBox.shrink();
}
/// Gets the position for the text selection handles, but desktop has none.
@override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
return Offset.zero;
}
}
/// Text selection controls that follows Mac design conventions.
final TextSelectionControls cupertinoDesktopTextSelectionControls =
_CupertinoDesktopTextSelectionControls();
// Generates the child that's passed into CupertinoDesktopTextSelectionToolbar.
class _CupertinoDesktopTextSelectionControlsToolbar extends StatefulWidget {
const _CupertinoDesktopTextSelectionControlsToolbar({
Key? key,
required this.clipboardStatus,
required this.endpoints,
required this.globalEditableRegion,
required this.handleCopy,
required this.handleCut,
required this.handlePaste,
required this.handleSelectAll,
required this.selectionMidpoint,
required this.textLineHeight,
required this.lastSecondaryTapDownPosition,
}) : super(key: key);
final ClipboardStatusNotifier? clipboardStatus;
final List<TextSelectionPoint> endpoints;
final Rect globalEditableRegion;
final VoidCallback? handleCopy;
final VoidCallback? handleCut;
final VoidCallback? handlePaste;
final VoidCallback? handleSelectAll;
final Offset? lastSecondaryTapDownPosition;
final Offset selectionMidpoint;
final double textLineHeight;
@override
_CupertinoDesktopTextSelectionControlsToolbarState createState() => _CupertinoDesktopTextSelectionControlsToolbarState();
}
class _CupertinoDesktopTextSelectionControlsToolbarState extends State<_CupertinoDesktopTextSelectionControlsToolbar> {
ClipboardStatusNotifier? _clipboardStatus;
void _onChangedClipboardStatus() {
setState(() {
// Inform the widget that the value of clipboardStatus has changed.
});
}
@override
void initState() {
super.initState();
if (widget.handlePaste != null) {
_clipboardStatus = widget.clipboardStatus ?? ClipboardStatusNotifier();
_clipboardStatus!.addListener(_onChangedClipboardStatus);
_clipboardStatus!.update();
}
}
@override
void didUpdateWidget(_CupertinoDesktopTextSelectionControlsToolbar oldWidget) {
super.didUpdateWidget(oldWidget);
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();
}
}
}
@override
void dispose() {
super.dispose();
// When used in an Overlay, this can be disposed after its creator has
// already disposed _clipboardStatus.
if (_clipboardStatus != null && !_clipboardStatus!.disposed) {
_clipboardStatus!.removeListener(_onChangedClipboardStatus);
if (widget.clipboardStatus == null) {
_clipboardStatus!.dispose();
}
}
}
@override
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) {
return const SizedBox(width: 0.0, height: 0.0);
}
assert(debugCheckHasMediaQuery(context));
final MediaQueryData mediaQuery = MediaQuery.of(context);
final Offset midpointAnchor = Offset(
(widget.selectionMidpoint.dx - widget.globalEditableRegion.left).clamp(
mediaQuery.padding.left,
mediaQuery.size.width - mediaQuery.padding.right,
),
widget.selectionMidpoint.dy - widget.globalEditableRegion.top,
);
final List<Widget> items = <Widget>[];
final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
final Widget onePhysicalPixelVerticalDivider =
SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
void addToolbarButton(
String text,
VoidCallback onPressed,
) {
if (items.isNotEmpty) {
items.add(onePhysicalPixelVerticalDivider);
}
items.add(_CupertinoDesktopTextSelectionToolbarButton.text(
context: context,
onPressed: onPressed,
text: text,
));
}
if (widget.handleCut != null) {
addToolbarButton(localizations.cutButtonLabel, widget.handleCut!);
}
if (widget.handleCopy != null) {
addToolbarButton(localizations.copyButtonLabel, widget.handleCopy!);
}
if (widget.handlePaste != null
&& _clipboardStatus!.value == ClipboardStatus.pasteable) {
addToolbarButton(localizations.pasteButtonLabel, widget.handlePaste!);
}
if (widget.handleSelectAll != null) {
addToolbarButton(localizations.selectAllButtonLabel, widget.handleSelectAll!);
}
// If there is no option available, build an empty widget.
if (items.isEmpty) {
return const SizedBox(width: 0.0, height: 0.0);
}
return _CupertinoDesktopTextSelectionToolbar(
anchor: widget.lastSecondaryTapDownPosition ?? midpointAnchor,
children: items,
);
}
}
/// A Mac-style text selection toolbar.
///
/// Typically displays buttons for text manipulation, e.g. copying and pasting
/// text.
///
/// Tries to position itself as closesly as possible to [anchor] while remaining
/// fully on-screen.
///
/// See also:
///
/// * [TextSelectionControls.buildToolbar], where this is used by default to
/// build a Mac-style toolbar.
/// * [TextSelectionToolbar], which is similar, but builds an Android-style
/// toolbar.
class _CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
/// Creates an instance of CupertinoTextSelectionToolbar.
const _CupertinoDesktopTextSelectionToolbar({
Key? key,
required this.anchor,
required this.children,
this.toolbarBuilder = _defaultToolbarBuilder,
}) : assert(children.length > 0),
super(key: key);
/// The point at which the toolbar will attempt to position itself as closely
/// as possible.
final Offset anchor;
/// {@macro flutter.material.TextSelectionToolbar.children}
///
/// See also:
/// * [CupertinoDesktopTextSelectionToolbarButton], which builds a default
/// Mac-style text selection toolbar text button.
final List<Widget> children;
/// {@macro flutter.material.TextSelectionToolbar.toolbarBuilder}
///
/// The given anchor and isAbove can be used to position an arrow, as in the
/// default Cupertino toolbar.
final ToolbarBuilder toolbarBuilder;
// Builds a toolbar just like the default Mac toolbar, with the right color
// background, padding, and rounded corners.
static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
return Container(
width: _kToolbarWidth,
decoration: BoxDecoration(
color: _kToolbarBackgroundColor.resolveFrom(context),
border: Border.all(
color: _kToolbarBorderColor.resolveFrom(context),
),
borderRadius: const BorderRadius.all(_kToolbarBorderRadius),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 0.0,
// This value was measured from a screenshot of TextEdit on MacOS
// 10.15.7 on a Macbook Pro.
vertical: 3.0,
),
child: child,
),
);
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
final MediaQueryData mediaQuery = MediaQuery.of(context);
final double paddingAbove = mediaQuery.padding.top + _kToolbarScreenPadding;
final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
return Padding(
padding: EdgeInsets.fromLTRB(
_kToolbarScreenPadding,
paddingAbove,
_kToolbarScreenPadding,
_kToolbarScreenPadding,
),
child: CustomSingleChildLayout(
delegate: _DesktopTextSelectionToolbarLayoutDelegate(
anchor: anchor - localAdjustment,
),
child: toolbarBuilder(context, Column(
mainAxisSize: MainAxisSize.min,
children: children,
)),
),
);
}
}
// Positions the toolbar at [anchor] if it fits, otherwise moves it so that it
// just fits fully on-screen.
//
// See also:
//
// * [CupertinoDesktopTextSelectionToolbar], which uses this to position itself.
// * [TextSelectionToolbarLayoutDelegate], which does a similar layout for
// the mobile text selection toolbars.
class _DesktopTextSelectionToolbarLayoutDelegate extends SingleChildLayoutDelegate {
/// Creates an instance of TextSelectionToolbarLayoutDelegate.
_DesktopTextSelectionToolbarLayoutDelegate({
required this.anchor,
});
/// The point at which to render the menu, if possible.
///
/// Should be provided in local coordinates.
final Offset anchor;
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return constraints.loosen();
}
@override
Offset getPositionForChild(Size size, Size childSize) {
final Offset overhang = Offset(
anchor.dx + childSize.width - size.width,
anchor.dy + childSize.height - size.height,
);
return Offset(
overhang.dx > 0.0 ? anchor.dx - overhang.dx : anchor.dx,
overhang.dy > 0.0 ? anchor.dy - overhang.dy : anchor.dy,
);
}
@override
bool shouldRelayout(_DesktopTextSelectionToolbarLayoutDelegate oldDelegate) {
return anchor != oldDelegate.anchor;
}
}
// These values were measured from a screenshot of TextEdit on MacOS 10.15.7 on
// a Macbook Pro.
const TextStyle _kToolbarButtonFontStyle = TextStyle(
inherit: false,
fontSize: 14.0,
letterSpacing: -0.15,
fontWeight: FontWeight.w400,
);
// This value was measured from a screenshot of TextEdit on MacOS 10.15.7 on a
// Macbook Pro.
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(
20.0,
0.0,
20.0,
3.0,
);
/// A button in the style of the Mac context menu buttons.
class _CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget {
/// Creates an instance of CupertinoDesktopTextSelectionToolbarButton.
const _CupertinoDesktopTextSelectionToolbarButton({
Key? key,
required this.onPressed,
required this.child,
}) : super(key: key);
/// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] whose child is
/// a [Text] widget styled like the default Mac context menu button.
_CupertinoDesktopTextSelectionToolbarButton.text({
Key? key,
required BuildContext context,
required this.onPressed,
required String text,
}) : child = Text(
text,
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle.copyWith(
color: const CupertinoDynamicColor.withBrightness(
color: CupertinoColors.black,
darkColor: CupertinoColors.white,
).resolveFrom(context),
),
),
super(key: key);
/// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
final VoidCallback onPressed;
/// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.child}
final Widget child;
@override
_CupertinoDesktopTextSelectionToolbarButtonState createState() => _CupertinoDesktopTextSelectionToolbarButtonState();
}
class _CupertinoDesktopTextSelectionToolbarButtonState extends State<_CupertinoDesktopTextSelectionToolbarButton> {
bool _isHovered = false;
void _onEnter(PointerEnterEvent event) {
setState(() {
_isHovered = true;
});
}
void _onExit(PointerExitEvent event) {
setState(() {
_isHovered = false;
});
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: MouseRegion(
onEnter: _onEnter,
onExit: _onExit,
child: CupertinoButton(
alignment: Alignment.centerLeft,
borderRadius: null,
color: _isHovered ? CupertinoTheme.of(context).primaryColor : null,
minSize: 0.0,
onPressed: widget.onPressed,
padding: _kToolbarButtonPadding,
pressedOpacity: 0.7,
child: widget.child,
),
),
);
}
}
......@@ -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(
......
......@@ -7,6 +7,7 @@ import 'dart:math' as math;
import 'dart:ui' as ui show window, BoxHeightStyle, BoxWidthStyle;
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/rendering.dart';
......@@ -14,8 +15,8 @@ import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind;
import '../widgets/editable_text_utils.dart' show findRenderEditable, globalize, textOffsetToPosition;
import '../widgets/semantics_tester.dart';
import '../widgets/text.dart' show findRenderEditable, globalize, textOffsetToPosition;
import 'feedback_tester.dart';
class MockClipboard {
......@@ -167,6 +168,78 @@ 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: TextField(
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('Cut'), findsOneWidget);
expect(find.text('Copy'), 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('TextField passes onEditingComplete to EditableText', (WidgetTester tester) async {
final VoidCallback onEditingComplete = () { };
......@@ -7144,6 +7217,10 @@ void main() {
await tester.longPressAt(textfieldStart + const Offset(50.0, 9.0));
await tester.pump(const Duration(milliseconds: 50));
expect(
controller.selection,
const TextSelection.collapsed(offset: 3, affinity: TextAffinity.downstream),
);
await tester.tapAt(textfieldStart + const Offset(150.0, 9.0));
await tester.pump(const Duration(milliseconds: 50));
......@@ -7161,7 +7238,65 @@ void main() {
const TextSelection(baseOffset: 8, extentOffset: 12),
);
expect(find.byType(CupertinoButton), findsNWidgets(3));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets(
'double click after a click on Mac',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final Offset textFieldStart = tester.getTopLeft(find.byType(TextField));
final TestGesture gesture = await tester.startGesture(
textFieldStart + const Offset(50.0, 9.0),
pointer: 7,
kind: PointerDeviceKind.mouse,
);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(
controller.selection,
const TextSelection.collapsed(offset: 3, affinity: TextAffinity.downstream),
);
await gesture.down(textFieldStart + const Offset(150.0, 9.0));
await tester.pump();
await gesture.up();
await tester.pump(const Duration(milliseconds: 50));
// First click moved the cursor to the precise location, not the start of
// the word.
expect(
controller.selection,
const TextSelection.collapsed(offset: 9, affinity: TextAffinity.downstream),
);
// Double click selection.
await gesture.down(textFieldStart + const Offset(150.0, 9.0));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(
controller.selection,
const TextSelection(baseOffset: 8, extentOffset: 12),
);
// The text selection toolbar isn't shown on Mac without a right click.
expect(find.byType(CupertinoButton), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.macOS }), skip: kIsWeb);
testWidgets('double tap chains work', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
......@@ -7225,7 +7360,92 @@ void main() {
const TextSelection(baseOffset: 8, extentOffset: 12),
);
expect(find.byType(CupertinoButton), findsNWidgets(3));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('double click chains work', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final Offset textFieldStart = tester.getTopLeft(find.byType(TextField));
// First click moves the cursor to the point of the click, not the edge of
// the clicked word.
final TestGesture gesture = await tester.startGesture(
textFieldStart + const Offset(50.0, 9.0),
pointer: 7,
kind: PointerDeviceKind.mouse,
);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump(const Duration(milliseconds: 50));
expect(
controller.selection,
const TextSelection.collapsed(offset: 3, affinity: TextAffinity.downstream),
);
// Second click selects.
await gesture.down(textFieldStart + const Offset(50.0, 9.0));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7),
);
expect(find.byType(CupertinoButton), findsNothing);
// Double tap selecting the same word somewhere else is fine.
await gesture.down(textFieldStart + const Offset(100.0, 9.0));
await tester.pump();
await gesture.up();
await tester.pump(const Duration(milliseconds: 50));
// First tap moved the cursor.
expect(
controller.selection,
const TextSelection.collapsed(offset: 6, affinity: TextAffinity.downstream),
);
await gesture.down(textFieldStart + const Offset(100.0, 9.0));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7),
);
expect(find.byType(CupertinoButton), findsNothing);
await gesture.down(textFieldStart + const Offset(150.0, 9.0));
await tester.pump();
await gesture.up();
await tester.pump(const Duration(milliseconds: 50));
// First tap moved the cursor.
expect(
controller.selection,
const TextSelection.collapsed(offset: 9, affinity: TextAffinity.downstream),
);
await gesture.down(textFieldStart + const Offset(150.0, 9.0));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(
controller.selection,
const TextSelection(baseOffset: 8, extentOffset: 12),
);
expect(find.byType(CupertinoButton), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.macOS }), skip: kIsWeb);
testWidgets('double tapping a space selects the previous word on iOS', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
......@@ -7348,7 +7568,78 @@ void main() {
expect(controller.value.selection, isNotNull);
expect(controller.value.selection.baseOffset, 0);
expect(controller.value.selection.extentOffset, 1);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia, TargetPlatform.android }));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia, TargetPlatform.android }));
testWidgets('selecting a space selects the space on Mac', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: ' blah blah',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
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 clicking 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();
await tester.pump();
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, 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('force press does not select a word', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
......@@ -7436,7 +7727,7 @@ void main() {
await gesture.up();
await tester.pumpAndSettle();
expect(find.byType(CupertinoButton), findsNWidgets(3));
}, 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 {
final TextEditingController controller = TextEditingController(
......
......@@ -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