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
5import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform;
6import 'package:flutter/painting.dart';
7import 'package:flutter/services.dart'
8 show SpellCheckResults, SpellCheckService, SuggestionSpan, TextEditingValue;
9
10import 'editable_text.dart' show EditableTextContextMenuBuilder;
11import '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
19class 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.
120List<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.
193TextSpan 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.
243List<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.
311List<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.
417void _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