Unverified Commit bf66cc2e authored by Renzo Olivares's avatar Renzo Olivares Committed by GitHub

Framework can receive TextEditingDeltas from engine (#88477)

parent 65d8dd98
......@@ -42,6 +42,7 @@ export 'src/services/system_chrome.dart';
export 'src/services/system_navigator.dart';
export 'src/services/system_sound.dart';
export 'src/services/text_editing.dart';
export 'src/services/text_editing_delta.dart';
export 'src/services/text_formatter.dart';
export 'src/services/text_input.dart';
export 'src/services/text_layout_metrics.dart';
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'text_editing.dart';
import 'text_input.dart' show TextEditingValue;
TextAffinity? _toTextAffinity(String? affinity) {
switch (affinity) {
case 'TextAffinity.downstream':
return TextAffinity.downstream;
case 'TextAffinity.upstream':
return TextAffinity.upstream;
}
return null;
}
/// 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) {
final String textStart = originalText.substring(0, start);
final String textEnd = originalText.substring(end, originalText.length);
final String newText = textStart + replacementText + textEnd;
return newText;
}
/// A structure representing a granular change that has occurred to the editing
/// state as a result of text editing.
///
/// See also:
///
/// * [TextEditingDeltaInsertion], a delta representing an insertion.
/// * [TextEditingDeltaDeletion], a delta representing a deletion.
/// * [TextEditingDeltaReplacement], a delta representing a replacement.
/// * [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]
/// to true.
abstract class TextEditingDelta {
/// Creates a delta for a given change to the editing state.
///
/// {@template flutter.services.TextEditingDelta}
/// The [oldText], [selection], and [composing] arguments must not be null.
/// {@endtemplate}
const TextEditingDelta({
required this.oldText,
required this.selection,
required this.composing,
}) : assert(oldText != null),
assert(selection != null),
assert(composing != null);
/// Creates an instance of this class from a JSON object by inferring the
/// type of delta based on values sent from the engine.
factory TextEditingDelta.fromJSON(Map<String, dynamic> encoded) {
// An insertion delta is one where replacement destination is collapsed.
//
// A deletion delta is one where the replacement source is empty.
//
// An insertion/deletion can still occur when the replacement destination is not
// collapsed, or the replacement source is not empty.
//
// On native platforms when composing text, the entire composing region is
// replaced on input, rather than reporting character by character
// insertion/deletion. In these cases we can detect if there was an
// insertion/deletion by checking if the text inside the original composing
// region was modified by the replacement. If the text is the same then we have
// an insertion/deletion. If the text is different then we can say we have
// a replacement.
//
// For example say we are currently composing the word: 'world'.
// Our current state is 'worl|' with the cursor at the end of 'l'. If we
// input the character 'd', the platform will tell us 'worl' was replaced
// with 'world' at range (0,4). Here we can check if the text found in the
// composing region (0,4) has been modified. We see that it hasn't because
// 'worl' == 'worl', so this means that the text in
// 'world'{replacementDestinationEnd, replacementDestinationStart + replacementSourceEnd}
// can be considered an insertion. In this case we inserted 'd'.
//
// Similarly for a a deletion, say we are currently composing the word: 'worl'.
// Our current state is 'world|' with the cursor at the end of 'd'. If we
// press backspace to delete the character 'd', the platform will tell us 'world'
// was replaced with 'worl' at range (0,5). Here we can check if the text found
// in the new composing region, is the same as the replacement text. We can do this
// by using oldText{replacementDestinationStart, replacementDestinationStart + replacementSourceEnd}
// which in this case is 'worl'. We then compare 'worl' with 'worl' and
// verify that they are the same. This means that the text in
// 'world'{replacementDestinationEnd, replacementDestinationStart + replacementSourceEnd} was deleted.
// In this case the character 'd' was deleted.
//
// A replacement delta occurs when the original composing region has been
// modified.
//
// 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;
final int replacementDestinationStart = encoded['deltaStart'] as int;
final int replacementDestinationEnd = encoded['deltaEnd'] as int;
final String replacementSource = encoded['deltaText'] as String;
const int replacementSourceStart = 0;
final int replacementSourceEnd = replacementSource.length;
// This delta is explicitly a non text update.
final bool isNonTextUpdate = replacementDestinationStart == -1 && replacementDestinationStart == replacementDestinationEnd;
final TextRange newComposing = TextRange(
start: encoded['composingBase'] as int? ?? -1,
end: encoded['composingExtent'] as int? ?? -1,
);
final TextSelection newSelection = TextSelection(
baseOffset: encoded['selectionBase'] as int? ?? -1,
extentOffset: encoded['selectionExtent'] as int? ?? -1,
affinity: _toTextAffinity(encoded['selectionAffinity'] as String?) ??
TextAffinity.downstream,
isDirectional: encoded['selectionIsDirectional'] as bool? ?? false,
);
if (isNonTextUpdate) {
return TextEditingDeltaNonTextUpdate(
oldText: oldText,
selection: newSelection,
composing: newComposing,
);
}
final String newText = _replace(oldText, replacementSource, replacementDestinationStart, replacementDestinationEnd);
final bool isEqual = oldText == newText;
final bool isDeletionGreaterThanOne = (replacementDestinationEnd - replacementDestinationStart) - (replacementSourceEnd - replacementSourceStart) > 1;
final bool isDeletingByReplacingWithEmpty = replacementSource.isEmpty && replacementSourceStart == 0 && replacementSourceStart == replacementSourceEnd;
final bool isReplacedByShorter = isDeletionGreaterThanOne && (replacementSourceEnd - replacementSourceStart < replacementDestinationEnd - replacementDestinationStart);
final bool isReplacedByLonger = replacementSourceEnd - replacementSourceStart > replacementDestinationEnd - replacementDestinationStart;
final bool isReplacedBySame = replacementSourceEnd - replacementSourceStart == replacementDestinationEnd - replacementDestinationStart;
final bool isInsertingInsideComposingRegion = replacementDestinationStart + replacementSourceEnd > replacementDestinationEnd;
final bool isDeletingInsideComposingRegion =
!isReplacedByShorter && !isDeletingByReplacingWithEmpty && replacementDestinationStart + replacementSourceEnd < replacementDestinationEnd;
String newComposingText;
String originalComposingText;
if (isDeletingByReplacingWithEmpty || isDeletingInsideComposingRegion || isReplacedByShorter) {
newComposingText = replacementSource.substring(replacementSourceStart, replacementSourceEnd);
originalComposingText = oldText.substring(replacementDestinationStart, replacementDestinationStart + replacementSourceEnd);
} else {
newComposingText = replacementSource.substring(replacementSourceStart, replacementSourceStart + (replacementDestinationEnd - replacementDestinationStart));
originalComposingText = oldText.substring(replacementDestinationStart, replacementDestinationEnd);
}
final bool isOriginalComposingRegionTextChanged = !(originalComposingText == newComposingText);
final bool isReplaced = isOriginalComposingRegionTextChanged ||
(isReplacedByLonger || isReplacedByShorter || isReplacedBySame);
if (isEqual) {
return TextEditingDeltaNonTextUpdate(
oldText: oldText,
selection: newSelection,
composing: newComposing,
);
} else if ((isDeletingByReplacingWithEmpty || isDeletingInsideComposingRegion) &&
!isOriginalComposingRegionTextChanged) { // Deletion.
int actualStart = replacementDestinationStart;
if (!isDeletionGreaterThanOne) {
actualStart = replacementDestinationEnd - 1;
}
return TextEditingDeltaDeletion(
oldText: oldText,
deletedRange: TextRange(
start: actualStart,
end: replacementDestinationEnd,
),
selection: newSelection,
composing: newComposing,
);
} else if ((replacementDestinationStart == replacementDestinationEnd || isInsertingInsideComposingRegion) &&
!isOriginalComposingRegionTextChanged) { // Insertion.
return TextEditingDeltaInsertion(
oldText: oldText,
textInserted: replacementSource.substring(replacementDestinationEnd - replacementDestinationStart, (replacementDestinationEnd - replacementDestinationStart) + (replacementSource.length - (replacementDestinationEnd - replacementDestinationStart))),
insertionOffset: replacementDestinationEnd,
selection: newSelection,
composing: newComposing,
);
} else if (isReplaced) { // Replacement.
return TextEditingDeltaReplacement(
oldText: oldText,
replacementText: replacementSource,
replacedRange: TextRange(
start: replacementDestinationStart,
end: replacementDestinationEnd,
),
selection: newSelection,
composing: newComposing,
);
}
assert(false);
return TextEditingDeltaNonTextUpdate(
oldText: oldText,
selection: newSelection,
composing: newComposing,
);
}
/// The old text state before the delta has occurred.
final String oldText;
/// The range of text that is currently selected after the delta has been
/// applied.
final TextSelection selection;
/// The range of text that is still being composed after the delta has been
/// applied.
final TextRange composing;
/// This method will take the given [TextEditingValue] and return a new
/// [TextEditingValue] with that instance of [TextEditingDelta] applied to it.
TextEditingValue apply(TextEditingValue value);
}
/// A structure representing an insertion of a single/or contigous sequence of
/// characters at some offset of an editing state.
@immutable
class TextEditingDeltaInsertion extends TextEditingDelta {
/// Creates an insertion delta for a given change to the editing state.
///
/// {@macro flutter.services.TextEditingDelta}
///
/// {@template flutter.services.TextEditingDelta.optIn}
/// See also:
///
/// * [TextInputConfiguration], to opt-in your [TextInputClient] to receive
/// [TextEditingDelta]'s you must set [TextInputConfiguration.enableDeltaModel]
/// to true.
/// {@endtemplate}
const TextEditingDeltaInsertion({
required String oldText,
required this.textInserted,
required this.insertionOffset,
required TextSelection selection,
required TextRange composing,
}) : super(
oldText: oldText,
selection: selection,
composing: composing,
);
/// The text that is being inserted into [oldText].
final String textInserted;
/// The offset in the [oldText] where the insertion begins.
final int insertionOffset;
@override
TextEditingValue apply(TextEditingValue value) {
// To stay inline with the plain text model we should follow a last write wins
// policy and apply the delta to the oldText. This is due to the asyncronous
// nature of the connection between the framework and platform text input plugins.
String newText = oldText;
newText = _replace(newText, textInserted, insertionOffset, insertionOffset);
return value.copyWith(text: newText, selection: selection, composing: composing);
}
}
/// A structure representing the deletion of a single/or contiguous sequence of
/// characters in an editing state.
@immutable
class TextEditingDeltaDeletion extends TextEditingDelta {
/// Creates a deletion delta for a given change to the editing state.
///
/// {@macro flutter.services.TextEditingDelta}
///
/// {@macro flutter.services.TextEditingDelta.optIn}
const TextEditingDeltaDeletion({
required String oldText,
required this.deletedRange,
required TextSelection selection,
required TextRange composing,
}) : super(
oldText: oldText,
selection: selection,
composing: composing,
);
/// The range in [oldText] that is being deleted.
final TextRange deletedRange;
/// The text from [oldText] that is being deleted.
String get textDeleted => oldText.substring(deletedRange.start, deletedRange.end);
@override
TextEditingValue apply(TextEditingValue value) {
// To stay inline with the plain text model we should follow a last write wins
// policy and apply the delta to the oldText. This is due to the asyncronous
// nature of the connection between the framework and platform text input plugins.
String newText = oldText;
newText = _replace(newText, '', deletedRange.start, deletedRange.end);
return value.copyWith(text: newText, selection: selection, composing: composing);
}
}
/// A structure representing a replacement of a range of characters with a
/// new sequence of text.
@immutable
class TextEditingDeltaReplacement extends TextEditingDelta {
/// Creates a replacement delta for a given change to the editing state.
///
/// The range that is being replaced can either grow or shrink based on the
/// given replacement text.
///
/// A replacement can occur in cases such as auto-correct, suggestions, and
/// when a selection is replaced by a single character.
///
/// {@macro flutter.services.TextEditingDelta}
///
/// {@macro flutter.services.TextEditingDelta.optIn}
const TextEditingDeltaReplacement({
required String oldText,
required this.replacementText,
required this.replacedRange,
required TextSelection selection,
required TextRange composing,
}) : super(
oldText: oldText,
selection: selection,
composing: composing,
);
/// The new text that is replacing [replacedRange] in [oldText].
final String replacementText;
/// The range in [oldText] that is being replaced.
final TextRange replacedRange;
/// The original text that is being replaced in [oldText].
String get textReplaced => oldText.substring(replacedRange.start, replacedRange.end);
@override
TextEditingValue apply(TextEditingValue value) {
// To stay inline with the plain text model we should follow a last write wins
// policy and apply the delta to the oldText. This is due to the asyncronous
// nature of the connection between the framework and platform text input plugins.
String newText = oldText;
newText = _replace(newText, replacementText, replacedRange.start, replacedRange.end);
return value.copyWith(text: newText, selection: selection, composing: composing);
}
}
/// A structure representing changes to the selection and/or composing regions
/// of an editing state and no changes to the text value.
@immutable
class TextEditingDeltaNonTextUpdate extends TextEditingDelta {
/// Creates a delta representing no updates to the text value of the current
/// editing state. This delta includes updates to the selection and/or composing
/// regions.
///
/// A situation where this delta would be created is when dragging the selection
/// handles. There are no changes to the text, but there are updates to the selection
/// and potentially the composing region as well.
///
/// {@macro flutter.services.TextEditingDelta}
///
/// {@macro flutter.services.TextEditingDelta.optIn}
const TextEditingDeltaNonTextUpdate({
required String oldText,
required TextSelection selection,
required TextRange composing,
}) : super(
oldText: oldText,
selection: selection,
composing: composing,
);
@override
TextEditingValue apply(TextEditingValue value) {
// To stay inline with the plain text model we should follow a last write wins
// policy and apply the delta to the oldText. This is due to the asyncronous
// nature of the connection between the framework and platform text input plugins.
return TextEditingValue(text: oldText, selection: selection, composing: composing);
}
}
......@@ -23,6 +23,7 @@ import 'platform_channel.dart';
import 'system_channels.dart';
import 'system_chrome.dart';
import 'text_editing.dart';
import 'text_editing_delta.dart';
export 'dart:ui' show TextAffinity;
......@@ -467,6 +468,7 @@ class TextInputConfiguration {
this.textCapitalization = TextCapitalization.none,
this.autofillConfiguration = AutofillConfiguration.disabled,
this.enableIMEPersonalizedLearning = true,
this.enableDeltaModel = false,
}) : assert(inputType != null),
assert(obscureText != null),
smartDashesType = smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled),
......@@ -476,7 +478,8 @@ class TextInputConfiguration {
assert(keyboardAppearance != null),
assert(inputAction != null),
assert(textCapitalization != null),
assert(enableIMEPersonalizedLearning != null);
assert(enableIMEPersonalizedLearning != null),
assert(enableDeltaModel != null);
/// The type of information for which to optimize the text input control.
final TextInputType inputType;
......@@ -622,6 +625,7 @@ class TextInputConfiguration {
TextCapitalization? textCapitalization,
bool? enableIMEPersonalizedLearning,
AutofillConfiguration? autofillConfiguration,
bool? enableDeltaModel,
}) {
return TextInputConfiguration(
inputType: inputType ?? this.inputType,
......@@ -636,8 +640,30 @@ class TextInputConfiguration {
keyboardAppearance: keyboardAppearance ?? this.keyboardAppearance,
enableIMEPersonalizedLearning: enableIMEPersonalizedLearning?? this.enableIMEPersonalizedLearning,
autofillConfiguration: autofillConfiguration ?? this.autofillConfiguration,
enableDeltaModel: enableDeltaModel ?? this.enableDeltaModel,
);
}
/// Whether to enable that the engine sends text input updates to the
/// framework as [TextEditingDelta]'s or as one [TextEditingValue].
///
/// When this is enabled platform text input updates will
/// come through [TextInputClient.updateEditingValueWithDeltas].
///
/// When this is disabled platform text input updates will come through
/// [TextInputClient.updateEditingValue].
///
/// Enabling this flag results in granular text updates being received from the
/// platforms text input control rather than a single new bulk editing state
/// given by [TextInputClient.updateEditingValue].
///
/// If the platform does not currently support the delta model then updates
/// for the editing state will continue to come through the
/// [TextInputClient.updateEditingValue] channel.
///
/// Defaults to false. Cannot be null.
final bool enableDeltaModel;
/// Returns a representation of this object as a JSON object.
Map<String, dynamic> toJson() {
final Map<String, dynamic>? autofill = autofillConfiguration.toJson();
......@@ -655,6 +681,7 @@ class TextInputConfiguration {
'keyboardAppearance': keyboardAppearance.toString(),
'enableIMEPersonalizedLearning': enableIMEPersonalizedLearning,
if (autofill != null) 'autofill': autofill,
'enableDeltaModel' : enableDeltaModel,
};
}
}
......@@ -956,6 +983,16 @@ abstract class TextInputClient {
/// formatting.
void updateEditingValue(TextEditingValue value);
/// Requests that this client update its editing state by applying the deltas
/// received from the engine.
///
/// 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.
///
/// {@macro flutter.services.TextEditingDelta.optIn}
void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas);
/// Requests that this client perform the given action.
void performAction(TextInputAction action);
......@@ -1447,6 +1484,18 @@ class TextInput {
case 'TextInputClient.updateEditingState':
_currentConnection!._client.updateEditingValue(TextEditingValue.fromJSON(args[1] as Map<String, dynamic>));
break;
case 'TextInputClient.updateEditingStateWithDeltas':
final List<TextEditingDelta> deltas = <TextEditingDelta>[];
final Map<String, dynamic> encoded = args[1] as Map<String, dynamic>;
for (final dynamic encodedDelta in encoded['deltas']) {
final TextEditingDelta delta = TextEditingDelta.fromJSON(encodedDelta as Map<String, dynamic>);
deltas.add(delta);
}
_currentConnection!._client.updateEditingValueWithDeltas(deltas);
break;
case 'TextInputClient.performAction':
_currentConnection!._client.performAction(_toTextInputAction(args[1] as String));
break;
......
......@@ -1795,6 +1795,15 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override
TextEditingValue get currentTextEditingValue => _value;
@override
void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {
TextEditingValue value = _value;
for (final TextEditingDelta delta in textEditingDeltas) {
value = delta.apply(value);
}
updateEditingValue(value);
}
@override
void updateEditingValue(TextEditingValue value) {
// This method handles text editing state updates from the platform text
......
......@@ -106,6 +106,19 @@ class FakeAutofillClient implements TextInputClient, AutofillClient {
latestMethodCall = 'updateEditingValue';
}
@override
void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {
TextEditingValue newEditingValue = currentTextEditingValue;
for (final TextEditingDelta delta in textEditingDeltas) {
newEditingValue = delta.apply(newEditingValue);
}
currentTextEditingValue = newEditingValue;
latestMethodCall = 'updateEditingValueWithDeltas';
}
@override
AutofillScope? currentAutofillScope;
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:convert' show jsonDecode;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('TextEditingDeltaInsertion', () {
test('Verify creation of insertion delta when inserting at a collapsed selection.', () {
const String jsonInsertionDelta = '{'
'"oldText": "",'
' "deltaText": "let there be text",'
' "deltaStart": 0,'
' "deltaEnd": 0,'
' "selectionBase": 17,'
' "selectionExtent": 17,'
' "selectionAffinity" : "TextAffinity.downstream" ,'
' "selectionIsDirectional": false,'
' "composingBase": -1,'
' "composingExtent": -1}';
final TextEditingDeltaInsertion delta = TextEditingDelta.fromJSON(jsonDecode(jsonInsertionDelta) as Map<String, dynamic>) as TextEditingDeltaInsertion;
const TextRange expectedComposing = TextRange.empty;
const int expectedInsertionOffset = 0;
const TextSelection expectedSelection = TextSelection.collapsed(offset: 17);
expect(delta.oldText, '');
expect(delta.textInserted, 'let there be text');
expect(delta.insertionOffset, expectedInsertionOffset);
expect(delta.selection, expectedSelection);
expect(delta.composing, expectedComposing);
});
test('Verify creation of insertion delta when inserting at end of composing region.', () {
const String jsonInsertionDelta = '{'
'"oldText": "hello worl",'
' "deltaText": "world",'
' "deltaStart": 6,'
' "deltaEnd": 10,'
' "selectionBase": 11,'
' "selectionExtent": 11,'
' "selectionAffinity" : "TextAffinity.downstream",'
' "selectionIsDirectional": false,'
' "composingBase": 6,'
' "composingExtent": 11}';
final TextEditingDeltaInsertion delta = TextEditingDelta.fromJSON(jsonDecode(jsonInsertionDelta) as Map<String, dynamic>) as TextEditingDeltaInsertion;
const TextRange expectedComposing = TextRange(start: 6, end: 11);
const int expectedInsertionOffset = 10;
const TextSelection expectedSelection = TextSelection.collapsed(offset: 11);
expect(delta.oldText, 'hello worl');
expect(delta.textInserted, 'd');
expect(delta.insertionOffset, expectedInsertionOffset);
expect(delta.selection, expectedSelection);
expect(delta.composing, expectedComposing);
});
});
group('TextEditingDeltaDeletion', () {
test('Verify creation of deletion delta when deleting.', () {
const String jsonDeletionDelta = '{'
'"oldText": "let there be text.",'
' "deltaText": "",'
' "deltaStart": 1,'
' "deltaEnd": 2,'
' "selectionBase": 1,'
' "selectionExtent": 1,'
' "selectionAffinity" : "TextAffinity.downstream" ,'
' "selectionIsDirectional": false,'
' "composingBase": -1,'
' "composingExtent": -1}';
final TextEditingDeltaDeletion delta = TextEditingDelta.fromJSON(jsonDecode(jsonDeletionDelta) as Map<String, dynamic>) as TextEditingDeltaDeletion;
const TextRange expectedComposing = TextRange.empty;
const TextRange expectedDeletedRange = TextRange(start: 1, end: 2);
const TextSelection expectedSelection = TextSelection.collapsed(offset: 1);
expect(delta.oldText, 'let there be text.');
expect(delta.textDeleted, 'e');
expect(delta.deletedRange, expectedDeletedRange);
expect(delta.selection, expectedSelection);
expect(delta.composing, expectedComposing);
});
test('Verify creation of deletion delta when deleting at end of composing region.', () {
const String jsonDeletionDelta = '{'
'"oldText": "hello world",'
' "deltaText": "worl",'
' "deltaStart": 6,'
' "deltaEnd": 11,'
' "selectionBase": 10,'
' "selectionExtent": 10,'
' "selectionAffinity" : "TextAffinity.downstream",'
' "selectionIsDirectional": false,'
' "composingBase": 6,'
' "composingExtent": 10}';
final TextEditingDeltaDeletion delta = TextEditingDelta.fromJSON(jsonDecode(jsonDeletionDelta) as Map<String, dynamic>) as TextEditingDeltaDeletion;
const TextRange expectedComposing = TextRange(start: 6, end: 10);
const TextRange expectedDeletedRange = TextRange(start: 10, end: 11);
const TextSelection expectedSelection = TextSelection.collapsed(offset: 10);
expect(delta.oldText, 'hello world');
expect(delta.textDeleted, 'd');
expect(delta.deletedRange, expectedDeletedRange);
expect(delta.selection, expectedSelection);
expect(delta.composing, expectedComposing);
});
});
group('TextEditingDeltaReplacement', () {
test('Verify creation of replacement delta when replacing with longer.', () {
const String jsonReplacementDelta = '{'
'"oldText": "hello worfi",'
' "deltaText": "working",'
' "deltaStart": 6,'
' "deltaEnd": 11,'
' "selectionBase": 13,'
' "selectionExtent": 13,'
' "selectionAffinity" : "TextAffinity.downstream",'
' "selectionIsDirectional": false,'
' "composingBase": 6,'
' "composingExtent": 13}';
final TextEditingDeltaReplacement delta = TextEditingDelta.fromJSON(jsonDecode(jsonReplacementDelta) as Map<String, dynamic>) as TextEditingDeltaReplacement;
const TextRange expectedComposing = TextRange(start: 6, end: 13);
const TextRange expectedReplacedRange = TextRange(start: 6, end: 11);
const TextSelection expectedSelection = TextSelection.collapsed(offset: 13);
expect(delta.oldText, 'hello worfi');
expect(delta.textReplaced, 'worfi');
expect(delta.replacementText, 'working');
expect(delta.replacedRange, expectedReplacedRange);
expect(delta.selection, expectedSelection);
expect(delta.composing, expectedComposing);
});
test('Verify creation of replacement delta when replacing with shorter.', () {
const String jsonReplacementDelta = '{'
'"oldText": "hello world",'
' "deltaText": "h",'
' "deltaStart": 6,'
' "deltaEnd": 11,'
' "selectionBase": 7,'
' "selectionExtent": 7,'
' "selectionAffinity" : "TextAffinity.downstream",'
' "selectionIsDirectional": false,'
' "composingBase": 6,'
' "composingExtent": 7}';
final TextEditingDeltaReplacement delta = TextEditingDelta.fromJSON(jsonDecode(jsonReplacementDelta) as Map<String, dynamic>) as TextEditingDeltaReplacement;
const TextRange expectedComposing = TextRange(start: 6, end: 7);
const TextRange expectedReplacedRange = TextRange(start: 6, end: 11);
const TextSelection expectedSelection = TextSelection.collapsed(offset: 7);
expect(delta.oldText, 'hello world');
expect(delta.textReplaced, 'world');
expect(delta.replacementText, 'h');
expect(delta.replacedRange, expectedReplacedRange);
expect(delta.selection, expectedSelection);
expect(delta.composing, expectedComposing);
});
test('Verify creation of replacement delta when replacing with same.', () {
const String jsonReplacementDelta = '{'
'"oldText": "hello world",'
' "deltaText": "words",'
' "deltaStart": 6,'
' "deltaEnd": 11,'
' "selectionBase": 11,'
' "selectionExtent": 11,'
' "selectionAffinity" : "TextAffinity.downstream",'
' "selectionIsDirectional": false,'
' "composingBase": 6,'
' "composingExtent": 11}';
final TextEditingDeltaReplacement delta = TextEditingDelta.fromJSON(jsonDecode(jsonReplacementDelta) as Map<String, dynamic>) as TextEditingDeltaReplacement;
const TextRange expectedComposing = TextRange(start: 6, end: 11);
const TextRange expectedReplacedRange = TextRange(start: 6, end: 11);
const TextSelection expectedSelection = TextSelection.collapsed(offset: 11);
expect(delta.oldText, 'hello world');
expect(delta.textReplaced, 'world');
expect(delta.replacementText, 'words');
expect(delta.replacedRange, expectedReplacedRange);
expect(delta.selection, expectedSelection);
expect(delta.composing, expectedComposing);
});
});
group('TextEditingDeltaNonTextUpdate', () {
test('Verify non text update delta created.', () {
const String jsonNonTextUpdateDelta = '{'
'"oldText": "hello world",'
' "deltaText": "",'
' "deltaStart": -1,'
' "deltaEnd": -1,'
' "selectionBase": 10,'
' "selectionExtent": 10,'
' "selectionAffinity" : "TextAffinity.downstream",'
' "selectionIsDirectional": false,'
' "composingBase": 6,'
' "composingExtent": 11}';
final TextEditingDeltaNonTextUpdate delta = TextEditingDelta.fromJSON(jsonDecode(jsonNonTextUpdateDelta) as Map<String, dynamic>) as TextEditingDeltaNonTextUpdate;
const TextRange expectedComposing = TextRange(start: 6, end: 11);
const TextSelection expectedSelection = TextSelection.collapsed(offset: 10);
expect(delta.oldText, 'hello world');
expect(delta.selection, expectedSelection);
expect(delta.composing, expectedComposing);
});
});
}
......@@ -119,6 +119,7 @@ void main() {
expect(configuration.inputType, TextInputType.text);
expect(configuration.readOnly, false);
expect(configuration.obscureText, false);
expect(configuration.enableDeltaModel, false);
expect(configuration.autocorrect, true);
expect(configuration.actionLabel, null);
expect(configuration.textCapitalization, TextCapitalization.none);
......@@ -450,6 +451,11 @@ class FakeTextInputClient implements TextInputClient {
latestMethodCall = 'updateEditingValue';
}
@override
void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {
latestMethodCall = 'updateEditingValueWithDeltas';
}
@override
void updateFloatingCursor(RawFloatingCursorPoint point) {
latestMethodCall = 'updateFloatingCursor';
......
......@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:convert' show jsonDecode;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
......@@ -6618,6 +6619,302 @@ void main() {
expect(focusNode.hasFocus, false);
});
group('TextEditingDelta', () {
testWidgets('TextEditingDeltaInsertion verification', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(devicePixelRatio: 1.0),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: EditableText(
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: Colors.red,
backgroundCursorColor: Colors.red,
keyboardType: TextInputType.multiline,
onChanged: (String value) { },
),
),
),
),
),
),
);
final EditableTextState state = tester.firstState(find.byType(EditableText));
const String jsonDelta = '{'
'"oldText": "",'
' "deltaText": "let there be text",'
' "deltaStart": 0,'
' "deltaEnd": 0,'
' "selectionBase": 17,'
' "selectionExtent": 17,'
' "selectionAffinity" : "TextAffinity.downstream" ,'
' "selectionIsDirectional": false,'
' "composingBase": -1,'
' "composingExtent": -1}';
final Map<String, dynamic> test = jsonDecode(jsonDelta) as Map<String, dynamic>;
final TextEditingDelta delta = TextEditingDelta.fromJSON(test);
expect(delta.runtimeType, TextEditingDeltaInsertion);
state.updateEditingValueWithDeltas(<TextEditingDelta>[delta]);
await tester.pump();
expect(controller.text, 'let there be text');
expect(controller.selection, delta.selection);
expect(state.currentTextEditingValue.composing, delta.composing);
});
testWidgets('TextEditingDeltaDeletion verification', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'let there be text');
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(devicePixelRatio: 1.0),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: EditableText(
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: Colors.red,
backgroundCursorColor: Colors.red,
keyboardType: TextInputType.multiline,
onChanged: (String value) { },
),
),
),
),
),
),
);
final EditableTextState state = tester.firstState(find.byType(EditableText));
const String jsonDelta = '{'
'"oldText": "let there be text",'
' "deltaText": "",'
' "deltaStart": 0,'
' "deltaEnd": 17,'
' "selectionBase": 0,'
' "selectionExtent": 0,'
' "selectionAffinity" : "TextAffinity.downstream" ,'
' "selectionIsDirectional": false,'
' "composingBase": -1,'
' "composingExtent": -1}';
final Map<String, dynamic> test = jsonDecode(jsonDelta) as Map<String, dynamic>;
final TextEditingDelta delta = TextEditingDelta.fromJSON(test);
expect(delta.runtimeType, TextEditingDeltaDeletion);
state.updateEditingValueWithDeltas(<TextEditingDelta>[delta]);
await tester.pump();
expect(controller.text, '');
expect(controller.selection, delta.selection);
expect(state.currentTextEditingValue.composing, delta.composing);
});
testWidgets('TextEditingDeltaReplacement verification', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'let there be text');
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(devicePixelRatio: 1.0),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: EditableText(
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: Colors.red,
backgroundCursorColor: Colors.red,
keyboardType: TextInputType.multiline,
onChanged: (String value) { },
),
),
),
),
),
),
);
final EditableTextState state = tester.firstState(find.byType(EditableText));
const String jsonDelta = '{'
'"oldText": "let there be text",'
' "deltaText": "this is your replacement text",'
' "deltaStart": 0,'
' "deltaEnd": 17,'
' "selectionBase": 0,'
' "selectionExtent": 0,'
' "selectionAffinity" : "TextAffinity.downstream",'
' "selectionIsDirectional": false,'
' "composingBase": -1,'
' "composingExtent": -1}';
final Map<String, dynamic> test = jsonDecode(jsonDelta) as Map<String, dynamic>;
final TextEditingDelta delta = TextEditingDelta.fromJSON(test);
expect(delta.runtimeType, TextEditingDeltaReplacement);
state.updateEditingValueWithDeltas(<TextEditingDelta>[delta]);
await tester.pump();
expect(controller.text, 'this is your replacement text');
expect(controller.selection, delta.selection);
expect(state.currentTextEditingValue.composing, delta.composing);
});
testWidgets('TextEditingDeltaNonTextUpdate verification', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'let there be text');
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(devicePixelRatio: 1.0),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: EditableText(
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: Colors.red,
backgroundCursorColor: Colors.red,
keyboardType: TextInputType.multiline,
onChanged: (String value) { },
),
),
),
),
),
),
);
final EditableTextState state = tester.firstState(find.byType(EditableText));
const String jsonDelta = '{'
'"oldText": "let there be text",'
' "deltaText": "",'
' "deltaStart": -1,'
' "deltaEnd": -1,'
' "selectionBase": 17,'
' "selectionExtent": 17,'
' "selectionAffinity" : "TextAffinity.downstream",'
' "selectionIsDirectional": false,'
' "composingBase": -1,'
' "composingExtent": -1}';
final Map<String, dynamic> test = jsonDecode(jsonDelta) as Map<String, dynamic>;
final TextEditingDelta delta = TextEditingDelta.fromJSON(test);
expect(delta.runtimeType, TextEditingDeltaNonTextUpdate);
state.updateEditingValueWithDeltas(<TextEditingDelta>[delta]);
await tester.pump();
expect(controller.text, 'let there be text');
expect(controller.selection, delta.selection);
expect(state.currentTextEditingValue.composing, delta.composing);
});
testWidgets('TextEditingDelta verify batch deltas apply', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(devicePixelRatio: 1.0),
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Material(
child: EditableText(
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: Colors.red,
backgroundCursorColor: Colors.red,
keyboardType: TextInputType.multiline,
onChanged: (String value) { },
),
),
),
),
),
),
);
final EditableTextState state = tester.firstState(find.byType(EditableText));
const String jsonInsertionDelta = '{'
'"oldText": "",'
' "deltaText": "let there be text",'
' "deltaStart": 0,'
' "deltaEnd": 0,'
' "selectionBase": 17,'
' "selectionExtent": 17,'
' "selectionAffinity" : "TextAffinity.downstream" ,'
' "selectionIsDirectional": false,'
' "composingBase": -1,'
' "composingExtent": -1}';
const String jsonDeletionDelta = '{'
'"oldText": "let there be text",'
' "deltaText": "",'
' "deltaStart": 12,'
' "deltaEnd": 17,'
' "selectionBase": 12,'
' "selectionExtent": 12,'
' "selectionAffinity" : "TextAffinity.downstream" ,'
' "selectionIsDirectional": false,'
' "composingBase": -1,'
' "composingExtent": -1}';
const String jsonReplacementDelta = '{'
'"oldText": "let there be",'
' "deltaText": "b light",'
' "deltaStart": 10,'
' "deltaEnd": 12,'
' "selectionBase": 17,'
' "selectionExtent": 17,'
' "selectionAffinity" : "TextAffinity.downstream" ,'
' "selectionIsDirectional": false,'
' "composingBase": -1,'
' "composingExtent": -1}';
const String jsonNonTextUpdateDelta = '{'
'"oldText": "let there b light",'
' "deltaText": "",'
' "deltaStart": -1,'
' "deltaEnd": -1,'
' "selectionBase": 17,'
' "selectionExtent": 17,'
' "selectionAffinity" : "TextAffinity.downstream",'
' "selectionIsDirectional": false,'
' "composingBase": -1,'
' "composingExtent": -1}';
final TextEditingDelta insertionDelta = TextEditingDelta.fromJSON(jsonDecode(jsonInsertionDelta) as Map<String, dynamic>);
final TextEditingDelta deletionDelta = TextEditingDelta.fromJSON(jsonDecode(jsonDeletionDelta) as Map<String, dynamic>);
final TextEditingDelta replacementDelta = TextEditingDelta.fromJSON(jsonDecode(jsonReplacementDelta) as Map<String, dynamic>);
final TextEditingDelta nonTextUpdateDelta = TextEditingDelta.fromJSON(jsonDecode(jsonNonTextUpdateDelta) as Map<String, dynamic>);
expect(insertionDelta.runtimeType, TextEditingDeltaInsertion);
expect(deletionDelta.runtimeType, TextEditingDeltaDeletion);
expect(replacementDelta.runtimeType, TextEditingDeltaReplacement);
expect(nonTextUpdateDelta.runtimeType, TextEditingDeltaNonTextUpdate);
state.updateEditingValueWithDeltas(<TextEditingDelta>[insertionDelta, deletionDelta, replacementDelta, nonTextUpdateDelta]);
await tester.pump();
expect(controller.text, 'let there b light');
expect(controller.selection, nonTextUpdateDelta.selection);
expect(state.currentTextEditingValue.composing, nonTextUpdateDelta.composing);
});
});
group('TextEditingController', () {
testWidgets('TextEditingController.text set to empty string clears field', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment