flutter_flutter/packages/flutter/test/material/text_selection_toolbar_test.dart
Kate Lovett 9d96df2364
Modernize framework lints (#179089)
WIP

Commits separated as follows:
- Update lints in analysis_options files
- Run `dart fix --apply`
- Clean up leftover analysis issues 
- Run `dart format .` in the right places.

Local analysis and testing passes. Checking CI now.

Part of https://github.com/flutter/flutter/issues/178827
- Adoption of flutter_lints in examples/api coming in a separate change
(cc @loic-sharma)

## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
2025-11-26 01:10:39 +00:00

459 lines
16 KiB
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/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../widgets/editable_text_utils.dart' show textOffsetToPosition;
const double _kToolbarContentDistance = 8.0;
// A custom text selection menu that just displays a single custom button.
class _CustomMaterialTextSelectionControls extends MaterialTextSelectionControls {
@override
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
double textLineHeight,
Offset selectionMidpoint,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ValueListenable<ClipboardStatus>? clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
final TextSelectionPoint startTextSelectionPoint = endpoints[0];
final TextSelectionPoint endTextSelectionPoint = endpoints.length > 1
? endpoints[1]
: endpoints[0];
final anchorAbove = Offset(
globalEditableRegion.left + selectionMidpoint.dx,
globalEditableRegion.top +
startTextSelectionPoint.point.dy -
textLineHeight -
_kToolbarContentDistance,
);
final anchorBelow = Offset(
globalEditableRegion.left + selectionMidpoint.dx,
globalEditableRegion.top +
endTextSelectionPoint.point.dy +
TextSelectionToolbar.kToolbarContentDistanceBelow,
);
return TextSelectionToolbar(
anchorAbove: anchorAbove,
anchorBelow: anchorBelow,
children: <Widget>[
TextSelectionToolbarTextButton(
padding: TextSelectionToolbarTextButton.getPadding(0, 1),
onPressed: () {},
child: const Text('Custom button'),
),
],
);
}
}
class TestBox extends SizedBox {
const TestBox({super.key, super.child}) : super(width: itemWidth, height: itemHeight);
static const double itemHeight = 44.0;
static const double itemWidth = 100.0;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// Find by a runtimeType String, including private types.
Finder findPrivate(String type) {
return find.descendant(
of: find.byType(MaterialApp),
matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == type),
);
}
// Finding TextSelectionToolbar won't give you the position as the user sees
// it because it's a full-sized Stack at the top level. This method finds the
// visible part of the toolbar for use in measurements.
Finder findToolbar() => findPrivate('_TextSelectionToolbarOverflowable');
Finder findOverflowButton() => findPrivate('_TextSelectionToolbarOverflowButton');
testWidgets('puts children in an overflow menu if they overflow', (WidgetTester tester) async {
late StateSetter setState;
final children = List<Widget>.generate(7, (int i) => const TestBox());
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return TextSelectionToolbar(
anchorAbove: const Offset(50.0, 100.0),
anchorBelow: const Offset(50.0, 200.0),
children: children,
);
},
),
),
),
);
// All children fit on the screen, so they are all rendered.
expect(find.byType(TestBox), findsNWidgets(children.length));
expect(findOverflowButton(), findsNothing);
// Adding one more child makes the children overflow.
setState(() {
children.add(const TestBox());
});
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNWidgets(children.length - 1));
expect(findOverflowButton(), findsOneWidget);
// Tap the overflow button to show the overflow menu.
await tester.tap(findOverflowButton());
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNWidgets(1));
expect(findOverflowButton(), findsOneWidget);
// Tap the overflow button again to hide the overflow menu.
await tester.tap(findOverflowButton());
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNWidgets(children.length - 1));
expect(findOverflowButton(), findsOneWidget);
});
testWidgets('positions itself at anchorAbove if it fits', (WidgetTester tester) async {
late StateSetter setState;
const height = 44.0;
const anchorBelowY = 500.0;
var anchorAboveY = 0.0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return TextSelectionToolbar(
anchorAbove: Offset(50.0, anchorAboveY),
anchorBelow: const Offset(50.0, anchorBelowY),
children: <Widget>[
Container(color: Colors.red, width: 50.0, height: height),
Container(color: Colors.green, width: 50.0, height: height),
Container(color: Colors.blue, width: 50.0, height: height),
],
);
},
),
),
),
);
// When the toolbar doesn't fit above aboveAnchor, it positions itself below
// belowAnchor.
double toolbarY = tester.getTopLeft(findToolbar()).dy;
expect(toolbarY, equals(anchorBelowY + TextSelectionToolbar.kToolbarContentDistanceBelow));
// Even when it barely doesn't fit.
setState(() {
anchorAboveY = 60.0;
});
await tester.pump();
toolbarY = tester.getTopLeft(findToolbar()).dy;
expect(toolbarY, equals(anchorBelowY + TextSelectionToolbar.kToolbarContentDistanceBelow));
// When it does fit above aboveAnchor, it positions itself there.
setState(() {
anchorAboveY = 70.0;
});
await tester.pump();
toolbarY = tester.getTopLeft(findToolbar()).dy;
expect(toolbarY, equals(anchorAboveY - height - _kToolbarContentDistance));
});
testWidgets('can create and use a custom toolbar', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SelectableText(
'Select me custom menu',
selectionControls: _CustomMaterialTextSelectionControls(),
),
),
),
),
);
// The selection menu is not initially shown.
expect(find.text('Custom button'), findsNothing);
// Long press on "custom" to select it.
final Offset customPos = textOffsetToPosition(tester, 11);
final TestGesture gesture = await tester.startGesture(customPos, pointer: 7);
await tester.pump(const Duration(seconds: 2));
await gesture.up();
await tester.pump();
// The custom selection menu is shown.
expect(find.text('Custom button'), findsOneWidget);
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsNothing);
expect(find.text('Select all'), findsNothing);
}, skip: kIsWeb); // [intended] We don't show the toolbar on the web.
for (final colorScheme in <ColorScheme>[ThemeData().colorScheme, ThemeData.dark().colorScheme]) {
testWidgets('default background color', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(colorScheme: colorScheme),
home: Scaffold(
body: Center(
child: TextSelectionToolbar(
anchorAbove: Offset.zero,
anchorBelow: Offset.zero,
children: <Widget>[
TextSelectionToolbarTextButton(
padding: TextSelectionToolbarTextButton.getPadding(0, 1),
onPressed: () {},
child: const Text('Custom button'),
),
],
),
),
),
),
);
Finder findToolbarContainer() {
return find.descendant(
of: find.byWidgetPredicate(
(Widget w) => '${w.runtimeType}' == '_TextSelectionToolbarContainer',
),
matching: find.byType(Material),
);
}
expect(findToolbarContainer(), findsAtLeastNWidgets(1));
final Material toolbarContainer = tester.widget(findToolbarContainer().first);
expect(
toolbarContainer.color,
// The default colors are hardcoded and don't take the default value of
// the theme's surface color.
switch (colorScheme.brightness) {
Brightness.light => const Color(0xffffffff),
Brightness.dark => const Color(0xff424242),
},
);
});
testWidgets('custom background color', (WidgetTester tester) async {
const Color customBackgroundColor = Colors.red;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(colorScheme: colorScheme.copyWith(surface: customBackgroundColor)),
home: Scaffold(
body: Center(
child: TextSelectionToolbar(
anchorAbove: Offset.zero,
anchorBelow: Offset.zero,
children: <Widget>[
TextSelectionToolbarTextButton(
padding: TextSelectionToolbarTextButton.getPadding(0, 1),
onPressed: () {},
child: const Text('Custom button'),
),
],
),
),
),
),
);
Finder findToolbarContainer() {
return find.descendant(
of: find.byWidgetPredicate(
(Widget w) => '${w.runtimeType}' == '_TextSelectionToolbarContainer',
),
matching: find.byType(Material),
);
}
expect(findToolbarContainer(), findsAtLeastNWidgets(1));
final Material toolbarContainer = tester.widget(findToolbarContainer().first);
expect(toolbarContainer.color, customBackgroundColor);
});
}
testWidgets('Overflowed menu expands children horizontally', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/144089.
late StateSetter setState;
final children = List<Widget>.generate(7, (int i) => const TestBox());
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return TextSelectionToolbar(
anchorAbove: const Offset(50.0, 100.0),
anchorBelow: const Offset(50.0, 200.0),
children: children,
);
},
),
),
),
);
// All children fit on the screen, so they are all rendered.
expect(find.byType(TestBox), findsNWidgets(children.length));
expect(findOverflowButton(), findsNothing);
const short = 'Short';
const medium = 'Medium length';
const long = 'Long label in the overflow menu';
// Adding several children makes the menu overflow.
setState(() {
children.addAll(const <Text>[Text(short), Text(medium), Text(long)]);
});
await tester.pumpAndSettle();
expect(findOverflowButton(), findsOneWidget);
// Tap the overflow button to show the overflow menu.
await tester.tap(findOverflowButton());
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNothing);
expect(find.byType(Text), findsNWidgets(3));
expect(findOverflowButton(), findsOneWidget);
Finder findToolbarContainer() {
return find.byWidgetPredicate(
(Widget w) => '${w.runtimeType}' == '_TextSelectionToolbarContainer',
);
}
expect(findToolbarContainer(), findsAtLeastNWidgets(1));
// Buttons have their width set to the container width.
final double overflowMenuWidth = tester.getRect(findToolbarContainer()).width;
expect(tester.getRect(find.text(long)).width, overflowMenuWidth);
expect(tester.getRect(find.text(medium)).width, overflowMenuWidth);
expect(tester.getRect(find.text(short)).width, overflowMenuWidth);
});
testWidgets('items are ordered right-to-left in RTL', (WidgetTester tester) async {
const itemCount = 3;
final children = List<Widget>.generate(
itemCount,
(int i) => TestBox(key: ValueKey<String>('item_$i'), child: Text('$i')),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Directionality(
textDirection: TextDirection.rtl,
child: TextSelectionToolbar(
anchorAbove: const Offset(50.0, 100.0),
anchorBelow: const Offset(50.0, 200.0),
children: children,
),
),
),
),
);
// Verify all items are visible.
expect(find.byType(TestBox), findsNWidgets(itemCount));
// Find all text widgets by their content and get their positions.
final textRects = List<Rect>.generate(itemCount, (int i) => tester.getRect(find.text('$i')));
// In RTL, items should be in reverse order (2, 1, 0).
// So item 2 should be leftmost, then 1, then 0.
for (var i = 0; i < itemCount - 1; i++) {
final Rect current = textRects[i];
final Rect next = textRects[i + 1];
// In RTL, each item should be to the left of the previous one.
expect(
next.right,
lessThanOrEqualTo(current.left),
reason: 'In RTL, item ${i + 1} should be to the left of item $i',
);
}
// Verify the visual order by checking the rightmost position.
final List<double> rightEdges = textRects.map((Rect r) => r.right).toList();
final sortedRightEdges = List<double>.from(rightEdges)
..sort((double a, double b) => b.compareTo(a));
expect(
rightEdges,
equals(sortedRightEdges),
reason: 'Items should be ordered right-to-left in RTL',
);
});
testWidgets('puts children in an overflow menu if they overflow in RTL', (
WidgetTester tester,
) async {
late StateSetter setState;
final children = List<Widget>.generate(7, (int i) => const TestBox());
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Directionality(
textDirection: TextDirection.rtl, // this makes the difference.
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return TextSelectionToolbar(
anchorAbove: const Offset(50.0, 100.0),
anchorBelow: const Offset(50.0, 200.0),
children: children,
);
},
),
),
),
),
);
// All children fit on the screen, so they are all rendered.
expect(find.byType(TestBox), findsNWidgets(children.length));
expect(findOverflowButton(), findsNothing);
// Adding one more child makes the children overflow.
setState(() {
children.add(const TestBox());
});
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNWidgets(children.length - 1));
expect(findOverflowButton(), findsOneWidget);
// Tap the overflow button to show the overflow menu.
await tester.tap(findOverflowButton());
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsOneWidget); // Only one item in the overflow menu.
expect(findOverflowButton(), findsOneWidget);
// Tap the overflow button again to hide the overflow menu.
await tester.tap(findOverflowButton());
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNWidgets(children.length - 1));
expect(findOverflowButton(), findsOneWidget);
});
}