// 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 endpoints, TextSelectionDelegate delegate, ValueListenable? 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: [ 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.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: [ 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 [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: [ 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: [ 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.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(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.generate( itemCount, (int i) => TestBox(key: ValueKey('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.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 rightEdges = textRects.map((Rect r) => r.right).toList(); final sortedRightEdges = List.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.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); }); }