// 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 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'clipboard_utils.dart'; import 'keyboard_utils.dart'; Offset textOffsetToPosition(RenderParagraph paragraph, int offset) { const caret = Rect.fromLTWH(0.0, 0.0, 2.0, 20.0); final Offset localOffset = paragraph.getOffsetForCaret(TextPosition(offset: offset), caret); return paragraph.localToGlobal(localOffset); } Offset globalize(Offset point, RenderBox box) { return box.localToGlobal(point); } void main() { TestWidgetsFlutterBinding.ensureInitialized(); final mockClipboard = MockClipboard(); setUp(() async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( SystemChannels.platform, mockClipboard.handleMethodCall, ); await Clipboard.setData(const ClipboardData(text: 'empty')); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( SystemChannels.platform, null, ); }); testWidgets('mouse can select multiple widgets', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: SelectionArea( selectionControls: materialTextSelectionControls, child: ListView.builder( itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); final RenderParagraph paragraph1 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); await tester.pump(); await gesture.moveTo(textOffsetToPosition(paragraph1, 4)); await tester.pump(); expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4)); final RenderParagraph paragraph2 = tester.renderObject( find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), ); await gesture.moveTo(textOffsetToPosition(paragraph2, 5)); // Should select the rest of paragraph 1. expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6)); expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); final RenderParagraph paragraph3 = tester.renderObject( find.descendant(of: find.text('Item 3'), matching: find.byType(RichText)), ); await gesture.moveTo(textOffsetToPosition(paragraph3, 3)); expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6)); expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3)); await gesture.up(); }); testWidgets('mouse can select multiple widgets - horizontal', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: SelectionArea( selectionControls: materialTextSelectionControls, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); final RenderParagraph paragraph1 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); await tester.pump(); await gesture.moveTo(textOffsetToPosition(paragraph1, 4)); await tester.pump(); expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4)); final RenderParagraph paragraph2 = tester.renderObject( find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), ); await gesture.moveTo(textOffsetToPosition(paragraph2, 5) + const Offset(0, 5)); // Should select the rest of paragraph 1. expect(paragraph1.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6)); expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); await gesture.up(); }); testWidgets('mouse can select multiple widgets on double-click drag', ( WidgetTester tester, ) async { await tester.pumpWidget( MaterialApp( home: SelectionArea( selectionControls: materialTextSelectionControls, child: ListView.builder( itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); final RenderParagraph paragraph1 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); await tester.pump(); await gesture.up(); await tester.pump(); await gesture.down(textOffsetToPosition(paragraph1, 2)); await tester.pumpAndSettle(); expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4)); await gesture.moveTo(textOffsetToPosition(paragraph1, 4)); await tester.pump(); expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); final RenderParagraph paragraph2 = tester.renderObject( find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), ); await gesture.moveTo(textOffsetToPosition(paragraph2, 4)); // Should select the rest of paragraph 1. expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); final RenderParagraph paragraph3 = tester.renderObject( find.descendant(of: find.text('Item 3'), matching: find.byType(RichText)), ); await gesture.moveTo(textOffsetToPosition(paragraph3, 3)); expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4)); await gesture.up(); }); testWidgets('mouse can select multiple widgets on double-click drag - horizontal', ( WidgetTester tester, ) async { await tester.pumpWidget( MaterialApp( home: SelectionArea( selectionControls: materialTextSelectionControls, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); final RenderParagraph paragraph1 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); await tester.pump(); await gesture.up(); await tester.pump(); await gesture.down(textOffsetToPosition(paragraph1, 2)); await tester.pumpAndSettle(); expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4)); await gesture.moveTo(textOffsetToPosition(paragraph1, 4)); await tester.pump(); expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5)); final RenderParagraph paragraph2 = tester.renderObject( find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), ); await gesture.moveTo(textOffsetToPosition(paragraph2, 5) + const Offset(0, 5)); // Should select the rest of paragraph 1. expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); await gesture.up(); }); testWidgets('mouse can select multiple widgets on triple-click drag', ( WidgetTester tester, ) async { await tester.pumpWidget( MaterialApp( home: SelectionArea( selectionControls: materialTextSelectionControls, child: ListView.builder( itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); final RenderParagraph paragraph1 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); await tester.pump(); await gesture.up(); await tester.pump(); await gesture.down(textOffsetToPosition(paragraph1, 2)); await tester.pump(); await gesture.up(); await tester.pump(); await gesture.down(textOffsetToPosition(paragraph1, 2)); await tester.pumpAndSettle(); expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); final RenderParagraph paragraph2 = tester.renderObject( find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), ); expect(paragraph2.selections.isEmpty, isTrue); await gesture.moveTo(textOffsetToPosition(paragraph2, 4)); // Should select paragraph 2. expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); final RenderParagraph paragraph3 = tester.renderObject( find.descendant(of: find.text('Item 3'), matching: find.byType(RichText)), ); expect(paragraph3.selections.isEmpty, isTrue); await gesture.moveTo(textOffsetToPosition(paragraph3, 3)); // Should select paragraph 3. expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); final RenderParagraph paragraph4 = tester.renderObject( find.descendant(of: find.text('Item 4'), matching: find.byType(RichText)), ); expect(paragraph4.selections.isEmpty, isTrue); await gesture.moveTo(textOffsetToPosition(paragraph4, 3)); // Should select paragraph 4. expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); expect(paragraph4.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); await gesture.up(); }); testWidgets('mouse can select multiple widgets on triple-click drag - horizontal', ( WidgetTester tester, ) async { await tester.pumpWidget( MaterialApp( home: SelectionArea( selectionControls: materialTextSelectionControls, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); final RenderParagraph paragraph1 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); await tester.pump(); await gesture.up(); await tester.pump(); await gesture.down(textOffsetToPosition(paragraph1, 2)); await tester.pump(); await gesture.up(); await tester.pump(); await gesture.down(textOffsetToPosition(paragraph1, 2)); await tester.pumpAndSettle(); expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); final RenderParagraph paragraph2 = tester.renderObject( find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), ); expect(paragraph2.selections.isEmpty, isTrue); await gesture.moveTo(textOffsetToPosition(paragraph2, 5) + const Offset(0, 50)); // Should select paragraph 2. expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); final RenderParagraph paragraph3 = tester.renderObject( find.descendant(of: find.text('Item 2'), matching: find.byType(RichText)), ); expect(paragraph3.selections.isEmpty, isTrue); await gesture.moveTo(textOffsetToPosition(paragraph3, 5) + const Offset(0, 50)); // Should select paragraph 3. expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); await gesture.up(); }); testWidgets('select to scroll forward', (WidgetTester tester) async { final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( selectionControls: materialTextSelectionControls, child: ListView.builder( controller: controller, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); final RenderParagraph paragraph1 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); await tester.pump(); expect(controller.offset, 0.0); double previousOffset = controller.offset; // Scrollable only auto scroll if the drag passes the boundary. await gesture.moveTo(tester.getBottomRight(find.byType(ListView)) + const Offset(0, 20)); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(controller.offset > previousOffset, isTrue); previousOffset = controller.offset; await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(controller.offset > previousOffset, isTrue); // Scroll to the end. await tester.pumpAndSettle(const Duration(seconds: 1)); expect(controller.offset, 4200.0); final RenderParagraph paragraph99 = tester.renderObject( find.descendant(of: find.text('Item 99'), matching: find.byType(RichText)), ); final RenderParagraph paragraph98 = tester.renderObject( find.descendant(of: find.text('Item 98'), matching: find.byType(RichText)), ); final RenderParagraph paragraph97 = tester.renderObject( find.descendant(of: find.text('Item 97'), matching: find.byType(RichText)), ); final RenderParagraph paragraph96 = tester.renderObject( find.descendant(of: find.text('Item 96'), matching: find.byType(RichText)), ); expect(paragraph99.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); expect(paragraph98.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); expect(paragraph97.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); expect(paragraph96.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); await gesture.up(); }); testWidgets('select to scroll works for small scrollable', (WidgetTester tester) async { final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: SelectionArea( selectionControls: materialTextSelectionControls, child: Scaffold( body: SizedBox( height: 10, child: ListView.builder( controller: controller, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ), ), ); await tester.pumpAndSettle(); final RenderParagraph paragraph1 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); await tester.pump(); expect(controller.offset, 0.0); double previousOffset = controller.offset; // Scrollable only auto scroll if the drag passes the boundary await gesture.moveTo(tester.getBottomRight(find.byType(ListView)) + const Offset(0, 20)); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(controller.offset > previousOffset, isTrue); previousOffset = controller.offset; await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(controller.offset > previousOffset, isTrue); await gesture.up(); // Shouldn't be stuck if gesture is up. await tester.pumpAndSettle(const Duration(seconds: 1)); expect(tester.takeException(), isNull); }); testWidgets('select to scroll backward', (WidgetTester tester) async { final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( selectionControls: materialTextSelectionControls, child: ListView.builder( controller: controller, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); controller.jumpTo(4000); await tester.pumpAndSettle(); final TestGesture gesture = await tester.startGesture( tester.getCenter(find.byType(ListView)), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); await tester.pump(); expect(controller.offset, 4000); double previousOffset = controller.offset; await gesture.moveTo(tester.getTopLeft(find.byType(ListView)) + const Offset(0, -20)); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(controller.offset < previousOffset, isTrue); previousOffset = controller.offset; await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(controller.offset < previousOffset, isTrue); // Scroll to the beginning. await tester.pumpAndSettle(const Duration(seconds: 1)); expect(controller.offset, 0.0); final RenderParagraph paragraph0 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final RenderParagraph paragraph1 = tester.renderObject( find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), ); final RenderParagraph paragraph2 = tester.renderObject( find.descendant(of: find.text('Item 2'), matching: find.byType(RichText)), ); final RenderParagraph paragraph3 = tester.renderObject( find.descendant(of: find.text('Item 3'), matching: find.byType(RichText)), ); expect(paragraph0.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0)); expect(paragraph1.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0)); expect(paragraph2.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0)); expect(paragraph3.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0)); }); testWidgets('select to scroll forward - horizontal', (WidgetTester tester) async { final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( selectionControls: materialTextSelectionControls, child: ListView.builder( scrollDirection: Axis.horizontal, controller: controller, itemCount: 10, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); final RenderParagraph paragraph1 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); await tester.pump(); expect(controller.offset, 0.0); double previousOffset = controller.offset; // Scrollable only auto scroll if the drag passes the boundary await gesture.moveTo(tester.getBottomRight(find.byType(ListView)) + const Offset(20, 0)); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(controller.offset > previousOffset, isTrue); previousOffset = controller.offset; await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(controller.offset > previousOffset, isTrue); // Scroll to the end. await tester.pumpAndSettle(const Duration(seconds: 1)); expect(controller.offset, 2080.0); final RenderParagraph paragraph9 = tester.renderObject( find.descendant(of: find.text('Item 9'), matching: find.byType(RichText)), ); final RenderParagraph paragraph8 = tester.renderObject( find.descendant(of: find.text('Item 8'), matching: find.byType(RichText)), ); final RenderParagraph paragraph7 = tester.renderObject( find.descendant(of: find.text('Item 7'), matching: find.byType(RichText)), ); expect(paragraph9.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); expect(paragraph8.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); expect(paragraph7.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6)); await gesture.up(); }); testWidgets('select to scroll backward - horizontal', (WidgetTester tester) async { final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( selectionControls: materialTextSelectionControls, child: ListView.builder( scrollDirection: Axis.horizontal, controller: controller, itemCount: 10, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); controller.jumpTo(2080); await tester.pumpAndSettle(); final TestGesture gesture = await tester.startGesture( tester.getCenter(find.byType(ListView)), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); await tester.pump(); expect(controller.offset, 2080); double previousOffset = controller.offset; await gesture.moveTo(tester.getTopLeft(find.byType(ListView)) + const Offset(-10, 0)); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(controller.offset < previousOffset, isTrue); previousOffset = controller.offset; await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(controller.offset < previousOffset, isTrue); // Scroll to the beginning. await tester.pumpAndSettle(const Duration(seconds: 1)); expect(controller.offset, 0.0); final RenderParagraph paragraph0 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final RenderParagraph paragraph1 = tester.renderObject( find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), ); final RenderParagraph paragraph2 = tester.renderObject( find.descendant(of: find.text('Item 2'), matching: find.byType(RichText)), ); expect(paragraph0.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0)); expect(paragraph1.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0)); expect(paragraph2.selections[0], const TextSelection(baseOffset: 6, extentOffset: 0)); await gesture.up(); }); testWidgets('preserve selection when out of view.', (WidgetTester tester) async { final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( selectionControls: materialTextSelectionControls, child: ListView.builder( controller: controller, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); controller.jumpTo(2000); await tester.pumpAndSettle(); expect(find.text('Item 50'), findsOneWidget); RenderParagraph paragraph50 = tester.renderObject( find.descendant(of: find.text('Item 50'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph50, 2), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); await tester.pump(); await gesture.moveTo(textOffsetToPosition(paragraph50, 4)); await gesture.up(); expect(paragraph50.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4)); controller.jumpTo(0); await tester.pumpAndSettle(); expect(find.text('Item 50'), findsNothing); controller.jumpTo(2000); await tester.pumpAndSettle(); expect(find.text('Item 50'), findsOneWidget); paragraph50 = tester.renderObject( find.descendant(of: find.text('Item 50'), matching: find.byType(RichText)), ); expect(paragraph50.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4)); controller.jumpTo(4000); await tester.pumpAndSettle(); expect(find.text('Item 50'), findsNothing); controller.jumpTo(2000); await tester.pumpAndSettle(); expect(find.text('Item 50'), findsOneWidget); paragraph50 = tester.renderObject( find.descendant(of: find.text('Item 50'), matching: find.byType(RichText)), ); expect(paragraph50.selections[0], const TextSelection(baseOffset: 2, extentOffset: 4)); }); testWidgets( 'can select all non-Apple', (WidgetTester tester) async { final node = FocusNode(); addTearDown(node.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( focusNode: node, selectionControls: materialTextSelectionControls, child: ListView.builder( itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); node.requestFocus(); await sendKeyCombination( tester, const SingleActivator(LogicalKeyboardKey.keyA, control: true), ); await tester.pump(); for (var i = 0; i < 13; i += 1) { final RenderParagraph paragraph = tester.renderObject( find.descendant(of: find.text('Item $i'), matching: find.byType(RichText)), ); expect( paragraph.selections[0], TextSelection(baseOffset: 0, extentOffset: 'Item $i'.length), ); } expect(find.text('Item 13'), findsNothing); }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia, }), ); testWidgets( 'can select all - Apple', (WidgetTester tester) async { final node = FocusNode(); addTearDown(node.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( focusNode: node, selectionControls: materialTextSelectionControls, child: ListView.builder( itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); node.requestFocus(); await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.keyA, meta: true)); await tester.pump(); for (var i = 0; i < 13; i += 1) { final RenderParagraph paragraph = tester.renderObject( find.descendant(of: find.text('Item $i'), matching: find.byType(RichText)), ); expect( paragraph.selections[0], TextSelection(baseOffset: 0, extentOffset: 'Item $i'.length), ); } expect(find.text('Item 13'), findsNothing); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS, }), ); testWidgets('select to scroll by dragging selection handles forward', ( WidgetTester tester, ) async { final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( selectionControls: materialTextSelectionControls, child: ListView.builder( controller: controller, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); // Long press to bring up the selection handles. final RenderParagraph paragraph0 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph0, 2)); addTearDown(gesture.removePointer); await tester.pump(const Duration(milliseconds: 500)); await gesture.up(); await tester.pumpAndSettle(); expect(paragraph0.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4)); final List boxes = paragraph0.getBoxesForSelection(paragraph0.selections[0]); expect(boxes.length, 1); // Find end handle. final Offset handlePos = globalize(boxes[0].toRect().bottomRight, paragraph0); await gesture.down(handlePos); expect(controller.offset, 0.0); double previousOffset = controller.offset; // Scrollable only auto scroll if the drag passes the boundary await gesture.moveTo(tester.getBottomRight(find.byType(ListView)) + const Offset(0, 40)); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(controller.offset > previousOffset, isTrue); previousOffset = controller.offset; await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(controller.offset > previousOffset, isTrue); // Scroll to the end. await tester.pumpAndSettle(const Duration(seconds: 1)); expect(controller.offset, 4200.0); final RenderParagraph paragraph99 = tester.renderObject( find.descendant(of: find.text('Item 99'), matching: find.byType(RichText)), ); final RenderParagraph paragraph98 = tester.renderObject( find.descendant(of: find.text('Item 98'), matching: find.byType(RichText)), ); final RenderParagraph paragraph97 = tester.renderObject( find.descendant(of: find.text('Item 97'), matching: find.byType(RichText)), ); final RenderParagraph paragraph96 = tester.renderObject( find.descendant(of: find.text('Item 96'), matching: find.byType(RichText)), ); expect(paragraph99.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); expect(paragraph98.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); expect(paragraph97.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); expect(paragraph96.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7)); await gesture.up(); }); testWidgets('select to scroll by dragging start selection handle stops scroll when released', ( WidgetTester tester, ) async { final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( selectionControls: materialTextSelectionControls, child: ListView.builder( controller: controller, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); // Long press to bring up the selection handles. final RenderParagraph paragraph0 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph0, 2)); addTearDown(gesture.removePointer); await tester.pump(const Duration(milliseconds: 500)); await gesture.up(); await tester.pumpAndSettle(); expect(paragraph0.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4)); final List boxes = paragraph0.getBoxesForSelection(paragraph0.selections[0]); expect(boxes.length, 1); // Find start handle. final Offset handlePos = globalize(boxes[0].toRect().bottomLeft, paragraph0); await gesture.down(handlePos); expect(controller.offset, 0.0); double previousOffset = controller.offset; // Scrollable only auto scroll if the drag passes the boundary. await gesture.moveTo(tester.getBottomRight(find.byType(ListView)) + const Offset(0, 40)); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(controller.offset > previousOffset, isTrue); previousOffset = controller.offset; await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(controller.offset > previousOffset, isTrue); previousOffset = controller.offset; // Release handle should stop scrolling. await gesture.up(); // Last scheduled scroll. await tester.pump(); await tester.pump(const Duration(seconds: 1)); previousOffset = controller.offset; await tester.pumpAndSettle(); expect(controller.offset, previousOffset); }); testWidgets('select to scroll by dragging end selection handle stops scroll when released', ( WidgetTester tester, ) async { final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( selectionControls: materialTextSelectionControls, child: ListView.builder( controller: controller, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); // Long press to bring up the selection handles. final RenderParagraph paragraph0 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph0, 2)); addTearDown(gesture.removePointer); await tester.pump(const Duration(milliseconds: 500)); await gesture.up(); await tester.pumpAndSettle(); expect(paragraph0.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4)); final List boxes = paragraph0.getBoxesForSelection(paragraph0.selections[0]); expect(boxes.length, 1); final Offset handlePos = globalize(boxes[0].toRect().bottomRight, paragraph0); await gesture.down(handlePos); expect(controller.offset, 0.0); double previousOffset = controller.offset; // Scrollable only auto scroll if the drag passes the boundary await gesture.moveTo(tester.getBottomRight(find.byType(ListView)) + const Offset(0, 40)); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(controller.offset > previousOffset, isTrue); previousOffset = controller.offset; await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(controller.offset > previousOffset, isTrue); previousOffset = controller.offset; // Release handle should stop scrolling. await gesture.up(); // Last scheduled scroll. await tester.pump(); await tester.pump(const Duration(seconds: 1)); previousOffset = controller.offset; await tester.pumpAndSettle(); expect(controller.offset, previousOffset); }); testWidgets('keyboard selection should auto scroll - vertical', (WidgetTester tester) async { final node = FocusNode(); addTearDown(node.dispose); final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( focusNode: node, selectionControls: materialTextSelectionControls, child: ListView.builder( controller: controller, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); final RenderParagraph paragraph9 = tester.renderObject( find.descendant(of: find.text('Item 9'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph9, 2), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); await gesture.moveTo(textOffsetToPosition(paragraph9, 4) + const Offset(0, 5)); await tester.pumpAndSettle(); await gesture.up(); await tester.pump(); expect(paragraph9.selections.length, 1); expect(paragraph9.selections[0].start, 2); expect(paragraph9.selections[0].end, 4); expect(controller.offset, 0.0); await sendKeyCombination( tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true), ); await tester.pump(); final RenderParagraph paragraph10 = tester.renderObject( find.descendant(of: find.text('Item 10'), matching: find.byType(RichText)), ); expect(paragraph10.selections.length, 1); expect(paragraph10.selections[0].start, 0); expect(paragraph10.selections[0].end, 4); expect(controller.offset, 0.0); await sendKeyCombination( tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true), ); await tester.pump(); final RenderParagraph paragraph11 = tester.renderObject( find.descendant(of: find.text('Item 11'), matching: find.byType(RichText)), ); expect(paragraph11.selections.length, 1); expect(paragraph11.selections[0].start, 0); expect(paragraph11.selections[0].end, 4); expect(controller.offset, 0.0); // Should start scrolling. await sendKeyCombination( tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true), ); await tester.pump(); final RenderParagraph paragraph12 = tester.renderObject( find.descendant(of: find.text('Item 12'), matching: find.byType(RichText)), ); expect(paragraph12.selections.length, 1); expect(paragraph12.selections[0].start, 0); expect(paragraph12.selections[0].end, 4); expect(controller.offset, 24.0); await sendKeyCombination( tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true), ); await tester.pump(); final RenderParagraph paragraph13 = tester.renderObject( find.descendant(of: find.text('Item 13'), matching: find.byType(RichText)), ); expect(paragraph13.selections.length, 1); expect(paragraph13.selections[0].start, 0); expect(paragraph13.selections[0].end, 4); expect(controller.offset, 72.0); }, variant: TargetPlatformVariant.all()); testWidgets('keyboard selection should auto scroll - vertical reversed', ( WidgetTester tester, ) async { final node = FocusNode(); addTearDown(node.dispose); final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( focusNode: node, selectionControls: materialTextSelectionControls, child: ListView.builder( controller: controller, reverse: true, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); final RenderParagraph paragraph9 = tester.renderObject( find.descendant(of: find.text('Item 9'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph9, 2), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); await gesture.moveTo(textOffsetToPosition(paragraph9, 4) + const Offset(0, 5)); await tester.pumpAndSettle(); await gesture.up(); await tester.pump(); expect(paragraph9.selections.length, 1); expect(paragraph9.selections[0].start, 2); expect(paragraph9.selections[0].end, 4); expect(controller.offset, 0.0); await sendKeyCombination( tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true), ); await tester.pump(); final RenderParagraph paragraph10 = tester.renderObject( find.descendant(of: find.text('Item 10'), matching: find.byType(RichText)), ); expect(paragraph10.selections.length, 1); expect(paragraph10.selections[0].start, 2); expect(paragraph10.selections[0].end, 7); expect(controller.offset, 0.0); await sendKeyCombination( tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true), ); await tester.pump(); final RenderParagraph paragraph11 = tester.renderObject( find.descendant(of: find.text('Item 11'), matching: find.byType(RichText)), ); expect(paragraph11.selections.length, 1); expect(paragraph11.selections[0].start, 2); expect(paragraph11.selections[0].end, 7); expect(controller.offset, 0.0); // Should start scrolling. await sendKeyCombination( tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true), ); await tester.pump(); final RenderParagraph paragraph12 = tester.renderObject( find.descendant(of: find.text('Item 12'), matching: find.byType(RichText)), ); expect(paragraph12.selections.length, 1); expect(paragraph12.selections[0].start, 2); expect(paragraph12.selections[0].end, 7); expect(controller.offset, 24.0); await sendKeyCombination( tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true), ); await tester.pump(); final RenderParagraph paragraph13 = tester.renderObject( find.descendant(of: find.text('Item 13'), matching: find.byType(RichText)), ); expect(paragraph13.selections.length, 1); expect(paragraph13.selections[0].start, 2); expect(paragraph13.selections[0].end, 7); expect(controller.offset, 72.0); }, variant: TargetPlatformVariant.all()); testWidgets('keyboard selection should auto scroll - horizontal', (WidgetTester tester) async { final node = FocusNode(); addTearDown(node.dispose); final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( focusNode: node, selectionControls: materialTextSelectionControls, child: ListView.builder( controller: controller, scrollDirection: Axis.horizontal, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); final RenderParagraph paragraph2 = tester.renderObject( find.descendant(of: find.text('Item 2'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph2, 0), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); await gesture.moveTo(textOffsetToPosition(paragraph2, 1) + const Offset(0, 5)); await tester.pumpAndSettle(); await gesture.up(); await tester.pump(); expect(paragraph2.selections.length, 1); expect(paragraph2.selections[0].start, 0); expect(paragraph2.selections[0].end, 1); expect(controller.offset, 0.0); await sendKeyCombination( tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true), ); await tester.pump(); expect(paragraph2.selections.length, 1); expect(paragraph2.selections[0].start, 0); expect(paragraph2.selections[0].end, 6); expect(controller.offset, 64.0); await sendKeyCombination( tester, const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true), ); await tester.pump(); final RenderParagraph paragraph3 = tester.renderObject( find.descendant(of: find.text('Item 3'), matching: find.byType(RichText)), ); expect(paragraph3.selections.length, 1); expect(paragraph3.selections[0].start, 0); expect(paragraph3.selections[0].end, 6); expect(controller.offset, 352.0); }, variant: TargetPlatformVariant.all()); testWidgets( 'keyboard selection should auto scroll - horizontal reversed', (WidgetTester tester) async { final node = FocusNode(); addTearDown(node.dispose); final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( focusNode: node, selectionControls: materialTextSelectionControls, child: ListView.builder( controller: controller, scrollDirection: Axis.horizontal, reverse: true, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); final RenderParagraph paragraph1 = tester.renderObject( find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph1, 5) + const Offset(0, 5), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); await gesture.moveTo(textOffsetToPosition(paragraph1, 4) + const Offset(0, 5)); await tester.pumpAndSettle(); await gesture.up(); await tester.pumpAndSettle(); expect(paragraph1.selections.length, 1); expect(paragraph1.selections[0].start, 4); expect(paragraph1.selections[0].end, 5); expect(controller.offset, 0.0); await sendKeyCombination( tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true), ); await tester.pump(); expect(paragraph1.selections.length, 1); expect(paragraph1.selections[0].start, 0); expect(paragraph1.selections[0].end, 5); expect(controller.offset, 0.0); await sendKeyCombination( tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true), ); await tester.pump(); final RenderParagraph paragraph2 = tester.renderObject( find.descendant(of: find.text('Item 2'), matching: find.byType(RichText)), ); expect(paragraph2.selections.length, 1); expect(paragraph2.selections[0].start, 0); expect(paragraph2.selections[0].end, 6); expect(controller.offset, 64.0); await sendKeyCombination( tester, const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true), ); await tester.pump(); final RenderParagraph paragraph3 = tester.renderObject( find.descendant(of: find.text('Item 3'), matching: find.byType(RichText)), ); expect(paragraph3.selections.length, 1); expect(paragraph3.selections[0].start, 0); expect(paragraph3.selections[0].end, 6); expect(controller.offset, 352.0); }, variant: TargetPlatformVariant.all(), ); group('Complex cases', () { testWidgets('selection starts outside of the scrollable', (WidgetTester tester) async { final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( selectionControls: materialTextSelectionControls, child: Column( children: [ const Text('Item 0'), SizedBox( height: 400, child: ListView.builder( controller: controller, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Inner item $index'); }, ), ), const Text('Item 1'), ], ), ), ), ); await tester.pumpAndSettle(); controller.jumpTo(1000); await tester.pumpAndSettle(); final RenderParagraph paragraph0 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph0, 2), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); final RenderParagraph paragraph1 = tester.renderObject( find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), ); await gesture.moveTo(textOffsetToPosition(paragraph1, 2) + const Offset(0, 5)); await tester.pumpAndSettle(); await gesture.up(); // The entire scrollable should be selected. expect(paragraph0.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6)); expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 2)); final RenderParagraph innerParagraph = tester.renderObject( find.descendant(of: find.text('Inner item 20'), matching: find.byType(RichText)), ); expect(innerParagraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 13)); // Should not scroll the inner scrollable. expect(controller.offset, 1000.0); }); testWidgets('nested scrollables keep selection alive', (WidgetTester tester) async { final outerController = ScrollController(); addTearDown(outerController.dispose); final innerController = ScrollController(); addTearDown(innerController.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( selectionControls: materialTextSelectionControls, child: ListView.builder( controller: outerController, itemCount: 100, itemBuilder: (BuildContext context, int index) { if (index == 2) { return SizedBox( height: 700, child: ListView.builder( controller: innerController, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Iteminner $index'); }, ), ); } return Text('Item $index'); }, ), ), ), ); await tester.pumpAndSettle(); innerController.jumpTo(1000); await tester.pumpAndSettle(); RenderParagraph innerParagraph23 = tester.renderObject( find.descendant(of: find.text('Iteminner 23'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(innerParagraph23, 2) + const Offset(0, 5), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); RenderParagraph innerParagraph24 = tester.renderObject( find.descendant(of: find.text('Iteminner 24'), matching: find.byType(RichText)), ); await gesture.moveTo(textOffsetToPosition(innerParagraph24, 2) + const Offset(0, 5)); await tester.pumpAndSettle(); await gesture.up(); expect(innerParagraph23.selections[0], const TextSelection(baseOffset: 2, extentOffset: 12)); expect(innerParagraph24.selections[0], const TextSelection(baseOffset: 0, extentOffset: 2)); innerController.jumpTo(2000); await tester.pumpAndSettle(); expect( find.descendant(of: find.text('Iteminner 23'), matching: find.byType(RichText)), findsNothing, ); outerController.jumpTo(2000); await tester.pumpAndSettle(); expect( find.descendant(of: find.text('Iteminner 23'), matching: find.byType(RichText)), findsNothing, ); // Selected item is still kept alive. expect( find.descendant( of: find.text('Iteminner 23'), matching: find.byType(RichText), skipOffstage: false, ), findsNothing, ); // Selection stays the same after scrolling back. outerController.jumpTo(0); await tester.pumpAndSettle(); expect(innerController.offset, 2000.0); innerController.jumpTo(1000); await tester.pumpAndSettle(); innerParagraph23 = tester.renderObject( find.descendant(of: find.text('Iteminner 23'), matching: find.byType(RichText)), ); innerParagraph24 = tester.renderObject( find.descendant(of: find.text('Iteminner 24'), matching: find.byType(RichText)), ); expect(innerParagraph23.selections[0], const TextSelection(baseOffset: 2, extentOffset: 12)); expect(innerParagraph24.selections[0], const TextSelection(baseOffset: 0, extentOffset: 2)); }); testWidgets( 'can copy off screen selection - Apple', (WidgetTester tester) async { final controller = ScrollController(); addTearDown(controller.dispose); final focusNode = FocusNode(); addTearDown(focusNode.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( focusNode: focusNode, selectionControls: materialTextSelectionControls, child: ListView.builder( controller: controller, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); focusNode.requestFocus(); await tester.pumpAndSettle(); final RenderParagraph paragraph0 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph0, 2) + const Offset(0, 5), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); final RenderParagraph paragraph1 = tester.renderObject( find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), ); await gesture.moveTo(textOffsetToPosition(paragraph1, 2) + const Offset(0, 5)); await tester.pumpAndSettle(); await gesture.up(); expect(paragraph0.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6)); expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 2)); // Scroll the selected text out off the screen. controller.jumpTo(1000); await tester.pumpAndSettle(); expect( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), findsNothing, ); expect( find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), findsNothing, ); // Start copying. await sendKeyCombination( tester, const SingleActivator(LogicalKeyboardKey.keyC, meta: true), ); final clipboardData = mockClipboard.clipboardData as Map; expect(clipboardData['text'], 'em 0It'); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS, }), ); testWidgets( 'can copy off screen selection - non-Apple', (WidgetTester tester) async { final controller = ScrollController(); addTearDown(controller.dispose); final focusNode = FocusNode(); addTearDown(focusNode.dispose); await tester.pumpWidget( MaterialApp( home: SelectionArea( focusNode: focusNode, selectionControls: materialTextSelectionControls, child: ListView.builder( controller: controller, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), ), ); focusNode.requestFocus(); await tester.pumpAndSettle(); final RenderParagraph paragraph0 = tester.renderObject( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), ); final TestGesture gesture = await tester.startGesture( textOffsetToPosition(paragraph0, 2) + const Offset(0, 5), kind: ui.PointerDeviceKind.mouse, ); addTearDown(gesture.removePointer); final RenderParagraph paragraph1 = tester.renderObject( find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), ); await gesture.moveTo(textOffsetToPosition(paragraph1, 2) + const Offset(0, 5)); await tester.pumpAndSettle(); await gesture.up(); expect(paragraph0.selections[0], const TextSelection(baseOffset: 2, extentOffset: 6)); expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 2)); // Scroll the selected text out off the screen. controller.jumpTo(1000); await tester.pumpAndSettle(); expect( find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)), findsNothing, ); expect( find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)), findsNothing, ); // Start copying. await sendKeyCombination( tester, const SingleActivator(LogicalKeyboardKey.keyC, control: true), ); final clipboardData = mockClipboard.clipboardData as Map; expect(clipboardData['text'], 'em 0It'); }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia, }), ); }); }