
This re-implements keyboard text selection so that it will work on platforms other than Android (e.g. macOS, Linux, etc.). Also, fixed a number of bugs in editing selection via a hardware keyboard (unable to select backwards, incorrect conversion to ASCII when cutting to clipboard, lack of support for CTRL-SHIFT-ARROW word selection, etc.). Did not address the keyboard locale issues that remain, or add platform specific switches for the bindings. All that will need some more design work before implementing them. Related Issues Fixes #31951
3727 lines
124 KiB
Dart
3727 lines
124 KiB
Dart
// Copyright 2015 The Chromium 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:async';
|
|
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind;
|
|
|
|
import '../widgets/semantics_tester.dart';
|
|
|
|
class MockClipboard {
|
|
Object _clipboardData = <String, dynamic>{
|
|
'text': null,
|
|
};
|
|
|
|
Future<dynamic> handleMethodCall(MethodCall methodCall) async {
|
|
switch (methodCall.method) {
|
|
case 'Clipboard.getData':
|
|
return _clipboardData;
|
|
case 'Clipboard.setData':
|
|
_clipboardData = methodCall.arguments;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
class MaterialLocalizationsDelegate extends LocalizationsDelegate<MaterialLocalizations> {
|
|
@override
|
|
bool isSupported(Locale locale) => true;
|
|
|
|
@override
|
|
Future<MaterialLocalizations> load(Locale locale) => DefaultMaterialLocalizations.load(locale);
|
|
|
|
@override
|
|
bool shouldReload(MaterialLocalizationsDelegate old) => false;
|
|
}
|
|
|
|
class WidgetsLocalizationsDelegate extends LocalizationsDelegate<WidgetsLocalizations> {
|
|
@override
|
|
bool isSupported(Locale locale) => true;
|
|
|
|
@override
|
|
Future<WidgetsLocalizations> load(Locale locale) => DefaultWidgetsLocalizations.load(locale);
|
|
|
|
@override
|
|
bool shouldReload(WidgetsLocalizationsDelegate old) => false;
|
|
}
|
|
|
|
Widget overlay({ Widget child }) {
|
|
final OverlayEntry entry = OverlayEntry(
|
|
builder: (BuildContext context) {
|
|
return Center(
|
|
child: Material(
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
return overlayWithEntry(entry);
|
|
}
|
|
|
|
Widget overlayWithEntry(OverlayEntry entry) {
|
|
return Localizations(
|
|
locale: const Locale('en', 'US'),
|
|
delegates: <LocalizationsDelegate<dynamic>>[
|
|
WidgetsLocalizationsDelegate(),
|
|
MaterialLocalizationsDelegate(),
|
|
],
|
|
child: Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: MediaQuery(
|
|
data: const MediaQueryData(size: Size(800.0, 600.0)),
|
|
child: Overlay(
|
|
initialEntries: <OverlayEntry>[
|
|
entry,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget boilerplate({ Widget child }) {
|
|
return Localizations(
|
|
locale: const Locale('en', 'US'),
|
|
delegates: <LocalizationsDelegate<dynamic>>[
|
|
WidgetsLocalizationsDelegate(),
|
|
MaterialLocalizationsDelegate(),
|
|
],
|
|
child: Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: MediaQuery(
|
|
data: const MediaQueryData(size: Size(800.0, 600.0)),
|
|
child: Center(
|
|
child: Material(
|
|
child: child,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> skipPastScrollingAnimation(WidgetTester tester) async {
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
}
|
|
|
|
double getOpacity(WidgetTester tester, Finder finder) {
|
|
return tester.widget<FadeTransition>(
|
|
find.ancestor(
|
|
of: finder,
|
|
matching: find.byType(FadeTransition),
|
|
),
|
|
).opacity.value;
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
final MockClipboard mockClipboard = MockClipboard();
|
|
SystemChannels.platform.setMockMethodCallHandler(mockClipboard.handleMethodCall);
|
|
|
|
const String kThreeLines =
|
|
'First line of text is\n'
|
|
'Second line goes until\n'
|
|
'Third line of stuff';
|
|
const String kMoreThanFourLines =
|
|
kThreeLines +
|
|
'\nFourth line won\'t display and ends at';
|
|
|
|
// Returns the first RenderEditable.
|
|
RenderEditable findRenderEditable(WidgetTester tester) {
|
|
final RenderObject root = tester.renderObject(find.byType(EditableText));
|
|
expect(root, isNotNull);
|
|
|
|
RenderEditable renderEditable;
|
|
void recursiveFinder(RenderObject child) {
|
|
if (child is RenderEditable) {
|
|
renderEditable = child;
|
|
return;
|
|
}
|
|
child.visitChildren(recursiveFinder);
|
|
}
|
|
root.visitChildren(recursiveFinder);
|
|
expect(renderEditable, isNotNull);
|
|
return renderEditable;
|
|
}
|
|
|
|
List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) {
|
|
return points.map<TextSelectionPoint>((TextSelectionPoint point) {
|
|
return TextSelectionPoint(
|
|
box.localToGlobal(point.point),
|
|
point.direction,
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
Offset textOffsetToPosition(WidgetTester tester, int offset) {
|
|
final RenderEditable renderEditable = findRenderEditable(tester);
|
|
final List<TextSelectionPoint> endpoints = globalize(
|
|
renderEditable.getEndpointsForSelection(
|
|
TextSelection.collapsed(offset: offset),
|
|
),
|
|
renderEditable,
|
|
);
|
|
expect(endpoints.length, 1);
|
|
return endpoints[0].point + const Offset(0.0, -2.0);
|
|
}
|
|
|
|
setUp(() {
|
|
debugResetSemanticsIdCounter();
|
|
});
|
|
|
|
Widget selectableTextBuilder({
|
|
String text = '',
|
|
int maxLines = 1,
|
|
}) {
|
|
return boilerplate(
|
|
child: SelectableText(
|
|
text,
|
|
style: const TextStyle(color: Colors.black, fontSize: 34.0),
|
|
maxLines: maxLines,
|
|
),
|
|
);
|
|
}
|
|
|
|
testWidgets('has expected defaults', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MediaQuery(
|
|
data: MediaQueryData(devicePixelRatio: 1.0),
|
|
child: Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: SelectableText('selectable text'),
|
|
),
|
|
),
|
|
);
|
|
|
|
final SelectableText selectableText =
|
|
tester.firstWidget(find.byType(SelectableText));
|
|
expect(selectableText.showCursor, false);
|
|
expect(selectableText.autofocus, false);
|
|
expect(selectableText.dragStartBehavior, DragStartBehavior.start);
|
|
expect(selectableText.cursorWidth, 2.0);
|
|
expect(selectableText.enableInteractiveSelection, true);
|
|
});
|
|
|
|
testWidgets('Rich selectable text has expected defaults', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MediaQuery(
|
|
data: MediaQueryData(devicePixelRatio: 1.0),
|
|
child: Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: SelectableText.rich(
|
|
TextSpan(
|
|
text: 'First line!',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontFamily: 'Roboto',
|
|
),
|
|
children: <TextSpan>[
|
|
TextSpan(
|
|
text: 'Second line!\n',
|
|
style: TextStyle(
|
|
fontSize: 30,
|
|
fontFamily: 'Roboto',
|
|
),
|
|
),
|
|
TextSpan(
|
|
text: 'Third line!\n',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontFamily: 'Roboto',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final SelectableText selectableText =
|
|
tester.firstWidget(find.byType(SelectableText));
|
|
expect(selectableText.showCursor, false);
|
|
expect(selectableText.autofocus, false);
|
|
expect(selectableText.dragStartBehavior, DragStartBehavior.start);
|
|
expect(selectableText.cursorWidth, 2.0);
|
|
expect(selectableText.enableInteractiveSelection, true);
|
|
});
|
|
|
|
testWidgets('Rich selectable text only support TextSpan', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MediaQuery(
|
|
data: MediaQueryData(devicePixelRatio: 1.0),
|
|
child: Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: SelectableText.rich(
|
|
TextSpan(
|
|
text: 'First line!',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontFamily: 'Roboto',
|
|
),
|
|
children: <InlineSpan>[
|
|
WidgetSpan(
|
|
child: SizedBox(
|
|
width: 120,
|
|
height: 50,
|
|
child: Card(
|
|
child: Center(
|
|
child: Text('Hello World!')
|
|
)
|
|
),
|
|
),
|
|
),
|
|
TextSpan(
|
|
text: 'Third line!\n',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontFamily: 'Roboto',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
expect(tester.takeException(), isAssertionError);
|
|
});
|
|
|
|
testWidgets('no text keyboard when widget is focused', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: const SelectableText('selectable text'),
|
|
),
|
|
);
|
|
await tester.tap(find.byType(SelectableText));
|
|
await tester.idle();
|
|
expect(tester.testTextInput.hasAnyClients, false);
|
|
});
|
|
|
|
testWidgets('Selectable Text has adaptive size', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
boilerplate(
|
|
child: const SelectableText('s'),
|
|
),
|
|
);
|
|
|
|
RenderBox findSelectableTextBox() => tester.renderObject(find.byType(SelectableText));
|
|
|
|
final RenderBox textBox = findSelectableTextBox();
|
|
expect(textBox.size, const Size(17.0, 14.0));
|
|
|
|
await tester.pumpWidget(
|
|
boilerplate(
|
|
child: const SelectableText('very very long'),
|
|
),
|
|
);
|
|
|
|
final RenderBox longtextBox = findSelectableTextBox();
|
|
expect(longtextBox.size, const Size(199.0, 14.0));
|
|
});
|
|
|
|
testWidgets('can switch between textWidthBasis', (WidgetTester tester) async {
|
|
RenderBox findTextBox() => tester.renderObject(find.byType(SelectableText));
|
|
const String text = 'I can face roll keyboardkeyboardaszzaaaaszzaaaaszzaaaaszzaaaa';
|
|
await tester.pumpWidget(
|
|
boilerplate(
|
|
child: const SelectableText(
|
|
text,
|
|
textWidthBasis: TextWidthBasis.parent,
|
|
),
|
|
),
|
|
);
|
|
RenderBox textBox = findTextBox();
|
|
expect(textBox.size, const Size(800.0, 28.0));
|
|
|
|
await tester.pumpWidget(
|
|
boilerplate(
|
|
child: const SelectableText(
|
|
text,
|
|
textWidthBasis: TextWidthBasis.longestLine,
|
|
),
|
|
),
|
|
);
|
|
textBox = findTextBox();
|
|
expect(textBox.size, const Size(633.0, 28.0));
|
|
});
|
|
|
|
testWidgets('Cursor blinks when showCursor is true', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: const SelectableText(
|
|
'some text',
|
|
showCursor: true,
|
|
),
|
|
),
|
|
);
|
|
await tester.tap(find.byType(SelectableText));
|
|
await tester.idle();
|
|
|
|
final EditableTextState editableText = tester.state(find.byType(EditableText));
|
|
|
|
// Check that the cursor visibility toggles after each blink interval.
|
|
final bool initialShowCursor = editableText.cursorCurrentlyVisible;
|
|
await tester.pump(editableText.cursorBlinkInterval);
|
|
expect(editableText.cursorCurrentlyVisible, equals(!initialShowCursor));
|
|
await tester.pump(editableText.cursorBlinkInterval);
|
|
expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor));
|
|
await tester.pump(editableText.cursorBlinkInterval ~/ 10);
|
|
expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor));
|
|
await tester.pump(editableText.cursorBlinkInterval);
|
|
expect(editableText.cursorCurrentlyVisible, equals(!initialShowCursor));
|
|
await tester.pump(editableText.cursorBlinkInterval);
|
|
expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor));
|
|
});
|
|
|
|
testWidgets('selectable text selection toolbar renders correctly inside opacity', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: Center(
|
|
child: Container(
|
|
width: 100,
|
|
height: 100,
|
|
child: const Opacity(
|
|
opacity: 0.5,
|
|
child: SelectableText('selectable text'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
// The selectWordsInRange with SelectionChangedCause.tap seems to be needed to show the toolbar.
|
|
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
|
|
state.renderEditable.selectWordsInRange(from: const Offset(0, 0), cause: SelectionChangedCause.tap);
|
|
|
|
expect(state.showToolbar(), true);
|
|
|
|
// This is needed for the AnimatedOpacity to turn from 0 to 1 so the toolbar is visible.
|
|
await tester.pumpAndSettle();
|
|
await tester.pump(const Duration(seconds: 1));
|
|
|
|
expect(find.text('SELECT ALL'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Caret position is updated on tap', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: const SelectableText('abc def ghi'),
|
|
),
|
|
);
|
|
final EditableText editableText = tester.widget(find.byType(EditableText));
|
|
expect(editableText.controller.selection.baseOffset, -1);
|
|
expect(editableText.controller.selection.extentOffset, -1);
|
|
|
|
// Tap to reposition the caret.
|
|
const int tapIndex = 4;
|
|
final Offset ePos = textOffsetToPosition(tester, tapIndex);
|
|
await tester.tapAt(ePos);
|
|
await tester.pump();
|
|
|
|
expect(editableText.controller.selection.baseOffset, tapIndex);
|
|
expect(editableText.controller.selection.extentOffset, tapIndex);
|
|
});
|
|
|
|
testWidgets('enableInteractiveSelection = false, tap', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: const SelectableText(
|
|
'abc def ghi',
|
|
enableInteractiveSelection: false,
|
|
),
|
|
),
|
|
);
|
|
final EditableText editableText = tester.widget(find.byType(EditableText));
|
|
expect(editableText.controller.selection.baseOffset, -1);
|
|
expect(editableText.controller.selection.extentOffset, -1);
|
|
|
|
// Tap would ordinarily reposition the caret.
|
|
const int tapIndex = 4;
|
|
final Offset ePos = textOffsetToPosition(tester, tapIndex);
|
|
await tester.tapAt(ePos);
|
|
await tester.pump();
|
|
|
|
expect(editableText.controller.selection.baseOffset, -1);
|
|
expect(editableText.controller.selection.extentOffset, -1);
|
|
});
|
|
|
|
testWidgets('enableInteractiveSelection = false, long-press', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: const SelectableText(
|
|
'abc def ghi',
|
|
enableInteractiveSelection: false,
|
|
),
|
|
),
|
|
);
|
|
final EditableText editableText = tester.widget(find.byType(EditableText));
|
|
expect(editableText.controller.selection.baseOffset, -1);
|
|
expect(editableText.controller.selection.extentOffset, -1);
|
|
|
|
// Long press the 'e' to select 'def'.
|
|
final Offset ePos = textOffsetToPosition(tester, 5);
|
|
final TestGesture gesture = await tester.startGesture(ePos, pointer: 7);
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await gesture.up();
|
|
await tester.pump();
|
|
|
|
expect(editableText.controller.selection.isCollapsed, true);
|
|
expect(editableText.controller.selection.baseOffset, -1);
|
|
expect(editableText.controller.selection.extentOffset, -1);
|
|
});
|
|
|
|
testWidgets('Can long press to select', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: const SelectableText('abc def ghi'),
|
|
),
|
|
);
|
|
|
|
final EditableText editableText = tester.widget(find.byType(EditableText));
|
|
|
|
expect(editableText.controller.selection.isCollapsed, true);
|
|
|
|
// Long press the 'e' to select 'def'.
|
|
const int tapIndex = 5;
|
|
final Offset ePos = textOffsetToPosition(tester, tapIndex);
|
|
await tester.longPressAt(ePos);
|
|
await tester.pump();
|
|
|
|
// 'def' is selected.
|
|
expect(editableText.controller.selection.baseOffset, 4);
|
|
expect(editableText.controller.selection.extentOffset, 7);
|
|
|
|
// Tapping elsewhere immediately collapses and moves the cursor.
|
|
await tester.tapAt(textOffsetToPosition(tester, 9));
|
|
await tester.pump();
|
|
|
|
expect(editableText.controller.selection.isCollapsed, true);
|
|
expect(editableText.controller.selection.baseOffset, 9);
|
|
});
|
|
|
|
testWidgets('Slight movements in longpress don\'t hide/show handles', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: const SelectableText('abc def ghi'),
|
|
),
|
|
);
|
|
// Long press the 'e' to select 'def', but don't release the gesture.
|
|
final Offset ePos = textOffsetToPosition(tester, 5);
|
|
final TestGesture gesture = await tester.startGesture(ePos, pointer: 7);
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Handles are shown
|
|
final Finder fadeFinder = find.byType(FadeTransition);
|
|
expect(fadeFinder, findsNWidgets(2)); // 2 handles, 1 toolbar
|
|
FadeTransition handle = tester.widget(fadeFinder.at(0));
|
|
expect(handle.opacity.value, equals(1.0));
|
|
|
|
// Move the gesture very slightly
|
|
await gesture.moveBy(const Offset(1.0, 1.0));
|
|
await tester.pump(TextSelectionOverlay.fadeDuration * 0.5);
|
|
handle = tester.widget(fadeFinder.at(0));
|
|
|
|
// The handle should still be fully opaque.
|
|
expect(handle.opacity.value, equals(1.0));
|
|
});
|
|
|
|
testWidgets('Mouse long press is just like a tap', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: const SelectableText('abc def ghi'),
|
|
),
|
|
);
|
|
|
|
final EditableText editableText = tester.widget(find.byType(EditableText));
|
|
|
|
// Long press the 'e' using a mouse device.
|
|
const int eIndex = 5;
|
|
final Offset ePos = textOffsetToPosition(tester, eIndex);
|
|
final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse);
|
|
addTearDown(gesture.removePointer);
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await gesture.up();
|
|
await tester.pump();
|
|
|
|
// The cursor is placed just like a regular tap.
|
|
expect(editableText.controller.selection.baseOffset, eIndex);
|
|
expect(editableText.controller.selection.extentOffset, eIndex);
|
|
});
|
|
|
|
testWidgets('selectable text basic', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: const SelectableText('selectable'),
|
|
),
|
|
);
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
|
|
// selectable text cannot open keyboard.
|
|
await tester.showKeyboard(find.byType(SelectableText));
|
|
expect(tester.testTextInput.hasAnyClients, false);
|
|
await skipPastScrollingAnimation(tester);
|
|
|
|
expect(editableTextWidget.controller.selection.isCollapsed, true);
|
|
|
|
await tester.tap(find.byType(SelectableText));
|
|
await tester.pump();
|
|
|
|
final EditableTextState editableText = tester.state(find.byType(EditableText));
|
|
// Collapse selection should not paint.
|
|
expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
|
|
// Long press on the 't' character of text 'selectable' to show context menu.
|
|
const int dIndex = 5;
|
|
final Offset dPos = textOffsetToPosition(tester, dIndex);
|
|
await tester.longPressAt(dPos);
|
|
await tester.pump();
|
|
|
|
// Context menu should not have paste and cut.
|
|
expect(find.text('COPY'), findsOneWidget);
|
|
expect(find.text('PASTE'), findsNothing);
|
|
expect(find.text('CUT'), findsNothing);
|
|
});
|
|
|
|
testWidgets('selectable text can disable toolbar options', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: const SelectableText(
|
|
'a selectable text',
|
|
toolbarOptions: ToolbarOptions(
|
|
copy: false,
|
|
selectAll: true,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
const int dIndex = 5;
|
|
final Offset dPos = textOffsetToPosition(tester, dIndex);
|
|
await tester.longPressAt(dPos);
|
|
await tester.pump();
|
|
// Context menu should not have copy.
|
|
expect(find.text('COPY'), findsNothing);
|
|
expect(find.text('SELECT ALL'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Can select text by dragging with a mouse', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: SelectableText(
|
|
'abc def ghi',
|
|
dragStartBehavior: DragStartBehavior.down,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
final Offset ePos = textOffsetToPosition(tester, 5);
|
|
final Offset gPos = textOffsetToPosition(tester, 8);
|
|
|
|
final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse);
|
|
addTearDown(gesture.removePointer);
|
|
await tester.pump();
|
|
await gesture.moveTo(gPos);
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.baseOffset, 5);
|
|
expect(controller.selection.extentOffset, 8);
|
|
});
|
|
|
|
testWidgets('Continuous dragging does not cause flickering', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: SelectableText(
|
|
'abc def ghi',
|
|
dragStartBehavior: DragStartBehavior.down,
|
|
style: TextStyle(fontFamily: 'Ahem', fontSize: 10.0),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
int selectionChangedCount = 0;
|
|
|
|
controller.addListener(() {
|
|
selectionChangedCount++;
|
|
});
|
|
|
|
final Offset cPos = textOffsetToPosition(tester, 2); // Index of 'c'.
|
|
final Offset gPos = textOffsetToPosition(tester, 8); // Index of 'g'.
|
|
final Offset hPos = textOffsetToPosition(tester, 9); // Index of 'h'.
|
|
|
|
// Drag from 'c' to 'g'.
|
|
final TestGesture gesture = await tester.startGesture(cPos, kind: PointerDeviceKind.mouse);
|
|
addTearDown(gesture.removePointer);
|
|
await tester.pump();
|
|
await gesture.moveTo(gPos);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(selectionChangedCount, isNonZero);
|
|
selectionChangedCount = 0;
|
|
expect(controller.selection.baseOffset, 2);
|
|
expect(controller.selection.extentOffset, 8);
|
|
|
|
// Tiny movement shouldn't cause text selection to change.
|
|
await gesture.moveTo(gPos + const Offset(4.0, 0.0));
|
|
await tester.pumpAndSettle();
|
|
expect(selectionChangedCount, 0);
|
|
|
|
// Now a text selection change will occur after a significant movement.
|
|
await gesture.moveTo(hPos);
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(selectionChangedCount, 1);
|
|
expect(controller.selection.baseOffset, 2);
|
|
expect(controller.selection.extentOffset, 9);
|
|
});
|
|
|
|
testWidgets('Dragging in opposite direction also works', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: SelectableText(
|
|
'abc def ghi',
|
|
dragStartBehavior: DragStartBehavior.down,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
final Offset ePos = textOffsetToPosition(tester, 5);
|
|
final Offset gPos = textOffsetToPosition(tester, 8);
|
|
|
|
final TestGesture gesture = await tester.startGesture(gPos, kind: PointerDeviceKind.mouse);
|
|
addTearDown(gesture.removePointer);
|
|
await tester.pump();
|
|
await gesture.moveTo(ePos);
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.baseOffset, 5);
|
|
expect(controller.selection.extentOffset, 8);
|
|
});
|
|
|
|
testWidgets('Slow mouse dragging also selects text', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: SelectableText(
|
|
'abc def ghi',
|
|
dragStartBehavior: DragStartBehavior.down,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
final Offset ePos = textOffsetToPosition(tester, 5);
|
|
final Offset gPos = textOffsetToPosition(tester,8);
|
|
|
|
final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse);
|
|
addTearDown(gesture.removePointer);
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await gesture.moveTo(gPos);
|
|
await tester.pump();
|
|
await gesture.up();
|
|
|
|
expect(controller.selection.baseOffset, 5);
|
|
expect(controller.selection.extentOffset,8);
|
|
});
|
|
|
|
testWidgets('Can drag handles to change selection', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: SelectableText(
|
|
'abc def ghi',
|
|
dragStartBehavior: DragStartBehavior.down,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// Long press the 'e' to select 'def'.
|
|
final Offset ePos = textOffsetToPosition(tester, 5);
|
|
TestGesture gesture = await tester.startGesture(ePos, pointer: 7);
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await gesture.up();
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
|
|
|
|
final TextSelection selection = controller.selection;
|
|
expect(selection.baseOffset, 4);
|
|
expect(selection.extentOffset, 7);
|
|
|
|
final RenderEditable renderEditable = findRenderEditable(tester);
|
|
final List<TextSelectionPoint> endpoints = globalize(
|
|
renderEditable.getEndpointsForSelection(selection),
|
|
renderEditable,
|
|
);
|
|
expect(endpoints.length, 2);
|
|
|
|
// Drag the right handle 2 letters to the right.
|
|
// We use a small offset because the endpoint is on the very corner
|
|
// of the handle.
|
|
Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0);
|
|
Offset newHandlePos = textOffsetToPosition(tester, 11);
|
|
gesture = await tester.startGesture(handlePos, pointer: 7);
|
|
await tester.pump();
|
|
await gesture.moveTo(newHandlePos);
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pump();
|
|
|
|
expect(controller.selection.baseOffset, 4);
|
|
expect(controller.selection.extentOffset, 11);
|
|
|
|
// Drag the left handle 2 letters to the left.
|
|
handlePos = endpoints[0].point + const Offset(-1.0, 1.0);
|
|
newHandlePos = textOffsetToPosition(tester, 0);
|
|
gesture = await tester.startGesture(handlePos, pointer: 7);
|
|
await tester.pump();
|
|
await gesture.moveTo(newHandlePos);
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pump();
|
|
|
|
expect(controller.selection.baseOffset, 0);
|
|
expect(controller.selection.extentOffset, 11);
|
|
});
|
|
|
|
testWidgets('Cannot drag one handle past the other', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: SelectableText(
|
|
'abc def ghi',
|
|
dragStartBehavior: DragStartBehavior.down,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// Long press the 'e' to select 'def'.
|
|
final Offset ePos = textOffsetToPosition(tester, 5); // Position before 'e'.
|
|
TestGesture gesture = await tester.startGesture(ePos, pointer: 7);
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await gesture.up();
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
|
|
|
|
final TextSelection selection = controller.selection;
|
|
expect(selection.baseOffset, 4);
|
|
expect(selection.extentOffset, 7);
|
|
|
|
final RenderEditable renderEditable = findRenderEditable(tester);
|
|
final List<TextSelectionPoint> endpoints = globalize(
|
|
renderEditable.getEndpointsForSelection(selection),
|
|
renderEditable,
|
|
);
|
|
expect(endpoints.length, 2);
|
|
|
|
// Drag the right handle until there's only 1 char selected.
|
|
// We use a small offset because the endpoint is on the very corner
|
|
// of the handle.
|
|
final Offset handlePos = endpoints[1].point + const Offset(4.0, 0.0);
|
|
Offset newHandlePos = textOffsetToPosition(tester, 5); // Position before 'e'.
|
|
gesture = await tester.startGesture(handlePos, pointer: 7);
|
|
await tester.pump();
|
|
await gesture.moveTo(newHandlePos);
|
|
await tester.pump();
|
|
|
|
expect(controller.selection.baseOffset, 4);
|
|
expect(controller.selection.extentOffset, 5);
|
|
|
|
newHandlePos = textOffsetToPosition(tester, 2); // Position before 'c'.
|
|
await gesture.moveTo(newHandlePos);
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pump();
|
|
|
|
expect(controller.selection.baseOffset, 4);
|
|
// The selection doesn't move beyond the left handle. There's always at
|
|
// least 1 char selected.
|
|
expect(controller.selection.extentOffset, 5);
|
|
});
|
|
|
|
testWidgets('Can use selection toolbar', (WidgetTester tester) async {
|
|
const String testValue = 'abc def ghi';
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: SelectableText(
|
|
testValue,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// Tap the selection handle to bring up the "paste / select all" menu.
|
|
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
|
|
final RenderEditable renderEditable = findRenderEditable(tester);
|
|
final List<TextSelectionPoint> endpoints = globalize(
|
|
renderEditable.getEndpointsForSelection(controller.selection),
|
|
renderEditable,
|
|
);
|
|
// Tapping on the part of the handle's GestureDetector where it overlaps
|
|
// with the text itself does not show the menu, so add a small vertical
|
|
// offset to tap below the text.
|
|
await tester.tapAt(endpoints[0].point + const Offset(1.0, 13.0));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
|
|
|
|
// SELECT ALL should select all the text.
|
|
await tester.tap(find.text('SELECT ALL'));
|
|
await tester.pump();
|
|
expect(controller.selection.baseOffset, 0);
|
|
expect(controller.selection.extentOffset, testValue.length);
|
|
|
|
// COPY should reset the selection.
|
|
await tester.tap(find.text('COPY'));
|
|
await skipPastScrollingAnimation(tester);
|
|
expect(controller.selection.isCollapsed, true);
|
|
});
|
|
|
|
testWidgets('Selectable height with maxLine', (WidgetTester tester) async {
|
|
await tester.pumpWidget(selectableTextBuilder());
|
|
|
|
RenderBox findTextBox() => tester.renderObject(find.byType(SelectableText));
|
|
|
|
final RenderBox textBox = findTextBox();
|
|
final Size emptyInputSize = textBox.size;
|
|
|
|
await tester.pumpWidget(selectableTextBuilder(text: 'No wrapping here.'));
|
|
expect(findTextBox(), equals(textBox));
|
|
expect(textBox.size.height, emptyInputSize.height);
|
|
|
|
// Even when entering multiline text, SelectableText doesn't grow. It's a single
|
|
// line input.
|
|
await tester.pumpWidget(selectableTextBuilder(text: kThreeLines));
|
|
expect(findTextBox(), equals(textBox));
|
|
expect(textBox.size.height, emptyInputSize.height);
|
|
|
|
// maxLines: 3 makes the SelectableText 3 lines tall
|
|
await tester.pumpWidget(selectableTextBuilder(maxLines: 3));
|
|
expect(findTextBox(), equals(textBox));
|
|
expect(textBox.size.height, greaterThan(emptyInputSize.height));
|
|
|
|
final Size threeLineInputSize = textBox.size;
|
|
|
|
// Filling with 3 lines of text stays the same size
|
|
await tester.pumpWidget(selectableTextBuilder(text: kThreeLines, maxLines: 3));
|
|
expect(findTextBox(), equals(textBox));
|
|
expect(textBox.size.height, threeLineInputSize.height);
|
|
|
|
// An extra line won't increase the size because we max at 3.
|
|
await tester.pumpWidget(selectableTextBuilder(text: kMoreThanFourLines, maxLines: 3));
|
|
expect(findTextBox(), equals(textBox));
|
|
expect(textBox.size.height, threeLineInputSize.height);
|
|
|
|
// But now it will... but it will max at four
|
|
await tester.pumpWidget(selectableTextBuilder(text: kMoreThanFourLines, maxLines: 4));
|
|
expect(findTextBox(), equals(textBox));
|
|
expect(textBox.size.height, greaterThan(threeLineInputSize.height));
|
|
|
|
final Size fourLineInputSize = textBox.size;
|
|
|
|
// Now it won't max out until the end
|
|
await tester.pumpWidget(selectableTextBuilder(maxLines: null));
|
|
expect(findTextBox(), equals(textBox));
|
|
expect(textBox.size, equals(emptyInputSize));
|
|
await tester.pumpWidget(selectableTextBuilder(text: kThreeLines, maxLines: null));
|
|
expect(textBox.size.height, equals(threeLineInputSize.height));
|
|
await tester.pumpWidget(selectableTextBuilder(text: kMoreThanFourLines, maxLines: null));
|
|
expect(textBox.size.height, greaterThan(fourLineInputSize.height));
|
|
});
|
|
|
|
testWidgets('Can drag handles to change selection in multiline', (WidgetTester tester) async {
|
|
const String testValue = kThreeLines;
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: const SelectableText(
|
|
testValue,
|
|
dragStartBehavior: DragStartBehavior.down,
|
|
style: TextStyle(color: Colors.black, fontSize: 34.0),
|
|
maxLines: 3,
|
|
strutStyle: StrutStyle.disabled,
|
|
),
|
|
),
|
|
);
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// Check that the text spans multiple lines.
|
|
final Offset firstPos = textOffsetToPosition(tester, testValue.indexOf('First'));
|
|
final Offset secondPos = textOffsetToPosition(tester, testValue.indexOf('Second'));
|
|
final Offset thirdPos = textOffsetToPosition(tester, testValue.indexOf('Third'));
|
|
final Offset middleStringPos = textOffsetToPosition(tester, testValue.indexOf('irst'));
|
|
|
|
expect(firstPos.dx, 24.5);
|
|
expect(secondPos.dx, 24.5);
|
|
expect(thirdPos.dx, 24.5);
|
|
expect(middleStringPos.dx, 58.5);
|
|
expect(firstPos.dx, secondPos.dx);
|
|
expect(firstPos.dx, thirdPos.dx);
|
|
expect(firstPos.dy, lessThan(secondPos.dy));
|
|
expect(secondPos.dy, lessThan(thirdPos.dy));
|
|
|
|
// Long press the 'n' in 'until' to select the word.
|
|
final Offset untilPos = textOffsetToPosition(tester, testValue.indexOf('until')+1);
|
|
TestGesture gesture = await tester.startGesture(untilPos, pointer: 7);
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await gesture.up();
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
|
|
|
|
expect(controller.selection.baseOffset, 39);
|
|
expect(controller.selection.extentOffset, 44);
|
|
|
|
final RenderEditable renderEditable = findRenderEditable(tester);
|
|
final List<TextSelectionPoint> endpoints = globalize(
|
|
renderEditable.getEndpointsForSelection(controller.selection),
|
|
renderEditable,
|
|
);
|
|
expect(endpoints.length, 2);
|
|
|
|
// Drag the right handle to the third line, just after 'Third'.
|
|
Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0);
|
|
Offset newHandlePos = textOffsetToPosition(tester, testValue.indexOf('Third') + 5);
|
|
gesture = await tester.startGesture(handlePos, pointer: 7);
|
|
await tester.pump();
|
|
await gesture.moveTo(newHandlePos);
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pump();
|
|
|
|
expect(controller.selection.baseOffset, 39);
|
|
expect(controller.selection.extentOffset, 50);
|
|
|
|
// Drag the left handle to the first line, just after 'First'.
|
|
handlePos = endpoints[0].point + const Offset(-1.0, 1.0);
|
|
newHandlePos = textOffsetToPosition(tester, testValue.indexOf('First') + 5);
|
|
gesture = await tester.startGesture(handlePos, pointer: 7);
|
|
await tester.pump();
|
|
await gesture.moveTo(newHandlePos);
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pump();
|
|
|
|
expect(controller.selection.baseOffset, 5);
|
|
expect(controller.selection.extentOffset, 50);
|
|
await tester.tap(find.text('COPY'));
|
|
await tester.pump();
|
|
expect(controller.selection.isCollapsed, true);
|
|
});
|
|
|
|
testWidgets('Can scroll multiline input', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: const SelectableText(
|
|
kMoreThanFourLines,
|
|
dragStartBehavior: DragStartBehavior.down,
|
|
style: TextStyle(color: Colors.black, fontSize: 34.0),
|
|
maxLines: 2,
|
|
),
|
|
),
|
|
);
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
RenderBox findInputBox() => tester.renderObject(find.byType(SelectableText));
|
|
final RenderBox inputBox = findInputBox();
|
|
|
|
// Check that the last line of text is not displayed.
|
|
final Offset firstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First'));
|
|
final Offset fourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth'));
|
|
expect(firstPos.dx, 0.0);
|
|
expect(fourthPos.dx, 0.0);
|
|
expect(firstPos.dx, fourthPos.dx);
|
|
expect(firstPos.dy, lessThan(fourthPos.dy));
|
|
expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(firstPos)), isTrue);
|
|
expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(fourthPos)), isFalse);
|
|
|
|
TestGesture gesture = await tester.startGesture(firstPos, pointer: 7);
|
|
await tester.pump();
|
|
await gesture.moveBy(const Offset(0.0, -1000.0));
|
|
await tester.pump(const Duration(seconds: 1));
|
|
// Wait and drag again to trigger https://github.com/flutter/flutter/issues/6329
|
|
// (No idea why this is necessary, but the bug wouldn't repro without it.)
|
|
await gesture.moveBy(const Offset(0.0, -1000.0));
|
|
await tester.pump(const Duration(seconds: 1));
|
|
await gesture.up();
|
|
await tester.pump();
|
|
await tester.pump(const Duration(seconds: 1));
|
|
|
|
// Now the first line is scrolled up, and the fourth line is visible.
|
|
Offset newFirstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First'));
|
|
Offset newFourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth'));
|
|
|
|
expect(newFirstPos.dy, lessThan(firstPos.dy));
|
|
expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFirstPos)), isFalse);
|
|
expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFourthPos)), isTrue);
|
|
|
|
// Now try scrolling by dragging the selection handle.
|
|
// Long press the middle of the word "won't" in the fourth line.
|
|
final Offset selectedWordPos = textOffsetToPosition(
|
|
tester,
|
|
kMoreThanFourLines.indexOf('Fourth line') + 14,
|
|
);
|
|
|
|
gesture = await tester.startGesture(selectedWordPos, pointer: 7);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
await gesture.up();
|
|
await tester.pump();
|
|
await tester.pump(const Duration(seconds: 1));
|
|
|
|
expect(controller.selection.base.offset, 77);
|
|
expect(controller.selection.extent.offset, 82);
|
|
// Sanity check for the word selected is the intended one.
|
|
expect(
|
|
controller.text.substring(controller.selection.baseOffset, controller.selection.extentOffset),
|
|
"won't",
|
|
);
|
|
|
|
final RenderEditable renderEditable = findRenderEditable(tester);
|
|
final List<TextSelectionPoint> endpoints = globalize(
|
|
renderEditable.getEndpointsForSelection(controller.selection),
|
|
renderEditable,
|
|
);
|
|
expect(endpoints.length, 2);
|
|
|
|
// Drag the left handle to the first line, just after 'First'.
|
|
final Offset handlePos = endpoints[0].point + const Offset(-1, 1);
|
|
final Offset newHandlePos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First') + 5);
|
|
gesture = await tester.startGesture(handlePos, pointer: 7);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
await gesture.moveTo(newHandlePos + const Offset(0.0, -10.0));
|
|
await tester.pump(const Duration(seconds: 1));
|
|
await gesture.up();
|
|
await tester.pump(const Duration(seconds: 1));
|
|
|
|
// The text should have scrolled up with the handle to keep the active
|
|
// cursor visible, back to its original position.
|
|
newFirstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First'));
|
|
newFourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth'));
|
|
expect(newFirstPos.dy, firstPos.dy);
|
|
expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFirstPos)), isTrue);
|
|
expect(inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFourthPos)), isFalse);
|
|
});
|
|
|
|
testWidgets('Can align to center', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: Container(
|
|
width: 300.0,
|
|
child: const SelectableText(
|
|
'abcd',
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final RenderEditable editable = findRenderEditable(tester);
|
|
|
|
final Offset topLeft = editable.localToGlobal(
|
|
editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft,
|
|
);
|
|
|
|
expect(topLeft.dx, equals(399.0));
|
|
});
|
|
|
|
testWidgets('Can align to center within center', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: Container(
|
|
width: 300.0,
|
|
child: const Center(
|
|
child: SelectableText(
|
|
'abcd',
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final RenderEditable editable = findRenderEditable(tester);
|
|
|
|
final Offset topLeft = editable.localToGlobal(
|
|
editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft,
|
|
);
|
|
|
|
expect(topLeft.dx, equals(399.0));
|
|
});
|
|
|
|
testWidgets('Selectable text drops selection when losing focus', (WidgetTester tester) async {
|
|
final Key key1 = UniqueKey();
|
|
final Key key2 = UniqueKey();
|
|
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: Column(
|
|
children: <Widget>[
|
|
SelectableText(
|
|
'text 1',
|
|
key: key1,
|
|
),
|
|
SelectableText(
|
|
'text 2',
|
|
key: key2,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.tap(find.byKey(key1));
|
|
await tester.pump();
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
controller.selection = const TextSelection(baseOffset: 0, extentOffset: 3);
|
|
await tester.pump();
|
|
expect(controller.selection, isNot(equals(TextRange.empty)));
|
|
|
|
await tester.tap(find.byKey(key2));
|
|
await tester.pump();
|
|
expect(controller.selection, equals(TextRange.empty));
|
|
});
|
|
|
|
testWidgets('Selectable text identifies as text field in semantics', (WidgetTester tester) async {
|
|
final SemanticsTester semantics = SemanticsTester(tester);
|
|
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: Center(
|
|
child: SelectableText('some text'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
semantics,
|
|
includesNodeWith(
|
|
flags: <SemanticsFlag>[
|
|
SemanticsFlag.isTextField,
|
|
SemanticsFlag.isReadOnly,
|
|
SemanticsFlag.isMultiline,
|
|
],
|
|
),
|
|
);
|
|
|
|
semantics.dispose();
|
|
});
|
|
|
|
group('Keyboard Tests', () {
|
|
TextEditingController controller;
|
|
|
|
Future<void> setupWidget(WidgetTester tester, String text) async {
|
|
final FocusNode focusNode = FocusNode();
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Material(
|
|
child: RawKeyboardListener(
|
|
focusNode: focusNode,
|
|
onKey: null,
|
|
child: SelectableText(
|
|
text,
|
|
maxLines: 3,
|
|
strutStyle: StrutStyle.disabled,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.tap(find.byType(SelectableText));
|
|
await tester.pumpAndSettle();
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
controller = editableTextWidget.controller;
|
|
}
|
|
|
|
testWidgets('Shift test 1', (WidgetTester tester) async {
|
|
await setupWidget(tester, 'a big house');
|
|
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft);
|
|
expect(controller.selection.extentOffset - controller.selection.baseOffset, -1);
|
|
});
|
|
|
|
testWidgets('Shift test 2', (WidgetTester tester) async {
|
|
await setupWidget(tester, 'abcdefghi');
|
|
|
|
controller.selection = const TextSelection.collapsed(offset: 3);
|
|
await tester.pump();
|
|
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
|
|
await tester.pumpAndSettle();
|
|
expect(controller.selection.extentOffset - controller.selection.baseOffset, 1);
|
|
});
|
|
|
|
testWidgets('Control Shift test', (WidgetTester tester) async {
|
|
await setupWidget(tester, 'their big house');
|
|
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.control);
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft);
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.extentOffset - controller.selection.baseOffset, -5);
|
|
});
|
|
|
|
testWidgets('Down and up test', (WidgetTester tester) async {
|
|
await setupWidget(tester, 'a big house');
|
|
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowUp);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.extentOffset - controller.selection.baseOffset, -11);
|
|
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowUp);
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.extentOffset - controller.selection.baseOffset, 0);
|
|
});
|
|
|
|
testWidgets('Down and up test 2', (WidgetTester tester) async {
|
|
await setupWidget(tester, 'a big house\njumped over a mouse\nOne more line yay');
|
|
|
|
controller.selection = const TextSelection.collapsed(offset: 0);
|
|
await tester.pump();
|
|
|
|
for (int i = 0; i < 5; i += 1) {
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
|
await tester.pumpAndSettle();
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.extentOffset - controller.selection.baseOffset, 12);
|
|
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
|
await tester.pumpAndSettle();
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.extentOffset - controller.selection.baseOffset, 32);
|
|
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
|
await tester.pumpAndSettle();
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.extentOffset - controller.selection.baseOffset, 12);
|
|
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
|
await tester.pumpAndSettle();
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.extentOffset - controller.selection.baseOffset, 0);
|
|
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
|
await tester.pumpAndSettle();
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.extentOffset - controller.selection.baseOffset, -5);
|
|
});
|
|
});
|
|
|
|
testWidgets('Copy test', (WidgetTester tester) async {
|
|
final FocusNode focusNode = FocusNode();
|
|
|
|
String clipboardContent = '';
|
|
SystemChannels.platform.setMockMethodCallHandler((MethodCall methodCall) async {
|
|
if (methodCall.method == 'Clipboard.setData')
|
|
clipboardContent = methodCall.arguments['text'];
|
|
else if (methodCall.method == 'Clipboard.getData')
|
|
return <String, dynamic>{'text': clipboardContent};
|
|
return null;
|
|
});
|
|
const String testValue = 'a big house\njumped over a mouse';
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Material(
|
|
child: RawKeyboardListener(
|
|
focusNode: focusNode,
|
|
onKey: null,
|
|
child: const SelectableText(
|
|
testValue,
|
|
maxLines: 3,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
focusNode.requestFocus();
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.byType(SelectableText));
|
|
await tester.pumpAndSettle();
|
|
|
|
controller.selection = const TextSelection.collapsed(offset: 0);
|
|
await tester.pump();
|
|
|
|
// Select the first 5 characters
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
|
for (int i = 0; i < 5; i += 1) {
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
|
|
|
// Copy them
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlRight);
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC);
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC);
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(clipboardContent, 'a big');
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
|
await tester.pumpAndSettle();
|
|
});
|
|
|
|
testWidgets('Select all test', (WidgetTester tester) async {
|
|
final FocusNode focusNode = FocusNode();
|
|
const String testValue = 'a big house\njumped over a mouse';
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Material(
|
|
child: RawKeyboardListener(
|
|
focusNode: focusNode,
|
|
onKey: null,
|
|
child: const SelectableText(
|
|
testValue,
|
|
maxLines: 3,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
focusNode.requestFocus();
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.byType(SelectableText));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Select All
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.control);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.keyA);
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.control);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(controller.selection.baseOffset, 0);
|
|
expect(controller.selection.extentOffset, 31);
|
|
});
|
|
|
|
testWidgets('Changing positions of selectable text', (WidgetTester tester) async {
|
|
final FocusNode focusNode = FocusNode();
|
|
final List<RawKeyEvent> events = <RawKeyEvent>[];
|
|
|
|
final Key key1 = UniqueKey();
|
|
final Key key2 = UniqueKey();
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home:
|
|
Material(
|
|
child: RawKeyboardListener(
|
|
focusNode: focusNode,
|
|
onKey: events.add,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: <Widget>[
|
|
SelectableText(
|
|
'a big house',
|
|
key: key1,
|
|
maxLines: 3,
|
|
),
|
|
SelectableText(
|
|
'another big house',
|
|
key: key2,
|
|
maxLines: 3,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
TextEditingController c1 = editableTextWidget.controller;
|
|
|
|
await tester.tap(find.byType(EditableText).first);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
|
for (int i = 0; i < 5; i += 1) {
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(c1.selection.extentOffset - c1.selection.baseOffset, -5);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home:
|
|
Material(
|
|
child: RawKeyboardListener(
|
|
focusNode: focusNode,
|
|
onKey: events.add,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: <Widget>[
|
|
SelectableText(
|
|
'another big house',
|
|
key: key2,
|
|
maxLines: 3,
|
|
),
|
|
SelectableText(
|
|
'a big house',
|
|
key: key1,
|
|
maxLines: 3,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
|
for (int i = 0; i < 5; i += 1) {
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
|
}
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
|
await tester.pumpAndSettle();
|
|
|
|
editableTextWidget = tester.widget(find.byType(EditableText).last);
|
|
c1 = editableTextWidget.controller;
|
|
|
|
expect(c1.selection.extentOffset - c1.selection.baseOffset, -6);
|
|
});
|
|
|
|
|
|
testWidgets('Changing focus test', (WidgetTester tester) async {
|
|
final FocusNode focusNode = FocusNode();
|
|
final List<RawKeyEvent> events = <RawKeyEvent>[];
|
|
|
|
final Key key1 = UniqueKey();
|
|
final Key key2 = UniqueKey();
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home:
|
|
Material(
|
|
child: RawKeyboardListener(
|
|
focusNode: focusNode,
|
|
onKey: events.add,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: <Widget>[
|
|
SelectableText(
|
|
'a big house',
|
|
key: key1,
|
|
maxLines: 3,
|
|
),
|
|
SelectableText(
|
|
'another big house',
|
|
key: key2,
|
|
maxLines: 3,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final EditableText editableTextWidget1 = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController c1 = editableTextWidget1.controller;
|
|
|
|
final EditableText editableTextWidget2 = tester.widget(find.byType(EditableText).last);
|
|
final TextEditingController c2 = editableTextWidget2.controller;
|
|
|
|
await tester.tap(find.byType(SelectableText).first);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
|
for (int i = 0; i < 5; i += 1) {
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(c1.selection.extentOffset - c1.selection.baseOffset, -5);
|
|
expect(c2.selection.extentOffset - c2.selection.baseOffset, 0);
|
|
|
|
await tester.tap(find.byType(SelectableText).last);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
|
|
for (int i = 0; i < 5; i += 1) {
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(c1.selection.extentOffset - c1.selection.baseOffset, 0);
|
|
expect(c2.selection.extentOffset - c2.selection.baseOffset, -5);
|
|
});
|
|
|
|
testWidgets('Caret works when maxLines is null', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: const SelectableText(
|
|
'x',
|
|
maxLines: null,
|
|
),
|
|
),
|
|
);
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
expect(controller.selection.baseOffset, -1);
|
|
|
|
// Tap the selection handle to bring up the "paste / select all" menu.
|
|
await tester.tapAt(textOffsetToPosition(tester, 0));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is
|
|
|
|
// Confirm that the selection was updated.
|
|
expect(controller.selection.baseOffset, 0);
|
|
});
|
|
|
|
testWidgets('SelectableText baseline alignment no-strut', (WidgetTester tester) async {
|
|
final Key keyA = UniqueKey();
|
|
final Key keyB = UniqueKey();
|
|
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
|
textBaseline: TextBaseline.alphabetic,
|
|
children: <Widget>[
|
|
Expanded(
|
|
child: SelectableText(
|
|
'A',
|
|
key: keyA,
|
|
style: const TextStyle(fontSize: 10.0),
|
|
strutStyle: StrutStyle.disabled,
|
|
),
|
|
),
|
|
const Text(
|
|
'abc',
|
|
style: TextStyle(fontSize: 20.0),
|
|
),
|
|
Expanded(
|
|
child: SelectableText(
|
|
'B',
|
|
key: keyB,
|
|
style: const TextStyle(fontSize: 30.0),
|
|
strutStyle: StrutStyle.disabled,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
// The Ahem font extends 0.2 * fontSize below the baseline.
|
|
// So the three row elements line up like this:
|
|
//
|
|
// A abc B
|
|
// --------- baseline
|
|
// 2 4 6 space below the baseline = 0.2 * fontSize
|
|
// --------- rowBottomY
|
|
|
|
final double rowBottomY = tester.getBottomLeft(find.byType(Row)).dy;
|
|
expect(tester.getBottomLeft(find.byKey(keyA)).dy, closeTo(rowBottomY - 4.0, 0.001));
|
|
expect(tester.getBottomLeft(find.text('abc')).dy, closeTo(rowBottomY - 2.0, 0.001));
|
|
expect(tester.getBottomLeft(find.byKey(keyB)).dy, rowBottomY);
|
|
});
|
|
|
|
testWidgets('SelectableText baseline alignment', (WidgetTester tester) async {
|
|
final Key keyA = UniqueKey();
|
|
final Key keyB = UniqueKey();
|
|
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
|
textBaseline: TextBaseline.alphabetic,
|
|
children: <Widget>[
|
|
Expanded(
|
|
child: SelectableText(
|
|
'A',
|
|
key: keyA,
|
|
style: const TextStyle(fontSize: 10.0),
|
|
),
|
|
),
|
|
const Text(
|
|
'abc',
|
|
style: TextStyle(fontSize: 20.0),
|
|
),
|
|
Expanded(
|
|
child: SelectableText(
|
|
'B',
|
|
key: keyB,
|
|
style: const TextStyle(fontSize: 30.0),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
// The Ahem font extends 0.2 * fontSize below the baseline.
|
|
// So the three row elements line up like this:
|
|
//
|
|
// A abc B
|
|
// --------- baseline
|
|
// 2 4 6 space below the baseline = 0.2 * fontSize
|
|
// --------- rowBottomY
|
|
|
|
final double rowBottomY = tester.getBottomLeft(find.byType(Row)).dy;
|
|
expect(tester.getBottomLeft(find.byKey(keyA)).dy, closeTo(rowBottomY - 4.0, 0.001));
|
|
expect(tester.getBottomLeft(find.text('abc')).dy, closeTo(rowBottomY - 2.0, 0.001));
|
|
expect(tester.getBottomLeft(find.byKey(keyB)).dy, rowBottomY);
|
|
});
|
|
|
|
testWidgets('SelectableText semantics', (WidgetTester tester) async {
|
|
final SemanticsTester semantics = SemanticsTester(tester);
|
|
final Key key = UniqueKey();
|
|
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: SelectableText(
|
|
'Guten Tag',
|
|
key: key,
|
|
),
|
|
),
|
|
);
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
expect(semantics, hasSemantics(TestSemantics.root(
|
|
children: <TestSemantics>[
|
|
TestSemantics.rootChild(
|
|
id: 1,
|
|
textDirection: TextDirection.ltr,
|
|
value: 'Guten Tag',
|
|
actions: <SemanticsAction>[
|
|
SemanticsAction.tap,
|
|
SemanticsAction.longPress,
|
|
],
|
|
flags: <SemanticsFlag>[
|
|
SemanticsFlag.isTextField,
|
|
SemanticsFlag.isReadOnly,
|
|
SemanticsFlag.isMultiline,
|
|
],
|
|
),
|
|
],
|
|
), ignoreTransform: true, ignoreRect: true));
|
|
|
|
await tester.tap(find.byKey(key));
|
|
await tester.pump();
|
|
|
|
controller.selection = const TextSelection.collapsed(offset: 9);
|
|
await tester.pump();
|
|
|
|
expect(semantics, hasSemantics(TestSemantics.root(
|
|
children: <TestSemantics>[
|
|
TestSemantics.rootChild(
|
|
id: 1,
|
|
textDirection: TextDirection.ltr,
|
|
value: 'Guten Tag',
|
|
textSelection: const TextSelection.collapsed(offset: 9),
|
|
actions: <SemanticsAction>[
|
|
SemanticsAction.tap,
|
|
SemanticsAction.longPress,
|
|
SemanticsAction.moveCursorBackwardByCharacter,
|
|
SemanticsAction.moveCursorBackwardByWord,
|
|
SemanticsAction.setSelection,
|
|
],
|
|
flags: <SemanticsFlag>[
|
|
SemanticsFlag.isReadOnly,
|
|
SemanticsFlag.isTextField,
|
|
SemanticsFlag.isMultiline,
|
|
SemanticsFlag.isFocused,
|
|
],
|
|
),
|
|
],
|
|
), ignoreTransform: true, ignoreRect: true));
|
|
|
|
controller.selection = const TextSelection.collapsed(offset: 4);
|
|
await tester.pump();
|
|
|
|
expect(semantics, hasSemantics(TestSemantics.root(
|
|
children: <TestSemantics>[
|
|
TestSemantics.rootChild(
|
|
id: 1,
|
|
textDirection: TextDirection.ltr,
|
|
textSelection: const TextSelection.collapsed(offset: 4),
|
|
value: 'Guten Tag',
|
|
actions: <SemanticsAction>[
|
|
SemanticsAction.tap,
|
|
SemanticsAction.longPress,
|
|
SemanticsAction.moveCursorBackwardByCharacter,
|
|
SemanticsAction.moveCursorForwardByCharacter,
|
|
SemanticsAction.moveCursorBackwardByWord,
|
|
SemanticsAction.moveCursorForwardByWord,
|
|
SemanticsAction.setSelection,
|
|
],
|
|
flags: <SemanticsFlag>[
|
|
SemanticsFlag.isReadOnly,
|
|
SemanticsFlag.isTextField,
|
|
SemanticsFlag.isMultiline,
|
|
SemanticsFlag.isFocused,
|
|
],
|
|
),
|
|
],
|
|
), ignoreTransform: true, ignoreRect: true));
|
|
|
|
controller.selection = const TextSelection.collapsed(offset: 0);
|
|
await tester.pump();
|
|
|
|
expect(semantics, hasSemantics(TestSemantics.root(
|
|
children: <TestSemantics>[
|
|
TestSemantics.rootChild(
|
|
id: 1,
|
|
textDirection: TextDirection.ltr,
|
|
textSelection: const TextSelection.collapsed(offset: 0),
|
|
value: 'Guten Tag',
|
|
actions: <SemanticsAction>[
|
|
SemanticsAction.tap,
|
|
SemanticsAction.longPress,
|
|
SemanticsAction.moveCursorForwardByCharacter,
|
|
SemanticsAction.moveCursorForwardByWord,
|
|
SemanticsAction.setSelection,
|
|
],
|
|
flags: <SemanticsFlag>[
|
|
SemanticsFlag.isReadOnly,
|
|
SemanticsFlag.isTextField,
|
|
SemanticsFlag.isMultiline,
|
|
SemanticsFlag.isFocused,
|
|
],
|
|
),
|
|
],
|
|
), ignoreTransform: true, ignoreRect: true));
|
|
|
|
semantics.dispose();
|
|
});
|
|
|
|
testWidgets('SelectableText semantics, enableInteractiveSelection = false', (WidgetTester tester) async {
|
|
final SemanticsTester semantics = SemanticsTester(tester);
|
|
final Key key = UniqueKey();
|
|
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: SelectableText(
|
|
'Guten Tag',
|
|
key: key,
|
|
enableInteractiveSelection: false,
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.tap(find.byKey(key));
|
|
await tester.pump();
|
|
|
|
expect(semantics, hasSemantics(TestSemantics.root(
|
|
children: <TestSemantics>[
|
|
TestSemantics.rootChild(
|
|
id: 1,
|
|
value: 'Guten Tag',
|
|
textDirection: TextDirection.ltr,
|
|
actions: <SemanticsAction>[
|
|
SemanticsAction.tap,
|
|
SemanticsAction.longPress,
|
|
// Absent the following because enableInteractiveSelection: false
|
|
// SemanticsAction.moveCursorBackwardByCharacter,
|
|
// SemanticsAction.moveCursorBackwardByWord,
|
|
// SemanticsAction.setSelection,
|
|
// SemanticsAction.paste,
|
|
],
|
|
flags: <SemanticsFlag>[
|
|
SemanticsFlag.isReadOnly,
|
|
SemanticsFlag.isTextField,
|
|
SemanticsFlag.isMultiline,
|
|
// SelectableText act like a text widget when enableInteractiveSelection
|
|
// is false. It will not respond to any pointer event.
|
|
// SemanticsFlag.isFocused,
|
|
],
|
|
),
|
|
],
|
|
), ignoreTransform: true, ignoreRect: true));
|
|
|
|
semantics.dispose();
|
|
});
|
|
|
|
testWidgets('SelectableText semantics for selections', (WidgetTester tester) async {
|
|
final SemanticsTester semantics = SemanticsTester(tester);
|
|
final Key key = UniqueKey();
|
|
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: SelectableText(
|
|
'Hello',
|
|
key: key,
|
|
),
|
|
),
|
|
);
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
expect(semantics, hasSemantics(TestSemantics.root(
|
|
children: <TestSemantics>[
|
|
TestSemantics.rootChild(
|
|
id: 1,
|
|
value: 'Hello',
|
|
textDirection: TextDirection.ltr,
|
|
actions: <SemanticsAction>[
|
|
SemanticsAction.tap,
|
|
SemanticsAction.longPress,
|
|
],
|
|
flags: <SemanticsFlag>[
|
|
SemanticsFlag.isReadOnly,
|
|
SemanticsFlag.isTextField,
|
|
SemanticsFlag.isMultiline,
|
|
],
|
|
),
|
|
],
|
|
), ignoreTransform: true, ignoreRect: true));
|
|
|
|
// Focus the selectable text
|
|
await tester.tap(find.byKey(key));
|
|
await tester.pump();
|
|
|
|
controller.selection = const TextSelection.collapsed(offset: 5);
|
|
await tester.pump();
|
|
|
|
expect(semantics, hasSemantics(TestSemantics.root(
|
|
children: <TestSemantics>[
|
|
TestSemantics.rootChild(
|
|
id: 1,
|
|
value: 'Hello',
|
|
textSelection: const TextSelection.collapsed(offset: 5),
|
|
textDirection: TextDirection.ltr,
|
|
actions: <SemanticsAction>[
|
|
SemanticsAction.tap,
|
|
SemanticsAction.longPress,
|
|
SemanticsAction.moveCursorBackwardByCharacter,
|
|
SemanticsAction.moveCursorBackwardByWord,
|
|
SemanticsAction.setSelection,
|
|
],
|
|
flags: <SemanticsFlag>[
|
|
SemanticsFlag.isReadOnly,
|
|
SemanticsFlag.isTextField,
|
|
SemanticsFlag.isMultiline,
|
|
SemanticsFlag.isFocused,
|
|
],
|
|
),
|
|
],
|
|
), ignoreTransform: true, ignoreRect: true));
|
|
|
|
controller.selection = const TextSelection(baseOffset: 5, extentOffset: 3);
|
|
await tester.pump();
|
|
|
|
expect(semantics, hasSemantics(TestSemantics.root(
|
|
children: <TestSemantics>[
|
|
TestSemantics.rootChild(
|
|
id: 1,
|
|
value: 'Hello',
|
|
textSelection: const TextSelection(baseOffset: 5, extentOffset: 3),
|
|
textDirection: TextDirection.ltr,
|
|
actions: <SemanticsAction>[
|
|
SemanticsAction.tap,
|
|
SemanticsAction.longPress,
|
|
SemanticsAction.moveCursorBackwardByCharacter,
|
|
SemanticsAction.moveCursorForwardByCharacter,
|
|
SemanticsAction.moveCursorBackwardByWord,
|
|
SemanticsAction.moveCursorForwardByWord,
|
|
SemanticsAction.setSelection,
|
|
SemanticsAction.copy,
|
|
],
|
|
flags: <SemanticsFlag>[
|
|
SemanticsFlag.isReadOnly,
|
|
SemanticsFlag.isTextField,
|
|
SemanticsFlag.isMultiline,
|
|
SemanticsFlag.isFocused,
|
|
],
|
|
),
|
|
],
|
|
), ignoreTransform: true, ignoreRect: true));
|
|
|
|
semantics.dispose();
|
|
});
|
|
|
|
testWidgets('SelectableText change selection with semantics', (WidgetTester tester) async {
|
|
final SemanticsTester semantics = SemanticsTester(tester);
|
|
final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner;
|
|
final Key key = UniqueKey();
|
|
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: SelectableText(
|
|
'Hello',
|
|
key: key,
|
|
),
|
|
),
|
|
);
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// Focus the selectable text
|
|
await tester.tap(find.byKey(key));
|
|
await tester.pump();
|
|
|
|
controller.selection = const TextSelection(baseOffset: 5, extentOffset: 5);
|
|
await tester.pump();
|
|
|
|
const int inputFieldId = 1;
|
|
|
|
expect(semantics, hasSemantics(TestSemantics.root(
|
|
children: <TestSemantics>[
|
|
TestSemantics.rootChild(
|
|
id: inputFieldId,
|
|
value: 'Hello',
|
|
textSelection: const TextSelection.collapsed(offset: 5),
|
|
textDirection: TextDirection.ltr,
|
|
actions: <SemanticsAction>[
|
|
SemanticsAction.tap,
|
|
SemanticsAction.longPress,
|
|
SemanticsAction.moveCursorBackwardByCharacter,
|
|
SemanticsAction.moveCursorBackwardByWord,
|
|
SemanticsAction.setSelection,
|
|
],
|
|
flags: <SemanticsFlag>[
|
|
SemanticsFlag.isReadOnly,
|
|
SemanticsFlag.isTextField,
|
|
SemanticsFlag.isMultiline,
|
|
SemanticsFlag.isFocused,
|
|
],
|
|
),
|
|
],
|
|
), ignoreTransform: true, ignoreRect: true));
|
|
|
|
// move cursor back once
|
|
semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{
|
|
'base': 4,
|
|
'extent': 4,
|
|
});
|
|
await tester.pump();
|
|
expect(controller.selection, const TextSelection.collapsed(offset: 4));
|
|
|
|
// move cursor to front
|
|
semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{
|
|
'base': 0,
|
|
'extent': 0,
|
|
});
|
|
await tester.pump();
|
|
expect(controller.selection, const TextSelection.collapsed(offset: 0));
|
|
|
|
// select all
|
|
semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{
|
|
'base': 0,
|
|
'extent': 5,
|
|
});
|
|
await tester.pump();
|
|
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
|
|
expect(semantics, hasSemantics(TestSemantics.root(
|
|
children: <TestSemantics>[
|
|
TestSemantics.rootChild(
|
|
id: inputFieldId,
|
|
value: 'Hello',
|
|
textSelection: const TextSelection(baseOffset: 0, extentOffset: 5),
|
|
textDirection: TextDirection.ltr,
|
|
actions: <SemanticsAction>[
|
|
SemanticsAction.tap,
|
|
SemanticsAction.longPress,
|
|
SemanticsAction.moveCursorBackwardByCharacter,
|
|
SemanticsAction.moveCursorBackwardByWord,
|
|
SemanticsAction.setSelection,
|
|
SemanticsAction.copy,
|
|
],
|
|
flags: <SemanticsFlag>[
|
|
SemanticsFlag.isReadOnly,
|
|
SemanticsFlag.isTextField,
|
|
SemanticsFlag.isMultiline,
|
|
SemanticsFlag.isFocused,
|
|
],
|
|
),
|
|
],
|
|
), ignoreTransform: true, ignoreRect: true));
|
|
|
|
semantics.dispose();
|
|
});
|
|
|
|
testWidgets('Can activate SelectableText with explicit controller via semantics', (WidgetTester tester) async {
|
|
// Regression test for https://github.com/flutter/flutter/issues/17801
|
|
|
|
const String testValue = 'Hello';
|
|
|
|
final SemanticsTester semantics = SemanticsTester(tester);
|
|
final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner;
|
|
final Key key = UniqueKey();
|
|
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: SelectableText(
|
|
testValue,
|
|
key: key,
|
|
),
|
|
),
|
|
);
|
|
|
|
const int inputFieldId = 1;
|
|
|
|
expect(semantics, hasSemantics(
|
|
TestSemantics.root(
|
|
children: <TestSemantics>[
|
|
TestSemantics(
|
|
id: inputFieldId,
|
|
flags: <SemanticsFlag>[
|
|
SemanticsFlag.isReadOnly,
|
|
SemanticsFlag.isTextField,
|
|
SemanticsFlag.isMultiline,
|
|
],
|
|
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.longPress],
|
|
value: testValue,
|
|
textDirection: TextDirection.ltr,
|
|
),
|
|
],
|
|
),
|
|
ignoreRect: true, ignoreTransform: true,
|
|
));
|
|
|
|
semanticsOwner.performAction(inputFieldId, SemanticsAction.tap);
|
|
await tester.pump();
|
|
|
|
expect(semantics, hasSemantics(
|
|
TestSemantics.root(
|
|
children: <TestSemantics>[
|
|
TestSemantics(
|
|
id: inputFieldId,
|
|
flags: <SemanticsFlag>[
|
|
SemanticsFlag.isReadOnly,
|
|
SemanticsFlag.isTextField,
|
|
SemanticsFlag.isMultiline,
|
|
SemanticsFlag.isFocused,
|
|
],
|
|
actions: <SemanticsAction>[
|
|
SemanticsAction.tap,
|
|
SemanticsAction.longPress,
|
|
SemanticsAction.moveCursorBackwardByCharacter,
|
|
SemanticsAction.moveCursorBackwardByWord,
|
|
SemanticsAction.setSelection,
|
|
],
|
|
value: testValue,
|
|
textDirection: TextDirection.ltr,
|
|
textSelection: const TextSelection(
|
|
baseOffset: testValue.length,
|
|
extentOffset: testValue.length,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
ignoreRect: true, ignoreTransform: true,
|
|
));
|
|
|
|
semantics.dispose();
|
|
});
|
|
|
|
testWidgets('SelectableText throws when not descended from a MediaQuery widget', (WidgetTester tester) async {
|
|
const Widget selectableText = SelectableText('something');
|
|
await tester.pumpWidget(selectableText);
|
|
final dynamic exception = tester.takeException();
|
|
expect(exception, isFlutterError);
|
|
expect(exception.toString(), startsWith('No MediaQuery widget found.\nSelectableText widgets require a MediaQuery widget ancestor.'));
|
|
});
|
|
|
|
testWidgets('onTap is called upon tap', (WidgetTester tester) async {
|
|
int tapCount = 0;
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: SelectableText(
|
|
'something',
|
|
onTap: () {
|
|
tapCount += 1;
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(tapCount, 0);
|
|
await tester.tap(find.byType(SelectableText));
|
|
// Wait a bit so they're all single taps and not double taps.
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
await tester.tap(find.byType(SelectableText));
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
await tester.tap(find.byType(SelectableText));
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
expect(tapCount, 3);
|
|
});
|
|
|
|
testWidgets('SelectableText style is merged with default text style', (WidgetTester tester) async {
|
|
// Regression test for https://github.com/flutter/flutter/issues/23994
|
|
final TextStyle defaultStyle = TextStyle(
|
|
color: Colors.blue[500],
|
|
);
|
|
Widget buildFrame(TextStyle style) {
|
|
return MaterialApp(
|
|
home: Material(
|
|
child: DefaultTextStyle (
|
|
style: defaultStyle,
|
|
child: Center(
|
|
child: SelectableText(
|
|
'something',
|
|
style: style,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Empty TextStyle is overridden by theme
|
|
await tester.pumpWidget(buildFrame(const TextStyle()));
|
|
EditableText editableText = tester.widget(find.byType(EditableText));
|
|
expect(editableText.style.color, defaultStyle.color);
|
|
expect(editableText.style.background, defaultStyle.background);
|
|
expect(editableText.style.shadows, defaultStyle.shadows);
|
|
expect(editableText.style.decoration, defaultStyle.decoration);
|
|
expect(editableText.style.locale, defaultStyle.locale);
|
|
expect(editableText.style.wordSpacing, defaultStyle.wordSpacing);
|
|
|
|
// Properties set on TextStyle override theme
|
|
const Color setColor = Colors.red;
|
|
await tester.pumpWidget(buildFrame(const TextStyle(color: setColor)));
|
|
editableText = tester.widget(find.byType(EditableText));
|
|
expect(editableText.style.color, setColor);
|
|
|
|
// inherit: false causes nothing to be merged in from theme
|
|
await tester.pumpWidget(buildFrame(const TextStyle(
|
|
fontSize: 24.0,
|
|
textBaseline: TextBaseline.alphabetic,
|
|
inherit: false,
|
|
)));
|
|
editableText = tester.widget(find.byType(EditableText));
|
|
expect(editableText.style.color, isNull);
|
|
});
|
|
|
|
testWidgets('style enforces required fields', (WidgetTester tester) async {
|
|
Widget buildFrame(TextStyle style) {
|
|
return MaterialApp(
|
|
home: Material(
|
|
child: SelectableText(
|
|
'something',
|
|
style: style,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
await tester.pumpWidget(buildFrame(const TextStyle(
|
|
inherit: false,
|
|
fontSize: 12.0,
|
|
textBaseline: TextBaseline.alphabetic,
|
|
)));
|
|
expect(tester.takeException(), isNull);
|
|
|
|
// With inherit not set to false, will pickup required fields from theme
|
|
await tester.pumpWidget(buildFrame(const TextStyle(
|
|
fontSize: 12.0,
|
|
)));
|
|
expect(tester.takeException(), isNull);
|
|
|
|
await tester.pumpWidget(buildFrame(const TextStyle(
|
|
inherit: false,
|
|
fontSize: 12.0,
|
|
)));
|
|
expect(tester.takeException(), isNotNull);
|
|
});
|
|
|
|
testWidgets(
|
|
'tap moves cursor to the edge of the word it tapped on (iOS)',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.iOS),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
|
|
await tester.pump();
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
// We moved the cursor.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
|
|
);
|
|
|
|
// But don't trigger the toolbar.
|
|
expect(find.byType(CupertinoButton), findsNothing);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'tap moves cursor to the position tapped (Android)',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: Center(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
|
|
await tester.pump();
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// We moved the cursor.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
|
|
);
|
|
|
|
// But don't trigger the toolbar.
|
|
expect(find.byType(FlatButton), findsNothing);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'two slow taps do not trigger a word selection (iOS)',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.iOS),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
|
|
await tester.pump();
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// Plain collapsed selection.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
|
|
);
|
|
|
|
// No toolbar.
|
|
expect(find.byType(CupertinoButton), findsNothing);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'double tap selects word and first tap of double tap moves cursor (iOS)',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.iOS),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
// This tap just puts the cursor somewhere different than where the double
|
|
// tap will occur to test that the double tap moves the existing cursor first.
|
|
await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
|
|
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// First tap moved the cursor.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream),
|
|
);
|
|
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
|
|
await tester.pump();
|
|
|
|
// Second tap selects the word around the cursor.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection(baseOffset: 8, extentOffset: 12),
|
|
);
|
|
|
|
// Selected text shows 1 toolbar buttons.
|
|
expect(find.byType(CupertinoButton), findsNWidgets(1));
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'double tap selects word and first tap of double tap moves cursor and shows toolbar (Android)',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: Center(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
// This tap just puts the cursor somewhere different than where the double
|
|
// tap will occur to test that the double tap moves the existing cursor first.
|
|
await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
|
|
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// First tap moved the cursor.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream),
|
|
);
|
|
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
|
|
await tester.pump();
|
|
|
|
// Second tap selects the word around the cursor.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection(baseOffset: 8, extentOffset: 12),
|
|
);
|
|
|
|
// Selected text shows 2 toolbar buttons: copy, select all
|
|
expect(find.byType(FlatButton), findsNWidgets(2));
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'double tap on top of cursor also selects word (Android)',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: Center(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
// Tap to put the cursor after the "w".
|
|
const int index = 3;
|
|
await tester.tapAt(textOffsetToPosition(tester, index));
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: index),
|
|
);
|
|
|
|
// Double tap on the same location.
|
|
await tester.tapAt(textOffsetToPosition(tester, index));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
// First tap doesn't change the selection
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: index),
|
|
);
|
|
|
|
// Second tap selects the word around the cursor.
|
|
await tester.tapAt(textOffsetToPosition(tester, index));
|
|
await tester.pump();
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection(baseOffset: 0, extentOffset: 7),
|
|
);
|
|
|
|
// Selected text shows 2 toolbar buttons: copy, select all
|
|
expect(find.byType(FlatButton), findsNWidgets(2));
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'double tap hold selects word (iOS)',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.iOS),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
final TestGesture gesture =
|
|
await tester.startGesture(selectableTextStart + const Offset(150.0, 5.0));
|
|
// Hold the press.
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection(baseOffset: 8, extentOffset: 12),
|
|
);
|
|
|
|
// Selected text shows 1 toolbar buttons.
|
|
expect(find.byType(CupertinoButton), findsNWidgets(1));
|
|
|
|
await gesture.up();
|
|
await tester.pump();
|
|
|
|
// Still selected.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection(baseOffset: 8, extentOffset: 12),
|
|
);
|
|
// The toolbar is still showing.
|
|
expect(find.byType(CupertinoButton), findsNWidgets(1));
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'tap after a double tap select is not affected (iOS)',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.iOS),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// First tap moved the cursor.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream),
|
|
);
|
|
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
|
|
await tester.tapAt(selectableTextStart + const Offset(100.0, 5.0));
|
|
await tester.pump();
|
|
|
|
// Plain collapsed selection at the edge of first word. In iOS 12, the
|
|
// the first tap after a double tap ends up putting the cursor at where
|
|
// you tapped instead of the edge like every other single tap. This is
|
|
// likely a bug in iOS 12 and not present in other versions.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 7),
|
|
);
|
|
|
|
// No toolbar.
|
|
expect(find.byType(CupertinoButton), findsNothing);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'long press moves cursor to the exact long press position and shows toolbar (iOS)',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.iOS),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0));
|
|
await tester.pump();
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// Collapsed cursor for iOS long press.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
|
|
);
|
|
|
|
// Collapsed toolbar shows 2 buttons.
|
|
expect(find.byType(CupertinoButton), findsNWidgets(1));
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'long press selects word and shows toolbar (Android)',
|
|
(WidgetTester tester) async {
|
|
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: Center(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0));
|
|
await tester.pump();
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection(baseOffset: 0, extentOffset: 7),
|
|
);
|
|
|
|
// Collapsed toolbar shows 2 buttons: copy, select all
|
|
expect(find.byType(FlatButton), findsNWidgets(2));
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'long press tap cannot initiate a double tap (iOS)',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.iOS),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
|
|
await tester.pump();
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// We ended up moving the cursor to the edge of the same word and dismissed
|
|
// the toolbar.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
|
|
);
|
|
|
|
expect(find.byType(CupertinoButton), findsNothing);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'long press drag moves the cursor under the drag and shows toolbar on lift (iOS)',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.iOS),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
final TestGesture gesture =
|
|
await tester.startGesture(selectableTextStart + const Offset(50.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// Long press on iOS shows collapsed selection cursor.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
|
|
);
|
|
// Cursor move doesn't trigger a toolbar initially.
|
|
expect(find.byType(CupertinoButton), findsNothing);
|
|
|
|
await gesture.moveBy(const Offset(50, 0));
|
|
await tester.pump();
|
|
|
|
// The selection position is now moved with the drag.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 7, affinity: TextAffinity.downstream),
|
|
);
|
|
// Still no toolbar.
|
|
expect(find.byType(CupertinoButton), findsNothing);
|
|
|
|
await gesture.moveBy(const Offset(50, 0));
|
|
await tester.pump();
|
|
|
|
// The selection position is now moved with the drag.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream),
|
|
);
|
|
// Still no toolbar.
|
|
expect(find.byType(CupertinoButton), findsNothing);
|
|
|
|
await gesture.up();
|
|
await tester.pump();
|
|
|
|
// The selection isn't affected by the gesture lift.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream),
|
|
);
|
|
// The toolbar now shows up.
|
|
expect(find.byType(CupertinoButton), findsNWidgets(1));
|
|
},
|
|
);
|
|
|
|
testWidgets('long press drag can edge scroll (iOS)', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.iOS),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText(
|
|
'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges',
|
|
maxLines: 1,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final RenderEditable renderEditable = findRenderEditable(tester);
|
|
|
|
List<TextSelectionPoint> lastCharEndpoint = renderEditable.getEndpointsForSelection(
|
|
const TextSelection.collapsed(offset: 66), // Last character's position.
|
|
);
|
|
|
|
expect(lastCharEndpoint.length, 1);
|
|
// Just testing the test and making sure that the last character is off
|
|
// the right side of the screen.
|
|
expect(lastCharEndpoint[0].point.dx, 924.0);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
final TestGesture gesture =
|
|
await tester.startGesture(selectableTextStart + const Offset(300, 5));
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 21),
|
|
);
|
|
expect(find.byType(CupertinoButton), findsNothing);
|
|
|
|
await gesture.moveBy(const Offset(600, 0));
|
|
// To the edge of the screen basically.
|
|
await tester.pump();
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 64, affinity: TextAffinity.downstream),
|
|
);
|
|
// Keep moving out.
|
|
await gesture.moveBy(const Offset(1, 0));
|
|
await tester.pump();
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream),
|
|
);
|
|
await gesture.moveBy(const Offset(1, 0));
|
|
await tester.pump();
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream),
|
|
); // We're at the edge now.
|
|
expect(find.byType(CupertinoButton), findsNothing);
|
|
|
|
await gesture.up();
|
|
await tester.pump();
|
|
|
|
// The selection isn't affected by the gesture lift.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream),
|
|
);
|
|
// The toolbar now shows up.
|
|
expect(find.byType(CupertinoButton), findsNWidgets(1));
|
|
|
|
lastCharEndpoint = renderEditable.getEndpointsForSelection(
|
|
const TextSelection.collapsed(offset: 66), // Last character's position.
|
|
);
|
|
|
|
expect(lastCharEndpoint.length, 1);
|
|
// The last character is now on screen near the right edge.
|
|
expect(lastCharEndpoint[0].point.dx, moreOrLessEquals(798, epsilon: 1));
|
|
|
|
final List<TextSelectionPoint> firstCharEndpoint = renderEditable.getEndpointsForSelection(
|
|
const TextSelection.collapsed(offset: 0), // First character's position.
|
|
);
|
|
expect(firstCharEndpoint.length, 1);
|
|
// The first character is now offscreen to the left.
|
|
expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-125, epsilon: 1));
|
|
});
|
|
|
|
testWidgets(
|
|
'long tap after a double tap select is not affected (iOS)',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.iOS),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// First tap moved the cursor to the beginning of the second word.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream),
|
|
);
|
|
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
|
|
await tester.longPressAt(selectableTextStart + const Offset(100.0, 5.0));
|
|
await tester.pump();
|
|
|
|
// Plain collapsed selection at the exact tap position.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 7),
|
|
);
|
|
|
|
// Long press toolbar.
|
|
expect(find.byType(CupertinoButton), findsNWidgets(1));
|
|
},
|
|
);
|
|
//convert
|
|
testWidgets(
|
|
'double tap after a long tap is not affected (iOS)',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.iOS),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// First tap moved the cursor.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream),
|
|
);
|
|
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
|
|
await tester.pump();
|
|
|
|
// Double tap selection.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection(baseOffset: 8, extentOffset: 12),
|
|
);
|
|
expect(find.byType(CupertinoButton), findsNWidgets(1));
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'double tap chains work (iOS)',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.iOS),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
|
|
);
|
|
await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection(baseOffset: 0, extentOffset: 7),
|
|
);
|
|
expect(find.byType(CupertinoButton), findsNWidgets(1));
|
|
|
|
// Double tap selecting the same word somewhere else is fine.
|
|
await tester.tapAt(selectableTextStart + const Offset(10.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
// First tap moved the cursor.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 0, affinity: TextAffinity.downstream),
|
|
);
|
|
await tester.tapAt(selectableTextStart + const Offset(10.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection(baseOffset: 0, extentOffset: 7),
|
|
);
|
|
expect(find.byType(CupertinoButton), findsNWidgets(1));
|
|
|
|
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
// First tap moved the cursor.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream),
|
|
);
|
|
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection(baseOffset: 8, extentOffset: 12),
|
|
);
|
|
expect(find.byType(CupertinoButton), findsNWidgets(1));
|
|
},
|
|
);
|
|
|
|
testWidgets('force press does not select a word on (android)', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset offset = tester.getTopLeft(find.byType(SelectableText)) + const Offset(150.0, 5.0);
|
|
|
|
const int pointerValue = 1;
|
|
final TestGesture gesture = await tester.createGesture();
|
|
await gesture.downWithCustomEvent(
|
|
offset,
|
|
PointerDownEvent(
|
|
pointer: pointerValue,
|
|
position: offset,
|
|
pressure: 0.0,
|
|
pressureMax: 6.0,
|
|
pressureMin: 0.0,
|
|
),
|
|
);
|
|
await gesture.updateWithCustomEvent(PointerMoveEvent(pointer: pointerValue, position: offset + const Offset(150.0, 5.0), pressure: 0.5, pressureMin: 0, pressureMax: 1));
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// We don't want this gesture to select any word on Android.
|
|
expect(controller.selection, const TextSelection.collapsed(offset: -1));
|
|
|
|
await gesture.up();
|
|
await tester.pump();
|
|
expect(find.byType(FlatButton), findsNothing);
|
|
});
|
|
|
|
testWidgets('force press selects word (iOS)', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.iOS),
|
|
home: const Material(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
const int pointerValue = 1;
|
|
final Offset offset = selectableTextStart + const Offset(150.0, 5.0);
|
|
final TestGesture gesture = await tester.createGesture();
|
|
await gesture.downWithCustomEvent(
|
|
offset,
|
|
PointerDownEvent(
|
|
pointer: pointerValue,
|
|
position: offset,
|
|
pressure: 0.0,
|
|
pressureMax: 6.0,
|
|
pressureMin: 0.0,
|
|
),
|
|
);
|
|
|
|
await gesture.updateWithCustomEvent(PointerMoveEvent(pointer: pointerValue, position: selectableTextStart + const Offset(150.0, 5.0), pressure: 0.5, pressureMin: 0, pressureMax: 1));
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// We expect the force press to select a word at the given location.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection(baseOffset: 8, extentOffset: 12),
|
|
);
|
|
|
|
await gesture.up();
|
|
await tester.pump();
|
|
expect(find.byType(CupertinoButton), findsNWidgets(1));
|
|
});
|
|
|
|
testWidgets('tap on non-force-press-supported devices work (iOS)', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.iOS),
|
|
home: const Material(
|
|
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
|
|
|
|
const int pointerValue = 1;
|
|
final Offset offset = selectableTextStart + const Offset(150.0, 5.0);
|
|
final TestGesture gesture = await tester.createGesture();
|
|
await gesture.downWithCustomEvent(
|
|
offset,
|
|
PointerDownEvent(
|
|
pointer: pointerValue,
|
|
position: offset,
|
|
// iPhone 6 and below report 0 across the board.
|
|
pressure: 0,
|
|
pressureMax: 0,
|
|
pressureMin: 0,
|
|
),
|
|
);
|
|
|
|
await gesture.updateWithCustomEvent(PointerMoveEvent(pointer: pointerValue, position: selectableTextStart + const Offset(150.0, 5.0), pressure: 0.5, pressureMin: 0, pressureMax: 1));
|
|
await gesture.up();
|
|
|
|
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
|
|
final TextEditingController controller = editableTextWidget.controller;
|
|
|
|
// The event should fallback to a normal tap and move the cursor.
|
|
// Single taps selects the edge of the word.
|
|
expect(
|
|
controller.selection,
|
|
const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream),
|
|
);
|
|
|
|
await tester.pump();
|
|
// Single taps shouldn't trigger the toolbar.
|
|
expect(find.byType(CupertinoButton), findsNothing);
|
|
});
|
|
|
|
testWidgets('default SelectableText debugFillProperties', (WidgetTester tester) async {
|
|
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
|
|
|
|
const SelectableText('something').debugFillProperties(builder);
|
|
|
|
final List<String> description = builder.properties
|
|
.where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info))
|
|
.map((DiagnosticsNode node) => node.toString()).toList();
|
|
|
|
expect(description, <String>['data: something']);
|
|
});
|
|
|
|
testWidgets('SelectableText implements debugFillProperties', (WidgetTester tester) async {
|
|
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
|
|
|
|
// properties.add(DiagnosticsProperty<String>('data', data, defaultValue: null));
|
|
// properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null));
|
|
// properties.add(DiagnosticsProperty<TextStyle>('style', style, defaultValue: null));
|
|
// properties.add(DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false));
|
|
// properties.add(DiagnosticsProperty<bool>('showCursor', showCursor, defaultValue: false));
|
|
// properties.add(IntProperty('maxLines', maxLines, defaultValue: null));
|
|
// properties.add(EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null));
|
|
// properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
|
|
// properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0));
|
|
// properties.add(DiagnosticsProperty<Radius>('cursorRadius', cursorRadius, defaultValue: null));
|
|
// properties.add(DiagnosticsProperty<Color>('cursorColor', cursorColor, defaultValue: null));
|
|
// properties.add(FlagProperty('selectionEnabled', value: selectionEnabled, defaultValue: true, ifFalse: 'selection disabled'));
|
|
// properties.add(DiagnosticsProperty<ScrollPhysics>('scrollPhysics', scrollPhysics, defaultValue: null));
|
|
// Not checking controller, inputFormatters, focusNode
|
|
const SelectableText(
|
|
'something',
|
|
style: TextStyle(color: Color(0xff00ff00)),
|
|
textAlign: TextAlign.end,
|
|
textDirection: TextDirection.ltr,
|
|
autofocus: true,
|
|
showCursor: true,
|
|
maxLines: 10,
|
|
cursorWidth: 1.0,
|
|
cursorRadius: Radius.zero,
|
|
cursorColor: Color(0xff00ff00),
|
|
scrollPhysics: ClampingScrollPhysics(),
|
|
enableInteractiveSelection: false,
|
|
).debugFillProperties(builder);
|
|
|
|
final List<String> description = builder.properties
|
|
.where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info))
|
|
.map((DiagnosticsNode node) => node.toString()).toList();
|
|
|
|
expect(description, <String>[
|
|
'data: something',
|
|
'style: TextStyle(inherit: true, color: Color(0xff00ff00))',
|
|
'autofocus: true',
|
|
'showCursor: true',
|
|
'maxLines: 10',
|
|
'textAlign: end',
|
|
'textDirection: ltr',
|
|
'cursorWidth: 1.0',
|
|
'cursorRadius: Radius.circular(0.0)',
|
|
'cursorColor: Color(0xff00ff00)',
|
|
'selection disabled',
|
|
'scrollPhysics: ClampingScrollPhysics',
|
|
]);
|
|
});
|
|
|
|
testWidgets(
|
|
'strut basic single line',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.android),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText('something'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
tester.getSize(find.byType(SelectableText)),
|
|
// This is the height of the decoration (24) plus the metrics from the default
|
|
// TextStyle of the theme (16).
|
|
const Size(129.0, 14.0),
|
|
);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'strut TextStyle increases height',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.android),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText(
|
|
'something',
|
|
style: TextStyle(fontSize: 20),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
tester.getSize(find.byType(SelectableText)),
|
|
// Strut should inherit the TextStyle.fontSize by default and produce the
|
|
// same height as if it were disabled.
|
|
const Size(183.0, 20.0),
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.android),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText(
|
|
'something',
|
|
style: TextStyle(fontSize: 20),
|
|
strutStyle: StrutStyle.disabled,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
tester.getSize(find.byType(SelectableText)),
|
|
// The height here should match the previous version with strut enabled.
|
|
const Size(183.0, 20.0),
|
|
);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'strut basic multi line',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.android),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText(
|
|
'something',
|
|
maxLines: 6,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
tester.getSize(find.byType(SelectableText)),
|
|
const Size(129.0, 84.0),
|
|
);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'strut no force small strut',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.android),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText(
|
|
'something',
|
|
maxLines: 6,
|
|
strutStyle: StrutStyle(
|
|
// The small strut is overtaken by the larger
|
|
// TextStyle fontSize.
|
|
fontSize: 5,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
tester.getSize(find.byType(SelectableText)),
|
|
// When the strut's height is smaller than TextStyle's and forceStrutHeight
|
|
// is disabled, then the TextStyle takes precedence. Should be the same height
|
|
// as 'strut basic multi line'.
|
|
const Size(129.0, 84.0),
|
|
);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'strut no force large strut',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.android),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText(
|
|
'something',
|
|
maxLines: 6,
|
|
strutStyle: StrutStyle(
|
|
fontSize: 25,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
tester.getSize(find.byType(SelectableText)),
|
|
// When the strut's height is larger than TextStyle's and forceStrutHeight
|
|
// is disabled, then the StrutStyle takes precedence.
|
|
const Size(129.0, 150.0),
|
|
);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'strut height override',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.android),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText(
|
|
'something',
|
|
maxLines: 3,
|
|
strutStyle: StrutStyle(
|
|
fontSize: 8,
|
|
forceStrutHeight: true,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
tester.getSize(find.byType(SelectableText)),
|
|
// The smaller font size of strut make the field shorter than normal.
|
|
const Size(129.0, 24.0),
|
|
);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'strut forces field taller',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(platform: TargetPlatform.android),
|
|
home: const Material(
|
|
child: Center(
|
|
child: SelectableText(
|
|
'something',
|
|
maxLines: 3,
|
|
style: TextStyle(fontSize: 10),
|
|
strutStyle: StrutStyle(
|
|
fontSize: 18,
|
|
forceStrutHeight: true,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
tester.getSize(find.byType(SelectableText)),
|
|
// When the strut fontSize is larger than a provided TextStyle, the
|
|
// the strut's height takes precedence.
|
|
const Size(93.0, 54.0),
|
|
);
|
|
},
|
|
);
|
|
|
|
testWidgets('Caret center position', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: Container(
|
|
width: 300.0,
|
|
child: const SelectableText(
|
|
'abcd',
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final RenderEditable editable = findRenderEditable(tester);
|
|
|
|
Offset topLeft = editable.localToGlobal(
|
|
editable.getLocalRectForCaret(const TextPosition(offset: 4)).topLeft,
|
|
);
|
|
expect(topLeft.dx, equals(427));
|
|
|
|
topLeft = editable.localToGlobal(
|
|
editable.getLocalRectForCaret(const TextPosition(offset: 3)).topLeft,
|
|
);
|
|
expect(topLeft.dx, equals(413));
|
|
|
|
topLeft = editable.localToGlobal(
|
|
editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft,
|
|
);
|
|
expect(topLeft.dx, equals(399));
|
|
|
|
topLeft = editable.localToGlobal(
|
|
editable.getLocalRectForCaret(const TextPosition(offset: 1)).topLeft,
|
|
);
|
|
expect(topLeft.dx, equals(385));
|
|
});
|
|
|
|
testWidgets('Caret indexes into trailing whitespace center align', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
overlay(
|
|
child: Container(
|
|
width: 300.0,
|
|
child: const SelectableText(
|
|
'abcd ',
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final RenderEditable editable = findRenderEditable(tester);
|
|
|
|
Offset topLeft = editable.localToGlobal(
|
|
editable.getLocalRectForCaret(const TextPosition(offset: 7)).topLeft,
|
|
);
|
|
expect(topLeft.dx, equals(469));
|
|
|
|
topLeft = editable.localToGlobal(
|
|
editable.getLocalRectForCaret(const TextPosition(offset: 8)).topLeft,
|
|
);
|
|
expect(topLeft.dx, equals(483));
|
|
|
|
topLeft = editable.localToGlobal(
|
|
editable.getLocalRectForCaret(const TextPosition(offset: 4)).topLeft,
|
|
);
|
|
expect(topLeft.dx, equals(427));
|
|
|
|
topLeft = editable.localToGlobal(
|
|
editable.getLocalRectForCaret(const TextPosition(offset: 3)).topLeft,
|
|
);
|
|
expect(topLeft.dx, equals(413));
|
|
|
|
topLeft = editable.localToGlobal(
|
|
editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft,
|
|
);
|
|
expect(topLeft.dx, equals(399));
|
|
|
|
topLeft = editable.localToGlobal(
|
|
editable.getLocalRectForCaret(const TextPosition(offset: 1)).topLeft,
|
|
);
|
|
expect(topLeft.dx, equals(385));
|
|
});
|
|
|
|
testWidgets('selection handles are rendered and not faded away', (WidgetTester tester) async {
|
|
const String testText = 'lorem ipsum';
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: SelectableText(testText),
|
|
),
|
|
),
|
|
);
|
|
|
|
final EditableTextState state =
|
|
tester.state<EditableTextState>(find.byType(EditableText));
|
|
final RenderEditable renderEditable = state.renderEditable;
|
|
|
|
await tester.tapAt(const Offset(20, 10));
|
|
renderEditable.selectWord(cause: SelectionChangedCause.longPress);
|
|
await tester.pumpAndSettle();
|
|
|
|
final List<Widget> transitions =
|
|
find.byType(FadeTransition).evaluate().map((Element e) => e.widget).toList();
|
|
// On Android, an empty app contains a single FadeTransition. The following
|
|
// two are the left and right text selection handles, respectively.
|
|
expect(transitions.length, 3);
|
|
final FadeTransition left = transitions[1];
|
|
final FadeTransition right = transitions[2];
|
|
|
|
expect(left.opacity.value, equals(1.0));
|
|
expect(right.opacity.value, equals(1.0));
|
|
});
|
|
|
|
testWidgets('iOS selection handles are rendered and not faded away', (WidgetTester tester) async {
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
const String testText = 'lorem ipsum';
|
|
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: SelectableText(testText),
|
|
),
|
|
),
|
|
);
|
|
|
|
final RenderEditable renderEditable =
|
|
tester.state<EditableTextState>(find.byType(EditableText)).renderEditable;
|
|
|
|
await tester.tapAt(const Offset(20, 10));
|
|
renderEditable.selectWord(cause: SelectionChangedCause.longPress);
|
|
await tester.pumpAndSettle();
|
|
|
|
final List<Widget> transitions =
|
|
find.byType(FadeTransition).evaluate().map((Element e) => e.widget).toList();
|
|
expect(transitions.length, 2);
|
|
final FadeTransition left = transitions[0];
|
|
final FadeTransition right = transitions[1];
|
|
|
|
expect(left.opacity.value, equals(1.0));
|
|
expect(right.opacity.value, equals(1.0));
|
|
|
|
debugDefaultTargetPlatformOverride = null;
|
|
});
|
|
|
|
testWidgets('Long press shows handles and toolbar', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: SelectableText('abc def ghi'),
|
|
),
|
|
),
|
|
);
|
|
|
|
// Long press at 'e' in 'def'.
|
|
final Offset ePos = textOffsetToPosition(tester, 5);
|
|
await tester.longPressAt(ePos);
|
|
await tester.pumpAndSettle();
|
|
|
|
final EditableTextState editableText = tester.state(find.byType(EditableText));
|
|
expect(editableText.selectionOverlay.handlesAreVisible, isTrue);
|
|
expect(editableText.selectionOverlay.toolbarIsVisible, isTrue);
|
|
});
|
|
|
|
testWidgets('Double tap shows handles and toolbar', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: SelectableText('abc def ghi'),
|
|
),
|
|
),
|
|
);
|
|
|
|
// Double tap at 'e' in 'def'.
|
|
final Offset ePos = textOffsetToPosition(tester, 5);
|
|
await tester.tapAt(ePos);
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
await tester.tapAt(ePos);
|
|
await tester.pump();
|
|
|
|
final EditableTextState editableText = tester.state(find.byType(EditableText));
|
|
expect(editableText.selectionOverlay.handlesAreVisible, isTrue);
|
|
expect(editableText.selectionOverlay.toolbarIsVisible, isTrue);
|
|
});
|
|
|
|
testWidgets(
|
|
'Mouse tap does not show handles nor toolbar',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: SelectableText('abc def ghi'),
|
|
),
|
|
),
|
|
);
|
|
|
|
// Long press to trigger the selectable text.
|
|
final Offset ePos = textOffsetToPosition(tester, 5);
|
|
final TestGesture gesture = await tester.startGesture(
|
|
ePos,
|
|
pointer: 7,
|
|
kind: PointerDeviceKind.mouse,
|
|
);
|
|
addTearDown(gesture.removePointer);
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pump();
|
|
|
|
final EditableTextState editableText = tester.state(find.byType(EditableText));
|
|
expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
|
|
expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'Mouse long press does not show handles nor toolbar',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: SelectableText('abc def ghi'),
|
|
),
|
|
),
|
|
);
|
|
|
|
// Long press to trigger the selectable text.
|
|
final Offset ePos = textOffsetToPosition(tester, 5);
|
|
final TestGesture gesture = await tester.startGesture(
|
|
ePos,
|
|
pointer: 7,
|
|
kind: PointerDeviceKind.mouse,
|
|
);
|
|
addTearDown(gesture.removePointer);
|
|
await tester.pump(const Duration(seconds: 2));
|
|
await gesture.up();
|
|
await tester.pump();
|
|
|
|
final EditableTextState editableText = tester.state(find.byType(EditableText));
|
|
expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
|
|
expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'Mouse double tap does not show handles nor toolbar',
|
|
(WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
const MaterialApp(
|
|
home: Material(
|
|
child: SelectableText('abc def ghi'),
|
|
),
|
|
),
|
|
);
|
|
|
|
// Double tap to trigger the selectable text.
|
|
final Offset selectableTextPos = tester.getCenter(find.byType(SelectableText));
|
|
final TestGesture gesture = await tester.startGesture(
|
|
selectableTextPos,
|
|
pointer: 7,
|
|
kind: PointerDeviceKind.mouse,
|
|
);
|
|
addTearDown(gesture.removePointer);
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
await gesture.up();
|
|
await tester.pump();
|
|
await gesture.down(selectableTextPos);
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pump();
|
|
|
|
final EditableTextState editableText = tester.state(find.byType(EditableText));
|
|
expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
|
|
expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
|
|
},
|
|
);
|
|
}
|