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
}
items.add(CupertinoDesktopTextSelectionToolbarButton.text(
context: context,
onPressed: onPressed,
text: text,
));
......
......@@ -2,6 +2,9 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
......@@ -10,23 +13,33 @@ import 'colors.dart';
// 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.
// These values were measured from a screenshot of the native context menu on
// macOS 13.2 on a Macbook Pro.
const double _kToolbarSaturationBoost = 3;
const double _kToolbarBlurSigma = 20;
const double _kToolbarWidth = 222.0;
const Radius _kToolbarBorderRadius = Radius.circular(4.0);
const EdgeInsets _kToolbarPadding = EdgeInsets.symmetric(
vertical: 3.0,
);
const Radius _kToolbarBorderRadius = Radius.circular(8.0);
const EdgeInsets _kToolbarPadding = EdgeInsets.all(6.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
// Macbook Pro.
const CupertinoDynamicColor _kToolbarBorderColor = CupertinoDynamicColor.withBrightness(
color: Color(0xFFBBBBBB),
darkColor: Color(0xFF505152),
// These values were measured from a screenshot of the native context menu on
// macOS 13.2 on a Macbook Pro.
const CupertinoDynamicColor _kToolbarBorderColor =
CupertinoDynamicColor.withBrightness(
color: Color(0xFFB8B8B8),
darkColor: Color(0xFF5B5B5B),
);
const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness(
color: Color(0xffECE8E6),
darkColor: Color(0xff302928),
const CupertinoDynamicColor _kToolbarBackgroundColor =
CupertinoDynamicColor.withBrightness(
color: Color(0xB2FFFFFF),
darkColor: Color(0xB2303030),
);
/// A macOS-style text selection toolbar.
......@@ -53,6 +66,23 @@ class CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
required this.children,
}) : 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}
final Offset anchor;
......@@ -68,6 +98,29 @@ class CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
return Container(
width: _kToolbarWidth,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(
boxShadow: _kToolbarShadow,
borderRadius: BorderRadius.all(_kToolbarBorderRadius),
),
child: BackdropFilter(
// Flutter web doesn't support ImageFilter.compose on CanvasKit yet
// (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(
......@@ -79,6 +132,8 @@ class CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
padding: _kToolbarPadding,
child: child,
),
),
),
);
}
......@@ -86,7 +141,8 @@ class CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
Widget build(BuildContext 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);
return Padding(
......
......@@ -10,8 +10,8 @@ import 'colors.dart';
import 'text_selection_toolbar_button.dart';
import 'theme.dart';
// These values were measured from a screenshot of TextEdit on MacOS 10.15.7 on
// a Macbook Pro.
// These values were measured from a screenshot of the native context menu on
// macOS 13.2 on a Macbook Pro.
const TextStyle _kToolbarButtonFontStyle = TextStyle(
inherit: false,
fontSize: 14.0,
......@@ -19,13 +19,13 @@ const TextStyle _kToolbarButtonFontStyle = TextStyle(
fontWeight: FontWeight.w400,
);
// This value was measured from a screenshot of TextEdit on MacOS 10.15.7 on a
// Macbook Pro.
// This value was measured from a screenshot of the native context menu on
// macOS 13.2 on a Macbook Pro.
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(
20.0,
0.0,
20.0,
3.0,
8.0,
2.0,
8.0,
5.0,
);
/// A button in the style of the Mac context menu buttons.
......@@ -37,26 +37,17 @@ class CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget {
super.key,
required this.onPressed,
required Widget this.child,
}) : buttonItem = null;
}) : buttonItem = null,
text = null;
/// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] whose child is
/// a [Text] widget styled like the default Mac context menu button.
CupertinoDesktopTextSelectionToolbarButton.text({
const CupertinoDesktopTextSelectionToolbarButton.text({
super.key,
required BuildContext context,
required this.onPressed,
required String text,
required this.text,
}) : buttonItem = null,
child = Text(
text,
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle.copyWith(
color: const CupertinoDynamicColor.withBrightness(
color: CupertinoColors.black,
darkColor: CupertinoColors.white,
).resolveFrom(context),
),
);
child = null;
/// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] from
/// the given [ContextMenuButtonItem].
......@@ -66,6 +57,7 @@ class CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget {
super.key,
required ContextMenuButtonItem this.buttonItem,
}) : onPressed = buttonItem.onPressed,
text = null,
child = null;
/// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
......@@ -77,11 +69,16 @@ class CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget {
/// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
final ContextMenuButtonItem? buttonItem;
/// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.text}
final String? text;
@override
State<CupertinoDesktopTextSelectionToolbarButton> createState() => _CupertinoDesktopTextSelectionToolbarButtonState();
State<CupertinoDesktopTextSelectionToolbarButton> createState() =>
_CupertinoDesktopTextSelectionToolbarButtonState();
}
class _CupertinoDesktopTextSelectionToolbarButtonState extends State<CupertinoDesktopTextSelectionToolbarButton> {
class _CupertinoDesktopTextSelectionToolbarButtonState
extends State<CupertinoDesktopTextSelectionToolbarButton> {
bool _isHovered = false;
void _onEnter(PointerEnterEvent event) {
......@@ -98,16 +95,24 @@ class _CupertinoDesktopTextSelectionToolbarButtonState extends State<CupertinoDe
@override
Widget build(BuildContext context) {
final Widget child = widget.child ?? Text(
CupertinoTextSelectionToolbarButton.getButtonLabel(context, widget.buttonItem!),
final Widget child = widget.child ??
Text(
widget.text ??
CupertinoTextSelectionToolbarButton.getButtonLabel(
context,
widget.buttonItem!,
),
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle.copyWith(
color: const CupertinoDynamicColor.withBrightness(
color: _isHovered
? CupertinoTheme.of(context).primaryContrastingColor
: const CupertinoDynamicColor.withBrightness(
color: CupertinoColors.black,
darkColor: CupertinoColors.white,
).resolveFrom(context),
),
);
return SizedBox(
width: double.infinity,
child: MouseRegion(
......@@ -115,7 +120,7 @@ class _CupertinoDesktopTextSelectionToolbarButtonState extends State<CupertinoDe
onExit: _onExit,
child: CupertinoButton(
alignment: Alignment.centerLeft,
borderRadius: null,
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
color: _isHovered ? CupertinoTheme.of(context).primaryColor : null,
minSize: 0.0,
onPressed: widget.onPressed,
......
......@@ -83,8 +83,10 @@ class CupertinoTextSelectionToolbarButton extends StatelessWidget {
/// {@endtemplate}
final ContextMenuButtonItem? buttonItem;
/// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.text}
/// The text used in the button's label when using
/// [CupertinoTextSelectionToolbarButton.text].
/// {@endtemplate}
final String? text;
/// Returns the default button label String for the button of the given
......
......@@ -271,7 +271,6 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget {
case TargetPlatform.macOS:
return buttonItems.map((ContextMenuButtonItem buttonItem) {
return CupertinoDesktopTextSelectionToolbarButton.text(
context: context,
onPressed: buttonItem.onPressed,
text: getButtonLabel(context, buttonItem),
);
......
......@@ -3,6 +3,7 @@
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
......@@ -29,6 +30,46 @@ void main() {
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 {
await tester.pumpWidget(
CupertinoApp(
......@@ -49,7 +90,8 @@ void main() {
expect(opacity.opacity.value, 1.0);
// 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);
await tester.pumpAndSettle();
......
......@@ -2,12 +2,120 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
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 {
// An arbitrary point on the screen to position at.
const Offset anchor = Offset(30.0, 40.0);
......@@ -29,7 +137,8 @@ void main() {
);
expect(
tester.getTopLeft(find.byType(CupertinoDesktopTextSelectionToolbarButton)),
tester
.getTopLeft(find.byType(CupertinoDesktopTextSelectionToolbarButton)),
// Greater than due to padding internal to the toolbar.
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