blob: b9cf4de25f768fe498b143636d4b81cf1d6054a8 [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.
/// @docImport 'text_input.dart';
library;
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) {
return switch (affinity) {
'TextAffinity.downstream' => TextAffinity.downstream,
'TextAffinity.upstream' => TextAffinity.upstream,
_ => null,
};
}
// Replaces a range of text in the original string with the text given in the
// replacement string.
String _replace(String originalText, String replacementText, TextRange replacementRange) {
assert(replacementRange.isValid);
return originalText.replaceRange(replacementRange.start, replacementRange.end, replacementText);
}
// Verify that the given range is within the text.
bool _debugTextRangeIsValid(TextRange range, String text) {
if (!range.isValid) {
return true;
}
return (range.start >= 0 && range.start <= text.length) &&
(range.end >= 0 && range.end <= text.length);
}
/// 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 with Diagnosticable {
/// Creates a delta for a given change to the editing state.
const TextEditingDelta({required this.oldText, required this.selection, required this.composing});
/// 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 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 oldText = encoded['oldText'] as String;
final replacementDestinationStart = encoded['deltaStart'] as int;
final replacementDestinationEnd = encoded['deltaEnd'] as int;
final replacementSource = encoded['deltaText'] as String;
const replacementSourceStart = 0;
final int replacementSourceEnd = replacementSource.length;
// This delta is explicitly a non text update.
final bool isNonTextUpdate =
replacementDestinationStart == -1 &&
replacementDestinationStart == replacementDestinationEnd;
final newComposing = TextRange(
start: encoded['composingBase'] as int? ?? -1,
end: encoded['composingExtent'] as int? ?? -1,
);
final 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) {
assert(
_debugTextRangeIsValid(newSelection, oldText),
'The selection range: $newSelection is not within the bounds of text: $oldText of length: ${oldText.length}',
);
assert(
_debugTextRangeIsValid(newComposing, oldText),
'The composing range: $newComposing is not within the bounds of text: $oldText of length: ${oldText.length}',
);
return TextEditingDeltaNonTextUpdate(
oldText: oldText,
selection: newSelection,
composing: newComposing,
);
}
assert(
_debugTextRangeIsValid(
TextRange(start: replacementDestinationStart, end: replacementDestinationEnd),
oldText,
),
'The delta range: ${TextRange(start: replacementSourceStart, end: replacementSourceEnd)} is not within the bounds of text: $oldText of length: ${oldText.length}',
);
final String newText = _replace(
oldText,
replacementSource,
TextRange(start: replacementDestinationStart, end: replacementDestinationEnd),
);
assert(
_debugTextRangeIsValid(newSelection, newText),
'The selection range: $newSelection is not within the bounds of text: $newText of length: ${newText.length}',
);
assert(
_debugTextRangeIsValid(newComposing, newText),
'The composing range: $newComposing is not within the bounds of text: $newText of length: ${newText.length}',
);
final 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 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.
var 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 contiguous 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.
///
/// {@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 asynchronous
// nature of the connection between the framework and platform text input plugins.
String newText = oldText;
assert(
_debugTextRangeIsValid(TextRange.collapsed(insertionOffset), newText),
'Applying TextEditingDeltaInsertion failed, the insertionOffset: $insertionOffset is not within the bounds of $newText of length: ${newText.length}',
);
newText = _replace(newText, textInserted, TextRange.collapsed(insertionOffset));
assert(
_debugTextRangeIsValid(selection, newText),
'Applying TextEditingDeltaInsertion failed, the selection range: $selection is not within the bounds of $newText of length: ${newText.length}',
);
assert(
_debugTextRangeIsValid(composing, newText),
'Applying TextEditingDeltaInsertion failed, the composing range: $composing is not within the bounds of $newText of length: ${newText.length}',
);
return value.copyWith(text: newText, selection: selection, composing: composing);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<String>('oldText', oldText));
properties.add(DiagnosticsProperty<String>('textInserted', textInserted));
properties.add(DiagnosticsProperty<int>('insertionOffset', insertionOffset));
properties.add(DiagnosticsProperty<TextSelection>('selection', selection));
properties.add(DiagnosticsProperty<TextRange>('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.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 asynchronous
// nature of the connection between the framework and platform text input plugins.
String newText = oldText;
assert(
_debugTextRangeIsValid(deletedRange, newText),
'Applying TextEditingDeltaDeletion failed, the deletedRange: $deletedRange is not within the bounds of $newText of length: ${newText.length}',
);
newText = _replace(newText, '', deletedRange);
assert(
_debugTextRangeIsValid(selection, newText),
'Applying TextEditingDeltaDeletion failed, the selection range: $selection is not within the bounds of $newText of length: ${newText.length}',
);
assert(
_debugTextRangeIsValid(composing, newText),
'Applying TextEditingDeltaDeletion failed, the composing range: $composing is not within the bounds of $newText of length: ${newText.length}',
);
return value.copyWith(text: newText, selection: selection, composing: composing);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<String>('oldText', oldText));
properties.add(DiagnosticsProperty<String>('textDeleted', textDeleted));
properties.add(DiagnosticsProperty<TextRange>('deletedRange', deletedRange));
properties.add(DiagnosticsProperty<TextSelection>('selection', selection));
properties.add(DiagnosticsProperty<TextRange>('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.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 asynchronous
// nature of the connection between the framework and platform text input plugins.
String newText = oldText;
assert(
_debugTextRangeIsValid(replacedRange, newText),
'Applying TextEditingDeltaReplacement failed, the replacedRange: $replacedRange is not within the bounds of $newText of length: ${newText.length}',
);
newText = _replace(newText, replacementText, replacedRange);
assert(
_debugTextRangeIsValid(selection, newText),
'Applying TextEditingDeltaReplacement failed, the selection range: $selection is not within the bounds of $newText of length: ${newText.length}',
);
assert(
_debugTextRangeIsValid(composing, newText),
'Applying TextEditingDeltaReplacement failed, the composing range: $composing is not within the bounds of $newText of length: ${newText.length}',
);
return value.copyWith(text: newText, selection: selection, composing: composing);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<String>('oldText', oldText));
properties.add(DiagnosticsProperty<String>('textReplaced', textReplaced));
properties.add(DiagnosticsProperty<String>('replacementText', replacementText));
properties.add(DiagnosticsProperty<TextRange>('replacedRange', replacedRange));
properties.add(DiagnosticsProperty<TextSelection>('selection', selection));
properties.add(DiagnosticsProperty<TextRange>('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.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 asynchronous
// nature of the connection between the framework and platform text input plugins.
assert(
_debugTextRangeIsValid(selection, oldText),
'Applying TextEditingDeltaNonTextUpdate failed, the selection range: $selection is not within the bounds of $oldText of length: ${oldText.length}',
);
assert(
_debugTextRangeIsValid(composing, oldText),
'Applying TextEditingDeltaNonTextUpdate failed, the composing region: $composing is not within the bounds of $oldText of length: ${oldText.length}',
);
return TextEditingValue(text: oldText, selection: selection, composing: composing);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<String>('oldText', oldText));
properties.add(DiagnosticsProperty<TextSelection>('selection', selection));
properties.add(DiagnosticsProperty<TextRange>('composing', composing));
}
}