Line data Source code
1 : // Copyright 2014 The Flutter Authors.
2 : // Copyright 2021 Suragch.
3 : // All rights reserved.
4 : // Use of this source code is governed by a BSD-style license that can be
5 : // found in the LICENSE file.
6 :
7 : import 'dart:collection';
8 : import 'dart:math' as math;
9 :
10 : import 'package:characters/characters.dart';
11 : import 'package:flutter/foundation.dart';
12 : import 'package:flutter/gestures.dart';
13 : import 'package:flutter/material.dart';
14 : import 'package:flutter/rendering.dart';
15 : import 'package:flutter/services.dart';
16 : import 'package:mongol/src/base/mongol_text_align.dart';
17 : import 'package:mongol/src/base/mongol_text_painter.dart';
18 :
19 : // ignore_for_file: deprecated_member_use_from_same_package
20 : // ignore_for_file: todo
21 :
22 : const double _kCaretGap = 1.0; // pixels
23 : const double _kCaretWidthOffset = 2.0; // pixels
24 :
25 : /// Signature for the callback that reports when the user changes the selection
26 : /// (including the cursor location).
27 : ///
28 : /// Used by [MongolRenderEditable.onSelectionChanged].
29 : @Deprecated(
30 : 'Signature of a deprecated class method, '
31 : 'textSelectionDelegate.userUpdateTextEditingValue. '
32 : 'This feature was deprecated after v1.26.0-17.2.pre.',
33 : )
34 : typedef MongolSelectionChangedHandler = void Function(
35 : TextSelection selection,
36 : MongolRenderEditable renderObject,
37 : SelectionChangedCause cause,
38 : );
39 :
40 : // Check if the given code unit is a white space or separator
41 : // character.
42 : //
43 : // Includes newline characters from ASCII and separators from the
44 : // [unicode separator category](https://www.compart.com/en/unicode/category/Zs)
45 3 : bool _isWhitespace(int codeUnit) {
46 : switch (codeUnit) {
47 3 : case 0x9: // horizontal tab
48 3 : case 0xA: // line feed
49 3 : case 0xB: // vertical tab
50 3 : case 0xC: // form feed
51 3 : case 0xD: // carriage return
52 3 : case 0x1C: // file separator
53 3 : case 0x1D: // group separator
54 3 : case 0x1E: // record separator
55 3 : case 0x1F: // unit separator
56 3 : case 0x20: // space
57 3 : case 0xA0: // no-break space
58 3 : case 0x1680: // ogham space mark
59 3 : case 0x2000: // en quad
60 3 : case 0x2001: // em quad
61 3 : case 0x2002: // en space
62 3 : case 0x2003: // em space
63 3 : case 0x2004: // three-per-em space
64 3 : case 0x2005: // four-er-em space
65 3 : case 0x2006: // six-per-em space
66 3 : case 0x2007: // figure space
67 3 : case 0x2008: // punctuation space
68 3 : case 0x2009: // thin space
69 3 : case 0x200A: // hair space
70 3 : case 0x202F: // narrow no-break space
71 3 : case 0x205F: // medium mathematical space
72 3 : case 0x3000: // ideographic space
73 : break;
74 : default:
75 : return false;
76 : }
77 : return true;
78 : }
79 :
80 : /// Displays some text in a scrollable container with a potentially blinking
81 : /// cursor and with gesture recognizers.
82 : ///
83 : /// This is the renderer for an editable vertical text field. It does not
84 : /// directly provide a means of editing the text, but it does handle text
85 : /// selection and manipulation of the text cursor.
86 : ///
87 : /// The [text] is displayed, scrolled by the given [offset], aligned according
88 : /// to [textAlign]. The [maxLines] property controls whether the text displays
89 : /// on one line or many. The [selection], if it is not collapsed, is painted in
90 : /// the [selectionColor]. If it _is_ collapsed, then it represents the cursor
91 : /// position. The cursor is shown while [showCursor] is true. It is painted in
92 : /// the [cursorColor].
93 : ///
94 : /// If, when the render object paints, the caret is found to have changed
95 : /// location, [onCaretChanged] is called.
96 : ///
97 : /// Keyboard handling, IME handling, scrolling, toggling the [showCursor] value
98 : /// to actually blink the cursor, and other features not mentioned above are the
99 : /// responsibility of higher layers and not handled by this object.
100 : class MongolRenderEditable extends RenderBox
101 : with RelayoutWhenSystemFontsChangeMixin {
102 : /// Creates a render object that implements the visual aspects of a text field.
103 : ///
104 : /// The [textAlign] argument must not be null. It defaults to
105 : /// [MongolTextAlign.top].
106 : ///
107 : /// If [showCursor] is not specified, then it defaults to hiding the cursor.
108 : ///
109 : /// The [maxLines] property can be set to null to remove the restriction on
110 : /// the number of lines. By default, it is 1, meaning this is a single-line
111 : /// text field. If it is not null, it must be greater than zero.
112 : ///
113 : /// The [offset] is required and must not be null. You can use
114 : /// [ViewportOffset.zero] if you have no need for scrolling.
115 3 : MongolRenderEditable({
116 : TextSpan? text,
117 : MongolTextAlign textAlign = MongolTextAlign.top,
118 : Color? cursorColor,
119 : ValueNotifier<bool>? showCursor,
120 : bool? hasFocus,
121 : required LayerLink startHandleLayerLink,
122 : required LayerLink endHandleLayerLink,
123 : int? maxLines = 1,
124 : int? minLines,
125 : bool expands = false,
126 : Color? selectionColor,
127 : double textScaleFactor = 1.0,
128 : TextSelection? selection,
129 : required ViewportOffset offset,
130 : @Deprecated(
131 : 'Uses the textSelectionDelegate.userUpdateTextEditingValue instead. '
132 : 'This feature was deprecated after v1.26.0-17.2.pre.',
133 : )
134 : this.onSelectionChanged,
135 : this.onCaretChanged,
136 : this.ignorePointer = false,
137 : bool readOnly = false,
138 : bool forceLine = true,
139 : String obscuringCharacter = '•',
140 : bool obscureText = false,
141 : double? cursorWidth,
142 : double cursorHeight = 1.0,
143 : Radius? cursorRadius,
144 : Offset cursorOffset = Offset.zero,
145 : double devicePixelRatio = 1.0,
146 : bool? enableInteractiveSelection,
147 : Clip clipBehavior = Clip.hardEdge,
148 : required this.textSelectionDelegate,
149 : MongolRenderEditablePainter? painter,
150 : MongolRenderEditablePainter? foregroundPainter,
151 3 : }) : assert(maxLines == null || maxLines > 0),
152 1 : assert(minLines == null || minLines > 0),
153 : assert(
154 1 : (maxLines == null) || (minLines == null) || (maxLines >= minLines),
155 : "minLines can't be greater than maxLines",
156 : ),
157 : assert(
158 0 : !expands || (maxLines == null && minLines == null),
159 : 'minLines and maxLines must be null when expands is true.',
160 : ),
161 9 : assert(obscuringCharacter.characters.length == 1),
162 0 : assert(cursorWidth == null || cursorWidth >= 0.0),
163 3 : assert(cursorHeight >= 0.0),
164 3 : _textPainter = MongolTextPainter(
165 : text: text,
166 : textAlign: textAlign,
167 : textScaleFactor: textScaleFactor,
168 : ),
169 1 : _showCursor = showCursor ?? ValueNotifier<bool>(false),
170 : _maxLines = maxLines,
171 : _minLines = minLines,
172 : _expands = expands,
173 : _selection = selection,
174 : _offset = offset,
175 : _cursorWidth = cursorWidth,
176 : _cursorHeight = cursorHeight,
177 : _enableInteractiveSelection = enableInteractiveSelection,
178 : _devicePixelRatio = devicePixelRatio,
179 : _startHandleLayerLink = startHandleLayerLink,
180 : _endHandleLayerLink = endHandleLayerLink,
181 : _obscuringCharacter = obscuringCharacter,
182 : _obscureText = obscureText,
183 : _readOnly = readOnly,
184 : _forceLine = forceLine,
185 : _clipBehavior = clipBehavior {
186 6 : assert(!_showCursor.value || cursorColor != null);
187 3 : this.hasFocus = hasFocus ?? false;
188 :
189 6 : _selectionPainter.highlightColor = selectionColor;
190 6 : _selectionPainter.highlightedRange = selection;
191 :
192 6 : _caretPainter.caretColor = cursorColor;
193 6 : _caretPainter.cursorRadius = cursorRadius;
194 6 : _caretPainter.cursorOffset = cursorOffset;
195 :
196 3 : _updateForegroundPainter(foregroundPainter);
197 3 : _updatePainter(painter);
198 : }
199 :
200 : /// Child render objects
201 : _MongolRenderEditableCustomPaint? _foregroundRenderObject;
202 : _MongolRenderEditableCustomPaint? _backgroundRenderObject;
203 :
204 3 : void _updateForegroundPainter(MongolRenderEditablePainter? newPainter) {
205 : final effectivePainter = (newPainter == null)
206 3 : ? _builtInForegroundPainters
207 1 : : _CompositeRenderEditablePainter(
208 1 : painters: <MongolRenderEditablePainter>[
209 1 : _builtInForegroundPainters,
210 : newPainter,
211 : ],
212 : );
213 :
214 3 : if (_foregroundRenderObject == null) {
215 : final foregroundRenderObject =
216 3 : _MongolRenderEditableCustomPaint(painter: effectivePainter);
217 3 : adoptChild(foregroundRenderObject);
218 3 : _foregroundRenderObject = foregroundRenderObject;
219 : } else {
220 2 : _foregroundRenderObject?.painter = effectivePainter;
221 : }
222 3 : _foregroundPainter = newPainter;
223 : }
224 :
225 : /// The [MongolRenderEditablePainter] to use for painting above this
226 : /// [MongolRenderEditable]'s text content.
227 : ///
228 : /// The new [MongolRenderEditablePainter] will replace the previously specified
229 : /// foreground painter, and schedule a repaint if the new painter's
230 : /// `shouldRepaint` method returns true.
231 0 : MongolRenderEditablePainter? get foregroundPainter => _foregroundPainter;
232 : MongolRenderEditablePainter? _foregroundPainter;
233 1 : set foregroundPainter(MongolRenderEditablePainter? newPainter) {
234 2 : if (newPainter == _foregroundPainter) return;
235 1 : _updateForegroundPainter(newPainter);
236 : }
237 :
238 3 : void _updatePainter(MongolRenderEditablePainter? newPainter) {
239 : final effectivePainter = (newPainter == null)
240 3 : ? _builtInPainters
241 1 : : _CompositeRenderEditablePainter(
242 1 : painters: <MongolRenderEditablePainter>[
243 1 : _builtInPainters,
244 : newPainter,
245 : ],
246 : );
247 :
248 3 : if (_backgroundRenderObject == null) {
249 : final backgroundRenderObject =
250 3 : _MongolRenderEditableCustomPaint(painter: effectivePainter);
251 3 : adoptChild(backgroundRenderObject);
252 3 : _backgroundRenderObject = backgroundRenderObject;
253 : } else {
254 2 : _backgroundRenderObject?.painter = effectivePainter;
255 : }
256 3 : _painter = newPainter;
257 : }
258 :
259 : /// Sets the [MongolRenderEditablePainter] to use for painting beneath this
260 : /// [MongolRenderEditable]'s text content.
261 : ///
262 : /// The new [MongolRenderEditablePainter] will replace the previously specified
263 : /// painter, and schedule a repaint if the new painter's `shouldRepaint`
264 : /// method returns true.
265 0 : MongolRenderEditablePainter? get painter => _painter;
266 : MongolRenderEditablePainter? _painter;
267 1 : set painter(MongolRenderEditablePainter? newPainter) {
268 2 : if (newPainter == _painter) return;
269 1 : _updatePainter(newPainter);
270 : }
271 :
272 : // Caret painters:
273 9 : late final _CaretPainter _caretPainter = _CaretPainter(_onCaretChanged);
274 :
275 : // Text Highlight painters:
276 : final _TextHighlightPainter _selectionPainter = _TextHighlightPainter();
277 :
278 3 : _CompositeRenderEditablePainter get _builtInForegroundPainters =>
279 6 : _cachedBuiltInForegroundPainters ??= _createBuiltInForegroundPainters();
280 : _CompositeRenderEditablePainter? _cachedBuiltInForegroundPainters;
281 3 : _CompositeRenderEditablePainter _createBuiltInForegroundPainters() {
282 3 : return _CompositeRenderEditablePainter(
283 3 : painters: <MongolRenderEditablePainter>[
284 3 : _caretPainter,
285 : ],
286 : );
287 : }
288 :
289 3 : _CompositeRenderEditablePainter get _builtInPainters =>
290 6 : _cachedBuiltInPainters ??= _createBuiltInPainters();
291 : _CompositeRenderEditablePainter? _cachedBuiltInPainters;
292 3 : _CompositeRenderEditablePainter _createBuiltInPainters() {
293 3 : return _CompositeRenderEditablePainter(
294 3 : painters: <MongolRenderEditablePainter>[
295 3 : _selectionPainter,
296 : ],
297 : );
298 : }
299 :
300 : /// Called when the selection changes.
301 : ///
302 : /// If this is null, then selection changes will be ignored.
303 : @Deprecated(
304 : 'Uses the textSelectionDelegate.userUpdateTextEditingValue instead. '
305 : 'This feature was deprecated after v1.26.0-17.2.pre.',
306 : )
307 : MongolSelectionChangedHandler? onSelectionChanged;
308 :
309 : double? _textLayoutLastMaxHeight;
310 : double? _textLayoutLastMinHeight;
311 :
312 : Rect? _lastCaretRect;
313 :
314 : /// Called during the paint phase when the caret location changes.
315 : CaretChangedHandler? onCaretChanged;
316 3 : void _onCaretChanged(Rect caretRect) {
317 11 : if (_lastCaretRect != caretRect) onCaretChanged?.call(caretRect);
318 6 : _lastCaretRect = (onCaretChanged == null) ? null : caretRect;
319 : }
320 :
321 : /// Whether the [handleEvent] will propagate pointer events to selection
322 : /// handlers.
323 : ///
324 : /// If this property is true, the [handleEvent] assumes that this renderer
325 : /// will be notified of input gestures via [handleTapDown], [handleTap],
326 : /// [handleDoubleTap], and [handleLongPress].
327 : ///
328 : /// If there are any gesture recognizers in the text span, the [handleEvent]
329 : /// will still propagate pointer events to those recognizers.
330 : ///
331 : /// The default value of this property is false.
332 : bool ignorePointer;
333 :
334 : /// The pixel ratio of the current device.
335 : ///
336 : /// Should be obtained by querying MediaQuery for the devicePixelRatio.
337 4 : double get devicePixelRatio => _devicePixelRatio;
338 : double _devicePixelRatio;
339 2 : set devicePixelRatio(double value) {
340 4 : if (devicePixelRatio == value) return;
341 0 : _devicePixelRatio = value;
342 0 : markNeedsTextLayout();
343 : }
344 :
345 : /// Character used for obscuring text if [obscureText] is true.
346 : ///
347 : /// Must have a length of exactly one.
348 6 : String get obscuringCharacter => _obscuringCharacter;
349 : String _obscuringCharacter;
350 2 : set obscuringCharacter(String value) {
351 4 : if (_obscuringCharacter == value) {
352 : return;
353 : }
354 0 : assert(value.characters.length == 1);
355 0 : _obscuringCharacter = value;
356 0 : markNeedsLayout();
357 : }
358 :
359 : /// Whether to hide the text being edited (e.g., for passwords).
360 6 : bool get obscureText => _obscureText;
361 : bool _obscureText;
362 2 : set obscureText(bool value) {
363 4 : if (_obscureText == value) return;
364 1 : _obscureText = value;
365 1 : markNeedsSemanticsUpdate();
366 : }
367 :
368 : /// The object that controls the text selection, used by this render object
369 : /// for implementing cut, copy, and paste keyboard shortcuts.
370 : ///
371 : /// It must not be null. It will make cut, copy and paste functionality work
372 : /// with the most recently set [TextSelectionDelegate].
373 : TextSelectionDelegate textSelectionDelegate;
374 :
375 : /// Track whether position of the start of the selected text is within the viewport.
376 : ///
377 : /// For example, if the text contains "Hello World", and the user selects
378 : /// "Hello", then scrolls so only "World" is visible, this will become false.
379 : /// If the user scrolls back so that the "H" is visible again, this will
380 : /// become true.
381 : ///
382 : /// This bool indicates whether the text is scrolled so that the handle is
383 : /// inside the text field viewport, as opposed to whether it is actually
384 : /// visible on the screen.
385 1 : ValueListenable<bool> get selectionStartInViewport =>
386 1 : _selectionStartInViewport;
387 : final ValueNotifier<bool> _selectionStartInViewport =
388 : ValueNotifier<bool>(true);
389 :
390 : /// Track whether position of the end of the selected text is within the viewport.
391 : ///
392 : /// For example, if the text contains "Hello World", and the user selects
393 : /// "World", then scrolls so only "Hello" is visible, this will become
394 : /// 'false'. If the user scrolls back so that the "d" is visible again, this
395 : /// will become 'true'.
396 : ///
397 : /// This bool indicates whether the text is scrolled so that the handle is
398 : /// inside the text field viewport, as opposed to whether it is actually
399 : /// visible on the screen.
400 2 : ValueListenable<bool> get selectionEndInViewport => _selectionEndInViewport;
401 : final ValueNotifier<bool> _selectionEndInViewport = ValueNotifier<bool>(true);
402 :
403 3 : void _updateSelectionExtentsVisibility(Offset effectiveOffset) {
404 3 : assert(selection != null);
405 6 : final visibleRegion = Offset.zero & size;
406 :
407 6 : final startOffset = _textPainter.getOffsetForCaret(
408 15 : TextPosition(offset: selection!.start, affinity: selection!.affinity),
409 3 : _caretPrototype,
410 : );
411 : // TODO(justinmc): https://github.com/flutter/flutter/issues/31495
412 : // Check if the selection is visible with an approximation because a
413 : // difference between rounded and unrounded values causes the caret to be
414 : // reported as having a slightly (< 0.5) negative y offset. This rounding
415 : // happens in paragraph.cc's layout and TextPainer's
416 : // _applyFloatingPointHack. Ideally, the rounding mismatch will be fixed and
417 : // this can be changed to be a strict check instead of an approximation.
418 : const visibleRegionSlop = 0.5;
419 6 : _selectionStartInViewport.value = visibleRegion
420 3 : .inflate(visibleRegionSlop)
421 6 : .contains(startOffset + effectiveOffset);
422 :
423 6 : final endOffset = _textPainter.getOffsetForCaret(
424 15 : TextPosition(offset: selection!.end, affinity: selection!.affinity),
425 3 : _caretPrototype,
426 : );
427 6 : _selectionEndInViewport.value = visibleRegion
428 3 : .inflate(visibleRegionSlop)
429 6 : .contains(endOffset + effectiveOffset);
430 : }
431 :
432 : // Holds the last cursor location the user selected in the case the user tries
433 : // to select horizontally past the end or beginning of the field. If they do,
434 : // then we need to keep the old cursor location so that we can go back to it
435 : // if they change their minds. Only used for moving selection right and left
436 : // in a multiline text field when selecting using the keyboard.
437 : int _cursorResetLocation = -1;
438 :
439 : // Whether we should reset the location of the cursor in the case the user
440 : // tries to select horizontally past the end or beginning of the field. If they
441 : // do, then we need to keep the old cursor location so that we can go back to
442 : // it if they change their minds. Only used for resetting selection left and
443 : // right in a multiline text field when selecting using the keyboard.
444 : bool _wasSelectingHorizontallyWithKeyboard = false;
445 :
446 3 : void _setTextEditingValue(
447 : TextEditingValue newValue, SelectionChangedCause cause) {
448 6 : textSelectionDelegate.textEditingValue = newValue;
449 6 : textSelectionDelegate.userUpdateTextEditingValue(newValue, cause);
450 : }
451 :
452 3 : void _setSelection(TextSelection nextSelection, SelectionChangedCause cause) {
453 3 : if (nextSelection.isValid) {
454 : // The nextSelection is calculated based on _plainText, which can be out
455 : // of sync with the textSelectionDelegate.textEditingValue by one frame.
456 : // This is due to the render editable and editable text handle pointer
457 : // event separately. If the editable text changes the text during the
458 : // event handler, the render editable will use the outdated text stored in
459 : // the _plainText when handling the pointer event.
460 : //
461 : // If this happens, we need to make sure the new selection is still valid.
462 12 : final textLength = textSelectionDelegate.textEditingValue.text.length;
463 3 : nextSelection = nextSelection.copyWith(
464 6 : baseOffset: math.min(nextSelection.baseOffset, textLength),
465 6 : extentOffset: math.min(nextSelection.extentOffset, textLength),
466 : );
467 : }
468 3 : _handleSelectionChange(nextSelection, cause);
469 3 : _setTextEditingValue(
470 9 : textSelectionDelegate.textEditingValue.copyWith(selection: nextSelection),
471 : cause,
472 : );
473 : }
474 :
475 3 : void _handleSelectionChange(
476 : TextSelection nextSelection,
477 : SelectionChangedCause cause,
478 : ) {
479 : // Changes made by the keyboard can sometimes be "out of band" for listening
480 : // components, so always send those events, even if we didn't think it
481 : // changed. Also, focusing an empty field is sent as a selection change even
482 : // if the selection offset didn't change.
483 6 : final focusingEmpty = (nextSelection.baseOffset == 0) &&
484 6 : (nextSelection.extentOffset == 0) &&
485 3 : !hasFocus;
486 6 : if (nextSelection == selection &&
487 3 : cause != SelectionChangedCause.keyboard &&
488 : !focusingEmpty) {
489 : return;
490 : }
491 4 : onSelectionChanged?.call(nextSelection, this, cause);
492 : }
493 :
494 6 : static final Set<LogicalKeyboardKey> _movementKeys = <LogicalKeyboardKey>{
495 3 : LogicalKeyboardKey.arrowRight,
496 3 : LogicalKeyboardKey.arrowLeft,
497 3 : LogicalKeyboardKey.arrowUp,
498 3 : LogicalKeyboardKey.arrowDown,
499 : };
500 :
501 6 : static final Set<LogicalKeyboardKey> _shortcutKeys = <LogicalKeyboardKey>{
502 3 : LogicalKeyboardKey.keyA,
503 3 : LogicalKeyboardKey.keyC,
504 3 : LogicalKeyboardKey.keyV,
505 3 : LogicalKeyboardKey.keyX,
506 3 : LogicalKeyboardKey.delete,
507 3 : LogicalKeyboardKey.backspace,
508 : };
509 :
510 9 : static final Set<LogicalKeyboardKey> _nonModifierKeys = <LogicalKeyboardKey>{
511 3 : ..._shortcutKeys,
512 3 : ..._movementKeys,
513 : };
514 :
515 4 : static final Set<LogicalKeyboardKey> _modifierKeys = <LogicalKeyboardKey>{
516 2 : LogicalKeyboardKey.shift,
517 2 : LogicalKeyboardKey.control,
518 2 : LogicalKeyboardKey.alt,
519 : };
520 :
521 4 : static final Set<LogicalKeyboardKey> _macOsModifierKeys =
522 : <LogicalKeyboardKey>{
523 2 : LogicalKeyboardKey.shift,
524 2 : LogicalKeyboardKey.meta,
525 2 : LogicalKeyboardKey.alt,
526 : };
527 :
528 6 : static final Set<LogicalKeyboardKey> _interestingKeys = <LogicalKeyboardKey>{
529 2 : ..._modifierKeys,
530 2 : ..._macOsModifierKeys,
531 2 : ..._nonModifierKeys,
532 : };
533 :
534 3 : void _handleKeyEvent(RawKeyEvent keyEvent) {
535 : if (kIsWeb) {
536 : // On web platform, we should ignore the key because it's processed already.
537 : return;
538 : }
539 :
540 3 : if (keyEvent is! RawKeyDownEvent) return;
541 : final keysPressed =
542 9 : LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed);
543 3 : final key = keyEvent.logicalKey;
544 :
545 6 : final isMacOS = keyEvent.data is RawKeyEventDataMacOs;
546 6 : if (!_nonModifierKeys.contains(key) ||
547 : keysPressed
548 5 : .difference(isMacOS ? _macOsModifierKeys : _modifierKeys)
549 4 : .length >
550 : 1 ||
551 6 : keysPressed.difference(_interestingKeys).isNotEmpty) {
552 : // If the most recently pressed key isn't a non-modifier key, or more than
553 : // one non-modifier key is down, or keys other than the ones we're interested in
554 : // are pressed, just ignore the keypress.
555 : return;
556 : }
557 :
558 : // TODO(ianh): It seems to be entirely possible for the selection to be null here, but
559 : // all the keyboard handling functions assume it is not.
560 2 : assert(selection != null);
561 :
562 : final isShortcutModifierPressed =
563 3 : isMacOS ? keyEvent.isMetaPressed : keyEvent.isControlPressed;
564 4 : if (isShortcutModifierPressed && _shortcutKeys.contains(key)) {
565 : // _handleShortcuts depends on being started in the same stack invocation
566 : // as the _handleKeyEvent method
567 2 : _handleShortcuts(key);
568 2 : } else if (key == LogicalKeyboardKey.delete) {
569 2 : _handleDelete(forward: true);
570 1 : } else if (key == LogicalKeyboardKey.backspace) {
571 1 : _handleDelete(forward: false);
572 : }
573 : }
574 :
575 : /// Returns the index into the string of the next character boundary after the
576 : /// given index.
577 : ///
578 : /// The character boundary is determined by the characters package, so
579 : /// surrogate pairs and extended grapheme clusters are considered.
580 : ///
581 : /// The index must be between 0 and string.length, inclusive. If given
582 : /// string.length, string.length is returned.
583 : ///
584 : /// Setting includeWhitespace to false will only return the index of non-space
585 : /// characters.
586 2 : @visibleForTesting
587 : static int nextCharacter(int index, String string,
588 : [bool includeWhitespace = true]) {
589 7 : assert(index >= 0 && index <= string.length);
590 4 : if (index == string.length) {
591 1 : return string.length;
592 : }
593 :
594 : var count = 0;
595 6 : final remaining = string.characters.skipWhile((String currentString) {
596 2 : if (count <= index) {
597 4 : count += currentString.length;
598 : return true;
599 : }
600 : if (includeWhitespace) {
601 : return false;
602 : }
603 2 : return _isWhitespace(currentString.codeUnitAt(0));
604 : });
605 8 : return string.length - remaining.toString().length;
606 : }
607 :
608 : /// Returns the index into the string of the previous character boundary
609 : /// before the given index.
610 : ///
611 : /// The character boundary is determined by the characters package, so
612 : /// surrogate pairs and extended grapheme clusters are considered.
613 : ///
614 : /// The index must be between 0 and string.length, inclusive. If index is 0,
615 : /// 0 will be returned.
616 : ///
617 : /// Setting includeWhitespace to false will only return the index of non-space
618 : /// characters.
619 1 : @visibleForTesting
620 : static int previousCharacter(int index, String string,
621 : [bool includeWhitespace = true]) {
622 4 : assert(index >= 0 && index <= string.length);
623 1 : if (index == 0) {
624 : return 0;
625 : }
626 :
627 : var count = 0;
628 : int? lastNonWhitespace;
629 2 : for (final currentString in string.characters) {
630 : if (!includeWhitespace &&
631 1 : !_isWhitespace(
632 4 : currentString.characters.first.toString().codeUnitAt(0))) {
633 : lastNonWhitespace = count;
634 : }
635 3 : if (count + currentString.length >= index) {
636 : return includeWhitespace ? count : lastNonWhitespace ?? 0;
637 : }
638 2 : count += currentString.length;
639 : }
640 : return 0;
641 : }
642 :
643 : // Return a new selection that has been moved up once.
644 : //
645 : // If it can't be moved up, the original TextSelection is returned.
646 1 : static TextSelection _moveGivenSelectionUp(
647 : TextSelection selection, String text) {
648 : // If the selection is already all the way up, there is nothing to do.
649 3 : if (selection.isCollapsed && selection.extentOffset <= 0) {
650 : return selection;
651 : }
652 :
653 : int previousExtent;
654 3 : if (selection.start != selection.end) {
655 1 : previousExtent = selection.start;
656 : } else {
657 2 : previousExtent = previousCharacter(selection.extentOffset, text);
658 : }
659 1 : final newSelection = selection.copyWith(
660 : extentOffset: previousExtent,
661 : );
662 :
663 1 : final newOffset = newSelection.extentOffset;
664 2 : return TextSelection.fromPosition(TextPosition(offset: newOffset));
665 : }
666 :
667 : // Return a new selection that has been moved down once.
668 : //
669 : // If it can't be moved down, the original TextSelection is returned.
670 1 : static TextSelection _moveGivenSelectionDown(
671 : TextSelection selection, String text) {
672 : // If the selection is already all the way down, there is nothing to do.
673 4 : if (selection.isCollapsed && selection.extentOffset >= text.length) {
674 : return selection;
675 : }
676 :
677 : int nextExtent;
678 3 : if (selection.start != selection.end) {
679 1 : nextExtent = selection.end;
680 : } else {
681 2 : nextExtent = nextCharacter(selection.extentOffset, text);
682 : }
683 1 : final nextSelection = selection.copyWith(extentOffset: nextExtent);
684 :
685 1 : var newOffset = nextSelection.extentOffset;
686 3 : newOffset = nextSelection.baseOffset > nextSelection.extentOffset
687 0 : ? nextSelection.baseOffset
688 1 : : nextSelection.extentOffset;
689 2 : return TextSelection.fromPosition(TextPosition(offset: newOffset));
690 : }
691 :
692 : // Return the offset at the start of the nearest word above the given
693 : // offset.
694 1 : static int _getAboveByWord(MongolTextPainter textPainter, int offset,
695 : [bool includeWhitespace = true]) {
696 : // If the offset is already all the way at the top, there is nothing to do.
697 1 : if (offset <= 0) {
698 : return offset;
699 : }
700 :
701 : // If we can just return the start of the text without checking for a word.
702 1 : if (offset == 1) {
703 : return 0;
704 : }
705 :
706 2 : final text = textPainter.text!.toPlainText();
707 1 : final startPoint = previousCharacter(offset, text, includeWhitespace);
708 2 : final word = textPainter.getWordBoundary(TextPosition(offset: startPoint));
709 1 : return word.start;
710 : }
711 :
712 : // Return the offset at the end of the nearest word below the given
713 : // offset.
714 1 : static int _getBelowByWord(MongolTextPainter textPainter, int offset,
715 : [bool includeWhitespace = true]) {
716 : // If the selection is already all the way at the bottom, there is nothing to do.
717 2 : final text = textPainter.text!.toPlainText();
718 2 : if (offset == text.length) {
719 : return offset;
720 : }
721 :
722 : // If we can just return the end of the text without checking for a word.
723 5 : if (offset == text.length - 1 || offset == text.length) {
724 0 : return text.length;
725 : }
726 :
727 : final startPoint =
728 2 : includeWhitespace || !_isWhitespace(text.codeUnitAt(offset))
729 : ? offset
730 1 : : nextCharacter(offset, text, includeWhitespace);
731 : final nextWord =
732 2 : textPainter.getWordBoundary(TextPosition(offset: startPoint));
733 1 : return nextWord.end;
734 : }
735 :
736 : // Return the given TextSelection extended up to the beginning of the
737 : // nearest word.
738 : //
739 : // See extendSelectionUpByWord for a detailed explanation of the two
740 : // optional parameters.
741 0 : static TextSelection _extendGivenSelectionUpByWord(
742 : MongolTextPainter textPainter,
743 : TextSelection selection, [
744 : bool includeWhitespace = true,
745 : bool stopAtReversal = false,
746 : ]) {
747 : // If the selection is already all the way at the top, there is nothing to do.
748 0 : if (selection.isCollapsed && selection.extentOffset <= 0) {
749 : return selection;
750 : }
751 :
752 : final leftOffset =
753 0 : _getAboveByWord(textPainter, selection.extentOffset, includeWhitespace);
754 :
755 : if (stopAtReversal &&
756 0 : selection.extentOffset > selection.baseOffset &&
757 0 : leftOffset < selection.baseOffset) {
758 0 : return selection.copyWith(
759 0 : extentOffset: selection.baseOffset,
760 : );
761 : }
762 :
763 0 : return selection.copyWith(
764 : extentOffset: leftOffset,
765 : );
766 : }
767 :
768 : // Return the given TextSelection extended down to the end of the nearest
769 : // word.
770 : //
771 : // See extendSelectionDownByWord for a detailed explanation of the two
772 : // optional parameters.
773 0 : static TextSelection _extendGivenSelectionDownByWord(
774 : MongolTextPainter textPainter,
775 : TextSelection selection, [
776 : bool includeWhitespace = true,
777 : bool stopAtReversal = false,
778 : ]) {
779 : // If the selection is already all the way down, there is nothing to do.
780 0 : final text = textPainter.text!.toPlainText();
781 0 : if (selection.isCollapsed && selection.extentOffset == text.length) {
782 : return selection;
783 : }
784 :
785 : final rightOffset =
786 0 : _getBelowByWord(textPainter, selection.extentOffset, includeWhitespace);
787 :
788 : if (stopAtReversal &&
789 0 : selection.baseOffset > selection.extentOffset &&
790 0 : rightOffset > selection.baseOffset) {
791 0 : return selection.copyWith(
792 0 : extentOffset: selection.baseOffset,
793 : );
794 : }
795 :
796 0 : return selection.copyWith(
797 : extentOffset: rightOffset,
798 : );
799 : }
800 :
801 : // Return the given TextSelection moved up to the end of the nearest word.
802 : //
803 : // A TextSelection that isn't collapsed will be collapsed and moved from the
804 : // extentOffset.
805 1 : static TextSelection _moveGivenSelectionUpByWord(
806 : MongolTextPainter textPainter,
807 : TextSelection selection, [
808 : bool includeWhitespace = true,
809 : ]) {
810 : // If the selection is already all the way at the top, there is nothing to do.
811 3 : if (selection.isCollapsed && selection.extentOffset <= 0) {
812 : return selection;
813 : }
814 :
815 : final leftOffset =
816 2 : _getAboveByWord(textPainter, selection.extentOffset, includeWhitespace);
817 1 : return selection.copyWith(
818 : baseOffset: leftOffset,
819 : extentOffset: leftOffset,
820 : );
821 : }
822 :
823 : // Return the given TextSelection moved down to the end of the nearest word.
824 : //
825 : // A TextSelection that isn't collapsed will be collapsed and moved from the
826 : // extentOffset.
827 1 : static TextSelection _moveGivenSelectionDownByWord(
828 : MongolTextPainter textPainter,
829 : TextSelection selection, [
830 : bool includeWhitespace = true,
831 : ]) {
832 : // If the selection is already all the way at the bottom, there is nothing to do.
833 2 : final text = textPainter.text!.toPlainText();
834 4 : if (selection.isCollapsed && selection.extentOffset == text.length) {
835 : return selection;
836 : }
837 :
838 : final rightOffset =
839 2 : _getBelowByWord(textPainter, selection.extentOffset, includeWhitespace);
840 1 : return selection.copyWith(
841 : baseOffset: rightOffset,
842 : extentOffset: rightOffset,
843 : );
844 : }
845 :
846 1 : static TextSelection _extendGivenSelectionUp(
847 : TextSelection selection,
848 : String text, [
849 : bool includeWhitespace = true,
850 : ]) {
851 : // If the selection is already all the way at the top, there is nothing to do.
852 2 : if (selection.extentOffset <= 0) {
853 : return selection;
854 : }
855 : final previousExtent =
856 2 : previousCharacter(selection.extentOffset, text, includeWhitespace);
857 1 : return selection.copyWith(extentOffset: previousExtent);
858 : }
859 :
860 1 : static TextSelection _extendGivenSelectionDown(
861 : TextSelection selection,
862 : String text, [
863 : bool includeWhitespace = true,
864 : ]) {
865 : // If the selection is already all the way at the bottom, there is nothing to do.
866 3 : if (selection.extentOffset >= text.length) {
867 : return selection;
868 : }
869 : final nextExtent =
870 2 : nextCharacter(selection.extentOffset, text, includeWhitespace);
871 1 : return selection.copyWith(extentOffset: nextExtent);
872 : }
873 :
874 : // Extend the current selection to the end of the field.
875 : //
876 : // If selectionEnabled is false, keeps the selection collapsed and moves it to
877 : // the end.
878 : //
879 : // The given [SelectionChangedCause] indicates the cause of this change and
880 : // will be passed to [onSelectionChanged].
881 : //
882 : // See also:
883 : //
884 : // * _extendSelectionToStart
885 0 : void _extendSelectionToEnd(SelectionChangedCause cause) {
886 0 : if (selection!.extentOffset == _plainText.length) {
887 : return;
888 : }
889 0 : if (!selectionEnabled) {
890 0 : return moveSelectionToEnd(cause);
891 : }
892 :
893 0 : final nextSelection = selection!.copyWith(
894 0 : extentOffset: _plainText.length,
895 : );
896 0 : _setSelection(nextSelection, cause);
897 : }
898 :
899 : // Extend the current selection to the start of the field.
900 : //
901 : // If selectionEnabled is false, keeps the selection collapsed and moves it to
902 : // the start.
903 : //
904 : // The given [SelectionChangedCause] indicates the cause of this change and
905 : // will be passed to [onSelectionChanged].
906 : //
907 : // See also:
908 : //
909 : // * _expandSelectionToEnd
910 0 : void _extendSelectionToStart(SelectionChangedCause cause) {
911 0 : if (selection!.extentOffset == 0) {
912 : return;
913 : }
914 0 : if (!selectionEnabled) {
915 0 : return moveSelectionToStart(cause);
916 : }
917 :
918 0 : final nextSelection = selection!.copyWith(
919 : extentOffset: 0,
920 : );
921 0 : _setSelection(nextSelection, cause);
922 : }
923 :
924 : // Returns the TextPosition to the left or right of the given offset.
925 0 : TextPosition _getTextPositionHorizontal(
926 : int textOffset, double horizontalOffset) {
927 0 : final caretOffset = _textPainter.getOffsetForCaret(
928 0 : TextPosition(offset: textOffset), _caretPrototype);
929 0 : final caretOffsetTranslated = caretOffset.translate(horizontalOffset, 0.0);
930 0 : return _textPainter.getPositionForOffset(caretOffsetTranslated);
931 : }
932 :
933 : // Returns the TextPosition left of the given offset into _plainText.
934 : //
935 : // If the offset is already on the first line, the given offset will be
936 : // returned.
937 0 : TextPosition _getTextPositionLeft(int offset) {
938 : // The caret offset gives a location in the upper left hand corner of
939 : // the caret so the middle of the line to the left is a half line to the left of that
940 : // point and the line to the right is 1.5 lines to the right of that point.
941 0 : final preferredLineWidth = _textPainter.preferredLineWidth;
942 0 : final horizontalOffset = -0.5 * preferredLineWidth;
943 0 : return _getTextPositionHorizontal(offset, horizontalOffset);
944 : }
945 :
946 : // Returns the TextPosition to the right of the given offset into _plainText.
947 : //
948 : // If the offset is already on the last line, the given offset will be
949 : // returned.
950 0 : TextPosition _getTextPositionRight(int offset) {
951 : // The caret offset gives a location in the upper left hand corner of
952 : // the caret so the middle of the line to the left is a half line to the left of that
953 : // point and the line to the right is 1.5 lines to the right of that point.
954 0 : final preferredLineWidth = _textPainter.preferredLineWidth;
955 0 : final horizontalOffset = 1.5 * preferredLineWidth;
956 0 : return _getTextPositionHorizontal(offset, horizontalOffset);
957 : }
958 :
959 : // Deletes the text within `selection` if it's non-empty.
960 0 : void _deleteSelection(TextSelection selection, SelectionChangedCause cause) {
961 0 : assert(!selection.isCollapsed);
962 :
963 0 : if (_readOnly || !selection.isValid || selection.isCollapsed) {
964 : return;
965 : }
966 :
967 0 : final text = textSelectionDelegate.textEditingValue.text;
968 0 : final textBefore = selection.textBefore(text);
969 0 : final textAfter = selection.textAfter(text);
970 0 : final cursorPosition = math.min(selection.start, selection.end);
971 0 : final newSelection = TextSelection.collapsed(offset: cursorPosition);
972 0 : _setTextEditingValue(
973 0 : TextEditingValue(text: textBefore + textAfter, selection: newSelection),
974 : cause,
975 : );
976 : }
977 :
978 : // Deletes the current non-empty selection.
979 : //
980 : // Operates on the text/selection contained in textSelectionDelegate, and does
981 : // not depend on `MongolRenderEditable.selection`.
982 : //
983 : // If the selection is currently non-empty, this method deletes the selected
984 : // text and returns true. Otherwise this method does nothing and returns
985 : // false.
986 1 : bool _deleteNonEmptySelection(SelectionChangedCause cause) {
987 : // TODO(LongCatIsLooong): remove this method from `RenderEditable`
988 : // https://github.com/flutter/flutter/issues/80226.
989 1 : assert(!readOnly);
990 2 : final controllerValue = textSelectionDelegate.textEditingValue;
991 1 : final selection = controllerValue.selection;
992 1 : assert(selection.isValid);
993 :
994 1 : if (selection.isCollapsed) {
995 : return false;
996 : }
997 :
998 2 : final textBefore = selection.textBefore(controllerValue.text);
999 2 : final textAfter = selection.textAfter(controllerValue.text);
1000 2 : final newSelection = TextSelection.collapsed(offset: selection.start);
1001 1 : final composing = controllerValue.composing;
1002 1 : final newComposingRange = !composing.isValid || composing.isCollapsed
1003 : ? TextRange.empty
1004 0 : : TextRange(
1005 0 : start: composing.start -
1006 0 : (composing.start - selection.start)
1007 0 : .clamp(0, selection.end - selection.start),
1008 0 : end: composing.end -
1009 0 : (composing.end - selection.start)
1010 0 : .clamp(0, selection.end - selection.start),
1011 : );
1012 :
1013 1 : _setTextEditingValue(
1014 1 : TextEditingValue(
1015 1 : text: textBefore + textAfter,
1016 : selection: newSelection,
1017 : composing: newComposingRange,
1018 : ),
1019 : cause,
1020 : );
1021 : return true;
1022 : }
1023 :
1024 : // Deletes the from the current collapsed selection to the start of the field.
1025 : //
1026 : // The given SelectionChangedCause indicates the cause of this change and
1027 : // will be passed to onSelectionChanged.
1028 : //
1029 : // See also:
1030 : // * _deleteToEnd
1031 1 : void _deleteToStart(TextSelection selection, SelectionChangedCause cause) {
1032 1 : assert(selection.isCollapsed);
1033 :
1034 2 : if (_readOnly || !selection.isValid) {
1035 : return;
1036 : }
1037 :
1038 3 : final text = textSelectionDelegate.textEditingValue.text;
1039 1 : final textBefore = selection.textBefore(text);
1040 :
1041 1 : if (textBefore.isEmpty) {
1042 : return;
1043 : }
1044 :
1045 1 : final textAfter = selection.textAfter(text);
1046 : const newSelection = TextSelection.collapsed(offset: 0);
1047 1 : _setTextEditingValue(
1048 1 : TextEditingValue(text: textAfter, selection: newSelection),
1049 : cause,
1050 : );
1051 : }
1052 :
1053 : // Deletes the from the current collapsed selection to the end of the field.
1054 : //
1055 : // The given SelectionChangedCause indicates the cause of this change and
1056 : // will be passed to onSelectionChanged.
1057 : //
1058 : // See also:
1059 : // * _deleteToStart
1060 1 : void _deleteToEnd(TextSelection selection, SelectionChangedCause cause) {
1061 1 : assert(selection.isCollapsed);
1062 :
1063 2 : if (_readOnly || !selection.isValid) {
1064 : return;
1065 : }
1066 :
1067 3 : final text = textSelectionDelegate.textEditingValue.text;
1068 1 : final textAfter = selection.textAfter(text);
1069 :
1070 1 : if (textAfter.isEmpty) {
1071 : return;
1072 : }
1073 :
1074 1 : final textBefore = selection.textBefore(text);
1075 2 : final newSelection = TextSelection.collapsed(offset: textBefore.length);
1076 1 : _setTextEditingValue(
1077 1 : TextEditingValue(text: textBefore, selection: newSelection),
1078 : cause,
1079 : );
1080 : }
1081 :
1082 : /// Deletes backwards from the selection in [textSelectionDelegate].
1083 : ///
1084 : /// This method operates on the text/selection contained in
1085 : /// [textSelectionDelegate], and does not depend on [selection].
1086 : ///
1087 : /// If the selection is collapsed, deletes a single character before the
1088 : /// cursor.
1089 : ///
1090 : /// If the selection is not collapsed, deletes the selection.
1091 : ///
1092 : /// The given [SelectionChangedCause] indicates the cause of this change and
1093 : /// will be passed to [onSelectionChanged].
1094 : ///
1095 : /// See also:
1096 : ///
1097 : /// * [deleteForward], which is same but in the opposite direction.
1098 1 : void delete(SelectionChangedCause cause) {
1099 : // `delete` does not depend on the text layout, and the boundary analysis is
1100 : // done using the `previousCharacter` method instead of ICU, we can keep
1101 : // deleting without having to layout the text. For this reason, we can
1102 : // directly delete the character before the caret in the controller.
1103 : //
1104 : // TODO(LongCatIsLooong): remove this method from RenderEditable.
1105 : // https://github.com/flutter/flutter/issues/80226.
1106 2 : final controllerValue = textSelectionDelegate.textEditingValue;
1107 1 : final selection = controllerValue.selection;
1108 :
1109 3 : if (!selection.isValid || readOnly || _deleteNonEmptySelection(cause)) {
1110 : return;
1111 : }
1112 :
1113 1 : assert(selection.isCollapsed);
1114 2 : final textBefore = selection.textBefore(controllerValue.text);
1115 1 : if (textBefore.isEmpty) {
1116 : return;
1117 : }
1118 :
1119 2 : final textAfter = selection.textAfter(controllerValue.text);
1120 :
1121 2 : final characterBoundary = previousCharacter(textBefore.length, textBefore);
1122 1 : final newSelection = TextSelection.collapsed(offset: characterBoundary);
1123 1 : final composing = controllerValue.composing;
1124 2 : assert(textBefore.length >= characterBoundary);
1125 2 : final newComposingRange = !composing.isValid || composing.isCollapsed
1126 : ? TextRange.empty
1127 1 : : TextRange(
1128 2 : start: composing.start -
1129 2 : (composing.start - characterBoundary)
1130 3 : .clamp(0, textBefore.length - characterBoundary),
1131 2 : end: composing.end -
1132 2 : (composing.end - characterBoundary)
1133 3 : .clamp(0, textBefore.length - characterBoundary),
1134 : );
1135 :
1136 1 : _setTextEditingValue(
1137 1 : TextEditingValue(
1138 2 : text: textBefore.substring(0, characterBoundary) + textAfter,
1139 : selection: newSelection,
1140 : composing: newComposingRange,
1141 : ),
1142 : cause,
1143 : );
1144 : }
1145 :
1146 : /// Deletes a word backwards from the current selection.
1147 : ///
1148 : /// If the [selection] is collapsed, deletes a word before the cursor.
1149 : ///
1150 : /// If the [selection] is not collapsed, deletes the selection.
1151 : ///
1152 : /// If [obscureText] is true, it treats the whole text content as
1153 : /// a single word.
1154 : ///
1155 : /// By default, includeWhitespace is set to true, meaning that whitespace can
1156 : /// be considered a word in itself. If set to false, the selection will be
1157 : /// extended past any whitespace and the first word following the whitespace.
1158 : ///
1159 : /// See also:
1160 : ///
1161 : /// * [deleteForwardByWord], which is same but in the opposite direction.
1162 1 : void deleteByWord(SelectionChangedCause cause,
1163 : [bool includeWhitespace = true]) {
1164 1 : assert(_selection != null);
1165 :
1166 3 : if (_readOnly || !_selection!.isValid) {
1167 : return;
1168 : }
1169 :
1170 2 : if (!_selection!.isCollapsed) {
1171 0 : return _deleteSelection(_selection!, cause);
1172 : }
1173 :
1174 : // When the text is obscured, the whole thing is treated as one big line.
1175 1 : if (obscureText) {
1176 2 : return _deleteToStart(_selection!, cause);
1177 : }
1178 :
1179 3 : final text = textSelectionDelegate.textEditingValue.text;
1180 2 : var textBefore = _selection!.textBefore(text);
1181 1 : if (textBefore.isEmpty) {
1182 : return;
1183 : }
1184 :
1185 : final characterBoundary =
1186 3 : _getAboveByWord(_textPainter, textBefore.length, includeWhitespace);
1187 2 : textBefore = textBefore.trimRight().substring(0, characterBoundary);
1188 :
1189 2 : final textAfter = _selection!.textAfter(text);
1190 1 : final newSelection = TextSelection.collapsed(offset: characterBoundary);
1191 1 : _setTextEditingValue(
1192 2 : TextEditingValue(text: textBefore + textAfter, selection: newSelection),
1193 : cause,
1194 : );
1195 : }
1196 :
1197 : /// Deletes a line backwards from the current selection.
1198 : ///
1199 : /// If the [selection] is collapsed, deletes a line before the cursor.
1200 : ///
1201 : /// If the [selection] is not collapsed, deletes the selection.
1202 : ///
1203 : /// If [obscureText] is true, it treats the whole text content as
1204 : /// a single word.
1205 : ///
1206 : /// See also:
1207 : ///
1208 : /// * [deleteForwardByLine], which is same but in the opposite direction.
1209 1 : void deleteByLine(SelectionChangedCause cause) {
1210 1 : assert(_selection != null);
1211 :
1212 3 : if (_readOnly || !_selection!.isValid) {
1213 : return;
1214 : }
1215 :
1216 2 : if (!_selection!.isCollapsed) {
1217 0 : return _deleteSelection(_selection!, cause);
1218 : }
1219 :
1220 : // When the text is obscured, the whole thing is treated as one big line.
1221 1 : if (obscureText) {
1222 2 : return _deleteToStart(_selection!, cause);
1223 : }
1224 :
1225 3 : final text = textSelectionDelegate.textEditingValue.text;
1226 2 : var textBefore = _selection!.textBefore(text);
1227 1 : if (textBefore.isEmpty) {
1228 : return;
1229 : }
1230 :
1231 : // When there is a line break, line delete shouldn't do anything
1232 : final isPreviousCharacterBreakLine =
1233 4 : textBefore.codeUnitAt(textBefore.length - 1) == 0x0A;
1234 : if (isPreviousCharacterBreakLine) {
1235 : return;
1236 : }
1237 :
1238 4 : final line = _getLineAtOffset(TextPosition(offset: textBefore.length - 1));
1239 2 : textBefore = textBefore.substring(0, line.start);
1240 :
1241 2 : final textAfter = _selection!.textAfter(text);
1242 2 : final newSelection = TextSelection.collapsed(offset: textBefore.length);
1243 1 : _setTextEditingValue(
1244 2 : TextEditingValue(text: textBefore + textAfter, selection: newSelection),
1245 : cause,
1246 : );
1247 : }
1248 :
1249 : /// Deletes in the foward direction, from the current selection in
1250 : /// [textSelectionDelegate].
1251 : ///
1252 : /// This method operates on the text/selection contained in
1253 : /// [textSelectionDelegate], and does not depend on [selection].
1254 : ///
1255 : /// If the selection is collapsed, deletes a single character after the
1256 : /// cursor.
1257 : ///
1258 : /// If the selection is not collapsed, deletes the selection.
1259 : ///
1260 : /// See also:
1261 : ///
1262 : /// * [delete], which is same but in the opposite direction.
1263 1 : void deleteForward(SelectionChangedCause cause) {
1264 : // TODO(LongCatIsLooong): remove this method from RenderEditable.
1265 : // https://github.com/flutter/flutter/issues/80226.
1266 2 : final controllerValue = textSelectionDelegate.textEditingValue;
1267 1 : final selection = controllerValue.selection;
1268 :
1269 3 : if (!selection.isValid || _readOnly || _deleteNonEmptySelection(cause)) {
1270 : return;
1271 : }
1272 :
1273 1 : assert(selection.isCollapsed);
1274 2 : final textAfter = selection.textAfter(controllerValue.text);
1275 1 : if (textAfter.isEmpty) {
1276 : return;
1277 : }
1278 :
1279 2 : final textBefore = selection.textBefore(controllerValue.text);
1280 1 : final characterBoundary = nextCharacter(0, textAfter);
1281 1 : final composing = controllerValue.composing;
1282 2 : final newComposingRange = !composing.isValid || composing.isCollapsed
1283 : ? TextRange.empty
1284 1 : : TextRange(
1285 2 : start: composing.start -
1286 3 : (composing.start - textBefore.length)
1287 1 : .clamp(0, characterBoundary),
1288 2 : end: composing.end -
1289 4 : (composing.end - textBefore.length).clamp(0, characterBoundary),
1290 : );
1291 1 : _setTextEditingValue(
1292 1 : TextEditingValue(
1293 2 : text: textBefore + textAfter.substring(characterBoundary),
1294 : selection: selection,
1295 : composing: newComposingRange,
1296 : ),
1297 : cause,
1298 : );
1299 : }
1300 :
1301 : /// Deletes a word in the foward direction from the current selection.
1302 : ///
1303 : /// If the [selection] is collapsed, deletes a word after the cursor.
1304 : ///
1305 : /// If the [selection] is not collapsed, deletes the selection.
1306 : ///
1307 : /// If [obscureText] is true, it treats the whole text content as
1308 : /// a single word.
1309 : ///
1310 : /// See also:
1311 : ///
1312 : /// * [deleteByWord], which is same but in the opposite direction.
1313 1 : void deleteForwardByWord(SelectionChangedCause cause,
1314 : [bool includeWhitespace = true]) {
1315 1 : assert(_selection != null);
1316 :
1317 3 : if (_readOnly || !_selection!.isValid) {
1318 : return;
1319 : }
1320 :
1321 2 : if (!_selection!.isCollapsed) {
1322 0 : return _deleteSelection(_selection!, cause);
1323 : }
1324 :
1325 : // When the text is obscured, the whole thing is treated as one big word.
1326 1 : if (obscureText) {
1327 2 : return _deleteToEnd(_selection!, cause);
1328 : }
1329 :
1330 3 : final text = textSelectionDelegate.textEditingValue.text;
1331 2 : var textAfter = _selection!.textAfter(text);
1332 :
1333 1 : if (textAfter.isEmpty) {
1334 : return;
1335 : }
1336 :
1337 2 : final textBefore = _selection!.textBefore(text);
1338 : final characterBoundary =
1339 3 : _getBelowByWord(_textPainter, textBefore.length, includeWhitespace);
1340 3 : textAfter = textAfter.substring(characterBoundary - textBefore.length);
1341 :
1342 1 : _setTextEditingValue(
1343 3 : TextEditingValue(text: textBefore + textAfter, selection: _selection!),
1344 : cause,
1345 : );
1346 : }
1347 :
1348 : /// Deletes a line in the foward direction from the current selection.
1349 : ///
1350 : /// If the [selection] is collapsed, deletes a line after the cursor.
1351 : ///
1352 : /// If the [selection] is not collapsed, deletes the selection.
1353 : ///
1354 : /// If [obscureText] is true, it treats the whole text content as
1355 : /// a single word.
1356 : ///
1357 : /// See also:
1358 : ///
1359 : /// * [deleteByLine], which is same but in the opposite direction.
1360 1 : void deleteForwardByLine(SelectionChangedCause cause) {
1361 1 : assert(_selection != null);
1362 :
1363 3 : if (_readOnly || !_selection!.isValid) {
1364 : return;
1365 : }
1366 :
1367 2 : if (!_selection!.isCollapsed) {
1368 0 : return _deleteSelection(_selection!, cause);
1369 : }
1370 :
1371 : // When the text is obscured, the whole thing is treated as one big line.
1372 1 : if (obscureText) {
1373 2 : return _deleteToEnd(_selection!, cause);
1374 : }
1375 :
1376 3 : final text = textSelectionDelegate.textEditingValue.text;
1377 2 : var textAfter = _selection!.textAfter(text);
1378 1 : if (textAfter.isEmpty) {
1379 : return;
1380 : }
1381 :
1382 : // When there is a line break, it shouldn't do anything.
1383 2 : final isNextCharacterBreakLine = textAfter.codeUnitAt(0) == 0x0A;
1384 : if (isNextCharacterBreakLine) {
1385 : return;
1386 : }
1387 :
1388 2 : final textBefore = _selection!.textBefore(text);
1389 3 : final line = _getLineAtOffset(TextPosition(offset: textBefore.length));
1390 : textAfter =
1391 5 : textAfter.substring(line.end - textBefore.length, textAfter.length);
1392 :
1393 1 : _setTextEditingValue(
1394 3 : TextEditingValue(text: textBefore + textAfter, selection: _selection!),
1395 : cause,
1396 : );
1397 : }
1398 :
1399 : /// Keeping [selection]'s [TextSelection.baseOffset] fixed, move the
1400 : /// [TextSelection.extentOffset] right by one line.
1401 : ///
1402 : /// If [selectionEnabled] is false, keeps the selection collapsed and just
1403 : /// moves it right.
1404 : ///
1405 : /// The given [SelectionChangedCause] indicates the cause of this change and
1406 : /// will be passed to [onSelectionChanged].
1407 : ///
1408 : /// See also:
1409 : ///
1410 : /// * [extendSelectionUp], which is same but in the opposite direction.
1411 0 : void extendSelectionRight(SelectionChangedCause cause) {
1412 0 : assert(selection != null);
1413 :
1414 : // If the selection is collapsed at the end of the field already, then
1415 : // nothing happens.
1416 0 : if (selection!.isCollapsed &&
1417 0 : selection!.extentOffset >= _plainText.length) {
1418 : return;
1419 : }
1420 0 : if (!selectionEnabled) {
1421 0 : return moveSelectionRight(cause);
1422 : }
1423 :
1424 0 : final positionBelow = _getTextPositionRight(selection!.extentOffset);
1425 : late final TextSelection nextSelection;
1426 0 : if (positionBelow.offset == selection!.extentOffset) {
1427 0 : nextSelection = selection!.copyWith(
1428 0 : extentOffset: _plainText.length,
1429 : );
1430 0 : _wasSelectingHorizontallyWithKeyboard = true;
1431 0 : } else if (_wasSelectingHorizontallyWithKeyboard) {
1432 0 : nextSelection = selection!.copyWith(
1433 0 : extentOffset: _cursorResetLocation,
1434 : );
1435 0 : _wasSelectingHorizontallyWithKeyboard = false;
1436 : } else {
1437 0 : nextSelection = selection!.copyWith(
1438 0 : extentOffset: positionBelow.offset,
1439 : );
1440 0 : _cursorResetLocation = nextSelection.extentOffset;
1441 : }
1442 :
1443 0 : _setSelection(nextSelection, cause);
1444 : }
1445 :
1446 : /// Expand the current [selection] to the end of the field.
1447 : ///
1448 : /// The selection will never shrink. The [TextSelection.extentOffset] will
1449 : // always be at the end of the field, regardless of the original order of
1450 : /// [TextSelection.baseOffset] and [TextSelection.extentOffset].
1451 : ///
1452 : /// If [selectionEnabled] is false, keeps the selection collapsed and moves it
1453 : /// to the end.
1454 : ///
1455 : /// See also:
1456 : ///
1457 : /// * [expandSelectionToStart], which is same but in the opposite direction.
1458 0 : void expandSelectionToEnd(SelectionChangedCause cause) {
1459 0 : assert(selection != null);
1460 :
1461 0 : if (selection!.extentOffset == _plainText.length) {
1462 : return;
1463 : }
1464 0 : if (!selectionEnabled) {
1465 0 : return moveSelectionToEnd(cause);
1466 : }
1467 :
1468 0 : final firstOffset = math.max(
1469 : 0,
1470 0 : math.min(
1471 0 : selection!.baseOffset,
1472 0 : selection!.extentOffset,
1473 : ));
1474 0 : final nextSelection = TextSelection(
1475 : baseOffset: firstOffset,
1476 0 : extentOffset: _plainText.length,
1477 : );
1478 0 : _setSelection(nextSelection, cause);
1479 : }
1480 :
1481 : /// Keeping [selection]'s [TextSelection.baseOffset] fixed, move the
1482 : /// [TextSelection.extentOffset] up.
1483 : ///
1484 : /// If [selectionEnabled] is false, keeps the selection collapsed and moves it
1485 : /// up.
1486 : ///
1487 : /// See also:
1488 : ///
1489 : /// * [extendSelectionDown], which is same but in the opposite direction.
1490 1 : void extendSelectionUp(SelectionChangedCause cause) {
1491 1 : assert(selection != null);
1492 :
1493 1 : if (!selectionEnabled) {
1494 0 : return moveSelectionUp(cause);
1495 : }
1496 :
1497 1 : final nextSelection = _extendGivenSelectionUp(
1498 1 : selection!,
1499 1 : _plainText,
1500 : );
1501 2 : if (nextSelection == selection) {
1502 : return;
1503 : }
1504 4 : final distance = selection!.extentOffset - nextSelection.extentOffset;
1505 2 : _cursorResetLocation -= distance;
1506 1 : _setSelection(nextSelection, cause);
1507 : }
1508 :
1509 : /// Extend the current [selection] to the start of
1510 : /// [TextSelection.extentOffset]'s line.
1511 : ///
1512 : /// Uses [TextSelection.baseOffset] as a pivot point and doesn't change it.
1513 : /// If [TextSelection.extentOffset] is below [TextSelection.baseOffset],
1514 : /// then collapses the selection.
1515 : ///
1516 : /// If [selectionEnabled] is false, keeps the selection collapsed and moves it
1517 : /// up by line.
1518 : ///
1519 : /// See also:
1520 : ///
1521 : /// * [extendSelectionDownByLine], which is same but in the opposite
1522 : /// direction.
1523 : /// * [expandSelectionDownByLine], which strictly grows the selection
1524 : /// regardless of the order.
1525 0 : void extendSelectionUpByLine(SelectionChangedCause cause) {
1526 0 : assert(selection != null);
1527 :
1528 0 : if (!selectionEnabled) {
1529 0 : return moveSelectionUpByLine(cause);
1530 : }
1531 :
1532 : // When going up, we want to skip over any whitespace before the line,
1533 : // so we go back to the first non-whitespace before asking for the line
1534 : // bounds, since _getLineAtOffset finds the line boundaries without
1535 : // including whitespace (like the newline).
1536 : final startPoint =
1537 0 : previousCharacter(selection!.extentOffset, _plainText, false);
1538 0 : final selectedLine = _getLineAtOffset(TextPosition(offset: startPoint));
1539 :
1540 : late final TextSelection nextSelection;
1541 0 : if (selection!.extentOffset > selection!.baseOffset) {
1542 0 : nextSelection = selection!.copyWith(
1543 0 : extentOffset: selection!.baseOffset,
1544 : );
1545 : } else {
1546 0 : nextSelection = selection!.copyWith(
1547 0 : extentOffset: selectedLine.baseOffset,
1548 : );
1549 : }
1550 :
1551 0 : _setSelection(nextSelection, cause);
1552 : }
1553 :
1554 : /// Keeping [selection]'s [TextSelection.baseOffset] fixed, move the
1555 : /// [TextSelection.extentOffset] down.
1556 : ///
1557 : /// If [selectionEnabled] is false, keeps the selection collapsed and moves it
1558 : /// down.
1559 : ///
1560 : /// See also:
1561 : ///
1562 : /// * [extendSelectionUp], which is same but in the opposite direction.
1563 1 : void extendSelectionDown(SelectionChangedCause cause) {
1564 1 : assert(selection != null);
1565 :
1566 1 : if (!selectionEnabled) {
1567 0 : return moveSelectionDown(cause);
1568 : }
1569 :
1570 1 : final nextSelection = _extendGivenSelectionDown(
1571 1 : selection!,
1572 1 : _plainText,
1573 : );
1574 2 : if (nextSelection == selection) {
1575 : return;
1576 : }
1577 4 : final distance = nextSelection.extentOffset - selection!.extentOffset;
1578 2 : _cursorResetLocation += distance;
1579 1 : _setSelection(nextSelection, cause);
1580 : }
1581 :
1582 : /// Extend the current [selection] to the end of [TextSelection.extentOffset]'s
1583 : /// line.
1584 : ///
1585 : /// Uses [TextSelection.baseOffset] as a pivot point and doesn't change it. If
1586 : /// [TextSelection.extentOffset] is above [TextSelection.baseOffset], then
1587 : /// collapses the selection.
1588 : ///
1589 : /// If [selectionEnabled] is false, keeps the selection collapsed and moves it
1590 : /// down by line.
1591 : ///
1592 : /// See also:
1593 : ///
1594 : /// * [extendSelectionUpByLine], which is same but in the opposite
1595 : /// direction.
1596 : /// * [expandSelectionDownByLine], which strictly grows the selection
1597 : /// regardless of the order.
1598 0 : void extendSelectionDownByLine(SelectionChangedCause cause) {
1599 0 : assert(selection != null);
1600 :
1601 0 : if (!selectionEnabled) {
1602 0 : return moveSelectionDownByLine(cause);
1603 : }
1604 :
1605 : final startPoint =
1606 0 : nextCharacter(selection!.extentOffset, _plainText, false);
1607 0 : final selectedLine = _getLineAtOffset(TextPosition(offset: startPoint));
1608 :
1609 : late final TextSelection nextSelection;
1610 0 : if (selection!.extentOffset < selection!.baseOffset) {
1611 0 : nextSelection = selection!.copyWith(
1612 0 : extentOffset: selection!.baseOffset,
1613 : );
1614 : } else {
1615 0 : nextSelection = selection!.copyWith(
1616 0 : extentOffset: selectedLine.extentOffset,
1617 : );
1618 : }
1619 :
1620 0 : _setSelection(nextSelection, cause);
1621 : }
1622 :
1623 : /// Keeping [selection]'s [TextSelection.baseOffset] fixed, move the
1624 : /// [TextSelection.extentOffset] left by one
1625 : /// line.
1626 : ///
1627 : /// If [selectionEnabled] is false, keeps the selection collapsed and moves it
1628 : /// left.
1629 : ///
1630 : /// See also:
1631 : ///
1632 : /// * [extendSelectionLeft], which is the same but in the opposite
1633 : /// direction.
1634 0 : void extendSelectionLeft(SelectionChangedCause cause) {
1635 0 : assert(selection != null);
1636 :
1637 : // If the selection is collapsed at the beginning of the field already, then
1638 : // nothing happens.
1639 0 : if (selection!.isCollapsed && selection!.extentOffset <= 0.0) {
1640 : return;
1641 : }
1642 0 : if (!selectionEnabled) {
1643 0 : return moveSelectionLeft(cause);
1644 : }
1645 :
1646 0 : final positionLeft = _getTextPositionLeft(selection!.extentOffset);
1647 : late final TextSelection nextSelection;
1648 0 : if (positionLeft.offset == selection!.extentOffset) {
1649 0 : nextSelection = selection!.copyWith(
1650 : extentOffset: 0,
1651 : );
1652 0 : _wasSelectingHorizontallyWithKeyboard = true;
1653 0 : } else if (_wasSelectingHorizontallyWithKeyboard) {
1654 0 : nextSelection = selection!.copyWith(
1655 0 : baseOffset: selection!.baseOffset,
1656 0 : extentOffset: _cursorResetLocation,
1657 : );
1658 0 : _wasSelectingHorizontallyWithKeyboard = false;
1659 : } else {
1660 0 : nextSelection = selection!.copyWith(
1661 0 : baseOffset: selection!.baseOffset,
1662 0 : extentOffset: positionLeft.offset,
1663 : );
1664 0 : _cursorResetLocation = nextSelection.extentOffset;
1665 : }
1666 :
1667 0 : _setSelection(nextSelection, cause);
1668 : }
1669 :
1670 : /// Expand the current [selection] to the start of the field.
1671 : ///
1672 : /// The selection will never shrink. The [TextSelection.extentOffset] will
1673 : /// always be at the start of the field, regardless of the original order of
1674 : /// [TextSelection.baseOffset] and [TextSelection.extentOffset].
1675 : ///
1676 : /// If [selectionEnabled] is false, keeps the selection collapsed and moves it
1677 : /// to the start.
1678 : ///
1679 : /// See also:
1680 : ///
1681 : /// * [expandSelectionToEnd], which is the same but in the opposite
1682 : /// direction.
1683 0 : void expandSelectionToStart(SelectionChangedCause cause) {
1684 0 : assert(selection != null);
1685 :
1686 0 : if (selection!.extentOffset == 0) {
1687 : return;
1688 : }
1689 0 : if (!selectionEnabled) {
1690 0 : return moveSelectionToStart(cause);
1691 : }
1692 :
1693 0 : final lastOffset = math.max(
1694 : 0,
1695 0 : math.max(
1696 0 : selection!.baseOffset,
1697 0 : selection!.extentOffset,
1698 : ));
1699 0 : final nextSelection = TextSelection(
1700 : baseOffset: lastOffset,
1701 : extentOffset: 0,
1702 : );
1703 0 : _setSelection(nextSelection, cause);
1704 : }
1705 :
1706 : /// Expand the current [selection] to the start of the line.
1707 : ///
1708 : /// The selection will never shrink. The upper offset will be expanded to the
1709 : /// beginning of its line, and the original order of baseOffset and
1710 : /// [TextSelection.extentOffset] will be preserved.
1711 : ///
1712 : /// If [selectionEnabled] is false, keeps the selection collapsed and moves it
1713 : /// left by line.
1714 : ///
1715 : /// See also:
1716 : ///
1717 : /// * [expandSelectionDownByLine], which is the same but in the opposite
1718 : /// direction.
1719 0 : void expandSelectionUpByLine(SelectionChangedCause cause) {
1720 0 : assert(selection != null);
1721 :
1722 0 : if (!selectionEnabled) {
1723 0 : return moveSelectionUpByLine(cause);
1724 : }
1725 :
1726 : final firstOffset =
1727 0 : math.min(selection!.baseOffset, selection!.extentOffset);
1728 0 : final startPoint = previousCharacter(firstOffset, _plainText, false);
1729 0 : final selectedLine = _getLineAtOffset(TextPosition(offset: startPoint));
1730 :
1731 : late final TextSelection nextSelection;
1732 0 : if (selection!.extentOffset <= selection!.baseOffset) {
1733 0 : nextSelection = selection!.copyWith(
1734 0 : extentOffset: selectedLine.baseOffset,
1735 : );
1736 : } else {
1737 0 : nextSelection = selection!.copyWith(
1738 0 : baseOffset: selectedLine.baseOffset,
1739 : );
1740 : }
1741 :
1742 0 : _setSelection(nextSelection, cause);
1743 : }
1744 :
1745 : /// Extend the current [selection] to the previous start of a word.
1746 : ///
1747 : /// By default, `includeWhitespace` is set to true, meaning that whitespace
1748 : /// can be considered a word in itself. If set to false, the selection will
1749 : /// be extended past any whitespace and the first word following the
1750 : /// whitespace.
1751 : ///
1752 : /// The `stopAtReversal` parameter is false by default, meaning that it's
1753 : /// ok for the base and extent to flip their order here. If set to true, then
1754 : /// the selection will collapse when it would otherwise reverse its order. A
1755 : /// selection that is already collapsed is not affected by this parameter.
1756 : ///
1757 : /// See also:
1758 : ///
1759 : /// * [extendSelectionDownByWord], which is the same but in the opposite
1760 : /// direction.
1761 0 : void extendSelectionUpByWord(
1762 : SelectionChangedCause cause, [
1763 : bool includeWhitespace = true,
1764 : bool stopAtReversal = false,
1765 : ]) {
1766 0 : assert(selection != null);
1767 :
1768 : // When the text is obscured, the whole thing is treated as one big word.
1769 0 : if (obscureText) {
1770 0 : return _extendSelectionToStart(cause);
1771 : }
1772 :
1773 : assert(
1774 0 : _textLayoutLastMaxHeight == constraints.maxHeight &&
1775 0 : _textLayoutLastMinHeight == constraints.minHeight,
1776 0 : 'Last height ($_textLayoutLastMinHeight, $_textLayoutLastMaxHeight) not the same as max height constraint (${constraints.minHeight}, ${constraints.maxHeight}).',
1777 : );
1778 0 : final nextSelection = _extendGivenSelectionUpByWord(
1779 0 : _textPainter,
1780 0 : selection!,
1781 : includeWhitespace,
1782 : stopAtReversal,
1783 : );
1784 0 : if (nextSelection == selection) {
1785 : return;
1786 : }
1787 0 : _setSelection(nextSelection, cause);
1788 : }
1789 :
1790 : /// Extend the current [selection] to the next end of a word.
1791 : ///
1792 : /// By default, `includeWhitespace` is set to true, meaning that whitespace
1793 : /// can be considered a word in itself. If set to false, the selection will
1794 : /// be extended past any whitespace and the first word following the
1795 : /// whitespace.
1796 : ///
1797 : /// See also:
1798 : ///
1799 : /// * [extendSelectionUpByWord], which is the same but in the opposite
1800 : /// direction.
1801 0 : void extendSelectionDownByWord(
1802 : SelectionChangedCause cause, [
1803 : bool includeWhitespace = true,
1804 : bool stopAtReversal = false,
1805 : ]) {
1806 0 : assert(selection != null);
1807 :
1808 : // When the text is obscured, the whole thing is treated as one big word.
1809 0 : if (obscureText) {
1810 0 : return _extendSelectionToEnd(cause);
1811 : }
1812 :
1813 : assert(
1814 0 : _textLayoutLastMaxHeight == constraints.maxHeight &&
1815 0 : _textLayoutLastMinHeight == constraints.minHeight,
1816 0 : 'Last height ($_textLayoutLastMinHeight, $_textLayoutLastMaxHeight) not the same as max height constraint (${constraints.minHeight}, ${constraints.maxHeight}).',
1817 : );
1818 0 : final nextSelection = _extendGivenSelectionDownByWord(
1819 0 : _textPainter,
1820 0 : selection!,
1821 : includeWhitespace,
1822 : stopAtReversal,
1823 : );
1824 0 : if (nextSelection == selection) {
1825 : return;
1826 : }
1827 0 : _setSelection(nextSelection, cause);
1828 : }
1829 :
1830 : /// Expand the current [selection] to the end of the line.
1831 : ///
1832 : /// The selection will never shrink. The lower offset will be expanded to the
1833 : /// end of its line and the original order of [TextSelection.baseOffset] and
1834 : /// [TextSelection.extentOffset] will be preserved.
1835 : ///
1836 : /// If [selectionEnabled] is false, keeps the selection collapsed and moves it
1837 : /// down by line.
1838 : ///
1839 : /// See also:
1840 : ///
1841 : /// * [expandSelectionUpByLine], which is the same but in the opposite
1842 : /// direction.
1843 0 : void expandSelectionDownByLine(SelectionChangedCause cause) {
1844 0 : assert(selection != null);
1845 :
1846 0 : if (!selectionEnabled) {
1847 0 : return moveSelectionDownByLine(cause);
1848 : }
1849 :
1850 0 : final lastOffset = math.max(selection!.baseOffset, selection!.extentOffset);
1851 0 : final startPoint = nextCharacter(lastOffset, _plainText, false);
1852 0 : final selectedLine = _getLineAtOffset(TextPosition(offset: startPoint));
1853 :
1854 : late final TextSelection nextSelection;
1855 0 : if (selection!.extentOffset >= selection!.baseOffset) {
1856 0 : nextSelection = selection!.copyWith(
1857 0 : extentOffset: selectedLine.extentOffset,
1858 : );
1859 : } else {
1860 0 : nextSelection = selection!.copyWith(
1861 0 : baseOffset: selectedLine.extentOffset,
1862 : );
1863 : }
1864 :
1865 0 : _setSelection(nextSelection, cause);
1866 : }
1867 :
1868 : /// Move the current [selection] to the next line.
1869 : ///
1870 : /// See also:
1871 : ///
1872 : /// * [moveSelectionLeft], which is the same but in the opposite direction.
1873 0 : void moveSelectionRight(SelectionChangedCause cause) {
1874 0 : assert(selection != null);
1875 :
1876 : // If the selection is collapsed at the end of the field already, then
1877 : // nothing happens.
1878 0 : if (selection!.isCollapsed &&
1879 0 : selection!.extentOffset >= _plainText.length) {
1880 : return;
1881 : }
1882 :
1883 0 : final positionRight = _getTextPositionRight(selection!.extentOffset);
1884 :
1885 : late final TextSelection nextSelection;
1886 0 : if (positionRight.offset == selection!.extentOffset) {
1887 0 : nextSelection = selection!.copyWith(
1888 0 : baseOffset: _plainText.length,
1889 0 : extentOffset: _plainText.length,
1890 : );
1891 0 : _wasSelectingHorizontallyWithKeyboard = false;
1892 : } else {
1893 0 : nextSelection = TextSelection.fromPosition(positionRight);
1894 0 : _cursorResetLocation = nextSelection.extentOffset;
1895 : }
1896 :
1897 0 : _setSelection(nextSelection, cause);
1898 : }
1899 :
1900 : /// Move the current [selection] up by one character.
1901 : ///
1902 : /// See also:
1903 : ///
1904 : /// * [moveSelectionDown], which is the same but in the opposite direction.
1905 1 : void moveSelectionUp(SelectionChangedCause cause) {
1906 1 : assert(selection != null);
1907 :
1908 1 : final nextSelection = _moveGivenSelectionUp(
1909 1 : selection!,
1910 1 : _plainText,
1911 : );
1912 2 : if (nextSelection == selection) {
1913 : return;
1914 : }
1915 2 : _cursorResetLocation -=
1916 4 : selection!.extentOffset - nextSelection.extentOffset;
1917 1 : _setSelection(nextSelection, cause);
1918 : }
1919 :
1920 : /// Move the current [selection] to the top of the current line.
1921 : ///
1922 : /// See also:
1923 : ///
1924 : /// * [moveSelectionDownByLine], which is the same but in the opposite
1925 : /// direction.
1926 1 : void moveSelectionUpByLine(SelectionChangedCause cause) {
1927 1 : assert(selection != null);
1928 :
1929 : // If the previous character is the edge of a line, don't do anything.
1930 : final previousPoint =
1931 4 : previousCharacter(selection!.extentOffset, _plainText, true);
1932 2 : final line = _getLineAtOffset(TextPosition(offset: previousPoint));
1933 2 : if (line.extentOffset == previousPoint) {
1934 : return;
1935 : }
1936 :
1937 : // When going up, we want to skip over any whitespace before the line,
1938 : // so we go back to the first non-whitespace before asking for the line
1939 : // bounds, since _getLineAtOffset finds the line boundaries without
1940 : // including whitespace (like the newline).
1941 : final startPoint =
1942 4 : previousCharacter(selection!.extentOffset, _plainText, false);
1943 2 : final selectedLine = _getLineAtOffset(TextPosition(offset: startPoint));
1944 1 : final nextSelection = TextSelection.collapsed(
1945 1 : offset: selectedLine.baseOffset,
1946 : );
1947 :
1948 1 : _setSelection(nextSelection, cause);
1949 : }
1950 :
1951 : /// Move the current [selection] to the previous start of a word.
1952 : ///
1953 : /// By default, includeWhitespace is set to true, meaning that whitespace can
1954 : /// be considered a word in itself. If set to false, the selection will be
1955 : /// moved past any whitespace and the first word following the whitespace.
1956 : ///
1957 : /// See also:
1958 : ///
1959 : /// * [moveSelectionDownByWord], which is the same but in the opposite
1960 : /// direction.
1961 1 : void moveSelectionUpByWord(SelectionChangedCause cause,
1962 : [bool includeWhitespace = true]) {
1963 1 : assert(selection != null);
1964 :
1965 : // When the text is obscured, the whole thing is treated as one big word.
1966 1 : if (obscureText) {
1967 0 : return moveSelectionToStart(cause);
1968 : }
1969 :
1970 : assert(
1971 4 : _textLayoutLastMaxHeight == constraints.maxHeight &&
1972 4 : _textLayoutLastMinHeight == constraints.minHeight,
1973 0 : 'Last height ($_textLayoutLastMinHeight, $_textLayoutLastMaxHeight) not the same as max height constraint (${constraints.minHeight}, ${constraints.maxHeight}).',
1974 : );
1975 1 : final nextSelection = _moveGivenSelectionUpByWord(
1976 1 : _textPainter,
1977 1 : selection!,
1978 : includeWhitespace,
1979 : );
1980 2 : if (nextSelection == selection) {
1981 : return;
1982 : }
1983 1 : _setSelection(nextSelection, cause);
1984 : }
1985 :
1986 : /// Move the current [selection] down by one character.
1987 : ///
1988 : /// See also:
1989 : ///
1990 : /// * [moveSelectionUp], which is the same but in the opposite direction.
1991 1 : void moveSelectionDown(SelectionChangedCause cause) {
1992 1 : assert(selection != null);
1993 :
1994 1 : final nextSelection = _moveGivenSelectionDown(
1995 1 : selection!,
1996 1 : _plainText,
1997 : );
1998 2 : if (nextSelection == selection) {
1999 : return;
2000 : }
2001 1 : _setSelection(nextSelection, cause);
2002 : }
2003 :
2004 : /// Move the current [selection] to the bottommost point of the current line.
2005 : ///
2006 : /// See also:
2007 : ///
2008 : /// * [moveSelectionUpByLine], which is the same but in the opposite
2009 : /// direction.
2010 1 : void moveSelectionDownByLine(SelectionChangedCause cause) {
2011 1 : assert(selection != null);
2012 :
2013 : // If already at the bottom edge of the line, do nothing.
2014 2 : final currentLine = _getLineAtOffset(TextPosition(
2015 2 : offset: selection!.extentOffset,
2016 : ));
2017 4 : if (currentLine.extentOffset == selection!.extentOffset) {
2018 : return;
2019 : }
2020 :
2021 : // When going down, we want to skip over any whitespace after the line,
2022 : // so we go forward to the first non-whitespace character before asking
2023 : // for the line bounds, since _getLineAtOffset finds the line
2024 : // boundaries without including whitespace (like the newline).
2025 : final startPoint =
2026 4 : nextCharacter(selection!.extentOffset, _plainText, false);
2027 2 : final selectedLine = _getLineAtOffset(TextPosition(offset: startPoint));
2028 1 : final nextSelection = TextSelection.collapsed(
2029 1 : offset: selectedLine.extentOffset,
2030 : );
2031 :
2032 1 : _setSelection(nextSelection, cause);
2033 : }
2034 :
2035 : /// Move the current [selection] to the next end of a word.
2036 : ///
2037 : /// By default, includeWhitespace is set to true, meaning that whitespace can
2038 : /// be considered a word in itself. If set to false, the selection will be
2039 : /// moved past any whitespace and the first word following the whitespace.
2040 : ///
2041 : /// See also:
2042 : ///
2043 : /// * [moveSelectionUpByWord], which is the same but in the opposite
2044 : /// direction.
2045 1 : void moveSelectionDownByWord(
2046 : SelectionChangedCause cause, [
2047 : bool includeWhitespace = true,
2048 : ]) {
2049 1 : assert(selection != null);
2050 :
2051 : // When the text is obscured, the whole thing is treated as one big word.
2052 1 : if (obscureText) {
2053 0 : return moveSelectionToEnd(cause);
2054 : }
2055 :
2056 : assert(
2057 4 : _textLayoutLastMaxHeight == constraints.maxHeight &&
2058 4 : _textLayoutLastMinHeight == constraints.minHeight,
2059 0 : 'Last height ($_textLayoutLastMinHeight, $_textLayoutLastMaxHeight) not the same as max height constraint (${constraints.minHeight}, ${constraints.maxHeight}).',
2060 : );
2061 1 : final nextSelection = _moveGivenSelectionDownByWord(
2062 1 : _textPainter,
2063 1 : selection!,
2064 : includeWhitespace,
2065 : );
2066 2 : if (nextSelection == selection) {
2067 : return;
2068 : }
2069 1 : _setSelection(nextSelection, cause);
2070 : }
2071 :
2072 : /// Move the current [selection] to the end of the field.
2073 : ///
2074 : /// See also:
2075 : ///
2076 : /// * [moveSelectionToStart], which is the same but in the opposite
2077 : /// direction.
2078 0 : void moveSelectionToEnd(SelectionChangedCause cause) {
2079 0 : assert(selection != null);
2080 :
2081 0 : if (selection!.isCollapsed &&
2082 0 : selection!.extentOffset == _plainText.length) {
2083 : return;
2084 : }
2085 0 : final nextSelection = TextSelection.collapsed(
2086 0 : offset: _plainText.length,
2087 : );
2088 0 : _setSelection(nextSelection, cause);
2089 : }
2090 :
2091 : /// Move the current [selection] to the start of the field.
2092 : ///
2093 : /// See also:
2094 : ///
2095 : /// * [moveSelectionToEnd], which is the same but in the opposite direction.
2096 0 : void moveSelectionToStart(SelectionChangedCause cause) {
2097 0 : assert(selection != null);
2098 :
2099 0 : if (selection!.isCollapsed && selection!.extentOffset == 0) {
2100 : return;
2101 : }
2102 : const nextSelection = TextSelection.collapsed(offset: 0);
2103 0 : _setSelection(nextSelection, cause);
2104 : }
2105 :
2106 : /// Move the current [selection] left by one line.
2107 : ///
2108 : /// See also:
2109 : ///
2110 : /// * [moveSelectionRight], which is the same but in the opposite direction.
2111 0 : void moveSelectionLeft(SelectionChangedCause cause) {
2112 0 : assert(selection != null);
2113 :
2114 : // If the selection is collapsed at the beginning of the field already, then
2115 : // nothing happens.
2116 0 : if (selection!.isCollapsed && selection!.extentOffset <= 0.0) {
2117 : return;
2118 : }
2119 :
2120 0 : final positionLeft = _getTextPositionLeft(selection!.extentOffset);
2121 : late final TextSelection nextSelection;
2122 0 : if (positionLeft.offset == selection!.extentOffset) {
2123 0 : nextSelection = selection!.copyWith(baseOffset: 0, extentOffset: 0);
2124 0 : _wasSelectingHorizontallyWithKeyboard = false;
2125 : } else {
2126 0 : nextSelection = selection!.copyWith(
2127 0 : baseOffset: positionLeft.offset,
2128 0 : extentOffset: positionLeft.offset,
2129 : );
2130 0 : _cursorResetLocation = nextSelection.extentOffset;
2131 : }
2132 :
2133 0 : _setSelection(nextSelection, cause);
2134 : }
2135 :
2136 : // Handles shortcut functionality including cut, copy, paste and select all
2137 : // using control/command + (X, C, V, A).
2138 2 : Future<void> _handleShortcuts(LogicalKeyboardKey key) async {
2139 6 : final selection = textSelectionDelegate.textEditingValue.selection;
2140 6 : final text = textSelectionDelegate.textEditingValue.text;
2141 4 : assert(_shortcutKeys.contains(key), 'shortcut key $key not recognized.');
2142 2 : if (key == LogicalKeyboardKey.keyC) {
2143 1 : if (!selection.isCollapsed) {
2144 : // ignore: unawaited_futures
2145 3 : Clipboard.setData(ClipboardData(text: selection.textInside(text)));
2146 : }
2147 : return;
2148 : }
2149 : TextEditingValue? value;
2150 3 : if (key == LogicalKeyboardKey.keyX && !_readOnly) {
2151 0 : if (!selection.isCollapsed) {
2152 : // ignore: unawaited_futures
2153 0 : Clipboard.setData(ClipboardData(text: selection.textInside(text)));
2154 0 : value = TextEditingValue(
2155 0 : text: selection.textBefore(text) + selection.textAfter(text),
2156 0 : selection: TextSelection.collapsed(
2157 0 : offset: math.min(selection.start, selection.end)),
2158 : );
2159 : }
2160 3 : } else if (key == LogicalKeyboardKey.keyV && !_readOnly) {
2161 : // Snapshot the input before using `await`.
2162 : // See https://github.com/flutter/flutter/issues/11427
2163 0 : final data = await Clipboard.getData(Clipboard.kTextPlain);
2164 : if (data != null) {
2165 0 : value = TextEditingValue(
2166 0 : text: selection.textBefore(text) +
2167 0 : data.text! +
2168 0 : selection.textAfter(text),
2169 0 : selection: TextSelection.collapsed(
2170 : offset:
2171 0 : math.min(selection.start, selection.end) + data.text!.length,
2172 : ),
2173 : );
2174 : }
2175 2 : } else if (key == LogicalKeyboardKey.keyA) {
2176 2 : value = TextEditingValue(
2177 : text: text,
2178 2 : selection: selection.copyWith(
2179 : baseOffset: 0,
2180 8 : extentOffset: textSelectionDelegate.textEditingValue.text.length,
2181 : ),
2182 : );
2183 : }
2184 : if (value != null) {
2185 2 : _setTextEditingValue(
2186 : value,
2187 : SelectionChangedCause.keyboard,
2188 : );
2189 : }
2190 : }
2191 :
2192 2 : void _handleDelete({required bool forward}) {
2193 6 : final selection = textSelectionDelegate.textEditingValue.selection;
2194 6 : final text = textSelectionDelegate.textEditingValue.text;
2195 2 : assert(_selection != null);
2196 4 : if (_readOnly || !selection.isValid) {
2197 : return;
2198 : }
2199 1 : var textBefore = selection.textBefore(text);
2200 1 : var textAfter = selection.textAfter(text);
2201 3 : var cursorPosition = math.min(selection.start, selection.end);
2202 : // If not deleting a selection, delete the next/previous character.
2203 1 : if (selection.isCollapsed) {
2204 0 : if (!forward && textBefore.isNotEmpty) {
2205 : final characterBoundary =
2206 0 : previousCharacter(textBefore.length, textBefore);
2207 0 : textBefore = textBefore.substring(0, characterBoundary);
2208 : cursorPosition = characterBoundary;
2209 : }
2210 1 : if (forward && textAfter.isNotEmpty) {
2211 1 : final deleteCount = nextCharacter(0, textAfter);
2212 1 : textAfter = textAfter.substring(deleteCount);
2213 : }
2214 : }
2215 1 : final newSelection = TextSelection.collapsed(offset: cursorPosition);
2216 1 : _setTextEditingValue(
2217 1 : TextEditingValue(
2218 1 : text: textBefore + textAfter,
2219 : selection: newSelection,
2220 : ),
2221 : SelectionChangedCause.keyboard,
2222 : );
2223 : }
2224 :
2225 3 : @override
2226 : void markNeedsPaint() {
2227 3 : super.markNeedsPaint();
2228 : // Tell the painers to repaint since text layout may have changed.
2229 6 : _foregroundRenderObject?.markNeedsPaint();
2230 6 : _backgroundRenderObject?.markNeedsPaint();
2231 : }
2232 :
2233 : /// Marks the render object as needing to be laid out again and have its text
2234 : /// metrics recomputed.
2235 : ///
2236 : /// Implies [markNeedsLayout].
2237 3 : @protected
2238 : void markNeedsTextLayout() {
2239 3 : _textLayoutLastMaxHeight = null;
2240 3 : _textLayoutLastMinHeight = null;
2241 3 : markNeedsLayout();
2242 : }
2243 :
2244 0 : @override
2245 : void systemFontsDidChange() {
2246 0 : super.systemFontsDidChange();
2247 0 : _textPainter.markNeedsLayout();
2248 0 : _textLayoutLastMaxHeight = null;
2249 0 : _textLayoutLastMinHeight = null;
2250 : }
2251 :
2252 : String? _cachedPlainText;
2253 : // Returns a plain text version of the text in the painter.
2254 : //
2255 : // Returns the obscured text when [obscureText] is true. See
2256 : // [obscureText] and [obscuringCharacter].
2257 3 : String get _plainText {
2258 3 : _cachedPlainText ??=
2259 9 : _textPainter.text!.toPlainText(includeSemanticsLabels: false);
2260 3 : return _cachedPlainText!;
2261 : }
2262 :
2263 : /// The text to display.
2264 9 : TextSpan? get text => _textPainter.text;
2265 : final MongolTextPainter _textPainter;
2266 3 : set text(TextSpan? value) {
2267 9 : if (_textPainter.text == value) return;
2268 6 : _textPainter.text = value;
2269 3 : _cachedPlainText = null;
2270 3 : markNeedsTextLayout();
2271 3 : markNeedsSemanticsUpdate();
2272 : }
2273 :
2274 : /// How the text should be aligned vertically.
2275 : ///
2276 : /// This must not be null.
2277 3 : MongolTextAlign get textAlign => _textPainter.textAlign;
2278 3 : set textAlign(MongolTextAlign value) {
2279 9 : if (_textPainter.textAlign == value) return;
2280 4 : _textPainter.textAlign = value;
2281 2 : markNeedsTextLayout();
2282 : }
2283 :
2284 : /// The color to use when painting the cursor.
2285 6 : Color? get cursorColor => _caretPainter.caretColor;
2286 3 : set cursorColor(Color? value) {
2287 6 : _caretPainter.caretColor = value;
2288 : }
2289 :
2290 : /// Whether to paint the cursor.
2291 6 : ValueNotifier<bool> get showCursor => _showCursor;
2292 : ValueNotifier<bool> _showCursor;
2293 3 : set showCursor(ValueNotifier<bool> value) {
2294 6 : if (_showCursor == value) return;
2295 4 : if (attached) _showCursor.removeListener(_showHideCursor);
2296 1 : _showCursor = value;
2297 1 : if (attached) {
2298 1 : _showHideCursor();
2299 3 : _showCursor.addListener(_showHideCursor);
2300 : }
2301 : }
2302 :
2303 3 : void _showHideCursor() {
2304 12 : _caretPainter.shouldPaint = showCursor.value;
2305 : }
2306 :
2307 : /// Whether the editable is currently focused.
2308 6 : bool get hasFocus => _hasFocus;
2309 : bool _hasFocus = false;
2310 : bool _listenerAttached = false;
2311 3 : set hasFocus(bool value) {
2312 6 : if (_hasFocus == value) return;
2313 3 : _hasFocus = value;
2314 3 : markNeedsSemanticsUpdate();
2315 :
2316 3 : if (!attached) {
2317 1 : assert(!_listenerAttached);
2318 : return;
2319 : }
2320 :
2321 3 : if (_hasFocus) {
2322 3 : assert(!_listenerAttached);
2323 : // TODO(justinmc): This listener should be ported to Actions and removed.
2324 : // https://github.com/flutter/flutter/issues/75004
2325 9 : RawKeyboard.instance.addListener(_handleKeyEvent);
2326 3 : _listenerAttached = true;
2327 : } else {
2328 2 : assert(_listenerAttached);
2329 : // TODO(justinmc): This listener should be ported to Actions and removed.
2330 : // https://github.com/flutter/flutter/issues/75004
2331 6 : RawKeyboard.instance.removeListener(_handleKeyEvent);
2332 2 : _listenerAttached = false;
2333 : }
2334 : }
2335 :
2336 : /// Whether this rendering object will take a full line regardless the
2337 : /// text height.
2338 6 : bool get forceLine => _forceLine;
2339 : bool _forceLine = false;
2340 2 : set forceLine(bool value) {
2341 4 : if (_forceLine == value) return;
2342 0 : _forceLine = value;
2343 0 : markNeedsLayout();
2344 : }
2345 :
2346 : /// Whether this rendering object is read only.
2347 6 : bool get readOnly => _readOnly;
2348 : bool _readOnly = false;
2349 2 : set readOnly(bool value) {
2350 4 : if (_readOnly == value) return;
2351 2 : _readOnly = value;
2352 2 : markNeedsSemanticsUpdate();
2353 : }
2354 :
2355 : /// The maximum number of lines for the text to span, wrapping if necessary.
2356 : ///
2357 : /// If this is 1 (the default), the text will not wrap, but will extend
2358 : /// indefinitely instead.
2359 : ///
2360 : /// If this is null, there is no limit to the number of lines.
2361 : ///
2362 : /// When this is not null, the intrinsic width of the render object is the
2363 : /// width of one line of text multiplied by this value. In other words, this
2364 : /// also controls the width of the actual editing widget.
2365 6 : int? get maxLines => _maxLines;
2366 : int? _maxLines;
2367 :
2368 : /// The value may be null. If it is not null, then it must be greater than zero.
2369 2 : set maxLines(int? value) {
2370 2 : assert(value == null || value > 0);
2371 4 : if (maxLines == value) return;
2372 1 : _maxLines = value;
2373 1 : markNeedsTextLayout();
2374 : }
2375 :
2376 : /// The minimum number of lines to occupy when the content spans fewer lines.
2377 : ///
2378 : /// If this is null (default), text container starts with enough horizontal space
2379 : /// for one line and grows to accommodate additional lines as they are entered.
2380 : ///
2381 : /// This can be used in combination with [maxLines] for a varying set of behaviors.
2382 : ///
2383 : /// If the value is set, it must be greater than zero. If the value is greater
2384 : /// than 1, [maxLines] should also be set to either null or greater than
2385 : /// this value.
2386 : ///
2387 : /// When [maxLines] is set as well, the width will grow between the indicated
2388 : /// range of lines. When [maxLines] is null, it will grow as wide as needed,
2389 : /// starting from [minLines].
2390 : ///
2391 : /// A few examples of behaviors possible with [minLines] and [maxLines] are as follows.
2392 : /// These apply equally to `MongolTextField`, `MongolTextFormField`,
2393 : /// and `MongolEditableText`.
2394 : ///
2395 : /// Input that always occupies at least 2 lines and has an infinite max.
2396 : /// Expands horizontally as needed.
2397 : /// ```dart
2398 : /// MongolTextField(minLines: 2)
2399 : /// ```
2400 : ///
2401 : /// Input whose width starts from 2 lines and grows up to 4 lines at which
2402 : /// point the width limit is reached. If additional lines are entered it will
2403 : /// scroll horizontally.
2404 : /// ```dart
2405 : /// TextField(minLines:2, maxLines: 4)
2406 : /// ```
2407 : ///
2408 : /// See the examples in [maxLines] for the complete picture of how [maxLines]
2409 : /// and [minLines] interact to produce various behaviors.
2410 : ///
2411 : /// Defaults to null.
2412 6 : int? get minLines => _minLines;
2413 : int? _minLines;
2414 :
2415 : /// The value may be null. If it is not null, then it must be greater than zero.
2416 2 : set minLines(int? value) {
2417 1 : assert(value == null || value > 0);
2418 4 : if (minLines == value) return;
2419 0 : _minLines = value;
2420 0 : markNeedsTextLayout();
2421 : }
2422 :
2423 : /// Whether this widget's width will be sized to fill its parent.
2424 : ///
2425 : /// If set to true and wrapped in a parent widget like [Expanded] or
2426 : /// [SizedBox], the input will expand to fill the parent.
2427 : ///
2428 : /// [maxLines] and [minLines] must both be null when this is set to true,
2429 : /// otherwise an error is thrown.
2430 : ///
2431 : /// Defaults to false.
2432 : ///
2433 : /// See the examples in [maxLines] for the complete picture of how [maxLines],
2434 : /// [minLines], and [expands] interact to produce various behaviors.
2435 : ///
2436 : /// Input that matches the width of its parent:
2437 : /// ```dart
2438 : /// Expanded(
2439 : /// child: TextField(maxLines: null, expands: true),
2440 : /// )
2441 : /// ```
2442 6 : bool get expands => _expands;
2443 : bool _expands;
2444 2 : set expands(bool value) {
2445 4 : if (expands == value) return;
2446 0 : _expands = value;
2447 0 : markNeedsTextLayout();
2448 : }
2449 :
2450 : /// The color to use when painting the selection.
2451 3 : Color? get selectionColor => _selectionPainter.highlightColor;
2452 2 : set selectionColor(Color? value) {
2453 4 : _selectionPainter.highlightColor = value;
2454 : }
2455 :
2456 : /// The number of font pixels for each logical pixel.
2457 : ///
2458 : /// For example, if the text scale factor is 1.5, text will be 50% larger than
2459 : /// the specified font size.
2460 3 : double get textScaleFactor => _textPainter.textScaleFactor;
2461 3 : set textScaleFactor(double value) {
2462 9 : if (_textPainter.textScaleFactor == value) return;
2463 2 : _textPainter.textScaleFactor = value;
2464 1 : markNeedsTextLayout();
2465 : }
2466 :
2467 : /// The region of text that is selected, if any.
2468 : ///
2469 : /// The caret position is represented by a collapsed selection.
2470 : ///
2471 : /// If [selection] is null, there is no selection and attempts to
2472 : /// manipulate the selection will throw.
2473 6 : TextSelection? get selection => _selection;
2474 : TextSelection? _selection;
2475 3 : set selection(TextSelection? value) {
2476 6 : if (_selection == value) return;
2477 3 : _selection = value;
2478 6 : _selectionPainter.highlightedRange = value;
2479 3 : markNeedsPaint();
2480 3 : markNeedsSemanticsUpdate();
2481 : }
2482 :
2483 : /// The offset at which the text should be painted.
2484 : ///
2485 : /// If the text content is larger than the editable line itself, the editable
2486 : /// line clips the text. This property controls which part of the text is
2487 : /// visible by shifting the text by the given offset before clipping.
2488 6 : ViewportOffset get offset => _offset;
2489 : ViewportOffset _offset;
2490 3 : set offset(ViewportOffset value) {
2491 6 : if (_offset == value) return;
2492 8 : if (attached) _offset.removeListener(markNeedsPaint);
2493 2 : _offset = value;
2494 8 : if (attached) _offset.addListener(markNeedsPaint);
2495 2 : markNeedsLayout();
2496 : }
2497 :
2498 : /// How wide the cursor will be.
2499 : ///
2500 : /// This can be null, in which case the getter will actually return [preferredLineWidth].
2501 : ///
2502 : /// Setting this to itself fixes the value to the current [preferredLineWidth]. Setting
2503 : /// this to null returns the behavior of deferring to [preferredLineWidth].
2504 9 : double get cursorWidth => _cursorWidth ?? preferredLineWidth;
2505 : double? _cursorWidth;
2506 2 : set cursorWidth(double? value) {
2507 4 : if (_cursorWidth == value) return;
2508 0 : _cursorWidth = value;
2509 0 : markNeedsLayout();
2510 : }
2511 :
2512 : /// How thick the cursor will be.
2513 : ///
2514 : /// The cursor will draw over the text. The cursor height will extend
2515 : /// down between the boundary of characters. This corresponds to extending
2516 : /// downstream relative to the selected position. Negative values may be used
2517 : /// to reverse this behavior.
2518 6 : double get cursorHeight => _cursorHeight;
2519 : double _cursorHeight = 1.0;
2520 3 : set cursorHeight(double value) {
2521 6 : if (_cursorHeight == value) {
2522 : return;
2523 : }
2524 2 : _cursorHeight = value;
2525 2 : markNeedsLayout();
2526 : }
2527 :
2528 : /// The offset that is used, in pixels, when painting the cursor on screen.
2529 : ///
2530 : /// By default, the cursor position should be set to an offset of
2531 : /// (0.0, -[cursorHeight] * 0.5) on iOS platforms and (0, 0) on Android
2532 : /// platforms. The origin from where the offset is applied to is the arbitrary
2533 : /// location where the cursor ends up being rendered from by default.
2534 6 : Offset get cursorOffset => _caretPainter.cursorOffset;
2535 2 : set cursorOffset(Offset value) {
2536 4 : _caretPainter.cursorOffset = value;
2537 : }
2538 :
2539 : /// How rounded the corners of the cursor should be.
2540 : ///
2541 : /// A null value is the same as [Radius.zero].
2542 3 : Radius? get cursorRadius => _caretPainter.cursorRadius;
2543 3 : set cursorRadius(Radius? value) {
2544 6 : _caretPainter.cursorRadius = value;
2545 : }
2546 :
2547 : /// The [LayerLink] of start selection handle.
2548 : ///
2549 : /// [MongolRenderEditable] is responsible for calculating the [Offset] of this
2550 : /// [LayerLink], which will be used as [CompositedTransformTarget] of start handle.
2551 6 : LayerLink get startHandleLayerLink => _startHandleLayerLink;
2552 : LayerLink _startHandleLayerLink;
2553 2 : set startHandleLayerLink(LayerLink value) {
2554 4 : if (_startHandleLayerLink == value) return;
2555 0 : _startHandleLayerLink = value;
2556 0 : markNeedsPaint();
2557 : }
2558 :
2559 : /// The [LayerLink] of end selection handle.
2560 : ///
2561 : /// [MongolRenderEditable] is responsible for calculating the [Offset] of this
2562 : /// [LayerLink], which will be used as [CompositedTransformTarget] of end handle.
2563 6 : LayerLink get endHandleLayerLink => _endHandleLayerLink;
2564 : LayerLink _endHandleLayerLink;
2565 2 : set endHandleLayerLink(LayerLink value) {
2566 4 : if (_endHandleLayerLink == value) return;
2567 0 : _endHandleLayerLink = value;
2568 0 : markNeedsPaint();
2569 : }
2570 :
2571 : /// Whether to allow the user to change the selection.
2572 : ///
2573 : /// Since [MongolRenderEditable] does not handle selection manipulation
2574 : /// itself, this actually only affects whether the accessibility
2575 : /// hints provided to the system (via
2576 : /// [describeSemanticsConfiguration]) will enable selection
2577 : /// manipulation. It's the responsibility of this object's owner
2578 : /// to provide selection manipulation affordances.
2579 : ///
2580 : /// This field is used by [selectionEnabled] (which then controls
2581 : /// the accessibility hints mentioned above). When null,
2582 : /// [obscureText] is used to determine the value of
2583 : /// [selectionEnabled] instead.
2584 6 : bool? get enableInteractiveSelection => _enableInteractiveSelection;
2585 : bool? _enableInteractiveSelection;
2586 0 : set enableInteractiveSelection(bool? value) {
2587 0 : if (_enableInteractiveSelection == value) return;
2588 0 : _enableInteractiveSelection = value;
2589 0 : markNeedsTextLayout();
2590 0 : markNeedsSemanticsUpdate();
2591 : }
2592 :
2593 : /// Whether interactive selection is enabled based on the values of
2594 : /// [enableInteractiveSelection] and [obscureText].
2595 : ///
2596 : /// Since [MongolRenderEditable] does not handle selection manipulation
2597 : /// itself, this actually only affects whether the accessibility
2598 : /// hints provided to the system (via
2599 : /// [describeSemanticsConfiguration]) will enable selection
2600 : /// manipulation. It's the responsibility of this object's owner
2601 : /// to provide selection manipulation affordances.
2602 : ///
2603 : /// By default, [enableInteractiveSelection] is null, [obscureText] is false,
2604 : /// and this getter returns true.
2605 : ///
2606 : /// If [enableInteractiveSelection] is null and [obscureText] is true, then this
2607 : /// getter returns false. This is the common case for password fields.
2608 : ///
2609 : /// If [enableInteractiveSelection] is non-null then its value is
2610 : /// returned. An application might [enableInteractiveSelection] to
2611 : /// true to enable interactive selection for a password field, or to
2612 : /// false to unconditionally disable interactive selection.
2613 3 : bool get selectionEnabled {
2614 4 : return enableInteractiveSelection ?? !obscureText;
2615 : }
2616 :
2617 : /// The maximum amount the text is allowed to scroll.
2618 : ///
2619 : /// This value is only valid after layout and can change as additional
2620 : /// text is entered or removed in order to accommodate expanding when
2621 : /// [expands] is set to true.
2622 4 : double get maxScrollExtent => _maxScrollExtent;
2623 : double _maxScrollExtent = 0;
2624 :
2625 9 : double get _caretMargin => _kCaretGap + cursorHeight;
2626 :
2627 : /// Defaults to [Clip.hardEdge], and must not be null.
2628 6 : Clip get clipBehavior => _clipBehavior;
2629 : Clip _clipBehavior = Clip.hardEdge;
2630 2 : set clipBehavior(Clip value) {
2631 4 : if (value != _clipBehavior) {
2632 1 : _clipBehavior = value;
2633 1 : markNeedsPaint();
2634 1 : markNeedsSemanticsUpdate();
2635 : }
2636 : }
2637 :
2638 : /// Collected during [describeSemanticsConfiguration], used by
2639 : /// [assembleSemanticsNode] and [_combineSemanticsInfo].
2640 : List<InlineSpanSemanticsInformation>? _semanticsInfo;
2641 :
2642 : // Caches [SemanticsNode]s created during [assembleSemanticsNode] so they
2643 : // can be re-used when [assembleSemanticsNode] is called again. This ensures
2644 : // stable ids for the [SemanticsNode]s of [TextSpan]s across
2645 : // [assembleSemanticsNode] invocations.
2646 : Queue<SemanticsNode>? _cachedChildNodes;
2647 :
2648 3 : @override
2649 : void describeSemanticsConfiguration(SemanticsConfiguration config) {
2650 3 : super.describeSemanticsConfiguration(config);
2651 12 : _semanticsInfo = _textPainter.text!.getSemanticsInformation();
2652 : // TODO(chunhtai): the macOS does not provide a public API to support text
2653 : // selections across multiple semantics nodes. Remove this platform check
2654 : // once we can support it.
2655 : // https://github.com/flutter/flutter/issues/77957
2656 6 : if (_semanticsInfo!.any(
2657 6 : (InlineSpanSemanticsInformation info) => info.recognizer != null) &&
2658 0 : defaultTargetPlatform != TargetPlatform.macOS) {
2659 0 : assert(readOnly && !obscureText);
2660 : // For Selectable rich text with recognizer, we need to create a semantics
2661 : // node for each text fragment.
2662 : config
2663 0 : ..isSemanticBoundary = true
2664 0 : ..explicitChildNodes = true;
2665 : return;
2666 : }
2667 : config
2668 3 : ..value =
2669 18 : obscureText ? obscuringCharacter * _plainText.length : _plainText
2670 6 : ..isObscured = obscureText
2671 6 : ..isMultiline = _isMultiline
2672 3 : ..textDirection = TextDirection.ltr
2673 6 : ..isFocused = hasFocus
2674 3 : ..isTextField = true
2675 6 : ..isReadOnly = readOnly;
2676 :
2677 5 : if (hasFocus && selectionEnabled) {
2678 4 : config.onSetSelection = _handleSetSelection;
2679 : }
2680 :
2681 9 : if (hasFocus && !readOnly) config.onSetText = _handleSetText;
2682 :
2683 12 : if (selectionEnabled && selection?.isValid == true) {
2684 6 : config.textSelection = selection;
2685 12 : if (_textPainter.getOffsetBefore(selection!.extentOffset) != null) {
2686 : config
2687 6 : ..onMoveCursorBackwardByWord = _handleMoveCursorBackwardByWord
2688 3 : ..onMoveCursorBackwardByCharacter =
2689 3 : _handleMoveCursorBackwardByCharacter;
2690 : }
2691 12 : if (_textPainter.getOffsetAfter(selection!.extentOffset) != null) {
2692 : config
2693 6 : ..onMoveCursorForwardByWord = _handleMoveCursorForwardByWord
2694 3 : ..onMoveCursorForwardByCharacter =
2695 3 : _handleMoveCursorForwardByCharacter;
2696 : }
2697 : }
2698 : }
2699 :
2700 0 : void _handleSetText(String text) {
2701 0 : textSelectionDelegate.userUpdateTextEditingValue(
2702 0 : TextEditingValue(
2703 : text: text,
2704 0 : selection: TextSelection.collapsed(offset: text.length),
2705 : ),
2706 : SelectionChangedCause.keyboard,
2707 : );
2708 : }
2709 :
2710 0 : @override
2711 : void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config,
2712 : Iterable<SemanticsNode> children) {
2713 0 : assert(_semanticsInfo != null && _semanticsInfo!.isNotEmpty);
2714 0 : final newChildren = <SemanticsNode>[];
2715 : Rect currentRect;
2716 : var ordinal = 0.0;
2717 : var start = 0;
2718 0 : final newChildCache = Queue<SemanticsNode>();
2719 0 : for (final info in combineSemanticsInfo(_semanticsInfo!)) {
2720 0 : assert(!info.isPlaceholder);
2721 0 : final selection = TextSelection(
2722 : baseOffset: start,
2723 0 : extentOffset: start + info.text.length,
2724 : );
2725 0 : start += info.text.length;
2726 :
2727 0 : final rects = _textPainter.getBoxesForSelection(selection);
2728 0 : if (rects.isEmpty) {
2729 : continue;
2730 : }
2731 0 : var rect = rects.first;
2732 0 : for (final textBox in rects.skip(1)) {
2733 0 : rect = rect.expandToInclude(textBox);
2734 : }
2735 : // Any of the text boxes may have had infinite dimensions.
2736 : // We shouldn't pass infinite dimensions up to the bridges.
2737 0 : rect = Rect.fromLTWH(
2738 0 : math.max(0.0, rect.left),
2739 0 : math.max(0.0, rect.top),
2740 0 : math.min(rect.width, constraints.maxWidth),
2741 0 : math.min(rect.height, constraints.maxHeight),
2742 : );
2743 : // Round the current rectangle to make this API testable and add some
2744 : // padding so that the accessibility rects do not overlap with the text.
2745 0 : currentRect = Rect.fromLTRB(
2746 0 : rect.left.floorToDouble() - 4.0,
2747 0 : rect.top.floorToDouble() - 4.0,
2748 0 : rect.right.ceilToDouble() + 4.0,
2749 0 : rect.bottom.ceilToDouble() + 4.0,
2750 : );
2751 0 : final configuration = SemanticsConfiguration()
2752 0 : ..sortKey = OrdinalSortKey(ordinal++)
2753 0 : ..textDirection = TextDirection.ltr
2754 0 : ..label = info.semanticsLabel ?? info.text;
2755 0 : final recognizer = info.recognizer;
2756 : if (recognizer != null) {
2757 0 : if (recognizer is TapGestureRecognizer) {
2758 0 : if (recognizer.onTap != null) {
2759 0 : configuration.onTap = recognizer.onTap;
2760 0 : configuration.isLink = true;
2761 : }
2762 0 : } else if (recognizer is DoubleTapGestureRecognizer) {
2763 0 : if (recognizer.onDoubleTap != null) {
2764 0 : configuration.onTap = recognizer.onDoubleTap;
2765 0 : configuration.isLink = true;
2766 : }
2767 0 : } else if (recognizer is LongPressGestureRecognizer) {
2768 0 : if (recognizer.onLongPress != null) {
2769 0 : configuration.onLongPress = recognizer.onLongPress;
2770 : }
2771 : } else {
2772 0 : assert(false, '${recognizer.runtimeType} is not supported.');
2773 : }
2774 : }
2775 0 : final newChild = (_cachedChildNodes?.isNotEmpty == true)
2776 0 : ? _cachedChildNodes!.removeFirst()
2777 0 : : SemanticsNode();
2778 : newChild
2779 0 : ..updateWith(config: configuration)
2780 0 : ..rect = currentRect;
2781 0 : newChildCache.addLast(newChild);
2782 0 : newChildren.add(newChild);
2783 : }
2784 0 : _cachedChildNodes = newChildCache;
2785 0 : node.updateWith(config: config, childrenInInversePaintOrder: newChildren);
2786 : }
2787 :
2788 0 : void _handleSetSelection(TextSelection selection) {
2789 0 : _handleSelectionChange(selection, SelectionChangedCause.keyboard);
2790 : }
2791 :
2792 0 : void _handleMoveCursorForwardByCharacter(bool extentSelection) {
2793 0 : assert(selection != null);
2794 0 : final extentOffset = _textPainter.getOffsetAfter(selection!.extentOffset);
2795 : if (extentOffset == null) return;
2796 0 : final baseOffset = !extentSelection ? extentOffset : selection!.baseOffset;
2797 0 : _handleSelectionChange(
2798 0 : TextSelection(baseOffset: baseOffset, extentOffset: extentOffset),
2799 : SelectionChangedCause.keyboard,
2800 : );
2801 : }
2802 :
2803 0 : void _handleMoveCursorBackwardByCharacter(bool extentSelection) {
2804 0 : assert(selection != null);
2805 0 : final extentOffset = _textPainter.getOffsetBefore(selection!.extentOffset);
2806 : if (extentOffset == null) return;
2807 0 : final baseOffset = !extentSelection ? extentOffset : selection!.baseOffset;
2808 0 : _handleSelectionChange(
2809 0 : TextSelection(baseOffset: baseOffset, extentOffset: extentOffset),
2810 : SelectionChangedCause.keyboard,
2811 : );
2812 : }
2813 :
2814 0 : void _handleMoveCursorForwardByWord(bool extentSelection) {
2815 0 : assert(selection != null);
2816 0 : final currentWord = _textPainter.getWordBoundary(selection!.extent);
2817 0 : final nextWord = _getNextWord(currentWord.end);
2818 : if (nextWord == null) return;
2819 0 : final baseOffset = extentSelection ? selection!.baseOffset : nextWord.start;
2820 0 : _handleSelectionChange(
2821 0 : TextSelection(
2822 : baseOffset: baseOffset,
2823 0 : extentOffset: nextWord.start,
2824 : ),
2825 : SelectionChangedCause.keyboard,
2826 : );
2827 : }
2828 :
2829 0 : void _handleMoveCursorBackwardByWord(bool extentSelection) {
2830 0 : assert(selection != null);
2831 0 : final currentWord = _textPainter.getWordBoundary(selection!.extent);
2832 0 : final previousWord = _getPreviousWord(currentWord.start - 1);
2833 : if (previousWord == null) return;
2834 : final baseOffset =
2835 0 : extentSelection ? selection!.baseOffset : previousWord.start;
2836 0 : _handleSelectionChange(
2837 0 : TextSelection(
2838 : baseOffset: baseOffset,
2839 0 : extentOffset: previousWord.start,
2840 : ),
2841 : SelectionChangedCause.keyboard,
2842 : );
2843 : }
2844 :
2845 1 : TextRange? _getNextWord(int offset) {
2846 : while (true) {
2847 3 : final range = _textPainter.getWordBoundary(TextPosition(offset: offset));
2848 2 : if (!range.isValid || range.isCollapsed) return null;
2849 1 : if (!_onlyWhitespace(range)) return range;
2850 1 : offset = range.end;
2851 : }
2852 : }
2853 :
2854 1 : TextRange? _getPreviousWord(int offset) {
2855 1 : while (offset >= 0) {
2856 3 : final range = _textPainter.getWordBoundary(TextPosition(offset: offset));
2857 2 : if (!range.isValid || range.isCollapsed) return null;
2858 1 : if (!_onlyWhitespace(range)) return range;
2859 2 : offset = range.start - 1;
2860 : }
2861 : return null;
2862 : }
2863 :
2864 : // Check if the given text range only contains white space or separator
2865 : // characters.
2866 : //
2867 : // Includes newline characters from ASCII and separators from the
2868 : // [unicode separator category](https://www.compart.com/en/unicode/category/Zs)
2869 1 : bool _onlyWhitespace(TextRange range) {
2870 4 : for (var i = range.start; i < range.end; i++) {
2871 2 : final codeUnit = text!.codeUnitAt(i)!;
2872 1 : if (!_isWhitespace(codeUnit)) {
2873 : return false;
2874 : }
2875 : }
2876 : return true;
2877 : }
2878 :
2879 3 : @override
2880 : void attach(PipelineOwner owner) {
2881 3 : super.attach(owner);
2882 6 : _foregroundRenderObject?.attach(owner);
2883 6 : _backgroundRenderObject?.attach(owner);
2884 :
2885 6 : _tap = TapGestureRecognizer(debugOwner: this)
2886 6 : ..onTapDown = _handleTapDown
2887 6 : ..onTap = _handleTap;
2888 6 : _longPress = LongPressGestureRecognizer(debugOwner: this)
2889 6 : ..onLongPress = _handleLongPress;
2890 9 : _offset.addListener(markNeedsPaint);
2891 3 : _showHideCursor();
2892 9 : _showCursor.addListener(_showHideCursor);
2893 3 : assert(!_listenerAttached);
2894 3 : if (_hasFocus) {
2895 0 : RawKeyboard.instance.addListener(_handleKeyEvent);
2896 0 : _listenerAttached = true;
2897 : }
2898 : }
2899 :
2900 3 : @override
2901 : void detach() {
2902 6 : _tap.dispose();
2903 6 : _longPress.dispose();
2904 9 : _offset.removeListener(markNeedsPaint);
2905 9 : _showCursor.removeListener(_showHideCursor);
2906 : // TODO(justinmc): This listener should be ported to Actions and removed.
2907 : // https://github.com/flutter/flutter/issues/75004
2908 3 : if (_listenerAttached) {
2909 9 : RawKeyboard.instance.removeListener(_handleKeyEvent);
2910 3 : _listenerAttached = false;
2911 : }
2912 3 : super.detach();
2913 6 : _foregroundRenderObject?.detach();
2914 6 : _backgroundRenderObject?.detach();
2915 : }
2916 :
2917 3 : @override
2918 : void redepthChildren() {
2919 3 : final RenderObject? foregroundChild = _foregroundRenderObject;
2920 3 : final RenderObject? backgroundChild = _backgroundRenderObject;
2921 3 : if (foregroundChild != null) redepthChild(foregroundChild);
2922 3 : if (backgroundChild != null) redepthChild(backgroundChild);
2923 : }
2924 :
2925 3 : @override
2926 : void visitChildren(RenderObjectVisitor visitor) {
2927 3 : final RenderObject? foregroundChild = _foregroundRenderObject;
2928 3 : final RenderObject? backgroundChild = _backgroundRenderObject;
2929 3 : if (foregroundChild != null) visitor(foregroundChild);
2930 3 : if (backgroundChild != null) visitor(backgroundChild);
2931 : }
2932 :
2933 9 : bool get _isMultiline => maxLines != 1;
2934 :
2935 6 : Axis get _viewportAxis => _isMultiline ? Axis.horizontal : Axis.vertical;
2936 :
2937 3 : Offset get _paintOffset {
2938 3 : switch (_viewportAxis) {
2939 3 : case Axis.horizontal:
2940 12 : return Offset(-offset.pixels, 0.0);
2941 3 : case Axis.vertical:
2942 12 : return Offset(0.0, -offset.pixels);
2943 : }
2944 : }
2945 :
2946 3 : double get _viewportExtent {
2947 3 : assert(hasSize);
2948 3 : switch (_viewportAxis) {
2949 3 : case Axis.horizontal:
2950 6 : return size.width;
2951 3 : case Axis.vertical:
2952 6 : return size.height;
2953 : }
2954 : }
2955 :
2956 3 : double _getMaxScrollExtent(Size contentSize) {
2957 3 : assert(hasSize);
2958 3 : switch (_viewportAxis) {
2959 3 : case Axis.horizontal:
2960 15 : return math.max(0.0, contentSize.width - size.width);
2961 3 : case Axis.vertical:
2962 15 : return math.max(0.0, contentSize.height - size.height);
2963 : }
2964 : }
2965 :
2966 : // We need to check the paint offset here because during animation, the start of
2967 : // the text may position outside the visible region even when the text fits.
2968 3 : bool get _hasVisualOverflow =>
2969 12 : _maxScrollExtent > 0 || _paintOffset != Offset.zero;
2970 :
2971 : /// Returns the local coordinates of the endpoints of the given selection.
2972 : ///
2973 : /// If the selection is collapsed (and therefore occupies a single point), the
2974 : /// returned list is of length one. Otherwise, the selection is not collapsed
2975 : /// and the returned list is of length two.
2976 : ///
2977 : /// See also:
2978 : ///
2979 : /// * [getLocalRectForCaret], which is the equivalent but for
2980 : /// a [TextPosition] rather than a [TextSelection].
2981 3 : List<TextSelectionPoint> getEndpointsForSelection(TextSelection selection) {
2982 3 : _layoutText(
2983 12 : minHeight: constraints.minHeight, maxHeight: constraints.maxHeight);
2984 :
2985 3 : final paintOffset = _paintOffset;
2986 :
2987 3 : final boxes = selection.isCollapsed
2988 3 : ? <Rect>[]
2989 6 : : _textPainter.getBoxesForSelection(selection);
2990 3 : if (boxes.isEmpty) {
2991 : final caretOffset =
2992 12 : _textPainter.getOffsetForCaret(selection.extent, _caretPrototype);
2993 12 : final start = Offset(preferredLineWidth, 0.0) + caretOffset + paintOffset;
2994 6 : return <TextSelectionPoint>[TextSelectionPoint(start, TextDirection.ltr)];
2995 : } else {
2996 18 : final start = Offset(boxes.first.left, boxes.first.top) + paintOffset;
2997 18 : final end = Offset(boxes.last.right, boxes.last.bottom) + paintOffset;
2998 3 : return <TextSelectionPoint>[
2999 3 : TextSelectionPoint(start, TextDirection.ltr),
3000 3 : TextSelectionPoint(end, TextDirection.ltr),
3001 : ];
3002 : }
3003 : }
3004 :
3005 : /// Returns the smallest [Rect], in the local coordinate system, that covers
3006 : /// the text within the [TextRange] specified.
3007 : ///
3008 : /// This method is used to calculate the approximate position of the IME bar
3009 : /// on iOS.
3010 : ///
3011 : /// Returns null if [TextRange.isValid] is false for the given `range`, or the
3012 : /// given `range` is collapsed.
3013 3 : Rect? getRectForComposingRange(TextRange range) {
3014 6 : if (!range.isValid || range.isCollapsed) {
3015 : return null;
3016 : }
3017 3 : _layoutText(
3018 12 : minHeight: constraints.minHeight, maxHeight: constraints.maxHeight);
3019 :
3020 6 : final boxes = _textPainter.getBoxesForSelection(
3021 9 : TextSelection(baseOffset: range.start, extentOffset: range.end),
3022 : );
3023 :
3024 : return boxes
3025 3 : .fold(
3026 : null,
3027 3 : (Rect? accum, Rect incoming) =>
3028 0 : accum?.expandToInclude(incoming) ?? incoming,
3029 : )
3030 6 : ?.shift(_paintOffset);
3031 : }
3032 :
3033 : /// Returns the position in the text for the given global coordinate.
3034 : ///
3035 : /// See also:
3036 : ///
3037 : /// * [getLocalRectForCaret], which is the reverse operation, taking
3038 : /// a [TextPosition] and returning a [Rect].
3039 : /// * [MongolTextPainter.getPositionForOffset], which is the equivalent method
3040 : /// for a [MongolTextPainter] object.
3041 0 : TextPosition getPositionForPoint(Offset globalPosition) {
3042 0 : _layoutText(
3043 0 : minHeight: constraints.minHeight, maxHeight: constraints.maxHeight);
3044 0 : globalPosition += -_paintOffset;
3045 0 : return _textPainter.getPositionForOffset(globalToLocal(globalPosition));
3046 : }
3047 :
3048 : /// Returns the [Rect] in local coordinates for the caret at the given text
3049 : /// position.
3050 : ///
3051 : /// See also:
3052 : ///
3053 : /// * [getPositionForPoint], which is the reverse operation, taking
3054 : /// an [Offset] in global coordinates and returning a [TextPosition].
3055 : /// * [getEndpointsForSelection], which is the equivalent but for
3056 : /// a selection rather than a particular text position.
3057 : /// * [MongolTextPainter.getOffsetForCaret], the equivalent method for a
3058 : /// [MongolTextPainter] object.
3059 2 : Rect getLocalRectForCaret(TextPosition caretPosition) {
3060 2 : _layoutText(
3061 8 : minHeight: constraints.minHeight, maxHeight: constraints.maxHeight);
3062 : final caretOffset =
3063 6 : _textPainter.getOffsetForCaret(caretPosition, _caretPrototype);
3064 : // This rect is the same as _caretPrototype but without the horizontal padding.
3065 6 : final rect = Rect.fromLTWH(0.0, 0.0, cursorWidth, cursorHeight)
3066 10 : .shift(caretOffset + _paintOffset + cursorOffset);
3067 : // Add additional cursor offset (generally only if on iOS).
3068 6 : return rect.shift(_snapToPhysicalPixel(rect.topLeft));
3069 : }
3070 :
3071 3 : @override
3072 : double computeMinIntrinsicHeight(double width) {
3073 3 : _layoutText(maxHeight: double.infinity);
3074 6 : return _textPainter.minIntrinsicHeight;
3075 : }
3076 :
3077 3 : @override
3078 : double computeMaxIntrinsicHeight(double width) {
3079 3 : _layoutText(maxHeight: double.infinity);
3080 12 : return _textPainter.maxIntrinsicHeight + cursorHeight;
3081 : }
3082 :
3083 : /// An estimate of the width of a line in the text. See [TextPainter.preferredLineWidth].
3084 : /// This does not require the layout to be updated.
3085 9 : double get preferredLineWidth => _textPainter.preferredLineWidth;
3086 :
3087 3 : double _preferredWidth(double height) {
3088 : // Lock width to maxLines if needed.
3089 6 : final lockedMax = maxLines != null && minLines == null;
3090 6 : final lockedBoth = minLines != null && minLines == maxLines;
3091 6 : final singleLine = maxLines == 1;
3092 : if (singleLine || lockedMax || lockedBoth) {
3093 9 : return preferredLineWidth * maxLines!;
3094 : }
3095 :
3096 : // Clamp width to minLines or maxLines if needed.
3097 4 : final minLimited = minLines != null && minLines! > 1;
3098 2 : final maxLimited = maxLines != null;
3099 : if (minLimited || maxLimited) {
3100 1 : _layoutText(maxHeight: height);
3101 6 : if (minLimited && _textPainter.width < preferredLineWidth * minLines!) {
3102 3 : return preferredLineWidth * minLines!;
3103 : }
3104 0 : if (maxLimited && _textPainter.width > preferredLineWidth * maxLines!) {
3105 0 : return preferredLineWidth * maxLines!;
3106 : }
3107 : }
3108 :
3109 : // Set the width based on the content.
3110 2 : if (height == double.infinity) {
3111 1 : final text = _plainText;
3112 : var lines = 1;
3113 3 : for (var index = 0; index < text.length; index += 1) {
3114 : const newline = 0x0A;
3115 2 : if (text.codeUnitAt(index) == newline) {
3116 1 : lines += 1;
3117 : }
3118 : }
3119 2 : return preferredLineWidth * lines;
3120 : }
3121 2 : _layoutText(maxHeight: height);
3122 8 : return math.max(preferredLineWidth, _textPainter.width);
3123 : }
3124 :
3125 3 : @override
3126 : double computeMinIntrinsicWidth(double height) {
3127 3 : return _preferredWidth(height);
3128 : }
3129 :
3130 3 : @override
3131 : double computeMaxIntrinsicWidth(double height) {
3132 3 : return _preferredWidth(height);
3133 : }
3134 :
3135 1 : @override
3136 : double computeDistanceToActualBaseline(TextBaseline baseline) {
3137 1 : _layoutText(
3138 4 : minHeight: constraints.minHeight, maxHeight: constraints.maxHeight);
3139 2 : return _textPainter.computeDistanceToActualBaseline(baseline);
3140 : }
3141 :
3142 2 : @override
3143 : bool hitTestSelf(Offset position) => true;
3144 :
3145 : late TapGestureRecognizer _tap;
3146 : late LongPressGestureRecognizer _longPress;
3147 :
3148 2 : @override
3149 : void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
3150 2 : assert(debugHandleEvent(event, entry));
3151 2 : if (event is PointerDownEvent) {
3152 2 : assert(!debugNeedsLayout);
3153 :
3154 2 : if (!ignorePointer) {
3155 : // Propagates the pointer event to selection handlers.
3156 2 : _tap.addPointer(event);
3157 2 : _longPress.addPointer(event);
3158 : }
3159 : }
3160 : }
3161 :
3162 : Offset? _lastTapDownPosition;
3163 : Offset? _lastSecondaryTapDownPosition;
3164 :
3165 : /// The position of the most recent secondary tap down event on this text
3166 : /// input.
3167 4 : Offset? get lastSecondaryTapDownPosition => _lastSecondaryTapDownPosition;
3168 :
3169 : /// Tracks the position of a secondary tap event.
3170 : ///
3171 : /// Should be called before attempting to change the selection based on the
3172 : /// position of a secondary tap.
3173 0 : void handleSecondaryTapDown(TapDownDetails details) {
3174 0 : _lastTapDownPosition = details.globalPosition;
3175 0 : _lastSecondaryTapDownPosition = details.globalPosition;
3176 : }
3177 :
3178 : /// If [ignorePointer] is false (the default) then this method is called by
3179 : /// the internal gesture recognizer's [TapGestureRecognizer.onTapDown]
3180 : /// callback.
3181 : ///
3182 : /// When [ignorePointer] is true, an ancestor widget must respond to tap
3183 : /// down events by calling this method.
3184 3 : void handleTapDown(TapDownDetails details) {
3185 6 : _lastTapDownPosition = details.globalPosition;
3186 : }
3187 :
3188 1 : void _handleTapDown(TapDownDetails details) {
3189 1 : assert(!ignorePointer);
3190 1 : handleTapDown(details);
3191 : }
3192 :
3193 : /// If [ignorePointer] is false (the default) then this method is called by
3194 : /// the internal gesture recognizer's [TapGestureRecognizer.onTap]
3195 : /// callback.
3196 : ///
3197 : /// When [ignorePointer] is true, an ancestor widget must respond to tap
3198 : /// events by calling this method.
3199 1 : void handleTap() {
3200 1 : selectPosition(cause: SelectionChangedCause.tap);
3201 : }
3202 :
3203 1 : void _handleTap() {
3204 1 : assert(!ignorePointer);
3205 1 : handleTap();
3206 : }
3207 :
3208 : /// If [ignorePointer] is false (the default) then this method is called by
3209 : /// the internal gesture recognizer's [DoubleTapGestureRecognizer.onDoubleTap]
3210 : /// callback.
3211 : ///
3212 : /// When [ignorePointer] is true, an ancestor widget must respond to double
3213 : /// tap events by calling this method.
3214 0 : void handleDoubleTap() {
3215 0 : selectWord(cause: SelectionChangedCause.doubleTap);
3216 : }
3217 :
3218 : /// If [ignorePointer] is false (the default) then this method is called by
3219 : /// the internal gesture recognizer's [LongPressGestureRecognizer.onLongPress]
3220 : /// callback.
3221 : ///
3222 : /// When [ignorePointer] is true, an ancestor widget must respond to long
3223 : /// press events by calling this method.
3224 0 : void handleLongPress() {
3225 0 : selectWord(cause: SelectionChangedCause.longPress);
3226 : }
3227 :
3228 0 : void _handleLongPress() {
3229 0 : assert(!ignorePointer);
3230 0 : handleLongPress();
3231 : }
3232 :
3233 : /// Move selection to the location of the last tap down.
3234 : ///
3235 : /// This method is mainly used to translate user inputs in global positions
3236 : /// into a [TextSelection]. When used in conjunction with a [MongolEditableText],
3237 : /// the selection change is fed back into [TextEditingController.selection].
3238 : ///
3239 : /// If you have a [TextEditingController], it's generally easier to
3240 : /// programmatically manipulate its `value` or `selection` directly.
3241 3 : void selectPosition({required SelectionChangedCause cause}) {
3242 6 : selectPositionAt(from: _lastTapDownPosition!, cause: cause);
3243 : }
3244 :
3245 : /// Select text between the global positions [from] and [to].
3246 : ///
3247 : /// [from] corresponds to the [TextSelection.baseOffset], and [to] corresponds
3248 : /// to the [TextSelection.extentOffset].
3249 3 : void selectPositionAt(
3250 : {required Offset from,
3251 : Offset? to,
3252 : required SelectionChangedCause cause}) {
3253 3 : _layoutText(
3254 12 : minHeight: constraints.minHeight, maxHeight: constraints.maxHeight);
3255 : final fromPosition =
3256 15 : _textPainter.getPositionForOffset(globalToLocal(from - _paintOffset));
3257 : final toPosition = (to == null)
3258 : ? null
3259 5 : : _textPainter.getPositionForOffset(globalToLocal(to - _paintOffset));
3260 :
3261 3 : final baseOffset = fromPosition.offset;
3262 4 : final extentOffset = toPosition?.offset ?? fromPosition.offset;
3263 :
3264 3 : final newSelection = TextSelection(
3265 : baseOffset: baseOffset,
3266 : extentOffset: extentOffset,
3267 3 : affinity: fromPosition.affinity,
3268 : );
3269 3 : _setSelection(newSelection, cause);
3270 : }
3271 :
3272 : /// Select a word around the location of the last tap down.
3273 2 : void selectWord({required SelectionChangedCause cause}) {
3274 4 : selectWordsInRange(from: _lastTapDownPosition!, cause: cause);
3275 : }
3276 :
3277 : /// Selects the set words of a paragraph in a given range of global positions.
3278 : ///
3279 : /// The first and last endpoints of the selection will always be at the
3280 : /// beginning and end of a word respectively.
3281 3 : void selectWordsInRange(
3282 : {required Offset from,
3283 : Offset? to,
3284 : required SelectionChangedCause cause}) {
3285 3 : _layoutText(
3286 12 : minHeight: constraints.minHeight, maxHeight: constraints.maxHeight);
3287 : final firstPosition =
3288 15 : _textPainter.getPositionForOffset(globalToLocal(from - _paintOffset));
3289 3 : final firstWord = _selectWordAtOffset(firstPosition);
3290 : final lastWord = (to == null)
3291 : ? firstWord
3292 2 : : _selectWordAtOffset(_textPainter
3293 4 : .getPositionForOffset(globalToLocal(to - _paintOffset)));
3294 :
3295 3 : _setSelection(
3296 3 : TextSelection(
3297 6 : baseOffset: firstWord.base.offset,
3298 6 : extentOffset: lastWord.extent.offset,
3299 3 : affinity: firstWord.affinity,
3300 : ),
3301 : cause,
3302 : );
3303 : }
3304 :
3305 : /// Move the selection to the beginning or end of a word.
3306 0 : void selectWordEdge({required SelectionChangedCause cause}) {
3307 0 : _layoutText(
3308 0 : minHeight: constraints.minHeight, maxHeight: constraints.maxHeight);
3309 0 : assert(_lastTapDownPosition != null);
3310 0 : final position = _textPainter.getPositionForOffset(
3311 0 : globalToLocal(_lastTapDownPosition! - _paintOffset));
3312 0 : final word = _textPainter.getWordBoundary(position);
3313 0 : if (position.offset - word.start <= 1) {
3314 0 : _handleSelectionChange(
3315 0 : TextSelection.collapsed(
3316 0 : offset: word.start, affinity: TextAffinity.downstream),
3317 : cause,
3318 : );
3319 : } else {
3320 0 : _handleSelectionChange(
3321 0 : TextSelection.collapsed(
3322 0 : offset: word.end, affinity: TextAffinity.upstream),
3323 : cause,
3324 : );
3325 : }
3326 : }
3327 :
3328 3 : TextSelection _selectWordAtOffset(TextPosition position) {
3329 : assert(
3330 12 : _textLayoutLastMaxHeight == constraints.maxHeight &&
3331 12 : _textLayoutLastMinHeight == constraints.minHeight,
3332 0 : 'Last height ($_textLayoutLastMinHeight, $_textLayoutLastMaxHeight) not the same as max height constraint (${constraints.minHeight}, ${constraints.maxHeight}).');
3333 6 : final word = _textPainter.getWordBoundary(position);
3334 : // When long-pressing past the end of the text, we want a collapsed cursor.
3335 9 : if (position.offset >= word.end) {
3336 2 : return TextSelection.fromPosition(position);
3337 : }
3338 : // If text is obscured, the entire sentence should be treated as one word.
3339 3 : if (obscureText) {
3340 3 : return TextSelection(baseOffset: 0, extentOffset: _plainText.length);
3341 : // On iOS, select the previous word if there is a previous word, or select
3342 : // to the end of the next word if there is a next word. Select nothing if
3343 : // there is neither a previous word nor a next word.
3344 : //
3345 : // If the platform is Android and the text is read only, try to select the
3346 : // previous word if there is one; otherwise, select the single whitespace at
3347 : // the position.
3348 12 : } else if (_isWhitespace(_plainText.codeUnitAt(position.offset)) &&
3349 2 : position.offset > 0) {
3350 2 : final previousWord = _getPreviousWord(word.start);
3351 1 : switch (defaultTargetPlatform) {
3352 1 : case TargetPlatform.iOS:
3353 : if (previousWord == null) {
3354 2 : final nextWord = _getNextWord(word.start);
3355 : if (nextWord == null) {
3356 2 : return TextSelection.collapsed(offset: position.offset);
3357 : }
3358 1 : return TextSelection(
3359 1 : baseOffset: position.offset,
3360 1 : extentOffset: nextWord.end,
3361 : );
3362 : }
3363 0 : return TextSelection(
3364 0 : baseOffset: previousWord.start,
3365 0 : extentOffset: position.offset,
3366 : );
3367 1 : case TargetPlatform.android:
3368 1 : if (readOnly) {
3369 : if (previousWord == null) {
3370 1 : return TextSelection(
3371 1 : baseOffset: position.offset,
3372 2 : extentOffset: position.offset + 1,
3373 : );
3374 : }
3375 0 : return TextSelection(
3376 0 : baseOffset: previousWord.start,
3377 0 : extentOffset: position.offset,
3378 : );
3379 : }
3380 : break;
3381 0 : case TargetPlatform.fuchsia:
3382 0 : case TargetPlatform.macOS:
3383 0 : case TargetPlatform.linux:
3384 0 : case TargetPlatform.windows:
3385 : break;
3386 : }
3387 : }
3388 :
3389 9 : return TextSelection(baseOffset: word.start, extentOffset: word.end);
3390 : }
3391 :
3392 1 : TextSelection _getLineAtOffset(TextPosition position) {
3393 : assert(
3394 4 : _textLayoutLastMaxHeight == constraints.maxHeight &&
3395 4 : _textLayoutLastMinHeight == constraints.minHeight,
3396 0 : 'Last height ($_textLayoutLastMinHeight, $_textLayoutLastMaxHeight) not the same as max height constraint (${constraints.minHeight}, ${constraints.maxHeight}).');
3397 2 : final line = _textPainter.getLineBoundary(position);
3398 3 : if (position.offset >= line.end) {
3399 1 : return TextSelection.fromPosition(position);
3400 : }
3401 : // If text is obscured, the entire string should be treated as one line.
3402 1 : if (obscureText) {
3403 0 : return TextSelection(baseOffset: 0, extentOffset: _plainText.length);
3404 : }
3405 3 : return TextSelection(baseOffset: line.start, extentOffset: line.end);
3406 : }
3407 :
3408 3 : void _layoutText(
3409 : {double minHeight = 0.0, double maxHeight = double.infinity}) {
3410 6 : if (_textLayoutLastMaxHeight == maxHeight &&
3411 6 : _textLayoutLastMinHeight == minHeight) return;
3412 9 : final availableMaxHeight = math.max(0.0, maxHeight - _caretMargin);
3413 3 : final availableMinHeight = math.min(minHeight, availableMaxHeight);
3414 3 : final textMaxHeight = _isMultiline ? availableMaxHeight : double.infinity;
3415 3 : final textMinHeight = forceLine ? availableMaxHeight : availableMinHeight;
3416 6 : _textPainter.layout(
3417 : minHeight: textMinHeight,
3418 : maxHeight: textMaxHeight,
3419 : );
3420 3 : _textLayoutLastMinHeight = minHeight;
3421 3 : _textLayoutLastMaxHeight = maxHeight;
3422 : }
3423 :
3424 : late Rect _caretPrototype;
3425 :
3426 3 : void _computeCaretPrototype() {
3427 3 : switch (defaultTargetPlatform) {
3428 3 : case TargetPlatform.iOS:
3429 3 : case TargetPlatform.macOS:
3430 3 : _caretPrototype =
3431 12 : Rect.fromLTWH(0.0, 0.0, cursorWidth + 2, cursorHeight);
3432 : break;
3433 3 : case TargetPlatform.android:
3434 2 : case TargetPlatform.fuchsia:
3435 2 : case TargetPlatform.linux:
3436 2 : case TargetPlatform.windows:
3437 6 : _caretPrototype = Rect.fromLTWH(_kCaretWidthOffset, 0.0,
3438 12 : cursorWidth - 2.0 * _kCaretWidthOffset, cursorHeight);
3439 : break;
3440 : }
3441 : }
3442 :
3443 : // Computes the offset to apply to the given [sourceOffset] so it perfectly
3444 : // snaps to physical pixels.
3445 3 : Offset _snapToPhysicalPixel(Offset sourceOffset) {
3446 3 : final globalOffset = localToGlobal(sourceOffset);
3447 6 : final pixelMultiple = 1.0 / _devicePixelRatio;
3448 3 : return Offset(
3449 6 : globalOffset.dx.isFinite
3450 15 : ? (globalOffset.dx / pixelMultiple).round() * pixelMultiple -
3451 3 : globalOffset.dx
3452 : : 0,
3453 6 : globalOffset.dy.isFinite
3454 15 : ? (globalOffset.dy / pixelMultiple).round() * pixelMultiple -
3455 3 : globalOffset.dy
3456 : : 0,
3457 : );
3458 : }
3459 :
3460 2 : @override
3461 : Size computeDryLayout(BoxConstraints constraints) {
3462 2 : _layoutText(
3463 4 : minHeight: constraints.minHeight, maxHeight: constraints.maxHeight);
3464 2 : final height = forceLine
3465 2 : ? constraints.maxHeight
3466 0 : : constraints.constrainHeight(_textPainter.size.height + _caretMargin);
3467 2 : return Size(
3468 6 : constraints.constrainWidth(_preferredWidth(constraints.maxHeight)),
3469 : height);
3470 : }
3471 :
3472 3 : @override
3473 : void performLayout() {
3474 3 : final constraints = this.constraints;
3475 3 : _layoutText(
3476 6 : minHeight: constraints.minHeight, maxHeight: constraints.maxHeight);
3477 3 : _computeCaretPrototype();
3478 : // We grab _textPainter.size here because assigning to `size` on the next
3479 : // line will trigger us to validate our intrinsic sizes, which will change
3480 : // _textPainter's layout because the intrinsic size calculations are
3481 : // destructive, which would mean we would get different results if we later
3482 : // used properties on _textPainter in this method.
3483 : // Other _textPainter state like didExceedMaxLines will also be affected,
3484 : // though we currently don't use those here.
3485 : // See also MongolRenderParagraph which has a similar issue.
3486 6 : final textPainterSize = _textPainter.size;
3487 3 : final height = forceLine
3488 3 : ? constraints.maxHeight
3489 0 : : constraints.constrainHeight(_textPainter.size.height + _caretMargin);
3490 6 : size = Size(
3491 9 : constraints.constrainWidth(_preferredWidth(constraints.maxHeight)),
3492 : height);
3493 : final contentSize =
3494 15 : Size(textPainterSize.width, textPainterSize.height + _caretMargin);
3495 :
3496 3 : final painterConstraints = BoxConstraints.tight(contentSize);
3497 :
3498 6 : _foregroundRenderObject?.layout(painterConstraints);
3499 6 : _backgroundRenderObject?.layout(painterConstraints);
3500 :
3501 6 : _maxScrollExtent = _getMaxScrollExtent(contentSize);
3502 9 : offset.applyViewportDimension(_viewportExtent);
3503 9 : offset.applyContentDimensions(0.0, _maxScrollExtent);
3504 : }
3505 :
3506 3 : void _paintContents(PaintingContext context, Offset offset) {
3507 : assert(
3508 12 : _textLayoutLastMaxHeight == constraints.maxHeight &&
3509 12 : _textLayoutLastMinHeight == constraints.minHeight,
3510 0 : 'Last height ($_textLayoutLastMinHeight, $_textLayoutLastMaxHeight) not the same as max height constraint (${constraints.minHeight}, ${constraints.maxHeight}).');
3511 6 : final effectiveOffset = offset + _paintOffset;
3512 :
3513 3 : if (selection != null) {
3514 3 : _updateSelectionExtentsVisibility(effectiveOffset);
3515 : }
3516 :
3517 3 : final RenderBox? foregroundChild = _foregroundRenderObject;
3518 3 : final RenderBox? backgroundChild = _backgroundRenderObject;
3519 :
3520 : // The painters paint in the viewport's coordinate space, since the
3521 : // textPainter's coordinate space is not known to high level widgets.
3522 3 : if (backgroundChild != null) context.paintChild(backgroundChild, offset);
3523 :
3524 9 : _textPainter.paint(context.canvas, effectiveOffset);
3525 :
3526 3 : if (foregroundChild != null) context.paintChild(foregroundChild, offset);
3527 : }
3528 :
3529 3 : void _paintHandleLayers(
3530 : PaintingContext context, List<TextSelectionPoint> endpoints) {
3531 6 : var startPoint = endpoints[0].point;
3532 3 : startPoint = Offset(
3533 12 : startPoint.dx.clamp(0.0, size.width),
3534 12 : startPoint.dy.clamp(0.0, size.height),
3535 : );
3536 3 : context.pushLayer(
3537 6 : LeaderLayer(link: startHandleLayerLink, offset: startPoint),
3538 : super.paint,
3539 : Offset.zero,
3540 : );
3541 6 : if (endpoints.length == 2) {
3542 6 : var endPoint = endpoints[1].point;
3543 3 : endPoint = Offset(
3544 12 : endPoint.dx.clamp(0.0, size.width),
3545 12 : endPoint.dy.clamp(0.0, size.height),
3546 : );
3547 3 : context.pushLayer(
3548 6 : LeaderLayer(link: endHandleLayerLink, offset: endPoint),
3549 : super.paint,
3550 : Offset.zero,
3551 : );
3552 : }
3553 : }
3554 :
3555 3 : @override
3556 : void paint(PaintingContext context, Offset offset) {
3557 3 : _layoutText(
3558 12 : minHeight: constraints.minHeight, maxHeight: constraints.maxHeight);
3559 9 : if (_hasVisualOverflow && clipBehavior != Clip.none) {
3560 6 : _clipRectLayer = context.pushClipRect(
3561 3 : needsCompositing,
3562 : offset,
3563 6 : Offset.zero & size,
3564 3 : _paintContents,
3565 3 : clipBehavior: clipBehavior,
3566 3 : oldLayer: _clipRectLayer,
3567 : );
3568 : } else {
3569 3 : _clipRectLayer = null;
3570 3 : _paintContents(context, offset);
3571 : }
3572 9 : _paintHandleLayers(context, getEndpointsForSelection(selection!));
3573 : }
3574 :
3575 : ClipRectLayer? _clipRectLayer;
3576 :
3577 0 : @override
3578 : Rect? describeApproximatePaintClip(RenderObject child) =>
3579 0 : _hasVisualOverflow ? Offset.zero & size : null;
3580 :
3581 1 : @override
3582 : void debugFillProperties(DiagnosticPropertiesBuilder properties) {
3583 1 : super.debugFillProperties(properties);
3584 3 : properties.add(ColorProperty('cursorColor', cursorColor));
3585 1 : properties.add(
3586 2 : DiagnosticsProperty<ValueNotifier<bool>>('showCursor', showCursor));
3587 3 : properties.add(IntProperty('maxLines', maxLines));
3588 3 : properties.add(IntProperty('minLines', minLines));
3589 1 : properties.add(
3590 2 : DiagnosticsProperty<bool>('expands', expands, defaultValue: false));
3591 3 : properties.add(ColorProperty('selectionColor', selectionColor));
3592 3 : properties.add(DoubleProperty('textScaleFactor', textScaleFactor));
3593 3 : properties.add(DiagnosticsProperty<TextSelection>('selection', selection));
3594 3 : properties.add(DiagnosticsProperty<ViewportOffset>('offset', offset));
3595 : }
3596 :
3597 1 : @override
3598 : List<DiagnosticsNode> debugDescribeChildren() {
3599 1 : return <DiagnosticsNode>[
3600 1 : if (text != null)
3601 2 : text!.toDiagnosticsNode(
3602 : name: 'text',
3603 : style: DiagnosticsTreeStyle.transition,
3604 : ),
3605 : ];
3606 : }
3607 : }
3608 :
3609 : class _MongolRenderEditableCustomPaint extends RenderBox {
3610 3 : _MongolRenderEditableCustomPaint({
3611 : MongolRenderEditablePainter? painter,
3612 : }) : _painter = painter,
3613 3 : super();
3614 :
3615 3 : @override
3616 3 : MongolRenderEditable? get parent => super.parent as MongolRenderEditable?;
3617 :
3618 3 : @override
3619 : bool get isRepaintBoundary => true;
3620 :
3621 3 : @override
3622 : bool get sizedByParent => true;
3623 :
3624 6 : MongolRenderEditablePainter? get painter => _painter;
3625 : MongolRenderEditablePainter? _painter;
3626 1 : set painter(MongolRenderEditablePainter? newValue) {
3627 2 : if (newValue == painter) return;
3628 :
3629 1 : final oldPainter = painter;
3630 1 : _painter = newValue;
3631 :
3632 2 : if (newValue?.shouldRepaint(oldPainter) ?? true) markNeedsPaint();
3633 :
3634 1 : if (attached) {
3635 2 : oldPainter?.removeListener(markNeedsPaint);
3636 2 : newValue?.addListener(markNeedsPaint);
3637 : }
3638 : }
3639 :
3640 3 : @override
3641 : void paint(PaintingContext context, Offset offset) {
3642 3 : final parent = this.parent;
3643 0 : assert(parent != null);
3644 3 : final painter = this.painter;
3645 : if (painter != null && parent != null) {
3646 9 : painter.paint(context.canvas, size, parent);
3647 : }
3648 : }
3649 :
3650 3 : @override
3651 : void attach(PipelineOwner owner) {
3652 3 : super.attach(owner);
3653 9 : _painter?.addListener(markNeedsPaint);
3654 : }
3655 :
3656 3 : @override
3657 : void detach() {
3658 9 : _painter?.removeListener(markNeedsPaint);
3659 3 : super.detach();
3660 : }
3661 :
3662 3 : @override
3663 3 : Size computeDryLayout(BoxConstraints constraints) => constraints.biggest;
3664 : }
3665 :
3666 : /// An interface that paints within a [MongolRenderEditable]'s bounds, above or
3667 : /// beneath its text content.
3668 : ///
3669 : /// This painter is typically used for painting auxiliary content that depends
3670 : /// on text layout metrics (for instance, for painting carets and text highlight
3671 : /// blocks). It can paint independently from its [MongolRenderEditable],
3672 : /// allowing it to repaint without triggering a repaint on the entire
3673 : /// [MongolRenderEditable] stack when only auxiliary content changes (e.g. a
3674 : /// blinking cursor) are present. It will be scheduled to repaint when:
3675 : ///
3676 : /// * It's assigned to a new [MongolRenderEditable] and the [shouldRepaint]
3677 : /// method returns true.
3678 : /// * Any of the [MongolRenderEditable]s it is attached to repaints.
3679 : /// * The [notifyListeners] method is called, which typically happens when the
3680 : /// painter's attributes change.
3681 : ///
3682 : /// See also:
3683 : ///
3684 : /// * [MongolRenderEditable.foregroundPainter], which takes a
3685 : /// [MongolRenderEditablePainter] and sets it as the foreground painter of
3686 : /// the [MongolRenderEditable].
3687 : /// * [MongolRenderEditable.painter], which takes a [MongolRenderEditablePainter]
3688 : /// and sets it as the background painter of the [MongolRenderEditable].
3689 : /// * [CustomPainter] a similar class which paints within a [RenderCustomPaint].
3690 : abstract class MongolRenderEditablePainter extends ChangeNotifier {
3691 : /// Determines whether repaint is needed when a new
3692 : /// [MongolRenderEditablePainter] is provided to a [MongolRenderEditable].
3693 : ///
3694 : /// If the new instance represents different information than the old
3695 : /// instance, then the method should return true, otherwise it should return
3696 : /// false. When [oldDelegate] is null, this method should always return true
3697 : /// unless the new painter initially does not paint anything.
3698 : ///
3699 : /// If the method returns false, then the [paint] call might be optimized
3700 : /// away. However, the [paint] method will get called whenever the
3701 : /// [MongolRenderEditable]s it attaches to repaint, even if [shouldRepaint]
3702 : /// returns false.
3703 : bool shouldRepaint(MongolRenderEditablePainter? oldDelegate);
3704 :
3705 : /// Paints within the bounds of a [MongolRenderEditable].
3706 : ///
3707 : /// The given [Canvas] has the same coordinate space as the
3708 : /// [MongolRenderEditable], which may be different from the coordinate space
3709 : /// the [MongolRenderEditable]'s [MongolTextPainter] uses, when the text moves
3710 : /// inside the [MongolRenderEditable].
3711 : ///
3712 : /// Paint operations performed outside of the region defined by the [canvas]'s
3713 : /// origin and the [size] parameter may get clipped, when
3714 : /// [MongolRenderEditable]'s [MongolRenderEditable.clipBehavior] is not
3715 : /// [Clip.none].
3716 : void paint(Canvas canvas, Size size, MongolRenderEditable renderEditable);
3717 : }
3718 :
3719 : class _TextHighlightPainter extends MongolRenderEditablePainter {
3720 3 : _TextHighlightPainter({TextRange? highlightedRange, Color? highlightColor})
3721 : : _highlightedRange = highlightedRange,
3722 : _highlightColor = highlightColor;
3723 :
3724 : final Paint highlightPaint = Paint();
3725 :
3726 6 : Color? get highlightColor => _highlightColor;
3727 : Color? _highlightColor;
3728 3 : set highlightColor(Color? newValue) {
3729 6 : if (newValue == _highlightColor) return;
3730 2 : _highlightColor = newValue;
3731 2 : notifyListeners();
3732 : }
3733 :
3734 6 : TextRange? get highlightedRange => _highlightedRange;
3735 : TextRange? _highlightedRange;
3736 3 : set highlightedRange(TextRange? newValue) {
3737 6 : if (newValue == _highlightedRange) return;
3738 3 : _highlightedRange = newValue;
3739 3 : notifyListeners();
3740 : }
3741 :
3742 3 : @override
3743 : void paint(Canvas canvas, Size size, MongolRenderEditable renderEditable) {
3744 3 : final range = highlightedRange;
3745 3 : final color = highlightColor;
3746 2 : if (range == null || color == null || range.isCollapsed) {
3747 : return;
3748 : }
3749 :
3750 4 : highlightPaint.color = color;
3751 4 : final boxes = renderEditable._textPainter.getBoxesForSelection(
3752 6 : TextSelection(baseOffset: range.start, extentOffset: range.end),
3753 : );
3754 :
3755 4 : for (final box in boxes) {
3756 8 : canvas.drawRect(box.shift(renderEditable._paintOffset), highlightPaint);
3757 : }
3758 : }
3759 :
3760 0 : @override
3761 : bool shouldRepaint(MongolRenderEditablePainter? oldDelegate) {
3762 : if (identical(oldDelegate, this)) {
3763 : return false;
3764 : }
3765 : if (oldDelegate == null) {
3766 0 : return highlightColor != null && highlightedRange != null;
3767 : }
3768 0 : return oldDelegate is! _TextHighlightPainter ||
3769 0 : oldDelegate.highlightColor != highlightColor ||
3770 0 : oldDelegate.highlightedRange != highlightedRange;
3771 : }
3772 : }
3773 :
3774 : class _CaretPainter extends MongolRenderEditablePainter {
3775 3 : _CaretPainter(this.caretPaintCallback);
3776 :
3777 6 : bool get shouldPaint => _shouldPaint;
3778 : bool _shouldPaint = true;
3779 3 : set shouldPaint(bool value) {
3780 6 : if (shouldPaint == value) return;
3781 3 : _shouldPaint = value;
3782 3 : notifyListeners();
3783 : }
3784 :
3785 : CaretChangedHandler caretPaintCallback;
3786 :
3787 : final Paint caretPaint = Paint();
3788 : late final Paint floatingCursorPaint = Paint();
3789 :
3790 6 : Color? get caretColor => _caretColor;
3791 : Color? _caretColor;
3792 3 : set caretColor(Color? value) {
3793 12 : if (caretColor?.value == value?.value) return;
3794 :
3795 3 : _caretColor = value;
3796 3 : notifyListeners();
3797 : }
3798 :
3799 6 : Radius? get cursorRadius => _cursorRadius;
3800 : Radius? _cursorRadius;
3801 3 : set cursorRadius(Radius? value) {
3802 6 : if (_cursorRadius == value) return;
3803 2 : _cursorRadius = value;
3804 2 : notifyListeners();
3805 : }
3806 :
3807 6 : Offset get cursorOffset => _cursorOffset;
3808 : Offset _cursorOffset = Offset.zero;
3809 3 : set cursorOffset(Offset value) {
3810 6 : if (_cursorOffset == value) return;
3811 1 : _cursorOffset = value;
3812 1 : notifyListeners();
3813 : }
3814 :
3815 3 : void paintRegularCursor(Canvas canvas, MongolRenderEditable renderEditable,
3816 : Color caretColor, TextPosition textPosition) {
3817 3 : final caretPrototype = renderEditable._caretPrototype;
3818 3 : final caretOffset = renderEditable._textPainter
3819 3 : .getOffsetForCaret(textPosition, caretPrototype);
3820 9 : var caretRect = caretPrototype.shift(caretOffset + cursorOffset);
3821 :
3822 3 : final caretWidth = renderEditable._textPainter
3823 3 : .getFullWidthForCaret(textPosition, caretPrototype);
3824 : if (caretWidth != null) {
3825 3 : caretRect = Rect.fromLTWH(
3826 6 : caretRect.left - _kCaretWidthOffset,
3827 3 : caretRect.top,
3828 : caretWidth,
3829 3 : caretRect.height,
3830 : );
3831 : }
3832 :
3833 6 : caretRect = caretRect.shift(renderEditable._paintOffset);
3834 : final integralRect =
3835 9 : caretRect.shift(renderEditable._snapToPhysicalPixel(caretRect.topLeft));
3836 :
3837 3 : if (shouldPaint) {
3838 3 : final radius = cursorRadius;
3839 6 : caretPaint.color = caretColor;
3840 : if (radius == null) {
3841 6 : canvas.drawRect(integralRect, caretPaint);
3842 : } else {
3843 2 : final caretRRect = RRect.fromRectAndRadius(integralRect, radius);
3844 4 : canvas.drawRRect(caretRRect, caretPaint);
3845 : }
3846 : }
3847 6 : caretPaintCallback(integralRect);
3848 : }
3849 :
3850 3 : @override
3851 : void paint(Canvas canvas, Size size, MongolRenderEditable renderEditable) {
3852 : // Compute the caret location even when `shouldPaint` is false.
3853 :
3854 3 : final selection = renderEditable.selection;
3855 :
3856 3 : if (selection == null || !selection.isCollapsed) {
3857 : return;
3858 : }
3859 :
3860 3 : final caretColor = this.caretColor;
3861 3 : final caretTextPosition = selection.extent;
3862 :
3863 : if (caretColor != null) {
3864 3 : paintRegularCursor(canvas, renderEditable, caretColor, caretTextPosition);
3865 : }
3866 : }
3867 :
3868 0 : @override
3869 : bool shouldRepaint(MongolRenderEditablePainter? oldDelegate) {
3870 : if (identical(this, oldDelegate)) return false;
3871 :
3872 0 : if (oldDelegate == null) return shouldPaint;
3873 0 : return oldDelegate is! _CaretPainter ||
3874 0 : oldDelegate.shouldPaint != shouldPaint ||
3875 0 : oldDelegate.caretColor != caretColor ||
3876 0 : oldDelegate.cursorRadius != cursorRadius ||
3877 0 : oldDelegate.cursorOffset != cursorOffset;
3878 : }
3879 : }
3880 :
3881 : class _CompositeRenderEditablePainter extends MongolRenderEditablePainter {
3882 3 : _CompositeRenderEditablePainter({required this.painters});
3883 :
3884 : final List<MongolRenderEditablePainter> painters;
3885 :
3886 3 : @override
3887 : void addListener(VoidCallback listener) {
3888 6 : for (final painter in painters) {
3889 3 : painter.addListener(listener);
3890 : }
3891 : }
3892 :
3893 3 : @override
3894 : void removeListener(VoidCallback listener) {
3895 6 : for (final painter in painters) {
3896 3 : painter.removeListener(listener);
3897 : }
3898 : }
3899 :
3900 3 : @override
3901 : void paint(Canvas canvas, Size size, MongolRenderEditable renderEditable) {
3902 6 : for (final painter in painters) {
3903 3 : painter.paint(canvas, size, renderEditable);
3904 : }
3905 : }
3906 :
3907 1 : @override
3908 : bool shouldRepaint(MongolRenderEditablePainter? oldDelegate) {
3909 : if (identical(oldDelegate, this)) return false;
3910 1 : if (oldDelegate is! _CompositeRenderEditablePainter ||
3911 5 : oldDelegate.painters.length != painters.length) return true;
3912 :
3913 2 : final oldPainters = oldDelegate.painters.iterator;
3914 2 : final newPainters = painters.iterator;
3915 2 : while (oldPainters.moveNext() && newPainters.moveNext()) {
3916 3 : if (newPainters.current.shouldRepaint(oldPainters.current)) return true;
3917 : }
3918 :
3919 : return false;
3920 : }
3921 : }
|