diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 8d4f26de5d..b96c4405bf 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -670,10 +670,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { // _handleShortcuts depends on being started in the same stack invocation // as the _handleKeyEvent method _handleShortcuts(key); - } else if (key == LogicalKeyboardKey.delete) { - _handleDelete(forward: true); - } else if (key == LogicalKeyboardKey.backspace) { - _handleDelete(forward: false); } } @@ -1015,16 +1011,381 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { return _getTextPositionVertical(offset, verticalOffset); } + // Deletes the current uncollapsed selection. + void _deleteSelection(TextSelection selection, SelectionChangedCause cause) { + assert(selection.isCollapsed == false); + + if (_readOnly || !selection.isValid || selection.isCollapsed) { + return; + } + + final String text = textSelectionDelegate.textEditingValue.text; + final String textBefore = selection.textBefore(text); + final String textAfter = selection.textAfter(text); + final int cursorPosition = math.min(selection.start, selection.end); + + final TextSelection newSelection = TextSelection.collapsed(offset: cursorPosition); + _setTextEditingValue( + TextEditingValue(text: textBefore + textAfter, selection: newSelection), + cause, + ); + } + + // Deletes the from the current collapsed selection to the start of the field. + // + // The given SelectionChangedCause indicates the cause of this change and + // will be passed to onSelectionChanged. + // + // See also: + // * _deleteToEnd + void _deleteToStart(TextSelection selection, SelectionChangedCause cause) { + assert(selection.isCollapsed); + + if (_readOnly || !selection.isValid) { + return; + } + + final String text = textSelectionDelegate.textEditingValue.text; + final String textBefore = selection.textBefore(text); + + if (textBefore.isEmpty) { + return; + } + + final String textAfter = selection.textAfter(text); + const TextSelection newSelection = TextSelection.collapsed(offset: 0); + _setTextEditingValue( + TextEditingValue(text: textAfter, selection: newSelection), + cause, + ); + } + + // Deletes the from the current collapsed selection to the end of the field. + // + // The given SelectionChangedCause indicates the cause of this change and + // will be passed to onSelectionChanged. + // + // See also: + // * _deleteToStart + void _deleteToEnd(TextSelection selection, SelectionChangedCause cause) { + assert(selection.isCollapsed); + + if (_readOnly || !selection.isValid) { + return; + } + + final String text = textSelectionDelegate.textEditingValue.text; + final String textAfter = selection.textAfter(text); + + if (textAfter.isEmpty) { + return; + } + + final String textBefore = selection.textBefore(text); + final TextSelection newSelection = TextSelection.collapsed(offset: textBefore.length); + _setTextEditingValue( + TextEditingValue(text: textBefore, selection: newSelection), + cause, + ); + } + + /// Deletes backwards from the current selection. + /// + /// If the [selection] is collapsed, deletes a single character before the + /// cursor. + /// + /// If the [selection] is not collapsed, deletes the selection. + /// + /// {@template flutter.rendering.RenderEditable.cause} + /// The given [SelectionChangedCause] indicates the cause of this change and + /// will be passed to [onSelectionChanged]. + /// {@endtemplate} + /// + /// See also: + /// + /// * [deleteForward], which is same but in the opposite direction. + void delete(SelectionChangedCause cause) { + assert(_selection != null); + + if (_readOnly || !_selection!.isValid) { + return; + } + + if (!_selection!.isCollapsed) { + return _deleteSelection(_selection!, cause); + } + + final String text = textSelectionDelegate.textEditingValue.text; + String textBefore = _selection!.textBefore(text); + if (textBefore.isEmpty) { + return; + } + + final int characterBoundary = previousCharacter(textBefore.length, textBefore); + textBefore = textBefore.substring(0, characterBoundary); + + final String textAfter = _selection!.textAfter(text); + final TextSelection newSelection = TextSelection.collapsed(offset: characterBoundary); + _setTextEditingValue( + TextEditingValue(text: textBefore + textAfter, selection: newSelection), + cause, + ); + } + + /// Deletes a word backwards from the current selection. + /// + /// If the [selection] is collapsed, deletes a word before the cursor. + /// + /// If the [selection] is not collapsed, deletes the selection. + /// + /// If [obscureText] is true, it treats the whole text content as + /// a single word. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// {@template flutter.rendering.RenderEditable.whiteSpace} + /// By default, includeWhitespace is set to true, meaning that whitespace can + /// be considered a word in itself. If set to false, the selection will be + /// extended past any whitespace and the first word following the whitespace. + /// {@endtemplate} + /// + /// See also: + /// + /// * [deleteForwardByWord], which is same but in the opposite direction. + void deleteByWord(SelectionChangedCause cause, [bool includeWhitespace = true]) { + assert(_selection != null); + + if (_readOnly || !_selection!.isValid) { + return; + } + + if (!_selection!.isCollapsed) { + return _deleteSelection(_selection!, cause); + } + + // When the text is obscured, the whole thing is treated as one big line. + if (obscureText) { + return _deleteToStart(_selection!, cause); + } + + final String text = textSelectionDelegate.textEditingValue.text; + String textBefore = _selection!.textBefore(text); + if (textBefore.isEmpty) { + return; + } + + final int characterBoundary = _getLeftByWord(_textPainter, textBefore.length, includeWhitespace); + textBefore = textBefore.trimRight().substring(0, characterBoundary); + + final String textAfter = _selection!.textAfter(text); + final TextSelection newSelection = TextSelection.collapsed(offset: characterBoundary); + _setTextEditingValue( + TextEditingValue(text: textBefore + textAfter, selection: newSelection), + cause, + ); + } + + /// Deletes a line backwards from the current selection. + /// + /// If the [selection] is collapsed, deletes a line before the cursor. + /// + /// If the [selection] is not collapsed, deletes the selection. + /// + /// If [obscureText] is true, it treats the whole text content as + /// a single word. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [deleteForwardByLine], which is same but in the opposite direction. + void deleteByLine(SelectionChangedCause cause) { + assert(_selection != null); + + if (_readOnly || !_selection!.isValid) { + return; + } + + if (!_selection!.isCollapsed) { + return _deleteSelection(_selection!, cause); + } + + // When the text is obscured, the whole thing is treated as one big line. + if (obscureText) { + return _deleteToStart(_selection!, cause); + } + + final String text = textSelectionDelegate.textEditingValue.text; + String textBefore = _selection!.textBefore(text); + if (textBefore.isEmpty) { + return; + } + + // When there is a line break, line delete shouldn't do anything + final bool isPreviousCharacterBreakLine = textBefore.codeUnitAt(textBefore.length - 1) == 0x0A; + if (isPreviousCharacterBreakLine) { + return; + } + + final TextSelection line = _getLineAtOffset(TextPosition(offset: textBefore.length - 1)); + textBefore = textBefore.substring(0, line.start); + + final String textAfter = _selection!.textAfter(text); + final TextSelection newSelection = TextSelection.collapsed(offset: textBefore.length); + _setTextEditingValue( + TextEditingValue(text: textBefore + textAfter, selection: newSelection), + cause, + ); + } + + /// Deletes in the foward direction from the current selection. + /// + /// If the [selection] is collapsed, deletes a single character after the + /// cursor. + /// + /// If the [selection] is not collapsed, deletes the selection. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [delete], which is same but in the opposite direction. + void deleteForward(SelectionChangedCause cause) { + assert(_selection != null); + + if (_readOnly || !_selection!.isValid) { + return; + } + + if (!_selection!.isCollapsed) { + return _deleteSelection(_selection!, cause); + } + + final String text = textSelectionDelegate.textEditingValue.text; + final String textBefore = _selection!.textBefore(text); + String textAfter = _selection!.textAfter(text); + + if (textAfter.isEmpty) { + return; + } + + final int deleteCount = nextCharacter(0, textAfter); + textAfter = textAfter.substring(deleteCount); + + _setTextEditingValue( + TextEditingValue(text: textBefore + textAfter, selection: _selection!), + cause, + ); + } + + /// Deletes a word in the foward direction from the current selection. + /// + /// If the [selection] is collapsed, deletes a word after the cursor. + /// + /// If the [selection] is not collapsed, deletes the selection. + /// + /// If [obscureText] is true, it treats the whole text content as + /// a single word. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// {@macro flutter.rendering.RenderEditable.whiteSpace} + /// + /// See also: + /// + /// * [deleteByWord], which is same but in the opposite direction. + void deleteForwardByWord(SelectionChangedCause cause, [bool includeWhitespace = true]) { + assert(_selection != null); + + if (_readOnly || !_selection!.isValid) { + return; + } + + if (!_selection!.isCollapsed) { + return _deleteSelection(_selection!, cause); + } + + // When the text is obscured, the whole thing is treated as one big word. + if (obscureText) { + return _deleteToEnd(_selection!, cause); + } + + final String text = textSelectionDelegate.textEditingValue.text; + String textAfter = _selection!.textAfter(text); + + if (textAfter.isEmpty) { + return; + } + + final String textBefore = _selection!.textBefore(text); + final int characterBoundary = _getRightByWord(_textPainter, textBefore.length, includeWhitespace); + textAfter = textAfter.substring(characterBoundary - textBefore.length); + + _setTextEditingValue( + TextEditingValue(text: textBefore + textAfter, selection: _selection!), + cause, + ); + } + + /// Deletes a line in the foward direction from the current selection. + /// + /// If the [selection] is collapsed, deletes a line after the cursor. + /// + /// If the [selection] is not collapsed, deletes the selection. + /// + /// If [obscureText] is true, it treats the whole text content as + /// a single word. + /// + /// {@macro flutter.rendering.RenderEditable.cause} + /// + /// See also: + /// + /// * [deleteByLine], which is same but in the opposite direction. + void deleteForwardByLine(SelectionChangedCause cause) { + assert(_selection != null); + + if (_readOnly || !_selection!.isValid) { + return; + } + + if (!_selection!.isCollapsed) { + return _deleteSelection(_selection!, cause); + } + + // When the text is obscured, the whole thing is treated as one big line. + if (obscureText) { + return _deleteToEnd(_selection!, cause); + } + + final String text = textSelectionDelegate.textEditingValue.text; + String textAfter = _selection!.textAfter(text); + if (textAfter.isEmpty) { + return; + } + + // When there is a line break, it shouldn't do anything. + final bool isNextCharacterBreakLine = textAfter.codeUnitAt(0) == 0x0A; + if (isNextCharacterBreakLine) { + return; + } + + final String textBefore = _selection!.textBefore(text); + final TextSelection line = _getLineAtOffset(TextPosition(offset: textBefore.length)); + textAfter = textAfter.substring(line.end - textBefore.length, textAfter.length); + + _setTextEditingValue( + TextEditingValue(text: textBefore + textAfter, selection: _selection!), + cause, + ); + } + /// Keeping [selection]'s [TextSelection.baseOffset] fixed, move the /// [TextSelection.extentOffset] down by one line. /// /// If [selectionEnabled] is false, keeps the selection collapsed and just /// moves it down. /// - /// {@template flutter.rendering.RenderEditable.cause} - /// The given [SelectionChangedCause] indicates the cause of this change and - /// will be passed to [onSelectionChanged]. - /// {@endtemplate} + /// {@macro flutter.rendering.RenderEditable.cause} /// /// See also: /// @@ -1375,10 +1736,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { /// /// {@macro flutter.rendering.RenderEditable.cause} /// - /// By default, `includeWhitespace` is set to true, meaning that whitespace - /// can be considered a word in itself. If set to false, the selection will - /// be extended past any whitespace and the first word following the - /// whitespace. + /// {@macro flutter.rendering.RenderEditable.whiteSpace} /// /// {@template flutter.rendering.RenderEditable.stopAtReversal} /// The `stopAtReversal` parameter is false by default, meaning that it's @@ -1418,13 +1776,11 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { /// /// {@macro flutter.rendering.RenderEditable.cause} /// - /// By default, `includeWhitespace` is set to true, meaning that whitespace - /// can be considered a word in itself. If set to false, the selection will - /// be extended past any whitespace and the first word following the - /// whitespace. + /// {@macro flutter.rendering.RenderEditable.whiteSpace} /// /// {@macro flutter.rendering.RenderEditable.stopAtReversal} /// + /// /// See also: /// /// * [extendSelectionLeftByWord], which is the same but in the opposite @@ -1581,9 +1937,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { /// /// {@macro flutter.rendering.RenderEditable.cause} /// - /// By default, includeWhitespace is set to true, meaning that whitespace can - /// be considered a word in itself. If set to false, the selection will be - /// moved past any whitespace and the first word following the whitespace. + /// {@macro flutter.rendering.RenderEditable.whiteSpace} /// /// See also: /// @@ -1667,9 +2021,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { /// /// {@macro flutter.rendering.RenderEditable.cause} /// - /// By default, includeWhitespace is set to true, meaning that whitespace can - /// be considered a word in itself. If set to false, the selection will be - /// moved past any whitespace and the first word following the whitespace. + /// {@macro flutter.rendering.RenderEditable.whiteSpace} /// /// See also: /// @@ -1817,38 +2169,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { } } - void _handleDelete({ required bool forward }) { - final TextSelection selection = textSelectionDelegate.textEditingValue.selection; - final String text = textSelectionDelegate.textEditingValue.text; - assert(_selection != null); - if (_readOnly || !selection.isValid) { - return; - } - String textBefore = selection.textBefore(text); - String textAfter = selection.textAfter(text); - int cursorPosition = math.min(selection.start, selection.end); - // If not deleting a selection, delete the next/previous character. - if (selection.isCollapsed) { - if (!forward && textBefore.isNotEmpty) { - final int characterBoundary = previousCharacter(textBefore.length, textBefore); - textBefore = textBefore.substring(0, characterBoundary); - cursorPosition = characterBoundary; - } - if (forward && textAfter.isNotEmpty) { - final int deleteCount = nextCharacter(0, textAfter); - textAfter = textAfter.substring(deleteCount); - } - } - final TextSelection newSelection = TextSelection.collapsed(offset: cursorPosition); - _setTextEditingValue( - TextEditingValue( - text: textBefore + textAfter, - selection: newSelection, - ), - SelectionChangedCause.keyboard, - ); - } - @override void markNeedsPaint() { super.markNeedsPaint(); diff --git a/packages/flutter/lib/src/widgets/default_text_editing_actions.dart b/packages/flutter/lib/src/widgets/default_text_editing_actions.dart index 13ee114fcc..e0e6b01663 100644 --- a/packages/flutter/lib/src/widgets/default_text_editing_actions.dart +++ b/packages/flutter/lib/src/widgets/default_text_editing_actions.dart @@ -36,6 +36,12 @@ class DefaultTextEditingActions extends Actions{ // 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(), @@ -76,6 +82,48 @@ class _DoNothingAndStopPropagationTextAction extends TextEditingAction { + @override + Object? invoke(DeleteTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.delete(SelectionChangedCause.keyboard); + } +} + +class _DeleteByWordTextAction extends TextEditingAction { + @override + Object? invoke(DeleteByWordTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.deleteByWord(SelectionChangedCause.keyboard, false); + } +} + +class _DeleteByLineTextAction extends TextEditingAction { + @override + Object? invoke(DeleteByLineTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.deleteByLine(SelectionChangedCause.keyboard); + } +} + +class _DeleteForwardTextAction extends TextEditingAction { + @override + Object? invoke(DeleteForwardTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.deleteForward(SelectionChangedCause.keyboard); + } +} + +class _DeleteForwardByWordTextAction extends TextEditingAction { + @override + Object? invoke(DeleteForwardByWordTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.deleteForwardByWord(SelectionChangedCause.keyboard, false); + } +} + +class _DeleteForwardByLineTextAction extends TextEditingAction { + @override + Object? invoke(DeleteForwardByLineTextIntent intent, [BuildContext? context]) { + textEditingActionTarget!.renderEditable.deleteForwardByLine(SelectionChangedCause.keyboard); + } +} + class _ExpandSelectionLeftByLineTextAction extends TextEditingAction { @override Object? invoke(ExpandSelectionLeftByLineTextIntent intent, [BuildContext? context]) { 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 b43e775a3d..5b9ba648b3 100644 --- a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart +++ b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart @@ -161,6 +161,12 @@ class DefaultTextEditingShortcuts extends Shortcuts { ); static final Map _androidShortcuts = { + LogicalKeySet(LogicalKeyboardKey.backspace): const DeleteTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.backspace): const DeleteByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.backspace): const DeleteByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.delete): const DeleteForwardTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.delete): const DeleteForwardByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.delete): const DeleteForwardByLineTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowDown): const MoveSelectionToEndTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByLineTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByLineTextIntent(), @@ -195,9 +201,17 @@ class DefaultTextEditingShortcuts extends Shortcuts { // * Meta + shift + arrow up // * Shift + end // * Shift + home + // * Meta + delete + // * Meta + backspace }; static final Map _fuchsiaShortcuts = { + LogicalKeySet(LogicalKeyboardKey.backspace): const DeleteTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.backspace): const DeleteByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.backspace): const DeleteByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.delete): const DeleteForwardTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.delete): const DeleteForwardByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.delete): const DeleteForwardByLineTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowDown): const MoveSelectionToEndTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByLineTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByLineTextIntent(), @@ -232,9 +246,17 @@ class DefaultTextEditingShortcuts extends Shortcuts { // * Meta + shift + arrow up // * Shift + end // * Shift + home + // * Meta + delete + // * Meta + backspace }; static final Map _iOSShortcuts = { + LogicalKeySet(LogicalKeyboardKey.backspace): const DeleteTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.backspace): const DeleteByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.backspace): const DeleteByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.delete): const DeleteForwardTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.delete): const DeleteForwardByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.delete): const DeleteForwardByLineTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowDown): const MoveSelectionToEndTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByLineTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByLineTextIntent(), @@ -269,9 +291,17 @@ class DefaultTextEditingShortcuts extends Shortcuts { // * Meta + shift + arrow up // * Shift + end // * Shift + home + // * Meta + delete + // * Meta + backspace }; static final Map _linuxShortcuts = { + LogicalKeySet(LogicalKeyboardKey.backspace): const DeleteTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.backspace): const DeleteByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.backspace): const DeleteByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.delete): const DeleteForwardTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.delete): const DeleteForwardByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.delete): const DeleteForwardByLineTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowDown): const MoveSelectionToEndTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByLineTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByLineTextIntent(), @@ -306,9 +336,17 @@ class DefaultTextEditingShortcuts extends Shortcuts { // * Meta + shift + arrow up // * Shift + end // * Shift + home + // * Meta + delete + // * Meta + backspace }; static final Map _macShortcuts = { + LogicalKeySet(LogicalKeyboardKey.backspace): const DeleteTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.backspace): const DeleteByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.backspace): const DeleteByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.delete): const DeleteForwardTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.delete): const DeleteForwardByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.delete): const DeleteForwardByLineTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowDown): const MoveSelectionRightByLineTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByWordTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByWordTextIntent(), @@ -343,9 +381,17 @@ class DefaultTextEditingShortcuts extends Shortcuts { // * Home // * Shift + end // * Shift + home + // * Control + delete + // * Control + backspace }; static final Map _windowsShortcuts = { + LogicalKeySet(LogicalKeyboardKey.backspace): const DeleteTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.backspace): const DeleteByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.backspace): const DeleteByLineTextIntent(), + LogicalKeySet(LogicalKeyboardKey.delete): const DeleteForwardTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.delete): const DeleteForwardByWordTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.delete): const DeleteForwardByLineTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowDown): const MoveSelectionToEndTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowLeft): const MoveSelectionLeftByLineTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowRight): const MoveSelectionRightByLineTextIntent(), @@ -380,11 +426,21 @@ class DefaultTextEditingShortcuts extends Shortcuts { // * Meta + shift + arrow left // * Meta + shift + arrow right // * Meta + shift + arrow up + // * Meta + delete + // * Meta + backspace }; // Web handles its text selection natively and doesn't use any of these // shortcuts in Flutter. static final Map _webShortcuts = { + LogicalKeySet(LogicalKeyboardKey.backspace): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.delete): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.backspace): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.delete): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.backspace): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.delete): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.backspace): const DoNothingAndStopPropagationTextIntent(), + LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.delete): const DoNothingAndStopPropagationTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowDown): const DoNothingAndStopPropagationTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowLeft): const DoNothingAndStopPropagationTextIntent(), LogicalKeySet(LogicalKeyboardKey.alt, LogicalKeyboardKey.arrowRight): const DoNothingAndStopPropagationTextIntent(), diff --git a/packages/flutter/lib/src/widgets/text_editing_intents.dart b/packages/flutter/lib/src/widgets/text_editing_intents.dart index c2f189ccf9..97a5459b79 100644 --- a/packages/flutter/lib/src/widgets/text_editing_intents.dart +++ b/packages/flutter/lib/src/widgets/text_editing_intents.dart @@ -4,6 +4,54 @@ import 'actions.dart'; +/// An [Intent] to delete a character in the backwards direction. +/// +/// {@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. /// diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index afc0c8d254..4f0c7fb613 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -962,8 +962,7 @@ void main() { expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 0); - await simulateKeyDownEvent(LogicalKeyboardKey.delete, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.delete, platform: 'android'); + editable.deleteForward(SelectionChangedCause.keyboard); expect(delegate.textEditingValue.text, 'est'); }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 @@ -1013,8 +1012,7 @@ void main() { expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 4); - await simulateKeyDownEvent(LogicalKeyboardKey.delete, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.delete, platform: 'android'); + editable.deleteForward(SelectionChangedCause.keyboard); expect(delegate.textEditingValue.text, '01236789'); }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 @@ -1064,8 +1062,7 @@ void main() { expect(currentSelection.isCollapsed, true); expect(currentSelection.baseOffset, 4); - await simulateKeyDownEvent(LogicalKeyboardKey.delete, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.delete, platform: 'android'); + editable.deleteForward(SelectionChangedCause.keyboard); expect(delegate.textEditingValue.text, '01232345'); }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 @@ -1170,8 +1167,7 @@ void main() { expect(editable.selection?.isCollapsed, true); expect(editable.selection?.baseOffset, 3); - await simulateKeyDownEvent(LogicalKeyboardKey.delete, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.delete, platform: 'android'); + editable.deleteForward(SelectionChangedCause.keyboard); expect(delegate.textEditingValue.text, 'W Sczebrzeszynie chrząszcz brzmi w trzcinie'); }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 @@ -1362,7 +1358,7 @@ void main() { }, skip: isBrowser); // https://github.com/flutter/flutter/issues/58068 group('delete', () { - test('handles selection', () async { + test('when as a non-collapsed selection, it should delete a selection', () async { final TextSelectionDelegate delegate = FakeEditableTextState() ..textEditingValue = const TextEditingValue( text: 'test', @@ -1392,130 +1388,13 @@ void main() { editable.hasFocus = true; pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.delete, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.delete, platform: 'android'); + editable.delete(SelectionChangedCause.keyboard); expect(delegate.textEditingValue.text, 'tt'); expect(delegate.textEditingValue.selection.isCollapsed, true); expect(delegate.textEditingValue.selection.baseOffset, 1); }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - test('is a no-op at the end of the text', () async { - final TextSelectionDelegate delegate = FakeEditableTextState() - ..textEditingValue = const TextEditingValue( - text: 'test', - selection: TextSelection.collapsed(offset: 4), - ); - final ViewportOffset viewportOffset = ViewportOffset.zero(); - final RenderEditable editable = RenderEditable( - backgroundCursorColor: Colors.grey, - selectionColor: Colors.black, - textDirection: TextDirection.ltr, - cursorColor: Colors.red, - offset: viewportOffset, - textSelectionDelegate: delegate, - onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, - startHandleLayerLink: LayerLink(), - endHandleLayerLink: LayerLink(), - text: const TextSpan( - text: 'test', - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - selection: const TextSelection.collapsed(offset: 4), - ); - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - await simulateKeyDownEvent(LogicalKeyboardKey.delete, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.delete, platform: 'android'); - expect(delegate.textEditingValue.text, 'test'); - expect(delegate.textEditingValue.selection.isCollapsed, true); - expect(delegate.textEditingValue.selection.baseOffset, 4); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - - test('handles obscured text', () async { - final TextSelectionDelegate delegate = FakeEditableTextState() - ..textEditingValue = const TextEditingValue( - text: 'test', - selection: TextSelection.collapsed(offset: 0), - ); - - final ViewportOffset viewportOffset = ViewportOffset.zero(); - final RenderEditable editable = RenderEditable( - backgroundCursorColor: Colors.grey, - selectionColor: Colors.black, - textDirection: TextDirection.ltr, - cursorColor: Colors.red, - offset: viewportOffset, - textSelectionDelegate: delegate, - obscureText: true, - onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, - startHandleLayerLink: LayerLink(), - endHandleLayerLink: LayerLink(), - text: const TextSpan( - text: '****', - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - selection: const TextSelection.collapsed(offset: 0), - ); - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - await simulateKeyDownEvent(LogicalKeyboardKey.delete, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.delete, platform: 'android'); - - expect(delegate.textEditingValue.text, 'est'); - expect(delegate.textEditingValue.selection.isCollapsed, true); - expect(delegate.textEditingValue.selection.baseOffset, 0); - }, skip: isBrowser); - }); - - group('backspace', () { - test('handles selection', () async { - final TextSelectionDelegate delegate = FakeEditableTextState() - ..textEditingValue = const TextEditingValue( - text: 'test', - selection: TextSelection(baseOffset: 1, extentOffset: 3), - ); - final ViewportOffset viewportOffset = ViewportOffset.zero(); - final RenderEditable editable = RenderEditable( - backgroundCursorColor: Colors.grey, - selectionColor: Colors.black, - textDirection: TextDirection.ltr, - cursorColor: Colors.red, - offset: viewportOffset, - textSelectionDelegate: delegate, - onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, - startHandleLayerLink: LayerLink(), - endHandleLayerLink: LayerLink(), - text: const TextSpan( - text: 'test', - style: TextStyle( - height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', - ), - ), - selection: const TextSelection(baseOffset: 1, extentOffset: 3), - ); - - layout(editable); - editable.hasFocus = true; - pumpFrame(); - - await simulateKeyDownEvent(LogicalKeyboardKey.backspace, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.backspace, platform: 'android'); - expect(delegate.textEditingValue.text, 'tt'); - expect(delegate.textEditingValue.selection.isCollapsed, true); - expect(delegate.textEditingValue.selection.baseOffset, 1); - }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - - test('handles simple text', () async { + test('when as simple text, it should delete the character to the left', () async { final TextSelectionDelegate delegate = FakeEditableTextState() ..textEditingValue = const TextEditingValue( text: 'test', @@ -1545,14 +1424,13 @@ void main() { editable.hasFocus = true; pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.backspace, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.backspace, platform: 'android'); + editable.delete(SelectionChangedCause.keyboard); expect(delegate.textEditingValue.text, 'tet'); expect(delegate.textEditingValue.selection.isCollapsed, true); expect(delegate.textEditingValue.selection.baseOffset, 2); }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - test('handles surrogate pairs', () async { + test('when has surrogate pairs, it should delete the pair', () async { final TextSelectionDelegate delegate = FakeEditableTextState() ..textEditingValue = const TextEditingValue( text: '\u{1F44D}', @@ -1582,14 +1460,13 @@ void main() { editable.hasFocus = true; pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.backspace, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.backspace, platform: 'android'); + editable.delete(SelectionChangedCause.keyboard); expect(delegate.textEditingValue.text, ''); expect(delegate.textEditingValue.selection.isCollapsed, true); expect(delegate.textEditingValue.selection.baseOffset, 0); }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - test('handles grapheme clusters', () async { + test('when has grapheme clusters, it should delete the grapheme cluster', () async { final TextSelectionDelegate delegate = FakeEditableTextState() ..textEditingValue = const TextEditingValue( text: '0123👨‍👩‍👦2345', @@ -1619,14 +1496,13 @@ void main() { editable.hasFocus = true; pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.backspace, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.backspace, platform: 'android'); + editable.delete(SelectionChangedCause.keyboard); expect(delegate.textEditingValue.text, '01232345'); expect(delegate.textEditingValue.selection.isCollapsed, true); expect(delegate.textEditingValue.selection.baseOffset, 4); }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - test('is a no-op at the start of the text', () async { + test('when is at the start of the text, it should be a no-op', () async { final TextSelectionDelegate delegate = FakeEditableTextState() ..textEditingValue = const TextEditingValue( text: 'test', @@ -1656,14 +1532,13 @@ void main() { editable.hasFocus = true; pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.backspace, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.backspace, platform: 'android'); + editable.delete(SelectionChangedCause.keyboard); expect(delegate.textEditingValue.text, 'test'); expect(delegate.textEditingValue.selection.isCollapsed, true); expect(delegate.textEditingValue.selection.baseOffset, 0); }, skip: isBrowser); // https://github.com/flutter/flutter/issues/61021 - test('handles obscured text', () async { + test('when input has obscured text, it should delete the character to the left', () async { final TextSelectionDelegate delegate = FakeEditableTextState() ..textEditingValue = const TextEditingValue( text: 'test', @@ -1695,13 +1570,1352 @@ void main() { editable.hasFocus = true; pumpFrame(); - await simulateKeyDownEvent(LogicalKeyboardKey.backspace, platform: 'android'); - await simulateKeyUpEvent(LogicalKeyboardKey.backspace, platform: 'android'); - + editable.delete(SelectionChangedCause.keyboard); expect(delegate.textEditingValue.text, 'tes'); expect(delegate.textEditingValue.selection.isCollapsed, true); expect(delegate.textEditingValue.selection.baseOffset, 3); }, skip: isBrowser); + + test('when using cjk characters', () async { + const String text = '用多個塊測試'; + const int offset = 4; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.delete(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, '用多個測試'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 3); + }, skip: isBrowser); + + test('when using rtl', () async { + const String text = 'برنامج أهلا بالعالم'; + const int offset = text.length; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.rtl, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.delete(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, 'برنامج أهلا بالعال'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, text.length - 1); + }, skip: isBrowser); + }); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteByWord(SelectionChangedCause.keyboard, false); + expect(delegate.textEditingValue.text, 'test h multiple blocks'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 5); + }, skip: isBrowser); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteByWord(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, 'test withmultiple blocks'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 9); + }, skip: isBrowser); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteByWord(SelectionChangedCause.keyboard, false); + expect(delegate.textEditingValue.text, 'test multiple blocks'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 5); + }, skip: isBrowser); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteByWord(SelectionChangedCause.keyboard, false); + expect(delegate.textEditingValue.text, 'test multiple blocks'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 5); + }, skip: isBrowser); + + test('when cursor is preceeded by tabs spaces', () async { + const String text = 'test with\t\t\tmultiple blocks'; + const int offset = 12; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteByWord(SelectionChangedCause.keyboard, false); + expect(delegate.textEditingValue.text, 'test multiple blocks'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 5); + }, skip: isBrowser); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteByWord(SelectionChangedCause.keyboard, false); + expect(delegate.textEditingValue.text, 'test multiple blocks'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 5); + }, skip: isBrowser); + + test('when using cjk characters', () async { + const String text = '用多個塊測試'; + const int offset = 4; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteByWord(SelectionChangedCause.keyboard, false); + expect(delegate.textEditingValue.text, '用多個測試'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 3); + }, skip: isBrowser); + + test('when using rtl', () async { + const String text = 'برنامج أهلا بالعالم'; + const int offset = text.length; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.rtl, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteByWord(SelectionChangedCause.keyboard, false); + expect(delegate.textEditingValue.text, 'برنامج أهلا '); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 12); + }, skip: isBrowser); + + test('when input has obscured text, it should delete everything before the selection', () async { + const int offset = 21; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: 'test with multiple\n\n words', + selection: TextSelection.collapsed(offset: offset), + ); + + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + obscureText: true, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: '****', + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteByWord(SelectionChangedCause.keyboard, false); + expect(delegate.textEditingValue.text, 'words'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 0); + }, skip: isBrowser); + }); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteByLine(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, ''); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 0); + }, skip: isBrowser); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteByLine(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, 'h multiple blocks'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 0); + }, skip: isBrowser); + + test('when previous character is a breakline, it should preserve it', () async { + const String text = 'test with\nmultiple blocks'; + const int offset = 10; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteByLine(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, text); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, offset); + }, skip: isBrowser); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteByLine(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, 'test with\n\nright here.\nmultiple blocks'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 11); + }, skip: isBrowser); + + test('when input has obscured text, it should delete everything before the selection', () async { + const int offset = 21; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: 'test with multiple\n\n words', + selection: TextSelection.collapsed(offset: offset), + ); + + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + obscureText: true, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: '****', + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteByLine(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, 'words'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 0); + }, skip: isBrowser); + }); + + group('deleteForward', () { + test('when as a non-collapsed selection, it should delete a selection', () async { + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: 'test', + selection: TextSelection(baseOffset: 1, extentOffset: 3), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: 'test', + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection(baseOffset: 1, extentOffset: 3), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForward(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, 'tt'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForwardByWord(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, 'test withmultiple blocks'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 9); + }, skip: isBrowser); + + test('when at the end of a text, it should be a no-op', () async { + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: 'test', + selection: TextSelection.collapsed(offset: 4), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: 'test', + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: 4), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForward(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, 'test'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.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 { + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: 'test', + selection: TextSelection.collapsed(offset: 0), + ); + + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + obscureText: true, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: '****', + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: 0), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForward(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, 'est'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 0); + }, skip: isBrowser); + + test('when using cjk characters', () async { + const String text = '用多個塊測試'; + const int offset = 0; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForward(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, '多個塊測試'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 0); + }, skip: isBrowser); + + test('when using rtl', () async { + const String text = 'برنامج أهلا بالعالم'; + const int offset = 0; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.rtl, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForward(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, 'رنامج أهلا بالعالم'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, 0); + }, skip: isBrowser); + + }); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForwardByWord(SelectionChangedCause.keyboard, false); + expect(delegate.textEditingValue.text, 'test w multiple blocks'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, offset); + }, skip: isBrowser); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForwardByWord(SelectionChangedCause.keyboard, false); + expect(delegate.textEditingValue.text, 'test with blocks'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, offset); + }, skip: isBrowser); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForwardByWord(SelectionChangedCause.keyboard, false); + expect(delegate.textEditingValue.text, 'test with blocks'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, offset); + }, skip: isBrowser); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForwardByWord(SelectionChangedCause.keyboard, false); + expect(delegate.textEditingValue.text, 'test with blocks'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, offset); + }, skip: isBrowser); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForwardByWord(SelectionChangedCause.keyboard, false); + expect(delegate.textEditingValue.text, 'test with blocks'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, offset); + }, skip: isBrowser); + + test('when using cjk characters', () async { + const String text = '用多個塊測試'; + const int offset = 0; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForwardByWord(SelectionChangedCause.keyboard, false); + expect(delegate.textEditingValue.text, '多個塊測試'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, offset); + }, skip: isBrowser); + + test('when using rtl', () async { + const String text = 'برنامج أهلا بالعالم'; + const int offset = 0; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.rtl, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForwardByWord(SelectionChangedCause.keyboard, false); + expect(delegate.textEditingValue.text, ' أهلا بالعالم'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, offset); + }, skip: isBrowser); + + test('when input has obscured text, it should delete everything after the selection', () async { + const int offset = 4; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: 'test with multiple\n\n words', + selection: TextSelection.collapsed(offset: offset), + ); + + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + obscureText: true, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: '****', + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForwardByWord(SelectionChangedCause.keyboard, false); + expect(delegate.textEditingValue.text, 'test'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, offset); + }, skip: isBrowser); + }); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForwardByLine(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, 'test'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, offset); + }, skip: isBrowser); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForwardByLine(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, 'test wit'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, offset); + }, skip: isBrowser); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForwardByLine(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, text); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, offset); + }, skip: isBrowser); + + 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 TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: offset), + ); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: text, + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForwardByLine(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, 'te\n\nMore stuff right here.\nmultiple blocks'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, offset); + }, skip: isBrowser); + + test('when input has obscured text, it should delete everything after the selection', () async { + const int offset = 4; + final TextSelectionDelegate delegate = FakeEditableTextState() + ..textEditingValue = const TextEditingValue( + text: 'test with multiple\n\n words', + selection: TextSelection.collapsed(offset: offset), + ); + + final ViewportOffset viewportOffset = ViewportOffset.zero(); + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + textSelectionDelegate: delegate, + obscureText: true, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) {}, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + text: const TextSpan( + text: '****', + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + selection: const TextSelection.collapsed(offset: offset), + ); + + layout(editable); + editable.hasFocus = true; + pumpFrame(); + + editable.deleteForwardByLine(SelectionChangedCause.keyboard); + expect(delegate.textEditingValue.text, 'test'); + expect(delegate.textEditingValue.selection.isCollapsed, true); + expect(delegate.textEditingValue.selection.baseOffset, offset); + }, skip: isBrowser); }); test('getEndpointsForSelection handles empty characters', () {