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 | |