Skip to content

Conversation

@justinmc
Copy link
Contributor

@justinmc justinmc commented Jan 21, 2022

Undo/redo for text editing when using the keyboard.

undo

A new private UndoRedo widget saves a stack of recent TextEditingValues, and provides the current state after undo/redo operations.

Currently does not hook up to 3 finger swipe to undo/redo on iOS, but we should in the future. I think this might be possible even inside of UndoRedo by adding a gesture detector there... #34749

The throttling behavior is kind of a "best fit" of all of our platforms. It seems like there are very subtle differences between how all of these platforms do this (see #24222 (comment)). I'll keep an eye out if users want different per-platform behavior or if they want control over it themselves, but for now hopefully mine is good enough that most users won't notice a difference.

Fixes #24222

@justinmc justinmc self-assigned this Jan 21, 2022
@flutter-dashboard flutter-dashboard bot added a: text input Entering text in a text field or keyboard related problems framework flutter/packages/flutter repository. See also f: labels. labels Jan 21, 2022
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any better name than onChanged or is that ok? It's a little confusing because it's only called on undo/redo, not when widget.controller changes for other reasons.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe onTriggered?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't matter for UndoRedo, but I couldn't get this to work with more than one argument because it doesn't seem possible to refer to all arguments in Dart. Maybe Dart isn't meant for this kind of functional programming.

@justinmc justinmc marked this pull request as ready for review January 24, 2022 21:48
Copy link
Contributor

@chunhtai chunhtai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something that cross my mind.

  1. The redo/undo from both rightclick contextmenu or system menu bar.
  2. accessibility, for example voiceover is enabled, redo/undo will be pronounced.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe define a constant instead?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also curious how we are going to support different platform behavior? macOS seems to have a vary unique way of determine the redo undo

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do the constant 👍 .

My plan is to not support different platform behavior for now. The different platforms seem so subtly different in weird ways and I'm hoping users don't care. I will monitor this and be ready to implement platform-specific behavior if anyone wants it, though.

The worst difference I can see now is that if you type very slowly, then the undo/redo chunks are small. On native Mac, the chunks are of a more consistent size regardless of the speed that you type.

If I do need to implement platform specific behavior here, and it's more complicated than just different Durations, then my general plan would be to make UndoRedoMac etc. widgets.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can be private

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
UndoRedoState createState() => UndoRedoState();
State<UndoRedo> createState() => _UndoRedoState();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, that's why I should autogenerate these things.

Copy link
Contributor

@dkwingsmt dkwingsmt Jan 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it's too late for this topic and you've probably had the discussion, but UndoRedo feels too "real" for how abstract its functionality is. Have we considered something like TextHistoryProvider?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I think you're probably right... Maybe TextEditingHistory?

Copy link
Contributor

@dkwingsmt dkwingsmt Jan 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe?

Suggested change
/// Listens for keyboard undo/redo shortcuts depending on the platform. Calls
/// Listens to keyboard undo/redo shortcuts depending on the platform. Calls

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's more accurate 👍

Copy link
Contributor

@dkwingsmt dkwingsmt Jan 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// [onChanged] when a shortcut is received that would change the state of these
/// [onChanged] when a shortcut is triggered that would affect the

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I think "these" was a typo.

Copy link
Contributor

@dkwingsmt dkwingsmt Jan 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that UndoRedo is split for code modularity and is meant to be private but testable. But can we just test EditableText in all of the following tests? The behavior of EditableText is what the users care and we maintain backward compatibility of. By making UndoRedo a private class, you're saying it is subject to refactory or breaking changes in the future, but at that time these tests will have to be migrated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I usually write small building blocks like this and then test them to convince myself that they work the way that I expect them to. It's useful for me personally to have the tests even if we refactor or change behavior. I don't want to burden everyone else if that's not Flutter's style though...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is mostly for visibleForTesting since it's not enforced by the compiler but by the linter, which the developer can turn off. The last time (which is a long time ago) I was told that it was discouraged (but not actually banned.) But I do think it reveals a problem where you're testing something that the users won't see, so might not be kept compatible during future development at all, i.e. adding unnecessary compatibility requirements. Anyway, just a soft suggestion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok thanks for the explanation. I probably am going against Flutter trends a little bit. I'll keep an eye out for similar patterns and problems with this code and consider changing it then. It wouldn't be terrible to just test the widget.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to what @dkwingsmt has said, it feels like these tests are testing implementation detail.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright I'm outnumbered, I'll remove the tests and make the class fully private. I'll also add another test to TextEditingHistory to make sure all of those cases are covered.

@justinmc
Copy link
Contributor Author

justinmc commented Jan 25, 2022

@chunhtai About your ideas:

  1. Undo/redo from right click and the system menu. This should work out nicely thanks to Actions/Intents, you should be able to Actions.invoke the UndoTextIntent or RedoTextIntent.
  2. VoiceOver. I hadn't thought about that. I just tried turning on VoiceOver on my Mac and playing around with TextEdit and with a Flutter app. In TextEdit, it doesn't verbally say "undo" when I cmd+z, it speaks the text nearest to the cursor. On the Flutter app, it speaks the letter nearest the cursor (unrelated to undo/redo, that seems like a bug, do you know if we have an issue for that?). Let me know if there's anything else I can check or that we should change for this PR.

@chunhtai
Copy link
Contributor

it speaks the text nearest to the cursor. On the Flutter app, it speaks the letter nearest the cursor (unrelated to undo/redo, that seems like a bug, do you know if we have an issue for that?). Let me know if there's anything else I can check or that we should change for this PR.

It is probably treats it as text changes, if you type a character, it will pronounce that character, if you delete a character, it will pronounce that character with a deeper tone. If you try on a native text field, does it pronounce redo/undo?

@bernaferrari
Copy link
Contributor

Is it possible to somehow disable/override your undo work? I like it, just some people already have their own undo solution, so I'm afraid it can be a breaking change.

@justinmc
Copy link
Contributor Author

@chunhtai No, it never said "undo" or "redo" on native.

@chunhtai
Copy link
Contributor

@chunhtai No, it never said "undo" or "redo" on native.

oh great! then we don't need to worry about it.

@justinmc
Copy link
Contributor Author

@bernaferrari Good point, thanks for bringing that up. If I make my actions overridable, you could disable it like this:

Actions(
  actions: {
    UndoTextIntent: DoNothingAction(),
    RedoTextIntent: DoNothingAction(),
  },
  child: ...
)

You could put this near the root of your application so that it will apply to all EditableTexts in your app.

I could also add a parameter to EditableText and TextField, etc. but maybe it's best just to disable it with Actions like this.

@bernaferrari
Copy link
Contributor

That could somehow work. In my app I have a custom undo implementation (which right now doesn't work inside a TextField, lol) but that could be handy eventually.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we keep this private?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes good catch, I'm no longer using this directly in the tests, so it can be fully private.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be reduced to value notifier according to the doc comment.

not too much of an issue if this is a private class though

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah this is a comment that I forgot to update, I'll fix it.

Originally I tried to make this widget work with any type of ValueNotifier, but it turns out that there is a good amount of text-editing-specific logic that is needed to get undo/redo to work with EditableText. For example, ignoring selection and composing updates. I think it makes sense to just put that here since we have no use for a more generic undo/redo widget now.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we are not modifying the value in controller in this widget a ValueNotifier of TextEditingValue should be enough, no?

but since this class can be private, I think it is probably fine as is

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe explain a bit why the state may be the same?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the code prevent duplicate history in _push method.

if (widget.controller.value == TextEditingValue.empty
        || widget.controller.text == _stack.currentValue?.text) {
      return;
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does, but it's still possible to be called without a state change, such as when the user presses cmd+shift+z to redo when there is nothing to redo. I'll make this clear in the docs, good catch.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is redundant

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, I think I was thinking this was public and needed a comment.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am curious whether we may want to reset the history in this case?

If we don't, we may need to call _push() here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I'm going to clear the stack here. I'm not sure if there is a real use case for ever changing the controller like this, but I'm guessing that if there is, the whole field is being reset and the history should reset too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_list is a private property, we should avoid mention it in public doc

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels weird that it has three different behavior.

  1. returns null if empty
  2. returns entry if undo
  3. return same entry if can't undo

I feel 1 and 3 should be treated the same so that the caller doesn't need to handle three cases

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same for redo

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also went back and forth on this when I wrote this code. I think of it like this:

  1. returns the current value (after any operation) in all cases.

If the stack is empty, then there is no current value, so it returns null. Otherwise it returns what the state should be after the undo/redo, even if nothing changed.

I'll make this a little more clear in the comments and the code.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to what @dkwingsmt has said, it feels like these tests are testing implementation detail.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also went back and forth on this when I wrote this code. I think of it like this:

returns the current value (after any operation) in all cases.
If the stack is empty, then there is no current value, so it returns null. Otherwise it > returns what the state should be after the undo/redo, even if nothing changed.

This is also related the onChange called with the same state. why the onChange needs to be fired if the state is the same? what is the difference to the caller for return null vs returning the same state?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also just noticed the same state will get short-circuited in _update method

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah good catch. I think it should only be called when the value changes so I'll update the docs.

@justinmc justinmc requested a review from chunhtai February 3, 2022 21:20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

continue on the called with the same state.

Isn't the _update method short-circuited if the state is the same (line 4233)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the docs.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also just noticed the same state will get short-circuited in _update method

@justinmc justinmc requested a review from chunhtai February 7, 2022 22:40
Copy link
Contributor

@chunhtai chunhtai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, except for whether we need to expose the throttle function

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to expose the throttle and its related function signature? I think we can testing the edtiabletext direction should be good enough

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it's the same argument as the other comment thread. I'll remove it.

engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Feb 8, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Feb 8, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Feb 9, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Feb 9, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Feb 9, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Feb 9, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Feb 9, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Feb 9, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Feb 9, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Feb 9, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Feb 9, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Feb 9, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Feb 9, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/plugins that referenced this pull request Feb 9, 2022
clocksmith pushed a commit to clocksmith/flutter that referenced this pull request Mar 8, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: text input Entering text in a text field or keyboard related problems framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add undo/redo functions to TextField.

5 participants