diff --git a/examples/api/lib/widgets/actions/action.action_overridable.0.dart b/examples/api/lib/widgets/actions/action.action_overridable.0.dart index 502b72f17b4..95efe6f594a 100644 --- a/examples/api/lib/widgets/actions/action.action_overridable.0.dart +++ b/examples/api/lib/widgets/actions/action.action_overridable.0.dart @@ -18,7 +18,7 @@ void main() { } // This implements a custom phone number input field that handles the -// [DeleteTextIntent] intent. +// [DeleteCharacterIntent] intent. class DigitInput extends StatefulWidget { const DigitInput({ Key? key, @@ -38,9 +38,9 @@ class DigitInput extends StatefulWidget { } class DigitInputState extends State { - late final Action _deleteTextAction = - CallbackAction( - onInvoke: (DeleteTextIntent intent) { + late final Action _deleteTextAction = + CallbackAction( + onInvoke: (DeleteCharacterIntent intent) { // For simplicity we delete everything in the section. widget.controller.clear(); }, @@ -50,8 +50,8 @@ class DigitInputState extends State { Widget build(BuildContext context) { return Actions( actions: >{ - // Make the default `DeleteTextIntent` handler overridable. - DeleteTextIntent: Action.overridable( + // Make the default `DeleteCharacterIntent` handler overridable. + DeleteCharacterIntent: Action.overridable( defaultAction: _deleteTextAction, context: context), }, child: TextField( @@ -79,12 +79,12 @@ class SimpleUSPhoneNumberEntry extends StatefulWidget { _SimpleUSPhoneNumberEntryState(); } -class _DeleteDigit extends Action { +class _DeleteDigit extends Action { _DeleteDigit(this.state); final _SimpleUSPhoneNumberEntryState state; @override - Object? invoke(DeleteTextIntent intent) { + Object? invoke(DeleteCharacterIntent intent) { assert(callingAction != null); callingAction?.invoke(intent); @@ -116,7 +116,7 @@ class _SimpleUSPhoneNumberEntryState extends State { Widget build(BuildContext context) { return Actions( actions: >{ - DeleteTextIntent: _DeleteDigit(this), + DeleteCharacterIntent: _DeleteDigit(this), }, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index 5b465114969..13beb120559 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -206,6 +206,7 @@ class TextPainter { /// in framework will automatically invoke this method. void markNeedsLayout() { _paragraph = null; + _lineMetricsCache = null; _previousCaretPosition = null; _previousCaretPrototype = null; } @@ -975,6 +976,7 @@ class TextPainter { return _paragraph!.getLineBoundary(position); } + List? _lineMetricsCache; /// Returns the full list of [LineMetrics] that describe in detail the various /// metrics of each laid out line. /// @@ -992,6 +994,6 @@ class TextPainter { /// should be invalidated upon the next successful [layout]. List computeLineMetrics() { assert(!_debugNeedsLayout); - return _paragraph!.computeLineMetrics(); + return _lineMetricsCache ??= _paragraph!.computeLineMetrics(); } } diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index dd19cf48be4..b62c2b3eaa5 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -4,7 +4,7 @@ import 'dart:collection'; import 'dart:math' as math; -import 'dart:ui' as ui show TextBox, BoxHeightStyle, BoxWidthStyle, PlaceholderAlignment; +import 'dart:ui' as ui show TextBox, BoxHeightStyle, BoxWidthStyle, PlaceholderAlignment, LineMetrics; import 'package:characters/characters.dart'; import 'package:flutter/foundation.dart'; @@ -77,6 +77,154 @@ class TextSelectionPoint { } } +/// The consecutive sequence of [TextPosition]s that the caret should move to +/// when the user navigates the paragraph using the upward arrow key or the +/// downward arrow key. +/// +/// {@template flutter.rendering.RenderEditable.verticalArrowKeyMovement} +/// When the user presses the upward arrow key or the downward arrow key, on +/// many platforms (macOS for instance), the caret will move to the previous +/// line or the next line, while maintaining its original horizontal location. +/// When it encounters a shorter line, the caret moves to the closest horizontal +/// location within that line, and restores the original horizontal location +/// when a long enough line is encountered. +/// +/// Additionally, the caret will move to the beginning of the document if the +/// upward arrow key is pressed and the caret is already on the first line. If +/// the downward arrow key is pressed next, the caret will restore its original +/// horizontal location and move to the second line. Similarly the caret moves +/// to the end of the document if the downward arrow key is pressed when it's +/// already on the last line. +/// +/// Consider a left-aligned paragraph: +/// aa| +/// a +/// aaa +/// where the caret was initially placed at the end of the first line. Pressing +/// the downward arrow key once will move the caret to the end of the second +/// line, and twice the arrow key moves to the third line after the second "a" +/// on that line. Pressing the downward arrow key again, the caret will move to +/// the end of the third line (the end of the document). Pressing the upward +/// arrow key in this state will result in the caret moving to the end of the +/// second line. +/// +/// Vertical caret runs are typically interrupted when the layout of the text +/// changes (including when the text itself changes), or when the selection is +/// changed by other input events or programmatically (for example, when the +/// user pressed the left arrow key). +/// {@endtemplate} +/// +/// The [movePrevious] method moves the caret location (which is +/// [VerticalCaretMovementRun.current]) to the previous line, and in case +/// the caret is already on the first line, the method does nothing and returns +/// false. Similarly the [moveNext] method moves the caret to the next line, and +/// returns false if the caret is already on the last line. +/// +/// If the underlying paragraph's layout changes, [isValid] becomes false and +/// the [VerticalCaretMovementRun] must not be used. The [isValid] property must +/// be checked before calling [movePrevious] and [moveNext], or accessing +/// [current]. +class VerticalCaretMovementRun extends BidirectionalIterator { + VerticalCaretMovementRun._( + this._editable, + this._lineMetrics, + this._currentTextPosition, + this._currentLine, + this._currentOffset, + ); + + Offset _currentOffset; + int _currentLine; + TextPosition _currentTextPosition; + + final List _lineMetrics; + final RenderEditable _editable; + + bool _isValid = true; + /// Whether this [VerticalCaretMovementRun] can still continue. + /// + /// A [VerticalCaretMovementRun] run is valid if the underlying text layout + /// hasn't changed. + /// + /// The [current] value and the [movePrevious] and [moveNext] methods must not + /// be accessed when [isValid] is false. + bool get isValid { + if (!_isValid) { + return false; + } + final List newLineMetrics = _editable._textPainter.computeLineMetrics(); + // Use the implementation detail of the computeLineMetrics method to figure + // out if the current text layout has been invalidated. + if (!identical(newLineMetrics, _lineMetrics)) { + _isValid = false; + } + return _isValid; + } + + // Computes the vertical distance from the `from` line's bottom to the `to` + // lines top. + double _lineDistance(int from, int to) { + double lineHeight = 0; + for (int index = from + 1; index < to; index += 1) { + lineHeight += _lineMetrics[index].height; + } + return lineHeight; + } + + final Map> _positionCache = >{}; + + MapEntry _getTextPositionForLine(int lineNumber) { + assert(isValid); + assert(lineNumber >= 0); + final MapEntry? cachedPosition = _positionCache[lineNumber]; + if (cachedPosition != null) { + return cachedPosition; + } + assert(lineNumber != _currentLine); + final double distanceY = lineNumber > _currentLine + ? _lineMetrics[_currentLine].descent + _lineMetrics[lineNumber].ascent + _lineDistance(_currentLine, lineNumber) + : - _lineMetrics[_currentLine].ascent - _lineMetrics[lineNumber].descent - _lineDistance(lineNumber, _currentLine); + + final Offset newOffset = _currentOffset.translate(0, distanceY); + final TextPosition closestPosition = _editable._textPainter.getPositionForOffset(newOffset); + final MapEntry position = MapEntry(newOffset, closestPosition); + _positionCache[lineNumber] = position; + return position; + } + + @override + TextPosition get current { + assert(isValid); + return _currentTextPosition; + } + + @override + bool moveNext() { + assert(isValid); + if (_currentLine + 1 >= _lineMetrics.length) { + return false; + } + final MapEntry position = _getTextPositionForLine(_currentLine + 1); + _currentLine += 1; + _currentOffset = position.key; + _currentTextPosition = position.value; + return true; + } + + @override + bool movePrevious() { + assert(isValid); + if (_currentLine <= 0) { + return false; + } + final MapEntry position = _getTextPositionForLine(_currentLine - 1); + _currentLine -= 1; + _currentOffset = position.key; + _currentTextPosition = position.value; + return true; + } +} + /// Displays some text in a scrollable container with a potentially blinking /// cursor and with gesture recognizers. /// @@ -2266,6 +2414,49 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, _caretPainter.showRegularCaret = _resetFloatingCursorAnimationValue == null; } + MapEntry _lineNumberFor(TextPosition startPosition, List metrics) { + // TODO(LongCatIsLooong): include line boundaries information in + // ui.LineMetrics, then we can get rid of this. + final Offset offset = _textPainter.getOffsetForCaret(startPosition, Rect.zero); + int line = 0; + double accumulatedHeight = 0; + for (final ui.LineMetrics lineMetrics in metrics) { + if (accumulatedHeight + lineMetrics.height > offset.dy) { + return MapEntry(line, Offset(offset.dx, lineMetrics.baseline)); + } + line += 1; + accumulatedHeight += lineMetrics.height; + } + assert(false, 'unable to find the line for $startPosition'); + return MapEntry(math.max(0, metrics.length - 1), Offset(offset.dx, accumulatedHeight)); + } + + /// Starts a [VerticalCaretMovementRun] at the given location in the text, for + /// handling consecutive vertical caret movements. + /// + /// This can be used to handle consecutive upward/downward arrow key movements + /// in an input field. + /// + /// {@macro flutter.rendering.RenderEditable.verticalArrowKeyMovement} + /// + /// The [VerticalCaretMovementRun.isValid] property indicates whether the text + /// layout has changed and the vertical caret run is invalidated. + /// + /// The caller should typically discard a [VerticalCaretMovementRun] when + /// its [VerticalCaretMovementRun.isValid] becomes false, or on other + /// occasions where the vertical caret run should be interrupted. + VerticalCaretMovementRun startVerticalCaretMovement(TextPosition startPosition) { + final List metrics = _textPainter.computeLineMetrics(); + final MapEntry currentLine = _lineNumberFor(startPosition, metrics); + return VerticalCaretMovementRun._( + this, + metrics, + startPosition, + currentLine.key, + currentLine.value, + ); + } + void _paintContents(PaintingContext context, Offset offset) { debugAssertLayoutUpToDate(); final Offset effectiveOffset = offset + _paintOffset; diff --git a/packages/flutter/lib/src/services/text_editing.dart b/packages/flutter/lib/src/services/text_editing.dart index 2e4583bacef..403fae567f3 100644 --- a/packages/flutter/lib/src/services/text_editing.dart +++ b/packages/flutter/lib/src/services/text_editing.dart @@ -80,8 +80,31 @@ class TextSelection extends TextRange { /// The position at which the selection originates. /// + /// {@template flutter.services.TextSelection.TextAffinity} + /// The [TextAffinity] of the resulting [TextPosition] is based on the + /// relative logical position in the text to the other selection endpoint: + /// * if [baseOffset] < [extentOffset], [base] will have + /// [TextAffinity.downstream] and [extent] will have + /// [TextAffinity.upstream]. + /// * if [baseOffset] > [extentOffset], [base] will have + /// [TextAffinity.upstream] and [extent] will have + /// [TextAffinity.downstream]. + /// * if [baseOffset] == [extentOffset], [base] and [extent] will both have + /// the collapsed selection's [affinity]. + /// {@endtemplate} + /// /// Might be larger than, smaller than, or equal to extent. - TextPosition get base => TextPosition(offset: baseOffset, affinity: affinity); + TextPosition get base { + final TextAffinity affinity; + if (!isValid || baseOffset == extentOffset) { + affinity = this.affinity; + } else if (baseOffset < extentOffset) { + affinity = TextAffinity.downstream; + } else { + affinity = TextAffinity.upstream; + } + return TextPosition(offset: baseOffset, affinity: affinity); + } /// The position at which the selection terminates. /// @@ -89,8 +112,20 @@ class TextSelection extends TextRange { /// value that changes. Similarly, if the current theme paints a caret on one /// side of the selection, this is the location at which to paint the caret. /// + /// {@macro flutter.services.TextSelection.TextAffinity} + /// /// Might be larger than, smaller than, or equal to base. - TextPosition get extent => TextPosition(offset: extentOffset, affinity: affinity); + TextPosition get extent { + final TextAffinity affinity; + if (!isValid || baseOffset == extentOffset) { + affinity = this.affinity; + } else if (baseOffset < extentOffset) { + affinity = TextAffinity.upstream; + } else { + affinity = TextAffinity.downstream; + } + return TextPosition(offset: extentOffset, affinity: affinity); + } @override String toString() { diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 40b5379d839..f546c36ab0b 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -834,6 +834,55 @@ class TextEditingValue { /// programming error. bool get isComposingRangeValid => composing.isValid && composing.isNormalized && composing.end <= text.length; + /// Returns a new [TextEditingValue], which is this [TextEditingValue] with + /// its [text] partially replaced by the `replacementString`. + /// + /// The `replacementRange` parameter specifies the range of the + /// [TextEditingValue.text] that needs to be replaced. + /// + /// The `replacementString` parameter specifies the string to replace the + /// given range of text with. + /// + /// This method also adjusts the selection range and the composing range of the + /// resulting [TextEditingValue], such that they point to the same substrings + /// as the correspoinding ranges in the original [TextEditingValue]. For + /// example, if the original [TextEditingValue] is "Hello world" with the word + /// "world" selected, replacing "Hello" with a different string using this + /// method will not change the selected word. + /// + /// This method does nothing if the given `replacementRange` is not + /// [TextRange.isValid]. + TextEditingValue replaced(TextRange replacementRange, String replacementString) { + if (!replacementRange.isValid) { + return this; + } + final String newText = text.replaceRange(replacementRange.start, replacementRange.end, replacementString); + + if (replacementRange.end - replacementRange.start == replacementString.length) { + return copyWith(text: newText); + } + + int adjustIndex(int originalIndex) { + // The length added by adding the replacementString. + final int replacedLength = originalIndex <= replacementRange.start && originalIndex < replacementRange.end ? 0 : replacementString.length; + // The length removed by removing the replacementRange. + final int removedLength = originalIndex.clamp(replacementRange.start, replacementRange.end) - replacementRange.start; + return originalIndex + replacedLength - removedLength; + } + + return TextEditingValue( + text: newText, + selection: TextSelection( + baseOffset: adjustIndex(selection.baseOffset), + extentOffset: adjustIndex(selection.extentOffset), + ), + composing: TextRange( + start: adjustIndex(composing.start), + end: adjustIndex(composing.end), + ), + ); + } + /// Returns a representation of this object as a JSON object. Map toJSON() { return { diff --git a/packages/flutter/lib/src/widgets/actions.dart b/packages/flutter/lib/src/widgets/actions.dart index ed517d7f7b2..7fc6b17fa85 100644 --- a/packages/flutter/lib/src/widgets/actions.dart +++ b/packages/flutter/lib/src/widgets/actions.dart @@ -142,8 +142,8 @@ abstract class Action with Diagnosticable { /// /// {@tool dartpad} /// This sample implements a custom text input field that handles the - /// [DeleteTextIntent] intent, as well as a US telephone number input widget - /// that consists of multiple text fields for area code, prefix and line + /// [DeleteCharacterIntent] intent, as well as a US telephone number input + /// widget that consists of multiple text fields for area code, prefix and line /// number. When the backspace key is pressed, the phone number input widget /// sends the focus to the preceding text field when the currently focused /// field becomes empty. diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index 96acb7796e9..16814929f15 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -12,7 +12,6 @@ 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'; @@ -1053,9 +1052,6 @@ 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} @@ -1676,11 +1672,9 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { child: DefaultTextEditingShortcuts( child: Actions( actions: widget.actions ?? WidgetsApp.defaultActions, - child: DefaultTextEditingActions( - child: FocusTraversalGroup( - policy: ReadingOrderTraversalPolicy(), - child: child, - ), + child: FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy(), + child: child, ), ), ), diff --git a/packages/flutter/lib/src/widgets/autocomplete.dart b/packages/flutter/lib/src/widgets/autocomplete.dart index 5802fdeba9f..0b30c284000 100644 --- a/packages/flutter/lib/src/widgets/autocomplete.dart +++ b/packages/flutter/lib/src/widgets/autocomplete.dart @@ -512,16 +512,12 @@ class _AutocompleteCallbackAction extends CallbackAction { } /// An [Intent] to highlight the previous option in the autocomplete list. -/// -/// {@macro flutter.widgets.TextEditingIntents.seeAlso} class AutocompletePreviousOptionIntent extends Intent { /// Creates an instance of AutocompletePreviousOptionIntent. const AutocompletePreviousOptionIntent(); } /// An [Intent] to highlight the next option in the autocomplete list. -/// -/// {@macro flutter.widgets.TextEditingIntents.seeAlso} class AutocompleteNextOptionIntent extends Intent { /// Creates an instance of AutocompleteNextOptionIntent. const AutocompleteNextOptionIntent(); diff --git a/packages/flutter/lib/src/widgets/default_text_editing_actions.dart b/packages/flutter/lib/src/widgets/default_text_editing_actions.dart deleted file mode 100644 index e0c91e79a76..00000000000 --- a/packages/flutter/lib/src/widgets/default_text_editing_actions.dart +++ /dev/null @@ -1,326 +0,0 @@ -// 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'; -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(), - DeleteTextIntent: _DeleteTextAction(), - DeleteByWordTextIntent: _DeleteByWordTextAction(), - DeleteByLineTextIntent: _DeleteByLineTextAction(), - DeleteForwardTextIntent: _DeleteForwardTextAction(), - DeleteForwardByWordTextIntent: _DeleteForwardByWordTextAction(), - DeleteForwardByLineTextIntent: _DeleteForwardByLineTextAction(), - ExtendSelectionDownTextIntent: _ExtendSelectionDownTextAction(), - ExtendSelectionLeftByLineTextIntent: _ExtendSelectionLeftByLineTextAction(), - ExtendSelectionLeftByWordTextIntent: _ExtendSelectionLeftByWordTextAction(), - ExtendSelectionLeftByWordAndStopAtReversalTextIntent: _ExtendSelectionLeftByWordAndStopAtReversalTextAction(), - ExtendSelectionLeftTextIntent: _ExtendSelectionLeftTextAction(), - ExtendSelectionRightByWordAndStopAtReversalTextIntent: _ExtendSelectionRightByWordAndStopAtReversalTextAction(), - 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(), - SelectAllTextIntent: _SelectAllTextAction(), - CopySelectionTextIntent: _CopySelectionTextAction(), - CutSelectionTextIntent: _CutSelectionTextAction(), - PasteTextIntent: _PasteTextAction(), - }; -} - -// 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) => false; - - @override - void invoke(DoNothingAndStopPropagationTextIntent intent, [BuildContext? context]) {} -} - -class _DeleteTextAction extends TextEditingAction { - @override - Object? invoke(DeleteTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.delete(SelectionChangedCause.keyboard); - } -} - -class _DeleteByWordTextAction extends TextEditingAction { - @override - Object? invoke(DeleteByWordTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.deleteByWord(SelectionChangedCause.keyboard, false); - } -} - -class _DeleteByLineTextAction extends TextEditingAction { - @override - Object? invoke(DeleteByLineTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.deleteByLine(SelectionChangedCause.keyboard); - } -} - -class _DeleteForwardTextAction extends TextEditingAction { - @override - Object? invoke(DeleteForwardTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.deleteForward(SelectionChangedCause.keyboard); - } -} - -class _DeleteForwardByWordTextAction extends TextEditingAction { - @override - Object? invoke(DeleteForwardByWordTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.deleteForwardByWord(SelectionChangedCause.keyboard, false); - } -} - -class _DeleteForwardByLineTextAction extends TextEditingAction { - @override - Object? invoke(DeleteForwardByLineTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.deleteForwardByLine(SelectionChangedCause.keyboard); - } -} - -class _ExpandSelectionLeftByLineTextAction extends TextEditingAction { - @override - Object? invoke(ExpandSelectionLeftByLineTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.expandSelectionLeftByLine(SelectionChangedCause.keyboard); - } -} - -class _ExpandSelectionRightByLineTextAction extends TextEditingAction { - @override - Object? invoke(ExpandSelectionRightByLineTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.expandSelectionRightByLine(SelectionChangedCause.keyboard); - } -} - -class _ExpandSelectionToEndTextAction extends TextEditingAction { - @override - Object? invoke(ExpandSelectionToEndTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.expandSelectionToEnd(SelectionChangedCause.keyboard); - } -} - -class _ExpandSelectionToStartTextAction extends TextEditingAction { - @override - Object? invoke(ExpandSelectionToStartTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.expandSelectionToStart(SelectionChangedCause.keyboard); - } -} - -class _ExtendSelectionDownTextAction extends TextEditingAction { - @override - Object? invoke(ExtendSelectionDownTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.extendSelectionDown(SelectionChangedCause.keyboard); - } -} - -class _ExtendSelectionLeftByLineTextAction extends TextEditingAction { - @override - Object? invoke(ExtendSelectionLeftByLineTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.extendSelectionLeftByLine(SelectionChangedCause.keyboard); - } -} - -class _ExtendSelectionLeftByWordAndStopAtReversalTextAction extends TextEditingAction { - @override - Object? invoke(ExtendSelectionLeftByWordAndStopAtReversalTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.extendSelectionLeftByWord(SelectionChangedCause.keyboard, false, true); - } -} - -class _ExtendSelectionLeftByWordTextAction extends TextEditingAction { - @override - Object? invoke(ExtendSelectionLeftByWordTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.extendSelectionLeftByWord(SelectionChangedCause.keyboard, false); - } -} - -class _ExtendSelectionLeftTextAction extends TextEditingAction { - @override - Object? invoke(ExtendSelectionLeftTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.extendSelectionLeft(SelectionChangedCause.keyboard); - } -} - -class _ExtendSelectionRightByLineTextAction extends TextEditingAction { - @override - Object? invoke(ExtendSelectionRightByLineTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.extendSelectionRightByLine(SelectionChangedCause.keyboard); - } -} - -class _ExtendSelectionRightByWordAndStopAtReversalTextAction extends TextEditingAction { - @override - Object? invoke(ExtendSelectionRightByWordAndStopAtReversalTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.extendSelectionRightByWord(SelectionChangedCause.keyboard, false, true); - } -} - -class _ExtendSelectionRightByWordTextAction extends TextEditingAction { - @override - Object? invoke(ExtendSelectionRightByWordTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.extendSelectionRightByWord(SelectionChangedCause.keyboard, false); - } -} - -class _ExtendSelectionRightTextAction extends TextEditingAction { - @override - Object? invoke(ExtendSelectionRightTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.extendSelectionRight(SelectionChangedCause.keyboard); - } -} - -class _ExtendSelectionUpTextAction extends TextEditingAction { - @override - Object? invoke(ExtendSelectionUpTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.extendSelectionUp(SelectionChangedCause.keyboard); - } -} - -class _MoveSelectionDownTextAction extends TextEditingAction { - @override - Object? invoke(MoveSelectionDownTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.moveSelectionDown(SelectionChangedCause.keyboard); - } -} - -class _MoveSelectionLeftTextAction extends TextEditingAction { - @override - Object? invoke(MoveSelectionLeftTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.moveSelectionLeft(SelectionChangedCause.keyboard); - } -} - -class _MoveSelectionRightTextAction extends TextEditingAction { - @override - Object? invoke(MoveSelectionRightTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.moveSelectionRight(SelectionChangedCause.keyboard); - } -} - -class _MoveSelectionUpTextAction extends TextEditingAction { - @override - Object? invoke(MoveSelectionUpTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.moveSelectionUp(SelectionChangedCause.keyboard); - } -} - -class _MoveSelectionLeftByLineTextAction extends TextEditingAction { - @override - Object? invoke(MoveSelectionLeftByLineTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.moveSelectionLeftByLine(SelectionChangedCause.keyboard); - } -} - -class _MoveSelectionLeftByWordTextAction extends TextEditingAction { - @override - Object? invoke(MoveSelectionLeftByWordTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.moveSelectionLeftByWord(SelectionChangedCause.keyboard, false); - } -} - -class _MoveSelectionRightByLineTextAction extends TextEditingAction { - @override - Object? invoke(MoveSelectionRightByLineTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.moveSelectionRightByLine(SelectionChangedCause.keyboard); - } -} - -class _MoveSelectionRightByWordTextAction extends TextEditingAction { - @override - Object? invoke(MoveSelectionRightByWordTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.moveSelectionRightByWord(SelectionChangedCause.keyboard, false); - } -} - -class _MoveSelectionToEndTextAction extends TextEditingAction { - @override - Object? invoke(MoveSelectionToEndTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.moveSelectionToEnd(SelectionChangedCause.keyboard); - } -} - -class _MoveSelectionToStartTextAction extends TextEditingAction { - @override - Object? invoke(MoveSelectionToStartTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.moveSelectionToStart(SelectionChangedCause.keyboard); - } -} - - -class _SelectAllTextAction extends TextEditingAction { - @override - Object? invoke(SelectAllTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.selectAll(SelectionChangedCause.keyboard); - } -} - -class _CopySelectionTextAction extends TextEditingAction { - @override - Object? invoke(CopySelectionTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.copySelection(SelectionChangedCause.keyboard); - } -} - -class _CutSelectionTextAction extends TextEditingAction { - @override - Object? invoke(CutSelectionTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.cutSelection(SelectionChangedCause.keyboard); - } -} - -class _PasteTextAction extends TextEditingAction { - @override - Object? invoke(PasteTextIntent intent, [BuildContext? context]) { - textEditingActionTarget!.pasteText(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 index a18dc58fcbd..ce1a4c68450 100644 --- a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart +++ b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart @@ -14,8 +14,8 @@ import 'text_editing_intents.dart'; /// 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]. +/// lower in the widget tree than this. See the [Action] class for an example +/// of remapping an [Intent] to a custom [Action]. /// /// {@tool snippet} /// @@ -143,9 +143,6 @@ import 'text_editing_intents.dart'; /// /// 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 @@ -160,200 +157,103 @@ class DefaultTextEditingShortcuts extends Shortcuts { child: child, ); - static const Map _androidShortcuts = { - SingleActivator(LogicalKeyboardKey.backspace): DeleteTextIntent(), - SingleActivator(LogicalKeyboardKey.backspace, control: true): DeleteByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.backspace, alt: true): DeleteByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.delete): DeleteForwardTextIntent(), - SingleActivator(LogicalKeyboardKey.delete, control: true): DeleteForwardByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.delete, alt: true): DeleteForwardByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, alt: true): MoveSelectionToEndTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): MoveSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): MoveSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, alt: true): MoveSelectionToStartTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): ExpandSelectionToEndTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: true): ExpandSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: true): ExpandSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): ExpandSelectionToStartTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown): MoveSelectionDownTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft): MoveSelectionLeftTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight): MoveSelectionRightTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp): MoveSelectionUpTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, control: true): MoveSelectionLeftByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, control: true): MoveSelectionRightByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, control: true): ExtendSelectionLeftByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, control: true): ExtendSelectionRightByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): ExtendSelectionDownTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): ExtendSelectionLeftTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): ExtendSelectionRightTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): ExtendSelectionUpTextIntent(), - SingleActivator(LogicalKeyboardKey.keyX, control: true): CutSelectionTextIntent(), - SingleActivator(LogicalKeyboardKey.keyC, control: true): CopySelectionTextIntent(), - SingleActivator(LogicalKeyboardKey.keyV, control: true): PasteTextIntent(), - SingleActivator(LogicalKeyboardKey.keyA, control: true): SelectAllTextIntent(), - // The following key combinations have no effect on text editing on this - // platform: - // * End - // * Home - // * Meta + X - // * Meta + C - // * Meta + V - // * Meta + A - // * 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 - // * Meta + delete - // * Meta + backspace + // These are shortcuts are shared between most platforms except macOS for it + // uses different modifier keys as the line/word modifer. + static const Map _commonShortcuts = { + // Delete Shortcuts. + SingleActivator(LogicalKeyboardKey.backspace): DeleteCharacterIntent(forward: false), + SingleActivator(LogicalKeyboardKey.backspace, control: true): DeleteToNextWordBoundaryIntent(forward: false), + SingleActivator(LogicalKeyboardKey.backspace, alt: true): DeleteToLineBreakIntent(forward: false), + SingleActivator(LogicalKeyboardKey.delete): DeleteCharacterIntent(forward: true), + SingleActivator(LogicalKeyboardKey.delete, control: true): DeleteToNextWordBoundaryIntent(forward: true), + SingleActivator(LogicalKeyboardKey.delete, alt: true): DeleteToLineBreakIntent(forward: true), + + // Arrow: Move Selection. + SingleActivator(LogicalKeyboardKey.arrowLeft): ExtendSelectionByCharacterIntent(forward: false, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.arrowRight): ExtendSelectionByCharacterIntent(forward: true, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.arrowUp): ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.arrowDown): ExtendSelectionVerticallyToAdjacentLineIntent(forward: true, collapseSelection: true), + + // Shift + Arrow: Extend Selection. + SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): ExtendSelectionByCharacterIntent(forward: false, collapseSelection: false), + SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): ExtendSelectionByCharacterIntent(forward: true, collapseSelection: false), + SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: false), + SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): ExtendSelectionVerticallyToAdjacentLineIntent(forward: true, collapseSelection: false), + + SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.arrowUp, alt: true): ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.arrowDown, alt: true): ExtendSelectionToDocumentBoundaryIntent(forward: true, collapseSelection: true), + + SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: true): ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: false), + SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: true): ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: false), + SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: false), + SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): ExtendSelectionToDocumentBoundaryIntent(forward: true, collapseSelection: false), + + SingleActivator(LogicalKeyboardKey.arrowLeft, control: true): ExtendSelectionToNextWordBoundaryIntent(forward: false, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.arrowRight, control: true): ExtendSelectionToNextWordBoundaryIntent(forward: true, collapseSelection: true), + + SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, control: true): ExtendSelectionToNextWordBoundaryIntent(forward: false, collapseSelection: false), + SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, control: true): ExtendSelectionToNextWordBoundaryIntent(forward: true, collapseSelection: false), + + SingleActivator(LogicalKeyboardKey.keyX, control: true): CopySelectionTextIntent.cut(SelectionChangedCause.keyboard), + SingleActivator(LogicalKeyboardKey.keyC, control: true): CopySelectionTextIntent.copy, + SingleActivator(LogicalKeyboardKey.keyV, control: true): PasteTextIntent(SelectionChangedCause.keyboard), + SingleActivator(LogicalKeyboardKey.keyA, control: true): SelectAllTextIntent(SelectionChangedCause.keyboard), }; - static const Map _fuchsiaShortcuts = { - SingleActivator(LogicalKeyboardKey.backspace): DeleteTextIntent(), - SingleActivator(LogicalKeyboardKey.backspace, control: true): DeleteByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.backspace, alt: true): DeleteByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.delete): DeleteForwardTextIntent(), - SingleActivator(LogicalKeyboardKey.delete, control: true): DeleteForwardByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.delete, alt: true): DeleteForwardByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, alt: true): MoveSelectionToEndTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): MoveSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): MoveSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, alt: true): MoveSelectionToStartTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): ExpandSelectionToEndTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: true): ExpandSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: true): ExpandSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): ExpandSelectionToStartTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown): MoveSelectionDownTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft): MoveSelectionLeftTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight): MoveSelectionRightTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp): MoveSelectionUpTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, control: true): MoveSelectionLeftByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, control: true): MoveSelectionRightByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, control: true): ExtendSelectionLeftByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, control: true): ExtendSelectionRightByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): ExtendSelectionDownTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): ExtendSelectionLeftTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): ExtendSelectionRightTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): ExtendSelectionUpTextIntent(), - SingleActivator(LogicalKeyboardKey.keyX, control: true): CutSelectionTextIntent(), - SingleActivator(LogicalKeyboardKey.keyC, control: true): CopySelectionTextIntent(), - SingleActivator(LogicalKeyboardKey.keyV, control: true): PasteTextIntent(), - SingleActivator(LogicalKeyboardKey.keyA, control: true): SelectAllTextIntent(), - // The following key combinations have no effect on text editing on this - // platform: - // * End - // * Home - // * Meta + X - // * Meta + C - // * Meta + V - // * Meta + A - // * 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 - // * Meta + delete - // * Meta + backspace - }; + // The following key combinations have no effect on text editing on this + // platform: + // * End + // * Home + // * Meta + X + // * Meta + C + // * Meta + V + // * Meta + A + // * 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 + // * Meta + delete + // * Meta + backspace + static const Map _androidShortcuts = _commonShortcuts; - static const Map _iOSShortcuts = { - SingleActivator(LogicalKeyboardKey.backspace): DeleteTextIntent(), - SingleActivator(LogicalKeyboardKey.backspace, control: true): DeleteByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.backspace, alt: true): DeleteByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.delete): DeleteForwardTextIntent(), - SingleActivator(LogicalKeyboardKey.delete, control: true): DeleteForwardByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.delete, alt: true): DeleteForwardByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, alt: true): MoveSelectionToEndTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): MoveSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): MoveSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, alt: true): MoveSelectionToStartTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): ExpandSelectionToEndTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: true): ExpandSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: true): ExpandSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): ExpandSelectionToStartTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown): MoveSelectionDownTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft): MoveSelectionLeftTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight): MoveSelectionRightTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp): MoveSelectionUpTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, control: true): MoveSelectionRightByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, control: true): MoveSelectionLeftByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, control: true): ExtendSelectionLeftByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, control: true): ExtendSelectionRightByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): ExtendSelectionDownTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): ExtendSelectionLeftTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): ExtendSelectionRightTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): ExtendSelectionUpTextIntent(), - SingleActivator(LogicalKeyboardKey.keyX, control: true): CutSelectionTextIntent(), - SingleActivator(LogicalKeyboardKey.keyC, control: true): CopySelectionTextIntent(), - SingleActivator(LogicalKeyboardKey.keyV, control: true): PasteTextIntent(), - SingleActivator(LogicalKeyboardKey.keyA, control: true): SelectAllTextIntent(), - // The following key combinations have no effect on text editing on this - // platform: - // * End - // * Home - // * Meta + X - // * Meta + C - // * Meta + V - // * Meta + A - // * 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 - // * Meta + delete - // * Meta + backspace - }; + static const Map _fuchsiaShortcuts = _androidShortcuts; + + // The following key combinations have no effect on text editing on this + // platform: + // * End + // * Home + // * Meta + X + // * Meta + C + // * Meta + V + // * Meta + A + // * 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 + // * Meta + delete + // * Meta + backspace + static const Map _iOSShortcuts = _commonShortcuts; static const Map _linuxShortcuts = { - SingleActivator(LogicalKeyboardKey.backspace): DeleteTextIntent(), - SingleActivator(LogicalKeyboardKey.backspace, control: true): DeleteByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.backspace, alt: true): DeleteByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.delete): DeleteForwardTextIntent(), - SingleActivator(LogicalKeyboardKey.delete, control: true): DeleteForwardByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.delete, alt: true): DeleteForwardByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, alt: true): MoveSelectionToEndTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): MoveSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): MoveSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, alt: true): MoveSelectionToStartTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): ExpandSelectionToEndTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: true): ExpandSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: true): ExpandSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): ExpandSelectionToStartTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown): MoveSelectionDownTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft): MoveSelectionLeftTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight): MoveSelectionRightTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp): MoveSelectionUpTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, control: true): MoveSelectionLeftByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, control: true): MoveSelectionRightByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, control: true): ExtendSelectionLeftByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, control: true): ExtendSelectionRightByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): ExtendSelectionDownTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): ExtendSelectionLeftTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): ExtendSelectionRightTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): ExtendSelectionUpTextIntent(), - SingleActivator(LogicalKeyboardKey.end): MoveSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.home): MoveSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.end, shift: true): ExtendSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.home, shift: true): ExtendSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.keyX, control: true): CutSelectionTextIntent(), - SingleActivator(LogicalKeyboardKey.keyC, control: true): CopySelectionTextIntent(), - SingleActivator(LogicalKeyboardKey.keyV, control: true): PasteTextIntent(), - SingleActivator(LogicalKeyboardKey.keyA, control: true): SelectAllTextIntent(), + ..._commonShortcuts, + SingleActivator(LogicalKeyboardKey.home): ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.end): ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.home, shift: true): ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: false), + SingleActivator(LogicalKeyboardKey.end, shift: true): ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: false), // The following key combinations have no effect on text editing on this // platform: // * Meta + X @@ -372,111 +272,74 @@ class DefaultTextEditingShortcuts extends Shortcuts { // * Meta + backspace }; + // macOS document shortcuts: https://support.apple.com/en-us/HT201236. + // The macOS shortcuts uses different word/line modifiers than most other + // platforms. static const Map _macShortcuts = { - SingleActivator(LogicalKeyboardKey.backspace): DeleteTextIntent(), - SingleActivator(LogicalKeyboardKey.backspace, alt: true): DeleteByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.backspace, meta: true): DeleteByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.delete): DeleteForwardTextIntent(), - SingleActivator(LogicalKeyboardKey.delete, alt: true): DeleteForwardByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.delete, meta: true): DeleteForwardByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, alt: true): MoveSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): MoveSelectionLeftByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): MoveSelectionRightByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, alt: true): MoveSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): ExtendSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: true): ExtendSelectionLeftByWordAndStopAtReversalTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: true): ExtendSelectionRightByWordAndStopAtReversalTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): ExtendSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown): MoveSelectionDownTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft): MoveSelectionLeftTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight): MoveSelectionRightTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp): MoveSelectionUpTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, meta: true): MoveSelectionToEndTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true): MoveSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, meta: true): MoveSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, meta: true): MoveSelectionToStartTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, meta: true): ExpandSelectionToEndTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, meta: true): ExpandSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, meta: true): ExpandSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, meta: true): ExpandSelectionToStartTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): ExtendSelectionDownTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): ExtendSelectionLeftTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): ExtendSelectionRightTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): ExtendSelectionUpTextIntent(), - SingleActivator(LogicalKeyboardKey.end, shift: true): ExpandSelectionToEndTextIntent(), - SingleActivator(LogicalKeyboardKey.home, shift: true): ExpandSelectionToStartTextIntent(), - SingleActivator(LogicalKeyboardKey.keyX, meta: true): CutSelectionTextIntent(), - SingleActivator(LogicalKeyboardKey.keyC, meta: true): CopySelectionTextIntent(), - SingleActivator(LogicalKeyboardKey.keyV, meta: true): PasteTextIntent(), - SingleActivator(LogicalKeyboardKey.keyA, meta: true): SelectAllTextIntent(), - // The following key combinations have no effect on text editing on this - // platform: - // * Control + X - // * Control + C - // * Control + V - // * Control + A - // * Control + arrow left - // * Control + arrow right - // * Control + shift + arrow left - // * Control + shift + arrow right - // * End - // * Home - // * Control + delete - // * Control + backspace + SingleActivator(LogicalKeyboardKey.backspace): DeleteCharacterIntent(forward: false), + SingleActivator(LogicalKeyboardKey.backspace, alt: true): DeleteToNextWordBoundaryIntent(forward: false), + SingleActivator(LogicalKeyboardKey.backspace, meta: true): DeleteToLineBreakIntent(forward: false), + SingleActivator(LogicalKeyboardKey.delete): DeleteCharacterIntent(forward: true), + SingleActivator(LogicalKeyboardKey.delete, alt: true): DeleteToNextWordBoundaryIntent(forward: true), + SingleActivator(LogicalKeyboardKey.delete, meta: true): DeleteToLineBreakIntent(forward: true), + + SingleActivator(LogicalKeyboardKey.arrowLeft): ExtendSelectionByCharacterIntent(forward: false, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.arrowRight): ExtendSelectionByCharacterIntent(forward: true, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.arrowUp): ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.arrowDown): ExtendSelectionVerticallyToAdjacentLineIntent(forward: true, collapseSelection: true), + + // Shift + Arrow: Extend Selection. + SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): ExtendSelectionByCharacterIntent(forward: false, collapseSelection: false), + SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): ExtendSelectionByCharacterIntent(forward: true, collapseSelection: false), + SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: false), + SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): ExtendSelectionVerticallyToAdjacentLineIntent(forward: true, collapseSelection: false), + + SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): ExtendSelectionToNextWordBoundaryIntent(forward: false, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): ExtendSelectionToNextWordBoundaryIntent(forward: true, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.arrowUp, alt: true): ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.arrowDown, alt: true): ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true), + + SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: true): ExtendSelectionToNextWordBoundaryOrCaretLocationIntent(forward: false), + SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: true): ExtendSelectionToNextWordBoundaryOrCaretLocationIntent(forward: true), + SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: false), + SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: false), + + SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true): ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.arrowRight, meta: true): ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.arrowUp, meta: true): ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.arrowDown, meta: true): ExtendSelectionToDocumentBoundaryIntent(forward: true, collapseSelection: true), + + SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, meta: true): ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: false), + SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, meta: true): ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: false), + SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, meta: true): ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: false), + SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, meta: true): ExtendSelectionToDocumentBoundaryIntent(forward: true, collapseSelection: false), + + SingleActivator(LogicalKeyboardKey.home, shift: true): ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: false), + SingleActivator(LogicalKeyboardKey.end, shift: true): ExtendSelectionToDocumentBoundaryIntent(forward: true, collapseSelection: false), + + SingleActivator(LogicalKeyboardKey.keyX, meta: true): CopySelectionTextIntent.cut(SelectionChangedCause.keyboard), + SingleActivator(LogicalKeyboardKey.keyC, meta: true): CopySelectionTextIntent.copy, + SingleActivator(LogicalKeyboardKey.keyV, meta: true): PasteTextIntent(SelectionChangedCause.keyboard), + SingleActivator(LogicalKeyboardKey.keyA, meta: true): SelectAllTextIntent(SelectionChangedCause.keyboard), }; - static const Map _windowsShortcuts = { - SingleActivator(LogicalKeyboardKey.backspace): DeleteTextIntent(), - SingleActivator(LogicalKeyboardKey.backspace, control: true): DeleteByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.backspace, alt: true): DeleteByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.delete): DeleteForwardTextIntent(), - SingleActivator(LogicalKeyboardKey.delete, control: true): DeleteForwardByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.delete, alt: true): DeleteForwardByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, alt: true): MoveSelectionToEndTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): MoveSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): MoveSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, alt: true): MoveSelectionToStartTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, alt: true): ExpandSelectionToEndTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, alt: true): ExpandSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, alt: true): ExpandSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, alt: true): ExpandSelectionToStartTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown): MoveSelectionDownTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft): MoveSelectionLeftTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight): MoveSelectionRightTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp): MoveSelectionUpTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, control: true): MoveSelectionLeftByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, control: true): MoveSelectionRightByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, control: true): ExtendSelectionLeftByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, control: true): ExtendSelectionRightByWordTextIntent(), - SingleActivator(LogicalKeyboardKey.end): MoveSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.home): MoveSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): ExtendSelectionDownTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): ExtendSelectionLeftTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): ExtendSelectionRightTextIntent(), - SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): ExtendSelectionUpTextIntent(), - SingleActivator(LogicalKeyboardKey.end, shift: true): ExtendSelectionRightByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.home, shift: true): ExtendSelectionLeftByLineTextIntent(), - SingleActivator(LogicalKeyboardKey.keyX, control: true): CutSelectionTextIntent(), - SingleActivator(LogicalKeyboardKey.keyC, control: true): CopySelectionTextIntent(), - SingleActivator(LogicalKeyboardKey.keyV, control: true): PasteTextIntent(), - SingleActivator(LogicalKeyboardKey.keyA, control: true): SelectAllTextIntent(), - // The following key combinations have no effect on text editing on this - // platform: - // * Meta + X - // * Meta + C - // * Meta + V - // * Meta + A - // * 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 - // * Meta + delete - // * Meta + backspace - }; + // The following key combinations have no effect on text editing on this + // platform: + // * Meta + X + // * Meta + C + // * Meta + V + // * Meta + A + // * 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 + // * Meta + delete + // * Meta + backspace + static const Map _windowsShortcuts = _linuxShortcuts; // Web handles its text selection natively and doesn't use any of these // shortcuts in Flutter. diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index a0cb911868a..c7fa74d90be 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -6,12 +6,14 @@ import 'dart:async'; import 'dart:math' as math; import 'dart:ui' as ui hide TextStyle; +import 'package:characters/characters.dart' show CharacterRange; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart' show DragStartBehavior; 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'; @@ -20,6 +22,7 @@ import 'constants.dart'; import 'debug.dart'; import 'focus_manager.dart'; import 'focus_scope.dart'; +import 'focus_traversal.dart'; import 'framework.dart'; import 'localizations.dart'; import 'media_query.dart'; @@ -28,7 +31,7 @@ import 'scroll_controller.dart'; import 'scroll_physics.dart'; import 'scrollable.dart'; import 'text.dart'; -import 'text_editing_action_target.dart'; +import 'text_editing_intents.dart'; import 'text_selection.dart'; import 'ticker_provider.dart'; import 'widget_span.dart'; @@ -345,6 +348,61 @@ class ToolbarOptions { /// is a full-featured, material-design text input field with placeholder text, /// labels, and [Form] integration. /// +/// ## Text Editing [Intent]s and Their Default [Action]s +/// +/// This widget provides default [Action]s for handling common text editing +/// [Intent]s such as deleting, copying and pasting in the text field. These +/// [Action]s can be directly invoked using [Actions.invoke] or the +/// [Actions.maybeInvoke] method. The default text editing keyboard [Shortcuts] +/// also use these [Intent]s and [Action]s to perform the text editing +/// operations they are bound to. +/// +/// The default handling of a specific [Intent] can be overridden by placing an +/// [Actions] widget above this widget. See the [Action] class and the +/// [Action.overridable] constructor for more information on how a pre-defined +/// overridable [Action] can be overridden. +/// +/// ### Intents for Deleting Text and Their Default Behavior +/// +/// | **Intent Class** | **Default Behavior when there's selected text** | **Default Behavior when there is a [caret](https://en.wikipedia.org/wiki/Caret_navigation) (The selection is [TextSelection.collapsed])** | +/// | :------------------------------- | :--------------------------------------------------- | :----------------------------------------------------------------------- | +/// | [DeleteCharacterIntent] | Deletes the selected text | Deletes the user-perceived character before or after the caret location. | +/// | [DeleteToNextWordBoundaryIntent] | Deletes the selected text and the word before/after the selection's [TextSelection.extent] position | Deletes from the caret location to the previous or the next word boundary | +/// | [DeleteToLineBreakIntent] | Deletes the selected text, and deletes to the start/end of the line from the selection's [TextSelection.extent] position | Deletes from the caret location to the logical start or end of the current line | +/// +/// ### Intents for Moving the [Caret](https://en.wikipedia.org/wiki/Caret_navigation) +/// +/// | **Intent Class** | **Default Behavior when there's selected text** | **Default Behavior when there is a caret ([TextSelection.collapsed])** | +/// | :----------------------------------------------------------------------------------- | :--------------------------------------------------------------- | :---------------------------------------------------------------------- | +/// | [ExtendSelectionByCharacterIntent](`collapseSelection: true`) | Collapses the selection to the logical start/end of the selection | Moves the caret past the user-perceived character before or after the current caret location. | +/// | [ExtendSelectionToNextWordBoundaryIntent](`collapseSelection: true`) | Collapses the selection to the word boundary before/after the selection's [TextSelection.extent] position | Moves the caret to the previous/next word boundary. | +/// | [ExtendSelectionToNextWordBoundaryOrCaretLocationIntent](`collapseSelection: true`) | Collapses the selection to the word boundary before/after the selection's [TextSelection.extent] position, or [TextSelection.base], whichever is closest in the given direction | Moves the caret to the previous/next word boundary. | +/// | [ExtendSelectionToLineBreakIntent](`collapseSelection: true`) | Collapses the selection to the start/end of the line at the selection's [TextSelection.extent] position | Moves the caret to the start/end of the current line .| +/// | [ExtendSelectionVerticallyToAdjacentLineIntent](`collapseSelection: true`) | Collapses the selection to the position closest to the selection's [TextSelection.extent], on the previous/next adjacent line | Moves the caret to the closest position on the previous/next adjacent line. | +/// | [ExtendSelectionToDocumentBoundaryIntent](`collapseSelection: true`) | Collapses the selection to the start/end of the document | Moves the caret to the start/end of the document. | +/// +/// #### Intents for Extending the Selection +/// +/// | **Intent Class** | **Default Behavior when there's selected text** | **Default Behavior when there is a caret ([TextSelection.collapsed])** | +/// | :----------------------------------------------------------------------------------- | :--------------------------------------------------------------- | :---------------------------------------------------------------------- | +/// | [ExtendSelectionByCharacterIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] past the user-perceived character before/after it | +/// | [ExtendSelectionToNextWordBoundaryIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the previous/next word boundary | +/// | [ExtendSelectionToNextWordBoundaryOrCaretLocationIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the previous/next word boundary, or [TextSelection.base] whichever is closest in the given direction | Moves the selection's [TextSelection.extent] to the previous/next word boundary. | +/// | [ExtendSelectionToLineBreakIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the start/end of the line | +/// | [ExtendSelectionVerticallyToAdjacentLineIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the closest position on the previous/next adjacent line | +/// | [ExtendSelectionToDocumentBoundaryIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the start/end of the document | +/// | [SelectAllTextIntent] | Selects the entire document | +/// +/// ### Other Intents +/// +/// | **Intent Class** | **Default Behavior** | +/// | :-------------------------------------- | :--------------------------------------------------- | +/// | [DoNothingAndStopPropagationTextIntent] | Does nothing in the input field, and prevents the key event from further propagating in the widget tree. | +/// | [ReplaceTextIntent] | Replaces the current [TextEditingValue] in the input field's [TextEditingController], and triggers all related user callbacks and [TextInputFormatter]s. | +/// | [UpdateSelectionIntent] | Updates the current selection in the input field's [TextEditingController], and triggers the [onSelectionChanged] callback. | +/// | [CopySelectionTextIntent] | Copies or cuts the selected text into the clipboard | +/// | [PasteTextIntent] | Inserts the current text in the clipboard after the caret location, or replaces the selected text if the selection is not collapsed. | +/// /// ## Gesture Events Handling /// /// This widget provides rudimentary, platform-agnostic gesture handling for @@ -1453,7 +1511,7 @@ class EditableText extends StatefulWidget { } /// State for a [EditableText]. -class EditableTextState extends State with AutomaticKeepAliveClientMixin, WidgetsBindingObserver, TickerProviderStateMixin, TextSelectionDelegate, TextEditingActionTarget implements TextInputClient, AutofillClient { +class EditableTextState extends State with AutomaticKeepAliveClientMixin, WidgetsBindingObserver, TickerProviderStateMixin, TextSelectionDelegate implements TextInputClient, AutofillClient { Timer? _cursorTimer; bool _targetCursorVisibility = false; final ValueNotifier _cursorVisibilityNotifier = ValueNotifier(true); @@ -1476,7 +1534,6 @@ class EditableTextState extends State with AutomaticKeepAliveClien final LayerLink _endHandleLayerLink = LayerLink(); bool _didAutoFocus = false; - FocusAttachment? _focusAttachment; AutofillGroupState? _currentAutofillScope; @override @@ -1534,62 +1591,24 @@ class EditableTextState extends State with AutomaticKeepAliveClien }); } - // Start TextEditingActionTarget. - - @override - TextLayoutMetrics get textLayoutMetrics => renderEditable; - - @override - bool get readOnly => widget.readOnly; - - @override - bool get obscureText => widget.obscureText; - - @override - bool get selectionEnabled => widget.selectionEnabled; - - @override - void debugAssertLayoutUpToDate() => renderEditable.debugAssertLayoutUpToDate(); - - /// {@macro flutter.widgets.TextEditingActionTarget.setSelection} - @override - void setSelection(TextSelection nextSelection, SelectionChangedCause cause) { - if (nextSelection == textEditingValue.selection) { - return; + TextEditingValue get _textEditingValueforTextLayoutMetrics { + final Widget? editableWidget =_editableKey.currentContext?.widget; + if (editableWidget is! _Editable) { + throw StateError('_Editable must be mounted.'); } - if (nextSelection.isValid) { - // The nextSelection is calculated based on _plainText, which can be out - // of sync with the textSelectionDelegate.textEditingValue by one frame. - // This is due to the render editable and editable text handle pointer - // event separately. If the editable text changes the text during the - // event handler, the render editable will use the outdated text stored in - // the _plainText when handling the pointer event. - // - // If this happens, we need to make sure the new selection is still valid. - final int textLength = textEditingValue.text.length; - nextSelection = nextSelection.copyWith( - baseOffset: math.min(nextSelection.baseOffset, textLength), - extentOffset: math.min(nextSelection.extentOffset, textLength), - ); - } - _handleSelectionChange(nextSelection, cause); - return super.setSelection(nextSelection, cause); + return editableWidget.value; } - /// {@macro flutter.widgets.TextEditingActionTarget.setTextEditingValue} - @override - void setTextEditingValue(TextEditingValue newValue, SelectionChangedCause cause) { - if (newValue == textEditingValue) { - return; - } - textEditingValue = newValue; - userUpdateTextEditingValue(newValue, cause); - } - - /// {@macro flutter.widgets.TextEditingActionTarget.copySelection} + /// Copy current selection to [Clipboard]. @override void copySelection(SelectionChangedCause cause) { - super.copySelection(cause); + final TextSelection selection = textEditingValue.selection; + final String text = textEditingValue.text; + assert(selection != null); + if (selection.isCollapsed) { + return; + } + Clipboard.setData(ClipboardData(text: selection.textInside(text))); if (cause == SelectionChangedCause.toolbar) { bringIntoView(textEditingValue.selection.extent); hideToolbar(false); @@ -1615,20 +1634,45 @@ class EditableTextState extends State with AutomaticKeepAliveClien } } - /// {@macro flutter.widgets.TextEditingActionTarget.cutSelection} + /// Cut current selection to [Clipboard]. @override void cutSelection(SelectionChangedCause cause) { - super.cutSelection(cause); + if (widget.readOnly) { + return; + } + final TextSelection selection = textEditingValue.selection; + final String text = textEditingValue.text; + assert(selection != null); + if (selection.isCollapsed) { + return; + } + Clipboard.setData(ClipboardData(text: selection.textInside(text))); + _replaceText(ReplaceTextIntent(textEditingValue, '', selection, cause)); if (cause == SelectionChangedCause.toolbar) { bringIntoView(textEditingValue.selection.extent); hideToolbar(); } } - /// {@macro flutter.widgets.TextEditingActionTarget.pasteText} + /// Paste text from [Clipboard]. @override Future pasteText(SelectionChangedCause cause) async { - super.pasteText(cause); + if (widget.readOnly) { + return; + } + final TextSelection selection = textEditingValue.selection; + assert(selection != null); + if (!selection.isValid) { + return; + } + // Snapshot the input before using `await`. + // See https://github.com/flutter/flutter/issues/11427 + final ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain); + if (data == null) { + return; + } + + _replaceText(ReplaceTextIntent(textEditingValue, data.text!, selection, cause)); if (cause == SelectionChangedCause.toolbar) { bringIntoView(textEditingValue.selection.extent); hideToolbar(); @@ -1638,29 +1682,17 @@ class EditableTextState extends State with AutomaticKeepAliveClien /// Select the entire text value. @override void selectAll(SelectionChangedCause cause) { - super.selectAll(cause); + userUpdateTextEditingValue( + textEditingValue.copyWith( + selection: TextSelection(baseOffset: 0, extentOffset: textEditingValue.text.length), + ), + cause, + ); if (cause == SelectionChangedCause.toolbar) { bringIntoView(textEditingValue.selection.extent); } } - // End TextEditingActionTarget. - - void _handleSelectionChange( - TextSelection nextSelection, - SelectionChangedCause cause, - ) { - // Changes made by the keyboard can sometimes be "out of band" for listening - // components, so always send those events, even if we didn't think it - // changed. Also, focusing an empty field is sent as a selection change even - // if the selection offset didn't change. - final bool focusingEmpty = nextSelection.baseOffset == 0 && nextSelection.extentOffset == 0 && !_hasFocus; - if (nextSelection == textEditingValue.selection && cause != SelectionChangedCause.keyboard && !focusingEmpty) { - return; - } - widget.onSelectionChanged?.call(nextSelection, cause); - } - // State lifecycle: @override @@ -1668,7 +1700,6 @@ class EditableTextState extends State with AutomaticKeepAliveClien super.initState(); _clipboardStatus?.addListener(_onChangedClipboardStatus); widget.controller.addListener(_didChangeTextEditingValue); - _focusAttachment = widget.focusNode.attach(context); widget.focusNode.addListener(_handleFocusChanged); _scrollController.addListener(_updateSelectionOverlayForScroll); _cursorVisibilityNotifier.value = widget.showCursor; @@ -1732,8 +1763,6 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (widget.focusNode != oldWidget.focusNode) { oldWidget.focusNode.removeListener(_handleFocusChanged); - _focusAttachment?.detach(); - _focusAttachment = widget.focusNode.attach(context); widget.focusNode.addListener(_handleFocusChanged); updateKeepAlive(); } @@ -1787,7 +1816,6 @@ class EditableTextState extends State with AutomaticKeepAliveClien _cursorBlinkOpacityController.dispose(); _selectionOverlay?.dispose(); _selectionOverlay = null; - _focusAttachment!.detach(); widget.focusNode.removeListener(_handleFocusChanged); WidgetsBinding.instance!.removeObserver(this); _clipboardStatus?.removeListener(_onChangedClipboardStatus); @@ -2603,6 +2631,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien // TODO(abarth): Teach RenderEditable about ValueNotifier // to avoid this setState(). setState(() { /* We use widget.controller.value in build(). */ }); + _adjacentLineAction.stopCurrentVerticalRunIfSelectionChanges(); } void _handleFocusChanged() { @@ -2818,87 +2847,196 @@ class EditableTextState extends State with AutomaticKeepAliveClien : null; } + + // --------------------------- Text Editing Actions --------------------------- + + _TextBoundary _characterBoundary(DirectionalTextEditingIntent intent) { + final _TextBoundary atomicTextBoundary = widget.obscureText ? _CodeUnitBoundary(_value) : _CharacterBoundary(_value); + return _CollapsedSelectionBoundary(atomicTextBoundary, intent.forward); + } + + _TextBoundary _nextWordBoundary(DirectionalTextEditingIntent intent) { + final _TextBoundary atomicTextBoundary; + final _TextBoundary boundary; + + if (widget.obscureText) { + atomicTextBoundary = _CodeUnitBoundary(_value); + boundary = _DocumentBoundary(_value); + } else { + final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics; + atomicTextBoundary = _CharacterBoundary(textEditingValue); + // This isn't enough. Newline characters. + boundary = _ExpandedTextBoundary(_WhitespaceBoundary(textEditingValue), _WordBoundary(renderEditable, textEditingValue)); + } + + final _MixedBoundary mixedBoundary = intent.forward + ? _MixedBoundary(atomicTextBoundary, boundary) + : _MixedBoundary(boundary, atomicTextBoundary); + // Use a _MixedBoundary to make sure we don't leave invalid codepoints in + // the field after deletion. + return _CollapsedSelectionBoundary(mixedBoundary, intent.forward); + } + + _TextBoundary _linebreak(DirectionalTextEditingIntent intent) { + final _TextBoundary atomicTextBoundary; + final _TextBoundary boundary; + + if (widget.obscureText) { + atomicTextBoundary = _CodeUnitBoundary(_value); + boundary = _DocumentBoundary(_value); + } else { + final TextEditingValue textEditingValue = _textEditingValueforTextLayoutMetrics; + atomicTextBoundary = _CharacterBoundary(textEditingValue); + boundary = _LineBreak(renderEditable, textEditingValue); + } + + // The _MixedBoundary is to make sure we don't leave invalid code units in + // the field after deletion. + // `boundary` doesn't need to be wrapped in a _CollapsedSelectionBoundary, + // since the document boundary is unique and the linebreak boundary is + // already caret-location based. + return intent.forward + ? _MixedBoundary(_CollapsedSelectionBoundary(atomicTextBoundary, true), boundary) + : _MixedBoundary(boundary, _CollapsedSelectionBoundary(atomicTextBoundary, false)); + } + + _TextBoundary _documentBoundary(DirectionalTextEditingIntent intent) => _DocumentBoundary(_value); + + Action _makeOverridable(Action defaultAction) { + return Action.overridable(context: context, defaultAction: defaultAction); + } + + void _replaceText(ReplaceTextIntent intent) { + userUpdateTextEditingValue( + intent.currentTextEditingValue.replaced(intent.replacementRange, intent.replacementText), + intent.cause, + ); + } + late final Action _replaceTextAction = CallbackAction(onInvoke: _replaceText); + + void _updateSelection(UpdateSelectionIntent intent) { + userUpdateTextEditingValue( + intent.currentTextEditingValue.copyWith(selection: intent.newSelection), + intent.cause, + ); + } + late final Action _updateSelectionAction = CallbackAction(onInvoke: _updateSelection); + + late final _UpdateTextSelectionToAdjacentLineAction _adjacentLineAction = _UpdateTextSelectionToAdjacentLineAction(this); + + late final Map> _actions = >{ + DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false), + ReplaceTextIntent: _replaceTextAction, + UpdateSelectionIntent: _updateSelectionAction, + DirectionalFocusIntent: DirectionalFocusAction.forTextField(), + + // Delete + DeleteCharacterIntent: _makeOverridable(_DeleteTextAction(this, _characterBoundary)), + DeleteToNextWordBoundaryIntent: _makeOverridable(_DeleteTextAction(this, _nextWordBoundary)), + DeleteToLineBreakIntent: _makeOverridable(_DeleteTextAction(this, _linebreak)), + + // Extend/Move Selection + ExtendSelectionByCharacterIntent: _makeOverridable(_UpdateTextSelectionAction(this, false, _characterBoundary,)), + ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction(this, true, _nextWordBoundary)), + ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction(this, true, _linebreak)), + ExtendSelectionVerticallyToAdjacentLineIntent: _makeOverridable(_adjacentLineAction), + ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction(this, true, _documentBoundary)), + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(_ExtendSelectionOrCaretPositionAction(this, _nextWordBoundary)), + + // Copy Paste + SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), + CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)), + PasteTextIntent: _makeOverridable(CallbackAction(onInvoke: (PasteTextIntent intent) => pasteText(intent.cause))), + }; + @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); - _focusAttachment!.reparent(); super.build(context); // See AutomaticKeepAliveClientMixin. final TextSelectionControls? controls = widget.selectionControls; return MouseRegion( cursor: widget.mouseCursor ?? SystemMouseCursors.text, - child: Scrollable( - excludeFromSemantics: true, - axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right, - controller: _scrollController, - physics: widget.scrollPhysics, - dragStartBehavior: widget.dragStartBehavior, - restorationId: widget.restorationId, - // If a ScrollBehavior is not provided, only apply scrollbars when - // multiline. The overscroll indicator should not be applied in - // either case, glowing or stretching. - scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith( - scrollbars: _isMultiline, - overscroll: false, - ), - viewportBuilder: (BuildContext context, ViewportOffset offset) { - return CompositedTransformTarget( - link: _toolbarLayerLink, - child: Semantics( - onCopy: _semanticsOnCopy(controls), - onCut: _semanticsOnCut(controls), - onPaste: _semanticsOnPaste(controls), - child: _Editable( - key: _editableKey, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - inlineSpan: buildTextSpan(), - value: _value, - cursorColor: _cursorColor, - backgroundCursorColor: widget.backgroundCursorColor, - showCursor: EditableText.debugDeterministicCursor - ? ValueNotifier(widget.showCursor) - : _cursorVisibilityNotifier, - forceLine: widget.forceLine, - readOnly: widget.readOnly, - hasFocus: _hasFocus, - maxLines: widget.maxLines, - minLines: widget.minLines, - expands: widget.expands, - strutStyle: widget.strutStyle, - selectionColor: widget.selectionColor, - textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context), - textAlign: widget.textAlign, - textDirection: _textDirection, - locale: widget.locale, - textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.of(context), - textWidthBasis: widget.textWidthBasis, - obscuringCharacter: widget.obscuringCharacter, - obscureText: widget.obscureText, - autocorrect: widget.autocorrect, - smartDashesType: widget.smartDashesType, - smartQuotesType: widget.smartQuotesType, - enableSuggestions: widget.enableSuggestions, - offset: offset, - onCaretChanged: _handleCaretChanged, - rendererIgnoresPointer: widget.rendererIgnoresPointer, - cursorWidth: widget.cursorWidth, - cursorHeight: widget.cursorHeight, - cursorRadius: widget.cursorRadius, - cursorOffset: widget.cursorOffset ?? Offset.zero, - selectionHeightStyle: widget.selectionHeightStyle, - selectionWidthStyle: widget.selectionWidthStyle, - paintCursorAboveText: widget.paintCursorAboveText, - enableInteractiveSelection: widget.enableInteractiveSelection, - textSelectionDelegate: this, - devicePixelRatio: _devicePixelRatio, - promptRectRange: _currentPromptRectRange, - promptRectColor: widget.autocorrectionTextRectColor, - clipBehavior: widget.clipBehavior, - ), + child: Actions( + actions: _actions, + child: Focus( + focusNode: widget.focusNode, + includeSemantics: false, + debugLabel: 'EditableText', + child: Scrollable( + excludeFromSemantics: true, + axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right, + controller: _scrollController, + physics: widget.scrollPhysics, + dragStartBehavior: widget.dragStartBehavior, + restorationId: widget.restorationId, + // If a ScrollBehavior is not provided, only apply scrollbars when + // multiline. The overscroll indicator should not be applied in + // either case, glowing or stretching. + scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith( + scrollbars: _isMultiline, + overscroll: false, ), - ); - }, + viewportBuilder: (BuildContext context, ViewportOffset offset) { + return CompositedTransformTarget( + link: _toolbarLayerLink, + child: Semantics( + onCopy: _semanticsOnCopy(controls), + onCut: _semanticsOnCut(controls), + onPaste: _semanticsOnPaste(controls), + child: _Editable( + key: _editableKey, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + inlineSpan: buildTextSpan(), + value: _value, + cursorColor: _cursorColor, + backgroundCursorColor: widget.backgroundCursorColor, + showCursor: EditableText.debugDeterministicCursor + ? ValueNotifier(widget.showCursor) + : _cursorVisibilityNotifier, + forceLine: widget.forceLine, + readOnly: widget.readOnly, + hasFocus: _hasFocus, + maxLines: widget.maxLines, + minLines: widget.minLines, + expands: widget.expands, + strutStyle: widget.strutStyle, + selectionColor: widget.selectionColor, + textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context), + textAlign: widget.textAlign, + textDirection: _textDirection, + locale: widget.locale, + textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.of(context), + textWidthBasis: widget.textWidthBasis, + obscuringCharacter: widget.obscuringCharacter, + obscureText: widget.obscureText, + autocorrect: widget.autocorrect, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, + enableSuggestions: widget.enableSuggestions, + offset: offset, + onCaretChanged: _handleCaretChanged, + rendererIgnoresPointer: widget.rendererIgnoresPointer, + cursorWidth: widget.cursorWidth, + cursorHeight: widget.cursorHeight, + cursorRadius: widget.cursorRadius, + cursorOffset: widget.cursorOffset ?? Offset.zero, + selectionHeightStyle: widget.selectionHeightStyle, + selectionWidthStyle: widget.selectionWidthStyle, + paintCursorAboveText: widget.paintCursorAboveText, + enableInteractiveSelection: widget.enableInteractiveSelection, + textSelectionDelegate: this, + devicePixelRatio: _devicePixelRatio, + promptRectRange: _currentPromptRectRange, + promptRectColor: widget.autocorrectionTextRectColor, + clipBehavior: widget.clipBehavior, + ), + ), + ); + }, + ), + ), ), ); } @@ -3125,3 +3263,536 @@ class _Editable extends MultiChildRenderObjectWidget { ..setPromptRectRange(promptRectRange); } } + +/// An interface for retriving the logical text boundary (left-closed-right-open) +/// at a given location in a document. +/// +/// Depending on the implementation of the [_TextBoundary], the input +/// [TextPosition] can either point to a code unit, or a position between 2 code +/// units (which can be visually represented by the caret if the selection were +/// to collapse to that position). +/// +/// For example, [_LineBreak] interprets the input [TextPosition] as a caret +/// location, since in Flutter the caret is generally painted between the +/// character the [TextPosition] points to and its previous character, and +/// [_LineBreak] cares about the affinity of the input [TextPosition]. Most +/// other text boundaries however, interpret the input [TextPosition] as the +/// location of a code unit in the document, since it's easier to reason about +/// the text boundary given a code unit in the text. +/// +/// To convert a "code-unit-based" [_TextBoundary] to "caret-location-based", +/// use the [_CollapsedSelectionBoundary] combinator. +abstract class _TextBoundary { + const _TextBoundary(); + + TextEditingValue get textEditingValue; + + /// Returns the leading text boundary at the given location, inclusive. + TextPosition getLeadingTextBoundaryAt(TextPosition position); + + /// Returns the trailing text boundary at the given location, exclusive. + TextPosition getTrailingTextBoundaryAt(TextPosition position); + + TextRange getTextBoundaryAt(TextPosition position) { + return TextRange( + start: getLeadingTextBoundaryAt(position).offset, + end: getTrailingTextBoundaryAt(position).offset, + ); + } +} + +// ----------------------------- Text Boundaries ----------------------------- + +class _CodeUnitBoundary extends _TextBoundary { + const _CodeUnitBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) => TextPosition(offset: position.offset); + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) => TextPosition(offset: math.min(position.offset + 1, textEditingValue.text.length)); +} + +// The word modifier generally removes the word boundaries around white spaces +// (and newlines), IOW white spaces and some other punctuations are considered +// a part of the next word in the search direction. +class _WhitespaceBoundary extends _TextBoundary { + const _WhitespaceBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + for (int index = position.offset; index >= 0; index -= 1) { + if (!TextLayoutMetrics.isWhitespace(textEditingValue.text.codeUnitAt(index))) { + return TextPosition(offset: index); + } + } + return const TextPosition(offset: 0); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + for (int index = position.offset; index < textEditingValue.text.length; index += 1) { + if (!TextLayoutMetrics.isWhitespace(textEditingValue.text.codeUnitAt(index))) { + return TextPosition(offset: index + 1); + } + } + return TextPosition(offset: textEditingValue.text.length); + } +} + +// Most apps delete the entire grapheme when the backspace key is pressed. +// Also always put the new caret location to character boundaries to avoid +// sending malformed UTF-16 code units to the paragraph builder. +class _CharacterBoundary extends _TextBoundary { + const _CharacterBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + final int endOffset = math.min(position.offset + 1, textEditingValue.text.length); + return TextPosition( + offset: CharacterRange.at(textEditingValue.text, position.offset, endOffset).stringBeforeLength, + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + final int endOffset = math.min(position.offset + 1, textEditingValue.text.length); + final CharacterRange range = CharacterRange.at(textEditingValue.text, position.offset, endOffset); + return TextPosition( + offset: textEditingValue.text.length - range.stringAfterLength, + ); + } + + @override + TextRange getTextBoundaryAt(TextPosition position) { + final int endOffset = math.min(position.offset + 1, textEditingValue.text.length); + final CharacterRange range = CharacterRange.at(textEditingValue.text, position.offset, endOffset); + return TextRange( + start: range.stringBeforeLength, + end: textEditingValue.text.length - range.stringAfterLength, + ); + } +} + +// [UAX #29](https://unicode.org/reports/tr29/) defined word boundaries. +class _WordBoundary extends _TextBoundary { + const _WordBoundary(this.textLayout, this.textEditingValue); + + final TextLayoutMetrics textLayout; + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getWordBoundary(position).start, + // Word boundary seems to always report downstream on many platforms. + affinity: TextAffinity.downstream, // ignore: avoid_redundant_argument_values + ); + } + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getWordBoundary(position).end, + // Word boundary seems to always report downstream on many platforms. + affinity: TextAffinity.downstream, // ignore: avoid_redundant_argument_values + ); + } +} + +// The linebreaks of the current text layout. The input [TextPosition]s are +// interpreted as caret locations because [TextPainter.getLineAtOffset] is +// text-affinity-aware. +class _LineBreak extends _TextBoundary { + const _LineBreak(this.textLayout, this.textEditingValue); + + final TextLayoutMetrics textLayout; + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getLineAtOffset(position).start, + ); + } + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textLayout.getLineAtOffset(position).end, + affinity: TextAffinity.upstream, + ); + } +} + +// The document boundary is unique and is a constant function of the input +// position. +class _DocumentBoundary extends _TextBoundary { + const _DocumentBoundary(this.textEditingValue); + + @override + final TextEditingValue textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) => const TextPosition(offset: 0); + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return TextPosition( + offset: textEditingValue.text.length, + affinity: TextAffinity.upstream, + ); + } +} + +// ------------------------ Text Boundary Combinators ------------------------ + +// Expands the innerTextBoundary with outerTextBoundary. +class _ExpandedTextBoundary extends _TextBoundary { + _ExpandedTextBoundary(this.innerTextBoundary, this.outerTextBoundary); + + final _TextBoundary innerTextBoundary; + final _TextBoundary outerTextBoundary; + + @override + TextEditingValue get textEditingValue { + assert(innerTextBoundary.textEditingValue == outerTextBoundary.textEditingValue); + return innerTextBoundary.textEditingValue; + } + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return outerTextBoundary.getLeadingTextBoundaryAt( + innerTextBoundary.getLeadingTextBoundaryAt(position), + ); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return outerTextBoundary.getTrailingTextBoundaryAt( + innerTextBoundary.getTrailingTextBoundaryAt(position), + ); + } +} + +// Force the innerTextBoundary to interpret the input [TextPosition]s as caret +// locations instead of code unit positions. +// +// The innerTextBoundary must be a [_TextBoundary] that interprets the input +// [TextPosition]s as code unit positions. +class _CollapsedSelectionBoundary extends _TextBoundary { + _CollapsedSelectionBoundary(this.innerTextBoundary, this.isForward); + + final _TextBoundary innerTextBoundary; + final bool isForward; + + @override + TextEditingValue get textEditingValue => innerTextBoundary.textEditingValue; + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) { + return isForward + ? innerTextBoundary.getLeadingTextBoundaryAt(position) + : position.offset <= 0 ? const TextPosition(offset: 0) : innerTextBoundary.getLeadingTextBoundaryAt(TextPosition(offset: position.offset - 1)); + } + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) { + return isForward + ? innerTextBoundary.getTrailingTextBoundaryAt(position) + : position.offset <= 0 ? const TextPosition(offset: 0) : innerTextBoundary.getTrailingTextBoundaryAt(TextPosition(offset: position.offset - 1)); + } +} + +// A _TextBoundary that creates a [TextRange] where its start is from the +// specified leading text boundary and its end is from the specified trailing +// text boundary. +class _MixedBoundary extends _TextBoundary { + _MixedBoundary(this.leadingTextBoundary, this.trailingTextBoundary); + + final _TextBoundary leadingTextBoundary; + final _TextBoundary trailingTextBoundary; + + @override + TextEditingValue get textEditingValue { + assert(leadingTextBoundary.textEditingValue == trailingTextBoundary.textEditingValue); + return leadingTextBoundary.textEditingValue; + } + + @override + TextPosition getLeadingTextBoundaryAt(TextPosition position) => leadingTextBoundary.getLeadingTextBoundaryAt(position); + + @override + TextPosition getTrailingTextBoundaryAt(TextPosition position) => trailingTextBoundary.getTrailingTextBoundaryAt(position); +} + +// ------------------------------- Text Actions ------------------------------- +class _DeleteTextAction extends ContextAction { + _DeleteTextAction(this.state, this.getTextBoundariesForIntent); + + final EditableTextState state; + final _TextBoundary Function(T intent) getTextBoundariesForIntent; + + TextRange _expandNonCollapsedRange(TextEditingValue value) { + final TextRange selection = value.selection; + assert(selection.isValid); + assert(!selection.isCollapsed); + final _TextBoundary atomicBoundary = state.widget.obscureText + ? _CodeUnitBoundary(value) + : _CharacterBoundary(value); + + return TextRange( + start: atomicBoundary.getLeadingTextBoundaryAt(TextPosition(offset: selection.start)).offset, + end: atomicBoundary.getTrailingTextBoundaryAt(TextPosition(offset: selection.end - 1)).offset, + ); + } + + @override + Object? invoke(T intent, [BuildContext? context]) { + final TextSelection selection = state._value.selection; + assert(selection.isValid); + + if (!selection.isCollapsed) { + return Actions.invoke( + context!, + ReplaceTextIntent(state._value, '', _expandNonCollapsedRange(state._value), SelectionChangedCause.keyboard), + ); + } + + final _TextBoundary textBoundary = getTextBoundariesForIntent(intent); + if (!textBoundary.textEditingValue.selection.isValid) { + return null; + } + if (!textBoundary.textEditingValue.selection.isCollapsed) { + return Actions.invoke( + context!, + ReplaceTextIntent(state._value, '', _expandNonCollapsedRange(textBoundary.textEditingValue), SelectionChangedCause.keyboard), + ); + } + + return Actions.invoke( + context!, + ReplaceTextIntent( + textBoundary.textEditingValue, + '', + textBoundary.getTextBoundaryAt(textBoundary.textEditingValue.selection.base), + SelectionChangedCause.keyboard, + ), + ); + } + + @override + bool get isActionEnabled => !state.widget.readOnly && state._value.selection.isValid; +} + +class _UpdateTextSelectionAction extends ContextAction { + _UpdateTextSelectionAction(this.state, this.ignoreNonCollapsedSelection, this.getTextBoundariesForIntent); + + final EditableTextState state; + final bool ignoreNonCollapsedSelection; + final _TextBoundary Function(T intent) getTextBoundariesForIntent; + + @override + Object? invoke(T intent, [BuildContext? context]) { + final TextSelection selection = state._value.selection; + assert(selection.isValid); + + final bool collapseSelection = intent.collapseSelection || !state.widget.selectionEnabled; + // Collapse to the logical start/end. + TextSelection _collapse(TextSelection selection) { + assert(selection.isValid); + assert(!selection.isCollapsed); + return selection.copyWith( + baseOffset: intent.forward ? selection.end : selection.start, + extentOffset: intent.forward ? selection.end : selection.start, + ); + } + + if (!selection.isCollapsed && !ignoreNonCollapsedSelection && collapseSelection) { + return Actions.invoke( + context!, + UpdateSelectionIntent(state._value, _collapse(selection), SelectionChangedCause.keyboard), + ); + } + + final _TextBoundary textBoundary = getTextBoundariesForIntent(intent); + final TextSelection textBoundarySelection = textBoundary.textEditingValue.selection; + if (!textBoundarySelection.isValid) { + return null; + } + if (!textBoundarySelection.isCollapsed && !ignoreNonCollapsedSelection && collapseSelection) { + return Actions.invoke( + context!, + UpdateSelectionIntent(state._value, _collapse(textBoundarySelection), SelectionChangedCause.keyboard), + ); + } + final TextPosition extent = textBoundarySelection.extent; + final TextPosition newExtent = intent.forward + ? textBoundary.getTrailingTextBoundaryAt(extent) + : textBoundary.getLeadingTextBoundaryAt(extent); + + final TextSelection newSelection = collapseSelection + ? TextSelection.fromPosition(newExtent) + : textBoundarySelection.extendTo(newExtent); + + return Actions.invoke( + context!, + UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, SelectionChangedCause.keyboard), + ); + } + + @override + bool get isActionEnabled => state._value.selection.isValid; +} + +class _ExtendSelectionOrCaretPositionAction extends ContextAction { + _ExtendSelectionOrCaretPositionAction(this.state, this.getTextBoundariesForIntent); + + final EditableTextState state; + final _TextBoundary Function(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent) getTextBoundariesForIntent; + + @override + Object? invoke(ExtendSelectionToNextWordBoundaryOrCaretLocationIntent intent, [BuildContext? context]) { + final TextSelection selection = state._value.selection; + assert(selection.isValid); + + final _TextBoundary textBoundary = getTextBoundariesForIntent(intent); + final TextSelection textBoundarySelection = textBoundary.textEditingValue.selection; + if (!textBoundarySelection.isValid) { + return null; + } + + final TextPosition extent = textBoundarySelection.extent; + final TextPosition newExtent = intent.forward + ? textBoundary.getTrailingTextBoundaryAt(extent) + : textBoundary.getLeadingTextBoundaryAt(extent); + + final TextSelection newSelection = (newExtent.offset - textBoundarySelection.baseOffset) * (textBoundarySelection.extentOffset - textBoundarySelection.baseOffset) < 0 + ? textBoundarySelection.copyWith( + extentOffset: textBoundarySelection.baseOffset, + affinity: textBoundarySelection.extentOffset > textBoundarySelection.baseOffset ? TextAffinity.downstream : TextAffinity.upstream, + ) + : textBoundarySelection.extendTo(newExtent); + + return Actions.invoke( + context!, + UpdateSelectionIntent(textBoundary.textEditingValue, newSelection, SelectionChangedCause.keyboard), + ); + } + + @override + bool get isActionEnabled => state.widget.selectionEnabled && state._value.selection.isValid; +} + +class _UpdateTextSelectionToAdjacentLineAction extends ContextAction { + _UpdateTextSelectionToAdjacentLineAction(this.state); + + final EditableTextState state; + + VerticalCaretMovementRun? _verticalMovementRun; + TextSelection? _runSelection; + + void stopCurrentVerticalRunIfSelectionChanges() { + final TextSelection? runSelection = _runSelection; + if (runSelection == null) { + assert(_verticalMovementRun == null); + return; + } + _runSelection = state._value.selection; + final TextSelection currentSelection = state.widget.controller.selection; + final bool continueCurrentRun = currentSelection.isValid && currentSelection.isCollapsed + && currentSelection.baseOffset == runSelection.baseOffset + && currentSelection.extentOffset == runSelection.extentOffset; + if (!continueCurrentRun) { + _verticalMovementRun = null; + _runSelection = null; + } + } + + @override + Object? invoke(T intent, [BuildContext? context]) { + assert(state._value.selection.isValid); + + final bool collapseSelection = intent.collapseSelection || !state.widget.selectionEnabled; + final TextEditingValue value = state._textEditingValueforTextLayoutMetrics; + if (!value.selection.isValid) { + return null; + } + + if (_verticalMovementRun?.isValid == false) { + _verticalMovementRun = null; + _runSelection = null; + } + + final VerticalCaretMovementRun currentRun = _verticalMovementRun + ?? state.renderEditable.startVerticalCaretMovement(state.renderEditable.selection!.extent); + + final bool shouldMove = intent.forward ? currentRun.moveNext() : currentRun.movePrevious(); + final TextPosition newExtent = shouldMove + ? currentRun.current + : (intent.forward ? TextPosition(offset: state._value.text.length) : const TextPosition(offset: 0)); + final TextSelection newSelection = collapseSelection + ? TextSelection.fromPosition(newExtent) + : value.selection.extendTo(newExtent); + + Actions.invoke( + context!, + UpdateSelectionIntent(value, newSelection, SelectionChangedCause.keyboard), + ); + if (state._value.selection == newSelection) { + _verticalMovementRun = currentRun; + _runSelection = newSelection; + } + } + + @override + bool get isActionEnabled => state._value.selection.isValid; +} + +class _SelectAllAction extends ContextAction { + _SelectAllAction(this.state); + + final EditableTextState state; + + @override + Object? invoke(SelectAllTextIntent intent, [BuildContext? context]) { + return Actions.invoke( + context!, + UpdateSelectionIntent( + state._value, + TextSelection(baseOffset: 0, extentOffset: state._value.text.length), + intent.cause, + ), + ); + } + + @override + bool get isActionEnabled => state.widget.selectionEnabled; +} + +class _CopySelectionAction extends ContextAction { + _CopySelectionAction(this.state); + + final EditableTextState state; + + @override + Object? invoke(CopySelectionTextIntent intent, [BuildContext? context]) { + if (intent.collapseSelection) { + state.cutSelection(intent.cause); + } else { + state.copySelection(intent.cause); + } + } + + @override + bool get isActionEnabled => state._value.selection.isValid && !state._value.selection.isCollapsed; +} diff --git a/packages/flutter/lib/src/widgets/focus_traversal.dart b/packages/flutter/lib/src/widgets/focus_traversal.dart index a1407568b36..9cddb98770f 100644 --- a/packages/flutter/lib/src/widgets/focus_traversal.dart +++ b/packages/flutter/lib/src/widgets/focus_traversal.dart @@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart'; import 'actions.dart'; import 'basic.dart'; -import 'editable_text.dart'; import 'focus_manager.dart'; import 'focus_scope.dart'; import 'framework.dart'; @@ -1722,9 +1721,18 @@ class DirectionalFocusIntent extends Intent { /// [LogicalKeyboardKey.arrowLeft], and [LogicalKeyboardKey.arrowRight] keys in /// the [WidgetsApp], with the appropriate associated directions. class DirectionalFocusAction extends Action { + /// Creates a [DirectionalFocusAction]. + DirectionalFocusAction() : _isForTextField = false; + + /// Creates a [DirectionalFocusAction] that ignores [DirectionalFocusIntent]s + /// whose `ignoreTextFields` field is true. + DirectionalFocusAction.forTextField() : _isForTextField = true; + + // Whether this action is defined in a text field. + final bool _isForTextField; @override void invoke(DirectionalFocusIntent intent) { - if (!intent.ignoreTextFields || primaryFocus!.context!.widget is! EditableText) { + if (!intent.ignoreTextFields || !_isForTextField) { primaryFocus!.focusInDirection(intent.direction); } } diff --git a/packages/flutter/lib/src/widgets/text_editing_action.dart b/packages/flutter/lib/src/widgets/text_editing_action.dart deleted file mode 100644 index b0340e43d36..00000000000 --- a/packages/flutter/lib/src/widgets/text_editing_action.dart +++ /dev/null @@ -1,48 +0,0 @@ -// 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'; -import 'focus_manager.dart'; -import 'framework.dart'; -import 'text_editing_action_target.dart'; - -/// 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. - return textEditingActionTarget != null; - } -} diff --git a/packages/flutter/lib/src/widgets/text_editing_intents.dart b/packages/flutter/lib/src/widgets/text_editing_intents.dart index 86aff682132..755858f4524 100644 --- a/packages/flutter/lib/src/widgets/text_editing_intents.dart +++ b/packages/flutter/lib/src/widgets/text_editing_intents.dart @@ -2,299 +2,226 @@ // 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 'actions.dart'; -/// An [Intent] to delete a character in the backwards direction. +/// An [Intent] to send the event straight to the engine. /// -/// {@macro flutter.widgets.TextEditingIntents.seeAlso} -class DeleteTextIntent extends Intent { - /// Creates an instance of DeleteTextIntent. - const DeleteTextIntent(); -} - -/// An [Intent] to delete a word in the backwards direction. -/// -/// {@macro flutter.widgets.TextEditingIntents.seeAlso} -class DeleteByWordTextIntent extends Intent { - /// Creates an instance of DeleteByWordTextIntent. - const DeleteByWordTextIntent(); -} - -/// An [Intent] to delete a line in the backwards direction. -/// -/// {@macro flutter.widgets.TextEditingIntents.seeAlso} -class DeleteByLineTextIntent extends Intent { - /// Creates an instance of DeleteByLineTextIntent. - const DeleteByLineTextIntent(); -} - -/// An [Intent] to delete in the forward direction. -/// -/// {@macro flutter.widgets.TextEditingIntents.seeAlso} -class DeleteForwardTextIntent extends Intent { - /// Creates an instance of DeleteForwardTextIntent. - const DeleteForwardTextIntent(); -} - -/// An [Intent] to delete a word in the forward direction. -/// -/// {@macro flutter.widgets.TextEditingIntents.seeAlso} -class DeleteForwardByWordTextIntent extends Intent { - /// Creates an instance of DeleteByWordTextIntent. - const DeleteForwardByWordTextIntent(); -} - -/// An [Intent] to delete a line in the forward direction. -/// -/// {@macro flutter.widgets.TextEditingIntents.seeAlso} -class DeleteForwardByLineTextIntent extends Intent { - /// Creates an instance of DeleteByLineTextIntent. - const DeleteForwardByLineTextIntent(); -} - -/// 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. + /// Creates an instance of [DoNothingAndStopPropagationTextIntent]. const DoNothingAndStopPropagationTextIntent(); } -/// An [Intent] to expand the selection left to the start/end of the current +/// A text editing related [Intent] that performs an operation towards a given +/// direction of the current caret location. +abstract class DirectionalTextEditingIntent extends Intent { + /// Creates a [DirectionalTextEditingIntent]. + const DirectionalTextEditingIntent(this.forward); + + /// Whether the input field, if applicable, should perform the text editing + /// operation from the current caret location towards the end of the document. + /// + /// Unless otherwise specified by the recipient of this intent, this parameter + /// uses the logical order of characters in the string to determind the + /// direction, and is not affected by the writing direction of the text. + final bool forward; +} + +/// Deletes the character before or after the caret location, based on whether +/// `forward` is true. +/// +/// {@template flutter.widgets.TextEditingIntents.logicalOrder} +/// {@endtemplate} +/// +/// Typically a text field will not respond to this intent if it has no active +/// caret ([TextSelection.isValid] is false for the current selection). +class DeleteCharacterIntent extends DirectionalTextEditingIntent { + /// Creates a [DeleteCharacterIntent]. + const DeleteCharacterIntent({ required bool forward }) : super(forward); +} + +/// Deletes from the current caret location to the previous or next word +/// boundary, based on whether `forward` is true. +class DeleteToNextWordBoundaryIntent extends DirectionalTextEditingIntent { + /// Creates a [DeleteToNextWordBoundaryIntent]. + const DeleteToNextWordBoundaryIntent({ required bool forward }) : super(forward); +} + +/// Deletes from the current caret location to the previous or next soft or hard +/// line break, based on whether `forward` is true. +class DeleteToLineBreakIntent extends DirectionalTextEditingIntent { + /// Creates a [DeleteToLineBreakIntent]. + const DeleteToLineBreakIntent({ required bool forward }) : super(forward); +} + +/// A [DirectionalTextEditingIntent] that moves the caret or the selection to a +/// new location. +abstract class DirectionalCaretMovementIntent extends DirectionalTextEditingIntent { + /// Creates a [DirectionalCaretMovementIntent]. + const DirectionalCaretMovementIntent(bool forward, this.collapseSelection) + : super(forward); + + /// Whether this [Intent] should make the selection collapsed (so it becomes a + /// caret), after the movement. + /// + /// When [collapseSelection] is false, the input field typically only moves + /// the current [TextSelection.extent] to the new location, while maintains + /// the current [TextSelection.base] location. + /// + /// When [collapseSelection] is true, the input field typically should move + /// both the [TextSelection.base] and the [TextSelection.extent] to the new + /// location. + final bool collapseSelection; +} + +/// Expands, or moves the current selection from the current +/// [TextSelection.extent] position to the previous or the next character +/// boundary. +class ExtendSelectionByCharacterIntent extends DirectionalCaretMovementIntent { + /// Creates an [ExtendSelectionByCharacterIntent]. + const ExtendSelectionByCharacterIntent({ + required bool forward, + required bool collapseSelection, + }) : super(forward, collapseSelection); +} + +/// Expands, or moves the current selection from the current +/// [TextSelection.extent] position to the previous or the next word +/// boundary. +class ExtendSelectionToNextWordBoundaryIntent extends DirectionalCaretMovementIntent { + /// Creates an [ExtendSelectionToNextWordBoundaryIntent]. + const ExtendSelectionToNextWordBoundaryIntent({ + required bool forward, + required bool collapseSelection, + }) : super(forward, collapseSelection); +} + +/// Expands, or moves the current selection from the current +/// [TextSelection.extent] position to the previous or the next word +/// boundary, or the [TextSelection.base] position if it's closer in the move +/// direction. +/// +/// This [Intent] typically has the same effect as an +/// [ExtendSelectionToNextWordBoundaryIntent], except it collapses the selection +/// when the order of [TextSelection.base] and [TextSelection.extent] would +/// reverse. +/// +/// This is typically only used on macOS. +class ExtendSelectionToNextWordBoundaryOrCaretLocationIntent extends DirectionalTextEditingIntent { + /// Creates an [ExtendSelectionToNextWordBoundaryOrCaretLocationIntent]. + const ExtendSelectionToNextWordBoundaryOrCaretLocationIntent({ + required bool forward, + }) : super(forward); +} + +/// Expands, or moves the current selection from the current +/// [TextSelection.extent] position to the closest line break in the direction +/// given by [forward]. +class ExtendSelectionToLineBreakIntent extends DirectionalCaretMovementIntent { + /// Creates an [ExtendSelectionToLineBreakIntent]. + const ExtendSelectionToLineBreakIntent({ + required bool forward, + required bool collapseSelection, + }) : super(forward, collapseSelection); +} + +/// Expands, or moves the current selection from the current +/// [TextSelection.extent] position to the closest position on the adjacent /// line. -/// -/// {@macro flutter.widgets.TextEditingIntents.seeAlso} -class ExpandSelectionLeftByLineTextIntent extends Intent { - /// Creates an instance of ExpandSelectionLeftByLineTextIntent. - const ExpandSelectionLeftByLineTextIntent(); +class ExtendSelectionVerticallyToAdjacentLineIntent extends DirectionalCaretMovementIntent { + /// Creates an [ExtendSelectionVerticallyToAdjacentLineIntent]. + const ExtendSelectionVerticallyToAdjacentLineIntent({ + required bool forward, + required bool collapseSelection, + }) : super(forward, collapseSelection); } -/// 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, collapsing -/// the selection if the order of [TextSelection.extentOffset] and -/// [TextSelection.baseOffset] would reverse. -/// -/// {@macro flutter.widgets.TextEditingIntents.seeAlso} -class ExtendSelectionLeftByWordAndStopAtReversalTextIntent extends Intent { - /// Creates an instance of ExtendSelectionLeftByWordAndStopAtReversalTextIntent. - const ExtendSelectionLeftByWordAndStopAtReversalTextIntent(); -} - -/// 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, collapsing -/// the selection if the order of [TextSelection.extentOffset] and -/// [TextSelection.baseOffset] would reverse. -/// -/// {@macro flutter.widgets.TextEditingIntents.seeAlso} -class ExtendSelectionRightByWordAndStopAtReversalTextIntent extends Intent { - /// Creates an instance of ExtendSelectionRightByWordAndStopAtReversalTextIntent. - const ExtendSelectionRightByWordAndStopAtReversalTextIntent(); -} - -/// 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(); +/// Expands, or moves the current selection from the current +/// [TextSelection.extent] position to the start or the end of the document. +class ExtendSelectionToDocumentBoundaryIntent extends DirectionalCaretMovementIntent { + /// Creates an [ExtendSelectionToDocumentBoundaryIntent]. + const ExtendSelectionToDocumentBoundaryIntent({ + required bool forward, + required bool collapseSelection, + }) : super(forward, collapseSelection); } /// An [Intent] to select everything in the field. -/// -/// {@macro flutter.widgets.TextEditingIntents.seeAlso} class SelectAllTextIntent extends Intent { - /// Creates an instance of SelectAllTextIntent. - const SelectAllTextIntent(); + /// Creates an instance of [SelectAllTextIntent]. + const SelectAllTextIntent(this.cause); + + /// {@template flutter.widgets.TextEditingIntents.cause} + /// The [SelectionChangedCause] that triggered the intent. + /// {@endtemplate} + final SelectionChangedCause cause; } -/// An [Intent] to copy selection in the field. -/// -/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +/// An [Intent] that represents a user interaction that attempts to copy or cut +/// the current selection in the field. class CopySelectionTextIntent extends Intent { - /// Creates an instance of CopyTextIntent. - const CopySelectionTextIntent(); -} + const CopySelectionTextIntent._(this.cause, this.collapseSelection); -/// An [Intent] to cut selection in the field. -/// -/// {@macro flutter.widgets.TextEditingIntents.seeAlso} -class CutSelectionTextIntent extends Intent { - /// Creates an instance of CutTextIntent. - const CutSelectionTextIntent(); + /// Creates an [Intent] that represents a user interaction that attempts to + /// cut the current selection in the field. + const CopySelectionTextIntent.cut(SelectionChangedCause cause) : this._(cause, true); + + /// An [Intent] that represents a user interaction that attempts to copy the + /// current selection in the field. + static const CopySelectionTextIntent copy = CopySelectionTextIntent._(SelectionChangedCause.keyboard, false); + + /// {@macro flutter.widgets.TextEditingIntents.cause} + final SelectionChangedCause cause; + + /// Whether the original text needs to be removed from the input field if the + /// copy action was successful. + final bool collapseSelection; } /// An [Intent] to paste text from [Clipboard] to the field. -/// -/// {@macro flutter.widgets.TextEditingIntents.seeAlso} class PasteTextIntent extends Intent { - /// Creates an instance of PasteTextIntent. - const PasteTextIntent(); + /// Creates an instance of [PasteTextIntent]. + const PasteTextIntent(this.cause); + + /// {@macro flutter.widgets.TextEditingIntents.cause} + final SelectionChangedCause cause; +} + +/// An [Intent] that represents a user interaction that attempts to modify the +/// current [TextEditingValue] in an input field. +class ReplaceTextIntent extends Intent { + /// Creates a [ReplaceTextIntent]. + const ReplaceTextIntent(this.currentTextEditingValue, this.replacementText, this.replacementRange, this.cause); + + /// The [TextEditingValue] that this [Intent]'s action should perform on. + final TextEditingValue currentTextEditingValue; + + /// The text to replace the original text within the [replacementRange] with. + final String replacementText; + + /// The range of text in [currentTextEditingValue] that needs to be replaced. + final TextRange replacementRange; + + /// {@macro flutter.widgets.TextEditingIntents.cause} + final SelectionChangedCause cause; +} + +/// An [Intent] that represents a user interaction that attempts to change the +/// selection in an input field. +class UpdateSelectionIntent extends Intent { + /// Creates a [UpdateSelectionIntent]. + const UpdateSelectionIntent(this.currentTextEditingValue, this.newSelection, this.cause); + + /// The [TextEditingValue] that this [Intent]'s action should perform on. + final TextEditingValue currentTextEditingValue; + + /// The new [TextSelection] the input field should adopt. + final TextSelection newSelection; + + /// {@macro flutter.widgets.TextEditingIntents.cause} + final SelectionChangedCause cause; } diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index db20cccf2dc..f3c0a02ffd2 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -33,7 +33,6 @@ 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'; @@ -120,8 +119,6 @@ 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_action_target.dart'; export 'src/widgets/text_editing_intents.dart'; export 'src/widgets/text_selection.dart'; export 'src/widgets/text_selection_toolbar_layout_delegate.dart'; diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index e37e33d582c..163b382b408 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -263,7 +263,7 @@ void main() { await tester.tap(find.text('Paste')); await tester.pumpAndSettle(); expect(controller.text, 'blah1 blah2blah1'); - expect(controller.selection, const TextSelection(baseOffset: 16, extentOffset: 16, affinity: TextAffinity.upstream)); + expect(controller.selection, const TextSelection.collapsed(offset: 16)); // Cut the first word. await gesture.down(midBlah1); diff --git a/packages/flutter/test/material/debug_test.dart b/packages/flutter/test/material/debug_test.dart index 934dd92db2a..f0095bed8f6 100644 --- a/packages/flutter/test/material/debug_test.dart +++ b/packages/flutter/test/material/debug_test.dart @@ -189,8 +189,6 @@ void main() { ' _FocusTraversalGroupMarker\n' ' FocusTraversalGroup\n' ' _ActionsMarker\n' - ' DefaultTextEditingActions\n' - ' _ActionsMarker\n' ' Actions\n' ' _ShortcutsMarker\n' ' Semantics\n' diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index f9b58e5dda0..2f8b741bd60 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -79,16 +79,14 @@ Widget overlayWithEntry(OverlayEntry entry) { MaterialLocalizationsDelegate(), ], child: DefaultTextEditingShortcuts( - child: DefaultTextEditingActions( - child: Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: const MediaQueryData(size: Size(800.0, 600.0)), - child: Overlay( - initialEntries: [ - entry, - ], - ), + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Overlay( + initialEntries: [ + entry, + ], ), ), ), @@ -260,14 +258,14 @@ void main() { await tester.pump(); await gesture.up(); await tester.pumpAndSettle(); - expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 11, affinity: TextAffinity.upstream)); + expect(controller.selection, const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream)); expect(find.text('Cut'), findsNothing); expect(find.text('Copy'), findsNothing); expect(find.text('Paste'), findsOneWidget); await tester.tap(find.text('Paste')); await tester.pumpAndSettle(); expect(controller.text, 'blah1 blah2blah1'); - expect(controller.selection, const TextSelection(baseOffset: 16, extentOffset: 16, affinity: TextAffinity.upstream)); + expect(controller.selection, const TextSelection.collapsed(offset: 16)); // Cut the first word. await gesture.down(midBlah1); diff --git a/packages/flutter/test/material/text_form_field_test.dart b/packages/flutter/test/material/text_form_field_test.dart index b567baffce8..3ac56189672 100644 --- a/packages/flutter/test/material/text_form_field_test.dart +++ b/packages/flutter/test/material/text_form_field_test.dart @@ -74,14 +74,14 @@ void main() { await tester.pump(); await gesture.up(); await tester.pumpAndSettle(); - expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 11, affinity: TextAffinity.upstream)); + expect(controller.selection, const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream)); expect(find.text('Cut'), findsNothing); expect(find.text('Copy'), findsNothing); expect(find.text('Paste'), findsOneWidget); await tester.tap(find.text('Paste')); await tester.pumpAndSettle(); expect(controller.text, 'blah1 blah2blah1'); - expect(controller.selection, const TextSelection(baseOffset: 16, extentOffset: 16, affinity: TextAffinity.upstream)); + expect(controller.selection, const TextSelection.collapsed(offset: 16)); // Cut the first word. await gesture.down(midBlah1); diff --git a/packages/flutter/test/services/text_input_test.dart b/packages/flutter/test/services/text_input_test.dart index f24fa0f263b..09e5831110d 100644 --- a/packages/flutter/test/services/text_input_test.dart +++ b/packages/flutter/test/services/text_input_test.dart @@ -43,6 +43,110 @@ void main() { }); }); + group('TextEditingValue', () { + group('replaced', () { + const String testText = 'From a false proposition, anything follows.'; + + test('selection deletion', () { + const TextSelection selection = TextSelection(baseOffset: 5, extentOffset: 13); + expect( + const TextEditingValue(text: testText, selection: selection).replaced(selection, ''), + const TextEditingValue(text: 'From proposition, anything follows.', selection: TextSelection.collapsed(offset: 5)), + ); + }); + + test('reversed selection deletion', () { + const TextSelection selection = TextSelection(baseOffset: 13, extentOffset: 5); + expect( + const TextEditingValue(text: testText, selection: selection).replaced(selection, ''), + const TextEditingValue(text: 'From proposition, anything follows.', selection: TextSelection.collapsed(offset: 5)), + ); + }); + + test('insert', () { + const TextSelection selection = TextSelection.collapsed(offset: 5); + expect( + const TextEditingValue(text: testText, selection: selection).replaced(selection, 'AA'), + const TextEditingValue( + text: 'From AAa false proposition, anything follows.', + // The caret moves to the end of the text inserted. + selection: TextSelection.collapsed(offset: 7), + ), + ); + }); + + test('replace before selection', () { + const TextSelection selection = TextSelection(baseOffset: 13, extentOffset: 5); + expect( + // From |a false |proposition, anything follows. + // Replace the first whitespace with "AA". + const TextEditingValue(text: testText, selection: selection).replaced(const TextRange(start: 4, end: 5), 'AA'), + const TextEditingValue(text: 'FromAAa false proposition, anything follows.', selection: TextSelection(baseOffset: 14, extentOffset: 6)), + ); + }); + + test('replace after selection', () { + const TextSelection selection = TextSelection(baseOffset: 13, extentOffset: 5); + expect( + // From |a false |proposition, anything follows. + // replace the first "p" with "AA". + const TextEditingValue(text: testText, selection: selection).replaced(const TextRange(start: 13, end: 14), 'AA'), + const TextEditingValue(text: 'From a false AAroposition, anything follows.', selection: selection), + ); + }); + + test('replace inside selection - start boundary', () { + const TextSelection selection = TextSelection(baseOffset: 13, extentOffset: 5); + expect( + // From |a false |proposition, anything follows. + // replace the first "a" with "AA". + const TextEditingValue(text: testText, selection: selection).replaced(const TextRange(start: 5, end: 6), 'AA'), + const TextEditingValue(text: 'From AA false proposition, anything follows.', selection: TextSelection(baseOffset: 14, extentOffset: 5)), + ); + }); + + test('replace inside selection - end boundary', () { + const TextSelection selection = TextSelection(baseOffset: 13, extentOffset: 5); + expect( + // From |a false |proposition, anything follows. + // replace the second whitespace with "AA". + const TextEditingValue(text: testText, selection: selection).replaced(const TextRange(start: 12, end: 13), 'AA'), + const TextEditingValue(text: 'From a falseAAproposition, anything follows.', selection: TextSelection(baseOffset: 14, extentOffset: 5)), + ); + }); + + test('delete after selection', () { + const TextSelection selection = TextSelection(baseOffset: 13, extentOffset: 5); + expect( + // From |a false |proposition, anything follows. + // Delete the first "p". + const TextEditingValue(text: testText, selection: selection).replaced(const TextRange(start: 13, end: 14), ''), + const TextEditingValue(text: 'From a false roposition, anything follows.', selection: selection), + ); + }); + + test('delete inside selection - start boundary', () { + const TextSelection selection = TextSelection(baseOffset: 13, extentOffset: 5); + expect( + // From |a false |proposition, anything follows. + // Delete the first "a". + const TextEditingValue(text: testText, selection: selection).replaced(const TextRange(start: 5, end: 6), ''), + const TextEditingValue(text: 'From false proposition, anything follows.', selection: TextSelection(baseOffset: 12, extentOffset: 5)), + ); + }); + + test('delete inside selection - end boundary', () { + const TextSelection selection = TextSelection(baseOffset: 13, extentOffset: 5); + expect( + // From |a false |proposition, anything follows. + // Delete the second whitespace. + const TextEditingValue(text: testText, selection: selection).replaced(const TextRange(start: 12, end: 13), ''), + const TextEditingValue(text: 'From a falseproposition, anything follows.', selection: TextSelection(baseOffset: 12, extentOffset: 5)), + ); + }); + }); + }); + group('TextInput message channels', () { late FakeTextChannel fakeTextChannel; diff --git a/packages/flutter/test/widgets/editable_text_shortcuts_tests.dart b/packages/flutter/test/widgets/editable_text_shortcuts_tests.dart new file mode 100644 index 00000000000..9b08d924502 --- /dev/null +++ b/packages/flutter/test/widgets/editable_text_shortcuts_tests.dart @@ -0,0 +1,1769 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future sendKeyCombination( + WidgetTester tester, + SingleActivator activator, +) async { + final List modifiers = [ + if (activator.control) LogicalKeyboardKey.control, + if (activator.shift) LogicalKeyboardKey.shift, + if (activator.alt) LogicalKeyboardKey.alt, + if (activator.meta) LogicalKeyboardKey.meta, + ]; + for (final LogicalKeyboardKey modifier in modifiers) { + await tester.sendKeyDownEvent(modifier); + } + await tester.sendKeyDownEvent(activator.trigger); + await tester.sendKeyUpEvent(activator.trigger); + await tester.pump(); + for (final LogicalKeyboardKey modifier in modifiers.reversed) { + await tester.sendKeyUpEvent(modifier); + } +} + +Iterable allModifierVariants(LogicalKeyboardKey trigger) { + const Iterable trueFalse = [false, true]; + return trueFalse.expand((bool shift) { + return trueFalse.expand((bool control) { + return trueFalse.expand((bool alt) { + return trueFalse.map((bool meta) => SingleActivator(trigger, shift: shift, control: control, alt: alt, meta: meta)); + }); + }); + }); +} + +void main() { + const String testText = + 'Now is the time for\n' // 20 + 'all good people\n' // 20 + 16 => 36 + 'to come to the aid\n' // 36 + 19 => 55 + 'of their country.'; // 55 + 17 => 72 + const String testCluster = '👨‍👩‍👦👨‍👩‍👦👨‍👩‍👦'; // 8 * 3 + + // Exactly 20 characters each line. + const String testSoftwrapText = + '0123456789ABCDEFGHIJ' + '0123456789ABCDEFGHIJ' + '0123456789ABCDEFGHIJ' + '0123456789ABCDEFGHIJ'; + final TextEditingController controller = TextEditingController(text: testText); + + final FocusNode focusNode = FocusNode(); + Widget buildEditableText({ TextAlign textAlign = TextAlign.left, bool readOnly = false, bool obscured = false }) { + return MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + // Softwrap at exactly 20 characters. + width: 201, + child: EditableText( + controller: controller, + showSelectionHandles: true, + autofocus: true, + focusNode: focusNode, + style: const TextStyle(fontSize: 10), + textScaleFactor: 1, + // Avoid the cursor from taking up width. + cursorWidth: 0, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + maxLines: obscured ? 1 : null, + readOnly: readOnly, + textAlign: textAlign, + obscureText: obscured, + ), + ), + ), + ); + } + + testWidgets( + 'Movement/Deletion shortcuts do nothing when the selection is invalid', + (WidgetTester tester) async { + await tester.pumpWidget(buildEditableText()); + controller.text = testText; + controller.selection = const TextSelection.collapsed(offset: -1); + await tester.pump(); + + const List triggers = [ + LogicalKeyboardKey.backspace, + LogicalKeyboardKey.delete, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.home, + LogicalKeyboardKey.end, + ]; + + for (final SingleActivator activator in triggers.expand(allModifierVariants)) { + await sendKeyCombination(tester, activator); + await tester.pump(); + expect(controller.text, testText, reason: activator.toString()); + expect(controller.selection, const TextSelection.collapsed(offset: -1), reason: activator.toString()); + } + }, + skip: kIsWeb, // [intended] + variant: TargetPlatformVariant.all(), + ); + + group('Common text editing shortcuts: ', + () { + final TargetPlatformVariant allExceptMacOS = TargetPlatformVariant(TargetPlatform.values.toSet()..remove(TargetPlatform.macOS)); + + group('backspace', () { + const LogicalKeyboardKey trigger = LogicalKeyboardKey.backspace; + + testWidgets('backspace', (WidgetTester tester) async { + controller.text = testText; + // Move the selection to the beginning of the 2nd line (after the newline + // character). + controller.selection = const TextSelection.collapsed( + offset: 20, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger)); + + expect( + controller.text, + 'Now is the time forall good people\n' + 'to come to the aid\n' + 'of their country.', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 19), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('backspace readonly', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 20, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText(readOnly: true)); + await sendKeyCombination(tester, const SingleActivator(trigger)); + + expect(controller.text, testText); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 20, affinity: TextAffinity.upstream), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('backspace at start', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 0, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger)); + + expect( + controller.text, + 'Now is the time for\n' + 'all good people\n' + 'to come to the aid\n' + 'of their country.', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('backspace at end', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 72, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger)); + + expect( + controller.text, + 'Now is the time for\n' + 'all good people\n' + 'to come to the aid\n' + 'of their country', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 71), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('backspace inside of a cluster', (WidgetTester tester) async { + controller.text = testCluster; + controller.selection = const TextSelection.collapsed( + offset: 1, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger)); + + expect( + controller.text, + '👨‍👩‍👦👨‍👩‍👦', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('backspace at cluster boundary', (WidgetTester tester) async { + controller.text = testCluster; + controller.selection = const TextSelection.collapsed( + offset: 8, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger)); + + expect( + controller.text, + '👨‍👩‍👦👨‍👩‍👦', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }, variant: TargetPlatformVariant.all()); + }); + + group('delete: ', () { + const LogicalKeyboardKey trigger = LogicalKeyboardKey.delete; + + testWidgets('delete', (WidgetTester tester) async { + controller.text = testText; + // Move the selection to the beginning of the 2nd line (after the newline + // character). + controller.selection = const TextSelection.collapsed( + offset: 20, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger)); + + expect( + controller.text, + 'Now is the time for\n' + 'll good people\n' + 'to come to the aid\n' + 'of their country.', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 20), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('delete readonly', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 20, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText(readOnly: true)); + await sendKeyCombination(tester, const SingleActivator(trigger)); + + expect(controller.text, testText); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 20, affinity: TextAffinity.upstream), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('delete at start', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 0, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger)); + + expect( + controller.text, + 'ow is the time for\n' + 'all good people\n' + 'to come to the aid\n' + 'of their country.', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('delete at end', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 72, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger)); + + expect( + controller.text, + 'Now is the time for\n' + 'all good people\n' + 'to come to the aid\n' + 'of their country.', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 72, affinity: TextAffinity.upstream), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('delete inside of a cluster', (WidgetTester tester) async { + controller.text = testCluster; + controller.selection = const TextSelection.collapsed( + offset: 1, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger)); + + expect( + controller.text, + '👨‍👩‍👦👨‍👩‍👦', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('delete at cluster boundary', (WidgetTester tester) async { + controller.text = testCluster; + controller.selection = const TextSelection.collapsed( + offset: 8, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger)); + + expect( + controller.text, + '👨‍👩‍👦👨‍👩‍👦', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 8), + ); + }, variant: TargetPlatformVariant.all()); + }); + + group('Non-collapsed delete', () { + // This shares the same logic as backspace. + const LogicalKeyboardKey trigger = LogicalKeyboardKey.delete; + + testWidgets('inside of a cluster', (WidgetTester tester) async { + controller.text = testCluster; + controller.selection = const TextSelection( + baseOffset: 9, + extentOffset: 12, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger)); + + expect( + controller.text, + '👨‍👩‍👦👨‍👩‍👦', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 8), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('at the boundaries of a cluster', (WidgetTester tester) async { + controller.text = testCluster; + controller.selection = const TextSelection( + baseOffset: 8, + extentOffset: 16, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger)); + + expect( + controller.text, + '👨‍👩‍👦👨‍👩‍👦', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 8), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('cross-cluster', (WidgetTester tester) async { + controller.text = testCluster; + controller.selection = const TextSelection( + baseOffset: 1, + extentOffset: 9, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger)); + + expect( + controller.text, + '👨‍👩‍👦', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('cross-cluster obscured text', (WidgetTester tester) async { + controller.text = testCluster; + controller.selection = const TextSelection( + baseOffset: 1, + extentOffset: 9, + ); + + await tester.pumpWidget(buildEditableText(obscured: true)); + await sendKeyCombination(tester, const SingleActivator(trigger)); + + expect( + controller.text, + '👨‍👩‍👦👨‍👩‍👦', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 1), + ); + }, variant: TargetPlatformVariant.all()); + }); + + group('word modifier + backspace', () { + const LogicalKeyboardKey trigger = LogicalKeyboardKey.backspace; + SingleActivator wordModifierBackspace() { + final bool isMacOS = defaultTargetPlatform == TargetPlatform.macOS; + return SingleActivator(trigger, control: !isMacOS, alt: isMacOS); + } + + testWidgets('WordModifier-backspace', (WidgetTester tester) async { + controller.text = testText; + // Place the caret before "people". + controller.selection = const TextSelection.collapsed( + offset: 29, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, wordModifierBackspace()); + + expect( + controller.text, + 'Now is the time for\n' + 'all people\n' + 'to come to the aid\n' + 'of their country.', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 24), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('readonly', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 29, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText(readOnly: true)); + await sendKeyCombination(tester, wordModifierBackspace()); + + expect(controller.text, testText); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 29, affinity: TextAffinity.upstream), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('at start', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 0, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, wordModifierBackspace()); + + expect( + controller.text, + 'Now is the time for\n' + 'all good people\n' + 'to come to the aid\n' + 'of their country.', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('at end', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 72, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, wordModifierBackspace()); + + expect( + controller.text, + 'Now is the time for\n' + 'all good people\n' + 'to come to the aid\n' + 'of their country', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 71), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('inside of a cluster', (WidgetTester tester) async { + controller.text = testCluster; + controller.selection = const TextSelection.collapsed( + offset: 1, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, wordModifierBackspace()); + + expect( + controller.text, + '👨‍👩‍👦👨‍👩‍👦', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('at cluster boundary', (WidgetTester tester) async { + controller.text = testCluster; + controller.selection = const TextSelection.collapsed( + offset: 8, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, wordModifierBackspace()); + + expect( + controller.text, + '👨‍👩‍👦👨‍👩‍👦', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }, variant: TargetPlatformVariant.all()); + }); + + group('word modifier + delete', () { + const LogicalKeyboardKey trigger = LogicalKeyboardKey.delete; + SingleActivator wordModifierDelete() { + final bool isMacOS = defaultTargetPlatform == TargetPlatform.macOS; + return SingleActivator(trigger, control: !isMacOS, alt: isMacOS); + } + + testWidgets('WordModifier-delete', (WidgetTester tester) async { + controller.text = testText; + // Place the caret after "all". + controller.selection = const TextSelection.collapsed( + offset: 23, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, wordModifierDelete()); + + expect( + controller.text, + 'Now is the time for\n' + 'all people\n' + 'to come to the aid\n' + 'of their country.', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 23), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('readonly', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 23, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText(readOnly: true)); + await sendKeyCombination(tester, wordModifierDelete()); + + expect(controller.text, testText); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 23, affinity: TextAffinity.upstream), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('at start', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 0, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, wordModifierDelete()); + + expect( + controller.text, + ' is the time for\n' + 'all good people\n' + 'to come to the aid\n' + 'of their country.', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('at end', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 72, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, wordModifierDelete()); + + expect(controller.text, testText); + expect( + controller.selection, + const TextSelection.collapsed(offset: 72, affinity: TextAffinity.upstream), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('inside of a cluster', (WidgetTester tester) async { + controller.text = testCluster; + controller.selection = const TextSelection.collapsed( + offset: 1, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, wordModifierDelete()); + + expect( + controller.text, + '👨‍👩‍👦👨‍👩‍👦', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('at cluster boundary', (WidgetTester tester) async { + controller.text = testCluster; + controller.selection = const TextSelection.collapsed( + offset: 8, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, wordModifierDelete()); + + expect( + controller.text, + '👨‍👩‍👦👨‍👩‍👦', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 8), + ); + }, variant: TargetPlatformVariant.all()); + }); + + group('line modifier + backspace', () { + const LogicalKeyboardKey trigger = LogicalKeyboardKey.backspace; + SingleActivator lineModifierBackspace() { + final bool isMacOS = defaultTargetPlatform == TargetPlatform.macOS; + return SingleActivator(trigger, meta: isMacOS, alt: !isMacOS); + } + + testWidgets('alt-backspace', (WidgetTester tester) async { + controller.text = testText; + // Place the caret before "people". + controller.selection = const TextSelection.collapsed( + offset: 29, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, lineModifierBackspace()); + + expect( + controller.text, + 'Now is the time for\n' + 'people\n' + 'to come to the aid\n' + 'of their country.', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 20), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('softwrap line boundary, upstream', (WidgetTester tester) async { + controller.text = testSoftwrapText; + // Place the caret at the beginning of the 3rd line. + controller.selection = const TextSelection.collapsed( + offset: 40, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, lineModifierBackspace()); + + expect( + controller.text, + '0123456789ABCDEFGHIJ' + '0123456789ABCDEFGHIJ' + '0123456789ABCDEFGHIJ' + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 20), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('softwrap line boundary, downstream', (WidgetTester tester) async { + controller.text = testSoftwrapText; + // Place the caret at the beginning of the 3rd line. + controller.selection = const TextSelection.collapsed( + offset: 40, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, lineModifierBackspace()); + + expect(controller.text, testSoftwrapText); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 40), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('readonly', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 29, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText(readOnly: true)); + await sendKeyCombination(tester, lineModifierBackspace()); + + expect(controller.text, testText); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 29, affinity: TextAffinity.upstream), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('at start', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 0, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, lineModifierBackspace()); + + expect( + controller.text, + 'Now is the time for\n' + 'all good people\n' + 'to come to the aid\n' + 'of their country.', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('at end', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 72, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, lineModifierBackspace()); + + expect( + controller.text, + 'Now is the time for\n' + 'all good people\n' + 'to come to the aid\n' + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 55), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('inside of a cluster', (WidgetTester tester) async { + controller.text = testCluster; + controller.selection = const TextSelection.collapsed( + offset: 1, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, lineModifierBackspace()); + + expect( + controller.text, + '👨‍👩‍👦👨‍👩‍👦', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('at cluster boundary', (WidgetTester tester) async { + controller.text = testCluster; + controller.selection = const TextSelection.collapsed( + offset: 8, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, lineModifierBackspace()); + + expect( + controller.text, + '👨‍👩‍👦👨‍👩‍👦', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }, variant: TargetPlatformVariant.all()); + }); + + group('line modifier + delete', () { + const LogicalKeyboardKey trigger = LogicalKeyboardKey.delete; + SingleActivator lineModifierDelete() { + final bool isMacOS = defaultTargetPlatform == TargetPlatform.macOS; + return SingleActivator(trigger, meta: isMacOS, alt: !isMacOS); + } + + testWidgets('alt-delete', (WidgetTester tester) async { + controller.text = testText; + // Place the caret after "all". + controller.selection = const TextSelection.collapsed( + offset: 23, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, lineModifierDelete()); + + expect( + controller.text, + 'Now is the time for\n' + 'all\n' + 'to come to the aid\n' + 'of their country.', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 23), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('softwrap line boundary, upstream', (WidgetTester tester) async { + controller.text = testSoftwrapText; + // Place the caret at the beginning of the 3rd line. + controller.selection = const TextSelection.collapsed( + offset: 40, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, lineModifierDelete()); + + expect(controller.text, testSoftwrapText); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 40, affinity: TextAffinity.upstream), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('softwrap line boundary, downstream', (WidgetTester tester) async { + controller.text = testSoftwrapText; + // Place the caret at the beginning of the 3rd line. + controller.selection = const TextSelection.collapsed( + offset: 40, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, lineModifierDelete()); + + expect( + controller.text, + '0123456789ABCDEFGHIJ' + '0123456789ABCDEFGHIJ' + '0123456789ABCDEFGHIJ' + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 40), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('readonly', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 23, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText(readOnly: true)); + await sendKeyCombination(tester, lineModifierDelete()); + + expect(controller.text, testText); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 23, affinity: TextAffinity.upstream), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('at start', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 0, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, lineModifierDelete()); + + expect( + controller.text, + '\n' + 'all good people\n' + 'to come to the aid\n' + 'of their country.', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('at end', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 72, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, lineModifierDelete()); + + expect(controller.text, testText); + expect( + controller.selection, + const TextSelection.collapsed(offset: 72, affinity: TextAffinity.upstream), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('inside of a cluster', (WidgetTester tester) async { + controller.text = testCluster; + controller.selection = const TextSelection.collapsed( + offset: 1, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, lineModifierDelete()); + + expect( + controller.text, + isEmpty, + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets('at cluster boundary', (WidgetTester tester) async { + controller.text = testCluster; + controller.selection = const TextSelection.collapsed( + offset: 8, + affinity: TextAffinity.upstream, + ); + + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, lineModifierDelete()); + + expect( + controller.text, + '👨‍👩‍👦', + ); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 8), + ); + }, variant: TargetPlatformVariant.all()); + }); + + group('Arrow Movement', () { + group('left', () { + const LogicalKeyboardKey trigger = LogicalKeyboardKey.arrowLeft; + + testWidgets('at start', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 0, + ); + + await tester.pumpWidget(buildEditableText()); + + for (final SingleActivator activator in allModifierVariants(trigger)) { + await sendKeyCombination(tester, activator); + await tester.pump(); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + reason: activator.toString(), + ); + } + }, variant: TargetPlatformVariant.all()); + + testWidgets('base arrow key movement', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 20, + ); + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger)); + await tester.pump(); + + expect(controller.selection, const TextSelection.collapsed( + offset: 19, + )); + }, variant: TargetPlatformVariant.all()); + + testWidgets('word modifier + arrow key movement', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 7, // Before the first "the" + ); + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger, control: true)); + await tester.pump(); + + expect(controller.selection, const TextSelection.collapsed( + offset: 4, + )); + }, variant: allExceptMacOS); + + testWidgets('line modifier + arrow key movement', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 24, // Before the "good". + ); + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger, alt: true)); + await tester.pump(); + + expect(controller.selection, const TextSelection.collapsed( + offset: 20, + )); + }, variant: allExceptMacOS); + }); + + group('right', () { + const LogicalKeyboardKey trigger = LogicalKeyboardKey.arrowRight; + + testWidgets('at end', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 72, + ); + + await tester.pumpWidget(buildEditableText()); + + for (final SingleActivator activator in allModifierVariants(trigger)) { + await sendKeyCombination(tester, activator); + await tester.pump(); + + expect(controller.selection.isCollapsed, isTrue, reason: activator.toString()); + expect(controller.selection.baseOffset, 72, reason: activator.toString()); + } + }, variant: TargetPlatformVariant.all()); + + testWidgets('base arrow key movement', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 20, + ); + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger)); + await tester.pump(); + + expect(controller.selection, const TextSelection.collapsed( + offset: 21, + )); + }, variant: TargetPlatformVariant.all()); + + testWidgets('word modifier + arrow key movement', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 7, // Before the first "the" + ); + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger, control: true)); + await tester.pump(); + + expect(controller.selection, const TextSelection.collapsed( + offset: 10, + )); + }, variant: allExceptMacOS); + + testWidgets('line modifier + arrow key movement', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 24, // Before the "good". + ); + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(trigger, alt: true)); + await tester.pump(); + + expect(controller.selection, const TextSelection.collapsed( + offset: 35, // Before the newline character. + affinity: TextAffinity.upstream, + )); + }, variant: allExceptMacOS); + }); + + group('With initial non-collapsed selection', () { + testWidgets('base arrow key movement', (WidgetTester tester) async { + controller.text = testText; + // The word "all" is selected. + controller.selection = const TextSelection( + baseOffset: 20, + extentOffset: 23, + ); + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft)); + await tester.pump(); + + expect(controller.selection, const TextSelection.collapsed( + offset: 20, + )); + + // The word "all" is selected. + controller.selection = const TextSelection( + baseOffset: 23, + extentOffset: 20, + ); + await tester.pump(); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 20, + )); + + // The word "all" is selected. + controller.selection = const TextSelection( + baseOffset: 20, + extentOffset: 23, + ); + await tester.pump(); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 23, + )); + + // The word "all" is selected. + controller.selection = const TextSelection( + baseOffset: 23, + extentOffset: 20, + ); + await tester.pump(); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 23, + )); + }, variant: TargetPlatformVariant.all()); + + testWidgets('word modifier + arrow key movement', (WidgetTester tester) async { + controller.text = testText; + // "good" to "come" is selected. + controller.selection = const TextSelection( + baseOffset: 24, + extentOffset: 43, + ); + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft, control: true)); + await tester.pump(); + + expect(controller.selection, const TextSelection.collapsed( + offset: 39, // Before "come". + )); + + // "good" to "come" is selected. + controller.selection = const TextSelection( + baseOffset: 43, + extentOffset: 24, + ); + await tester.pump(); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft, control: true)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 20, // Before "all". + //offset: 39, // Before "come". + )); + + // "good" to "come" is selected. + controller.selection = const TextSelection( + baseOffset: 24, + extentOffset: 43, + ); + await tester.pump(); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, control: true)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 46, // After "to". + )); + + // "good" to "come" is selected. + controller.selection = const TextSelection( + baseOffset: 43, + extentOffset: 24, + ); + await tester.pump(); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, control: true)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 28, // After "good". + )); + }, variant: allExceptMacOS); + + testWidgets('line modifier + arrow key movement', (WidgetTester tester) async { + controller.text = testText; + // "good" to "come" is selected. + controller.selection = const TextSelection( + baseOffset: 24, + extentOffset: 43, + ); + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true)); + await tester.pump(); + + expect(controller.selection, const TextSelection.collapsed( + offset: 36, // Before "to". + )); + + // "good" to "come" is selected. + controller.selection = const TextSelection( + baseOffset: 43, + extentOffset: 24, + ); + await tester.pump(); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 20, // Before "all". + )); + + // "good" to "come" is selected. + controller.selection = const TextSelection( + baseOffset: 24, + extentOffset: 43, + ); + await tester.pump(); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 54, // After "aid". + affinity: TextAffinity.upstream, + )); + + // "good" to "come" is selected. + controller.selection = const TextSelection( + baseOffset: 43, + extentOffset: 24, + ); + await tester.pump(); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 35, // After "people". + affinity: TextAffinity.upstream, + )); + }, variant: allExceptMacOS); + }); + + group('vertical movement', () { + testWidgets('at start', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 0, + ); + + await tester.pumpWidget(buildEditableText()); + + for (final SingleActivator activator in allModifierVariants(LogicalKeyboardKey.arrowUp)) { + await sendKeyCombination(tester, activator); + await tester.pump(); + + expect(controller.text, testText); + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + reason: activator.toString(), + ); + } + }, variant: TargetPlatformVariant.all()); + + testWidgets('at end', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 72, + ); + + await tester.pumpWidget(buildEditableText()); + + for (final SingleActivator activator in allModifierVariants(LogicalKeyboardKey.arrowDown)) { + await sendKeyCombination(tester, activator); + await tester.pump(); + + expect(controller.text, testText); + expect(controller.selection.baseOffset, 72, reason: activator.toString()); + expect(controller.selection.extentOffset, 72, reason: activator.toString()); + } + }, variant: TargetPlatformVariant.all()); + + testWidgets('run', (WidgetTester tester) async { + controller.text = + 'aa\n' // 3 + 'a\n' // 3 + 2 = 5 + 'aa\n' // 5 + 3 = 8 + 'aaa\n' // 8 + 4 = 12 + 'aaaa'; // 12 + 4 = 16 + + controller.selection = const TextSelection.collapsed( + offset: 2, + ); + await tester.pumpWidget(buildEditableText()); + + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 4, + affinity: TextAffinity.upstream, + )); + + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 7, + affinity: TextAffinity.upstream, + )); + + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 10, + )); + + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 14, + )); + + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 16, + )); + + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 10, + )); + + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 7, + affinity: TextAffinity.upstream, + )); + + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 4, + affinity: TextAffinity.upstream, + )); + + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 2, + affinity: TextAffinity.upstream, + )); + + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 0, + )); + + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 4, + affinity: TextAffinity.upstream, + )); + }, variant: TargetPlatformVariant.all()); + + testWidgets('run can be interrupted by layout changes', (WidgetTester tester) async { + controller.text = + 'aa\n' // 3 + 'a\n' // 3 + 2 = 5 + 'aa\n' // 5 + 3 = 8 + 'aaa\n' // 8 + 4 = 12 + 'aaaa'; // 12 + 4 = 16 + + controller.selection = const TextSelection.collapsed( + offset: 2, + ); + await tester.pumpWidget(buildEditableText()); + + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 0, + )); + + // Layout changes. + await tester.pumpWidget(buildEditableText(textAlign: TextAlign.right)); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown)); + await tester.pump(); + + expect(controller.selection, const TextSelection.collapsed( + offset: 3, + )); + }, variant: TargetPlatformVariant.all()); + + testWidgets('run can be interrupted by selection changes', (WidgetTester tester) async { + controller.text = + 'aa\n' // 3 + 'a\n' // 3 + 2 = 5 + 'aa\n' // 5 + 3 = 8 + 'aaa\n' // 8 + 4 = 12 + 'aaaa'; // 12 + 4 = 16 + + controller.selection = const TextSelection.collapsed( + offset: 2, + ); + await tester.pumpWidget(buildEditableText()); + + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowUp)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 0, + )); + + controller.selection = const TextSelection.collapsed( + offset: 1, + ); + await tester.pump(); + controller.selection = const TextSelection.collapsed( + offset: 0, + ); + await tester.pump(); + + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 3, // Would have been 4 if the run wasn't interrupted. + )); + }, variant: TargetPlatformVariant.all()); + }); + }); + }, + skip: kIsWeb, // [intended] + ); + + group('macOS shortcuts', () { + final TargetPlatformVariant macOSOnly = TargetPlatformVariant.only(TargetPlatform.macOS); + + testWidgets('word modifier + arrowLeft', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 7, // Before the first "the" + ); + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true)); + await tester.pump(); + + expect(controller.selection, const TextSelection.collapsed( + offset: 4, + )); + }, variant: macOSOnly); + + testWidgets('word modifier + arrowRight', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 7, // Before the first "the" + ); + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true)); + await tester.pump(); + + expect(controller.selection, const TextSelection.collapsed( + offset: 10, + )); + }, variant: macOSOnly); + + testWidgets('line modifier + arrowLeft', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 24, // Before the "good". + ); + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true)); + await tester.pump(); + + expect(controller.selection, const TextSelection.collapsed( + offset: 20, + )); + }, variant: macOSOnly); + + testWidgets('line modifier + arrowRight', (WidgetTester tester) async { + controller.text = testText; + controller.selection = const TextSelection.collapsed( + offset: 24, // Before the "good". + ); + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true)); + await tester.pump(); + + expect(controller.selection, const TextSelection.collapsed( + offset: 35, // Before the newline character. + affinity: TextAffinity.upstream, + )); + }, variant: macOSOnly); + + testWidgets('word modifier + arrow key movement', (WidgetTester tester) async { + controller.text = testText; + // "good" to "come" is selected. + controller.selection = const TextSelection( + baseOffset: 24, + extentOffset: 43, + ); + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true)); + await tester.pump(); + + expect(controller.selection, const TextSelection.collapsed( + offset: 39, // Before "come". + )); + + // "good" to "come" is selected. + controller.selection = const TextSelection( + baseOffset: 43, + extentOffset: 24, + ); + await tester.pump(); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 20, // Before "all". + //offset: 39, // Before "come". + )); + + // "good" to "come" is selected. + controller.selection = const TextSelection( + baseOffset: 24, + extentOffset: 43, + ); + await tester.pump(); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 46, // After "to". + )); + + // "good" to "come" is selected. + controller.selection = const TextSelection( + baseOffset: 43, + extentOffset: 24, + ); + await tester.pump(); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 28, // After "good". + )); + }, variant: macOSOnly); + + testWidgets('line modifier + arrow key movement', (WidgetTester tester) async { + controller.text = testText; + // "good" to "come" is selected. + controller.selection = const TextSelection( + baseOffset: 24, + extentOffset: 43, + ); + await tester.pumpWidget(buildEditableText()); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true)); + await tester.pump(); + + expect(controller.selection, const TextSelection.collapsed( + offset: 36, // Before "to". + )); + + // "good" to "come" is selected. + controller.selection = const TextSelection( + baseOffset: 43, + extentOffset: 24, + ); + await tester.pump(); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 20, // Before "all". + )); + + // "good" to "come" is selected. + controller.selection = const TextSelection( + baseOffset: 24, + extentOffset: 43, + ); + await tester.pump(); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 54, // After "aid". + affinity: TextAffinity.upstream, + )); + + // "good" to "come" is selected. + controller.selection = const TextSelection( + baseOffset: 43, + extentOffset: 24, + ); + await tester.pump(); + await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed( + offset: 35, // After "people". + affinity: TextAffinity.upstream, + )); + }, variant: macOSOnly); + }); +} diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 3ccb3c219c2..9634b26cb7a 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -4312,10 +4312,10 @@ void main() { // toolbar. Until we change that, this test should remain skipped. }, skip: kIsWeb); // [intended] - const String testText = 'Now is the time for\n' - 'all good people\n' - 'to come to the aid\n' - 'of their country.'; + const String testText = 'Now is the time for\n' // 20 + 'all good people\n' // 20 + 16 => 36 + 'to come to the aid\n' // 36 + 19 => 55 + 'of their country.'; // 55 + 17 => 72 Future sendKeys( WidgetTester tester, @@ -4461,7 +4461,6 @@ void main() { const TextSelection( baseOffset: 0, extentOffset: 0, - affinity: TextAffinity.upstream, ), ), reason: 'on $platform', @@ -4483,7 +4482,6 @@ void main() { const TextSelection( baseOffset: 0, extentOffset: 0, - affinity: TextAffinity.upstream, ), ), reason: 'on $platform', @@ -4637,7 +4635,7 @@ void main() { equals( const TextSelection( baseOffset: 20, - extentOffset: 57, + extentOffset: 39, ), ), reason: 'on $platform', @@ -4659,14 +4657,13 @@ void main() { equals( const TextSelection( baseOffset: 20, - extentOffset: 72, + extentOffset: 54, affinity: TextAffinity.upstream, ), ), reason: 'on $platform', ); - // Can't move left by line because we're already at the beginning of a line. await sendKeys( tester, [ @@ -4682,7 +4679,7 @@ void main() { equals( const TextSelection( baseOffset: 20, - extentOffset: 72, + extentOffset: 36, affinity: TextAffinity.upstream, ), ), @@ -4746,6 +4743,7 @@ void main() { equals( const TextSelection.collapsed( offset: testText.length, + affinity: TextAffinity.upstream, ), ), reason: 'on $platform', @@ -4767,7 +4765,6 @@ void main() { equals( const TextSelection.collapsed( offset: 0, - affinity: TextAffinity.upstream, ), ), reason: 'on $platform', @@ -4832,7 +4829,7 @@ void main() { selection, equals( const TextSelection( - baseOffset: testText.length, + baseOffset: 3, extentOffset: 0, affinity: TextAffinity.upstream, ), @@ -4854,7 +4851,6 @@ void main() { equals( const TextSelection.collapsed( offset: 0, - affinity: TextAffinity.upstream, ), ), reason: 'on $platform', @@ -4878,7 +4874,6 @@ void main() { const TextSelection( baseOffset: 10, extentOffset: 10, - affinity: TextAffinity.upstream, ), ), reason: 'on $platform', @@ -4948,7 +4943,6 @@ void main() { const TextSelection( baseOffset: 4, extentOffset: 4, - affinity: TextAffinity.upstream, ), ), reason: 'on $platform', @@ -4985,7 +4979,6 @@ void main() { const TextSelection( baseOffset: 10, extentOffset: 10, - affinity: TextAffinity.upstream, ), ), reason: 'on $platform', @@ -5031,7 +5024,6 @@ void main() { const TextSelection( baseOffset: 0, extentOffset: 0, - affinity: TextAffinity.upstream, ), ), reason: 'on $platform', @@ -5308,9 +5300,9 @@ void main() { targetPlatform: defaultTargetPlatform, ); - late final int afterHomeOffset; - late final int afterEndOffset; - late final TextAffinity afterEndAffinity; + final int afterHomeOffset; + final int afterEndOffset; + final TextAffinity afterEndAffinity; switch (defaultTargetPlatform) { // These platforms don't handle home/end at all. case TargetPlatform.android: @@ -7869,11 +7861,11 @@ void main() { testWidgets('can change behavior by overriding text editing shortcuts', (WidgetTester tester) async { const Map testShortcuts = { - SingleActivator(LogicalKeyboardKey.arrowLeft): MoveSelectionRightTextIntent(), - SingleActivator(LogicalKeyboardKey.keyX, control: true): MoveSelectionRightTextIntent(), - SingleActivator(LogicalKeyboardKey.keyC, control: true): MoveSelectionRightTextIntent(), - SingleActivator(LogicalKeyboardKey.keyV, control: true): MoveSelectionRightTextIntent(), - SingleActivator(LogicalKeyboardKey.keyA, control: true): MoveSelectionRightTextIntent(), + SingleActivator(LogicalKeyboardKey.arrowLeft): ExtendSelectionByCharacterIntent(forward: true, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.keyX, control: true): ExtendSelectionByCharacterIntent(forward: true, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.keyC, control: true): ExtendSelectionByCharacterIntent(forward: true, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.keyV, control: true): ExtendSelectionByCharacterIntent(forward: true, collapseSelection: true), + SingleActivator(LogicalKeyboardKey.keyA, control: true): ExtendSelectionByCharacterIntent(forward: true, collapseSelection: true), }; final TextEditingController controller = TextEditingController(text: testText); controller.selection = const TextSelection( @@ -8071,7 +8063,7 @@ void main() { }, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended] testWidgets('navigating multiline text', (WidgetTester tester) async { - const String multilineText = 'word word word\nword word\nword'; + const String multilineText = 'word word word\nword word\nword'; // 15 + 10 + 4; final TextEditingController controller = TextEditingController(text: multilineText); // wo|rd wo|rd controller.selection = const TextSelection( @@ -8134,8 +8126,8 @@ void main() { targetPlatform: defaultTargetPlatform, ); expect(controller.selection.isCollapsed, false); - expect(controller.selection.baseOffset, 15); - expect(controller.selection.extentOffset, 24); + expect(controller.selection.baseOffset, 17); + expect(controller.selection.extentOffset, 15); // Set the caret to the end of a line. controller.selection = const TextSelection( @@ -8173,8 +8165,8 @@ void main() { targetPlatform: defaultTargetPlatform, ); expect(controller.selection.isCollapsed, false); - expect(controller.selection.baseOffset, 15); - expect(controller.selection.extentOffset, 24); + expect(controller.selection.baseOffset, 24); + expect(controller.selection.extentOffset, 15); // Set the caret to the start of a line. controller.selection = const TextSelection( @@ -8267,7 +8259,7 @@ void main() { controller.selection, equals( const TextSelection( - baseOffset: 9, + baseOffset: 7, extentOffset: 0, affinity: TextAffinity.upstream, ), @@ -8290,7 +8282,7 @@ void main() { controller.selection, equals( const TextSelection( - baseOffset: 9, + baseOffset: 7, extentOffset: 0, affinity: TextAffinity.upstream, ), @@ -8304,7 +8296,7 @@ void main() { variant: const TargetPlatformVariant({ TargetPlatform.macOS }) ); - testWidgets('can change behavior by overriding text editing actions', (WidgetTester tester) async { + testWidgets('can change text editing behavior by overriding actions', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(text: testText); controller.selection = const TextSelection( baseOffset: 0, @@ -8312,34 +8304,16 @@ void main() { affinity: TextAffinity.upstream, ); bool myIntentWasCalled = false; - final _MyMoveSelectionRightTextAction myMoveSelectionRightTextAction = - _MyMoveSelectionRightTextAction( - onInvoke: () { - myIntentWasCalled = true; - }, + final CallbackAction overrideAction = CallbackAction( + onInvoke: (ExtendSelectionByCharacterIntent intent) { myIntentWasCalled = true; }, ); - const Iterable testSingleActivators = { - SingleActivator(LogicalKeyboardKey.arrowLeft), - SingleActivator(LogicalKeyboardKey.keyX, control: true), - SingleActivator(LogicalKeyboardKey.keyC, control: true), - SingleActivator(LogicalKeyboardKey.keyV, control: true), - SingleActivator(LogicalKeyboardKey.keyA, control: true), - }; - - final Map> testActions = >{ - MoveSelectionLeftTextIntent: myMoveSelectionRightTextAction, - CutSelectionTextIntent: myMoveSelectionRightTextAction, - CopySelectionTextIntent: myMoveSelectionRightTextAction, - PasteTextIntent: myMoveSelectionRightTextAction, - SelectAllTextIntent: myMoveSelectionRightTextAction, - }; await tester.pumpWidget(MaterialApp( home: Align( alignment: Alignment.topLeft, child: SizedBox( width: 400, child: Actions( - actions: testActions, + actions: >{ ExtendSelectionByCharacterIntent: overrideAction, }, child: EditableText( maxLines: 10, controller: controller, @@ -8357,32 +8331,13 @@ void main() { ), ), )); - 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); - expect(myIntentWasCalled, isFalse); - - // And the testSingleActivators also moves to the right due to the Shortcuts override. - for (final SingleActivator singleActivator in testSingleActivators) { - myIntentWasCalled = false; - controller.selection = const TextSelection.collapsed(offset: 0); - await tester.pump(); - - await sendKeys( - tester, - [singleActivator.trigger], - shortcutModifier: singleActivator.control, - targetPlatform: defaultTargetPlatform, - ); - expect(controller.selection.isCollapsed, isTrue); - expect(controller.selection.baseOffset, 1); - expect(myIntentWasCalled, isTrue); - } + expect(controller.selection.baseOffset, 0); + expect(myIntentWasCalled, isTrue); // On web, using keyboard for selection is handled by the browser. }, skip: kIsWeb); // [intended] @@ -8404,10 +8359,8 @@ void main() { width: 400, child: Actions( actions: >{ - MoveSelectionRightTextIntent: _MyMoveSelectionRightTextAction( - onInvoke: () { - myIntentWasCalled = true; - }, + ExtendSelectionByCharacterIntent: CallbackAction( + onInvoke: (ExtendSelectionByCharacterIntent intent) { myIntentWasCalled = true; }, ), }, child: EditableText( @@ -8441,7 +8394,7 @@ void main() { await tester.pump(); expect(myIntentWasCalled, isTrue); expect(controller.selection.isCollapsed, true); - expect(controller.selection.baseOffset, 1); + expect(controller.selection.baseOffset, 0); } }, variant: KeySimulatorTransitModeVariant.all()); @@ -8860,20 +8813,6 @@ class _AccentColorTextEditingController extends TextEditingController { } } -class _MyMoveSelectionRightTextAction extends TextEditingAction { - _MyMoveSelectionRightTextAction({ - required this.onInvoke, - }) : super(); - - final VoidCallback onInvoke; - - @override - Object? invoke(Intent intent, [BuildContext? context]) { - textEditingActionTarget!.moveSelectionRight(SelectionChangedCause.keyboard); - onInvoke(); - } -} - class _TestScrollController extends ScrollController { bool get attached => hasListeners; } diff --git a/packages/flutter/test/widgets/selectable_text_test.dart b/packages/flutter/test/widgets/selectable_text_test.dart index 99c47908e7c..6473cf5026c 100644 --- a/packages/flutter/test/widgets/selectable_text_test.dart +++ b/packages/flutter/test/widgets/selectable_text_test.dart @@ -184,80 +184,6 @@ void main() { ); } - testWidgets('can use the desktop cut/copy/paste buttons on desktop', (WidgetTester tester) async { - final TextEditingController controller = TextEditingController( - text: 'blah1 blah2', - ); - await tester.pumpWidget( - MaterialApp( - home: Material( - child: Center( - child: TextFormField( - controller: controller, - ), - ), - ), - ), - ); - - // Initially, the menu is not shown and there is no selection. - expect(find.byType(CupertinoButton), findsNothing); - expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1)); - - final Offset midBlah1 = textOffsetToPosition(tester, 2); - - // Right clicking shows the menu. - final TestGesture gesture = await tester.startGesture( - midBlah1, - kind: PointerDeviceKind.mouse, - buttons: kSecondaryMouseButton, - ); - addTearDown(gesture.removePointer); - await tester.pump(); - await gesture.up(); - await tester.pumpAndSettle(); - expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); - expect(find.text('Copy'), findsOneWidget); - expect(find.text('Cut'), findsOneWidget); - expect(find.text('Paste'), findsOneWidget); - - // Copy the first word. - await tester.tap(find.text('Copy')); - await tester.pumpAndSettle(); - expect(controller.text, 'blah1 blah2'); - expect(controller.selection, const TextSelection(baseOffset: 5, extentOffset: 5)); - expect(find.byType(CupertinoButton), findsNothing); - - // Paste it at the end. - await gesture.down(textOffsetToPosition(tester, controller.text.length)); - await tester.pump(); - await gesture.up(); - await tester.pumpAndSettle(); - expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 11, affinity: TextAffinity.upstream)); - expect(find.text('Cut'), findsNothing); - expect(find.text('Copy'), findsNothing); - expect(find.text('Paste'), findsOneWidget); - await tester.tap(find.text('Paste')); - await tester.pumpAndSettle(); - expect(controller.text, 'blah1 blah2blah1'); - expect(controller.selection, const TextSelection(baseOffset: 16, extentOffset: 16, affinity: TextAffinity.upstream)); - - // Cut the first word. - await gesture.down(midBlah1); - await tester.pump(); - await gesture.up(); - await tester.pumpAndSettle(); - expect(find.text('Cut'), findsOneWidget); - expect(find.text('Copy'), findsOneWidget); - expect(find.text('Paste'), findsOneWidget); - expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); - await tester.tap(find.text('Cut')); - await tester.pumpAndSettle(); - expect(controller.text, ' blah2blah1'); - expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 0)); - expect(find.byType(CupertinoButton), findsNothing); - }, variant: TargetPlatformVariant.desktop(), skip: kIsWeb); // [intended] toolbar is handled by the browser. - testWidgets('has expected defaults', (WidgetTester tester) async { await tester.pumpWidget( boilerplate( diff --git a/packages/flutter/test/widgets/text_editing_action_target_test.dart b/packages/flutter/test/widgets/text_editing_action_target_test.dart deleted file mode 100644 index 99363055a73..00000000000 --- a/packages/flutter/test/widgets/text_editing_action_target_test.dart +++ /dev/null @@ -1,1951 +0,0 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:io' show Platform; - -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../rendering/rendering_tester.dart'; -import 'clipboard_utils.dart'; - -class _FakeEditableTextState with TextSelectionDelegate, TextEditingActionTarget { - _FakeEditableTextState({ - required this.textEditingValue, - // Render editable parameters: - this.obscureText = false, - required this.textSpan, - this.textDirection = TextDirection.ltr, - }); - - final TextDirection textDirection; - final TextSpan textSpan; - - RenderEditable? _renderEditable; - RenderEditable get renderEditable { - if (_renderEditable != null) { - return _renderEditable!; - } - _renderEditable = RenderEditable( - backgroundCursorColor: Colors.grey, - selectionColor: Colors.black, - textDirection: textDirection, - cursorColor: Colors.red, - offset: ViewportOffset.zero(), - textSelectionDelegate: this, - startHandleLayerLink: LayerLink(), - endHandleLayerLink: LayerLink(), - text: textSpan, - selection: textEditingValue.selection, - ); - return _renderEditable!; - } - - // Start TextSelectionDelegate - - @override - TextEditingValue textEditingValue; - - @override - void hideToolbar([bool hideHandles = true]) { } - - @override - void userUpdateTextEditingValue(TextEditingValue value, SelectionChangedCause cause) { } - - @override - void bringIntoView(TextPosition position) { } - - // End TextSelectionDelegate - // Start TextEditingActionTarget - - @override - bool get readOnly => false; - - @override - final bool obscureText; - - @override - bool get selectionEnabled => true; - - @override - TextLayoutMetrics get textLayoutMetrics => renderEditable; - - @override - void setSelection(TextSelection selection, SelectionChangedCause cause) { - renderEditable.selection = selection; - textEditingValue = textEditingValue.copyWith( - selection: selection, - ); - } - - @override - void setTextEditingValue(TextEditingValue newValue, SelectionChangedCause cause) { - textEditingValue = newValue; - } - - @override - void debugAssertLayoutUpToDate() {} - - // End TextEditingActionTarget -} - -void main() { - final MockClipboard mockClipboard = MockClipboard(); - // Ensure that all TestRenderingFlutterBinding bindings are initialized. - renderer; - - setUp(() async { - TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - mockClipboard.handleMethodCall, - ); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - null, - ); - }); - - test('moveSelectionLeft/RightByLine stays on the current line', () async { - const String text = 'one two three\n\nfour five six'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: 0), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - - editableTextState.setSelection(const TextSelection.collapsed(offset: 0), SelectionChangedCause.tap); - pumpFrame(); - - // Move to the end of the first line. - editableTextState.moveSelectionRightByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 13); - expect(editableTextState.textEditingValue.selection.affinity, TextAffinity.upstream); - // 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. - editableTextState.moveSelectionRightByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 13); - expect(editableTextState.textEditingValue.selection.affinity, TextAffinity.upstream); - - // Move back to the start of the line. - editableTextState.moveSelectionLeftByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 0); - expect(editableTextState.textEditingValue.selection.affinity, TextAffinity.downstream); - - // Trying moveSelectionLeftByLine does nothing at the leftmost of the field. - editableTextState.moveSelectionLeftByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 0); - expect(editableTextState.textEditingValue.selection.affinity, TextAffinity.downstream); - - // Move the selection to the empty line. - editableTextState.moveSelectionRightByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 13); - expect(editableTextState.textEditingValue.selection.affinity, TextAffinity.upstream); - editableTextState.moveSelectionRight(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 14); - expect(editableTextState.textEditingValue.selection.affinity, TextAffinity.downstream); - - // Neither moveSelectionLeftByLine nor moveSelectionRightByLine do anything - // here, because we're at both the beginning and end of the line. - editableTextState.moveSelectionLeftByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 14); - expect(editableTextState.textEditingValue.selection.affinity, TextAffinity.downstream); - editableTextState.moveSelectionRightByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 14); - expect(editableTextState.textEditingValue.selection.affinity, TextAffinity.downstream); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - - test('arrow keys and delete handle simple text correctly', () async { - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: 'test', - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: 'test', - selection: TextSelection.collapsed(offset: 0), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - - editableTextState.setSelection(const TextSelection.collapsed(offset: 0), SelectionChangedCause.tap); - pumpFrame(); - - editableTextState.moveSelectionRight(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 1); - - editableTextState.moveSelectionLeft(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 0); - - editableTextState.deleteForward(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'est'); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - - test('arrow keys and delete handle surrogate pairs correctly', () async { - const String text = '0123😆6789'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: 0), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - - editableTextState.setSelection(const TextSelection.collapsed(offset: 4), SelectionChangedCause.keyboard); - pumpFrame(); - - editableTextState.moveSelectionRight(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 6); - - editableTextState.moveSelectionLeft(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 4); - - editableTextState.deleteForward(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, '01236789'); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - - test('arrow keys and delete handle grapheme clusters correctly', () async { - const String text = '0123👨‍👩‍👦2345'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: 0), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - - editableTextState.setSelection(const TextSelection.collapsed(offset: 4), SelectionChangedCause.tap); - pumpFrame(); - - editableTextState.moveSelectionRight(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 12); - - editableTextState.moveSelectionLeft(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 4); - - editableTextState.deleteForward(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, '01232345'); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - - test('arrow keys and delete handle surrogate pairs correctly case 2', () async { - const String text = '\u{1F44D}'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: 0), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - - editableTextState.setSelection(const TextSelection.collapsed(offset: 0), SelectionChangedCause.tap); - pumpFrame(); - - editableTextState.moveSelectionRight(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 2); - - editableTextState.moveSelectionLeft(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 0); - - editableTextState.deleteForward(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, ''); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - - test('arrow keys work after detaching the widget and attaching it again', () async { - const String text = 'W Szczebrzeszynie chrząszcz brzmi w trzcinie'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: 0), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - final PipelineOwner pipelineOwner = PipelineOwner(); - editable.attach(pipelineOwner); - editable.hasFocus = true; - editable.detach(); - layout(editable); - editable.hasFocus = true; - editableTextState.setSelection(const TextSelection.collapsed(offset: 0), SelectionChangedCause.tap); - pumpFrame(); - - editableTextState.moveSelectionRight(SelectionChangedCause.keyboard); - editableTextState.moveSelectionRight(SelectionChangedCause.keyboard); - editableTextState.moveSelectionRight(SelectionChangedCause.keyboard); - editableTextState.moveSelectionRight(SelectionChangedCause.keyboard); - expect(editable.selection?.isCollapsed, true); - expect(editable.selection?.baseOffset, 4); - - editableTextState.moveSelectionLeft(SelectionChangedCause.keyboard); - expect(editable.selection?.isCollapsed, true); - expect(editable.selection?.baseOffset, 3); - - editableTextState.deleteForward(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'W Sczebrzeszynie chrząszcz brzmi w trzcinie'); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - - test('RenderEditable registers and unregisters raw keyboard listener correctly', () async { - const String text = 'how are you'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: 0), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - final PipelineOwner pipelineOwner = PipelineOwner(); - editable.attach(pipelineOwner); - - editableTextState.deleteForward(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'ow are you'); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - - test('arrow keys with selection text', () async { - const String text = '012345'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: 0), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - - editableTextState.setSelection(const TextSelection(baseOffset: 2, extentOffset: 4), SelectionChangedCause.tap); - pumpFrame(); - - editableTextState.moveSelectionRight(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 4); - - editableTextState.setSelection(const TextSelection(baseOffset: 4, extentOffset: 2), SelectionChangedCause.tap); - pumpFrame(); - - editableTextState.moveSelectionRight(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 4); - - editableTextState.setSelection(const TextSelection(baseOffset: 2, extentOffset: 4), SelectionChangedCause.tap); - pumpFrame(); - - editableTextState.moveSelectionLeft(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 2); - - editableTextState.setSelection(const TextSelection(baseOffset: 4, extentOffset: 2), SelectionChangedCause.tap); - pumpFrame(); - - editableTextState.moveSelectionLeft(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 2); - }); - - test('arrow keys with selection text and shift', () async { - const String text = '012345'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: 0), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - - editableTextState.setSelection(const TextSelection(baseOffset: 2, extentOffset: 4), SelectionChangedCause.tap); - pumpFrame(); - - editableTextState.extendSelectionRight(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, false); - expect(editableTextState.textEditingValue.selection.baseOffset, 2); - expect(editableTextState.textEditingValue.selection.extentOffset, 5); - - editableTextState.setSelection(const TextSelection(baseOffset: 4, extentOffset: 2), SelectionChangedCause.tap); - pumpFrame(); - - editableTextState.extendSelectionRight(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, false); - expect(editableTextState.textEditingValue.selection.baseOffset, 4); - expect(editableTextState.textEditingValue.selection.extentOffset, 3); - - editableTextState.setSelection(const TextSelection(baseOffset: 2, extentOffset: 4), SelectionChangedCause.tap); - pumpFrame(); - - editableTextState.extendSelectionLeft(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, false); - expect(editableTextState.textEditingValue.selection.baseOffset, 2); - expect(editableTextState.textEditingValue.selection.extentOffset, 3); - - editableTextState.setSelection(const TextSelection(baseOffset: 4, extentOffset: 2), SelectionChangedCause.tap); - pumpFrame(); - - editableTextState.extendSelectionLeft(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, false); - expect(editableTextState.textEditingValue.selection.baseOffset, 4); - expect(editableTextState.textEditingValue.selection.extentOffset, 1); - }); - - test('respects enableInteractiveSelection', () async { - const String text = '012345'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: 0), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - - editableTextState.setSelection(const TextSelection.collapsed(offset: 2), SelectionChangedCause.tap); - pumpFrame(); - - await simulateKeyDownEvent(LogicalKeyboardKey.shift); - - editableTextState.moveSelectionRight(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 3); - - editableTextState.moveSelectionLeft(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 2); - - final LogicalKeyboardKey wordModifier = - Platform.isMacOS ? LogicalKeyboardKey.alt : LogicalKeyboardKey.control; - - await simulateKeyDownEvent(wordModifier); - - editableTextState.moveSelectionRightByWord(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 6); - - editableTextState.moveSelectionLeftByWord(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 0); - - await simulateKeyUpEvent(wordModifier); - await simulateKeyUpEvent(LogicalKeyboardKey.shift); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/87681 - - group('delete', () { - test('when as a non-collapsed selection, it should delete a selection', () async { - const String text = 'test'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection(baseOffset: 1, extentOffset: 3), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.delete(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'tt'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 1); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - - test('when as simple text, it should delete the character to the left', () async { - const String text = 'test'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: 3), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.delete(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'tet'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 2); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - - test('when has surrogate pairs, it should delete the pair', () async { - const String text = '\u{1F44D}'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: 2), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.delete(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, ''); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 0); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - - test('when has grapheme clusters, it should delete the grapheme cluster', () async { - const String text = '0123👨‍👩‍👦2345'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: 12), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.delete(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, '01232345'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 4); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - - test('when is at the start of the text, it should be a no-op', () async { - const String text = 'test'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: 0), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.delete(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'test'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 0); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - - test('when input has obscured text, it should delete the character to the left', () async { - const String text = 'test'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - obscureText: true, - textSpan: const TextSpan( - text: '****', - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: 4), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.delete(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'tes'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 3); - }); - - test('when using cjk characters', () async { - const String text = '用多個塊測試'; - const int offset = 4; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.delete(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, '用多個測試'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 3); - }); - - test('when using rtl', () async { - const String text = 'برنامج أهلا بالعالم'; - const int offset = text.length; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textDirection: TextDirection.rtl, - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.delete(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'برنامج أهلا بالعال'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, text.length - 1); - }); - }); - - group('deleteByWord', () { - test('when cursor is on the middle of a word, it should delete the left part of the word', () async { - const String text = 'test with multiple blocks'; - const int offset = 8; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteByWord(SelectionChangedCause.keyboard, false); - expect(editableTextState.textEditingValue.text, 'test h multiple blocks'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 5); - }); - - test('when includeWhiteSpace is true, it should treat a whiteSpace as a single word', () async { - const String text = 'test with multiple blocks'; - const int offset = 10; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteByWord(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'test withmultiple blocks'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 9); - }); - - test('when cursor is after a word, it should delete the whole word', () async { - const String text = 'test with multiple blocks'; - const int offset = 9; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteByWord(SelectionChangedCause.keyboard, false); - expect(editableTextState.textEditingValue.text, 'test multiple blocks'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 5); - }); - - test('when cursor is preceeded by white spaces, it should delete the spaces and the next word to the left', () async { - const String text = 'test with multiple blocks'; - const int offset = 12; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteByWord(SelectionChangedCause.keyboard, false); - expect(editableTextState.textEditingValue.text, 'test multiple blocks'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 5); - }); - - test('when cursor is preceeded by tabs spaces', () async { - const String text = 'test with\t\t\tmultiple blocks'; - const int offset = 12; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteByWord(SelectionChangedCause.keyboard, false); - expect(editableTextState.textEditingValue.text, 'test multiple blocks'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 5); - }); - - test('when cursor is preceeded by break line, it should delete the breaking line and the word right before it', () async { - const String text = 'test with\nmultiple blocks'; - const int offset = 10; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteByWord(SelectionChangedCause.keyboard, false); - expect(editableTextState.textEditingValue.text, 'test multiple blocks'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 5); - }); - - test('when using cjk characters', () async { - const String text = '用多個塊測試'; - const int offset = 4; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteByWord(SelectionChangedCause.keyboard, false); - expect(editableTextState.textEditingValue.text, '用多個測試'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 3); - }); - - test('when using rtl', () async { - const String text = 'برنامج أهلا بالعالم'; - const int offset = text.length; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textDirection: TextDirection.rtl, - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteByWord(SelectionChangedCause.keyboard, false); - expect(editableTextState.textEditingValue.text, 'برنامج أهلا '); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 12); - }); - - test('when input has obscured text, it should delete everything before the selection', () async { - const int offset = 21; - const String text = 'test with multiple\n\n words'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - obscureText: true, - textSpan: const TextSpan( - text: '****', - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteByWord(SelectionChangedCause.keyboard, false); - expect(editableTextState.textEditingValue.text, 'words'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 0); - }); - }); - - group('deleteByLine', () { - test('when cursor is on last character of a line, it should delete everything to the left', () async { - const String text = 'test with multiple blocks'; - const int offset = text.length; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, ''); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 0); - }); - - test('when cursor is on the middle of a word, it should delete delete everything to the left', () async { - const String text = 'test with multiple blocks'; - const int offset = 8; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'h multiple blocks'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 0); - }); - - test('when previous character is a breakline, it should preserve it', () async { - const String text = 'test with\nmultiple blocks'; - const int offset = 10; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, text); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, offset); - }); - - test('when text is multiline, it should delete until the first line break it finds', () async { - const String text = 'test with\n\nMore stuff right here.\nmultiple blocks'; - const int offset = 22; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'test with\n\nright here.\nmultiple blocks'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 11); - }); - - test('when input has obscured text, it should delete everything before the selection', () async { - const int offset = 21; - const String text = 'test with multiple\n\n words'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - obscureText: true, - textSpan: const TextSpan( - text: '****', - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'words'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 0); - }); - }); - - group('deleteForward', () { - test('when as a non-collapsed selection, it should delete a selection', () async { - const String text = 'test'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection(baseOffset: 1, extentOffset: 3), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForward(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'tt'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 1); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - - test('when includeWhiteSpace is true, it should treat a whiteSpace as a single word', () async { - const String text = 'test with multiple blocks'; - const int offset = 9; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForwardByWord(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'test withmultiple blocks'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 9); - }); - - test('when at the end of a text, it should be a no-op', () async { - const String text = 'test'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: 4), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForward(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'test'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 4); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - - test('when the input has obscured text, it should delete the forward character', () async { - const String text = 'test'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - obscureText: true, - textSpan: const TextSpan( - text: '****', - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: 0), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForward(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'est'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 0); - }); - - test('when using cjk characters', () async { - const String text = '用多個塊測試'; - const int offset = 0; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForward(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, '多個塊測試'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 0); - }); - - test('when using rtl', () async { - const String text = 'برنامج أهلا بالعالم'; - const int offset = 0; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textDirection: TextDirection.rtl, - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForward(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'رنامج أهلا بالعالم'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, 0); - }); - }); - - group('deleteForwardByWord', () { - test('when cursor is on the middle of a word, it should delete the next part of the word', () async { - const String text = 'test with multiple blocks'; - const int offset = 6; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForwardByWord(SelectionChangedCause.keyboard, false); - expect(editableTextState.textEditingValue.text, 'test w multiple blocks'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, offset); - }); - - test('when cursor is before a word, it should delete the whole word', () async { - const String text = 'test with multiple blocks'; - const int offset = 10; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForwardByWord(SelectionChangedCause.keyboard, false); - expect(editableTextState.textEditingValue.text, 'test with blocks'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, offset); - }); - - test('when cursor is preceeded by white spaces, it should delete the spaces and the next word', () async { - const String text = 'test with multiple blocks'; - const int offset = 9; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForwardByWord(SelectionChangedCause.keyboard, false); - expect(editableTextState.textEditingValue.text, 'test with blocks'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, offset); - }); - - test('when cursor is before tabs, it should delete the tabs and the next word', () async { - const String text = 'test with\t\t\tmultiple blocks'; - const int offset = 9; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForwardByWord(SelectionChangedCause.keyboard, false); - expect(editableTextState.textEditingValue.text, 'test with blocks'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, offset); - }); - - test('when cursor is followed by break line, it should delete the next word', () async { - const String text = 'test with\n\n\nmultiple blocks'; - const int offset = 9; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForwardByWord(SelectionChangedCause.keyboard, false); - expect(editableTextState.textEditingValue.text, 'test with blocks'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, offset); - }); - - test('when using cjk characters', () async { - const String text = '用多個塊測試'; - const int offset = 0; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForwardByWord(SelectionChangedCause.keyboard, false); - expect(editableTextState.textEditingValue.text, '多個塊測試'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, offset); - }); - - test('when using rtl', () async { - const String text = 'برنامج أهلا بالعالم'; - const int offset = 0; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textDirection: TextDirection.rtl, - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForwardByWord(SelectionChangedCause.keyboard, false); - expect(editableTextState.textEditingValue.text, ' أهلا بالعالم'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, offset); - }); - - test('when input has obscured text, it should delete everything after the selection', () async { - const int offset = 4; - const String text = 'test'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - obscureText: true, - textSpan: const TextSpan( - text: '****', - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForwardByWord(SelectionChangedCause.keyboard, false); - expect(editableTextState.textEditingValue.text, 'test'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, offset); - }); - }); - - group('deleteForwardByLine', () { - test('when cursor is on first character of a line, it should delete everything that follows', () async { - const String text = 'test with multiple blocks'; - const int offset = 4; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForwardByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'test'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, offset); - }); - - test('when cursor is on the middle of a word, it should delete delete everything that follows', () async { - const String text = 'test with multiple blocks'; - const int offset = 8; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForwardByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'test wit'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, offset); - }); - - test('when next character is a breakline, it should preserve it', () async { - const String text = 'test with\n\n\nmultiple blocks'; - const int offset = 9; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForwardByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, text); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, offset); - }); - - test('when text is multiline, it should delete until the first line break it finds', () async { - const String text = 'test with\n\nMore stuff right here.\nmultiple blocks'; - const int offset = 2; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForwardByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'te\n\nMore stuff right here.\nmultiple blocks'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, offset); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/87685 - - test('when input has obscured text, it should delete everything after the selection', () async { - const String text = 'test with multiple\n\n words'; - const int offset = 4; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - obscureText: true, - textSpan: const TextSpan( - text: '****', - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: offset), - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - editableTextState.deleteForwardByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.text, 'test'); - expect(editableTextState.textEditingValue.selection.isCollapsed, true); - expect(editableTextState.textEditingValue.selection.baseOffset, offset); - }); - }); - - group('delete API implementations', () { - // Regression test for: https://github.com/flutter/flutter/issues/80226. - // - // This textSelectionDelegate has different text and selection from the - // render editable. - late _FakeEditableTextState delegate; - - late RenderEditable editable; - - setUp(() { - delegate = _FakeEditableTextState( - textSpan: TextSpan( - text: 'A ' * 50, - style: const TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: 'BBB', - selection: TextSelection.collapsed(offset: 0), - ), - ); - editable = delegate.renderEditable; - }); - - void verifyDoesNotCrashWithInconsistentTextEditingValue(void Function(SelectionChangedCause) method) { - editable = RenderEditable( - text: TextSpan( - text: 'A ' * 50, - ), - startHandleLayerLink: LayerLink(), - endHandleLayerLink: LayerLink(), - textDirection: TextDirection.ltr, - offset: ViewportOffset.fixed(0), - textSelectionDelegate: delegate, - selection: const TextSelection(baseOffset: 0, extentOffset: 50), - ); - - layout(editable, constraints: BoxConstraints.loose(const Size(500.0, 500.0))); - dynamic error; - try { - method(SelectionChangedCause.tap); - } catch (e) { - error = e; - } - expect(error, isNull); - } - - test('delete is not racy and handles composing region correctly', () { - delegate.textEditingValue = const TextEditingValue( - text: 'ABCDEF', - selection: TextSelection.collapsed(offset: 2), - composing: TextRange(start: 1, end: 6), - ); - verifyDoesNotCrashWithInconsistentTextEditingValue(delegate.delete); - final TextEditingValue textEditingValue = editable.textSelectionDelegate.textEditingValue; - expect(textEditingValue.text, 'ACDEF'); - expect(textEditingValue.selection.isCollapsed, isTrue); - expect(textEditingValue.selection.baseOffset, 1); - expect(textEditingValue.composing, const TextRange(start: 1, end: 5)); - }); - - test('deleteForward is not racy and handles composing region correctly', () { - delegate.textEditingValue = const TextEditingValue( - text: 'ABCDEF', - selection: TextSelection.collapsed(offset: 2), - composing: TextRange(start: 2, end: 6), - ); - final TextEditingActionTarget target = delegate; - verifyDoesNotCrashWithInconsistentTextEditingValue(target.deleteForward); - final TextEditingValue textEditingValue = editable.textSelectionDelegate.textEditingValue; - expect(textEditingValue.text, 'ABDEF'); - expect(textEditingValue.selection.isCollapsed, isTrue); - expect(textEditingValue.selection.baseOffset, 2); - expect(textEditingValue.composing, const TextRange(start: 2, end: 5)); - }); - }); - - test("When a selection is needed but it's invalid, nothing is changed", () async { - const String text = 'one two three\n\nfour five six'; - final _FakeEditableTextState editableTextState = _FakeEditableTextState( - textSpan: const TextSpan( - text: text, - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - textEditingValue: const TextEditingValue( - text: text, - ), - ); - final RenderEditable editable = editableTextState.renderEditable; - - layout(editable); - editable.hasFocus = true; - - editableTextState.delete(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - expect(editableTextState.textEditingValue.text, text); - - editableTextState.deleteByWord(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - expect(editableTextState.textEditingValue.text, text); - - editableTextState.deleteByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - expect(editableTextState.textEditingValue.text, text); - - editableTextState.deleteForward(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - expect(editableTextState.textEditingValue.text, text); - - editableTextState.deleteForwardByWord(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - expect(editableTextState.textEditingValue.text, text); - - editableTextState.deleteForwardByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - expect(editableTextState.textEditingValue.text, text); - - editableTextState.deleteToEnd(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - expect(editableTextState.textEditingValue.text, text); - - editableTextState.deleteToStart(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - expect(editableTextState.textEditingValue.text, text); - - editableTextState.expandSelectionToEnd(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.expandSelectionToStart(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.expandSelectionLeftByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.expandSelectionRightByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.extendSelectionDown(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.extendSelectionLeft(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.extendSelectionLeft(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.extendSelectionLeftByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.extendSelectionRight(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.extendSelectionRightByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.extendSelectionLeftByWord(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.extendSelectionRightByWord(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.extendSelectionUp(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.moveSelectionLeftByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.moveSelectionDown(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.moveSelectionLeft(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.moveSelectionLeftByWord(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.moveSelectionRight(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.moveSelectionRightByLine(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.moveSelectionRightByWord(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.moveSelectionUp(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - - editableTextState.copySelection(SelectionChangedCause.keyboard); - ClipboardData? clipboardData = await Clipboard.getData(Clipboard.kTextPlain); - expect(clipboardData?.text, null); - - editableTextState.cutSelection(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - clipboardData = await Clipboard.getData(Clipboard.kTextPlain); - expect(clipboardData?.text, null); - - editableTextState.pasteText(SelectionChangedCause.keyboard); - expect(editableTextState.textEditingValue.selection.isValid, false); - expect(editableTextState.textEditingValue.text, text); - }); - - group('nextCharacter', () { - test('handles normal strings correctly', () { - expect(TextEditingActionTarget.nextCharacter(0, '01234567'), 1); - expect(TextEditingActionTarget.nextCharacter(3, '01234567'), 4); - expect(TextEditingActionTarget.nextCharacter(7, '01234567'), 8); - expect(TextEditingActionTarget.nextCharacter(8, '01234567'), 8); - }); - - test('throws for invalid indices', () { - expect(() => TextEditingActionTarget.nextCharacter(-1, '01234567'), throwsAssertionError); - expect(() => TextEditingActionTarget.nextCharacter(9, '01234567'), throwsAssertionError); - }); - - test('skips spaces in normal strings when includeWhitespace is false', () { - expect(TextEditingActionTarget.nextCharacter(3, '0123 5678', false), 5); - expect(TextEditingActionTarget.nextCharacter(4, '0123 5678', false), 5); - expect(TextEditingActionTarget.nextCharacter(3, '0123 0123', false), 10); - expect(TextEditingActionTarget.nextCharacter(2, '0123 0123', false), 3); - expect(TextEditingActionTarget.nextCharacter(4, '0123 0123', false), 10); - expect(TextEditingActionTarget.nextCharacter(9, '0123 0123', false), 10); - expect(TextEditingActionTarget.nextCharacter(10, '0123 0123', false), 11); - // If the subsequent characters are all whitespace, it returns the length - // of the string. - expect(TextEditingActionTarget.nextCharacter(5, '0123 ', false), 10); - }); - - test('handles surrogate pairs correctly', () { - expect(TextEditingActionTarget.nextCharacter(3, '0123👨👩👦0123'), 4); - expect(TextEditingActionTarget.nextCharacter(4, '0123👨👩👦0123'), 6); - expect(TextEditingActionTarget.nextCharacter(5, '0123👨👩👦0123'), 6); - expect(TextEditingActionTarget.nextCharacter(6, '0123👨👩👦0123'), 8); - expect(TextEditingActionTarget.nextCharacter(7, '0123👨👩👦0123'), 8); - expect(TextEditingActionTarget.nextCharacter(8, '0123👨👩👦0123'), 10); - expect(TextEditingActionTarget.nextCharacter(9, '0123👨👩👦0123'), 10); - expect(TextEditingActionTarget.nextCharacter(10, '0123👨👩👦0123'), 11); - }); - - test('handles extended grapheme clusters correctly', () { - expect(TextEditingActionTarget.nextCharacter(3, '0123👨‍👩‍👦2345'), 4); - expect(TextEditingActionTarget.nextCharacter(4, '0123👨‍👩‍👦2345'), 12); - // Even when extent falls within an extended grapheme cluster, it still - // identifies the whole grapheme cluster. - expect(TextEditingActionTarget.nextCharacter(5, '0123👨‍👩‍👦2345'), 12); - expect(TextEditingActionTarget.nextCharacter(12, '0123👨‍👩‍👦2345'), 13); - }); - }); - - group('previousCharacter', () { - test('handles normal strings correctly', () { - expect(TextEditingActionTarget.previousCharacter(8, '01234567'), 7); - expect(TextEditingActionTarget.previousCharacter(0, '01234567'), 0); - expect(TextEditingActionTarget.previousCharacter(1, '01234567'), 0); - expect(TextEditingActionTarget.previousCharacter(5, '01234567'), 4); - expect(TextEditingActionTarget.previousCharacter(8, '01234567'), 7); - }); - - test('throws for invalid indices', () { - expect(() => TextEditingActionTarget.previousCharacter(-1, '01234567'), throwsAssertionError); - expect(() => TextEditingActionTarget.previousCharacter(9, '01234567'), throwsAssertionError); - }); - - test('skips spaces in normal strings when includeWhitespace is false', () { - expect(TextEditingActionTarget.previousCharacter(5, '0123 0123', false), 3); - expect(TextEditingActionTarget.previousCharacter(10, '0123 0123', false), 3); - expect(TextEditingActionTarget.previousCharacter(11, '0123 0123', false), 10); - expect(TextEditingActionTarget.previousCharacter(9, '0123 0123', false), 3); - expect(TextEditingActionTarget.previousCharacter(4, '0123 0123', false), 3); - expect(TextEditingActionTarget.previousCharacter(3, '0123 0123', false), 2); - // If the previous characters are all whitespace, it returns zero. - expect(TextEditingActionTarget.previousCharacter(3, ' 0123', false), 0); - }); - - test('handles surrogate pairs correctly', () { - expect(TextEditingActionTarget.previousCharacter(11, '0123👨👩👦0123'), 10); - expect(TextEditingActionTarget.previousCharacter(10, '0123👨👩👦0123'), 8); - expect(TextEditingActionTarget.previousCharacter(9, '0123👨👩👦0123'), 8); - expect(TextEditingActionTarget.previousCharacter(8, '0123👨👩👦0123'), 6); - expect(TextEditingActionTarget.previousCharacter(7, '0123👨👩👦0123'), 6); - expect(TextEditingActionTarget.previousCharacter(6, '0123👨👩👦0123'), 4); - expect(TextEditingActionTarget.previousCharacter(5, '0123👨👩👦0123'), 4); - expect(TextEditingActionTarget.previousCharacter(4, '0123👨👩👦0123'), 3); - expect(TextEditingActionTarget.previousCharacter(3, '0123👨👩👦0123'), 2); - }); - - test('handles extended grapheme clusters correctly', () { - expect(TextEditingActionTarget.previousCharacter(13, '0123👨‍👩‍👦2345'), 12); - // Even when extent falls within an extended grapheme cluster, it still - // identifies the whole grapheme cluster. - expect(TextEditingActionTarget.previousCharacter(12, '0123👨‍👩‍👦2345'), 4); - expect(TextEditingActionTarget.previousCharacter(11, '0123👨‍👩‍👦2345'), 4); - expect(TextEditingActionTarget.previousCharacter(5, '0123👨‍👩‍👦2345'), 4); - expect(TextEditingActionTarget.previousCharacter(4, '0123👨‍👩‍👦2345'), 3); - }); - }); -}