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';
6library;
7
8import 'dart:math' as math;
9
10import 'package:flutter/foundation.dart';
11import 'package:flutter/painting.dart';
12import 'package:flutter/services.dart'
13 show SpellCheckResults, SpellCheckService, SuggestionSpan, TextEditingValue;
14
15import '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
23class 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.
129List<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.
206TextSpan 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.
259List<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.
315List<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.
416void _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

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com