-
Notifications
You must be signed in to change notification settings - Fork 6k
TextEditingDelta support for Android #28175
TextEditingDelta support for Android #28175
Conversation
justinmc
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for building this, it really makes the design doc more real. I left a bunch of questions in the comments, but I think the biggest decisions to make are:
- Are we sending the right data? Does it meet the needs to the developers that will use this, and are we able to send this data for all of Flutter's platforms?
- Is this free of asynchronous race condition bugs? Maybe with this simple API, it's up to the consumer in the framework to avoid async problems.
| @Override | ||
| public SpannableStringBuilder append(char text) { | ||
| Log.e("DELTAS", "insert #1 is called"); | ||
| return append(String.valueOf(text)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess this is the actual implementation of this append, you're just overriding it in order to be able to log here? And below.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh and you don't seem to need to override these to determine the type, you can just infer it inside of replace.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah all these overrides where just to see if we actually hit these calls.
shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java
Show resolved
Hide resolved
| selectionEnd, | ||
| composingStart, | ||
| composingEnd); | ||
| textInputChannel.updateEditingStateWithDelta( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You may have explained this to me already somewhere, but why not send this as a part of updateEditingState?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think putting it in updateEditingState implies that we are going to be updating the TextEditingValue with it, but in fact they are two separate entities. One may want to subscribe to the TextEditingDelta without the TextEditingValue to create their own model.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
True... Do you still have a POC somewhere of actually creating a simple rich text editor in the framework? Did you use the TextEditingValue coming from updateEditingState at all, or did you do everything with the deltas only?
I'm thinking that if you did need both, it may be very hard to correlate the calls to updateEditingState and updateEditingStateWithDelta with each other in the framework since they are called asynchronously.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In that POC I did everything with TextEditingValue, and just used the deltas as extra information to expand/contract ranges.
I agree a developer should only subscribe to one or the other. I think how it would work on this new model is that a developer either subscribes to updateEditingValue or updateEditingValueWithDelta. If using updateEditingValue then they are using the plain text model where the TextEditingValue is always replaced by the new one. If using updateEditingValueWithDelta they subscribe to deltas that they can apply to their TextEditingValue/or their custom equivalent. This delta should also include the new composing/selection region from the engine so a developer could also apply that to their model.
In regards to how framework updates will be consumed by the engine for now the framework would keep using setEditingState, completely replacing the TextEditingValue on the engine side.
| // Fields for delta. | ||
| private String oldText; | ||
| private String deltaText; | ||
| private String deltaType; | ||
| private int deltaStart; | ||
| private int deltaEnd; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be cleaner to have a separate TextEditingDelta class?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Definitely!
| "DELTAS", | ||
| "replace(" + start + ", " + end + ", " + tb + ", " + tbstart + ", " + tbend + ")"); | ||
|
|
||
| final boolean isDeletionGreaterThanOne = end - (start + tbend) > 1; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we had a TextEditingDelta class (as I mentioned above), then maybe we could encapsulate most of this logic.
| } else if (isReplaced) { | ||
| Log.e("DELTAS", "REPLACEMENT"); | ||
| setDeltas(toString(), tb.subSequence(tbstart, tbend).toString(), "REPLACEMENT", start, end); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it ever possible that setDeltas won't be called here? It seems like bugs could arise from that, like sending old deltas data in updateEditingStateWithDelta.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And is it ever possible that some crazy change could happen that wouldn't be supported by a single TextEditingDelta? Maybe Android is massaging these types of changes into a single call to replace for you. Like what if you had two insertions at once?
"bbb" => "cbbbc"
Is there any way we can test something like that? I guess Android would just replace the entire "bbb" string rather than trying to do an insertion at the beginning and another at the end.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that might be possible not to hit setDeltas we definitely need to make sure to cover all the cases here. Yeah in the case you mention android would replace the entire string with "cbbbc". I think in this case all we can say is what the platform gives us, and in Android's case it is that the entire word was replaced.
|
|
||
| if (isEqual) { | ||
| Log.e("DELTAS", "EQUALITY"); | ||
| setDeltas(toString(), "", "EQUALITY", -1, -1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be better to just not call updateEditingStateWithDelta at all in this case?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, but it is called every time the editing state changes see https://github.com/flutter/engine/pull/28175/files#diff-6d16487c29b469c59861d81341897ed392331e365647f6c13c18459800cc854dR653 I think this is the place we want to put it to keep it in sync with the actual text editing changes.
The editing state could change for reasons not pertaining to the actual raw text chagning, such as a change in the selection/composing region, so I think we need this equality case for these cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah so if the user just moves the selection around with the arrow keys, they will get a bunch of EQUALITY deltas.
I wonder how exactly these calls would be used in the framework, similar to my other comment here, to help me see how these equality deltas would be used.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A use case of this could be.
Developer subscribes to updateEditingValueWithDelta. They update the currentTextEditingValue by applying the delta to it.
Say we are typing "hello"
Engine sends "h" delta
Framework receives "h" delta applies it to currentTextEditingValue.
Engine sends "e" delta
Framework receives "e" delta and applies it to currentTextEditingValue.
.
.
.
.
Engine sends "o" delta
Framework receives "o" delta and applies it to currentTextEditingValue.
User changes selection
Engine sends equality delta to framework
Framework receives equality delta and does nothing (or updates their current selection/composing region with the new one).
Writing this makes me think that we should also be sending the new selection/composing region with this delta. So when a developer gets a delta from updateEditingValueWithDelta they also get the updated selection/composing region they can apply to the TextEditingValue/whatever structure they have representing a TextEditingValue. Any thoughts on this?
| Log.e(TAG, "editing state should not be changed in a listener callback"); | ||
| } | ||
|
|
||
| Log.e( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Weill replace be called when the engine receives a text change from the framework?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes replace is called when the engine receives a text change. It replaces the entire value with the one from the framework.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it useful to setDeltas in that case? Could that delta ever end up being sent back to the framework when it shouldn't be?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks much cleaner with the TextEditingDelta class, though I left one comment about maybe refactoring it a bit.
I also responded to some comments outside of this review above accidentally, FYI.
| return deltaEnd; | ||
| } | ||
|
|
||
| private void setDeltas( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be cleaner to create a new TextEditingDelta instance rather than always mutating one instance? That would definitely be my recommendation in Dart though I'm less confident about these patterns in Java.
Then reasonDeltas could be a constructor.
| deltaType = type; | ||
| } | ||
|
|
||
| public void reasonDeltas( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When you get to writing tests, it will be nice to be able to test all of this logic directly without needing to spin up a TextInputChannel and everything.
0447ae2 to
796a826
Compare
justinmc
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Check out my comments but I think this is otherwise ready to open to broader review.
| mImm.restartInput(view); | ||
| mRestartInputPending = false; | ||
| } else { | ||
| mEditable.clearBatchDeltas(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Consider commenting here and other places where clearBatchDeltas is called to explain why it's being called there. At a high level it makes sense to clear it when the framework is sending an update, but it seems really nuanced to know when exactly to clear. If it's easily explainable then a comment might be good.
| // Make sure last delta has the most up to date composing region. | ||
| // This can happen when the IME is restarted because the framework has changed the | ||
| // composing region. We do not have access to the new composing region until after | ||
| // the restart has completed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tricky.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason to special case the composing region change caused by IME? Shouldn't the information already be captured by the deltas recorded by the ListenableEditingState class?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For some reason when I capture it in ListenableEditingState the composing region is still (-1,-1). It is not until I arrive at ListenableEditingState that I'm able to grab the most up to date composing regions. I think this is somewhat consistent to how updateEditingState works, where we send the composing/selection regions we grab in didChangeEditingState.
I have observed that this is only the case when the composing region restarts. In other cases the selection/composing regions are up to date after the editable has been mutated with replace.
| } | ||
|
|
||
| textInputChannel.updateEditingStateWithDeltas(inputTarget.id, batchTextEditingDeltas); | ||
| mEditable.clearBatchDeltas(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This clearBatchDeltas makes sense!
|
|
||
| if (autofill.uniqueIdentifier.equals(currentAutofill.uniqueIdentifier)) { | ||
| // Autofilling the current client is the same as handling user input | ||
| // from the virtual keyboard. Setting the editable to newState and an |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By "virtual keyboard" do you mean the usual Android on-screen keyboard? I think you should refer to it as the "soft keyboard" based on my understanding of Flutter's usual terminology, unless you've seen this way elsewhere. In the framework there is a PR referring to virtual keyboards as keyboards implemented in-app in Flutter itself (flutter/flutter#68988), so that made me notice this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Eh I guess we do have virtualId here... I guess the naming is up to you, whatever you think is best.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The virtualId here is the id of the virtual view structure used for autofill.
| assertEquals(0, delta.getNewComposingStart()); | ||
| assertEquals(5, delta.getNewComposingEnd()); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for thoroughly testing the TextEditingDelta constructor!
| "TextInputClient.updateEditingStateWithTag", Arrays.asList(inputClientId, json)); | ||
| } | ||
|
|
||
| public void updateEditingStateWithDeltasWithTag( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method is for autofill only (which generally just replaces all the current content of the text field). So it probably shouldn't be using deltas. Also it's unlikely that users would want to autofill a rich text field.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense, responded to this topic in another comment.
| // This can happen when the IME is restarted because the framework has changed the | ||
| // composing region. We do not have access to the new composing region until after | ||
| // the restart has completed. | ||
| if (mEditable.getBatchTextEditingDeltas().size() - 1 >= 0) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: collection.size() - 1 >= 0 => !collection.isEmpty() (not sure if there's an isNotEmpty() method).
Also the else if can just be else I think?
| mEditable.popTextEditingDeltaFromList(); | ||
| mEditable.addTextEditingDeltaToList(delta); | ||
| } else if (mEditable.getBatchTextEditingDeltas().size() == 0) { | ||
| // Make sure we always have at least one delta when triggering a framework update, in |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What would be a case that there's no deltas recorded but the engine has a different text editing value and needs to update the framework?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure I follow this example. If the engine has changed its text editing value then it has most likely gone through the replace method and generated a delta.
| // Make sure last delta has the most up to date composing region. | ||
| // This can happen when the IME is restarted because the framework has changed the | ||
| // composing region. We do not have access to the new composing region until after | ||
| // the restart has completed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason to special case the composing region change caused by IME? Shouldn't the information already be captured by the deltas recorded by the ListenableEditingState class?
| final HashMap<String, TextInputChannel.TextEditState> editingValues = new HashMap<>(); | ||
| for (int i = 0; i < values.size(); i++) { | ||
| int virtualId = values.keyAt(i); | ||
| if (configuration.enableDeltaModel) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We probably don't want to enable delta model for autofill?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought about this, I had included it just because I was trying to get every feature that works with the TextField on the engine side working with this delta model. I agree rich text editors will probably not be using autofill, do you think it is alright to not include this then? I was thinking this might be good to have for the future but I guess are plans for this are not concrete.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the thing autofill does is slightly different from any of the delta types specified: it replaces the entirety of the document, instead of the content in [0, currentLength). They're slightly different since we're dealing with async communications.
For example if the user autofills a password field and at the same time the framework changes the text to be longer, then we want the obscured field has the right characters in it, instead of just replacing the original range.
| } | ||
|
|
||
| public void popTextEditingDeltaFromList() { | ||
| mBatchTextEditingDeltas.remove(mBatchTextEditingDeltas.size() - 1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: do java lists have "pop()" or something similar?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did not find an equivalent in the docs.
|
|
||
| private static final String TAG = "TextEditingDelta"; | ||
|
|
||
| public TextEditingDelta( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was expecting that we can just extract the tb{start, end}, start, end and tb from the replace implementation and send them verbatim to the framework, and the framework will have to implement the same replace method to apply it to a TextEditingValue. Would that not work?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That wouldn't work in some cases. Say the case where we are typing "world".
Current state: "worl|"
soft keyboard input: "d"
replace method call: replace(0,4, world, 0, 5)
or for a deletion
Current state: "world|"
soft keyboard input: backspace
replace method call: replace(0,5, worl, 0, 4)
If typing at the end of the composing region the text in the composing region is replaced with the entire new composing text.
If we move the cursor inside the word currently being composed like below we get this call.
current state: "wo|ld"
soft keyboard input: "r"
replace method call: replace(2,2, r, 0,1)
Here we are actually given the insertion point and the character inserted, where when it is at the end of the composing region we must do some reasoning.
On iOS we have something slightly different as well.
Current state: "world|"
soft keyboard input: backspace
replaceRangeWithLocal (4,5) with empty string ranges (0,0)
another thing iOS does is when typing with a CJK keyboard it will exhibit behavior similar to Android composing region where the entire composing region is replaced on input.
Because these values can vary a bit by platform for the same operation I think it is important to unify them into one format. I'm unsure if a replacement of "world" with "worl" would have the same result as replacing "d" in "world" with an empty string regarding if ranges of "world" where bolded/styled. Would these ranges still have the information needed to contract/expand as expected?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah makes sense, thank you for the clarification! Do you think we'll be able to extract that info directly from the InputConnection interface (instead of replace)? It doesn't have to be implemented that way right now if it is (it's probably going to be a little complicated to implement since InputConnectionAdaptor and TextInputPlugin currently don't directly talk to each other). I'm just wondering if we can avoid the manual diffing process here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately from my research of the API I don't think so. Even the InputConnectionAdaptor does not provide these granular differences between editing states.
InputConnectionAdaptor.commitText and InputConnectionAdaptor.setComposingText both call their super class BaseInputConnection which calls replaceText() and routes that to SpannableStringBuilder.replace(). Only in some cases will the IME actually provide granular changes like in InputConnectionAdaptor.deleteSurroundingText which does call SpannableStringBuilder.delete() that is in the end also routed to replace(). In most cases for Android since the composing region is always active we are always replacing the entire composing region.
Maybe I missed something but this is my conclusion after my research.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see. So setComposingText and commit* can delete/insert/replace, but they all only operate on a specific range.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd prefer a bit more documentation in this class. If this were the entrypoint for someone learning about this feature, the documentation here is a bit lacking to properly introduce what this does and find the rest of the code.
| }; | ||
| } | ||
|
|
||
| public ArrayList<TextEditingDelta> getBatchTextEditingDeltas() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: maybe we should just have a "pop" method that returns the deltas and clear the batched deltas.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Every time you retrieve the deltas you should typically delete them from ListenableEditingState too I think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah that's correct this makes sense then.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: get implies idempotency. A different verb maybe?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree that I wouldn't expect the .clear() with the "get" name. Maybe... "flush"? Maybe not...
Also, should clearBatchDeltas be clearBatchTextEditingDeltas? Since this method name refers to TextEditingDeltas.
|
|
||
| if (autofill.uniqueIdentifier.equals(currentAutofill.uniqueIdentifier)) { | ||
| // Autofilling the current client is the same as handling user input | ||
| // from the virtual keyboard. Setting the editable to newState and an |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The virtualId here is the id of the virtual view structure used for autofill.
|
|
||
| private static final String TAG = "TextEditingDelta"; | ||
|
|
||
| public TextEditingDelta( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah makes sense, thank you for the clarification! Do you think we'll be able to extract that info directly from the InputConnection interface (instead of replace)? It doesn't have to be implemented that way right now if it is (it's probably going to be a little complicated to implement since InputConnectionAdaptor and TextInputPlugin currently don't directly talk to each other). I'm just wondering if we can avoid the manual diffing process here.
| }; | ||
| } | ||
|
|
||
| public ArrayList<TextEditingDelta> getBatchTextEditingDeltas() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Every time you retrieve the deltas you should typically delete them from ListenableEditingState too I think?
| new TextEditingDelta( | ||
| toString(), | ||
| "", | ||
| "TextEditingDeltaType.equality", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: I think "equality" is a confusing name. "cursorUpdate"? Or "selectionOnly"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Equality could also represent a composing region update. Maybe regionUpdate or selectionOrComposingRegionUpdate, nonTextUpdate?
| final HashMap<String, TextInputChannel.TextEditState> editingValues = new HashMap<>(); | ||
| for (int i = 0; i < values.size(); i++) { | ||
| int virtualId = values.keyAt(i); | ||
| if (configuration.enableDeltaModel) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the thing autofill does is slightly different from any of the delta types specified: it replaces the entirety of the document, instead of the content in [0, currentLength). They're slightly different since we're dealing with async communications.
For example if the user autofills a password field and at the same time the framework changes the text to be longer, then we want the obscured field has the right characters in it, instead of just replacing the original range.
| setDeltas(oldText, deltaText, deltaType, deltaStart, deltaEnd); | ||
| } | ||
|
|
||
| public void setNewComposingStart(int newStart) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you still need to expose these methods? Would it be possible to make this class immutable?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is possible now.
LongCatIsLooong
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The approach LGTM.
| newComposingStart = composingStart; | ||
| newComposingEnd = composingEnd; | ||
| final boolean isDeletionGreaterThanOne = end - (start + tbend) > 1; | ||
| final boolean isCalledFromDelete = tb == "" && tbstart == 0 && tbstart == tbend; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is the tb == "" check because of the delete implementation here?
https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/text/SpannableStringBuilder.java;l=230-237?q=spannablestringbuilder
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah that's where I got that from.
|
|
||
| private static final String TAG = "TextEditingDelta"; | ||
|
|
||
| public TextEditingDelta( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see. So setComposingText and commit* can delete/insert/replace, but they all only operate on a specific range.
ed9bb5e to
29bcc62
Compare
| }; | ||
| } | ||
|
|
||
| public ArrayList<TextEditingDelta> getBatchTextEditingDeltas() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: get implies idempotency. A different verb maybe?
| super.setSpan(what, start, end, flags); | ||
| // Setting a span does not involve mutating the text value in the editing state. Here we create | ||
| // a non text update delta with any updated selection and composing regions. | ||
| mBatchTextEditingDeltas.add( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it possible for setSpan to be invoked from within the replace method? If it is then when that happens we'll see an extra nonTextUpdate for the change?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is possible but it shouldn't affect the final result since they are nonTextUpdates. In the end the final TextEditingDelta in the batch will have the most up to date selection/composing regions. In theory if setSpan is called in replace we will want to capture that anyways.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The batch may look something like this if super.replace() calls setSpan.
[nonTextUpdate, nonTextUpdate, nonTextUpdate, delta]
In this case the last delta will still have the most up to date composing/selection regions. We capture the delta after super.replace() has been called so the actual delta will always be the last one applied.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ideally there should be no overlap between consecutive deltas and there should be no redundant deltas. Should be fine for now.
| setDeltas(oldText, deltaText, deltaType, deltaStart, deltaEnd); | ||
| } | ||
|
|
||
| public CharSequence getOldText() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you need these getters? The text input plugin only need to be able to build deltas and serialize them to JSON right? Are these for writing tests?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes there are now purely for tests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Maybe write a comment or comments stating that these are for testing only.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know java too well but do you need expose them as public if the test is in the same package?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I think we do. I just tried to switch them to private and it complained.
justinmc
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks a lot more straightforward now that a lot of delta logic has been moved to the framework 👍
| }; | ||
| } | ||
|
|
||
| public ArrayList<TextEditingDelta> getBatchTextEditingDeltas() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree that I wouldn't expect the .clear() with the "get" name. Maybe... "flush"? Maybe not...
Also, should clearBatchDeltas be clearBatchTextEditingDeltas? Since this method name refers to TextEditingDeltas.
| Log.e(TAG, "editing state should not be changed in a listener callback"); | ||
| } | ||
|
|
||
| Log.v(TAG, "replace(" + start + ", " + end + ", " + tb + ", " + tbstart + ", " + tbend + ")"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you intend to remove this Log?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I just cleaned up the logs.
| setDeltas(oldText, deltaText, deltaType, deltaStart, deltaEnd); | ||
| } | ||
|
|
||
| public CharSequence getOldText() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Maybe write a comment or comments stating that these are for testing only.
|
|
||
| private void setDeltas(CharSequence oldTxt, CharSequence newTxt, int newStart, int newExtent) { | ||
| oldText = oldTxt; | ||
| deltaText = newTxt; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why "Txt" instead of "Text"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did this avoid shadowing, but I can also just use this.
| // latest TextEditState from the framework. | ||
| @VisibleForTesting | ||
| void setTextInputEditingState(View view, TextInputChannel.TextEditState state) { | ||
| Log.v( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Remove this Log?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in another comment.
| newComposingEnd); | ||
|
|
||
| assertEquals(oldText, delta.getOldText()); | ||
| assertEquals("hello", delta.getDeltaText()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why the string literal instead of textAfterChange?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated
| false, | ||
| true, | ||
| true, | ||
| true, // Enable delta model. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: What do you think of adding a test where this is false and asserting that there are no deltas created and no calls to updateEditingStateWithDeltas?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added tests
|
Ready for re-review. The main change is that the main logic for deltas has moved to the framework. There was a lot of common logic between the Android and iOS engine PR's and this helps centralize the logic. |
justinmc
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM 👍
Caveat: My comment in the framework PR about the json format for batched deltas. Is we decide to change that, this PR will need a small change too.
| false, | ||
| true, | ||
| true, | ||
| false, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: You have a comment "Enable delta model." on all the other tests where this is true, maybe add it here too.
| for (TextEditingDelta delta : batchDeltas) { | ||
| deltas.put(delta.toJSON()); | ||
| } | ||
| state.put("batchDeltas", deltas); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I made a comment in the framework about how we send these batched deltas: flutter/flutter#88477 (comment)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is all good now, we always send an array of batchDeltas.
| CharSequence oldEditable, | ||
| int start, | ||
| int end, | ||
| CharSequence tb, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
write this out instead of an abbreviation
|
|
||
| public TextEditingDelta( | ||
| CharSequence oldEditable, | ||
| int start, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
specify what kind of start/end. Since you have lots of similarly named vars, it is important to disambiguate whenever possible
| // Setting a span does not involve mutating the text value in the editing state. Here we create | ||
| // a non text update delta with any updated selection and composing regions. | ||
| mBatchTextEditingDeltas.add( | ||
| new TextEditingDelta( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should make a "nonTextUpdate" delta be its own constructor. It is not always obvious to pass "", -1, -1. A separate constructor for a separate case would prevent any accidental misuse or misconfiguration of this particular type of delta.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This makes sense, I do this already in the iOS PR.
LongCatIsLooong
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM module comments from other reviewers.
| super.setSpan(what, start, end, flags); | ||
| // Setting a span does not involve mutating the text value in the editing state. Here we create | ||
| // a non text update delta with any updated selection and composing regions. | ||
| mBatchTextEditingDeltas.add( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ideally there should be no overlap between consecutive deltas and there should be no redundant deltas. Should be fine for now.
| setDeltas(oldText, deltaText, deltaType, deltaStart, deltaEnd); | ||
| } | ||
|
|
||
| public CharSequence getOldText() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know java too well but do you need expose them as public if the test is in the same package?
| import org.json.JSONObject; | ||
|
|
||
| /// A representation of the change that occured to an editing state, along with the resulting | ||
| /// composing and selection regions. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we explicitly differentiate between text content changes and selection/composing region index only changes? However it is handled, it should be documented.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Never mind, that is under the umbrella of TextEditingDeltaNonTextUpdate
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On the framework we do, but on the engine there is no differentiation. This differentiation is documented on the framework side, should it also have something here?
Edit: didn't see your comment before this, disregard.
…mework and when we skip updating the framework
f51c01c to
4a13b0a
Compare
Description
Design Doc
This change aims to collect the changes made to the editing value in the Android text input plugin, and send them to the framework through a platform channel.
Framework PR: flutter/flutter#88477
Related Issues
Partially fixes flutter/flutter#87972
Tests
Pre-launch Checklist
writing and running engine tests.
///).