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