blob: e08f959b86e4e6d5462362c1f26d2452b860bef3 [file] [log] [blame]
// 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 or the selection is invalid, 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;
}
if (!textEditingValue.selection.isValid) {
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 or the selection is invalid, 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 (!textEditingValue.selection.isValid) {
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 or the selection is invalid, 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;
}
if (!textEditingValue.selection.isValid) {
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 or the selection is invalid, 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;
}
if (!textEditingValue.selection.isValid) {
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 or the selection is invalid, 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 (!textEditingValue.selection.isValid) {
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 or the selection is invalid, 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 (!textEditingValue.selection.isValid) {
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.
///
/// If [readOnly] is true or the selection is invalid, does nothing.
///
/// See also:
/// * [deleteToStart]
void deleteToEnd(SelectionChangedCause cause) {
assert(textEditingValue.selection.isCollapsed);
if (readOnly) {
return;
}
if (!textEditingValue.selection.isValid) {
return;
}
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.
///
/// If [readOnly] is true or the selection is invalid, does nothing.
///
/// See also:
/// * [deleteToEnd]
void deleteToStart(SelectionChangedCause cause) {
assert(textEditingValue.selection.isCollapsed);
if (readOnly) {
return;
}
if (!textEditingValue.selection.isValid) {
return;
}
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.
///
/// If the selection is invalid, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [expandSelectionToStart], which is same but in the opposite direction.
void expandSelectionToEnd(SelectionChangedCause cause) {
if (!textEditingValue.selection.isValid) {
return;
}
if (!selectionEnabled) {
return moveSelectionToEnd(cause);
}
final TextPosition nextPosition = TextPosition(
offset: textEditingValue.text.length,
);
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.
///
/// If the selection is invalid, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [expandSelectionToEnd], which is the same but in the opposite
/// direction.
void expandSelectionToStart(SelectionChangedCause cause) {
if (!textEditingValue.selection.isValid) {
return;
}
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.
///
/// If the selection is invalid, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [expandSelectionRightByLine], which is the same but in the opposite
/// direction.
void expandSelectionLeftByLine(SelectionChangedCause cause) {
if (!textEditingValue.selection.isValid) {
return;
}
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.
///
/// If the selection is invalid, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [expandSelectionLeftByLine], which is the same but in the opposite
/// direction.
void expandSelectionRightByLine(SelectionChangedCause cause) {
if (!textEditingValue.selection.isValid) {
return;
}
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.
///
/// If the selection is invalid, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [extendSelectionUp], which is same but in the opposite direction.
void extendSelectionDown(SelectionChangedCause cause) {
if (!textEditingValue.selection.isValid) {
return;
}
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.
///
/// If the selection is invalid, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [extendSelectionRight], which is same but in the opposite direction.
void extendSelectionLeft(SelectionChangedCause cause) {
if (!textEditingValue.selection.isValid) {
return;
}
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.
///
/// If the selection is invalid, does nothing.
///
/// {@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 (!textEditingValue.selection.isValid) {
return;
}
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,
));
}
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.
///
/// If the selection is invalid, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [extendSelectionLeft], which is same but in the opposite direction.
void extendSelectionRight(SelectionChangedCause cause) {
if (!textEditingValue.selection.isValid) {
return;
}
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.
///
/// If the selection is invalid, does nothing.
///
/// {@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 (!textEditingValue.selection.isValid) {
return;
}
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.
///
/// If the selection is invalid, does nothing.
///
/// {@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]) {
if (!textEditingValue.selection.isValid) {
return;
}
// 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.
///
/// If the selection is invalid, does nothing.
///
/// {@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]) {
if (!textEditingValue.selection.isValid) {
return;
}
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.
///
/// If the selection is invalid, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [extendSelectionDown], which is the same but in the opposite
/// direction.
void extendSelectionUp(SelectionChangedCause cause) {
if (!textEditingValue.selection.isValid) {
return;
}
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.
///
/// If the selection is invalid, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [moveSelectionRightByLine], which is the same but in the opposite
/// direction.
void moveSelectionLeftByLine(SelectionChangedCause cause) {
if (!textEditingValue.selection.isValid) {
return;
}
// 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,
));
setSelection(nextSelection, cause);
}
/// Move the current selection to the next line.
///
/// If the selection is invalid, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [moveSelectionUp], which is the same but in the opposite direction.
void moveSelectionDown(SelectionChangedCause cause) {
if (!textEditingValue.selection.isValid) {
return;
}
// 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.
///
/// If the selection is invalid, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [moveSelectionRight], which is the same but in the opposite direction.
void moveSelectionLeft(SelectionChangedCause cause) {
if (!textEditingValue.selection.isValid) {
return;
}
// 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.
///
/// If the selection is invalid, does nothing.
///
/// {@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]) {
if (!textEditingValue.selection.isValid) {
return;
}
// 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 the selection is invalid or 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 (!textEditingValue.selection.isValid) {
return;
}
// 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.
///
/// If the selection is invalid, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [moveSelectionLeftByLine], which is the same but in the opposite
/// direction.
void moveSelectionRightByLine(SelectionChangedCause cause) {
if (!textEditingValue.selection.isValid) {
return;
}
// 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.
///
/// If the selection is invalid, does nothing.
///
/// {@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]) {
if (!textEditingValue.selection.isValid) {
return;
}
// 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,
);
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.
///
/// If the selection is invalid, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
///
/// See also:
///
/// * [moveSelectionDown], which is the same but in the opposite direction.
void moveSelectionUp(SelectionChangedCause cause) {
if (!textEditingValue.selection.isValid) {
return;
}
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}
///
/// If the selection is collapsed or invalid, does nothing.
///
/// {@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 || !selection.isValid) {
return;
}
Clipboard.setData(ClipboardData(text: selection.textInside(text)));
}
/// {@template flutter.widgets.TextEditingActionTarget.cutSelection}
/// Cut current selection to Clipboard.
/// {@endtemplate}
///
/// If [readOnly] is true or the selection is invalid, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
void cutSelection(SelectionChangedCause cause) {
final TextSelection selection = textEditingValue.selection;
if (readOnly || !selection.isValid) {
return;
}
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.
///
/// If [readOnly] is true or the selection is invalid, does nothing.
///
/// {@macro flutter.widgets.TextEditingActionTarget.cause}
Future<void> pasteText(SelectionChangedCause cause) async {
final TextSelection selection = textEditingValue.selection;
if (readOnly || !selection.isValid) {
return;
}
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,
);
}
}