Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Framework can receive TextEditingDeltas from engine #88477

Merged
merged 81 commits into from Sep 16, 2021

Conversation

Renzo-Olivares
Copy link
Contributor

@Renzo-Olivares Renzo-Olivares commented Aug 18, 2021

Description

Design Doc

This change implements a new channel for the TextInputClient.

TextInputClient.updateEditingValueWithDeltas(List<TextEditingDelta> deltas) will be used to receive changes made to the editing value from the engine.

This delta model is opt-inable through a flag in TextInputConfiguration called enableDeltaModel. This will tell the engine to use updateEditingValueWithDeltas instead of updateEditingValue.

Demo part 1 Demo part 2
demo-part1 demo-part2
final TextEditingController _controller = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            TextField(
              autofocus: true,
              controller: _controller,
              maxLines: null,
            ),
          ],
        ),
      ),
    );
  }

See #87972 (comment) for more details

Engine PR: flutter/engine#28175

Related Issues

Partially fixes #87972

Tests

Pre-launch Checklist

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I read the [Tree Hygiene] wiki page, which explains my responsibilities.
  • I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement].
  • I signed the [CLA].
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making or feature I am adding, or Hixie said the PR is test-exempt.
  • All existing and new tests are passing.

@flutter-dashboard flutter-dashboard bot added f: material design flutter/packages/flutter/material repository. framework flutter/packages/flutter repository. See also f: labels. labels Aug 18, 2021
@google-cla google-cla bot added the cla: yes label Aug 18, 2021
Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

One quick comment, but actually I'm leaving all of my important comments to the engine PR, no need to reiterate here.

factory TextEditingDelta.fromJSON(Map<String, dynamic> encoded) {
TextEditingDeltaType? deltaType = null;

switch (encoded['delta'] as String) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe this could be a Map?

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

I couldn't help myself leaving a bunch of nits even though I know this is a WIP PR, sorry, feel free to ignore for now. But also some thoughts about how to get this ready for merge and stuff.

@@ -671,6 +678,87 @@ class RawFloatingCursorPoint {
final FloatingCursorDragState state;
}

/// A [TextEditingDeltaType.insertion] singifies there has been a single/sequence
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: I think usually you would have a description of the enum overall up here, and then split the enum values into different lines and document them down below. Maybe TextAffinity as an example.

/// The old text state before the delta has occured.
final String oldText;

/// The raw delta value.
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd still be confused what this is. Maybe explain what it is in the case of an insertion and replacement (and for other types it's empty if that's true)?

packages/flutter/lib/src/services/text_input.dart Outdated Show resolved Hide resolved
/// The new [TextRange] as a result of the delta.
final TextRange deltaRange;

final TextSelection selection;
Copy link
Contributor

Choose a reason for hiding this comment

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

Docs here and for composing below. Maybe you can use a macro to take them from TextEditingValue.

/// See [TextEditingDeltaType] for more information.
final TextEditingDeltaType deltaType;

/// The new [TextRange] as a result of the delta.
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd also still be confused about this. Maybe explain by-type again.

Comment on lines 744 to 897
deltaRange: TextRange(
start: encoded['deltaStart'] as int? ?? -1,
end: encoded['deltaEnd'] as int? ?? -1,
Copy link
Contributor

Choose a reason for hiding this comment

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

@LongCatIsLooong I know we've been trying to move away from using -1 to represent invalid. Is that something we can start to do here, or should we just follow the existing pattern for now?

Copy link
Contributor

Choose a reason for hiding this comment

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

No unfortunately. That's one large breaking change.

});

/// The old text state before the delta has occured.
final String oldText;
Copy link
Contributor

Choose a reason for hiding this comment

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

I know we were talking about the ability to save space by only sending the deltas and not the whole document each time (at some point in the future). However, if we're still sending the entire oldText, we won't have any savings. Could this theoretically be a hash or something in the future?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We might actually be able to remove oldText all together what do you think? The framework technically has the oldText already so I don't think we need to send it.

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 a little bit weird to send a delta without any indication of what it applies to, but I guess it makes sense to remove it for now since we currently haven't seen a need for it. Keep an eye out for bugs with the engine and framework getting out of sync though. If that seems possible then maybe we can add a hash in the future.

Copy link
Contributor

Choose a reason for hiding this comment

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

Update: Seems like we're keeping it in order to make sure the delta applies to the current text, for now, but we could investigate removing it for space savings later.

@@ -55,6 +55,49 @@ const Duration _kCursorBlinkWaitForStart = Duration(milliseconds: 150);
// is shown in an obscured text field.
const int _kObscureShowLatestCharCursorTicks = 3;

class DeltaTextEditingController extends TextEditingController {
TextEditingValue applyDeltas(List<TextEditingDelta> deltas) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This method should be thoroughly tested (when everything is finalized).

@@ -2625,6 +2674,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
currentEditingValue: currentTextEditingValue,
),
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
enableDeltaModel: true, //Proof of concept.
Copy link
Contributor

Choose a reason for hiding this comment

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

When this is finalized, maybe we could move DeltaTextEditingController out of the framework as an example, but add to the framework the updateEditingValueWithDeltas and enableDeltaModel parameters to all of our text editing classes? Or what were you thinking?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Initially I thought we should obscure enableDeltaModel and keep it limited to TextInputConfiguration, but this would lock out developers that want to use a TextField or EditableText as a base for their rich text editors. However, I think to fully support those developers we will need to finish implementing deltas for framework text editing actions/shortcuts etc as well. So for now I think we should keep it limited to TextInputConfiguration until we are able to implement the framework side of the deltas. This gives developers who are aiming to build more complex editors to choose whether to subscribe to the delta model or the plain text model.

DeltaTextEditingController should definitely move out of the framework until we decide how we want to incorporate TextEditingDeltas into our own editable text widgets as the source of truth.

Copy link
Contributor

Choose a reason for hiding this comment

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

Discussed offline: For now keep it limited to TextInputConfiguration. If/when we want to open it up to EditableText in the future, maybe we could add enableDeltaModel and updateEditingValueWithDeltas params.

Copy link
Contributor

Choose a reason for hiding this comment

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

Are we proposing to put enableDeltaModel in production now?

DeltaTextEditingController({ String? text })
: super.fromValue(text == null ? TextEditingValue.empty : TextEditingValue(text: text));

TextEditingValue applyDeltas(List<TextEditingDelta> deltas) {
Copy link
Contributor

Choose a reason for hiding this comment

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

What if you add an apply method to TextEditingDelta that takes a TextEditingValue and returns a new TextEditingValue with the delta applied? Then in each subclass of TextEditingDelta you can put the specific logic there rather than using a switch here.

Also, maybe you could get rid of deltaType altogether. If you really do need to check the type, you could do delta is TextEditingDeltaInsertion or whatever.

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 we should keep deltaType to avoid having the developer use dynamic type checking through is. The is check is discourage in our style guide.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also we talked offline about the apply method and the conclusion is that it may tie TextEditingDelta and TextEditingValue together too closely... Interested in what others think though.

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

I also think this is ready to open to review. The architecture here seems pretty minimal in a good way to me. I'm interested in what everyone else thinks of creating the enableDeltaModel flag for updateEditingValueWithDeltas, which then just calls through to updateEditingValue.

@@ -607,6 +609,11 @@ class TextInputConfiguration {
/// {@endtemplate}
final bool enableIMEPersonalizedLearning;

/// Whether to enable that the engine sends text input updates to the
/// framework as [TextEditingDelta]'s or as one [TextEditingValue].
/// Defaults to false. Cannot be null.
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Should be an empty line above this one:

///
/// Defaults to false. Cannot be null.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, is this the place where we should thoroughly explain what this is for? You could mention updateEditingValueWithDeltas and updateEditingValue, and maybe mention that updateEditingValue doesn't work when this is true (is that right?).

Long term it would also be cool to have an example of a simple rich text editor or something in the docs somewhere. Or maybe your demo of just showing the current change.

equality,

/// If ever created this delta should be thrown away.
none
Copy link
Contributor

Choose a reason for hiding this comment

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

Comma here. Are we sure that none is needed now?

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 isn't. I had it in there because TextEditingDelta was not abstract but we can make it abstract.


/// Replaces a range of text in the original string with the text given in the
/// replacement string.
String replace(String originalText, String replacementText, int start, int end) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Could this be static?

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!

Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason it's made a public method (and in a mixin)? Would it be possible to just leave it as a private global function?

Copy link
Contributor

Choose a reason for hiding this comment

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

+1, I can't see why this should be a mixin.

Comment on lines 734 to 774
/// This class should not be used directly, and should be extended for different
/// types of deltas.
Copy link
Contributor

Choose a reason for hiding this comment

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

Should it be abstract then?

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 this makes sense. Can't remember why I didn't make it abstract to begin with.

Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: probably no need to mention that it shouldn't be used directly now that it's not possible to use it directly.

/// The old text state before the delta has occured.
final String oldText;

/// This value will slightly vary based on the [TextEditingDeltaType].
Copy link
Contributor

Choose a reason for hiding this comment

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

I would make this the second line and add a new first line that summarizes this more explicitly, maybe like "Any String needed to clarify the delta, depending on the type." I don't know, trying to make this fit our style of having a brief summary as the first sentence: https://dart.dev/guides/language/effective-dart/documentation#do-start-doc-comments-with-a-single-sentence-summary

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 delatRange below.

Comment on lines 823 to 877
TextEditingValue apply(TextEditingValue value) {
return apply(value);
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm confused about where this apply comes from but maybe I just missed something... Is this a method that a subclass must implement itself?

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 each subclass implements this method themselves.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

By making the class abstract it makes this a little more clear that each subclass should implement it itself.

// an editing state are accumulated before being sent to the framework.
// This is called batch editing on Android, and on other platforms the
// TextInputPlugin does not adhere to this type of editing.
if (encoded['batchDeltas'] != null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm interested in what other reviewers think of this. In some cases multiple deltas are sent in one updateEditingValueWithDeltas, and in others just one is sent. Is this the cleanest way to do that?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah I have the same question. Since framework is not consuming the "isBatched" information, would it be OK if we always send a list of deltas?

Copy link
Contributor

Choose a reason for hiding this comment

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

@Renzo-Olivares Was there ever any more discussion about this?

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 ok with always sending a list, even if only one is included. The complexity of a boolean check here is more confusing than just a static batch model.

Copy link
Contributor

Choose a reason for hiding this comment

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

On a different thought, the json probably needs to be a map anyways. We need to added a global timestamp to the message in the future to help with synchronization: #89394.

Copy link
Contributor

Choose a reason for hiding this comment

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

So let's always send it as a list, where encoded would always look something like this?

{
  deltas: [ ... ], // One or many, always an array.
  timestamp: ..., // Whenever we add this.
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

{ 
    deltas: [ ... ], // One or many, always an array. 
    timestamp: ..., // Whenever we add this.
 }

and for platforms without batch edits

{ 
   oldText:,
   deltaText:,
   deltaStart:,
   deltaEnd:,
   selectionStart:,
   selectionEnd:,
   composingStart:,
   composingEnd:,
   timestamp: ..., // Whenever we add this.
}

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 thought the always sending as a list was in relation to sending the deltas as a list to updateEditingValueWithDeltas. Is it regarding the encoded json format?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah sorry I digressed. I was suggesting the json should be a map with a list of deltas.

  • a map so we can add a fields in the future.
  • a list of deltas so we don't have to write separately logic for handling batched/non-batched updates .

@@ -2625,6 +2674,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
currentEditingValue: currentTextEditingValue,
),
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
enableDeltaModel: true, //Proof of concept.
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we proposing to put enableDeltaModel in production now?

for (final TextEditingDelta delta in textEditingDeltas) {
value = delta.apply(value);
}
updateEditingValue(value);
Copy link
Contributor

Choose a reason for hiding this comment

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

Smart, just call through to updateEditingValue... For a rich text editor author, could they still disable this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

A rich text editor author at this low level I think would just implement updateEditingValueWithDeltas themselves.

expect(state.currentTextEditingValue.composing, equalityDelta.composing);
});
});

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 have any integration tests that test text editing? If so, maybe as an easy additional test for this, we could run them a second time with enableDeltaModel set to true, and make sure that text editing still works as normal. Not sure if we have any though.

@Renzo-Olivares Renzo-Olivares changed the title [WIP] Framework can receive TextEditingDeltas from engine Framework can receive TextEditingDeltas from engine Aug 30, 2021
@Renzo-Olivares Renzo-Olivares marked this pull request as ready for review August 30, 2021 19:47
/// A replacement can occur in cases such as auto-correct, suggestions, and
/// when a selection is replaced by a single character.
/// {@endtemplate}
replacement,
Copy link
Contributor

Choose a reason for hiding this comment

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

Isn't insertion equivalent to a replacement on a collapsed range? Adding a hardcoded type like this for something that is semantically equivalent to existing API may result in redundancy, though this does make a bit more sense to laypeople

Copy link
Contributor

Choose a reason for hiding this comment

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

Same goes for deletion, is it not equivalent to a replacement with an emptystring? Do we have a way to disambiguate between an emptystring replacement vs a deletion, collapsed replacement vs insertion?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'd like to offer a couple thoughts on this. One of them is specific, the other is more generic to the overall approach.

something that is semantically equivalent to existing API

I think this statement is a bit inaccurate. The changes are "technically equivalent", they are not "semantically equivalent". A user who "inserts" content is not a user who is "replacing" content. From a user/domain perspective, these are distinct intentions, typically utilized for different use-cases. There could be any number of app-level reactions that care about the semantic distinction.

Second, I'd like to repeat a point that I've made elsewhere in this project, but it's on direct display in this particular question/recommendation. This proposed delta API, along with this recommendation to combine event types, are both predicated on the idea of a "least common denominator" approach to fundamental text input. It's a reduction of nuanced platform behaviors to what the Flutter team deems worthy of Flutter apps. Consider that the details that are made available at this bottleneck point within the framework are the only details that any app developer can ever gain access to (without maintaining a permanent fork of the engine).

Historically, Flutter has claimed to oppose the "least common denominator" problem that tends to arise in cross-platform toolkits, but this problem began as a result of an artificially limited API, and we seem to be continuing that trend with this delta PR. Flutter suggests that we embrace the yak shave, but this project is still lacking a deep audit of text-based APIs on the 6 major platforms. How do we know what we're leaving out? Flutter believes in aggressive composition, but rather than port each platforms text APIs and then compose them into a unified API, this approach creates a unified API monolith, from which app developers cannot escape (without maintaining a permanent fork of the engine).

I recommend retaining absolutely all text-related information within the fundamental text APIs, both with regard to this specific area, as well as the overall project, in general. This recommendation is based on the fact that Flutter is a UI toolkit, and text interaction is an absolutely fundamental, universal UI interaction. This is the core of what Flutter exists to do. This is not an area to "simplify" platform APIs. App developers need to know what the platform is doing with text.

Copy link
Contributor

Choose a reason for hiding this comment

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

I agree we should definitely avoid a least common denominator approach, which is definitely not the intention here. The engine PRs have changed a little bit. Let us know if there is any information from the platform that is being thrown away.

///
/// This value will slightly vary based on the [TextEditingDeltaType].
///
/// For a [TextEditingDeltaType.insertion] this will be the character/s being
Copy link
Contributor

Choose a reason for hiding this comment

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

Bulleted list? Might be more clear.

// an editing state are accumulated before being sent to the framework.
// This is called batch editing on Android, and on other platforms the
// TextInputPlugin does not adhere to this type of editing.
if (encoded['batchDeltas'] != null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah I have the same question. Since framework is not consuming the "isBatched" information, would it be OK if we always send a list of deltas?

/// handles. There are no changes to the text, but there are updates to the selection
/// and potentially the composing region as well.
/// {@endtemplate}
equality,
Copy link
Contributor

Choose a reason for hiding this comment

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

"cursorUpdate" maybe? "equality" sounds like a very strong word to me.


/// Replaces a range of text in the original string with the text given in the
/// replacement string.
String replace(String originalText, String replacementText, int start, int end) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason it's made a public method (and in a mixin)? Would it be possible to just leave it as a private global function?

case 'TextEditingDeltaType.equality':
return TextEditingDeltaEquality.fromJSON(encoded);
default:
return TextEditingDeltaEquality.fromJSON(encoded);
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: maybe assert(false) here if the default clause should not be reachable.

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 point

/// For a [TextEditingDeltaType.replacement] this will be the range of
/// characters that are being replaced.
///
/// For a [TextEditingDeltaType.equality] this will be a collapsed range
Copy link
Contributor

Choose a reason for hiding this comment

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

If a property does not make sense in a subtype then it may make sense to remove it from the common interface. Same for deltaText.

/// {@macro flutter.services.TextEditingDelta.apply}
@override
TextEditingValue apply(TextEditingValue value) {
String newText = value.text;
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be using oldText instead of value.text? It may crash if value.text is, say, empty.

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 have added a check to verify if oldText == value.text. If this condition is not true then we return the original TextEditingValue.

/// {@macro flutter.services.TextEditingDelta.apply}
@override
TextEditingValue apply(TextEditingValue value) {
String newText = value.text;
Copy link
Contributor

Choose a reason for hiding this comment

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

Same as the previous comment. The base string should probably be oldText? Also how should conflicts be resolved if value.text is not oldText?

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

Some comments to address but I like this with the fromJSON logic here in the framework instead of the engine!


/// {@template flutter.services.TextEditingDeltaReplacement}
/// The delta is replacing a range of characters with a new sequence of text.
/// The range that is being replaced can either grow or shrink based on the
Copy link
Contributor

Choose a reason for hiding this comment

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

An empty line should be between these two lines, and below.

/// The delta is not modifying the text. There are potentially selection and
/// composing region updates in the delta that still need to be applied to your
/// text model.
/// A situation where this delta would be created is when dragging the selection
Copy link
Contributor

Choose a reason for hiding this comment

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

Empty line above this line too.

/// A replacement can occur in cases such as auto-correct, suggestions, and
/// when a selection is replaced by a single character.
/// {@endtemplate}
replacement,
Copy link
Contributor

Choose a reason for hiding this comment

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

I agree we should definitely avoid a least common denominator approach, which is definitely not the intention here. The engine PRs have changed a little bit. Let us know if there is any information from the platform that is being thrown away.


/// Replaces a range of text in the original string with the text given in the
/// replacement string.
String replace(String originalText, String replacementText, int start, int end) {
Copy link
Contributor

Choose a reason for hiding this comment

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

+1, I can't see why this should be a mixin.

Comment on lines 734 to 774
/// This class should not be used directly, and should be extended for different
/// types of deltas.
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: probably no need to mention that it shouldn't be used directly now that it's not possible to use it directly.

);
}

assert(false);
Copy link
Contributor

Choose a reason for hiding this comment

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

So this should never be reached, but if it is reached in prod, then use a TextEditingDeltaNonTextUpdate? Maybe explain this in the message of the assert.

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 old text state before the delta has occured.
final String oldText;
Copy link
Contributor

Choose a reason for hiding this comment

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

Update: Seems like we're keeping it in order to make sure the delta applies to the current text, for now, but we could investigate removing it for space savings later.

///
/// See [TextEditingDeltaType] for more information.
/// {@endtemplate}
TextEditingDeltaType get deltaType;
Copy link
Contributor

Choose a reason for hiding this comment

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

What should users do with deltaType if they implement their own TextEditingDelta subclass?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was discussed offline but @LongCatIsLooong suggested converting any new types to one of the basic types we have introduced replacement/deletion/insertion/nonTextUpdate. If a developer does want to implement their own TextEditingDelta subclass I think that would be the appropriate course of action if they want to avoid any framework changes.

Though how a developer would plug this new delta type into the current pipeline has not been addressed. Currently that is all handled by the TextInput._handleTextInputInvocation and all the developer will ever receive from updateEditingValueWithDeltas is the final list of TextEditingDeltas. In theory they could process this list of TextEditingDeltas even further to include their own custom TextEditingDelta types, but there is no way to include their new delta type in the processing before this point.

deltaText: deltaText,
deltaRange: deltaRange,
selection: selection,
composing:composing,
Copy link
Contributor

Choose a reason for hiding this comment

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

Space here after the colon (and in other subclasses like this).

expect(controller.selection, nonTextUpdateDelta.selection);
expect(state.currentTextEditingValue.composing, nonTextUpdateDelta.composing);
});
});
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you should add some unit tests to services/text_input_test.dart that test TextEditingDelta.fromJSON directly since there is a lot of logic in there. Just make a bunch of calls to it with various kinds of json data and expect it to create the correct TextEditingDelta.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That sounds like a good idea to me. I'll still leave these tests in editable_test_text.dart as they test the updateEditingValueWithDelta function.

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

The only thing I see left is the batchDeltas array thing I commented on.

// an editing state are accumulated before being sent to the framework.
// This is called batch editing on Android, and on other platforms the
// TextInputPlugin does not adhere to this type of editing.
if (encoded['batchDeltas'] != null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

So let's always send it as a list, where encoded would always look something like this?

{
  deltas: [ ... ], // One or many, always an array.
  timestamp: ..., // Whenever we add this.
}

void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {
TextEditingValue value = _value;
for (final TextEditingDelta delta in textEditingDeltas) {
print('Delta class type: ' + delta.runtimeType.toString());
Copy link
Contributor

Choose a reason for hiding this comment

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

Prints to remove before this is final.

/// The offset in the [oldText] where the insertion begins.
final int insertionOffset;

/// {@macro flutter.services.TextEditingDelta.apply}
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: the documentation is inherited so you don't have to use the macro here.

/// Requests that this client update its editing state by applying the delta
/// received from the engine.
///
/// The [TextEditingDelta] is treated as a change that will be applied to the client's
Copy link
Contributor

Choose a reason for hiding this comment

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

How is this different from the first paragraph?

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh emphasis on "change" to explain what "delta" means?

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.

// nature of the connection between the framework and platform text input plugins.
try {
if (oldText != value.text) {
throw FlutterError('The editing state is out of sync between the framework and the engine.');
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you can just call FlutterError.reportError here? Also the oldText != value.text should be in an assert probably.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm still not completely convinced that this should be an error. Conflicts can happen (especially when the developer has additional input formatters/mutating onChanged callbacks), and the error will probably get spammed in these scenarios. Also it is typically not a programmer error thus not actionable (and a little bit confusing).

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm ok with just a comment here then. We can revisit this if it ever seems to be a problem for developers.

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 agree that this may lead to a lot of log spam. I though this log would be useful, but if the developer cannot act on it there doesn't seem to be a good reason to have it. Maybe just the comment will suffice?

If a developer wanted to act on this asynchronous failure then they would have to extend their desired TextEditingDelta implementation, and capture the error themselves in the apply method and have logic to handle it. For example say they wanted to see if this happens with an insertion. They would extend TextEditingDeltaInsertion and override apply. They would convert any TextEditingDeltaInsertion to their new TextEditingDeltaNewInsertion and apply those to their editing value instead of the original ones.

// an editing state are accumulated before being sent to the framework.
// This is called batch editing on Android, and on other platforms the
// TextInputPlugin does not adhere to this type of editing.
if (encoded['batchDeltas'] != null) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah sorry I digressed. I was suggesting the json should be a map with a list of deltas.

  • a map so we can add a fields in the future.
  • a list of deltas so we don't have to write separately logic for handling batched/non-batched updates .

}
}

/// The delta is replacing a range of characters with a new sequence of text.
Copy link
Contributor

Choose a reason for hiding this comment

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

The phrasing of these descriptions currently assume previous knowledge of what a "delta" is.

This should be worded more generally, describing what it is and does without large assumptions of previous knowledge of the system

Copy link
Contributor

Choose a reason for hiding this comment

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

This applies to the other docs for the other classes as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the feedback! I updated all the docs.

/// framework as [TextEditingDelta]'s or as one [TextEditingValue].
///
/// When this is enabled platform text input updates will
/// come through [TextInputClient.updateEditingValueWithDeltas].
Copy link
Contributor

Choose a reason for hiding this comment

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

May want to also mention that using this results in more granular explicit set of changes rather than batched together general updates to the editing value. We want to help developers find/decide if this feature is right for them.

// A non text update delta occurs when the selection and/or composing region
// has been changed by the platform, and there have been no changes to the
// text value.
final String oldText = encoded['oldText'] as String;
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of parsing the type from the properties of the data, can we concretely determine this from the source in the embedding and pass this information up? This parsing seems to work, but I imagine it could be vulnerable to edge cases in the case of wild and crazy deltas. It would be better to be absolutely certain when possible, and fallback on this algo only when the source cannot distinguish for certain.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Part of this algorithm is using the method definitions from the platforms embedding to concretely determine if something an insertion or a deletion.

From android Editable documentation for their replace() method. Editable has insert/append/delete as well but they are all convenience methods that call replace().

     * Replaces the specified range (<code>st&hellip;en</code>) of text in this
     * Editable with a copy of the slice <code>start&hellip;end</code> from
     * <code>source</code>.  The destination slice may be empty, in which case
     * the operation is an insertion, or the source slice may be empty,
     * in which case the operation is a deletion.

Similarly looking at iOS deleteBackwards method https://github.com/flutter/engine/blob/e8c44adc49d56e0d00a74dde5f79ddfe526fbd37/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm#L1468 that also uses an empty source string as a replacement for the deleted range. insertText similarly inserts some text at the current selection https://github.com/flutter/engine/blob/45dc2fee1304805f54d72563f49873e226e4cd95/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm#L1399. Both of these methods are convenience methods for replaceRange WithText.

The iOS and Android PR's before the inference logic was moved to the framework where very similar.
flutter/engine@2148347
flutter/engine@402280e
The only real new case iOS was handling is that when typing with CJK keyboard (simplified chinese pinyin for example) it actually first types the english characters first and then replaces them with the chinese characters. On Android the word is not inserted into the editing state until it has been fully composed. Keeping in mind there similarity I thought it would be better to centralize all the logic in the framework.

The web also behaves similarly but less information is given to us upfront since we delegate all of the text input control to the <input>/<textarea> element. In this case we must use the relevant API's like beforeinput and compositionupdate to capture the parameters we need. Since nothing is given to us upfront, we must normalize the parameters into a common format.

The only ambiguous part comes from dealing with cases of the composing region. Where we check if the original composing region has been changed, if it has then it is a replacement, if it hasn't then it is either an insertion/deletion. That is the main inference going on.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, thanks for the breakdown, that SGTM

/// * [TextEditingDeltaNonTextUpdate], a delta representing an update to the
/// selection and/or composing region.
/// * [TextInputConfiguration], to opt-in your [TextInputClient] to receive
/// [TextEditingDelta]'s you must set [TextInputConfiguration.enableDeltaModel]
Copy link
Contributor

Choose a reason for hiding this comment

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

In general, can we make sure we link to the [TextInputConfiguration.enableDeltaModel] more frequently? It may be confusing for developers to find the entry-point into the feature since it is not frequently referred to. We can simply note that the feature only works if this flag is enabled in a few more relevant places.

}
}

/// The delta is not modifying the text. There are potentially selection and
Copy link
Contributor

Choose a reason for hiding this comment

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

Keep first paragraph a one-sentence overview. Details can go below.

///
/// The list of [TextEditingDelta]'s are treated as changes that will be applied
/// to the client's editing state. A change is any mutation to the raw text
/// value, or any updates to the selection and/or composing region.
Copy link
Contributor

Choose a reason for hiding this comment

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

Mention opt-in flag here, or people may be confused why this is never called

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

LGTM 👍

// an editing state are accumulated before being sent to the framework.
// This is called batch editing on Android, and on other platforms the
// TextInputPlugin does not adhere to this type of editing.
for (final dynamic encodedDelta in encoded['batchDeltas']) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks good always using an array of batchDeltas now 👍

Copy link
Contributor

Choose a reason for hiding this comment

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

Actually not to overly complicate things, but should we just call this deltas now?

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 agree will also remove the comment above this regarding batch editing.

Copy link
Contributor

@GaryQian GaryQian left a comment

Choose a reason for hiding this comment

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

LGTM! :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
f: material design flutter/packages/flutter/material repository. framework flutter/packages/flutter repository. See also f: labels.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Flutter's text input plugin should report deltas instead of full text blocks
6 participants