Unverified Commit 7684f8b7 authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Reland "Make FilteringTextInputFormatter's filtering Selection/Composing...

Reland "Make FilteringTextInputFormatter's filtering Selection/Composing Region agnostic" #89327 (#90211)
parent ab51a026
......@@ -773,9 +773,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.
......
......@@ -2375,10 +2375,19 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
final bool selectionChanged = _value.selection != value.selection;
if (textChanged) {
try {
value = widget.inputFormatters?.fold<TextEditingValue>(
value,
(TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(_value, newValue),
) ?? value;
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'widgets',
context: ErrorDescription('while applying input formatters'),
));
}
}
// Put all optional user callback invocations in a batch edit to prevent
......
......@@ -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,247 @@ 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);
}
});
test('FilteringTextInputFormatter basic filtering test', () {
final RegExp filter = RegExp('[A-Za-z0-9.@-]*');
final TextInputFormatter formatter = FilteringTextInputFormatter.allow(filter);
const TextEditingValue oldValue = TextEditingValue.empty;
const TextEditingValue newValue = TextEditingValue(text: 'ab&&ca@bcabc');
expect(formatter.formatEditUpdate(oldValue, newValue).text, 'abca@bcabc');
});
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,
);
});
});
}
......@@ -7750,6 +7750,39 @@ void main() {
expect(error, isFlutterError);
expect(error.toString(), contains(errorText));
});
testWidgets('input formatters can throw errors', (WidgetTester tester) async {
final TextInputFormatter badFormatter = TextInputFormatter.withFunction(
(TextEditingValue oldValue, TextEditingValue newValue) => throw FlutterError(errorText),
);
final TextEditingController controller = TextEditingController(
text: 'flutter is the best!',
);
await tester.pumpWidget(MaterialApp(
home: EditableText(
showSelectionHandles: true,
maxLines: 2,
controller: controller,
inputFormatters: <TextInputFormatter>[badFormatter],
focusNode: FocusNode(),
cursorColor: Colors.red,
backgroundCursorColor: Colors.blue,
style: Typography.material2018(platform: TargetPlatform.android).black.subtitle1!.copyWith(fontFamily: 'Roboto'),
keyboardType: TextInputType.text,
),
));
// Interact with the field to establish the input connection.
await tester.tap(find.byType(EditableText));
await tester.pump();
await tester.enterText(find.byType(EditableText), 'text');
final dynamic error = tester.takeException();
expect(error, isFlutterError);
expect(error.toString(), contains(errorText));
expect(controller.text, 'text');
});
});
// Regression test for https://github.com/flutter/flutter/issues/72400.
......
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