blob: 45c5473777b452126de2f85cbbc6fce4082f6884 [file] [log] [blame]
// 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:ui' show TextRange;
import 'package:flutter/foundation.dart';
import 'text_editing.dart';
import 'text_input.dart' show TextEditingValue;
export 'dart:ui' show TextRange;
export 'text_editing.dart' show TextSelection;
export '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 [DeltaTextInputClient] 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 [DeltaTextInputClient] to receive
/// [TextEditingDelta]'s you must set [TextInputConfiguration.enableDeltaModel]
/// to true.
/// {@endtemplate}
const TextEditingDeltaInsertion({
required super.oldText,
required this.textInserted,
required this.insertionOffset,
required super.selection,
required super.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 super.oldText,
required this.deletedRange,
required super.selection,
required super.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 super.oldText,
required this.replacementText,
required this.replacedRange,
required super.selection,
required super.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 super.oldText,
required super.selection,
required super.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);
}
}