From fa98a5226fac2a8cda76b5fe042237b94211be4d Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Mon, 7 Feb 2022 17:45:19 -0800 Subject: [PATCH] Undo/redo (#96968) --- .../default_text_editing_shortcuts.dart | 8 + .../lib/src/widgets/editable_text.dart | 426 +++++++++++++---- .../lib/src/widgets/text_editing_intents.dart | 22 +- .../test/widgets/editable_text_test.dart | 448 ++++++++++++++++++ 4 files changed, 820 insertions(+), 84 deletions(-) diff --git a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart index 370dd4b8190..b97f3f6c9ce 100644 --- a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart +++ b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart @@ -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 diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 29d15cdbbd2..c4ef29db721 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -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 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(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(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 { @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 _stack = _UndoStack(); + late final _Throttled _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( + 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: > { + UndoTextIntent: Action.overridable(context: context, defaultAction: CallbackAction(onInvoke: _undo)), + RedoTextIntent: Action.overridable(context: context, defaultAction: CallbackAction(onInvoke: _redo)), + }, + child: widget.child, + ); + } +} + +/// A data structure representing a chronological list of states that can be +/// undone and redone. +class _UndoStack { + /// Creates an instance of [_UndoStack]. + _UndoStack(); + + final List _list = []; + + // 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 = void Function(T currentArg); + +/// A function that has been throttled by [_throttle]. +typedef _Throttled = 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 _throttle({ + required Duration duration, + required _Throttleable 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!; + }; +} diff --git a/packages/flutter/lib/src/widgets/text_editing_intents.dart b/packages/flutter/lib/src/widgets/text_editing_intents.dart index b4554f56243..9ba725082fc 100644 --- a/packages/flutter/lib/src/widgets/text_editing_intents.dart +++ b/packages/flutter/lib/src/widgets/text_editing_intents.dart @@ -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. diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 036b5a2ba19..60b50213cd7 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -9959,6 +9959,454 @@ void main() { isNot(contains(matchesMethodCall('TextInput.requestAutofill'))), ); }); + + group('TextEditingHistory', () { + Future sendUndoRedo(WidgetTester tester, [bool redo = false]) { + return sendKeys( + tester, + [ + LogicalKeyboardKey.keyZ, + ], + shortcutModifier: true, + shift: redo, + targetPlatform: defaultTargetPlatform, + ); + } + + Future sendUndo(WidgetTester tester) => sendUndoRedo(tester); + Future 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 {