From 2a67bf78f00dcfa9ed1d3cd6186a69cf17d553ab Mon Sep 17 00:00:00 2001 From: Jami Couch Date: Wed, 8 Mar 2023 13:45:49 -0600 Subject: [PATCH] Add support for iOS UndoManager (#98294) Add support for iOS UndoManager --- .../undo_history_controller.0.dart | 81 +++ packages/flutter/lib/services.dart | 1 + .../flutter/lib/src/cupertino/text_field.dart | 7 + .../flutter/lib/src/material/text_field.dart | 6 + .../lib/src/services/system_channels.dart | 6 + .../lib/src/services/undo_manager.dart | 131 +++++ .../lib/src/widgets/editable_text.dart | 324 ++--------- .../flutter/lib/src/widgets/undo_history.dart | 483 ++++++++++++++++ packages/flutter/lib/widgets.dart | 1 + .../test/services/undo_manager_test.dart | 67 +++ .../test/widgets/editable_text_test.dart | 76 +-- .../test/widgets/editable_text_utils.dart | 64 +++ .../test/widgets/undo_history_test.dart | 517 ++++++++++++++++++ .../flutter_test/lib/src/test_text_input.dart | 12 + 14 files changed, 1429 insertions(+), 347 deletions(-) create mode 100644 examples/api/lib/widgets/undo_history/undo_history_controller.0.dart create mode 100644 packages/flutter/lib/src/services/undo_manager.dart create mode 100644 packages/flutter/lib/src/widgets/undo_history.dart create mode 100644 packages/flutter/test/services/undo_manager_test.dart create mode 100644 packages/flutter/test/widgets/undo_history_test.dart diff --git a/examples/api/lib/widgets/undo_history/undo_history_controller.0.dart b/examples/api/lib/widgets/undo_history/undo_history_controller.0.dart new file mode 100644 index 000000000000..55cd50dac23b --- /dev/null +++ b/examples/api/lib/widgets/undo_history/undo_history_controller.0.dart @@ -0,0 +1,81 @@ +// 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. + +// Flutter code sample for UndoHistoryController. + +import 'package:flutter/material.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + static const String _title = 'Flutter Code Sample'; + + @override + Widget build(BuildContext context) { + return const MaterialApp( + title: _title, + home: MyHomePage(), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key}); + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + final TextEditingController _controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + final UndoHistoryController _undoController = UndoHistoryController(); + + TextStyle? get enabledStyle => Theme.of(context).textTheme.bodyMedium; + TextStyle? get disabledStyle => Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + maxLines: 4, + controller: _controller, + focusNode: _focusNode, + undoController: _undoController, + ), + ValueListenableBuilder( + valueListenable: _undoController, + builder: (BuildContext context, UndoHistoryValue value, Widget? child) { + return Row( + children: [ + TextButton( + child: Text('Undo', style: value.canUndo ? enabledStyle : disabledStyle), + onPressed: () { + _undoController.undo(); + }, + ), + TextButton( + child: Text('Redo', style: value.canRedo ? enabledStyle : disabledStyle), + onPressed: () { + _undoController.redo(); + }, + ), + ], + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/packages/flutter/lib/services.dart b/packages/flutter/lib/services.dart index 7e7fb0275edd..9bca45367455 100644 --- a/packages/flutter/lib/services.dart +++ b/packages/flutter/lib/services.dart @@ -52,3 +52,4 @@ export 'src/services/text_editing_delta.dart'; export 'src/services/text_formatter.dart'; export 'src/services/text_input.dart'; export 'src/services/text_layout_metrics.dart'; +export 'src/services/undo_manager.dart'; diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index f3eb80024338..ef5718c4a4d3 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -214,6 +214,7 @@ class CupertinoTextField extends StatefulWidget { super.key, this.controller, this.focusNode, + this.undoController, this.decoration = _kDefaultRoundedBorderDecoration, this.padding = const EdgeInsets.all(7.0), this.placeholder, @@ -347,6 +348,7 @@ class CupertinoTextField extends StatefulWidget { super.key, this.controller, this.focusNode, + this.undoController, this.decoration, this.padding = const EdgeInsets.all(7.0), this.placeholder, @@ -780,6 +782,9 @@ class CupertinoTextField extends StatefulWidget { decorationStyle: TextDecorationStyle.dotted, ); + /// {@macro flutter.widgets.undoHistory.controller} + final UndoHistoryController? undoController; + @override State createState() => _CupertinoTextFieldState(); @@ -788,6 +793,7 @@ class CupertinoTextField extends StatefulWidget { super.debugFillProperties(properties); properties.add(DiagnosticsProperty('controller', controller, defaultValue: null)); properties.add(DiagnosticsProperty('focusNode', focusNode, defaultValue: null)); + properties.add(DiagnosticsProperty('undoController', undoController, defaultValue: null)); properties.add(DiagnosticsProperty('decoration', decoration)); properties.add(DiagnosticsProperty('padding', padding)); properties.add(StringProperty('placeholder', placeholder)); @@ -1277,6 +1283,7 @@ class _CupertinoTextFieldState extends State with Restoratio child: EditableText( key: editableTextKey, controller: controller, + undoController: widget.undoController, readOnly: widget.readOnly, toolbarOptions: widget.toolbarOptions, showCursor: widget.showCursor, diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index dc261592faef..ded4202761d8 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -255,6 +255,7 @@ class TextField extends StatefulWidget { super.key, this.controller, this.focusNode, + this.undoController, this.decoration = const InputDecoration(), TextInputType? keyboardType, this.textInputAction, @@ -774,6 +775,9 @@ class TextField extends StatefulWidget { /// be possible to move the focus to the text field with tab key. final bool canRequestFocus; + /// {@macro flutter.widgets.undoHistory.controller} + final UndoHistoryController? undoController; + static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) { return AdaptiveTextSelectionToolbar.editableText( editableTextState: editableTextState, @@ -834,6 +838,7 @@ class TextField extends StatefulWidget { super.debugFillProperties(properties); properties.add(DiagnosticsProperty('controller', controller, defaultValue: null)); properties.add(DiagnosticsProperty('focusNode', focusNode, defaultValue: null)); + properties.add(DiagnosticsProperty('undoController', undoController, defaultValue: null)); properties.add(DiagnosticsProperty('enabled', enabled, defaultValue: null)); properties.add(DiagnosticsProperty('decoration', decoration, defaultValue: const InputDecoration())); properties.add(DiagnosticsProperty('keyboardType', keyboardType, defaultValue: TextInputType.text)); @@ -1313,6 +1318,7 @@ class _TextFieldState extends State with RestorationMixin implements showSelectionHandles: _showSelectionHandles, controller: controller, focusNode: focusNode, + undoController: widget.undoController, keyboardType: widget.keyboardType, textInputAction: widget.textInputAction, textCapitalization: widget.textCapitalization, diff --git a/packages/flutter/lib/src/services/system_channels.dart b/packages/flutter/lib/src/services/system_channels.dart index d2628a570eb4..d7ccd213f8b6 100644 --- a/packages/flutter/lib/src/services/system_channels.dart +++ b/packages/flutter/lib/src/services/system_channels.dart @@ -244,6 +244,12 @@ class SystemChannels { 'flutter/spellcheck', ); + /// A JSON [MethodChannel] for handling undo events. + static const MethodChannel undoManager = OptionalMethodChannel( + 'flutter/undomanager', + JSONMethodCodec(), + ); + /// A JSON [BasicMessageChannel] for keyboard events. /// /// Each incoming message received on this channel (registered using diff --git a/packages/flutter/lib/src/services/undo_manager.dart b/packages/flutter/lib/src/services/undo_manager.dart new file mode 100644 index 000000000000..71837ad24dbc --- /dev/null +++ b/packages/flutter/lib/src/services/undo_manager.dart @@ -0,0 +1,131 @@ +// 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 'package:flutter/foundation.dart'; + +import '../../services.dart'; + +/// The direction in which an undo action should be performed, whether undo or redo. +enum UndoDirection { + /// Perform an undo action. + undo, + + /// Perform a redo action. + redo +} + +/// A low-level interface to the system's undo manager. +/// +/// To receive events from the system undo manager, create an +/// [UndoManagerClient] and set it as the [client] on [UndoManager]. +/// +/// The [setUndoState] method can be used to update the system's undo manager +/// using the [canUndo] and [canRedo] parameters. +/// +/// When the system undo or redo button is tapped, the current +/// [UndoManagerClient] will receive [UndoManagerClient.handlePlatformUndo] +/// with an [UndoDirection] representing whether the event is "undo" or "redo". +/// +/// Currently, only iOS has an UndoManagerPlugin implemented on the engine side. +/// On iOS, this can be used to listen to the keyboard undo/redo buttons and the +/// undo/redo gestures. +/// +/// See also: +/// +/// * [NSUndoManager](https://developer.apple.com/documentation/foundation/nsundomanager) +class UndoManager { + UndoManager._() { + _channel = SystemChannels.undoManager; + _channel.setMethodCallHandler(_handleUndoManagerInvocation); + } + + /// Set the [MethodChannel] used to communicate with the system's undo manager. + /// + /// This is only meant for testing within the Flutter SDK. Changing this + /// will break the ability to set the undo status or receive undo and redo + /// events from the system. This has no effect if asserts are disabled. + @visibleForTesting + static void setChannel(MethodChannel newChannel) { + assert(() { + _instance._channel = newChannel..setMethodCallHandler(_instance._handleUndoManagerInvocation); + return true; + }()); + } + + static final UndoManager _instance = UndoManager._(); + + /// Receive undo and redo events from the system's [UndoManager]. + /// + /// Setting the [client] will cause [UndoManagerClient.handlePlatformUndo] + /// to be called when a system undo or redo is triggered, such as by tapping + /// the undo/redo keyboard buttons or using the 3-finger swipe gestures. + static set client(UndoManagerClient? client) { + _instance._currentClient = client; + } + + /// Return the current [UndoManagerClient]. + static UndoManagerClient? get client => _instance._currentClient; + + /// Set the current state of the system UndoManager. [canUndo] and [canRedo] + /// control the respective "undo" and "redo" buttons of the system UndoManager. + static void setUndoState({bool canUndo = false, bool canRedo = false}) { + _instance._setUndoState(canUndo: canUndo, canRedo: canRedo); + } + + late MethodChannel _channel; + + UndoManagerClient? _currentClient; + + Future _handleUndoManagerInvocation(MethodCall methodCall) async { + final String method = methodCall.method; + final List args = methodCall.arguments as List; + if (method == 'UndoManagerClient.handleUndo') { + assert(_currentClient != null, 'There must be a current UndoManagerClient.'); + _currentClient!.handlePlatformUndo(_toUndoDirection(args[0] as String)); + + return; + } + + throw MissingPluginException(); + } + + void _setUndoState({bool canUndo = false, bool canRedo = false}) { + _channel.invokeMethod( + 'UndoManager.setUndoState', + {'canUndo': canUndo, 'canRedo': canRedo} + ); + } + + UndoDirection _toUndoDirection(String direction) { + switch (direction) { + case 'undo': + return UndoDirection.undo; + case 'redo': + return UndoDirection.redo; + } + throw FlutterError.fromParts([ErrorSummary('Unknown undo direction: $direction')]); + } +} + +/// An interface to receive events from a native UndoManager. +mixin UndoManagerClient { + /// Requests that the client perform an undo or redo operation. + /// + /// Currently only used on iOS 9+ when the undo or redo methods are invoked + /// by the platform. For example, when using three-finger swipe gestures, + /// the iPad keyboard, or voice control. + void handlePlatformUndo(UndoDirection direction); + + /// Reverts the value on the stack to the previous value. + void undo(); + + /// Updates the value on the stack to the next value. + void redo(); + + /// Will be true if there are past values on the stack. + bool get canUndo; + + /// Will be true if there are future values on the stack. + bool get canRedo; +} diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 32366462fdd1..f6656f55f189 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -43,6 +43,7 @@ import 'text_editing_intents.dart'; import 'text_selection.dart'; import 'text_selection_toolbar_anchors.dart'; import 'ticker_provider.dart'; +import 'undo_history.dart'; import 'view.dart'; import 'widget_span.dart'; @@ -806,10 +807,10 @@ class EditableText extends StatefulWidget { this.contextMenuBuilder, this.spellCheckConfiguration, this.magnifierConfiguration = TextMagnifierConfiguration.disabled, + this.undoController, }) : assert(obscuringCharacter.length == 1), smartDashesType = smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), - assert(maxLines == null || maxLines > 0), assert(minLines == null || minLines > 0), assert( (maxLines == null) || (minLines == null) || (maxLines >= minLines), @@ -965,6 +966,11 @@ class EditableText extends StatefulWidget { /// The text style to use for the editable text. final TextStyle style; + /// Controls the undo state of the current editable text. + /// + /// If null, this widget will create its own [UndoHistoryController]. + final UndoHistoryController? undoController; + /// {@template flutter.widgets.editableText.strutStyle} /// The strut style used for the vertical layout. /// @@ -2032,6 +2038,7 @@ class EditableText extends StatefulWidget { properties.add(DiagnosticsProperty('scribbleEnabled', scribbleEnabled, defaultValue: true)); properties.add(DiagnosticsProperty('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true)); properties.add(DiagnosticsProperty('enableInteractiveSelection', enableInteractiveSelection, defaultValue: true)); + properties.add(DiagnosticsProperty('undoController', undoController, defaultValue: null)); properties.add(DiagnosticsProperty('spellCheckConfiguration', spellCheckConfiguration, defaultValue: null)); properties.add(DiagnosticsProperty>('contentCommitMimeTypes', contentInsertionConfiguration?.allowedMimeTypes ?? const [], defaultValue: contentInsertionConfiguration == null ? const [] : kDefaultContentInsertionMimeTypes)); } @@ -4488,11 +4495,42 @@ class EditableTextState extends State with AutomaticKeepAliveClien cursor: widget.mouseCursor ?? SystemMouseCursors.text, child: Actions( actions: _actions, - child: _TextEditingHistory( - controller: widget.controller, + child: UndoHistory( + value: widget.controller, onTriggered: (TextEditingValue value) { userUpdateTextEditingValue(value, SelectionChangedCause.keyboard); }, + shouldChangeUndoStack: (TextEditingValue? oldValue, TextEditingValue newValue) { + if (newValue == TextEditingValue.empty) { + return false; + } + + if (oldValue == null) { + return true; + } + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + // Composing text is not counted in history coalescing. + if (!widget.controller.value.composing.isCollapsed) { + return false; + } + break; + case TargetPlatform.android: + // Gboard on Android puts non-CJK words in composing regions. Coalesce + // composing text in order to allow the saving of partial words in that + // case. + break; + } + + return oldValue.text != newValue.text || oldValue.composing != newValue.composing; + }, + focusNode: widget.focusNode, + controller: widget.undoController, child: Focus( focusNode: widget.focusNode, includeSemantics: false, @@ -5266,286 +5304,6 @@ class _CopySelectionAction extends ContextAction { 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({ - required this.child, - required this.controller, - required this.onTriggered, - }); - - /// 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 is used to prevent a reentrant call to the history (a call to _undo or _redo - // should not call _push to add a new entry in the history). - bool _locked = false; - - // 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; - } - _locked = true; - widget.onTriggered(widget.controller.value.copyWith( - text: nextValue.text, - selection: nextValue.selection, - )); - _locked = false; - } - - void _push() { - // Do not try to push a new state when the change is related to an undo or redo. - if (_locked) { - return; - } - if (widget.controller.value == TextEditingValue.empty) { - return; - } - - switch (defaultTargetPlatform) { - case TargetPlatform.iOS: - case TargetPlatform.macOS: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - // Composing text is not counted in history coalescing. - if (!widget.controller.value.composing.isCollapsed) { - return; - } - break; - case TargetPlatform.android: - // Gboard on Android puts non-CJK words in composing regions. Coalesce - // composing text in order to allow the saving of partial words in that - // case. - break; - } - - _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 -1 if the list is empty. - int _index = -1; - - /// 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 != _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!; - }; -} - /// The start and end glyph heights of some range of text. @immutable class _GlyphHeights { diff --git a/packages/flutter/lib/src/widgets/undo_history.dart b/packages/flutter/lib/src/widgets/undo_history.dart new file mode 100644 index 000000000000..ae960f276d1a --- /dev/null +++ b/packages/flutter/lib/src/widgets/undo_history.dart @@ -0,0 +1,483 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'actions.dart'; +import 'focus_manager.dart'; +import 'framework.dart'; +import 'text_editing_intents.dart'; + +/// Provides undo/redo capabilities for a [ValueNotifier]. +/// +/// Listens to [value] and saves relevant values for undoing/redoing. The +/// cadence at which values are saved is a best approximation of the native +/// behaviors of a number of hardware keyboard on Flutter's desktop +/// platforms, as there are subtle differences between each of the platforms. +/// +/// Listens to keyboard undo/redo shortcuts and calls [onTriggered] when a +/// shortcut is triggered that would affect the state of the [value]. +/// +/// The [child] must manage focus on the [focusNode]. For example, using a +/// [TextField] or [Focus] widget. +class UndoHistory extends StatefulWidget { + /// Creates an instance of [UndoHistory]. + const UndoHistory({ + super.key, + this.shouldChangeUndoStack, + required this.value, + required this.onTriggered, + required this.focusNode, + this.controller, + required this.child, + }); + + /// The value to track over time. + final ValueNotifier value; + + /// Called when checking whether a value change should be pushed onto + /// the undo stack. + final bool Function(T? oldValue, T newValue)? shouldChangeUndoStack; + + /// 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. + /// + /// Changes to the [value] while this method is running will not be recorded + /// on the undo stack. For example, a [TextInputFormatter] may change the value + /// from what was on the undo stack, but this new value will not be recorded, + /// as that would wipe out the redo history. + final void Function(T value) onTriggered; + + /// The [FocusNode] that will be used to listen for focus to set the initial + /// undo state for the element. + final FocusNode focusNode; + + /// {@template flutter.widgets.undoHistory.controller} + /// Controls the undo state. + /// + /// If null, this widget will create its own [UndoHistoryController]. + /// {@endtemplate} + final UndoHistoryController? controller; + + /// The child widget of [UndoHistory]. + final Widget child; + + @override + State> createState() => UndoHistoryState(); +} + +/// State for a [UndoHistory]. +/// +/// Provides [undo], [redo], [canUndo], and [canRedo] for programmatic access +/// to the undo state for custom undo and redo UI implementations. +@visibleForTesting +class UndoHistoryState extends State> with UndoManagerClient { + final _UndoStack _stack = _UndoStack(); + late final _Throttled _throttledPush; + Timer? _throttleTimer; + bool _duringTrigger = false; + + // 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); + + // Record the last value to prevent pushing multiple + // of the same value in a row onto the undo stack. For example, _push gets + // called both in initState and when the EditableText receives focus. + T? _lastValue; + + UndoHistoryController? _controller; + + UndoHistoryController get _effectiveController => widget.controller ?? (_controller ??= UndoHistoryController()); + + @override + void undo() { + _update(_stack.undo()); + _updateState(); + } + + @override + void redo() { + _update(_stack.redo()); + _updateState(); + } + + @override + bool get canUndo => _stack.canUndo; + + @override + bool get canRedo => _stack.canRedo; + + void _updateState() { + _effectiveController.value = UndoHistoryValue(canUndo: canUndo, canRedo: canRedo); + + if (defaultTargetPlatform != TargetPlatform.iOS) { + return; + } + + if (UndoManager.client == this) { + UndoManager.setUndoState(canUndo: canUndo, canRedo: canRedo); + } + } + + void _undoFromIntent(UndoTextIntent intent) { + undo(); + } + + void _redoFromIntent(RedoTextIntent intent) { + redo(); + } + + void _update(T? nextValue) { + if (nextValue == null) { + return; + } + if (nextValue == _lastValue) { + return; + } + _lastValue = nextValue; + _duringTrigger = true; + try { + widget.onTriggered(nextValue); + assert(widget.value.value == nextValue); + } finally { + _duringTrigger = false; + } + } + + void _push() { + if (widget.value.value == _lastValue) { + return; + } + + if (_duringTrigger) { + return; + } + + if (!(widget.shouldChangeUndoStack?.call(_lastValue, widget.value.value) ?? true)) { + return; + } + + _lastValue = widget.value.value; + + _throttleTimer = _throttledPush(widget.value.value); + } + + void _handleFocus() { + if (!widget.focusNode.hasFocus) { + return; + } + UndoManager.client = this; + _updateState(); + } + + @override + void handlePlatformUndo(UndoDirection direction) { + switch(direction) { + case UndoDirection.undo: + undo(); + break; + case UndoDirection.redo: + redo(); + break; + } + } + + @override + void initState() { + super.initState(); + _throttledPush = _throttle( + duration: _kThrottleDuration, + function: (T currentValue) { + _stack.push(currentValue); + _updateState(); + }, + ); + _push(); + widget.value.addListener(_push); + _handleFocus(); + widget.focusNode.addListener(_handleFocus); + _effectiveController.onUndo.addListener(undo); + _effectiveController.onRedo.addListener(redo); + } + + @override + void didUpdateWidget(UndoHistory oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != oldWidget.value) { + _stack.clear(); + oldWidget.value.removeListener(_push); + widget.value.addListener(_push); + } + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode.removeListener(_handleFocus); + widget.focusNode.addListener(_handleFocus); + } + if (widget.controller != oldWidget.controller) { + _effectiveController.onUndo.removeListener(undo); + _effectiveController.onRedo.removeListener(redo); + _controller?.dispose(); + _controller = null; + _effectiveController.onUndo.addListener(undo); + _effectiveController.onRedo.addListener(redo); + } + } + + @override + void dispose() { + widget.value.removeListener(_push); + widget.focusNode.removeListener(_handleFocus); + _effectiveController.onUndo.removeListener(undo); + _effectiveController.onRedo.removeListener(redo); + _controller?.dispose(); + _throttleTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Actions( + actions: >{ + UndoTextIntent: Action.overridable(context: context, defaultAction: CallbackAction(onInvoke: _undoFromIntent)), + RedoTextIntent: Action.overridable(context: context, defaultAction: CallbackAction(onInvoke: _redoFromIntent)), + }, + child: widget.child, + ); + } +} + +/// Represents whether the current undo stack can undo or redo. +@immutable +class UndoHistoryValue { + /// Creates a value for whether the current undo stack can undo or redo. + /// + /// The [canUndo] and [canRedo] arguments must have a value, but default to + /// false. + const UndoHistoryValue({this.canUndo = false, this.canRedo = false}); + + /// A value corresponding to an undo stack that can neither undo nor redo. + static const UndoHistoryValue empty = UndoHistoryValue(); + + /// Whether the current undo stack can perform an undo operation. + final bool canUndo; + + /// Whether the current undo stack can perform a redo operation. + final bool canRedo; + + @override + String toString() => '${objectRuntimeType(this, 'UndoHistoryValue')}(canUndo: $canUndo, canRedo: $canRedo)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is UndoHistoryValue && other.canUndo == canUndo && other.canRedo == canRedo; + } + + @override + int get hashCode => Object.hash( + canUndo.hashCode, + canRedo.hashCode, + ); +} + +/// A controller for the undo history, for example for an editable text field. +/// +/// Whenever a change happens to the underlying value that the [UndoHistory] +/// widget tracks, that widget updates the [value] and the controller notifies +/// it's listeners. Listeners can then read the canUndo and canRedo +/// properties of the value to discover whether [undo] or [redo] are possible. +/// +/// The controller also has [undo] and [redo] methods to modify the undo +/// history. +/// +/// {@tool dartpad} +/// This example creates a [TextField] with an [UndoHistoryController] +/// which provides undo and redo buttons. +/// +/// ** See code in examples/api/lib/widgets/undo_history/undo_history_controller.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [EditableText], which uses the [UndoHistory] widget and allows +/// control of the underlying history using an [UndoHistoryController]. +class UndoHistoryController extends ValueNotifier { + /// Creates a controller for an [UndoHistory] widget. + UndoHistoryController({UndoHistoryValue? value}) : super(value ?? UndoHistoryValue.empty); + + /// Notifies listeners that [undo] has been called. + final ChangeNotifier onUndo = ChangeNotifier(); + + /// Notifies listeners that [redo] has been called. + final ChangeNotifier onRedo = ChangeNotifier(); + + /// Reverts the value on the stack to the previous value. + void undo() { + if (!value.canUndo) { + return; + } + + onUndo.notifyListeners(); + } + + /// Updates the value on the stack to the next value. + void redo() { + if (!value.canRedo) { + return; + } + + onRedo.notifyListeners(); + } + + @override + void dispose() { + onUndo.dispose(); + onRedo.dispose(); + super.dispose(); + } +} + +/// 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 -1 if the list is empty. + int _index = -1; + + /// Returns the current value of the stack. + T? get currentValue => _list.isEmpty ? null : _list[_index]; + + bool get canUndo => _list.isNotEmpty && _index > 0; + + bool get canRedo => _list.isNotEmpty && _index < _list.length - 1; + + /// 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 != _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/widgets.dart b/packages/flutter/lib/widgets.dart index 27b8865f5d5b..0464daa9c75c 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -147,6 +147,7 @@ export 'src/widgets/ticker_provider.dart'; export 'src/widgets/title.dart'; export 'src/widgets/transitions.dart'; export 'src/widgets/tween_animation_builder.dart'; +export 'src/widgets/undo_history.dart'; export 'src/widgets/unique_widget.dart'; export 'src/widgets/value_listenable_builder.dart'; export 'src/widgets/view.dart'; diff --git a/packages/flutter/test/services/undo_manager_test.dart b/packages/flutter/test/services/undo_manager_test.dart new file mode 100644 index 000000000000..1e3a89dfbc42 --- /dev/null +++ b/packages/flutter/test/services/undo_manager_test.dart @@ -0,0 +1,67 @@ +// 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 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Undo Interactions', () { + test('UndoManagerClient handleUndo', () async { + // Assemble an UndoManagerClient so we can verify its change in state. + final _FakeUndoManagerClient client = _FakeUndoManagerClient(); + UndoManager.client = client; + + expect(client.latestMethodCall, isEmpty); + + // Send handleUndo message with "undo" as the direction. + ByteData? messageBytes = const JSONMessageCodec().encodeMessage({ + 'args': ['undo'], + 'method': 'UndoManagerClient.handleUndo', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/undomanager', + messageBytes, + null, + ); + + expect(client.latestMethodCall, 'handlePlatformUndo(${UndoDirection.undo})'); + + // Send handleUndo message with "undo" as the direction. + messageBytes = const JSONMessageCodec().encodeMessage({ + 'args': ['redo'], + 'method': 'UndoManagerClient.handleUndo', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/undomanager', + messageBytes, + (ByteData? _) {}, + ); + + expect(client.latestMethodCall, 'handlePlatformUndo(${UndoDirection.redo})'); + }); + }); +} + +class _FakeUndoManagerClient with UndoManagerClient { + String latestMethodCall = ''; + + @override + void undo() {} + + @override + void redo() {} + + @override + bool get canUndo => false; + + @override + bool get canRedo => false; + + @override + void handlePlatformUndo(UndoDirection direction) { + latestMethodCall = 'handlePlatformUndo($direction)'; + } +} diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 1bf00f6fa001..02622209b788 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -6057,68 +6057,6 @@ void main() { 'to come to the aid\n' // 36 + 19 => 55 'of their country.'; // 55 + 17 => 72 - Future sendKeys( - WidgetTester tester, - List keys, { - bool shift = false, - bool wordModifier = false, - bool lineModifier = false, - bool shortcutModifier = false, - required TargetPlatform targetPlatform, - }) async { - final String targetPlatformString = targetPlatform.toString(); - final String platform = targetPlatformString.substring(targetPlatformString.indexOf('.') + 1).toLowerCase(); - if (shift) { - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft, platform: platform); - } - if (shortcutModifier) { - await tester.sendKeyDownEvent( - platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.controlLeft, - platform: platform, - ); - } - if (wordModifier) { - await tester.sendKeyDownEvent( - platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.altLeft : LogicalKeyboardKey.controlLeft, - platform: platform, - ); - } - if (lineModifier) { - await tester.sendKeyDownEvent( - platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.altLeft, - platform: platform, - ); - } - for (final LogicalKeyboardKey key in keys) { - await tester.sendKeyEvent(key, platform: platform); - await tester.pump(); - } - if (lineModifier) { - await tester.sendKeyUpEvent( - platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.altLeft, - platform: platform, - ); - } - if (wordModifier) { - await tester.sendKeyUpEvent( - platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.altLeft : LogicalKeyboardKey.controlLeft, - platform: platform, - ); - } - if (shortcutModifier) { - await tester.sendKeyUpEvent( - platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.controlLeft, - platform: platform, - ); - } - if (shift) { - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft, platform: platform); - } - if (shift || wordModifier || lineModifier) { - await tester.pump(); - } - } - Future testTextEditing(WidgetTester tester, {required TargetPlatform targetPlatform}) async { final String targetPlatformString = targetPlatform.toString(); final String platform = targetPlatformString.substring(targetPlatformString.indexOf('.') + 1).toLowerCase(); @@ -13066,14 +13004,14 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async // Undo first insertion. await sendUndo(tester); - expect(controller.value, composingStep2.copyWith(composing: TextRange.empty)); + expect(controller.value, composingStep2); // Waiting for the throttling beetween undos should have no effect. await tester.pump(const Duration(milliseconds: 500)); // Undo second insertion. await sendUndo(tester); - expect(controller.value, composingStep1.copyWith(composing: TextRange.empty)); + expect(controller.value, composingStep1); // On web, these keyboard shortcuts are handled by the browser. }, variant: TargetPlatformVariant.only(TargetPlatform.android), skip: kIsWeb); // [intended] @@ -13594,6 +13532,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async controller.value, const TextEditingValue( text: '1 nihao', + composing: TextRange(start: 2, end: 7), selection: TextSelection.collapsed(offset: 7), ), ); @@ -13603,6 +13542,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async controller.value, const TextEditingValue( text: '1 ni', + composing: TextRange(start: 2, end: 4), selection: TextSelection.collapsed(offset: 4), ), ); @@ -13620,6 +13560,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async controller.value, const TextEditingValue( text: '1 ni', + composing: TextRange(start: 2, end: 4), selection: TextSelection.collapsed(offset: 4), ), ); @@ -13628,6 +13569,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async controller.value, const TextEditingValue( text: '1 nihao', + composing: TextRange(start: 2, end: 7), selection: TextSelection.collapsed(offset: 7), ), ); @@ -13653,6 +13595,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async controller.value, const TextEditingValue( text: '1 nihao', + composing: TextRange(start: 2, end: 7), selection: TextSelection.collapsed(offset: 7), ), ); @@ -13661,6 +13604,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async controller.value, const TextEditingValue( text: '1 ni', + composing: TextRange(start: 2, end: 4), selection: TextSelection.collapsed(offset: 4), ), ); @@ -13700,6 +13644,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async controller.value, const TextEditingValue( text: '1 ni', + composing: TextRange(start: 2, end: 4), selection: TextSelection.collapsed(offset: 4), ), ); @@ -13708,6 +13653,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async controller.value, const TextEditingValue( text: '1 nihao', + composing: TextRange(start: 2, end: 7), selection: TextSelection.collapsed(offset: 7), ), ); @@ -13829,6 +13775,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async controller.value, const TextEditingValue( text: '1 2 ni', + composing: TextRange(start: 4, end: 6), selection: TextSelection.collapsed(offset: 6), ), ); @@ -13887,6 +13834,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async controller.value, const TextEditingValue( text: '1 2 ni', + composing: TextRange(start: 4, end: 6), selection: TextSelection.collapsed(offset: 6), ), ); diff --git a/packages/flutter/test/widgets/editable_text_utils.dart b/packages/flutter/test/widgets/editable_text_utils.dart index abcb1c875bfc..d058437f10fe 100644 --- a/packages/flutter/test/widgets/editable_text_utils.dart +++ b/packages/flutter/test/widgets/editable_text_utils.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; /// On web, the context menu (aka toolbar) is provided by the browser. @@ -49,6 +50,69 @@ Offset textOffsetToPosition(WidgetTester tester, int offset, {int index = 0}) { return endpoints[0].point + const Offset(kIsWeb? 1.0 : 0.0, -2.0); } +/// Mimic key press events by sending key down and key up events via the [tester]. +Future sendKeys( + WidgetTester tester, + List keys, { + bool shift = false, + bool wordModifier = false, + bool lineModifier = false, + bool shortcutModifier = false, + required TargetPlatform targetPlatform, + }) async { + final String targetPlatformString = targetPlatform.toString(); + final String platform = targetPlatformString.substring(targetPlatformString.indexOf('.') + 1).toLowerCase(); + if (shift) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft, platform: platform); + } + if (shortcutModifier) { + await tester.sendKeyDownEvent( + platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.controlLeft, + platform: platform, + ); + } + if (wordModifier) { + await tester.sendKeyDownEvent( + platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.altLeft : LogicalKeyboardKey.controlLeft, + platform: platform, + ); + } + if (lineModifier) { + await tester.sendKeyDownEvent( + platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.altLeft, + platform: platform, + ); + } + for (final LogicalKeyboardKey key in keys) { + await tester.sendKeyEvent(key, platform: platform); + await tester.pump(); + } + if (lineModifier) { + await tester.sendKeyUpEvent( + platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.altLeft, + platform: platform, + ); + } + if (wordModifier) { + await tester.sendKeyUpEvent( + platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.altLeft : LogicalKeyboardKey.controlLeft, + platform: platform, + ); + } + if (shortcutModifier) { + await tester.sendKeyUpEvent( + platform == 'macos' || platform == 'ios' ? LogicalKeyboardKey.metaLeft : LogicalKeyboardKey.controlLeft, + platform: platform, + ); + } + if (shift) { + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft, platform: platform); + } + if (shift || wordModifier || lineModifier) { + await tester.pump(); + } +} + // Simple controller that builds a WidgetSpan with 100 height. class OverflowWidgetTextEditingController extends TextEditingController { @override diff --git a/packages/flutter/test/widgets/undo_history_test.dart b/packages/flutter/test/widgets/undo_history_test.dart new file mode 100644 index 000000000000..9d2a8d0b66ab --- /dev/null +++ b/packages/flutter/test/widgets/undo_history_test.dart @@ -0,0 +1,517 @@ +// 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 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'editable_text_utils.dart'; + +final FocusNode focusNode = FocusNode(debugLabel: 'UndoHistory Node'); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('UndoHistory', () { + 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('allows undo and redo to be called programmatically from the UndoHistoryController', (WidgetTester tester) async { + final ValueNotifier value = ValueNotifier(0); + final UndoHistoryController controller = UndoHistoryController(); + await tester.pumpWidget( + MaterialApp( + home: UndoHistory( + value: value, + controller: controller, + onTriggered: (int newValue) { + value.value = newValue; + }, + focusNode: focusNode, + child: Container(), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 500)); + + // Undo/redo have no effect if the value has never changed. + expect(controller.value.canUndo, false); + expect(controller.value.canRedo, false); + controller.undo(); + expect(value.value, 0); + controller.redo(); + expect(value.value, 0); + + focusNode.requestFocus(); + await tester.pump(); + expect(controller.value.canUndo, false); + expect(controller.value.canRedo, false); + controller.undo(); + expect(value.value, 0); + controller.redo(); + expect(value.value, 0); + + value.value = 1; + + // Wait for the throttling. + await tester.pump(const Duration(milliseconds: 500)); + + // Can undo/redo a single change. + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + controller.undo(); + expect(value.value, 0); + expect(controller.value.canUndo, false); + expect(controller.value.canRedo, true); + controller.redo(); + expect(value.value, 1); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + + value.value = 2; + await tester.pump(const Duration(milliseconds: 500)); + + // And can undo/redo multiple changes. + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + controller.undo(); + expect(value.value, 1); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, true); + controller.undo(); + expect(value.value, 0); + expect(controller.value.canUndo, false); + expect(controller.value.canRedo, true); + controller.redo(); + expect(value.value, 1); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, true); + controller.redo(); + expect(value.value, 2); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + + // Changing the value again clears the redo stack. + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + controller.undo(); + expect(value.value, 1); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, true); + value.value = 3; + await tester.pump(const Duration(milliseconds: 500)); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + }, variant: TargetPlatformVariant.all()); + + testWidgets('allows undo and redo to be called using the keyboard', (WidgetTester tester) async { + final ValueNotifier value = ValueNotifier(0); + final UndoHistoryController controller = UndoHistoryController(); + await tester.pumpWidget( + MaterialApp( + home: UndoHistory( + controller: controller, + value: value, + onTriggered: (int newValue) { + value.value = newValue; + }, + focusNode: focusNode, + child: Focus( + focusNode: focusNode, + child: Container(), + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 500)); + + // Undo/redo have no effect if the value has never changed. + expect(controller.value.canUndo, false); + expect(controller.value.canRedo, false); + await sendUndo(tester); + expect(value.value, 0); + await sendRedo(tester); + expect(value.value, 0); + + focusNode.requestFocus(); + await tester.pump(); + expect(controller.value.canUndo, false); + expect(controller.value.canRedo, false); + await sendUndo(tester); + expect(value.value, 0); + await sendRedo(tester); + expect(value.value, 0); + + value.value = 1; + + // Wait for the throttling. + await tester.pump(const Duration(milliseconds: 500)); + + // Can undo/redo a single change. + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + await sendUndo(tester); + expect(value.value, 0); + expect(controller.value.canUndo, false); + expect(controller.value.canRedo, true); + await sendRedo(tester); + expect(value.value, 1); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + + value.value = 2; + await tester.pump(const Duration(milliseconds: 500)); + + // And can undo/redo multiple changes. + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + await sendUndo(tester); + expect(value.value, 1); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, true); + await sendUndo(tester); + expect(value.value, 0); + expect(controller.value.canUndo, false); + expect(controller.value.canRedo, true); + await sendRedo(tester); + expect(value.value, 1); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, true); + await sendRedo(tester); + expect(value.value, 2); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + + // Changing the value again clears the redo stack. + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + await sendUndo(tester); + expect(value.value, 1); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, true); + value.value = 3; + await tester.pump(const Duration(milliseconds: 500)); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + }, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended] + + testWidgets('duplicate changes do not affect the undo history', (WidgetTester tester) async { + final ValueNotifier value = ValueNotifier(0); + final UndoHistoryController controller = UndoHistoryController(); + await tester.pumpWidget( + MaterialApp( + home: UndoHistory( + controller: controller, + value: value, + onTriggered: (int newValue) { + value.value = newValue; + }, + focusNode: focusNode, + child: Container(), + ), + ), + ); + + focusNode.requestFocus(); + + // Wait for the throttling. + await tester.pump(const Duration(milliseconds: 500)); + + value.value = 1; + + // Wait for the throttling. + await tester.pump(const Duration(milliseconds: 500)); + + // Can undo/redo a single change. + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + controller.undo(); + expect(value.value, 0); + expect(controller.value.canUndo, false); + expect(controller.value.canRedo, true); + controller.redo(); + expect(value.value, 1); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + + // Changes that result in the same state won't be saved on the undo stack. + value.value = 1; + await tester.pump(const Duration(milliseconds: 500)); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + controller.undo(); + expect(value.value, 0); + expect(controller.value.canUndo, false); + expect(controller.value.canRedo, true); + }, variant: TargetPlatformVariant.all()); + + testWidgets('ignores value changes pushed during onTriggered', (WidgetTester tester) async { + final ValueNotifier value = ValueNotifier(0); + final UndoHistoryController controller = UndoHistoryController(); + int Function(int newValue) valueToUse = (int value) => value; + final GlobalKey> key = GlobalKey>(); + await tester.pumpWidget( + MaterialApp( + home: UndoHistory( + key: key, + value: value, + controller: controller, + onTriggered: (int newValue) { + value.value = valueToUse(newValue); + }, + focusNode: focusNode, + child: Container(), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 500)); + + // Undo/redo have no effect if the value has never changed. + expect(controller.value.canUndo, false); + expect(controller.value.canRedo, false); + controller.undo(); + expect(value.value, 0); + controller.redo(); + expect(value.value, 0); + + focusNode.requestFocus(); + await tester.pump(); + expect(controller.value.canUndo, false); + expect(controller.value.canRedo, false); + controller.undo(); + expect(value.value, 0); + controller.redo(); + expect(value.value, 0); + + value.value = 1; + + // Wait for the throttling. + await tester.pump(const Duration(milliseconds: 500)); + + valueToUse = (int value) => 3; + expect(() => key.currentState!.undo(), throwsAssertionError); + }, variant: TargetPlatformVariant.all()); + + testWidgets('changes should send setUndoState to the UndoManagerConnection on iOS', (WidgetTester tester) async { + final List log = []; + SystemChannels.undoManager.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + }); + final FocusNode focusNode = FocusNode(); + + final ValueNotifier value = ValueNotifier(0); + final UndoHistoryController controller = UndoHistoryController(); + await tester.pumpWidget( + MaterialApp( + home: UndoHistory( + controller: controller, + value: value, + onTriggered: (int newValue) { + value.value = newValue; + }, + focusNode: focusNode, + child: Focus( + focusNode: focusNode, + child: Container(), + ), + ), + ), + ); + + await tester.pump(); + + focusNode.requestFocus(); + await tester.pump(); + + // Wait for the throttling. + await tester.pump(const Duration(milliseconds: 500)); + + // Undo and redo should both be disabled. + MethodCall methodCall = log.lastWhere((MethodCall m) => m.method == 'UndoManager.setUndoState'); + expect(methodCall.method, 'UndoManager.setUndoState'); + expect(methodCall.arguments as Map, {'canUndo': false, 'canRedo': false}); + + // Making a change should enable undo. + value.value = 1; + await tester.pump(const Duration(milliseconds: 500)); + + methodCall = log.lastWhere((MethodCall m) => m.method == 'UndoManager.setUndoState'); + expect(methodCall.method, 'UndoManager.setUndoState'); + expect(methodCall.arguments as Map, {'canUndo': true, 'canRedo': false}); + + // Undo should remain enabled after another change. + value.value = 2; + await tester.pump(const Duration(milliseconds: 500)); + + methodCall = log.lastWhere((MethodCall m) => m.method == 'UndoManager.setUndoState'); + expect(methodCall.method, 'UndoManager.setUndoState'); + expect(methodCall.arguments as Map, {'canUndo': true, 'canRedo': false}); + + // Undo and redo should be enabled after one undo. + controller.undo(); + methodCall = log.lastWhere((MethodCall m) => m.method == 'UndoManager.setUndoState'); + expect(methodCall.method, 'UndoManager.setUndoState'); + expect(methodCall.arguments as Map, {'canUndo': true, 'canRedo': true}); + + // Only redo should be enabled after a second undo. + controller.undo(); + methodCall = log.lastWhere((MethodCall m) => m.method == 'UndoManager.setUndoState'); + expect(methodCall.method, 'UndoManager.setUndoState'); + expect(methodCall.arguments as Map, {'canUndo': false, 'canRedo': true}); + }, variant: const TargetPlatformVariant({TargetPlatform.iOS}), skip: kIsWeb); // [intended] + + testWidgets('handlePlatformUndo should undo or redo appropriately on iOS', (WidgetTester tester) async { + final ValueNotifier value = ValueNotifier(0); + final UndoHistoryController controller = UndoHistoryController(); + await tester.pumpWidget( + MaterialApp( + home: UndoHistory( + controller: controller, + value: value, + onTriggered: (int newValue) { + value.value = newValue; + }, + focusNode: focusNode, + child: Focus( + focusNode: focusNode, + child: Container(), + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 500)); + focusNode.requestFocus(); + await tester.pump(); + + // Undo/redo have no effect if the value has never changed. + expect(controller.value.canUndo, false); + expect(controller.value.canRedo, false); + UndoManager.client!.handlePlatformUndo(UndoDirection.undo); + expect(value.value, 0); + UndoManager.client!.handlePlatformUndo(UndoDirection.redo); + expect(value.value, 0); + + value.value = 1; + + // Wait for the throttling. + await tester.pump(const Duration(milliseconds: 500)); + + // Can undo/redo a single change. + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + UndoManager.client!.handlePlatformUndo(UndoDirection.undo); + expect(value.value, 0); + expect(controller.value.canUndo, false); + expect(controller.value.canRedo, true); + UndoManager.client!.handlePlatformUndo(UndoDirection.redo); + expect(value.value, 1); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + + value.value = 2; + await tester.pump(const Duration(milliseconds: 500)); + + // And can undo/redo multiple changes. + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + UndoManager.client!.handlePlatformUndo(UndoDirection.undo); + expect(value.value, 1); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, true); + UndoManager.client!.handlePlatformUndo(UndoDirection.undo); + expect(value.value, 0); + expect(controller.value.canUndo, false); + expect(controller.value.canRedo, true); + UndoManager.client!.handlePlatformUndo(UndoDirection.redo); + expect(value.value, 1); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, true); + UndoManager.client!.handlePlatformUndo(UndoDirection.redo); + expect(value.value, 2); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + + // Changing the value again clears the redo stack. + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + UndoManager.client!.handlePlatformUndo(UndoDirection.undo); + expect(value.value, 1); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, true); + value.value = 3; + await tester.pump(const Duration(milliseconds: 500)); + expect(controller.value.canUndo, true); + expect(controller.value.canRedo, false); + }, variant: const TargetPlatformVariant({TargetPlatform.iOS}), skip: kIsWeb); // [intended] + }); + + group('UndoHistoryController', () { + testWidgets('UndoHistoryController notifies onUndo listeners onUndo', (WidgetTester tester) async { + int calls = 0; + final UndoHistoryController controller = UndoHistoryController(); + controller.onUndo.addListener(() { + calls++; + }); + + // Does not notify the listener if canUndo is false. + controller.undo(); + expect(calls, 0); + + // Does notify the listener if canUndo is true. + controller.value = const UndoHistoryValue(canUndo: true); + controller.undo(); + expect(calls, 1); + }); + + testWidgets('UndoHistoryController notifies onRedo listeners onRedo', (WidgetTester tester) async { + int calls = 0; + final UndoHistoryController controller = UndoHistoryController(); + controller.onRedo.addListener(() { + calls++; + }); + + // Does not notify the listener if canUndo is false. + controller.redo(); + expect(calls, 0); + + // Does notify the listener if canRedo is true. + controller.value = const UndoHistoryValue(canRedo: true); + controller.redo(); + expect(calls, 1); + }); + + testWidgets('UndoHistoryController notifies listeners on value change', (WidgetTester tester) async { + int calls = 0; + final UndoHistoryController controller = UndoHistoryController(value: const UndoHistoryValue(canUndo: true)); + controller.addListener(() { + calls++; + }); + + // Does not notify if the value is the same. + controller.value = const UndoHistoryValue(canUndo: true); + expect(calls, 0); + + // Does notify if the value has changed. + controller.value = const UndoHistoryValue(canRedo: true); + expect(calls, 1); + }); + }); +} diff --git a/packages/flutter_test/lib/src/test_text_input.dart b/packages/flutter_test/lib/src/test_text_input.dart index 1aa2d8c0873c..5271a1da0fde 100644 --- a/packages/flutter_test/lib/src/test_text_input.dart +++ b/packages/flutter_test/lib/src/test_text_input.dart @@ -370,4 +370,16 @@ class TestTextInput { Future handleKeyUpEvent(LogicalKeyboardKey key) async { await _keyHandler?.handleKeyUpEvent(key); } + + /// Simulates iOS responding to an undo or redo gesture or button. + Future handleKeyboardUndo(String direction) async { + assert(isRegistered); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall('TextInputClient.handleUndo', [direction]), + ), + (ByteData? data) {/* response from framework is discarded */}, + ); + } }