Unverified Commit 0b1832ba authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Make `FilteringTextInputFormatter`'s filtering Selection/Composing Region agnostic (#89327)

parent b2550fe5
......@@ -121,6 +121,94 @@ class _SimpleTextInputFormatter extends TextInputFormatter {
}
}
// A mutable, half-open range [`base`, `extent`) within a string.
class _MutableTextRange {
_MutableTextRange(this.base, this.extent);
static _MutableTextRange? fromComposingRange(TextRange range) {
return range.isValid && !range.isCollapsed
? _MutableTextRange(range.start, range.end)
: null;
}
static _MutableTextRange? fromTextSelection(TextSelection selection) {
return selection.isValid
? _MutableTextRange(selection.baseOffset, selection.extentOffset)
: null;
}
/// The start index of the range, inclusive.
///
/// The value of [base] should always be greater than or equal to 0, and can
/// be larger than, smaller than, or equal to [extent].
int base;
/// The end index of the range, exclusive.
///
/// The value of [extent] should always be greater than or equal to 0, and can
/// be larger than, smaller than, or equal to [base].
int extent;
}
// The intermediate state of a [FilteringTextInputFormatter] when it's
// formatting a new user input.
class _TextEditingValueAccumulator {
_TextEditingValueAccumulator(this.inputValue)
: selection = _MutableTextRange.fromTextSelection(inputValue.selection),
composingRegion = _MutableTextRange.fromComposingRange(inputValue.composing);
// The original string that was sent to the [FilteringTextInputFormatter] as
// input.
final TextEditingValue inputValue;
/// The [StringBuffer] that contains the string which has already been
/// formatted.
///
/// In a [FilteringTextInputFormatter], typically the replacement string,
/// instead of the original string within the given range, is written to this
/// [StringBuffer].
final StringBuffer stringBuffer = StringBuffer();
/// The updated selection, as well as the original selection from the input
/// [TextEditingValue] of the [FilteringTextInputFormatter].
///
/// This parameter will be null if the input [TextEditingValue.selection] is
/// invalid.
final _MutableTextRange? selection;
/// The updated composing region, as well as the original composing region
/// from the input [TextEditingValue] of the [FilteringTextInputFormatter].
///
/// This parameter will be null if the input [TextEditingValue.composing] is
/// invalid or collapsed.
final _MutableTextRange? composingRegion;
// Whether this state object has reached its end-of-life.
bool debugFinalized = false;
TextEditingValue finalize() {
debugFinalized = true;
final _MutableTextRange? selection = this.selection;
final _MutableTextRange? composingRegion = this.composingRegion;
return TextEditingValue(
text: stringBuffer.toString(),
composing: composingRegion == null || composingRegion.base == composingRegion.extent
? TextRange.empty
: TextRange(start: composingRegion.base, end: composingRegion.extent),
selection: selection == null
? const TextSelection.collapsed(offset: -1)
: TextSelection(
baseOffset: selection.base,
extentOffset: selection.extent,
// Try to preserve the selection affinity and isDirectional. This
// may not make sense if the selection has changed.
affinity: inputValue.selection.affinity,
isDirectional: inputValue.selection.isDirectional,
),
);
}
}
/// A [TextInputFormatter] that prevents the insertion of characters
/// matching (or not matching) a particular pattern.
///
......@@ -159,33 +247,26 @@ class FilteringTextInputFormatter extends TextInputFormatter {
/// The [filterPattern] and [replacementString] arguments
/// must not be null.
FilteringTextInputFormatter.allow(
this.filterPattern, {
this.replacementString = '',
}) : assert(filterPattern != null),
assert(replacementString != null),
allow = true;
Pattern filterPattern, {
String replacementString = '',
}) : this(filterPattern, allow: true, replacementString: replacementString);
/// Creates a formatter that blocks characters matching a pattern.
///
/// The [filterPattern] and [replacementString] arguments
/// must not be null.
FilteringTextInputFormatter.deny(
this.filterPattern, {
this.replacementString = '',
}) : assert(filterPattern != null),
assert(replacementString != null),
allow = false;
Pattern filterPattern, {
String replacementString = '',
}) : this(filterPattern, allow: false, replacementString: replacementString);
/// A [Pattern] to match and replace in incoming [TextEditingValue]s.
/// A [Pattern] to match or replace in incoming [TextEditingValue]s.
///
/// The behavior of the pattern depends on the [allow] property. If
/// it is true, then this is an allow list, specifying a pattern that
/// characters must match to be accepted. Otherwise, it is a deny list,
/// specifying a pattern that characters must not match to be accepted.
///
/// In general, the pattern should only match one character at a
/// time. See the discussion at [replacementString].
///
/// {@tool snippet}
/// Typically the pattern is a regular expression, as in:
///
......@@ -246,16 +327,18 @@ class FilteringTextInputFormatter extends TextInputFormatter {
/// string) because both of the "o"s would be matched simultaneously
/// by the pattern.
///
/// Additionally, each segment of the string before, during, and
/// after the current selection in the [TextEditingValue] is handled
/// separately. This means that, in the case of the "Into the Woods"
/// example above, if the selection ended between the two "o"s in
/// "Woods", even if the pattern was `RegExp('o+')`, the result
/// would be "Int* the W**ds", since the two "o"s would be handled
/// in separate passes.
///
/// See also [String.splitMapJoin], which is used to implement this
/// behavior in both cases.
/// The filter may adjust the selection and the composing region of the text
/// after applying the text replacement, such that they still cover the same
/// text. For instance, if the pattern was `o+` and the last character "s" was
/// selected: "Into The Wood|s|", then the result will be "Into The W*d|s|",
/// with the selection still around the same character "s" despite that it is
/// now the 12th character.
///
/// In the case where one end point of the selection (or the composing region)
/// is strictly inside the banned pattern (for example, "Into The |Wo|ods"),
/// that endpoint will be moved to the end of the replacement string (it will
/// become "Into The |W*|ds" if the pattern was `o+` and the original text and
/// selection were "Into The |Wo|ods").
final String replacementString;
@override
......@@ -263,16 +346,61 @@ class FilteringTextInputFormatter extends TextInputFormatter {
TextEditingValue oldValue, // unused.
TextEditingValue newValue,
) {
return _selectionAwareTextManipulation(
newValue,
(String substring) {
return substring.splitMapJoin(
filterPattern,
onMatch: !allow ? (Match match) => replacementString : null,
onNonMatch: allow ? (String nonMatch) => nonMatch.isNotEmpty ? replacementString : '' : null,
);
},
);
final _TextEditingValueAccumulator formatState = _TextEditingValueAccumulator(newValue);
assert(!formatState.debugFinalized);
final Iterable<Match> matches = filterPattern.allMatches(newValue.text);
Match? previousMatch;
for (final Match match in matches) {
assert(match.end > match.start);
// Compute the non-match region between this `Match` and the previous
// `Match`. Depending on the value of `allow`, either the match region or
// the non-match region is the banned pattern.
//
// The non-matching region.
_processRegion(allow, previousMatch?.end ?? 0, match.start, formatState);
assert(!formatState.debugFinalized);
// The matched region.
_processRegion(!allow, match.start, match.end, formatState);
assert(!formatState.debugFinalized);
previousMatch = match;
}
// Handle the last non-matching region between the last match region and the
// end of the text.
_processRegion(allow, previousMatch?.end ?? 0, newValue.text.length, formatState);
assert(!formatState.debugFinalized);
return formatState.finalize();
}
void _processRegion(bool isBannedRegion, int regionStart, int regionEnd, _TextEditingValueAccumulator state) {
final String replacementString = isBannedRegion
? (regionStart == regionEnd ? '' : this.replacementString)
: state.inputValue.text.substring(regionStart, regionEnd);
state.stringBuffer.write(replacementString);
if (replacementString.length == regionEnd - regionStart) {
// We don't have to adjust the indices if the replaced string and the
// replacement string have the same length.
return;
}
int adjustIndex(int originalIndex) {
// Add to the index the length of the replacement if needed.
// The condition of the ternary operator is chosen such that the index
// will be placed **after** the replacement text, if the index was
// strictly inside the banned pattern.
final int replacedLength = regionStart >= originalIndex ? 0 : replacementString.length;
// Subtract from the index the length of the banned pattern before the index.
return replacedLength - (originalIndex.clamp(regionStart, regionEnd) - regionStart);
}
state.selection?.base += adjustIndex(state.inputValue.selection.baseOffset);
state.selection?.extent += adjustIndex(state.inputValue.selection.extentOffset);
state.composingRegion?.base += adjustIndex(state.inputValue.composing.start);
state.composingRegion?.extent += adjustIndex(state.inputValue.composing.end);
}
/// A [TextInputFormatter] that forces input to be a single line.
......@@ -527,45 +655,3 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter {
}
}
}
TextEditingValue _selectionAwareTextManipulation(
TextEditingValue value,
String Function(String substring) substringManipulation,
) {
final int selectionStartIndex = value.selection.start;
final int selectionEndIndex = value.selection.end;
String manipulatedText;
TextSelection? manipulatedSelection;
if (selectionStartIndex < 0 || selectionEndIndex < 0) {
manipulatedText = substringManipulation(value.text);
} else {
final String beforeSelection = substringManipulation(
value.text.substring(0, selectionStartIndex),
);
final String inSelection = substringManipulation(
value.text.substring(selectionStartIndex, selectionEndIndex),
);
final String afterSelection = substringManipulation(
value.text.substring(selectionEndIndex),
);
manipulatedText = beforeSelection + inSelection + afterSelection;
if (value.selection.baseOffset > value.selection.extentOffset) {
manipulatedSelection = value.selection.copyWith(
baseOffset: beforeSelection.length + inSelection.length,
extentOffset: beforeSelection.length,
);
} else {
manipulatedSelection = value.selection.copyWith(
baseOffset: beforeSelection.length,
extentOffset: beforeSelection.length + inSelection.length,
);
}
}
return TextEditingValue(
text: manipulatedText,
selection: manipulatedSelection ?? const TextSelection.collapsed(offset: -1),
composing: manipulatedText == value.text
? value.composing
: TextRange.empty,
);
}
......@@ -741,9 +741,38 @@ class TextEditingValue {
final String text;
/// The range of text that is currently selected.
///
/// When [selection] is a [TextSelection] that has the same non-negative
/// `baseOffset` and `extentOffset`, the [selection] property represents the
/// caret position.
///
/// If the current [selection] has a negative `baseOffset` or `extentOffset`,
/// then the text currently does not have a selection or a caret location, and
/// most text editing operations that rely on the current selection (for
/// instance, insert a character at the caret location) will do nothing.
final TextSelection selection;
/// The range of text that is still being composed.
///
/// Composing regions are created by input methods (IMEs) to indicate the text
/// within a certain range is provisional. For instance, the Android Gboard
/// app's English keyboard puts the current word under the caret into a
/// composing region to indicate the word is subject to autocorrect or
/// prediction changes.
///
/// Composing regions can also be used for performing multistage input, which
/// is typically used by IMEs designed for phoetic keyboard to enter
/// ideographic symbols. As an example, many CJK keyboards require the user to
/// enter a latin alphabet sequence and then convert it to CJK characters. On
/// iOS, the default software keyboards do not have a dedicated view to show
/// the unfinished latin sequence, so it's displayed directly in the text
/// field, inside of a composing region.
///
/// The composing region should typically only be changed by the IME, or the
/// user via interacting with the IME.
///
/// If the range represented by this property is [TextRange.empty], then the
/// text is not currently being composed.
final TextRange composing;
/// A value that corresponds to the empty string with no selection and no composing range.
......
......@@ -64,6 +64,7 @@ void main() {
const TextEditingValue(text: 'Int* the W*ds'),
);
// "Into the Wo|ods|"
const TextEditingValue selectedIntoTheWoods = TextEditingValue(text: 'Into the Woods', selection: TextSelection(baseOffset: 11, extentOffset: 14));
expect(
FilteringTextInputFormatter('o', allow: true, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods),
......@@ -79,7 +80,7 @@ void main() {
);
expect(
FilteringTextInputFormatter(RegExp('o+'), allow: false, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods),
const TextEditingValue(text: 'Int* the W**ds', selection: TextSelection(baseOffset: 11, extentOffset: 14)),
const TextEditingValue(text: 'Int* the W*ds', selection: TextSelection(baseOffset: 11, extentOffset: 13)),
);
});
......@@ -624,4 +625,237 @@ void main() {
// cursor must be now at fourth position (right after the number 9)
expect(formatted.selection.baseOffset, equals(4));
});
test('FilteringTextInputFormatter should filter independent of selection', () {
// Regression test for https://github.com/flutter/flutter/issues/80842.
final TextInputFormatter formatter = FilteringTextInputFormatter.deny('abc', replacementString: '*');
const TextEditingValue oldValue = TextEditingValue.empty;
const TextEditingValue newValue = TextEditingValue(text: 'abcabcabc');
final String filteredText = formatter.formatEditUpdate(oldValue, newValue).text;
for (int i = 0; i < newValue.text.length; i += 1) {
final String text = formatter.formatEditUpdate(
oldValue,
newValue.copyWith(selection: TextSelection.collapsed(offset: i)),
).text;
expect(filteredText, text);
}
});
test('FilteringTextInputFormatter should filter independent of composingRegion', () {
final TextInputFormatter formatter = FilteringTextInputFormatter.deny('abc', replacementString: '*');
const TextEditingValue oldValue = TextEditingValue.empty;
const TextEditingValue newValue = TextEditingValue(text: 'abcabcabc');
final String filteredText = formatter.formatEditUpdate(oldValue, newValue).text;
for (int i = 0; i < newValue.text.length; i += 1) {
final String text = formatter.formatEditUpdate(
oldValue,
newValue.copyWith(composing: TextRange.collapsed(i)),
).text;
expect(filteredText, text);
}
});
group('FilteringTextInputFormatter region', () {
const TextEditingValue oldValue = TextEditingValue.empty;
test('Preserves selection region', () {
const TextEditingValue newValue = TextEditingValue(text: 'AAABBBCCC');
// AAA | BBB | CCC => AAA | **** | CCC
expect(
FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate(
oldValue,
newValue.copyWith(
selection: const TextSelection(baseOffset: 6, extentOffset: 3),
),
).selection,
const TextSelection(baseOffset: 7, extentOffset: 3),
);
// AAA | BBB CCC | => AAA | **** CCC |
expect(
FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate(
oldValue,
newValue.copyWith(
selection: const TextSelection(baseOffset: 9, extentOffset: 3),
),
).selection,
const TextSelection(baseOffset: 10, extentOffset: 3),
);
// AAA BBB | CCC | => AAA **** | CCC |
expect(
FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate(
oldValue,
newValue.copyWith(
selection: const TextSelection(baseOffset: 9, extentOffset: 6),
),
).selection,
const TextSelection(baseOffset: 10, extentOffset: 7),
);
// AAAB | B | BCCC => AAA***|CCC
// Same length replacement, keep the selection at where it is.
expect(
FilteringTextInputFormatter.deny('BBB', replacementString: '***').formatEditUpdate(
oldValue,
newValue.copyWith(
selection: const TextSelection(baseOffset: 5, extentOffset: 4),
),
).selection,
const TextSelection(baseOffset: 5, extentOffset: 4),
);
// AAA | BBB | CCC => AAA | CCC
expect(
FilteringTextInputFormatter.deny('BBB', replacementString: '').formatEditUpdate(
oldValue,
newValue.copyWith(
selection: const TextSelection(baseOffset: 6, extentOffset: 3),
),
).selection,
const TextSelection(baseOffset: 3, extentOffset: 3),
);
expect(
FilteringTextInputFormatter.deny('BBB', replacementString: '').formatEditUpdate(
oldValue,
newValue.copyWith(
selection: const TextSelection(baseOffset: 6, extentOffset: 3),
),
).selection,
const TextSelection(baseOffset: 3, extentOffset: 3),
);
// The unfortunate case, we don't know for sure where to put the selection
// so put it after the replacement string.
// AAAB|B|BCCC => AAA****|CCC
expect(
FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate(
oldValue,
newValue.copyWith(
selection: const TextSelection(baseOffset: 5, extentOffset: 4),
),
).selection,
const TextSelection(baseOffset: 7, extentOffset: 7),
);
});
test('Preserves selection region, allow', () {
const TextEditingValue newValue = TextEditingValue(text: 'AAABBBCCC');
// AAA | BBB | CCC => **** | BBB | ****
expect(
FilteringTextInputFormatter.allow('BBB', replacementString: '****').formatEditUpdate(
oldValue,
newValue.copyWith(
selection: const TextSelection(baseOffset: 6, extentOffset: 3),
),
).selection,
const TextSelection(baseOffset: 7, extentOffset: 4),
);
// | AAABBBCCC | => | ****BBB**** |
expect(
FilteringTextInputFormatter.allow('BBB', replacementString: '****').formatEditUpdate(
oldValue,
newValue.copyWith(
selection: const TextSelection(baseOffset: 9, extentOffset: 0),
),
).selection,
const TextSelection(baseOffset: 11, extentOffset: 0),
);
// AAABBB | CCC | => ****BBB | **** |
expect(
FilteringTextInputFormatter.allow('BBB', replacementString: '****').formatEditUpdate(
oldValue,
newValue.copyWith(
selection: const TextSelection(baseOffset: 9, extentOffset: 6),
),
).selection,
const TextSelection(baseOffset: 11, extentOffset: 7),
);
// Overlapping matches: AAA | BBBBB | CCC => | BBB |
expect(
FilteringTextInputFormatter.allow('BBB', replacementString: '').formatEditUpdate(
oldValue,
const TextEditingValue(
text: 'AAABBBBBCCC',
selection: TextSelection(baseOffset: 8, extentOffset: 3),
),
).selection,
const TextSelection(baseOffset: 3, extentOffset: 0),
);
});
test('Preserves composing region', () {
const TextEditingValue newValue = TextEditingValue(text: 'AAABBBCCC');
// AAA | BBB | CCC => AAA | **** | CCC
expect(
FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate(
oldValue,
newValue.copyWith(
composing: const TextRange(start: 3, end: 6),
),
).composing,
const TextRange(start: 3, end: 7),
);
// AAA | BBB CCC | => AAA | **** CCC |
expect(
FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate(
oldValue,
newValue.copyWith(
composing: const TextRange(start: 3, end: 9),
),
).composing,
const TextRange(start: 3, end: 10),
);
// AAA BBB | CCC | => AAA **** | CCC |
expect(
FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate(
oldValue,
newValue.copyWith(
composing: const TextRange(start: 6, end: 9),
),
).composing,
const TextRange(start: 7, end: 10),
);
// AAAB | B | BCCC => AAA*** | CCC
// Same length replacement, don't move the composing region.
expect(
FilteringTextInputFormatter.deny('BBB', replacementString: '***').formatEditUpdate(
oldValue,
newValue.copyWith(
composing: const TextRange(start: 4, end: 5),
),
).composing,
const TextRange(start: 4, end: 5),
);
// AAA | BBB | CCC => | AAA CCC
expect(
FilteringTextInputFormatter.deny('BBB', replacementString: '').formatEditUpdate(
oldValue,
newValue.copyWith(
composing: const TextRange(start: 3, end: 6),
),
).composing,
TextRange.empty,
);
});
});
}
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