From c42b36f6b47f788332b5462d23c6155ba0455d5c Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Thu, 10 Feb 2022 19:45:16 -0800 Subject: [PATCH] Windows/Linux keyboard shortcuts at a wordwrap (#96323) --- .../default_text_editing_shortcuts.dart | 14 +- .../lib/src/widgets/editable_text.dart | 86 +- .../lib/src/widgets/text_editing_intents.dart | 51 +- .../test/widgets/editable_text_test.dart | 951 +++++++++++++++++- 4 files changed, 1058 insertions(+), 44 deletions(-) diff --git a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart index b97f3f6c9ce..71ccd914a9d 100644 --- a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart +++ b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart @@ -317,8 +317,10 @@ class DefaultTextEditingShortcuts extends Shortcuts { const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, meta: true): const ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: false), const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, meta: true): const ExtendSelectionToDocumentBoundaryIntent(forward: true, collapseSelection: false), - const SingleActivator(LogicalKeyboardKey.home, shift: true): const ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: false), - const SingleActivator(LogicalKeyboardKey.end, shift: true): const ExtendSelectionToDocumentBoundaryIntent(forward: true, collapseSelection: false), + const SingleActivator(LogicalKeyboardKey.home): const ScrollToDocumentBoundaryIntent(forward: false), + const SingleActivator(LogicalKeyboardKey.end): const ScrollToDocumentBoundaryIntent(forward: true), + const SingleActivator(LogicalKeyboardKey.home, shift: true): const ExpandSelectionToDocumentBoundaryIntent(forward: false), + const SingleActivator(LogicalKeyboardKey.end, shift: true): const ExpandSelectionToDocumentBoundaryIntent(forward: true), const SingleActivator(LogicalKeyboardKey.keyX, meta: true): const CopySelectionTextIntent.cut(SelectionChangedCause.keyboard), const SingleActivator(LogicalKeyboardKey.keyC, meta: true): CopySelectionTextIntent.copy, @@ -349,10 +351,10 @@ class DefaultTextEditingShortcuts extends Shortcuts { // * Meta + backspace static final Map _windowsShortcuts = { ..._commonShortcuts, - const SingleActivator(LogicalKeyboardKey.home): const ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true), - const SingleActivator(LogicalKeyboardKey.end): const ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true), - const SingleActivator(LogicalKeyboardKey.home, shift: true): const ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: false), - const SingleActivator(LogicalKeyboardKey.end, shift: true): const ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: false), + const SingleActivator(LogicalKeyboardKey.home): const ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true, continuesAtWrap: true), + const SingleActivator(LogicalKeyboardKey.end): const ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true, continuesAtWrap: true), + const SingleActivator(LogicalKeyboardKey.home, shift: true): const ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: false, continuesAtWrap: true), + const SingleActivator(LogicalKeyboardKey.end, shift: true): const ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: false, continuesAtWrap: true), const SingleActivator(LogicalKeyboardKey.home, control: true): const ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: true), const SingleActivator(LogicalKeyboardKey.end, control: true): const ExtendSelectionToDocumentBoundaryIntent(forward: true, collapseSelection: true), const SingleActivator(LogicalKeyboardKey.home, shift: true, control: true): const ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: false), diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index c4ef29db721..6c11a9fa97f 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -3069,7 +3069,18 @@ class EditableTextState extends State with AutomaticKeepAliveClien } late final Action _replaceTextAction = CallbackAction(onInvoke: _replaceText); + // Scrolls either to the beginning or end of the document depending on the + // intent's `forward` parameter. + void _scrollToDocumentBoundary(ScrollToDocumentBoundaryIntent intent) { + if (intent.forward) { + bringIntoView(TextPosition(offset: _value.text.length)); + } else { + bringIntoView(const TextPosition(offset: 0)); + } + } + void _updateSelection(UpdateSelectionIntent intent) { + bringIntoView(intent.newSelection.extent); userUpdateTextEditingValue( intent.currentTextEditingValue.copyWith(selection: intent.newSelection), intent.cause, @@ -3079,28 +3090,38 @@ class EditableTextState extends State with AutomaticKeepAliveClien late final _UpdateTextSelectionToAdjacentLineAction _adjacentLineAction = _UpdateTextSelectionToAdjacentLineAction(this); - void _expandSelection(ExpandSelectionToLineBreakIntent intent) { + void _expandSelectionToDocumentBoundary(ExpandSelectionToDocumentBoundaryIntent intent) { + final _TextBoundary textBoundary = _documentBoundary(intent); + _expandSelection(intent.forward, textBoundary, true); + } + + void _expandSelectionToLinebreak(ExpandSelectionToLineBreakIntent intent) { final _TextBoundary textBoundary = _linebreak(intent); + _expandSelection(intent.forward, textBoundary); + } + + void _expandSelection(bool forward, _TextBoundary textBoundary, [bool extentAtIndex = false]) { final TextSelection textBoundarySelection = textBoundary.textEditingValue.selection; if (!textBoundarySelection.isValid) { return; } final bool inOrder = textBoundarySelection.baseOffset <= textBoundarySelection.extentOffset; - final bool towardsExtent = intent.forward == inOrder; + final bool towardsExtent = forward == inOrder; final TextPosition position = towardsExtent ? textBoundarySelection.extent : textBoundarySelection.base; - final TextPosition newExtent = intent.forward + final TextPosition newExtent = forward ? textBoundary.getTrailingTextBoundaryAt(position) : textBoundary.getLeadingTextBoundaryAt(position); - final TextSelection newSelection = textBoundarySelection.expandTo(newExtent, textBoundarySelection.isCollapsed); + final TextSelection newSelection = textBoundarySelection.expandTo(newExtent, textBoundarySelection.isCollapsed || extentAtIndex); userUpdateTextEditingValue( _value.copyWith(selection: newSelection), SelectionChangedCause.keyboard, ); + bringIntoView(newSelection.extent); } late final Map> _actions = >{ @@ -3118,10 +3139,12 @@ class EditableTextState extends State with AutomaticKeepAliveClien ExtendSelectionByCharacterIntent: _makeOverridable(_UpdateTextSelectionAction(this, false, _characterBoundary,)), ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction(this, true, _nextWordBoundary)), ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction(this, true, _linebreak)), - ExpandSelectionToLineBreakIntent: _makeOverridable(CallbackAction(onInvoke: _expandSelection)), + ExpandSelectionToLineBreakIntent: _makeOverridable(CallbackAction(onInvoke: _expandSelectionToLinebreak)), + ExpandSelectionToDocumentBoundaryIntent: _makeOverridable(CallbackAction(onInvoke: _expandSelectionToDocumentBoundary)), ExtendSelectionVerticallyToAdjacentLineIntent: _makeOverridable(_adjacentLineAction), ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction(this, true, _documentBoundary)), ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(_ExtendSelectionOrCaretPositionAction(this, _nextWordBoundary)), + ScrollToDocumentBoundaryIntent: _makeOverridable(CallbackAction(onInvoke: _scrollToDocumentBoundary)), // Copy Paste SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), @@ -3763,7 +3786,10 @@ class _WordBoundary extends _TextBoundary { // interpreted as caret locations because [TextPainter.getLineAtOffset] is // text-affinity-aware. class _LineBreak extends _TextBoundary { - const _LineBreak(this.textLayout, this.textEditingValue); + const _LineBreak( + this.textLayout, + this.textEditingValue, + ); final TextLayoutMetrics textLayout; @@ -3776,6 +3802,7 @@ class _LineBreak extends _TextBoundary { offset: textLayout.getLineAtOffset(position).start, ); } + @override TextPosition getTrailingTextBoundaryAt(TextPosition position) { return TextPosition( @@ -3945,12 +3972,39 @@ class _DeleteTextAction extends ContextA } class _UpdateTextSelectionAction extends ContextAction { - _UpdateTextSelectionAction(this.state, this.ignoreNonCollapsedSelection, this.getTextBoundariesForIntent); + _UpdateTextSelectionAction( + this.state, + this.ignoreNonCollapsedSelection, + this.getTextBoundariesForIntent, + ); final EditableTextState state; final bool ignoreNonCollapsedSelection; final _TextBoundary Function(T intent) getTextBoundariesForIntent; + static const int NEWLINE_CODE_UNIT = 10; + + // Returns true iff the given position is at a wordwrap boundary in the + // upstream position. + bool _isAtWordwrapUpstream(TextPosition position) { + final TextPosition end = TextPosition( + offset: state.renderEditable.getLineAtOffset(position).end, + affinity: TextAffinity.upstream, + ); + return end == position && end.offset != state.textEditingValue.text.length + && state.textEditingValue.text.codeUnitAt(position.offset) != NEWLINE_CODE_UNIT; + } + + // Returns true iff the given position at a wordwrap boundary in the + // downstream position. + bool _isAtWordwrapDownstream(TextPosition position) { + final TextPosition start = TextPosition( + offset: state.renderEditable.getLineAtOffset(position).start, + ); + return start == position && start.offset != 0 + && state.textEditingValue.text.codeUnitAt(position.offset - 1) != NEWLINE_CODE_UNIT; + } + @override Object? invoke(T intent, [BuildContext? context]) { final TextSelection selection = state._value.selection; @@ -3986,7 +4040,23 @@ class _UpdateTextSelectionAction exten ); } - final TextPosition extent = textBoundarySelection.extent; + TextPosition extent = textBoundarySelection.extent; + + // If continuesAtWrap is true extent and is at the relevant wordwrap, then + // move it just to the other side of the wordwrap. + if (intent.continuesAtWrap) { + if (intent.forward && _isAtWordwrapUpstream(extent)) { + extent = TextPosition( + offset: extent.offset, + ); + } else if (!intent.forward && _isAtWordwrapDownstream(extent)) { + extent = TextPosition( + offset: extent.offset, + affinity: TextAffinity.upstream, + ); + } + } + final TextPosition newExtent = intent.forward ? textBoundary.getTrailingTextBoundaryAt(extent) : textBoundary.getLeadingTextBoundaryAt(extent); diff --git a/packages/flutter/lib/src/widgets/text_editing_intents.dart b/packages/flutter/lib/src/widgets/text_editing_intents.dart index 9ba725082fc..a07e0960012 100644 --- a/packages/flutter/lib/src/widgets/text_editing_intents.dart +++ b/packages/flutter/lib/src/widgets/text_editing_intents.dart @@ -20,7 +20,9 @@ class DoNothingAndStopPropagationTextIntent extends Intent { /// direction of the current caret location. abstract class DirectionalTextEditingIntent extends Intent { /// Creates a [DirectionalTextEditingIntent]. - const DirectionalTextEditingIntent(this.forward); + const DirectionalTextEditingIntent( + this.forward, + ); /// Whether the input field, if applicable, should perform the text editing /// operation from the current caret location towards the end of the document. @@ -65,7 +67,10 @@ abstract class DirectionalCaretMovementIntent extends DirectionalTextEditingInte const DirectionalCaretMovementIntent( bool forward, this.collapseSelection, - [this.collapseAtReversal = false] + [ + this.collapseAtReversal = false, + this.continuesAtWrap = false, + ] ) : assert(!collapseSelection || !collapseAtReversal), super(forward); @@ -90,6 +95,14 @@ abstract class DirectionalCaretMovementIntent extends DirectionalTextEditingInte /// /// Cannot be true when collapseSelection is true. final bool collapseAtReversal; + + /// Whether or not to continue to the next line at a wordwrap. + /// + /// If true, when an [Intent] to go to the beginning/end of a wordwrapped line + /// is received and the selection is already at the beginning/end of the line, + /// then the selection will be moved to the next/previous line. If false, the + /// selection will remain at the wordwrap. + final bool continuesAtWrap; } /// Extends, or moves the current selection from the current @@ -132,6 +145,23 @@ class ExtendSelectionToNextWordBoundaryOrCaretLocationIntent extends Directional }) : super(forward); } +/// Expands the current selection to the document boundary in the direction +/// given by [forward]. +/// +/// Unlike [ExpandSelectionToLineBreakIntent], the extent will be moved, which +/// matches the behavior on MacOS. +/// +/// See also: +/// +/// [ExtendSelectionToDocumentBoundaryIntent], which is similar but always +/// moves the extent. +class ExpandSelectionToDocumentBoundaryIntent extends DirectionalTextEditingIntent { + /// Creates an [ExpandSelectionToDocumentBoundaryIntent]. + const ExpandSelectionToDocumentBoundaryIntent({ + required bool forward, + }) : super(forward); +} + /// Expands the current selection to the closest line break in the direction /// given by [forward]. /// @@ -165,8 +195,9 @@ class ExtendSelectionToLineBreakIntent extends DirectionalCaretMovementIntent { required bool forward, required bool collapseSelection, bool collapseAtReversal = false, + bool continuesAtWrap = false, }) : assert(!collapseSelection || !collapseAtReversal), - super(forward, collapseSelection, collapseAtReversal); + super(forward, collapseSelection, collapseAtReversal, continuesAtWrap); } /// Extends, or moves the current selection from the current @@ -182,6 +213,11 @@ class ExtendSelectionVerticallyToAdjacentLineIntent extends DirectionalCaretMove /// Extends, or moves the current selection from the current /// [TextSelection.extent] position to the start or the end of the document. +/// +/// See also: +/// +/// [ExtendSelectionToDocumentBoundaryIntent], which is similar but always +/// increases the size of the selection. class ExtendSelectionToDocumentBoundaryIntent extends DirectionalCaretMovementIntent { /// Creates an [ExtendSelectionToDocumentBoundaryIntent]. const ExtendSelectionToDocumentBoundaryIntent({ @@ -190,6 +226,15 @@ class ExtendSelectionToDocumentBoundaryIntent extends DirectionalCaretMovementIn }) : super(forward, collapseSelection); } +/// Scrolls to the beginning or end of the document depending on the [forward] +/// parameter. +class ScrollToDocumentBoundaryIntent extends DirectionalTextEditingIntent { + /// Creates a [ScrollToDocumentBoundaryIntent]. + const ScrollToDocumentBoundaryIntent({ + required bool forward, + }) : super(forward); +} + /// An [Intent] to select everything in the field. class SelectAllTextIntent extends Intent { /// Creates an instance of [SelectAllTextIntent]. diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index ac30949f35a..40ee809d536 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -5934,38 +5934,38 @@ void main() { targetPlatform: defaultTargetPlatform, ); - final int afterHomeOffset; - final int afterEndOffset; - final TextAffinity afterEndAffinity; switch (defaultTargetPlatform) { - // These platforms don't handle home/end at all. + // These platforms don't move the selection with home/end at all. case TargetPlatform.android: case TargetPlatform.iOS: case TargetPlatform.fuchsia: case TargetPlatform.macOS: - afterHomeOffset = 23; - afterEndOffset = 23; - afterEndAffinity = TextAffinity.downstream; + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 23, + ), + ), + reason: 'on $platform', + ); break; // These platforms go to the line start/end. case TargetPlatform.linux: case TargetPlatform.windows: - afterHomeOffset = 20; - afterEndOffset = 35; - afterEndAffinity = TextAffinity.upstream; + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 20, + ), + ), + reason: 'on $platform', + ); break; } - expect( - selection, - equals( - TextSelection.collapsed( - offset: afterHomeOffset, - ), - ), - reason: 'on $platform', - ); expect(controller.text, equals(testText), reason: 'on $platform'); await sendKeys( @@ -5976,17 +5976,363 @@ void main() { targetPlatform: defaultTargetPlatform, ); + switch (defaultTargetPlatform) { + // These platforms don't move the selection with home/end at all. + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + case TargetPlatform.macOS: + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 23, + ), + ), + reason: 'on $platform', + ); + break; + + // These platforms go to the line start/end. + case TargetPlatform.linux: + case TargetPlatform.windows: + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 35, + affinity: TextAffinity.upstream, + ), + ), + reason: 'on $platform', + ); + break; + } + expect(controller.text, equals(testText), reason: 'on $platform'); + }, + skip: kIsWeb, // [intended] on web these keys are handled by the browser. + variant: TargetPlatformVariant.all(), + ); + + testWidgets('home keys and wordwraps', (WidgetTester tester) async { + final String targetPlatformString = defaultTargetPlatform.toString(); + final String platform = targetPlatformString.substring(targetPlatformString.indexOf('.') + 1).toLowerCase(); + const String testText = 'Now is the time for all good people to come to the aid of their country. Now is the time for all good people to come to the aid of their country.'; + final TextEditingController controller = TextEditingController(text: testText); + controller.selection = const TextSelection( + baseOffset: 0, + extentOffset: 0, + affinity: TextAffinity.upstream, + ); + late TextSelection selection; + late SelectionChangedCause cause; + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: EditableText( + maxLines: 10, + controller: controller, + showSelectionHandles: true, + autofocus: true, + focusNode: FocusNode(), + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + textAlign: TextAlign.right, + onSelectionChanged: (TextSelection newSelection, SelectionChangedCause? newCause) { + selection = newSelection; + cause = newCause!; + }, + ), + ), + ), + )); + + await tester.pump(); // Wait for autofocus to take effect. + + // Move near the middle of the document. + await sendKeys( + tester, + [ + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowRight, + ], + targetPlatform: defaultTargetPlatform, + ); + + expect(cause, equals(SelectionChangedCause.keyboard), reason: 'on $platform'); expect( selection, equals( - TextSelection.collapsed( - offset: afterEndOffset, - affinity: afterEndAffinity, + const TextSelection.collapsed( + offset: 32, ), ), reason: 'on $platform', ); + + await sendKeys( + tester, + [ + LogicalKeyboardKey.home, + ], + targetPlatform: defaultTargetPlatform, + ); + + switch (defaultTargetPlatform) { + // These platforms don't move the selection with home/end at all. + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + case TargetPlatform.macOS: + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 32, + ), + ), + reason: 'on $platform', + ); + break; + + // These platforms go to the line start/end. + case TargetPlatform.linux: + case TargetPlatform.windows: + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 29, + ), + ), + reason: 'on $platform', + ); + break; + } + expect(controller.text, equals(testText), reason: 'on $platform'); + + await sendKeys( + tester, + [ + LogicalKeyboardKey.home, + ], + targetPlatform: defaultTargetPlatform, + ); + + switch (defaultTargetPlatform) { + // These platforms don't move the selection with home/end at all still. + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + case TargetPlatform.macOS: + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 32, + ), + ), + reason: 'on $platform', + ); + break; + + // Linux does nothing at a wordwrap with subsequent presses. + case TargetPlatform.linux: + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 29, + ), + ), + reason: 'on $platform', + ); + break; + + // Windows jumps to the previous wordwrapped line. + case TargetPlatform.windows: + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 0, + ), + ), + reason: 'on $platform', + ); + break; + } + }, + skip: kIsWeb, // [intended] on web these keys are handled by the browser. + variant: TargetPlatformVariant.all(), + ); + + testWidgets('end keys and wordwraps', (WidgetTester tester) async { + final String targetPlatformString = defaultTargetPlatform.toString(); + final String platform = targetPlatformString.substring(targetPlatformString.indexOf('.') + 1).toLowerCase(); + const String testText = 'Now is the time for all good people to come to the aid of their country. Now is the time for all good people to come to the aid of their country.'; + final TextEditingController controller = TextEditingController(text: testText); + controller.selection = const TextSelection( + baseOffset: 0, + extentOffset: 0, + affinity: TextAffinity.upstream, + ); + late TextSelection selection; + late SelectionChangedCause cause; + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: EditableText( + maxLines: 10, + controller: controller, + showSelectionHandles: true, + autofocus: true, + focusNode: FocusNode(), + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + textAlign: TextAlign.right, + onSelectionChanged: (TextSelection newSelection, SelectionChangedCause? newCause) { + selection = newSelection; + cause = newCause!; + }, + ), + ), + ), + )); + + await tester.pump(); // Wait for autofocus to take effect. + + // Move near the middle of the document. + await sendKeys( + tester, + [ + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowRight, + ], + targetPlatform: defaultTargetPlatform, + ); + + expect(cause, equals(SelectionChangedCause.keyboard), reason: 'on $platform'); + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 32, + ), + ), + reason: 'on $platform', + ); + + await sendKeys( + tester, + [ + LogicalKeyboardKey.end, + ], + targetPlatform: defaultTargetPlatform, + ); + + switch (defaultTargetPlatform) { + // These platforms don't move the selection with home/end at all. + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + case TargetPlatform.macOS: + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 32, + ), + ), + reason: 'on $platform', + ); + break; + + // These platforms go to the line start/end. + case TargetPlatform.linux: + case TargetPlatform.windows: + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 58, + affinity: TextAffinity.upstream, + ), + ), + reason: 'on $platform', + ); + break; + } + expect(controller.text, equals(testText), reason: 'on $platform'); + + await sendKeys( + tester, + [ + LogicalKeyboardKey.end, + ], + targetPlatform: defaultTargetPlatform, + ); + + switch (defaultTargetPlatform) { + // These platforms don't move the selection with home/end at all still. + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + case TargetPlatform.macOS: + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 32, + ), + ), + reason: 'on $platform', + ); + break; + + // Linux does nothing at a wordwrap with subsequent presses. + case TargetPlatform.linux: + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 58, + affinity: TextAffinity.upstream, + ), + ), + reason: 'on $platform', + ); + break; + + // Windows jumps to the next wordwrapped line. + case TargetPlatform.windows: + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 84, + affinity: TextAffinity.upstream, + ), + ), + reason: 'on $platform', + ); + break; + } }, skip: kIsWeb, // [intended] on web these keys are handled by the browser. variant: TargetPlatformVariant.all(), @@ -6301,6 +6647,557 @@ void main() { variant: const TargetPlatformVariant({ TargetPlatform.windows }) ); + testWidgets('home/end keys scrolling (Mac only)', (WidgetTester tester) async { + const String testText = 'Now is the time for all good people to come to the aid of their country. Now is the time for all good people to come to the aid of their country.'; + final TextEditingController controller = TextEditingController(text: testText); + controller.selection = const TextSelection( + baseOffset: 0, + extentOffset: 0, + affinity: TextAffinity.upstream, + ); + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: EditableText( + maxLines: 10, + controller: controller, + showSelectionHandles: true, + autofocus: true, + focusNode: FocusNode(), + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + textAlign: TextAlign.right, + ), + ), + ), + )); + + await tester.pump(); // Wait for autofocus to take effect. + + final Scrollable scrollable = tester.widget(find.byType(Scrollable)); + + expect(scrollable.controller!.offset, 0.0); + + // Scroll to the end of the document with the end key. + await sendKeys( + tester, + [ + LogicalKeyboardKey.end, + ], + targetPlatform: defaultTargetPlatform, + ); + final double maxScrollExtent = scrollable.controller!.position.maxScrollExtent; + expect(scrollable.controller!.offset, maxScrollExtent); + + // Scroll back to the beginning of the document with the home key. + await sendKeys( + tester, + [ + LogicalKeyboardKey.home, + ], + targetPlatform: defaultTargetPlatform, + ); + expect(scrollable.controller!.offset, 0.0); + }, + skip: kIsWeb, // [intended] on web these keys are handled by the browser. + variant: const TargetPlatformVariant({ TargetPlatform.macOS }) + ); + + testWidgets('shift + home keys and wordwraps', (WidgetTester tester) async { + final String targetPlatformString = defaultTargetPlatform.toString(); + final String platform = targetPlatformString.substring(targetPlatformString.indexOf('.') + 1).toLowerCase(); + const String testText = 'Now is the time for all good people to come to the aid of their country. Now is the time for all good people to come to the aid of their country.'; + final TextEditingController controller = TextEditingController(text: testText); + controller.selection = const TextSelection( + baseOffset: 0, + extentOffset: 0, + affinity: TextAffinity.upstream, + ); + late TextSelection selection; + late SelectionChangedCause cause; + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: EditableText( + maxLines: 10, + controller: controller, + showSelectionHandles: true, + autofocus: true, + focusNode: FocusNode(), + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + textAlign: TextAlign.right, + onSelectionChanged: (TextSelection newSelection, SelectionChangedCause? newCause) { + selection = newSelection; + cause = newCause!; + }, + ), + ), + ), + )); + + await tester.pump(); // Wait for autofocus to take effect. + + // Move near the middle of the document. + await sendKeys( + tester, + [ + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowRight, + ], + targetPlatform: defaultTargetPlatform, + ); + + expect(cause, equals(SelectionChangedCause.keyboard), reason: 'on $platform'); + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 32, + ), + ), + reason: 'on $platform', + ); + + await sendKeys( + tester, + [ + LogicalKeyboardKey.home, + ], + shift: true, + targetPlatform: defaultTargetPlatform, + ); + + switch (defaultTargetPlatform) { + // These platforms don't move the selection with shift + home/end at all. + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 32, + ), + ), + reason: 'on $platform', + ); + break; + + // Mac selects to the start of the document. + case TargetPlatform.macOS: + expect( + selection, + equals( + const TextSelection( + baseOffset: 32, + extentOffset: 0, + ), + ), + reason: 'on $platform', + ); + break; + + // These platforms select to the line start. + case TargetPlatform.linux: + case TargetPlatform.windows: + expect( + selection, + equals( + const TextSelection( + baseOffset: 32, + extentOffset: 29, + ), + ), + reason: 'on $platform', + ); + break; + } + + expect(controller.text, equals(testText), reason: 'on $platform'); + + await sendKeys( + tester, + [ + LogicalKeyboardKey.home, + ], + shift: true, + targetPlatform: defaultTargetPlatform, + ); + + switch (defaultTargetPlatform) { + // These platforms don't move the selection with home/end at all still. + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 32, + ), + ), + reason: 'on $platform', + ); + break; + + // Mac selects to the start of the document. + case TargetPlatform.macOS: + expect( + selection, + equals( + const TextSelection( + baseOffset: 32, + extentOffset: 0, + ), + ), + reason: 'on $platform', + ); + break; + + // Linux does nothing at a wordwrap with subsequent presses. + case TargetPlatform.linux: + expect( + selection, + equals( + const TextSelection( + baseOffset: 32, + extentOffset: 29, + ), + ), + reason: 'on $platform', + ); + break; + + // Windows jumps to the previous wordwrapped line. + case TargetPlatform.windows: + expect( + selection, + equals( + const TextSelection( + baseOffset: 32, + extentOffset: 0, + ), + ), + reason: 'on $platform', + ); + break; + } + }, + skip: kIsWeb, // [intended] on web these keys are handled by the browser. + variant: TargetPlatformVariant.all(), + ); + + testWidgets('shift + end keys and wordwraps', (WidgetTester tester) async { + final String targetPlatformString = defaultTargetPlatform.toString(); + final String platform = targetPlatformString.substring(targetPlatformString.indexOf('.') + 1).toLowerCase(); + const String testText = 'Now is the time for all good people to come to the aid of their country. Now is the time for all good people to come to the aid of their country.'; + final TextEditingController controller = TextEditingController(text: testText); + controller.selection = const TextSelection( + baseOffset: 0, + extentOffset: 0, + affinity: TextAffinity.upstream, + ); + late TextSelection selection; + late SelectionChangedCause cause; + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: EditableText( + maxLines: 10, + controller: controller, + showSelectionHandles: true, + autofocus: true, + focusNode: FocusNode(), + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + textAlign: TextAlign.right, + onSelectionChanged: (TextSelection newSelection, SelectionChangedCause? newCause) { + selection = newSelection; + cause = newCause!; + }, + ), + ), + ), + )); + + await tester.pump(); // Wait for autofocus to take effect. + + // Move near the middle of the document. + await sendKeys( + tester, + [ + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowRight, + ], + targetPlatform: defaultTargetPlatform, + ); + + expect(cause, equals(SelectionChangedCause.keyboard), reason: 'on $platform'); + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 32, + ), + ), + reason: 'on $platform', + ); + + await sendKeys( + tester, + [ + LogicalKeyboardKey.end, + ], + shift: true, + targetPlatform: defaultTargetPlatform, + ); + + switch (defaultTargetPlatform) { + // These platforms don't move the selection with home/end at all. + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 32, + ), + ), + reason: 'on $platform', + ); + break; + + // Mac selects to the end of the document. + case TargetPlatform.macOS: + expect( + selection, + equals( + const TextSelection( + baseOffset: 32, + extentOffset: 145, + ), + ), + reason: 'on $platform', + ); + break; + + // These platforms select to the line end. + case TargetPlatform.linux: + case TargetPlatform.windows: + expect( + selection, + equals( + const TextSelection( + baseOffset: 32, + extentOffset: 58, + affinity: TextAffinity.upstream, + ), + ), + reason: 'on $platform', + ); + break; + } + expect(controller.text, equals(testText), reason: 'on $platform'); + + await sendKeys( + tester, + [ + LogicalKeyboardKey.end, + ], + shift: true, + targetPlatform: defaultTargetPlatform, + ); + + switch (defaultTargetPlatform) { + // These platforms don't move the selection with home/end at all still. + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 32, + ), + ), + reason: 'on $platform', + ); + break; + + // Mac stays at the end of the document. + case TargetPlatform.macOS: + expect( + selection, + equals( + const TextSelection( + baseOffset: 32, + extentOffset: 145, + ), + ), + reason: 'on $platform', + ); + break; + + // Linux does nothing at a wordwrap with subsequent presses. + case TargetPlatform.linux: + expect( + selection, + equals( + const TextSelection( + baseOffset: 32, + extentOffset: 58, + affinity: TextAffinity.upstream, + ), + ), + reason: 'on $platform', + ); + break; + + // Windows jumps to the previous wordwrapped line. + case TargetPlatform.windows: + expect( + selection, + equals( + const TextSelection( + baseOffset: 32, + extentOffset: 84, + affinity: TextAffinity.upstream, + ), + ), + reason: 'on $platform', + ); + break; + } + }, + skip: kIsWeb, // [intended] on web these keys are handled by the browser. + variant: TargetPlatformVariant.all(), + ); + + testWidgets('shift + home/end keys to document boundary (Mac only)', (WidgetTester tester) async { + const String testText = 'Now is the time for all good people to come to the aid of their country. Now is the time for all good people to come to the aid of their country.'; + final TextEditingController controller = TextEditingController(text: testText); + controller.selection = const TextSelection( + baseOffset: 0, + extentOffset: 0, + affinity: TextAffinity.upstream, + ); + late TextSelection selection; + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: EditableText( + maxLines: 10, + controller: controller, + showSelectionHandles: true, + autofocus: true, + focusNode: FocusNode(), + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + textAlign: TextAlign.right, + onSelectionChanged: (TextSelection newSelection, SelectionChangedCause? newCause) { + selection = newSelection; + }, + ), + ), + ), + )); + + await tester.pump(); // Wait for autofocus to take effect. + + final Scrollable scrollable = tester.widget(find.byType(Scrollable)); + expect(scrollable.controller!.offset, 0.0); + + // Move near the middle of the document. + await sendKeys( + tester, + [ + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowRight, + ], + targetPlatform: defaultTargetPlatform, + ); + expect( + selection, + equals( + const TextSelection.collapsed( + offset: 32, + ), + ), + ); + + // Expand to the start of the document with the home key. + await sendKeys( + tester, + [ + LogicalKeyboardKey.home, + ], + shift: true, + targetPlatform: defaultTargetPlatform, + ); + expect(scrollable.controller!.offset, 0.0); + expect( + selection, + equals( + const TextSelection( + baseOffset: 32, + extentOffset: 0, + ), + ), + ); + + // Expand to the end of the document with the end key. + await sendKeys( + tester, + [ + LogicalKeyboardKey.end, + ], + shift: true, + targetPlatform: defaultTargetPlatform, + ); + final double maxScrollExtent = scrollable.controller!.position.maxScrollExtent; + expect(scrollable.controller!.offset, maxScrollExtent); + expect( + selection, + equals( + const TextSelection( + baseOffset: 0, + extentOffset: 145, + ), + ), + ); + }, + skip: kIsWeb, // [intended] on web these keys are handled by the browser. + variant: const TargetPlatformVariant({ TargetPlatform.macOS }) + ); + testWidgets('control + home/end keys (Windows only)', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(text: testText); controller.selection = const TextSelection( @@ -9533,7 +10430,7 @@ void main() { variant: const TargetPlatformVariant({ TargetPlatform.macOS }) ); - testWidgets('expanding selection to start/end', (WidgetTester tester) async { + testWidgets('expanding selection to start/end single line', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(text: 'word word word'); // word wo|rd| word controller.selection = const TextSelection( @@ -9583,7 +10480,7 @@ void main() { controller.selection, equals( const TextSelection( - baseOffset: 7, + baseOffset: 9, extentOffset: 0, affinity: TextAffinity.upstream, ), @@ -9595,7 +10492,7 @@ void main() { await sendKeys( tester, [ - LogicalKeyboardKey.home, + LogicalKeyboardKey.end, ], shift: true, targetPlatform: defaultTargetPlatform, @@ -9606,8 +10503,8 @@ void main() { controller.selection, equals( const TextSelection( - baseOffset: 7, - extentOffset: 0, + baseOffset: 0, + extentOffset: 14, affinity: TextAffinity.upstream, ), ),