Skip to content

Commit e0ed12c

Browse files
authored
Characters Package (flutter#53381)
1 parent c5527dc commit e0ed12c

File tree

9 files changed

+737
-331
lines changed

9 files changed

+737
-331
lines changed

packages/flutter/lib/src/material/text_field.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle;
88

9+
import 'package:characters/characters.dart';
910
import 'package:flutter/cupertino.dart';
1011
import 'package:flutter/rendering.dart';
1112
import 'package:flutter/services.dart';
@@ -797,7 +798,7 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe
797798

798799
bool get _isEnabled => widget.enabled ?? widget.decoration?.enabled ?? true;
799800

800-
int get _currentLength => _effectiveController.value.text.runes.length;
801+
int get _currentLength => _effectiveController.value.text.characters.length;
801802

802803
InputDecoration _getEffectiveDecoration() {
803804
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
@@ -851,7 +852,7 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe
851852
semanticCounterText = localizations.remainingTextFieldCharacterCount(remaining);
852853

853854
// Handle length exceeds maxLength
854-
if (_effectiveController.value.text.runes.length > widget.maxLength) {
855+
if (_effectiveController.value.text.characters.length > widget.maxLength) {
855856
return effectiveDecoration.copyWith(
856857
errorText: effectiveDecoration.errorText ?? '',
857858
counterStyle: effectiveDecoration.errorStyle

packages/flutter/lib/src/painting/text_painter.dart

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -600,15 +600,15 @@ class TextPainter {
600600

601601
// Complex glyphs can be represented by two or more UTF16 codepoints. This
602602
// checks if the value represents a UTF16 glyph by itself or is a 'surrogate'.
603-
bool _isUtf16Surrogate(int value) {
603+
static bool _isUtf16Surrogate(int value) {
604604
return value & 0xF800 == 0xD800;
605605
}
606606

607607
// Checks if the glyph is either [Unicode.RLM] or [Unicode.LRM]. These values take
608608
// up zero space and do not have valid bounding boxes around them.
609609
//
610610
// We do not directly use the [Unicode] constants since they are strings.
611-
bool _isUnicodeDirectionality(int value) {
611+
static bool _isUnicodeDirectionality(int value) {
612612
return value == 0x200F || value == 0x200E;
613613
}
614614

@@ -637,15 +637,13 @@ class TextPainter {
637637

638638
// Get the Rect of the cursor (in logical pixels) based off the near edge
639639
// of the character upstream from the given string offset.
640-
// TODO(garyq): Use actual extended grapheme cluster length instead of
641-
// an increasing cluster length amount to achieve deterministic performance.
642640
Rect _getRectFromUpstream(int offset, Rect caretPrototype) {
643641
final String flattenedText = _text.toPlainText(includePlaceholders: false);
644642
final int prevCodeUnit = _text.codeUnitAt(max(0, offset - 1));
645643
if (prevCodeUnit == null)
646644
return null;
647645

648-
// Check for multi-code-unit glyphs such as emojis or zero width joiner
646+
// Check for multi-code-unit glyphs such as emojis or zero width joiner.
649647
final bool needsSearch = _isUtf16Surrogate(prevCodeUnit) || _text.codeUnitAt(offset) == _zwjUtf16 || _isUnicodeDirectionality(prevCodeUnit);
650648
int graphemeClusterLength = needsSearch ? 2 : 1;
651649
List<TextBox> boxes = <TextBox>[];
@@ -688,8 +686,6 @@ class TextPainter {
688686

689687
// Get the Rect of the cursor (in logical pixels) based off the near edge
690688
// of the character downstream from the given string offset.
691-
// TODO(garyq): Use actual extended grapheme cluster length instead of
692-
// an increasing cluster length amount to achieve deterministic performance.
693689
Rect _getRectFromDownstream(int offset, Rect caretPrototype) {
694690
final String flattenedText = _text.toPlainText(includePlaceholders: false);
695691
// We cap the offset at the final index of the _text.

packages/flutter/lib/src/rendering/editable.dart

Lines changed: 81 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import 'dart:math' as math;
88
import 'dart:ui' as ui show TextBox, lerpDouble, BoxHeightStyle, BoxWidthStyle;
99

10+
import 'package:characters/characters.dart';
1011
import 'package:flutter/foundation.dart';
1112
import 'package:flutter/gestures.dart';
1213
import 'package:flutter/semantics.dart';
@@ -140,18 +141,6 @@ bool _isWhitespace(int codeUnit) {
140141
return true;
141142
}
142143

143-
/// Returns true if [codeUnit] is a leading (high) surrogate for a surrogate
144-
/// pair.
145-
bool _isLeadingSurrogate(int codeUnit) {
146-
return codeUnit & 0xFC00 == 0xD800;
147-
}
148-
149-
/// Returns true if [codeUnit] is a trailing (low) surrogate for a surrogate
150-
/// pair.
151-
bool _isTrailingSurrogate(int codeUnit) {
152-
return codeUnit & 0xFC00 == 0xDC00;
153-
}
154-
155144
/// Displays some text in a scrollable container with a potentially blinking
156145
/// cursor and with gesture recognizers.
157146
///
@@ -251,7 +240,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
251240
assert(ignorePointer != null),
252241
assert(textWidthBasis != null),
253242
assert(paintCursorAboveText != null),
254-
assert(obscuringCharacter != null && obscuringCharacter.length == 1),
243+
assert(obscuringCharacter != null && obscuringCharacter.characters.length == 1),
255244
assert(obscureText != null),
256245
assert(textSelectionDelegate != null),
257246
assert(cursorWidth != null && cursorWidth >= 0.0),
@@ -366,7 +355,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
366355
if (_obscuringCharacter == value) {
367356
return;
368357
}
369-
assert(value != null && value.length == 1);
358+
assert(value != null && value.characters.length == 1);
370359
_obscuringCharacter = value;
371360
markNeedsLayout();
372361
}
@@ -518,10 +507,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
518507
..._nonModifierKeys,
519508
};
520509

521-
// TODO(goderbauer): doesn't handle extended grapheme clusters with more than one Unicode scalar value (https://github.com/flutter/flutter/issues/13404).
522-
// This is because some of this code depends upon counting the length of the
523-
// string using Unicode scalar values, rather than using the number of
524-
// extended grapheme clusters (a.k.a. "characters" in the end user's mind).
525510
void _handleKeyEvent(RawKeyEvent keyEvent) {
526511
if(kIsWeb) {
527512
// On web platform, we should ignore the key because it's processed already.
@@ -557,6 +542,71 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
557542
}
558543
}
559544

545+
/// Returns the index into the string of the next character boundary after the
546+
/// given index.
547+
///
548+
/// The character boundary is determined by the characters package, so
549+
/// surrogate pairs and extended grapheme clusters are considered.
550+
///
551+
/// The index must be between 0 and string.length, inclusive. If given
552+
/// string.length, string.length is returned.
553+
///
554+
/// Setting includeWhitespace to false will only return the index of non-space
555+
/// characters.
556+
@visibleForTesting
557+
static int nextCharacter(int index, String string, [bool includeWhitespace = true]) {
558+
assert(index >= 0 && index <= string.length);
559+
if (index == string.length) {
560+
return string.length;
561+
}
562+
563+
int count = 0;
564+
final Characters remaining = string.characters.skipWhile((String currentString) {
565+
if (count <= index) {
566+
count += currentString.length;
567+
return true;
568+
}
569+
if (includeWhitespace) {
570+
return false;
571+
}
572+
return _isWhitespace(currentString.characters.first.toString().codeUnitAt(0));
573+
});
574+
return string.length - remaining.toString().length;
575+
}
576+
577+
/// Returns the index into the string of the previous character boundary
578+
/// before the given index.
579+
///
580+
/// The character boundary is determined by the characters package, so
581+
/// surrogate pairs and extended grapheme clusters are considered.
582+
///
583+
/// The index must be between 0 and string.length, inclusive. If index is 0,
584+
/// 0 will be returned.
585+
///
586+
/// Setting includeWhitespace to false will only return the index of non-space
587+
/// characters.
588+
@visibleForTesting
589+
static int previousCharacter(int index, String string, [bool includeWhitespace = true]) {
590+
assert(index >= 0 && index <= string.length);
591+
if (index == 0) {
592+
return 0;
593+
}
594+
595+
int count = 0;
596+
int lastNonWhitespace;
597+
for (final String currentString in string.characters) {
598+
if (!includeWhitespace &&
599+
!_isWhitespace(currentString.characters.first.toString().codeUnitAt(0))) {
600+
lastNonWhitespace = count;
601+
}
602+
if (count + currentString.length >= index) {
603+
return includeWhitespace ? count : lastNonWhitespace ?? 0;
604+
}
605+
count += currentString.length;
606+
}
607+
return 0;
608+
}
609+
560610
void _handleMovement(
561611
LogicalKeyboardKey key, {
562612
@required bool wordModifier,
@@ -575,23 +625,6 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
575625
final bool upArrow = key == LogicalKeyboardKey.arrowUp;
576626
final bool downArrow = key == LogicalKeyboardKey.arrowDown;
577627

578-
// Find the previous non-whitespace character
579-
int previousNonWhitespace(int extent) {
580-
int result = math.max(extent - 1, 0);
581-
while (result > 0 && _isWhitespace(_plainText.codeUnitAt(result))) {
582-
result -= 1;
583-
}
584-
return result;
585-
}
586-
587-
int nextNonWhitespace(int extent) {
588-
int result = math.min(extent + 1, _plainText.length);
589-
while (result < _plainText.length && _isWhitespace(_plainText.codeUnitAt(result))) {
590-
result += 1;
591-
}
592-
return result;
593-
}
594-
595628
if ((rightArrow || leftArrow) && !(rightArrow && leftArrow)) {
596629
// Jump to begin/end of word.
597630
if (wordModifier) {
@@ -602,15 +635,15 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
602635
// so we go back to the first non-whitespace before asking for the word
603636
// boundary, since _selectWordAtOffset finds the word boundaries without
604637
// including whitespace.
605-
final int startPoint = previousNonWhitespace(newSelection.extentOffset);
638+
final int startPoint = previousCharacter(newSelection.extentOffset, _plainText, false);
606639
final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: startPoint));
607640
newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset);
608641
} else {
609642
// When going right, we want to skip over any whitespace after the word,
610643
// so we go forward to the first non-whitespace character before asking
611644
// for the word bounds, since _selectWordAtOffset finds the word
612645
// boundaries without including whitespace.
613-
final int startPoint = nextNonWhitespace(newSelection.extentOffset);
646+
final int startPoint = nextCharacter(newSelection.extentOffset, _plainText, false);
614647
final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: startPoint));
615648
newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset);
616649
}
@@ -622,30 +655,32 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
622655
// so we go back to the first non-whitespace before asking for the line
623656
// bounds, since _selectLineAtOffset finds the line boundaries without
624657
// including whitespace (like the newline).
625-
final int startPoint = previousNonWhitespace(newSelection.extentOffset);
658+
final int startPoint = previousCharacter(newSelection.extentOffset, _plainText, false);
626659
final TextSelection textSelection = _selectLineAtOffset(TextPosition(offset: startPoint));
627660
newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset);
628661
} else {
629662
// When going right, we want to skip over any whitespace after the line,
630663
// so we go forward to the first non-whitespace character before asking
631664
// for the line bounds, since _selectLineAtOffset finds the line
632665
// boundaries without including whitespace (like the newline).
633-
final int startPoint = nextNonWhitespace(newSelection.extentOffset);
666+
final int startPoint = nextCharacter(newSelection.extentOffset, _plainText, false);
634667
final TextSelection textSelection = _selectLineAtOffset(TextPosition(offset: startPoint));
635668
newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset);
636669
}
637670
} else {
638671
if (rightArrow && newSelection.extentOffset < _plainText.length) {
639-
final int delta = _isLeadingSurrogate(text.codeUnitAt(newSelection.extentOffset)) ? 2 : 1;
640-
newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset + delta);
672+
final int nextExtent = nextCharacter(newSelection.extentOffset, _plainText);
673+
final int distance = nextExtent - newSelection.extentOffset;
674+
newSelection = newSelection.copyWith(extentOffset: nextExtent);
641675
if (shift) {
642-
_cursorResetLocation += 1;
676+
_cursorResetLocation += distance;
643677
}
644678
} else if (leftArrow && newSelection.extentOffset > 0) {
645-
final int delta = _isTrailingSurrogate(text.codeUnitAt(newSelection.extentOffset - 1)) ? 2 : 1;
646-
newSelection = newSelection.copyWith(extentOffset: newSelection.extentOffset - delta);
679+
final int previousExtent = previousCharacter(newSelection.extentOffset, _plainText);
680+
final int distance = newSelection.extentOffset - previousExtent;
681+
newSelection = newSelection.copyWith(extentOffset: previousExtent);
647682
if (shift) {
648-
_cursorResetLocation -= 1;
683+
_cursorResetLocation -= distance;
649684
}
650685
}
651686
}
@@ -763,7 +798,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin {
763798
void _handleDelete() {
764799
final String textAfter = selection.textAfter(_plainText);
765800
if (textAfter.isNotEmpty) {
766-
final int deleteCount = _isLeadingSurrogate(textAfter.codeUnitAt(0)) ? 2 : 1;
801+
final int deleteCount = nextCharacter(0, textAfter);
767802
textSelectionDelegate.textEditingValue = TextEditingValue(
768803
text: selection.textBefore(_plainText)
769804
+ selection.textAfter(_plainText).substring(deleteCount),

packages/flutter/lib/src/services/text_formatter.dart

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import 'dart:math' as math;
88

9+
import 'package:characters/characters.dart';
910
import 'package:flutter/foundation.dart' show visibleForTesting;
1011
import 'text_editing.dart';
1112
import 'text_input.dart';
@@ -169,24 +170,24 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter {
169170
/// characters.
170171
final int maxLength;
171172

172-
// TODO(justinmc): This should be updated to use characters instead of runes,
173-
// see the comment in formatEditUpdate.
174-
/// Truncate the given TextEditingValue to maxLength runes.
173+
/// Truncate the given TextEditingValue to maxLength characters.
174+
///
175+
/// See also:
176+
/// * [Dart's characters package](https://pub.dev/packages/characters).
177+
/// * [Dart's documenetation on runes and grapheme clusters](https://dart.dev/guides/language/language-tour#runes-and-grapheme-clusters).
175178
@visibleForTesting
176179
static TextEditingValue truncate(TextEditingValue value, int maxLength) {
177-
final TextSelection newSelection = value.selection.copyWith(
178-
baseOffset: math.min(value.selection.start, maxLength),
179-
extentOffset: math.min(value.selection.end, maxLength),
180-
);
181-
final RuneIterator iterator = RuneIterator(value.text);
182-
if (iterator.moveNext())
183-
for (int count = 0; count < maxLength; ++count)
184-
if (!iterator.moveNext())
185-
break;
186-
final String truncated = value.text.substring(0, iterator.rawIndex);
180+
final CharacterRange iterator = CharacterRange(value.text);
181+
if (value.text.characters.length > maxLength) {
182+
iterator.expandNext(maxLength);
183+
}
184+
final String truncated = iterator.current;
187185
return TextEditingValue(
188186
text: truncated,
189-
selection: newSelection,
187+
selection: value.selection.copyWith(
188+
baseOffset: math.min(value.selection.start, truncated.length),
189+
extentOffset: math.min(value.selection.end, truncated.length),
190+
),
190191
composing: TextRange.empty,
191192
);
192193
}
@@ -196,18 +197,10 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter {
196197
TextEditingValue oldValue, // unused.
197198
TextEditingValue newValue,
198199
) {
199-
// This does not count grapheme clusters (i.e. characters visible to the user),
200-
// it counts Unicode runes, which leaves out a number of useful possible
201-
// characters (like many emoji), so this will be inaccurate in the
202-
// presence of those characters. The Dart lang bug
203-
// https://github.com/dart-lang/sdk/issues/28404 has been filed to
204-
// address this in Dart.
205-
// TODO(justinmc): convert this to count actual characters using Dart's
206-
// characters package (https://pub.dev/packages/characters).
207-
if (maxLength != null && maxLength > 0 && newValue.text.runes.length > maxLength) {
200+
if (maxLength != null && maxLength > 0 && newValue.text.characters.length > maxLength) {
208201
// If already at the maximum and tried to enter even more, keep the old
209202
// value.
210-
if (oldValue.text.runes.length == maxLength) {
203+
if (oldValue.text.characters.length == maxLength) {
211204
return oldValue;
212205
}
213206
return truncate(newValue, maxLength);

0 commit comments

Comments
 (0)