@@ -121,6 +121,94 @@ class _SimpleTextInputFormatter extends TextInputFormatter {
121
121
}
122
122
}
123
123
124
+ // A mutable, half-open range [`base`, `extent`) within a string.
125
+ class _MutableTextRange {
126
+ _MutableTextRange (this .base , this .extent);
127
+
128
+ static _MutableTextRange ? fromComposingRange (TextRange range) {
129
+ return range.isValid && ! range.isCollapsed
130
+ ? _MutableTextRange (range.start, range.end)
131
+ : null ;
132
+ }
133
+
134
+ static _MutableTextRange ? fromTextSelection (TextSelection selection) {
135
+ return selection.isValid
136
+ ? _MutableTextRange (selection.baseOffset, selection.extentOffset)
137
+ : null ;
138
+ }
139
+
140
+ /// The start index of the range, inclusive.
141
+ ///
142
+ /// The value of [base] should always be greater than or equal to 0, and can
143
+ /// be larger than, smaller than, or equal to [extent] .
144
+ int base ;
145
+
146
+ /// The end index of the range, exclusive.
147
+ ///
148
+ /// The value of [extent] should always be greater than or equal to 0, and can
149
+ /// be larger than, smaller than, or equal to [base] .
150
+ int extent;
151
+ }
152
+
153
+ // The intermediate state of a [FilteringTextInputFormatter] when it's
154
+ // formatting a new user input.
155
+ class _TextEditingValueAccumulator {
156
+ _TextEditingValueAccumulator (this .inputValue)
157
+ : selection = _MutableTextRange .fromTextSelection (inputValue.selection),
158
+ composingRegion = _MutableTextRange .fromComposingRange (inputValue.composing);
159
+
160
+ // The original string that was sent to the [FilteringTextInputFormatter] as
161
+ // input.
162
+ final TextEditingValue inputValue;
163
+
164
+ /// The [StringBuffer] that contains the string which has already been
165
+ /// formatted.
166
+ ///
167
+ /// In a [FilteringTextInputFormatter] , typically the replacement string,
168
+ /// instead of the original string within the given range, is written to this
169
+ /// [StringBuffer] .
170
+ final StringBuffer stringBuffer = StringBuffer ();
171
+
172
+ /// The updated selection, as well as the original selection from the input
173
+ /// [TextEditingValue] of the [FilteringTextInputFormatter] .
174
+ ///
175
+ /// This parameter will be null if the input [TextEditingValue.selection] is
176
+ /// invalid.
177
+ final _MutableTextRange ? selection;
178
+
179
+ /// The updated composing region, as well as the original composing region
180
+ /// from the input [TextEditingValue] of the [FilteringTextInputFormatter] .
181
+ ///
182
+ /// This parameter will be null if the input [TextEditingValue.composing] is
183
+ /// invalid or collapsed.
184
+ final _MutableTextRange ? composingRegion;
185
+
186
+ // Whether this state object has reached its end-of-life.
187
+ bool debugFinalized = false ;
188
+
189
+ TextEditingValue finalize () {
190
+ debugFinalized = true ;
191
+ final _MutableTextRange ? selection = this .selection;
192
+ final _MutableTextRange ? composingRegion = this .composingRegion;
193
+ return TextEditingValue (
194
+ text: stringBuffer.toString (),
195
+ composing: composingRegion == null || composingRegion.base == composingRegion.extent
196
+ ? TextRange .empty
197
+ : TextRange (start: composingRegion.base , end: composingRegion.extent),
198
+ selection: selection == null
199
+ ? const TextSelection .collapsed (offset: - 1 )
200
+ : TextSelection (
201
+ baseOffset: selection.base ,
202
+ extentOffset: selection.extent,
203
+ // Try to preserve the selection affinity and isDirectional. This
204
+ // may not make sense if the selection has changed.
205
+ affinity: inputValue.selection.affinity,
206
+ isDirectional: inputValue.selection.isDirectional,
207
+ ),
208
+ );
209
+ }
210
+ }
211
+
124
212
/// A [TextInputFormatter] that prevents the insertion of characters
125
213
/// matching (or not matching) a particular pattern.
126
214
///
@@ -159,33 +247,26 @@ class FilteringTextInputFormatter extends TextInputFormatter {
159
247
/// The [filterPattern] and [replacementString] arguments
160
248
/// must not be null.
161
249
FilteringTextInputFormatter .allow (
162
- this .filterPattern, {
163
- this .replacementString = '' ,
164
- }) : assert (filterPattern != null ),
165
- assert (replacementString != null ),
166
- allow = true ;
250
+ Pattern filterPattern, {
251
+ String replacementString = '' ,
252
+ }) : this (filterPattern, allow: true , replacementString: replacementString);
167
253
168
254
/// Creates a formatter that blocks characters matching a pattern.
169
255
///
170
256
/// The [filterPattern] and [replacementString] arguments
171
257
/// must not be null.
172
258
FilteringTextInputFormatter .deny (
173
- this .filterPattern, {
174
- this .replacementString = '' ,
175
- }) : assert (filterPattern != null ),
176
- assert (replacementString != null ),
177
- allow = false ;
259
+ Pattern filterPattern, {
260
+ String replacementString = '' ,
261
+ }) : this (filterPattern, allow: false , replacementString: replacementString);
178
262
179
- /// A [Pattern] to match and replace in incoming [TextEditingValue] s.
263
+ /// A [Pattern] to match or replace in incoming [TextEditingValue] s.
180
264
///
181
265
/// The behavior of the pattern depends on the [allow] property. If
182
266
/// it is true, then this is an allow list, specifying a pattern that
183
267
/// characters must match to be accepted. Otherwise, it is a deny list,
184
268
/// specifying a pattern that characters must not match to be accepted.
185
269
///
186
- /// In general, the pattern should only match one character at a
187
- /// time. See the discussion at [replacementString] .
188
- ///
189
270
/// {@tool snippet}
190
271
/// Typically the pattern is a regular expression, as in:
191
272
///
@@ -246,33 +327,78 @@ class FilteringTextInputFormatter extends TextInputFormatter {
246
327
/// string) because both of the "o"s would be matched simultaneously
247
328
/// by the pattern.
248
329
///
249
- /// Additionally, each segment of the string before, during, and
250
- /// after the current selection in the [TextEditingValue] is handled
251
- /// separately. This means that, in the case of the "Into the Woods"
252
- /// example above, if the selection ended between the two "o"s in
253
- /// "Woods", even if the pattern was `RegExp('o+')` , the result
254
- /// would be "Int* the W**ds", since the two "o"s would be handled
255
- /// in separate passes.
256
- ///
257
- /// See also [String.splitMapJoin] , which is used to implement this
258
- /// behavior in both cases.
330
+ /// The filter may adjust the selection and the composing region of the text
331
+ /// after applying the text replacement, such that they still cover the same
332
+ /// text. For instance, if the pattern was `o+` and the last character "s" was
333
+ /// selected: "Into The Wood|s|", then the result will be "Into The W*d|s|",
334
+ /// with the selection still around the same character "s" despite that it is
335
+ /// now the 12th character.
336
+ ///
337
+ /// In the case where one end point of the selection (or the composing region)
338
+ /// is strictly inside the banned pattern (for example, "Into The |Wo|ods"),
339
+ /// that endpoint will be moved to the end of the replacement string (it will
340
+ /// become "Into The |W*|ds" if the pattern was `o+` and the original text and
341
+ /// selection were "Into The |Wo|ods").
259
342
final String replacementString;
260
343
261
344
@override
262
345
TextEditingValue formatEditUpdate (
263
346
TextEditingValue oldValue, // unused.
264
347
TextEditingValue newValue,
265
348
) {
266
- return _selectionAwareTextManipulation (
267
- newValue,
268
- (String substring) {
269
- return substring.splitMapJoin (
270
- filterPattern,
271
- onMatch: ! allow ? (Match match) => replacementString : null ,
272
- onNonMatch: allow ? (String nonMatch) => nonMatch.isNotEmpty ? replacementString : '' : null ,
273
- );
274
- },
275
- );
349
+ final _TextEditingValueAccumulator formatState = _TextEditingValueAccumulator (newValue);
350
+ assert (! formatState.debugFinalized);
351
+
352
+ final Iterable <Match > matches = filterPattern.allMatches (newValue.text);
353
+ Match ? previousMatch;
354
+ for (final Match match in matches) {
355
+ assert (match.end >= match.start);
356
+ // Compute the non-match region between this `Match` and the previous
357
+ // `Match`. Depending on the value of `allow`, either the match region or
358
+ // the non-match region is the banned pattern.
359
+ //
360
+ // The non-matching region.
361
+ _processRegion (allow, previousMatch? .end ?? 0 , match.start, formatState);
362
+ assert (! formatState.debugFinalized);
363
+ // The matched region.
364
+ _processRegion (! allow, match.start, match.end, formatState);
365
+ assert (! formatState.debugFinalized);
366
+
367
+ previousMatch = match;
368
+ }
369
+
370
+ // Handle the last non-matching region between the last match region and the
371
+ // end of the text.
372
+ _processRegion (allow, previousMatch? .end ?? 0 , newValue.text.length, formatState);
373
+ assert (! formatState.debugFinalized);
374
+ return formatState.finalize ();
375
+ }
376
+
377
+ void _processRegion (bool isBannedRegion, int regionStart, int regionEnd, _TextEditingValueAccumulator state) {
378
+ final String replacementString = isBannedRegion
379
+ ? (regionStart == regionEnd ? '' : this .replacementString)
380
+ : state.inputValue.text.substring (regionStart, regionEnd);
381
+
382
+ state.stringBuffer.write (replacementString);
383
+
384
+ if (replacementString.length == regionEnd - regionStart) {
385
+ // We don't have to adjust the indices if the replaced string and the
386
+ // replacement string have the same length.
387
+ return ;
388
+ }
389
+
390
+ int adjustIndex (int originalIndex) {
391
+ // The length added by adding the replacementString.
392
+ final int replacedLength = originalIndex <= regionStart && originalIndex < regionEnd ? 0 : replacementString.length;
393
+ // The length removed by removing the replacementRange.
394
+ final int removedLength = originalIndex.clamp (regionStart, regionEnd) - regionStart;
395
+ return replacedLength - removedLength;
396
+ }
397
+
398
+ state.selection? .base += adjustIndex (state.inputValue.selection.baseOffset);
399
+ state.selection? .extent += adjustIndex (state.inputValue.selection.extentOffset);
400
+ state.composingRegion? .base += adjustIndex (state.inputValue.composing.start);
401
+ state.composingRegion? .extent += adjustIndex (state.inputValue.composing.end);
276
402
}
277
403
278
404
/// A [TextInputFormatter] that forces input to be a single line.
@@ -527,45 +653,3 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter {
527
653
}
528
654
}
529
655
}
530
-
531
- TextEditingValue _selectionAwareTextManipulation (
532
- TextEditingValue value,
533
- String Function (String substring) substringManipulation,
534
- ) {
535
- final int selectionStartIndex = value.selection.start;
536
- final int selectionEndIndex = value.selection.end;
537
- String manipulatedText;
538
- TextSelection ? manipulatedSelection;
539
- if (selectionStartIndex < 0 || selectionEndIndex < 0 ) {
540
- manipulatedText = substringManipulation (value.text);
541
- } else {
542
- final String beforeSelection = substringManipulation (
543
- value.text.substring (0 , selectionStartIndex),
544
- );
545
- final String inSelection = substringManipulation (
546
- value.text.substring (selectionStartIndex, selectionEndIndex),
547
- );
548
- final String afterSelection = substringManipulation (
549
- value.text.substring (selectionEndIndex),
550
- );
551
- manipulatedText = beforeSelection + inSelection + afterSelection;
552
- if (value.selection.baseOffset > value.selection.extentOffset) {
553
- manipulatedSelection = value.selection.copyWith (
554
- baseOffset: beforeSelection.length + inSelection.length,
555
- extentOffset: beforeSelection.length,
556
- );
557
- } else {
558
- manipulatedSelection = value.selection.copyWith (
559
- baseOffset: beforeSelection.length,
560
- extentOffset: beforeSelection.length + inSelection.length,
561
- );
562
- }
563
- }
564
- return TextEditingValue (
565
- text: manipulatedText,
566
- selection: manipulatedSelection ?? const TextSelection .collapsed (offset: - 1 ),
567
- composing: manipulatedText == value.text
568
- ? value.composing
569
- : TextRange .empty,
570
- );
571
- }
0 commit comments