Unverified Commit fa98a522 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Undo/redo (#96968)

parent 93a1b7a5
......@@ -205,6 +205,8 @@ class DefaultTextEditingShortcuts extends Shortcuts {
const SingleActivator(LogicalKeyboardKey.keyC, control: true): CopySelectionTextIntent.copy,
const SingleActivator(LogicalKeyboardKey.keyV, control: true): const PasteTextIntent(SelectionChangedCause.keyboard),
const SingleActivator(LogicalKeyboardKey.keyA, control: true): const SelectAllTextIntent(SelectionChangedCause.keyboard),
const SingleActivator(LogicalKeyboardKey.keyZ, control: true): const UndoTextIntent(SelectionChangedCause.keyboard),
const SingleActivator(LogicalKeyboardKey.keyZ, shift: true, control: true): const RedoTextIntent(SelectionChangedCause.keyboard),
};
// The following key combinations have no effect on text editing on this
......@@ -215,6 +217,7 @@ class DefaultTextEditingShortcuts extends Shortcuts {
// * Meta + C
// * Meta + V
// * Meta + A
// * Meta + shift? + Z
// * Meta + shift? + arrow down
// * Meta + shift? + arrow left
// * Meta + shift? + arrow right
......@@ -235,6 +238,7 @@ class DefaultTextEditingShortcuts extends Shortcuts {
// * Meta + C
// * Meta + V
// * Meta + A
// * Meta + shift? + Z
// * Meta + shift? + arrow down
// * Meta + shift? + arrow left
// * Meta + shift? + arrow right
......@@ -259,6 +263,7 @@ class DefaultTextEditingShortcuts extends Shortcuts {
// * Meta + C
// * Meta + V
// * Meta + A
// * Meta + shift? + Z
// * Meta + shift? + arrow down
// * Meta + shift? + arrow left
// * Meta + shift? + arrow right
......@@ -319,12 +324,15 @@ class DefaultTextEditingShortcuts extends Shortcuts {
const SingleActivator(LogicalKeyboardKey.keyC, meta: true): CopySelectionTextIntent.copy,
const SingleActivator(LogicalKeyboardKey.keyV, meta: true): const PasteTextIntent(SelectionChangedCause.keyboard),
const SingleActivator(LogicalKeyboardKey.keyA, meta: true): const SelectAllTextIntent(SelectionChangedCause.keyboard),
const SingleActivator(LogicalKeyboardKey.keyZ, meta: true): const UndoTextIntent(SelectionChangedCause.keyboard),
const SingleActivator(LogicalKeyboardKey.keyZ, shift: true, meta: true): const RedoTextIntent(SelectionChangedCause.keyboard),
// The following key combinations have no effect on text editing on this
// platform:
// * End
// * Home
// * Control + shift? + end
// * Control + shift? + home
// * Control + shift? + Z
};
// The following key combinations have no effect on text editing on this
......
......@@ -30,6 +30,7 @@ import 'scroll_configuration.dart';
import 'scroll_controller.dart';
import 'scroll_physics.dart';
import 'scrollable.dart';
import 'shortcuts.dart';
import 'text.dart';
import 'text_editing_intents.dart';
import 'text_selection.dart';
......@@ -3138,6 +3139,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
cursor: widget.mouseCursor ?? SystemMouseCursors.text,
child: Actions(
actions: _actions,
child: _TextEditingHistory(
controller: widget.controller,
onTriggered: (TextEditingValue value) {
userUpdateTextEditingValue(value, SelectionChangedCause.keyboard);
},
child: Focus(
focusNode: widget.focusNode,
includeSemantics: false,
......@@ -3226,6 +3232,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
),
),
),
),
);
}
......@@ -4154,3 +4161,256 @@ class _CopySelectionAction extends ContextAction<CopySelectionTextIntent> {
@override
bool get isActionEnabled => state._value.selection.isValid && !state._value.selection.isCollapsed;
}
/// A void function that takes a [TextEditingValue].
@visibleForTesting
typedef TextEditingValueCallback = void Function(TextEditingValue value);
/// Provides undo/redo capabilities for text editing.
///
/// Listens to [controller] as a [ValueNotifier] and saves relevant values for
/// undoing/redoing. The cadence at which values are saved is a best
/// approximation of the native behaviors of a hardware keyboard on Flutter's
/// desktop platforms, as there are subtle differences between each of these
/// platforms.
///
/// Listens to keyboard undo/redo shortcuts and calls [onTriggered] when a
/// shortcut is triggered that would affect the state of the [controller].
class _TextEditingHistory extends StatefulWidget {
/// Creates an instance of [_TextEditingHistory].
const _TextEditingHistory({
Key? key,
required this.child,
required this.controller,
required this.onTriggered,
}) : super(key: key);
/// The child widget of [_TextEditingHistory].
final Widget child;
/// The [TextEditingController] to save the state of over time.
final TextEditingController controller;
/// Called when an undo or redo causes a state change.
///
/// If the state would still be the same before and after the undo/redo, this
/// will not be called. For example, receiving a redo when there is nothing
/// to redo will not call this method.
///
/// It is also not called when the controller is changed for reasons other
/// than undo/redo.
final TextEditingValueCallback onTriggered;
@override
State<_TextEditingHistory> createState() => _TextEditingHistoryState();
}
class _TextEditingHistoryState extends State<_TextEditingHistory> {
final _UndoStack<TextEditingValue> _stack = _UndoStack<TextEditingValue>();
late final _Throttled<TextEditingValue> _throttledPush;
Timer? _throttleTimer;
// This duration was chosen as a best fit for the behavior of Mac, Linux,
// and Windows undo/redo state save durations, but it is not perfect for any
// of them.
static const Duration _kThrottleDuration = Duration(milliseconds: 500);
void _undo(UndoTextIntent intent) {
_update(_stack.undo());
}
void _redo(RedoTextIntent intent) {
_update(_stack.redo());
}
void _update(TextEditingValue? nextValue) {
if (nextValue == null) {
return;
}
if (nextValue.text == widget.controller.text) {
return;
}
widget.onTriggered(widget.controller.value.copyWith(
text: nextValue.text,
selection: nextValue.selection,
));
}
void _push() {
if (widget.controller.value == TextEditingValue.empty) {
return;
}
_throttleTimer = _throttledPush(widget.controller.value);
}
@override
void initState() {
super.initState();
_throttledPush = _throttle<TextEditingValue>(
duration: _kThrottleDuration,
function: _stack.push,
);
_push();
widget.controller.addListener(_push);
}
@override
void didUpdateWidget(_TextEditingHistory oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller != oldWidget.controller) {
_stack.clear();
oldWidget.controller.removeListener(_push);
widget.controller.addListener(_push);
}
}
@override
void dispose() {
widget.controller.removeListener(_push);
_throttleTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Actions(
actions: <Type, Action<Intent>> {
UndoTextIntent: Action<UndoTextIntent>.overridable(context: context, defaultAction: CallbackAction<UndoTextIntent>(onInvoke: _undo)),
RedoTextIntent: Action<RedoTextIntent>.overridable(context: context, defaultAction: CallbackAction<RedoTextIntent>(onInvoke: _redo)),
},
child: widget.child,
);
}
}
/// A data structure representing a chronological list of states that can be
/// undone and redone.
class _UndoStack<T> {
/// Creates an instance of [_UndoStack].
_UndoStack();
final List<T> _list = <T>[];
// The index of the current value, or null if the list is emtpy.
late int _index;
/// Returns the current value of the stack.
T? get currentValue => _list.isEmpty ? null : _list[_index];
/// Add a new state change to the stack.
///
/// Pushing identical objects will not create multiple entries.
void push(T value) {
if (_list.isEmpty) {
_index = 0;
_list.add(value);
return;
}
assert(_index < _list.length && _index >= 0);
if (value == currentValue) {
return;
}
// If anything has been undone in this stack, remove those irrelevant states
// before adding the new one.
if (_index != null && _index != _list.length - 1) {
_list.removeRange(_index + 1, _list.length);
}
_list.add(value);
_index = _list.length - 1;
}
/// Returns the current value after an undo operation.
///
/// An undo operation moves the current value to the previously pushed value,
/// if any.
///
/// Iff the stack is completely empty, then returns null.
T? undo() {
if (_list.isEmpty) {
return null;
}
assert(_index < _list.length && _index >= 0);
if (_index != 0) {
_index = _index - 1;
}
return currentValue;
}
/// Returns the current value after a redo operation.
///
/// A redo operation moves the current value to the value that was last
/// undone, if any.
///
/// Iff the stack is completely empty, then returns null.
T? redo() {
if (_list.isEmpty) {
return null;
}
assert(_index < _list.length && _index >= 0);
if (_index < _list.length - 1) {
_index = _index + 1;
}
return currentValue;
}
/// Remove everything from the stack.
void clear() {
_list.clear();
_index = -1;
}
@override
String toString() {
return '_UndoStack $_list';
}
}
/// A function that can be throttled with the throttle function.
typedef _Throttleable<T> = void Function(T currentArg);
/// A function that has been throttled by [_throttle].
typedef _Throttled<T> = Timer Function(T currentArg);
/// Returns a _Throttled that will call through to the given function only a
/// maximum of once per duration.
///
/// Only works for functions that take exactly one argument and return void.
_Throttled<T> _throttle<T>({
required Duration duration,
required _Throttleable<T> function,
// If true, calls at the start of the timer.
bool leadingEdge = false,
}) {
Timer? timer;
bool calledDuringTimer = false;
late T arg;
return (T currentArg) {
arg = currentArg;
if (timer != null) {
calledDuringTimer = true;
return timer!;
}
if (leadingEdge) {
function(arg);
}
calledDuringTimer = false;
timer = Timer(duration, () {
if (!leadingEdge || calledDuringTimer) {
function(arg);
}
timer = null;
});
return timer!;
};
}
......@@ -231,6 +231,16 @@ class PasteTextIntent extends Intent {
final SelectionChangedCause cause;
}
/// An [Intent] that represents a user interaction that attempts to go back to
/// the previous editing state.
class RedoTextIntent extends Intent {
/// Creates a [RedoTextIntent].
const RedoTextIntent(this.cause);
/// {@macro flutter.widgets.TextEditingIntents.cause}
final SelectionChangedCause cause;
}
/// An [Intent] that represents a user interaction that attempts to modify the
/// current [TextEditingValue] in an input field.
class ReplaceTextIntent extends Intent {
......@@ -250,10 +260,20 @@ class ReplaceTextIntent extends Intent {
final SelectionChangedCause cause;
}
/// An [Intent] that represents a user interaction that attempts to go back to
/// the previous editing state.
class UndoTextIntent extends Intent {
/// Creates an [UndoTextIntent].
const UndoTextIntent(this.cause);
/// {@macro flutter.widgets.TextEditingIntents.cause}
final SelectionChangedCause cause;
}
/// An [Intent] that represents a user interaction that attempts to change the
/// selection in an input field.
class UpdateSelectionIntent extends Intent {
/// Creates a [UpdateSelectionIntent].
/// Creates an [UpdateSelectionIntent].
const UpdateSelectionIntent(this.currentTextEditingValue, this.newSelection, this.cause);
/// The [TextEditingValue] that this [Intent]'s action should perform on.
......
......@@ -9959,6 +9959,454 @@ void main() {
isNot(contains(matchesMethodCall('TextInput.requestAutofill'))),
);
});
group('TextEditingHistory', () {
Future<void> sendUndoRedo(WidgetTester tester, [bool redo = false]) {
return sendKeys(
tester,
<LogicalKeyboardKey>[
LogicalKeyboardKey.keyZ,
],
shortcutModifier: true,
shift: redo,
targetPlatform: defaultTargetPlatform,
);
}
Future<void> sendUndo(WidgetTester tester) => sendUndoRedo(tester);
Future<void> sendRedo(WidgetTester tester) => sendUndoRedo(tester, true);
testWidgets('inside EditableText', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
final FocusNode focusNode = FocusNode();
await tester.pumpWidget(
MaterialApp(
home: EditableText(
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
cursorOpacityAnimates: true,
autofillHints: null,
),
),
);
expect(
controller.value,
TextEditingValue.empty,
);
// Undo/redo have no effect on an empty field that has never been edited.
await sendUndo(tester);
expect(
controller.value,
TextEditingValue.empty,
);
await sendRedo(tester);
expect(
controller.value,
TextEditingValue.empty,
);
await tester.pump();
expect(
controller.value,
TextEditingValue.empty,
);
focusNode.requestFocus();
expect(
controller.value,
TextEditingValue.empty,
);
await tester.pump();
expect(
controller.value,
const TextEditingValue(
selection: TextSelection.collapsed(offset: 0),
),
);
// Wait for the throttling.
await tester.pump(const Duration(milliseconds: 500));
// Undo/redo still have no effect. The field is focused and the value has
// changed, but the text remains empty.
await sendUndo(tester);
expect(
controller.value,
const TextEditingValue(
selection: TextSelection.collapsed(offset: 0),
),
);
await sendRedo(tester);
expect(
controller.value,
const TextEditingValue(
selection: TextSelection.collapsed(offset: 0),
),
);
await tester.enterText(find.byType(EditableText), '1');
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
await tester.pump(const Duration(milliseconds: 500));
// Can undo/redo a single insertion.
await sendUndo(tester);
expect(
controller.value,
const TextEditingValue(
selection: TextSelection.collapsed(offset: 0),
),
);
await sendUndo(tester);
expect(
controller.value,
const TextEditingValue(
selection: TextSelection.collapsed(offset: 0),
),
);
await sendRedo(tester);
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
await sendRedo(tester);
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
// And can undo/redo multiple insertions.
await tester.enterText(find.byType(EditableText), '13');
expect(
controller.value,
const TextEditingValue(
text: '13',
selection: TextSelection.collapsed(offset: 2),
),
);
await tester.pump(const Duration(milliseconds: 500));
await sendRedo(tester);
expect(
controller.value,
const TextEditingValue(
text: '13',
selection: TextSelection.collapsed(offset: 2),
),
);
await sendUndo(tester);
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
await sendUndo(tester);
expect(
controller.value,
const TextEditingValue(
selection: TextSelection.collapsed(offset: 0),
),
);
await sendRedo(tester);
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
await sendRedo(tester);
expect(
controller.value,
const TextEditingValue(
text: '13',
selection: TextSelection.collapsed(offset: 2),
),
);
// Can change the middle of the stack timeline.
await sendUndo(tester);
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
await tester.enterText(find.byType(EditableText), '12');
await tester.pump(const Duration(milliseconds: 500));
await sendRedo(tester);
expect(
controller.value,
const TextEditingValue(
text: '12',
selection: TextSelection.collapsed(offset: 2),
),
);
await sendRedo(tester);
expect(
controller.value,
const TextEditingValue(
text: '12',
selection: TextSelection.collapsed(offset: 2),
),
);
await sendUndo(tester);
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
await sendUndo(tester);
expect(
controller.value,
const TextEditingValue(
selection: TextSelection.collapsed(offset: 0),
),
);
await sendRedo(tester);
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
await sendRedo(tester);
expect(
controller.value,
const TextEditingValue(
text: '12',
selection: TextSelection.collapsed(offset: 2),
),
);
// On web, these keyboard shortcuts are handled by the browser.
}, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended]
testWidgets('inside EditableText, duplicate changes', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
final FocusNode focusNode = FocusNode();
await tester.pumpWidget(
MaterialApp(
home: EditableText(
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
cursorOpacityAnimates: true,
autofillHints: null,
),
),
);
expect(
controller.value,
TextEditingValue.empty,
);
focusNode.requestFocus();
expect(
controller.value,
TextEditingValue.empty,
);
await tester.pump();
expect(
controller.value,
const TextEditingValue(
selection: TextSelection.collapsed(offset: 0),
),
);
// Wait for the throttling.
await tester.pump(const Duration(milliseconds: 500));
await tester.enterText(find.byType(EditableText), '1');
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
await tester.pump(const Duration(milliseconds: 500));
// Can undo/redo a single insertion.
await sendUndo(tester);
expect(
controller.value,
const TextEditingValue(
selection: TextSelection.collapsed(offset: 0),
),
);
await sendRedo(tester);
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
// Changes that result in the same state won't be saved on the undo stack.
await tester.enterText(find.byType(EditableText), '12');
expect(
controller.value,
const TextEditingValue(
text: '12',
selection: TextSelection.collapsed(offset: 2),
),
);
await tester.enterText(find.byType(EditableText), '1');
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
await tester.pump(const Duration(milliseconds: 500));
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
await sendRedo(tester);
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
await sendUndo(tester);
expect(
controller.value,
const TextEditingValue(
selection: TextSelection.collapsed(offset: 0),
),
);
await sendUndo(tester);
expect(
controller.value,
const TextEditingValue(
selection: TextSelection.collapsed(offset: 0),
),
);
await sendRedo(tester);
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
await sendRedo(tester);
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
// On web, these keyboard shortcuts are handled by the browser.
}, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended]
testWidgets('inside EditableText, autofocus', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: EditableText(
autofocus: true,
controller: controller,
focusNode: FocusNode(),
style: textStyle,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
cursorOpacityAnimates: true,
autofillHints: null,
),
),
);
expect(
controller.value,
const TextEditingValue(
selection: TextSelection.collapsed(offset: 0),
),
);
await tester.pump();
expect(
controller.value,
const TextEditingValue(
selection: TextSelection.collapsed(offset: 0),
),
);
// Wait for the throttling.
await tester.pump(const Duration(milliseconds: 500));
await tester.enterText(find.byType(EditableText), '1');
await tester.pump(const Duration(milliseconds: 500));
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
await sendUndo(tester);
expect(
controller.value,
const TextEditingValue(
selection: TextSelection.collapsed(offset: 0),
),
);
await sendUndo(tester);
expect(
controller.value,
const TextEditingValue(
selection: TextSelection.collapsed(offset: 0),
),
);
await sendRedo(tester);
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
await sendRedo(tester);
expect(
controller.value,
const TextEditingValue(
text: '1',
selection: TextSelection.collapsed(offset: 1),
),
);
}, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended]
});
}
class UnsettableController extends TextEditingController {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment