Skip to content

Commit d4f884e

Browse files
authored
Make selectable text mouse cursor configurable (#125133)
## Description This PR introduces `DefaultSelectionStyle.mouseCursor` to configure the mouse cursor over selectable text. It also applies this solution to `InkResponse` to make the mouse cursor win over the default one provided by selectable `Text` for many Material components (such as buttons). ### Before https://user-images.githubusercontent.com/840911/233627729-ddf98e2a-444d-4c6d-a6d5-f521982f48dd.mov ### After https://user-images.githubusercontent.com/840911/233627718-8871a68f-d33c-44cf-b4a1-91bb1fcdf076.mov ## Related Issue Fixes flutter/flutter#104595 ## Tests Adds 6 tests.
1 parent d186792 commit d4f884e

9 files changed

Lines changed: 204 additions & 21 deletions

File tree

packages/flutter/lib/src/material/ink_well.dart

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,23 +1310,26 @@ class _InkResponseState extends State<_InkResponseStateWidget>
13101310
cursor: effectiveMouseCursor,
13111311
onEnter: handleMouseEnter,
13121312
onExit: handleMouseExit,
1313-
child: Semantics(
1314-
onTap: widget.excludeFromSemantics || widget.onTap == null ? null : simulateTap,
1315-
onLongPress: widget.excludeFromSemantics || widget.onLongPress == null ? null : simulateLongPress,
1316-
child: GestureDetector(
1317-
onTapDown: _primaryEnabled ? handleTapDown : null,
1318-
onTapUp: _primaryEnabled ? handleTapUp : null,
1319-
onTap: _primaryEnabled ? handleTap : null,
1320-
onTapCancel: _primaryEnabled ? handleTapCancel : null,
1321-
onDoubleTap: widget.onDoubleTap != null ? handleDoubleTap : null,
1322-
onLongPress: widget.onLongPress != null ? handleLongPress : null,
1323-
onSecondaryTapDown: _secondaryEnabled ? handleSecondaryTapDown : null,
1324-
onSecondaryTapUp: _secondaryEnabled ? handleSecondaryTapUp: null,
1325-
onSecondaryTap: _secondaryEnabled ? handleSecondaryTap : null,
1326-
onSecondaryTapCancel: _secondaryEnabled ? handleSecondaryTapCancel : null,
1327-
behavior: HitTestBehavior.opaque,
1328-
excludeFromSemantics: true,
1329-
child: widget.child,
1313+
child: DefaultSelectionStyle.merge(
1314+
mouseCursor: effectiveMouseCursor,
1315+
child: Semantics(
1316+
onTap: widget.excludeFromSemantics || widget.onTap == null ? null : simulateTap,
1317+
onLongPress: widget.excludeFromSemantics || widget.onLongPress == null ? null : simulateLongPress,
1318+
child: GestureDetector(
1319+
onTapDown: _primaryEnabled ? handleTapDown : null,
1320+
onTapUp: _primaryEnabled ? handleTapUp : null,
1321+
onTap: _primaryEnabled ? handleTap : null,
1322+
onTapCancel: _primaryEnabled ? handleTapCancel : null,
1323+
onDoubleTap: widget.onDoubleTap != null ? handleDoubleTap : null,
1324+
onLongPress: widget.onLongPress != null ? handleLongPress : null,
1325+
onSecondaryTapDown: _secondaryEnabled ? handleSecondaryTapDown : null,
1326+
onSecondaryTapUp: _secondaryEnabled ? handleSecondaryTapUp: null,
1327+
onSecondaryTap: _secondaryEnabled ? handleSecondaryTap : null,
1328+
onSecondaryTapCancel: _secondaryEnabled ? handleSecondaryTapCancel : null,
1329+
behavior: HitTestBehavior.opaque,
1330+
excludeFromSemantics: true,
1331+
child: widget.child,
1332+
),
13301333
),
13311334
),
13321335
),

packages/flutter/lib/src/widgets/default_selection_style.dart

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5-
import 'dart:ui';
6-
5+
import 'basic.dart';
76
import 'framework.dart';
87
import 'inherited_theme.dart';
98

@@ -27,6 +26,7 @@ class DefaultSelectionStyle extends InheritedTheme {
2726
super.key,
2827
this.cursorColor,
2928
this.selectionColor,
29+
this.mouseCursor,
3030
required super.child,
3131
});
3232

@@ -41,8 +41,35 @@ class DefaultSelectionStyle extends InheritedTheme {
4141
const DefaultSelectionStyle.fallback({ super.key })
4242
: cursorColor = null,
4343
selectionColor = null,
44+
mouseCursor = null,
4445
super(child: const _NullWidget());
4546

47+
/// Creates a default selection style that overrides the selection styles in
48+
/// scope at this point in the widget tree.
49+
///
50+
/// Any Arguments that are not null replace the corresponding properties on the
51+
/// default selection style for the [BuildContext] where the widget is inserted.
52+
static Widget merge({
53+
Key? key,
54+
Color? cursorColor,
55+
Color? selectionColor,
56+
MouseCursor? mouseCursor,
57+
required Widget child,
58+
}) {
59+
return Builder(
60+
builder: (BuildContext context) {
61+
final DefaultSelectionStyle parent = DefaultSelectionStyle.of(context);
62+
return DefaultSelectionStyle(
63+
key: key,
64+
cursorColor: cursorColor ?? parent.cursorColor,
65+
selectionColor: selectionColor ?? parent.selectionColor,
66+
mouseCursor: mouseCursor ?? parent.mouseCursor,
67+
child: child,
68+
);
69+
},
70+
);
71+
}
72+
4673
/// The default cursor and selection color (semi-transparent grey).
4774
///
4875
/// This is the color that the [Text] widget uses when the specified selection
@@ -58,6 +85,11 @@ class DefaultSelectionStyle extends InheritedTheme {
5885
/// The background color of selected text.
5986
final Color? selectionColor;
6087

88+
/// The [MouseCursor] for mouse pointers hovering over selectable Text widgets.
89+
///
90+
/// If this property is null, [SystemMouseCursors.text] will be used.
91+
final MouseCursor? mouseCursor;
92+
6193
/// The closest instance of this class that encloses the given context.
6294
///
6395
/// If no such instance exists, returns an instance created by
@@ -77,14 +109,16 @@ class DefaultSelectionStyle extends InheritedTheme {
77109
return DefaultSelectionStyle(
78110
cursorColor: cursorColor,
79111
selectionColor: selectionColor,
112+
mouseCursor: mouseCursor,
80113
child: child
81114
);
82115
}
83116

84117
@override
85118
bool updateShouldNotify(DefaultSelectionStyle oldWidget) {
86119
return cursorColor != oldWidget.cursorColor ||
87-
selectionColor != oldWidget.selectionColor;
120+
selectionColor != oldWidget.selectionColor ||
121+
mouseCursor != oldWidget.mouseCursor;
88122
}
89123
}
90124

packages/flutter/lib/src/widgets/text.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -616,7 +616,7 @@ class Text extends StatelessWidget {
616616
);
617617
if (registrar != null) {
618618
result = MouseRegion(
619-
cursor: SystemMouseCursors.text,
619+
cursor: DefaultSelectionStyle.of(context).mouseCursor ?? SystemMouseCursors.text,
620620
child: result,
621621
);
622622
}

packages/flutter/test/material/elevated_button_test.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1608,6 +1608,29 @@ void main() {
16081608
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic);
16091609
});
16101610

1611+
testWidgets('ElevatedButton in SelectionArea changes mouse cursor when hovered', (WidgetTester tester) async {
1612+
// Regression test for https://github.com/flutter/flutter/issues/104595.
1613+
await tester.pumpWidget(MaterialApp(
1614+
home: SelectionArea(
1615+
child: ElevatedButton(
1616+
style: ElevatedButton.styleFrom(
1617+
enabledMouseCursor: SystemMouseCursors.click,
1618+
disabledMouseCursor: SystemMouseCursors.grab,
1619+
),
1620+
onPressed: () {},
1621+
child: const Text('button'),
1622+
),
1623+
),
1624+
));
1625+
1626+
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
1627+
await gesture.addPointer(location: tester.getCenter(find.byType(Text)));
1628+
1629+
await tester.pump();
1630+
1631+
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click);
1632+
});
1633+
16111634
testWidgets('Ink Response shape matches Material shape', (WidgetTester tester) async {
16121635
// This is a regression test for https://github.com/flutter/flutter/issues/91844
16131636

packages/flutter/test/material/filled_button_test.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,6 +1614,29 @@ void main() {
16141614
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic);
16151615
});
16161616

1617+
testWidgets('FilledButton in SelectionArea changes mouse cursor when hovered', (WidgetTester tester) async {
1618+
// Regression test for https://github.com/flutter/flutter/issues/104595.
1619+
await tester.pumpWidget(MaterialApp(
1620+
home: SelectionArea(
1621+
child: FilledButton(
1622+
style: FilledButton.styleFrom(
1623+
enabledMouseCursor: SystemMouseCursors.click,
1624+
disabledMouseCursor: SystemMouseCursors.grab,
1625+
),
1626+
onPressed: () {},
1627+
child: const Text('button'),
1628+
),
1629+
),
1630+
));
1631+
1632+
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
1633+
await gesture.addPointer(location: tester.getCenter(find.byType(Text)));
1634+
1635+
await tester.pump();
1636+
1637+
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click);
1638+
});
1639+
16171640
testWidgets('Ink Response shape matches Material shape', (WidgetTester tester) async {
16181641
Widget buildFrame({BorderSide? side}) {
16191642
return MaterialApp(

packages/flutter/test/material/ink_well_test.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,27 @@ testWidgets('InkResponse radius can be updated', (WidgetTester tester) async {
10201020
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic);
10211021
});
10221022

1023+
testWidgets('InkResponse containing selectable text changes mouse cursor when hovered', (WidgetTester tester) async {
1024+
// Regression test for https://github.com/flutter/flutter/issues/104595.
1025+
await tester.pumpWidget(MaterialApp(
1026+
home: SelectionArea(
1027+
child: Material(
1028+
child: InkResponse(
1029+
onTap: () {},
1030+
child: const Text('button'),
1031+
),
1032+
),
1033+
),
1034+
));
1035+
1036+
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
1037+
await gesture.addPointer(location: tester.getCenter(find.byType(Text)));
1038+
1039+
await tester.pump();
1040+
1041+
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click);
1042+
});
1043+
10231044
group('feedback', () {
10241045
late FeedbackTester feedback;
10251046

packages/flutter/test/material/outlined_button_test.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1764,6 +1764,29 @@ void main() {
17641764
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic);
17651765
});
17661766

1767+
testWidgets('OutlinedButton in SelectionArea changes mouse cursor when hovered', (WidgetTester tester) async {
1768+
// Regression test for https://github.com/flutter/flutter/issues/104595.
1769+
await tester.pumpWidget(MaterialApp(
1770+
home: SelectionArea(
1771+
child: OutlinedButton(
1772+
style: OutlinedButton.styleFrom(
1773+
enabledMouseCursor: SystemMouseCursors.click,
1774+
disabledMouseCursor: SystemMouseCursors.grab,
1775+
),
1776+
onPressed: () {},
1777+
child: const Text('button'),
1778+
),
1779+
),
1780+
));
1781+
1782+
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
1783+
await gesture.addPointer(location: tester.getCenter(find.byType(Text)));
1784+
1785+
await tester.pump();
1786+
1787+
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click);
1788+
});
1789+
17671790
testWidgets('OutlinedButton.styleFrom can be used to set foreground and background colors', (WidgetTester tester) async {
17681791
await tester.pumpWidget(
17691792
MaterialApp(

packages/flutter/test/material/text_button_test.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1570,6 +1570,29 @@ void main() {
15701570
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic);
15711571
});
15721572

1573+
testWidgets('TextButton in SelectionArea changes mouse cursor when hovered', (WidgetTester tester) async {
1574+
// Regression test for https://github.com/flutter/flutter/issues/104595.
1575+
await tester.pumpWidget(MaterialApp(
1576+
home: SelectionArea(
1577+
child: TextButton(
1578+
style: TextButton.styleFrom(
1579+
enabledMouseCursor: SystemMouseCursors.click,
1580+
disabledMouseCursor: SystemMouseCursors.grab,
1581+
),
1582+
onPressed: () {},
1583+
child: const Text('button'),
1584+
),
1585+
),
1586+
));
1587+
1588+
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
1589+
await gesture.addPointer(location: tester.getCenter(find.byType(Text)));
1590+
1591+
await tester.pump();
1592+
1593+
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click);
1594+
});
1595+
15731596
testWidgets('TextButton.styleFrom can be used to set foreground and background colors', (WidgetTester tester) async {
15741597
await tester.pumpWidget(
15751598
MaterialApp(

packages/flutter/test/widgets/text_test.dart

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1584,6 +1584,39 @@ void main() {
15841584
await tester.tap(find.text('Hello World'));
15851585
expect(tester.takeException(), isNull);
15861586
});
1587+
1588+
testWidgets('Mouse hovering over selectable Text uses SystemMouseCursor.text', (WidgetTester tester) async {
1589+
await tester.pumpWidget(const MaterialApp(
1590+
home: SelectionArea(
1591+
child: Text('Flutter'),
1592+
),
1593+
));
1594+
1595+
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
1596+
await gesture.addPointer(location: tester.getCenter(find.byType(Text)));
1597+
1598+
await tester.pump();
1599+
1600+
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.text);
1601+
});
1602+
1603+
testWidgets('Mouse hovering over selectable Text uses default selection style mouse cursor', (WidgetTester tester) async {
1604+
await tester.pumpWidget(MaterialApp(
1605+
home: SelectionArea(
1606+
child: DefaultSelectionStyle.merge(
1607+
mouseCursor: SystemMouseCursors.click,
1608+
child: const Text('Flutter'),
1609+
),
1610+
),
1611+
));
1612+
1613+
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
1614+
await gesture.addPointer(location: tester.getCenter(find.byType(Text)));
1615+
1616+
await tester.pump();
1617+
1618+
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click);
1619+
});
15871620
}
15881621

15891622
Future<void> _pumpTextWidget({

0 commit comments

Comments
 (0)