From 09cc1c4cdabb69f7163632afba7e0298881fbc0b Mon Sep 17 00:00:00 2001 From: Bruno Leroux Date: Thu, 16 Mar 2023 09:18:57 +0100 Subject: [PATCH] Fix cursor disappear on undo. (#122402) Fix cursor disappear on undo. --- .../lib/src/widgets/editable_text.dart | 2 +- .../flutter/lib/src/widgets/undo_history.dart | 28 +++++----- .../test/widgets/editable_text_test.dart | 52 +++++++++++++++++++ 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 49fee7617a..5614329b0a 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -4510,7 +4510,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien userUpdateTextEditingValue(value, SelectionChangedCause.keyboard); }, shouldChangeUndoStack: (TextEditingValue? oldValue, TextEditingValue newValue) { - if (newValue == TextEditingValue.empty) { + if (!newValue.selection.isValid) { return false; } diff --git a/packages/flutter/lib/src/widgets/undo_history.dart b/packages/flutter/lib/src/widgets/undo_history.dart index ae960f276d..d094d31b88 100644 --- a/packages/flutter/lib/src/widgets/undo_history.dart +++ b/packages/flutter/lib/src/widgets/undo_history.dart @@ -100,7 +100,19 @@ class UndoHistoryState extends State> with UndoManagerClient { @override void undo() { - _update(_stack.undo()); + if (_stack.currentValue == null) { + // Returns early if there is not a first value registered in the history. + // This is important because, if an undo is received while the initial + // value is being pushed (a.k.a when the field gets the focus but the + // throttling delay is pending), the initial push should not be canceled. + return; + } + if (_throttleTimer?.isActive ?? false) { + _throttleTimer?.cancel(); // Cancel ongoing push, if any. + _update(_stack.currentValue); + } else { + _update(_stack.undo()); + } _updateState(); } @@ -455,27 +467,17 @@ typedef _Throttled = Timer Function(T currentArg); _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; + if (timer != null && timer!.isActive) { return timer!; } - if (leadingEdge) { - function(arg); - } - calledDuringTimer = false; timer = Timer(duration, () { - if (!leadingEdge || calledDuringTimer) { - function(arg); - } + function(arg); timer = null; }); return timer!; diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 9ebaa8df98..ce684b664b 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -13011,6 +13011,58 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async // On web, these keyboard shortcuts are handled by the browser. }, variant: TargetPlatformVariant.only(TargetPlatform.android), skip: kIsWeb); // [intended] + // Regression test for https://github.com/flutter/flutter/issues/120194. + testWidgets('Cursor does not jump after undo', (WidgetTester tester) async { + // Initialize the controller with a non empty text. + final TextEditingController controller = TextEditingController(text: textA); + final FocusNode focusNode = FocusNode(); + await tester.pumpWidget(boilerplate(controller, focusNode)); + + // Focus the field and wait for throttling delay to get the initial + // state saved in text editing history. + focusNode.requestFocus(); + await tester.pump(); + await waitForThrottling(tester); + expect(controller.value, textACollapsedAtEnd); + + // Insert some text. + await tester.enterText(find.byType(EditableText), textAB); + expect(controller.value, textABCollapsedAtEnd); + + // Undo the insertion without waiting for the throttling delay. + await sendUndo(tester); + expect(controller.value.selection.isValid, true); + expect(controller.value, textACollapsedAtEnd); + + // On web, these keyboard shortcuts are handled by the browser. + }, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended] + + testWidgets('Initial value is recorded when an undo is received just after getting the focus', (WidgetTester tester) async { + // Initialize the controller with a non empty text. + final TextEditingController controller = TextEditingController(text: textA); + final FocusNode focusNode = FocusNode(); + await tester.pumpWidget(boilerplate(controller, focusNode)); + + // Focus the field and do not wait for throttling delay before calling undo. + focusNode.requestFocus(); + await tester.pump(); + await sendUndo(tester); + await waitForThrottling(tester); + expect(controller.value, textACollapsedAtEnd); + + // Insert some text. + await tester.enterText(find.byType(EditableText), textAB); + expect(controller.value, textABCollapsedAtEnd); + + // Undo the insertion. + await sendUndo(tester); + + // Initial text should have been recorded and restored. + expect(controller.value, textACollapsedAtEnd); + + // On web, these keyboard shortcuts are handled by the browser. + }, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended] + testWidgets('Can make changes in the middle of the history', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(); final FocusNode focusNode = FocusNode();