Unverified Commit f86b9220 authored by Luccas Clezar's avatar Luccas Clezar Committed by GitHub

Update Cupertino desktop text selection toolbar (#121829)

Visual fidelity of the right-click context menu on MacOS.
parent 8a815c1d
...@@ -182,7 +182,6 @@ class _CupertinoDesktopTextSelectionControlsToolbarState extends State<_Cupertin ...@@ -182,7 +182,6 @@ class _CupertinoDesktopTextSelectionControlsToolbarState extends State<_Cupertin
} }
items.add(CupertinoDesktopTextSelectionToolbarButton.text( items.add(CupertinoDesktopTextSelectionToolbarButton.text(
context: context,
onPressed: onPressed, onPressed: onPressed,
text: text, text: text,
)); ));
......
...@@ -2,6 +2,9 @@ ...@@ -2,6 +2,9 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'colors.dart'; import 'colors.dart';
...@@ -10,23 +13,33 @@ import 'colors.dart'; ...@@ -10,23 +13,33 @@ import 'colors.dart';
// the screen. // the screen.
const double _kToolbarScreenPadding = 8.0; const double _kToolbarScreenPadding = 8.0;
// These values were measured from a screenshot of TextEdit on macOS 10.15.7 on // These values were measured from a screenshot of the native context menu on
// a Macbook Pro. // macOS 13.2 on a Macbook Pro.
const double _kToolbarSaturationBoost = 3;
const double _kToolbarBlurSigma = 20;
const double _kToolbarWidth = 222.0; const double _kToolbarWidth = 222.0;
const Radius _kToolbarBorderRadius = Radius.circular(4.0); const Radius _kToolbarBorderRadius = Radius.circular(8.0);
const EdgeInsets _kToolbarPadding = EdgeInsets.symmetric( const EdgeInsets _kToolbarPadding = EdgeInsets.all(6.0);
vertical: 3.0, const List<BoxShadow> _kToolbarShadow = <BoxShadow>[
); BoxShadow(
color: Color.fromARGB(60, 0, 0, 0),
blurRadius: 10.0,
spreadRadius: 0.5,
offset: Offset(0.0, 4.0),
),
];
// These values were measured from a screenshot of TextEdit on macOS 10.16 on a // These values were measured from a screenshot of the native context menu on
// Macbook Pro. // macOS 13.2 on a Macbook Pro.
const CupertinoDynamicColor _kToolbarBorderColor = CupertinoDynamicColor.withBrightness( const CupertinoDynamicColor _kToolbarBorderColor =
color: Color(0xFFBBBBBB), CupertinoDynamicColor.withBrightness(
darkColor: Color(0xFF505152), color: Color(0xFFB8B8B8),
darkColor: Color(0xFF5B5B5B),
); );
const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness( const CupertinoDynamicColor _kToolbarBackgroundColor =
color: Color(0xffECE8E6), CupertinoDynamicColor.withBrightness(
darkColor: Color(0xff302928), color: Color(0xB2FFFFFF),
darkColor: Color(0xB2303030),
); );
/// A macOS-style text selection toolbar. /// A macOS-style text selection toolbar.
...@@ -53,6 +66,23 @@ class CupertinoDesktopTextSelectionToolbar extends StatelessWidget { ...@@ -53,6 +66,23 @@ class CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
required this.children, required this.children,
}) : assert(children.length > 0); }) : assert(children.length > 0);
/// Creates a 5x5 matrix that increases saturation when used with [ColorFilter.matrix].
///
/// The numbers were taken from this comment:
/// [Cupertino blurs should boost saturation](https://github.com/flutter/flutter/issues/29483#issuecomment-477334981).
static List<double> _matrixWithSaturation(double saturation) {
final double r = 0.213 * (1 - saturation);
final double g = 0.715 * (1 - saturation);
final double b = 0.072 * (1 - saturation);
return <double>[
r + saturation, g, b, 0, 0, //
r, g + saturation, b, 0, 0, //
r, g, b + saturation, 0, 0, //
0, 0, 0, 1, 0, //
];
}
/// {@macro flutter.material.DesktopTextSelectionToolbar.anchor} /// {@macro flutter.material.DesktopTextSelectionToolbar.anchor}
final Offset anchor; final Offset anchor;
...@@ -68,16 +98,41 @@ class CupertinoDesktopTextSelectionToolbar extends StatelessWidget { ...@@ -68,16 +98,41 @@ class CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
static Widget _defaultToolbarBuilder(BuildContext context, Widget child) { static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
return Container( return Container(
width: _kToolbarWidth, width: _kToolbarWidth,
decoration: BoxDecoration( clipBehavior: Clip.hardEdge,
color: _kToolbarBackgroundColor.resolveFrom(context), decoration: const BoxDecoration(
border: Border.all( boxShadow: _kToolbarShadow,
color: _kToolbarBorderColor.resolveFrom(context), borderRadius: BorderRadius.all(_kToolbarBorderRadius),
),
borderRadius: const BorderRadius.all(_kToolbarBorderRadius),
), ),
child: Padding( child: BackdropFilter(
padding: _kToolbarPadding, // Flutter web doesn't support ImageFilter.compose on CanvasKit yet
child: child, // (https://github.com/flutter/flutter/issues/120123).
filter: kIsWeb
? ImageFilter.blur(
sigmaX: _kToolbarBlurSigma,
sigmaY: _kToolbarBlurSigma,
)
: ImageFilter.compose(
outer: ColorFilter.matrix(
_matrixWithSaturation(_kToolbarSaturationBoost),
),
inner: ImageFilter.blur(
sigmaX: _kToolbarBlurSigma,
sigmaY: _kToolbarBlurSigma,
),
),
child: DecoratedBox(
decoration: BoxDecoration(
color: _kToolbarBackgroundColor.resolveFrom(context),
border: Border.all(
color: _kToolbarBorderColor.resolveFrom(context),
),
borderRadius: const BorderRadius.all(_kToolbarBorderRadius),
),
child: Padding(
padding: _kToolbarPadding,
child: child,
),
),
), ),
); );
} }
...@@ -86,7 +141,8 @@ class CupertinoDesktopTextSelectionToolbar extends StatelessWidget { ...@@ -86,7 +141,8 @@ class CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context)); assert(debugCheckHasMediaQuery(context));
final double paddingAbove = MediaQuery.paddingOf(context).top + _kToolbarScreenPadding; final double paddingAbove =
MediaQuery.paddingOf(context).top + _kToolbarScreenPadding;
final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove); final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
return Padding( return Padding(
......
...@@ -10,8 +10,8 @@ import 'colors.dart'; ...@@ -10,8 +10,8 @@ import 'colors.dart';
import 'text_selection_toolbar_button.dart'; import 'text_selection_toolbar_button.dart';
import 'theme.dart'; import 'theme.dart';
// These values were measured from a screenshot of TextEdit on MacOS 10.15.7 on // These values were measured from a screenshot of the native context menu on
// a Macbook Pro. // macOS 13.2 on a Macbook Pro.
const TextStyle _kToolbarButtonFontStyle = TextStyle( const TextStyle _kToolbarButtonFontStyle = TextStyle(
inherit: false, inherit: false,
fontSize: 14.0, fontSize: 14.0,
...@@ -19,13 +19,13 @@ const TextStyle _kToolbarButtonFontStyle = TextStyle( ...@@ -19,13 +19,13 @@ const TextStyle _kToolbarButtonFontStyle = TextStyle(
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
); );
// This value was measured from a screenshot of TextEdit on MacOS 10.15.7 on a // This value was measured from a screenshot of the native context menu on
// Macbook Pro. // macOS 13.2 on a Macbook Pro.
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB( const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(
20.0, 8.0,
0.0, 2.0,
20.0, 8.0,
3.0, 5.0,
); );
/// A button in the style of the Mac context menu buttons. /// A button in the style of the Mac context menu buttons.
...@@ -37,26 +37,17 @@ class CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget { ...@@ -37,26 +37,17 @@ class CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget {
super.key, super.key,
required this.onPressed, required this.onPressed,
required Widget this.child, required Widget this.child,
}) : buttonItem = null; }) : buttonItem = null,
text = null;
/// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] whose child is /// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] whose child is
/// a [Text] widget styled like the default Mac context menu button. /// a [Text] widget styled like the default Mac context menu button.
CupertinoDesktopTextSelectionToolbarButton.text({ const CupertinoDesktopTextSelectionToolbarButton.text({
super.key, super.key,
required BuildContext context,
required this.onPressed, required this.onPressed,
required String text, required this.text,
}) : buttonItem = null, }) : buttonItem = null,
child = Text( child = null;
text,
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle.copyWith(
color: const CupertinoDynamicColor.withBrightness(
color: CupertinoColors.black,
darkColor: CupertinoColors.white,
).resolveFrom(context),
),
);
/// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] from /// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] from
/// the given [ContextMenuButtonItem]. /// the given [ContextMenuButtonItem].
...@@ -65,8 +56,9 @@ class CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget { ...@@ -65,8 +56,9 @@ class CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget {
CupertinoDesktopTextSelectionToolbarButton.buttonItem({ CupertinoDesktopTextSelectionToolbarButton.buttonItem({
super.key, super.key,
required ContextMenuButtonItem this.buttonItem, required ContextMenuButtonItem this.buttonItem,
}) : onPressed = buttonItem.onPressed, }) : onPressed = buttonItem.onPressed,
child = null; text = null,
child = null;
/// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed} /// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
final VoidCallback? onPressed; final VoidCallback? onPressed;
...@@ -77,11 +69,16 @@ class CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget { ...@@ -77,11 +69,16 @@ class CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget {
/// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed} /// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
final ContextMenuButtonItem? buttonItem; final ContextMenuButtonItem? buttonItem;
/// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.text}
final String? text;
@override @override
State<CupertinoDesktopTextSelectionToolbarButton> createState() => _CupertinoDesktopTextSelectionToolbarButtonState(); State<CupertinoDesktopTextSelectionToolbarButton> createState() =>
_CupertinoDesktopTextSelectionToolbarButtonState();
} }
class _CupertinoDesktopTextSelectionToolbarButtonState extends State<CupertinoDesktopTextSelectionToolbarButton> { class _CupertinoDesktopTextSelectionToolbarButtonState
extends State<CupertinoDesktopTextSelectionToolbarButton> {
bool _isHovered = false; bool _isHovered = false;
void _onEnter(PointerEnterEvent event) { void _onEnter(PointerEnterEvent event) {
...@@ -98,16 +95,24 @@ class _CupertinoDesktopTextSelectionToolbarButtonState extends State<CupertinoDe ...@@ -98,16 +95,24 @@ class _CupertinoDesktopTextSelectionToolbarButtonState extends State<CupertinoDe
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Widget child = widget.child ?? Text( final Widget child = widget.child ??
CupertinoTextSelectionToolbarButton.getButtonLabel(context, widget.buttonItem!), Text(
overflow: TextOverflow.ellipsis, widget.text ??
style: _kToolbarButtonFontStyle.copyWith( CupertinoTextSelectionToolbarButton.getButtonLabel(
color: const CupertinoDynamicColor.withBrightness( context,
color: CupertinoColors.black, widget.buttonItem!,
darkColor: CupertinoColors.white, ),
).resolveFrom(context), overflow: TextOverflow.ellipsis,
), style: _kToolbarButtonFontStyle.copyWith(
); color: _isHovered
? CupertinoTheme.of(context).primaryContrastingColor
: const CupertinoDynamicColor.withBrightness(
color: CupertinoColors.black,
darkColor: CupertinoColors.white,
).resolveFrom(context),
),
);
return SizedBox( return SizedBox(
width: double.infinity, width: double.infinity,
child: MouseRegion( child: MouseRegion(
...@@ -115,7 +120,7 @@ class _CupertinoDesktopTextSelectionToolbarButtonState extends State<CupertinoDe ...@@ -115,7 +120,7 @@ class _CupertinoDesktopTextSelectionToolbarButtonState extends State<CupertinoDe
onExit: _onExit, onExit: _onExit,
child: CupertinoButton( child: CupertinoButton(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
borderRadius: null, borderRadius: const BorderRadius.all(Radius.circular(4.0)),
color: _isHovered ? CupertinoTheme.of(context).primaryColor : null, color: _isHovered ? CupertinoTheme.of(context).primaryColor : null,
minSize: 0.0, minSize: 0.0,
onPressed: widget.onPressed, onPressed: widget.onPressed,
......
...@@ -83,8 +83,10 @@ class CupertinoTextSelectionToolbarButton extends StatelessWidget { ...@@ -83,8 +83,10 @@ class CupertinoTextSelectionToolbarButton extends StatelessWidget {
/// {@endtemplate} /// {@endtemplate}
final ContextMenuButtonItem? buttonItem; final ContextMenuButtonItem? buttonItem;
/// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.text}
/// The text used in the button's label when using /// The text used in the button's label when using
/// [CupertinoTextSelectionToolbarButton.text]. /// [CupertinoTextSelectionToolbarButton.text].
/// {@endtemplate}
final String? text; final String? text;
/// Returns the default button label String for the button of the given /// Returns the default button label String for the button of the given
......
...@@ -271,7 +271,6 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget { ...@@ -271,7 +271,6 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget {
case TargetPlatform.macOS: case TargetPlatform.macOS:
return buttonItems.map((ContextMenuButtonItem buttonItem) { return buttonItems.map((ContextMenuButtonItem buttonItem) {
return CupertinoDesktopTextSelectionToolbarButton.text( return CupertinoDesktopTextSelectionToolbarButton.text(
context: context,
onPressed: buttonItem.onPressed, onPressed: buttonItem.onPressed,
text: getButtonLabel(context, buttonItem), text: getButtonLabel(context, buttonItem),
); );
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
...@@ -29,6 +30,46 @@ void main() { ...@@ -29,6 +30,46 @@ void main() {
expect(pressed, true); expect(pressed, true);
}); });
testWidgets('keeps contrast with background on hover',
(WidgetTester tester) async {
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoDesktopTextSelectionToolbarButton.text(
text: 'Tap me',
onPressed: () {},
),
),
),
);
final BuildContext context =
tester.element(find.byType(CupertinoDesktopTextSelectionToolbarButton));
// The Text color is a CupertinoDynamicColor so we have to compare the color
// values instead of just comparing the colors themselves.
expect(
(tester.firstWidget(find.text('Tap me')) as Text).style!.color!.value,
CupertinoColors.black.value,
);
// Hover gesture
final TestGesture gesture =
await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(tester
.getCenter(find.byType(CupertinoDesktopTextSelectionToolbarButton)));
await tester.pumpAndSettle();
// The color here should be a standard Color, there's no need to use value.
expect(
(tester.firstWidget(find.text('Tap me')) as Text).style!.color,
CupertinoTheme.of(context).primaryContrastingColor,
);
});
testWidgets('pressedOpacity defaults to 0.1', (WidgetTester tester) async { testWidgets('pressedOpacity defaults to 0.1', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
CupertinoApp( CupertinoApp(
...@@ -49,7 +90,8 @@ void main() { ...@@ -49,7 +90,8 @@ void main() {
expect(opacity.opacity.value, 1.0); expect(opacity.opacity.value, 1.0);
// Make a "down" gesture on the button. // Make a "down" gesture on the button.
final Offset center = tester.getCenter(find.byType(CupertinoDesktopTextSelectionToolbarButton)); final Offset center = tester
.getCenter(find.byType(CupertinoDesktopTextSelectionToolbarButton));
final TestGesture gesture = await tester.startGesture(center); final TestGesture gesture = await tester.startGesture(center);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
......
...@@ -2,12 +2,120 @@ ...@@ -2,12 +2,120 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('has correct backdrop filters', (WidgetTester tester) async {
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoDesktopTextSelectionToolbar(
anchor: Offset.zero,
children: <Widget>[
CupertinoDesktopTextSelectionToolbarButton(
child: const Text('Tap me'),
onPressed: () {},
),
],
),
),
),
);
final BackdropFilter toolbarFilter = tester.firstWidget<BackdropFilter>(
find.descendant(
of: find.byType(CupertinoDesktopTextSelectionToolbar),
matching: find.byType(BackdropFilter),
),
);
expect(
toolbarFilter.filter.runtimeType,
// _ComposeImageFilter is internal so we can't test if its filters are
// for blur and saturation, but checking if it's a _ComposeImageFilter
// should be enough. Outer and inner parameters don't matter, we just need
// a new _ComposeImageFilter to get its runtimeType.
//
// As web doesn't support ImageFilter.compose, we use just blur when
// kIsWeb.
kIsWeb
? ImageFilter.blur().runtimeType
: ImageFilter.compose(
outer: ImageFilter.blur(),
inner: ImageFilter.blur(),
).runtimeType,
);
});
testWidgets('has shadow', (WidgetTester tester) async {
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoDesktopTextSelectionToolbar(
anchor: Offset.zero,
children: <Widget>[
CupertinoDesktopTextSelectionToolbarButton(
child: const Text('Tap me'),
onPressed: () {},
),
],
),
),
),
);
final DecoratedBox decoratedBox = tester.firstWidget<DecoratedBox>(
find.descendant(
of: find.byType(CupertinoDesktopTextSelectionToolbar),
matching: find.byType(DecoratedBox),
),
);
expect(
(decoratedBox.decoration as BoxDecoration).boxShadow,
isNotNull,
);
});
testWidgets('is translucent', (WidgetTester tester) async {
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoDesktopTextSelectionToolbar(
anchor: Offset.zero,
children: <Widget>[
CupertinoDesktopTextSelectionToolbarButton(
child: const Text('Tap me'),
onPressed: () {},
),
],
),
),
),
);
final DecoratedBox decoratedBox = tester
.widgetList<DecoratedBox>(
find.descendant(
of: find.byType(CupertinoDesktopTextSelectionToolbar),
matching: find.byType(DecoratedBox),
),
)
// The second DecoratedBox should be the one with color.
.elementAt(1);
expect(
(decoratedBox.decoration as BoxDecoration).color!.opacity,
lessThan(1.0),
);
});
testWidgets('positions itself at the anchor', (WidgetTester tester) async { testWidgets('positions itself at the anchor', (WidgetTester tester) async {
// An arbitrary point on the screen to position at. // An arbitrary point on the screen to position at.
const Offset anchor = Offset(30.0, 40.0); const Offset anchor = Offset(30.0, 40.0);
...@@ -29,7 +137,8 @@ void main() { ...@@ -29,7 +137,8 @@ void main() {
); );
expect( expect(
tester.getTopLeft(find.byType(CupertinoDesktopTextSelectionToolbarButton)), tester
.getTopLeft(find.byType(CupertinoDesktopTextSelectionToolbarButton)),
// Greater than due to padding internal to the toolbar. // Greater than due to padding internal to the toolbar.
greaterThan(anchor), greaterThan(anchor),
); );
......
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