// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /// @docImport 'package:flutter/cupertino.dart'; library; import 'dart:collection'; import 'dart:math' as math; import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, LineMetrics, SemanticsInputType, TextBox; import 'package:PiliPlus/common/widgets/text_field/controller.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; const double _kCaretGap = 1.0; // pixels const double _kCaretHeightOffset = 2.0; // pixels // The additional size on the x and y axis with which to expand the prototype // cursor to render the floating cursor in pixels. const EdgeInsets _kFloatingCursorSizeIncrease = EdgeInsets.symmetric( horizontal: 0.5, vertical: 1.0, ); // The corner radius of the floating cursor in pixels. const Radius _kFloatingCursorRadius = Radius.circular(1.0); // This constant represents the shortest squared distance required between the floating cursor // and the regular cursor when both are present in the text field. // If the squared distance between the two cursors is less than this value, // it's not necessary to display both cursors at the same time. // This behavior is consistent with the one observed in iOS UITextField. const double _kShortestDistanceSquaredWithFloatingAndRegularCursors = 15.0 * 15.0; /// The consecutive sequence of [TextPosition]s that the caret should move to /// when the user navigates the paragraph using the upward arrow key or the /// downward arrow key. /// /// {@template flutter.rendering.RenderEditable.verticalArrowKeyMovement} /// When the user presses the upward arrow key or the downward arrow key, on /// many platforms (macOS for instance), the caret will move to the previous /// line or the next line, while maintaining its original horizontal location. /// When it encounters a shorter line, the caret moves to the closest horizontal /// location within that line, and restores the original horizontal location /// when a long enough line is encountered. /// /// Additionally, the caret will move to the beginning of the document if the /// upward arrow key is pressed and the caret is already on the first line. If /// the downward arrow key is pressed next, the caret will restore its original /// horizontal location and move to the second line. Similarly the caret moves /// to the end of the document if the downward arrow key is pressed when it's /// already on the last line. /// /// Consider a left-aligned paragraph: /// aa| /// a /// aaa /// where the caret was initially placed at the end of the first line. Pressing /// the downward arrow key once will move the caret to the end of the second /// line, and twice the arrow key moves to the third line after the second "a" /// on that line. Pressing the downward arrow key again, the caret will move to /// the end of the third line (the end of the document). Pressing the upward /// arrow key in this state will result in the caret moving to the end of the /// second line. /// /// Vertical caret runs are typically interrupted when the layout of the text /// changes (including when the text itself changes), or when the selection is /// changed by other input events or programmatically (for example, when the /// user pressed the left arrow key). /// {@endtemplate} /// /// The [movePrevious] method moves the caret location (which is /// [VerticalCaretMovementRun.current]) to the previous line, and in case /// the caret is already on the first line, the method does nothing and returns /// false. Similarly the [moveNext] method moves the caret to the next line, and /// returns false if the caret is already on the last line. /// /// The [moveByOffset] method takes a pixel offset from the current position to move /// the caret up or down. /// /// If the underlying paragraph's layout changes, [isValid] becomes false and /// the [VerticalCaretMovementRun] must not be used. The [isValid] property must /// be checked before calling [movePrevious], [moveNext] and [moveByOffset], /// or accessing [current]. class VerticalCaretMovementRun implements Iterator { VerticalCaretMovementRun._( this._editable, this._lineMetrics, this._currentTextPosition, this._currentLine, this._currentOffset, ); Offset _currentOffset; int _currentLine; TextPosition _currentTextPosition; final List _lineMetrics; final RenderEditable _editable; bool _isValid = true; /// Whether this [VerticalCaretMovementRun] can still continue. /// /// A [VerticalCaretMovementRun] run is valid if the underlying text layout /// hasn't changed. /// /// The [current] value and the [movePrevious], [moveNext] and [moveByOffset] /// methods must not be accessed when [isValid] is false. bool get isValid { if (!_isValid) { return false; } final List newLineMetrics = _editable._textPainter.computeLineMetrics(); // Use the implementation detail of the computeLineMetrics method to figure // out if the current text layout has been invalidated. if (!identical(newLineMetrics, _lineMetrics)) { _isValid = false; } return _isValid; } final Map> _positionCache = >{}; MapEntry _getTextPositionForLine(int lineNumber) { assert(isValid); assert(lineNumber >= 0); final MapEntry? cachedPosition = _positionCache[lineNumber]; if (cachedPosition != null) { return cachedPosition; } assert(lineNumber != _currentLine); final Offset newOffset = Offset( _currentOffset.dx, _lineMetrics[lineNumber].baseline, ); final TextPosition closestPosition = _editable._textPainter.getPositionForOffset(newOffset); final MapEntry position = MapEntry(newOffset, closestPosition); _positionCache[lineNumber] = position; return position; } @override TextPosition get current { assert(isValid); return _currentTextPosition; } @override bool moveNext() { assert(isValid); if (_currentLine + 1 >= _lineMetrics.length) { return false; } final MapEntry position = _getTextPositionForLine( _currentLine + 1, ); _currentLine += 1; _currentOffset = position.key; _currentTextPosition = position.value; return true; } /// Move back to the previous element. /// /// Returns true and updates [current] if successful. bool movePrevious() { assert(isValid); if (_currentLine <= 0) { return false; } final MapEntry position = _getTextPositionForLine( _currentLine - 1, ); _currentLine -= 1; _currentOffset = position.key; _currentTextPosition = position.value; return true; } /// Move forward or backward by a number of elements determined /// by pixel [offset]. /// /// If [offset] is negative, move backward; otherwise move forward. /// /// Returns true and updates [current] if successful. bool moveByOffset(double offset) { final Offset initialOffset = _currentOffset; if (offset >= 0.0) { while (_currentOffset.dy < initialOffset.dy + offset) { if (!moveNext()) { break; } } } else { while (_currentOffset.dy > initialOffset.dy + offset) { if (!movePrevious()) { break; } } } return initialOffset != _currentOffset; } } /// Displays some text in a scrollable container with a potentially blinking /// cursor and with gesture recognizers. /// /// This is the renderer for an editable text field. It does not directly /// provide affordances for editing the text, but it does handle text selection /// and manipulation of the text cursor. /// /// The [text] is displayed, scrolled by the given [offset], aligned according /// to [textAlign]. The [maxLines] property controls whether the text displays /// on one line or many. The [selection], if it is not collapsed, is painted in /// the [selectionColor]. If it _is_ collapsed, then it represents the cursor /// position. The cursor is shown while [showCursor] is true. It is painted in /// the [cursorColor]. /// /// Keyboard handling, IME handling, scrolling, toggling the [showCursor] value /// to actually blink the cursor, and other features not mentioned above are the /// responsibility of higher layers and not handled by this object. class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, ContainerRenderObjectMixin, RenderInlineChildrenContainerDefaults implements TextLayoutMetrics { /// Creates a render object that implements the visual aspects of a text field. /// /// The [textAlign] argument defaults to [TextAlign.start]. /// /// If [showCursor] is not specified, then it defaults to hiding the cursor. /// /// The [maxLines] property can be set to null to remove the restriction on /// the number of lines. By default, it is 1, meaning this is a single-line /// text field. If it is not null, it must be greater than zero. /// /// Use [ViewportOffset.zero] for the [offset] if there is no need for /// scrolling. RenderEditable({ InlineSpan? text, required TextDirection textDirection, TextAlign textAlign = TextAlign.start, Color? cursorColor, Color? backgroundCursorColor, ValueNotifier? showCursor, bool? hasFocus, required LayerLink startHandleLayerLink, required LayerLink endHandleLayerLink, int? maxLines = 1, int? minLines, bool expands = false, StrutStyle? strutStyle, Color? selectionColor, @Deprecated( 'Use textScaler instead. ' 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' 'This feature was deprecated after v3.12.0-2.0.pre.', ) double textScaleFactor = 1.0, TextScaler textScaler = TextScaler.noScaling, TextSelection? selection, required ViewportOffset offset, this.ignorePointer = false, bool readOnly = false, bool forceLine = true, TextHeightBehavior? textHeightBehavior, TextWidthBasis textWidthBasis = TextWidthBasis.parent, String obscuringCharacter = '•', bool obscureText = false, Locale? locale, double cursorWidth = 1.0, double? cursorHeight, Radius? cursorRadius, bool paintCursorAboveText = false, Offset cursorOffset = Offset.zero, double devicePixelRatio = 1.0, ui.BoxHeightStyle selectionHeightStyle = ui.BoxHeightStyle.tight, ui.BoxWidthStyle selectionWidthStyle = ui.BoxWidthStyle.tight, bool? enableInteractiveSelection, this.floatingCursorAddedMargin = const EdgeInsets.fromLTRB(4, 4, 4, 5), TextRange? promptRectRange, Color? promptRectColor, Clip clipBehavior = Clip.hardEdge, required this.textSelectionDelegate, RenderEditablePainter? painter, RenderEditablePainter? foregroundPainter, List? children, required this.controller, }) : assert(maxLines == null || maxLines > 0), assert(minLines == null || minLines > 0), assert( (maxLines == null) || (minLines == null) || (maxLines >= minLines), "minLines can't be greater than maxLines", ), assert( !expands || (maxLines == null && minLines == null), 'minLines and maxLines must be null when expands is true.', ), assert( identical(textScaler, TextScaler.noScaling) || textScaleFactor == 1.0, 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.', ), assert(obscuringCharacter.characters.length == 1), assert(cursorWidth >= 0.0), assert(cursorHeight == null || cursorHeight >= 0.0), _textPainter = TextPainter( text: text, textAlign: textAlign, textDirection: textDirection, textScaler: textScaler == TextScaler.noScaling ? TextScaler.linear(textScaleFactor) : textScaler, locale: locale, maxLines: maxLines == 1 ? 1 : null, strutStyle: strutStyle, textHeightBehavior: textHeightBehavior, textWidthBasis: textWidthBasis, ), _showCursor = showCursor ?? ValueNotifier(false), _maxLines = maxLines, _minLines = minLines, _expands = expands, _selection = selection, _offset = offset, _cursorWidth = cursorWidth, _cursorHeight = cursorHeight, _paintCursorOnTop = paintCursorAboveText, _enableInteractiveSelection = enableInteractiveSelection, _devicePixelRatio = devicePixelRatio, _startHandleLayerLink = startHandleLayerLink, _endHandleLayerLink = endHandleLayerLink, _obscuringCharacter = obscuringCharacter, _obscureText = obscureText, _readOnly = readOnly, _forceLine = forceLine, _clipBehavior = clipBehavior, _hasFocus = hasFocus ?? false, _disposeShowCursor = showCursor == null { assert(!_showCursor.value || cursorColor != null); _selectionPainter.highlightColor = selectionColor; _selectionPainter.highlightedRange = selection; _selectionPainter.selectionHeightStyle = selectionHeightStyle; _selectionPainter.selectionWidthStyle = selectionWidthStyle; _autocorrectHighlightPainter.highlightColor = promptRectColor; _autocorrectHighlightPainter.highlightedRange = promptRectRange; _caretPainter.caretColor = cursorColor; _caretPainter.cursorRadius = cursorRadius; _caretPainter.cursorOffset = cursorOffset; _caretPainter.backgroundCursorColor = backgroundCursorColor; _updateForegroundPainter(foregroundPainter); _updatePainter(painter); addAll(children); } final RichTextEditingController controller; /// Child render objects _RenderEditableCustomPaint? _foregroundRenderObject; _RenderEditableCustomPaint? _backgroundRenderObject; @override void dispose() { _leaderLayerHandler.layer = null; _foregroundRenderObject?.dispose(); _foregroundRenderObject = null; _backgroundRenderObject?.dispose(); _backgroundRenderObject = null; _clipRectLayer.layer = null; _cachedBuiltInForegroundPainters?.dispose(); _cachedBuiltInPainters?.dispose(); _selectionStartInViewport.dispose(); _selectionEndInViewport.dispose(); _autocorrectHighlightPainter.dispose(); _selectionPainter.dispose(); _caretPainter.dispose(); _textPainter.dispose(); _textIntrinsicsCache?.dispose(); if (_disposeShowCursor) { _showCursor.dispose(); _disposeShowCursor = false; } super.dispose(); } void _updateForegroundPainter(RenderEditablePainter? newPainter) { final _CompositeRenderEditablePainter effectivePainter = newPainter == null ? _builtInForegroundPainters : _CompositeRenderEditablePainter( painters: [ _builtInForegroundPainters, newPainter, ], ); if (_foregroundRenderObject == null) { final _RenderEditableCustomPaint foregroundRenderObject = _RenderEditableCustomPaint(painter: effectivePainter); adoptChild(foregroundRenderObject); _foregroundRenderObject = foregroundRenderObject; } else { _foregroundRenderObject?.painter = effectivePainter; } _foregroundPainter = newPainter; } /// The [RenderEditablePainter] to use for painting above this /// [RenderEditable]'s text content. /// /// The new [RenderEditablePainter] will replace the previously specified /// foreground painter, and schedule a repaint if the new painter's /// `shouldRepaint` method returns true. RenderEditablePainter? get foregroundPainter => _foregroundPainter; RenderEditablePainter? _foregroundPainter; set foregroundPainter(RenderEditablePainter? newPainter) { if (newPainter == _foregroundPainter) { return; } _updateForegroundPainter(newPainter); } void _updatePainter(RenderEditablePainter? newPainter) { final _CompositeRenderEditablePainter effectivePainter = newPainter == null ? _builtInPainters : _CompositeRenderEditablePainter( painters: [_builtInPainters, newPainter], ); if (_backgroundRenderObject == null) { final _RenderEditableCustomPaint backgroundRenderObject = _RenderEditableCustomPaint(painter: effectivePainter); adoptChild(backgroundRenderObject); _backgroundRenderObject = backgroundRenderObject; } else { _backgroundRenderObject?.painter = effectivePainter; } _painter = newPainter; } /// Sets the [RenderEditablePainter] to use for painting beneath this /// [RenderEditable]'s text content. /// /// The new [RenderEditablePainter] will replace the previously specified /// painter, and schedule a repaint if the new painter's `shouldRepaint` /// method returns true. RenderEditablePainter? get painter => _painter; RenderEditablePainter? _painter; set painter(RenderEditablePainter? newPainter) { if (newPainter == _painter) { return; } _updatePainter(newPainter); } // Caret Painters: // A single painter for both the regular caret and the floating cursor. late final _CaretPainter _caretPainter = _CaretPainter(); // Text Highlight painters: final _TextHighlightPainter _selectionPainter = _TextHighlightPainter(); final _TextHighlightPainter _autocorrectHighlightPainter = _TextHighlightPainter(); _CompositeRenderEditablePainter get _builtInForegroundPainters => _cachedBuiltInForegroundPainters ??= _createBuiltInForegroundPainters(); _CompositeRenderEditablePainter? _cachedBuiltInForegroundPainters; _CompositeRenderEditablePainter _createBuiltInForegroundPainters() { return _CompositeRenderEditablePainter( painters: [ if (paintCursorAboveText) _caretPainter, ], ); } _CompositeRenderEditablePainter get _builtInPainters => _cachedBuiltInPainters ??= _createBuiltInPainters(); _CompositeRenderEditablePainter? _cachedBuiltInPainters; _CompositeRenderEditablePainter _createBuiltInPainters() { return _CompositeRenderEditablePainter( painters: [ _autocorrectHighlightPainter, _selectionPainter, if (!paintCursorAboveText) _caretPainter, ], ); } /// Whether the [handleEvent] will propagate pointer events to selection /// handlers. /// /// If this property is true, the [handleEvent] assumes that this renderer /// will be notified of input gestures via [handleTapDown], [handleTap], /// [handleDoubleTap], and [handleLongPress]. /// /// If there are any gesture recognizers in the text span, the [handleEvent] /// will still propagate pointer events to those recognizers. /// /// The default value of this property is false. bool ignorePointer; /// {@macro dart.ui.textHeightBehavior} TextHeightBehavior? get textHeightBehavior => _textPainter.textHeightBehavior; set textHeightBehavior(TextHeightBehavior? value) { if (_textPainter.textHeightBehavior == value) { return; } _textPainter.textHeightBehavior = value; markNeedsLayout(); } /// {@macro flutter.painting.textPainter.textWidthBasis} TextWidthBasis get textWidthBasis => _textPainter.textWidthBasis; set textWidthBasis(TextWidthBasis value) { if (_textPainter.textWidthBasis == value) { return; } _textPainter.textWidthBasis = value; markNeedsLayout(); } /// The pixel ratio of the current device. /// /// Should be obtained by querying MediaQuery for the devicePixelRatio. double get devicePixelRatio => _devicePixelRatio; double _devicePixelRatio; set devicePixelRatio(double value) { if (devicePixelRatio == value) { return; } _devicePixelRatio = value; markNeedsLayout(); } /// Character used for obscuring text if [obscureText] is true. /// /// Must have a length of exactly one. String get obscuringCharacter => _obscuringCharacter; String _obscuringCharacter; set obscuringCharacter(String value) { if (_obscuringCharacter == value) { return; } assert(value.characters.length == 1); _obscuringCharacter = value; markNeedsLayout(); } /// Whether to hide the text being edited (e.g., for passwords). bool get obscureText => _obscureText; bool _obscureText; set obscureText(bool value) { if (_obscureText == value) { return; } _obscureText = value; _cachedAttributedValue = null; markNeedsSemanticsUpdate(); } /// Controls how tall the selection highlight boxes are computed to be. /// /// See [ui.BoxHeightStyle] for details on available styles. ui.BoxHeightStyle get selectionHeightStyle => _selectionPainter.selectionHeightStyle; set selectionHeightStyle(ui.BoxHeightStyle value) { _selectionPainter.selectionHeightStyle = value; } /// Controls how wide the selection highlight boxes are computed to be. /// /// See [ui.BoxWidthStyle] for details on available styles. ui.BoxWidthStyle get selectionWidthStyle => _selectionPainter.selectionWidthStyle; set selectionWidthStyle(ui.BoxWidthStyle value) { _selectionPainter.selectionWidthStyle = value; } /// The object that controls the text selection, used by this render object /// for implementing cut, copy, and paste keyboard shortcuts. /// /// It will make cut, copy and paste functionality work with the most recently /// set [TextSelectionDelegate]. TextSelectionDelegate textSelectionDelegate; /// Track whether position of the start of the selected text is within the viewport. /// /// For example, if the text contains "Hello World", and the user selects /// "Hello", then scrolls so only "World" is visible, this will become false. /// If the user scrolls back so that the "H" is visible again, this will /// become true. /// /// This bool indicates whether the text is scrolled so that the handle is /// inside the text field viewport, as opposed to whether it is actually /// visible on the screen. ValueListenable get selectionStartInViewport => _selectionStartInViewport; final ValueNotifier _selectionStartInViewport = ValueNotifier( true, ); /// Track whether position of the end of the selected text is within the viewport. /// /// For example, if the text contains "Hello World", and the user selects /// "World", then scrolls so only "Hello" is visible, this will become /// 'false'. If the user scrolls back so that the "d" is visible again, this /// will become 'true'. /// /// This bool indicates whether the text is scrolled so that the handle is /// inside the text field viewport, as opposed to whether it is actually /// visible on the screen. ValueListenable get selectionEndInViewport => _selectionEndInViewport; final ValueNotifier _selectionEndInViewport = ValueNotifier(true); /// Returns the TextPosition above or below the given offset. TextPosition _getTextPositionVertical( TextPosition position, double verticalOffset, ) { final Offset caretOffset = _textPainter.getOffsetForCaret( position, _caretPrototype, ); final Offset caretOffsetTranslated = caretOffset.translate( 0.0, verticalOffset, ); return _textPainter.getPositionForOffset(caretOffsetTranslated); } // Start TextLayoutMetrics. /// {@macro flutter.services.TextLayoutMetrics.getLineAtOffset} @override TextSelection getLineAtOffset(TextPosition position) { final TextRange line = _textPainter.getLineBoundary(position); // If text is obscured, the entire string should be treated as one line. if (obscureText) { return TextSelection(baseOffset: 0, extentOffset: plainText.length); } return TextSelection(baseOffset: line.start, extentOffset: line.end); } /// {@macro flutter.painting.TextPainter.getWordBoundary} @override TextRange getWordBoundary(TextPosition position) { return _textPainter.getWordBoundary(position); } /// {@macro flutter.services.TextLayoutMetrics.getTextPositionAbove} @override TextPosition getTextPositionAbove(TextPosition position) { // The caret offset gives a location in the upper left hand corner of // the caret so the middle of the line above is a half line above that // point and the line below is 1.5 lines below that point. final double preferredLineHeight = _textPainter.preferredLineHeight; final double verticalOffset = -0.5 * preferredLineHeight; return _getTextPositionVertical(position, verticalOffset); } /// {@macro flutter.services.TextLayoutMetrics.getTextPositionBelow} @override TextPosition getTextPositionBelow(TextPosition position) { // The caret offset gives a location in the upper left hand corner of // the caret so the middle of the line above is a half line above that // point and the line below is 1.5 lines below that point. final double preferredLineHeight = _textPainter.preferredLineHeight; final double verticalOffset = 1.5 * preferredLineHeight; return _getTextPositionVertical(position, verticalOffset); } // End TextLayoutMetrics. void _updateSelectionExtentsVisibility(Offset effectiveOffset) { assert(selection != null); if (!selection!.isValid) { _selectionStartInViewport.value = false; _selectionEndInViewport.value = false; return; } final Rect visibleRegion = Offset.zero & size; final Offset startOffset = _textPainter.getOffsetForCaret( TextPosition(offset: selection!.start, affinity: selection!.affinity), _caretPrototype, ); // Check if the selection is visible with an approximation because a // difference between rounded and unrounded values causes the caret to be // reported as having a slightly (< 0.5) negative y offset. This rounding // happens in paragraph.cc's layout and TextPainter's // _applyFloatingPointHack. Ideally, the rounding mismatch will be fixed and // this can be changed to be a strict check instead of an approximation. const double visibleRegionSlop = 0.5; _selectionStartInViewport.value = visibleRegion .inflate(visibleRegionSlop) .contains(startOffset + effectiveOffset); final Offset endOffset = _textPainter.getOffsetForCaret( TextPosition(offset: selection!.end, affinity: selection!.affinity), _caretPrototype, ); _selectionEndInViewport.value = visibleRegion .inflate(visibleRegionSlop) .contains(endOffset + effectiveOffset); } void _setTextEditingValue( TextEditingValue newValue, SelectionChangedCause cause, ) { textSelectionDelegate.userUpdateTextEditingValue(newValue, cause); } void _setSelection(TextSelection nextSelection, SelectionChangedCause cause) { if (nextSelection.isValid) { // The nextSelection is calculated based on plainText, which can be out // of sync with the textSelectionDelegate.textEditingValue by one frame. // This is due to the render editable and editable text handle pointer // event separately. If the editable text changes the text during the // event handler, the render editable will use the outdated text stored in // the plainText when handling the pointer event. // // If this happens, we need to make sure the new selection is still valid. final int textLength = textSelectionDelegate.textEditingValue.text.length; nextSelection = nextSelection.copyWith( baseOffset: math.min(nextSelection.baseOffset, textLength), extentOffset: math.min(nextSelection.extentOffset, textLength), ); } _setTextEditingValue( textSelectionDelegate.textEditingValue.copyWith(selection: nextSelection), cause, ); } @override void markNeedsPaint() { super.markNeedsPaint(); // Tell the painters to repaint since text layout may have changed. _foregroundRenderObject?.markNeedsPaint(); _backgroundRenderObject?.markNeedsPaint(); } @override void systemFontsDidChange() { super.systemFontsDidChange(); _textPainter.markNeedsLayout(); } /// Returns a plain text version of the text in [TextPainter]. /// /// If [obscureText] is true, returns the obscured text. See /// [obscureText] and [obscuringCharacter]. /// In order to get the styled text as an [InlineSpan] tree, use [text]. String get plainText => _textPainter.plainText; /// The text to paint in the form of a tree of [InlineSpan]s. /// /// In order to get the plain text representation, use [plainText]. InlineSpan? get text => _textPainter.text; final TextPainter _textPainter; AttributedString? _cachedAttributedValue; List? _cachedCombinedSemanticsInfos; set text(InlineSpan? value) { if (_textPainter.text == value) { return; } _cachedLineBreakCount = null; _textPainter.text = value; _cachedAttributedValue = null; _cachedCombinedSemanticsInfos = null; markNeedsLayout(); markNeedsSemanticsUpdate(); } TextPainter? _textIntrinsicsCache; TextPainter get _textIntrinsics { return (_textIntrinsicsCache ??= TextPainter()) ..text = _textPainter.text ..textAlign = _textPainter.textAlign ..textDirection = _textPainter.textDirection ..textScaler = _textPainter.textScaler ..maxLines = _textPainter.maxLines ..ellipsis = _textPainter.ellipsis ..locale = _textPainter.locale ..strutStyle = _textPainter.strutStyle ..textWidthBasis = _textPainter.textWidthBasis ..textHeightBehavior = _textPainter.textHeightBehavior; } /// How the text should be aligned horizontally. TextAlign get textAlign => _textPainter.textAlign; set textAlign(TextAlign value) { if (_textPainter.textAlign == value) { return; } _textPainter.textAlign = value; markNeedsLayout(); } /// The directionality of the text. /// /// This decides how the [TextAlign.start], [TextAlign.end], and /// [TextAlign.justify] values of [textAlign] are interpreted. /// /// This is also used to disambiguate how to render bidirectional text. For /// example, if the [text] is an English phrase followed by a Hebrew phrase, /// in a [TextDirection.ltr] context the English phrase will be on the left /// and the Hebrew phrase to its right, while in a [TextDirection.rtl] /// context, the English phrase will be on the right and the Hebrew phrase on /// its left. // TextPainter.textDirection is nullable, but it is set to a // non-null value in the RenderEditable constructor and we refuse to // set it to null here, so _textPainter.textDirection cannot be null. TextDirection get textDirection => _textPainter.textDirection!; set textDirection(TextDirection value) { if (_textPainter.textDirection == value) { return; } _textPainter.textDirection = value; markNeedsLayout(); markNeedsSemanticsUpdate(); } /// Used by this renderer's internal [TextPainter] to select a locale-specific /// font. /// /// In some cases the same Unicode character may be rendered differently depending /// on the locale. For example the '骨' character is rendered differently in /// the Chinese and Japanese locales. In these cases the [locale] may be used /// to select a locale-specific font. /// /// If this value is null, a system-dependent algorithm is used to select /// the font. Locale? get locale => _textPainter.locale; set locale(Locale? value) { if (_textPainter.locale == value) { return; } _textPainter.locale = value; markNeedsLayout(); } /// The [StrutStyle] used by the renderer's internal [TextPainter] to /// determine the strut to use. StrutStyle? get strutStyle => _textPainter.strutStyle; set strutStyle(StrutStyle? value) { if (_textPainter.strutStyle == value) { return; } _textPainter.strutStyle = value; markNeedsLayout(); } /// The color to use when painting the cursor. Color? get cursorColor => _caretPainter.caretColor; set cursorColor(Color? value) { _caretPainter.caretColor = value; } /// The color to use when painting the cursor aligned to the text while /// rendering the floating cursor. /// /// Typically this would be set to [CupertinoColors.inactiveGray]. /// /// If this is null, the background cursor is not painted. /// /// See also: /// /// * [FloatingCursorDragState], which explains the floating cursor feature /// in detail. Color? get backgroundCursorColor => _caretPainter.backgroundCursorColor; set backgroundCursorColor(Color? value) { _caretPainter.backgroundCursorColor = value; } bool _disposeShowCursor; /// Whether to paint the cursor. ValueNotifier get showCursor => _showCursor; ValueNotifier _showCursor; set showCursor(ValueNotifier value) { if (_showCursor == value) { return; } if (attached) { _showCursor.removeListener(_showHideCursor); } if (_disposeShowCursor) { _showCursor.dispose(); _disposeShowCursor = false; } _showCursor = value; if (attached) { _showHideCursor(); _showCursor.addListener(_showHideCursor); } } void _showHideCursor() { _caretPainter.shouldPaint = showCursor.value; } /// Whether the editable is currently focused. bool get hasFocus => _hasFocus; bool _hasFocus = false; set hasFocus(bool value) { if (_hasFocus == value) { return; } _hasFocus = value; markNeedsSemanticsUpdate(); } /// Whether this rendering object will take a full line regardless the text width. bool get forceLine => _forceLine; bool _forceLine = false; set forceLine(bool value) { if (_forceLine == value) { return; } _forceLine = value; markNeedsLayout(); } /// Whether this rendering object is read only. bool get readOnly => _readOnly; bool _readOnly = false; set readOnly(bool value) { if (_readOnly == value) { return; } _readOnly = value; markNeedsSemanticsUpdate(); } /// The maximum number of lines for the text to span, wrapping if necessary. /// /// If this is 1 (the default), the text will not wrap, but will extend /// indefinitely instead. /// /// If this is null, there is no limit to the number of lines. /// /// When this is not null, the intrinsic height of the render object is the /// height of one line of text multiplied by this value. In other words, this /// also controls the height of the actual editing widget. int? get maxLines => _maxLines; int? _maxLines; /// The value may be null. If it is not null, then it must be greater than zero. set maxLines(int? value) { assert(value == null || value > 0); if (maxLines == value) { return; } _maxLines = value; // Special case maxLines == 1 to keep only the first line so we can get the // height of the first line in case there are hard line breaks in the text. // See the `_preferredHeight` method. _textPainter.maxLines = value == 1 ? 1 : null; markNeedsLayout(); } /// {@macro flutter.widgets.editableText.minLines} int? get minLines => _minLines; int? _minLines; /// The value may be null. If it is not null, then it must be greater than zero. set minLines(int? value) { assert(value == null || value > 0); if (minLines == value) { return; } _minLines = value; markNeedsLayout(); } /// {@macro flutter.widgets.editableText.expands} bool get expands => _expands; bool _expands; set expands(bool value) { if (expands == value) { return; } _expands = value; markNeedsLayout(); } /// The color to use when painting the selection. Color? get selectionColor => _selectionPainter.highlightColor; set selectionColor(Color? value) { _selectionPainter.highlightColor = value; } /// Deprecated. Will be removed in a future version of Flutter. Use /// [textScaler] instead. /// /// The number of font pixels for each logical pixel. /// /// For example, if the text scale factor is 1.5, text will be 50% larger than /// the specified font size. @Deprecated( 'Use textScaler instead. ' 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' 'This feature was deprecated after v3.12.0-2.0.pre.', ) double get textScaleFactor => _textPainter.textScaleFactor; @Deprecated( 'Use textScaler instead. ' 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' 'This feature was deprecated after v3.12.0-2.0.pre.', ) set textScaleFactor(double value) { textScaler = TextScaler.linear(value); } /// {@macro flutter.painting.textPainter.textScaler} TextScaler get textScaler => _textPainter.textScaler; set textScaler(TextScaler value) { if (_textPainter.textScaler == value) { return; } _textPainter.textScaler = value; markNeedsLayout(); } /// The region of text that is selected, if any. /// /// The caret position is represented by a collapsed selection. /// /// If [selection] is null, there is no selection and attempts to /// manipulate the selection will throw. TextSelection? get selection => _selection; TextSelection? _selection; set selection(TextSelection? value) { if (_selection == value) { return; } _selection = value; _selectionPainter.highlightedRange = value; markNeedsPaint(); markNeedsSemanticsUpdate(); } /// The offset at which the text should be painted. /// /// If the text content is larger than the editable line itself, the editable /// line clips the text. This property controls which part of the text is /// visible by shifting the text by the given offset before clipping. ViewportOffset get offset => _offset; ViewportOffset _offset; set offset(ViewportOffset value) { if (_offset == value) { return; } if (attached) { _offset.removeListener(markNeedsPaint); } _offset = value; if (attached) { _offset.addListener(markNeedsPaint); } markNeedsLayout(); } /// How thick the cursor will be. double get cursorWidth => _cursorWidth; double _cursorWidth = 1.0; set cursorWidth(double value) { if (_cursorWidth == value) { return; } _cursorWidth = value; markNeedsLayout(); } /// How tall the cursor will be. /// /// This can be null, in which case the getter will actually return [preferredLineHeight]. /// /// Setting this to itself fixes the value to the current [preferredLineHeight]. Setting /// this to null returns the behavior of deferring to [preferredLineHeight]. // TODO(ianh): This is a confusing API. We should have a separate getter for the effective cursor height. double get cursorHeight => _cursorHeight ?? preferredLineHeight; double? _cursorHeight; set cursorHeight(double? value) { if (_cursorHeight == value) { return; } _cursorHeight = value; markNeedsLayout(); } /// {@template flutter.rendering.RenderEditable.paintCursorAboveText} /// If the cursor should be painted on top of the text or underneath it. /// /// By default, the cursor should be painted on top for iOS platforms and /// underneath for Android platforms. /// {@endtemplate} bool get paintCursorAboveText => _paintCursorOnTop; bool _paintCursorOnTop; set paintCursorAboveText(bool value) { if (_paintCursorOnTop == value) { return; } _paintCursorOnTop = value; // Clear cached built-in painters and reconfigure painters. _cachedBuiltInForegroundPainters = null; _cachedBuiltInPainters = null; // Call update methods to rebuild and set the effective painters. _updateForegroundPainter(_foregroundPainter); _updatePainter(_painter); } /// {@template flutter.rendering.RenderEditable.cursorOffset} /// The offset that is used, in pixels, when painting the cursor on screen. /// /// By default, the cursor position should be set to an offset of /// (-[cursorWidth] * 0.5, 0.0) on iOS platforms and (0, 0) on Android /// platforms. The origin from where the offset is applied to is the arbitrary /// location where the cursor ends up being rendered from by default. /// {@endtemplate} Offset get cursorOffset => _caretPainter.cursorOffset; set cursorOffset(Offset value) { _caretPainter.cursorOffset = value; } /// How rounded the corners of the cursor should be. /// /// A null value is the same as [Radius.zero]. Radius? get cursorRadius => _caretPainter.cursorRadius; set cursorRadius(Radius? value) { _caretPainter.cursorRadius = value; } /// The [LayerLink] of start selection handle. /// /// [RenderEditable] is responsible for calculating the [Offset] of this /// [LayerLink], which will be used as [CompositedTransformTarget] of start handle. LayerLink get startHandleLayerLink => _startHandleLayerLink; LayerLink _startHandleLayerLink; set startHandleLayerLink(LayerLink value) { if (_startHandleLayerLink == value) { return; } _startHandleLayerLink = value; markNeedsPaint(); } /// The [LayerLink] of end selection handle. /// /// [RenderEditable] is responsible for calculating the [Offset] of this /// [LayerLink], which will be used as [CompositedTransformTarget] of end handle. LayerLink get endHandleLayerLink => _endHandleLayerLink; LayerLink _endHandleLayerLink; set endHandleLayerLink(LayerLink value) { if (_endHandleLayerLink == value) { return; } _endHandleLayerLink = value; markNeedsPaint(); } /// The padding applied to text field. Used to determine the bounds when /// moving the floating cursor. /// /// Defaults to a padding with left, top and right set to 4, bottom to 5. /// /// See also: /// /// * [FloatingCursorDragState], which explains the floating cursor feature /// in detail. EdgeInsets floatingCursorAddedMargin; /// Returns true if the floating cursor is visible, false otherwise. bool get floatingCursorOn => _floatingCursorOn; bool _floatingCursorOn = false; late TextPosition _floatingCursorTextPosition; /// Whether to allow the user to change the selection. /// /// Since [RenderEditable] does not handle selection manipulation /// itself, this actually only affects whether the accessibility /// hints provided to the system (via /// [describeSemanticsConfiguration]) will enable selection /// manipulation. It's the responsibility of this object's owner /// to provide selection manipulation affordances. /// /// This field is used by [selectionEnabled] (which then controls /// the accessibility hints mentioned above). When null, /// [obscureText] is used to determine the value of /// [selectionEnabled] instead. bool? get enableInteractiveSelection => _enableInteractiveSelection; bool? _enableInteractiveSelection; set enableInteractiveSelection(bool? value) { if (_enableInteractiveSelection == value) { return; } _enableInteractiveSelection = value; markNeedsLayout(); markNeedsSemanticsUpdate(); } /// Whether interactive selection are enabled based on the values of /// [enableInteractiveSelection] and [obscureText]. /// /// Since [RenderEditable] does not handle selection manipulation /// itself, this actually only affects whether the accessibility /// hints provided to the system (via /// [describeSemanticsConfiguration]) will enable selection /// manipulation. It's the responsibility of this object's owner /// to provide selection manipulation affordances. /// /// By default, [enableInteractiveSelection] is null, [obscureText] is false, /// and this getter returns true. /// /// If [enableInteractiveSelection] is null and [obscureText] is true, then this /// getter returns false. This is the common case for password fields. /// /// If [enableInteractiveSelection] is non-null then its value is /// returned. An application might [enableInteractiveSelection] to /// true to enable interactive selection for a password field, or to /// false to unconditionally disable interactive selection. bool get selectionEnabled { return enableInteractiveSelection ?? !obscureText; } /// The color used to paint the prompt rectangle. /// /// The prompt rectangle will only be requested on non-web iOS applications. // TODO(ianh): We should change the getter to return null when _promptRectRange is null // (otherwise, if you set it to null and then get it, you get back non-null). // Alternatively, we could stop supporting setting this to null. Color? get promptRectColor => _autocorrectHighlightPainter.highlightColor; set promptRectColor(Color? newValue) { _autocorrectHighlightPainter.highlightColor = newValue; } /// Dismisses the currently displayed prompt rectangle and displays a new prompt rectangle /// over [newRange] in the given color [promptRectColor]. /// /// The prompt rectangle will only be requested on non-web iOS applications. /// /// When set to null, the currently displayed prompt rectangle (if any) will be dismissed. // ignore: use_setters_to_change_properties, (API predates enforcing the lint) void setPromptRectRange(TextRange? newRange) { _autocorrectHighlightPainter.highlightedRange = newRange; } /// The maximum amount the text is allowed to scroll. /// /// This value is only valid after layout and can change as additional /// text is entered or removed in order to accommodate expanding when /// [expands] is set to true. double get maxScrollExtent => _maxScrollExtent; double _maxScrollExtent = 0; double get _caretMargin => _kCaretGap + cursorWidth; /// {@macro flutter.material.Material.clipBehavior} /// /// Defaults to [Clip.hardEdge]. Clip get clipBehavior => _clipBehavior; Clip _clipBehavior = Clip.hardEdge; set clipBehavior(Clip value) { if (value != _clipBehavior) { _clipBehavior = value; markNeedsPaint(); markNeedsSemanticsUpdate(); } } /// Collected during [describeSemanticsConfiguration], used by /// [assembleSemanticsNode]. List? _semanticsInfo; // Caches [SemanticsNode]s created during [assembleSemanticsNode] so they // can be re-used when [assembleSemanticsNode] is called again. This ensures // stable ids for the [SemanticsNode]s of [TextSpan]s across // [assembleSemanticsNode] invocations. LinkedHashMap? _cachedChildNodes; /// Returns a list of rects that bound the given selection, and the text /// direction. The text direction is used by the engine to calculate /// the closest position to a given point. /// /// See [TextPainter.getBoxesForSelection] for more details. List getBoxesForSelection(TextSelection selection) { _computeTextMetricsIfNeeded(); return _textPainter .getBoxesForSelection(selection) .map( (TextBox textBox) => TextBox.fromLTRBD( textBox.left + _paintOffset.dx, textBox.top + _paintOffset.dy, textBox.right + _paintOffset.dx, textBox.bottom + _paintOffset.dy, textBox.direction, ), ) .toList(); } @override void describeSemanticsConfiguration(SemanticsConfiguration config) { super.describeSemanticsConfiguration(config); _semanticsInfo = _textPainter.text!.getSemanticsInformation(); // TODO(chunhtai): the macOS does not provide a public API to support text // selections across multiple semantics nodes. Remove this platform check // once we can support it. // https://github.com/flutter/flutter/issues/77957 if (_semanticsInfo!.any( (InlineSpanSemanticsInformation info) => info.recognizer != null, ) && defaultTargetPlatform != TargetPlatform.macOS) { // assert(readOnly && !obscureText); // For Selectable rich text with recognizer, we need to create a semantics // node for each text fragment. config ..isSemanticBoundary = true ..explicitChildNodes = true; return; } if (_cachedAttributedValue == null) { if (obscureText) { _cachedAttributedValue = AttributedString( obscuringCharacter * plainText.length, ); } else { final StringBuffer buffer = StringBuffer(); int offset = 0; final List attributes = []; for (final InlineSpanSemanticsInformation info in _semanticsInfo!) { final String label = info.semanticsLabel ?? info.text; for (final StringAttribute infoAttribute in info.stringAttributes) { final TextRange originalRange = infoAttribute.range; attributes.add( infoAttribute.copy( range: TextRange( start: offset + originalRange.start, end: offset + originalRange.end, ), ), ); } buffer.write(label); offset += label.length; } _cachedAttributedValue = AttributedString( buffer.toString(), attributes: attributes, ); } } config ..attributedValue = _cachedAttributedValue! ..isObscured = obscureText ..isMultiline = _isMultiline ..textDirection = textDirection ..isFocused = hasFocus ..isTextField = true ..isReadOnly = readOnly // This is the default for customer that uses RenderEditable directly. // The real value is typically set by EditableText. ..inputType = ui.SemanticsInputType.text; if (hasFocus && selectionEnabled) { config.onSetSelection = _handleSetSelection; } if (hasFocus && !readOnly) { config.onSetText = _handleSetText; } if (selectionEnabled && (selection?.isValid ?? false)) { config.textSelection = selection; if (_textPainter.getOffsetBefore(selection!.extentOffset) != null) { config ..onMoveCursorBackwardByWord = _handleMoveCursorBackwardByWord ..onMoveCursorBackwardByCharacter = _handleMoveCursorBackwardByCharacter; } if (_textPainter.getOffsetAfter(selection!.extentOffset) != null) { config ..onMoveCursorForwardByWord = _handleMoveCursorForwardByWord ..onMoveCursorForwardByCharacter = _handleMoveCursorForwardByCharacter; } } } void _handleSetText(String text) { textSelectionDelegate.userUpdateTextEditingValue( TextEditingValue( text: text, selection: TextSelection.collapsed(offset: text.length), ), SelectionChangedCause.keyboard, ); } @override void assembleSemanticsNode( SemanticsNode node, SemanticsConfiguration config, Iterable children, ) { assert(_semanticsInfo != null && _semanticsInfo!.isNotEmpty); final List newChildren = []; TextDirection currentDirection = textDirection; Rect currentRect; double ordinal = 0.0; int start = 0; int placeholderIndex = 0; int childIndex = 0; RenderBox? child = firstChild; final LinkedHashMap newChildCache = LinkedHashMap(); _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!); for (final InlineSpanSemanticsInformation info in _cachedCombinedSemanticsInfos!) { final TextSelection selection = TextSelection( baseOffset: start, extentOffset: start + info.text.length, ); start += info.text.length; if (info.isPlaceholder) { // A placeholder span may have 0 to multiple semantics nodes, we need // to annotate all of the semantics nodes belong to this span. while (children.length > childIndex && children .elementAt(childIndex) .isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) { final SemanticsNode childNode = children.elementAt(childIndex); final TextParentData parentData = child!.parentData! as TextParentData; assert(parentData.offset != null); newChildren.add(childNode); childIndex += 1; } child = childAfter(child!); placeholderIndex += 1; } else { final TextDirection initialDirection = currentDirection; final List rects = _textPainter.getBoxesForSelection( selection, ); if (rects.isEmpty) { continue; } Rect rect = rects.first.toRect(); currentDirection = rects.first.direction; for (final ui.TextBox textBox in rects.skip(1)) { rect = rect.expandToInclude(textBox.toRect()); currentDirection = textBox.direction; } // Any of the text boxes may have had infinite dimensions. // We shouldn't pass infinite dimensions up to the bridges. rect = Rect.fromLTWH( math.max(0.0, rect.left), math.max(0.0, rect.top), math.min(rect.width, constraints.maxWidth), math.min(rect.height, constraints.maxHeight), ); // Round the current rectangle to make this API testable and add some // padding so that the accessibility rects do not overlap with the text. currentRect = Rect.fromLTRB( rect.left.floorToDouble() - 4.0, rect.top.floorToDouble() - 4.0, rect.right.ceilToDouble() + 4.0, rect.bottom.ceilToDouble() + 4.0, ); final SemanticsConfiguration configuration = SemanticsConfiguration() ..sortKey = OrdinalSortKey(ordinal++) ..textDirection = initialDirection ..attributedLabel = AttributedString( info.semanticsLabel ?? info.text, attributes: info.stringAttributes, ); switch (info.recognizer) { case TapGestureRecognizer(onTap: final VoidCallback? handler): case DoubleTapGestureRecognizer( onDoubleTap: final VoidCallback? handler, ): if (handler != null) { configuration.onTap = handler; configuration.isLink = true; } case LongPressGestureRecognizer( onLongPress: final GestureLongPressCallback? onLongPress, ): if (onLongPress != null) { configuration.onLongPress = onLongPress; } case null: break; default: assert(false, '${info.recognizer.runtimeType} is not supported.'); } if (node.parentPaintClipRect != null) { final Rect paintRect = node.parentPaintClipRect!.intersect( currentRect, ); configuration.isHidden = paintRect.isEmpty && !currentRect.isEmpty; } late final SemanticsNode newChild; if (_cachedChildNodes?.isNotEmpty ?? false) { newChild = _cachedChildNodes!.remove(_cachedChildNodes!.keys.first)!; } else { final UniqueKey key = UniqueKey(); newChild = SemanticsNode( key: key, showOnScreen: _createShowOnScreenFor(key), ); } newChild ..updateWith(config: configuration) ..rect = currentRect; newChildCache[newChild.key!] = newChild; newChildren.add(newChild); } } _cachedChildNodes = newChildCache; node.updateWith(config: config, childrenInInversePaintOrder: newChildren); } VoidCallback? _createShowOnScreenFor(Key key) { return () { final SemanticsNode node = _cachedChildNodes![key]!; showOnScreen(descendant: this, rect: node.rect); }; } // TODO(ianh): in theory, [selection] could become null between when // we last called describeSemanticsConfiguration and when the // callbacks are invoked, in which case the callbacks will crash... void _handleSetSelection(TextSelection selection) { _setSelection(selection, SelectionChangedCause.keyboard); } void _handleMoveCursorForwardByCharacter(bool extendSelection) { assert(selection != null); final int? extentOffset = _textPainter.getOffsetAfter( selection!.extentOffset, ); if (extentOffset == null) { return; } final int baseOffset = !extendSelection ? extentOffset : selection!.baseOffset; _setSelection( TextSelection(baseOffset: baseOffset, extentOffset: extentOffset), SelectionChangedCause.keyboard, ); } void _handleMoveCursorBackwardByCharacter(bool extendSelection) { assert(selection != null); final int? extentOffset = _textPainter.getOffsetBefore( selection!.extentOffset, ); if (extentOffset == null) { return; } final int baseOffset = !extendSelection ? extentOffset : selection!.baseOffset; _setSelection( TextSelection(baseOffset: baseOffset, extentOffset: extentOffset), SelectionChangedCause.keyboard, ); } void _handleMoveCursorForwardByWord(bool extendSelection) { assert(selection != null); final TextRange currentWord = _textPainter.getWordBoundary( selection!.extent, ); final TextRange? nextWord = _getNextWord(currentWord.end); if (nextWord == null) { return; } final int baseOffset = extendSelection ? selection!.baseOffset : nextWord.start; _setSelection( TextSelection(baseOffset: baseOffset, extentOffset: nextWord.start), SelectionChangedCause.keyboard, ); } void _handleMoveCursorBackwardByWord(bool extendSelection) { assert(selection != null); final TextRange currentWord = _textPainter.getWordBoundary( selection!.extent, ); final TextRange? previousWord = _getPreviousWord(currentWord.start - 1); if (previousWord == null) { return; } final int baseOffset = extendSelection ? selection!.baseOffset : previousWord.start; _setSelection( TextSelection(baseOffset: baseOffset, extentOffset: previousWord.start), SelectionChangedCause.keyboard, ); } TextRange? _getNextWord(int offset) { while (true) { final TextRange range = _textPainter.getWordBoundary( TextPosition(offset: offset), ); if (!range.isValid || range.isCollapsed) { return null; } if (!_onlyWhitespace(range)) { return range; } offset = range.end; } } TextRange? _getPreviousWord(int offset) { while (offset >= 0) { final TextRange range = _textPainter.getWordBoundary( TextPosition(offset: offset), ); if (!range.isValid || range.isCollapsed) { return null; } if (!_onlyWhitespace(range)) { return range; } offset = range.start - 1; } return null; } // Check if the given text range only contains white space or separator // characters. // // Includes newline characters from ASCII and separators from the // [unicode separator category](https://www.compart.com/en/unicode/category/Zs) // TODO(zanderso): replace when we expose this ICU information. bool _onlyWhitespace(TextRange range) { for (int i = range.start; i < range.end; i++) { final int codeUnit = text!.codeUnitAt(i)!; if (!TextLayoutMetrics.isWhitespace(codeUnit)) { return false; } } return true; } @override void attach(PipelineOwner owner) { super.attach(owner); _foregroundRenderObject?.attach(owner); _backgroundRenderObject?.attach(owner); _tap = TapGestureRecognizer(debugOwner: this) ..onTapDown = _handleTapDown ..onTap = _handleTap; _longPress = LongPressGestureRecognizer(debugOwner: this) ..onLongPress = _handleLongPress; _offset.addListener(markNeedsPaint); _showHideCursor(); _showCursor.addListener(_showHideCursor); } @override void detach() { _tap.dispose(); _longPress.dispose(); _offset.removeListener(markNeedsPaint); _showCursor.removeListener(_showHideCursor); super.detach(); _foregroundRenderObject?.detach(); _backgroundRenderObject?.detach(); } @override void redepthChildren() { final RenderObject? foregroundChild = _foregroundRenderObject; final RenderObject? backgroundChild = _backgroundRenderObject; if (foregroundChild != null) { redepthChild(foregroundChild); } if (backgroundChild != null) { redepthChild(backgroundChild); } super.redepthChildren(); } @override void visitChildren(RenderObjectVisitor visitor) { final RenderObject? foregroundChild = _foregroundRenderObject; final RenderObject? backgroundChild = _backgroundRenderObject; if (foregroundChild != null) { visitor(foregroundChild); } if (backgroundChild != null) { visitor(backgroundChild); } super.visitChildren(visitor); } bool get _isMultiline => maxLines != 1; Axis get _viewportAxis => _isMultiline ? Axis.vertical : Axis.horizontal; Offset get _paintOffset => switch (_viewportAxis) { Axis.horizontal => Offset(-offset.pixels, 0.0), Axis.vertical => Offset(0.0, -offset.pixels), }; double get _viewportExtent { assert(hasSize); return switch (_viewportAxis) { Axis.horizontal => size.width, Axis.vertical => size.height, }; } double _getMaxScrollExtent(Size contentSize) { assert(hasSize); return switch (_viewportAxis) { Axis.horizontal => math.max(0.0, contentSize.width - size.width), Axis.vertical => math.max(0.0, contentSize.height - size.height), }; } // We need to check the paint offset here because during animation, the start of // the text may position outside the visible region even when the text fits. bool get _hasVisualOverflow => _maxScrollExtent > 0 || _paintOffset != Offset.zero; /// Returns the local coordinates of the endpoints of the given selection. /// /// If the selection is collapsed (and therefore occupies a single point), the /// returned list is of length one. Otherwise, the selection is not collapsed /// and the returned list is of length two. In this case, however, the two /// points might actually be co-located (e.g., because of a bidirectional /// selection that contains some text but whose ends meet in the middle). /// /// See also: /// /// * [getLocalRectForCaret], which is the equivalent but for /// a [TextPosition] rather than a [TextSelection]. List getEndpointsForSelection(TextSelection selection) { _computeTextMetricsIfNeeded(); final Offset paintOffset = _paintOffset; final List boxes = selection.isCollapsed ? [] : _textPainter.getBoxesForSelection( selection, boxHeightStyle: selectionHeightStyle, boxWidthStyle: selectionWidthStyle, ); if (boxes.isEmpty) { // TODO(mpcomplete): This doesn't work well at an RTL/LTR boundary. final Offset caretOffset = _textPainter.getOffsetForCaret( selection.extent, _caretPrototype, ); final Offset start = Offset(0.0, preferredLineHeight) + caretOffset + paintOffset; return [TextSelectionPoint(start, null)]; } else { final Offset start = Offset( clampDouble(boxes.first.start, 0, _textPainter.size.width), boxes.first.bottom, ) + paintOffset; final Offset end = Offset( clampDouble(boxes.last.end, 0, _textPainter.size.width), boxes.last.bottom, ) + paintOffset; return [ TextSelectionPoint(start, boxes.first.direction), TextSelectionPoint(end, boxes.last.direction), ]; } } /// Returns the smallest [Rect], in the local coordinate system, that covers /// the text within the [TextRange] specified. /// /// This method is used to calculate the approximate position of the IME bar /// on iOS. /// /// Returns null if [TextRange.isValid] is false for the given `range`, or the /// given `range` is collapsed. Rect? getRectForComposingRange(TextRange range) { if (!range.isValid || range.isCollapsed) { return null; } _computeTextMetricsIfNeeded(); final List boxes = _textPainter.getBoxesForSelection( TextSelection(baseOffset: range.start, extentOffset: range.end), boxHeightStyle: selectionHeightStyle, boxWidthStyle: selectionWidthStyle, ); return boxes .fold( null, (Rect? accum, TextBox incoming) => accum?.expandToInclude(incoming.toRect()) ?? incoming.toRect(), ) ?.shift(_paintOffset); } /// Returns the position in the text for the given global coordinate. /// /// See also: /// /// * [getLocalRectForCaret], which is the reverse operation, taking /// a [TextPosition] and returning a [Rect]. /// * [TextPainter.getPositionForOffset], which is the equivalent method /// for a [TextPainter] object. TextPosition getPositionForPoint(Offset globalPosition) { _computeTextMetricsIfNeeded(); return _textPainter.getPositionForOffset( globalToLocal(globalPosition) - _paintOffset, ); } /// Returns the [Rect] in local coordinates for the caret at the given text /// position. /// /// See also: /// /// * [getPositionForPoint], which is the reverse operation, taking /// an [Offset] in global coordinates and returning a [TextPosition]. /// * [getEndpointsForSelection], which is the equivalent but for /// a selection rather than a particular text position. /// * [TextPainter.getOffsetForCaret], the equivalent method for a /// [TextPainter] object. Rect getLocalRectForCaret(TextPosition caretPosition) { _computeTextMetricsIfNeeded(); final Rect caretPrototype = _caretPrototype; final Offset caretOffset = _textPainter.getOffsetForCaret( caretPosition, caretPrototype, ); Rect caretRect = caretPrototype.shift(caretOffset + cursorOffset); final double scrollableWidth = math.max( _textPainter.width + _caretMargin, size.width, ); final double caretX = clampDouble( caretRect.left, 0, math.max(scrollableWidth - _caretMargin, 0), ); caretRect = Offset(caretX, caretRect.top) & caretRect.size; final double fullHeight = _textPainter.getFullHeightForCaret( caretPosition, caretPrototype, ); switch (defaultTargetPlatform) { case TargetPlatform.iOS: case TargetPlatform.macOS: // Center the caret vertically along the text. final double heightDiff = fullHeight - caretRect.height; caretRect = Rect.fromLTWH( caretRect.left, caretRect.top + heightDiff / 2, caretRect.width, caretRect.height, ); case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: // Override the height to take the full height of the glyph at the TextPosition // when not on iOS. iOS has special handling that creates a taller caret. // TODO(garyq): see https://github.com/flutter/flutter/issues/120836. final double caretHeight = cursorHeight; // Center the caret vertically along the text. final double heightDiff = fullHeight - caretHeight; caretRect = Rect.fromLTWH( caretRect.left, caretRect.top - _kCaretHeightOffset + heightDiff / 2, caretRect.width, caretHeight, ); } caretRect = caretRect.shift(_paintOffset); return caretRect.shift(_snapToPhysicalPixel(caretRect.topLeft)); } @override double computeMinIntrinsicWidth(double height) { final List placeholderDimensions = layoutInlineChildren( double.infinity, (RenderBox child, BoxConstraints constraints) => Size(child.getMinIntrinsicWidth(double.infinity), 0.0), ChildLayoutHelper.getDryBaseline, ); final (double minWidth, double maxWidth) = _adjustConstraints(); return (_textIntrinsics ..setPlaceholderDimensions(placeholderDimensions) ..layout(minWidth: minWidth, maxWidth: maxWidth)) .minIntrinsicWidth; } @override double computeMaxIntrinsicWidth(double height) { final List placeholderDimensions = layoutInlineChildren( double.infinity, // Height and baseline is irrelevant as all text will be laid // out in a single line. Therefore, using 0.0 as a dummy for the height. (RenderBox child, BoxConstraints constraints) => Size(child.getMaxIntrinsicWidth(double.infinity), 0.0), ChildLayoutHelper.getDryBaseline, ); final (double minWidth, double maxWidth) = _adjustConstraints(); return (_textIntrinsics ..setPlaceholderDimensions(placeholderDimensions) ..layout(minWidth: minWidth, maxWidth: maxWidth)) .maxIntrinsicWidth + _caretMargin; } /// An estimate of the height of a line in the text. See [TextPainter.preferredLineHeight]. /// This does not require the layout to be updated. double get preferredLineHeight => _textPainter.preferredLineHeight; int? _cachedLineBreakCount; int _countHardLineBreaks(String text) { final int? cachedValue = _cachedLineBreakCount; if (cachedValue != null) { return cachedValue; } int count = 0; for (int index = 0; index < text.length; index += 1) { switch (text.codeUnitAt(index)) { case 0x000A: // LF case 0x0085: // NEL case 0x000B: // VT case 0x000C: // FF, treating it as a regular line separator case 0x2028: // LS case 0x2029: // PS count += 1; } } return _cachedLineBreakCount = count; } double _preferredHeight(double width) { final int? maxLines = this.maxLines; final int? minLines = this.minLines ?? maxLines; final double minHeight = preferredLineHeight * (minLines ?? 0); assert(maxLines != 1 || _textIntrinsics.maxLines == 1); if (maxLines == null) { final double estimatedHeight; if (width == double.infinity) { estimatedHeight = preferredLineHeight * (_countHardLineBreaks(plainText) + 1); } else { final (double minWidth, double maxWidth) = _adjustConstraints( maxWidth: width, ); estimatedHeight = (_textIntrinsics ..layout(minWidth: minWidth, maxWidth: maxWidth)) .height; } return math.max(estimatedHeight, minHeight); } // Special case maxLines == 1 since it forces the scrollable direction // to be horizontal. Report the real height to prevent the text from being // clipped. if (maxLines == 1) { // The _layoutText call lays out the paragraph using infinite width when // maxLines == 1. Also _textPainter.maxLines will be set to 1 so should // there be any line breaks only the first line is shown. final (double minWidth, double maxWidth) = _adjustConstraints( maxWidth: width, ); return (_textIntrinsics..layout(minWidth: minWidth, maxWidth: maxWidth)) .height; } if (minLines == maxLines) { return minHeight; } final double maxHeight = preferredLineHeight * maxLines; final (double minWidth, double maxWidth) = _adjustConstraints( maxWidth: width, ); return clampDouble( (_textIntrinsics..layout(minWidth: minWidth, maxWidth: maxWidth)).height, minHeight, maxHeight, ); } @override double computeMinIntrinsicHeight(double width) => getMaxIntrinsicHeight(width); @override double computeMaxIntrinsicHeight(double width) { _textIntrinsics.setPlaceholderDimensions( layoutInlineChildren( width, ChildLayoutHelper.dryLayoutChild, ChildLayoutHelper.getDryBaseline, ), ); return _preferredHeight(width); } @override double computeDistanceToActualBaseline(TextBaseline baseline) { _computeTextMetricsIfNeeded(); return _textPainter.computeDistanceToActualBaseline(baseline); } @override bool hitTestSelf(Offset position) => true; @override @protected bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { final Offset effectivePosition = position - _paintOffset; final GlyphInfo? glyph = _textPainter.getClosestGlyphForOffset( effectivePosition, ); // The hit-test can't fall through the horizontal gaps between visually // adjacent characters on the same line, even with a large letter-spacing or // text justification, as graphemeClusterLayoutBounds.width is the advance // width to the next character, so there's no gap between their // graphemeClusterLayoutBounds rects. final InlineSpan? spanHit = glyph != null && glyph.graphemeClusterLayoutBounds.contains(effectivePosition) ? _textPainter.text!.getSpanForPosition( TextPosition(offset: glyph.graphemeClusterCodeUnitRange.start), ) : null; switch (spanHit) { case final HitTestTarget span: result.add(HitTestEntry(span)); return true; case _: return hitTestInlineChildren(result, effectivePosition); } } late TapGestureRecognizer _tap; late LongPressGestureRecognizer _longPress; @override void handleEvent(PointerEvent event, BoxHitTestEntry entry) { assert(debugHandleEvent(event, entry)); if (event is PointerDownEvent) { assert(!debugNeedsLayout); if (!ignorePointer) { // Propagates the pointer event to selection handlers. _tap.addPointer(event); _longPress.addPointer(event); } } } Offset? _lastTapDownPosition; Offset? _lastSecondaryTapDownPosition; /// {@template flutter.rendering.RenderEditable.lastSecondaryTapDownPosition} /// The position of the most recent secondary tap down event on this text /// input. /// {@endtemplate} Offset? get lastSecondaryTapDownPosition => _lastSecondaryTapDownPosition; /// Tracks the position of a secondary tap event. /// /// Should be called before attempting to change the selection based on the /// position of a secondary tap. void handleSecondaryTapDown(TapDownDetails details) { _lastTapDownPosition = details.globalPosition; _lastSecondaryTapDownPosition = details.globalPosition; } /// If [ignorePointer] is false (the default) then this method is called by /// the internal gesture recognizer's [TapGestureRecognizer.onTapDown] /// callback. /// /// When [ignorePointer] is true, an ancestor widget must respond to tap /// down events by calling this method. void handleTapDown(TapDownDetails details) { _lastTapDownPosition = details.globalPosition; } void _handleTapDown(TapDownDetails details) { assert(!ignorePointer); handleTapDown(details); } /// If [ignorePointer] is false (the default) then this method is called by /// the internal gesture recognizer's [TapGestureRecognizer.onTap] /// callback. /// /// When [ignorePointer] is true, an ancestor widget must respond to tap /// events by calling this method. void handleTap() { selectPosition(cause: SelectionChangedCause.tap); } void _handleTap() { assert(!ignorePointer); handleTap(); } /// If [ignorePointer] is false (the default) then this method is called by /// the internal gesture recognizer's [DoubleTapGestureRecognizer.onDoubleTap] /// callback. /// /// When [ignorePointer] is true, an ancestor widget must respond to double /// tap events by calling this method. void handleDoubleTap() { selectWord(cause: SelectionChangedCause.doubleTap); } /// If [ignorePointer] is false (the default) then this method is called by /// the internal gesture recognizer's [LongPressGestureRecognizer.onLongPress] /// callback. /// /// When [ignorePointer] is true, an ancestor widget must respond to long /// press events by calling this method. void handleLongPress() { selectWord(cause: SelectionChangedCause.longPress); } void _handleLongPress() { assert(!ignorePointer); handleLongPress(); } /// Move selection to the location of the last tap down. /// /// {@template flutter.rendering.RenderEditable.selectPosition} /// This method is mainly used to translate user inputs in global positions /// into a [TextSelection]. When used in conjunction with a [EditableText], /// the selection change is fed back into [TextEditingController.selection]. /// /// If you have a [TextEditingController], it's generally easier to /// programmatically manipulate its `value` or `selection` directly. /// {@endtemplate} void selectPosition({required SelectionChangedCause cause}) { selectPositionAt(from: _lastTapDownPosition!, cause: cause); } /// Select text between the global positions [from] and [to]. /// /// [from] corresponds to the [TextSelection.baseOffset], and [to] corresponds /// to the [TextSelection.extentOffset]. void selectPositionAt({ required Offset from, Offset? to, required SelectionChangedCause cause, }) { final localFrom = globalToLocal(from); _computeTextMetricsIfNeeded(); final TextPosition fromPosition = _textPainter.getPositionForOffset( localFrom - _paintOffset, ); final TextPosition? toPosition = to == null ? null : _textPainter.getPositionForOffset( globalToLocal(to) - _paintOffset, ); int baseOffset = fromPosition.offset; int extentOffset = toPosition?.offset ?? fromPosition.offset; // bggRGjQaUbCoE tap if (toPosition == null) { baseOffset = controller.tapOffset( baseOffset, textPainter: _textPainter, localPos: localFrom, lastTapDownPosition: from, ); extentOffset = baseOffset; } final TextSelection newSelection = TextSelection( baseOffset: baseOffset, extentOffset: extentOffset, affinity: fromPosition.affinity, ); _setSelection(newSelection, cause); } /// {@macro flutter.painting.TextPainter.wordBoundaries} WordBoundary get wordBoundaries => _textPainter.wordBoundaries; /// Select a word around the location of the last tap down. /// /// {@macro flutter.rendering.RenderEditable.selectPosition} void selectWord({required SelectionChangedCause cause}) { selectWordsInRange(from: _lastTapDownPosition!, cause: cause); } /// Selects the set words of a paragraph that intersect a given range of global positions. /// /// The set of words selected are not strictly bounded by the range of global positions. /// /// The first and last endpoints of the selection will always be at the /// beginning and end of a word respectively. /// /// {@macro flutter.rendering.RenderEditable.selectPosition} void selectWordsInRange({ required Offset from, Offset? to, required SelectionChangedCause cause, }) { _computeTextMetricsIfNeeded(); final TextPosition fromPosition = _textPainter.getPositionForOffset( globalToLocal(from) - _paintOffset, ); final TextSelection fromWord = getWordAtOffset(fromPosition); final TextPosition toPosition = to == null ? fromPosition : _textPainter.getPositionForOffset( globalToLocal(to) - _paintOffset, ); final TextSelection toWord = toPosition == fromPosition ? fromWord : getWordAtOffset(toPosition); final bool isFromWordBeforeToWord = fromWord.start < toWord.end; // bggRGjQaUbCoE longpress var startOffset = isFromWordBeforeToWord ? fromWord.baseOffset : toWord.baseOffset; var endOffset = isFromWordBeforeToWord ? toWord.extentOffset : fromWord.extentOffset; final newOffset = controller.longPressOffset(startOffset, endOffset); startOffset = newOffset.startOffset; endOffset = newOffset.endOffset; _setSelection( TextSelection( baseOffset: isFromWordBeforeToWord ? startOffset : endOffset, extentOffset: isFromWordBeforeToWord ? endOffset : startOffset, affinity: fromWord.affinity, ), cause, ); } /// Move the selection to the beginning or end of a word. /// /// {@macro flutter.rendering.RenderEditable.selectPosition} void selectWordEdge({required SelectionChangedCause cause}) { _computeTextMetricsIfNeeded(); assert(_lastTapDownPosition != null); final localPos = globalToLocal(_lastTapDownPosition!); TextPosition position = _textPainter.getPositionForOffset( localPos - _paintOffset, ); // bggRGjQaUbCoE ios tap final newOffset = controller.tapOffset( position.offset, textPainter: _textPainter, localPos: localPos, lastTapDownPosition: _lastTapDownPosition!, ); position = TextPosition(offset: newOffset); final TextRange word = _textPainter.getWordBoundary(position); late TextSelection newSelection; if (position.offset <= word.start) { newSelection = TextSelection.collapsed(offset: word.start); } else { newSelection = TextSelection.collapsed( offset: word.end, affinity: TextAffinity.upstream, ); } _setSelection(newSelection, cause); } /// Returns a [TextSelection] that encompasses the word at the given /// [TextPosition]. @visibleForTesting TextSelection getWordAtOffset(TextPosition position) { // When long-pressing past the end of the text, we want a collapsed cursor. if (position.offset >= plainText.length) { return TextSelection.fromPosition( TextPosition(offset: plainText.length, affinity: TextAffinity.upstream), ); } // If text is obscured, the entire sentence should be treated as one word. if (obscureText) { return TextSelection(baseOffset: 0, extentOffset: plainText.length); } final TextRange word = _textPainter.getWordBoundary(position); final int effectiveOffset; switch (position.affinity) { case TextAffinity.upstream: // upstream affinity is effectively -1 in text position. effectiveOffset = position.offset - 1; case TextAffinity.downstream: effectiveOffset = position.offset; } assert(effectiveOffset >= 0); // On iOS, select the previous word if there is a previous word, or select // to the end of the next word if there is a next word. Select nothing if // there is neither a previous word nor a next word. // // If the platform is Android and the text is read only, try to select the // previous word if there is one; otherwise, select the single whitespace at // the position. if (effectiveOffset > 0 && TextLayoutMetrics.isWhitespace(plainText.codeUnitAt(effectiveOffset))) { final TextRange? previousWord = _getPreviousWord(word.start); switch (defaultTargetPlatform) { case TargetPlatform.iOS: if (previousWord == null) { final TextRange? nextWord = _getNextWord(word.start); if (nextWord == null) { return TextSelection.collapsed(offset: position.offset); } return TextSelection( baseOffset: position.offset, extentOffset: nextWord.end, ); } return TextSelection( baseOffset: previousWord.start, extentOffset: position.offset, ); case TargetPlatform.android: if (readOnly) { if (previousWord == null) { return TextSelection( baseOffset: position.offset, extentOffset: position.offset + 1, ); } return TextSelection( baseOffset: previousWord.start, extentOffset: position.offset, ); } case TargetPlatform.fuchsia: case TargetPlatform.macOS: case TargetPlatform.linux: case TargetPlatform.windows: break; } } return TextSelection(baseOffset: word.start, extentOffset: word.end); } // Placeholder dimensions representing the sizes of child inline widgets. // // These need to be cached because the text painter's placeholder dimensions // will be overwritten during intrinsic width/height calculations and must be // restored to the original values before final layout and painting. List? _placeholderDimensions; (double minWidth, double maxWidth) _adjustConstraints({ double minWidth = 0.0, double maxWidth = double.infinity, }) { final double availableMaxWidth = math.max(0.0, maxWidth - _caretMargin); final double availableMinWidth = math.min(minWidth, availableMaxWidth); return ( forceLine ? availableMaxWidth : availableMinWidth, _isMultiline ? availableMaxWidth : double.infinity, ); } // Computes the text metrics if `_textPainter`'s layout information was marked // as dirty. // // This method must be called in `RenderEditable`'s public methods that expose // `_textPainter`'s metrics. For instance, `systemFontsDidChange` sets // _textPainter._paragraph to null, so accessing _textPainter's metrics // immediately after `systemFontsDidChange` without first calling this method // may crash. // // This method is also called in various paint methods (`RenderEditable.paint` // as well as its foreground/background painters' `paint`). It's needed // because invisible render objects kept in the tree by `KeepAlive` may not // get a chance to do layout but can still paint. // See https://github.com/flutter/flutter/issues/84896. // // This method only re-computes layout if the underlying `_textPainter`'s // layout cache is invalidated (by calling `TextPainter.markNeedsLayout`), or // the constraints used to layout the `_textPainter` is different. See // `TextPainter.layout`. void _computeTextMetricsIfNeeded() { final (double minWidth, double maxWidth) = _adjustConstraints( minWidth: constraints.minWidth, maxWidth: constraints.maxWidth, ); _textPainter.layout(minWidth: minWidth, maxWidth: maxWidth); } late Rect _caretPrototype; // TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/120836 // /// On iOS, the cursor is taller than the cursor on Android. The height /// of the cursor for iOS is approximate and obtained through an eyeball /// comparison. void _computeCaretPrototype() { switch (defaultTargetPlatform) { case TargetPlatform.iOS: case TargetPlatform.macOS: _caretPrototype = Rect.fromLTWH( 0.0, 0.0, cursorWidth, cursorHeight + 2, ); case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: _caretPrototype = Rect.fromLTWH( 0.0, _kCaretHeightOffset, cursorWidth, cursorHeight - 2.0 * _kCaretHeightOffset, ); } } // Computes the offset to apply to the given [sourceOffset] so it perfectly // snaps to physical pixels. Offset _snapToPhysicalPixel(Offset sourceOffset) { final Offset globalOffset = localToGlobal(sourceOffset); final double pixelMultiple = 1.0 / _devicePixelRatio; return Offset( globalOffset.dx.isFinite ? (globalOffset.dx / pixelMultiple).round() * pixelMultiple - globalOffset.dx : 0, globalOffset.dy.isFinite ? (globalOffset.dy / pixelMultiple).round() * pixelMultiple - globalOffset.dy : 0, ); } @override @protected Size computeDryLayout(covariant BoxConstraints constraints) { final (double minWidth, double maxWidth) = _adjustConstraints( minWidth: constraints.minWidth, maxWidth: constraints.maxWidth, ); _textIntrinsics ..setPlaceholderDimensions( layoutInlineChildren( constraints.maxWidth, ChildLayoutHelper.dryLayoutChild, ChildLayoutHelper.getDryBaseline, ), ) ..layout(minWidth: minWidth, maxWidth: maxWidth); final double width = forceLine ? constraints.maxWidth : constraints.constrainWidth( _textIntrinsics.size.width + _caretMargin, ); return Size( width, constraints.constrainHeight(_preferredHeight(constraints.maxWidth)), ); } @override double computeDryBaseline( covariant BoxConstraints constraints, TextBaseline baseline, ) { final (double minWidth, double maxWidth) = _adjustConstraints( minWidth: constraints.minWidth, maxWidth: constraints.maxWidth, ); _textIntrinsics ..setPlaceholderDimensions( layoutInlineChildren( constraints.maxWidth, ChildLayoutHelper.dryLayoutChild, ChildLayoutHelper.getDryBaseline, ), ) ..layout(minWidth: minWidth, maxWidth: maxWidth); return _textIntrinsics.computeDistanceToActualBaseline(baseline); } @override void performLayout() { final BoxConstraints constraints = this.constraints; _placeholderDimensions = layoutInlineChildren( constraints.maxWidth, ChildLayoutHelper.layoutChild, ChildLayoutHelper.getBaseline, ); final (double minWidth, double maxWidth) = _adjustConstraints( minWidth: constraints.minWidth, maxWidth: constraints.maxWidth, ); _textPainter ..setPlaceholderDimensions(_placeholderDimensions) ..layout(minWidth: minWidth, maxWidth: maxWidth); positionInlineChildren(_textPainter.inlinePlaceholderBoxes!); _computeCaretPrototype(); final double width = forceLine ? constraints.maxWidth : constraints.constrainWidth(_textPainter.width + _caretMargin); assert(maxLines != 1 || _textPainter.maxLines == 1); final double preferredHeight = switch (maxLines) { null => math.max( _textPainter.height, preferredLineHeight * (minLines ?? 0), ), 1 => _textPainter.height, final int maxLines => clampDouble( _textPainter.height, preferredLineHeight * (minLines ?? maxLines), preferredLineHeight * maxLines, ), }; size = Size(width, constraints.constrainHeight(preferredHeight)); final Size contentSize = Size( _textPainter.width + _caretMargin, _textPainter.height, ); final BoxConstraints painterConstraints = BoxConstraints.tight(contentSize); _foregroundRenderObject?.layout(painterConstraints); _backgroundRenderObject?.layout(painterConstraints); _maxScrollExtent = _getMaxScrollExtent(contentSize); offset.applyViewportDimension(_viewportExtent); offset.applyContentDimensions(0.0, _maxScrollExtent); } // The relative origin in relation to the distance the user has theoretically // dragged the floating cursor offscreen. This value is used to account for the // difference in the rendering position and the raw offset value. Offset _relativeOrigin = Offset.zero; Offset? _previousOffset; bool _shouldResetOrigin = true; bool _resetOriginOnLeft = false; bool _resetOriginOnRight = false; bool _resetOriginOnTop = false; bool _resetOriginOnBottom = false; double? _resetFloatingCursorAnimationValue; static Offset _calculateAdjustedCursorOffset( Offset offset, Rect boundingRects, ) { final double adjustedX = clampDouble( offset.dx, boundingRects.left, boundingRects.right, ); final double adjustedY = clampDouble( offset.dy, boundingRects.top, boundingRects.bottom, ); return Offset(adjustedX, adjustedY); } /// Returns the position within the text field closest to the raw cursor offset. /// /// See also: /// /// * [FloatingCursorDragState], which explains the floating cursor feature /// in detail. Offset calculateBoundedFloatingCursorOffset( Offset rawCursorOffset, { bool? shouldResetOrigin, }) { Offset deltaPosition = Offset.zero; final double topBound = -floatingCursorAddedMargin.top; final double bottomBound = math.min(size.height, _textPainter.height) - preferredLineHeight + floatingCursorAddedMargin.bottom; final double leftBound = -floatingCursorAddedMargin.left; final double rightBound = math.min(size.width, _textPainter.width) + floatingCursorAddedMargin.right; final Rect boundingRects = Rect.fromLTRB( leftBound, topBound, rightBound, bottomBound, ); if (shouldResetOrigin != null) { _shouldResetOrigin = shouldResetOrigin; } if (!_shouldResetOrigin) { return _calculateAdjustedCursorOffset(rawCursorOffset, boundingRects); } if (_previousOffset != null) { deltaPosition = rawCursorOffset - _previousOffset!; } // If the raw cursor offset has gone off an edge, we want to reset the relative // origin of the dragging when the user drags back into the field. if (_resetOriginOnLeft && deltaPosition.dx > 0) { _relativeOrigin = Offset( rawCursorOffset.dx - boundingRects.left, _relativeOrigin.dy, ); _resetOriginOnLeft = false; } else if (_resetOriginOnRight && deltaPosition.dx < 0) { _relativeOrigin = Offset( rawCursorOffset.dx - boundingRects.right, _relativeOrigin.dy, ); _resetOriginOnRight = false; } if (_resetOriginOnTop && deltaPosition.dy > 0) { _relativeOrigin = Offset( _relativeOrigin.dx, rawCursorOffset.dy - boundingRects.top, ); _resetOriginOnTop = false; } else if (_resetOriginOnBottom && deltaPosition.dy < 0) { _relativeOrigin = Offset( _relativeOrigin.dx, rawCursorOffset.dy - boundingRects.bottom, ); _resetOriginOnBottom = false; } final double currentX = rawCursorOffset.dx - _relativeOrigin.dx; final double currentY = rawCursorOffset.dy - _relativeOrigin.dy; final Offset adjustedOffset = _calculateAdjustedCursorOffset( Offset(currentX, currentY), boundingRects, ); if (currentX < boundingRects.left && deltaPosition.dx < 0) { _resetOriginOnLeft = true; } else if (currentX > boundingRects.right && deltaPosition.dx > 0) { _resetOriginOnRight = true; } if (currentY < boundingRects.top && deltaPosition.dy < 0) { _resetOriginOnTop = true; } else if (currentY > boundingRects.bottom && deltaPosition.dy > 0) { _resetOriginOnBottom = true; } _previousOffset = rawCursorOffset; return adjustedOffset; } /// Sets the screen position of the floating cursor and the text position /// closest to the cursor. /// /// See also: /// /// * [FloatingCursorDragState], which explains the floating cursor feature /// in detail. void setFloatingCursor( FloatingCursorDragState state, Offset boundedOffset, TextPosition lastTextPosition, { double? resetLerpValue, }) { if (state == FloatingCursorDragState.End) { _relativeOrigin = Offset.zero; _previousOffset = null; _shouldResetOrigin = true; _resetOriginOnBottom = false; _resetOriginOnTop = false; _resetOriginOnRight = false; _resetOriginOnBottom = false; } _floatingCursorOn = state != FloatingCursorDragState.End; _resetFloatingCursorAnimationValue = resetLerpValue; if (_floatingCursorOn) { _floatingCursorTextPosition = lastTextPosition; final double? animationValue = _resetFloatingCursorAnimationValue; final EdgeInsets sizeAdjustment = animationValue != null ? EdgeInsets.lerp( _kFloatingCursorSizeIncrease, EdgeInsets.zero, animationValue, )! : _kFloatingCursorSizeIncrease; _caretPainter.floatingCursorRect = sizeAdjustment.inflateRect(_caretPrototype).shift(boundedOffset); } else { _caretPainter.floatingCursorRect = null; } _caretPainter.showRegularCaret = _resetFloatingCursorAnimationValue == null; } MapEntry _lineNumberFor( TextPosition startPosition, List metrics, ) { // TODO(LongCatIsLooong): include line boundaries information in // ui.LineMetrics, then we can get rid of this. final Offset offset = _textPainter.getOffsetForCaret( startPosition, Rect.zero, ); for (final ui.LineMetrics lineMetrics in metrics) { if (lineMetrics.baseline > offset.dy) { return MapEntry( lineMetrics.lineNumber, Offset(offset.dx, lineMetrics.baseline), ); } } assert( startPosition.offset == 0, 'unable to find the line for $startPosition', ); return MapEntry( math.max(0, metrics.length - 1), Offset( offset.dx, metrics.isNotEmpty ? metrics.last.baseline + metrics.last.descent : 0.0, ), ); } /// Starts a [VerticalCaretMovementRun] at the given location in the text, for /// handling consecutive vertical caret movements. /// /// This can be used to handle consecutive upward/downward arrow key movements /// in an input field. /// /// {@macro flutter.rendering.RenderEditable.verticalArrowKeyMovement} /// /// The [VerticalCaretMovementRun.isValid] property indicates whether the text /// layout has changed and the vertical caret run is invalidated. /// /// The caller should typically discard a [VerticalCaretMovementRun] when /// its [VerticalCaretMovementRun.isValid] becomes false, or on other /// occasions where the vertical caret run should be interrupted. VerticalCaretMovementRun startVerticalCaretMovement( TextPosition startPosition, ) { final List metrics = _textPainter.computeLineMetrics(); final MapEntry currentLine = _lineNumberFor( startPosition, metrics, ); return VerticalCaretMovementRun._( this, metrics, startPosition, currentLine.key, currentLine.value, ); } void _paintContents(PaintingContext context, Offset offset) { final Offset effectiveOffset = offset + _paintOffset; if (selection != null && !_floatingCursorOn) { _updateSelectionExtentsVisibility(effectiveOffset); } final RenderBox? foregroundChild = _foregroundRenderObject; final RenderBox? backgroundChild = _backgroundRenderObject; // The painters paint in the viewport's coordinate space, since the // textPainter's coordinate space is not known to high level widgets. if (backgroundChild != null) { context.paintChild(backgroundChild, offset); } _textPainter.paint(context.canvas, effectiveOffset); paintInlineChildren(context, effectiveOffset); if (foregroundChild != null) { context.paintChild(foregroundChild, offset); } } final LayerHandle _leaderLayerHandler = LayerHandle(); void _paintHandleLayers( PaintingContext context, List endpoints, Offset offset, ) { Offset startPoint = endpoints[0].point; startPoint = Offset( clampDouble(startPoint.dx, 0.0, size.width), clampDouble(startPoint.dy, 0.0, size.height), ); _leaderLayerHandler.layer = LeaderLayer( link: startHandleLayerLink, offset: startPoint + offset, ); context.pushLayer(_leaderLayerHandler.layer!, super.paint, Offset.zero); if (endpoints.length == 2) { Offset endPoint = endpoints[1].point; endPoint = Offset( clampDouble(endPoint.dx, 0.0, size.width), clampDouble(endPoint.dy, 0.0, size.height), ); context.pushLayer( LeaderLayer(link: endHandleLayerLink, offset: endPoint + offset), super.paint, Offset.zero, ); } else if (selection!.isCollapsed) { context.pushLayer( LeaderLayer(link: endHandleLayerLink, offset: startPoint + offset), super.paint, Offset.zero, ); } } @override void applyPaintTransform(RenderBox child, Matrix4 transform) { if (child == _foregroundRenderObject || child == _backgroundRenderObject) { return; } defaultApplyPaintTransform(child, transform); } @override void paint(PaintingContext context, Offset offset) { _computeTextMetricsIfNeeded(); if (_hasVisualOverflow && clipBehavior != Clip.none) { _clipRectLayer.layer = context.pushClipRect( needsCompositing, offset, Offset.zero & size, _paintContents, clipBehavior: clipBehavior, oldLayer: _clipRectLayer.layer, ); } else { _clipRectLayer.layer = null; _paintContents(context, offset); } final TextSelection? selection = this.selection; if (selection != null && selection.isValid) { _paintHandleLayers(context, getEndpointsForSelection(selection), offset); } } final LayerHandle _clipRectLayer = LayerHandle(); @override Rect? describeApproximatePaintClip(RenderObject child) { switch (clipBehavior) { case Clip.none: return null; case Clip.hardEdge: case Clip.antiAlias: case Clip.antiAliasWithSaveLayer: return _hasVisualOverflow ? Offset.zero & size : null; } } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(ColorProperty('cursorColor', cursorColor)); properties.add( DiagnosticsProperty>('showCursor', showCursor), ); properties.add(IntProperty('maxLines', maxLines)); properties.add(IntProperty('minLines', minLines)); properties.add( DiagnosticsProperty('expands', expands, defaultValue: false), ); properties.add(ColorProperty('selectionColor', selectionColor)); properties.add( DiagnosticsProperty( 'textScaler', textScaler, defaultValue: TextScaler.noScaling, ), ); properties.add( DiagnosticsProperty('locale', locale, defaultValue: null), ); properties.add(DiagnosticsProperty('selection', selection)); properties.add(DiagnosticsProperty('offset', offset)); } @override List debugDescribeChildren() { return [ if (text != null) text!.toDiagnosticsNode( name: 'text', style: DiagnosticsTreeStyle.transition, ), ]; } } class _RenderEditableCustomPaint extends RenderBox { _RenderEditableCustomPaint({RenderEditablePainter? painter}) : _painter = painter, super(); @override RenderEditable? get parent => super.parent as RenderEditable?; @override bool get isRepaintBoundary => true; @override bool get sizedByParent => true; RenderEditablePainter? get painter => _painter; RenderEditablePainter? _painter; set painter(RenderEditablePainter? newValue) { if (newValue == painter) { return; } final RenderEditablePainter? oldPainter = painter; _painter = newValue; if (newValue?.shouldRepaint(oldPainter) ?? true) { markNeedsPaint(); } if (attached) { oldPainter?.removeListener(markNeedsPaint); newValue?.addListener(markNeedsPaint); } } @override void paint(PaintingContext context, Offset offset) { final RenderEditable? parent = this.parent; assert(parent != null); final RenderEditablePainter? painter = this.painter; if (painter != null && parent != null) { parent._computeTextMetricsIfNeeded(); painter.paint(context.canvas, size, parent); } } @override void attach(PipelineOwner owner) { super.attach(owner); _painter?.addListener(markNeedsPaint); } @override void detach() { _painter?.removeListener(markNeedsPaint); super.detach(); } @override @protected Size computeDryLayout(covariant BoxConstraints constraints) => constraints.biggest; } /// An interface that paints within a [RenderEditable]'s bounds, above or /// beneath its text content. /// /// This painter is typically used for painting auxiliary content that depends /// on text layout metrics (for instance, for painting carets and text highlight /// blocks). It can paint independently from its [RenderEditable], allowing it /// to repaint without triggering a repaint on the entire [RenderEditable] stack /// when only auxiliary content changes (e.g. a blinking cursor) are present. It /// will be scheduled to repaint when: /// /// * It's assigned to a new [RenderEditable] (replacing a prior /// [RenderEditablePainter]) and the [shouldRepaint] method returns true. /// * Any of the [RenderEditable]s it is attached to repaints. /// * The [notifyListeners] method is called, which typically happens when the /// painter's attributes change. /// /// See also: /// /// * [RenderEditable.foregroundPainter], which takes a [RenderEditablePainter] /// and sets it as the foreground painter of the [RenderEditable]. /// * [RenderEditable.painter], which takes a [RenderEditablePainter] /// and sets it as the background painter of the [RenderEditable]. /// * [CustomPainter], a similar class which paints within a [RenderCustomPaint]. abstract class RenderEditablePainter extends ChangeNotifier { /// Determines whether repaint is needed when a new [RenderEditablePainter] /// is provided to a [RenderEditable]. /// /// If the new instance represents different information than the old /// instance, then the method should return true, otherwise it should return /// false. When [oldDelegate] is null, this method should always return true /// unless the new painter initially does not paint anything. /// /// If the method returns false, then the [paint] call might be optimized /// away. However, the [paint] method will get called whenever the /// [RenderEditable]s it attaches to repaint, even if [shouldRepaint] returns /// false. bool shouldRepaint(RenderEditablePainter? oldDelegate); /// Paints within the bounds of a [RenderEditable]. /// /// The given [Canvas] has the same coordinate space as the [RenderEditable], /// which may be different from the coordinate space the [RenderEditable]'s /// [TextPainter] uses, when the text moves inside the [RenderEditable]. /// /// Paint operations performed outside of the region defined by the [canvas]'s /// origin and the [size] parameter may get clipped, when [RenderEditable]'s /// [RenderEditable.clipBehavior] is not [Clip.none]. void paint(Canvas canvas, Size size, RenderEditable renderEditable); } class _TextHighlightPainter extends RenderEditablePainter { _TextHighlightPainter({TextRange? highlightedRange, Color? highlightColor}) : _highlightedRange = highlightedRange, _highlightColor = highlightColor; final Paint highlightPaint = Paint(); Color? get highlightColor => _highlightColor; Color? _highlightColor; set highlightColor(Color? newValue) { if (newValue == _highlightColor) { return; } _highlightColor = newValue; notifyListeners(); } TextRange? get highlightedRange => _highlightedRange; TextRange? _highlightedRange; set highlightedRange(TextRange? newValue) { if (newValue == _highlightedRange) { return; } _highlightedRange = newValue; notifyListeners(); } /// Controls how tall the selection highlight boxes are computed to be. /// /// See [ui.BoxHeightStyle] for details on available styles. ui.BoxHeightStyle get selectionHeightStyle => _selectionHeightStyle; ui.BoxHeightStyle _selectionHeightStyle = ui.BoxHeightStyle.tight; set selectionHeightStyle(ui.BoxHeightStyle value) { if (_selectionHeightStyle == value) { return; } _selectionHeightStyle = value; notifyListeners(); } /// Controls how wide the selection highlight boxes are computed to be. /// /// See [ui.BoxWidthStyle] for details on available styles. ui.BoxWidthStyle get selectionWidthStyle => _selectionWidthStyle; ui.BoxWidthStyle _selectionWidthStyle = ui.BoxWidthStyle.tight; set selectionWidthStyle(ui.BoxWidthStyle value) { if (_selectionWidthStyle == value) { return; } _selectionWidthStyle = value; notifyListeners(); } @override void paint(Canvas canvas, Size size, RenderEditable renderEditable) { final TextRange? range = highlightedRange; final Color? color = highlightColor; if (range == null || color == null || range.isCollapsed) { return; } highlightPaint.color = color; final TextPainter textPainter = renderEditable._textPainter; final List boxes = textPainter.getBoxesForSelection( TextSelection(baseOffset: range.start, extentOffset: range.end), boxHeightStyle: selectionHeightStyle, boxWidthStyle: selectionWidthStyle, ); for (final TextBox box in boxes) { canvas.drawRect( box.toRect().shift(renderEditable._paintOffset).intersect( Rect.fromLTWH(0, 0, textPainter.width, textPainter.height), ), highlightPaint, ); } } @override bool shouldRepaint(RenderEditablePainter? oldDelegate) { if (identical(oldDelegate, this)) { return false; } if (oldDelegate == null) { return highlightColor != null && highlightedRange != null; } return oldDelegate is! _TextHighlightPainter || oldDelegate.highlightColor != highlightColor || oldDelegate.highlightedRange != highlightedRange || oldDelegate.selectionHeightStyle != selectionHeightStyle || oldDelegate.selectionWidthStyle != selectionWidthStyle; } } class _CaretPainter extends RenderEditablePainter { _CaretPainter(); bool get shouldPaint => _shouldPaint; bool _shouldPaint = true; set shouldPaint(bool value) { if (shouldPaint == value) { return; } _shouldPaint = value; notifyListeners(); } // This is directly manipulated by the RenderEditable during // setFloatingCursor. // // When changing this value, the caller is responsible for ensuring that // listeners are notified. bool showRegularCaret = false; final Paint caretPaint = Paint(); late final Paint floatingCursorPaint = Paint(); Color? get caretColor => _caretColor; Color? _caretColor; set caretColor(Color? value) { if (caretColor?.value == value?.value) { return; } _caretColor = value; notifyListeners(); } Radius? get cursorRadius => _cursorRadius; Radius? _cursorRadius; set cursorRadius(Radius? value) { if (_cursorRadius == value) { return; } _cursorRadius = value; notifyListeners(); } Offset get cursorOffset => _cursorOffset; Offset _cursorOffset = Offset.zero; set cursorOffset(Offset value) { if (_cursorOffset == value) { return; } _cursorOffset = value; notifyListeners(); } Color? get backgroundCursorColor => _backgroundCursorColor; Color? _backgroundCursorColor; set backgroundCursorColor(Color? value) { if (backgroundCursorColor?.value == value?.value) { return; } _backgroundCursorColor = value; if (showRegularCaret) { notifyListeners(); } } Rect? get floatingCursorRect => _floatingCursorRect; Rect? _floatingCursorRect; set floatingCursorRect(Rect? value) { if (_floatingCursorRect == value) { return; } _floatingCursorRect = value; notifyListeners(); } void paintRegularCursor( Canvas canvas, RenderEditable renderEditable, Color caretColor, TextPosition textPosition, ) { final Rect integralRect = renderEditable.getLocalRectForCaret(textPosition); if (shouldPaint) { if (floatingCursorRect != null) { final double distanceSquared = (floatingCursorRect!.center - integralRect.center).distanceSquared; if (distanceSquared < _kShortestDistanceSquaredWithFloatingAndRegularCursors) { return; } } final Radius? radius = cursorRadius; caretPaint.color = caretColor; if (radius == null) { canvas.drawRect(integralRect, caretPaint); } else { final RRect caretRRect = RRect.fromRectAndRadius(integralRect, radius); canvas.drawRRect(caretRRect, caretPaint); } } } @override void paint(Canvas canvas, Size size, RenderEditable renderEditable) { // Compute the caret location even when `shouldPaint` is false. final TextSelection? selection = renderEditable.selection; if (selection == null || !selection.isCollapsed || !selection.isValid) { return; } final Rect? floatingCursorRect = this.floatingCursorRect; final Color? caretColor = floatingCursorRect == null ? this.caretColor : showRegularCaret ? backgroundCursorColor : null; final TextPosition caretTextPosition = floatingCursorRect == null ? selection.extent : renderEditable._floatingCursorTextPosition; if (caretColor != null) { paintRegularCursor(canvas, renderEditable, caretColor, caretTextPosition); } final Color? floatingCursorColor = this.caretColor?.withOpacity(0.75); // Floating Cursor. if (floatingCursorRect == null || floatingCursorColor == null || !shouldPaint) { return; } canvas.drawRRect( RRect.fromRectAndRadius(floatingCursorRect, _kFloatingCursorRadius), floatingCursorPaint..color = floatingCursorColor, ); } @override bool shouldRepaint(RenderEditablePainter? oldDelegate) { if (identical(this, oldDelegate)) { return false; } if (oldDelegate == null) { return shouldPaint; } return oldDelegate is! _CaretPainter || oldDelegate.shouldPaint != shouldPaint || oldDelegate.showRegularCaret != showRegularCaret || oldDelegate.caretColor != caretColor || oldDelegate.cursorRadius != cursorRadius || oldDelegate.cursorOffset != cursorOffset || oldDelegate.backgroundCursorColor != backgroundCursorColor || oldDelegate.floatingCursorRect != floatingCursorRect; } } class _CompositeRenderEditablePainter extends RenderEditablePainter { _CompositeRenderEditablePainter({required this.painters}); final List painters; @override void addListener(VoidCallback listener) { for (final RenderEditablePainter painter in painters) { painter.addListener(listener); } } @override void removeListener(VoidCallback listener) { for (final RenderEditablePainter painter in painters) { painter.removeListener(listener); } } @override void paint(Canvas canvas, Size size, RenderEditable renderEditable) { for (final RenderEditablePainter painter in painters) { painter.paint(canvas, size, renderEditable); } } @override bool shouldRepaint(RenderEditablePainter? oldDelegate) { if (identical(oldDelegate, this)) { return false; } if (oldDelegate is! _CompositeRenderEditablePainter || oldDelegate.painters.length != painters.length) { return true; } final Iterator oldPainters = oldDelegate.painters.iterator; final Iterator newPainters = painters.iterator; while (oldPainters.moveNext() && newPainters.moveNext()) { if (newPainters.current.shouldRepaint(oldPainters.current)) { return true; } } return false; } }