diff --git a/packages/flutter/lib/src/widgets/spell_check.dart b/packages/flutter/lib/src/widgets/spell_check.dart index 32f77478ab3..a6fb8887e29 100644 --- a/packages/flutter/lib/src/widgets/spell_check.dart +++ b/packages/flutter/lib/src/widgets/spell_check.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform; import 'package:flutter/painting.dart'; import 'package:flutter/services.dart' show SpellCheckResults, SpellCheckService, SuggestionSpan, TextEditingValue; @@ -108,71 +109,63 @@ class SpellCheckConfiguration { List _correctSpellCheckResults( String newText, String resultsText, List results) { final List correctedSpellCheckResults = []; - int spanPointer = 0; int offset = 0; - int foundIndex; - int spanLength; - SuggestionSpan currentSpan; - SuggestionSpan adjustedSpan; - String currentSpanText; - String newSpanText = ''; - bool currentSpanValid = false; - RegExp regex; // Assumes that the order of spans has not been jumbled for optimization // purposes, and will only search since the previously found span. int searchStart = 0; while (spanPointer < results.length) { - // Try finding SuggestionSpan from old results (currentSpan) in new text. - currentSpan = results[spanPointer]; - currentSpanText = + final SuggestionSpan currentSpan = results[spanPointer]; + final String currentSpanText = resultsText.substring(currentSpan.range.start, currentSpan.range.end); + final int spanLength = currentSpan.range.end - currentSpan.range.start; - try { - // currentSpan was found and can be applied to new text. - newSpanText = newText.substring( - currentSpan.range.start + offset, currentSpan.range.end + offset); - currentSpanValid = true; - } catch (e) { - // currentSpan is invalid and needs to be searched for in newText. - currentSpanValid = false; - } + // Try finding SuggestionSpan from resultsText in new text. + final RegExp currentSpanTextRegexp = RegExp('\\b$currentSpanText\\b'); + final int foundIndex = newText.substring(searchStart).indexOf(currentSpanTextRegexp); - if (currentSpanValid && newSpanText == currentSpanText) { - // currentSpan was found at the same index in new text and old text - // (resultsText), so apply it to new text by adding it to the list of + // Check whether word was found exactly where expected or elsewhere in the newText. + final bool currentSpanFoundExactly = currentSpan.range.start == foundIndex + searchStart; + final bool currentSpanFoundExactlyWithOffset = currentSpan.range.start + offset == foundIndex + searchStart; + final bool currentSpanFoundElsewhere = foundIndex >= 0; + + if (currentSpanFoundExactly || currentSpanFoundExactlyWithOffset) { + // currentSpan was found at the same index in newText and resutsText + // or at the same index with the previously calculated adjustment by + // the offset value, so apply it to new text by adding it to the list of // corrected results. - searchStart = currentSpan.range.end + offset; - adjustedSpan = SuggestionSpan( - TextRange( - start: currentSpan.range.start + offset, end: searchStart), - currentSpan.suggestions + final SuggestionSpan adjustedSpan = SuggestionSpan( + TextRange( + start: currentSpan.range.start + offset, + end: currentSpan.range.end + offset, + ), + currentSpan.suggestions, ); + + // Start search for the next misspelled word at the end of currentSpan. + searchStart = currentSpan.range.end + 1 + offset; correctedSpellCheckResults.add(adjustedSpan); - } else { - // Search for currentSpan in new text and if found, apply it to new text - // by adding it to the list of corrected results. - regex = RegExp('\\b$currentSpanText\\b'); - foundIndex = newText.substring(searchStart).indexOf(regex); + } else if (currentSpanFoundElsewhere) { + // Word was pushed forward but not modified. + final int adjustedSpanStart = searchStart + foundIndex; + final int adjustedSpanEnd = adjustedSpanStart + spanLength; + final SuggestionSpan adjustedSpan = SuggestionSpan( + TextRange(start: adjustedSpanStart, end: adjustedSpanEnd), + currentSpan.suggestions, + ); - if (foundIndex >= 0) { - foundIndex += searchStart; - spanLength = currentSpan.range.end - currentSpan.range.start; - searchStart = foundIndex + spanLength; - adjustedSpan = SuggestionSpan( - TextRange(start: foundIndex, end: searchStart), - currentSpan.suggestions - ); - offset = foundIndex - currentSpan.range.start; - - correctedSpellCheckResults.add(adjustedSpan); - } + // Start search for the next misspelled word at the end of the + // adjusted currentSpan. + searchStart = adjustedSpanEnd + 1; + // Adjust offset to reflect the difference between where currentSpan + // was positioned in resultsText versus in newText. + offset = adjustedSpanStart - currentSpan.range.start; + correctedSpellCheckResults.add(adjustedSpan); } spanPointer++; } - return correctedSpellCheckResults; } @@ -201,31 +194,121 @@ TextSpan buildTextSpanWithSpellCheckSuggestions( value.text, spellCheckResultsText, spellCheckResultsSpans); } - return TextSpan( + // We will draw the TextSpan tree based on the composing region, if it is + // available. + // TODO(camsim99): The two separate stratgies for building TextSpan trees + // based on the availability of a composing region should be merged: + // https://github.com/flutter/flutter/issues/124142. + final bool shouldConsiderComposingRegion = defaultTargetPlatform == TargetPlatform.android; + if (shouldConsiderComposingRegion) { + return TextSpan( style: style, - children: _buildSubtreesWithMisspelledWordsIndicated( + children: _buildSubtreesWithComposingRegion( spellCheckResultsSpans, value, style, misspelledTextStyle, - composingWithinCurrentTextRange - ) + composingWithinCurrentTextRange, + ), ); + } + + return TextSpan( + style: style, + children: _buildSubtreesWithoutComposingRegion( + spellCheckResultsSpans, + value, + style, + misspelledTextStyle, + value.selection.baseOffset, + ), + ); } -/// Builds [TextSpan] subtree for text with misspelled words. -List _buildSubtreesWithMisspelledWordsIndicated( +/// Builds the [TextSpan] tree for spell check without considering the composing +/// region. Instead, uses the cursor to identify the word that's actively being +/// edited and shouldn't be spell checked. This is useful for platforms and IMEs +/// that don't use the composing region for the active word. +List _buildSubtreesWithoutComposingRegion( + List? spellCheckSuggestions, + TextEditingValue value, + TextStyle? style, + TextStyle misspelledStyle, + int cursorIndex, +) { + final List textSpanTreeChildren = []; + + int textPointer = 0; + int currentSpanPointer = 0; + int endIndex; + final String text = value.text; + final TextStyle misspelledJointStyle = + style?.merge(misspelledStyle) ?? misspelledStyle; + bool cursorInCurrentSpan = false; + + // Add text interwoven with any misspelled words to the tree. + if (spellCheckSuggestions != null) { + while (textPointer < text.length && + currentSpanPointer < spellCheckSuggestions.length) { + final SuggestionSpan currentSpan = spellCheckSuggestions[currentSpanPointer]; + + if (currentSpan.range.start > textPointer) { + endIndex = currentSpan.range.start < text.length + ? currentSpan.range.start + : text.length; + textSpanTreeChildren.add( + TextSpan( + style: style, + text: text.substring(textPointer, endIndex), + ) + ); + textPointer = endIndex; + } else { + endIndex = + currentSpan.range.end < text.length ? currentSpan.range.end : text.length; + cursorInCurrentSpan = currentSpan.range.start <= cursorIndex && currentSpan.range.end >= cursorIndex; + textSpanTreeChildren.add( + TextSpan( + style: cursorInCurrentSpan + ? style + : misspelledJointStyle, + text: text.substring(currentSpan.range.start, endIndex), + ) + ); + + textPointer = endIndex; + currentSpanPointer++; + } + } + } + + // Add any remaining text to the tree if applicable. + if (textPointer < text.length) { + textSpanTreeChildren.add( + TextSpan( + style: style, + text: text.substring(textPointer, text.length), + ) + ); + } + + return textSpanTreeChildren; +} + +/// Builds [TextSpan] subtree for text with misspelled words with logic based on +/// a valid composing region. +List _buildSubtreesWithComposingRegion( List? spellCheckSuggestions, TextEditingValue value, TextStyle? style, TextStyle misspelledStyle, bool composingWithinCurrentTextRange) { - final List tsTreeChildren = []; + final List textSpanTreeChildren = []; int textPointer = 0; - int currSpanPointer = 0; + int currentSpanPointer = 0; int endIndex; - SuggestionSpan currSpan; + SuggestionSpan currentSpan; final String text = value.text; final TextRange composingRegion = value.composing; final TextStyle composingTextStyle = @@ -234,17 +317,17 @@ List _buildSubtreesWithMisspelledWordsIndicated( final TextStyle misspelledJointStyle = style?.merge(misspelledStyle) ?? misspelledStyle; bool textPointerWithinComposingRegion = false; - bool currSpanIsComposingRegion = false; + bool currentSpanIsComposingRegion = false; // Add text interwoven with any misspelled words to the tree. if (spellCheckSuggestions != null) { while (textPointer < text.length && - currSpanPointer < spellCheckSuggestions.length) { - currSpan = spellCheckSuggestions[currSpanPointer]; + currentSpanPointer < spellCheckSuggestions.length) { + currentSpan = spellCheckSuggestions[currentSpanPointer]; - if (currSpan.range.start > textPointer) { - endIndex = currSpan.range.start < text.length - ? currSpan.range.start + if (currentSpan.range.start > textPointer) { + endIndex = currentSpan.range.start < text.length + ? currentSpan.range.start : text.length; textPointerWithinComposingRegion = composingRegion.start >= textPointer && @@ -252,19 +335,19 @@ List _buildSubtreesWithMisspelledWordsIndicated( !composingWithinCurrentTextRange; if (textPointerWithinComposingRegion) { - _addComposingRegionTextSpans(tsTreeChildren, text, textPointer, + _addComposingRegionTextSpans(textSpanTreeChildren, text, textPointer, composingRegion, style, composingTextStyle); - tsTreeChildren.add( + textSpanTreeChildren.add( TextSpan( style: style, - text: text.substring(composingRegion.end, endIndex) + text: text.substring(composingRegion.end, endIndex), ) ); } else { - tsTreeChildren.add( + textSpanTreeChildren.add( TextSpan( style: style, - text: text.substring(textPointer, endIndex) + text: text.substring(textPointer, endIndex), ) ); } @@ -272,21 +355,21 @@ List _buildSubtreesWithMisspelledWordsIndicated( textPointer = endIndex; } else { endIndex = - currSpan.range.end < text.length ? currSpan.range.end : text.length; - currSpanIsComposingRegion = textPointer >= composingRegion.start && + currentSpan.range.end < text.length ? currentSpan.range.end : text.length; + currentSpanIsComposingRegion = textPointer >= composingRegion.start && endIndex <= composingRegion.end && !composingWithinCurrentTextRange; - tsTreeChildren.add( + textSpanTreeChildren.add( TextSpan( - style: currSpanIsComposingRegion + style: currentSpanIsComposingRegion ? composingTextStyle : misspelledJointStyle, - text: text.substring(currSpan.range.start, endIndex) + text: text.substring(currentSpan.range.start, endIndex), ) ); textPointer = endIndex; - currSpanPointer++; + currentSpanPointer++; } } } @@ -295,27 +378,27 @@ List _buildSubtreesWithMisspelledWordsIndicated( if (textPointer < text.length) { if (textPointer < composingRegion.start && !composingWithinCurrentTextRange) { - _addComposingRegionTextSpans(tsTreeChildren, text, textPointer, + _addComposingRegionTextSpans(textSpanTreeChildren, text, textPointer, composingRegion, style, composingTextStyle); if (composingRegion.end != text.length) { - tsTreeChildren.add( + textSpanTreeChildren.add( TextSpan( style: style, - text: text.substring(composingRegion.end, text.length) + text: text.substring(composingRegion.end, text.length), ) ); } } else { - tsTreeChildren.add( + textSpanTreeChildren.add( TextSpan( - style: style, text: text.substring(textPointer, text.length) + style: style, text: text.substring(textPointer, text.length), ) ); } } - return tsTreeChildren; + return textSpanTreeChildren; } /// Helper method to create [TextSpan] tree children for specified range of @@ -330,13 +413,13 @@ void _addComposingRegionTextSpans( treeChildren.add( TextSpan( style: style, - text: text.substring(start, composingRegion.start) + text: text.substring(start, composingRegion.start), ) ); treeChildren.add( TextSpan( style: composingTextStyle, - text: text.substring(composingRegion.start, composingRegion.end) + text: text.substring(composingRegion.start, composingRegion.end), ) ); } diff --git a/packages/flutter/test/widgets/spell_check_test.dart b/packages/flutter/test/widgets/spell_check_test.dart index 4ac339b5765..f9b89dcde36 100644 --- a/packages/flutter/test/widgets/spell_check_test.dart +++ b/packages/flutter/test/widgets/spell_check_test.dart @@ -17,9 +17,9 @@ void main() { misspelledTextStyle = TextField.materialMisspelledTextStyle; }); - test( - 'buildTextSpanWithSpellCheckSuggestions ignores composing region when composing region out of range', - () { + testWidgets( + 'buildTextSpanWithSpellCheckSuggestions ignores composing region when composing region out of range', + (WidgetTester tester) async { const String text = 'Hello, wrold! Hey'; const TextEditingValue value = TextEditingValue(text: text); const bool composingRegionOutOfRange = true; @@ -43,12 +43,12 @@ void main() { spellCheckResults, ); - expect(textSpanTree, equals(expectedTextSpanTree)); - }); + expect(textSpanTree, equals(expectedTextSpanTree)); + }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.iOS })); - test( - 'buildTextSpanWithSpellCheckSuggestions, isolated misspelled word with separate composing region example', - () { + testWidgets( + 'buildTextSpanWithSpellCheckSuggestions, isolated misspelled word with separate composing region example', + (WidgetTester tester) async { const String text = 'Hello, wrold! Hey'; const TextEditingValue value = TextEditingValue( text: text, composing: TextRange(start: 14, end: 17)); @@ -75,11 +75,11 @@ void main() { ); expect(textSpanTree, equals(expectedTextSpanTree)); - }); + }, variant: const TargetPlatformVariant({ TargetPlatform.android })); - test( - 'buildTextSpanWithSpellCheckSuggestions, composing region and misspelled words overlap example', - () { + testWidgets( + 'buildTextSpanWithSpellCheckSuggestions, composing region and misspelled words overlap example', + (WidgetTester tester) async { const String text = 'Right worng worng right'; const TextEditingValue value = TextEditingValue( text: text, composing: TextRange(start: 12, end: 17)); @@ -109,11 +109,11 @@ void main() { ); expect(textSpanTree, equals(expectedTextSpanTree)); - }); + }, variant: const TargetPlatformVariant({ TargetPlatform.android })); - test( - 'buildTextSpanWithSpellCheckSuggestions, consecutive misspelled words example', - () { + testWidgets( + 'buildTextSpanWithSpellCheckSuggestions, consecutive misspelled words example', + (WidgetTester tester) async { const String text = 'Right worng worng right'; const TextEditingValue value = TextEditingValue(text: text); const bool composingRegionOutOfRange = true; @@ -142,15 +142,14 @@ void main() { ); expect(textSpanTree, equals(expectedTextSpanTree)); - }); + }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.iOS })); - test( - 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results text shorter than actual text example', - () { + testWidgets( + 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results text shorter than actual text example', + (WidgetTester tester) async { const String text = 'Hello, wrold! Hey'; const String resultsText = 'Hello, wrold!'; - const TextEditingValue value = TextEditingValue( - text: text, composing: TextRange(start: 14, end: 17)); + const TextEditingValue value = TextEditingValue(text: text); const bool composingRegionOutOfRange = false; const SpellCheckResults spellCheckResults = SpellCheckResults(resultsText, [ @@ -161,8 +160,7 @@ void main() { final TextSpan expectedTextSpanTree = TextSpan(children: [ const TextSpan(text: 'Hello, '), TextSpan(style: misspelledTextStyle, text: 'wrold'), - const TextSpan(text: '! '), - TextSpan(style: composingStyle, text: 'Hey') + const TextSpan(text: '! Hey'), ]); final TextSpan textSpanTree = buildTextSpanWithSpellCheckSuggestions( @@ -174,15 +172,14 @@ void main() { ); expect(textSpanTree, equals(expectedTextSpanTree)); - }); + }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.iOS })); - test( - 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results text longer with more misspelled words than actual text example', - () { + testWidgets( + 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results text longer with more misspelled words than actual text example', + (WidgetTester tester) async { const String text = 'Hello, wrold! Hey'; const String resultsText = 'Hello, wrold Hey feirnd!'; - const TextEditingValue value = TextEditingValue( - text: text, composing: TextRange(start: 14, end: 17)); + const TextEditingValue value = TextEditingValue(text: text); const bool composingRegionOutOfRange = false; const SpellCheckResults spellCheckResults = SpellCheckResults(resultsText, [ @@ -195,8 +192,7 @@ void main() { final TextSpan expectedTextSpanTree = TextSpan(children: [ const TextSpan(text: 'Hello, '), TextSpan(style: misspelledTextStyle, text: 'wrold'), - const TextSpan(text: '! '), - TextSpan(style: composingStyle, text: 'Hey') + const TextSpan(text: '! Hey'), ]); final TextSpan textSpanTree = buildTextSpanWithSpellCheckSuggestions( @@ -208,24 +204,22 @@ void main() { ); expect(textSpanTree, equals(expectedTextSpanTree)); - }); + }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.iOS })); - test( - 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results text mismatched example', - () { + testWidgets( + 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results text mismatched example', + (WidgetTester tester) async { const String text = 'Hello, wrold! Hey'; const String resultsText = 'Hello, wrild! Hey'; - const TextEditingValue value = TextEditingValue( - text: text, composing: TextRange(start: 14, end: 17)); + const TextEditingValue value = TextEditingValue(text: text); const bool composingRegionOutOfRange = false; const SpellCheckResults spellCheckResults = SpellCheckResults(resultsText, [ SuggestionSpan(TextRange(start: 7, end: 12), ['wild', 'world']), ]); - final TextSpan expectedTextSpanTree = TextSpan(children: [ - const TextSpan(text: 'Hello, wrold! '), - TextSpan(style: composingStyle, text: 'Hey') + const TextSpan expectedTextSpanTree = TextSpan(children: [ + TextSpan(text: 'Hello, wrold! Hey'), ]); final TextSpan textSpanTree = buildTextSpanWithSpellCheckSuggestions( @@ -237,15 +231,14 @@ void main() { ); expect(textSpanTree, equals(expectedTextSpanTree)); - }); + }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.iOS })); - test( - 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results shifted forward example', - () { + testWidgets( + 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results shifted forward example', + (WidgetTester tester) async { const String text = 'Hello, there wrold! Hey'; const String resultsText = 'Hello, wrold! Hey'; - const TextEditingValue value = TextEditingValue( - text: text, composing: TextRange(start: 20, end: 23)); + const TextEditingValue value = TextEditingValue(text: text); const bool composingRegionOutOfRange = false; const SpellCheckResults spellCheckResults = SpellCheckResults(resultsText, [ @@ -256,8 +249,7 @@ void main() { final TextSpan expectedTextSpanTree = TextSpan(children: [ const TextSpan(text: 'Hello, there '), TextSpan(style: misspelledTextStyle, text: 'wrold'), - const TextSpan(text: '! '), - TextSpan(style: composingStyle, text: 'Hey') + const TextSpan(text: '! Hey'), ]); final TextSpan textSpanTree = buildTextSpanWithSpellCheckSuggestions( @@ -269,15 +261,14 @@ void main() { ); expect(textSpanTree, equals(expectedTextSpanTree)); - }); + }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.iOS })); - test( - 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results shifted backwards example', - () { + testWidgets( + 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results shifted backwards example', + (WidgetTester tester) async { const String text = 'Hello, wrold! Hey'; const String resultsText = 'Hello, great wrold! Hey'; - const TextEditingValue value = TextEditingValue( - text: text, composing: TextRange(start: 14, end: 17)); + const TextEditingValue value = TextEditingValue(text: text); const bool composingRegionOutOfRange = false; const SpellCheckResults spellCheckResults = SpellCheckResults(resultsText, [ @@ -288,8 +279,7 @@ void main() { final TextSpan expectedTextSpanTree = TextSpan(children: [ const TextSpan(text: 'Hello, '), TextSpan(style: misspelledTextStyle, text: 'wrold'), - const TextSpan(text: '! '), - TextSpan(style: composingStyle, text: 'Hey') + const TextSpan(text: '! Hey'), ]); final TextSpan textSpanTree = buildTextSpanWithSpellCheckSuggestions( @@ -301,15 +291,14 @@ void main() { ); expect(textSpanTree, equals(expectedTextSpanTree)); - }); + }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.iOS })); - test( - 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results shifted backwards and forwards example', - () { + testWidgets( + 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results shifted backwards and forwards example', + (WidgetTester tester) async { const String text = 'Hello, wrold! And Hye!'; const String resultsText = 'Hello, great wrold! Hye!'; - const TextEditingValue value = TextEditingValue( - text: text, composing: TextRange(start: 14, end: 17)); + const TextEditingValue value = TextEditingValue(text: text); const bool composingRegionOutOfRange = false; const SpellCheckResults spellCheckResults = SpellCheckResults(resultsText, [ @@ -321,9 +310,7 @@ void main() { final TextSpan expectedTextSpanTree = TextSpan(children: [ const TextSpan(text: 'Hello, '), TextSpan(style: misspelledTextStyle, text: 'wrold'), - const TextSpan(text: '! '), - TextSpan(style: composingStyle, text: 'And'), - const TextSpan(text: ' '), + const TextSpan(text: '! And '), TextSpan(style: misspelledTextStyle, text: 'Hye'), const TextSpan(text: '!') ]); @@ -337,5 +324,33 @@ void main() { ); expect(textSpanTree, equals(expectedTextSpanTree)); - }); + }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.iOS })); + + testWidgets( + 'buildTextSpanWithSpellCheckSuggestions discards result when additions are made to misspelled word example', + (WidgetTester tester) async { + const String text = 'Hello, wroldd!'; + const String resultsText = 'Hello, wrold!'; + const TextEditingValue value = TextEditingValue(text: text); + const bool composingRegionOutOfRange = false; + const SpellCheckResults spellCheckResults = + SpellCheckResults(resultsText, [ + SuggestionSpan( + TextRange(start: 7, end: 12), ['world', 'word', 'old']), + ]); + + const TextSpan expectedTextSpanTree = TextSpan(children: [ + TextSpan(text: 'Hello, wroldd!'), + ]); + final TextSpan textSpanTree = + buildTextSpanWithSpellCheckSuggestions( + value, + composingRegionOutOfRange, + null, + misspelledTextStyle, + spellCheckResults, + ); + + expect(textSpanTree, equals(expectedTextSpanTree)); + }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.iOS })); }