diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index a32a7026fd2..5ae2ed9da3e 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -4,7 +4,7 @@ import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; -import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb; +import 'package:flutter/foundation.dart' show defaultTargetPlatform; import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -1202,7 +1202,7 @@ class _CupertinoTextFieldState extends State with Restoratio ), ); - final Widget child = Semantics( + return Semantics( enabled: enabled, onTap: !enabled || widget.readOnly ? null : () { if (!controller.selection.isValid) { @@ -1226,13 +1226,5 @@ class _CupertinoTextFieldState extends State with Restoratio ), ), ); - - if (kIsWeb) { - return Shortcuts( - shortcuts: scrollShortcutOverrides, - child: child, - ); - } - return child; } } diff --git a/packages/flutter/lib/src/material/app.dart b/packages/flutter/lib/src/material/app.dart index 1073d6ac86f..3dadd76f872 100644 --- a/packages/flutter/lib/src/material/app.dart +++ b/packages/flutter/lib/src/material/app.dart @@ -884,7 +884,7 @@ class _MaterialAppState extends State { child: HeroControllerScope( controller: _heroController, child: result, - ) + ), ); } } diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index e77a7070a6e..f64349900c1 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -1290,7 +1290,7 @@ class _TextFieldState extends State with RestorationMixin implements semanticsMaxValueLength = null; } - child = MouseRegion( + return MouseRegion( cursor: effectiveMouseCursor, onEnter: (PointerEnterEvent event) => _handleHover(true), onExit: (PointerExitEvent event) => _handleHover(false), @@ -1317,13 +1317,5 @@ class _TextFieldState extends State with RestorationMixin implements ), ), ); - - if (kIsWeb) { - return Shortcuts( - shortcuts: scrollShortcutOverrides, - child: child, - ); - } - return child; } } diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 6709d82c5ba..1cd99663542 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -652,12 +652,8 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { // all the keyboard handling functions assume it is not. assert(selection != null); - final bool isWordModifierPressed = isMacOS ? keyEvent.isAltPressed : keyEvent.isControlPressed; - final bool isLineModifierPressed = isMacOS ? keyEvent.isMetaPressed : keyEvent.isAltPressed; final bool isShortcutModifierPressed = isMacOS ? keyEvent.isMetaPressed : keyEvent.isControlPressed; - if (_movementKeys.contains(key)) { - _handleMovement(key, wordModifier: isWordModifierPressed, lineModifier: isLineModifierPressed, shift: keyEvent.isShiftPressed); - } else if (isShortcutModifierPressed && _shortcutKeys.contains(key)) { + if (isShortcutModifierPressed && _shortcutKeys.contains(key)) { // _handleShortcuts depends on being started in the same stack invocation // as the _handleKeyEvent method _handleShortcuts(key); @@ -733,176 +729,993 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { return 0; } - void _handleMovement( - LogicalKeyboardKey key, { - required bool wordModifier, - required bool lineModifier, - required bool shift, - }){ - if (wordModifier && lineModifier) { - // If both modifiers are down, nothing happens on any of the platforms. + // Return a new selection that has been moved left once. + // + // If it can't be moved left, the original TextSelection is returned. + static TextSelection _moveGivenSelectionLeft(TextSelection selection, String text) { + // If the selection is already all the way left, there is nothing to do. + if (selection.isCollapsed && selection.extentOffset <= 0) { + return selection; + } + + int previousExtent; + if (selection.start != selection.end) { + previousExtent = selection.start; + } else { + previousExtent = previousCharacter(selection.extentOffset, text); + } + final TextSelection newSelection = selection.copyWith( + extentOffset: previousExtent, + ); + + final int newOffset = newSelection.extentOffset; + return TextSelection.fromPosition(TextPosition(offset: newOffset)); + } + + // Return a new selection that has been moved right once. + // + // If it can't be moved right, the original TextSelection is returned. + static TextSelection _moveGivenSelectionRight(TextSelection selection, String text) { + // If the selection is already all the way right, there is nothing to do. + if (selection.isCollapsed && selection.extentOffset >= text.length) { + return selection; + } + + int nextExtent; + if (selection.start != selection.end) { + nextExtent = selection.end; + } else { + nextExtent = nextCharacter(selection.extentOffset, text); + } + final TextSelection nextSelection = selection.copyWith(extentOffset: nextExtent); + + int newOffset = nextSelection.extentOffset; + newOffset = nextSelection.baseOffset > nextSelection.extentOffset + ? nextSelection.baseOffset : nextSelection.extentOffset; + return TextSelection.fromPosition(TextPosition(offset: newOffset)); + } + + // Return the offset at the start of the nearest word to the left of the given + // offset. + static int _getLeftByWord(TextPainter textPainter, int offset, [bool includeWhitespace = true]) { + // If the offset is already all the way left, there is nothing to do. + if (offset <= 0) { + return offset; + } + + // If we can just return the start of the text without checking for a word. + if (offset == 1) { + return 0; + } + + final String text = textPainter.text!.toPlainText(); + final int startPoint = previousCharacter(offset, text, includeWhitespace); + final TextRange word = textPainter.getWordBoundary(TextPosition(offset: startPoint)); + return word.start; + } + + // Return the offset at the end of the nearest word to the right of the given + // offset. + static int _getRightByWord(TextPainter textPainter, int offset, [bool includeWhitespace = true]) { + // If the selection is already all the way right, there is nothing to do. + final String text = textPainter.text!.toPlainText(); + if (offset == text.length) { + return offset; + } + + // If we can just return the end of the text without checking for a word. + if (offset == text.length - 1 || offset == text.length) { + return text.length; + } + + final int startPoint = includeWhitespace || !_isWhitespace(text.codeUnitAt(offset)) + ? offset + : nextCharacter(offset, text, includeWhitespace); + final TextRange nextWord = textPainter.getWordBoundary(TextPosition(offset: startPoint)); + return nextWord.end; + } + + // Return the given TextSelection extended left to the beginning of the + // nearest word. + static TextSelection _extendGivenSelectionLeftByWord(TextPainter textPainter, TextSelection selection, [bool includeWhitespace = true]) { + // If the selection is already all the way left, there is nothing to do. + if (selection.isCollapsed && selection.extentOffset <= 0) { + return selection; + } + + final int leftOffset = _getLeftByWord(textPainter, selection.extentOffset, includeWhitespace); + return selection.copyWith( + extentOffset: leftOffset, + ); + } + + // Return the given TextSelection extended right to the end of the nearest + // word. + static TextSelection _extendGivenSelectionRightByWord(TextPainter textPainter, TextSelection selection, [bool includeWhitespace = true]) { + // If the selection is already all the way right, there is nothing to do. + final String text = textPainter.text!.toPlainText(); + if (selection.isCollapsed && selection.extentOffset == text.length) { + return selection; + } + + final int rightOffset = _getRightByWord(textPainter, selection.extentOffset, includeWhitespace); + return selection.copyWith( + extentOffset: rightOffset, + ); + } + + // Return the given TextSelection moved left to the end of the nearest word. + // + // A TextSelection that isn't collapsed will be collapsed and moved from the + // extentOffset. + static TextSelection _moveGivenSelectionLeftByWord(TextPainter textPainter, TextSelection selection, [bool includeWhitespace = true]) { + // If the selection is already all the way left, there is nothing to do. + if (selection.isCollapsed && selection.extentOffset <= 0) { + return selection; + } + + final int leftOffset = _getLeftByWord(textPainter, selection.extentOffset, includeWhitespace); + return selection.copyWith( + baseOffset: leftOffset, + extentOffset: leftOffset, + ); + } + + // Return the given TextSelection moved right to the end of the nearest word. + // + // A TextSelection that isn't collapsed will be collapsed and moved from the + // extentOffset. + static TextSelection _moveGivenSelectionRightByWord(TextPainter textPainter, TextSelection selection, [bool includeWhitespace = true]) { + // If the selection is already all the way right, there is nothing to do. + final String text = textPainter.text!.toPlainText(); + if (selection.isCollapsed && selection.extentOffset == text.length) { + return selection; + } + + final int rightOffset = _getRightByWord(textPainter, selection.extentOffset, includeWhitespace); + return selection.copyWith( + baseOffset: rightOffset, + extentOffset: rightOffset, + ); + } + + static TextSelection _extendGivenSelectionLeft(TextSelection selection, String text, [bool includeWhitespace = true]) { + // If the selection is already all the way left, there is nothing to do. + if (selection.extentOffset <= 0) { + return selection; + } + final int previousExtent = previousCharacter(selection.extentOffset, text, includeWhitespace); + return selection.copyWith(extentOffset: previousExtent); + } + + static TextSelection _extendGivenSelectionRight(TextSelection selection, String text, [bool includeWhitespace = true]) { + // If the selection is already all the way right, there is nothing to do. + if (selection.extentOffset >= text.length) { + return selection; + } + final int nextExtent = nextCharacter(selection.extentOffset, text, includeWhitespace); + return selection.copyWith(extentOffset: nextExtent); + } + + // Extend the current selection to the end of the field. + // + // If selectionEnabled is false, keeps the selection collapsed and moves it to + // the end. + // + // The given [SelectionChangedCause] indicates the cause of this change and + // will be passed to [onSelectionChanged]. + // + // See also: + // + // * _extendSelectionToStart + void _extendSelectionToEnd(SelectionChangedCause cause) { + if (selection!.extentOffset == _plainText.length) { return; } + if (!selectionEnabled) { + return moveSelectionToEnd(cause); + } + + final TextSelection nextSelection = selection!.copyWith( + extentOffset: _plainText.length, + ); + _setSelection(nextSelection, cause); + } + + // Extend the current selection to the start of the field. + // + // If selectionEnabled is false, keeps the selection collapsed and moves it to + // the start. + // + // The given [SelectionChangedCause] indicates the cause of this change and + // will be passed to [onSelectionChanged]. + // + // See also: + // + // * _expandSelectionToEnd + void _extendSelectionToStart(SelectionChangedCause cause) { + if (selection!.extentOffset == 0) { + return; + } + if (!selectionEnabled) { + return moveSelectionToStart(cause); + } + + final TextSelection nextSelection = selection!.copyWith( + extentOffset: 0, + ); + _setSelection(nextSelection, cause); + } + + // Returns the TextPosition above or below the given offset. + TextPosition _getTextPositionVertical(int textOffset, double verticalOffset) { + final Offset caretOffset = _textPainter.getOffsetForCaret(TextPosition(offset: textOffset), _caretPrototype); + final Offset caretOffsetTranslated = caretOffset.translate(0.0, verticalOffset); + return _textPainter.getPositionForOffset(caretOffsetTranslated); + } + + // Returns the TextPosition above the given offset into _plainText. + // + // If the offset is already on the first line, the given offset will be + // returned. + TextPosition _getTextPositionAbove(int offset) { + // The caret offset gives a location in the upper left hand corner of + // the caret so the middle of the line above is a half line above that + // point and the line below is 1.5 lines below that point. + final double preferredLineHeight = _textPainter.preferredLineHeight; + final double verticalOffset = -0.5 * preferredLineHeight; + return _getTextPositionVertical(offset, verticalOffset); + } + + // Returns the TextPosition below the given offset into _plainText. + // + // If the offset is already on the last line, the given offset will be + // returned. + TextPosition _getTextPositionBelow(int offset) { + // The caret offset gives a location in the upper left hand corner of + // the caret so the middle of the line above is a half line above that + // point and the line below is 1.5 lines below that point. + final double preferredLineHeight = _textPainter.preferredLineHeight; + final double verticalOffset = 1.5 * preferredLineHeight; + return _getTextPositionVertical(offset, verticalOffset); + } + + /// Keeping [selection]'s [TextSelection.baseOffset] fixed, move the + /// [TextSelection.extentOffset] down by one line. + /// + /// If [selectionEnabled] is false, keeps the selection collapsed and just + /// moves it down. + /// + /// {@template flutter.rendering.RenderEditable.cause} + /// The given [SelectionChangedCause] indicates the cause of this change and + /// will be passed to [onSelectionChanged]. + /// {@endtemplate} + /// + /// See also: + /// + /// * [extendSelectionUp], which is same but in the opposite direction. + void extendSelectionDown(SelectionChangedCause cause) { assert(selection != null); - TextSelection newSelection = selection!; - - final bool rightArrow = key == LogicalKeyboardKey.arrowRight; - final bool leftArrow = key == LogicalKeyboardKey.arrowLeft; - final bool upArrow = key == LogicalKeyboardKey.arrowUp; - final bool downArrow = key == LogicalKeyboardKey.arrowDown; - - if ((rightArrow || leftArrow) && !(rightArrow && leftArrow)) { - // Jump to begin/end of word. - if (wordModifier) { - // If control/option is pressed, we will decide which way to look for a - // word based on which arrow is pressed. - if (leftArrow) { - // When going left, we want to skip over any whitespace before the word, - // so we go back to the first non-whitespace before asking for the word - // boundary, since _selectWordAtOffset finds the word boundaries without - // including whitespace. - final int startPoint = previousCharacter(newSelection.extentOffset, _plainText, false); - final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: startPoint)); - newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset); - } else { - // When going right, we want to skip over any whitespace after the word, - // so we go forward to the first non-whitespace character before asking - // for the word bounds, since _selectWordAtOffset finds the word - // boundaries without including whitespace. - final int startPoint = nextCharacter(newSelection.extentOffset, _plainText, false); - final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: startPoint)); - newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset); - } - } else if (lineModifier) { - // If control/command is pressed, we will decide which way to expand to - // the beginning/end of the line based on which arrow is pressed. - if (leftArrow) { - // When going left, we want to skip over any whitespace before the line, - // so we go back to the first non-whitespace before asking for the line - // bounds, since _selectLineAtOffset finds the line boundaries without - // including whitespace (like the newline). - final int startPoint = previousCharacter(newSelection.extentOffset, _plainText, false); - final TextSelection textSelection = _selectLineAtOffset(TextPosition(offset: startPoint)); - newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset); - } else { - // When going right, we want to skip over any whitespace after the line, - // so we go forward to the first non-whitespace character before asking - // for the line bounds, since _selectLineAtOffset finds the line - // boundaries without including whitespace (like the newline). - final int startPoint = nextCharacter(newSelection.extentOffset, _plainText, false); - final TextSelection textSelection = _selectLineAtOffset(TextPosition(offset: startPoint)); - newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset); - } - } else { - // The directional arrows move the TextSelection.extentOffset, while the - // base remains fixed. - if (rightArrow && newSelection.extentOffset < _plainText.length) { - int nextExtent; - if (!shift && !wordModifier && !lineModifier && newSelection.start != newSelection.end) { - nextExtent = newSelection.end; - } else { - nextExtent = nextCharacter(newSelection.extentOffset, _plainText); - } - final int distance = nextExtent - newSelection.extentOffset; - newSelection = newSelection.copyWith(extentOffset: nextExtent); - if (shift) { - _cursorResetLocation += distance; - } - } else if (leftArrow && newSelection.extentOffset > 0) { - int previousExtent; - if (!shift && !wordModifier && !lineModifier && newSelection.start != newSelection.end) { - previousExtent = newSelection.start; - } else { - previousExtent = previousCharacter(newSelection.extentOffset, _plainText); - } - final int distance = newSelection.extentOffset - previousExtent; - newSelection = newSelection.copyWith(extentOffset: previousExtent); - if (shift) { - _cursorResetLocation -= distance; - } - } - } + // If the selection is collapsed at the end of the field already, then + // nothing happens. + if (selection!.isCollapsed && selection!.extentOffset >= _plainText.length) { + return; + } + if (!selectionEnabled) { + return moveSelectionDown(cause); } - // Handles moving the cursor vertically as well as taking care of the - // case where the user moves the cursor to the end or beginning of the text - // and then back up or down. - if (downArrow || upArrow) { - if (lineModifier) { - if (upArrow) { - // Extend the selection to the beginning of the field. - final int upperOffset = math.max(0, math.max( - newSelection.baseOffset, - newSelection.extentOffset, - )); - newSelection = TextSelection( - baseOffset: shift ? upperOffset : 0, - extentOffset: 0, - ); - } else { - // Extend the selection to the end of the field. - final int lowerOffset = math.max(0, math.min( - newSelection.baseOffset, - newSelection.extentOffset, - )); - newSelection = TextSelection( - baseOffset: shift ? lowerOffset : _plainText.length, - extentOffset: _plainText.length, - ); - } - } else { - // The caret offset gives a location in the upper left hand corner of - // the caret so the middle of the line above is a half line above that - // point and the line below is 1.5 lines below that point. - final double preferredLineHeight = _textPainter.preferredLineHeight; - final double verticalOffset = upArrow ? -0.5 * preferredLineHeight : 1.5 * preferredLineHeight; - - final Offset caretOffset = _textPainter.getOffsetForCaret(TextPosition(offset: newSelection.extentOffset), _caretPrototype); - final Offset caretOffsetTranslated = caretOffset.translate(0.0, verticalOffset); - final TextPosition position = _textPainter.getPositionForOffset(caretOffsetTranslated); - - // To account for the possibility where the user vertically highlights - // all the way to the top or bottom of the text, we hold the previous - // cursor location. This allows us to restore to this position in the - // case that the user wants to unhighlight some text. - if (position.offset == newSelection.extentOffset) { - if (downArrow) { - newSelection = newSelection.copyWith(extentOffset: _plainText.length); - } else if (upArrow) { - newSelection = newSelection.copyWith(extentOffset: 0); - } - _wasSelectingVerticallyWithKeyboard = shift; - } else if (_wasSelectingVerticallyWithKeyboard && shift) { - newSelection = newSelection.copyWith(extentOffset: _cursorResetLocation); - _wasSelectingVerticallyWithKeyboard = false; - } else { - newSelection = newSelection.copyWith(extentOffset: position.offset); - _cursorResetLocation = newSelection.extentOffset; - } - } + final TextPosition positionBelow = _getTextPositionBelow(selection!.extentOffset); + late final TextSelection nextSelection; + if (positionBelow.offset == selection!.extentOffset) { + nextSelection = selection!.copyWith( + extentOffset: _plainText.length, + ); + _wasSelectingVerticallyWithKeyboard = true; + } else if (_wasSelectingVerticallyWithKeyboard) { + nextSelection = selection!.copyWith( + extentOffset: _cursorResetLocation, + ); + _wasSelectingVerticallyWithKeyboard = false; + } else { + nextSelection = selection!.copyWith( + extentOffset: positionBelow.offset, + ); + _cursorResetLocation = nextSelection.extentOffset; } - // Just place the collapsed selection at the end or beginning of the region - // if shift isn't down or selection isn't enabled. - if (!shift || !selectionEnabled) { - // We want to put the cursor at the correct location depending on which - // arrow is used while there is a selection. - int newOffset = newSelection.extentOffset; - if (!selection!.isCollapsed) { - if (leftArrow) { - newOffset = newSelection.baseOffset < newSelection.extentOffset ? newSelection.baseOffset : newSelection.extentOffset; - } else if (rightArrow) { - newOffset = newSelection.baseOffset > newSelection.extentOffset ? newSelection.baseOffset : newSelection.extentOffset; - } - } - newSelection = TextSelection.fromPosition(TextPosition(offset: newOffset)); + _setSelection(nextSelection, cause); + } + + /// Expand the current [selection] to the end of the field. + /// + /// The selection will never shrink. The [TextSelection.extentOffset] will + // always be at the end of the field, regardless of the original order of + /// [TextSelection.baseOffset] and [TextSelection.extentOffset]. + /// + /// If [selectionEnabled] is false, keeps the selection collapsed and moves it + /// to the end. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [expandSelectionToStart], which is same but in the opposite direction. + void expandSelectionToEnd(SelectionChangedCause cause) { + assert(selection != null); + + if (selection!.extentOffset == _plainText.length) { + return; + } + if (!selectionEnabled) { + return moveSelectionToEnd(cause); } - _setSelection( - newSelection, - SelectionChangedCause.keyboard, + final int firstOffset = math.max(0, math.min( + selection!.baseOffset, + selection!.extentOffset, + )); + final TextSelection nextSelection = TextSelection( + baseOffset: firstOffset, + extentOffset: _plainText.length, ); + _setSelection(nextSelection, cause); + } + + /// Keeping [selection]'s [TextSelection.baseOffset] fixed, move the + /// [TextSelection.extentOffset] left. + /// + /// If [selectionEnabled] is false, keeps the selection collapsed and moves it + /// left. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [extendSelectionRight], which is same but in the opposite direction. + void extendSelectionLeft(SelectionChangedCause cause) { + assert(selection != null); + + if (!selectionEnabled) { + return moveSelectionLeft(cause); + } + + final TextSelection nextSelection = _extendGivenSelectionLeft( + selection!, + _plainText, + ); + if (nextSelection == selection) { + return; + } + final int distance = selection!.extentOffset - nextSelection.extentOffset; + _cursorResetLocation -= distance; + _setSelection(nextSelection, cause); + } + + /// Extend the current [selection] to the start of + /// [TextSelection.extentOffset]'s line. + /// + /// Uses [TextSelection.baseOffset] as a pivot point and doesn't change it. + /// If [TextSelection.extentOffset] is right of [TextSelection.baseOffset], + /// then collapses the selection. + /// + /// If [selectionEnabled] is false, keeps the selection collapsed and moves it + /// left by line. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [extendSelectionRightByLine], which is same but in the opposite + /// direction. + /// * [expandSelectionRightByLine], which strictly grows the selection + /// regardless of the order. + void extendSelectionLeftByLine(SelectionChangedCause cause) { + assert(selection != null); + + if (!selectionEnabled) { + return moveSelectionLeftByLine(cause); + } + + // When going left, we want to skip over any whitespace before the line, + // so we go back to the first non-whitespace before asking for the line + // bounds, since _getLineAtOffset finds the line boundaries without + // including whitespace (like the newline). + final int startPoint = previousCharacter(selection!.extentOffset, _plainText, false); + final TextSelection selectedLine = _getLineAtOffset(TextPosition(offset: startPoint)); + + late final TextSelection nextSelection; + if (selection!.extentOffset > selection!.baseOffset) { + nextSelection = selection!.copyWith( + extentOffset: selection!.baseOffset, + ); + } else { + nextSelection = selection!.copyWith( + extentOffset: selectedLine.baseOffset, + ); + } + + _setSelection(nextSelection, cause); + } + + /// Keeping [selection]'s [TextSelection.baseOffset] fixed, move the + /// [TextSelection.extentOffset] right. + /// + /// If [selectionEnabled] is false, keeps the selection collapsed and moves it + /// right. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [extendSelectionLeft], which is same but in the opposite direction. + void extendSelectionRight(SelectionChangedCause cause) { + assert(selection != null); + + if (!selectionEnabled) { + return moveSelectionRight(cause); + } + + final TextSelection nextSelection = _extendGivenSelectionRight( + selection!, + _plainText, + ); + if (nextSelection == selection) { + return; + } + final int distance = nextSelection.extentOffset - selection!.extentOffset; + _cursorResetLocation += distance; + _setSelection(nextSelection, cause); + } + + /// Extend the current [selection] to the end of [TextSelection.extentOffset]'s + /// line. + /// + /// Uses [TextSelection.baseOffset] as a pivot point and doesn't change it. If + /// [TextSelection.extentOffset] is left of [TextSelection.baseOffset], then + /// collapses the selection. + /// + /// If [selectionEnabled] is false, keeps the selection collapsed and moves it + /// right by line. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [extendSelectionLeftByLine], which is same but in the opposite + /// direction. + /// * [expandSelectionRightByLine], which strictly grows the selection + /// regardless of the order. + void extendSelectionRightByLine(SelectionChangedCause cause) { + assert(selection != null); + + if (!selectionEnabled) { + return moveSelectionRightByLine(cause); + } + + final int startPoint = nextCharacter(selection!.extentOffset, _plainText, false); + final TextSelection selectedLine = _getLineAtOffset(TextPosition(offset: startPoint)); + + late final TextSelection nextSelection; + if (selection!.extentOffset < selection!.baseOffset) { + nextSelection = selection!.copyWith( + extentOffset: selection!.baseOffset, + ); + } else { + nextSelection = selection!.copyWith( + extentOffset: selectedLine.extentOffset, + ); + } + + _setSelection(nextSelection, cause); + } + + /// Keeping [selection]'s [TextSelection.baseOffset] fixed, move the + /// [TextSelection.extentOffset] up by one + /// line. + /// + /// If [selectionEnabled] is false, keeps the selection collapsed and moves it + /// up. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [extendSelectionDown], which is the same but in the opposite + /// direction. + void extendSelectionUp(SelectionChangedCause cause) { + assert(selection != null); + + // If the selection is collapsed at the beginning of the field already, then + // nothing happens. + if (selection!.isCollapsed && selection!.extentOffset <= 0.0) { + return; + } + if (!selectionEnabled) { + return moveSelectionUp(cause); + } + + final TextPosition positionAbove = _getTextPositionAbove(selection!.extentOffset); + late final TextSelection nextSelection; + if (positionAbove.offset == selection!.extentOffset) { + nextSelection = selection!.copyWith( + extentOffset: 0, + ); + _wasSelectingVerticallyWithKeyboard = true; + } else if (_wasSelectingVerticallyWithKeyboard) { + nextSelection = selection!.copyWith( + baseOffset: selection!.baseOffset, + extentOffset: _cursorResetLocation, + ); + _wasSelectingVerticallyWithKeyboard = false; + } else { + nextSelection = selection!.copyWith( + baseOffset: selection!.baseOffset, + extentOffset: positionAbove.offset, + ); + _cursorResetLocation = nextSelection.extentOffset; + } + + _setSelection(nextSelection, cause); + } + + /// Expand the current [selection] to the start of the field. + /// + /// The selection will never shrink. The [TextSelection.extentOffset] will + /// always be at the start of the field, regardless of the original order of + /// [TextSelection.baseOffset] and [TextSelection.extentOffset]. + /// + /// If [selectionEnabled] is false, keeps the selection collapsed and moves it + /// to the start. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [expandSelectionToEnd], which is the same but in the opposite + /// direction. + void expandSelectionToStart(SelectionChangedCause cause) { + assert(selection != null); + + if (selection!.extentOffset == 0) { + return; + } + if (!selectionEnabled) { + return moveSelectionToStart(cause); + } + + final int lastOffset = math.max(0, math.max( + selection!.baseOffset, + selection!.extentOffset, + )); + final TextSelection nextSelection = TextSelection( + baseOffset: lastOffset, + extentOffset: 0, + ); + _setSelection(nextSelection, cause); + } + + /// Expand the current [selection] to the start of the line. + /// + /// The selection will never shrink. The upper offset will be expanded to the + /// beginning of its line, and the original order of baseOffset and + /// [TextSelection.extentOffset] will be preserved. + /// + /// If [selectionEnabled] is false, keeps the selection collapsed and moves it + /// left by line. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [expandSelectionRightByLine], which is the same but in the opposite + /// direction. + void expandSelectionLeftByLine(SelectionChangedCause cause) { + assert(selection != null); + + if (!selectionEnabled) { + return moveSelectionLeftByLine(cause); + } + + final int firstOffset = math.min(selection!.baseOffset, selection!.extentOffset); + final int startPoint = previousCharacter(firstOffset, _plainText, false); + final TextSelection selectedLine = _getLineAtOffset(TextPosition(offset: startPoint)); + + late final TextSelection nextSelection; + if (selection!.extentOffset <= selection!.baseOffset) { + nextSelection = selection!.copyWith( + extentOffset: selectedLine.baseOffset, + ); + } else { + nextSelection = selection!.copyWith( + baseOffset: selectedLine.baseOffset, + ); + } + + _setSelection(nextSelection, cause); + } + + /// Extend the current [selection] to the previous start of a word. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// By default, includeWhitespace is set to true, meaning that whitespace can + /// be considered a word in itself. If set to false, the selection will be + /// extended past any whitespace and the first word following the whitespace. + /// + /// See also: + /// + /// * [extendSelectionRightByWord], which is the same but in the opposite + /// direction. + void extendSelectionLeftByWord(SelectionChangedCause cause, [bool includeWhitespace = true]) { + assert(selection != null); + + // When the text is obscured, the whole thing is treated as one big word. + if (obscureText) { + return _extendSelectionToStart(cause); + } + + assert(_textLayoutLastMaxWidth == constraints.maxWidth && + _textLayoutLastMinWidth == constraints.minWidth, + 'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).'); + final TextSelection nextSelection = _extendGivenSelectionLeftByWord( + _textPainter, + selection!, + includeWhitespace, + ); + if (nextSelection == selection) { + return; + } + _setSelection(nextSelection, cause); + } + + /// Extend the current [selection] to the next end of a word. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// By default, includeWhitespace is set to true, meaning that whitespace can + /// be considered a word in itself. If set to false, the selection will be + /// extended past any whitespace and the first word following the whitespace. + /// + /// See also: + /// + /// * [extendSelectionLeftByWord], which is the same but in the opposite + /// direction. + void extendSelectionRightByWord(SelectionChangedCause cause, [bool includeWhitespace = true]) { + assert(selection != null); + + // When the text is obscured, the whole thing is treated as one big word. + if (obscureText) { + return _extendSelectionToEnd(cause); + } + + assert(_textLayoutLastMaxWidth == constraints.maxWidth && + _textLayoutLastMinWidth == constraints.minWidth, + 'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).'); + final TextSelection nextSelection = _extendGivenSelectionRightByWord( + _textPainter, + selection!, + includeWhitespace, + ); + if (nextSelection == selection) { + return; + } + _setSelection(nextSelection, cause); + } + + /// Expand the current [selection] to the end of the line. + /// + /// The selection will never shrink. The lower offset will be expanded to the + /// end of its line and the original order of [TextSelection.baseOffset] and + /// [TextSelection.extentOffset] will be preserved. + /// + /// If [selectionEnabled] is false, keeps the selection collapsed and moves it + /// right by line. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [expandSelectionLeftByLine], which is the same but in the opposite + /// direction. + void expandSelectionRightByLine(SelectionChangedCause cause) { + assert(selection != null); + + if (!selectionEnabled) { + return moveSelectionRightByLine(cause); + } + + final int lastOffset = math.max(selection!.baseOffset, selection!.extentOffset); + final int startPoint = nextCharacter(lastOffset, _plainText, false); + final TextSelection selectedLine = _getLineAtOffset(TextPosition(offset: startPoint)); + + late final TextSelection nextSelection; + if (selection!.extentOffset >= selection!.baseOffset) { + nextSelection = selection!.copyWith( + extentOffset: selectedLine.extentOffset, + ); + } else { + nextSelection = selection!.copyWith( + baseOffset: selectedLine.extentOffset, + ); + } + + _setSelection(nextSelection, cause); + } + + /// Move the current [selection] to the next line. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [moveSelectionUp], which is the same but in the opposite direction. + void moveSelectionDown(SelectionChangedCause cause) { + assert(selection != null); + + // If the selection is collapsed at the end of the field already, then + // nothing happens. + if (selection!.isCollapsed && selection!.extentOffset >= _plainText.length) { + return; + } + + final TextPosition positionBelow = _getTextPositionBelow(selection!.extentOffset); + + late final TextSelection nextSelection; + if (positionBelow.offset == selection!.extentOffset) { + nextSelection = selection!.copyWith( + baseOffset: _plainText.length, + extentOffset: _plainText.length, + ); + _wasSelectingVerticallyWithKeyboard = false; + } else { + nextSelection = TextSelection.fromPosition(positionBelow); + _cursorResetLocation = nextSelection.extentOffset; + } + + _setSelection(nextSelection, cause); + } + + /// Move the current [selection] left by one character. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [moveSelectionRight], which is the same but in the opposite direction. + void moveSelectionLeft(SelectionChangedCause cause) { + assert(selection != null); + + final TextSelection nextSelection = _moveGivenSelectionLeft( + selection!, + _plainText, + ); + if (nextSelection == selection) { + return; + } + _cursorResetLocation -= selection!.extentOffset - nextSelection.extentOffset; + _setSelection(nextSelection, cause); + } + + /// Move the current [selection] to the leftmost of the current line. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [moveSelectionRightByLine], which is the same but in the opposite + /// direction. + void moveSelectionLeftByLine(SelectionChangedCause cause) { + assert(selection != null); + + // If the previous character is the edge of a line, don't do anything. + final int previousPoint = previousCharacter(selection!.extentOffset, _plainText, true); + final TextSelection line = _getLineAtOffset(TextPosition(offset: previousPoint)); + if (line.extentOffset == previousPoint) { + return; + } + + // When going left, we want to skip over any whitespace before the line, + // so we go back to the first non-whitespace before asking for the line + // bounds, since _getLineAtOffset finds the line boundaries without + // including whitespace (like the newline). + final int startPoint = previousCharacter(selection!.extentOffset, _plainText, false); + final TextSelection selectedLine = _getLineAtOffset(TextPosition(offset: startPoint)); + final TextSelection nextSelection = TextSelection.collapsed( + offset: selectedLine.baseOffset, + ); + + _setSelection(nextSelection, cause); + } + + /// Move the current [selection] to the previous start of a word. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// By default, includeWhitespace is set to true, meaning that whitespace can + /// be considered a word in itself. If set to false, the selection will be + /// moved past any whitespace and the first word following the whitespace. + /// + /// See also: + /// + /// * [moveSelectionRightByWord], which is the same but in the opposite + /// direction. + void moveSelectionLeftByWord(SelectionChangedCause cause, [bool includeWhitespace = true]) { + assert(selection != null); + + // When the text is obscured, the whole thing is treated as one big word. + if (obscureText) { + return moveSelectionToStart(cause); + } + + assert(_textLayoutLastMaxWidth == constraints.maxWidth && + _textLayoutLastMinWidth == constraints.minWidth, + 'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).'); + final TextSelection nextSelection = _moveGivenSelectionLeftByWord( + _textPainter, + selection!, + includeWhitespace, + ); + if (nextSelection == selection) { + return; + } + _setSelection(nextSelection, cause); + } + + /// Move the current [selection] to the right by one character. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [moveSelectionLeft], which is the same but in the opposite direction. + void moveSelectionRight(SelectionChangedCause cause) { + assert(selection != null); + + final TextSelection nextSelection = _moveGivenSelectionRight( + selection!, + _plainText, + ); + if (nextSelection == selection) { + return; + } + _setSelection(nextSelection, cause); + } + + /// Move the current [selection] to the rightmost point of the current line. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [moveSelectionLeftByLine], which is the same but in the opposite + /// direction. + void moveSelectionRightByLine(SelectionChangedCause cause) { + assert(selection != null); + + // If already at the right edge of the line, do nothing. + final TextSelection currentLine = _getLineAtOffset(TextPosition( + offset: selection!.extentOffset, + )); + if (currentLine.extentOffset == selection!.extentOffset) { + return; + } + + // When going right, we want to skip over any whitespace after the line, + // so we go forward to the first non-whitespace character before asking + // for the line bounds, since _getLineAtOffset finds the line + // boundaries without including whitespace (like the newline). + final int startPoint = nextCharacter(selection!.extentOffset, _plainText, false); + final TextSelection selectedLine = _getLineAtOffset(TextPosition(offset: startPoint)); + final TextSelection nextSelection = TextSelection.collapsed( + offset: selectedLine.extentOffset, + ); + + _setSelection(nextSelection, cause); + } + + /// Move the current [selection] to the next end of a word. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// By default, includeWhitespace is set to true, meaning that whitespace can + /// be considered a word in itself. If set to false, the selection will be + /// moved past any whitespace and the first word following the whitespace. + /// + /// See also: + /// + /// * [moveSelectionLeftByWord], which is the same but in the opposite + /// direction. + void moveSelectionRightByWord(SelectionChangedCause cause, [bool includeWhitespace = true]) { + assert(selection != null); + + // When the text is obscured, the whole thing is treated as one big word. + if (obscureText) { + return moveSelectionToEnd(cause); + } + + assert(_textLayoutLastMaxWidth == constraints.maxWidth && + _textLayoutLastMinWidth == constraints.minWidth, + 'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).'); + final TextSelection nextSelection = _moveGivenSelectionRightByWord( + _textPainter, + selection!, + includeWhitespace, + ); + if (nextSelection == selection) { + return; + } + _setSelection(nextSelection, cause); + } + + /// Move the current [selection] to the end of the field. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [moveSelectionToStart], which is the same but in the opposite + /// direction. + void moveSelectionToEnd(SelectionChangedCause cause) { + assert(selection != null); + + if (selection!.isCollapsed && selection!.extentOffset == _plainText.length) { + return; + } + final TextSelection nextSelection = TextSelection.collapsed( + offset: _plainText.length, + ); + _setSelection(nextSelection, cause); + } + + /// Move the current [selection] to the start of the field. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [moveSelectionToEnd], which is the same but in the opposite direction. + void moveSelectionToStart(SelectionChangedCause cause) { + assert(selection != null); + + if (selection!.isCollapsed && selection!.extentOffset == 0) { + return; + } + const TextSelection nextSelection = TextSelection.collapsed(offset: 0); + _setSelection(nextSelection, cause); + } + + /// Move the current [selection] up by one line. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [moveSelectionDown], which is the same but in the opposite direction. + void moveSelectionUp(SelectionChangedCause cause) { + assert(selection != null); + + // If the selection is collapsed at the beginning of the field already, then + // nothing happens. + if (selection!.isCollapsed && selection!.extentOffset <= 0.0) { + return; + } + + final TextPosition positionAbove = _getTextPositionAbove(selection!.extentOffset); + late final TextSelection nextSelection; + if (positionAbove.offset == selection!.extentOffset) { + nextSelection = selection!.copyWith(baseOffset: 0, extentOffset: 0); + _wasSelectingVerticallyWithKeyboard = false; + } else { + nextSelection = selection!.copyWith( + baseOffset: positionAbove.offset, + extentOffset: positionAbove.offset, + ); + _cursorResetLocation = nextSelection.extentOffset; + } + + _setSelection(nextSelection, cause); } // Handles shortcut functionality including cut, copy, paste and select all @@ -1150,10 +1963,14 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _hasFocus = value; if (_hasFocus) { assert(!_listenerAttached); + // TODO(justinmc): This listener should be ported to Actions and removed. + // https://github.com/flutter/flutter/issues/75004 RawKeyboard.instance.addListener(_handleKeyEvent); _listenerAttached = true; } else { assert(_listenerAttached); + // TODO(justinmc): This listener should be ported to Actions and removed. + // https://github.com/flutter/flutter/issues/75004 RawKeyboard.instance.removeListener(_handleKeyEvent); _listenerAttached = false; } @@ -1752,6 +2569,8 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _longPress.dispose(); _offset.removeListener(markNeedsPaint); _showCursor.removeListener(_showHideCursor); + // TODO(justinmc): This listener should be ported to Actions and removed. + // https://github.com/flutter/flutter/issues/75004 if (_listenerAttached) RawKeyboard.instance.removeListener(_handleKeyEvent); super.detach(); @@ -2129,15 +2948,18 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { assert(from != null); _layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); final TextPosition firstPosition = _textPainter.getPositionForOffset(globalToLocal(from - _paintOffset)); - final TextSelection firstWord = _selectWordAtOffset(firstPosition); + final TextSelection firstWord = _getWordAtOffset(firstPosition); final TextSelection lastWord = to == null ? - firstWord : _selectWordAtOffset(_textPainter.getPositionForOffset(globalToLocal(to - _paintOffset))); - final TextSelection newSelection = TextSelection( - baseOffset: firstWord.base.offset, - extentOffset: lastWord.extent.offset, - affinity: firstWord.affinity, + firstWord : _getWordAtOffset(_textPainter.getPositionForOffset(globalToLocal(to - _paintOffset))); + + _setSelection( + TextSelection( + baseOffset: firstWord.base.offset, + extentOffset: lastWord.extent.offset, + affinity: firstWord.affinity, + ), + cause, ); - _setSelection(newSelection, cause); } /// Move the selection to the beginning or end of a word. @@ -2158,7 +2980,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _setSelection(newSelection, cause); } - TextSelection _selectWordAtOffset(TextPosition position) { + TextSelection _getWordAtOffset(TextPosition position) { assert(_textLayoutLastMaxWidth == constraints.maxWidth && _textLayoutLastMinWidth == constraints.minWidth, 'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).'); @@ -2201,7 +3023,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { return TextSelection(baseOffset: word.start, extentOffset: word.end); } - TextSelection _selectLineAtOffset(TextPosition position) { + TextSelection _getLineAtOffset(TextPosition position) { assert(_textLayoutLastMaxWidth == constraints.maxWidth && _textLayoutLastMinWidth == constraints.minWidth, 'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).'); diff --git a/packages/flutter/lib/src/services/text_editing.dart b/packages/flutter/lib/src/services/text_editing.dart index 32c1c49467c..a562debbecd 100644 --- a/packages/flutter/lib/src/services/text_editing.dart +++ b/packages/flutter/lib/src/services/text_editing.dart @@ -2,7 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. - import 'dart:ui' show hashValues, TextAffinity, TextPosition, TextRange; import 'package:flutter/foundation.dart'; diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index d2a78a15c1b..23e5a0a0294 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -12,6 +12,8 @@ import 'actions.dart'; import 'banner.dart'; import 'basic.dart'; import 'binding.dart'; +import 'default_text_editing_actions.dart'; +import 'default_text_editing_shortcuts.dart'; import 'focus_traversal.dart'; import 'framework.dart'; import 'localizations.dart'; @@ -861,6 +863,9 @@ class WidgetsApp extends StatefulWidget { /// The default map of keyboard shortcuts to intents for the application. /// /// By default, this is set to [WidgetsApp.defaultShortcuts]. + /// + /// Passing this will not replace [DefaultTextEditingShortcuts]. These can be + /// overridden by using a [Shortcuts] widget lower in the widget tree. /// {@endtemplate} /// /// {@tool snippet} @@ -910,6 +915,9 @@ class WidgetsApp extends StatefulWidget { /// the [actions] for this app. You may also add to the bindings, or override /// specific bindings for a widget subtree, by adding your own [Actions] /// widget. + /// + /// Passing this will not replace [DefaultTextEditingActions]. These can be + /// overridden by placing an [Actions] widget lower in the widget tree. /// {@endtemplate} /// /// {@tool snippet} @@ -1621,20 +1629,27 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { : _locale!; assert(_debugCheckLocalizations(appLocale)); + return RootRestorationScope( restorationId: widget.restorationScopeId, child: Shortcuts( - shortcuts: widget.shortcuts ?? WidgetsApp.defaultShortcuts, debugLabel: '', - child: Actions( - actions: widget.actions ?? WidgetsApp.defaultActions, - child: FocusTraversalGroup( - policy: ReadingOrderTraversalPolicy(), - child: _MediaQueryFromWindow( - child: Localizations( - locale: appLocale, - delegates: _localizationsDelegates.toList(), - child: title, + shortcuts: widget.shortcuts ?? WidgetsApp.defaultShortcuts, + // DefaultTextEditingShortcuts is nested inside Shortcuts so that it can + // fall through to the defaultShortcuts. + child: DefaultTextEditingShortcuts( + child: Actions( + actions: widget.actions ?? WidgetsApp.defaultActions, + child: DefaultTextEditingActions( + child: FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy(), + child: _MediaQueryFromWindow( + child: Localizations( + locale: appLocale, + delegates: _localizationsDelegates.toList(), + child: title, + ), + ), ), ), ), diff --git a/packages/flutter/lib/src/widgets/default_text_editing_actions.dart b/packages/flutter/lib/src/widgets/default_text_editing_actions.dart new file mode 100644 index 00000000000..a580ca6f5bc --- /dev/null +++ b/packages/flutter/lib/src/widgets/default_text_editing_actions.dart @@ -0,0 +1,231 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +import 'actions.dart'; +import 'editable_text.dart'; +import 'framework.dart'; +import 'text_editing_action.dart'; +import 'text_editing_intents.dart'; + +/// An [Actions] widget that handles the default text editing behavior for +/// Flutter on the current platform. +/// +/// This default behavior can be overridden by placing an [Actions] widget lower +/// in the widget tree than this. See [DefaultTextEditingShortcuts] for an example of +/// remapping keyboard keys to an existing text editing [Intent]. +/// +/// See also: +/// +/// * [DefaultTextEditingShortcuts], which maps keyboard keys to many of the +/// [Intent]s that are handled here. +/// * [WidgetsApp], which creates a DefaultTextEditingShortcuts. +class DefaultTextEditingActions extends Actions{ + /// Creates an instance of DefaultTextEditingActions. + DefaultTextEditingActions({ + Key? key, + required Widget child, + }) : super( + key: key, + actions: _shortcutsActions, + child: child, + ); + + // These Intents are triggered by DefaultTextEditingShortcuts. They are included + // regardless of the platform; it's up to DefaultTextEditingShortcuts to decide which + // are called on which platform. + static final Map> _shortcutsActions = >{ + DoNothingAndStopPropagationTextIntent: _DoNothingAndStopPropagationTextAction(), + ExtendSelectionDownTextIntent: _ExtendSelectionDownTextAction(), + ExtendSelectionLeftByLineTextIntent: _ExtendSelectionLeftByLineTextAction(), + ExtendSelectionLeftByWordTextIntent: _ExtendSelectionLeftByWordTextAction(), + ExtendSelectionLeftTextIntent: _ExtendSelectionLeftTextAction(), + ExtendSelectionRightByWordTextIntent: _ExtendSelectionRightByWordTextAction(), + ExtendSelectionRightByLineTextIntent: _ExtendSelectionRightByLineTextAction(), + ExtendSelectionRightTextIntent: _ExtendSelectionRightTextAction(), + ExtendSelectionUpTextIntent: _ExtendSelectionUpTextAction(), + ExpandSelectionLeftByLineTextIntent: _ExpandSelectionLeftByLineTextAction(), + ExpandSelectionRightByLineTextIntent: _ExpandSelectionRightByLineTextAction(), + ExpandSelectionToEndTextIntent: _ExpandSelectionToEndTextAction(), + ExpandSelectionToStartTextIntent: _ExpandSelectionToStartTextAction(), + MoveSelectionDownTextIntent: _MoveSelectionDownTextAction(), + MoveSelectionLeftByLineTextIntent: _MoveSelectionLeftByLineTextAction(), + MoveSelectionLeftByWordTextIntent: _MoveSelectionLeftByWordTextAction(), + MoveSelectionLeftTextIntent: _MoveSelectionLeftTextAction(), + MoveSelectionRightByLineTextIntent: _MoveSelectionRightByLineTextAction(), + MoveSelectionRightByWordTextIntent: _MoveSelectionRightByWordTextAction(), + MoveSelectionRightTextIntent: _MoveSelectionRightTextAction(), + MoveSelectionToEndTextIntent: _MoveSelectionToEndTextAction(), + MoveSelectionToStartTextIntent: _MoveSelectionToStartTextAction(), + MoveSelectionUpTextIntent: _MoveSelectionUpTextAction(), + }; +} + +// This allows the web engine to handle text editing events natively while using +// the same TextEditingAction logic to only handle events from a +// TextEditingTarget. +class _DoNothingAndStopPropagationTextAction extends TextEditingAction { + _DoNothingAndStopPropagationTextAction(); + + @override + bool consumesKey(Intent intent) => true; + + @override + void invoke(DoNothingAndStopPropagationTextIntent intent, [BuildContext? context]) {} +} + +class _ExpandSelectionLeftByLineTextAction extends TextEditingAction { + @override + Object? invoke(ExpandSelectionLeftByLineTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.expandSelectionLeftByLine(SelectionChangedCause.keyboard); + } +} + +class _ExpandSelectionRightByLineTextAction extends TextEditingAction { + @override + Object? invoke(ExpandSelectionRightByLineTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.expandSelectionRightByLine(SelectionChangedCause.keyboard); + } +} + +class _ExpandSelectionToEndTextAction extends TextEditingAction { + @override + Object? invoke(ExpandSelectionToEndTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.expandSelectionToEnd(SelectionChangedCause.keyboard); + } +} + +class _ExpandSelectionToStartTextAction extends TextEditingAction { + @override + Object? invoke(ExpandSelectionToStartTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.expandSelectionToStart(SelectionChangedCause.keyboard); + } +} + +class _ExtendSelectionDownTextAction extends TextEditingAction { + @override + Object? invoke(ExtendSelectionDownTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.extendSelectionDown(SelectionChangedCause.keyboard); + } +} + +class _ExtendSelectionLeftByLineTextAction extends TextEditingAction { + @override + Object? invoke(ExtendSelectionLeftByLineTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.extendSelectionLeftByLine(SelectionChangedCause.keyboard); + } +} + +class _ExtendSelectionLeftByWordTextAction extends TextEditingAction { + @override + Object? invoke(ExtendSelectionLeftByWordTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.extendSelectionLeftByWord(SelectionChangedCause.keyboard, false); + } +} + +class _ExtendSelectionLeftTextAction extends TextEditingAction { + @override + Object? invoke(ExtendSelectionLeftTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.extendSelectionLeft(SelectionChangedCause.keyboard); + } +} + +class _ExtendSelectionRightByLineTextAction extends TextEditingAction { + @override + Object? invoke(ExtendSelectionRightByLineTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.extendSelectionRightByLine(SelectionChangedCause.keyboard); + } +} + +class _ExtendSelectionRightByWordTextAction extends TextEditingAction { + @override + Object? invoke(ExtendSelectionRightByWordTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.extendSelectionRightByWord(SelectionChangedCause.keyboard, false); + } +} + +class _ExtendSelectionRightTextAction extends TextEditingAction { + @override + Object? invoke(ExtendSelectionRightTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.extendSelectionRight(SelectionChangedCause.keyboard); + } +} + +class _ExtendSelectionUpTextAction extends TextEditingAction { + @override + Object? invoke(ExtendSelectionUpTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.extendSelectionUp(SelectionChangedCause.keyboard); + } +} + +class _MoveSelectionDownTextAction extends TextEditingAction { + @override + Object? invoke(MoveSelectionDownTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.moveSelectionDown(SelectionChangedCause.keyboard); + } +} + +class _MoveSelectionLeftTextAction extends TextEditingAction { + @override + Object? invoke(MoveSelectionLeftTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.moveSelectionLeft(SelectionChangedCause.keyboard); + } +} + +class _MoveSelectionRightTextAction extends TextEditingAction { + @override + Object? invoke(MoveSelectionRightTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.moveSelectionRight(SelectionChangedCause.keyboard); + } +} + +class _MoveSelectionUpTextAction extends TextEditingAction { + @override + Object? invoke(MoveSelectionUpTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.moveSelectionUp(SelectionChangedCause.keyboard); + } +} + +class _MoveSelectionLeftByLineTextAction extends TextEditingAction { + @override + Object? invoke(MoveSelectionLeftByLineTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.moveSelectionLeftByLine(SelectionChangedCause.keyboard); + } +} + +class _MoveSelectionLeftByWordTextAction extends TextEditingAction { + @override + Object? invoke(MoveSelectionLeftByWordTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.moveSelectionLeftByWord(SelectionChangedCause.keyboard, false); + } +} + +class _MoveSelectionRightByLineTextAction extends TextEditingAction { + @override + Object? invoke(MoveSelectionRightByLineTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.moveSelectionRightByLine(SelectionChangedCause.keyboard); + } +} + +class _MoveSelectionRightByWordTextAction extends TextEditingAction { + @override + Object? invoke(MoveSelectionRightByWordTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.moveSelectionRightByWord(SelectionChangedCause.keyboard, false); + } +} + +class _MoveSelectionToEndTextAction extends TextEditingAction { + @override + Object? invoke(MoveSelectionToEndTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.moveSelectionToEnd(SelectionChangedCause.keyboard); + } +} + +class _MoveSelectionToStartTextAction extends TextEditingAction { + @override + Object? invoke(MoveSelectionToStartTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.moveSelectionToStart(SelectionChangedCause.keyboard); + } +} diff --git a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart new file mode 100644 index 00000000000..95a39f6efd5 --- /dev/null +++ b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart @@ -0,0 +1,443 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; + +import 'actions.dart'; +import 'framework.dart'; +import 'shortcuts.dart'; +import 'text_editing_intents.dart'; + +/// A [Shortcuts] widget with the shortcuts used for the default text editing +/// behavior. +/// +/// This default behavior can be overridden by placing a [Shortcuts] widget +/// lower in the widget tree than this. See [DefaultTextEditingActions] for an example +/// of remapping a text editing [Intent] to a custom [Action]. +/// +/// {@tool snippet} +/// +/// This example shows how to use an additional [Shortcuts] widget to override +/// some default text editing keyboard shortcuts to have new behavior. Instead +/// of moving the cursor, alt + up/down will change the focused widget. +/// +/// ```dart +/// @override +/// Widget build(BuildContext context) { +/// // If using WidgetsApp or its descendents MaterialApp or CupertinoApp, +/// // then DefaultTextEditingShortcuts is already being inserted into the +/// // widget tree. +/// return DefaultTextEditingShortcuts( +/// child: Center( +/// child: Shortcuts( +/// shortcuts: { +/// LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowDown): NextFocusIntent(), +/// LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowUp): PreviousFocusIntent(), +/// }, +/// child: Column( +/// children: [ +/// TextField( +/// decoration: InputDecoration( +/// hintText: 'alt + down moves to the next field.', +/// ), +/// ), +/// TextField( +/// decoration: InputDecoration( +/// hintText: 'And alt + up moves to the previous.', +/// ), +/// ), +/// ], +/// ), +/// ), +/// ), +/// ); +/// } +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// This example shows how to use an additional [Shortcuts] widget to override +/// default text editing shortcuts to have completely custom behavior defined by +/// a custom Intent and Action. Here, the up/down arrow keys increment/decrement +/// a counter instead of moving the cursor. +/// +/// ```dart +/// class IncrementCounterIntent extends Intent {} +/// class DecrementCounterIntent extends Intent {} +/// +/// class MyWidget extends StatefulWidget { +/// MyWidget({ Key? key }) : super(key: key); +/// +/// @override +/// MyWidgetState createState() => MyWidgetState(); +/// } +/// +/// class MyWidgetState extends State { +/// +/// int _counter = 0; +/// +/// @override +/// Widget build(BuildContext context) { +/// // If using WidgetsApp or its descendents MaterialApp or CupertinoApp, +/// // then DefaultTextEditingShortcuts is already being inserted into the +/// // widget tree. +/// return DefaultTextEditingShortcuts( +/// child: Center( +/// child: Column( +/// mainAxisAlignment: MainAxisAlignment.center, +/// children: [ +/// Text( +/// 'You have pushed the button this many times:', +/// ), +/// Text( +/// '$_counter', +/// style: Theme.of(context).textTheme.headline4, +/// ), +/// Shortcuts( +/// shortcuts: { +/// LogicalKeySet(LogicalKeyboardKey.arrowUp): IncrementCounterIntent(), +/// LogicalKeySet(LogicalKeyboardKey.arrowDown): DecrementCounterIntent(), +/// }, +/// child: Actions( +/// actions: >{ +/// IncrementCounterIntent: CallbackAction( +/// onInvoke: (IncrementCounterIntent intent) { +/// setState(() { +/// _counter++; +/// }); +/// }, +/// ), +/// DecrementCounterIntent: CallbackAction( +/// onInvoke: (DecrementCounterIntent intent) { +/// setState(() { +/// _counter--; +/// }); +/// }, +/// ), +/// }, +/// child: TextField( +/// maxLines: 2, +/// decoration: InputDecoration( +/// hintText: 'Up/down increment/decrement here.', +/// ), +/// ), +/// ), +/// ), +/// TextField( +/// maxLines: 2, +/// decoration: InputDecoration( +/// hintText: 'Up/down behave normally here.', +/// ), +/// ), +/// ], +/// ), +/// ), +/// ); +/// } +/// } +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [DefaultTextEditingActions], which contains all of the [Action]s that +/// respond to the [Intent]s in these shortcuts with the default text editing +/// behavior. +/// * [WidgetsApp], which creates a DefaultTextEditingShortcuts. +class DefaultTextEditingShortcuts extends Shortcuts { + /// Creates a [Shortcuts] widget that provides the default text editing + /// shortcuts on the current platform. + DefaultTextEditingShortcuts({ + Key? key, + required Widget child, + }) : super( + key: key, + debugLabel: '', + shortcuts: _shortcuts, + child: child, + ); + + static final Map _androidShortcuts = { + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowDown): const MoveSelectionToEndTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowUp): const MoveSelectionToStartTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowDown): const ExpandSelectionToEndTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExpandSelectionLeftByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExpandSelectionRightByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowUp): const ExpandSelectionToStartTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowDown): const MoveSelectionDownTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowRight): const MoveSelectionRightTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowUp): const MoveSelectionUpTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExtendSelectionLeftByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExtendSelectionRightByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowDown): const ExtendSelectionDownTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExtendSelectionLeftTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExtendSelectionRightTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowUp): const ExtendSelectionUpTextIntent(), + // The following key combinations have no effect on text edition on this + // platform: + // * End + // * Home + // * Meta + arrow down + // * Meta + arrow left + // * Meta + arrow right + // * Meta + arrow up + // * Meta + shift + arrow down + // * Meta + shift + arrow left + // * Meta + shift + arrow right + // * Meta + shift + arrow up + // * Shift + end + // * Shift + home + }; + + static final Map _fuchsiaShortcuts = { + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowDown): const MoveSelectionToEndTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowUp): const MoveSelectionToStartTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowDown): const ExpandSelectionToEndTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExpandSelectionLeftByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExpandSelectionRightByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowUp): const ExpandSelectionToStartTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowDown): const MoveSelectionDownTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowRight): const MoveSelectionRightTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowUp): const MoveSelectionUpTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExtendSelectionLeftByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExtendSelectionRightByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowDown): const ExtendSelectionDownTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExtendSelectionLeftTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExtendSelectionRightTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowUp): const ExtendSelectionUpTextIntent(), + // The following key combinations have no effect on text edition on this + // platform: + // * Meta + arrow down + // * End + // * Home + // * Meta + arrow left + // * Meta + arrow right + // * Meta + arrow up + // * Meta + shift + arrow down + // * Meta + shift + arrow left + // * Meta + shift + arrow right + // * Meta + shift + arrow up + // * Shift + end + // * Shift + home + }; + + static final Map _iOSShortcuts = { + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowDown): const MoveSelectionToEndTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowUp): const MoveSelectionToStartTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowDown): const ExpandSelectionToEndTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExpandSelectionLeftByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExpandSelectionRightByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowUp): const ExpandSelectionToStartTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowDown): const MoveSelectionDownTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowRight): const MoveSelectionRightTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowUp): const MoveSelectionUpTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExtendSelectionLeftByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExtendSelectionRightByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowDown): const ExtendSelectionDownTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExtendSelectionLeftTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExtendSelectionRightTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowUp): const ExtendSelectionUpTextIntent(), + // The following key combinations have no effect on text edition on this + // platform: + // * Meta + arrow down + // * End + // * Home + // * Meta + arrow left + // * Meta + arrow right + // * Meta + arrow up + // * Meta + shift + arrow down + // * Meta + shift + arrow left + // * Meta + shift + arrow right + // * Meta + shift + arrow up + // * Shift + end + // * Shift + home + }; + + static final Map _linuxShortcuts = { + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowDown): const MoveSelectionToEndTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowUp): const MoveSelectionToStartTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowDown): const ExpandSelectionToEndTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExpandSelectionLeftByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExpandSelectionRightByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowUp): const ExpandSelectionToStartTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowDown): const MoveSelectionDownTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowRight): const MoveSelectionRightTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowUp): const MoveSelectionUpTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExtendSelectionLeftByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExtendSelectionRightByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowDown): const ExtendSelectionDownTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExtendSelectionLeftTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExtendSelectionRightTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowUp): const ExtendSelectionUpTextIntent(), + // The following key combinations have no effect on text edition on this + // platform: + // * Meta + arrow down + // * End + // * Home + // * Meta + arrow left + // * Meta + arrow right + // * Meta + arrow up + // * Meta + shift + arrow down + // * Meta + shift + arrow left + // * Meta + shift + arrow right + // * Meta + shift + arrow up + // * Shift + end + // * Shift + home + }; + + static final Map _macShortcuts = { + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowDown): const MoveSelectionRightByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowUp): const MoveSelectionLeftByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowDown): const ExtendSelectionRightByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExtendSelectionLeftByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExtendSelectionRightByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowUp): const ExtendSelectionLeftByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowDown): const MoveSelectionDownTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowRight): const MoveSelectionRightTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowUp): const MoveSelectionUpTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.arrowDown): const MoveSelectionToEndTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.arrowUp): const MoveSelectionToStartTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowDown): const ExpandSelectionToEndTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExpandSelectionLeftByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExpandSelectionRightByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowUp): const ExpandSelectionToStartTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowDown): const ExtendSelectionDownTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExtendSelectionLeftTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExtendSelectionRightTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowUp): const ExtendSelectionUpTextIntent(), + // The following key combinations have no effect on text edition on this + // platform: + // * Control + arrow left + // * Control + arrow right + // * Control + shift + arrow left + // * Control + shift + arrow right + // * End + // * Home + // * Shift + end + // * Shift + home + }; + + static final Map _windowsShortcuts = { + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowDown): const MoveSelectionToEndTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowUp): const MoveSelectionToStartTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowDown): const ExpandSelectionToEndTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExpandSelectionLeftByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExpandSelectionRightByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowUp): const ExpandSelectionToStartTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowDown): const MoveSelectionDownTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowRight): const MoveSelectionRightTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowUp): const MoveSelectionUpTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExtendSelectionLeftByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExtendSelectionRightByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.end): const MoveSelectionRightByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.home): const MoveSelectionLeftByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowDown): const ExtendSelectionDownTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const ExtendSelectionLeftTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const ExtendSelectionRightTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowUp): const ExtendSelectionUpTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.end): const ExpandSelectionRightByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.home): const ExpandSelectionLeftByLineTextIntent(), + // The following key combinations have no effect on text edition on this + // platform: + // * Meta + arrow down + // * Meta + arrow left + // * Meta + arrow right + // * Meta + arrow up + // * Meta + shift + arrow down + // * Meta + shift + arrow left + // * Meta + shift + arrow right + // * Meta + shift + arrow up + }; + + // Web handles its text selection natively and doesn't use any of these + // shortcuts in Flutter. + static final Map _webShortcuts = { + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowDown): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowLeft): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowRight): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowUp): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowDown): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowUp): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowDown): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowRight): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowUp): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.arrowLeft): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.arrowRight): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.end): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.home): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.arrowDown): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.arrowLeft): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.arrowRight): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.arrowUp): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowDown): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowUp): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowDown): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowLeft): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowRight): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.arrowUp): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.end): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.home): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.space): const DoNothingAndStopPropagationTextIntent(), + }; + + static Map get _shortcuts { + if (kIsWeb) { + return _webShortcuts; + } + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return _androidShortcuts; + case TargetPlatform.fuchsia: + return _fuchsiaShortcuts; + case TargetPlatform.iOS: + return _iOSShortcuts; + case TargetPlatform.linux: + return _linuxShortcuts; + case TargetPlatform.macOS: + return _macShortcuts; + case TargetPlatform.windows: + return _windowsShortcuts; + } + } +} diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index e97564f9c7a..43e8e93f827 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -12,7 +12,6 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; -import 'actions.dart'; import 'autofill.dart'; import 'automatic_keep_alive.dart'; import 'basic.dart'; @@ -27,8 +26,8 @@ import 'media_query.dart'; import 'scroll_controller.dart'; import 'scroll_physics.dart'; import 'scrollable.dart'; -import 'shortcuts.dart'; import 'text.dart'; +import 'text_editing_action.dart'; import 'text_selection.dart'; import 'ticker_provider.dart'; @@ -54,17 +53,6 @@ const Duration _kCursorBlinkWaitForStart = Duration(milliseconds: 150); // is shown in an obscured text field. const int _kObscureShowLatestCharCursorTicks = 3; -/// A map used to disable scrolling shortcuts in text fields. -/// -/// This is a temporary fix for: https://github.com/flutter/flutter/issues/74191 -final Map scrollShortcutOverrides = { - LogicalKeySet(LogicalKeyboardKey.space): DoNothingAndStopPropagationIntent(), - LogicalKeySet(LogicalKeyboardKey.arrowUp): DoNothingAndStopPropagationIntent(), - LogicalKeySet(LogicalKeyboardKey.arrowDown): DoNothingAndStopPropagationIntent(), - LogicalKeySet(LogicalKeyboardKey.arrowLeft): DoNothingAndStopPropagationIntent(), - LogicalKeySet(LogicalKeyboardKey.arrowRight): DoNothingAndStopPropagationIntent(), -}; - /// A controller for an editable text field. /// /// Whenever the user modifies a text field with an associated @@ -1491,7 +1479,7 @@ class EditableText extends StatefulWidget { } /// State for a [EditableText]. -class EditableTextState extends State with AutomaticKeepAliveClientMixin, WidgetsBindingObserver, TickerProviderStateMixin, TextSelectionDelegate implements TextInputClient, AutofillClient { +class EditableTextState extends State with AutomaticKeepAliveClientMixin, WidgetsBindingObserver, TickerProviderStateMixin, TextSelectionDelegate implements TextInputClient, AutofillClient, TextEditingActionTarget { Timer? _cursorTimer; bool _targetCursorVisibility = false; final ValueNotifier _cursorVisibilityNotifier = ValueNotifier(true); @@ -2472,6 +2460,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien /// /// This property is typically used to notify the renderer of input gestures /// when [RenderEditable.ignorePointer] is true. + @override RenderEditable get renderEditable => _editableKey.currentContext!.findRenderObject()! as RenderEditable; @override diff --git a/packages/flutter/lib/src/widgets/text_editing_action.dart b/packages/flutter/lib/src/widgets/text_editing_action.dart new file mode 100644 index 00000000000..971559ff077 --- /dev/null +++ b/packages/flutter/lib/src/widgets/text_editing_action.dart @@ -0,0 +1,68 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart' show RenderEditable; + +import 'actions.dart'; +import 'editable_text.dart'; +import 'focus_manager.dart'; +import 'framework.dart'; + +/// The recipient of a [TextEditingAction]. +/// +/// TextEditingActions will only be enabled when an implementer of this class is +/// focused. +/// +/// See also: +/// +/// * [EditableTextState], which implements this and is the most typical +/// target of a TextEditingAction. +abstract class TextEditingActionTarget { + /// The renderer that handles [TextEditingAction]s. + /// + /// See also: + /// + /// * [EditableTextState.renderEditable], which overrides this. + RenderEditable get renderEditable; +} + +/// An [Action] related to editing text. +/// +/// Enables itself only when a [TextEditingActionTarget], e.g. [EditableText], +/// is currently focused. The result of this is that when a +/// TextEditingActionTarget is not focused, it will fall through to any +/// non-TextEditingAction that handles the same shortcut. For example, +/// overriding the tab key in [Shortcuts] with a TextEditingAction will only +/// invoke your TextEditingAction when a TextEditingActionTarget is focused, +/// otherwise the default tab behavior will apply. +/// +/// The currently focused TextEditingActionTarget is available in the [invoke] +/// method via [textEditingActionTarget]. +/// +/// See also: +/// +/// * [CallbackAction], which is a similar Action type but unrelated to text +/// editing. +abstract class TextEditingAction extends ContextAction { + /// Returns the currently focused [TextEditingAction], or null if none is + /// focused. + @protected + TextEditingActionTarget? get textEditingActionTarget { + // If a TextEditingActionTarget is not focused, then ignore this action. + if (primaryFocus?.context == null + || primaryFocus!.context! is! StatefulElement + || ((primaryFocus!.context! as StatefulElement).state is! TextEditingActionTarget)) { + return null; + } + return (primaryFocus!.context! as StatefulElement).state as TextEditingActionTarget; + } + + @override + bool isEnabled(T intent) { + // The Action is disabled if there is no focused TextEditingActionTarget, or + // if the platform is web, because web lets the browser handle text editing. + return !kIsWeb && textEditingActionTarget != null; + } +} diff --git a/packages/flutter/lib/src/widgets/text_editing_intents.dart b/packages/flutter/lib/src/widgets/text_editing_intents.dart new file mode 100644 index 00000000000..2a6d01294df --- /dev/null +++ b/packages/flutter/lib/src/widgets/text_editing_intents.dart @@ -0,0 +1,200 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'actions.dart'; + +/// An [Intent] to send the event straight to the engine, but only if a +/// TextEditingTarget is focused. +/// +/// {@template flutter.widgets.TextEditingIntents.seeAlso} +/// See also: +/// +/// * [DefaultTextEditingActions], which responds to this [Intent]. +/// * [DefaultTextEditingShortcuts], which triggers this [Intent]. +/// {@endtemplate} +class DoNothingAndStopPropagationTextIntent extends Intent{ + /// Creates an instance of DoNothingAndStopPropagationTextIntent. + const DoNothingAndStopPropagationTextIntent(); +} + +/// An [Intent] to expand the selection left to the start/end of the current +/// line. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class ExpandSelectionLeftByLineTextIntent extends Intent { + /// Creates an instance of ExpandSelectionLeftByLineTextIntent. + const ExpandSelectionLeftByLineTextIntent(); +} + +/// An [Intent] to expand the selection right to the start/end of the current +/// field. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class ExpandSelectionRightByLineTextIntent extends Intent{ + /// Creates an instance of ExpandSelectionRightByLineTextIntent. + const ExpandSelectionRightByLineTextIntent(); +} + +/// An [Intent] to expand the selection to the end of the field. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class ExpandSelectionToEndTextIntent extends Intent{ + /// Creates an instance of ExpandSelectionToEndTextIntent. + const ExpandSelectionToEndTextIntent(); +} + +/// An [Intent] to expand the selection to the start of the field. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class ExpandSelectionToStartTextIntent extends Intent{ + /// Creates an instance of ExpandSelectionToStartTextIntent. + const ExpandSelectionToStartTextIntent(); +} + +/// An [Intent] to extend the selection down by one line. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class ExtendSelectionDownTextIntent extends Intent{ + /// Creates an instance of ExtendSelectionDownTextIntent. + const ExtendSelectionDownTextIntent(); +} + +/// An [Intent] to extend the selection left to the start/end of the current +/// line. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class ExtendSelectionLeftByLineTextIntent extends Intent{ + /// Creates an instance of ExtendSelectionLeftByLineTextIntent. + const ExtendSelectionLeftByLineTextIntent(); +} + +/// An [Intent] to extend the selection left past the nearest word. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class ExtendSelectionLeftByWordTextIntent extends Intent{ + /// Creates an instance of ExtendSelectionLeftByWordTextIntent. + const ExtendSelectionLeftByWordTextIntent(); +} + +/// An [Intent] to extend the selection left by one character. +/// platform for the shift + arrow-left key event. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class ExtendSelectionLeftTextIntent extends Intent{ + /// Creates an instance of ExtendSelectionLeftTextIntent. + const ExtendSelectionLeftTextIntent(); +} + +/// An [Intent] to extend the selection right to the start/end of the current +/// line. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class ExtendSelectionRightByLineTextIntent extends Intent{ + /// Creates an instance of ExtendSelectionRightByLineTextIntent. + const ExtendSelectionRightByLineTextIntent(); +} + +/// An [Intent] to extend the selection right past the nearest word. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class ExtendSelectionRightByWordTextIntent extends Intent{ + /// Creates an instance of ExtendSelectionRightByWordTextIntent. + const ExtendSelectionRightByWordTextIntent(); +} + +/// An [Intent] to extend the selection right by one character. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class ExtendSelectionRightTextIntent extends Intent{ + /// Creates an instance of ExtendSelectionRightTextIntent. + const ExtendSelectionRightTextIntent(); +} + +/// An [Intent] to extend the selection up by one line. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class ExtendSelectionUpTextIntent extends Intent{ + /// Creates an instance of ExtendSelectionUpTextIntent. + const ExtendSelectionUpTextIntent(); +} + +/// An [Intent] to move the selection down by one line. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class MoveSelectionDownTextIntent extends Intent{ + /// Creates an instance of MoveSelectionDownTextIntent. + const MoveSelectionDownTextIntent(); +} + +/// An [Intent] to move the selection left by one line. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class MoveSelectionLeftByLineTextIntent extends Intent{ + /// Creates an instance of MoveSelectionLeftByLineTextIntent. + const MoveSelectionLeftByLineTextIntent(); +} + +/// An [Intent] to move the selection left past the nearest word. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class MoveSelectionLeftByWordTextIntent extends Intent{ + /// Creates an instance of MoveSelectionLeftByWordTextIntent. + const MoveSelectionLeftByWordTextIntent(); +} + +/// An [Intent] to move the selection left by one character. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class MoveSelectionLeftTextIntent extends Intent{ + /// Creates an instance of MoveSelectionLeftTextIntent. + const MoveSelectionLeftTextIntent(); +} + +/// An [Intent] to move the selection to the start of the field. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class MoveSelectionToStartTextIntent extends Intent{ + /// Creates an instance of MoveSelectionToStartTextIntent. + const MoveSelectionToStartTextIntent(); +} + +/// An [Intent] to move the selection right by one line. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class MoveSelectionRightByLineTextIntent extends Intent{ + /// Creates an instance of MoveSelectionRightByLineTextIntent. + const MoveSelectionRightByLineTextIntent(); +} + +/// An [Intent] to move the selection right past the nearest word. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class MoveSelectionRightByWordTextIntent extends Intent{ + /// Creates an instance of MoveSelectionRightByWordTextIntent. + const MoveSelectionRightByWordTextIntent(); +} + +/// An [Intent] to move the selection right by one character. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class MoveSelectionRightTextIntent extends Intent{ + /// Creates an instance of MoveSelectionRightTextIntent. + const MoveSelectionRightTextIntent(); +} + +/// An [Intent] to move the selection to the end of the field. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class MoveSelectionToEndTextIntent extends Intent{ + /// Creates an instance of MoveSelectionToEndTextIntent. + const MoveSelectionToEndTextIntent(); +} + +/// An [Intent] to move the selection up by one character. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class MoveSelectionUpTextIntent extends Intent{ + /// Creates an instance of MoveSelectionUpTextIntent. + const MoveSelectionUpTextIntent(); +} diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index d4af98033e3..15783a7ae62 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -194,6 +194,9 @@ abstract class TextSelectionControls { return delegate.selectAllEnabled && delegate.textEditingValue.text.isNotEmpty && delegate.textEditingValue.selection.isCollapsed; } + // TODO(justinmc): This and other methods should be ported to Actions and + // removed, along with their keyboard shortcut equivalents. + // https://github.com/flutter/flutter/issues/75004 /// Copy the current selection of the text field managed by the given /// `delegate` to the [Clipboard]. Then, remove the selected text from the /// text field and hide the toolbar. diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index dcde0dee977..48716c77543 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -33,6 +33,8 @@ export 'src/widgets/bottom_navigation_bar_item.dart'; export 'src/widgets/color_filter.dart'; export 'src/widgets/container.dart'; export 'src/widgets/debug.dart'; +export 'src/widgets/default_text_editing_actions.dart'; +export 'src/widgets/default_text_editing_shortcuts.dart'; export 'src/widgets/desktop_text_selection_toolbar_layout_delegate.dart'; export 'src/widgets/dismissible.dart'; export 'src/widgets/disposable_build_context.dart'; @@ -116,6 +118,8 @@ export 'src/widgets/spacer.dart'; export 'src/widgets/status_transitions.dart'; export 'src/widgets/table.dart'; export 'src/widgets/text.dart'; +export 'src/widgets/text_editing_action.dart'; +export 'src/widgets/text_editing_intents.dart'; export 'src/widgets/text_selection.dart'; export 'src/widgets/text_selection_toolbar_layout_delegate.dart'; export 'src/widgets/texture.dart'; diff --git a/packages/flutter/test/material/debug_test.dart b/packages/flutter/test/material/debug_test.dart index b456b6b059a..0c132c7afc6 100644 --- a/packages/flutter/test/material/debug_test.dart +++ b/packages/flutter/test/material/debug_test.dart @@ -186,11 +186,18 @@ void main() { ' _FocusTraversalGroupMarker\n' ' FocusTraversalGroup\n' ' _ActionsMarker\n' + ' DefaultTextEditingActions\n' + ' _ActionsMarker\n' ' Actions\n' ' _ShortcutsMarker\n' ' Semantics\n' ' _FocusMarker\n' ' Focus\n' + ' DefaultTextEditingShortcuts\n' + ' _ShortcutsMarker\n' + ' Semantics\n' + ' _FocusMarker\n' + ' Focus\n' ' Shortcuts\n' ' UnmanagedRestorationScope\n' ' RestorationScope\n' diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 9f5c73bc2ab..598c2fe6a9d 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -78,14 +78,18 @@ Widget overlayWithEntry(OverlayEntry entry) { WidgetsLocalizationsDelegate(), MaterialLocalizationsDelegate(), ], - child: Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: const MediaQueryData(size: Size(800.0, 600.0)), - child: Overlay( - initialEntries: [ - entry, - ], + child: DefaultTextEditingShortcuts( + child: DefaultTextEditingActions( + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Overlay( + initialEntries: [ + entry, + ], + ), + ), ), ), ), diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index 65d2f33a51b..bd2875ddd12 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -412,58 +412,6 @@ void main() { expect(editable, paintsExactlyCountTimes(#drawRect, 1)); }); - test('ignore key event from web platform', () async { - final TextSelectionDelegate delegate = FakeEditableTextState(); - final ViewportOffset viewportOffset = ViewportOffset.zero(); - late TextSelection currentSelection; - final RenderEditable editable = RenderEditable( - backgroundCursorColor: Colors.grey, - selectionColor: Colors.black, - textDirection: TextDirection.ltr, - cursorColor: Colors.red, - offset: viewportOffset, - // This makes the scroll axis vertical. - maxLines: 2, - textSelectionDelegate: delegate, - onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) { - currentSelection = selection; - }, - startHandleLayerLink: LayerLink(), - endHandleLayerLink: LayerLink(), - text: const TextSpan( - text: 'test\ntest', - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - selection: const TextSelection.collapsed( - offset: 4, - ), - ); - - layout(editable); - editable.hasFocus = true; - - expect( - editable, - paints..paragraph(offset: Offset.zero), - ); - - editable.selectPositionAt(from: Offset.zero, cause: SelectionChangedCause.tap); - editable.selection = const TextSelection.collapsed(offset: 0); - pumpFrame(); - - if(kIsWeb) { - await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight, platform: 'web'); - expect(currentSelection.isCollapsed, true); - expect(currentSelection.baseOffset, 0); - } else { - await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); - expect(currentSelection.isCollapsed, true); - expect(currentSelection.baseOffset, 1); - } - }); - test('selects correct place with offsets', () { final TextSelectionDelegate delegate = FakeEditableTextState(); final ViewportOffset viewportOffset = ViewportOffset.zero(); @@ -748,14 +696,16 @@ void main() { expect(editable.maxScrollExtent, equals(10)); }, skip: isBrowser); // https://github.com/flutter/flutter/issues/42772 - test('arrow keys and delete handle simple text correctly', () async { + test('moveSelectionLeft/RightByLine stays on the current line', () async { + const String text = 'one two three\n\nfour five six'; final TextSelectionDelegate delegate = FakeEditableTextState() ..textEditingValue = const TextEditingValue( - text: 'test', + text: text, selection: TextSelection.collapsed(offset: 0), ); final ViewportOffset viewportOffset = ViewportOffset.zero(); late TextSelection currentSelection; + final RenderEditable editable = RenderEditable( backgroundCursorColor: Colors.grey, selectionColor: Colors.black, @@ -764,6 +714,88 @@ void main() { offset: viewportOffset, textSelectionDelegate: delegate, onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) { + renderObject.selection = selection; + currentSelection = selection; + }, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed( + offset: 0, + ), + ); + + layout(editable); + editable.hasFocus = true; + + editable.selectPositionAt(from: Offset.zero, cause: SelectionChangedCause.tap); + editable.selection = const TextSelection.collapsed(offset: 0); + pumpFrame(); + + // Move to the end of the first line. + editable.moveSelectionRightByLine(SelectionChangedCause.keyboard); + expect(currentSelection.isCollapsed, true); + expect(currentSelection.baseOffset, 13); + // RenderEditable relies on its parent that passes onSelectionChanged to set + // the selection. + + // Try moveSelectionRightByLine again and nothing happens because we're + // already at the end of a line. + editable.moveSelectionRightByLine(SelectionChangedCause.keyboard); + expect(currentSelection.isCollapsed, true); + expect(currentSelection.baseOffset, 13); + + // Move back to the start of the line. + editable.moveSelectionLeftByLine(SelectionChangedCause.keyboard); + expect(currentSelection.isCollapsed, true); + expect(currentSelection.baseOffset, 0); + + // Trying moveSelectionLeftByLine does nothing at the leftmost of the field. + editable.moveSelectionLeftByLine(SelectionChangedCause.keyboard); + expect(currentSelection.isCollapsed, true); + expect(currentSelection.baseOffset, 0); + + // Move the selection to the empty line. + editable.moveSelectionRightByLine(SelectionChangedCause.keyboard); + expect(currentSelection.isCollapsed, true); + expect(currentSelection.baseOffset, 13); + editable.moveSelectionRight(SelectionChangedCause.keyboard); + expect(currentSelection.isCollapsed, true); + expect(currentSelection.baseOffset, 14); + + // Neither moveSelectionLeftByLine nor moveSelectionRightByLine do anything + // here, because we're at both the beginning and end of the line. + editable.moveSelectionLeftByLine(SelectionChangedCause.keyboard); + expect(currentSelection.isCollapsed, true); + expect(currentSelection.baseOffset, 14); + editable.moveSelectionRightByLine(SelectionChangedCause.keyboard); + expect(currentSelection.isCollapsed, true); + expect(currentSelection.baseOffset, 14); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 + + test('arrow keys and delete handle simple text correctly', () async { + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: 'test', + selection: TextSelection.collapsed(offset: 0), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + late TextSelection currentSelection; + + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) { + renderObject.selection = selection; currentSelection = selection; }, startHandleLayerLink: LayerLink(), @@ -786,13 +818,11 @@ void main() { editable.selection = const TextSelection.collapsed(offset: 0); pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); + editable.moveSelectionRight(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 1); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowLeft, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowLeft, platform: 'android'); + editable.moveSelectionLeft(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 0); @@ -817,6 +847,7 @@ void main() { offset: viewportOffset, textSelectionDelegate: delegate, onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) { + renderObject.selection = selection; currentSelection = selection; }, startHandleLayerLink: LayerLink(), @@ -838,17 +869,13 @@ void main() { editable.selection = const TextSelection.collapsed(offset: 4); pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); + editable.moveSelectionRight(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 6); - editable.selection = currentSelection; - await simulateKeyDownEvent(LogicalKeyboardKey.arrowLeft, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowLeft, platform: 'android'); + editable.moveSelectionLeft(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 4); - editable.selection = currentSelection; await simulateKeyDownEvent(LogicalKeyboardKey.delete, platform: 'android'); await simulateKeyUpEvent(LogicalKeyboardKey.delete, platform: 'android'); @@ -871,6 +898,7 @@ void main() { offset: viewportOffset, textSelectionDelegate: delegate, onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) { + renderObject.selection = selection; currentSelection = selection; }, startHandleLayerLink: LayerLink(), @@ -892,17 +920,13 @@ void main() { editable.selection = const TextSelection.collapsed(offset: 4); pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); + editable.moveSelectionRight(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 12); - editable.selection = currentSelection; - await simulateKeyDownEvent(LogicalKeyboardKey.arrowLeft, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowLeft, platform: 'android'); + editable.moveSelectionLeft(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 4); - editable.selection = currentSelection; await simulateKeyDownEvent(LogicalKeyboardKey.delete, platform: 'android'); await simulateKeyUpEvent(LogicalKeyboardKey.delete, platform: 'android'); @@ -921,6 +945,7 @@ void main() { offset: viewportOffset, textSelectionDelegate: delegate, onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) { + renderObject.selection = selection; currentSelection = selection; }, startHandleLayerLink: LayerLink(), @@ -943,13 +968,11 @@ void main() { editable.selection = const TextSelection.collapsed(offset: 0); pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); + editable.moveSelectionRight(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 2); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowLeft, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowLeft, platform: 'android'); + editable.moveSelectionLeft(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 0); @@ -998,19 +1021,14 @@ void main() { editable.selection = const TextSelection.collapsed(offset: 0); pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); + editable.moveSelectionRight(SelectionChangedCause.keyboard); + editable.moveSelectionRight(SelectionChangedCause.keyboard); + editable.moveSelectionRight(SelectionChangedCause.keyboard); + editable.moveSelectionRight(SelectionChangedCause.keyboard); expect(editable.selection?.isCollapsed, true); expect(editable.selection?.baseOffset, 4); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowLeft, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowLeft, platform: 'android'); + editable.moveSelectionLeft(SelectionChangedCause.keyboard); expect(editable.selection?.isCollapsed, true); expect(editable.selection?.baseOffset, 3); @@ -1031,6 +1049,7 @@ void main() { offset: viewportOffset, textSelectionDelegate: delegate, onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) { + renderObject.selection = selection; currentSelection = selection; }, startHandleLayerLink: LayerLink(), @@ -1050,32 +1069,28 @@ void main() { editable.selection = const TextSelection(baseOffset: 2, extentOffset: 4); pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight); + editable.moveSelectionRight(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 4); editable.selection = const TextSelection(baseOffset: 4, extentOffset: 2); pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight); + editable.moveSelectionRight(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 4); editable.selection = const TextSelection(baseOffset: 2, extentOffset: 4); pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowLeft); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowLeft); + editable.moveSelectionLeft(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 2); editable.selection = const TextSelection(baseOffset: 4, extentOffset: 2); pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowLeft); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowLeft); + editable.moveSelectionLeft(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 2); }, skip: isBrowser); // https://github.com/flutter/flutter/issues/58068 @@ -1092,6 +1107,7 @@ void main() { offset: viewportOffset, textSelectionDelegate: delegate, onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) { + renderObject.selection = selection; currentSelection = selection; }, startHandleLayerLink: LayerLink(), @@ -1111,10 +1127,7 @@ void main() { editable.selection = const TextSelection(baseOffset: 2, extentOffset: 4); pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.shift); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight); - await simulateKeyUpEvent(LogicalKeyboardKey.shift); + editable.extendSelectionRight(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, false); expect(currentSelection.baseOffset, 2); expect(currentSelection.extentOffset, 5); @@ -1122,10 +1135,7 @@ void main() { editable.selection = const TextSelection(baseOffset: 4, extentOffset: 2); pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.shift); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight); - await simulateKeyUpEvent(LogicalKeyboardKey.shift); + editable.extendSelectionRight(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, false); expect(currentSelection.baseOffset, 4); expect(currentSelection.extentOffset, 3); @@ -1133,10 +1143,7 @@ void main() { editable.selection = const TextSelection(baseOffset: 2, extentOffset: 4); pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.shift); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowLeft); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowLeft); - await simulateKeyUpEvent(LogicalKeyboardKey.shift); + editable.extendSelectionLeft(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, false); expect(currentSelection.baseOffset, 2); expect(currentSelection.extentOffset, 3); @@ -1144,10 +1151,7 @@ void main() { editable.selection = const TextSelection(baseOffset: 4, extentOffset: 2); pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.shift); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowLeft); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowLeft); - await simulateKeyUpEvent(LogicalKeyboardKey.shift); + editable.extendSelectionLeft(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, false); expect(currentSelection.baseOffset, 4); expect(currentSelection.extentOffset, 1); @@ -1165,6 +1169,7 @@ void main() { offset: viewportOffset, textSelectionDelegate: delegate, onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) { + renderObject.selection = selection; currentSelection = selection; }, startHandleLayerLink: LayerLink(), @@ -1187,34 +1192,26 @@ void main() { await simulateKeyDownEvent(LogicalKeyboardKey.shift); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight); + editable.moveSelectionRight(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 3); - editable.selection = currentSelection; - await simulateKeyDownEvent(LogicalKeyboardKey.arrowLeft); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowLeft); + editable.moveSelectionLeft(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 2); - editable.selection = currentSelection; final LogicalKeyboardKey wordModifier = Platform.isMacOS ? LogicalKeyboardKey.alt : LogicalKeyboardKey.control; await simulateKeyDownEvent(wordModifier); - await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowRight); + editable.moveSelectionRightByWord(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 6); - editable.selection = currentSelection; - await simulateKeyDownEvent(LogicalKeyboardKey.arrowLeft); - await simulateKeyUpEvent(LogicalKeyboardKey.arrowLeft); + editable.moveSelectionLeftByWord(SelectionChangedCause.keyboard); expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 0); - editable.selection = currentSelection; await simulateKeyUpEvent(wordModifier); await simulateKeyUpEvent(LogicalKeyboardKey.shift); diff --git a/packages/flutter/test/widgets/app_test.dart b/packages/flutter/test/widgets/app_test.dart index 08320ab4fa0..960d22081f8 100644 --- a/packages/flutter/test/widgets/app_test.dart +++ b/packages/flutter/test/widgets/app_test.dart @@ -39,7 +39,7 @@ void main() { expect(find.byKey(key), findsOneWidget); }); - testWidgets('WidgetsApp can override default key bindings', (WidgetTester tester) async { + testWidgets('WidgetsApp default key bindings', (WidgetTester tester) async { bool? checked = false; final GlobalKey key = GlobalKey(); await tester.pumpWidget( @@ -64,9 +64,12 @@ void main() { await tester.pumpAndSettle(); // Default key mapping worked. expect(checked, isTrue); - checked = false; + }); + testWidgets('WidgetsApp can override default key bindings', (WidgetTester tester) async { final TestAction action = TestAction(); + bool? checked = false; + final GlobalKey key = GlobalKey(); await tester.pumpWidget( WidgetsApp( key: key, diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 71079ef1e37..5b305409041 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -4181,7 +4181,7 @@ void main() { reason: 'on $platform', ); - // Select to the beginning of the line. + // Select to the beginning of the first line. await sendKeys( tester, [ @@ -4196,8 +4196,8 @@ void main() { selection, equals( const TextSelection( - baseOffset: 20, - extentOffset: 55, + baseOffset: 0, + extentOffset: 72, affinity: TextAffinity.downstream, ), ), @@ -4562,25 +4562,12 @@ void main() { expect(controller.text, isEmpty, reason: 'on $platform'); } - testWidgets('keyboard text selection works as expected on linux', (WidgetTester tester) async { - await testTextEditing(tester, platform: 'linux'); + testWidgets('keyboard text selection works', (WidgetTester tester) async { + final String targetPlatform = defaultTargetPlatform.toString(); + final String platform = targetPlatform.substring(targetPlatform.indexOf('.') + 1).toLowerCase(); + await testTextEditing(tester, platform: platform); // On web, using keyboard for selection is handled by the browser. - }, skip: kIsWeb); - - testWidgets('keyboard text selection works as expected on android', (WidgetTester tester) async { - await testTextEditing(tester, platform: 'android'); - // On web, using keyboard for selection is handled by the browser. - }, skip: kIsWeb); - - testWidgets('keyboard text selection works as expected on fuchsia', (WidgetTester tester) async { - await testTextEditing(tester, platform: 'fuchsia'); - // On web, using keyboard for selection is handled by the browser. - }, skip: kIsWeb); - - testWidgets('keyboard text selection works as expected on macos', (WidgetTester tester) async { - await testTextEditing(tester, platform: 'macos'); - // On web, using keyboard for selection is handled by the browser. - }, skip: kIsWeb); + }, skip: kIsWeb, variant: TargetPlatformVariant.all()); testWidgets('keyboard shortcuts respect read-only', (WidgetTester tester) async { final String platform = describeEnum(defaultTargetPlatform).toLowerCase(); @@ -7182,6 +7169,172 @@ void main() { expect(tester.takeException(), null); }); + + testWidgets('can change behavior by overriding text editing shortcuts', (WidgetTester tester) async { + 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: Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.arrowLeft): const MoveSelectionRightTextIntent(), + }, + child: EditableText( + maxLines: 10, + controller: controller, + showSelectionHandles: true, + autofocus: true, + focusNode: focusNode, + style: Typography.material2018(platform: TargetPlatform.android).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. + + // The right arrow key moves to the right as usual. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 1); + + // And the left arrow also moves to the right due to the Shortcuts override. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 2); + + // On web, using keyboard for selection is handled by the browser. + }, skip: kIsWeb); + + testWidgets('can change behavior by overriding text editing actions', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: testText); + controller.selection = const TextSelection( + baseOffset: 0, + extentOffset: 0, + affinity: TextAffinity.upstream, + ); + late final bool myIntentWasCalled; + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: Actions( + actions: >{ + MoveSelectionLeftTextIntent: _MyMoveSelectionRightTextAction( + onInvoke: () { + myIntentWasCalled = true; + }, + ), + }, + child: EditableText( + maxLines: 10, + controller: controller, + showSelectionHandles: true, + autofocus: true, + focusNode: focusNode, + style: Typography.material2018(platform: TargetPlatform.android).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. + + // The right arrow key moves to the right as usual. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 1); + + // And the left arrow also moves to the right due to the Actions override. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 2); + expect(myIntentWasCalled, isTrue); + + // On web, using keyboard for selection is handled by the browser. + }, skip: kIsWeb); + + testWidgets('ignore key event from web platform', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'test\ntest', + ); + controller.selection = const TextSelection( + baseOffset: 0, + extentOffset: 0, + affinity: TextAffinity.upstream, + ); + bool myIntentWasCalled = false; + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: Actions( + actions: >{ + MoveSelectionRightTextIntent: _MyMoveSelectionRightTextAction( + onInvoke: () { + myIntentWasCalled = true; + }, + ), + }, + child: EditableText( + maxLines: 10, + controller: controller, + showSelectionHandles: true, + autofocus: true, + focusNode: focusNode, + style: Typography.material2018(platform: TargetPlatform.android).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. + + if (kIsWeb) { + await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight, platform: 'web'); + await tester.pump(); + expect(myIntentWasCalled, isFalse); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 0); + } else { + await simulateKeyDownEvent(LogicalKeyboardKey.arrowRight, platform: 'android'); + await tester.pump(); + expect(myIntentWasCalled, isTrue); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 1); + } + }); } class UnsettableController extends TextEditingController { @@ -7410,3 +7563,17 @@ class _AccentColorTextEditingController extends TextEditingController { return super.buildTextSpan(context: context, style: TextStyle(color: color), withComposing: withComposing); } } + +class _MyMoveSelectionRightTextAction extends TextEditingAction { + _MyMoveSelectionRightTextAction({ + required this.onInvoke, + }) : super(); + + final VoidCallback onInvoke; + + @override + Object? invoke(Intent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.moveSelectionRight(SelectionChangedCause.keyboard); + onInvoke(); + } +}