| 1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
| 2 | // Use of this source code is governed by a BSD-style license that can be |
| 3 | // found in the LICENSE file. |
| 4 | |
| 5 | /// @docImport 'editable_text.dart'; |
| 6 | library; |
| 7 | |
| 8 | import 'dart:math' as math; |
| 9 | |
| 10 | import 'package:flutter/foundation.dart'; |
| 11 | import 'package:flutter/painting.dart'; |
| 12 | import 'package:flutter/services.dart' |
| 13 | show SpellCheckResults, SpellCheckService, SuggestionSpan, TextEditingValue; |
| 14 | |
| 15 | import 'editable_text.dart' show EditableTextContextMenuBuilder; |
| 16 | |
| 17 | /// Controls how spell check is performed for text input. |
| 18 | /// |
| 19 | /// This configuration determines the [SpellCheckService] used to fetch the |
| 20 | /// [List<SuggestionSpan>] spell check results and the [TextStyle] used to |
| 21 | /// mark misspelled words within text input. |
| 22 | @immutable |
| 23 | class SpellCheckConfiguration { |
| 24 | /// Creates a configuration that specifies the service and suggestions handler |
| 25 | /// for spell check. |
| 26 | const SpellCheckConfiguration({ |
| 27 | this.spellCheckService, |
| 28 | this.misspelledSelectionColor, |
| 29 | this.misspelledTextStyle, |
| 30 | this.spellCheckSuggestionsToolbarBuilder, |
| 31 | }) : _spellCheckEnabled = true; |
| 32 | |
| 33 | /// Creates a configuration that disables spell check. |
| 34 | const SpellCheckConfiguration.disabled() |
| 35 | : _spellCheckEnabled = false, |
| 36 | spellCheckService = null, |
| 37 | spellCheckSuggestionsToolbarBuilder = null, |
| 38 | misspelledTextStyle = null, |
| 39 | misspelledSelectionColor = null; |
| 40 | |
| 41 | /// The service used to fetch spell check results for text input. |
| 42 | final SpellCheckService? spellCheckService; |
| 43 | |
| 44 | /// The color the paint the selection highlight when spell check is showing |
| 45 | /// suggestions for a misspelled word. |
| 46 | /// |
| 47 | /// For example, on iOS, the selection appears red while the spell check menu |
| 48 | /// is showing. |
| 49 | final Color? misspelledSelectionColor; |
| 50 | |
| 51 | /// Style used to indicate misspelled words. |
| 52 | /// |
| 53 | /// This is nullable to allow style-specific wrappers of [EditableText] |
| 54 | /// to infer this, but this must be specified if this configuration is |
| 55 | /// provided directly to [EditableText] or its construction will fail with an |
| 56 | /// assertion error. |
| 57 | final TextStyle? misspelledTextStyle; |
| 58 | |
| 59 | /// Builds the toolbar used to display spell check suggestions for misspelled |
| 60 | /// words. |
| 61 | final EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder; |
| 62 | |
| 63 | final bool _spellCheckEnabled; |
| 64 | |
| 65 | /// Whether or not the configuration should enable or disable spell check. |
| 66 | bool get spellCheckEnabled => _spellCheckEnabled; |
| 67 | |
| 68 | /// Returns a copy of the current [SpellCheckConfiguration] instance with |
| 69 | /// specified overrides. |
| 70 | SpellCheckConfiguration copyWith({ |
| 71 | SpellCheckService? spellCheckService, |
| 72 | Color? misspelledSelectionColor, |
| 73 | TextStyle? misspelledTextStyle, |
| 74 | EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder, |
| 75 | }) { |
| 76 | if (!_spellCheckEnabled) { |
| 77 | // A new configuration should be constructed to enable spell check. |
| 78 | return const SpellCheckConfiguration.disabled(); |
| 79 | } |
| 80 | |
| 81 | return SpellCheckConfiguration( |
| 82 | spellCheckService: spellCheckService ?? this.spellCheckService, |
| 83 | misspelledSelectionColor: misspelledSelectionColor ?? this.misspelledSelectionColor, |
| 84 | misspelledTextStyle: misspelledTextStyle ?? this.misspelledTextStyle, |
| 85 | spellCheckSuggestionsToolbarBuilder: |
| 86 | spellCheckSuggestionsToolbarBuilder ?? this.spellCheckSuggestionsToolbarBuilder, |
| 87 | ); |
| 88 | } |
| 89 | |
| 90 | @override |
| 91 | String toString() { |
| 92 | return ' ${objectRuntimeType(this, 'SpellCheckConfiguration' )}(' |
| 93 | ' ${_spellCheckEnabled ? 'enabled' : 'disabled' }, ' |
| 94 | 'service: $spellCheckService, ' |
| 95 | 'text style: $misspelledTextStyle, ' |
| 96 | 'toolbar builder: $spellCheckSuggestionsToolbarBuilder' |
| 97 | ')' ; |
| 98 | } |
| 99 | |
| 100 | @override |
| 101 | bool operator ==(Object other) { |
| 102 | if (other.runtimeType != runtimeType) { |
| 103 | return false; |
| 104 | } |
| 105 | return other is SpellCheckConfiguration && |
| 106 | other.spellCheckService == spellCheckService && |
| 107 | other.misspelledTextStyle == misspelledTextStyle && |
| 108 | other.spellCheckSuggestionsToolbarBuilder == spellCheckSuggestionsToolbarBuilder && |
| 109 | other._spellCheckEnabled == _spellCheckEnabled; |
| 110 | } |
| 111 | |
| 112 | @override |
| 113 | int get hashCode => Object.hash( |
| 114 | spellCheckService, |
| 115 | misspelledTextStyle, |
| 116 | spellCheckSuggestionsToolbarBuilder, |
| 117 | _spellCheckEnabled, |
| 118 | ); |
| 119 | } |
| 120 | |
| 121 | // Methods for displaying spell check results: |
| 122 | |
| 123 | /// Adjusts spell check results to correspond to [newText] if the only results |
| 124 | /// that the handler has access to are the [results] corresponding to |
| 125 | /// [resultsText]. |
| 126 | /// |
| 127 | /// Used in the case where the request for the spell check results of the |
| 128 | /// [newText] is lagging in order to avoid display of incorrect results. |
| 129 | List<SuggestionSpan> _correctSpellCheckResults( |
| 130 | String newText, |
| 131 | String resultsText, |
| 132 | List<SuggestionSpan> results, |
| 133 | ) { |
| 134 | final List<SuggestionSpan> correctedSpellCheckResults = <SuggestionSpan>[]; |
| 135 | int spanPointer = 0; |
| 136 | int offset = 0; |
| 137 | |
| 138 | // Assumes that the order of spans has not been jumbled for optimization |
| 139 | // purposes, and will only search since the previously found span. |
| 140 | int searchStart = 0; |
| 141 | |
| 142 | while (spanPointer < results.length) { |
| 143 | final SuggestionSpan currentSpan = results[spanPointer]; |
| 144 | final String currentSpanText = resultsText.substring( |
| 145 | currentSpan.range.start, |
| 146 | currentSpan.range.end, |
| 147 | ); |
| 148 | final int spanLength = currentSpan.range.end - currentSpan.range.start; |
| 149 | |
| 150 | // Try finding SuggestionSpan from resultsText in new text. |
| 151 | final String escapedText = RegExp.escape(currentSpanText); |
| 152 | final RegExp currentSpanTextRegexp = RegExp('\\b $escapedText\\b' ); |
| 153 | final int foundIndex = newText.substring(searchStart).indexOf(currentSpanTextRegexp); |
| 154 | |
| 155 | // Check whether word was found exactly where expected or elsewhere in the newText. |
| 156 | final bool currentSpanFoundExactly = currentSpan.range.start == foundIndex + searchStart; |
| 157 | final bool currentSpanFoundExactlyWithOffset = |
| 158 | currentSpan.range.start + offset == foundIndex + searchStart; |
| 159 | final bool currentSpanFoundElsewhere = foundIndex >= 0; |
| 160 | |
| 161 | if (currentSpanFoundExactly || currentSpanFoundExactlyWithOffset) { |
| 162 | // currentSpan was found at the same index in newText and resultsText |
| 163 | // or at the same index with the previously calculated adjustment by |
| 164 | // the offset value, so apply it to new text by adding it to the list of |
| 165 | // corrected results. |
| 166 | final SuggestionSpan adjustedSpan = SuggestionSpan( |
| 167 | TextRange(start: currentSpan.range.start + offset, end: currentSpan.range.end + offset), |
| 168 | currentSpan.suggestions, |
| 169 | ); |
| 170 | |
| 171 | // Start search for the next misspelled word at the end of currentSpan. |
| 172 | searchStart = math.min(currentSpan.range.end + 1 + offset, newText.length); |
| 173 | correctedSpellCheckResults.add(adjustedSpan); |
| 174 | } else if (currentSpanFoundElsewhere) { |
| 175 | // Word was pushed forward but not modified. |
| 176 | final int adjustedSpanStart = searchStart + foundIndex; |
| 177 | final int adjustedSpanEnd = adjustedSpanStart + spanLength; |
| 178 | final SuggestionSpan adjustedSpan = SuggestionSpan( |
| 179 | TextRange(start: adjustedSpanStart, end: adjustedSpanEnd), |
| 180 | currentSpan.suggestions, |
| 181 | ); |
| 182 | |
| 183 | // Start search for the next misspelled word at the end of the |
| 184 | // adjusted currentSpan. |
| 185 | searchStart = math.min(adjustedSpanEnd + 1, newText.length); |
| 186 | // Adjust offset to reflect the difference between where currentSpan |
| 187 | // was positioned in resultsText versus in newText. |
| 188 | offset = adjustedSpanStart - currentSpan.range.start; |
| 189 | correctedSpellCheckResults.add(adjustedSpan); |
| 190 | } |
| 191 | spanPointer++; |
| 192 | } |
| 193 | return correctedSpellCheckResults; |
| 194 | } |
| 195 | |
| 196 | /// Builds the [TextSpan] tree given the current state of the text input and |
| 197 | /// spell check results. |
| 198 | /// |
| 199 | /// The [value] is the current [TextEditingValue] requested to be rendered |
| 200 | /// by a text input widget. The [composingWithinCurrentTextRange] value |
| 201 | /// represents whether or not there is a valid composing region in the |
| 202 | /// [value]. The [style] is the [TextStyle] to render the [value]'s text with, |
| 203 | /// and the [misspelledTextStyle] is the [TextStyle] to render misspelled |
| 204 | /// words within the [value]'s text with. The [spellCheckResults] are the |
| 205 | /// results of spell checking the [value]'s text. |
| 206 | TextSpan buildTextSpanWithSpellCheckSuggestions( |
| 207 | TextEditingValue value, |
| 208 | bool composingWithinCurrentTextRange, |
| 209 | TextStyle? style, |
| 210 | TextStyle misspelledTextStyle, |
| 211 | SpellCheckResults spellCheckResults, |
| 212 | ) { |
| 213 | List<SuggestionSpan> spellCheckResultsSpans = spellCheckResults.suggestionSpans; |
| 214 | final String spellCheckResultsText = spellCheckResults.spellCheckedText; |
| 215 | |
| 216 | if (spellCheckResultsText != value.text) { |
| 217 | spellCheckResultsSpans = _correctSpellCheckResults( |
| 218 | value.text, |
| 219 | spellCheckResultsText, |
| 220 | spellCheckResultsSpans, |
| 221 | ); |
| 222 | } |
| 223 | |
| 224 | // We will draw the TextSpan tree based on the composing region, if it is |
| 225 | // available. |
| 226 | // TODO(camsim99): The two separate strategies for building TextSpan trees |
| 227 | // based on the availability of a composing region should be merged: |
| 228 | // https://github.com/flutter/flutter/issues/124142. |
| 229 | final bool shouldConsiderComposingRegion = defaultTargetPlatform == TargetPlatform.android; |
| 230 | if (shouldConsiderComposingRegion) { |
| 231 | return TextSpan( |
| 232 | style: style, |
| 233 | children: _buildSubtreesWithComposingRegion( |
| 234 | spellCheckResultsSpans, |
| 235 | value, |
| 236 | style, |
| 237 | misspelledTextStyle, |
| 238 | composingWithinCurrentTextRange, |
| 239 | ), |
| 240 | ); |
| 241 | } |
| 242 | |
| 243 | return TextSpan( |
| 244 | style: style, |
| 245 | children: _buildSubtreesWithoutComposingRegion( |
| 246 | spellCheckResultsSpans, |
| 247 | value, |
| 248 | style, |
| 249 | misspelledTextStyle, |
| 250 | value.selection.baseOffset, |
| 251 | ), |
| 252 | ); |
| 253 | } |
| 254 | |
| 255 | /// Builds the [TextSpan] tree for spell check without considering the composing |
| 256 | /// region. Instead, uses the cursor to identify the word that's actively being |
| 257 | /// edited and shouldn't be spell checked. This is useful for platforms and IMEs |
| 258 | /// that don't use the composing region for the active word. |
| 259 | List<TextSpan> _buildSubtreesWithoutComposingRegion( |
| 260 | List<SuggestionSpan>? spellCheckSuggestions, |
| 261 | TextEditingValue value, |
| 262 | TextStyle? style, |
| 263 | TextStyle misspelledStyle, |
| 264 | int cursorIndex, |
| 265 | ) { |
| 266 | final List<TextSpan> textSpanTreeChildren = <TextSpan>[]; |
| 267 | |
| 268 | int textPointer = 0; |
| 269 | int currentSpanPointer = 0; |
| 270 | int endIndex; |
| 271 | final String text = value.text; |
| 272 | final TextStyle misspelledJointStyle = style?.merge(misspelledStyle) ?? misspelledStyle; |
| 273 | bool cursorInCurrentSpan = false; |
| 274 | |
| 275 | // Add text interwoven with any misspelled words to the tree. |
| 276 | if (spellCheckSuggestions != null) { |
| 277 | while (textPointer < text.length && currentSpanPointer < spellCheckSuggestions.length) { |
| 278 | final SuggestionSpan currentSpan = spellCheckSuggestions[currentSpanPointer]; |
| 279 | |
| 280 | if (currentSpan.range.start > textPointer) { |
| 281 | endIndex = currentSpan.range.start < text.length ? currentSpan.range.start : text.length; |
| 282 | textSpanTreeChildren.add( |
| 283 | TextSpan(style: style, text: text.substring(textPointer, endIndex)), |
| 284 | ); |
| 285 | textPointer = endIndex; |
| 286 | } else { |
| 287 | endIndex = currentSpan.range.end < text.length ? currentSpan.range.end : text.length; |
| 288 | cursorInCurrentSpan = |
| 289 | currentSpan.range.start <= cursorIndex && currentSpan.range.end >= cursorIndex; |
| 290 | textSpanTreeChildren.add( |
| 291 | TextSpan( |
| 292 | style: cursorInCurrentSpan ? style : misspelledJointStyle, |
| 293 | text: text.substring(currentSpan.range.start, endIndex), |
| 294 | ), |
| 295 | ); |
| 296 | |
| 297 | textPointer = endIndex; |
| 298 | currentSpanPointer++; |
| 299 | } |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | // Add any remaining text to the tree if applicable. |
| 304 | if (textPointer < text.length) { |
| 305 | textSpanTreeChildren.add( |
| 306 | TextSpan(style: style, text: text.substring(textPointer, text.length)), |
| 307 | ); |
| 308 | } |
| 309 | |
| 310 | return textSpanTreeChildren; |
| 311 | } |
| 312 | |
| 313 | /// Builds [TextSpan] subtree for text with misspelled words with logic based on |
| 314 | /// a valid composing region. |
| 315 | List<TextSpan> _buildSubtreesWithComposingRegion( |
| 316 | List<SuggestionSpan>? spellCheckSuggestions, |
| 317 | TextEditingValue value, |
| 318 | TextStyle? style, |
| 319 | TextStyle misspelledStyle, |
| 320 | bool composingWithinCurrentTextRange, |
| 321 | ) { |
| 322 | final List<TextSpan> textSpanTreeChildren = <TextSpan>[]; |
| 323 | |
| 324 | int textPointer = 0; |
| 325 | int currentSpanPointer = 0; |
| 326 | int endIndex; |
| 327 | SuggestionSpan currentSpan; |
| 328 | final String text = value.text; |
| 329 | final TextRange composingRegion = value.composing; |
| 330 | final TextStyle composingTextStyle = |
| 331 | style?.merge(const TextStyle(decoration: TextDecoration.underline)) ?? |
| 332 | const TextStyle(decoration: TextDecoration.underline); |
| 333 | final TextStyle misspelledJointStyle = style?.merge(misspelledStyle) ?? misspelledStyle; |
| 334 | bool textPointerWithinComposingRegion = false; |
| 335 | bool currentSpanIsComposingRegion = false; |
| 336 | |
| 337 | // Add text interwoven with any misspelled words to the tree. |
| 338 | if (spellCheckSuggestions != null) { |
| 339 | while (textPointer < text.length && currentSpanPointer < spellCheckSuggestions.length) { |
| 340 | currentSpan = spellCheckSuggestions[currentSpanPointer]; |
| 341 | |
| 342 | if (currentSpan.range.start > textPointer) { |
| 343 | endIndex = currentSpan.range.start < text.length ? currentSpan.range.start : text.length; |
| 344 | textPointerWithinComposingRegion = |
| 345 | composingRegion.start >= textPointer && |
| 346 | composingRegion.end <= endIndex && |
| 347 | !composingWithinCurrentTextRange; |
| 348 | |
| 349 | if (textPointerWithinComposingRegion) { |
| 350 | _addComposingRegionTextSpans( |
| 351 | textSpanTreeChildren, |
| 352 | text, |
| 353 | textPointer, |
| 354 | composingRegion, |
| 355 | style, |
| 356 | composingTextStyle, |
| 357 | ); |
| 358 | textSpanTreeChildren.add( |
| 359 | TextSpan(style: style, text: text.substring(composingRegion.end, endIndex)), |
| 360 | ); |
| 361 | } else { |
| 362 | textSpanTreeChildren.add( |
| 363 | TextSpan(style: style, text: text.substring(textPointer, endIndex)), |
| 364 | ); |
| 365 | } |
| 366 | |
| 367 | textPointer = endIndex; |
| 368 | } else { |
| 369 | endIndex = currentSpan.range.end < text.length ? currentSpan.range.end : text.length; |
| 370 | currentSpanIsComposingRegion = |
| 371 | textPointer >= composingRegion.start && |
| 372 | endIndex <= composingRegion.end && |
| 373 | !composingWithinCurrentTextRange; |
| 374 | textSpanTreeChildren.add( |
| 375 | TextSpan( |
| 376 | style: currentSpanIsComposingRegion ? composingTextStyle : misspelledJointStyle, |
| 377 | text: text.substring(currentSpan.range.start, endIndex), |
| 378 | ), |
| 379 | ); |
| 380 | |
| 381 | textPointer = endIndex; |
| 382 | currentSpanPointer++; |
| 383 | } |
| 384 | } |
| 385 | } |
| 386 | |
| 387 | // Add any remaining text to the tree if applicable. |
| 388 | if (textPointer < text.length) { |
| 389 | if (textPointer < composingRegion.start && !composingWithinCurrentTextRange) { |
| 390 | _addComposingRegionTextSpans( |
| 391 | textSpanTreeChildren, |
| 392 | text, |
| 393 | textPointer, |
| 394 | composingRegion, |
| 395 | style, |
| 396 | composingTextStyle, |
| 397 | ); |
| 398 | |
| 399 | if (composingRegion.end != text.length) { |
| 400 | textSpanTreeChildren.add( |
| 401 | TextSpan(style: style, text: text.substring(composingRegion.end, text.length)), |
| 402 | ); |
| 403 | } |
| 404 | } else { |
| 405 | textSpanTreeChildren.add( |
| 406 | TextSpan(style: style, text: text.substring(textPointer, text.length)), |
| 407 | ); |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | return textSpanTreeChildren; |
| 412 | } |
| 413 | |
| 414 | /// Helper method to create [TextSpan] tree children for specified range of |
| 415 | /// text up to and including the composing region. |
| 416 | void _addComposingRegionTextSpans( |
| 417 | List<TextSpan> treeChildren, |
| 418 | String text, |
| 419 | int start, |
| 420 | TextRange composingRegion, |
| 421 | TextStyle? style, |
| 422 | TextStyle composingTextStyle, |
| 423 | ) { |
| 424 | treeChildren.add(TextSpan(style: style, text: text.substring(start, composingRegion.start))); |
| 425 | treeChildren.add( |
| 426 | TextSpan( |
| 427 | style: composingTextStyle, |
| 428 | text: text.substring(composingRegion.start, composingRegion.end), |
| 429 | ), |
| 430 | ); |
| 431 | } |
| 432 | |