Undo/redo (#96968)
This commit is contained in:
parent
93a1b7a577
commit
fa98a5226f
@ -205,6 +205,8 @@ class DefaultTextEditingShortcuts extends Shortcuts {
|
||||
const SingleActivator(LogicalKeyboardKey.keyC, control: true): CopySelectionTextIntent.copy,
|
||||
const SingleActivator(LogicalKeyboardKey.keyV, control: true): const PasteTextIntent(SelectionChangedCause.keyboard),
|
||||
const SingleActivator(LogicalKeyboardKey.keyA, control: true): const SelectAllTextIntent(SelectionChangedCause.keyboard),
|
||||
const SingleActivator(LogicalKeyboardKey.keyZ, control: true): const UndoTextIntent(SelectionChangedCause.keyboard),
|
||||
const SingleActivator(LogicalKeyboardKey.keyZ, shift: true, control: true): const RedoTextIntent(SelectionChangedCause.keyboard),
|
||||
};
|
||||
|
||||
// The following key combinations have no effect on text editing on this
|
||||
@ -215,6 +217,7 @@ class DefaultTextEditingShortcuts extends Shortcuts {
|
||||
// * Meta + C
|
||||
// * Meta + V
|
||||
// * Meta + A
|
||||
// * Meta + shift? + Z
|
||||
// * Meta + shift? + arrow down
|
||||
// * Meta + shift? + arrow left
|
||||
// * Meta + shift? + arrow right
|
||||
@ -235,6 +238,7 @@ class DefaultTextEditingShortcuts extends Shortcuts {
|
||||
// * Meta + C
|
||||
// * Meta + V
|
||||
// * Meta + A
|
||||
// * Meta + shift? + Z
|
||||
// * Meta + shift? + arrow down
|
||||
// * Meta + shift? + arrow left
|
||||
// * Meta + shift? + arrow right
|
||||
@ -259,6 +263,7 @@ class DefaultTextEditingShortcuts extends Shortcuts {
|
||||
// * Meta + C
|
||||
// * Meta + V
|
||||
// * Meta + A
|
||||
// * Meta + shift? + Z
|
||||
// * Meta + shift? + arrow down
|
||||
// * Meta + shift? + arrow left
|
||||
// * Meta + shift? + arrow right
|
||||
@ -319,12 +324,15 @@ class DefaultTextEditingShortcuts extends Shortcuts {
|
||||
const SingleActivator(LogicalKeyboardKey.keyC, meta: true): CopySelectionTextIntent.copy,
|
||||
const SingleActivator(LogicalKeyboardKey.keyV, meta: true): const PasteTextIntent(SelectionChangedCause.keyboard),
|
||||
const SingleActivator(LogicalKeyboardKey.keyA, meta: true): const SelectAllTextIntent(SelectionChangedCause.keyboard),
|
||||
const SingleActivator(LogicalKeyboardKey.keyZ, meta: true): const UndoTextIntent(SelectionChangedCause.keyboard),
|
||||
const SingleActivator(LogicalKeyboardKey.keyZ, shift: true, meta: true): const RedoTextIntent(SelectionChangedCause.keyboard),
|
||||
// The following key combinations have no effect on text editing on this
|
||||
// platform:
|
||||
// * End
|
||||
// * Home
|
||||
// * Control + shift? + end
|
||||
// * Control + shift? + home
|
||||
// * Control + shift? + Z
|
||||
};
|
||||
|
||||
// The following key combinations have no effect on text editing on this
|
||||
|
@ -30,6 +30,7 @@ import 'scroll_configuration.dart';
|
||||
import 'scroll_controller.dart';
|
||||
import 'scroll_physics.dart';
|
||||
import 'scrollable.dart';
|
||||
import 'shortcuts.dart';
|
||||
import 'text.dart';
|
||||
import 'text_editing_intents.dart';
|
||||
import 'text_selection.dart';
|
||||
@ -3138,91 +3139,97 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
cursor: widget.mouseCursor ?? SystemMouseCursors.text,
|
||||
child: Actions(
|
||||
actions: _actions,
|
||||
child: Focus(
|
||||
focusNode: widget.focusNode,
|
||||
includeSemantics: false,
|
||||
debugLabel: 'EditableText',
|
||||
child: Scrollable(
|
||||
excludeFromSemantics: true,
|
||||
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
|
||||
controller: _scrollController,
|
||||
physics: widget.scrollPhysics,
|
||||
dragStartBehavior: widget.dragStartBehavior,
|
||||
restorationId: widget.restorationId,
|
||||
// If a ScrollBehavior is not provided, only apply scrollbars when
|
||||
// multiline. The overscroll indicator should not be applied in
|
||||
// either case, glowing or stretching.
|
||||
scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(
|
||||
scrollbars: _isMultiline,
|
||||
overscroll: false,
|
||||
),
|
||||
viewportBuilder: (BuildContext context, ViewportOffset offset) {
|
||||
return CompositedTransformTarget(
|
||||
link: _toolbarLayerLink,
|
||||
child: Semantics(
|
||||
onCopy: _semanticsOnCopy(controls),
|
||||
onCut: _semanticsOnCut(controls),
|
||||
onPaste: _semanticsOnPaste(controls),
|
||||
child: _ScribbleFocusable(
|
||||
focusNode: widget.focusNode,
|
||||
editableKey: _editableKey,
|
||||
enabled: widget.scribbleEnabled,
|
||||
updateSelectionRects: () {
|
||||
_openInputConnection();
|
||||
_updateSelectionRects(force: true);
|
||||
},
|
||||
child: _Editable(
|
||||
key: _editableKey,
|
||||
startHandleLayerLink: _startHandleLayerLink,
|
||||
endHandleLayerLink: _endHandleLayerLink,
|
||||
inlineSpan: buildTextSpan(),
|
||||
value: _value,
|
||||
cursorColor: _cursorColor,
|
||||
backgroundCursorColor: widget.backgroundCursorColor,
|
||||
showCursor: EditableText.debugDeterministicCursor
|
||||
? ValueNotifier<bool>(widget.showCursor)
|
||||
: _cursorVisibilityNotifier,
|
||||
forceLine: widget.forceLine,
|
||||
readOnly: widget.readOnly,
|
||||
hasFocus: _hasFocus,
|
||||
maxLines: widget.maxLines,
|
||||
minLines: widget.minLines,
|
||||
expands: widget.expands,
|
||||
strutStyle: widget.strutStyle,
|
||||
selectionColor: widget.selectionColor,
|
||||
textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context),
|
||||
textAlign: widget.textAlign,
|
||||
textDirection: _textDirection,
|
||||
locale: widget.locale,
|
||||
textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.of(context),
|
||||
textWidthBasis: widget.textWidthBasis,
|
||||
obscuringCharacter: widget.obscuringCharacter,
|
||||
obscureText: widget.obscureText,
|
||||
autocorrect: widget.autocorrect,
|
||||
smartDashesType: widget.smartDashesType,
|
||||
smartQuotesType: widget.smartQuotesType,
|
||||
enableSuggestions: widget.enableSuggestions,
|
||||
offset: offset,
|
||||
onCaretChanged: _handleCaretChanged,
|
||||
rendererIgnoresPointer: widget.rendererIgnoresPointer,
|
||||
cursorWidth: widget.cursorWidth,
|
||||
cursorHeight: widget.cursorHeight,
|
||||
cursorRadius: widget.cursorRadius,
|
||||
cursorOffset: widget.cursorOffset ?? Offset.zero,
|
||||
selectionHeightStyle: widget.selectionHeightStyle,
|
||||
selectionWidthStyle: widget.selectionWidthStyle,
|
||||
paintCursorAboveText: widget.paintCursorAboveText,
|
||||
enableInteractiveSelection: widget.enableInteractiveSelection && (!widget.readOnly || !widget.obscureText),
|
||||
textSelectionDelegate: this,
|
||||
devicePixelRatio: _devicePixelRatio,
|
||||
promptRectRange: _currentPromptRectRange,
|
||||
promptRectColor: widget.autocorrectionTextRectColor,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
child: _TextEditingHistory(
|
||||
controller: widget.controller,
|
||||
onTriggered: (TextEditingValue value) {
|
||||
userUpdateTextEditingValue(value, SelectionChangedCause.keyboard);
|
||||
},
|
||||
child: Focus(
|
||||
focusNode: widget.focusNode,
|
||||
includeSemantics: false,
|
||||
debugLabel: 'EditableText',
|
||||
child: Scrollable(
|
||||
excludeFromSemantics: true,
|
||||
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
|
||||
controller: _scrollController,
|
||||
physics: widget.scrollPhysics,
|
||||
dragStartBehavior: widget.dragStartBehavior,
|
||||
restorationId: widget.restorationId,
|
||||
// If a ScrollBehavior is not provided, only apply scrollbars when
|
||||
// multiline. The overscroll indicator should not be applied in
|
||||
// either case, glowing or stretching.
|
||||
scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(
|
||||
scrollbars: _isMultiline,
|
||||
overscroll: false,
|
||||
),
|
||||
viewportBuilder: (BuildContext context, ViewportOffset offset) {
|
||||
return CompositedTransformTarget(
|
||||
link: _toolbarLayerLink,
|
||||
child: Semantics(
|
||||
onCopy: _semanticsOnCopy(controls),
|
||||
onCut: _semanticsOnCut(controls),
|
||||
onPaste: _semanticsOnPaste(controls),
|
||||
child: _ScribbleFocusable(
|
||||
focusNode: widget.focusNode,
|
||||
editableKey: _editableKey,
|
||||
enabled: widget.scribbleEnabled,
|
||||
updateSelectionRects: () {
|
||||
_openInputConnection();
|
||||
_updateSelectionRects(force: true);
|
||||
},
|
||||
child: _Editable(
|
||||
key: _editableKey,
|
||||
startHandleLayerLink: _startHandleLayerLink,
|
||||
endHandleLayerLink: _endHandleLayerLink,
|
||||
inlineSpan: buildTextSpan(),
|
||||
value: _value,
|
||||
cursorColor: _cursorColor,
|
||||
backgroundCursorColor: widget.backgroundCursorColor,
|
||||
showCursor: EditableText.debugDeterministicCursor
|
||||
? ValueNotifier<bool>(widget.showCursor)
|
||||
: _cursorVisibilityNotifier,
|
||||
forceLine: widget.forceLine,
|
||||
readOnly: widget.readOnly,
|
||||
hasFocus: _hasFocus,
|
||||
maxLines: widget.maxLines,
|
||||
minLines: widget.minLines,
|
||||
expands: widget.expands,
|
||||
strutStyle: widget.strutStyle,
|
||||
selectionColor: widget.selectionColor,
|
||||
textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context),
|
||||
textAlign: widget.textAlign,
|
||||
textDirection: _textDirection,
|
||||
locale: widget.locale,
|
||||
textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.of(context),
|
||||
textWidthBasis: widget.textWidthBasis,
|
||||
obscuringCharacter: widget.obscuringCharacter,
|
||||
obscureText: widget.obscureText,
|
||||
autocorrect: widget.autocorrect,
|
||||
smartDashesType: widget.smartDashesType,
|
||||
smartQuotesType: widget.smartQuotesType,
|
||||
enableSuggestions: widget.enableSuggestions,
|
||||
offset: offset,
|
||||
onCaretChanged: _handleCaretChanged,
|
||||
rendererIgnoresPointer: widget.rendererIgnoresPointer,
|
||||
cursorWidth: widget.cursorWidth,
|
||||
cursorHeight: widget.cursorHeight,
|
||||
cursorRadius: widget.cursorRadius,
|
||||
cursorOffset: widget.cursorOffset ?? Offset.zero,
|
||||
selectionHeightStyle: widget.selectionHeightStyle,
|
||||
selectionWidthStyle: widget.selectionWidthStyle,
|
||||
paintCursorAboveText: widget.paintCursorAboveText,
|
||||
enableInteractiveSelection: widget.enableInteractiveSelection && (!widget.readOnly || !widget.obscureText),
|
||||
textSelectionDelegate: this,
|
||||
devicePixelRatio: _devicePixelRatio,
|
||||
promptRectRange: _currentPromptRectRange,
|
||||
promptRectColor: widget.autocorrectionTextRectColor,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -4154,3 +4161,256 @@ class _CopySelectionAction extends ContextAction<CopySelectionTextIntent> {
|
||||
@override
|
||||
bool get isActionEnabled => state._value.selection.isValid && !state._value.selection.isCollapsed;
|
||||
}
|
||||
|
||||
/// A void function that takes a [TextEditingValue].
|
||||
@visibleForTesting
|
||||
typedef TextEditingValueCallback = void Function(TextEditingValue value);
|
||||
|
||||
/// Provides undo/redo capabilities for text editing.
|
||||
///
|
||||
/// Listens to [controller] as a [ValueNotifier] and saves relevant values for
|
||||
/// undoing/redoing. The cadence at which values are saved is a best
|
||||
/// approximation of the native behaviors of a hardware keyboard on Flutter's
|
||||
/// desktop platforms, as there are subtle differences between each of these
|
||||
/// platforms.
|
||||
///
|
||||
/// Listens to keyboard undo/redo shortcuts and calls [onTriggered] when a
|
||||
/// shortcut is triggered that would affect the state of the [controller].
|
||||
class _TextEditingHistory extends StatefulWidget {
|
||||
/// Creates an instance of [_TextEditingHistory].
|
||||
const _TextEditingHistory({
|
||||
Key? key,
|
||||
required this.child,
|
||||
required this.controller,
|
||||
required this.onTriggered,
|
||||
}) : super(key: key);
|
||||
|
||||
/// The child widget of [_TextEditingHistory].
|
||||
final Widget child;
|
||||
|
||||
/// The [TextEditingController] to save the state of over time.
|
||||
final TextEditingController controller;
|
||||
|
||||
/// Called when an undo or redo causes a state change.
|
||||
///
|
||||
/// If the state would still be the same before and after the undo/redo, this
|
||||
/// will not be called. For example, receiving a redo when there is nothing
|
||||
/// to redo will not call this method.
|
||||
///
|
||||
/// It is also not called when the controller is changed for reasons other
|
||||
/// than undo/redo.
|
||||
final TextEditingValueCallback onTriggered;
|
||||
|
||||
@override
|
||||
State<_TextEditingHistory> createState() => _TextEditingHistoryState();
|
||||
}
|
||||
|
||||
class _TextEditingHistoryState extends State<_TextEditingHistory> {
|
||||
final _UndoStack<TextEditingValue> _stack = _UndoStack<TextEditingValue>();
|
||||
late final _Throttled<TextEditingValue> _throttledPush;
|
||||
Timer? _throttleTimer;
|
||||
|
||||
// This duration was chosen as a best fit for the behavior of Mac, Linux,
|
||||
// and Windows undo/redo state save durations, but it is not perfect for any
|
||||
// of them.
|
||||
static const Duration _kThrottleDuration = Duration(milliseconds: 500);
|
||||
|
||||
void _undo(UndoTextIntent intent) {
|
||||
_update(_stack.undo());
|
||||
}
|
||||
|
||||
void _redo(RedoTextIntent intent) {
|
||||
_update(_stack.redo());
|
||||
}
|
||||
|
||||
void _update(TextEditingValue? nextValue) {
|
||||
if (nextValue == null) {
|
||||
return;
|
||||
}
|
||||
if (nextValue.text == widget.controller.text) {
|
||||
return;
|
||||
}
|
||||
widget.onTriggered(widget.controller.value.copyWith(
|
||||
text: nextValue.text,
|
||||
selection: nextValue.selection,
|
||||
));
|
||||
}
|
||||
|
||||
void _push() {
|
||||
if (widget.controller.value == TextEditingValue.empty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_throttleTimer = _throttledPush(widget.controller.value);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_throttledPush = _throttle<TextEditingValue>(
|
||||
duration: _kThrottleDuration,
|
||||
function: _stack.push,
|
||||
);
|
||||
_push();
|
||||
widget.controller.addListener(_push);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_TextEditingHistory oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.controller != oldWidget.controller) {
|
||||
_stack.clear();
|
||||
oldWidget.controller.removeListener(_push);
|
||||
widget.controller.addListener(_push);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_push);
|
||||
_throttleTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Actions(
|
||||
actions: <Type, Action<Intent>> {
|
||||
UndoTextIntent: Action<UndoTextIntent>.overridable(context: context, defaultAction: CallbackAction<UndoTextIntent>(onInvoke: _undo)),
|
||||
RedoTextIntent: Action<RedoTextIntent>.overridable(context: context, defaultAction: CallbackAction<RedoTextIntent>(onInvoke: _redo)),
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A data structure representing a chronological list of states that can be
|
||||
/// undone and redone.
|
||||
class _UndoStack<T> {
|
||||
/// Creates an instance of [_UndoStack].
|
||||
_UndoStack();
|
||||
|
||||
final List<T> _list = <T>[];
|
||||
|
||||
// The index of the current value, or null if the list is emtpy.
|
||||
late int _index;
|
||||
|
||||
/// Returns the current value of the stack.
|
||||
T? get currentValue => _list.isEmpty ? null : _list[_index];
|
||||
|
||||
/// Add a new state change to the stack.
|
||||
///
|
||||
/// Pushing identical objects will not create multiple entries.
|
||||
void push(T value) {
|
||||
if (_list.isEmpty) {
|
||||
_index = 0;
|
||||
_list.add(value);
|
||||
return;
|
||||
}
|
||||
|
||||
assert(_index < _list.length && _index >= 0);
|
||||
|
||||
if (value == currentValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If anything has been undone in this stack, remove those irrelevant states
|
||||
// before adding the new one.
|
||||
if (_index != null && _index != _list.length - 1) {
|
||||
_list.removeRange(_index + 1, _list.length);
|
||||
}
|
||||
_list.add(value);
|
||||
_index = _list.length - 1;
|
||||
}
|
||||
|
||||
/// Returns the current value after an undo operation.
|
||||
///
|
||||
/// An undo operation moves the current value to the previously pushed value,
|
||||
/// if any.
|
||||
///
|
||||
/// Iff the stack is completely empty, then returns null.
|
||||
T? undo() {
|
||||
if (_list.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
assert(_index < _list.length && _index >= 0);
|
||||
|
||||
if (_index != 0) {
|
||||
_index = _index - 1;
|
||||
}
|
||||
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
/// Returns the current value after a redo operation.
|
||||
///
|
||||
/// A redo operation moves the current value to the value that was last
|
||||
/// undone, if any.
|
||||
///
|
||||
/// Iff the stack is completely empty, then returns null.
|
||||
T? redo() {
|
||||
if (_list.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
assert(_index < _list.length && _index >= 0);
|
||||
|
||||
if (_index < _list.length - 1) {
|
||||
_index = _index + 1;
|
||||
}
|
||||
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
/// Remove everything from the stack.
|
||||
void clear() {
|
||||
_list.clear();
|
||||
_index = -1;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '_UndoStack $_list';
|
||||
}
|
||||
}
|
||||
|
||||
/// A function that can be throttled with the throttle function.
|
||||
typedef _Throttleable<T> = void Function(T currentArg);
|
||||
|
||||
/// A function that has been throttled by [_throttle].
|
||||
typedef _Throttled<T> = Timer Function(T currentArg);
|
||||
|
||||
/// Returns a _Throttled that will call through to the given function only a
|
||||
/// maximum of once per duration.
|
||||
///
|
||||
/// Only works for functions that take exactly one argument and return void.
|
||||
_Throttled<T> _throttle<T>({
|
||||
required Duration duration,
|
||||
required _Throttleable<T> function,
|
||||
// If true, calls at the start of the timer.
|
||||
bool leadingEdge = false,
|
||||
}) {
|
||||
Timer? timer;
|
||||
bool calledDuringTimer = false;
|
||||
late T arg;
|
||||
|
||||
return (T currentArg) {
|
||||
arg = currentArg;
|
||||
if (timer != null) {
|
||||
calledDuringTimer = true;
|
||||
return timer!;
|
||||
}
|
||||
if (leadingEdge) {
|
||||
function(arg);
|
||||
}
|
||||
calledDuringTimer = false;
|
||||
timer = Timer(duration, () {
|
||||
if (!leadingEdge || calledDuringTimer) {
|
||||
function(arg);
|
||||
}
|
||||
timer = null;
|
||||
});
|
||||
return timer!;
|
||||
};
|
||||
}
|
||||
|
@ -231,6 +231,16 @@ class PasteTextIntent extends Intent {
|
||||
final SelectionChangedCause cause;
|
||||
}
|
||||
|
||||
/// An [Intent] that represents a user interaction that attempts to go back to
|
||||
/// the previous editing state.
|
||||
class RedoTextIntent extends Intent {
|
||||
/// Creates a [RedoTextIntent].
|
||||
const RedoTextIntent(this.cause);
|
||||
|
||||
/// {@macro flutter.widgets.TextEditingIntents.cause}
|
||||
final SelectionChangedCause cause;
|
||||
}
|
||||
|
||||
/// An [Intent] that represents a user interaction that attempts to modify the
|
||||
/// current [TextEditingValue] in an input field.
|
||||
class ReplaceTextIntent extends Intent {
|
||||
@ -250,10 +260,20 @@ class ReplaceTextIntent extends Intent {
|
||||
final SelectionChangedCause cause;
|
||||
}
|
||||
|
||||
/// An [Intent] that represents a user interaction that attempts to go back to
|
||||
/// the previous editing state.
|
||||
class UndoTextIntent extends Intent {
|
||||
/// Creates an [UndoTextIntent].
|
||||
const UndoTextIntent(this.cause);
|
||||
|
||||
/// {@macro flutter.widgets.TextEditingIntents.cause}
|
||||
final SelectionChangedCause cause;
|
||||
}
|
||||
|
||||
/// An [Intent] that represents a user interaction that attempts to change the
|
||||
/// selection in an input field.
|
||||
class UpdateSelectionIntent extends Intent {
|
||||
/// Creates a [UpdateSelectionIntent].
|
||||
/// Creates an [UpdateSelectionIntent].
|
||||
const UpdateSelectionIntent(this.currentTextEditingValue, this.newSelection, this.cause);
|
||||
|
||||
/// The [TextEditingValue] that this [Intent]'s action should perform on.
|
||||
|
@ -9959,6 +9959,454 @@ void main() {
|
||||
isNot(contains(matchesMethodCall('TextInput.requestAutofill'))),
|
||||
);
|
||||
});
|
||||
|
||||
group('TextEditingHistory', () {
|
||||
Future<void> sendUndoRedo(WidgetTester tester, [bool redo = false]) {
|
||||
return sendKeys(
|
||||
tester,
|
||||
<LogicalKeyboardKey>[
|
||||
LogicalKeyboardKey.keyZ,
|
||||
],
|
||||
shortcutModifier: true,
|
||||
shift: redo,
|
||||
targetPlatform: defaultTargetPlatform,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendUndo(WidgetTester tester) => sendUndoRedo(tester);
|
||||
Future<void> sendRedo(WidgetTester tester) => sendUndoRedo(tester, true);
|
||||
|
||||
testWidgets('inside EditableText', (WidgetTester tester) async {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
final FocusNode focusNode = FocusNode();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: EditableText(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
style: textStyle,
|
||||
cursorColor: Colors.blue,
|
||||
backgroundCursorColor: Colors.grey,
|
||||
cursorOpacityAnimates: true,
|
||||
autofillHints: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
controller.value,
|
||||
TextEditingValue.empty,
|
||||
);
|
||||
|
||||
// Undo/redo have no effect on an empty field that has never been edited.
|
||||
await sendUndo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
TextEditingValue.empty,
|
||||
);
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
TextEditingValue.empty,
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
expect(
|
||||
controller.value,
|
||||
TextEditingValue.empty,
|
||||
);
|
||||
|
||||
focusNode.requestFocus();
|
||||
expect(
|
||||
controller.value,
|
||||
TextEditingValue.empty,
|
||||
);
|
||||
await tester.pump();
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for the throttling.
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
// Undo/redo still have no effect. The field is focused and the value has
|
||||
// changed, but the text remains empty.
|
||||
await sendUndo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
),
|
||||
);
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(EditableText), '1');
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
// Can undo/redo a single insertion.
|
||||
await sendUndo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
),
|
||||
);
|
||||
await sendUndo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
),
|
||||
);
|
||||
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
|
||||
// And can undo/redo multiple insertions.
|
||||
await tester.enterText(find.byType(EditableText), '13');
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '13',
|
||||
selection: TextSelection.collapsed(offset: 2),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '13',
|
||||
selection: TextSelection.collapsed(offset: 2),
|
||||
),
|
||||
);
|
||||
await sendUndo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
await sendUndo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
),
|
||||
);
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '13',
|
||||
selection: TextSelection.collapsed(offset: 2),
|
||||
),
|
||||
);
|
||||
|
||||
// Can change the middle of the stack timeline.
|
||||
await sendUndo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
await tester.enterText(find.byType(EditableText), '12');
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '12',
|
||||
selection: TextSelection.collapsed(offset: 2),
|
||||
),
|
||||
);
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '12',
|
||||
selection: TextSelection.collapsed(offset: 2),
|
||||
),
|
||||
);
|
||||
await sendUndo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
await sendUndo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
),
|
||||
);
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '12',
|
||||
selection: TextSelection.collapsed(offset: 2),
|
||||
),
|
||||
);
|
||||
// On web, these keyboard shortcuts are handled by the browser.
|
||||
}, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended]
|
||||
|
||||
testWidgets('inside EditableText, duplicate changes', (WidgetTester tester) async {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
final FocusNode focusNode = FocusNode();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: EditableText(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
style: textStyle,
|
||||
cursorColor: Colors.blue,
|
||||
backgroundCursorColor: Colors.grey,
|
||||
cursorOpacityAnimates: true,
|
||||
autofillHints: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
controller.value,
|
||||
TextEditingValue.empty,
|
||||
);
|
||||
|
||||
focusNode.requestFocus();
|
||||
expect(
|
||||
controller.value,
|
||||
TextEditingValue.empty,
|
||||
);
|
||||
await tester.pump();
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for the throttling.
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
await tester.enterText(find.byType(EditableText), '1');
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
// Can undo/redo a single insertion.
|
||||
await sendUndo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
),
|
||||
);
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
|
||||
// Changes that result in the same state won't be saved on the undo stack.
|
||||
await tester.enterText(find.byType(EditableText), '12');
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '12',
|
||||
selection: TextSelection.collapsed(offset: 2),
|
||||
),
|
||||
);
|
||||
await tester.enterText(find.byType(EditableText), '1');
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
await sendUndo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
),
|
||||
);
|
||||
await sendUndo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
),
|
||||
);
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
// On web, these keyboard shortcuts are handled by the browser.
|
||||
}, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended]
|
||||
|
||||
testWidgets('inside EditableText, autofocus', (WidgetTester tester) async {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: EditableText(
|
||||
autofocus: true,
|
||||
controller: controller,
|
||||
focusNode: FocusNode(),
|
||||
style: textStyle,
|
||||
cursorColor: Colors.blue,
|
||||
backgroundCursorColor: Colors.grey,
|
||||
cursorOpacityAnimates: true,
|
||||
autofillHints: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
),
|
||||
);
|
||||
// Wait for the throttling.
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.enterText(find.byType(EditableText), '1');
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
await sendUndo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
),
|
||||
);
|
||||
await sendUndo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
),
|
||||
);
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
await sendRedo(tester);
|
||||
expect(
|
||||
controller.value,
|
||||
const TextEditingValue(
|
||||
text: '1',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
),
|
||||
);
|
||||
}, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended]
|
||||
});
|
||||
}
|
||||
|
||||
class UnsettableController extends TextEditingController {
|
||||
|
Loading…
x
Reference in New Issue
Block a user