mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
selectionEnabled was not taken into account in: key movement handling in RenderEditable mouse drag handling in TextSelectionGestureDetectorBuilder
2410 lines
91 KiB
Dart
2410 lines
91 KiB
Dart
// 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.
|
|
|
|
import 'dart:math' as math;
|
|
import 'dart:ui' as ui show TextBox, lerpDouble, BoxHeightStyle, BoxWidthStyle;
|
|
|
|
import 'package:characters/characters.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/semantics.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import 'box.dart';
|
|
import 'layer.dart';
|
|
import 'object.dart';
|
|
import 'viewport_offset.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 Offset _kFloatingCaretSizeIncrease = Offset(0.5, 1.0);
|
|
|
|
// The corner radius of the floating cursor in pixels.
|
|
const double _kFloatingCaretRadius = 1.0;
|
|
|
|
/// Signature for the callback that reports when the user changes the selection
|
|
/// (including the cursor location).
|
|
///
|
|
/// Used by [RenderEditable.onSelectionChanged].
|
|
typedef SelectionChangedHandler = void Function(TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause);
|
|
|
|
/// Indicates what triggered the change in selected text (including changes to
|
|
/// the cursor location).
|
|
enum SelectionChangedCause {
|
|
/// The user tapped on the text and that caused the selection (or the location
|
|
/// of the cursor) to change.
|
|
tap,
|
|
|
|
/// The user tapped twice in quick succession on the text and that caused
|
|
/// the selection (or the location of the cursor) to change.
|
|
doubleTap,
|
|
|
|
/// The user long-pressed the text and that caused the selection (or the
|
|
/// location of the cursor) to change.
|
|
longPress,
|
|
|
|
/// The user force-pressed the text and that caused the selection (or the
|
|
/// location of the cursor) to change.
|
|
forcePress,
|
|
|
|
/// The user used the keyboard to change the selection or the location of the
|
|
/// cursor.
|
|
///
|
|
/// Keyboard-triggered selection changes may be caused by the IME as well as
|
|
/// by accessibility tools (e.g. TalkBack on Android).
|
|
keyboard,
|
|
|
|
/// The user used the mouse to change the selection by dragging over a piece
|
|
/// of text.
|
|
drag,
|
|
}
|
|
|
|
/// Signature for the callback that reports when the caret location changes.
|
|
///
|
|
/// Used by [RenderEditable.onCaretChanged].
|
|
typedef CaretChangedHandler = void Function(Rect caretRect);
|
|
|
|
/// Represents the coordinates of the point in a selection, and the text
|
|
/// direction at that point, relative to top left of the [RenderEditable] that
|
|
/// holds the selection.
|
|
@immutable
|
|
class TextSelectionPoint {
|
|
/// Creates a description of a point in a text selection.
|
|
///
|
|
/// The [point] argument must not be null.
|
|
const TextSelectionPoint(this.point, this.direction)
|
|
: assert(point != null);
|
|
|
|
/// Coordinates of the lower left or lower right corner of the selection,
|
|
/// relative to the top left of the [RenderEditable] object.
|
|
final Offset point;
|
|
|
|
/// Direction of the text at this edge of the selection.
|
|
final TextDirection? direction;
|
|
|
|
@override
|
|
String toString() {
|
|
switch (direction) {
|
|
case TextDirection.ltr:
|
|
return '$point-ltr';
|
|
case TextDirection.rtl:
|
|
return '$point-rtl';
|
|
case null:
|
|
return '$point';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if the given code unit is a white space or separator
|
|
// character.
|
|
//
|
|
// Includes newline characters from ASCII and separators from the
|
|
// [unicode separator category](https://www.compart.com/en/unicode/category/Zs)
|
|
// TODO(gspencergoog): replace when we expose this ICU information.
|
|
bool _isWhitespace(int codeUnit) {
|
|
switch (codeUnit) {
|
|
case 0x9: // horizontal tab
|
|
case 0xA: // line feed
|
|
case 0xB: // vertical tab
|
|
case 0xC: // form feed
|
|
case 0xD: // carriage return
|
|
case 0x1C: // file separator
|
|
case 0x1D: // group separator
|
|
case 0x1E: // record separator
|
|
case 0x1F: // unit separator
|
|
case 0x20: // space
|
|
case 0xA0: // no-break space
|
|
case 0x1680: // ogham space mark
|
|
case 0x2000: // en quad
|
|
case 0x2001: // em quad
|
|
case 0x2002: // en space
|
|
case 0x2003: // em space
|
|
case 0x2004: // three-per-em space
|
|
case 0x2005: // four-er-em space
|
|
case 0x2006: // six-per-em space
|
|
case 0x2007: // figure space
|
|
case 0x2008: // punctuation space
|
|
case 0x2009: // thin space
|
|
case 0x200A: // hair space
|
|
case 0x202F: // narrow no-break space
|
|
case 0x205F: // medium mathematical space
|
|
case 0x3000: // ideographic space
|
|
break;
|
|
default:
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// 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].
|
|
///
|
|
/// If, when the render object paints, the caret is found to have changed
|
|
/// location, [onCaretChanged] is called.
|
|
///
|
|
/// The user may interact with the render object by tapping or long-pressing.
|
|
/// When the user does so, the selection is updated, and [onSelectionChanged] is
|
|
/// called.
|
|
///
|
|
/// 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 {
|
|
/// Creates a render object that implements the visual aspects of a text field.
|
|
///
|
|
/// The [textAlign] argument must not be null. It defaults to [TextAlign.start].
|
|
///
|
|
/// The [textDirection] argument must not be null.
|
|
///
|
|
/// 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.
|
|
///
|
|
/// The [offset] is required and must not be null. You can use [new
|
|
/// ViewportOffset.zero] if you have no need for scrolling.
|
|
RenderEditable({
|
|
TextSpan? text,
|
|
required TextDirection textDirection,
|
|
TextAlign textAlign = TextAlign.start,
|
|
Color? cursorColor,
|
|
Color? backgroundCursorColor,
|
|
ValueNotifier<bool>? showCursor,
|
|
bool? hasFocus,
|
|
required LayerLink startHandleLayerLink,
|
|
required LayerLink endHandleLayerLink,
|
|
int? maxLines = 1,
|
|
int? minLines,
|
|
bool expands = false,
|
|
StrutStyle? strutStyle,
|
|
Color? selectionColor,
|
|
double textScaleFactor = 1.0,
|
|
TextSelection? selection,
|
|
required ViewportOffset offset,
|
|
this.onSelectionChanged,
|
|
this.onCaretChanged,
|
|
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,
|
|
double devicePixelRatio = 1.0,
|
|
ui.BoxHeightStyle selectionHeightStyle = ui.BoxHeightStyle.tight,
|
|
ui.BoxWidthStyle selectionWidthStyle = ui.BoxWidthStyle.tight,
|
|
bool? enableInteractiveSelection,
|
|
EdgeInsets floatingCursorAddedMargin = const EdgeInsets.fromLTRB(4, 4, 4, 5),
|
|
TextRange? promptRectRange,
|
|
Color? promptRectColor,
|
|
Clip clipBehavior = Clip.hardEdge,
|
|
required this.textSelectionDelegate,
|
|
}) : assert(textAlign != null),
|
|
assert(textDirection != null, 'RenderEditable created without a textDirection.'),
|
|
assert(maxLines == null || maxLines > 0),
|
|
assert(minLines == null || minLines > 0),
|
|
assert(startHandleLayerLink != null),
|
|
assert(endHandleLayerLink != null),
|
|
assert(
|
|
(maxLines == null) || (minLines == null) || (maxLines >= minLines),
|
|
"minLines can't be greater than maxLines",
|
|
),
|
|
assert(expands != null),
|
|
assert(
|
|
!expands || (maxLines == null && minLines == null),
|
|
'minLines and maxLines must be null when expands is true.',
|
|
),
|
|
assert(textScaleFactor != null),
|
|
assert(offset != null),
|
|
assert(ignorePointer != null),
|
|
assert(textWidthBasis != null),
|
|
assert(paintCursorAboveText != null),
|
|
assert(obscuringCharacter != null && obscuringCharacter.characters.length == 1),
|
|
assert(obscureText != null),
|
|
assert(textSelectionDelegate != null),
|
|
assert(cursorWidth != null && cursorWidth >= 0.0),
|
|
assert(cursorHeight == null || cursorHeight >= 0.0),
|
|
assert(readOnly != null),
|
|
assert(forceLine != null),
|
|
assert(devicePixelRatio != null),
|
|
assert(selectionHeightStyle != null),
|
|
assert(selectionWidthStyle != null),
|
|
assert(clipBehavior != null),
|
|
_textPainter = TextPainter(
|
|
text: text,
|
|
textAlign: textAlign,
|
|
textDirection: textDirection,
|
|
textScaleFactor: textScaleFactor,
|
|
locale: locale,
|
|
strutStyle: strutStyle,
|
|
textHeightBehavior: textHeightBehavior,
|
|
textWidthBasis: textWidthBasis,
|
|
),
|
|
_cursorColor = cursorColor,
|
|
_backgroundCursorColor = backgroundCursorColor,
|
|
_showCursor = showCursor ?? ValueNotifier<bool>(false),
|
|
_maxLines = maxLines,
|
|
_minLines = minLines,
|
|
_expands = expands,
|
|
_selectionColor = selectionColor,
|
|
_selection = selection,
|
|
_offset = offset,
|
|
_cursorWidth = cursorWidth,
|
|
_cursorHeight = cursorHeight,
|
|
_cursorRadius = cursorRadius,
|
|
_paintCursorOnTop = paintCursorAboveText,
|
|
_cursorOffset = cursorOffset,
|
|
_floatingCursorAddedMargin = floatingCursorAddedMargin,
|
|
_enableInteractiveSelection = enableInteractiveSelection,
|
|
_devicePixelRatio = devicePixelRatio,
|
|
_selectionHeightStyle = selectionHeightStyle,
|
|
_selectionWidthStyle = selectionWidthStyle,
|
|
_startHandleLayerLink = startHandleLayerLink,
|
|
_endHandleLayerLink = endHandleLayerLink,
|
|
_obscuringCharacter = obscuringCharacter,
|
|
_obscureText = obscureText,
|
|
_readOnly = readOnly,
|
|
_forceLine = forceLine,
|
|
_promptRectRange = promptRectRange,
|
|
_clipBehavior = clipBehavior {
|
|
assert(_showCursor != null);
|
|
assert(!_showCursor.value || cursorColor != null);
|
|
this.hasFocus = hasFocus ?? false;
|
|
if (promptRectColor != null)
|
|
_promptRectPaint.color = promptRectColor;
|
|
}
|
|
|
|
/// Called when the selection changes.
|
|
///
|
|
/// If this is null, then selection changes will be ignored.
|
|
SelectionChangedHandler? onSelectionChanged;
|
|
|
|
double? _textLayoutLastMaxWidth;
|
|
double? _textLayoutLastMinWidth;
|
|
|
|
/// Called during the paint phase when the caret location changes.
|
|
CaretChangedHandler? onCaretChanged;
|
|
|
|
/// 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 flutter.dart:ui.textHeightBehavior}
|
|
TextHeightBehavior? get textHeightBehavior => _textPainter.textHeightBehavior;
|
|
set textHeightBehavior(TextHeightBehavior? value) {
|
|
if (_textPainter.textHeightBehavior == value)
|
|
return;
|
|
_textPainter.textHeightBehavior = value;
|
|
markNeedsTextLayout();
|
|
}
|
|
|
|
/// {@macro flutter.painting.textPainter.textWidthBasis}
|
|
TextWidthBasis get textWidthBasis => _textPainter.textWidthBasis;
|
|
set textWidthBasis(TextWidthBasis value) {
|
|
assert(value != null);
|
|
if (_textPainter.textWidthBasis == value)
|
|
return;
|
|
_textPainter.textWidthBasis = value;
|
|
markNeedsTextLayout();
|
|
}
|
|
|
|
/// 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;
|
|
markNeedsTextLayout();
|
|
}
|
|
|
|
/// Character used for obscuring text if [obscureText] is true.
|
|
///
|
|
/// Cannot be null, and must have a length of exactly one.
|
|
String get obscuringCharacter => _obscuringCharacter;
|
|
String _obscuringCharacter;
|
|
set obscuringCharacter(String value) {
|
|
if (_obscuringCharacter == value) {
|
|
return;
|
|
}
|
|
assert(value != null && 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;
|
|
markNeedsSemanticsUpdate();
|
|
}
|
|
|
|
/// The object that controls the text selection, used by this render object
|
|
/// for implementing cut, copy, and paste keyboard shortcuts.
|
|
///
|
|
/// It must not be null. It will make cut, copy and paste functionality work
|
|
/// with the most recently set [TextSelectionDelegate].
|
|
TextSelectionDelegate textSelectionDelegate;
|
|
|
|
Rect? _lastCaretRect;
|
|
|
|
/// 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<bool> get selectionStartInViewport => _selectionStartInViewport;
|
|
final ValueNotifier<bool> _selectionStartInViewport = ValueNotifier<bool>(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<bool> get selectionEndInViewport => _selectionEndInViewport;
|
|
final ValueNotifier<bool> _selectionEndInViewport = ValueNotifier<bool>(true);
|
|
|
|
void _updateSelectionExtentsVisibility(Offset effectiveOffset) {
|
|
assert(selection != null);
|
|
final Rect visibleRegion = Offset.zero & size;
|
|
|
|
final Offset startOffset = _textPainter.getOffsetForCaret(
|
|
TextPosition(offset: selection!.start, affinity: selection!.affinity),
|
|
_caretPrototype,
|
|
);
|
|
// TODO(justinmc): https://github.com/flutter/flutter/issues/31495
|
|
// 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 TextPainer'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);
|
|
}
|
|
|
|
// Holds the last cursor location the user selected in the case the user tries
|
|
// to select vertically past the end or beginning of the field. If they do,
|
|
// then we need to keep the old cursor location so that we can go back to it
|
|
// if they change their minds. Only used for moving selection up and down in a
|
|
// multiline text field when selecting using the keyboard.
|
|
int _cursorResetLocation = -1;
|
|
|
|
// Whether we should reset the location of the cursor in the case the user
|
|
// tries to select vertically past the end or beginning of the field. If they
|
|
// do, then we need to keep the old cursor location so that we can go back to
|
|
// it if they change their minds. Only used for resetting selection up and
|
|
// down in a multiline text field when selecting using the keyboard.
|
|
bool _wasSelectingVerticallyWithKeyboard = false;
|
|
|
|
// Call through to onSelectionChanged.
|
|
void _handleSelectionChange(
|
|
TextSelection nextSelection,
|
|
SelectionChangedCause cause,
|
|
) {
|
|
// Changes made by the keyboard can sometimes be "out of band" for listening
|
|
// components, so always send those events, even if we didn't think it
|
|
// changed. Also, focusing an empty field is sent as a selection change even
|
|
// if the selection offset didn't change.
|
|
final bool focusingEmpty = nextSelection.baseOffset == 0 && nextSelection.extentOffset == 0 && !hasFocus;
|
|
if (nextSelection == selection && cause != SelectionChangedCause.keyboard && !focusingEmpty) {
|
|
return;
|
|
}
|
|
if (onSelectionChanged != null) {
|
|
onSelectionChanged!(nextSelection, this, cause);
|
|
}
|
|
}
|
|
|
|
static final Set<LogicalKeyboardKey> _movementKeys = <LogicalKeyboardKey>{
|
|
LogicalKeyboardKey.arrowRight,
|
|
LogicalKeyboardKey.arrowLeft,
|
|
LogicalKeyboardKey.arrowUp,
|
|
LogicalKeyboardKey.arrowDown,
|
|
};
|
|
|
|
static final Set<LogicalKeyboardKey> _shortcutKeys = <LogicalKeyboardKey>{
|
|
LogicalKeyboardKey.keyA,
|
|
LogicalKeyboardKey.keyC,
|
|
LogicalKeyboardKey.keyV,
|
|
LogicalKeyboardKey.keyX,
|
|
LogicalKeyboardKey.delete,
|
|
LogicalKeyboardKey.backspace,
|
|
};
|
|
|
|
static final Set<LogicalKeyboardKey> _nonModifierKeys = <LogicalKeyboardKey>{
|
|
..._shortcutKeys,
|
|
..._movementKeys,
|
|
};
|
|
|
|
static final Set<LogicalKeyboardKey> _modifierKeys = <LogicalKeyboardKey>{
|
|
LogicalKeyboardKey.shift,
|
|
LogicalKeyboardKey.control,
|
|
LogicalKeyboardKey.alt,
|
|
};
|
|
|
|
static final Set<LogicalKeyboardKey> _macOsModifierKeys = <LogicalKeyboardKey>{
|
|
LogicalKeyboardKey.shift,
|
|
LogicalKeyboardKey.meta,
|
|
LogicalKeyboardKey.alt,
|
|
};
|
|
|
|
static final Set<LogicalKeyboardKey> _interestingKeys = <LogicalKeyboardKey>{
|
|
..._modifierKeys,
|
|
..._macOsModifierKeys,
|
|
..._nonModifierKeys,
|
|
};
|
|
|
|
void _handleKeyEvent(RawKeyEvent keyEvent) {
|
|
if (kIsWeb) {
|
|
// On web platform, we should ignore the key because it's processed already.
|
|
return;
|
|
}
|
|
|
|
if (keyEvent is! RawKeyDownEvent || onSelectionChanged == null)
|
|
return;
|
|
final Set<LogicalKeyboardKey> keysPressed = LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed);
|
|
final LogicalKeyboardKey key = keyEvent.logicalKey;
|
|
|
|
final bool isMacOS = keyEvent.data is RawKeyEventDataMacOs;
|
|
if (!_nonModifierKeys.contains(key) ||
|
|
keysPressed.difference(isMacOS ? _macOsModifierKeys : _modifierKeys).length > 1 ||
|
|
keysPressed.difference(_interestingKeys).isNotEmpty) {
|
|
// If the most recently pressed key isn't a non-modifier key, or more than
|
|
// one non-modifier key is down, or keys other than the ones we're interested in
|
|
// are pressed, just ignore the keypress.
|
|
return;
|
|
}
|
|
|
|
// TODO(ianh): It seems to be entirely possible for the selection to be null here, but
|
|
// all the keyboard handling functions assume it is not.
|
|
assert(selection != null);
|
|
|
|
final bool isWordModifierPressed = isMacOS ? keyEvent.isAltPressed : keyEvent.isControlPressed;
|
|
final bool isLineModifierPressed = isMacOS ? keyEvent.isMetaPressed : keyEvent.isAltPressed;
|
|
final bool isShortcutModifierPressed = isMacOS ? keyEvent.isMetaPressed : keyEvent.isControlPressed;
|
|
if (_movementKeys.contains(key)) {
|
|
_handleMovement(key, wordModifier: isWordModifierPressed, lineModifier: isLineModifierPressed, shift: keyEvent.isShiftPressed);
|
|
} else if (isShortcutModifierPressed && _shortcutKeys.contains(key)) {
|
|
// _handleShortcuts depends on being started in the same stack invocation
|
|
// as the _handleKeyEvent method
|
|
_handleShortcuts(key);
|
|
} else if (key == LogicalKeyboardKey.delete) {
|
|
_handleDelete(forward: true);
|
|
} else if (key == LogicalKeyboardKey.backspace) {
|
|
_handleDelete(forward: false);
|
|
}
|
|
}
|
|
|
|
/// Returns the index into the string of the next character boundary after the
|
|
/// given index.
|
|
///
|
|
/// The character boundary is determined by the characters package, so
|
|
/// surrogate pairs and extended grapheme clusters are considered.
|
|
///
|
|
/// The index must be between 0 and string.length, inclusive. If given
|
|
/// string.length, string.length is returned.
|
|
///
|
|
/// Setting includeWhitespace to false will only return the index of non-space
|
|
/// characters.
|
|
@visibleForTesting
|
|
static int nextCharacter(int index, String string, [bool includeWhitespace = true]) {
|
|
assert(index >= 0 && index <= string.length);
|
|
if (index == string.length) {
|
|
return string.length;
|
|
}
|
|
|
|
int count = 0;
|
|
final Characters remaining = string.characters.skipWhile((String currentString) {
|
|
if (count <= index) {
|
|
count += currentString.length;
|
|
return true;
|
|
}
|
|
if (includeWhitespace) {
|
|
return false;
|
|
}
|
|
return _isWhitespace(currentString.codeUnitAt(0));
|
|
});
|
|
return string.length - remaining.toString().length;
|
|
}
|
|
|
|
/// Returns the index into the string of the previous character boundary
|
|
/// before the given index.
|
|
///
|
|
/// The character boundary is determined by the characters package, so
|
|
/// surrogate pairs and extended grapheme clusters are considered.
|
|
///
|
|
/// The index must be between 0 and string.length, inclusive. If index is 0,
|
|
/// 0 will be returned.
|
|
///
|
|
/// Setting includeWhitespace to false will only return the index of non-space
|
|
/// characters.
|
|
@visibleForTesting
|
|
static int previousCharacter(int index, String string, [bool includeWhitespace = true]) {
|
|
assert(index >= 0 && index <= string.length);
|
|
if (index == 0) {
|
|
return 0;
|
|
}
|
|
|
|
int count = 0;
|
|
int? lastNonWhitespace;
|
|
for (final String currentString in string.characters) {
|
|
if (!includeWhitespace &&
|
|
!_isWhitespace(currentString.characters.first.toString().codeUnitAt(0))) {
|
|
lastNonWhitespace = count;
|
|
}
|
|
if (count + currentString.length >= index) {
|
|
return includeWhitespace ? count : lastNonWhitespace ?? 0;
|
|
}
|
|
count += currentString.length;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
void _handleMovement(
|
|
LogicalKeyboardKey key, {
|
|
required bool wordModifier,
|
|
required bool lineModifier,
|
|
required bool shift,
|
|
}){
|
|
if (wordModifier && lineModifier) {
|
|
// If both modifiers are down, nothing happens on any of the platforms.
|
|
return;
|
|
}
|
|
assert(selection != null);
|
|
|
|
TextSelection newSelection = selection!;
|
|
|
|
final bool rightArrow = key == LogicalKeyboardKey.arrowRight;
|
|
final bool leftArrow = key == LogicalKeyboardKey.arrowLeft;
|
|
final bool upArrow = key == LogicalKeyboardKey.arrowUp;
|
|
final bool downArrow = key == LogicalKeyboardKey.arrowDown;
|
|
|
|
if ((rightArrow || leftArrow) && !(rightArrow && leftArrow)) {
|
|
// Jump to begin/end of word.
|
|
if (wordModifier) {
|
|
// If control/option is pressed, we will decide which way to look for a
|
|
// word based on which arrow is pressed.
|
|
if (leftArrow) {
|
|
// When going left, we want to skip over any whitespace before the word,
|
|
// so we go back to the first non-whitespace before asking for the word
|
|
// boundary, since _selectWordAtOffset finds the word boundaries without
|
|
// including whitespace.
|
|
final int startPoint = previousCharacter(newSelection.extentOffset, _plainText, false);
|
|
final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: startPoint));
|
|
newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset);
|
|
} else {
|
|
// When going right, we want to skip over any whitespace after the word,
|
|
// so we go forward to the first non-whitespace character before asking
|
|
// for the word bounds, since _selectWordAtOffset finds the word
|
|
// boundaries without including whitespace.
|
|
final int startPoint = nextCharacter(newSelection.extentOffset, _plainText, false);
|
|
final TextSelection textSelection = _selectWordAtOffset(TextPosition(offset: startPoint));
|
|
newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset);
|
|
}
|
|
} else if (lineModifier) {
|
|
// If control/command is pressed, we will decide which way to expand to
|
|
// the beginning/end of the line based on which arrow is pressed.
|
|
if (leftArrow) {
|
|
// When going left, we want to skip over any whitespace before the line,
|
|
// so we go back to the first non-whitespace before asking for the line
|
|
// bounds, since _selectLineAtOffset finds the line boundaries without
|
|
// including whitespace (like the newline).
|
|
final int startPoint = previousCharacter(newSelection.extentOffset, _plainText, false);
|
|
final TextSelection textSelection = _selectLineAtOffset(TextPosition(offset: startPoint));
|
|
newSelection = newSelection.copyWith(extentOffset: textSelection.baseOffset);
|
|
} else {
|
|
// When going right, we want to skip over any whitespace after the line,
|
|
// so we go forward to the first non-whitespace character before asking
|
|
// for the line bounds, since _selectLineAtOffset finds the line
|
|
// boundaries without including whitespace (like the newline).
|
|
final int startPoint = nextCharacter(newSelection.extentOffset, _plainText, false);
|
|
final TextSelection textSelection = _selectLineAtOffset(TextPosition(offset: startPoint));
|
|
newSelection = newSelection.copyWith(extentOffset: textSelection.extentOffset);
|
|
}
|
|
} else {
|
|
// The directional arrows move the TextSelection.extentOffset, while the
|
|
// base remains fixed.
|
|
if (rightArrow && newSelection.extentOffset < _plainText.length) {
|
|
int nextExtent;
|
|
if (!shift && !wordModifier && !lineModifier && newSelection.start != newSelection.end) {
|
|
nextExtent = newSelection.end;
|
|
} else {
|
|
nextExtent = nextCharacter(newSelection.extentOffset, _plainText);
|
|
}
|
|
final int distance = nextExtent - newSelection.extentOffset;
|
|
newSelection = newSelection.copyWith(extentOffset: nextExtent);
|
|
if (shift) {
|
|
_cursorResetLocation += distance;
|
|
}
|
|
} else if (leftArrow && newSelection.extentOffset > 0) {
|
|
int previousExtent;
|
|
if (!shift && !wordModifier && !lineModifier && newSelection.start != newSelection.end) {
|
|
previousExtent = newSelection.start;
|
|
} else {
|
|
previousExtent = previousCharacter(newSelection.extentOffset, _plainText);
|
|
}
|
|
final int distance = newSelection.extentOffset - previousExtent;
|
|
newSelection = newSelection.copyWith(extentOffset: previousExtent);
|
|
if (shift) {
|
|
_cursorResetLocation -= distance;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handles moving the cursor vertically as well as taking care of the
|
|
// case where the user moves the cursor to the end or beginning of the text
|
|
// and then back up or down.
|
|
if (downArrow || upArrow) {
|
|
// 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 = upArrow ? -0.5 * preferredLineHeight : 1.5 * preferredLineHeight;
|
|
|
|
final Offset caretOffset = _textPainter.getOffsetForCaret(TextPosition(offset: newSelection.extentOffset), _caretPrototype);
|
|
final Offset caretOffsetTranslated = caretOffset.translate(0.0, verticalOffset);
|
|
final TextPosition position = _textPainter.getPositionForOffset(caretOffsetTranslated);
|
|
|
|
// To account for the possibility where the user vertically highlights
|
|
// all the way to the top or bottom of the text, we hold the previous
|
|
// cursor location. This allows us to restore to this position in the
|
|
// case that the user wants to unhighlight some text.
|
|
if (position.offset == newSelection.extentOffset) {
|
|
if (downArrow) {
|
|
newSelection = newSelection.copyWith(extentOffset: _plainText.length);
|
|
} else if (upArrow) {
|
|
newSelection = newSelection.copyWith(extentOffset: 0);
|
|
}
|
|
_wasSelectingVerticallyWithKeyboard = shift;
|
|
} else if (_wasSelectingVerticallyWithKeyboard && shift) {
|
|
newSelection = newSelection.copyWith(extentOffset: _cursorResetLocation);
|
|
_wasSelectingVerticallyWithKeyboard = false;
|
|
} else {
|
|
newSelection = newSelection.copyWith(extentOffset: position.offset);
|
|
_cursorResetLocation = newSelection.extentOffset;
|
|
}
|
|
}
|
|
|
|
// Just place the collapsed selection at the end or beginning of the region
|
|
// if shift isn't down or selection isn't enabled.
|
|
if (!shift || !selectionEnabled) {
|
|
// We want to put the cursor at the correct location depending on which
|
|
// arrow is used while there is a selection.
|
|
int newOffset = newSelection.extentOffset;
|
|
if (!selection!.isCollapsed) {
|
|
if (leftArrow) {
|
|
newOffset = newSelection.baseOffset < newSelection.extentOffset ? newSelection.baseOffset : newSelection.extentOffset;
|
|
} else if (rightArrow) {
|
|
newOffset = newSelection.baseOffset > newSelection.extentOffset ? newSelection.baseOffset : newSelection.extentOffset;
|
|
}
|
|
}
|
|
newSelection = TextSelection.fromPosition(TextPosition(offset: newOffset));
|
|
}
|
|
|
|
_handleSelectionChange(
|
|
newSelection,
|
|
SelectionChangedCause.keyboard,
|
|
);
|
|
// Update the text selection delegate so that the engine knows what we did.
|
|
textSelectionDelegate.textEditingValue = textSelectionDelegate.textEditingValue.copyWith(selection: newSelection);
|
|
}
|
|
|
|
// Handles shortcut functionality including cut, copy, paste and select all
|
|
// using control/command + (X, C, V, A).
|
|
Future<void> _handleShortcuts(LogicalKeyboardKey key) async {
|
|
final TextSelection selection = textSelectionDelegate.textEditingValue.selection;
|
|
final String text = textSelectionDelegate.textEditingValue.text;
|
|
assert(selection != null);
|
|
assert(_shortcutKeys.contains(key), 'shortcut key $key not recognized.');
|
|
if (key == LogicalKeyboardKey.keyC) {
|
|
if (!selection.isCollapsed) {
|
|
Clipboard.setData(
|
|
ClipboardData(text: selection.textInside(text)));
|
|
}
|
|
return;
|
|
}
|
|
TextEditingValue? value;
|
|
if (key == LogicalKeyboardKey.keyX && !_readOnly) {
|
|
if (!selection.isCollapsed) {
|
|
Clipboard.setData(ClipboardData(text: selection.textInside(text)));
|
|
value = TextEditingValue(
|
|
text: selection.textBefore(text) + selection.textAfter(text),
|
|
selection: TextSelection.collapsed(offset: math.min(selection.start, selection.end)),
|
|
);
|
|
}
|
|
} else if (key == LogicalKeyboardKey.keyV && !_readOnly) {
|
|
// Snapshot the input before using `await`.
|
|
// See https://github.com/flutter/flutter/issues/11427
|
|
final ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
|
|
if (data != null) {
|
|
value = TextEditingValue(
|
|
text: selection.textBefore(text) + data.text! + selection.textAfter(text),
|
|
selection: TextSelection.collapsed(
|
|
offset: math.min(selection.start, selection.end) + data.text!.length,
|
|
),
|
|
);
|
|
}
|
|
} else if (key == LogicalKeyboardKey.keyA) {
|
|
value = TextEditingValue(
|
|
text: text,
|
|
selection: selection.copyWith(
|
|
baseOffset: 0,
|
|
extentOffset: textSelectionDelegate.textEditingValue.text.length,
|
|
),
|
|
);
|
|
}
|
|
if (value != null) {
|
|
if (textSelectionDelegate.textEditingValue.selection != value.selection) {
|
|
_handleSelectionChange(
|
|
value.selection,
|
|
SelectionChangedCause.keyboard,
|
|
);
|
|
}
|
|
textSelectionDelegate.textEditingValue = value;
|
|
}
|
|
}
|
|
|
|
void _handleDelete({ required bool forward }) {
|
|
final TextSelection selection = textSelectionDelegate.textEditingValue.selection;
|
|
final String text = textSelectionDelegate.textEditingValue.text;
|
|
assert(_selection != null);
|
|
if (_readOnly) {
|
|
return;
|
|
}
|
|
String textBefore = selection.textBefore(text);
|
|
String textAfter = selection.textAfter(text);
|
|
int cursorPosition = math.min(selection.start, selection.end);
|
|
// If not deleting a selection, delete the next/previous character.
|
|
if (selection.isCollapsed) {
|
|
if (!forward && textBefore.isNotEmpty) {
|
|
final int characterBoundary = previousCharacter(textBefore.length, textBefore);
|
|
textBefore = textBefore.substring(0, characterBoundary);
|
|
cursorPosition = characterBoundary;
|
|
}
|
|
if (forward && textAfter.isNotEmpty) {
|
|
final int deleteCount = nextCharacter(0, textAfter);
|
|
textAfter = textAfter.substring(deleteCount);
|
|
}
|
|
}
|
|
final TextSelection newSelection = TextSelection.collapsed(offset: cursorPosition);
|
|
if (selection != newSelection) {
|
|
_handleSelectionChange(
|
|
newSelection,
|
|
SelectionChangedCause.keyboard,
|
|
);
|
|
}
|
|
textSelectionDelegate.textEditingValue = TextEditingValue(
|
|
text: textBefore + textAfter,
|
|
selection: newSelection,
|
|
);
|
|
}
|
|
|
|
/// Marks the render object as needing to be laid out again and have its text
|
|
/// metrics recomputed.
|
|
///
|
|
/// Implies [markNeedsLayout].
|
|
@protected
|
|
void markNeedsTextLayout() {
|
|
_textLayoutLastMaxWidth = null;
|
|
_textLayoutLastMinWidth = null;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
@override
|
|
void systemFontsDidChange() {
|
|
super.systemFontsDidChange();
|
|
_textPainter.markNeedsLayout();
|
|
_textLayoutLastMaxWidth = null;
|
|
_textLayoutLastMinWidth = null;
|
|
}
|
|
|
|
String? _cachedPlainText;
|
|
// Returns a plain text version of the text in the painter.
|
|
//
|
|
// Returns the obscured text when [obscureText] is true. See
|
|
// [obscureText] and [obscuringCharacter].
|
|
String get _plainText {
|
|
_cachedPlainText ??= _textPainter.text!.toPlainText();
|
|
return _cachedPlainText!;
|
|
}
|
|
|
|
/// The text to display.
|
|
TextSpan? get text => _textPainter.text as TextSpan?;
|
|
final TextPainter _textPainter;
|
|
set text(TextSpan? value) {
|
|
if (_textPainter.text == value)
|
|
return;
|
|
_textPainter.text = value;
|
|
_cachedPlainText = null;
|
|
markNeedsTextLayout();
|
|
markNeedsSemanticsUpdate();
|
|
}
|
|
|
|
/// How the text should be aligned horizontally.
|
|
///
|
|
/// This must not be null.
|
|
TextAlign get textAlign => _textPainter.textAlign;
|
|
set textAlign(TextAlign value) {
|
|
assert(value != null);
|
|
if (_textPainter.textAlign == value)
|
|
return;
|
|
_textPainter.textAlign = value;
|
|
markNeedsTextLayout();
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// This must not be null.
|
|
// 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) {
|
|
assert(value != null);
|
|
if (_textPainter.textDirection == value)
|
|
return;
|
|
_textPainter.textDirection = value;
|
|
markNeedsTextLayout();
|
|
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;
|
|
markNeedsTextLayout();
|
|
}
|
|
|
|
/// 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;
|
|
markNeedsTextLayout();
|
|
}
|
|
|
|
/// The color to use when painting the cursor.
|
|
Color? get cursorColor => _cursorColor;
|
|
Color? _cursorColor;
|
|
set cursorColor(Color? value) {
|
|
if (_cursorColor == value)
|
|
return;
|
|
_cursorColor = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
/// The color to use when painting the cursor aligned to the text while
|
|
/// rendering the floating cursor.
|
|
///
|
|
/// The default is light grey.
|
|
Color? get backgroundCursorColor => _backgroundCursorColor;
|
|
Color? _backgroundCursorColor;
|
|
set backgroundCursorColor(Color? value) {
|
|
if (backgroundCursorColor == value)
|
|
return;
|
|
_backgroundCursorColor = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
/// Whether to paint the cursor.
|
|
ValueNotifier<bool> get showCursor => _showCursor;
|
|
ValueNotifier<bool> _showCursor;
|
|
set showCursor(ValueNotifier<bool> value) {
|
|
assert(value != null);
|
|
if (_showCursor == value)
|
|
return;
|
|
if (attached)
|
|
_showCursor.removeListener(markNeedsPaint);
|
|
_showCursor = value;
|
|
if (attached)
|
|
_showCursor.addListener(markNeedsPaint);
|
|
markNeedsPaint();
|
|
}
|
|
|
|
/// Whether the editable is currently focused.
|
|
bool get hasFocus => _hasFocus;
|
|
bool _hasFocus = false;
|
|
bool _listenerAttached = false;
|
|
set hasFocus(bool value) {
|
|
assert(value != null);
|
|
if (_hasFocus == value)
|
|
return;
|
|
_hasFocus = value;
|
|
if (_hasFocus) {
|
|
assert(!_listenerAttached);
|
|
RawKeyboard.instance.addListener(_handleKeyEvent);
|
|
_listenerAttached = true;
|
|
} else {
|
|
assert(_listenerAttached);
|
|
RawKeyboard.instance.removeListener(_handleKeyEvent);
|
|
_listenerAttached = false;
|
|
}
|
|
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) {
|
|
assert(value != null);
|
|
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) {
|
|
assert(value != null);
|
|
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;
|
|
markNeedsTextLayout();
|
|
}
|
|
|
|
/// {@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;
|
|
markNeedsTextLayout();
|
|
}
|
|
|
|
/// {@macro flutter.widgets.editableText.expands}
|
|
bool get expands => _expands;
|
|
bool _expands;
|
|
set expands(bool value) {
|
|
assert(value != null);
|
|
if (expands == value)
|
|
return;
|
|
_expands = value;
|
|
markNeedsTextLayout();
|
|
}
|
|
|
|
/// The color to use when painting the selection.
|
|
Color? get selectionColor => _selectionColor;
|
|
Color? _selectionColor;
|
|
set selectionColor(Color? value) {
|
|
if (_selectionColor == value)
|
|
return;
|
|
_selectionColor = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
/// 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.
|
|
double get textScaleFactor => _textPainter.textScaleFactor;
|
|
set textScaleFactor(double value) {
|
|
assert(value != null);
|
|
if (_textPainter.textScaleFactor == value)
|
|
return;
|
|
_textPainter.textScaleFactor = value;
|
|
markNeedsTextLayout();
|
|
}
|
|
|
|
List<ui.TextBox>? _selectionRects;
|
|
|
|
/// 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;
|
|
_selectionRects = null;
|
|
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) {
|
|
assert(value != null);
|
|
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;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
/// {@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 => _cursorOffset;
|
|
Offset? _cursorOffset;
|
|
set cursorOffset(Offset? value) {
|
|
if (_cursorOffset == value)
|
|
return;
|
|
_cursorOffset = value;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
/// How rounded the corners of the cursor should be.
|
|
///
|
|
/// A null value is the same as [Radius.zero].
|
|
Radius? get cursorRadius => _cursorRadius;
|
|
Radius? _cursorRadius;
|
|
set cursorRadius(Radius? value) {
|
|
if (_cursorRadius == value)
|
|
return;
|
|
_cursorRadius = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
/// 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.
|
|
EdgeInsets get floatingCursorAddedMargin => _floatingCursorAddedMargin;
|
|
EdgeInsets _floatingCursorAddedMargin;
|
|
set floatingCursorAddedMargin(EdgeInsets value) {
|
|
if (_floatingCursorAddedMargin == value)
|
|
return;
|
|
_floatingCursorAddedMargin = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
bool _floatingCursorOn = false;
|
|
late Offset _floatingCursorOffset;
|
|
late TextPosition _floatingCursorTextPosition;
|
|
|
|
/// 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;
|
|
set selectionHeightStyle(ui.BoxHeightStyle value) {
|
|
assert(value != null);
|
|
if (_selectionHeightStyle == value)
|
|
return;
|
|
_selectionHeightStyle = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
/// 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;
|
|
set selectionWidthStyle(ui.BoxWidthStyle value) {
|
|
assert(value != null);
|
|
if (_selectionWidthStyle == value)
|
|
return;
|
|
_selectionWidthStyle = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
/// 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;
|
|
markNeedsTextLayout();
|
|
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 => _promptRectPaint.color;
|
|
set promptRectColor(Color? newValue) {
|
|
// Painter.color cannot be null.
|
|
if (newValue == null) {
|
|
setPromptRectRange(null);
|
|
return;
|
|
}
|
|
|
|
if (promptRectColor == newValue)
|
|
return;
|
|
|
|
_promptRectPaint.color = newValue;
|
|
if (_promptRectRange != null)
|
|
markNeedsPaint();
|
|
}
|
|
|
|
TextRange? _promptRectRange;
|
|
/// 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.
|
|
void setPromptRectRange(TextRange? newRange) {
|
|
if (_promptRectRange == newRange)
|
|
return;
|
|
|
|
_promptRectRange = newRange;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
/// 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], and must not be null.
|
|
Clip get clipBehavior => _clipBehavior;
|
|
Clip _clipBehavior = Clip.hardEdge;
|
|
set clipBehavior(Clip value) {
|
|
assert(value != null);
|
|
if (value != _clipBehavior) {
|
|
_clipBehavior = value;
|
|
markNeedsPaint();
|
|
markNeedsSemanticsUpdate();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void describeSemanticsConfiguration(SemanticsConfiguration config) {
|
|
super.describeSemanticsConfiguration(config);
|
|
|
|
config
|
|
..value = obscureText
|
|
? obscuringCharacter * _plainText.length
|
|
: _plainText
|
|
..isObscured = obscureText
|
|
..isMultiline = _isMultiline
|
|
..textDirection = textDirection
|
|
..isFocused = hasFocus
|
|
..isTextField = true
|
|
..isReadOnly = readOnly;
|
|
|
|
if (hasFocus && selectionEnabled)
|
|
config.onSetSelection = _handleSetSelection;
|
|
|
|
if (selectionEnabled && selection?.isValid == true) {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
_handleSelectionChange(selection, SelectionChangedCause.keyboard);
|
|
}
|
|
|
|
void _handleMoveCursorForwardByCharacter(bool extentSelection) {
|
|
assert(selection != null);
|
|
final int? extentOffset = _textPainter.getOffsetAfter(selection!.extentOffset);
|
|
if (extentOffset == null)
|
|
return;
|
|
final int baseOffset = !extentSelection ? extentOffset : selection!.baseOffset;
|
|
_handleSelectionChange(
|
|
TextSelection(baseOffset: baseOffset, extentOffset: extentOffset), SelectionChangedCause.keyboard,
|
|
);
|
|
}
|
|
|
|
void _handleMoveCursorBackwardByCharacter(bool extentSelection) {
|
|
assert(selection != null);
|
|
final int? extentOffset = _textPainter.getOffsetBefore(selection!.extentOffset);
|
|
if (extentOffset == null)
|
|
return;
|
|
final int baseOffset = !extentSelection ? extentOffset : selection!.baseOffset;
|
|
_handleSelectionChange(
|
|
TextSelection(baseOffset: baseOffset, extentOffset: extentOffset), SelectionChangedCause.keyboard,
|
|
);
|
|
}
|
|
|
|
void _handleMoveCursorForwardByWord(bool extentSelection) {
|
|
assert(selection != null);
|
|
final TextRange currentWord = _textPainter.getWordBoundary(selection!.extent);
|
|
final TextRange? nextWord = _getNextWord(currentWord.end);
|
|
if (nextWord == null)
|
|
return;
|
|
final int baseOffset = extentSelection ? selection!.baseOffset : nextWord.start;
|
|
_handleSelectionChange(
|
|
TextSelection(
|
|
baseOffset: baseOffset,
|
|
extentOffset: nextWord.start,
|
|
),
|
|
SelectionChangedCause.keyboard,
|
|
);
|
|
}
|
|
|
|
void _handleMoveCursorBackwardByWord(bool extentSelection) {
|
|
assert(selection != null);
|
|
final TextRange currentWord = _textPainter.getWordBoundary(selection!.extent);
|
|
final TextRange? previousWord = _getPreviousWord(currentWord.start - 1);
|
|
if (previousWord == null)
|
|
return;
|
|
final int baseOffset = extentSelection ? selection!.baseOffset : previousWord.start;
|
|
_handleSelectionChange(
|
|
TextSelection(
|
|
baseOffset: baseOffset,
|
|
extentOffset: previousWord.start,
|
|
),
|
|
SelectionChangedCause.keyboard,
|
|
);
|
|
}
|
|
|
|
TextRange? _getNextWord(int offset) {
|
|
while (true) {
|
|
final TextRange range = _textPainter.getWordBoundary(TextPosition(offset: offset));
|
|
if (range == null || !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 == null || !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(jonahwilliams): 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 (!_isWhitespace(codeUnit)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
void attach(PipelineOwner owner) {
|
|
super.attach(owner);
|
|
_tap = TapGestureRecognizer(debugOwner: this)
|
|
..onTapDown = _handleTapDown
|
|
..onTap = _handleTap;
|
|
_longPress = LongPressGestureRecognizer(debugOwner: this)..onLongPress = _handleLongPress;
|
|
_offset.addListener(markNeedsPaint);
|
|
_showCursor.addListener(markNeedsPaint);
|
|
}
|
|
|
|
@override
|
|
void detach() {
|
|
_tap.dispose();
|
|
_longPress.dispose();
|
|
_offset.removeListener(markNeedsPaint);
|
|
_showCursor.removeListener(markNeedsPaint);
|
|
if (_listenerAttached)
|
|
RawKeyboard.instance.removeListener(_handleKeyEvent);
|
|
super.detach();
|
|
}
|
|
|
|
bool get _isMultiline => maxLines != 1;
|
|
|
|
Axis get _viewportAxis => _isMultiline ? Axis.vertical : Axis.horizontal;
|
|
|
|
Offset get _paintOffset {
|
|
switch (_viewportAxis) {
|
|
case Axis.horizontal:
|
|
return Offset(-offset.pixels, 0.0);
|
|
case Axis.vertical:
|
|
return Offset(0.0, -offset.pixels);
|
|
}
|
|
}
|
|
|
|
double get _viewportExtent {
|
|
assert(hasSize);
|
|
switch (_viewportAxis) {
|
|
case Axis.horizontal:
|
|
return size.width;
|
|
case Axis.vertical:
|
|
return size.height;
|
|
}
|
|
}
|
|
|
|
double _getMaxScrollExtent(Size contentSize) {
|
|
assert(hasSize);
|
|
switch (_viewportAxis) {
|
|
case Axis.horizontal:
|
|
return math.max(0.0, contentSize.width - size.width);
|
|
case Axis.vertical:
|
|
return 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<TextSelectionPoint> getEndpointsForSelection(TextSelection selection) {
|
|
assert(constraints != null);
|
|
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
|
|
|
|
final Offset paintOffset = _paintOffset;
|
|
|
|
|
|
final List<ui.TextBox> boxes = selection.isCollapsed ?
|
|
<ui.TextBox>[] : _textPainter.getBoxesForSelection(selection);
|
|
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>[TextSelectionPoint(start, null)];
|
|
} else {
|
|
final Offset start = Offset(boxes.first.start, boxes.first.bottom) + paintOffset;
|
|
final Offset end = Offset(boxes.last.end, boxes.last.bottom) + paintOffset;
|
|
return <TextSelectionPoint>[
|
|
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) {
|
|
assert(constraints != null);
|
|
if (!range.isValid || range.isCollapsed)
|
|
return null;
|
|
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
|
|
|
|
final List<ui.TextBox> boxes = _textPainter.getBoxesForSelection(
|
|
TextSelection(baseOffset: range.start, extentOffset: range.end),
|
|
);
|
|
|
|
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) {
|
|
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
|
|
globalPosition += -_paintOffset;
|
|
return _textPainter.getPositionForOffset(globalToLocal(globalPosition));
|
|
}
|
|
|
|
/// 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) {
|
|
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
|
|
final Offset caretOffset = _textPainter.getOffsetForCaret(caretPosition, _caretPrototype);
|
|
// This rect is the same as _caretPrototype but without the vertical padding.
|
|
Rect rect = Rect.fromLTWH(0.0, 0.0, cursorWidth, cursorHeight).shift(caretOffset + _paintOffset);
|
|
// Add additional cursor offset (generally only if on iOS).
|
|
if (_cursorOffset != null)
|
|
rect = rect.shift(_cursorOffset!);
|
|
|
|
return rect.shift(_getPixelPerfectCursorOffset(rect));
|
|
}
|
|
|
|
@override
|
|
double computeMinIntrinsicWidth(double height) {
|
|
_layoutText(maxWidth: double.infinity);
|
|
return _textPainter.minIntrinsicWidth;
|
|
}
|
|
|
|
@override
|
|
double computeMaxIntrinsicWidth(double height) {
|
|
_layoutText(maxWidth: double.infinity);
|
|
return _textPainter.maxIntrinsicWidth + cursorWidth;
|
|
}
|
|
|
|
/// An estimate of the height of a line in the text. See [TextPainter.preferredLineHeight].
|
|
/// This does not required the layout to be updated.
|
|
double get preferredLineHeight => _textPainter.preferredLineHeight;
|
|
|
|
double _preferredHeight(double width) {
|
|
// Lock height to maxLines if needed.
|
|
final bool lockedMax = maxLines != null && minLines == null;
|
|
final bool lockedBoth = minLines != null && minLines == maxLines;
|
|
final bool singleLine = maxLines == 1;
|
|
if (singleLine || lockedMax || lockedBoth) {
|
|
return preferredLineHeight * maxLines!;
|
|
}
|
|
|
|
// Clamp height to minLines or maxLines if needed.
|
|
final bool minLimited = minLines != null && minLines! > 1;
|
|
final bool maxLimited = maxLines != null;
|
|
if (minLimited || maxLimited) {
|
|
_layoutText(maxWidth: width);
|
|
if (minLimited && _textPainter.height < preferredLineHeight * minLines!) {
|
|
return preferredLineHeight * minLines!;
|
|
}
|
|
if (maxLimited && _textPainter.height > preferredLineHeight * maxLines!) {
|
|
return preferredLineHeight * maxLines!;
|
|
}
|
|
}
|
|
|
|
// Set the height based on the content.
|
|
if (width == double.infinity) {
|
|
final String text = _plainText;
|
|
int lines = 1;
|
|
for (int index = 0; index < text.length; index += 1) {
|
|
if (text.codeUnitAt(index) == 0x0A) // count explicit line breaks
|
|
lines += 1;
|
|
}
|
|
return preferredLineHeight * lines;
|
|
}
|
|
_layoutText(maxWidth: width);
|
|
return math.max(preferredLineHeight, _textPainter.height);
|
|
}
|
|
|
|
@override
|
|
double computeMinIntrinsicHeight(double width) {
|
|
return _preferredHeight(width);
|
|
}
|
|
|
|
@override
|
|
double computeMaxIntrinsicHeight(double width) {
|
|
return _preferredHeight(width);
|
|
}
|
|
|
|
@override
|
|
double computeDistanceToActualBaseline(TextBaseline baseline) {
|
|
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
|
|
return _textPainter.computeDistanceToActualBaseline(baseline);
|
|
}
|
|
|
|
@override
|
|
bool hitTestSelf(Offset position) => true;
|
|
|
|
late TapGestureRecognizer _tap;
|
|
late LongPressGestureRecognizer _longPress;
|
|
|
|
@override
|
|
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
|
|
assert(debugHandleEvent(event, entry));
|
|
if (event is PointerDownEvent) {
|
|
assert(!debugNeedsLayout);
|
|
// Checks if there is any gesture recognizer in the text span.
|
|
final Offset offset = entry.localPosition;
|
|
final TextPosition position = _textPainter.getPositionForOffset(offset);
|
|
final InlineSpan? span = _textPainter.text!.getSpanForPosition(position);
|
|
if (span != null && span is TextSpan) {
|
|
final TextSpan textSpan = span;
|
|
textSpan.recognizer?.addPointer(event);
|
|
}
|
|
|
|
if (!ignorePointer && onSelectionChanged != null) {
|
|
// Propagates the pointer event to selection handlers.
|
|
_tap.addPointer(event);
|
|
_longPress.addPointer(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
Offset? _lastTapDownPosition;
|
|
|
|
/// 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 }) {
|
|
assert(cause != null);
|
|
assert(from != null);
|
|
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
|
|
if (onSelectionChanged == null) {
|
|
return;
|
|
}
|
|
final TextPosition fromPosition = _textPainter.getPositionForOffset(globalToLocal(from - _paintOffset));
|
|
final TextPosition? toPosition = to == null
|
|
? null
|
|
: _textPainter.getPositionForOffset(globalToLocal(to - _paintOffset));
|
|
|
|
final int baseOffset = fromPosition.offset;
|
|
final int extentOffset = toPosition?.offset ?? fromPosition.offset;
|
|
|
|
final TextSelection newSelection = TextSelection(
|
|
baseOffset: baseOffset,
|
|
extentOffset: extentOffset,
|
|
affinity: fromPosition.affinity,
|
|
);
|
|
// Call [onSelectionChanged] only when the selection actually changed.
|
|
_handleSelectionChange(newSelection, cause);
|
|
}
|
|
|
|
/// 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 in a given 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 }) {
|
|
assert(cause != null);
|
|
assert(from != null);
|
|
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
|
|
if (onSelectionChanged == null) {
|
|
return;
|
|
}
|
|
final TextPosition firstPosition = _textPainter.getPositionForOffset(globalToLocal(from - _paintOffset));
|
|
final TextSelection firstWord = _selectWordAtOffset(firstPosition);
|
|
final TextSelection lastWord = to == null ?
|
|
firstWord : _selectWordAtOffset(_textPainter.getPositionForOffset(globalToLocal(to - _paintOffset)));
|
|
|
|
_handleSelectionChange(
|
|
TextSelection(
|
|
baseOffset: firstWord.base.offset,
|
|
extentOffset: lastWord.extent.offset,
|
|
affinity: firstWord.affinity,
|
|
),
|
|
cause,
|
|
);
|
|
}
|
|
|
|
/// Move the selection to the beginning or end of a word.
|
|
///
|
|
/// {@macro flutter.rendering.RenderEditable.selectPosition}
|
|
void selectWordEdge({ required SelectionChangedCause cause }) {
|
|
assert(cause != null);
|
|
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
|
|
assert(_lastTapDownPosition != null);
|
|
if (onSelectionChanged == null) {
|
|
return;
|
|
}
|
|
final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition! - _paintOffset));
|
|
final TextRange word = _textPainter.getWordBoundary(position);
|
|
if (position.offset - word.start <= 1) {
|
|
_handleSelectionChange(
|
|
TextSelection.collapsed(offset: word.start, affinity: TextAffinity.downstream),
|
|
cause,
|
|
);
|
|
} else {
|
|
_handleSelectionChange(
|
|
TextSelection.collapsed(offset: word.end, affinity: TextAffinity.upstream),
|
|
cause,
|
|
);
|
|
}
|
|
}
|
|
|
|
TextSelection _selectWordAtOffset(TextPosition position) {
|
|
assert(_textLayoutLastMaxWidth == constraints.maxWidth &&
|
|
_textLayoutLastMinWidth == constraints.minWidth,
|
|
'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).');
|
|
final TextRange word = _textPainter.getWordBoundary(position);
|
|
// When long-pressing past the end of the text, we want a collapsed cursor.
|
|
if (position.offset >= word.end)
|
|
return TextSelection.fromPosition(position);
|
|
// If text is obscured, the entire sentence should be treated as one word.
|
|
if (obscureText) {
|
|
return TextSelection(baseOffset: 0, extentOffset: _plainText.length);
|
|
// If the word is a space, on iOS try to select the previous word instead.
|
|
} else if (text?.text != null
|
|
&& _isWhitespace(text!.text!.codeUnitAt(position.offset))
|
|
&& position.offset > 0) {
|
|
assert(defaultTargetPlatform != null);
|
|
switch (defaultTargetPlatform) {
|
|
case TargetPlatform.iOS:
|
|
int startIndex = position.offset - 1;
|
|
while (startIndex > 0
|
|
&& (_isWhitespace(text!.text!.codeUnitAt(startIndex))
|
|
|| text!.text! == '\u200e' || text!.text! == '\u200f')) {
|
|
startIndex--;
|
|
}
|
|
if (startIndex > 0) {
|
|
final TextPosition positionBeforeSpace = TextPosition(
|
|
offset: startIndex,
|
|
affinity: position.affinity,
|
|
);
|
|
final TextRange wordBeforeSpace = _textPainter.getWordBoundary(
|
|
positionBeforeSpace,
|
|
);
|
|
startIndex = wordBeforeSpace.start;
|
|
}
|
|
return TextSelection(
|
|
baseOffset: startIndex,
|
|
extentOffset: position.offset,
|
|
);
|
|
case TargetPlatform.android:
|
|
case TargetPlatform.fuchsia:
|
|
case TargetPlatform.macOS:
|
|
case TargetPlatform.linux:
|
|
case TargetPlatform.windows:
|
|
break;
|
|
}
|
|
}
|
|
return TextSelection(baseOffset: word.start, extentOffset: word.end);
|
|
}
|
|
|
|
TextSelection _selectLineAtOffset(TextPosition position) {
|
|
assert(_textLayoutLastMaxWidth == constraints.maxWidth &&
|
|
_textLayoutLastMinWidth == constraints.minWidth,
|
|
'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).');
|
|
final TextRange line = _textPainter.getLineBoundary(position);
|
|
if (position.offset >= line.end)
|
|
return TextSelection.fromPosition(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);
|
|
}
|
|
|
|
void _layoutText({ double minWidth = 0.0, double maxWidth = double.infinity }) {
|
|
assert(maxWidth != null && minWidth != null);
|
|
if (_textLayoutLastMaxWidth == maxWidth && _textLayoutLastMinWidth == minWidth)
|
|
return;
|
|
final double availableMaxWidth = math.max(0.0, maxWidth - _caretMargin);
|
|
final double availableMinWidth = math.min(minWidth, availableMaxWidth);
|
|
final double textMaxWidth = _isMultiline ? availableMaxWidth : double.infinity;
|
|
final double textMinWidth = forceLine ? availableMaxWidth : availableMinWidth;
|
|
_textPainter.layout(
|
|
minWidth: textMinWidth,
|
|
maxWidth: textMaxWidth,
|
|
);
|
|
_textLayoutLastMinWidth = minWidth;
|
|
_textLayoutLastMaxWidth = maxWidth;
|
|
}
|
|
|
|
late Rect _caretPrototype;
|
|
|
|
// TODO(garyq): This is no longer producing the highest-fidelity caret
|
|
// heights for Android, especially when non-alphabetic languages
|
|
// are involved. The current implementation overrides the height set
|
|
// here with the full measured height of the text on Android which looks
|
|
// superior (subjectively and in terms of fidelity) in _paintCaret. We
|
|
// should rework this properly to once again match the platform. The constant
|
|
// _kCaretHeightOffset scales poorly for small font sizes.
|
|
//
|
|
/// 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() {
|
|
assert(defaultTargetPlatform != null);
|
|
switch (defaultTargetPlatform) {
|
|
case TargetPlatform.iOS:
|
|
case TargetPlatform.macOS:
|
|
_caretPrototype = Rect.fromLTWH(0.0, 0.0, cursorWidth, cursorHeight + 2);
|
|
break;
|
|
case TargetPlatform.android:
|
|
case TargetPlatform.fuchsia:
|
|
case TargetPlatform.linux:
|
|
case TargetPlatform.windows:
|
|
_caretPrototype = Rect.fromLTWH(0.0, _kCaretHeightOffset, cursorWidth, cursorHeight - 2.0 * _kCaretHeightOffset);
|
|
break;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Size computeDryLayout(BoxConstraints constraints) {
|
|
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
|
|
final double width = forceLine ? constraints.maxWidth : constraints
|
|
.constrainWidth(_textPainter.size.width + _caretMargin);
|
|
return Size(width, constraints.constrainHeight(_preferredHeight(constraints.maxWidth)));
|
|
}
|
|
|
|
@override
|
|
void performLayout() {
|
|
final BoxConstraints constraints = this.constraints;
|
|
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
|
|
_computeCaretPrototype();
|
|
_selectionRects = null;
|
|
// We grab _textPainter.size here because assigning to `size` on the next
|
|
// line will trigger us to validate our intrinsic sizes, which will change
|
|
// _textPainter's layout because the intrinsic size calculations are
|
|
// destructive, which would mean we would get different results if we later
|
|
// used properties on _textPainter in this method.
|
|
// Other _textPainter state like didExceedMaxLines will also be affected,
|
|
// though we currently don't use those here.
|
|
// See also RenderParagraph which has a similar issue.
|
|
final Size textPainterSize = _textPainter.size;
|
|
final double width = forceLine ? constraints.maxWidth : constraints
|
|
.constrainWidth(_textPainter.size.width + _caretMargin);
|
|
size = Size(width, constraints.constrainHeight(_preferredHeight(constraints.maxWidth)));
|
|
final Size contentSize = Size(textPainterSize.width + _caretMargin, textPainterSize.height);
|
|
_maxScrollExtent = _getMaxScrollExtent(contentSize);
|
|
offset.applyViewportDimension(_viewportExtent);
|
|
offset.applyContentDimensions(0.0, _maxScrollExtent);
|
|
}
|
|
|
|
/// Computes the offset to apply to the given [caretRect] so it perfectly
|
|
/// snaps to physical pixels.
|
|
Offset _getPixelPerfectCursorOffset(Rect caretRect) {
|
|
final Offset caretPosition = localToGlobal(caretRect.topLeft);
|
|
final double pixelMultiple = 1.0 / _devicePixelRatio;
|
|
final double pixelPerfectOffsetX = caretPosition.dx.isFinite
|
|
? (caretPosition.dx / pixelMultiple).round() * pixelMultiple - caretPosition.dx
|
|
: 0;
|
|
final double pixelPerfectOffsetY = caretPosition.dy.isFinite
|
|
? (caretPosition.dy / pixelMultiple).round() * pixelMultiple - caretPosition.dy
|
|
: 0;
|
|
return Offset(pixelPerfectOffsetX, pixelPerfectOffsetY);
|
|
}
|
|
|
|
void _paintCaret(Canvas canvas, Offset effectiveOffset, TextPosition textPosition) {
|
|
assert(_textLayoutLastMaxWidth == constraints.maxWidth &&
|
|
_textLayoutLastMinWidth == constraints.minWidth,
|
|
'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).');
|
|
assert(_caretPrototype != null);
|
|
|
|
// If the floating cursor is enabled, the text cursor's color is [backgroundCursorColor] while
|
|
// the floating cursor's color is _cursorColor;
|
|
final Paint paint = Paint()
|
|
..color = (_floatingCursorOn ? backgroundCursorColor : _cursorColor)!;
|
|
final Offset caretOffset = _textPainter.getOffsetForCaret(textPosition, _caretPrototype) + effectiveOffset;
|
|
Rect caretRect = _caretPrototype.shift(caretOffset);
|
|
if (_cursorOffset != null)
|
|
caretRect = caretRect.shift(_cursorOffset!);
|
|
|
|
final double? caretHeight = _textPainter.getFullHeightForCaret(textPosition, _caretPrototype);
|
|
if (caretHeight != null) {
|
|
switch (defaultTargetPlatform) {
|
|
case TargetPlatform.iOS:
|
|
case TargetPlatform.macOS:
|
|
final double heightDiff = caretHeight - caretRect.height;
|
|
// Center the caret vertically along the text.
|
|
caretRect = Rect.fromLTWH(
|
|
caretRect.left,
|
|
caretRect.top + heightDiff / 2,
|
|
caretRect.width,
|
|
caretRect.height,
|
|
);
|
|
break;
|
|
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 the TODO for _computeCaretPrototype().
|
|
caretRect = Rect.fromLTWH(
|
|
caretRect.left,
|
|
caretRect.top - _kCaretHeightOffset,
|
|
caretRect.width,
|
|
caretHeight,
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
caretRect = caretRect.shift(_getPixelPerfectCursorOffset(caretRect));
|
|
|
|
if (cursorRadius == null) {
|
|
canvas.drawRect(caretRect, paint);
|
|
} else {
|
|
final RRect caretRRect = RRect.fromRectAndRadius(caretRect, cursorRadius!);
|
|
canvas.drawRRect(caretRRect, paint);
|
|
}
|
|
|
|
if (caretRect != _lastCaretRect) {
|
|
_lastCaretRect = caretRect;
|
|
if (onCaretChanged != null)
|
|
onCaretChanged!(caretRect);
|
|
}
|
|
}
|
|
|
|
/// Sets the screen position of the floating cursor and the text position
|
|
/// closest to the cursor.
|
|
void setFloatingCursor(FloatingCursorDragState state, Offset boundedOffset, TextPosition lastTextPosition, { double? resetLerpValue }) {
|
|
assert(state != null);
|
|
assert(boundedOffset != null);
|
|
assert(lastTextPosition != null);
|
|
if (state == FloatingCursorDragState.Start) {
|
|
_relativeOrigin = const Offset(0, 0);
|
|
_previousOffset = null;
|
|
_resetOriginOnBottom = false;
|
|
_resetOriginOnTop = false;
|
|
_resetOriginOnRight = false;
|
|
_resetOriginOnBottom = false;
|
|
}
|
|
_floatingCursorOn = state != FloatingCursorDragState.End;
|
|
_resetFloatingCursorAnimationValue = resetLerpValue;
|
|
if (_floatingCursorOn) {
|
|
_floatingCursorOffset = boundedOffset;
|
|
_floatingCursorTextPosition = lastTextPosition;
|
|
}
|
|
markNeedsPaint();
|
|
}
|
|
|
|
void _paintFloatingCaret(Canvas canvas, Offset effectiveOffset) {
|
|
assert(_textLayoutLastMaxWidth == constraints.maxWidth &&
|
|
_textLayoutLastMinWidth == constraints.minWidth,
|
|
'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).');
|
|
assert(_floatingCursorOn);
|
|
|
|
// We always want the floating cursor to render at full opacity.
|
|
final Paint paint = Paint()..color = _cursorColor!.withOpacity(0.75);
|
|
|
|
double sizeAdjustmentX = _kFloatingCaretSizeIncrease.dx;
|
|
double sizeAdjustmentY = _kFloatingCaretSizeIncrease.dy;
|
|
|
|
if (_resetFloatingCursorAnimationValue != null) {
|
|
sizeAdjustmentX = ui.lerpDouble(sizeAdjustmentX, 0, _resetFloatingCursorAnimationValue!)!;
|
|
sizeAdjustmentY = ui.lerpDouble(sizeAdjustmentY, 0, _resetFloatingCursorAnimationValue!)!;
|
|
}
|
|
|
|
final Rect floatingCaretPrototype = Rect.fromLTRB(
|
|
_caretPrototype.left - sizeAdjustmentX,
|
|
_caretPrototype.top - sizeAdjustmentY,
|
|
_caretPrototype.right + sizeAdjustmentX,
|
|
_caretPrototype.bottom + sizeAdjustmentY,
|
|
);
|
|
|
|
final Rect caretRect = floatingCaretPrototype.shift(effectiveOffset);
|
|
const Radius floatingCursorRadius = Radius.circular(_kFloatingCaretRadius);
|
|
final RRect caretRRect = RRect.fromRectAndRadius(caretRect, floatingCursorRadius);
|
|
canvas.drawRRect(caretRRect, paint);
|
|
}
|
|
|
|
// 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 = const Offset(0, 0);
|
|
Offset? _previousOffset;
|
|
bool _resetOriginOnLeft = false;
|
|
bool _resetOriginOnRight = false;
|
|
bool _resetOriginOnTop = false;
|
|
bool _resetOriginOnBottom = false;
|
|
double? _resetFloatingCursorAnimationValue;
|
|
|
|
/// Returns the position within the text field closest to the raw cursor offset.
|
|
Offset calculateBoundedFloatingCursorOffset(Offset rawCursorOffset) {
|
|
Offset deltaPosition = const Offset(0, 0);
|
|
final double topBound = -floatingCursorAddedMargin.top;
|
|
final double bottomBound = _textPainter.height - preferredLineHeight + floatingCursorAddedMargin.bottom;
|
|
final double leftBound = -floatingCursorAddedMargin.left;
|
|
final double rightBound = _textPainter.width + floatingCursorAddedMargin.right;
|
|
|
|
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 - leftBound, _relativeOrigin.dy);
|
|
_resetOriginOnLeft = false;
|
|
} else if (_resetOriginOnRight && deltaPosition.dx < 0) {
|
|
_relativeOrigin = Offset(rawCursorOffset.dx - rightBound, _relativeOrigin.dy);
|
|
_resetOriginOnRight = false;
|
|
}
|
|
if (_resetOriginOnTop && deltaPosition.dy > 0) {
|
|
_relativeOrigin = Offset(_relativeOrigin.dx, rawCursorOffset.dy - topBound);
|
|
_resetOriginOnTop = false;
|
|
} else if (_resetOriginOnBottom && deltaPosition.dy < 0) {
|
|
_relativeOrigin = Offset(_relativeOrigin.dx, rawCursorOffset.dy - bottomBound);
|
|
_resetOriginOnBottom = false;
|
|
}
|
|
|
|
final double currentX = rawCursorOffset.dx - _relativeOrigin.dx;
|
|
final double currentY = rawCursorOffset.dy - _relativeOrigin.dy;
|
|
final double adjustedX = math.min(math.max(currentX, leftBound), rightBound);
|
|
final double adjustedY = math.min(math.max(currentY, topBound), bottomBound);
|
|
final Offset adjustedOffset = Offset(adjustedX, adjustedY);
|
|
|
|
if (currentX < leftBound && deltaPosition.dx < 0)
|
|
_resetOriginOnLeft = true;
|
|
else if (currentX > rightBound && deltaPosition.dx > 0)
|
|
_resetOriginOnRight = true;
|
|
if (currentY < topBound && deltaPosition.dy < 0)
|
|
_resetOriginOnTop = true;
|
|
else if (currentY > bottomBound && deltaPosition.dy > 0)
|
|
_resetOriginOnBottom = true;
|
|
|
|
_previousOffset = rawCursorOffset;
|
|
|
|
return adjustedOffset;
|
|
}
|
|
|
|
void _paintSelection(Canvas canvas, Offset effectiveOffset) {
|
|
assert(_textLayoutLastMaxWidth == constraints.maxWidth &&
|
|
_textLayoutLastMinWidth == constraints.minWidth,
|
|
'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).');
|
|
assert(_selectionRects != null);
|
|
final Paint paint = Paint()..color = _selectionColor!;
|
|
for (final ui.TextBox box in _selectionRects!)
|
|
canvas.drawRect(box.toRect().shift(effectiveOffset), paint);
|
|
}
|
|
|
|
final Paint _promptRectPaint = Paint();
|
|
void _paintPromptRectIfNeeded(Canvas canvas, Offset effectiveOffset) {
|
|
if (_promptRectRange == null || promptRectColor == null) {
|
|
return;
|
|
}
|
|
|
|
final List<TextBox> boxes = _textPainter.getBoxesForSelection(
|
|
TextSelection(
|
|
baseOffset: _promptRectRange!.start,
|
|
extentOffset: _promptRectRange!.end,
|
|
),
|
|
);
|
|
|
|
for (final TextBox box in boxes) {
|
|
canvas.drawRect(box.toRect().shift(effectiveOffset), _promptRectPaint);
|
|
}
|
|
}
|
|
|
|
void _paintContents(PaintingContext context, Offset offset) {
|
|
assert(_textLayoutLastMaxWidth == constraints.maxWidth &&
|
|
_textLayoutLastMinWidth == constraints.minWidth,
|
|
'Last width ($_textLayoutLastMinWidth, $_textLayoutLastMaxWidth) not the same as max width constraint (${constraints.minWidth}, ${constraints.maxWidth}).');
|
|
final Offset effectiveOffset = offset + _paintOffset;
|
|
|
|
bool showSelection = false;
|
|
bool showCaret = false;
|
|
|
|
if (selection != null && !_floatingCursorOn) {
|
|
if (selection!.isCollapsed && _showCursor.value && cursorColor != null)
|
|
showCaret = true;
|
|
else if (!selection!.isCollapsed && _selectionColor != null)
|
|
showSelection = true;
|
|
_updateSelectionExtentsVisibility(effectiveOffset);
|
|
}
|
|
|
|
if (showSelection) {
|
|
assert(selection != null);
|
|
_selectionRects ??= _textPainter.getBoxesForSelection(selection!, boxHeightStyle: _selectionHeightStyle, boxWidthStyle: _selectionWidthStyle);
|
|
_paintSelection(context.canvas, effectiveOffset);
|
|
}
|
|
|
|
_paintPromptRectIfNeeded(context.canvas, effectiveOffset);
|
|
|
|
// On iOS, the cursor is painted over the text, on Android, it's painted
|
|
// under it.
|
|
if (paintCursorAboveText)
|
|
_textPainter.paint(context.canvas, effectiveOffset);
|
|
|
|
if (showCaret) {
|
|
assert(selection != null);
|
|
_paintCaret(context.canvas, effectiveOffset, selection!.extent);
|
|
}
|
|
|
|
if (!paintCursorAboveText)
|
|
_textPainter.paint(context.canvas, effectiveOffset);
|
|
|
|
if (_floatingCursorOn) {
|
|
if (_resetFloatingCursorAnimationValue == null)
|
|
_paintCaret(context.canvas, effectiveOffset, _floatingCursorTextPosition);
|
|
_paintFloatingCaret(context.canvas, _floatingCursorOffset);
|
|
}
|
|
}
|
|
|
|
void _paintHandleLayers(PaintingContext context, List<TextSelectionPoint> endpoints) {
|
|
Offset startPoint = endpoints[0].point;
|
|
startPoint = Offset(
|
|
startPoint.dx.clamp(0.0, size.width),
|
|
startPoint.dy.clamp(0.0, size.height),
|
|
);
|
|
context.pushLayer(
|
|
LeaderLayer(link: startHandleLayerLink, offset: startPoint),
|
|
super.paint,
|
|
Offset.zero,
|
|
);
|
|
if (endpoints.length == 2) {
|
|
Offset endPoint = endpoints[1].point;
|
|
endPoint = Offset(
|
|
endPoint.dx.clamp(0.0, size.width),
|
|
endPoint.dy.clamp(0.0, size.height),
|
|
);
|
|
context.pushLayer(
|
|
LeaderLayer(link: endHandleLayerLink, offset: endPoint),
|
|
super.paint,
|
|
Offset.zero,
|
|
);
|
|
}
|
|
}
|
|
@override
|
|
void paint(PaintingContext context, Offset offset) {
|
|
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
|
|
if (_hasVisualOverflow && clipBehavior != Clip.none) {
|
|
_clipRectLayer = context.pushClipRect(needsCompositing, offset, Offset.zero & size, _paintContents,
|
|
clipBehavior: clipBehavior, oldLayer: _clipRectLayer);
|
|
} else {
|
|
_clipRectLayer = null;
|
|
_paintContents(context, offset);
|
|
}
|
|
_paintHandleLayers(context, getEndpointsForSelection(selection!));
|
|
}
|
|
|
|
ClipRectLayer? _clipRectLayer;
|
|
|
|
@override
|
|
Rect? describeApproximatePaintClip(RenderObject child) => _hasVisualOverflow ? Offset.zero & size : null;
|
|
|
|
@override
|
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
|
super.debugFillProperties(properties);
|
|
properties.add(ColorProperty('cursorColor', cursorColor));
|
|
properties.add(DiagnosticsProperty<ValueNotifier<bool>>('showCursor', showCursor));
|
|
properties.add(IntProperty('maxLines', maxLines));
|
|
properties.add(IntProperty('minLines', minLines));
|
|
properties.add(DiagnosticsProperty<bool>('expands', expands, defaultValue: false));
|
|
properties.add(ColorProperty('selectionColor', selectionColor));
|
|
properties.add(DoubleProperty('textScaleFactor', textScaleFactor));
|
|
properties.add(DiagnosticsProperty<Locale>('locale', locale, defaultValue: null));
|
|
properties.add(DiagnosticsProperty<TextSelection>('selection', selection));
|
|
properties.add(DiagnosticsProperty<ViewportOffset>('offset', offset));
|
|
}
|
|
|
|
@override
|
|
List<DiagnosticsNode> debugDescribeChildren() {
|
|
return <DiagnosticsNode>[
|
|
if (text != null)
|
|
text!.toDiagnosticsNode(
|
|
name: 'text',
|
|
style: DiagnosticsTreeStyle.transition,
|
|
),
|
|
];
|
|
}
|
|
}
|