Unverified Commit ffcd32eb authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Move text editing `Action`s to `EditableTextState` (#90684)

parent 035dfc87
...@@ -18,7 +18,7 @@ void main() { ...@@ -18,7 +18,7 @@ void main() {
} }
// This implements a custom phone number input field that handles the // This implements a custom phone number input field that handles the
// [DeleteTextIntent] intent. // [DeleteCharacterIntent] intent.
class DigitInput extends StatefulWidget { class DigitInput extends StatefulWidget {
const DigitInput({ const DigitInput({
Key? key, Key? key,
...@@ -38,9 +38,9 @@ class DigitInput extends StatefulWidget { ...@@ -38,9 +38,9 @@ class DigitInput extends StatefulWidget {
} }
class DigitInputState extends State<DigitInput> { class DigitInputState extends State<DigitInput> {
late final Action<DeleteTextIntent> _deleteTextAction = late final Action<DeleteCharacterIntent> _deleteTextAction =
CallbackAction<DeleteTextIntent>( CallbackAction<DeleteCharacterIntent>(
onInvoke: (DeleteTextIntent intent) { onInvoke: (DeleteCharacterIntent intent) {
// For simplicity we delete everything in the section. // For simplicity we delete everything in the section.
widget.controller.clear(); widget.controller.clear();
}, },
...@@ -50,8 +50,8 @@ class DigitInputState extends State<DigitInput> { ...@@ -50,8 +50,8 @@ class DigitInputState extends State<DigitInput> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Actions( return Actions(
actions: <Type, Action<Intent>>{ actions: <Type, Action<Intent>>{
// Make the default `DeleteTextIntent` handler overridable. // Make the default `DeleteCharacterIntent` handler overridable.
DeleteTextIntent: Action<DeleteTextIntent>.overridable( DeleteCharacterIntent: Action<DeleteCharacterIntent>.overridable(
defaultAction: _deleteTextAction, context: context), defaultAction: _deleteTextAction, context: context),
}, },
child: TextField( child: TextField(
...@@ -79,12 +79,12 @@ class SimpleUSPhoneNumberEntry extends StatefulWidget { ...@@ -79,12 +79,12 @@ class SimpleUSPhoneNumberEntry extends StatefulWidget {
_SimpleUSPhoneNumberEntryState(); _SimpleUSPhoneNumberEntryState();
} }
class _DeleteDigit extends Action<DeleteTextIntent> { class _DeleteDigit extends Action<DeleteCharacterIntent> {
_DeleteDigit(this.state); _DeleteDigit(this.state);
final _SimpleUSPhoneNumberEntryState state; final _SimpleUSPhoneNumberEntryState state;
@override @override
Object? invoke(DeleteTextIntent intent) { Object? invoke(DeleteCharacterIntent intent) {
assert(callingAction != null); assert(callingAction != null);
callingAction?.invoke(intent); callingAction?.invoke(intent);
...@@ -116,7 +116,7 @@ class _SimpleUSPhoneNumberEntryState extends State<SimpleUSPhoneNumberEntry> { ...@@ -116,7 +116,7 @@ class _SimpleUSPhoneNumberEntryState extends State<SimpleUSPhoneNumberEntry> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Actions( return Actions(
actions: <Type, Action<Intent>>{ actions: <Type, Action<Intent>>{
DeleteTextIntent: _DeleteDigit(this), DeleteCharacterIntent: _DeleteDigit(this),
}, },
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
......
...@@ -206,6 +206,7 @@ class TextPainter { ...@@ -206,6 +206,7 @@ class TextPainter {
/// in framework will automatically invoke this method. /// in framework will automatically invoke this method.
void markNeedsLayout() { void markNeedsLayout() {
_paragraph = null; _paragraph = null;
_lineMetricsCache = null;
_previousCaretPosition = null; _previousCaretPosition = null;
_previousCaretPrototype = null; _previousCaretPrototype = null;
} }
...@@ -975,6 +976,7 @@ class TextPainter { ...@@ -975,6 +976,7 @@ class TextPainter {
return _paragraph!.getLineBoundary(position); return _paragraph!.getLineBoundary(position);
} }
List<ui.LineMetrics>? _lineMetricsCache;
/// Returns the full list of [LineMetrics] that describe in detail the various /// Returns the full list of [LineMetrics] that describe in detail the various
/// metrics of each laid out line. /// metrics of each laid out line.
/// ///
...@@ -992,6 +994,6 @@ class TextPainter { ...@@ -992,6 +994,6 @@ class TextPainter {
/// should be invalidated upon the next successful [layout]. /// should be invalidated upon the next successful [layout].
List<ui.LineMetrics> computeLineMetrics() { List<ui.LineMetrics> computeLineMetrics() {
assert(!_debugNeedsLayout); assert(!_debugNeedsLayout);
return _paragraph!.computeLineMetrics(); return _lineMetricsCache ??= _paragraph!.computeLineMetrics();
} }
} }
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
import 'dart:collection'; import 'dart:collection';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui' as ui show TextBox, BoxHeightStyle, BoxWidthStyle, PlaceholderAlignment; import 'dart:ui' as ui show TextBox, BoxHeightStyle, BoxWidthStyle, PlaceholderAlignment, LineMetrics;
import 'package:characters/characters.dart'; import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
...@@ -77,6 +77,154 @@ class TextSelectionPoint { ...@@ -77,6 +77,154 @@ class TextSelectionPoint {
} }
} }
/// The consecutive sequence of [TextPosition]s that the caret should move to
/// when the user navigates the paragraph using the upward arrow key or the
/// downward arrow key.
///
/// {@template flutter.rendering.RenderEditable.verticalArrowKeyMovement}
/// When the user presses the upward arrow key or the downward arrow key, on
/// many platforms (macOS for instance), the caret will move to the previous
/// line or the next line, while maintaining its original horizontal location.
/// When it encounters a shorter line, the caret moves to the closest horizontal
/// location within that line, and restores the original horizontal location
/// when a long enough line is encountered.
///
/// Additionally, the caret will move to the beginning of the document if the
/// upward arrow key is pressed and the caret is already on the first line. If
/// the downward arrow key is pressed next, the caret will restore its original
/// horizontal location and move to the second line. Similarly the caret moves
/// to the end of the document if the downward arrow key is pressed when it's
/// already on the last line.
///
/// Consider a left-aligned paragraph:
/// aa|
/// a
/// aaa
/// where the caret was initially placed at the end of the first line. Pressing
/// the downward arrow key once will move the caret to the end of the second
/// line, and twice the arrow key moves to the third line after the second "a"
/// on that line. Pressing the downward arrow key again, the caret will move to
/// the end of the third line (the end of the document). Pressing the upward
/// arrow key in this state will result in the caret moving to the end of the
/// second line.
///
/// Vertical caret runs are typically interrupted when the layout of the text
/// changes (including when the text itself changes), or when the selection is
/// changed by other input events or programmatically (for example, when the
/// user pressed the left arrow key).
/// {@endtemplate}
///
/// The [movePrevious] method moves the caret location (which is
/// [VerticalCaretMovementRun.current]) to the previous line, and in case
/// the caret is already on the first line, the method does nothing and returns
/// false. Similarly the [moveNext] method moves the caret to the next line, and
/// returns false if the caret is already on the last line.
///
/// If the underlying paragraph's layout changes, [isValid] becomes false and
/// the [VerticalCaretMovementRun] must not be used. The [isValid] property must
/// be checked before calling [movePrevious] and [moveNext], or accessing
/// [current].
class VerticalCaretMovementRun extends BidirectionalIterator<TextPosition> {
VerticalCaretMovementRun._(
this._editable,
this._lineMetrics,
this._currentTextPosition,
this._currentLine,
this._currentOffset,
);
Offset _currentOffset;
int _currentLine;
TextPosition _currentTextPosition;
final List<ui.LineMetrics> _lineMetrics;
final RenderEditable _editable;
bool _isValid = true;
/// Whether this [VerticalCaretMovementRun] can still continue.
///
/// A [VerticalCaretMovementRun] run is valid if the underlying text layout
/// hasn't changed.
///
/// The [current] value and the [movePrevious] and [moveNext] methods must not
/// be accessed when [isValid] is false.
bool get isValid {
if (!_isValid) {
return false;
}
final List<ui.LineMetrics> newLineMetrics = _editable._textPainter.computeLineMetrics();
// Use the implementation detail of the computeLineMetrics method to figure
// out if the current text layout has been invalidated.
if (!identical(newLineMetrics, _lineMetrics)) {
_isValid = false;
}
return _isValid;
}
// Computes the vertical distance from the `from` line's bottom to the `to`
// lines top.
double _lineDistance(int from, int to) {
double lineHeight = 0;
for (int index = from + 1; index < to; index += 1) {
lineHeight += _lineMetrics[index].height;
}
return lineHeight;
}
final Map<int, MapEntry<Offset, TextPosition>> _positionCache = <int, MapEntry<Offset, TextPosition>>{};
MapEntry<Offset, TextPosition> _getTextPositionForLine(int lineNumber) {
assert(isValid);
assert(lineNumber >= 0);
final MapEntry<Offset, TextPosition>? cachedPosition = _positionCache[lineNumber];
if (cachedPosition != null) {
return cachedPosition;
}
assert(lineNumber != _currentLine);
final double distanceY = lineNumber > _currentLine
? _lineMetrics[_currentLine].descent + _lineMetrics[lineNumber].ascent + _lineDistance(_currentLine, lineNumber)
: - _lineMetrics[_currentLine].ascent - _lineMetrics[lineNumber].descent - _lineDistance(lineNumber, _currentLine);
final Offset newOffset = _currentOffset.translate(0, distanceY);
final TextPosition closestPosition = _editable._textPainter.getPositionForOffset(newOffset);
final MapEntry<Offset, TextPosition> position = MapEntry<Offset, TextPosition>(newOffset, closestPosition);
_positionCache[lineNumber] = position;
return position;
}
@override
TextPosition get current {
assert(isValid);
return _currentTextPosition;
}
@override
bool moveNext() {
assert(isValid);
if (_currentLine + 1 >= _lineMetrics.length) {
return false;
}
final MapEntry<Offset, TextPosition> position = _getTextPositionForLine(_currentLine + 1);
_currentLine += 1;
_currentOffset = position.key;
_currentTextPosition = position.value;
return true;
}
@override
bool movePrevious() {
assert(isValid);
if (_currentLine <= 0) {
return false;
}
final MapEntry<Offset, TextPosition> position = _getTextPositionForLine(_currentLine - 1);
_currentLine -= 1;
_currentOffset = position.key;
_currentTextPosition = position.value;
return true;
}
}
/// Displays some text in a scrollable container with a potentially blinking /// Displays some text in a scrollable container with a potentially blinking
/// cursor and with gesture recognizers. /// cursor and with gesture recognizers.
/// ///
...@@ -2266,6 +2414,49 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ...@@ -2266,6 +2414,49 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
_caretPainter.showRegularCaret = _resetFloatingCursorAnimationValue == null; _caretPainter.showRegularCaret = _resetFloatingCursorAnimationValue == null;
} }
MapEntry<int, Offset> _lineNumberFor(TextPosition startPosition, List<ui.LineMetrics> metrics) {
// TODO(LongCatIsLooong): include line boundaries information in
// ui.LineMetrics, then we can get rid of this.
final Offset offset = _textPainter.getOffsetForCaret(startPosition, Rect.zero);
int line = 0;
double accumulatedHeight = 0;
for (final ui.LineMetrics lineMetrics in metrics) {
if (accumulatedHeight + lineMetrics.height > offset.dy) {
return MapEntry<int, Offset>(line, Offset(offset.dx, lineMetrics.baseline));
}
line += 1;
accumulatedHeight += lineMetrics.height;
}
assert(false, 'unable to find the line for $startPosition');
return MapEntry<int, Offset>(math.max(0, metrics.length - 1), Offset(offset.dx, accumulatedHeight));
}
/// Starts a [VerticalCaretMovementRun] at the given location in the text, for
/// handling consecutive vertical caret movements.
///
/// This can be used to handle consecutive upward/downward arrow key movements
/// in an input field.
///
/// {@macro flutter.rendering.RenderEditable.verticalArrowKeyMovement}
///
/// The [VerticalCaretMovementRun.isValid] property indicates whether the text
/// layout has changed and the vertical caret run is invalidated.
///
/// The caller should typically discard a [VerticalCaretMovementRun] when
/// its [VerticalCaretMovementRun.isValid] becomes false, or on other
/// occasions where the vertical caret run should be interrupted.
VerticalCaretMovementRun startVerticalCaretMovement(TextPosition startPosition) {
final List<ui.LineMetrics> metrics = _textPainter.computeLineMetrics();
final MapEntry<int, Offset> currentLine = _lineNumberFor(startPosition, metrics);
return VerticalCaretMovementRun._(
this,
metrics,
startPosition,
currentLine.key,
currentLine.value,
);
}
void _paintContents(PaintingContext context, Offset offset) { void _paintContents(PaintingContext context, Offset offset) {
debugAssertLayoutUpToDate(); debugAssertLayoutUpToDate();
final Offset effectiveOffset = offset + _paintOffset; final Offset effectiveOffset = offset + _paintOffset;
......
...@@ -80,8 +80,31 @@ class TextSelection extends TextRange { ...@@ -80,8 +80,31 @@ class TextSelection extends TextRange {
/// The position at which the selection originates. /// The position at which the selection originates.
/// ///
/// {@template flutter.services.TextSelection.TextAffinity}
/// The [TextAffinity] of the resulting [TextPosition] is based on the
/// relative logical position in the text to the other selection endpoint:
/// * if [baseOffset] < [extentOffset], [base] will have
/// [TextAffinity.downstream] and [extent] will have
/// [TextAffinity.upstream].
/// * if [baseOffset] > [extentOffset], [base] will have
/// [TextAffinity.upstream] and [extent] will have
/// [TextAffinity.downstream].
/// * if [baseOffset] == [extentOffset], [base] and [extent] will both have
/// the collapsed selection's [affinity].
/// {@endtemplate}
///
/// Might be larger than, smaller than, or equal to extent. /// Might be larger than, smaller than, or equal to extent.
TextPosition get base => TextPosition(offset: baseOffset, affinity: affinity); TextPosition get base {
final TextAffinity affinity;
if (!isValid || baseOffset == extentOffset) {
affinity = this.affinity;
} else if (baseOffset < extentOffset) {
affinity = TextAffinity.downstream;
} else {
affinity = TextAffinity.upstream;
}
return TextPosition(offset: baseOffset, affinity: affinity);
}
/// The position at which the selection terminates. /// The position at which the selection terminates.
/// ///
...@@ -89,8 +112,20 @@ class TextSelection extends TextRange { ...@@ -89,8 +112,20 @@ class TextSelection extends TextRange {
/// value that changes. Similarly, if the current theme paints a caret on one /// value that changes. Similarly, if the current theme paints a caret on one
/// side of the selection, this is the location at which to paint the caret. /// side of the selection, this is the location at which to paint the caret.
/// ///
/// {@macro flutter.services.TextSelection.TextAffinity}
///
/// Might be larger than, smaller than, or equal to base. /// Might be larger than, smaller than, or equal to base.
TextPosition get extent => TextPosition(offset: extentOffset, affinity: affinity); TextPosition get extent {
final TextAffinity affinity;
if (!isValid || baseOffset == extentOffset) {
affinity = this.affinity;
} else if (baseOffset < extentOffset) {
affinity = TextAffinity.upstream;
} else {
affinity = TextAffinity.downstream;
}
return TextPosition(offset: extentOffset, affinity: affinity);
}
@override @override
String toString() { String toString() {
......
...@@ -834,6 +834,55 @@ class TextEditingValue { ...@@ -834,6 +834,55 @@ class TextEditingValue {
/// programming error. /// programming error.
bool get isComposingRangeValid => composing.isValid && composing.isNormalized && composing.end <= text.length; bool get isComposingRangeValid => composing.isValid && composing.isNormalized && composing.end <= text.length;
/// Returns a new [TextEditingValue], which is this [TextEditingValue] with
/// its [text] partially replaced by the `replacementString`.
///
/// The `replacementRange` parameter specifies the range of the
/// [TextEditingValue.text] that needs to be replaced.
///
/// The `replacementString` parameter specifies the string to replace the
/// given range of text with.
///
/// This method also adjusts the selection range and the composing range of the
/// resulting [TextEditingValue], such that they point to the same substrings
/// as the correspoinding ranges in the original [TextEditingValue]. For
/// example, if the original [TextEditingValue] is "Hello world" with the word
/// "world" selected, replacing "Hello" with a different string using this
/// method will not change the selected word.
///
/// This method does nothing if the given `replacementRange` is not
/// [TextRange.isValid].
TextEditingValue replaced(TextRange replacementRange, String replacementString) {
if (!replacementRange.isValid) {
return this;
}
final String newText = text.replaceRange(replacementRange.start, replacementRange.end, replacementString);
if (replacementRange.end - replacementRange.start == replacementString.length) {
return copyWith(text: newText);
}
int adjustIndex(int originalIndex) {
// The length added by adding the replacementString.
final int replacedLength = originalIndex <= replacementRange.start && originalIndex < replacementRange.end ? 0 : replacementString.length;
// The length removed by removing the replacementRange.
final int removedLength = originalIndex.clamp(replacementRange.start, replacementRange.end) - replacementRange.start;
return originalIndex + replacedLength - removedLength;
}
return TextEditingValue(
text: newText,
selection: TextSelection(
baseOffset: adjustIndex(selection.baseOffset),
extentOffset: adjustIndex(selection.extentOffset),
),
composing: TextRange(
start: adjustIndex(composing.start),
end: adjustIndex(composing.end),
),
);
}
/// Returns a representation of this object as a JSON object. /// Returns a representation of this object as a JSON object.
Map<String, dynamic> toJSON() { Map<String, dynamic> toJSON() {
return <String, dynamic>{ return <String, dynamic>{
......
...@@ -142,8 +142,8 @@ abstract class Action<T extends Intent> with Diagnosticable { ...@@ -142,8 +142,8 @@ abstract class Action<T extends Intent> with Diagnosticable {
/// ///
/// {@tool dartpad} /// {@tool dartpad}
/// This sample implements a custom text input field that handles the /// This sample implements a custom text input field that handles the
/// [DeleteTextIntent] intent, as well as a US telephone number input widget /// [DeleteCharacterIntent] intent, as well as a US telephone number input
/// that consists of multiple text fields for area code, prefix and line /// widget that consists of multiple text fields for area code, prefix and line
/// number. When the backspace key is pressed, the phone number input widget /// number. When the backspace key is pressed, the phone number input widget
/// sends the focus to the preceding text field when the currently focused /// sends the focus to the preceding text field when the currently focused
/// field becomes empty. /// field becomes empty.
......
...@@ -12,7 +12,6 @@ import 'actions.dart'; ...@@ -12,7 +12,6 @@ import 'actions.dart';
import 'banner.dart'; import 'banner.dart';
import 'basic.dart'; import 'basic.dart';
import 'binding.dart'; import 'binding.dart';
import 'default_text_editing_actions.dart';
import 'default_text_editing_shortcuts.dart'; import 'default_text_editing_shortcuts.dart';
import 'focus_traversal.dart'; import 'focus_traversal.dart';
import 'framework.dart'; import 'framework.dart';
...@@ -1053,9 +1052,6 @@ class WidgetsApp extends StatefulWidget { ...@@ -1053,9 +1052,6 @@ class WidgetsApp extends StatefulWidget {
/// the [actions] for this app. You may also add to the bindings, or override /// the [actions] for this app. You may also add to the bindings, or override
/// specific bindings for a widget subtree, by adding your own [Actions] /// specific bindings for a widget subtree, by adding your own [Actions]
/// widget. /// widget.
///
/// Passing this will not replace [DefaultTextEditingActions]. These can be
/// overridden by placing an [Actions] widget lower in the widget tree.
/// {@endtemplate} /// {@endtemplate}
/// ///
/// {@tool snippet} /// {@tool snippet}
...@@ -1676,7 +1672,6 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver { ...@@ -1676,7 +1672,6 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
child: DefaultTextEditingShortcuts( child: DefaultTextEditingShortcuts(
child: Actions( child: Actions(
actions: widget.actions ?? WidgetsApp.defaultActions, actions: widget.actions ?? WidgetsApp.defaultActions,
child: DefaultTextEditingActions(
child: FocusTraversalGroup( child: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(), policy: ReadingOrderTraversalPolicy(),
child: child, child: child,
...@@ -1684,7 +1679,6 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver { ...@@ -1684,7 +1679,6 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
), ),
), ),
), ),
),
); );
} }
} }
...@@ -512,16 +512,12 @@ class _AutocompleteCallbackAction<T extends Intent> extends CallbackAction<T> { ...@@ -512,16 +512,12 @@ class _AutocompleteCallbackAction<T extends Intent> extends CallbackAction<T> {
} }
/// An [Intent] to highlight the previous option in the autocomplete list. /// An [Intent] to highlight the previous option in the autocomplete list.
///
/// {@macro flutter.widgets.TextEditingIntents.seeAlso}
class AutocompletePreviousOptionIntent extends Intent { class AutocompletePreviousOptionIntent extends Intent {
/// Creates an instance of AutocompletePreviousOptionIntent. /// Creates an instance of AutocompletePreviousOptionIntent.
const AutocompletePreviousOptionIntent(); const AutocompletePreviousOptionIntent();
} }
/// An [Intent] to highlight the next option in the autocomplete list. /// An [Intent] to highlight the next option in the autocomplete list.
///
/// {@macro flutter.widgets.TextEditingIntents.seeAlso}
class AutocompleteNextOptionIntent extends Intent { class AutocompleteNextOptionIntent extends Intent {
/// Creates an instance of AutocompleteNextOptionIntent. /// Creates an instance of AutocompleteNextOptionIntent.
const AutocompleteNextOptionIntent(); const AutocompleteNextOptionIntent();
......
...@@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart'; ...@@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart';
import 'actions.dart'; import 'actions.dart';
import 'basic.dart'; import 'basic.dart';
import 'editable_text.dart';
import 'focus_manager.dart'; import 'focus_manager.dart';
import 'focus_scope.dart'; import 'focus_scope.dart';
import 'framework.dart'; import 'framework.dart';
...@@ -1722,9 +1721,18 @@ class DirectionalFocusIntent extends Intent { ...@@ -1722,9 +1721,18 @@ class DirectionalFocusIntent extends Intent {
/// [LogicalKeyboardKey.arrowLeft], and [LogicalKeyboardKey.arrowRight] keys in /// [LogicalKeyboardKey.arrowLeft], and [LogicalKeyboardKey.arrowRight] keys in
/// the [WidgetsApp], with the appropriate associated directions. /// the [WidgetsApp], with the appropriate associated directions.
class DirectionalFocusAction extends Action<DirectionalFocusIntent> { class DirectionalFocusAction extends Action<DirectionalFocusIntent> {
/// Creates a [DirectionalFocusAction].
DirectionalFocusAction() : _isForTextField = false;
/// Creates a [DirectionalFocusAction] that ignores [DirectionalFocusIntent]s
/// whose `ignoreTextFields` field is true.
DirectionalFocusAction.forTextField() : _isForTextField = true;
// Whether this action is defined in a text field.
final bool _isForTextField;
@override @override
void invoke(DirectionalFocusIntent intent) { void invoke(DirectionalFocusIntent intent) {
if (!intent.ignoreTextFields || primaryFocus!.context!.widget is! EditableText) { if (!intent.ignoreTextFields || !_isForTextField) {
primaryFocus!.focusInDirection(intent.direction); primaryFocus!.focusInDirection(intent.direction);
} }
} }
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'actions.dart';
import 'focus_manager.dart';
import 'framework.dart';
import 'text_editing_action_target.dart';
/// An [Action] related to editing text.
///
/// Enables itself only when a [TextEditingActionTarget], e.g. [EditableText],
/// is currently focused. The result of this is that when a
/// TextEditingActionTarget is not focused, it will fall through to any
/// non-TextEditingAction that handles the same shortcut. For example,
/// overriding the tab key in [Shortcuts] with a TextEditingAction will only
/// invoke your TextEditingAction when a TextEditingActionTarget is focused,
/// otherwise the default tab behavior will apply.
///
/// The currently focused TextEditingActionTarget is available in the [invoke]
/// method via [textEditingActionTarget].
///
/// See also:
///
/// * [CallbackAction], which is a similar Action type but unrelated to text
/// editing.
abstract class TextEditingAction<T extends Intent> extends ContextAction<T> {
/// Returns the currently focused [TextEditingAction], or null if none is
/// focused.
@protected
TextEditingActionTarget? get textEditingActionTarget {
// If a TextEditingActionTarget is not focused, then ignore this action.
if (primaryFocus?.context == null ||
primaryFocus!.context! is! StatefulElement ||
((primaryFocus!.context! as StatefulElement).state
is! TextEditingActionTarget)) {
return null;
}
return (primaryFocus!.context! as StatefulElement).state
as TextEditingActionTarget;
}
@override
bool isEnabled(T intent) {
// The Action is disabled if there is no focused TextEditingActionTarget.
return textEditingActionTarget != null;
}
}
...@@ -33,7 +33,6 @@ export 'src/widgets/bottom_navigation_bar_item.dart'; ...@@ -33,7 +33,6 @@ export 'src/widgets/bottom_navigation_bar_item.dart';
export 'src/widgets/color_filter.dart'; export 'src/widgets/color_filter.dart';
export 'src/widgets/container.dart'; export 'src/widgets/container.dart';
export 'src/widgets/debug.dart'; export 'src/widgets/debug.dart';
export 'src/widgets/default_text_editing_actions.dart';
export 'src/widgets/default_text_editing_shortcuts.dart'; export 'src/widgets/default_text_editing_shortcuts.dart';
export 'src/widgets/desktop_text_selection_toolbar_layout_delegate.dart'; export 'src/widgets/desktop_text_selection_toolbar_layout_delegate.dart';
export 'src/widgets/dismissible.dart'; export 'src/widgets/dismissible.dart';
...@@ -120,8 +119,6 @@ export 'src/widgets/spacer.dart'; ...@@ -120,8 +119,6 @@ export 'src/widgets/spacer.dart';
export 'src/widgets/status_transitions.dart'; export 'src/widgets/status_transitions.dart';
export 'src/widgets/table.dart'; export 'src/widgets/table.dart';
export 'src/widgets/text.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_editing_intents.dart';
export 'src/widgets/text_selection.dart'; export 'src/widgets/text_selection.dart';
export 'src/widgets/text_selection_toolbar_layout_delegate.dart'; export 'src/widgets/text_selection_toolbar_layout_delegate.dart';
......
...@@ -263,7 +263,7 @@ void main() { ...@@ -263,7 +263,7 @@ void main() {
await tester.tap(find.text('Paste')); await tester.tap(find.text('Paste'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(controller.text, 'blah1 blah2blah1'); expect(controller.text, 'blah1 blah2blah1');
expect(controller.selection, const TextSelection(baseOffset: 16, extentOffset: 16, affinity: TextAffinity.upstream)); expect(controller.selection, const TextSelection.collapsed(offset: 16));
// Cut the first word. // Cut the first word.
await gesture.down(midBlah1); await gesture.down(midBlah1);
......
...@@ -189,8 +189,6 @@ void main() { ...@@ -189,8 +189,6 @@ void main() {
' _FocusTraversalGroupMarker\n' ' _FocusTraversalGroupMarker\n'
' FocusTraversalGroup\n' ' FocusTraversalGroup\n'
' _ActionsMarker\n' ' _ActionsMarker\n'
' DefaultTextEditingActions\n'
' _ActionsMarker\n'
' Actions\n' ' Actions\n'
' _ShortcutsMarker\n' ' _ShortcutsMarker\n'
' Semantics\n' ' Semantics\n'
......
...@@ -79,7 +79,6 @@ Widget overlayWithEntry(OverlayEntry entry) { ...@@ -79,7 +79,6 @@ Widget overlayWithEntry(OverlayEntry entry) {
MaterialLocalizationsDelegate(), MaterialLocalizationsDelegate(),
], ],
child: DefaultTextEditingShortcuts( child: DefaultTextEditingShortcuts(
child: DefaultTextEditingActions(
child: Directionality( child: Directionality(
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
child: MediaQuery( child: MediaQuery(
...@@ -92,7 +91,6 @@ Widget overlayWithEntry(OverlayEntry entry) { ...@@ -92,7 +91,6 @@ Widget overlayWithEntry(OverlayEntry entry) {
), ),
), ),
), ),
),
); );
} }
...@@ -260,14 +258,14 @@ void main() { ...@@ -260,14 +258,14 @@ void main() {
await tester.pump(); await tester.pump();
await gesture.up(); await gesture.up();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 11, affinity: TextAffinity.upstream)); expect(controller.selection, const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream));
expect(find.text('Cut'), findsNothing); expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing); expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsOneWidget); expect(find.text('Paste'), findsOneWidget);
await tester.tap(find.text('Paste')); await tester.tap(find.text('Paste'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(controller.text, 'blah1 blah2blah1'); expect(controller.text, 'blah1 blah2blah1');
expect(controller.selection, const TextSelection(baseOffset: 16, extentOffset: 16, affinity: TextAffinity.upstream)); expect(controller.selection, const TextSelection.collapsed(offset: 16));
// Cut the first word. // Cut the first word.
await gesture.down(midBlah1); await gesture.down(midBlah1);
......
...@@ -74,14 +74,14 @@ void main() { ...@@ -74,14 +74,14 @@ void main() {
await tester.pump(); await tester.pump();
await gesture.up(); await gesture.up();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 11, affinity: TextAffinity.upstream)); expect(controller.selection, const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream));
expect(find.text('Cut'), findsNothing); expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing); expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsOneWidget); expect(find.text('Paste'), findsOneWidget);
await tester.tap(find.text('Paste')); await tester.tap(find.text('Paste'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(controller.text, 'blah1 blah2blah1'); expect(controller.text, 'blah1 blah2blah1');
expect(controller.selection, const TextSelection(baseOffset: 16, extentOffset: 16, affinity: TextAffinity.upstream)); expect(controller.selection, const TextSelection.collapsed(offset: 16));
// Cut the first word. // Cut the first word.
await gesture.down(midBlah1); await gesture.down(midBlah1);
......
...@@ -43,6 +43,110 @@ void main() { ...@@ -43,6 +43,110 @@ void main() {
}); });
}); });
group('TextEditingValue', () {
group('replaced', () {
const String testText = 'From a false proposition, anything follows.';
test('selection deletion', () {
const TextSelection selection = TextSelection(baseOffset: 5, extentOffset: 13);
expect(
const TextEditingValue(text: testText, selection: selection).replaced(selection, ''),
const TextEditingValue(text: 'From proposition, anything follows.', selection: TextSelection.collapsed(offset: 5)),
);
});
test('reversed selection deletion', () {
const TextSelection selection = TextSelection(baseOffset: 13, extentOffset: 5);
expect(
const TextEditingValue(text: testText, selection: selection).replaced(selection, ''),
const TextEditingValue(text: 'From proposition, anything follows.', selection: TextSelection.collapsed(offset: 5)),
);
});
test('insert', () {
const TextSelection selection = TextSelection.collapsed(offset: 5);
expect(
const TextEditingValue(text: testText, selection: selection).replaced(selection, 'AA'),
const TextEditingValue(
text: 'From AAa false proposition, anything follows.',
// The caret moves to the end of the text inserted.
selection: TextSelection.collapsed(offset: 7),
),
);
});
test('replace before selection', () {
const TextSelection selection = TextSelection(baseOffset: 13, extentOffset: 5);
expect(
// From |a false |proposition, anything follows.
// Replace the first whitespace with "AA".
const TextEditingValue(text: testText, selection: selection).replaced(const TextRange(start: 4, end: 5), 'AA'),
const TextEditingValue(text: 'FromAAa false proposition, anything follows.', selection: TextSelection(baseOffset: 14, extentOffset: 6)),
);
});
test('replace after selection', () {
const TextSelection selection = TextSelection(baseOffset: 13, extentOffset: 5);
expect(
// From |a false |proposition, anything follows.
// replace the first "p" with "AA".
const TextEditingValue(text: testText, selection: selection).replaced(const TextRange(start: 13, end: 14), 'AA'),
const TextEditingValue(text: 'From a false AAroposition, anything follows.', selection: selection),
);
});
test('replace inside selection - start boundary', () {
const TextSelection selection = TextSelection(baseOffset: 13, extentOffset: 5);
expect(
// From |a false |proposition, anything follows.
// replace the first "a" with "AA".
const TextEditingValue(text: testText, selection: selection).replaced(const TextRange(start: 5, end: 6), 'AA'),
const TextEditingValue(text: 'From AA false proposition, anything follows.', selection: TextSelection(baseOffset: 14, extentOffset: 5)),
);
});
test('replace inside selection - end boundary', () {
const TextSelection selection = TextSelection(baseOffset: 13, extentOffset: 5);
expect(
// From |a false |proposition, anything follows.
// replace the second whitespace with "AA".
const TextEditingValue(text: testText, selection: selection).replaced(const TextRange(start: 12, end: 13), 'AA'),
const TextEditingValue(text: 'From a falseAAproposition, anything follows.', selection: TextSelection(baseOffset: 14, extentOffset: 5)),
);
});
test('delete after selection', () {
const TextSelection selection = TextSelection(baseOffset: 13, extentOffset: 5);
expect(
// From |a false |proposition, anything follows.
// Delete the first "p".
const TextEditingValue(text: testText, selection: selection).replaced(const TextRange(start: 13, end: 14), ''),
const TextEditingValue(text: 'From a false roposition, anything follows.', selection: selection),
);
});
test('delete inside selection - start boundary', () {
const TextSelection selection = TextSelection(baseOffset: 13, extentOffset: 5);
expect(
// From |a false |proposition, anything follows.
// Delete the first "a".
const TextEditingValue(text: testText, selection: selection).replaced(const TextRange(start: 5, end: 6), ''),
const TextEditingValue(text: 'From false proposition, anything follows.', selection: TextSelection(baseOffset: 12, extentOffset: 5)),
);
});
test('delete inside selection - end boundary', () {
const TextSelection selection = TextSelection(baseOffset: 13, extentOffset: 5);
expect(
// From |a false |proposition, anything follows.
// Delete the second whitespace.
const TextEditingValue(text: testText, selection: selection).replaced(const TextRange(start: 12, end: 13), ''),
const TextEditingValue(text: 'From a falseproposition, anything follows.', selection: TextSelection(baseOffset: 12, extentOffset: 5)),
);
});
});
});
group('TextInput message channels', () { group('TextInput message channels', () {
late FakeTextChannel fakeTextChannel; late FakeTextChannel fakeTextChannel;
......
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