Unverified Commit 2382b4c0 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Text Editing Model Refactor (#86736)

Simplifying and refactoring parts of RenderEditable. Functionality is the same.
parent 5445c9fd
......@@ -44,3 +44,4 @@ export 'src/services/system_sound.dart';
export 'src/services/text_editing.dart';
export 'src/services/text_formatter.dart';
export 'src/services/text_input.dart';
export 'src/services/text_layout_metrics.dart';
......@@ -901,6 +901,7 @@ class TextPainter {
return _paragraph!.getPositionForOffset(offset);
}
/// {@template flutter.painting.TextPainter.getWordBoundary}
/// Returns the text range of the word at the given offset. Characters not
/// part of a word, such as spaces, symbols, and punctuation, have word breaks
/// on both sides. In such cases, this method will return a text range that
......@@ -908,6 +909,7 @@ class TextPainter {
///
/// Word boundaries are defined more precisely in Unicode Standard Annex #29
/// <http://www.unicode.org/reports/tr29/#Word_Boundaries>.
/// {@endtemplate}
TextRange getWordBoundary(TextPosition position) {
assert(!_needsLayout);
return _paragraph!.getWordBoundary(position);
......
......@@ -77,47 +77,6 @@ class TextSelectionPoint {
}
}
// Check if the given code unit is a white space or separator
// character.
//
// Includes newline characters from ASCII and separators from the
// [unicode separator category](https://www.compart.com/en/unicode/category/Zs)
// TODO(gspencergoog): replace when we expose this ICU information.
bool _isWhitespace(int codeUnit) {
switch (codeUnit) {
case 0x9: // horizontal tab
case 0xA: // line feed
case 0xB: // vertical tab
case 0xC: // form feed
case 0xD: // carriage return
case 0x1C: // file separator
case 0x1D: // group separator
case 0x1E: // record separator
case 0x1F: // unit separator
case 0x20: // space
case 0xA0: // no-break space
case 0x1680: // ogham space mark
case 0x2000: // en quad
case 0x2001: // em quad
case 0x2002: // en space
case 0x2003: // em space
case 0x2004: // three-per-em space
case 0x2005: // four-er-em space
case 0x2006: // six-per-em space
case 0x2007: // figure space
case 0x2008: // punctuation space
case 0x2009: // thin space
case 0x200A: // hair space
case 0x202F: // narrow no-break space
case 0x205F: // medium mathematical space
case 0x3000: // ideographic space
break;
default:
return false;
}
return true;
}
/// Displays some text in a scrollable container with a potentially blinking
/// cursor and with gesture recognizers.
///
......@@ -138,7 +97,7 @@ bool _isWhitespace(int codeUnit) {
/// Keyboard handling, IME handling, scrolling, toggling the [showCursor] value
/// to actually blink the cursor, and other features not mentioned above are the
/// responsibility of higher layers and not handled by this object.
class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ContainerRenderObjectMixin<RenderBox, TextParentData>, RenderBoxContainerDefaultsMixin<RenderBox, TextParentData> {
class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ContainerRenderObjectMixin<RenderBox, TextParentData>, RenderBoxContainerDefaultsMixin<RenderBox, TextParentData> implements TextLayoutMetrics {
/// Creates a render object that implements the visual aspects of a text field.
///
/// The [textAlign] argument must not be null. It defaults to [TextAlign.start].
......@@ -419,6 +378,15 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
double? _textLayoutLastMaxWidth;
double? _textLayoutLastMinWidth;
/// Assert that the last layout still matches the constraints.
void debugAssertLayoutUpToDate() {
assert(
_textLayoutLastMaxWidth == constraints.maxWidth &&
_textLayoutLastMinWidth == constraints.minWidth,
'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).',
);
}
Rect? _lastCaretRect;
// TODO(LongCatIsLooong): currently EditableText uses this callback to keep
// the text field visible. But we don't always paint the caret, for example
......@@ -428,1811 +396,246 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
void _onCaretChanged(Rect caretRect) {
if (_lastCaretRect != caretRect)
onCaretChanged?.call(caretRect);
_lastCaretRect = onCaretChanged == null ? null : caretRect;
}
/// Whether the [handleEvent] will propagate pointer events to selection
/// handlers.
///
/// If this property is true, the [handleEvent] assumes that this renderer
/// will be notified of input gestures via [handleTapDown], [handleTap],
/// [handleDoubleTap], and [handleLongPress].
///
/// If there are any gesture recognizers in the text span, the [handleEvent]
/// will still propagate pointer events to those recognizers.
///
/// The default value of this property is false.
bool ignorePointer;
/// {@macro flutter.dart:ui.textHeightBehavior}
TextHeightBehavior? get textHeightBehavior => _textPainter.textHeightBehavior;
set textHeightBehavior(TextHeightBehavior? value) {
if (_textPainter.textHeightBehavior == value)
return;
_textPainter.textHeightBehavior = value;
markNeedsTextLayout();
}
/// {@macro flutter.painting.textPainter.textWidthBasis}
TextWidthBasis get textWidthBasis => _textPainter.textWidthBasis;
set textWidthBasis(TextWidthBasis value) {
assert(value != null);
if (_textPainter.textWidthBasis == value)
return;
_textPainter.textWidthBasis = value;
markNeedsTextLayout();
}
/// The pixel ratio of the current device.
///
/// Should be obtained by querying MediaQuery for the devicePixelRatio.
double get devicePixelRatio => _devicePixelRatio;
double _devicePixelRatio;
set devicePixelRatio(double value) {
if (devicePixelRatio == value)
return;
_devicePixelRatio = value;
markNeedsTextLayout();
}
/// Character used for obscuring text if [obscureText] is true.
///
/// Cannot be null, and must have a length of exactly one.
String get obscuringCharacter => _obscuringCharacter;
String _obscuringCharacter;
set obscuringCharacter(String value) {
if (_obscuringCharacter == value) {
return;
}
assert(value != null && value.characters.length == 1);
_obscuringCharacter = value;
markNeedsLayout();
}
/// Whether to hide the text being edited (e.g., for passwords).
bool get obscureText => _obscureText;
bool _obscureText;
set obscureText(bool value) {
if (_obscureText == value)
return;
_obscureText = value;
markNeedsSemanticsUpdate();
}
/// Controls how tall the selection highlight boxes are computed to be.
///
/// See [ui.BoxHeightStyle] for details on available styles.
ui.BoxHeightStyle get selectionHeightStyle => _selectionPainter.selectionHeightStyle;
set selectionHeightStyle(ui.BoxHeightStyle value) {
_selectionPainter.selectionHeightStyle = value;
}
/// Controls how wide the selection highlight boxes are computed to be.
///
/// See [ui.BoxWidthStyle] for details on available styles.
ui.BoxWidthStyle get selectionWidthStyle => _selectionPainter.selectionWidthStyle;
set selectionWidthStyle(ui.BoxWidthStyle value) {
_selectionPainter.selectionWidthStyle = value;
}
/// The object that controls the text selection, used by this render object
/// for implementing cut, copy, and paste keyboard shortcuts.
///
/// It must not be null. It will make cut, copy and paste functionality work
/// with the most recently set [TextSelectionDelegate].
TextSelectionDelegate textSelectionDelegate;
/// Track whether position of the start of the selected text is within the viewport.
///
/// For example, if the text contains "Hello World", and the user selects
/// "Hello", then scrolls so only "World" is visible, this will become false.
/// If the user scrolls back so that the "H" is visible again, this will
/// become true.
///
/// This bool indicates whether the text is scrolled so that the handle is
/// inside the text field viewport, as opposed to whether it is actually
/// visible on the screen.
ValueListenable<bool> get selectionStartInViewport => _selectionStartInViewport;
final ValueNotifier<bool> _selectionStartInViewport = ValueNotifier<bool>(true);
/// Track whether position of the end of the selected text is within the viewport.
///
/// For example, if the text contains "Hello World", and the user selects
/// "World", then scrolls so only "Hello" is visible, this will become
/// 'false'. If the user scrolls back so that the "d" is visible again, this
/// will become 'true'.
///
/// This bool indicates whether the text is scrolled so that the handle is
/// inside the text field viewport, as opposed to whether it is actually
/// visible on the screen.
ValueListenable<bool> get selectionEndInViewport => _selectionEndInViewport;
final ValueNotifier<bool> _selectionEndInViewport = ValueNotifier<bool>(true);
void _updateSelectionExtentsVisibility(Offset effectiveOffset) {
assert(selection != null);
final Rect visibleRegion = Offset.zero & size;
final Offset startOffset = _textPainter.getOffsetForCaret(
TextPosition(offset: selection!.start, affinity: selection!.affinity),
_caretPrototype,
);
// TODO(justinmc): https://github.com/flutter/flutter/issues/31495
// Check if the selection is visible with an approximation because a
// difference between rounded and unrounded values causes the caret to be
// reported as having a slightly (< 0.5) negative y offset. This rounding
// happens in paragraph.cc's layout and TextPainer's
// _applyFloatingPointHack. Ideally, the rounding mismatch will be fixed and
// this can be changed to be a strict check instead of an approximation.
const double visibleRegionSlop = 0.5;
_selectionStartInViewport.value = visibleRegion
.inflate(visibleRegionSlop)
.contains(startOffset + effectiveOffset);
final Offset endOffset = _textPainter.getOffsetForCaret(
TextPosition(offset: selection!.end, affinity: selection!.affinity),
_caretPrototype,
);
_selectionEndInViewport.value = visibleRegion
.inflate(visibleRegionSlop)
.contains(endOffset + effectiveOffset);
}
// Holds the last cursor location the user selected in the case the user tries
// to select vertically past the end or beginning of the field. If they do,
// then we need to keep the old cursor location so that we can go back to it
// if they change their minds. Only used for moving selection up and down in a
// multiline text field when selecting using the keyboard.
int _cursorResetLocation = -1;
// Whether we should reset the location of the cursor in the case the user
// tries to select vertically past the end or beginning of the field. If they
// do, then we need to keep the old cursor location so that we can go back to
// it if they change their minds. Only used for resetting selection up and
// down in a multiline text field when selecting using the keyboard.
bool _wasSelectingVerticallyWithKeyboard = false;
void _setTextEditingValue(TextEditingValue newValue, SelectionChangedCause cause) {
textSelectionDelegate.textEditingValue = newValue;
textSelectionDelegate.userUpdateTextEditingValue(newValue, cause);
}
void _setSelection(TextSelection nextSelection, SelectionChangedCause cause) {
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 = textSelectionDelegate.textEditingValue.text.length;
nextSelection = nextSelection.copyWith(
baseOffset: math.min(nextSelection.baseOffset, textLength),
extentOffset: math.min(nextSelection.extentOffset, textLength),
);
}
_handleSelectionChange(nextSelection, cause);
_setTextEditingValue(
textSelectionDelegate.textEditingValue.copyWith(selection: nextSelection),
cause,
);
}
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 == selection && cause != SelectionChangedCause.keyboard && !focusingEmpty) {
return;
}
onSelectionChanged?.call(nextSelection, this, cause);
}
/// Returns the index into the string of the next character boundary after the
/// given index.
///
/// The character boundary is determined by the characters package, so
/// surrogate pairs and extended grapheme clusters are considered.
///
/// The index must be between 0 and string.length, inclusive. If given
/// string.length, string.length is returned.
///
/// Setting includeWhitespace to false will only return the index of non-space
/// characters.
@visibleForTesting
static int nextCharacter(int index, String string, [bool includeWhitespace = true]) {
assert(index >= 0 && index <= string.length);
if (index == string.length) {
return string.length;
}
int count = 0;
final Characters remaining = string.characters.skipWhile((String currentString) {
if (count <= index) {
count += currentString.length;
return true;
}
if (includeWhitespace) {
return false;
}
return _isWhitespace(currentString.codeUnitAt(0));
});
return string.length - remaining.toString().length;
}
/// Returns the index into the string of the previous character boundary
/// before the given index.
///
/// The character boundary is determined by the characters package, so
/// surrogate pairs and extended grapheme clusters are considered.
///
/// The index must be between 0 and string.length, inclusive. If index is 0,
/// 0 will be returned.
///
/// Setting includeWhitespace to false will only return the index of non-space
/// characters.
@visibleForTesting
static int previousCharacter(int index, String string, [bool includeWhitespace = true]) {
assert(index >= 0 && index <= string.length);
if (index == 0) {
return 0;
}
int count = 0;
int? lastNonWhitespace;
for (final String currentString in string.characters) {
if (!includeWhitespace &&
!_isWhitespace(currentString.characters.first.codeUnitAt(0))) {
lastNonWhitespace = count;
}
if (count + currentString.length >= index) {
return includeWhitespace ? count : lastNonWhitespace ?? 0;
}
count += currentString.length;
}
return 0;
}
// Return a new selection that has been moved left once.
//
// If it can't be moved left, the original TextSelection is returned.
static TextSelection _moveGivenSelectionLeft(TextSelection selection, String text) {
// If the selection is already all the way left, there is nothing to do.
if (selection.isCollapsed && selection.extentOffset <= 0) {
return selection;
}
int previousExtent;
if (selection.start != selection.end) {
previousExtent = selection.start;
} else {
previousExtent = previousCharacter(selection.extentOffset, text);
}
final TextSelection newSelection = selection.copyWith(
extentOffset: previousExtent,
);
final int newOffset = newSelection.extentOffset;
return TextSelection.fromPosition(TextPosition(offset: newOffset));
}
// Return a new selection that has been moved right once.
//
// If it can't be moved right, the original TextSelection is returned.
static TextSelection _moveGivenSelectionRight(TextSelection selection, String text) {
// If the selection is already all the way right, there is nothing to do.
if (selection.isCollapsed && selection.extentOffset >= text.length) {
return selection;
}
int nextExtent;
if (selection.start != selection.end) {
nextExtent = selection.end;
} else {
nextExtent = nextCharacter(selection.extentOffset, text);
}
final TextSelection nextSelection = selection.copyWith(extentOffset: nextExtent);
int newOffset = nextSelection.extentOffset;
newOffset = nextSelection.baseOffset > nextSelection.extentOffset
? nextSelection.baseOffset : nextSelection.extentOffset;
return TextSelection.fromPosition(TextPosition(offset: newOffset));
}
// Return the offset at the start of the nearest word to the left of the given
// offset.
static int _getLeftByWord(TextPainter textPainter, int offset, [bool includeWhitespace = true]) {
// If the offset is already all the way left, there is nothing to do.
if (offset <= 0) {
return offset;
}
// If we can just return the start of the text without checking for a word.
if (offset == 1) {
return 0;
}
final String text = textPainter.text!.toPlainText();
final int startPoint = previousCharacter(offset, text, includeWhitespace);
final TextRange word = textPainter.getWordBoundary(TextPosition(offset: startPoint));
return word.start;
}
// Return the offset at the end of the nearest word to the right of the given
// offset.
static int _getRightByWord(TextPainter textPainter, int offset, [bool includeWhitespace = true]) {
// If the selection is already all the way right, there is nothing to do.
final String text = textPainter.text!.toPlainText();
if (offset == text.length) {
return offset;
}
// If we can just return the end of the text without checking for a word.
if (offset == text.length - 1 || offset == text.length) {
return text.length;
}
final int startPoint = includeWhitespace || !_isWhitespace(text.codeUnitAt(offset))
? offset
: nextCharacter(offset, text, includeWhitespace);
final TextRange nextWord = textPainter.getWordBoundary(TextPosition(offset: startPoint));
return nextWord.end;
}
// Return the given TextSelection extended left to the beginning of the
// nearest word.
//
// See extendSelectionLeftByWord for a detailed explanation of the two
// optional parameters.
static TextSelection _extendGivenSelectionLeftByWord(TextPainter textPainter, TextSelection selection, [bool includeWhitespace = true, bool stopAtReversal = false]) {
// If the selection is already all the way left, there is nothing to do.
if (selection.isCollapsed && selection.extentOffset <= 0) {
return selection;
}
final int leftOffset = _getLeftByWord(textPainter, selection.extentOffset, includeWhitespace);
if (stopAtReversal && selection.extentOffset > selection.baseOffset
&& leftOffset < selection.baseOffset) {
return selection.copyWith(
extentOffset: selection.baseOffset,
);
}
return selection.copyWith(
extentOffset: leftOffset,
);
}
// Return the given TextSelection extended right to the end of the nearest
// word.
//
// See extendSelectionRightByWord for a detailed explanation of the two
// optional parameters.
static TextSelection _extendGivenSelectionRightByWord(TextPainter textPainter, TextSelection selection, [bool includeWhitespace = true, bool stopAtReversal = false]) {
// If the selection is already all the way right, there is nothing to do.
final String text = textPainter.text!.toPlainText();
if (selection.isCollapsed && selection.extentOffset == text.length) {
return selection;
}
final int rightOffset = _getRightByWord(textPainter, selection.extentOffset, includeWhitespace);
if (stopAtReversal && selection.baseOffset > selection.extentOffset
&& rightOffset > selection.baseOffset) {
return selection.copyWith(
extentOffset: selection.baseOffset,
);
}
return selection.copyWith(
extentOffset: rightOffset,
);
}
// Return the given TextSelection moved left to the end of the nearest word.
//
// A TextSelection that isn't collapsed will be collapsed and moved from the
// extentOffset.
static TextSelection _moveGivenSelectionLeftByWord(TextPainter textPainter, TextSelection selection, [bool includeWhitespace = true]) {
// If the selection is already all the way left, there is nothing to do.
if (selection.isCollapsed && selection.extentOffset <= 0) {
return selection;
}
final int leftOffset = _getLeftByWord(textPainter, selection.extentOffset, includeWhitespace);
return selection.copyWith(
baseOffset: leftOffset,
extentOffset: leftOffset,
);
}
// Return the given TextSelection moved right to the end of the nearest word.
//
// A TextSelection that isn't collapsed will be collapsed and moved from the
// extentOffset.
static TextSelection _moveGivenSelectionRightByWord(TextPainter textPainter, TextSelection selection, [bool includeWhitespace = true]) {
// If the selection is already all the way right, there is nothing to do.
final String text = textPainter.text!.toPlainText();
if (selection.isCollapsed && selection.extentOffset == text.length) {
return selection;
}
final int rightOffset = _getRightByWord(textPainter, selection.extentOffset, includeWhitespace);
return selection.copyWith(
baseOffset: rightOffset,
extentOffset: rightOffset,
);
}
static TextSelection _extendGivenSelectionLeft(TextSelection selection, String text, [bool includeWhitespace = true]) {
// If the selection is already all the way left, there is nothing to do.
if (selection.extentOffset <= 0) {
return selection;
}
final int previousExtent = previousCharacter(selection.extentOffset, text, includeWhitespace);
return selection.copyWith(extentOffset: previousExtent);
}
static TextSelection _extendGivenSelectionRight(TextSelection selection, String text, [bool includeWhitespace = true]) {
// If the selection is already all the way right, there is nothing to do.
if (selection.extentOffset >= text.length) {
return selection;
}
final int nextExtent = nextCharacter(selection.extentOffset, text, includeWhitespace);
return selection.copyWith(extentOffset: nextExtent);
}
// Extend the current selection to the end of the field.
//
// If selectionEnabled is false, keeps the selection collapsed and moves it to
// the end.
//
// The given [SelectionChangedCause] indicates the cause of this change and
// will be passed to [onSelectionChanged].
//
// See also:
//
// * _extendSelectionToStart
void _extendSelectionToEnd(SelectionChangedCause cause) {
if (selection!.extentOffset == _plainText.length) {
return;
}
if (!selectionEnabled) {
return moveSelectionToEnd(cause);
}
final TextSelection nextSelection = selection!.copyWith(
extentOffset: _plainText.length,
);
_setSelection(nextSelection, cause);
}
// Extend the current selection to the start of the field.
//
// If selectionEnabled is false, keeps the selection collapsed and moves it to
// the start.
//
// The given [SelectionChangedCause] indicates the cause of this change and
// will be passed to [onSelectionChanged].
//
// See also:
//
// * _extendSelectionToEnd
void _extendSelectionToStart(SelectionChangedCause cause) {
if (selection!.extentOffset == 0) {
return;
}
if (!selectionEnabled) {
return moveSelectionToStart(cause);
}
final TextSelection nextSelection = selection!.copyWith(
extentOffset: 0,
);
_setSelection(nextSelection, cause);
}
// Returns the TextPosition above or below the given offset.
TextPosition _getTextPositionVertical(int textOffset, double verticalOffset) {
final Offset caretOffset = _textPainter.getOffsetForCaret(TextPosition(offset: textOffset), _caretPrototype);
final Offset caretOffsetTranslated = caretOffset.translate(0.0, verticalOffset);
return _textPainter.getPositionForOffset(caretOffsetTranslated);
}
// Returns the TextPosition above the given offset into _plainText.
//
// If the offset is already on the first line, the given offset will be
// returned.
TextPosition _getTextPositionAbove(int offset) {
// The caret offset gives a location in the upper left hand corner of
// the caret so the middle of the line above is a half line above that
// point and the line below is 1.5 lines below that point.
final double preferredLineHeight = _textPainter.preferredLineHeight;
final double verticalOffset = -0.5 * preferredLineHeight;
return _getTextPositionVertical(offset, verticalOffset);
}
// Returns the TextPosition below the given offset into _plainText.
//
// If the offset is already on the last line, the given offset will be
// returned.
TextPosition _getTextPositionBelow(int offset) {
// The caret offset gives a location in the upper left hand corner of
// the caret so the middle of the line above is a half line above that
// point and the line below is 1.5 lines below that point.
final double preferredLineHeight = _textPainter.preferredLineHeight;
final double verticalOffset = 1.5 * preferredLineHeight;
return _getTextPositionVertical(offset, verticalOffset);
}
// Deletes the text within `selection` if it's non-empty.
void _deleteSelection(TextSelection selection, SelectionChangedCause cause) {
assert(!selection.isCollapsed);
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 current non-empty selection.
//
// Operates on the text/selection contained in textSelectionDelegate, and does
// not depend on `RenderEditable.selection`.
//
// If the selection is currently non-empty, this method deletes the selected
// text and returns true. Otherwise this method does nothing and returns
// false.
bool _deleteNonEmptySelection(SelectionChangedCause cause) {
// TODO(LongCatIsLooong): remove this method from `RenderEditable`
// https://github.com/flutter/flutter/issues/80226.
assert(!readOnly);
final TextEditingValue controllerValue = textSelectionDelegate.textEditingValue;
final TextSelection selection = controllerValue.selection;
assert(selection.isValid);
if (selection.isCollapsed) {
return false;
}
final String textBefore = selection.textBefore(controllerValue.text);
final String textAfter = selection.textAfter(controllerValue.text);
final TextSelection newSelection = TextSelection.collapsed(offset: selection.start);
final TextRange composing = controllerValue.composing;
final TextRange newComposingRange = !composing.isValid || composing.isCollapsed
? TextRange.empty
: TextRange(
start: composing.start - (composing.start - selection.start).clamp(0, selection.end - selection.start),
end: composing.end - (composing.end - selection.start).clamp(0, selection.end - selection.start),
);
_setTextEditingValue(
TextEditingValue(
text: textBefore + textAfter,
selection: newSelection,
composing: newComposingRange,
),
cause,
);
return true;
}
// 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 selection in [textSelectionDelegate].
///
/// This method operates on the text/selection contained in
/// [textSelectionDelegate], and does not depend on [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) {
// `delete` does not depend on the text layout, and the boundary analysis is
// done using the `previousCharacter` method instead of ICU, we can keep
// deleting without having to layout the text. For this reason, we can
// directly delete the character before the caret in the controller.
//
// TODO(LongCatIsLooong): remove this method from RenderEditable.
// https://github.com/flutter/flutter/issues/80226.
final TextEditingValue controllerValue = textSelectionDelegate.textEditingValue;
final TextSelection selection = controllerValue.selection;
if (!selection.isValid || readOnly || _deleteNonEmptySelection(cause)) {
return;
}
assert(selection.isCollapsed);
final String textBefore = selection.textBefore(controllerValue.text);
if (textBefore.isEmpty) {
return;
}
final String textAfter = selection.textAfter(controllerValue.text);
final int characterBoundary = previousCharacter(textBefore.length, textBefore);
final TextSelection newSelection = TextSelection.collapsed(offset: characterBoundary);
final TextRange composing = controllerValue.composing;
assert(textBefore.length >= characterBoundary);
final TextRange newComposingRange = !composing.isValid || composing.isCollapsed
? TextRange.empty
: TextRange(
start: composing.start - (composing.start - characterBoundary).clamp(0, textBefore.length - characterBoundary),
end: composing.end - (composing.end - characterBoundary).clamp(0, textBefore.length - characterBoundary),
);
_setTextEditingValue(
TextEditingValue(
text: textBefore.substring(0, characterBoundary) + textAfter,
selection: newSelection,
composing: newComposingRange,
),
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 forward direction, from the current selection in
/// [textSelectionDelegate].
///
/// This method operates on the text/selection contained in
/// [textSelectionDelegate], and does not depend on [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) {
// TODO(LongCatIsLooong): remove this method from RenderEditable.
// https://github.com/flutter/flutter/issues/80226.
final TextEditingValue controllerValue = textSelectionDelegate.textEditingValue;
final TextSelection selection = controllerValue.selection;
if (!selection.isValid || _readOnly || _deleteNonEmptySelection(cause)) {
return;
}
assert(selection.isCollapsed);
final String textAfter = selection.textAfter(controllerValue.text);
if (textAfter.isEmpty) {
return;
}
final String textBefore = selection.textBefore(controllerValue.text);
final int characterBoundary = nextCharacter(0, textAfter);
final TextRange composing = controllerValue.composing;
final TextRange newComposingRange = !composing.isValid || composing.isCollapsed
? TextRange.empty
: TextRange(
start: composing.start - (composing.start - textBefore.length).clamp(0, characterBoundary),
end: composing.end - (composing.end - textBefore.length).clamp(0, characterBoundary),
);
_setTextEditingValue(
TextEditingValue(
text: textBefore + textAfter.substring(characterBoundary),
selection: selection,
composing: newComposingRange,
),
cause,
);
}
/// Deletes a word in the forward 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 forward 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.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// See also:
///
/// * [extendSelectionUp], which is same but in the opposite direction.
void extendSelectionDown(SelectionChangedCause cause) {
assert(selection != null);
// If the selection is collapsed at the end of the field already, then
// nothing happens.
if (selection!.isCollapsed && selection!.extentOffset >= _plainText.length) {
return;
}
if (!selectionEnabled) {
return moveSelectionDown(cause);
}
final TextPosition positionBelow = _getTextPositionBelow(selection!.extentOffset);
late final TextSelection nextSelection;
if (positionBelow.offset == selection!.extentOffset) {
nextSelection = selection!.copyWith(
extentOffset: _plainText.length,
);
_wasSelectingVerticallyWithKeyboard = true;
} else if (_wasSelectingVerticallyWithKeyboard) {
nextSelection = selection!.copyWith(
extentOffset: _cursorResetLocation,
);
_wasSelectingVerticallyWithKeyboard = false;
} else {
nextSelection = selection!.copyWith(
extentOffset: positionBelow.offset,
);
_cursorResetLocation = nextSelection.extentOffset;
}
_setSelection(nextSelection, cause);
}
/// Expand the current [selection] to the end of the field.
///
/// The selection will never shrink. The [TextSelection.extentOffset] will
// always be at the end of the field, regardless of the original order of
/// [TextSelection.baseOffset] and [TextSelection.extentOffset].
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// to the end.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// See also:
///
/// * [expandSelectionToStart], which is same but in the opposite direction.
void expandSelectionToEnd(SelectionChangedCause cause) {
assert(selection != null);
if (selection!.extentOffset == _plainText.length) {
return;
}
if (!selectionEnabled) {
return moveSelectionToEnd(cause);
}
final int firstOffset = math.max(0, math.min(
selection!.baseOffset,
selection!.extentOffset,
));
final TextSelection nextSelection = TextSelection(
baseOffset: firstOffset,
extentOffset: _plainText.length,
);
_setSelection(nextSelection, cause);
}
/// Keeping [selection]'s [TextSelection.baseOffset] fixed, move the
/// [TextSelection.extentOffset] left.
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// left.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// See also:
///
/// * [extendSelectionRight], which is same but in the opposite direction.
void extendSelectionLeft(SelectionChangedCause cause) {
assert(selection != null);
if (!selectionEnabled) {
return moveSelectionLeft(cause);
}
final TextSelection nextSelection = _extendGivenSelectionLeft(
selection!,
_plainText,
);
if (nextSelection == selection) {
return;
}
final int distance = selection!.extentOffset - nextSelection.extentOffset;
_cursorResetLocation -= distance;
_setSelection(nextSelection, cause);
}
/// Extend the current [selection] to the start of
/// [TextSelection.extentOffset]'s line.
///
/// Uses [TextSelection.baseOffset] as a pivot point and doesn't change it.
/// If [TextSelection.extentOffset] is right of [TextSelection.baseOffset],
/// then collapses the selection.
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// left by line.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// See also:
///
/// * [extendSelectionRightByLine], which is same but in the opposite
/// direction.
/// * [expandSelectionRightByLine], which strictly grows the selection
/// regardless of the order.
void extendSelectionLeftByLine(SelectionChangedCause cause) {
assert(selection != null);
if (!selectionEnabled) {
return moveSelectionLeftByLine(cause);
}
// When going left, we want to skip over any whitespace before the line,
// so we go back to the first non-whitespace before asking for the line
// bounds, since _getLineAtOffset finds the line boundaries without
// including whitespace (like the newline).
final int startPoint = previousCharacter(selection!.extentOffset, _plainText, false);
final TextSelection selectedLine = _getLineAtOffset(TextPosition(offset: startPoint));
late final TextSelection nextSelection;
if (selection!.extentOffset > selection!.baseOffset) {
nextSelection = selection!.copyWith(
extentOffset: selection!.baseOffset,
);
} else {
nextSelection = selection!.copyWith(
extentOffset: selectedLine.baseOffset,
);
}
_setSelection(nextSelection, cause);
}
/// Keeping [selection]'s [TextSelection.baseOffset] fixed, move the
/// [TextSelection.extentOffset] right.
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// right.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// See also:
///
/// * [extendSelectionLeft], which is same but in the opposite direction.
void extendSelectionRight(SelectionChangedCause cause) {
assert(selection != null);
if (!selectionEnabled) {
return moveSelectionRight(cause);
}
final TextSelection nextSelection = _extendGivenSelectionRight(
selection!,
_plainText,
);
if (nextSelection == selection) {
return;
}
final int distance = nextSelection.extentOffset - selection!.extentOffset;
_cursorResetLocation += distance;
_setSelection(nextSelection, cause);
}
/// Extend the current [selection] to the end of [TextSelection.extentOffset]'s
/// line.
///
/// Uses [TextSelection.baseOffset] as a pivot point and doesn't change it. If
/// [TextSelection.extentOffset] is left of [TextSelection.baseOffset], then
/// collapses the selection.
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// right by line.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// See also:
///
/// * [extendSelectionLeftByLine], which is same but in the opposite
/// direction.
/// * [expandSelectionRightByLine], which strictly grows the selection
/// regardless of the order.
void extendSelectionRightByLine(SelectionChangedCause cause) {
assert(selection != null);
if (!selectionEnabled) {
return moveSelectionRightByLine(cause);
}
final int startPoint = nextCharacter(selection!.extentOffset, _plainText, false);
final TextSelection selectedLine = _getLineAtOffset(TextPosition(offset: startPoint));
late final TextSelection nextSelection;
if (selection!.extentOffset < selection!.baseOffset) {
nextSelection = selection!.copyWith(
extentOffset: selection!.baseOffset,
);
} else {
nextSelection = selection!.copyWith(
extentOffset: selectedLine.extentOffset,
);
}
_setSelection(nextSelection, cause);
}
/// Keeping [selection]'s [TextSelection.baseOffset] fixed, move the
/// [TextSelection.extentOffset] up by one
/// line.
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// up.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// See also:
///
/// * [extendSelectionDown], which is the same but in the opposite
/// direction.
void extendSelectionUp(SelectionChangedCause cause) {
assert(selection != null);
// If the selection is collapsed at the beginning of the field already, then
// nothing happens.
if (selection!.isCollapsed && selection!.extentOffset <= 0.0) {
return;
}
if (!selectionEnabled) {
return moveSelectionUp(cause);
}
final TextPosition positionAbove = _getTextPositionAbove(selection!.extentOffset);
late final TextSelection nextSelection;
if (positionAbove.offset == selection!.extentOffset) {
nextSelection = selection!.copyWith(
extentOffset: 0,
);
_wasSelectingVerticallyWithKeyboard = true;
} else if (_wasSelectingVerticallyWithKeyboard) {
nextSelection = selection!.copyWith(
baseOffset: selection!.baseOffset,
extentOffset: _cursorResetLocation,
);
_wasSelectingVerticallyWithKeyboard = false;
} else {
nextSelection = selection!.copyWith(
baseOffset: selection!.baseOffset,
extentOffset: positionAbove.offset,
);
_cursorResetLocation = nextSelection.extentOffset;
}
_setSelection(nextSelection, cause);
}
/// Expand the current [selection] to the start of the field.
///
/// The selection will never shrink. The [TextSelection.extentOffset] will
/// always be at the start of the field, regardless of the original order of
/// [TextSelection.baseOffset] and [TextSelection.extentOffset].
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// to the start.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// See also:
///
/// * [expandSelectionToEnd], which is the same but in the opposite
/// direction.
void expandSelectionToStart(SelectionChangedCause cause) {
assert(selection != null);
if (selection!.extentOffset == 0) {
return;
}
if (!selectionEnabled) {
return moveSelectionToStart(cause);
}
final int lastOffset = math.max(0, math.max(
selection!.baseOffset,
selection!.extentOffset,
));
final TextSelection nextSelection = TextSelection(
baseOffset: lastOffset,
extentOffset: 0,
);
_setSelection(nextSelection, cause);
}
/// Expand the current [selection] to the start of the line.
///
/// The selection will never shrink. The upper offset will be expanded to the
/// beginning of its line, and the original order of baseOffset and
/// [TextSelection.extentOffset] will be preserved.
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// left by line.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// See also:
///
/// * [expandSelectionRightByLine], which is the same but in the opposite
/// direction.
void expandSelectionLeftByLine(SelectionChangedCause cause) {
assert(selection != null);
if (!selectionEnabled) {
return moveSelectionLeftByLine(cause);
}
// If the lowest edge of the selection is at the start of a line, don't do
// anything.
// TODO(justinmc): Support selection with multiple TextAffinities.
// https://github.com/flutter/flutter/issues/88135
final TextSelection currentLine = _getLineAtOffset(TextPosition(
offset: selection!.start,
affinity: selection!.isCollapsed ? selection!.affinity : TextAffinity.downstream,
));
if (currentLine.baseOffset == selection!.start) {
return;
}
late final TextSelection nextSelection;
if (selection!.extentOffset <= selection!.baseOffset) {
nextSelection = selection!.copyWith(
extentOffset: currentLine.baseOffset,
);
} else {
nextSelection = selection!.copyWith(
baseOffset: currentLine.baseOffset,
);
}
_setSelection(nextSelection, cause);
}
/// Extend the current [selection] to the previous start of a word.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// {@macro flutter.rendering.RenderEditable.whiteSpace}
///
/// {@template flutter.rendering.RenderEditable.stopAtReversal}
/// The `stopAtReversal` parameter is false by default, meaning that it's
/// ok for the base and extent to flip their order here. If set to true, then
/// the selection will collapse when it would otherwise reverse its order. A
/// selection that is already collapsed is not affected by this parameter.
/// {@endtemplate}
///
/// See also:
///
/// * [extendSelectionRightByWord], which is the same but in the opposite
/// direction.
void extendSelectionLeftByWord(SelectionChangedCause cause, [bool includeWhitespace = true, bool stopAtReversal = false]) {
assert(selection != null);
// When the text is obscured, the whole thing is treated as one big word.
if (obscureText) {
return _extendSelectionToStart(cause);
}
assert(
_textLayoutLastMaxWidth == constraints.maxWidth &&
_textLayoutLastMinWidth == constraints.minWidth,
'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).',
);
final TextSelection nextSelection = _extendGivenSelectionLeftByWord(
_textPainter,
selection!,
includeWhitespace,
stopAtReversal,
);
if (nextSelection == selection) {
return;
}
_setSelection(nextSelection, cause);
}
/// Extend the current [selection] to the next end of a word.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// {@macro flutter.rendering.RenderEditable.whiteSpace}
///
/// {@macro flutter.rendering.RenderEditable.stopAtReversal}
///
///
/// See also:
///
/// * [extendSelectionLeftByWord], which is the same but in the opposite
/// direction.
void extendSelectionRightByWord(SelectionChangedCause cause, [bool includeWhitespace = true, bool stopAtReversal = false]) {
assert(selection != null);
// When the text is obscured, the whole thing is treated as one big word.
if (obscureText) {
return _extendSelectionToEnd(cause);
}
assert(
_textLayoutLastMaxWidth == constraints.maxWidth &&
_textLayoutLastMinWidth == constraints.minWidth,
'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).',
);
final TextSelection nextSelection = _extendGivenSelectionRightByWord(
_textPainter,
selection!,
includeWhitespace,
stopAtReversal,
);
if (nextSelection == selection) {
return;
}
_setSelection(nextSelection, cause);
}
/// Expand the current [selection] to the end of the line.
///
/// The selection will never shrink. The lower offset will be expanded to the
/// end of its line and the original order of [TextSelection.baseOffset] and
/// [TextSelection.extentOffset] will be preserved.
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// right by line.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// See also:
///
/// * [expandSelectionLeftByLine], which is the same but in the opposite
/// direction.
void expandSelectionRightByLine(SelectionChangedCause cause) {
assert(selection != null);
if (!selectionEnabled) {
return moveSelectionRightByLine(cause);
}
// If greatest edge is already at the end of a line, don't do anything.
// TODO(justinmc): Support selection with multiple TextAffinities.
// https://github.com/flutter/flutter/issues/88135
final TextSelection currentLine = _getLineAtOffset(TextPosition(
offset: selection!.end,
affinity: selection!.isCollapsed ? selection!.affinity : TextAffinity.upstream,
));
if (currentLine.extentOffset == selection!.end) {
return;
}
final int startPoint = nextCharacter(selection!.end, _plainText, false);
final TextSelection selectedLine = _getLineAtOffset(TextPosition(offset: startPoint));
late final TextSelection nextSelection;
if (selection!.baseOffset <= selection!.extentOffset) {
nextSelection = selection!.copyWith(
extentOffset: selectedLine.extentOffset,
affinity: TextAffinity.upstream,
);
} else {
nextSelection = selection!.copyWith(
baseOffset: selectedLine.extentOffset,
affinity: TextAffinity.upstream,
);
}
_setSelection(nextSelection, cause);
}
/// Move the current [selection] to the next line.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// See also:
///
/// * [moveSelectionUp], which is the same but in the opposite direction.
void moveSelectionDown(SelectionChangedCause cause) {
assert(selection != null);
// If the selection is collapsed at the end of the field already, then
// nothing happens.
if (selection!.isCollapsed && selection!.extentOffset >= _plainText.length) {
return;
}
final TextPosition positionBelow = _getTextPositionBelow(selection!.extentOffset);
late final TextSelection nextSelection;
if (positionBelow.offset == selection!.extentOffset) {
nextSelection = selection!.copyWith(
baseOffset: _plainText.length,
extentOffset: _plainText.length,
);
_wasSelectingVerticallyWithKeyboard = false;
} else {
nextSelection = TextSelection.fromPosition(positionBelow);
_cursorResetLocation = nextSelection.extentOffset;
}
_setSelection(nextSelection, cause);
}
/// Move the current [selection] left by one character.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// See also:
///
/// * [moveSelectionRight], which is the same but in the opposite direction.
void moveSelectionLeft(SelectionChangedCause cause) {
assert(selection != null);
final TextSelection nextSelection = _moveGivenSelectionLeft(
selection!,
_plainText,
);
if (nextSelection == selection) {
return;
}
_cursorResetLocation -= selection!.extentOffset - nextSelection.extentOffset;
_setSelection(nextSelection, cause);
}
/// Move the current [selection] to the leftmost of the current line.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// See also:
///
/// * [moveSelectionRightByLine], which is the same but in the opposite
/// direction.
void moveSelectionLeftByLine(SelectionChangedCause cause) {
assert(selection != null);
// If already at the left edge of the line, do nothing.
final TextSelection currentLine = _getLineAtOffset(selection!.extent);
if (currentLine.baseOffset == selection!.extentOffset) {
return;
}
// When going left, we want to skip over any whitespace before the line,
// so we go back to the first non-whitespace before asking for the line
// bounds, since _getLineAtOffset finds the line boundaries without
// including whitespace (like the newline).
final int startPoint = previousCharacter(selection!.extentOffset, _plainText, false);
final TextSelection selectedLine = _getLineAtOffset(TextPosition(offset: startPoint));
final TextSelection nextSelection = TextSelection.collapsed(
offset: selectedLine.baseOffset,
affinity: TextAffinity.downstream,
);
_setSelection(nextSelection, cause);
}
/// Move the current [selection] to the previous start of a word.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// {@macro flutter.rendering.RenderEditable.whiteSpace}
///
/// See also:
///
/// * [moveSelectionRightByWord], which is the same but in the opposite
/// direction.
void moveSelectionLeftByWord(SelectionChangedCause cause, [bool includeWhitespace = true]) {
assert(selection != null);
// When the text is obscured, the whole thing is treated as one big word.
if (obscureText) {
return moveSelectionToStart(cause);
}
assert(
_textLayoutLastMaxWidth == constraints.maxWidth &&
_textLayoutLastMinWidth == constraints.minWidth,
'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).',
);
final TextSelection nextSelection = _moveGivenSelectionLeftByWord(
_textPainter,
selection!,
includeWhitespace,
);
if (nextSelection == selection) {
return;
}
_setSelection(nextSelection, cause);
_lastCaretRect = onCaretChanged == null ? null : caretRect;
}
/// Move the current [selection] to the right by one character.
/// Whether the [handleEvent] will propagate pointer events to selection
/// handlers.
///
/// {@macro flutter.rendering.RenderEditable.cause}
/// If this property is true, the [handleEvent] assumes that this renderer
/// will be notified of input gestures via [handleTapDown], [handleTap],
/// [handleDoubleTap], and [handleLongPress].
///
/// See also:
/// If there are any gesture recognizers in the text span, the [handleEvent]
/// will still propagate pointer events to those recognizers.
///
/// * [moveSelectionLeft], which is the same but in the opposite direction.
void moveSelectionRight(SelectionChangedCause cause) {
assert(selection != null);
/// The default value of this property is false.
bool ignorePointer;
final TextSelection nextSelection = _moveGivenSelectionRight(
selection!,
_plainText,
);
if (nextSelection == selection) {
/// {@macro flutter.dart:ui.textHeightBehavior}
TextHeightBehavior? get textHeightBehavior => _textPainter.textHeightBehavior;
set textHeightBehavior(TextHeightBehavior? value) {
if (_textPainter.textHeightBehavior == value)
return;
_textPainter.textHeightBehavior = value;
markNeedsTextLayout();
}
_setSelection(nextSelection, cause);
}
/// Move the current [selection] to the rightmost point of the current line.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// See also:
///
/// * [moveSelectionLeftByLine], which is the same but in the opposite
/// direction.
void moveSelectionRightByLine(SelectionChangedCause cause) {
assert(selection != null);
// If already at the right edge of the line, do nothing.
final TextSelection currentLine = _getLineAtOffset(selection!.extent);
if (currentLine.extentOffset == selection!.extentOffset) {
/// {@macro flutter.painting.textPainter.textWidthBasis}
TextWidthBasis get textWidthBasis => _textPainter.textWidthBasis;
set textWidthBasis(TextWidthBasis value) {
assert(value != null);
if (_textPainter.textWidthBasis == value)
return;
_textPainter.textWidthBasis = value;
markNeedsTextLayout();
}
// When going right, we want to skip over any whitespace after the line,
// so we go forward to the first non-whitespace character before asking
// for the line bounds, since _getLineAtOffset finds the line
// boundaries without including whitespace (like the newline).
final int startPoint = nextCharacter(selection!.extentOffset, _plainText, false);
final TextSelection selectedLine = _getLineAtOffset(TextPosition(
offset: startPoint,
affinity: TextAffinity.upstream,
));
final TextSelection nextSelection = TextSelection.collapsed(
offset: selectedLine.extentOffset,
affinity: TextAffinity.upstream,
);
_setSelection(nextSelection, cause);
/// The pixel ratio of the current device.
///
/// Should be obtained by querying MediaQuery for the devicePixelRatio.
double get devicePixelRatio => _devicePixelRatio;
double _devicePixelRatio;
set devicePixelRatio(double value) {
if (devicePixelRatio == value)
return;
_devicePixelRatio = value;
markNeedsTextLayout();
}
/// Move the current [selection] to the next end of a word.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// {@macro flutter.rendering.RenderEditable.whiteSpace}
///
/// See also:
/// Character used for obscuring text if [obscureText] is true.
///
/// * [moveSelectionLeftByWord], which is the same but in the opposite
/// direction.
void moveSelectionRightByWord(SelectionChangedCause cause, [bool includeWhitespace = true]) {
assert(selection != null);
// When the text is obscured, the whole thing is treated as one big word.
if (obscureText) {
return moveSelectionToEnd(cause);
/// Cannot be null, and must have a length of exactly one.
String get obscuringCharacter => _obscuringCharacter;
String _obscuringCharacter;
set obscuringCharacter(String value) {
if (_obscuringCharacter == value) {
return;
}
assert(value != null && value.characters.length == 1);
_obscuringCharacter = value;
markNeedsLayout();
}
assert(
_textLayoutLastMaxWidth == constraints.maxWidth &&
_textLayoutLastMinWidth == constraints.minWidth,
'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).',
);
final TextSelection nextSelection = _moveGivenSelectionRightByWord(
_textPainter,
selection!,
includeWhitespace,
);
if (nextSelection == selection) {
/// Whether to hide the text being edited (e.g., for passwords).
bool get obscureText => _obscureText;
bool _obscureText;
set obscureText(bool value) {
if (_obscureText == value)
return;
}
_setSelection(nextSelection, cause);
_obscureText = value;
markNeedsSemanticsUpdate();
}
/// Move the current [selection] to the end of the field.
///
/// {@macro flutter.rendering.RenderEditable.cause}
///
/// See also:
/// Controls how tall the selection highlight boxes are computed to be.
///
/// * [moveSelectionToStart], which is the same but in the opposite
/// direction.
void moveSelectionToEnd(SelectionChangedCause cause) {
assert(selection != null);
if (selection!.isCollapsed && selection!.extentOffset == _plainText.length) {
return;
/// See [ui.BoxHeightStyle] for details on available styles.
ui.BoxHeightStyle get selectionHeightStyle => _selectionPainter.selectionHeightStyle;
set selectionHeightStyle(ui.BoxHeightStyle value) {
_selectionPainter.selectionHeightStyle = value;
}
final TextSelection nextSelection = TextSelection.collapsed(
offset: _plainText.length,
);
_setSelection(nextSelection, cause);
/// Controls how wide the selection highlight boxes are computed to be.
///
/// See [ui.BoxWidthStyle] for details on available styles.
ui.BoxWidthStyle get selectionWidthStyle => _selectionPainter.selectionWidthStyle;
set selectionWidthStyle(ui.BoxWidthStyle value) {
_selectionPainter.selectionWidthStyle = value;
}
/// Move the current [selection] to the start of the field.
/// The object that controls the text selection, used by this render object
/// for implementing cut, copy, and paste keyboard shortcuts.
///
/// {@macro flutter.rendering.RenderEditable.cause}
/// It must not be null. It will make cut, copy and paste functionality work
/// with the most recently set [TextSelectionDelegate].
TextSelectionDelegate textSelectionDelegate;
/// Track whether position of the start of the selected text is within the viewport.
///
/// See also:
/// For example, if the text contains "Hello World", and the user selects
/// "Hello", then scrolls so only "World" is visible, this will become false.
/// If the user scrolls back so that the "H" is visible again, this will
/// become true.
///
/// * [moveSelectionToEnd], which is the same but in the opposite direction.
void moveSelectionToStart(SelectionChangedCause cause) {
assert(selection != null);
if (selection!.isCollapsed && selection!.extentOffset == 0) {
return;
}
const TextSelection nextSelection = TextSelection.collapsed(offset: 0);
_setSelection(nextSelection, cause);
}
/// This bool indicates whether the text is scrolled so that the handle is
/// inside the text field viewport, as opposed to whether it is actually
/// visible on the screen.
ValueListenable<bool> get selectionStartInViewport => _selectionStartInViewport;
final ValueNotifier<bool> _selectionStartInViewport = ValueNotifier<bool>(true);
/// Move the current [selection] up by one line.
///
/// {@macro flutter.rendering.RenderEditable.cause}
/// Track whether position of the end of the selected text is within the viewport.
///
/// See also:
/// For example, if the text contains "Hello World", and the user selects
/// "World", then scrolls so only "Hello" is visible, this will become
/// 'false'. If the user scrolls back so that the "d" is visible again, this
/// will become 'true'.
///
/// * [moveSelectionDown], which is the same but in the opposite direction.
void moveSelectionUp(SelectionChangedCause cause) {
assert(selection != null);
/// This bool indicates whether the text is scrolled so that the handle is
/// inside the text field viewport, as opposed to whether it is actually
/// visible on the screen.
ValueListenable<bool> get selectionEndInViewport => _selectionEndInViewport;
final ValueNotifier<bool> _selectionEndInViewport = ValueNotifier<bool>(true);
// If the selection is collapsed at the beginning of the field already, then
// nothing happens.
if (selection!.isCollapsed && selection!.extentOffset <= 0.0) {
return;
/// Returns the TextPosition above or below the given offset.
TextPosition _getTextPositionVertical(TextPosition position, double verticalOffset) {
final Offset caretOffset = _textPainter.getOffsetForCaret(position, _caretPrototype);
final Offset caretOffsetTranslated = caretOffset.translate(0.0, verticalOffset);
return _textPainter.getPositionForOffset(caretOffsetTranslated);
}
final TextPosition positionAbove = _getTextPositionAbove(selection!.extentOffset);
late final TextSelection nextSelection;
if (positionAbove.offset == selection!.extentOffset) {
nextSelection = selection!.copyWith(baseOffset: 0, extentOffset: 0);
_wasSelectingVerticallyWithKeyboard = false;
} else {
nextSelection = selection!.copyWith(
baseOffset: positionAbove.offset,
extentOffset: positionAbove.offset,
);
_cursorResetLocation = nextSelection.extentOffset;
}
// Start TextLayoutMetrics.
_setSelection(nextSelection, cause);
/// {@macro flutter.services.TextLayoutMetrics.getLineAtOffset}
@override
TextSelection getLineAtOffset(TextPosition position) {
debugAssertLayoutUpToDate();
final TextRange line = _textPainter.getLineBoundary(position);
// If text is obscured, the entire string should be treated as one line.
if (obscureText) {
return TextSelection(baseOffset: 0, extentOffset: _plainText.length);
}
/// Set the current [selection] to contain the entire text value.
///
/// {@macro flutter.rendering.RenderEditable.cause}
void selectAll(SelectionChangedCause cause) {
textSelectionDelegate.selectAll(cause);
return TextSelection(baseOffset: line.start, extentOffset: line.end);
}
/// Copy current [selection] to [Clipboard].
///
/// {@macro flutter.rendering.RenderEditable.cause}
void copySelection(SelectionChangedCause cause) {
final TextSelection selection = textSelectionDelegate.textEditingValue.selection;
assert(selection != null);
if (selection.isCollapsed) {
return;
/// {@macro flutter.painting.TextPainter.getWordBoundary}
@override
TextRange getWordBoundary(TextPosition position) {
return _textPainter.getWordBoundary(position);
}
textSelectionDelegate.copySelection(cause);
/// {@macro flutter.services.TextLayoutMetrics.getTextPositionAbove}
@override
TextPosition getTextPositionAbove(TextPosition position) {
// The caret offset gives a location in the upper left hand corner of
// the caret so the middle of the line above is a half line above that
// point and the line below is 1.5 lines below that point.
final double preferredLineHeight = _textPainter.preferredLineHeight;
final double verticalOffset = -0.5 * preferredLineHeight;
return _getTextPositionVertical(position, verticalOffset);
}
/// Cut current [selection] to Clipboard.
///
/// {@macro flutter.rendering.RenderEditable.cause}
void cutSelection(SelectionChangedCause cause) {
if (_readOnly) {
return;
/// {@macro flutter.services.TextLayoutMetrics.getTextPositionBelow}
@override
TextPosition getTextPositionBelow(TextPosition position) {
// The caret offset gives a location in the upper left hand corner of
// the caret so the middle of the line above is a half line above that
// point and the line below is 1.5 lines below that point.
final double preferredLineHeight = _textPainter.preferredLineHeight;
final double verticalOffset = 1.5 * preferredLineHeight;
return _getTextPositionVertical(position, verticalOffset);
}
final TextSelection selection = textSelectionDelegate.textEditingValue.selection;
// End TextLayoutMetrics.
void _updateSelectionExtentsVisibility(Offset effectiveOffset) {
assert(selection != null);
if (selection.isCollapsed) {
return;
final Rect visibleRegion = Offset.zero & size;
final Offset startOffset = _textPainter.getOffsetForCaret(
TextPosition(offset: selection!.start, affinity: selection!.affinity),
_caretPrototype,
);
// Check if the selection is visible with an approximation because a
// difference between rounded and unrounded values causes the caret to be
// reported as having a slightly (< 0.5) negative y offset. This rounding
// happens in paragraph.cc's layout and TextPainer's
// _applyFloatingPointHack. Ideally, the rounding mismatch will be fixed and
// this can be changed to be a strict check instead of an approximation.
const double visibleRegionSlop = 0.5;
_selectionStartInViewport.value = visibleRegion
.inflate(visibleRegionSlop)
.contains(startOffset + effectiveOffset);
final Offset endOffset = _textPainter.getOffsetForCaret(
TextPosition(offset: selection!.end, affinity: selection!.affinity),
_caretPrototype,
);
_selectionEndInViewport.value = visibleRegion
.inflate(visibleRegionSlop)
.contains(endOffset + effectiveOffset);
}
textSelectionDelegate.cutSelection(cause);
void _setTextEditingValue(TextEditingValue newValue, SelectionChangedCause cause) {
textSelectionDelegate.textEditingValue = newValue;
textSelectionDelegate.userUpdateTextEditingValue(newValue, cause);
}
/// Paste text from [Clipboard].
///
/// If there is currently a selection, it will be replaced.
///
/// {@macro flutter.rendering.RenderEditable.cause}
Future<void> pasteText(SelectionChangedCause cause) async {
if (_readOnly) {
return;
void _setSelection(TextSelection nextSelection, SelectionChangedCause cause) {
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 = textSelectionDelegate.textEditingValue.text.length;
nextSelection = nextSelection.copyWith(
baseOffset: math.min(nextSelection.baseOffset, textLength),
extentOffset: math.min(nextSelection.extentOffset, textLength),
);
}
final TextSelection selection = textSelectionDelegate.textEditingValue.selection;
assert(selection != null);
if (!selection.isValid) {
return;
_handleSelectionChange(nextSelection, cause);
_setTextEditingValue(
textSelectionDelegate.textEditingValue.copyWith(selection: nextSelection),
cause,
);
}
textSelectionDelegate.pasteText(cause);
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 == selection && cause != SelectionChangedCause.keyboard && !focusingEmpty) {
return;
}
onSelectionChanged?.call(nextSelection, this, cause);
}
@override
......@@ -3008,7 +1411,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
bool _onlyWhitespace(TextRange range) {
for (int i = range.start; i < range.end; i++) {
final int codeUnit = text!.codeUnitAt(i)!;
if (!_isWhitespace(codeUnit)) {
if (!TextLayoutMetrics.isWhitespace(codeUnit)) {
return false;
}
}
......@@ -3486,11 +1889,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
}
TextSelection _getWordAtOffset(TextPosition position) {
assert(
_textLayoutLastMaxWidth == constraints.maxWidth &&
_textLayoutLastMinWidth == constraints.minWidth,
'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).',
);
debugAssertLayoutUpToDate();
final TextRange word = _textPainter.getWordBoundary(position);
// When long-pressing past the end of the text, we want a collapsed cursor.
if (position.offset >= word.end)
......@@ -3505,7 +1904,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
// If the platform is Android and the text is read only, try to select the
// previous word if there is one; otherwise, select the single whitespace at
// the position.
} else if (_isWhitespace(_plainText.codeUnitAt(position.offset))
} else if (TextLayoutMetrics.isWhitespace(_plainText.codeUnitAt(position.offset))
&& position.offset > 0) {
assert(defaultTargetPlatform != null);
final TextRange? previousWord = _getPreviousWord(word.start);
......@@ -3550,20 +1949,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
return TextSelection(baseOffset: word.start, extentOffset: word.end);
}
TextSelection _getLineAtOffset(TextPosition position) {
assert(
_textLayoutLastMaxWidth == constraints.maxWidth &&
_textLayoutLastMinWidth == constraints.minWidth,
'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).',
);
final TextRange line = _textPainter.getLineBoundary(position);
// If text is obscured, the entire string should be treated as one line.
if (obscureText) {
return TextSelection(baseOffset: 0, extentOffset: _plainText.length);
}
return TextSelection(baseOffset: line.start, extentOffset: line.end);
}
// Placeholder dimensions representing the sizes of child inline widgets.
//
// These need to be cached because the text painter's placeholder dimensions
......@@ -3877,11 +2262,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
}
void _paintContents(PaintingContext context, Offset offset) {
assert(
_textLayoutLastMaxWidth == constraints.maxWidth &&
_textLayoutLastMinWidth == constraints.minWidth,
'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).',
);
debugAssertLayoutUpToDate();
final Offset effectiveOffset = offset + _paintOffset;
if (selection != null && !_floatingCursorOn) {
......
......@@ -2,7 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'text_input.dart';
......
......@@ -144,4 +144,82 @@ class TextSelection extends TextRange {
isDirectional: isDirectional ?? this.isDirectional,
);
}
/// Returns the smallest [TextSelection] that this could expand to in order to
/// include the given [TextPosition].
///
/// If the given [TextPosition] is already inside of the selection, then
/// returns `this` without change.
///
/// The returned selection will always be a strict superset of the current
/// selection. In other words, the selection grows to include the given
/// [TextPosition].
///
/// If extentAtIndex is true, then the [TextSelection.extentOffset] will be
/// placed at the given index regardless of the original order of it and
/// [TextSelection.baseOffset]. Otherwise, their order will be preserved.
///
/// ## Difference with [extendTo]
/// In contrast with this method, [extendTo] is a pivot; it holds
/// [TextSelection.baseOffset] fixed while moving [TextSelection.extentOffset]
/// to the given [TextPosition]. It doesn't strictly grow the selection and
/// may collapse it or flip its order.
TextSelection expandTo(TextPosition position, [bool extentAtIndex = false]) {
// If position is already within in the selection, there's nothing to do.
if (position.offset >= start && position.offset <= end) {
return this;
}
final bool normalized = baseOffset <= extentOffset;
if (position.offset <= start) {
// Here the position is somewhere before the selection: ..|..[...]....
if (extentAtIndex) {
return copyWith(
baseOffset: end,
extentOffset: position.offset,
affinity: position.affinity,
);
}
return copyWith(
baseOffset: normalized ? position.offset : baseOffset,
extentOffset: normalized ? extentOffset : position.offset,
);
}
// Here the position is somewhere after the selection: ....[...]..|..
if (extentAtIndex) {
return copyWith(
baseOffset: start,
extentOffset: position.offset,
affinity: position.affinity,
);
}
return copyWith(
baseOffset: normalized ? baseOffset : position.offset,
extentOffset: normalized ? position.offset : extentOffset,
);
}
/// Keeping the selection's [TextSelection.baseOffset] fixed, pivot the
/// [TextSelection.extentOffset] to the given [TextPosition].
///
/// In some cases, the [TextSelection.baseOffset] and
/// [TextSelection.extentOffset] may flip during this operation, or the size
/// of the selection may shrink.
///
/// ## Difference with [expandTo]
/// In contrast with this method, [expandTo] is strictly growth; the
/// selection is grown to include the given [TextPosition] and will never
/// shrink.
TextSelection extendTo(TextPosition position) {
// If the selection's extent is at the position already, then nothing
// happens.
if (extent == position) {
return this;
}
return copyWith(
extentOffset: position.offset,
affinity: position.affinity,
);
}
}
......@@ -9,7 +9,6 @@ import 'dart:ui' show
Offset,
Size,
Rect,
TextAffinity,
TextAlign,
TextDirection,
hashValues;
......@@ -17,7 +16,7 @@ import 'dart:ui' show
import 'package:flutter/foundation.dart';
import 'package:vector_math/vector_math_64.dart' show Matrix4;
import '../../services.dart' show Clipboard, ClipboardData;
import '../../services.dart' show Clipboard;
import 'autofill.dart';
import 'message_codec.dart';
import 'platform_channel.dart';
......@@ -738,19 +737,6 @@ class TextEditingValue {
);
}
/// Returns a representation of this object as a JSON object.
Map<String, dynamic> toJSON() {
return <String, dynamic>{
'text': text,
'selectionBase': selection.baseOffset,
'selectionExtent': selection.extentOffset,
'selectionAffinity': selection.affinity.toString(),
'selectionIsDirectional': selection.isDirectional,
'composingBase': composing.start,
'composingExtent': composing.end,
};
}
/// The current text being edited.
final String text;
......@@ -787,6 +773,19 @@ class TextEditingValue {
/// programming error.
bool get isComposingRangeValid => composing.isValid && composing.isNormalized && composing.end <= text.length;
/// Returns a representation of this object as a JSON object.
Map<String, dynamic> toJSON() {
return <String, dynamic>{
'text': text,
'selectionBase': selection.baseOffset,
'selectionExtent': selection.extentOffset,
'selectionAffinity': selection.affinity.toString(),
'selectionIsDirectional': selection.isDirectional,
'composingBase': composing.start,
'composingExtent': composing.end,
};
}
@override
String toString() => '${objectRuntimeType(this, 'TextEditingValue')}(text: \u2524$text\u251C, selection: $selection, composing: $composing)';
......@@ -902,27 +901,7 @@ mixin TextSelectionDelegate {
///
/// If and only if [cause] is [SelectionChangedCause.toolbar], the toolbar
/// will be hidden and the current selection will be scrolled into view.
void cutSelection(SelectionChangedCause cause) {
final TextSelection selection = textEditingValue.selection;
final String text = textEditingValue.text;
Clipboard.setData(ClipboardData(
text: selection.textInside(text),
));
userUpdateTextEditingValue(
TextEditingValue(
text: selection.textBefore(text) + selection.textAfter(text),
selection: TextSelection.collapsed(
offset: selection.start,
),
),
cause,
);
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
hideToolbar();
}
}
void cutSelection(SelectionChangedCause cause);
/// Paste text from [Clipboard].
///
......@@ -930,84 +909,19 @@ mixin TextSelectionDelegate {
///
/// If and only if [cause] is [SelectionChangedCause.toolbar], the toolbar
/// will be hidden and the current selection will be scrolled into view.
Future<void> pasteText(SelectionChangedCause cause) async {
final TextEditingValue value = textEditingValue;
// 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) {
userUpdateTextEditingValue(
TextEditingValue(
text: value.selection.textBefore(value.text)
+ data.text!
+ value.selection.textAfter(value.text),
selection: TextSelection.collapsed(
offset: value.selection.start + data.text!.length,
),
),
cause,
);
}
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
hideToolbar();
}
}
Future<void> pasteText(SelectionChangedCause cause);
/// Set the current selection to contain the entire text value.
///
/// If and only if [cause] is [SelectionChangedCause.toolbar], the selection
/// will be scrolled into view.
void selectAll(SelectionChangedCause cause) {
userUpdateTextEditingValue(
TextEditingValue(
text: textEditingValue.text,
selection: textEditingValue.selection.copyWith(
baseOffset: 0,
extentOffset: textEditingValue.text.length,
),
),
cause,
);
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
}
}
void selectAll(SelectionChangedCause cause);
/// Copy current selection to [Clipboard].
///
/// If [cause] is [SelectionChangedCause.toolbar], the position of
/// [bringIntoView] to selection will be called and hide toolbar.
void copySelection(SelectionChangedCause cause) {
final TextEditingValue value = textEditingValue;
Clipboard.setData(ClipboardData(
text: value.selection.textInside(value.text),
));
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
hideToolbar(false);
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
break;
case TargetPlatform.macOS:
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
// Collapse the selection and hide the toolbar and handles.
userUpdateTextEditingValue(
TextEditingValue(
text: value.text,
selection: TextSelection.collapsed(offset: value.selection.end),
),
cause,
);
break;
}
}
}
void copySelection(SelectionChangedCause cause);
}
/// An interface to receive information from [TextInput].
......
// 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 'text_editing.dart';
/// A read-only interface for accessing visual information about the
/// implementing text.
abstract class TextLayoutMetrics {
// TODO(gspencergoog): replace when we expose this ICU information.
/// Check if the given code unit is a white space or separator
/// character.
///
/// Includes newline characters from ASCII and separators from the
/// [unicode separator category](https://www.compart.com/en/unicode/category/Zs)
static bool isWhitespace(int codeUnit) {
switch (codeUnit) {
case 0x9: // horizontal tab
case 0xA: // line feed
case 0xB: // vertical tab
case 0xC: // form feed
case 0xD: // carriage return
case 0x1C: // file separator
case 0x1D: // group separator
case 0x1E: // record separator
case 0x1F: // unit separator
case 0x20: // space
case 0xA0: // no-break space
case 0x1680: // ogham space mark
case 0x2000: // en quad
case 0x2001: // em quad
case 0x2002: // en space
case 0x2003: // em space
case 0x2004: // three-per-em space
case 0x2005: // four-er-em space
case 0x2006: // six-per-em space
case 0x2007: // figure space
case 0x2008: // punctuation space
case 0x2009: // thin space
case 0x200A: // hair space
case 0x202F: // narrow no-break space
case 0x205F: // medium mathematical space
case 0x3000: // ideographic space
break;
default:
return false;
}
return true;
}
/// {@template flutter.services.TextLayoutMetrics.getLineAtOffset}
/// Return a [TextSelection] containing the line of the given [TextPosition].
/// {@endtemplate}
TextSelection getLineAtOffset(TextPosition position);
/// {@macro flutter.painting.TextPainter.getWordBoundary}
TextRange getWordBoundary(TextPosition position);
/// {@template flutter.services.TextLayoutMetrics.getTextPositionAbove}
/// Returns the TextPosition above the given offset into the text.
///
/// If the offset is already on the first line, the given offset will be
/// returned.
/// {@endtemplate}
TextPosition getTextPositionAbove(TextPosition position);
/// {@template flutter.services.TextLayoutMetrics.getTextPositionBelow}
/// Returns the TextPosition below the given offset into the text.
///
/// If the offset is already on the last line, the given offset will be
/// returned.
/// {@endtemplate}
TextPosition getTextPositionBelow(TextPosition position);
}
......@@ -89,210 +89,210 @@ class _DoNothingAndStopPropagationTextAction extends TextEditingAction<DoNothing
class _DeleteTextAction extends TextEditingAction<DeleteTextIntent> {
@override
Object? invoke(DeleteTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.delete(SelectionChangedCause.keyboard);
textEditingActionTarget!.delete(SelectionChangedCause.keyboard);
}
}
class _DeleteByWordTextAction extends TextEditingAction<DeleteByWordTextIntent> {
@override
Object? invoke(DeleteByWordTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.deleteByWord(SelectionChangedCause.keyboard, false);
textEditingActionTarget!.deleteByWord(SelectionChangedCause.keyboard, false);
}
}
class _DeleteByLineTextAction extends TextEditingAction<DeleteByLineTextIntent> {
@override
Object? invoke(DeleteByLineTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.deleteByLine(SelectionChangedCause.keyboard);
textEditingActionTarget!.deleteByLine(SelectionChangedCause.keyboard);
}
}
class _DeleteForwardTextAction extends TextEditingAction<DeleteForwardTextIntent> {
@override
Object? invoke(DeleteForwardTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.deleteForward(SelectionChangedCause.keyboard);
textEditingActionTarget!.deleteForward(SelectionChangedCause.keyboard);
}
}
class _DeleteForwardByWordTextAction extends TextEditingAction<DeleteForwardByWordTextIntent> {
@override
Object? invoke(DeleteForwardByWordTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.deleteForwardByWord(SelectionChangedCause.keyboard, false);
textEditingActionTarget!.deleteForwardByWord(SelectionChangedCause.keyboard, false);
}
}
class _DeleteForwardByLineTextAction extends TextEditingAction<DeleteForwardByLineTextIntent> {
@override
Object? invoke(DeleteForwardByLineTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.deleteForwardByLine(SelectionChangedCause.keyboard);
textEditingActionTarget!.deleteForwardByLine(SelectionChangedCause.keyboard);
}
}
class _ExpandSelectionLeftByLineTextAction extends TextEditingAction<ExpandSelectionLeftByLineTextIntent> {
@override
Object? invoke(ExpandSelectionLeftByLineTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.expandSelectionLeftByLine(SelectionChangedCause.keyboard);
textEditingActionTarget!.expandSelectionLeftByLine(SelectionChangedCause.keyboard);
}
}
class _ExpandSelectionRightByLineTextAction extends TextEditingAction<ExpandSelectionRightByLineTextIntent> {
@override
Object? invoke(ExpandSelectionRightByLineTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.expandSelectionRightByLine(SelectionChangedCause.keyboard);
textEditingActionTarget!.expandSelectionRightByLine(SelectionChangedCause.keyboard);
}
}
class _ExpandSelectionToEndTextAction extends TextEditingAction<ExpandSelectionToEndTextIntent> {
@override
Object? invoke(ExpandSelectionToEndTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.expandSelectionToEnd(SelectionChangedCause.keyboard);
textEditingActionTarget!.expandSelectionToEnd(SelectionChangedCause.keyboard);
}
}
class _ExpandSelectionToStartTextAction extends TextEditingAction<ExpandSelectionToStartTextIntent> {
@override
Object? invoke(ExpandSelectionToStartTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.expandSelectionToStart(SelectionChangedCause.keyboard);
textEditingActionTarget!.expandSelectionToStart(SelectionChangedCause.keyboard);
}
}
class _ExtendSelectionDownTextAction extends TextEditingAction<ExtendSelectionDownTextIntent> {
@override
Object? invoke(ExtendSelectionDownTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.extendSelectionDown(SelectionChangedCause.keyboard);
textEditingActionTarget!.extendSelectionDown(SelectionChangedCause.keyboard);
}
}
class _ExtendSelectionLeftByLineTextAction extends TextEditingAction<ExtendSelectionLeftByLineTextIntent> {
@override
Object? invoke(ExtendSelectionLeftByLineTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.extendSelectionLeftByLine(SelectionChangedCause.keyboard);
textEditingActionTarget!.extendSelectionLeftByLine(SelectionChangedCause.keyboard);
}
}
class _ExtendSelectionLeftByWordAndStopAtReversalTextAction extends TextEditingAction<ExtendSelectionLeftByWordAndStopAtReversalTextIntent> {
@override
Object? invoke(ExtendSelectionLeftByWordAndStopAtReversalTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.extendSelectionLeftByWord(SelectionChangedCause.keyboard, false, true);
textEditingActionTarget!.extendSelectionLeftByWord(SelectionChangedCause.keyboard, false, true);
}
}
class _ExtendSelectionLeftByWordTextAction extends TextEditingAction<ExtendSelectionLeftByWordTextIntent> {
@override
Object? invoke(ExtendSelectionLeftByWordTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.extendSelectionLeftByWord(SelectionChangedCause.keyboard, false);
textEditingActionTarget!.extendSelectionLeftByWord(SelectionChangedCause.keyboard, false);
}
}
class _ExtendSelectionLeftTextAction extends TextEditingAction<ExtendSelectionLeftTextIntent> {
@override
Object? invoke(ExtendSelectionLeftTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.extendSelectionLeft(SelectionChangedCause.keyboard);
textEditingActionTarget!.extendSelectionLeft(SelectionChangedCause.keyboard);
}
}
class _ExtendSelectionRightByLineTextAction extends TextEditingAction<ExtendSelectionRightByLineTextIntent> {
@override
Object? invoke(ExtendSelectionRightByLineTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.extendSelectionRightByLine(SelectionChangedCause.keyboard);
textEditingActionTarget!.extendSelectionRightByLine(SelectionChangedCause.keyboard);
}
}
class _ExtendSelectionRightByWordAndStopAtReversalTextAction extends TextEditingAction<ExtendSelectionRightByWordAndStopAtReversalTextIntent> {
@override
Object? invoke(ExtendSelectionRightByWordAndStopAtReversalTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.extendSelectionRightByWord(SelectionChangedCause.keyboard, false, true);
textEditingActionTarget!.extendSelectionRightByWord(SelectionChangedCause.keyboard, false, true);
}
}
class _ExtendSelectionRightByWordTextAction extends TextEditingAction<ExtendSelectionRightByWordTextIntent> {
@override
Object? invoke(ExtendSelectionRightByWordTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.extendSelectionRightByWord(SelectionChangedCause.keyboard, false);
textEditingActionTarget!.extendSelectionRightByWord(SelectionChangedCause.keyboard, false);
}
}
class _ExtendSelectionRightTextAction extends TextEditingAction<ExtendSelectionRightTextIntent> {
@override
Object? invoke(ExtendSelectionRightTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.extendSelectionRight(SelectionChangedCause.keyboard);
textEditingActionTarget!.extendSelectionRight(SelectionChangedCause.keyboard);
}
}
class _ExtendSelectionUpTextAction extends TextEditingAction<ExtendSelectionUpTextIntent> {
@override
Object? invoke(ExtendSelectionUpTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.extendSelectionUp(SelectionChangedCause.keyboard);
textEditingActionTarget!.extendSelectionUp(SelectionChangedCause.keyboard);
}
}
class _MoveSelectionDownTextAction extends TextEditingAction<MoveSelectionDownTextIntent> {
@override
Object? invoke(MoveSelectionDownTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.moveSelectionDown(SelectionChangedCause.keyboard);
textEditingActionTarget!.moveSelectionDown(SelectionChangedCause.keyboard);
}
}
class _MoveSelectionLeftTextAction extends TextEditingAction<MoveSelectionLeftTextIntent> {
@override
Object? invoke(MoveSelectionLeftTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.moveSelectionLeft(SelectionChangedCause.keyboard);
textEditingActionTarget!.moveSelectionLeft(SelectionChangedCause.keyboard);
}
}
class _MoveSelectionRightTextAction extends TextEditingAction<MoveSelectionRightTextIntent> {
@override
Object? invoke(MoveSelectionRightTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.moveSelectionRight(SelectionChangedCause.keyboard);
textEditingActionTarget!.moveSelectionRight(SelectionChangedCause.keyboard);
}
}
class _MoveSelectionUpTextAction extends TextEditingAction<MoveSelectionUpTextIntent> {
@override
Object? invoke(MoveSelectionUpTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.moveSelectionUp(SelectionChangedCause.keyboard);
textEditingActionTarget!.moveSelectionUp(SelectionChangedCause.keyboard);
}
}
class _MoveSelectionLeftByLineTextAction extends TextEditingAction<MoveSelectionLeftByLineTextIntent> {
@override
Object? invoke(MoveSelectionLeftByLineTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.moveSelectionLeftByLine(SelectionChangedCause.keyboard);
textEditingActionTarget!.moveSelectionLeftByLine(SelectionChangedCause.keyboard);
}
}
class _MoveSelectionLeftByWordTextAction extends TextEditingAction<MoveSelectionLeftByWordTextIntent> {
@override
Object? invoke(MoveSelectionLeftByWordTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.moveSelectionLeftByWord(SelectionChangedCause.keyboard, false);
textEditingActionTarget!.moveSelectionLeftByWord(SelectionChangedCause.keyboard, false);
}
}
class _MoveSelectionRightByLineTextAction extends TextEditingAction<MoveSelectionRightByLineTextIntent> {
@override
Object? invoke(MoveSelectionRightByLineTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.moveSelectionRightByLine(SelectionChangedCause.keyboard);
textEditingActionTarget!.moveSelectionRightByLine(SelectionChangedCause.keyboard);
}
}
class _MoveSelectionRightByWordTextAction extends TextEditingAction<MoveSelectionRightByWordTextIntent> {
@override
Object? invoke(MoveSelectionRightByWordTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.moveSelectionRightByWord(SelectionChangedCause.keyboard, false);
textEditingActionTarget!.moveSelectionRightByWord(SelectionChangedCause.keyboard, false);
}
}
class _MoveSelectionToEndTextAction extends TextEditingAction<MoveSelectionToEndTextIntent> {
@override
Object? invoke(MoveSelectionToEndTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.moveSelectionToEnd(SelectionChangedCause.keyboard);
textEditingActionTarget!.moveSelectionToEnd(SelectionChangedCause.keyboard);
}
}
class _MoveSelectionToStartTextAction extends TextEditingAction<MoveSelectionToStartTextIntent> {
@override
Object? invoke(MoveSelectionToStartTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.moveSelectionToStart(SelectionChangedCause.keyboard);
textEditingActionTarget!.moveSelectionToStart(SelectionChangedCause.keyboard);
}
}
......@@ -300,27 +300,27 @@ class _MoveSelectionToStartTextAction extends TextEditingAction<MoveSelectionToS
class _SelectAllTextAction extends TextEditingAction<SelectAllTextIntent> {
@override
Object? invoke(SelectAllTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.selectAll(SelectionChangedCause.keyboard);
textEditingActionTarget!.selectAll(SelectionChangedCause.keyboard);
}
}
class _CopySelectionTextAction extends TextEditingAction<CopySelectionTextIntent> {
@override
Object? invoke(CopySelectionTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.copySelection(SelectionChangedCause.keyboard);
textEditingActionTarget!.copySelection(SelectionChangedCause.keyboard);
}
}
class _CutSelectionTextAction extends TextEditingAction<CutSelectionTextIntent> {
@override
Object? invoke(CutSelectionTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.cutSelection(SelectionChangedCause.keyboard);
textEditingActionTarget!.cutSelection(SelectionChangedCause.keyboard);
}
}
class _PasteTextAction extends TextEditingAction<PasteTextIntent> {
@override
Object? invoke(PasteTextIntent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.pasteText(SelectionChangedCause.keyboard);
textEditingActionTarget!.pasteText(SelectionChangedCause.keyboard);
}
}
......@@ -28,7 +28,7 @@ import 'scroll_controller.dart';
import 'scroll_physics.dart';
import 'scrollable.dart';
import 'text.dart';
import 'text_editing_action.dart';
import 'text_editing_action_target.dart';
import 'text_selection.dart';
import 'ticker_provider.dart';
import 'widget_span.dart';
......@@ -1453,7 +1453,7 @@ class EditableText extends StatefulWidget {
}
/// State for a [EditableText].
class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin<EditableText>, WidgetsBindingObserver, TickerProviderStateMixin<EditableText>, TextSelectionDelegate implements TextInputClient, AutofillClient, TextEditingActionTarget {
class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin<EditableText>, WidgetsBindingObserver, TickerProviderStateMixin<EditableText>, TextSelectionDelegate, TextEditingActionTarget implements TextInputClient, AutofillClient {
Timer? _cursorTimer;
bool _targetCursorVisibility = false;
final ValueNotifier<bool> _cursorVisibilityNotifier = ValueNotifier<bool>(true);
......@@ -1534,6 +1534,133 @@ class EditableTextState extends State<EditableText> 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;
}
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);
}
/// {@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}
@override
void copySelection(SelectionChangedCause cause) {
super.copySelection(cause);
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
hideToolbar(false);
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
break;
case TargetPlatform.macOS:
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
// Collapse the selection and hide the toolbar and handles.
userUpdateTextEditingValue(
TextEditingValue(
text: textEditingValue.text,
selection: TextSelection.collapsed(offset: textEditingValue.selection.end),
),
SelectionChangedCause.toolbar,
);
break;
}
}
}
/// {@macro flutter.widgets.TextEditingActionTarget.cutSelection}
@override
void cutSelection(SelectionChangedCause cause) {
super.cutSelection(cause);
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
hideToolbar();
}
}
/// {@macro flutter.widgets.TextEditingActionTarget.pasteText}
@override
Future<void> pasteText(SelectionChangedCause cause) async {
super.pasteText(cause);
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
hideToolbar();
}
}
/// Select the entire text value.
@override
void selectAll(SelectionChangedCause cause) {
super.selectAll(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
......@@ -2459,7 +2586,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
///
/// This property is typically used to notify the renderer of input gestures
/// when [RenderEditable.ignorePointer] is true.
@override
RenderEditable get renderEditable => _editableKey.currentContext!.findRenderObject()! as RenderEditable;
@override
......
......@@ -2,30 +2,10 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/rendering.dart' show RenderEditable;
import 'actions.dart';
import 'editable_text.dart';
import 'focus_manager.dart';
import 'framework.dart';
/// The recipient of a [TextEditingAction].
///
/// TextEditingActions will only be enabled when an implementer of this class is
/// focused.
///
/// See also:
///
/// * [EditableTextState], which implements this and is the most typical
/// target of a TextEditingAction.
abstract class TextEditingActionTarget {
/// The renderer that handles [TextEditingAction]s.
///
/// See also:
///
/// * [EditableTextState.renderEditable], which overrides this.
RenderEditable get renderEditable;
}
import 'text_editing_action_target.dart';
/// An [Action] related to editing text.
///
......@@ -50,12 +30,14 @@ abstract class TextEditingAction<T extends Intent> extends ContextAction<T> {
@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)) {
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;
return (primaryFocus!.context! as StatefulElement).state
as TextEditingActionTarget;
}
@override
......
// 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:math' as math;
import 'dart:ui' show TextAffinity, TextPosition;
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'
show Clipboard, ClipboardData, TextLayoutMetrics, TextRange;
import 'editable_text.dart';
/// The recipient of a [TextEditingAction].
///
/// TextEditingActions will only be enabled when an implementer of this class is
/// focused.
///
/// See also:
///
/// * [EditableTextState], which implements this and is the most typical
/// target of a TextEditingAction.
abstract class TextEditingActionTarget {
/// Whether the characters in the field are obscured from the user.
///
/// When true, the entire contents of the field are treated as one word.
bool get obscureText;
/// Whether the field currently in a read-only state.
///
/// When true, [textEditingValue]'s text may not be modified, but its selection can be.
bool get readOnly;
/// Whether the [textEditingValue]'s selection can be modified.
bool get selectionEnabled;
/// Provides information about the text that is the target of this action.
///
/// See also:
///
/// * [EditableTextState.renderEditable], which overrides this.
TextLayoutMetrics get textLayoutMetrics;
/// The [TextEditingValue] expressed in this field.
TextEditingValue get textEditingValue;
// Holds the last cursor location the user selected in the case the user tries
// to select vertically past the end or beginning of the field. If they do,
// then we need to keep the old cursor location so that we can go back to it
// if they change their minds. Only used for moving selection up and down in a
// multiline text field when selecting using the keyboard.
int _cursorResetLocation = -1;
// Whether we should reset the location of the cursor in the case the user
// tries to select vertically past the end or beginning of the field. If they
// do, then we need to keep the old cursor location so that we can go back to
// it if they change their minds. Only used for resetting selection up and
// down in a multiline text field when selecting using the keyboard.
bool _wasSelectingVerticallyWithKeyboard = false;
/// Called when assuming that the text layout is in sync with
/// [textEditingValue].
///
/// Can be overridden to assert that this is a valid assumption.
void debugAssertLayoutUpToDate();
/// Returns the index into the string of the next character boundary after the
/// given index.
///
/// The character boundary is determined by the characters package, so
/// surrogate pairs and extended grapheme clusters are considered.
///
/// The index must be between 0 and string.length, inclusive. If given
/// string.length, string.length is returned.
///
/// Setting includeWhitespace to false will only return the index of non-space
/// characters.
@visibleForTesting
static int nextCharacter(int index, String string, [bool includeWhitespace = true]) {
assert(index >= 0 && index <= string.length);
if (index == string.length) {
return string.length;
}
final CharacterRange range = CharacterRange.at(string, 0, index);
// If index is not on a character boundary, return the next character
// boundary.
if (range.current.length != index) {
return range.current.length;
}
range.expandNext();
if (!includeWhitespace) {
range.expandWhile((String character) {
return TextLayoutMetrics.isWhitespace(character.codeUnitAt(0));
});
}
return range.current.length;
}
/// Returns the index into the string of the previous character boundary
/// before the given index.
///
/// The character boundary is determined by the characters package, so
/// surrogate pairs and extended grapheme clusters are considered.
///
/// The index must be between 0 and string.length, inclusive. If index is 0,
/// 0 will be returned.
///
/// Setting includeWhitespace to false will only return the index of non-space
/// characters.
@visibleForTesting
static int previousCharacter(int index, String string, [bool includeWhitespace = true]) {
assert(index >= 0 && index <= string.length);
if (index == 0) {
return 0;
}
final CharacterRange range = CharacterRange.at(string, 0, index);
// If index is not on a character boundary, return the previous character
// boundary.
if (range.current.length != index) {
range.dropLast();
return range.current.length;
}
range.dropLast();
if (!includeWhitespace) {
while (range.currentCharacters.isNotEmpty
&& TextLayoutMetrics.isWhitespace(range.charactersAfter.first.codeUnitAt(0))) {
range.dropLast();
}
}
return range.current.length;
}
/// {@template flutter.widgets.TextEditingActionTarget.setSelection}
/// Called to update the [TextSelection] in the current [TextEditingValue].
/// {@endtemplate}
void setSelection(TextSelection nextSelection, SelectionChangedCause cause) {
if (nextSelection == textEditingValue.selection) {
return;
}
setTextEditingValue(
textEditingValue.copyWith(selection: nextSelection),
cause,
);
}
/// {@template flutter.widgets.TextEditingActionTarget.setTextEditingValue}
/// Called to update the current [TextEditingValue].
/// {@endtemplate}
void setTextEditingValue(TextEditingValue newValue, SelectionChangedCause cause);
// Extend the current selection to the end of the field.
//
// If selectionEnabled is false, keeps the selection collapsed and moves it to
// the end.
//
// See also:
//
// * _extendSelectionToStart
void _extendSelectionToEnd(SelectionChangedCause cause) {
if (textEditingValue.selection.extentOffset == textEditingValue.text.length) {
return;
}
final TextSelection nextSelection = textEditingValue.selection.copyWith(
extentOffset: textEditingValue.text.length,
);
return setSelection(nextSelection, cause);
}
// Extend the current selection to the start of the field.
//
// If selectionEnabled is false, keeps the selection collapsed and moves it to
// the start.
//
// The given [SelectionChangedCause] indicates the cause of this change and
// will be passed to [setSelection].
//
// See also:
//
// * _extendSelectionToEnd
void _extendSelectionToStart(SelectionChangedCause cause) {
if (!selectionEnabled) {
return moveSelectionToStart(cause);
}
setSelection(textEditingValue.selection.extendTo(const TextPosition(
offset: 0,
affinity: TextAffinity.upstream,
)), cause);
}
// Return the offset at the start of the nearest word to the left of the
// given offset.
int _getLeftByWord(int offset, [bool includeWhitespace = true]) {
// If the offset is already all the way left, there is nothing to do.
if (offset <= 0) {
return offset;
}
// If we can just return the start of the text without checking for a word.
if (offset == 1) {
return 0;
}
final int startPoint = previousCharacter(
offset, textEditingValue.text, includeWhitespace);
final TextRange word =
textLayoutMetrics.getWordBoundary(TextPosition(offset: startPoint, affinity: textEditingValue.selection.affinity));
return word.start;
}
/// Return the offset at the end of the nearest word to the right of the given
/// offset.
int _getRightByWord(int offset, [bool includeWhitespace = true]) {
// If the selection is already all the way right, there is nothing to do.
if (offset == textEditingValue.text.length) {
return offset;
}
// If we can just return the end of the text without checking for a word.
if (offset == textEditingValue.text.length - 1 || offset == textEditingValue.text.length) {
return textEditingValue.text.length;
}
final int startPoint = includeWhitespace ||
!TextLayoutMetrics.isWhitespace(textEditingValue.text.codeUnitAt(offset))
? offset
: nextCharacter(offset, textEditingValue.text, includeWhitespace);
final TextRange nextWord =
textLayoutMetrics.getWordBoundary(TextPosition(offset: startPoint, affinity: textEditingValue.selection.affinity));
return nextWord.end;
}
// Deletes the current non-empty selection.
//
// If the selection is currently non-empty, this method deletes the selected
// text. Otherwise this method does nothing.
TextEditingValue _deleteNonEmptySelection() {
assert(textEditingValue.selection.isValid);
assert(!textEditingValue.selection.isCollapsed);
final String textBefore = textEditingValue.selection.textBefore(textEditingValue.text);
final String textAfter = textEditingValue.selection.textAfter(textEditingValue.text);
final TextSelection newSelection = TextSelection.collapsed(
offset: textEditingValue.selection.start,
affinity: textEditingValue.selection.affinity,
);
final TextRange newComposingRange = !textEditingValue.composing.isValid || textEditingValue.composing.isCollapsed
? TextRange.empty
: TextRange(
start: textEditingValue.composing.start - (textEditingValue.composing.start - textEditingValue.selection.start).clamp(0, textEditingValue.selection.end - textEditingValue.selection.start),
end: textEditingValue.composing.end - (textEditingValue.composing.end - textEditingValue.selection.start).clamp(0, textEditingValue.selection.end - textEditingValue.selection.start),
);
return TextEditingValue(
text: textBefore + textAfter,
selection: newSelection,
composing: newComposingRange,
);
}
/// Returns a new TextEditingValue representing a deletion from the current
/// [selection] to the given index, inclusively.
///
/// If the selection is not collapsed, deletes the selection regardless of the
/// given index.
///
/// The composing region, if any, will also be adjusted to remove the deleted
/// characters.
TextEditingValue _deleteTo(TextPosition position) {
assert(textEditingValue.selection != null);
if (!textEditingValue.selection.isValid) {
return textEditingValue;
}
if (!textEditingValue.selection.isCollapsed) {
return _deleteNonEmptySelection();
}
if (position.offset == textEditingValue.selection.extentOffset) {
return textEditingValue;
}
final TextRange deletion = TextRange(
start: math.min(position.offset, textEditingValue.selection.extentOffset),
end: math.max(position.offset, textEditingValue.selection.extentOffset),
);
final String deleted = deletion.textInside(textEditingValue.text);
if (deletion.textInside(textEditingValue.text).isEmpty) {
return textEditingValue;
}
final int charactersDeletedBeforeComposingStart =
(textEditingValue.composing.start - deletion.start).clamp(0, deleted.length);
final int charactersDeletedBeforeComposingEnd =
(textEditingValue.composing.end - deletion.start).clamp(0, deleted.length);
final TextRange nextComposingRange = !textEditingValue.composing.isValid || textEditingValue.composing.isCollapsed
? TextRange.empty
: TextRange(
start: textEditingValue.composing.start - charactersDeletedBeforeComposingStart,
end: textEditingValue.composing.end - charactersDeletedBeforeComposingEnd,
);
return TextEditingValue(
text: deletion.textBefore(textEditingValue.text) + deletion.textAfter(textEditingValue.text),
selection: TextSelection.collapsed(
offset: deletion.start,
affinity: position.affinity,
),
composing: nextComposingRange,
);
}
/// 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.
///
/// If [readOnly] is true, does nothing.
///
/// {@template flutter.widgets.TextEditingActionTarget.cause}
/// The given [SelectionChangedCause] indicates the cause of this change and
/// will be passed to [setSelection].
/// {@endtemplate}
///
/// See also:
///
/// * [deleteForward], which is same but in the opposite direction.
void delete(SelectionChangedCause cause) {
if (readOnly) {
return;
}
// `delete` does not depend on the text layout, and the boundary analysis is
// done using the `previousCharacter` method instead of ICU, we can keep
// deleting without having to layout the text. For this reason, we can
// directly delete the character before the caret in the controller.
final String textBefore = textEditingValue.selection.textBefore(textEditingValue.text);
final int characterBoundary = previousCharacter(
textBefore.length,
textBefore,
);
final TextPosition position = TextPosition(offset: characterBoundary);
setTextEditingValue(_deleteTo(position), 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 [readOnly] is true, does nothing.
///
/// If [obscureText] is true, it treats the whole text content as a single
/// word.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// {@template flutter.widgets.TextEditingActionTarget.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]) {
if (readOnly) {
return;
}
if (obscureText) {
// When the text is obscured, the whole thing is treated as one big line.
return deleteToStart(cause);
}
final String textBefore = textEditingValue.selection.textBefore(textEditingValue.text);
final int characterBoundary =
_getLeftByWord(textBefore.length, includeWhitespace);
final TextEditingValue nextValue = _deleteTo(TextPosition(offset: characterBoundary));
setTextEditingValue(nextValue, 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.
///
/// If [readOnly] is true, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [deleteForwardByLine], which is same but in the opposite direction.
void deleteByLine(SelectionChangedCause cause) {
if (readOnly) {
return;
}
// When there is a line break, line delete shouldn't do anything
final String textBefore = textEditingValue.selection.textBefore(textEditingValue.text);
final bool isPreviousCharacterBreakLine =
textBefore.codeUnitAt(textBefore.length - 1) == 0x0A;
if (isPreviousCharacterBreakLine) {
return;
}
// When the text is obscured, the whole thing is treated as one big line.
if (obscureText) {
return deleteToStart(cause);
}
final TextSelection line = textLayoutMetrics.getLineAtOffset(
TextPosition(offset: textBefore.length - 1),
);
setTextEditingValue(_deleteTo(TextPosition(offset: line.start)), cause);
}
/// Deletes in the forward direction.
///
/// If the selection is collapsed, deletes a single character after the
/// cursor.
///
/// If the selection is not collapsed, deletes the selection.
///
/// If [readOnly] is true, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [delete], which is the same but in the opposite direction.
void deleteForward(SelectionChangedCause cause) {
if (readOnly) {
return;
}
final String textAfter = textEditingValue.selection.textAfter(textEditingValue.text);
final int characterBoundary = nextCharacter(0, textAfter);
setTextEditingValue(_deleteTo(TextPosition(offset: textEditingValue.selection.end + characterBoundary)), cause);
}
/// Deletes a word in the forward 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 [readOnly] is true, does nothing.
///
/// If [obscureText] is true, it treats the whole text content as
/// a single word.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// {@macro flutter.widgets.TextEditingActionTarget.whiteSpace}
///
/// See also:
///
/// * [deleteByWord], which is same but in the opposite direction.
void deleteForwardByWord(SelectionChangedCause cause,
[bool includeWhitespace = true]) {
if (readOnly) {
return;
}
if (obscureText) {
// When the text is obscured, the whole thing is treated as one big word.
return deleteToEnd(cause);
}
final String textBefore = textEditingValue.selection.textBefore(textEditingValue.text);
final int characterBoundary = _getRightByWord(textBefore.length, includeWhitespace);
final TextEditingValue nextValue = _deleteTo(TextPosition(offset: characterBoundary));
setTextEditingValue(nextValue, cause);
}
/// Deletes a line in the forward 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 [readOnly] is true, does nothing.
///
/// If [obscureText] is true, it treats the whole text content as
/// a single word.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [deleteByLine], which is same but in the opposite direction.
void deleteForwardByLine(SelectionChangedCause cause) {
if (readOnly) {
return;
}
if (obscureText) {
// When the text is obscured, the whole thing is treated as one big line.
return deleteToEnd(cause);
}
// When there is a line break, it shouldn't do anything.
final String textAfter = textEditingValue.selection.textAfter(textEditingValue.text);
final bool isNextCharacterBreakLine = textAfter.codeUnitAt(0) == 0x0A;
if (isNextCharacterBreakLine) {
return;
}
final String textBefore = textEditingValue.selection.textBefore(textEditingValue.text);
final TextSelection line = textLayoutMetrics.getLineAtOffset(
TextPosition(offset: textBefore.length),
);
setTextEditingValue(_deleteTo(TextPosition(offset: line.end)), 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 setSelection.
///
/// See also:
/// * [deleteToStart]
void deleteToEnd(SelectionChangedCause cause) {
assert(textEditingValue.selection.isCollapsed);
setTextEditingValue(_deleteTo(TextPosition(offset: textEditingValue.text.length)), 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 setSelection.
///
/// See also:
/// * [deleteToEnd]
void deleteToStart(SelectionChangedCause cause) {
assert(textEditingValue.selection.isCollapsed);
setTextEditingValue(_deleteTo(const TextPosition(offset: 0)), cause);
}
/// Expand the current selection to the end of the field.
///
/// The selection will never shrink. The [TextSelection.extentOffset] will
// always be at the end of the field, regardless of the original order of
/// [TextSelection.baseOffset] and [TextSelection.extentOffset].
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// to the end.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [expandSelectionToStart], which is same but in the opposite direction.
void expandSelectionToEnd(SelectionChangedCause cause) {
if (!selectionEnabled) {
return moveSelectionToEnd(cause);
}
final TextPosition nextPosition = TextPosition(
offset: textEditingValue.text.length,
affinity: TextAffinity.downstream,
);
setSelection(textEditingValue.selection.expandTo(nextPosition, true), cause);
}
/// Expand the current selection to the start of the field.
///
/// The selection will never shrink. The [TextSelection.extentOffset] will
/// always be at the start of the field, regardless of the original order of
/// [TextSelection.baseOffset] and [TextSelection.extentOffset].
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// to the start.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [expandSelectionToEnd], which is the same but in the opposite
/// direction.
void expandSelectionToStart(SelectionChangedCause cause) {
if (!selectionEnabled) {
return moveSelectionToStart(cause);
}
const TextPosition nextPosition = TextPosition(
offset: 0,
affinity: TextAffinity.upstream,
);
setSelection(textEditingValue.selection.expandTo(nextPosition, true), cause);
}
/// Expand the current selection to the smallest selection that includes the
/// start of the line.
///
/// The selection will never shrink. The upper offset will be expanded to the
/// beginning of its line, and the original order of baseOffset and
/// [TextSelection.extentOffset] will be preserved.
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// left by line.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [expandSelectionRightByLine], which is the same but in the opposite
/// direction.
void expandSelectionLeftByLine(SelectionChangedCause cause) {
if (!selectionEnabled) {
return moveSelectionLeftByLine(cause);
}
// If the lowest edge of the selection is at the start of a line, don't do
// anything.
// TODO(justinmc): Support selection with multiple TextAffinities.
// https://github.com/flutter/flutter/issues/88135
final TextSelection currentLine = textLayoutMetrics.getLineAtOffset(
TextPosition(
offset: textEditingValue.selection.start,
affinity: textEditingValue.selection.isCollapsed
? textEditingValue.selection.affinity
: TextAffinity.downstream,
),
);
if (currentLine.baseOffset == textEditingValue.selection.start) {
return;
}
setSelection(textEditingValue.selection.expandTo(TextPosition(
offset: currentLine.baseOffset,
affinity: textEditingValue.selection.affinity,
)), cause);
}
/// Expand the current selection to the smallest selection that includes the
/// end of the line.
///
/// The selection will never shrink. The lower offset will be expanded to the
/// end of its line and the original order of [TextSelection.baseOffset] and
/// [TextSelection.extentOffset] will be preserved.
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// right by line.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [expandSelectionLeftByLine], which is the same but in the opposite
/// direction.
void expandSelectionRightByLine(SelectionChangedCause cause) {
if (!selectionEnabled) {
return moveSelectionRightByLine(cause);
}
// If greatest edge is already at the end of a line, don't do anything.
// TODO(justinmc): Support selection with multiple TextAffinities.
// https://github.com/flutter/flutter/issues/88135
final TextSelection currentLine = textLayoutMetrics.getLineAtOffset(
TextPosition(
offset: textEditingValue.selection.end,
affinity: textEditingValue.selection.isCollapsed
? textEditingValue.selection.affinity
: TextAffinity.upstream,
),
);
if (currentLine.extentOffset == textEditingValue.selection.end) {
return;
}
final TextSelection nextSelection = textEditingValue.selection.expandTo(
TextPosition(
offset: currentLine.extentOffset,
affinity: TextAffinity.upstream,
),
);
setSelection(nextSelection, 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.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [extendSelectionUp], which is same but in the opposite direction.
void extendSelectionDown(SelectionChangedCause cause) {
if (!selectionEnabled) {
return moveSelectionDown(cause);
}
// If the selection is collapsed at the end of the field already, then
// nothing happens.
if (textEditingValue.selection.isCollapsed &&
textEditingValue.selection.extentOffset >= textEditingValue.text.length) {
return;
}
int index =
textLayoutMetrics.getTextPositionBelow(textEditingValue.selection.extent).offset;
if (index == textEditingValue.selection.extentOffset) {
index = textEditingValue.text.length;
_wasSelectingVerticallyWithKeyboard = true;
} else if (_wasSelectingVerticallyWithKeyboard) {
index = _cursorResetLocation;
_wasSelectingVerticallyWithKeyboard = false;
} else {
_cursorResetLocation = index;
}
final TextPosition nextPosition = TextPosition(
offset: index,
affinity: textEditingValue.selection.affinity,
);
setSelection(textEditingValue.selection.extendTo(nextPosition), cause);
}
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// left.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [extendSelectionRight], which is same but in the opposite direction.
void extendSelectionLeft(SelectionChangedCause cause) {
if (!selectionEnabled) {
return moveSelectionLeft(cause);
}
// If the selection is already all the way left, there is nothing to do.
if (textEditingValue.selection.extentOffset <= 0) {
return;
}
final int previousExtent = previousCharacter(
textEditingValue.selection.extentOffset,
textEditingValue.text,
);
final int distance = textEditingValue.selection.extentOffset - previousExtent;
_cursorResetLocation -= distance;
setSelection(textEditingValue.selection.extendTo(TextPosition(offset: previousExtent, affinity: textEditingValue.selection.affinity)), cause);
}
/// Extend the current selection to the start of
/// [TextSelection.extentOffset]'s line.
///
/// Uses [TextSelection.baseOffset] as a pivot point and doesn't change it.
/// If [TextSelection.extentOffset] is right of [TextSelection.baseOffset],
/// then the selection will be collapsed.
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// left by line.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [extendSelectionRightByLine], which is same but in the opposite
/// direction.
/// * [expandSelectionRightByLine], which strictly grows the selection
/// regardless of the order.
void extendSelectionLeftByLine(SelectionChangedCause cause) {
if (!selectionEnabled) {
return moveSelectionLeftByLine(cause);
}
// When going left, we want to skip over any whitespace before the line,
// so we go back to the first non-whitespace before asking for the line
// bounds, since getLineAtOffset finds the line boundaries without
// including whitespace (like the newline).
final int startPoint = previousCharacter(
textEditingValue.selection.extentOffset, textEditingValue.text, false);
final TextSelection selectedLine = textLayoutMetrics.getLineAtOffset(
TextPosition(offset: startPoint),
);
late final TextSelection nextSelection;
// If the extent and base offsets would reverse order, then instead the
// selection collapses.
if (textEditingValue.selection.extentOffset > textEditingValue.selection.baseOffset) {
nextSelection = textEditingValue.selection.copyWith(
extentOffset: textEditingValue.selection.baseOffset,
);
} else {
nextSelection = textEditingValue.selection.extendTo(TextPosition(
offset: selectedLine.baseOffset,
affinity: TextAffinity.downstream,
));
}
setSelection(nextSelection, cause);
}
/// Keeping selection's [TextSelection.baseOffset] fixed, move the
/// [TextSelection.extentOffset] right.
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// right.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [extendSelectionLeft], which is same but in the opposite direction.
void extendSelectionRight(SelectionChangedCause cause) {
if (!selectionEnabled) {
return moveSelectionRight(cause);
}
// If the selection is already all the way right, there is nothing to do.
if (textEditingValue.selection.extentOffset >= textEditingValue.text.length) {
return;
}
final int nextExtent = nextCharacter(
textEditingValue.selection.extentOffset, textEditingValue.text);
final int distance = nextExtent - textEditingValue.selection.extentOffset;
_cursorResetLocation += distance;
setSelection(textEditingValue.selection.extendTo(TextPosition(offset: nextExtent, affinity: textEditingValue.selection.affinity)), cause);
}
/// Extend the current selection to the end of [TextSelection.extentOffset]'s
/// line.
///
/// Uses [TextSelection.baseOffset] as a pivot point and doesn't change it. If
/// [TextSelection.extentOffset] is left of [TextSelection.baseOffset], then
/// collapses the selection.
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// right by line.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [extendSelectionLeftByLine], which is same but in the opposite
/// direction.
/// * [expandSelectionRightByLine], which strictly grows the selection
/// regardless of the order.
void extendSelectionRightByLine(SelectionChangedCause cause) {
if (!selectionEnabled) {
return moveSelectionRightByLine(cause);
}
final int startPoint = nextCharacter(
textEditingValue.selection.extentOffset, textEditingValue.text, false);
final TextSelection selectedLine = textLayoutMetrics.getLineAtOffset(
TextPosition(offset: startPoint),
);
// If the extent and base offsets would reverse order, then instead the
// selection collapses.
late final TextSelection nextSelection;
if (textEditingValue.selection.extentOffset < textEditingValue.selection.baseOffset) {
nextSelection = textEditingValue.selection.copyWith(
extentOffset: textEditingValue.selection.baseOffset,
);
} else {
nextSelection = textEditingValue.selection.extendTo(TextPosition(
offset: selectedLine.extentOffset,
affinity: TextAffinity.upstream,
));
}
setSelection(nextSelection, cause);
}
/// Extend the current selection to the previous start of a word.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// {@macro flutter.widgets.TextEditingActionTarget.whiteSpace}
///
/// {@template flutter.widgets.TextEditingActionTarget.stopAtReversal}
/// The `stopAtReversal` parameter is false by default, meaning that it's
/// ok for the base and extent to flip their order here. If set to true, then
/// the selection will collapse when it would otherwise reverse its order. A
/// selection that is already collapsed is not affected by this parameter.
/// {@endtemplate}
///
/// See also:
///
/// * [extendSelectionRightByWord], which is the same but in the opposite
/// direction.
void extendSelectionLeftByWord(SelectionChangedCause cause,
[bool includeWhitespace = true, bool stopAtReversal = false]) {
// When the text is obscured, the whole thing is treated as one big word.
if (obscureText) {
return _extendSelectionToStart(cause);
}
debugAssertLayoutUpToDate();
// If the selection is already all the way left, there is nothing to do.
if (textEditingValue.selection.isCollapsed && textEditingValue.selection.extentOffset <= 0) {
return;
}
final int leftOffset =
_getLeftByWord(textEditingValue.selection.extentOffset, includeWhitespace);
late final TextSelection nextSelection;
if (stopAtReversal &&
textEditingValue.selection.extentOffset > textEditingValue.selection.baseOffset &&
leftOffset < textEditingValue.selection.baseOffset) {
nextSelection = textEditingValue.selection.extendTo(TextPosition(offset: textEditingValue.selection.baseOffset));
} else {
nextSelection = textEditingValue.selection.extendTo(TextPosition(offset: leftOffset, affinity: textEditingValue.selection.affinity));
}
if (nextSelection == textEditingValue.selection) {
return;
}
setSelection(nextSelection, cause);
}
/// Extend the current selection to the next end of a word.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// {@macro flutter.widgets.TextEditingActionTarget.whiteSpace}
///
/// {@macro flutter.widgets.TextEditingActionTarget.stopAtReversal}
///
/// See also:
///
/// * [extendSelectionLeftByWord], which is the same but in the opposite
/// direction.
void extendSelectionRightByWord(SelectionChangedCause cause,
[bool includeWhitespace = true, bool stopAtReversal = false]) {
debugAssertLayoutUpToDate();
// When the text is obscured, the whole thing is treated as one big word.
if (obscureText) {
return _extendSelectionToEnd(cause);
}
// If the selection is already all the way right, there is nothing to do.
if (textEditingValue.selection.isCollapsed &&
textEditingValue.selection.extentOffset == textEditingValue.text.length) {
return;
}
final int rightOffset =
_getRightByWord(textEditingValue.selection.extentOffset, includeWhitespace);
late final TextSelection nextSelection;
if (stopAtReversal &&
textEditingValue.selection.baseOffset > textEditingValue.selection.extentOffset &&
rightOffset > textEditingValue.selection.baseOffset) {
nextSelection = TextSelection.fromPosition(
TextPosition(offset: textEditingValue.selection.baseOffset),
);
} else {
nextSelection = textEditingValue.selection.extendTo(TextPosition(offset: rightOffset, affinity: textEditingValue.selection.affinity));
}
if (nextSelection == textEditingValue.selection) {
return;
}
setSelection(nextSelection, cause);
}
/// Keeping selection's [TextSelection.baseOffset] fixed, move the
/// [TextSelection.extentOffset] up by one
/// line.
///
/// If [selectionEnabled] is false, keeps the selection collapsed and moves it
/// up.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [extendSelectionDown], which is the same but in the opposite
/// direction.
void extendSelectionUp(SelectionChangedCause cause) {
if (!selectionEnabled) {
return moveSelectionUp(cause);
}
// If the selection is collapsed at the beginning of the field already, then
// nothing happens.
if (textEditingValue.selection.isCollapsed && textEditingValue.selection.extentOffset <= 0.0) {
return;
}
final TextPosition positionAbove =
textLayoutMetrics.getTextPositionAbove(textEditingValue.selection.extent);
late final TextSelection nextSelection;
if (positionAbove.offset == textEditingValue.selection.extentOffset) {
nextSelection = textEditingValue.selection.copyWith(
extentOffset: 0,
affinity: TextAffinity.upstream,
);
_wasSelectingVerticallyWithKeyboard = true;
} else if (_wasSelectingVerticallyWithKeyboard) {
nextSelection = textEditingValue.selection.copyWith(
baseOffset: textEditingValue.selection.baseOffset,
extentOffset: _cursorResetLocation,
);
_wasSelectingVerticallyWithKeyboard = false;
} else {
nextSelection = textEditingValue.selection.copyWith(
baseOffset: textEditingValue.selection.baseOffset,
extentOffset: positionAbove.offset,
affinity: positionAbove.affinity,
);
_cursorResetLocation = nextSelection.extentOffset;
}
setSelection(nextSelection, cause);
}
/// Move the current selection to the leftmost point of the current line.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [moveSelectionRightByLine], which is the same but in the opposite
/// direction.
void moveSelectionLeftByLine(SelectionChangedCause cause) {
// If already at the left edge of the line, do nothing.
final TextSelection currentLine = textLayoutMetrics.getLineAtOffset(
textEditingValue.selection.extent,
);
if (currentLine.baseOffset == textEditingValue.selection.extentOffset) {
return;
}
// When going left, we want to skip over any whitespace before the line,
// so we go back to the first non-whitespace before asking for the line
// bounds, since getLineAtOffset finds the line boundaries without
// including whitespace (like the newline).
final int startPoint = previousCharacter(
textEditingValue.selection.extentOffset, textEditingValue.text, false);
final TextSelection selectedLine = textLayoutMetrics.getLineAtOffset(
TextPosition(offset: startPoint),
);
final TextSelection nextSelection = TextSelection.fromPosition(TextPosition(
offset: selectedLine.baseOffset,
affinity: TextAffinity.downstream,
));
setSelection(nextSelection, cause);
}
/// Move the current selection to the next line.
///
/// Move the current selection to the next line.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [moveSelectionUp], which is the same but in the opposite direction.
void moveSelectionDown(SelectionChangedCause cause) {
// If the selection is collapsed at the end of the field already, then
// nothing happens.
if (textEditingValue.selection.isCollapsed &&
textEditingValue.selection.extentOffset >= textEditingValue.text.length) {
return;
}
final TextPosition positionBelow =
textLayoutMetrics.getTextPositionBelow(textEditingValue.selection.extent);
late final TextSelection nextSelection;
if (positionBelow.offset == textEditingValue.selection.extentOffset) {
nextSelection = textEditingValue.selection.copyWith(
baseOffset: textEditingValue.text.length,
extentOffset: textEditingValue.text.length,
);
} else {
nextSelection = TextSelection.fromPosition(positionBelow);
}
if (textEditingValue.selection.extentOffset == textEditingValue.text.length) {
_wasSelectingVerticallyWithKeyboard = false;
} else {
_cursorResetLocation = nextSelection.extentOffset;
}
setSelection(nextSelection, cause);
}
/// Move the current selection left by one character.
///
/// If it can't be moved left, do nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [moveSelectionRight], which is the same but in the opposite direction.
void moveSelectionLeft(SelectionChangedCause cause) {
// If the selection is already all the way left, there is nothing to do.
if (textEditingValue.selection.isCollapsed && textEditingValue.selection.extentOffset <= 0) {
return;
}
int previousExtent;
if (textEditingValue.selection.start != textEditingValue.selection.end) {
previousExtent = textEditingValue.selection.start;
} else {
previousExtent = previousCharacter(
textEditingValue.selection.extentOffset, textEditingValue.text);
}
final TextSelection nextSelection = TextSelection.fromPosition(
TextPosition(
offset: previousExtent,
affinity: textEditingValue.selection.affinity,
),
);
if (nextSelection == textEditingValue.selection) {
return;
}
_cursorResetLocation -=
textEditingValue.selection.extentOffset - nextSelection.extentOffset;
setSelection(nextSelection, cause);
}
/// Move the current selection to the previous start of a word.
///
/// A TextSelection that isn't collapsed will be collapsed and moved from the
/// extentOffset.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// {@macro flutter.widgets.TextEditingActionTarget.whiteSpace}
///
/// See also:
///
/// * [moveSelectionRightByWord], which is the same but in the opposite
/// direction.
void moveSelectionLeftByWord(SelectionChangedCause cause,
[bool includeWhitespace = true]) {
// When the text is obscured, the whole thing is treated as one big word.
if (obscureText) {
return moveSelectionToStart(cause);
}
debugAssertLayoutUpToDate();
// If the selection is already all the way left, there is nothing to do.
if (textEditingValue.selection.isCollapsed && textEditingValue.selection.extentOffset <= 0) {
return;
}
final int leftOffset =
_getLeftByWord(textEditingValue.selection.extentOffset, includeWhitespace);
final TextSelection nextSelection = TextSelection.fromPosition(TextPosition(offset: leftOffset, affinity: textEditingValue.selection.affinity));
if (nextSelection == textEditingValue.selection) {
return;
}
setSelection(nextSelection, cause);
}
/// Move the current selection to the right by one character.
///
/// If it can't be moved right, do nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [moveSelectionLeft], which is the same but in the opposite direction.
void moveSelectionRight(SelectionChangedCause cause) {
// If the selection is already all the way right, there is nothing to do.
if (textEditingValue.selection.isCollapsed &&
textEditingValue.selection.extentOffset >= textEditingValue.text.length) {
return;
}
int nextExtent;
if (textEditingValue.selection.start != textEditingValue.selection.end) {
nextExtent = textEditingValue.selection.end;
} else {
nextExtent = nextCharacter(
textEditingValue.selection.extentOffset, textEditingValue.text);
}
final TextSelection nextSelection = TextSelection.fromPosition(TextPosition(
offset: nextExtent,
));
if (nextSelection == textEditingValue.selection) {
return;
}
setSelection(nextSelection, cause);
}
/// Move the current selection to the rightmost point of the current line.
///
/// Move the current selection to the rightmost point of the current line.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [moveSelectionLeftByLine], which is the same but in the opposite
/// direction.
void moveSelectionRightByLine(SelectionChangedCause cause) {
// If already at the right edge of the line, do nothing.
final TextSelection currentLine = textLayoutMetrics.getLineAtOffset(
textEditingValue.selection.extent,
);
if (currentLine.extentOffset == textEditingValue.selection.extentOffset) {
return;
}
// When going right, we want to skip over any whitespace after the line,
// so we go forward to the first non-whitespace character before asking
// for the line bounds, since getLineAtOffset finds the line
// boundaries without including whitespace (like the newline).
final int startPoint = nextCharacter(
textEditingValue.selection.extentOffset, textEditingValue.text, false);
final TextSelection selectedLine = textLayoutMetrics.getLineAtOffset(
TextPosition(
offset: startPoint,
affinity: TextAffinity.upstream,
),
);
final TextSelection nextSelection = TextSelection.fromPosition(TextPosition(
offset: selectedLine.extentOffset,
affinity: TextAffinity.upstream,
));
setSelection(nextSelection, cause);
}
/// Move the current selection to the next end of a word.
///
/// A TextSelection that isn't collapsed will be collapsed and moved from the
/// extentOffset.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// {@macro flutter.widgets.TextEditingActionTarget.whiteSpace}
///
/// See also:
///
/// * [moveSelectionLeftByWord], which is the same but in the opposite
/// direction.
void moveSelectionRightByWord(SelectionChangedCause cause,
[bool includeWhitespace = true]) {
// When the text is obscured, the whole thing is treated as one big word.
if (obscureText) {
return moveSelectionToEnd(cause);
}
debugAssertLayoutUpToDate();
// If the selection is already all the way right, there is nothing to do.
if (textEditingValue.selection.isCollapsed &&
textEditingValue.selection.extentOffset == textEditingValue.text.length) {
return;
}
final int rightOffset =
_getRightByWord(textEditingValue.selection.extentOffset, includeWhitespace);
final TextSelection nextSelection = TextSelection.fromPosition(TextPosition(offset: rightOffset, affinity: textEditingValue.selection.affinity));
if (nextSelection == textEditingValue.selection) {
return;
}
setSelection(nextSelection, cause);
}
/// Move the current selection to the end of the field.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [moveSelectionToStart], which is the same but in the opposite
/// direction.
void moveSelectionToEnd(SelectionChangedCause cause) {
final TextPosition nextPosition = TextPosition(
offset: textEditingValue.text.length,
affinity: TextAffinity.downstream,
);
setSelection(TextSelection.fromPosition(nextPosition), cause);
}
/// Move the current selection to the start of the field.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [moveSelectionToEnd], which is the same but in the opposite direction.
void moveSelectionToStart(SelectionChangedCause cause) {
const TextPosition nextPosition = TextPosition(
offset: 0,
affinity: TextAffinity.upstream,
);
setSelection(TextSelection.fromPosition(nextPosition), cause);
}
/// Move the current selection up by one line.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [moveSelectionDown], which is the same but in the opposite direction.
void moveSelectionUp(SelectionChangedCause cause) {
final int nextIndex =
textLayoutMetrics.getTextPositionAbove(textEditingValue.selection.extent).offset;
if (nextIndex == textEditingValue.selection.extentOffset) {
_wasSelectingVerticallyWithKeyboard = false;
return moveSelectionToStart(cause);
}
_cursorResetLocation = nextIndex;
setSelection(TextSelection.fromPosition(TextPosition(offset: nextIndex, affinity: textEditingValue.selection.affinity)), cause);
}
/// Select the entire text value.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
void selectAll(SelectionChangedCause cause) {
setSelection(
textEditingValue.selection.copyWith(
baseOffset: 0,
extentOffset: textEditingValue.text.length,
),
cause,
);
}
/// {@template flutter.widgets.TextEditingActionTarget.copySelection}
/// Copy current selection to [Clipboard].
/// {@endtemplate}
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
void copySelection(SelectionChangedCause 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)));
}
/// {@template flutter.widgets.TextEditingActionTarget.cutSelection}
/// Cut current selection to Clipboard.
/// {@endtemplate}
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
void cutSelection(SelectionChangedCause cause) {
if (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)));
setTextEditingValue(
TextEditingValue(
text: selection.textBefore(text) + selection.textAfter(text),
selection: TextSelection.collapsed(
offset: math.min(selection.start, selection.end),
affinity: selection.affinity,
),
),
cause,
);
}
/// {@template flutter.widgets.TextEditingActionTarget.pasteText}
/// Paste text from [Clipboard].
/// {@endtemplate}
///
/// If there is currently a selection, it will be replaced.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
Future<void> pasteText(SelectionChangedCause cause) async {
if (readOnly) {
return;
}
final TextSelection selection = textEditingValue.selection;
final String text = textEditingValue.text;
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;
}
setTextEditingValue(
TextEditingValue(
text: selection.textBefore(text) +
data.text! +
selection.textAfter(text),
selection: TextSelection.collapsed(
offset:
math.min(selection.start, selection.end) + data.text!.length,
affinity: selection.affinity,
),
),
cause,
);
}
}
......@@ -239,6 +239,7 @@ abstract class TextSelectionControls {
/// by the user.
void handleSelectAll(TextSelectionDelegate delegate) {
delegate.selectAll(SelectionChangedCause.toolbar);
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
}
}
......
......@@ -121,6 +121,7 @@ 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';
......
......@@ -262,7 +262,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));
expect(controller.selection, const TextSelection(baseOffset: 16, extentOffset: 16, affinity: TextAffinity.upstream));
// Cut the first word.
await gesture.down(midBlah1);
......
......@@ -267,7 +267,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));
expect(controller.selection, const TextSelection(baseOffset: 16, extentOffset: 16, affinity: TextAffinity.upstream));
// Cut the first word.
await gesture.down(midBlah1);
......@@ -642,7 +642,7 @@ void main() {
actualNewValue,
const TextEditingValue(
text: '12',
selection: TextSelection.collapsed(offset: 2),
selection: TextSelection.collapsed(offset: 2, affinity: TextAffinity.downstream),
),
);
}, skip: areKeyEventsHandledByPlatform); // [intended] only applies to platforms where we handle key events.
......@@ -1266,7 +1266,6 @@ void main() {
expect(handle.opacity.value, equals(1.0));
});
testWidgets('Long pressing a field with selection 0,0 shows the selection menu', (WidgetTester tester) async {
await tester.pumpWidget(overlay(
child: TextField(
......
......@@ -81,7 +81,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));
expect(controller.selection, const TextSelection(baseOffset: 16, extentOffset: 16, affinity: TextAffinity.upstream));
// Cut the first word.
await gesture.down(midBlah1);
......
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -4693,7 +4693,7 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 0,
affinity: TextAffinity.downstream,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -4764,7 +4764,7 @@ void main() {
const TextSelection(
baseOffset: testText.length,
extentOffset: 0,
affinity: TextAffinity.downstream,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -4786,7 +4786,7 @@ void main() {
equals(
const TextSelection.collapsed(
offset: 0,
affinity: TextAffinity.downstream,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -4810,7 +4810,7 @@ void main() {
const TextSelection(
baseOffset: 10,
extentOffset: 10,
affinity: TextAffinity.downstream,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -4834,7 +4834,7 @@ void main() {
const TextSelection(
baseOffset: 10,
extentOffset: 7,
affinity: TextAffinity.downstream,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -4857,7 +4857,7 @@ void main() {
const TextSelection(
baseOffset: 10,
extentOffset: 4,
affinity: TextAffinity.downstream,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -4880,7 +4880,7 @@ void main() {
const TextSelection(
baseOffset: 4,
extentOffset: 4,
affinity: TextAffinity.downstream,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -4917,7 +4917,7 @@ void main() {
const TextSelection(
baseOffset: 10,
extentOffset: 10,
affinity: TextAffinity.downstream,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -4941,7 +4941,7 @@ void main() {
const TextSelection(
baseOffset: 0,
extentOffset: testText.length,
affinity: TextAffinity.downstream,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -4963,7 +4963,7 @@ void main() {
const TextSelection(
baseOffset: 0,
extentOffset: 0,
affinity: TextAffinity.downstream,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -5372,72 +5372,135 @@ void main() {
targetPlatform: defaultTargetPlatform,
);
late final int afterHomeOffset;
late final int afterEndOffset;
expect(controller.text, equals(testText), reason: 'on $platform');
final TextSelection selectionAfterHome = selection;
// Move back to position 23.
controller.selection = const TextSelection.collapsed(
offset: 23,
affinity: TextAffinity.downstream,
);
await tester.pump();
await sendKeys(
tester,
<LogicalKeyboardKey>[
LogicalKeyboardKey.end,
],
shift: true,
targetPlatform: defaultTargetPlatform,
);
expect(controller.text, equals(testText), reason: 'on $platform');
final TextSelection selectionAfterEnd = selection;
switch (defaultTargetPlatform) {
// These platforms don't handle shift + home/end at all.
case TargetPlatform.android:
case TargetPlatform.iOS:
case TargetPlatform.fuchsia:
afterHomeOffset = 23;
afterEndOffset = 23;
expect(
selectionAfterHome,
equals(
const TextSelection(
baseOffset: 23,
extentOffset: 23,
affinity: TextAffinity.downstream,
),
),
reason: 'on $platform',
);
expect(
selectionAfterEnd,
equals(
const TextSelection(
baseOffset: 23,
extentOffset: 23,
affinity: TextAffinity.downstream,
),
),
reason: 'on $platform',
);
break;
// These platforms go to the line start/end.
// Linux extends to the line start/end.
case TargetPlatform.linux:
case TargetPlatform.windows:
afterHomeOffset = 20;
afterEndOffset = 35;
break;
// Mac goes to the start/end of the document.
case TargetPlatform.macOS:
afterHomeOffset = 0;
afterEndOffset = 72;
expect(
selectionAfterHome,
equals(
const TextSelection(
baseOffset: 23,
extentOffset: 20,
affinity: TextAffinity.downstream,
),
),
reason: 'on $platform',
);
expect(
selectionAfterEnd,
equals(
const TextSelection(
baseOffset: 23,
extentOffset: 35,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
);
break;
}
// Windows expands to the line start/end.
case TargetPlatform.windows:
expect(
selection,
selectionAfterHome,
equals(
TextSelection(
const TextSelection(
baseOffset: 23,
extentOffset: afterHomeOffset,
extentOffset: 20,
affinity: TextAffinity.downstream,
),
),
reason: 'on $platform',
);
expect(controller.text, equals(testText), reason: 'on $platform');
// Move back to position 23.
controller.selection = const TextSelection.collapsed(
offset: 23,
expect(
selectionAfterEnd,
equals(
const TextSelection(
baseOffset: 23,
extentOffset: 35,
affinity: TextAffinity.downstream,
),
),
reason: 'on $platform',
);
await tester.pump();
break;
await sendKeys(
tester,
<LogicalKeyboardKey>[
LogicalKeyboardKey.end,
],
shift: true,
targetPlatform: defaultTargetPlatform,
// Mac goes to the start/end of the document.
case TargetPlatform.macOS:
expect(
selectionAfterHome,
equals(
const TextSelection(
baseOffset: 23,
extentOffset: 0,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
);
expect(
selection,
selectionAfterEnd,
equals(
TextSelection(
const TextSelection(
baseOffset: 23,
extentOffset: afterEndOffset,
extentOffset: 72,
affinity: TextAffinity.downstream,
),
),
reason: 'on $platform',
);
expect(controller.text, equals(testText), reason: 'on $platform');
break;
}
},
skip: kIsWeb, // [intended] on web these keys are handled by the browser.
variant: TargetPlatformVariant.all(),
......@@ -8029,8 +8092,8 @@ void main() {
targetPlatform: defaultTargetPlatform,
);
expect(controller.selection.isCollapsed, false);
expect(controller.selection.baseOffset, 24);
expect(controller.selection.extentOffset, 15);
expect(controller.selection.baseOffset, 15);
expect(controller.selection.extentOffset, 24);
// Set the caret to the start of a line.
controller.selection = const TextSelection(
......@@ -8125,7 +8188,7 @@ void main() {
const TextSelection(
baseOffset: 9,
extentOffset: 0,
affinity: TextAffinity.downstream,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -8148,7 +8211,7 @@ void main() {
const TextSelection(
baseOffset: 9,
extentOffset: 0,
affinity: TextAffinity.downstream,
affinity: TextAffinity.upstream,
),
),
reason: 'on $platform',
......@@ -8729,7 +8792,7 @@ class _MyMoveSelectionRightTextAction extends TextEditingAction<Intent> {
@override
Object? invoke(Intent intent, [BuildContext? context]) {
textEditingActionTarget!.renderEditable.moveSelectionRight(SelectionChangedCause.keyboard);
textEditingActionTarget!.moveSelectionRight(SelectionChangedCause.keyboard);
onInvoke();
}
}
......
......@@ -240,7 +240,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));
expect(controller.selection, const TextSelection(baseOffset: 16, extentOffset: 16, affinity: TextAffinity.upstream));
// Cut the first word.
await gesture.down(midBlah1);
......@@ -1923,10 +1923,9 @@ void main() {
editableTextWidget = tester.widget(find.byType(EditableText).last);
c1 = editableTextWidget.controller;
expect(c1.selection.extentOffset - c1.selection.baseOffset, -6);
expect(c1.selection.extentOffset - c1.selection.baseOffset, -10);
}, variant: KeySimulatorTransitModeVariant.all());
testWidgets('Changing focus test', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
final List<RawKeyEvent> events = <RawKeyEvent>[];
......
// 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';
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,
textAlign: TextAlign.start,
);
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() {
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));
});
});
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);
});
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment