Unverified Commit d4f884e0 authored by Bruno Leroux's avatar Bruno Leroux Committed by GitHub

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 https://github.com/flutter/flutter/issues/104595

## Tests

Adds 6 tests.
parent d186792c
......@@ -1310,23 +1310,26 @@ class _InkResponseState extends State<_InkResponseStateWidget>
cursor: effectiveMouseCursor,
onEnter: handleMouseEnter,
onExit: handleMouseExit,
child: Semantics(
onTap: widget.excludeFromSemantics || widget.onTap == null ? null : simulateTap,
onLongPress: widget.excludeFromSemantics || widget.onLongPress == null ? null : simulateLongPress,
child: GestureDetector(
onTapDown: _primaryEnabled ? handleTapDown : null,
onTapUp: _primaryEnabled ? handleTapUp : null,
onTap: _primaryEnabled ? handleTap : null,
onTapCancel: _primaryEnabled ? handleTapCancel : null,
onDoubleTap: widget.onDoubleTap != null ? handleDoubleTap : null,
onLongPress: widget.onLongPress != null ? handleLongPress : null,
onSecondaryTapDown: _secondaryEnabled ? handleSecondaryTapDown : null,
onSecondaryTapUp: _secondaryEnabled ? handleSecondaryTapUp: null,
onSecondaryTap: _secondaryEnabled ? handleSecondaryTap : null,
onSecondaryTapCancel: _secondaryEnabled ? handleSecondaryTapCancel : null,
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
child: widget.child,
child: DefaultSelectionStyle.merge(
mouseCursor: effectiveMouseCursor,
child: Semantics(
onTap: widget.excludeFromSemantics || widget.onTap == null ? null : simulateTap,
onLongPress: widget.excludeFromSemantics || widget.onLongPress == null ? null : simulateLongPress,
child: GestureDetector(
onTapDown: _primaryEnabled ? handleTapDown : null,
onTapUp: _primaryEnabled ? handleTapUp : null,
onTap: _primaryEnabled ? handleTap : null,
onTapCancel: _primaryEnabled ? handleTapCancel : null,
onDoubleTap: widget.onDoubleTap != null ? handleDoubleTap : null,
onLongPress: widget.onLongPress != null ? handleLongPress : null,
onSecondaryTapDown: _secondaryEnabled ? handleSecondaryTapDown : null,
onSecondaryTapUp: _secondaryEnabled ? handleSecondaryTapUp: null,
onSecondaryTap: _secondaryEnabled ? handleSecondaryTap : null,
onSecondaryTapCancel: _secondaryEnabled ? handleSecondaryTapCancel : null,
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
child: widget.child,
),
),
),
),
......
......@@ -2,8 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'basic.dart';
import 'framework.dart';
import 'inherited_theme.dart';
......@@ -27,6 +26,7 @@ class DefaultSelectionStyle extends InheritedTheme {
super.key,
this.cursorColor,
this.selectionColor,
this.mouseCursor,
required super.child,
});
......@@ -41,8 +41,35 @@ class DefaultSelectionStyle extends InheritedTheme {
const DefaultSelectionStyle.fallback({ super.key })
: cursorColor = null,
selectionColor = null,
mouseCursor = null,
super(child: const _NullWidget());
/// Creates a default selection style that overrides the selection styles in
/// scope at this point in the widget tree.
///
/// Any Arguments that are not null replace the corresponding properties on the
/// default selection style for the [BuildContext] where the widget is inserted.
static Widget merge({
Key? key,
Color? cursorColor,
Color? selectionColor,
MouseCursor? mouseCursor,
required Widget child,
}) {
return Builder(
builder: (BuildContext context) {
final DefaultSelectionStyle parent = DefaultSelectionStyle.of(context);
return DefaultSelectionStyle(
key: key,
cursorColor: cursorColor ?? parent.cursorColor,
selectionColor: selectionColor ?? parent.selectionColor,
mouseCursor: mouseCursor ?? parent.mouseCursor,
child: child,
);
},
);
}
/// The default cursor and selection color (semi-transparent grey).
///
/// This is the color that the [Text] widget uses when the specified selection
......@@ -58,6 +85,11 @@ class DefaultSelectionStyle extends InheritedTheme {
/// The background color of selected text.
final Color? selectionColor;
/// The [MouseCursor] for mouse pointers hovering over selectable Text widgets.
///
/// If this property is null, [SystemMouseCursors.text] will be used.
final MouseCursor? mouseCursor;
/// The closest instance of this class that encloses the given context.
///
/// If no such instance exists, returns an instance created by
......@@ -77,6 +109,7 @@ class DefaultSelectionStyle extends InheritedTheme {
return DefaultSelectionStyle(
cursorColor: cursorColor,
selectionColor: selectionColor,
mouseCursor: mouseCursor,
child: child
);
}
......@@ -84,7 +117,8 @@ class DefaultSelectionStyle extends InheritedTheme {
@override
bool updateShouldNotify(DefaultSelectionStyle oldWidget) {
return cursorColor != oldWidget.cursorColor ||
selectionColor != oldWidget.selectionColor;
selectionColor != oldWidget.selectionColor ||
mouseCursor != oldWidget.mouseCursor;
}
}
......
......@@ -616,7 +616,7 @@ class Text extends StatelessWidget {
);
if (registrar != null) {
result = MouseRegion(
cursor: SystemMouseCursors.text,
cursor: DefaultSelectionStyle.of(context).mouseCursor ?? SystemMouseCursors.text,
child: result,
);
}
......
......@@ -1608,6 +1608,29 @@ void main() {
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic);
});
testWidgets('ElevatedButton in SelectionArea changes mouse cursor when hovered', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/104595.
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
enabledMouseCursor: SystemMouseCursors.click,
disabledMouseCursor: SystemMouseCursors.grab,
),
onPressed: () {},
child: const Text('button'),
),
),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
await gesture.addPointer(location: tester.getCenter(find.byType(Text)));
await tester.pump();
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click);
});
testWidgets('Ink Response shape matches Material shape', (WidgetTester tester) async {
// This is a regression test for https://github.com/flutter/flutter/issues/91844
......
......@@ -1614,6 +1614,29 @@ void main() {
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic);
});
testWidgets('FilledButton in SelectionArea changes mouse cursor when hovered', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/104595.
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
child: FilledButton(
style: FilledButton.styleFrom(
enabledMouseCursor: SystemMouseCursors.click,
disabledMouseCursor: SystemMouseCursors.grab,
),
onPressed: () {},
child: const Text('button'),
),
),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
await gesture.addPointer(location: tester.getCenter(find.byType(Text)));
await tester.pump();
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click);
});
testWidgets('Ink Response shape matches Material shape', (WidgetTester tester) async {
Widget buildFrame({BorderSide? side}) {
return MaterialApp(
......
......@@ -1020,6 +1020,27 @@ testWidgets('InkResponse radius can be updated', (WidgetTester tester) async {
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic);
});
testWidgets('InkResponse containing selectable text changes mouse cursor when hovered', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/104595.
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
child: Material(
child: InkResponse(
onTap: () {},
child: const Text('button'),
),
),
),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
await gesture.addPointer(location: tester.getCenter(find.byType(Text)));
await tester.pump();
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click);
});
group('feedback', () {
late FeedbackTester feedback;
......
......@@ -1764,6 +1764,29 @@ void main() {
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic);
});
testWidgets('OutlinedButton in SelectionArea changes mouse cursor when hovered', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/104595.
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
child: OutlinedButton(
style: OutlinedButton.styleFrom(
enabledMouseCursor: SystemMouseCursors.click,
disabledMouseCursor: SystemMouseCursors.grab,
),
onPressed: () {},
child: const Text('button'),
),
),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
await gesture.addPointer(location: tester.getCenter(find.byType(Text)));
await tester.pump();
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click);
});
testWidgets('OutlinedButton.styleFrom can be used to set foreground and background colors', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
......
......@@ -1570,6 +1570,29 @@ void main() {
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic);
});
testWidgets('TextButton in SelectionArea changes mouse cursor when hovered', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/104595.
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
child: TextButton(
style: TextButton.styleFrom(
enabledMouseCursor: SystemMouseCursors.click,
disabledMouseCursor: SystemMouseCursors.grab,
),
onPressed: () {},
child: const Text('button'),
),
),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
await gesture.addPointer(location: tester.getCenter(find.byType(Text)));
await tester.pump();
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click);
});
testWidgets('TextButton.styleFrom can be used to set foreground and background colors', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
......
......@@ -1584,6 +1584,39 @@ void main() {
await tester.tap(find.text('Hello World'));
expect(tester.takeException(), isNull);
});
testWidgets('Mouse hovering over selectable Text uses SystemMouseCursor.text', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(
home: SelectionArea(
child: Text('Flutter'),
),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
await gesture.addPointer(location: tester.getCenter(find.byType(Text)));
await tester.pump();
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.text);
});
testWidgets('Mouse hovering over selectable Text uses default selection style mouse cursor', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
child: DefaultSelectionStyle.merge(
mouseCursor: SystemMouseCursors.click,
child: const Text('Flutter'),
),
),
));
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
await gesture.addPointer(location: tester.getCenter(find.byType(Text)));
await tester.pump();
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click);
});
}
Future<void> _pumpTextWidget({
......
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