77import 'dart:math' as math;
88import 'dart:ui' as ui show TextBox, lerpDouble, BoxHeightStyle, BoxWidthStyle;
99
10+ import 'package:characters/characters.dart' ;
1011import 'package:flutter/foundation.dart' ;
1112import 'package:flutter/gestures.dart' ;
1213import '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),
0 commit comments