diff --git a/README.md b/README.md
index f0a612889..2218ecd00 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,7 @@
## feat
-- [x] 动态/评论@用户
+- [x] 发布动态/评论支持`富文本编辑`/`表情显示`/`@用户`
- [x] 修改消息设置
- [x] 修改聊天设置
- [x] 展示折叠消息
diff --git a/lib/common/widgets/text_field/controller.dart b/lib/common/widgets/text_field/controller.dart
new file mode 100644
index 000000000..77bbfb3fb
--- /dev/null
+++ b/lib/common/widgets/text_field/controller.dart
@@ -0,0 +1,973 @@
+/*
+ * This file is part of PiliPlus
+ *
+ * PiliPlus is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * PiliPlus is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with PiliPlus. If not, see .
+ */
+
+import 'dart:math';
+
+import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
+import 'package:PiliPlus/models/common/image_type.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+///
+/// created by bggRGjQaUbCoE on 2025/6/27
+///
+
+enum RichTextType { text, composing, at, emoji }
+
+class Emote {
+ late String url;
+ late double width;
+ late double height;
+
+ Emote({
+ required this.url,
+ required this.width,
+ double? height,
+ }) : height = height ?? width;
+}
+
+mixin RichTextTypeMixin {
+ RichTextType get type;
+ Emote? get emote;
+ String? get uid;
+ String? get rawText;
+}
+
+extension TextEditingDeltaExt on TextEditingDelta {
+ ({RichTextType type, String? rawText, Emote? emote, String? uid}) get config {
+ if (this case RichTextTypeMixin e) {
+ return (type: e.type, rawText: e.rawText, emote: e.emote, uid: e.uid);
+ }
+ return (
+ type: composing.isValid ? RichTextType.composing : RichTextType.text,
+ rawText: null,
+ emote: null,
+ uid: null
+ );
+ }
+
+ bool get isText {
+ if (this case RichTextTypeMixin e) {
+ return e.type == RichTextType.text;
+ }
+ return !composing.isValid;
+ }
+
+ bool get isComposing {
+ return composing.isValid;
+ }
+}
+
+class RichTextEditingDeltaInsertion extends TextEditingDeltaInsertion
+ with RichTextTypeMixin {
+ RichTextEditingDeltaInsertion({
+ required super.oldText,
+ required super.textInserted,
+ required super.insertionOffset,
+ required super.selection,
+ required super.composing,
+ RichTextType? type,
+ this.emote,
+ this.uid,
+ this.rawText,
+ }) {
+ this.type = type ??
+ (composing.isValid ? RichTextType.composing : RichTextType.text);
+ }
+
+ @override
+ late final RichTextType type;
+
+ @override
+ final Emote? emote;
+
+ @override
+ final String? uid;
+
+ @override
+ final String? rawText;
+}
+
+class RichTextEditingDeltaReplacement extends TextEditingDeltaReplacement
+ with RichTextTypeMixin {
+ RichTextEditingDeltaReplacement({
+ required super.oldText,
+ required super.replacementText,
+ required super.replacedRange,
+ required super.selection,
+ required super.composing,
+ RichTextType? type,
+ this.emote,
+ this.uid,
+ this.rawText,
+ }) {
+ this.type = type ??
+ (composing.isValid ? RichTextType.composing : RichTextType.text);
+ }
+
+ @override
+ late final RichTextType type;
+
+ @override
+ final Emote? emote;
+
+ @override
+ final String? uid;
+
+ @override
+ final String? rawText;
+}
+
+class RichTextItem {
+ late RichTextType type;
+ late String text;
+ String? _rawText;
+ late TextRange range;
+ Emote? emote;
+ String? uid;
+
+ String get rawText => _rawText ?? text;
+
+ bool get isText => type == RichTextType.text;
+
+ bool get isComposing => type == RichTextType.composing;
+
+ bool get isRich => type == RichTextType.at || type == RichTextType.emoji;
+
+ RichTextItem({
+ this.type = RichTextType.text,
+ required this.text,
+ String? rawText,
+ required this.range,
+ this.emote,
+ this.uid,
+ }) {
+ _rawText = rawText;
+ }
+
+ RichTextItem.fromStart(
+ this.text, {
+ String? rawText,
+ this.type = RichTextType.text,
+ this.emote,
+ this.uid,
+ }) {
+ range = TextRange(start: 0, end: text.length);
+ _rawText = rawText;
+ }
+
+ List? onInsert(
+ TextEditingDeltaInsertion delta,
+ RichTextEditingController controller,
+ ) {
+ final int insertionOffset = delta.insertionOffset;
+
+ if (range.end < insertionOffset) {
+ return null;
+ }
+
+ if (insertionOffset == 0 && range.start == 0) {
+ final insertedLength = delta.textInserted.length;
+ controller.newSelection = TextSelection.collapsed(offset: insertedLength);
+ if (isText && delta.isText) {
+ text = delta.textInserted + text;
+ range = TextRange(start: range.start, end: range.start + text.length);
+ return null;
+ }
+ range = TextRange(
+ start: range.start + insertedLength,
+ end: range.end + insertedLength,
+ );
+ final config = delta.config;
+ final insertedItem = RichTextItem.fromStart(
+ delta.textInserted,
+ rawText: config.rawText,
+ type: config.type,
+ emote: config.emote,
+ uid: config.uid,
+ );
+ return [insertedItem];
+ }
+
+ if (range.start >= insertionOffset) {
+ final int insertedLength = delta.textInserted.length;
+ range = TextRange(
+ start: range.start + insertedLength,
+ end: range.end + insertedLength,
+ );
+ return null;
+ }
+
+ if (range.end == insertionOffset) {
+ final end = insertionOffset + delta.textInserted.length;
+ controller.newSelection = TextSelection.collapsed(offset: end);
+ if ((isText && delta.isText) || (isComposing && delta.isComposing)) {
+ text += delta.textInserted;
+ range = TextRange(start: range.start, end: end);
+ return null;
+ }
+ final config = delta.config;
+ final insertedItem = RichTextItem(
+ type: config.type,
+ emote: config.emote,
+ uid: config.uid,
+ text: delta.textInserted,
+ rawText: config.rawText,
+ range: TextRange(start: insertionOffset, end: end),
+ );
+ return [insertedItem];
+ }
+
+ if (isText &&
+ range.start < insertionOffset &&
+ range.end > insertionOffset) {
+ final leadingText = text.substring(0, insertionOffset - range.start);
+ final trailingString = text.substring(leadingText.length);
+ final insertEnd = insertionOffset + delta.textInserted.length;
+ controller.newSelection = TextSelection.collapsed(offset: insertEnd);
+ if (delta.isText) {
+ text = leadingText + delta.textInserted + trailingString;
+ range = TextRange(
+ start: range.start,
+ end: range.start + text.length,
+ );
+ return null;
+ }
+ final config = delta.config;
+ final insertedItem = RichTextItem(
+ type: config.type,
+ emote: config.emote,
+ uid: config.uid,
+ text: delta.textInserted,
+ rawText: config.rawText,
+ range: TextRange(start: insertionOffset, end: insertEnd),
+ );
+ final trailItem = RichTextItem(
+ text: trailingString,
+ range: TextRange(
+ start: insertEnd,
+ end: insertEnd + trailingString.length,
+ ),
+ );
+ text = leadingText;
+ range = TextRange(
+ start: range.start,
+ end: range.start + leadingText.length,
+ );
+ return [insertedItem, trailItem];
+ }
+
+ return null;
+ }
+
+ ({bool remove, bool cal})? onDelete(
+ TextEditingDeltaDeletion delta,
+ RichTextEditingController controller,
+ int? delLength,
+ ) {
+ final deletedRange = delta.deletedRange;
+
+ if (range.end <= deletedRange.start) {
+ return null;
+ }
+
+ if (range.start >= deletedRange.end) {
+ final length = delLength ?? delta.textDeleted.length;
+ range = TextRange(
+ start: range.start - length,
+ end: range.end - length,
+ );
+ return null;
+ }
+
+ if (range.start < deletedRange.start && range.end > deletedRange.end) {
+ if (isRich) {
+ controller.newSelection = TextSelection.collapsed(offset: range.start);
+ return (remove: true, cal: true);
+ }
+ text = text.replaceRange(
+ deletedRange.start - range.start,
+ deletedRange.end - range.start,
+ '',
+ );
+ range = TextRange(start: range.start, end: range.start + text.length);
+ controller.newSelection =
+ TextSelection.collapsed(offset: deletedRange.start);
+ return null;
+ }
+
+ if (range.start >= deletedRange.start && range.end <= deletedRange.end) {
+ if (range.start == deletedRange.start) {
+ controller.newSelection = TextSelection.collapsed(offset: range.start);
+ }
+ return (remove: true, cal: false);
+ }
+
+ if (range.start < deletedRange.start && range.end <= deletedRange.end) {
+ if (isRich) {
+ controller.newSelection = TextSelection.collapsed(offset: range.start);
+ return (remove: true, cal: true);
+ }
+ text = text.replaceRange(
+ text.length - (range.end - deletedRange.start),
+ null,
+ '',
+ );
+ range = TextRange(
+ start: range.start,
+ end: deletedRange.start,
+ );
+ controller.newSelection =
+ TextSelection.collapsed(offset: deletedRange.start);
+ return null;
+ }
+
+ if (range.start >= deletedRange.start && range.end > deletedRange.end) {
+ final start = min(deletedRange.start, range.start);
+ controller.newSelection = TextSelection.collapsed(offset: start);
+ if (isRich) {
+ return (remove: true, cal: true);
+ }
+ text = text.substring(deletedRange.end - range.start);
+ range = TextRange(
+ start: start,
+ end: start + text.length,
+ );
+ return null;
+ }
+
+ return null;
+ }
+
+ ({bool remove, List? toAdd})? onReplace(
+ TextEditingDeltaReplacement delta,
+ RichTextEditingController controller,
+ ) {
+ final replacedRange = delta.replacedRange;
+
+ if (range.end <= replacedRange.start) {
+ return null;
+ }
+
+ if (range.start >= replacedRange.end) {
+ final before = replacedRange.end - replacedRange.start;
+ final after = delta.replacementText.length;
+ final length = after - before;
+ range = TextRange(
+ start: range.start + length,
+ end: range.end + length,
+ );
+ return null;
+ }
+
+ if (range.start < replacedRange.start && range.end > replacedRange.end) {
+ if (isText) {
+ if (delta.isText) {
+ text = text.replaceRange(
+ replacedRange.start - range.start,
+ replacedRange.end - range.start,
+ delta.replacementText,
+ );
+ final end = range.start + text.length;
+ range = TextRange(start: range.start, end: end);
+ controller.newSelection = TextSelection.collapsed(
+ offset: replacedRange.start + delta.replacementText.length);
+ return null;
+ } else {
+ final leadingText =
+ text.substring(0, replacedRange.start - range.start);
+ final trailString = text.substring(replacedRange.end - range.start);
+ final insertEnd = replacedRange.start + delta.replacementText.length;
+ controller.newSelection = TextSelection.collapsed(offset: insertEnd);
+ final config = delta.config;
+ final insertedItem = RichTextItem(
+ type: config.type,
+ emote: config.emote,
+ uid: config.uid,
+ text: delta.replacementText,
+ rawText: config.rawText,
+ range: TextRange(
+ start: replacedRange.start,
+ end: insertEnd,
+ ),
+ );
+ final trailItem = RichTextItem(
+ text: trailString,
+ range: TextRange(
+ start: insertEnd,
+ end: insertEnd + trailString.length,
+ ),
+ );
+ text = leadingText;
+ range = TextRange(
+ start: range.start,
+ end: range.start + leadingText.length,
+ );
+ return (
+ remove: false,
+ toAdd: [insertedItem, trailItem],
+ );
+ }
+ }
+ final config = delta.config;
+ text = delta.replacementText;
+ type = config.type;
+ emote = config.emote;
+ uid = config.uid;
+ final end = range.start + text.length;
+ range = TextRange(start: range.start, end: end);
+ controller.newSelection = TextSelection.collapsed(offset: end);
+ return null;
+ }
+
+ if (range.start >= replacedRange.start && range.end <= replacedRange.end) {
+ if (range.start == replacedRange.start) {
+ text = delta.replacementText;
+ final config = delta.config;
+ _rawText = config.rawText;
+ type = config.type;
+ emote = config.emote;
+ uid = config.uid;
+ final end = range.start + text.length;
+ range = TextRange(start: range.start, end: end);
+ controller.newSelection = TextSelection.collapsed(offset: end);
+ return (remove: false, toAdd: null);
+ }
+ return (remove: true, toAdd: null);
+ }
+
+ if (range.start < replacedRange.start && range.end <= replacedRange.end) {
+ if (isText) {
+ if (delta.isText) {
+ text = text.replaceRange(
+ text.length - (range.end - replacedRange.start),
+ null,
+ delta.replacementText,
+ );
+ final end = range.start + text.length;
+ range = TextRange(start: range.start, end: end);
+ controller.newSelection = TextSelection.collapsed(offset: end);
+ return null;
+ } else {
+ text = text.replaceRange(
+ text.length - (range.end - replacedRange.start),
+ null,
+ '',
+ );
+ range = TextRange(start: range.start, end: range.start + text.length);
+ final end = replacedRange.start + delta.replacementText.length;
+ final config = delta.config;
+ final insertedItem = RichTextItem(
+ text: delta.replacementText,
+ rawText: config.rawText,
+ type: config.type,
+ emote: config.emote,
+ uid: config.uid,
+ range: TextRange(start: replacedRange.start, end: end),
+ );
+ controller.newSelection = TextSelection.collapsed(offset: end);
+ return (remove: false, toAdd: [insertedItem]);
+ }
+ }
+ text = delta.replacementText;
+ final config = delta.config;
+ type = config.type;
+ emote = config.emote;
+ uid = config.uid;
+ final end = range.start + text.length;
+ range = TextRange(start: range.start, end: end);
+ controller.newSelection = TextSelection.collapsed(offset: end);
+ return null;
+ }
+
+ if (range.start >= replacedRange.start && range.end > replacedRange.end) {
+ if (range.start > replacedRange.start) {
+ if (isText) {
+ text = text.substring(replacedRange.end - range.start);
+ final start = replacedRange.start + delta.replacementText.length;
+ range = TextRange(start: start, end: start + text.length);
+ return null;
+ }
+ return (remove: true, toAdd: null);
+ }
+ if (isText) {
+ if (delta.isText) {
+ text = text.replaceRange(
+ 0,
+ replacedRange.end - range.start,
+ delta.replacementText,
+ );
+ final end = range.start + text.length;
+ range = TextRange(start: range.start, end: end);
+ controller.newSelection = TextSelection.collapsed(offset: end);
+ return null;
+ } else {
+ final end = range.start + delta.replacementText.length;
+ final config = delta.config;
+ final insertedItem = RichTextItem(
+ text: delta.replacementText,
+ rawText: config.rawText,
+ type: config.type,
+ emote: config.emote,
+ uid: config.uid,
+ range: TextRange(start: range.start, end: end),
+ );
+ controller.newSelection = TextSelection.collapsed(offset: end);
+ text = text.substring(replacedRange.end - range.start);
+ range = TextRange(start: end, end: end + text.length);
+ return (remove: true, toAdd: [insertedItem]);
+ }
+ }
+ text = delta.replacementText;
+ final config = delta.config;
+ type = config.type;
+ emote = config.emote;
+ uid = config.uid;
+ final end = range.start + text.length;
+ range = TextRange(start: range.start, end: end);
+ controller.newSelection = TextSelection.collapsed(offset: end);
+ return null;
+ }
+
+ return null;
+ }
+
+ @override
+ String toString() {
+ return '\ntype: [${type.name}],'
+ 'text: [$text],'
+ 'rawText: [$_rawText],'
+ '\nrange: [$range]\n';
+ }
+}
+
+class RichTextEditingController extends TextEditingController {
+ RichTextEditingController({
+ List? items,
+ this.onMention,
+ }) : super(
+ text: items != null && items.isNotEmpty
+ ? (StringBuffer()..writeAll(items.map((e) => e.text))).toString()
+ : null,
+ ) {
+ if (items != null && items.isNotEmpty) {
+ this.items.addAll(items);
+ }
+ }
+
+ final VoidCallback? onMention;
+
+ TextSelection newSelection = const TextSelection.collapsed(offset: 0);
+
+ final List items = [];
+
+ String get plainText {
+ if (items.isEmpty) {
+ return '';
+ }
+ final buffer = StringBuffer();
+ for (var e in items) {
+ buffer.write(e.text);
+ }
+ return buffer.toString();
+ }
+
+ String get rawText {
+ if (items.isEmpty) {
+ return '';
+ }
+ final buffer = StringBuffer();
+ for (var e in items) {
+ if (e.type == RichTextType.at) {
+ buffer.write(e.text);
+ } else {
+ buffer.write(e.rawText);
+ }
+ }
+ return buffer.toString();
+ }
+
+ void syncRichText(TextEditingDelta delta) {
+ if (text.isEmpty) {
+ items.clear();
+ }
+
+ int? addIndex;
+ List? toAdd;
+
+ int? delLength;
+ List? toDel;
+
+ switch (delta) {
+ case TextEditingDeltaInsertion e:
+ if (e.textInserted == '@') {
+ onMention?.call();
+ }
+ if (items.isEmpty) {
+ final config = delta.config;
+ items.add(
+ RichTextItem.fromStart(
+ delta.textInserted,
+ rawText: config.rawText,
+ type: config.type,
+ emote: config.emote,
+ uid: config.uid,
+ ),
+ );
+ newSelection =
+ TextSelection.collapsed(offset: delta.textInserted.length);
+ return;
+ }
+ for (int index = 0; index < items.length; index++) {
+ List? newItems = items[index].onInsert(e, this);
+ if (newItems != null) {
+ addIndex = (e.insertionOffset == 0 && index == 0) ? 0 : index + 1;
+ toAdd = newItems;
+ }
+ }
+
+ case TextEditingDeltaDeletion e:
+ for (int index = 0; index < items.length; index++) {
+ final item = items[index];
+ ({bool remove, bool cal})? res = item.onDelete(e, this, delLength);
+ if (res != null) {
+ if (res.remove) {
+ (toDel ??= []).add(item);
+ }
+ if (res.cal) {
+ delLength ??= item.text.length;
+ }
+ }
+ }
+
+ case TextEditingDeltaReplacement e:
+ for (int index = 0; index < items.length; index++) {
+ final item = items[index];
+ ({bool remove, List? toAdd})? res =
+ item.onReplace(e, this);
+ if (res != null) {
+ if (res.toAdd != null) {
+ addIndex = res.remove
+ ? index
+ : (e.replacedRange.start == 0 && index == 0)
+ ? 0
+ : index + 1;
+ (toAdd ??= []).addAll(res.toAdd!);
+ } else if (res.remove) {
+ (toDel ??= []).add(item);
+ }
+ }
+ }
+
+ case TextEditingDeltaNonTextUpdate e:
+ newSelection = e.selection;
+ if (newSelection.isCollapsed) {
+ final newPos = dragOffset(newSelection.base);
+ newSelection = newSelection.copyWith(
+ baseOffset: newPos.offset, extentOffset: newPos.offset);
+ } else {
+ final isNormalized =
+ newSelection.baseOffset < newSelection.extentOffset;
+ var startOffset = newSelection.start;
+ var endOffset = newSelection.end;
+ final newOffset = longPressOffset(startOffset, endOffset);
+ startOffset = newOffset.startOffset;
+ endOffset = newOffset.endOffset;
+ newSelection = newSelection.copyWith(
+ baseOffset: isNormalized ? startOffset : endOffset,
+ extentOffset: isNormalized ? endOffset : startOffset,
+ );
+ }
+ }
+
+ if (addIndex != null && toAdd?.isNotEmpty == true) {
+ items.insertAll(addIndex, toAdd!);
+ }
+ if (toDel?.isNotEmpty == true) {
+ for (var item in toDel!) {
+ items.remove(item);
+ }
+ }
+ }
+
+ TextStyle? composingStyle;
+ TextStyle? richStyle;
+
+ @override
+ TextSpan buildTextSpan({
+ required BuildContext context,
+ TextStyle? style,
+ required bool withComposing,
+ }) {
+ assert(
+ !value.composing.isValid || !withComposing || value.isComposingRangeValid,
+ );
+
+ final bool composingRegionOutOfRange =
+ !value.isComposingRangeValid || !withComposing;
+
+ // if (composingRegionOutOfRange) {
+ // return TextSpan(style: style, text: text);
+ // }
+
+ // debugPrint('$items,,\n$selection');
+
+ return TextSpan(
+ style: style,
+ children: items.map((e) {
+ switch (e.type) {
+ case RichTextType.text:
+ return TextSpan(text: e.text);
+ case RichTextType.composing:
+ composingStyle ??= style?.merge(
+ const TextStyle(decoration: TextDecoration.underline)) ??
+ const TextStyle(decoration: TextDecoration.underline);
+ if (composingRegionOutOfRange) {
+ e.type = RichTextType.text;
+ }
+ return TextSpan(
+ text: e.text,
+ style: composingRegionOutOfRange ? null : composingStyle,
+ );
+ case RichTextType.at:
+ richStyle ??= (style ?? const TextStyle())
+ .copyWith(color: Theme.of(context).colorScheme.primary);
+ return TextSpan(
+ text: e.text,
+ style: richStyle,
+ );
+ case RichTextType.emoji:
+ final emote = e.emote;
+ if (emote != null) {
+ return WidgetSpan(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 2),
+ child: NetworkImgLayer(
+ src: emote.url,
+ width: 22, // emote.width,
+ height: 22, // emote.height,
+ type: ImageType.emote,
+ boxFit: BoxFit.contain,
+ ),
+ ),
+ );
+ }
+ return TextSpan(text: e.text);
+ }
+ }).toList(),
+ );
+
+ // final TextStyle composingStyle =
+ // style?.merge(const TextStyle(decoration: TextDecoration.underline)) ??
+ // const TextStyle(decoration: TextDecoration.underline);
+ // return TextSpan(
+ // style: style,
+ // children: [
+ // TextSpan(text: value.composing.textBefore(value.text)),
+ // TextSpan(
+ // style: composingStyle,
+ // text: value.composing.textInside(value.text)),
+ // TextSpan(text: value.composing.textAfter(value.text)),
+ // ],
+ // );
+ }
+
+ @override
+ void clear() {
+ items.clear();
+ super.clear();
+ }
+
+ @override
+ void dispose() {
+ items.clear();
+ super.dispose();
+ }
+
+ TextPosition dragOffset(TextPosition position) {
+ final offset = position.offset;
+ for (var e in items) {
+ final range = e.range;
+ if (offset >= range.end) {
+ continue;
+ }
+ if (offset <= range.start) {
+ break;
+ }
+ if (e.isRich) {
+ if (offset * 2 > range.start + range.end) {
+ return TextPosition(offset: range.end);
+ } else {
+ return TextPosition(offset: range.start);
+ }
+ }
+ }
+ return position;
+ }
+
+ int tapOffset(
+ int offset, {
+ required TextPainter textPainter,
+ required Offset localPos,
+ required Offset lastTapDownPosition,
+ }) {
+ for (var e in items) {
+ final range = e.range;
+ if (offset >= range.end) {
+ continue;
+ }
+ if (offset < range.start) {
+ break;
+ }
+ // emoji tap
+ if (offset == range.start) {
+ if (e.emote != null) {
+ final cloestOffset = textPainter.getClosestGlyphForOffset(localPos);
+ if (cloestOffset != null) {
+ final offsetRect = cloestOffset.graphemeClusterLayoutBounds;
+ final offsetRange = cloestOffset.graphemeClusterCodeUnitRange;
+ if (lastTapDownPosition.dx > offsetRect.right) {
+ return offsetRange.end;
+ } else {
+ return offsetRange.start;
+ }
+ }
+ }
+ } else {
+ if (e.isRich) {
+ if (offset * 2 > range.start + range.end) {
+ return range.end;
+ } else {
+ return range.start;
+ }
+ }
+ }
+ }
+ return offset;
+ }
+
+ ({int startOffset, int endOffset}) longPressOffset(
+ int startOffset,
+ int endOffset,
+ ) {
+ for (var e in items) {
+ final range = e.range;
+ if (startOffset >= range.end) {
+ continue;
+ }
+ if (endOffset <= range.start) {
+ break;
+ }
+ late final cal = range.start + range.end;
+ if (startOffset > range.start && startOffset < range.end) {
+ if (e.isRich) {
+ if (startOffset * 2 > cal) {
+ startOffset = range.end;
+ } else {
+ startOffset = range.start;
+ }
+ }
+ }
+ if (endOffset > range.start && endOffset < range.end) {
+ if (e.isRich) {
+ if (endOffset * 2 > cal) {
+ endOffset = range.end;
+ } else {
+ endOffset = range.start;
+ }
+ }
+ }
+ }
+ return (startOffset: startOffset, endOffset: endOffset);
+ }
+
+ TextSelection keyboardOffset(TextSelection newSelection) {
+ final offset = newSelection.baseOffset;
+ for (var e in items) {
+ final range = e.range;
+ if (offset >= range.end) {
+ continue;
+ }
+ if (offset <= range.start) {
+ break;
+ }
+ if (offset > range.start && offset < range.end) {
+ if (e.isRich) {
+ if (offset < value.selection.baseOffset) {
+ return newSelection.copyWith(
+ baseOffset: range.start, extentOffset: range.start);
+ } else {
+ return newSelection.copyWith(
+ baseOffset: range.end, extentOffset: range.end);
+ }
+ }
+ }
+ }
+ return newSelection;
+ }
+
+ TextSelection keyboardOffsets(TextSelection newSelection) {
+ final startOffset = newSelection.start;
+ final endOffset = newSelection.end;
+ final isNormalized = newSelection.baseOffset < newSelection.extentOffset;
+ for (var e in items) {
+ final range = e.range;
+ if (startOffset >= range.end) {
+ continue;
+ }
+ if (endOffset <= range.start) {
+ break;
+ }
+ if (isNormalized) {
+ if (startOffset <= range.start &&
+ endOffset > range.start &&
+ endOffset < range.end) {
+ if (e.isRich) {
+ if (endOffset < selection.extentOffset) {
+ return newSelection.copyWith(
+ baseOffset: startOffset,
+ extentOffset: range.start,
+ );
+ } else {
+ return newSelection.copyWith(
+ baseOffset: startOffset,
+ extentOffset: range.end,
+ );
+ }
+ }
+ }
+ } else {
+ if (startOffset < range.end && startOffset > range.start) {
+ if (e.isRich) {
+ if (startOffset > selection.extentOffset) {
+ return newSelection.copyWith(
+ baseOffset: endOffset,
+ extentOffset: range.end,
+ );
+ } else {
+ return newSelection.copyWith(
+ baseOffset: endOffset,
+ extentOffset: range.start,
+ );
+ }
+ }
+ }
+ }
+ }
+ return newSelection;
+ }
+}
diff --git a/lib/common/widgets/text_field/cupertino/cupertino_text_field.dart b/lib/common/widgets/text_field/cupertino/cupertino_text_field.dart
index 4780715d1..250639311 100644
--- a/lib/common/widgets/text_field/cupertino/cupertino_text_field.dart
+++ b/lib/common/widgets/text_field/cupertino/cupertino_text_field.dart
@@ -7,6 +7,7 @@ library;
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle;
+import 'package:PiliPlus/common/widgets/text_field/controller.dart';
import 'package:PiliPlus/common/widgets/text_field/cupertino/cupertino_adaptive_text_selection_toolbar.dart';
import 'package:PiliPlus/common/widgets/text_field/cupertino/cupertino_spell_check_suggestions_toolbar.dart';
import 'package:PiliPlus/common/widgets/text_field/editable_text.dart';
@@ -23,7 +24,8 @@ import 'package:flutter/cupertino.dart'
CupertinoSpellCheckSuggestionsToolbar,
CupertinoAdaptiveTextSelectionToolbar,
TextSelectionGestureDetectorBuilderDelegate,
- TextSelectionGestureDetectorBuilder;
+ TextSelectionGestureDetectorBuilder,
+ TextSelectionOverlay;
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
@@ -114,11 +116,11 @@ enum OverlayVisibilityMode {
class _CupertinoTextFieldSelectionGestureDetectorBuilder
extends TextSelectionGestureDetectorBuilder {
_CupertinoTextFieldSelectionGestureDetectorBuilder(
- {required _CupertinoTextFieldState state})
+ {required _CupertinoRichTextFieldState state})
: _state = state,
super(delegate: state);
- final _CupertinoTextFieldState _state;
+ final _CupertinoRichTextFieldState _state;
@override
void onSingleTapUp(TapDragUpDetails details) {
@@ -162,7 +164,7 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder
/// {@macro flutter.widgets.EditableText.onChanged}
///
/// {@tool dartpad}
-/// This example shows how to set the initial value of the [CupertinoTextField] using
+/// This example shows how to set the initial value of the [CupertinoRichTextField] using
/// a [controller] that already contains some text.
///
/// ** See code in examples/api/lib/cupertino/text_field/cupertino_text_field.0.dart **
@@ -177,18 +179,18 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder
///
/// {@macro flutter.material.textfield.wantKeepAlive}
///
-/// Remember to call [TextEditingController.dispose] when it is no longer
+/// Remember to call [RichTextEditingController.dispose] when it is no longer
/// needed. This will ensure we discard any resources used by the object.
///
/// {@macro flutter.widgets.editableText.showCaretOnScreen}
///
/// ## Scrolling Considerations
///
-/// If this [CupertinoTextField] is not a descendant of [Scaffold] and is being
+/// If this [CupertinoRichTextField] is not a descendant of [Scaffold] and is being
/// used within a [Scrollable] or nested [Scrollable]s, consider placing a
/// [ScrollNotificationObserver] above the root [Scrollable] that contains this
-/// [CupertinoTextField] to ensure proper scroll coordination for
-/// [CupertinoTextField] and its components like [TextSelectionOverlay].
+/// [CupertinoRichTextField] to ensure proper scroll coordination for
+/// [CupertinoRichTextField] and its components like [TextSelectionOverlay].
///
/// See also:
///
@@ -197,12 +199,12 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder
/// Design UI conventions.
/// * [EditableText], which is the raw text editing control at the heart of a
/// [TextField].
-/// * Learn how to use a [TextEditingController] in one of our [cookbook recipes](https://docs.flutter.dev/cookbook/forms/text-field-changes#2-use-a-texteditingcontroller).
+/// * Learn how to use a [RichTextEditingController] in one of our [cookbook recipes](https://docs.flutter.dev/cookbook/forms/text-field-changes#2-use-a-texteditingcontroller).
/// *
-class CupertinoTextField extends StatefulWidget {
+class CupertinoRichTextField extends StatefulWidget {
/// Creates an iOS-style text field.
///
- /// To provide a prefilled text entry, pass in a [TextEditingController] with
+ /// To provide a prefilled text entry, pass in a [RichTextEditingController] with
/// an initial value to the [controller] parameter.
///
/// To provide a hint placeholder text that appears when the text entry is
@@ -238,10 +240,10 @@ class CupertinoTextField extends StatefulWidget {
/// * [expands], to allow the widget to size itself to its parent's height.
/// * [maxLength], which discusses the precise meaning of "number of
/// characters" and how it may differ from the intuitive meaning.
- const CupertinoTextField({
+ const CupertinoRichTextField({
super.key,
this.groupId = EditableText,
- this.controller,
+ required this.controller,
this.focusNode,
this.undoController,
this.decoration = _kDefaultRoundedBorderDecoration,
@@ -354,7 +356,7 @@ class CupertinoTextField extends StatefulWidget {
/// Creates a borderless iOS-style text field.
///
- /// To provide a prefilled text entry, pass in a [TextEditingController] with
+ /// To provide a prefilled text entry, pass in a [RichTextEditingController] with
/// an initial value to the [controller] parameter.
///
/// To provide a hint placeholder text that appears when the text entry is
@@ -382,10 +384,10 @@ class CupertinoTextField extends StatefulWidget {
/// * [expands], to allow the widget to size itself to its parent's height.
/// * [maxLength], which discusses the precise meaning of "number of
/// characters" and how it may differ from the intuitive meaning.
- const CupertinoTextField.borderless({
+ const CupertinoRichTextField.borderless({
super.key,
this.groupId = EditableText,
- this.controller,
+ required this.controller,
this.focusNode,
this.undoController,
this.decoration,
@@ -497,8 +499,8 @@ class CupertinoTextField extends StatefulWidget {
/// Controls the text being edited.
///
- /// If null, this widget will create its own [TextEditingController].
- final TextEditingController? controller;
+ /// If null, this widget will create its own [RichTextEditingController].
+ final RichTextEditingController controller;
/// {@macro flutter.widgets.Focus.focusNode}
final FocusNode? focusNode;
@@ -567,7 +569,7 @@ class CupertinoTextField extends StatefulWidget {
/// Show an iOS-style clear button to clear the current text entry.
///
/// Can be made to appear depending on various text states of the
- /// [TextEditingController].
+ /// [RichTextEditingController].
///
/// Will only appear if no [suffix] widget is appearing.
///
@@ -882,11 +884,11 @@ class CupertinoTextField extends StatefulWidget {
///
/// See also:
/// * [spellCheckConfiguration], where this is typically specified for
- /// [CupertinoTextField].
+ /// [CupertinoRichTextField].
/// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the
- /// parameter for which this is the default value for [CupertinoTextField].
+ /// parameter for which this is the default value for [CupertinoRichTextField].
/// * [TextField.defaultSpellCheckSuggestionsToolbarBuilder], which is like
- /// this but specifies the default for [CupertinoTextField].
+ /// this but specifies the default for [CupertinoRichTextField].
@visibleForTesting
static Widget defaultSpellCheckSuggestionsToolbarBuilder(
BuildContext context,
@@ -900,13 +902,13 @@ class CupertinoTextField extends StatefulWidget {
final UndoHistoryController? undoController;
@override
- State createState() => _CupertinoTextFieldState();
+ State createState() => _CupertinoRichTextFieldState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(
- DiagnosticsProperty('controller', controller,
+ DiagnosticsProperty('controller', controller,
defaultValue: null),
);
properties.add(DiagnosticsProperty('focusNode', focusNode,
@@ -1111,24 +1113,24 @@ class CupertinoTextField extends StatefulWidget {
return configuration.copyWith(
misspelledTextStyle: configuration.misspelledTextStyle ??
- CupertinoTextField.cupertinoMisspelledTextStyle,
+ CupertinoRichTextField.cupertinoMisspelledTextStyle,
misspelledSelectionColor: configuration.misspelledSelectionColor ??
- CupertinoTextField.kMisspelledSelectionColor,
+ CupertinoRichTextField.kMisspelledSelectionColor,
spellCheckSuggestionsToolbarBuilder:
configuration.spellCheckSuggestionsToolbarBuilder ??
- CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder,
+ CupertinoRichTextField.defaultSpellCheckSuggestionsToolbarBuilder,
);
}
}
-class _CupertinoTextFieldState extends State
- with RestorationMixin, AutomaticKeepAliveClientMixin
+class _CupertinoRichTextFieldState extends State
+ with RestorationMixin, AutomaticKeepAliveClientMixin
implements TextSelectionGestureDetectorBuilderDelegate, AutofillClient {
final GlobalKey _clearGlobalKey = GlobalKey();
- RestorableTextEditingController? _controller;
- TextEditingController get _effectiveController =>
- widget.controller ?? _controller!.value;
+ // RestorableRichTextEditingController? _controller;
+ RichTextEditingController get _effectiveController => widget.controller;
+ // widget.controller ?? _controller!.value;
FocusNode? _focusNode;
FocusNode get _effectiveFocusNode =>
@@ -1162,23 +1164,23 @@ class _CupertinoTextFieldState extends State
_CupertinoTextFieldSelectionGestureDetectorBuilder(
state: this,
);
- if (widget.controller == null) {
- _createLocalController();
- }
+ // if (widget.controller == null) {
+ // _createLocalController();
+ // }
_effectiveFocusNode.canRequestFocus = widget.enabled;
_effectiveFocusNode.addListener(_handleFocusChanged);
}
@override
- void didUpdateWidget(CupertinoTextField oldWidget) {
+ void didUpdateWidget(CupertinoRichTextField oldWidget) {
super.didUpdateWidget(oldWidget);
- if (widget.controller == null && oldWidget.controller != null) {
- _createLocalController(oldWidget.controller!.value);
- } else if (widget.controller != null && oldWidget.controller == null) {
- unregisterFromRestoration(_controller!);
- _controller!.dispose();
- _controller = null;
- }
+ // if (widget.controller == null && oldWidget.controller != null) {
+ // _createLocalController(oldWidget.controller!.value);
+ // } else if (widget.controller != null && oldWidget.controller == null) {
+ // unregisterFromRestoration(_controller!);
+ // _controller!.dispose();
+ // _controller = null;
+ // }
if (widget.focusNode != oldWidget.focusNode) {
(oldWidget.focusNode ?? _focusNode)?.removeListener(_handleFocusChanged);
@@ -1189,26 +1191,26 @@ class _CupertinoTextFieldState extends State
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
- if (_controller != null) {
- _registerController();
- }
+ // if (_controller != null) {
+ // _registerController();
+ // }
}
- void _registerController() {
- assert(_controller != null);
- registerForRestoration(_controller!, 'controller');
- _controller!.value.addListener(updateKeepAlive);
- }
+ // void _registerController() {
+ // assert(_controller != null);
+ // registerForRestoration(_controller!, 'controller');
+ // _controller!.value.addListener(updateKeepAlive);
+ // }
- void _createLocalController([TextEditingValue? value]) {
- assert(_controller == null);
- _controller = value == null
- ? RestorableTextEditingController()
- : RestorableTextEditingController.fromValue(value);
- if (!restorePending) {
- _registerController();
- }
- }
+ // void _createLocalController([TextEditingValue? value]) {
+ // assert(_controller == null);
+ // _controller = value == null
+ // ? RestorableRichTextEditingController()
+ // : RestorableRichTextEditingController.fromValue(value);
+ // if (!restorePending) {
+ // _registerController();
+ // }
+ // }
@override
String? get restorationId => widget.restorationId;
@@ -1217,7 +1219,7 @@ class _CupertinoTextFieldState extends State
void dispose() {
_effectiveFocusNode.removeListener(_handleFocusChanged);
_focusNode?.dispose();
- _controller?.dispose();
+ // _controller?.dispose();
super.dispose();
}
@@ -1297,7 +1299,7 @@ class _CupertinoTextFieldState extends State
}
@override
- bool get wantKeepAlive => _controller?.value.text.isNotEmpty ?? false;
+ bool get wantKeepAlive => _effectiveController.value.text.isNotEmpty;
static bool _shouldShowAttachment({
required OverlayVisibilityMode attachment,
@@ -1489,7 +1491,7 @@ class _CupertinoTextFieldState extends State
Widget build(BuildContext context) {
super.build(context); // See AutomaticKeepAliveClientMixin.
assert(debugCheckHasDirectionality(context));
- final TextEditingController controller = _effectiveController;
+ final RichTextEditingController controller = _effectiveController;
TextSelectionControls? textSelectionControls = widget.selectionControls;
VoidCallback? handleDidGainAccessibilityFocus;
@@ -1608,7 +1610,7 @@ class _CupertinoTextFieldState extends State
// ensure that configuration uses Cupertino text style for misspelled words
// unless a custom style is specified.
final SpellCheckConfiguration spellCheckConfiguration =
- CupertinoTextField.inferIOSSpellCheckConfiguration(
+ CupertinoRichTextField.inferIOSSpellCheckConfiguration(
widget.spellCheckConfiguration);
final Widget paddedEditable = Padding(
@@ -1643,7 +1645,7 @@ class _CupertinoTextFieldState extends State
minLines: widget.minLines,
expands: widget.expands,
magnifierConfiguration: widget.magnifierConfiguration ??
- CupertinoTextField._iosMagnifierConfiguration,
+ CupertinoRichTextField._iosMagnifierConfiguration,
// Only show the selection highlight when the text field is focused.
selectionColor:
_effectiveFocusNode.hasFocus ? selectionColor : null,
diff --git a/lib/common/widgets/text_field/editable.dart b/lib/common/widgets/text_field/editable.dart
new file mode 100644
index 000000000..b750b443b
--- /dev/null
+++ b/lib/common/widgets/text_field/editable.dart
@@ -0,0 +1,3376 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/// @docImport 'package:flutter/cupertino.dart';
+library;
+
+import 'dart:collection';
+import 'dart:math' as math;
+import 'dart:ui' as ui
+ show
+ BoxHeightStyle,
+ BoxWidthStyle,
+ LineMetrics,
+ SemanticsInputType,
+ TextBox;
+
+import 'package:PiliPlus/common/widgets/text_field/controller.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
+
+const double _kCaretGap = 1.0; // pixels
+const double _kCaretHeightOffset = 2.0; // pixels
+
+// The additional size on the x and y axis with which to expand the prototype
+// cursor to render the floating cursor in pixels.
+const EdgeInsets _kFloatingCursorSizeIncrease = EdgeInsets.symmetric(
+ horizontal: 0.5,
+ vertical: 1.0,
+);
+
+// The corner radius of the floating cursor in pixels.
+const Radius _kFloatingCursorRadius = Radius.circular(1.0);
+
+// This constant represents the shortest squared distance required between the floating cursor
+// and the regular cursor when both are present in the text field.
+// If the squared distance between the two cursors is less than this value,
+// it's not necessary to display both cursors at the same time.
+// This behavior is consistent with the one observed in iOS UITextField.
+const double _kShortestDistanceSquaredWithFloatingAndRegularCursors =
+ 15.0 * 15.0;
+
+/// The consecutive sequence of [TextPosition]s that the caret should move to
+/// when the user navigates the paragraph using the upward arrow key or the
+/// downward arrow key.
+///
+/// {@template flutter.rendering.RenderEditable.verticalArrowKeyMovement}
+/// When the user presses the upward arrow key or the downward arrow key, on
+/// many platforms (macOS for instance), the caret will move to the previous
+/// line or the next line, while maintaining its original horizontal location.
+/// When it encounters a shorter line, the caret moves to the closest horizontal
+/// location within that line, and restores the original horizontal location
+/// when a long enough line is encountered.
+///
+/// Additionally, the caret will move to the beginning of the document if the
+/// upward arrow key is pressed and the caret is already on the first line. If
+/// the downward arrow key is pressed next, the caret will restore its original
+/// horizontal location and move to the second line. Similarly the caret moves
+/// to the end of the document if the downward arrow key is pressed when it's
+/// already on the last line.
+///
+/// Consider a left-aligned paragraph:
+/// aa|
+/// a
+/// aaa
+/// where the caret was initially placed at the end of the first line. Pressing
+/// the downward arrow key once will move the caret to the end of the second
+/// line, and twice the arrow key moves to the third line after the second "a"
+/// on that line. Pressing the downward arrow key again, the caret will move to
+/// the end of the third line (the end of the document). Pressing the upward
+/// arrow key in this state will result in the caret moving to the end of the
+/// second line.
+///
+/// Vertical caret runs are typically interrupted when the layout of the text
+/// changes (including when the text itself changes), or when the selection is
+/// changed by other input events or programmatically (for example, when the
+/// user pressed the left arrow key).
+/// {@endtemplate}
+///
+/// The [movePrevious] method moves the caret location (which is
+/// [VerticalCaretMovementRun.current]) to the previous line, and in case
+/// the caret is already on the first line, the method does nothing and returns
+/// false. Similarly the [moveNext] method moves the caret to the next line, and
+/// returns false if the caret is already on the last line.
+///
+/// The [moveByOffset] method takes a pixel offset from the current position to move
+/// the caret up or down.
+///
+/// If the underlying paragraph's layout changes, [isValid] becomes false and
+/// the [VerticalCaretMovementRun] must not be used. The [isValid] property must
+/// be checked before calling [movePrevious], [moveNext] and [moveByOffset],
+/// or accessing [current].
+class VerticalCaretMovementRun implements Iterator {
+ VerticalCaretMovementRun._(
+ this._editable,
+ this._lineMetrics,
+ this._currentTextPosition,
+ this._currentLine,
+ this._currentOffset,
+ );
+
+ Offset _currentOffset;
+ int _currentLine;
+ TextPosition _currentTextPosition;
+
+ final List _lineMetrics;
+ final RenderEditable _editable;
+
+ bool _isValid = true;
+
+ /// Whether this [VerticalCaretMovementRun] can still continue.
+ ///
+ /// A [VerticalCaretMovementRun] run is valid if the underlying text layout
+ /// hasn't changed.
+ ///
+ /// The [current] value and the [movePrevious], [moveNext] and [moveByOffset]
+ /// methods must not be accessed when [isValid] is false.
+ bool get isValid {
+ if (!_isValid) {
+ return false;
+ }
+ final List newLineMetrics =
+ _editable._textPainter.computeLineMetrics();
+ // Use the implementation detail of the computeLineMetrics method to figure
+ // out if the current text layout has been invalidated.
+ if (!identical(newLineMetrics, _lineMetrics)) {
+ _isValid = false;
+ }
+ return _isValid;
+ }
+
+ final Map> _positionCache =
+ >{};
+
+ MapEntry _getTextPositionForLine(int lineNumber) {
+ assert(isValid);
+ assert(lineNumber >= 0);
+ final MapEntry? cachedPosition =
+ _positionCache[lineNumber];
+ if (cachedPosition != null) {
+ return cachedPosition;
+ }
+ assert(lineNumber != _currentLine);
+
+ final Offset newOffset = Offset(
+ _currentOffset.dx,
+ _lineMetrics[lineNumber].baseline,
+ );
+ final TextPosition closestPosition =
+ _editable._textPainter.getPositionForOffset(newOffset);
+ final MapEntry position =
+ MapEntry(newOffset, closestPosition);
+ _positionCache[lineNumber] = position;
+ return position;
+ }
+
+ @override
+ TextPosition get current {
+ assert(isValid);
+ return _currentTextPosition;
+ }
+
+ @override
+ bool moveNext() {
+ assert(isValid);
+ if (_currentLine + 1 >= _lineMetrics.length) {
+ return false;
+ }
+ final MapEntry position = _getTextPositionForLine(
+ _currentLine + 1,
+ );
+ _currentLine += 1;
+ _currentOffset = position.key;
+ _currentTextPosition = position.value;
+ return true;
+ }
+
+ /// Move back to the previous element.
+ ///
+ /// Returns true and updates [current] if successful.
+ bool movePrevious() {
+ assert(isValid);
+ if (_currentLine <= 0) {
+ return false;
+ }
+ final MapEntry position = _getTextPositionForLine(
+ _currentLine - 1,
+ );
+ _currentLine -= 1;
+ _currentOffset = position.key;
+ _currentTextPosition = position.value;
+ return true;
+ }
+
+ /// Move forward or backward by a number of elements determined
+ /// by pixel [offset].
+ ///
+ /// If [offset] is negative, move backward; otherwise move forward.
+ ///
+ /// Returns true and updates [current] if successful.
+ bool moveByOffset(double offset) {
+ final Offset initialOffset = _currentOffset;
+ if (offset >= 0.0) {
+ while (_currentOffset.dy < initialOffset.dy + offset) {
+ if (!moveNext()) {
+ break;
+ }
+ }
+ } else {
+ while (_currentOffset.dy > initialOffset.dy + offset) {
+ if (!movePrevious()) {
+ break;
+ }
+ }
+ }
+ return initialOffset != _currentOffset;
+ }
+}
+
+/// Displays some text in a scrollable container with a potentially blinking
+/// cursor and with gesture recognizers.
+///
+/// This is the renderer for an editable text field. It does not directly
+/// provide affordances for editing the text, but it does handle text selection
+/// and manipulation of the text cursor.
+///
+/// The [text] is displayed, scrolled by the given [offset], aligned according
+/// to [textAlign]. The [maxLines] property controls whether the text displays
+/// on one line or many. The [selection], if it is not collapsed, is painted in
+/// the [selectionColor]. If it _is_ collapsed, then it represents the cursor
+/// position. The cursor is shown while [showCursor] is true. It is painted in
+/// the [cursorColor].
+///
+/// Keyboard handling, IME handling, scrolling, toggling the [showCursor] value
+/// to actually blink the cursor, and other features not mentioned above are the
+/// responsibility of higher layers and not handled by this object.
+class RenderEditable extends RenderBox
+ with
+ RelayoutWhenSystemFontsChangeMixin,
+ ContainerRenderObjectMixin,
+ RenderInlineChildrenContainerDefaults
+ implements TextLayoutMetrics {
+ /// Creates a render object that implements the visual aspects of a text field.
+ ///
+ /// The [textAlign] argument defaults to [TextAlign.start].
+ ///
+ /// If [showCursor] is not specified, then it defaults to hiding the cursor.
+ ///
+ /// The [maxLines] property can be set to null to remove the restriction on
+ /// the number of lines. By default, it is 1, meaning this is a single-line
+ /// text field. If it is not null, it must be greater than zero.
+ ///
+ /// Use [ViewportOffset.zero] for the [offset] if there is no need for
+ /// scrolling.
+ RenderEditable({
+ InlineSpan? text,
+ required TextDirection textDirection,
+ TextAlign textAlign = TextAlign.start,
+ Color? cursorColor,
+ Color? backgroundCursorColor,
+ ValueNotifier? showCursor,
+ bool? hasFocus,
+ required LayerLink startHandleLayerLink,
+ required LayerLink endHandleLayerLink,
+ int? maxLines = 1,
+ int? minLines,
+ bool expands = false,
+ StrutStyle? strutStyle,
+ Color? selectionColor,
+ @Deprecated(
+ 'Use textScaler instead. '
+ 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. '
+ 'This feature was deprecated after v3.12.0-2.0.pre.',
+ )
+ double textScaleFactor = 1.0,
+ TextScaler textScaler = TextScaler.noScaling,
+ TextSelection? selection,
+ required ViewportOffset offset,
+ this.ignorePointer = false,
+ bool readOnly = false,
+ bool forceLine = true,
+ TextHeightBehavior? textHeightBehavior,
+ TextWidthBasis textWidthBasis = TextWidthBasis.parent,
+ String obscuringCharacter = '•',
+ bool obscureText = false,
+ Locale? locale,
+ double cursorWidth = 1.0,
+ double? cursorHeight,
+ Radius? cursorRadius,
+ bool paintCursorAboveText = false,
+ Offset cursorOffset = Offset.zero,
+ double devicePixelRatio = 1.0,
+ ui.BoxHeightStyle selectionHeightStyle = ui.BoxHeightStyle.tight,
+ ui.BoxWidthStyle selectionWidthStyle = ui.BoxWidthStyle.tight,
+ bool? enableInteractiveSelection,
+ this.floatingCursorAddedMargin = const EdgeInsets.fromLTRB(4, 4, 4, 5),
+ TextRange? promptRectRange,
+ Color? promptRectColor,
+ Clip clipBehavior = Clip.hardEdge,
+ required this.textSelectionDelegate,
+ RenderEditablePainter? painter,
+ RenderEditablePainter? foregroundPainter,
+ List? children,
+ required this.controller,
+ }) : assert(maxLines == null || maxLines > 0),
+ assert(minLines == null || minLines > 0),
+ assert(
+ (maxLines == null) || (minLines == null) || (maxLines >= minLines),
+ "minLines can't be greater than maxLines",
+ ),
+ assert(
+ !expands || (maxLines == null && minLines == null),
+ 'minLines and maxLines must be null when expands is true.',
+ ),
+ assert(
+ identical(textScaler, TextScaler.noScaling) || textScaleFactor == 1.0,
+ 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.',
+ ),
+ assert(obscuringCharacter.characters.length == 1),
+ assert(cursorWidth >= 0.0),
+ assert(cursorHeight == null || cursorHeight >= 0.0),
+ _textPainter = TextPainter(
+ text: text,
+ textAlign: textAlign,
+ textDirection: textDirection,
+ textScaler: textScaler == TextScaler.noScaling
+ ? TextScaler.linear(textScaleFactor)
+ : textScaler,
+ locale: locale,
+ maxLines: maxLines == 1 ? 1 : null,
+ strutStyle: strutStyle,
+ textHeightBehavior: textHeightBehavior,
+ textWidthBasis: textWidthBasis,
+ ),
+ _showCursor = showCursor ?? ValueNotifier(false),
+ _maxLines = maxLines,
+ _minLines = minLines,
+ _expands = expands,
+ _selection = selection,
+ _offset = offset,
+ _cursorWidth = cursorWidth,
+ _cursorHeight = cursorHeight,
+ _paintCursorOnTop = paintCursorAboveText,
+ _enableInteractiveSelection = enableInteractiveSelection,
+ _devicePixelRatio = devicePixelRatio,
+ _startHandleLayerLink = startHandleLayerLink,
+ _endHandleLayerLink = endHandleLayerLink,
+ _obscuringCharacter = obscuringCharacter,
+ _obscureText = obscureText,
+ _readOnly = readOnly,
+ _forceLine = forceLine,
+ _clipBehavior = clipBehavior,
+ _hasFocus = hasFocus ?? false,
+ _disposeShowCursor = showCursor == null {
+ assert(!_showCursor.value || cursorColor != null);
+
+ _selectionPainter.highlightColor = selectionColor;
+ _selectionPainter.highlightedRange = selection;
+ _selectionPainter.selectionHeightStyle = selectionHeightStyle;
+ _selectionPainter.selectionWidthStyle = selectionWidthStyle;
+
+ _autocorrectHighlightPainter.highlightColor = promptRectColor;
+ _autocorrectHighlightPainter.highlightedRange = promptRectRange;
+
+ _caretPainter.caretColor = cursorColor;
+ _caretPainter.cursorRadius = cursorRadius;
+ _caretPainter.cursorOffset = cursorOffset;
+ _caretPainter.backgroundCursorColor = backgroundCursorColor;
+
+ _updateForegroundPainter(foregroundPainter);
+ _updatePainter(painter);
+ addAll(children);
+ }
+
+ final RichTextEditingController controller;
+
+ /// Child render objects
+ _RenderEditableCustomPaint? _foregroundRenderObject;
+ _RenderEditableCustomPaint? _backgroundRenderObject;
+
+ @override
+ void dispose() {
+ _leaderLayerHandler.layer = null;
+ _foregroundRenderObject?.dispose();
+ _foregroundRenderObject = null;
+ _backgroundRenderObject?.dispose();
+ _backgroundRenderObject = null;
+ _clipRectLayer.layer = null;
+ _cachedBuiltInForegroundPainters?.dispose();
+ _cachedBuiltInPainters?.dispose();
+ _selectionStartInViewport.dispose();
+ _selectionEndInViewport.dispose();
+ _autocorrectHighlightPainter.dispose();
+ _selectionPainter.dispose();
+ _caretPainter.dispose();
+ _textPainter.dispose();
+ _textIntrinsicsCache?.dispose();
+ if (_disposeShowCursor) {
+ _showCursor.dispose();
+ _disposeShowCursor = false;
+ }
+ super.dispose();
+ }
+
+ void _updateForegroundPainter(RenderEditablePainter? newPainter) {
+ final _CompositeRenderEditablePainter effectivePainter = newPainter == null
+ ? _builtInForegroundPainters
+ : _CompositeRenderEditablePainter(
+ painters: [
+ _builtInForegroundPainters,
+ newPainter,
+ ],
+ );
+
+ if (_foregroundRenderObject == null) {
+ final _RenderEditableCustomPaint foregroundRenderObject =
+ _RenderEditableCustomPaint(painter: effectivePainter);
+ adoptChild(foregroundRenderObject);
+ _foregroundRenderObject = foregroundRenderObject;
+ } else {
+ _foregroundRenderObject?.painter = effectivePainter;
+ }
+ _foregroundPainter = newPainter;
+ }
+
+ /// The [RenderEditablePainter] to use for painting above this
+ /// [RenderEditable]'s text content.
+ ///
+ /// The new [RenderEditablePainter] will replace the previously specified
+ /// foreground painter, and schedule a repaint if the new painter's
+ /// `shouldRepaint` method returns true.
+ RenderEditablePainter? get foregroundPainter => _foregroundPainter;
+ RenderEditablePainter? _foregroundPainter;
+ set foregroundPainter(RenderEditablePainter? newPainter) {
+ if (newPainter == _foregroundPainter) {
+ return;
+ }
+ _updateForegroundPainter(newPainter);
+ }
+
+ void _updatePainter(RenderEditablePainter? newPainter) {
+ final _CompositeRenderEditablePainter effectivePainter = newPainter == null
+ ? _builtInPainters
+ : _CompositeRenderEditablePainter(
+ painters: [_builtInPainters, newPainter],
+ );
+
+ if (_backgroundRenderObject == null) {
+ final _RenderEditableCustomPaint backgroundRenderObject =
+ _RenderEditableCustomPaint(painter: effectivePainter);
+ adoptChild(backgroundRenderObject);
+ _backgroundRenderObject = backgroundRenderObject;
+ } else {
+ _backgroundRenderObject?.painter = effectivePainter;
+ }
+ _painter = newPainter;
+ }
+
+ /// Sets the [RenderEditablePainter] to use for painting beneath this
+ /// [RenderEditable]'s text content.
+ ///
+ /// The new [RenderEditablePainter] will replace the previously specified
+ /// painter, and schedule a repaint if the new painter's `shouldRepaint`
+ /// method returns true.
+ RenderEditablePainter? get painter => _painter;
+ RenderEditablePainter? _painter;
+ set painter(RenderEditablePainter? newPainter) {
+ if (newPainter == _painter) {
+ return;
+ }
+ _updatePainter(newPainter);
+ }
+
+ // Caret Painters:
+ // A single painter for both the regular caret and the floating cursor.
+ late final _CaretPainter _caretPainter = _CaretPainter();
+
+ // Text Highlight painters:
+ final _TextHighlightPainter _selectionPainter = _TextHighlightPainter();
+ final _TextHighlightPainter _autocorrectHighlightPainter =
+ _TextHighlightPainter();
+
+ _CompositeRenderEditablePainter get _builtInForegroundPainters =>
+ _cachedBuiltInForegroundPainters ??= _createBuiltInForegroundPainters();
+ _CompositeRenderEditablePainter? _cachedBuiltInForegroundPainters;
+ _CompositeRenderEditablePainter _createBuiltInForegroundPainters() {
+ return _CompositeRenderEditablePainter(
+ painters: [
+ if (paintCursorAboveText) _caretPainter,
+ ],
+ );
+ }
+
+ _CompositeRenderEditablePainter get _builtInPainters =>
+ _cachedBuiltInPainters ??= _createBuiltInPainters();
+ _CompositeRenderEditablePainter? _cachedBuiltInPainters;
+ _CompositeRenderEditablePainter _createBuiltInPainters() {
+ return _CompositeRenderEditablePainter(
+ painters: [
+ _autocorrectHighlightPainter,
+ _selectionPainter,
+ if (!paintCursorAboveText) _caretPainter,
+ ],
+ );
+ }
+
+ /// Whether the [handleEvent] will propagate pointer events to selection
+ /// handlers.
+ ///
+ /// If this property is true, the [handleEvent] assumes that this renderer
+ /// will be notified of input gestures via [handleTapDown], [handleTap],
+ /// [handleDoubleTap], and [handleLongPress].
+ ///
+ /// If there are any gesture recognizers in the text span, the [handleEvent]
+ /// will still propagate pointer events to those recognizers.
+ ///
+ /// The default value of this property is false.
+ bool ignorePointer;
+
+ /// {@macro dart.ui.textHeightBehavior}
+ TextHeightBehavior? get textHeightBehavior => _textPainter.textHeightBehavior;
+ set textHeightBehavior(TextHeightBehavior? value) {
+ if (_textPainter.textHeightBehavior == value) {
+ return;
+ }
+ _textPainter.textHeightBehavior = value;
+ markNeedsLayout();
+ }
+
+ /// {@macro flutter.painting.textPainter.textWidthBasis}
+ TextWidthBasis get textWidthBasis => _textPainter.textWidthBasis;
+ set textWidthBasis(TextWidthBasis value) {
+ if (_textPainter.textWidthBasis == value) {
+ return;
+ }
+ _textPainter.textWidthBasis = value;
+ markNeedsLayout();
+ }
+
+ /// The pixel ratio of the current device.
+ ///
+ /// Should be obtained by querying MediaQuery for the devicePixelRatio.
+ double get devicePixelRatio => _devicePixelRatio;
+ double _devicePixelRatio;
+ set devicePixelRatio(double value) {
+ if (devicePixelRatio == value) {
+ return;
+ }
+ _devicePixelRatio = value;
+ markNeedsLayout();
+ }
+
+ /// Character used for obscuring text if [obscureText] is true.
+ ///
+ /// Must have a length of exactly one.
+ String get obscuringCharacter => _obscuringCharacter;
+ String _obscuringCharacter;
+ set obscuringCharacter(String value) {
+ if (_obscuringCharacter == value) {
+ return;
+ }
+ assert(value.characters.length == 1);
+ _obscuringCharacter = value;
+ markNeedsLayout();
+ }
+
+ /// Whether to hide the text being edited (e.g., for passwords).
+ bool get obscureText => _obscureText;
+ bool _obscureText;
+ set obscureText(bool value) {
+ if (_obscureText == value) {
+ return;
+ }
+ _obscureText = value;
+ _cachedAttributedValue = null;
+ markNeedsSemanticsUpdate();
+ }
+
+ /// Controls how tall the selection highlight boxes are computed to be.
+ ///
+ /// See [ui.BoxHeightStyle] for details on available styles.
+ ui.BoxHeightStyle get selectionHeightStyle =>
+ _selectionPainter.selectionHeightStyle;
+ set selectionHeightStyle(ui.BoxHeightStyle value) {
+ _selectionPainter.selectionHeightStyle = value;
+ }
+
+ /// Controls how wide the selection highlight boxes are computed to be.
+ ///
+ /// See [ui.BoxWidthStyle] for details on available styles.
+ ui.BoxWidthStyle get selectionWidthStyle =>
+ _selectionPainter.selectionWidthStyle;
+ set selectionWidthStyle(ui.BoxWidthStyle value) {
+ _selectionPainter.selectionWidthStyle = value;
+ }
+
+ /// The object that controls the text selection, used by this render object
+ /// for implementing cut, copy, and paste keyboard shortcuts.
+ ///
+ /// It will make cut, copy and paste functionality work with the most recently
+ /// set [TextSelectionDelegate].
+ TextSelectionDelegate textSelectionDelegate;
+
+ /// Track whether position of the start of the selected text is within the viewport.
+ ///
+ /// For example, if the text contains "Hello World", and the user selects
+ /// "Hello", then scrolls so only "World" is visible, this will become false.
+ /// If the user scrolls back so that the "H" is visible again, this will
+ /// become true.
+ ///
+ /// This bool indicates whether the text is scrolled so that the handle is
+ /// inside the text field viewport, as opposed to whether it is actually
+ /// visible on the screen.
+ ValueListenable get selectionStartInViewport =>
+ _selectionStartInViewport;
+ final ValueNotifier _selectionStartInViewport = ValueNotifier(
+ true,
+ );
+
+ /// Track whether position of the end of the selected text is within the viewport.
+ ///
+ /// For example, if the text contains "Hello World", and the user selects
+ /// "World", then scrolls so only "Hello" is visible, this will become
+ /// 'false'. If the user scrolls back so that the "d" is visible again, this
+ /// will become 'true'.
+ ///
+ /// This bool indicates whether the text is scrolled so that the handle is
+ /// inside the text field viewport, as opposed to whether it is actually
+ /// visible on the screen.
+ ValueListenable get selectionEndInViewport => _selectionEndInViewport;
+ final ValueNotifier _selectionEndInViewport = ValueNotifier(true);
+
+ /// Returns the TextPosition above or below the given offset.
+ TextPosition _getTextPositionVertical(
+ TextPosition position,
+ double verticalOffset,
+ ) {
+ final Offset caretOffset = _textPainter.getOffsetForCaret(
+ position,
+ _caretPrototype,
+ );
+ final Offset caretOffsetTranslated = caretOffset.translate(
+ 0.0,
+ verticalOffset,
+ );
+ return _textPainter.getPositionForOffset(caretOffsetTranslated);
+ }
+
+ // Start TextLayoutMetrics.
+
+ /// {@macro flutter.services.TextLayoutMetrics.getLineAtOffset}
+ @override
+ TextSelection getLineAtOffset(TextPosition position) {
+ final TextRange line = _textPainter.getLineBoundary(position);
+ // If text is obscured, the entire string should be treated as one line.
+ if (obscureText) {
+ return TextSelection(baseOffset: 0, extentOffset: plainText.length);
+ }
+ return TextSelection(baseOffset: line.start, extentOffset: line.end);
+ }
+
+ /// {@macro flutter.painting.TextPainter.getWordBoundary}
+ @override
+ TextRange getWordBoundary(TextPosition position) {
+ return _textPainter.getWordBoundary(position);
+ }
+
+ /// {@macro flutter.services.TextLayoutMetrics.getTextPositionAbove}
+ @override
+ TextPosition getTextPositionAbove(TextPosition position) {
+ // The caret offset gives a location in the upper left hand corner of
+ // the caret so the middle of the line above is a half line above that
+ // point and the line below is 1.5 lines below that point.
+ final double preferredLineHeight = _textPainter.preferredLineHeight;
+ final double verticalOffset = -0.5 * preferredLineHeight;
+ return _getTextPositionVertical(position, verticalOffset);
+ }
+
+ /// {@macro flutter.services.TextLayoutMetrics.getTextPositionBelow}
+ @override
+ TextPosition getTextPositionBelow(TextPosition position) {
+ // The caret offset gives a location in the upper left hand corner of
+ // the caret so the middle of the line above is a half line above that
+ // point and the line below is 1.5 lines below that point.
+ final double preferredLineHeight = _textPainter.preferredLineHeight;
+ final double verticalOffset = 1.5 * preferredLineHeight;
+ return _getTextPositionVertical(position, verticalOffset);
+ }
+
+ // End TextLayoutMetrics.
+
+ void _updateSelectionExtentsVisibility(Offset effectiveOffset) {
+ assert(selection != null);
+ if (!selection!.isValid) {
+ _selectionStartInViewport.value = false;
+ _selectionEndInViewport.value = false;
+ return;
+ }
+ final Rect visibleRegion = Offset.zero & size;
+
+ final Offset startOffset = _textPainter.getOffsetForCaret(
+ TextPosition(offset: selection!.start, affinity: selection!.affinity),
+ _caretPrototype,
+ );
+ // Check if the selection is visible with an approximation because a
+ // difference between rounded and unrounded values causes the caret to be
+ // reported as having a slightly (< 0.5) negative y offset. This rounding
+ // happens in paragraph.cc's layout and TextPainter's
+ // _applyFloatingPointHack. Ideally, the rounding mismatch will be fixed and
+ // this can be changed to be a strict check instead of an approximation.
+ const double visibleRegionSlop = 0.5;
+ _selectionStartInViewport.value = visibleRegion
+ .inflate(visibleRegionSlop)
+ .contains(startOffset + effectiveOffset);
+
+ final Offset endOffset = _textPainter.getOffsetForCaret(
+ TextPosition(offset: selection!.end, affinity: selection!.affinity),
+ _caretPrototype,
+ );
+ _selectionEndInViewport.value = visibleRegion
+ .inflate(visibleRegionSlop)
+ .contains(endOffset + effectiveOffset);
+ }
+
+ void _setTextEditingValue(
+ TextEditingValue newValue,
+ SelectionChangedCause cause,
+ ) {
+ textSelectionDelegate.userUpdateTextEditingValue(newValue, cause);
+ }
+
+ void _setSelection(TextSelection nextSelection, SelectionChangedCause cause) {
+ if (nextSelection.isValid) {
+ // The nextSelection is calculated based on plainText, which can be out
+ // of sync with the textSelectionDelegate.textEditingValue by one frame.
+ // This is due to the render editable and editable text handle pointer
+ // event separately. If the editable text changes the text during the
+ // event handler, the render editable will use the outdated text stored in
+ // the plainText when handling the pointer event.
+ //
+ // If this happens, we need to make sure the new selection is still valid.
+ final int textLength = textSelectionDelegate.textEditingValue.text.length;
+ nextSelection = nextSelection.copyWith(
+ baseOffset: math.min(nextSelection.baseOffset, textLength),
+ extentOffset: math.min(nextSelection.extentOffset, textLength),
+ );
+ }
+ _setTextEditingValue(
+ textSelectionDelegate.textEditingValue.copyWith(selection: nextSelection),
+ cause,
+ );
+ }
+
+ @override
+ void markNeedsPaint() {
+ super.markNeedsPaint();
+ // Tell the painters to repaint since text layout may have changed.
+ _foregroundRenderObject?.markNeedsPaint();
+ _backgroundRenderObject?.markNeedsPaint();
+ }
+
+ @override
+ void systemFontsDidChange() {
+ super.systemFontsDidChange();
+ _textPainter.markNeedsLayout();
+ }
+
+ /// Returns a plain text version of the text in [TextPainter].
+ ///
+ /// If [obscureText] is true, returns the obscured text. See
+ /// [obscureText] and [obscuringCharacter].
+ /// In order to get the styled text as an [InlineSpan] tree, use [text].
+ String get plainText => _textPainter.plainText;
+
+ /// The text to paint in the form of a tree of [InlineSpan]s.
+ ///
+ /// In order to get the plain text representation, use [plainText].
+ InlineSpan? get text => _textPainter.text;
+ final TextPainter _textPainter;
+ AttributedString? _cachedAttributedValue;
+ List? _cachedCombinedSemanticsInfos;
+ set text(InlineSpan? value) {
+ if (_textPainter.text == value) {
+ return;
+ }
+ _cachedLineBreakCount = null;
+ _textPainter.text = value;
+ _cachedAttributedValue = null;
+ _cachedCombinedSemanticsInfos = null;
+ markNeedsLayout();
+ markNeedsSemanticsUpdate();
+ }
+
+ TextPainter? _textIntrinsicsCache;
+ TextPainter get _textIntrinsics {
+ return (_textIntrinsicsCache ??= TextPainter())
+ ..text = _textPainter.text
+ ..textAlign = _textPainter.textAlign
+ ..textDirection = _textPainter.textDirection
+ ..textScaler = _textPainter.textScaler
+ ..maxLines = _textPainter.maxLines
+ ..ellipsis = _textPainter.ellipsis
+ ..locale = _textPainter.locale
+ ..strutStyle = _textPainter.strutStyle
+ ..textWidthBasis = _textPainter.textWidthBasis
+ ..textHeightBehavior = _textPainter.textHeightBehavior;
+ }
+
+ /// How the text should be aligned horizontally.
+ TextAlign get textAlign => _textPainter.textAlign;
+ set textAlign(TextAlign value) {
+ if (_textPainter.textAlign == value) {
+ return;
+ }
+ _textPainter.textAlign = value;
+ markNeedsLayout();
+ }
+
+ /// The directionality of the text.
+ ///
+ /// This decides how the [TextAlign.start], [TextAlign.end], and
+ /// [TextAlign.justify] values of [textAlign] are interpreted.
+ ///
+ /// This is also used to disambiguate how to render bidirectional text. For
+ /// example, if the [text] is an English phrase followed by a Hebrew phrase,
+ /// in a [TextDirection.ltr] context the English phrase will be on the left
+ /// and the Hebrew phrase to its right, while in a [TextDirection.rtl]
+ /// context, the English phrase will be on the right and the Hebrew phrase on
+ /// its left.
+ // TextPainter.textDirection is nullable, but it is set to a
+ // non-null value in the RenderEditable constructor and we refuse to
+ // set it to null here, so _textPainter.textDirection cannot be null.
+ TextDirection get textDirection => _textPainter.textDirection!;
+ set textDirection(TextDirection value) {
+ if (_textPainter.textDirection == value) {
+ return;
+ }
+ _textPainter.textDirection = value;
+ markNeedsLayout();
+ markNeedsSemanticsUpdate();
+ }
+
+ /// Used by this renderer's internal [TextPainter] to select a locale-specific
+ /// font.
+ ///
+ /// In some cases the same Unicode character may be rendered differently depending
+ /// on the locale. For example the '骨' character is rendered differently in
+ /// the Chinese and Japanese locales. In these cases the [locale] may be used
+ /// to select a locale-specific font.
+ ///
+ /// If this value is null, a system-dependent algorithm is used to select
+ /// the font.
+ Locale? get locale => _textPainter.locale;
+ set locale(Locale? value) {
+ if (_textPainter.locale == value) {
+ return;
+ }
+ _textPainter.locale = value;
+ markNeedsLayout();
+ }
+
+ /// The [StrutStyle] used by the renderer's internal [TextPainter] to
+ /// determine the strut to use.
+ StrutStyle? get strutStyle => _textPainter.strutStyle;
+ set strutStyle(StrutStyle? value) {
+ if (_textPainter.strutStyle == value) {
+ return;
+ }
+ _textPainter.strutStyle = value;
+ markNeedsLayout();
+ }
+
+ /// The color to use when painting the cursor.
+ Color? get cursorColor => _caretPainter.caretColor;
+ set cursorColor(Color? value) {
+ _caretPainter.caretColor = value;
+ }
+
+ /// The color to use when painting the cursor aligned to the text while
+ /// rendering the floating cursor.
+ ///
+ /// Typically this would be set to [CupertinoColors.inactiveGray].
+ ///
+ /// If this is null, the background cursor is not painted.
+ ///
+ /// See also:
+ ///
+ /// * [FloatingCursorDragState], which explains the floating cursor feature
+ /// in detail.
+ Color? get backgroundCursorColor => _caretPainter.backgroundCursorColor;
+ set backgroundCursorColor(Color? value) {
+ _caretPainter.backgroundCursorColor = value;
+ }
+
+ bool _disposeShowCursor;
+
+ /// Whether to paint the cursor.
+ ValueNotifier get showCursor => _showCursor;
+ ValueNotifier _showCursor;
+ set showCursor(ValueNotifier value) {
+ if (_showCursor == value) {
+ return;
+ }
+ if (attached) {
+ _showCursor.removeListener(_showHideCursor);
+ }
+ if (_disposeShowCursor) {
+ _showCursor.dispose();
+ _disposeShowCursor = false;
+ }
+ _showCursor = value;
+ if (attached) {
+ _showHideCursor();
+ _showCursor.addListener(_showHideCursor);
+ }
+ }
+
+ void _showHideCursor() {
+ _caretPainter.shouldPaint = showCursor.value;
+ }
+
+ /// Whether the editable is currently focused.
+ bool get hasFocus => _hasFocus;
+ bool _hasFocus = false;
+ set hasFocus(bool value) {
+ if (_hasFocus == value) {
+ return;
+ }
+ _hasFocus = value;
+ markNeedsSemanticsUpdate();
+ }
+
+ /// Whether this rendering object will take a full line regardless the text width.
+ bool get forceLine => _forceLine;
+ bool _forceLine = false;
+ set forceLine(bool value) {
+ if (_forceLine == value) {
+ return;
+ }
+ _forceLine = value;
+ markNeedsLayout();
+ }
+
+ /// Whether this rendering object is read only.
+ bool get readOnly => _readOnly;
+ bool _readOnly = false;
+ set readOnly(bool value) {
+ if (_readOnly == value) {
+ return;
+ }
+ _readOnly = value;
+ markNeedsSemanticsUpdate();
+ }
+
+ /// The maximum number of lines for the text to span, wrapping if necessary.
+ ///
+ /// If this is 1 (the default), the text will not wrap, but will extend
+ /// indefinitely instead.
+ ///
+ /// If this is null, there is no limit to the number of lines.
+ ///
+ /// When this is not null, the intrinsic height of the render object is the
+ /// height of one line of text multiplied by this value. In other words, this
+ /// also controls the height of the actual editing widget.
+ int? get maxLines => _maxLines;
+ int? _maxLines;
+
+ /// The value may be null. If it is not null, then it must be greater than zero.
+ set maxLines(int? value) {
+ assert(value == null || value > 0);
+ if (maxLines == value) {
+ return;
+ }
+ _maxLines = value;
+
+ // Special case maxLines == 1 to keep only the first line so we can get the
+ // height of the first line in case there are hard line breaks in the text.
+ // See the `_preferredHeight` method.
+ _textPainter.maxLines = value == 1 ? 1 : null;
+ markNeedsLayout();
+ }
+
+ /// {@macro flutter.widgets.editableText.minLines}
+ int? get minLines => _minLines;
+ int? _minLines;
+
+ /// The value may be null. If it is not null, then it must be greater than zero.
+ set minLines(int? value) {
+ assert(value == null || value > 0);
+ if (minLines == value) {
+ return;
+ }
+ _minLines = value;
+ markNeedsLayout();
+ }
+
+ /// {@macro flutter.widgets.editableText.expands}
+ bool get expands => _expands;
+ bool _expands;
+ set expands(bool value) {
+ if (expands == value) {
+ return;
+ }
+ _expands = value;
+ markNeedsLayout();
+ }
+
+ /// The color to use when painting the selection.
+ Color? get selectionColor => _selectionPainter.highlightColor;
+ set selectionColor(Color? value) {
+ _selectionPainter.highlightColor = value;
+ }
+
+ /// Deprecated. Will be removed in a future version of Flutter. Use
+ /// [textScaler] instead.
+ ///
+ /// The number of font pixels for each logical pixel.
+ ///
+ /// For example, if the text scale factor is 1.5, text will be 50% larger than
+ /// the specified font size.
+ @Deprecated(
+ 'Use textScaler instead. '
+ 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. '
+ 'This feature was deprecated after v3.12.0-2.0.pre.',
+ )
+ double get textScaleFactor => _textPainter.textScaleFactor;
+ @Deprecated(
+ 'Use textScaler instead. '
+ 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. '
+ 'This feature was deprecated after v3.12.0-2.0.pre.',
+ )
+ set textScaleFactor(double value) {
+ textScaler = TextScaler.linear(value);
+ }
+
+ /// {@macro flutter.painting.textPainter.textScaler}
+ TextScaler get textScaler => _textPainter.textScaler;
+ set textScaler(TextScaler value) {
+ if (_textPainter.textScaler == value) {
+ return;
+ }
+ _textPainter.textScaler = value;
+ markNeedsLayout();
+ }
+
+ /// The region of text that is selected, if any.
+ ///
+ /// The caret position is represented by a collapsed selection.
+ ///
+ /// If [selection] is null, there is no selection and attempts to
+ /// manipulate the selection will throw.
+ TextSelection? get selection => _selection;
+ TextSelection? _selection;
+ set selection(TextSelection? value) {
+ if (_selection == value) {
+ return;
+ }
+ _selection = value;
+ _selectionPainter.highlightedRange = value;
+ markNeedsPaint();
+ markNeedsSemanticsUpdate();
+ }
+
+ /// The offset at which the text should be painted.
+ ///
+ /// If the text content is larger than the editable line itself, the editable
+ /// line clips the text. This property controls which part of the text is
+ /// visible by shifting the text by the given offset before clipping.
+ ViewportOffset get offset => _offset;
+ ViewportOffset _offset;
+ set offset(ViewportOffset value) {
+ if (_offset == value) {
+ return;
+ }
+ if (attached) {
+ _offset.removeListener(markNeedsPaint);
+ }
+ _offset = value;
+ if (attached) {
+ _offset.addListener(markNeedsPaint);
+ }
+ markNeedsLayout();
+ }
+
+ /// How thick the cursor will be.
+ double get cursorWidth => _cursorWidth;
+ double _cursorWidth = 1.0;
+ set cursorWidth(double value) {
+ if (_cursorWidth == value) {
+ return;
+ }
+ _cursorWidth = value;
+ markNeedsLayout();
+ }
+
+ /// How tall the cursor will be.
+ ///
+ /// This can be null, in which case the getter will actually return [preferredLineHeight].
+ ///
+ /// Setting this to itself fixes the value to the current [preferredLineHeight]. Setting
+ /// this to null returns the behavior of deferring to [preferredLineHeight].
+ // TODO(ianh): This is a confusing API. We should have a separate getter for the effective cursor height.
+ double get cursorHeight => _cursorHeight ?? preferredLineHeight;
+ double? _cursorHeight;
+ set cursorHeight(double? value) {
+ if (_cursorHeight == value) {
+ return;
+ }
+ _cursorHeight = value;
+ markNeedsLayout();
+ }
+
+ /// {@template flutter.rendering.RenderEditable.paintCursorAboveText}
+ /// If the cursor should be painted on top of the text or underneath it.
+ ///
+ /// By default, the cursor should be painted on top for iOS platforms and
+ /// underneath for Android platforms.
+ /// {@endtemplate}
+ bool get paintCursorAboveText => _paintCursorOnTop;
+ bool _paintCursorOnTop;
+ set paintCursorAboveText(bool value) {
+ if (_paintCursorOnTop == value) {
+ return;
+ }
+ _paintCursorOnTop = value;
+ // Clear cached built-in painters and reconfigure painters.
+ _cachedBuiltInForegroundPainters = null;
+ _cachedBuiltInPainters = null;
+ // Call update methods to rebuild and set the effective painters.
+ _updateForegroundPainter(_foregroundPainter);
+ _updatePainter(_painter);
+ }
+
+ /// {@template flutter.rendering.RenderEditable.cursorOffset}
+ /// The offset that is used, in pixels, when painting the cursor on screen.
+ ///
+ /// By default, the cursor position should be set to an offset of
+ /// (-[cursorWidth] * 0.5, 0.0) on iOS platforms and (0, 0) on Android
+ /// platforms. The origin from where the offset is applied to is the arbitrary
+ /// location where the cursor ends up being rendered from by default.
+ /// {@endtemplate}
+ Offset get cursorOffset => _caretPainter.cursorOffset;
+ set cursorOffset(Offset value) {
+ _caretPainter.cursorOffset = value;
+ }
+
+ /// How rounded the corners of the cursor should be.
+ ///
+ /// A null value is the same as [Radius.zero].
+ Radius? get cursorRadius => _caretPainter.cursorRadius;
+ set cursorRadius(Radius? value) {
+ _caretPainter.cursorRadius = value;
+ }
+
+ /// The [LayerLink] of start selection handle.
+ ///
+ /// [RenderEditable] is responsible for calculating the [Offset] of this
+ /// [LayerLink], which will be used as [CompositedTransformTarget] of start handle.
+ LayerLink get startHandleLayerLink => _startHandleLayerLink;
+ LayerLink _startHandleLayerLink;
+ set startHandleLayerLink(LayerLink value) {
+ if (_startHandleLayerLink == value) {
+ return;
+ }
+ _startHandleLayerLink = value;
+ markNeedsPaint();
+ }
+
+ /// The [LayerLink] of end selection handle.
+ ///
+ /// [RenderEditable] is responsible for calculating the [Offset] of this
+ /// [LayerLink], which will be used as [CompositedTransformTarget] of end handle.
+ LayerLink get endHandleLayerLink => _endHandleLayerLink;
+ LayerLink _endHandleLayerLink;
+ set endHandleLayerLink(LayerLink value) {
+ if (_endHandleLayerLink == value) {
+ return;
+ }
+ _endHandleLayerLink = value;
+ markNeedsPaint();
+ }
+
+ /// The padding applied to text field. Used to determine the bounds when
+ /// moving the floating cursor.
+ ///
+ /// Defaults to a padding with left, top and right set to 4, bottom to 5.
+ ///
+ /// See also:
+ ///
+ /// * [FloatingCursorDragState], which explains the floating cursor feature
+ /// in detail.
+ EdgeInsets floatingCursorAddedMargin;
+
+ /// Returns true if the floating cursor is visible, false otherwise.
+ bool get floatingCursorOn => _floatingCursorOn;
+ bool _floatingCursorOn = false;
+ late TextPosition _floatingCursorTextPosition;
+
+ /// Whether to allow the user to change the selection.
+ ///
+ /// Since [RenderEditable] does not handle selection manipulation
+ /// itself, this actually only affects whether the accessibility
+ /// hints provided to the system (via
+ /// [describeSemanticsConfiguration]) will enable selection
+ /// manipulation. It's the responsibility of this object's owner
+ /// to provide selection manipulation affordances.
+ ///
+ /// This field is used by [selectionEnabled] (which then controls
+ /// the accessibility hints mentioned above). When null,
+ /// [obscureText] is used to determine the value of
+ /// [selectionEnabled] instead.
+ bool? get enableInteractiveSelection => _enableInteractiveSelection;
+ bool? _enableInteractiveSelection;
+ set enableInteractiveSelection(bool? value) {
+ if (_enableInteractiveSelection == value) {
+ return;
+ }
+ _enableInteractiveSelection = value;
+ markNeedsLayout();
+ markNeedsSemanticsUpdate();
+ }
+
+ /// Whether interactive selection are enabled based on the values of
+ /// [enableInteractiveSelection] and [obscureText].
+ ///
+ /// Since [RenderEditable] does not handle selection manipulation
+ /// itself, this actually only affects whether the accessibility
+ /// hints provided to the system (via
+ /// [describeSemanticsConfiguration]) will enable selection
+ /// manipulation. It's the responsibility of this object's owner
+ /// to provide selection manipulation affordances.
+ ///
+ /// By default, [enableInteractiveSelection] is null, [obscureText] is false,
+ /// and this getter returns true.
+ ///
+ /// If [enableInteractiveSelection] is null and [obscureText] is true, then this
+ /// getter returns false. This is the common case for password fields.
+ ///
+ /// If [enableInteractiveSelection] is non-null then its value is
+ /// returned. An application might [enableInteractiveSelection] to
+ /// true to enable interactive selection for a password field, or to
+ /// false to unconditionally disable interactive selection.
+ bool get selectionEnabled {
+ return enableInteractiveSelection ?? !obscureText;
+ }
+
+ /// The color used to paint the prompt rectangle.
+ ///
+ /// The prompt rectangle will only be requested on non-web iOS applications.
+ // TODO(ianh): We should change the getter to return null when _promptRectRange is null
+ // (otherwise, if you set it to null and then get it, you get back non-null).
+ // Alternatively, we could stop supporting setting this to null.
+ Color? get promptRectColor => _autocorrectHighlightPainter.highlightColor;
+ set promptRectColor(Color? newValue) {
+ _autocorrectHighlightPainter.highlightColor = newValue;
+ }
+
+ /// Dismisses the currently displayed prompt rectangle and displays a new prompt rectangle
+ /// over [newRange] in the given color [promptRectColor].
+ ///
+ /// The prompt rectangle will only be requested on non-web iOS applications.
+ ///
+ /// When set to null, the currently displayed prompt rectangle (if any) will be dismissed.
+ // ignore: use_setters_to_change_properties, (API predates enforcing the lint)
+ void setPromptRectRange(TextRange? newRange) {
+ _autocorrectHighlightPainter.highlightedRange = newRange;
+ }
+
+ /// The maximum amount the text is allowed to scroll.
+ ///
+ /// This value is only valid after layout and can change as additional
+ /// text is entered or removed in order to accommodate expanding when
+ /// [expands] is set to true.
+ double get maxScrollExtent => _maxScrollExtent;
+ double _maxScrollExtent = 0;
+
+ double get _caretMargin => _kCaretGap + cursorWidth;
+
+ /// {@macro flutter.material.Material.clipBehavior}
+ ///
+ /// Defaults to [Clip.hardEdge].
+ Clip get clipBehavior => _clipBehavior;
+ Clip _clipBehavior = Clip.hardEdge;
+ set clipBehavior(Clip value) {
+ if (value != _clipBehavior) {
+ _clipBehavior = value;
+ markNeedsPaint();
+ markNeedsSemanticsUpdate();
+ }
+ }
+
+ /// Collected during [describeSemanticsConfiguration], used by
+ /// [assembleSemanticsNode].
+ List? _semanticsInfo;
+
+ // Caches [SemanticsNode]s created during [assembleSemanticsNode] so they
+ // can be re-used when [assembleSemanticsNode] is called again. This ensures
+ // stable ids for the [SemanticsNode]s of [TextSpan]s across
+ // [assembleSemanticsNode] invocations.
+ LinkedHashMap? _cachedChildNodes;
+
+ /// Returns a list of rects that bound the given selection, and the text
+ /// direction. The text direction is used by the engine to calculate
+ /// the closest position to a given point.
+ ///
+ /// See [TextPainter.getBoxesForSelection] for more details.
+ List getBoxesForSelection(TextSelection selection) {
+ _computeTextMetricsIfNeeded();
+ return _textPainter
+ .getBoxesForSelection(selection)
+ .map(
+ (TextBox textBox) => TextBox.fromLTRBD(
+ textBox.left + _paintOffset.dx,
+ textBox.top + _paintOffset.dy,
+ textBox.right + _paintOffset.dx,
+ textBox.bottom + _paintOffset.dy,
+ textBox.direction,
+ ),
+ )
+ .toList();
+ }
+
+ @override
+ void describeSemanticsConfiguration(SemanticsConfiguration config) {
+ super.describeSemanticsConfiguration(config);
+ _semanticsInfo = _textPainter.text!.getSemanticsInformation();
+ // TODO(chunhtai): the macOS does not provide a public API to support text
+ // selections across multiple semantics nodes. Remove this platform check
+ // once we can support it.
+ // https://github.com/flutter/flutter/issues/77957
+ if (_semanticsInfo!.any(
+ (InlineSpanSemanticsInformation info) => info.recognizer != null,
+ ) &&
+ defaultTargetPlatform != TargetPlatform.macOS) {
+ // assert(readOnly && !obscureText);
+ // For Selectable rich text with recognizer, we need to create a semantics
+ // node for each text fragment.
+ config
+ ..isSemanticBoundary = true
+ ..explicitChildNodes = true;
+ return;
+ }
+ if (_cachedAttributedValue == null) {
+ if (obscureText) {
+ _cachedAttributedValue = AttributedString(
+ obscuringCharacter * plainText.length,
+ );
+ } else {
+ final StringBuffer buffer = StringBuffer();
+ int offset = 0;
+ final List attributes = [];
+ for (final InlineSpanSemanticsInformation info in _semanticsInfo!) {
+ final String label = info.semanticsLabel ?? info.text;
+ for (final StringAttribute infoAttribute in info.stringAttributes) {
+ final TextRange originalRange = infoAttribute.range;
+ attributes.add(
+ infoAttribute.copy(
+ range: TextRange(
+ start: offset + originalRange.start,
+ end: offset + originalRange.end,
+ ),
+ ),
+ );
+ }
+ buffer.write(label);
+ offset += label.length;
+ }
+ _cachedAttributedValue = AttributedString(
+ buffer.toString(),
+ attributes: attributes,
+ );
+ }
+ }
+ config
+ ..attributedValue = _cachedAttributedValue!
+ ..isObscured = obscureText
+ ..isMultiline = _isMultiline
+ ..textDirection = textDirection
+ ..isFocused = hasFocus
+ ..isTextField = true
+ ..isReadOnly = readOnly
+ // This is the default for customer that uses RenderEditable directly.
+ // The real value is typically set by EditableText.
+ ..inputType = ui.SemanticsInputType.text;
+
+ if (hasFocus && selectionEnabled) {
+ config.onSetSelection = _handleSetSelection;
+ }
+
+ if (hasFocus && !readOnly) {
+ config.onSetText = _handleSetText;
+ }
+
+ if (selectionEnabled && (selection?.isValid ?? false)) {
+ config.textSelection = selection;
+ if (_textPainter.getOffsetBefore(selection!.extentOffset) != null) {
+ config
+ ..onMoveCursorBackwardByWord = _handleMoveCursorBackwardByWord
+ ..onMoveCursorBackwardByCharacter =
+ _handleMoveCursorBackwardByCharacter;
+ }
+ if (_textPainter.getOffsetAfter(selection!.extentOffset) != null) {
+ config
+ ..onMoveCursorForwardByWord = _handleMoveCursorForwardByWord
+ ..onMoveCursorForwardByCharacter =
+ _handleMoveCursorForwardByCharacter;
+ }
+ }
+ }
+
+ void _handleSetText(String text) {
+ textSelectionDelegate.userUpdateTextEditingValue(
+ TextEditingValue(
+ text: text,
+ selection: TextSelection.collapsed(offset: text.length),
+ ),
+ SelectionChangedCause.keyboard,
+ );
+ }
+
+ @override
+ void assembleSemanticsNode(
+ SemanticsNode node,
+ SemanticsConfiguration config,
+ Iterable children,
+ ) {
+ assert(_semanticsInfo != null && _semanticsInfo!.isNotEmpty);
+ final List newChildren = [];
+ TextDirection currentDirection = textDirection;
+ Rect currentRect;
+ double ordinal = 0.0;
+ int start = 0;
+ int placeholderIndex = 0;
+ int childIndex = 0;
+ RenderBox? child = firstChild;
+ final LinkedHashMap newChildCache =
+ LinkedHashMap();
+ _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!);
+ for (final InlineSpanSemanticsInformation info
+ in _cachedCombinedSemanticsInfos!) {
+ final TextSelection selection = TextSelection(
+ baseOffset: start,
+ extentOffset: start + info.text.length,
+ );
+ start += info.text.length;
+
+ if (info.isPlaceholder) {
+ // A placeholder span may have 0 to multiple semantics nodes, we need
+ // to annotate all of the semantics nodes belong to this span.
+ while (children.length > childIndex &&
+ children
+ .elementAt(childIndex)
+ .isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) {
+ final SemanticsNode childNode = children.elementAt(childIndex);
+ final TextParentData parentData =
+ child!.parentData! as TextParentData;
+ assert(parentData.offset != null);
+ newChildren.add(childNode);
+ childIndex += 1;
+ }
+ child = childAfter(child!);
+ placeholderIndex += 1;
+ } else {
+ final TextDirection initialDirection = currentDirection;
+ final List rects = _textPainter.getBoxesForSelection(
+ selection,
+ );
+ if (rects.isEmpty) {
+ continue;
+ }
+ Rect rect = rects.first.toRect();
+ currentDirection = rects.first.direction;
+ for (final ui.TextBox textBox in rects.skip(1)) {
+ rect = rect.expandToInclude(textBox.toRect());
+ currentDirection = textBox.direction;
+ }
+ // Any of the text boxes may have had infinite dimensions.
+ // We shouldn't pass infinite dimensions up to the bridges.
+ rect = Rect.fromLTWH(
+ math.max(0.0, rect.left),
+ math.max(0.0, rect.top),
+ math.min(rect.width, constraints.maxWidth),
+ math.min(rect.height, constraints.maxHeight),
+ );
+ // Round the current rectangle to make this API testable and add some
+ // padding so that the accessibility rects do not overlap with the text.
+ currentRect = Rect.fromLTRB(
+ rect.left.floorToDouble() - 4.0,
+ rect.top.floorToDouble() - 4.0,
+ rect.right.ceilToDouble() + 4.0,
+ rect.bottom.ceilToDouble() + 4.0,
+ );
+ final SemanticsConfiguration configuration = SemanticsConfiguration()
+ ..sortKey = OrdinalSortKey(ordinal++)
+ ..textDirection = initialDirection
+ ..attributedLabel = AttributedString(
+ info.semanticsLabel ?? info.text,
+ attributes: info.stringAttributes,
+ );
+ switch (info.recognizer) {
+ case TapGestureRecognizer(onTap: final VoidCallback? handler):
+ case DoubleTapGestureRecognizer(
+ onDoubleTap: final VoidCallback? handler,
+ ):
+ if (handler != null) {
+ configuration.onTap = handler;
+ configuration.isLink = true;
+ }
+ case LongPressGestureRecognizer(
+ onLongPress: final GestureLongPressCallback? onLongPress,
+ ):
+ if (onLongPress != null) {
+ configuration.onLongPress = onLongPress;
+ }
+ case null:
+ break;
+ default:
+ assert(false, '${info.recognizer.runtimeType} is not supported.');
+ }
+ if (node.parentPaintClipRect != null) {
+ final Rect paintRect = node.parentPaintClipRect!.intersect(
+ currentRect,
+ );
+ configuration.isHidden = paintRect.isEmpty && !currentRect.isEmpty;
+ }
+ late final SemanticsNode newChild;
+ if (_cachedChildNodes?.isNotEmpty ?? false) {
+ newChild = _cachedChildNodes!.remove(_cachedChildNodes!.keys.first)!;
+ } else {
+ final UniqueKey key = UniqueKey();
+ newChild = SemanticsNode(
+ key: key,
+ showOnScreen: _createShowOnScreenFor(key),
+ );
+ }
+ newChild
+ ..updateWith(config: configuration)
+ ..rect = currentRect;
+ newChildCache[newChild.key!] = newChild;
+ newChildren.add(newChild);
+ }
+ }
+ _cachedChildNodes = newChildCache;
+ node.updateWith(config: config, childrenInInversePaintOrder: newChildren);
+ }
+
+ VoidCallback? _createShowOnScreenFor(Key key) {
+ return () {
+ final SemanticsNode node = _cachedChildNodes![key]!;
+ showOnScreen(descendant: this, rect: node.rect);
+ };
+ }
+
+ // TODO(ianh): in theory, [selection] could become null between when
+ // we last called describeSemanticsConfiguration and when the
+ // callbacks are invoked, in which case the callbacks will crash...
+
+ void _handleSetSelection(TextSelection selection) {
+ _setSelection(selection, SelectionChangedCause.keyboard);
+ }
+
+ void _handleMoveCursorForwardByCharacter(bool extendSelection) {
+ assert(selection != null);
+ final int? extentOffset = _textPainter.getOffsetAfter(
+ selection!.extentOffset,
+ );
+ if (extentOffset == null) {
+ return;
+ }
+ final int baseOffset =
+ !extendSelection ? extentOffset : selection!.baseOffset;
+ _setSelection(
+ TextSelection(baseOffset: baseOffset, extentOffset: extentOffset),
+ SelectionChangedCause.keyboard,
+ );
+ }
+
+ void _handleMoveCursorBackwardByCharacter(bool extendSelection) {
+ assert(selection != null);
+ final int? extentOffset = _textPainter.getOffsetBefore(
+ selection!.extentOffset,
+ );
+ if (extentOffset == null) {
+ return;
+ }
+ final int baseOffset =
+ !extendSelection ? extentOffset : selection!.baseOffset;
+ _setSelection(
+ TextSelection(baseOffset: baseOffset, extentOffset: extentOffset),
+ SelectionChangedCause.keyboard,
+ );
+ }
+
+ void _handleMoveCursorForwardByWord(bool extendSelection) {
+ assert(selection != null);
+ final TextRange currentWord = _textPainter.getWordBoundary(
+ selection!.extent,
+ );
+ final TextRange? nextWord = _getNextWord(currentWord.end);
+ if (nextWord == null) {
+ return;
+ }
+ final int baseOffset =
+ extendSelection ? selection!.baseOffset : nextWord.start;
+ _setSelection(
+ TextSelection(baseOffset: baseOffset, extentOffset: nextWord.start),
+ SelectionChangedCause.keyboard,
+ );
+ }
+
+ void _handleMoveCursorBackwardByWord(bool extendSelection) {
+ assert(selection != null);
+ final TextRange currentWord = _textPainter.getWordBoundary(
+ selection!.extent,
+ );
+ final TextRange? previousWord = _getPreviousWord(currentWord.start - 1);
+ if (previousWord == null) {
+ return;
+ }
+ final int baseOffset =
+ extendSelection ? selection!.baseOffset : previousWord.start;
+ _setSelection(
+ TextSelection(baseOffset: baseOffset, extentOffset: previousWord.start),
+ SelectionChangedCause.keyboard,
+ );
+ }
+
+ TextRange? _getNextWord(int offset) {
+ while (true) {
+ final TextRange range = _textPainter.getWordBoundary(
+ TextPosition(offset: offset),
+ );
+ if (!range.isValid || range.isCollapsed) {
+ return null;
+ }
+ if (!_onlyWhitespace(range)) {
+ return range;
+ }
+ offset = range.end;
+ }
+ }
+
+ TextRange? _getPreviousWord(int offset) {
+ while (offset >= 0) {
+ final TextRange range = _textPainter.getWordBoundary(
+ TextPosition(offset: offset),
+ );
+ if (!range.isValid || range.isCollapsed) {
+ return null;
+ }
+ if (!_onlyWhitespace(range)) {
+ return range;
+ }
+ offset = range.start - 1;
+ }
+ return null;
+ }
+
+ // Check if the given text range only contains white space or separator
+ // characters.
+ //
+ // Includes newline characters from ASCII and separators from the
+ // [unicode separator category](https://www.compart.com/en/unicode/category/Zs)
+ // TODO(zanderso): replace when we expose this ICU information.
+ bool _onlyWhitespace(TextRange range) {
+ for (int i = range.start; i < range.end; i++) {
+ final int codeUnit = text!.codeUnitAt(i)!;
+ if (!TextLayoutMetrics.isWhitespace(codeUnit)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @override
+ void attach(PipelineOwner owner) {
+ super.attach(owner);
+ _foregroundRenderObject?.attach(owner);
+ _backgroundRenderObject?.attach(owner);
+
+ _tap = TapGestureRecognizer(debugOwner: this)
+ ..onTapDown = _handleTapDown
+ ..onTap = _handleTap;
+ _longPress = LongPressGestureRecognizer(debugOwner: this)
+ ..onLongPress = _handleLongPress;
+ _offset.addListener(markNeedsPaint);
+ _showHideCursor();
+ _showCursor.addListener(_showHideCursor);
+ }
+
+ @override
+ void detach() {
+ _tap.dispose();
+ _longPress.dispose();
+ _offset.removeListener(markNeedsPaint);
+ _showCursor.removeListener(_showHideCursor);
+ super.detach();
+ _foregroundRenderObject?.detach();
+ _backgroundRenderObject?.detach();
+ }
+
+ @override
+ void redepthChildren() {
+ final RenderObject? foregroundChild = _foregroundRenderObject;
+ final RenderObject? backgroundChild = _backgroundRenderObject;
+ if (foregroundChild != null) {
+ redepthChild(foregroundChild);
+ }
+ if (backgroundChild != null) {
+ redepthChild(backgroundChild);
+ }
+ super.redepthChildren();
+ }
+
+ @override
+ void visitChildren(RenderObjectVisitor visitor) {
+ final RenderObject? foregroundChild = _foregroundRenderObject;
+ final RenderObject? backgroundChild = _backgroundRenderObject;
+ if (foregroundChild != null) {
+ visitor(foregroundChild);
+ }
+ if (backgroundChild != null) {
+ visitor(backgroundChild);
+ }
+ super.visitChildren(visitor);
+ }
+
+ bool get _isMultiline => maxLines != 1;
+
+ Axis get _viewportAxis => _isMultiline ? Axis.vertical : Axis.horizontal;
+
+ Offset get _paintOffset => switch (_viewportAxis) {
+ Axis.horizontal => Offset(-offset.pixels, 0.0),
+ Axis.vertical => Offset(0.0, -offset.pixels),
+ };
+
+ double get _viewportExtent {
+ assert(hasSize);
+ return switch (_viewportAxis) {
+ Axis.horizontal => size.width,
+ Axis.vertical => size.height,
+ };
+ }
+
+ double _getMaxScrollExtent(Size contentSize) {
+ assert(hasSize);
+ return switch (_viewportAxis) {
+ Axis.horizontal => math.max(0.0, contentSize.width - size.width),
+ Axis.vertical => math.max(0.0, contentSize.height - size.height),
+ };
+ }
+
+ // We need to check the paint offset here because during animation, the start of
+ // the text may position outside the visible region even when the text fits.
+ bool get _hasVisualOverflow =>
+ _maxScrollExtent > 0 || _paintOffset != Offset.zero;
+
+ /// Returns the local coordinates of the endpoints of the given selection.
+ ///
+ /// If the selection is collapsed (and therefore occupies a single point), the
+ /// returned list is of length one. Otherwise, the selection is not collapsed
+ /// and the returned list is of length two. In this case, however, the two
+ /// points might actually be co-located (e.g., because of a bidirectional
+ /// selection that contains some text but whose ends meet in the middle).
+ ///
+ /// See also:
+ ///
+ /// * [getLocalRectForCaret], which is the equivalent but for
+ /// a [TextPosition] rather than a [TextSelection].
+ List getEndpointsForSelection(TextSelection selection) {
+ _computeTextMetricsIfNeeded();
+
+ final Offset paintOffset = _paintOffset;
+
+ final List boxes = selection.isCollapsed
+ ? []
+ : _textPainter.getBoxesForSelection(
+ selection,
+ boxHeightStyle: selectionHeightStyle,
+ boxWidthStyle: selectionWidthStyle,
+ );
+ if (boxes.isEmpty) {
+ // TODO(mpcomplete): This doesn't work well at an RTL/LTR boundary.
+ final Offset caretOffset = _textPainter.getOffsetForCaret(
+ selection.extent,
+ _caretPrototype,
+ );
+ final Offset start =
+ Offset(0.0, preferredLineHeight) + caretOffset + paintOffset;
+ return [TextSelectionPoint(start, null)];
+ } else {
+ final Offset start = Offset(
+ clampDouble(boxes.first.start, 0, _textPainter.size.width),
+ boxes.first.bottom,
+ ) +
+ paintOffset;
+ final Offset end = Offset(
+ clampDouble(boxes.last.end, 0, _textPainter.size.width),
+ boxes.last.bottom,
+ ) +
+ paintOffset;
+ return [
+ TextSelectionPoint(start, boxes.first.direction),
+ TextSelectionPoint(end, boxes.last.direction),
+ ];
+ }
+ }
+
+ /// Returns the smallest [Rect], in the local coordinate system, that covers
+ /// the text within the [TextRange] specified.
+ ///
+ /// This method is used to calculate the approximate position of the IME bar
+ /// on iOS.
+ ///
+ /// Returns null if [TextRange.isValid] is false for the given `range`, or the
+ /// given `range` is collapsed.
+ Rect? getRectForComposingRange(TextRange range) {
+ if (!range.isValid || range.isCollapsed) {
+ return null;
+ }
+ _computeTextMetricsIfNeeded();
+
+ final List boxes = _textPainter.getBoxesForSelection(
+ TextSelection(baseOffset: range.start, extentOffset: range.end),
+ boxHeightStyle: selectionHeightStyle,
+ boxWidthStyle: selectionWidthStyle,
+ );
+
+ return boxes
+ .fold(
+ null,
+ (Rect? accum, TextBox incoming) =>
+ accum?.expandToInclude(incoming.toRect()) ?? incoming.toRect(),
+ )
+ ?.shift(_paintOffset);
+ }
+
+ /// Returns the position in the text for the given global coordinate.
+ ///
+ /// See also:
+ ///
+ /// * [getLocalRectForCaret], which is the reverse operation, taking
+ /// a [TextPosition] and returning a [Rect].
+ /// * [TextPainter.getPositionForOffset], which is the equivalent method
+ /// for a [TextPainter] object.
+ TextPosition getPositionForPoint(Offset globalPosition) {
+ _computeTextMetricsIfNeeded();
+ return _textPainter.getPositionForOffset(
+ globalToLocal(globalPosition) - _paintOffset,
+ );
+ }
+
+ /// Returns the [Rect] in local coordinates for the caret at the given text
+ /// position.
+ ///
+ /// See also:
+ ///
+ /// * [getPositionForPoint], which is the reverse operation, taking
+ /// an [Offset] in global coordinates and returning a [TextPosition].
+ /// * [getEndpointsForSelection], which is the equivalent but for
+ /// a selection rather than a particular text position.
+ /// * [TextPainter.getOffsetForCaret], the equivalent method for a
+ /// [TextPainter] object.
+ Rect getLocalRectForCaret(TextPosition caretPosition) {
+ _computeTextMetricsIfNeeded();
+ final Rect caretPrototype = _caretPrototype;
+ final Offset caretOffset = _textPainter.getOffsetForCaret(
+ caretPosition,
+ caretPrototype,
+ );
+ Rect caretRect = caretPrototype.shift(caretOffset + cursorOffset);
+ final double scrollableWidth = math.max(
+ _textPainter.width + _caretMargin,
+ size.width,
+ );
+
+ final double caretX = clampDouble(
+ caretRect.left,
+ 0,
+ math.max(scrollableWidth - _caretMargin, 0),
+ );
+ caretRect = Offset(caretX, caretRect.top) & caretRect.size;
+
+ final double fullHeight = _textPainter.getFullHeightForCaret(
+ caretPosition,
+ caretPrototype,
+ );
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.iOS:
+ case TargetPlatform.macOS:
+ // Center the caret vertically along the text.
+ final double heightDiff = fullHeight - caretRect.height;
+ caretRect = Rect.fromLTWH(
+ caretRect.left,
+ caretRect.top + heightDiff / 2,
+ caretRect.width,
+ caretRect.height,
+ );
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ // Override the height to take the full height of the glyph at the TextPosition
+ // when not on iOS. iOS has special handling that creates a taller caret.
+ // TODO(garyq): see https://github.com/flutter/flutter/issues/120836.
+ final double caretHeight = cursorHeight;
+ // Center the caret vertically along the text.
+ final double heightDiff = fullHeight - caretHeight;
+ caretRect = Rect.fromLTWH(
+ caretRect.left,
+ caretRect.top - _kCaretHeightOffset + heightDiff / 2,
+ caretRect.width,
+ caretHeight,
+ );
+ }
+
+ caretRect = caretRect.shift(_paintOffset);
+ return caretRect.shift(_snapToPhysicalPixel(caretRect.topLeft));
+ }
+
+ @override
+ double computeMinIntrinsicWidth(double height) {
+ final List placeholderDimensions =
+ layoutInlineChildren(
+ double.infinity,
+ (RenderBox child, BoxConstraints constraints) =>
+ Size(child.getMinIntrinsicWidth(double.infinity), 0.0),
+ ChildLayoutHelper.getDryBaseline,
+ );
+ final (double minWidth, double maxWidth) = _adjustConstraints();
+ return (_textIntrinsics
+ ..setPlaceholderDimensions(placeholderDimensions)
+ ..layout(minWidth: minWidth, maxWidth: maxWidth))
+ .minIntrinsicWidth;
+ }
+
+ @override
+ double computeMaxIntrinsicWidth(double height) {
+ final List placeholderDimensions =
+ layoutInlineChildren(
+ double.infinity,
+ // Height and baseline is irrelevant as all text will be laid
+ // out in a single line. Therefore, using 0.0 as a dummy for the height.
+ (RenderBox child, BoxConstraints constraints) =>
+ Size(child.getMaxIntrinsicWidth(double.infinity), 0.0),
+ ChildLayoutHelper.getDryBaseline,
+ );
+ final (double minWidth, double maxWidth) = _adjustConstraints();
+ return (_textIntrinsics
+ ..setPlaceholderDimensions(placeholderDimensions)
+ ..layout(minWidth: minWidth, maxWidth: maxWidth))
+ .maxIntrinsicWidth +
+ _caretMargin;
+ }
+
+ /// An estimate of the height of a line in the text. See [TextPainter.preferredLineHeight].
+ /// This does not require the layout to be updated.
+ double get preferredLineHeight => _textPainter.preferredLineHeight;
+
+ int? _cachedLineBreakCount;
+ int _countHardLineBreaks(String text) {
+ final int? cachedValue = _cachedLineBreakCount;
+ if (cachedValue != null) {
+ return cachedValue;
+ }
+ int count = 0;
+ for (int index = 0; index < text.length; index += 1) {
+ switch (text.codeUnitAt(index)) {
+ case 0x000A: // LF
+ case 0x0085: // NEL
+ case 0x000B: // VT
+ case 0x000C: // FF, treating it as a regular line separator
+ case 0x2028: // LS
+ case 0x2029: // PS
+ count += 1;
+ }
+ }
+ return _cachedLineBreakCount = count;
+ }
+
+ double _preferredHeight(double width) {
+ final int? maxLines = this.maxLines;
+ final int? minLines = this.minLines ?? maxLines;
+ final double minHeight = preferredLineHeight * (minLines ?? 0);
+ assert(maxLines != 1 || _textIntrinsics.maxLines == 1);
+
+ if (maxLines == null) {
+ final double estimatedHeight;
+ if (width == double.infinity) {
+ estimatedHeight =
+ preferredLineHeight * (_countHardLineBreaks(plainText) + 1);
+ } else {
+ final (double minWidth, double maxWidth) = _adjustConstraints(
+ maxWidth: width,
+ );
+ estimatedHeight = (_textIntrinsics
+ ..layout(minWidth: minWidth, maxWidth: maxWidth))
+ .height;
+ }
+ return math.max(estimatedHeight, minHeight);
+ }
+
+ // Special case maxLines == 1 since it forces the scrollable direction
+ // to be horizontal. Report the real height to prevent the text from being
+ // clipped.
+ if (maxLines == 1) {
+ // The _layoutText call lays out the paragraph using infinite width when
+ // maxLines == 1. Also _textPainter.maxLines will be set to 1 so should
+ // there be any line breaks only the first line is shown.
+ final (double minWidth, double maxWidth) = _adjustConstraints(
+ maxWidth: width,
+ );
+ return (_textIntrinsics..layout(minWidth: minWidth, maxWidth: maxWidth))
+ .height;
+ }
+ if (minLines == maxLines) {
+ return minHeight;
+ }
+ final double maxHeight = preferredLineHeight * maxLines;
+ final (double minWidth, double maxWidth) = _adjustConstraints(
+ maxWidth: width,
+ );
+ return clampDouble(
+ (_textIntrinsics..layout(minWidth: minWidth, maxWidth: maxWidth)).height,
+ minHeight,
+ maxHeight,
+ );
+ }
+
+ @override
+ double computeMinIntrinsicHeight(double width) =>
+ getMaxIntrinsicHeight(width);
+
+ @override
+ double computeMaxIntrinsicHeight(double width) {
+ _textIntrinsics.setPlaceholderDimensions(
+ layoutInlineChildren(
+ width,
+ ChildLayoutHelper.dryLayoutChild,
+ ChildLayoutHelper.getDryBaseline,
+ ),
+ );
+ return _preferredHeight(width);
+ }
+
+ @override
+ double computeDistanceToActualBaseline(TextBaseline baseline) {
+ _computeTextMetricsIfNeeded();
+ return _textPainter.computeDistanceToActualBaseline(baseline);
+ }
+
+ @override
+ bool hitTestSelf(Offset position) => true;
+
+ @override
+ @protected
+ bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
+ final Offset effectivePosition = position - _paintOffset;
+ final GlyphInfo? glyph = _textPainter.getClosestGlyphForOffset(
+ effectivePosition,
+ );
+ // The hit-test can't fall through the horizontal gaps between visually
+ // adjacent characters on the same line, even with a large letter-spacing or
+ // text justification, as graphemeClusterLayoutBounds.width is the advance
+ // width to the next character, so there's no gap between their
+ // graphemeClusterLayoutBounds rects.
+ final InlineSpan? spanHit = glyph != null &&
+ glyph.graphemeClusterLayoutBounds.contains(effectivePosition)
+ ? _textPainter.text!.getSpanForPosition(
+ TextPosition(offset: glyph.graphemeClusterCodeUnitRange.start),
+ )
+ : null;
+ switch (spanHit) {
+ case final HitTestTarget span:
+ result.add(HitTestEntry(span));
+ return true;
+ case _:
+ return hitTestInlineChildren(result, effectivePosition);
+ }
+ }
+
+ late TapGestureRecognizer _tap;
+ late LongPressGestureRecognizer _longPress;
+
+ @override
+ void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
+ assert(debugHandleEvent(event, entry));
+ if (event is PointerDownEvent) {
+ assert(!debugNeedsLayout);
+
+ if (!ignorePointer) {
+ // Propagates the pointer event to selection handlers.
+ _tap.addPointer(event);
+ _longPress.addPointer(event);
+ }
+ }
+ }
+
+ Offset? _lastTapDownPosition;
+ Offset? _lastSecondaryTapDownPosition;
+
+ /// {@template flutter.rendering.RenderEditable.lastSecondaryTapDownPosition}
+ /// The position of the most recent secondary tap down event on this text
+ /// input.
+ /// {@endtemplate}
+ Offset? get lastSecondaryTapDownPosition => _lastSecondaryTapDownPosition;
+
+ /// Tracks the position of a secondary tap event.
+ ///
+ /// Should be called before attempting to change the selection based on the
+ /// position of a secondary tap.
+ void handleSecondaryTapDown(TapDownDetails details) {
+ _lastTapDownPosition = details.globalPosition;
+ _lastSecondaryTapDownPosition = details.globalPosition;
+ }
+
+ /// If [ignorePointer] is false (the default) then this method is called by
+ /// the internal gesture recognizer's [TapGestureRecognizer.onTapDown]
+ /// callback.
+ ///
+ /// When [ignorePointer] is true, an ancestor widget must respond to tap
+ /// down events by calling this method.
+ void handleTapDown(TapDownDetails details) {
+ _lastTapDownPosition = details.globalPosition;
+ }
+
+ void _handleTapDown(TapDownDetails details) {
+ assert(!ignorePointer);
+ handleTapDown(details);
+ }
+
+ /// If [ignorePointer] is false (the default) then this method is called by
+ /// the internal gesture recognizer's [TapGestureRecognizer.onTap]
+ /// callback.
+ ///
+ /// When [ignorePointer] is true, an ancestor widget must respond to tap
+ /// events by calling this method.
+ void handleTap() {
+ selectPosition(cause: SelectionChangedCause.tap);
+ }
+
+ void _handleTap() {
+ assert(!ignorePointer);
+ handleTap();
+ }
+
+ /// If [ignorePointer] is false (the default) then this method is called by
+ /// the internal gesture recognizer's [DoubleTapGestureRecognizer.onDoubleTap]
+ /// callback.
+ ///
+ /// When [ignorePointer] is true, an ancestor widget must respond to double
+ /// tap events by calling this method.
+ void handleDoubleTap() {
+ selectWord(cause: SelectionChangedCause.doubleTap);
+ }
+
+ /// If [ignorePointer] is false (the default) then this method is called by
+ /// the internal gesture recognizer's [LongPressGestureRecognizer.onLongPress]
+ /// callback.
+ ///
+ /// When [ignorePointer] is true, an ancestor widget must respond to long
+ /// press events by calling this method.
+ void handleLongPress() {
+ selectWord(cause: SelectionChangedCause.longPress);
+ }
+
+ void _handleLongPress() {
+ assert(!ignorePointer);
+ handleLongPress();
+ }
+
+ /// Move selection to the location of the last tap down.
+ ///
+ /// {@template flutter.rendering.RenderEditable.selectPosition}
+ /// This method is mainly used to translate user inputs in global positions
+ /// into a [TextSelection]. When used in conjunction with a [EditableText],
+ /// the selection change is fed back into [TextEditingController.selection].
+ ///
+ /// If you have a [TextEditingController], it's generally easier to
+ /// programmatically manipulate its `value` or `selection` directly.
+ /// {@endtemplate}
+ void selectPosition({required SelectionChangedCause cause}) {
+ selectPositionAt(from: _lastTapDownPosition!, cause: cause);
+ }
+
+ /// Select text between the global positions [from] and [to].
+ ///
+ /// [from] corresponds to the [TextSelection.baseOffset], and [to] corresponds
+ /// to the [TextSelection.extentOffset].
+ void selectPositionAt({
+ required Offset from,
+ Offset? to,
+ required SelectionChangedCause cause,
+ }) {
+ final localFrom = globalToLocal(from);
+ _computeTextMetricsIfNeeded();
+ final TextPosition fromPosition = _textPainter.getPositionForOffset(
+ localFrom - _paintOffset,
+ );
+
+ final TextPosition? toPosition = to == null
+ ? null
+ : _textPainter.getPositionForOffset(
+ globalToLocal(to) - _paintOffset,
+ );
+
+ int baseOffset = fromPosition.offset;
+ int extentOffset = toPosition?.offset ?? fromPosition.offset;
+
+ // bggRGjQaUbCoE tap
+ if (toPosition == null) {
+ baseOffset = controller.tapOffset(
+ baseOffset,
+ textPainter: _textPainter,
+ localPos: localFrom,
+ lastTapDownPosition: from,
+ );
+ extentOffset = baseOffset;
+ }
+
+ final TextSelection newSelection = TextSelection(
+ baseOffset: baseOffset,
+ extentOffset: extentOffset,
+ affinity: fromPosition.affinity,
+ );
+
+ _setSelection(newSelection, cause);
+ }
+
+ /// {@macro flutter.painting.TextPainter.wordBoundaries}
+ WordBoundary get wordBoundaries => _textPainter.wordBoundaries;
+
+ /// Select a word around the location of the last tap down.
+ ///
+ /// {@macro flutter.rendering.RenderEditable.selectPosition}
+ void selectWord({required SelectionChangedCause cause}) {
+ selectWordsInRange(from: _lastTapDownPosition!, cause: cause);
+ }
+
+ /// Selects the set words of a paragraph that intersect a given range of global positions.
+ ///
+ /// The set of words selected are not strictly bounded by the range of global positions.
+ ///
+ /// The first and last endpoints of the selection will always be at the
+ /// beginning and end of a word respectively.
+ ///
+ /// {@macro flutter.rendering.RenderEditable.selectPosition}
+
+ void selectWordsInRange({
+ required Offset from,
+ Offset? to,
+ required SelectionChangedCause cause,
+ }) {
+ _computeTextMetricsIfNeeded();
+ final TextPosition fromPosition = _textPainter.getPositionForOffset(
+ globalToLocal(from) - _paintOffset,
+ );
+ final TextSelection fromWord = getWordAtOffset(fromPosition);
+ final TextPosition toPosition = to == null
+ ? fromPosition
+ : _textPainter.getPositionForOffset(
+ globalToLocal(to) - _paintOffset,
+ );
+ final TextSelection toWord =
+ toPosition == fromPosition ? fromWord : getWordAtOffset(toPosition);
+ final bool isFromWordBeforeToWord = fromWord.start < toWord.end;
+
+ // bggRGjQaUbCoE longpress
+ var startOffset =
+ isFromWordBeforeToWord ? fromWord.baseOffset : toWord.baseOffset;
+ var endOffset =
+ isFromWordBeforeToWord ? toWord.extentOffset : fromWord.extentOffset;
+ final newOffset = controller.longPressOffset(startOffset, endOffset);
+ startOffset = newOffset.startOffset;
+ endOffset = newOffset.endOffset;
+
+ _setSelection(
+ TextSelection(
+ baseOffset: isFromWordBeforeToWord ? startOffset : endOffset,
+ extentOffset: isFromWordBeforeToWord ? endOffset : startOffset,
+ affinity: fromWord.affinity,
+ ),
+ cause,
+ );
+ }
+
+ /// Move the selection to the beginning or end of a word.
+ ///
+ /// {@macro flutter.rendering.RenderEditable.selectPosition}
+ void selectWordEdge({required SelectionChangedCause cause}) {
+ _computeTextMetricsIfNeeded();
+ assert(_lastTapDownPosition != null);
+ final localPos = globalToLocal(_lastTapDownPosition!);
+ TextPosition position = _textPainter.getPositionForOffset(
+ localPos - _paintOffset,
+ );
+
+ // bggRGjQaUbCoE ios tap
+ final newOffset = controller.tapOffset(
+ position.offset,
+ textPainter: _textPainter,
+ localPos: localPos,
+ lastTapDownPosition: _lastTapDownPosition!,
+ );
+ position = TextPosition(offset: newOffset);
+
+ final TextRange word = _textPainter.getWordBoundary(position);
+ late TextSelection newSelection;
+ if (position.offset <= word.start) {
+ newSelection = TextSelection.collapsed(offset: word.start);
+ } else {
+ newSelection = TextSelection.collapsed(
+ offset: word.end,
+ affinity: TextAffinity.upstream,
+ );
+ }
+ _setSelection(newSelection, cause);
+ }
+
+ /// Returns a [TextSelection] that encompasses the word at the given
+ /// [TextPosition].
+ @visibleForTesting
+ TextSelection getWordAtOffset(TextPosition position) {
+ // When long-pressing past the end of the text, we want a collapsed cursor.
+ if (position.offset >= plainText.length) {
+ return TextSelection.fromPosition(
+ TextPosition(offset: plainText.length, affinity: TextAffinity.upstream),
+ );
+ }
+ // If text is obscured, the entire sentence should be treated as one word.
+ if (obscureText) {
+ return TextSelection(baseOffset: 0, extentOffset: plainText.length);
+ }
+ final TextRange word = _textPainter.getWordBoundary(position);
+ final int effectiveOffset;
+ switch (position.affinity) {
+ case TextAffinity.upstream:
+ // upstream affinity is effectively -1 in text position.
+ effectiveOffset = position.offset - 1;
+ case TextAffinity.downstream:
+ effectiveOffset = position.offset;
+ }
+ assert(effectiveOffset >= 0);
+
+ // On iOS, select the previous word if there is a previous word, or select
+ // to the end of the next word if there is a next word. Select nothing if
+ // there is neither a previous word nor a next word.
+ //
+ // If the platform is Android and the text is read only, try to select the
+ // previous word if there is one; otherwise, select the single whitespace at
+ // the position.
+ if (effectiveOffset > 0 &&
+ TextLayoutMetrics.isWhitespace(plainText.codeUnitAt(effectiveOffset))) {
+ final TextRange? previousWord = _getPreviousWord(word.start);
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.iOS:
+ if (previousWord == null) {
+ final TextRange? nextWord = _getNextWord(word.start);
+ if (nextWord == null) {
+ return TextSelection.collapsed(offset: position.offset);
+ }
+ return TextSelection(
+ baseOffset: position.offset,
+ extentOffset: nextWord.end,
+ );
+ }
+ return TextSelection(
+ baseOffset: previousWord.start,
+ extentOffset: position.offset,
+ );
+ case TargetPlatform.android:
+ if (readOnly) {
+ if (previousWord == null) {
+ return TextSelection(
+ baseOffset: position.offset,
+ extentOffset: position.offset + 1,
+ );
+ }
+ return TextSelection(
+ baseOffset: previousWord.start,
+ extentOffset: position.offset,
+ );
+ }
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.macOS:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ break;
+ }
+ }
+
+ return TextSelection(baseOffset: word.start, extentOffset: word.end);
+ }
+
+ // Placeholder dimensions representing the sizes of child inline widgets.
+ //
+ // These need to be cached because the text painter's placeholder dimensions
+ // will be overwritten during intrinsic width/height calculations and must be
+ // restored to the original values before final layout and painting.
+ List? _placeholderDimensions;
+
+ (double minWidth, double maxWidth) _adjustConstraints({
+ double minWidth = 0.0,
+ double maxWidth = double.infinity,
+ }) {
+ final double availableMaxWidth = math.max(0.0, maxWidth - _caretMargin);
+ final double availableMinWidth = math.min(minWidth, availableMaxWidth);
+ return (
+ forceLine ? availableMaxWidth : availableMinWidth,
+ _isMultiline ? availableMaxWidth : double.infinity,
+ );
+ }
+
+ // Computes the text metrics if `_textPainter`'s layout information was marked
+ // as dirty.
+ //
+ // This method must be called in `RenderEditable`'s public methods that expose
+ // `_textPainter`'s metrics. For instance, `systemFontsDidChange` sets
+ // _textPainter._paragraph to null, so accessing _textPainter's metrics
+ // immediately after `systemFontsDidChange` without first calling this method
+ // may crash.
+ //
+ // This method is also called in various paint methods (`RenderEditable.paint`
+ // as well as its foreground/background painters' `paint`). It's needed
+ // because invisible render objects kept in the tree by `KeepAlive` may not
+ // get a chance to do layout but can still paint.
+ // See https://github.com/flutter/flutter/issues/84896.
+ //
+ // This method only re-computes layout if the underlying `_textPainter`'s
+ // layout cache is invalidated (by calling `TextPainter.markNeedsLayout`), or
+ // the constraints used to layout the `_textPainter` is different. See
+ // `TextPainter.layout`.
+ void _computeTextMetricsIfNeeded() {
+ final (double minWidth, double maxWidth) = _adjustConstraints(
+ minWidth: constraints.minWidth,
+ maxWidth: constraints.maxWidth,
+ );
+ _textPainter.layout(minWidth: minWidth, maxWidth: maxWidth);
+ }
+
+ late Rect _caretPrototype;
+
+ // TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/120836
+ //
+ /// On iOS, the cursor is taller than the cursor on Android. The height
+ /// of the cursor for iOS is approximate and obtained through an eyeball
+ /// comparison.
+ void _computeCaretPrototype() {
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.iOS:
+ case TargetPlatform.macOS:
+ _caretPrototype = Rect.fromLTWH(
+ 0.0,
+ 0.0,
+ cursorWidth,
+ cursorHeight + 2,
+ );
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ _caretPrototype = Rect.fromLTWH(
+ 0.0,
+ _kCaretHeightOffset,
+ cursorWidth,
+ cursorHeight - 2.0 * _kCaretHeightOffset,
+ );
+ }
+ }
+
+ // Computes the offset to apply to the given [sourceOffset] so it perfectly
+ // snaps to physical pixels.
+ Offset _snapToPhysicalPixel(Offset sourceOffset) {
+ final Offset globalOffset = localToGlobal(sourceOffset);
+ final double pixelMultiple = 1.0 / _devicePixelRatio;
+ return Offset(
+ globalOffset.dx.isFinite
+ ? (globalOffset.dx / pixelMultiple).round() * pixelMultiple -
+ globalOffset.dx
+ : 0,
+ globalOffset.dy.isFinite
+ ? (globalOffset.dy / pixelMultiple).round() * pixelMultiple -
+ globalOffset.dy
+ : 0,
+ );
+ }
+
+ @override
+ @protected
+ Size computeDryLayout(covariant BoxConstraints constraints) {
+ final (double minWidth, double maxWidth) = _adjustConstraints(
+ minWidth: constraints.minWidth,
+ maxWidth: constraints.maxWidth,
+ );
+ _textIntrinsics
+ ..setPlaceholderDimensions(
+ layoutInlineChildren(
+ constraints.maxWidth,
+ ChildLayoutHelper.dryLayoutChild,
+ ChildLayoutHelper.getDryBaseline,
+ ),
+ )
+ ..layout(minWidth: minWidth, maxWidth: maxWidth);
+ final double width = forceLine
+ ? constraints.maxWidth
+ : constraints.constrainWidth(
+ _textIntrinsics.size.width + _caretMargin,
+ );
+ return Size(
+ width,
+ constraints.constrainHeight(_preferredHeight(constraints.maxWidth)),
+ );
+ }
+
+ @override
+ double computeDryBaseline(
+ covariant BoxConstraints constraints,
+ TextBaseline baseline,
+ ) {
+ final (double minWidth, double maxWidth) = _adjustConstraints(
+ minWidth: constraints.minWidth,
+ maxWidth: constraints.maxWidth,
+ );
+ _textIntrinsics
+ ..setPlaceholderDimensions(
+ layoutInlineChildren(
+ constraints.maxWidth,
+ ChildLayoutHelper.dryLayoutChild,
+ ChildLayoutHelper.getDryBaseline,
+ ),
+ )
+ ..layout(minWidth: minWidth, maxWidth: maxWidth);
+ return _textIntrinsics.computeDistanceToActualBaseline(baseline);
+ }
+
+ @override
+ void performLayout() {
+ final BoxConstraints constraints = this.constraints;
+ _placeholderDimensions = layoutInlineChildren(
+ constraints.maxWidth,
+ ChildLayoutHelper.layoutChild,
+ ChildLayoutHelper.getBaseline,
+ );
+ final (double minWidth, double maxWidth) = _adjustConstraints(
+ minWidth: constraints.minWidth,
+ maxWidth: constraints.maxWidth,
+ );
+ _textPainter
+ ..setPlaceholderDimensions(_placeholderDimensions)
+ ..layout(minWidth: minWidth, maxWidth: maxWidth);
+ positionInlineChildren(_textPainter.inlinePlaceholderBoxes!);
+ _computeCaretPrototype();
+
+ final double width = forceLine
+ ? constraints.maxWidth
+ : constraints.constrainWidth(_textPainter.width + _caretMargin);
+ assert(maxLines != 1 || _textPainter.maxLines == 1);
+ final double preferredHeight = switch (maxLines) {
+ null => math.max(
+ _textPainter.height,
+ preferredLineHeight * (minLines ?? 0),
+ ),
+ 1 => _textPainter.height,
+ final int maxLines => clampDouble(
+ _textPainter.height,
+ preferredLineHeight * (minLines ?? maxLines),
+ preferredLineHeight * maxLines,
+ ),
+ };
+
+ size = Size(width, constraints.constrainHeight(preferredHeight));
+ final Size contentSize = Size(
+ _textPainter.width + _caretMargin,
+ _textPainter.height,
+ );
+
+ final BoxConstraints painterConstraints = BoxConstraints.tight(contentSize);
+
+ _foregroundRenderObject?.layout(painterConstraints);
+ _backgroundRenderObject?.layout(painterConstraints);
+
+ _maxScrollExtent = _getMaxScrollExtent(contentSize);
+ offset.applyViewportDimension(_viewportExtent);
+ offset.applyContentDimensions(0.0, _maxScrollExtent);
+ }
+
+ // The relative origin in relation to the distance the user has theoretically
+ // dragged the floating cursor offscreen. This value is used to account for the
+ // difference in the rendering position and the raw offset value.
+ Offset _relativeOrigin = Offset.zero;
+ Offset? _previousOffset;
+ bool _shouldResetOrigin = true;
+ bool _resetOriginOnLeft = false;
+ bool _resetOriginOnRight = false;
+ bool _resetOriginOnTop = false;
+ bool _resetOriginOnBottom = false;
+ double? _resetFloatingCursorAnimationValue;
+
+ static Offset _calculateAdjustedCursorOffset(
+ Offset offset,
+ Rect boundingRects,
+ ) {
+ final double adjustedX = clampDouble(
+ offset.dx,
+ boundingRects.left,
+ boundingRects.right,
+ );
+ final double adjustedY = clampDouble(
+ offset.dy,
+ boundingRects.top,
+ boundingRects.bottom,
+ );
+ return Offset(adjustedX, adjustedY);
+ }
+
+ /// Returns the position within the text field closest to the raw cursor offset.
+ ///
+ /// See also:
+ ///
+ /// * [FloatingCursorDragState], which explains the floating cursor feature
+ /// in detail.
+ Offset calculateBoundedFloatingCursorOffset(
+ Offset rawCursorOffset, {
+ bool? shouldResetOrigin,
+ }) {
+ Offset deltaPosition = Offset.zero;
+ final double topBound = -floatingCursorAddedMargin.top;
+ final double bottomBound = math.min(size.height, _textPainter.height) -
+ preferredLineHeight +
+ floatingCursorAddedMargin.bottom;
+ final double leftBound = -floatingCursorAddedMargin.left;
+ final double rightBound = math.min(size.width, _textPainter.width) +
+ floatingCursorAddedMargin.right;
+ final Rect boundingRects = Rect.fromLTRB(
+ leftBound,
+ topBound,
+ rightBound,
+ bottomBound,
+ );
+
+ if (shouldResetOrigin != null) {
+ _shouldResetOrigin = shouldResetOrigin;
+ }
+
+ if (!_shouldResetOrigin) {
+ return _calculateAdjustedCursorOffset(rawCursorOffset, boundingRects);
+ }
+
+ if (_previousOffset != null) {
+ deltaPosition = rawCursorOffset - _previousOffset!;
+ }
+
+ // If the raw cursor offset has gone off an edge, we want to reset the relative
+ // origin of the dragging when the user drags back into the field.
+ if (_resetOriginOnLeft && deltaPosition.dx > 0) {
+ _relativeOrigin = Offset(
+ rawCursorOffset.dx - boundingRects.left,
+ _relativeOrigin.dy,
+ );
+ _resetOriginOnLeft = false;
+ } else if (_resetOriginOnRight && deltaPosition.dx < 0) {
+ _relativeOrigin = Offset(
+ rawCursorOffset.dx - boundingRects.right,
+ _relativeOrigin.dy,
+ );
+ _resetOriginOnRight = false;
+ }
+ if (_resetOriginOnTop && deltaPosition.dy > 0) {
+ _relativeOrigin = Offset(
+ _relativeOrigin.dx,
+ rawCursorOffset.dy - boundingRects.top,
+ );
+ _resetOriginOnTop = false;
+ } else if (_resetOriginOnBottom && deltaPosition.dy < 0) {
+ _relativeOrigin = Offset(
+ _relativeOrigin.dx,
+ rawCursorOffset.dy - boundingRects.bottom,
+ );
+ _resetOriginOnBottom = false;
+ }
+
+ final double currentX = rawCursorOffset.dx - _relativeOrigin.dx;
+ final double currentY = rawCursorOffset.dy - _relativeOrigin.dy;
+ final Offset adjustedOffset = _calculateAdjustedCursorOffset(
+ Offset(currentX, currentY),
+ boundingRects,
+ );
+
+ if (currentX < boundingRects.left && deltaPosition.dx < 0) {
+ _resetOriginOnLeft = true;
+ } else if (currentX > boundingRects.right && deltaPosition.dx > 0) {
+ _resetOriginOnRight = true;
+ }
+ if (currentY < boundingRects.top && deltaPosition.dy < 0) {
+ _resetOriginOnTop = true;
+ } else if (currentY > boundingRects.bottom && deltaPosition.dy > 0) {
+ _resetOriginOnBottom = true;
+ }
+
+ _previousOffset = rawCursorOffset;
+
+ return adjustedOffset;
+ }
+
+ /// Sets the screen position of the floating cursor and the text position
+ /// closest to the cursor.
+ ///
+ /// See also:
+ ///
+ /// * [FloatingCursorDragState], which explains the floating cursor feature
+ /// in detail.
+ void setFloatingCursor(
+ FloatingCursorDragState state,
+ Offset boundedOffset,
+ TextPosition lastTextPosition, {
+ double? resetLerpValue,
+ }) {
+ if (state == FloatingCursorDragState.End) {
+ _relativeOrigin = Offset.zero;
+ _previousOffset = null;
+ _shouldResetOrigin = true;
+ _resetOriginOnBottom = false;
+ _resetOriginOnTop = false;
+ _resetOriginOnRight = false;
+ _resetOriginOnBottom = false;
+ }
+ _floatingCursorOn = state != FloatingCursorDragState.End;
+ _resetFloatingCursorAnimationValue = resetLerpValue;
+ if (_floatingCursorOn) {
+ _floatingCursorTextPosition = lastTextPosition;
+ final double? animationValue = _resetFloatingCursorAnimationValue;
+ final EdgeInsets sizeAdjustment = animationValue != null
+ ? EdgeInsets.lerp(
+ _kFloatingCursorSizeIncrease,
+ EdgeInsets.zero,
+ animationValue,
+ )!
+ : _kFloatingCursorSizeIncrease;
+ _caretPainter.floatingCursorRect =
+ sizeAdjustment.inflateRect(_caretPrototype).shift(boundedOffset);
+ } else {
+ _caretPainter.floatingCursorRect = null;
+ }
+ _caretPainter.showRegularCaret = _resetFloatingCursorAnimationValue == null;
+ }
+
+ MapEntry _lineNumberFor(
+ TextPosition startPosition,
+ List metrics,
+ ) {
+ // TODO(LongCatIsLooong): include line boundaries information in
+ // ui.LineMetrics, then we can get rid of this.
+ final Offset offset = _textPainter.getOffsetForCaret(
+ startPosition,
+ Rect.zero,
+ );
+ for (final ui.LineMetrics lineMetrics in metrics) {
+ if (lineMetrics.baseline > offset.dy) {
+ return MapEntry(
+ lineMetrics.lineNumber,
+ Offset(offset.dx, lineMetrics.baseline),
+ );
+ }
+ }
+ assert(
+ startPosition.offset == 0,
+ 'unable to find the line for $startPosition',
+ );
+ return MapEntry(
+ math.max(0, metrics.length - 1),
+ Offset(
+ offset.dx,
+ metrics.isNotEmpty ? metrics.last.baseline + metrics.last.descent : 0.0,
+ ),
+ );
+ }
+
+ /// Starts a [VerticalCaretMovementRun] at the given location in the text, for
+ /// handling consecutive vertical caret movements.
+ ///
+ /// This can be used to handle consecutive upward/downward arrow key movements
+ /// in an input field.
+ ///
+ /// {@macro flutter.rendering.RenderEditable.verticalArrowKeyMovement}
+ ///
+ /// The [VerticalCaretMovementRun.isValid] property indicates whether the text
+ /// layout has changed and the vertical caret run is invalidated.
+ ///
+ /// The caller should typically discard a [VerticalCaretMovementRun] when
+ /// its [VerticalCaretMovementRun.isValid] becomes false, or on other
+ /// occasions where the vertical caret run should be interrupted.
+ VerticalCaretMovementRun startVerticalCaretMovement(
+ TextPosition startPosition,
+ ) {
+ final List metrics = _textPainter.computeLineMetrics();
+ final MapEntry currentLine = _lineNumberFor(
+ startPosition,
+ metrics,
+ );
+ return VerticalCaretMovementRun._(
+ this,
+ metrics,
+ startPosition,
+ currentLine.key,
+ currentLine.value,
+ );
+ }
+
+ void _paintContents(PaintingContext context, Offset offset) {
+ final Offset effectiveOffset = offset + _paintOffset;
+
+ if (selection != null && !_floatingCursorOn) {
+ _updateSelectionExtentsVisibility(effectiveOffset);
+ }
+
+ final RenderBox? foregroundChild = _foregroundRenderObject;
+ final RenderBox? backgroundChild = _backgroundRenderObject;
+
+ // The painters paint in the viewport's coordinate space, since the
+ // textPainter's coordinate space is not known to high level widgets.
+ if (backgroundChild != null) {
+ context.paintChild(backgroundChild, offset);
+ }
+
+ _textPainter.paint(context.canvas, effectiveOffset);
+ paintInlineChildren(context, effectiveOffset);
+
+ if (foregroundChild != null) {
+ context.paintChild(foregroundChild, offset);
+ }
+ }
+
+ final LayerHandle _leaderLayerHandler =
+ LayerHandle();
+
+ void _paintHandleLayers(
+ PaintingContext context,
+ List endpoints,
+ Offset offset,
+ ) {
+ Offset startPoint = endpoints[0].point;
+ startPoint = Offset(
+ clampDouble(startPoint.dx, 0.0, size.width),
+ clampDouble(startPoint.dy, 0.0, size.height),
+ );
+ _leaderLayerHandler.layer = LeaderLayer(
+ link: startHandleLayerLink,
+ offset: startPoint + offset,
+ );
+ context.pushLayer(_leaderLayerHandler.layer!, super.paint, Offset.zero);
+ if (endpoints.length == 2) {
+ Offset endPoint = endpoints[1].point;
+ endPoint = Offset(
+ clampDouble(endPoint.dx, 0.0, size.width),
+ clampDouble(endPoint.dy, 0.0, size.height),
+ );
+ context.pushLayer(
+ LeaderLayer(link: endHandleLayerLink, offset: endPoint + offset),
+ super.paint,
+ Offset.zero,
+ );
+ } else if (selection!.isCollapsed) {
+ context.pushLayer(
+ LeaderLayer(link: endHandleLayerLink, offset: startPoint + offset),
+ super.paint,
+ Offset.zero,
+ );
+ }
+ }
+
+ @override
+ void applyPaintTransform(RenderBox child, Matrix4 transform) {
+ if (child == _foregroundRenderObject || child == _backgroundRenderObject) {
+ return;
+ }
+ defaultApplyPaintTransform(child, transform);
+ }
+
+ @override
+ void paint(PaintingContext context, Offset offset) {
+ _computeTextMetricsIfNeeded();
+ if (_hasVisualOverflow && clipBehavior != Clip.none) {
+ _clipRectLayer.layer = context.pushClipRect(
+ needsCompositing,
+ offset,
+ Offset.zero & size,
+ _paintContents,
+ clipBehavior: clipBehavior,
+ oldLayer: _clipRectLayer.layer,
+ );
+ } else {
+ _clipRectLayer.layer = null;
+ _paintContents(context, offset);
+ }
+ final TextSelection? selection = this.selection;
+ if (selection != null && selection.isValid) {
+ _paintHandleLayers(context, getEndpointsForSelection(selection), offset);
+ }
+ }
+
+ final LayerHandle _clipRectLayer =
+ LayerHandle();
+
+ @override
+ Rect? describeApproximatePaintClip(RenderObject child) {
+ switch (clipBehavior) {
+ case Clip.none:
+ return null;
+ case Clip.hardEdge:
+ case Clip.antiAlias:
+ case Clip.antiAliasWithSaveLayer:
+ return _hasVisualOverflow ? Offset.zero & size : null;
+ }
+ }
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(ColorProperty('cursorColor', cursorColor));
+ properties.add(
+ DiagnosticsProperty>('showCursor', showCursor),
+ );
+ properties.add(IntProperty('maxLines', maxLines));
+ properties.add(IntProperty('minLines', minLines));
+ properties.add(
+ DiagnosticsProperty('expands', expands, defaultValue: false),
+ );
+ properties.add(ColorProperty('selectionColor', selectionColor));
+ properties.add(
+ DiagnosticsProperty(
+ 'textScaler',
+ textScaler,
+ defaultValue: TextScaler.noScaling,
+ ),
+ );
+ properties.add(
+ DiagnosticsProperty('locale', locale, defaultValue: null),
+ );
+ properties.add(DiagnosticsProperty('selection', selection));
+ properties.add(DiagnosticsProperty('offset', offset));
+ }
+
+ @override
+ List debugDescribeChildren() {
+ return [
+ if (text != null)
+ text!.toDiagnosticsNode(
+ name: 'text',
+ style: DiagnosticsTreeStyle.transition,
+ ),
+ ];
+ }
+}
+
+class _RenderEditableCustomPaint extends RenderBox {
+ _RenderEditableCustomPaint({RenderEditablePainter? painter})
+ : _painter = painter,
+ super();
+
+ @override
+ RenderEditable? get parent => super.parent as RenderEditable?;
+
+ @override
+ bool get isRepaintBoundary => true;
+
+ @override
+ bool get sizedByParent => true;
+
+ RenderEditablePainter? get painter => _painter;
+ RenderEditablePainter? _painter;
+ set painter(RenderEditablePainter? newValue) {
+ if (newValue == painter) {
+ return;
+ }
+
+ final RenderEditablePainter? oldPainter = painter;
+ _painter = newValue;
+
+ if (newValue?.shouldRepaint(oldPainter) ?? true) {
+ markNeedsPaint();
+ }
+
+ if (attached) {
+ oldPainter?.removeListener(markNeedsPaint);
+ newValue?.addListener(markNeedsPaint);
+ }
+ }
+
+ @override
+ void paint(PaintingContext context, Offset offset) {
+ final RenderEditable? parent = this.parent;
+ assert(parent != null);
+ final RenderEditablePainter? painter = this.painter;
+ if (painter != null && parent != null) {
+ parent._computeTextMetricsIfNeeded();
+ painter.paint(context.canvas, size, parent);
+ }
+ }
+
+ @override
+ void attach(PipelineOwner owner) {
+ super.attach(owner);
+ _painter?.addListener(markNeedsPaint);
+ }
+
+ @override
+ void detach() {
+ _painter?.removeListener(markNeedsPaint);
+ super.detach();
+ }
+
+ @override
+ @protected
+ Size computeDryLayout(covariant BoxConstraints constraints) =>
+ constraints.biggest;
+}
+
+/// An interface that paints within a [RenderEditable]'s bounds, above or
+/// beneath its text content.
+///
+/// This painter is typically used for painting auxiliary content that depends
+/// on text layout metrics (for instance, for painting carets and text highlight
+/// blocks). It can paint independently from its [RenderEditable], allowing it
+/// to repaint without triggering a repaint on the entire [RenderEditable] stack
+/// when only auxiliary content changes (e.g. a blinking cursor) are present. It
+/// will be scheduled to repaint when:
+///
+/// * It's assigned to a new [RenderEditable] (replacing a prior
+/// [RenderEditablePainter]) and the [shouldRepaint] method returns true.
+/// * Any of the [RenderEditable]s it is attached to repaints.
+/// * The [notifyListeners] method is called, which typically happens when the
+/// painter's attributes change.
+///
+/// See also:
+///
+/// * [RenderEditable.foregroundPainter], which takes a [RenderEditablePainter]
+/// and sets it as the foreground painter of the [RenderEditable].
+/// * [RenderEditable.painter], which takes a [RenderEditablePainter]
+/// and sets it as the background painter of the [RenderEditable].
+/// * [CustomPainter], a similar class which paints within a [RenderCustomPaint].
+abstract class RenderEditablePainter extends ChangeNotifier {
+ /// Determines whether repaint is needed when a new [RenderEditablePainter]
+ /// is provided to a [RenderEditable].
+ ///
+ /// If the new instance represents different information than the old
+ /// instance, then the method should return true, otherwise it should return
+ /// false. When [oldDelegate] is null, this method should always return true
+ /// unless the new painter initially does not paint anything.
+ ///
+ /// If the method returns false, then the [paint] call might be optimized
+ /// away. However, the [paint] method will get called whenever the
+ /// [RenderEditable]s it attaches to repaint, even if [shouldRepaint] returns
+ /// false.
+ bool shouldRepaint(RenderEditablePainter? oldDelegate);
+
+ /// Paints within the bounds of a [RenderEditable].
+ ///
+ /// The given [Canvas] has the same coordinate space as the [RenderEditable],
+ /// which may be different from the coordinate space the [RenderEditable]'s
+ /// [TextPainter] uses, when the text moves inside the [RenderEditable].
+ ///
+ /// Paint operations performed outside of the region defined by the [canvas]'s
+ /// origin and the [size] parameter may get clipped, when [RenderEditable]'s
+ /// [RenderEditable.clipBehavior] is not [Clip.none].
+ void paint(Canvas canvas, Size size, RenderEditable renderEditable);
+}
+
+class _TextHighlightPainter extends RenderEditablePainter {
+ _TextHighlightPainter({TextRange? highlightedRange, Color? highlightColor})
+ : _highlightedRange = highlightedRange,
+ _highlightColor = highlightColor;
+
+ final Paint highlightPaint = Paint();
+
+ Color? get highlightColor => _highlightColor;
+ Color? _highlightColor;
+ set highlightColor(Color? newValue) {
+ if (newValue == _highlightColor) {
+ return;
+ }
+ _highlightColor = newValue;
+ notifyListeners();
+ }
+
+ TextRange? get highlightedRange => _highlightedRange;
+ TextRange? _highlightedRange;
+ set highlightedRange(TextRange? newValue) {
+ if (newValue == _highlightedRange) {
+ return;
+ }
+ _highlightedRange = newValue;
+ notifyListeners();
+ }
+
+ /// Controls how tall the selection highlight boxes are computed to be.
+ ///
+ /// See [ui.BoxHeightStyle] for details on available styles.
+ ui.BoxHeightStyle get selectionHeightStyle => _selectionHeightStyle;
+ ui.BoxHeightStyle _selectionHeightStyle = ui.BoxHeightStyle.tight;
+ set selectionHeightStyle(ui.BoxHeightStyle value) {
+ if (_selectionHeightStyle == value) {
+ return;
+ }
+ _selectionHeightStyle = value;
+ notifyListeners();
+ }
+
+ /// Controls how wide the selection highlight boxes are computed to be.
+ ///
+ /// See [ui.BoxWidthStyle] for details on available styles.
+ ui.BoxWidthStyle get selectionWidthStyle => _selectionWidthStyle;
+ ui.BoxWidthStyle _selectionWidthStyle = ui.BoxWidthStyle.tight;
+ set selectionWidthStyle(ui.BoxWidthStyle value) {
+ if (_selectionWidthStyle == value) {
+ return;
+ }
+ _selectionWidthStyle = value;
+ notifyListeners();
+ }
+
+ @override
+ void paint(Canvas canvas, Size size, RenderEditable renderEditable) {
+ final TextRange? range = highlightedRange;
+ final Color? color = highlightColor;
+ if (range == null || color == null || range.isCollapsed) {
+ return;
+ }
+
+ highlightPaint.color = color;
+ final TextPainter textPainter = renderEditable._textPainter;
+ final List boxes = textPainter.getBoxesForSelection(
+ TextSelection(baseOffset: range.start, extentOffset: range.end),
+ boxHeightStyle: selectionHeightStyle,
+ boxWidthStyle: selectionWidthStyle,
+ );
+
+ for (final TextBox box in boxes) {
+ canvas.drawRect(
+ box.toRect().shift(renderEditable._paintOffset).intersect(
+ Rect.fromLTWH(0, 0, textPainter.width, textPainter.height),
+ ),
+ highlightPaint,
+ );
+ }
+ }
+
+ @override
+ bool shouldRepaint(RenderEditablePainter? oldDelegate) {
+ if (identical(oldDelegate, this)) {
+ return false;
+ }
+ if (oldDelegate == null) {
+ return highlightColor != null && highlightedRange != null;
+ }
+ return oldDelegate is! _TextHighlightPainter ||
+ oldDelegate.highlightColor != highlightColor ||
+ oldDelegate.highlightedRange != highlightedRange ||
+ oldDelegate.selectionHeightStyle != selectionHeightStyle ||
+ oldDelegate.selectionWidthStyle != selectionWidthStyle;
+ }
+}
+
+class _CaretPainter extends RenderEditablePainter {
+ _CaretPainter();
+
+ bool get shouldPaint => _shouldPaint;
+ bool _shouldPaint = true;
+ set shouldPaint(bool value) {
+ if (shouldPaint == value) {
+ return;
+ }
+ _shouldPaint = value;
+ notifyListeners();
+ }
+
+ // This is directly manipulated by the RenderEditable during
+ // setFloatingCursor.
+ //
+ // When changing this value, the caller is responsible for ensuring that
+ // listeners are notified.
+ bool showRegularCaret = false;
+
+ final Paint caretPaint = Paint();
+ late final Paint floatingCursorPaint = Paint();
+
+ Color? get caretColor => _caretColor;
+ Color? _caretColor;
+ set caretColor(Color? value) {
+ if (caretColor?.value == value?.value) {
+ return;
+ }
+
+ _caretColor = value;
+ notifyListeners();
+ }
+
+ Radius? get cursorRadius => _cursorRadius;
+ Radius? _cursorRadius;
+ set cursorRadius(Radius? value) {
+ if (_cursorRadius == value) {
+ return;
+ }
+ _cursorRadius = value;
+ notifyListeners();
+ }
+
+ Offset get cursorOffset => _cursorOffset;
+ Offset _cursorOffset = Offset.zero;
+ set cursorOffset(Offset value) {
+ if (_cursorOffset == value) {
+ return;
+ }
+ _cursorOffset = value;
+ notifyListeners();
+ }
+
+ Color? get backgroundCursorColor => _backgroundCursorColor;
+ Color? _backgroundCursorColor;
+ set backgroundCursorColor(Color? value) {
+ if (backgroundCursorColor?.value == value?.value) {
+ return;
+ }
+
+ _backgroundCursorColor = value;
+ if (showRegularCaret) {
+ notifyListeners();
+ }
+ }
+
+ Rect? get floatingCursorRect => _floatingCursorRect;
+ Rect? _floatingCursorRect;
+ set floatingCursorRect(Rect? value) {
+ if (_floatingCursorRect == value) {
+ return;
+ }
+ _floatingCursorRect = value;
+ notifyListeners();
+ }
+
+ void paintRegularCursor(
+ Canvas canvas,
+ RenderEditable renderEditable,
+ Color caretColor,
+ TextPosition textPosition,
+ ) {
+ final Rect integralRect = renderEditable.getLocalRectForCaret(textPosition);
+ if (shouldPaint) {
+ if (floatingCursorRect != null) {
+ final double distanceSquared =
+ (floatingCursorRect!.center - integralRect.center).distanceSquared;
+ if (distanceSquared <
+ _kShortestDistanceSquaredWithFloatingAndRegularCursors) {
+ return;
+ }
+ }
+ final Radius? radius = cursorRadius;
+ caretPaint.color = caretColor;
+ if (radius == null) {
+ canvas.drawRect(integralRect, caretPaint);
+ } else {
+ final RRect caretRRect = RRect.fromRectAndRadius(integralRect, radius);
+ canvas.drawRRect(caretRRect, caretPaint);
+ }
+ }
+ }
+
+ @override
+ void paint(Canvas canvas, Size size, RenderEditable renderEditable) {
+ // Compute the caret location even when `shouldPaint` is false.
+
+ final TextSelection? selection = renderEditable.selection;
+
+ if (selection == null || !selection.isCollapsed || !selection.isValid) {
+ return;
+ }
+
+ final Rect? floatingCursorRect = this.floatingCursorRect;
+
+ final Color? caretColor = floatingCursorRect == null
+ ? this.caretColor
+ : showRegularCaret
+ ? backgroundCursorColor
+ : null;
+ final TextPosition caretTextPosition = floatingCursorRect == null
+ ? selection.extent
+ : renderEditable._floatingCursorTextPosition;
+
+ if (caretColor != null) {
+ paintRegularCursor(canvas, renderEditable, caretColor, caretTextPosition);
+ }
+
+ final Color? floatingCursorColor = this.caretColor?.withOpacity(0.75);
+ // Floating Cursor.
+ if (floatingCursorRect == null ||
+ floatingCursorColor == null ||
+ !shouldPaint) {
+ return;
+ }
+
+ canvas.drawRRect(
+ RRect.fromRectAndRadius(floatingCursorRect, _kFloatingCursorRadius),
+ floatingCursorPaint..color = floatingCursorColor,
+ );
+ }
+
+ @override
+ bool shouldRepaint(RenderEditablePainter? oldDelegate) {
+ if (identical(this, oldDelegate)) {
+ return false;
+ }
+
+ if (oldDelegate == null) {
+ return shouldPaint;
+ }
+ return oldDelegate is! _CaretPainter ||
+ oldDelegate.shouldPaint != shouldPaint ||
+ oldDelegate.showRegularCaret != showRegularCaret ||
+ oldDelegate.caretColor != caretColor ||
+ oldDelegate.cursorRadius != cursorRadius ||
+ oldDelegate.cursorOffset != cursorOffset ||
+ oldDelegate.backgroundCursorColor != backgroundCursorColor ||
+ oldDelegate.floatingCursorRect != floatingCursorRect;
+ }
+}
+
+class _CompositeRenderEditablePainter extends RenderEditablePainter {
+ _CompositeRenderEditablePainter({required this.painters});
+
+ final List painters;
+
+ @override
+ void addListener(VoidCallback listener) {
+ for (final RenderEditablePainter painter in painters) {
+ painter.addListener(listener);
+ }
+ }
+
+ @override
+ void removeListener(VoidCallback listener) {
+ for (final RenderEditablePainter painter in painters) {
+ painter.removeListener(listener);
+ }
+ }
+
+ @override
+ void paint(Canvas canvas, Size size, RenderEditable renderEditable) {
+ for (final RenderEditablePainter painter in painters) {
+ painter.paint(canvas, size, renderEditable);
+ }
+ }
+
+ @override
+ bool shouldRepaint(RenderEditablePainter? oldDelegate) {
+ if (identical(oldDelegate, this)) {
+ return false;
+ }
+ if (oldDelegate is! _CompositeRenderEditablePainter ||
+ oldDelegate.painters.length != painters.length) {
+ return true;
+ }
+
+ final Iterator oldPainters =
+ oldDelegate.painters.iterator;
+ final Iterator newPainters = painters.iterator;
+ while (oldPainters.moveNext() && newPainters.moveNext()) {
+ if (newPainters.current.shouldRepaint(oldPainters.current)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/lib/common/widgets/text_field/editable_text.dart b/lib/common/widgets/text_field/editable_text.dart
index 913f7dd09..ba110cb2b 100644
--- a/lib/common/widgets/text_field/editable_text.dart
+++ b/lib/common/widgets/text_field/editable_text.dart
@@ -21,12 +21,20 @@ import 'dart:math' as math;
import 'dart:ui' as ui hide TextStyle;
import 'dart:ui';
+import 'package:PiliPlus/common/widgets/text_field/controller.dart';
+import 'package:PiliPlus/common/widgets/text_field/editable.dart';
import 'package:PiliPlus/common/widgets/text_field/spell_check.dart';
+import 'package:PiliPlus/common/widgets/text_field/text_selection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'
- hide SpellCheckConfiguration, buildTextSpanWithSpellCheckSuggestions;
-import 'package:flutter/rendering.dart';
+ hide
+ SpellCheckConfiguration,
+ buildTextSpanWithSpellCheckSuggestions,
+ TextSelectionOverlay,
+ TextSelectionGestureDetectorBuilder;
+import 'package:flutter/rendering.dart'
+ hide RenderEditable, VerticalCaretMovementRun;
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
@@ -124,7 +132,7 @@ class _RenderCompositionCallback extends RenderProxyBox {
/// A controller for an editable text field.
///
/// Whenever the user modifies a text field with an associated
-/// [TextEditingController], the text field updates [value] and the controller
+/// [RichTextEditingController], the text field updates [value] and the controller
/// notifies its listeners. Listeners can then read the [text] and [selection]
/// properties to learn what the user has typed or how the selection has been
/// updated.
@@ -132,7 +140,7 @@ class _RenderCompositionCallback extends RenderProxyBox {
/// Similarly, if you modify the [text] or [selection] properties, the text
/// field will be notified and will update itself appropriately.
///
-/// A [TextEditingController] can also be used to provide an initial value for a
+/// A [RichTextEditingController] can also be used to provide an initial value for a
/// text field. If you build a text field with a controller that already has
/// [text], the text field will use that text as its initial value.
///
@@ -150,11 +158,11 @@ class _RenderCompositionCallback extends RenderProxyBox {
/// controller's [value] instead. Setting [text] will clear the selection
/// and composing range.
///
-/// Remember to [dispose] of the [TextEditingController] when it is no longer
+/// Remember to [dispose] of the [RichTextEditingController] when it is no longer
/// needed. This will ensure we discard any resources used by the object.
///
/// {@tool dartpad}
-/// This example creates a [TextField] with a [TextEditingController] whose
+/// This example creates a [TextField] with a [RichTextEditingController] whose
/// change listener forces the entered text to be lower case and keeps the
/// cursor at the end of the input.
///
@@ -164,10 +172,10 @@ class _RenderCompositionCallback extends RenderProxyBox {
/// See also:
///
/// * [TextField], which is a Material Design text field that can be controlled
-/// with a [TextEditingController].
+/// with a [RichTextEditingController].
/// * [EditableText], which is a raw region of editable text that can be
-/// controlled with a [TextEditingController].
-/// * Learn how to use a [TextEditingController] in one of our [cookbook recipes](https://docs.flutter.dev/cookbook/forms/text-field-changes#2-use-a-texteditingcontroller).
+/// controlled with a [RichTextEditingController].
+/// * Learn how to use a [RichTextEditingController] in one of our [cookbook recipes](https://docs.flutter.dev/cookbook/forms/text-field-changes#2-use-a-texteditingcontroller).
// A time-value pair that represents a key frame in an animation.
class _KeyFrame {
@@ -273,7 +281,7 @@ class _DiscreteKeyFrameSimulation extends Simulation {
///
/// * The [inputFormatters] will be first applied to the user input.
///
-/// * The [controller]'s [TextEditingController.value] will be updated with the
+/// * The [controller]'s [RichTextEditingController.value] will be updated with the
/// formatted result, and the [controller]'s listeners will be notified.
///
/// * The [onChanged] callback, if specified, will be called last.
@@ -371,8 +379,8 @@ class _DiscreteKeyFrameSimulation extends Simulation {
/// | **Intent Class** | **Default Behavior** |
/// | :-------------------------------------- | :--------------------------------------------------- |
/// | [DoNothingAndStopPropagationTextIntent] | Does nothing in the input field, and prevents the key event from further propagating in the widget tree. |
-/// | [ReplaceTextIntent] | Replaces the current [TextEditingValue] in the input field's [TextEditingController], and triggers all related user callbacks and [TextInputFormatter]s. |
-/// | [UpdateSelectionIntent] | Updates the current selection in the input field's [TextEditingController], and triggers the [onSelectionChanged] callback. |
+/// | [ReplaceTextIntent] | Replaces the current [TextEditingValue] in the input field's [RichTextEditingController], and triggers all related user callbacks and [TextInputFormatter]s. |
+/// | [UpdateSelectionIntent] | Updates the current selection in the input field's [RichTextEditingController], and triggers the [onSelectionChanged] callback. |
/// | [CopySelectionTextIntent] | Copies or cuts the selected text into the clipboard |
/// | [PasteTextIntent] | Inserts the current text in the clipboard after the caret location, or replaces the selected text if the selection is not collapsed. |
///
@@ -573,8 +581,6 @@ class EditableText extends StatefulWidget {
this.spellCheckConfiguration,
this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
this.undoController,
- this.onDelAtUser,
- this.onMention,
}) : assert(obscuringCharacter.length == 1),
smartDashesType = smartDashesType ??
(obscureText ? SmartDashesType.disabled : SmartDashesType.enabled),
@@ -634,12 +640,8 @@ class EditableText extends StatefulWidget {
: inputFormatters,
showCursor = showCursor ?? !readOnly;
- final VoidCallback? onMention;
-
- final ValueChanged? onDelAtUser;
-
/// Controls the text being edited.
- final TextEditingController controller;
+ final RichTextEditingController controller;
/// Controls whether this widget has keyboard focus.
final FocusNode focusNode;
@@ -1068,7 +1070,7 @@ class EditableText extends StatefulWidget {
///
/// To be notified of all changes to the TextField's text, cursor,
/// and selection, one can add a listener to its [controller] with
- /// [TextEditingController.addListener].
+ /// [RichTextEditingController.addListener].
///
/// [onChanged] is called before [onSubmitted] when user indicates completion
/// of editing, such as when pressing the "done" button on the keyboard. That
@@ -1104,7 +1106,7 @@ class EditableText extends StatefulWidget {
/// runs and can validate and change ("format") the input value.
/// * [onEditingComplete], [onSubmitted], [onSelectionChanged]:
/// which are more specialized input change notifications.
- /// * [TextEditingController], which implements the [Listenable] interface
+ /// * [RichTextEditingController], which implements the [Listenable] interface
/// and notifies its listeners on [TextEditingValue] changes.
final ValueChanged? onChanged;
@@ -1268,7 +1270,7 @@ class EditableText extends StatefulWidget {
///
/// See also:
///
- /// * [TextEditingController], which implements the [Listenable] interface
+ /// * [RichTextEditingController], which implements the [Listenable] interface
/// and notifies its listeners on [TextEditingValue] changes.
/// {@endtemplate}
final List? inputFormatters;
@@ -1572,7 +1574,7 @@ class EditableText extends StatefulWidget {
///
/// Persisting and restoring the content of the [EditableText] is the
/// responsibility of the owner of the [controller], who may use a
- /// [RestorableTextEditingController] for that purpose.
+ /// [RestorableRichTextEditingController] for that purpose.
///
/// See also:
///
@@ -1955,8 +1957,8 @@ class EditableText extends StatefulWidget {
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
- properties.add(
- DiagnosticsProperty('controller', controller));
+ properties.add(DiagnosticsProperty(
+ 'controller', controller));
properties.add(DiagnosticsProperty('focusNode', focusNode));
properties.add(DiagnosticsProperty('obscureText', obscureText,
defaultValue: false));
@@ -2373,7 +2375,8 @@ class EditableTextState extends State
if (selection.isCollapsed || widget.obscureText) {
return;
}
- final String text = textEditingValue.text;
+ // TODO copy
+ String text = textEditingValue.text;
Clipboard.setData(ClipboardData(text: selection.textInside(text)));
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
@@ -2388,6 +2391,7 @@ class EditableTextState extends State
case TargetPlatform.android:
case TargetPlatform.fuchsia:
// Collapse the selection and hide the toolbar and handles.
+
userUpdateTextEditingValue(
TextEditingValue(
text: textEditingValue.text,
@@ -2455,13 +2459,37 @@ class EditableTextState extends State
final TextSelection selection = textEditingValue.selection;
final int lastSelectionIndex =
math.max(selection.baseOffset, selection.extentOffset);
- final TextEditingValue collapsedTextEditingValue =
- textEditingValue.copyWith(
- selection: TextSelection.collapsed(offset: lastSelectionIndex),
+ // final TextEditingValue collapsedTextEditingValue =
+ // textEditingValue.copyWith(
+ // selection: TextSelection.collapsed(offset: lastSelectionIndex),
+ // );
+ // final newValue = collapsedTextEditingValue.replaced(selection, text);
+
+ widget.controller.syncRichText(
+ selection.isCollapsed
+ ? TextEditingDeltaInsertion(
+ oldText: textEditingValue.text,
+ textInserted: text,
+ insertionOffset: selection.baseOffset,
+ selection: TextSelection.collapsed(offset: lastSelectionIndex),
+ composing: TextRange.empty,
+ )
+ : TextEditingDeltaReplacement(
+ oldText: textEditingValue.text,
+ replacementText: text,
+ replacedRange: selection,
+ selection: TextSelection.collapsed(offset: lastSelectionIndex),
+ composing: TextRange.empty,
+ ),
);
- userUpdateTextEditingValue(
- collapsedTextEditingValue.replaced(selection, text), cause);
+ final newValue = _value.copyWith(
+ text: widget.controller.plainText,
+ selection: widget.controller.newSelection,
+ );
+
+ userUpdateTextEditingValue(newValue, cause);
+
if (cause == SelectionChangedCause.toolbar) {
// Schedule a call to bringIntoView() after renderEditable updates.
SchedulerBinding.instance.addPostFrameCallback((_) {
@@ -2481,6 +2509,7 @@ class EditableTextState extends State
// selecting it.
return;
}
+
userUpdateTextEditingValue(
textEditingValue.copyWith(
selection: TextSelection(
@@ -3165,7 +3194,7 @@ class EditableTextState extends State
// everything else.
value = _value.copyWith(selection: value.selection);
}
- _lastKnownRemoteTextEditingValue = value;
+ _lastKnownRemoteTextEditingValue = _value;
if (value == _value) {
// This is possible, for example, when the numeric keyboard is input,
@@ -3257,47 +3286,25 @@ class EditableTextState extends State
}
}
- static final _atUserRegex = RegExp(r'@[\u4e00-\u9fa5a-zA-Z\d_-]+ $');
-
@override
void updateEditingValueWithDeltas(List textEditingDeltas) {
- var last = textEditingDeltas.lastOrNull;
- if (last case TextEditingDeltaInsertion e) {
- if (e.textInserted == '@') {
- widget.onMention?.call();
- }
- } else if (last case TextEditingDeltaDeletion e) {
- if (e.textDeleted == ' ') {
- final selection = _value.selection;
- if (selection.isCollapsed) {
- final text = _value.text;
- final offset = selection.baseOffset;
-
- RegExpMatch? match =
- _atUserRegex.firstMatch(text.substring(0, offset));
-
- if (match != null) {
- userUpdateTextEditingValue(
- TextEditingDeltaDeletion(
- oldText: e.oldText,
- deletedRange: TextRange(start: match.start, end: match.end),
- selection: TextSelection.collapsed(offset: match.start),
- composing: e.composing,
- ).apply(_value),
- SelectionChangedCause.keyboard,
- );
- widget.onDelAtUser?.call(match.group(0)!.trim());
- return;
- }
- }
- }
- }
-
- TextEditingValue value = _value;
for (final TextEditingDelta delta in textEditingDeltas) {
- value = delta.apply(value);
+ widget.controller.syncRichText(delta);
}
- updateEditingValue(value);
+
+ final newValue = _value.copyWith(
+ text: widget.controller.plainText,
+ selection: widget.controller.newSelection,
+ composing: textEditingDeltas.lastOrNull?.composing,
+ );
+
+ updateEditingValue(newValue);
+
+ // TextEditingValue value = _value;
+ // for (final TextEditingDelta delta in textEditingDeltas) {
+ // value = delta.apply(value);
+ // }
+ // updateEditingValue(value);
}
@override
@@ -3388,6 +3395,10 @@ class EditableTextState extends State
renderEditable
.localToGlobal(_lastBoundedOffset! + _floatingCursorOffset),
);
+
+ // bggRGjQaUbCoE ios single long press
+ _lastTextPosition = widget.controller.dragOffset(_lastTextPosition!);
+
renderEditable.setFloatingCursor(
point.state, _lastBoundedOffset!, _lastTextPosition!);
case FloatingCursorDragState.End:
@@ -4005,6 +4016,7 @@ class EditableTextState extends State
final EditableTextContextMenuBuilder? contextMenuBuilder =
widget.contextMenuBuilder;
final TextSelectionOverlay selectionOverlay = TextSelectionOverlay(
+ controller: widget.controller,
clipboardStatus: clipboardStatus,
context: context,
value: _value,
@@ -4248,6 +4260,15 @@ class EditableTextState extends State
final bool textCommitted =
!oldValue.composing.isCollapsed && value.composing.isCollapsed;
final bool selectionChanged = oldValue.selection != value.selection;
+ // if (!textChanged && selectionChanged) {
+ // value = value.copyWith(
+ // selection: widget.controller.updateSelection(
+ // oldSelection: _value.selection,
+ // newSelection: value.selection,
+ // cause: cause,
+ // ),
+ // );
+ // }
if (textChanged || textCommitted) {
// Only apply input formatters if the text has changed (including uncommitted
@@ -5144,10 +5165,34 @@ class EditableTextState extends State
void _replaceText(ReplaceTextIntent intent) {
final TextEditingValue oldValue = _value;
- final TextEditingValue newValue = intent.currentTextEditingValue.replaced(
- intent.replacementRange,
- intent.replacementText,
+ // final TextEditingValue newValue = intent.currentTextEditingValue.replaced(
+ // intent.replacementRange,
+ // intent.replacementText,
+ // );
+ widget.controller.syncRichText(
+ intent.replacementText.isEmpty
+ ? TextEditingDeltaDeletion(
+ oldText: oldValue.text,
+ deletedRange: intent.replacementRange,
+ selection: TextSelection.collapsed(
+ offset: intent.replacementRange.start),
+ composing: TextRange.empty,
+ )
+ : TextEditingDeltaReplacement(
+ oldText: oldValue.text,
+ replacementText: intent.replacementText,
+ replacedRange: intent.replacementRange,
+ selection: TextSelection.collapsed(
+ offset: intent.replacementRange.start),
+ composing: TextRange.empty,
+ ),
);
+
+ final newValue = oldValue.copyWith(
+ text: widget.controller.plainText,
+ selection: widget.controller.newSelection,
+ );
+
userUpdateTextEditingValue(newValue, intent.cause);
// If there's no change in text and selection (e.g. when selecting and
@@ -5258,6 +5303,7 @@ class EditableTextState extends State
}
bringIntoView(nextSelection.extent);
+
userUpdateTextEditingValue(
_value.copyWith(selection: nextSelection),
SelectionChangedCause.keyboard,
@@ -5275,8 +5321,17 @@ class EditableTextState extends State
);
bringIntoView(intent.newSelection.extent);
+
+ // bggRGjQaUbCoE keyboard
+ TextSelection newSelection = intent.newSelection;
+ if (newSelection.isCollapsed) {
+ newSelection = widget.controller.keyboardOffset(newSelection);
+ } else {
+ newSelection = widget.controller.keyboardOffsets(newSelection);
+ }
+
userUpdateTextEditingValue(
- intent.currentTextEditingValue.copyWith(selection: intent.newSelection),
+ intent.currentTextEditingValue.copyWith(selection: newSelection),
intent.cause,
);
}
@@ -5358,8 +5413,6 @@ class EditableTextState extends State
this,
_characterBoundary,
_moveBeyondTextBoundary,
- atUserRegex: _atUserRegex,
- onDelAtUser: widget.onDelAtUser,
),
),
DeleteToNextWordBoundaryIntent: _makeOverridable(
@@ -5623,6 +5676,7 @@ class EditableTextState extends State
child: SizeChangedLayoutNotifier(
child: _Editable(
key: _editableKey,
+ controller: widget.controller,
startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink,
inlineSpan: buildTextSpan(),
@@ -5823,6 +5877,7 @@ class _Editable extends MultiChildRenderObjectWidget {
this.promptRectRange,
this.promptRectColor,
required this.clipBehavior,
+ required this.controller,
}) : super(
children: WidgetSpan.extractFromInlineSpan(inlineSpan, textScaler));
@@ -5864,10 +5919,12 @@ class _Editable extends MultiChildRenderObjectWidget {
final TextRange? promptRectRange;
final Color? promptRectColor;
final Clip clipBehavior;
+ final RichTextEditingController controller;
@override
RenderEditable createRenderObject(BuildContext context) {
return RenderEditable(
+ controller: controller,
text: inlineSpan,
cursorColor: cursorColor,
startHandleLayerLink: startHandleLayerLink,
@@ -6200,16 +6257,12 @@ class _DeleteTextAction
_DeleteTextAction(
this.state,
this.getTextBoundary,
- this._applyTextBoundary, {
- this.atUserRegex,
- this.onDelAtUser,
- });
+ this._applyTextBoundary,
+ );
final EditableTextState state;
final TextBoundary Function() getTextBoundary;
final _ApplyTextBoundary _applyTextBoundary;
- final RegExp? atUserRegex;
- final ValueChanged? onDelAtUser;
void _hideToolbarIfTextChanged(ReplaceTextIntent intent) {
if (state._selectionOverlay == null ||
@@ -6255,28 +6308,6 @@ class _DeleteTextAction
return Actions.invoke(context!, replaceTextIntent);
}
- final value = state._value;
- final text = value.text;
-
- if (!intent.forward) {
- if (text.isNotEmpty && selection.baseOffset != 0) {
- String subText = text.substring(0, selection.baseOffset);
- RegExpMatch? match = atUserRegex?.firstMatch(subText);
- if (match != null) {
- onDelAtUser?.call(match.group(0)!.trim());
- final range = TextRange(start: match.start, end: match.end);
- final ReplaceTextIntent replaceTextIntent = ReplaceTextIntent(
- value,
- '',
- range,
- SelectionChangedCause.keyboard,
- );
- _hideToolbarIfTextChanged(replaceTextIntent);
- return Actions.invoke(context!, replaceTextIntent);
- }
- }
- }
-
final int target =
_applyTextBoundary(selection.base, intent.forward, getTextBoundary())
.offset;
@@ -6284,14 +6315,14 @@ class _DeleteTextAction
final TextRange rangeToDelete = TextSelection(
baseOffset: intent.forward
? atomicBoundary.getLeadingTextBoundaryAt(selection.baseOffset) ??
- text.length
+ state._value.text.length
: atomicBoundary
.getTrailingTextBoundaryAt(selection.baseOffset - 1) ??
0,
extentOffset: target,
);
final ReplaceTextIntent replaceTextIntent = ReplaceTextIntent(
- value,
+ state._value,
'',
rangeToDelete,
SelectionChangedCause.keyboard,
diff --git a/lib/common/widgets/text_field/text_field.dart b/lib/common/widgets/text_field/text_field.dart
index 1925d2ddd..26c3811fd 100644
--- a/lib/common/widgets/text_field/text_field.dart
+++ b/lib/common/widgets/text_field/text_field.dart
@@ -14,6 +14,7 @@ library;
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle;
import 'package:PiliPlus/common/widgets/text_field/adaptive_text_selection_toolbar.dart';
+import 'package:PiliPlus/common/widgets/text_field/controller.dart';
import 'package:PiliPlus/common/widgets/text_field/cupertino/cupertino_spell_check_suggestions_toolbar.dart';
import 'package:PiliPlus/common/widgets/text_field/cupertino/cupertino_text_field.dart';
import 'package:PiliPlus/common/widgets/text_field/editable_text.dart';
@@ -32,7 +33,8 @@ import 'package:flutter/cupertino.dart'
buildTextSpanWithSpellCheckSuggestions,
CupertinoTextField,
TextSelectionGestureDetectorBuilderDelegate,
- TextSelectionGestureDetectorBuilder;
+ TextSelectionGestureDetectorBuilder,
+ TextSelectionOverlay;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'
@@ -46,7 +48,8 @@ import 'package:flutter/material.dart'
EditableTextContextMenuBuilder,
buildTextSpanWithSpellCheckSuggestions,
TextSelectionGestureDetectorBuilderDelegate,
- TextSelectionGestureDetectorBuilder;
+ TextSelectionGestureDetectorBuilder,
+ TextSelectionOverlay;
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
@@ -62,7 +65,7 @@ export 'package:flutter/services.dart'
// late BuildContext context;
// late FocusNode myFocusNode;
-/// Signature for the [TextField.buildCounter] callback.
+/// Signature for the [RichTextField.buildCounter] callback.
typedef InputCounterWidgetBuilder = Widget? Function(
/// The build context for the TextField.
BuildContext context, {
@@ -79,11 +82,12 @@ typedef InputCounterWidgetBuilder = Widget? Function(
class _TextFieldSelectionGestureDetectorBuilder
extends TextSelectionGestureDetectorBuilder {
- _TextFieldSelectionGestureDetectorBuilder({required _TextFieldState state})
+ _TextFieldSelectionGestureDetectorBuilder(
+ {required _RichTextFieldState state})
: _state = state,
super(delegate: state);
- final _TextFieldState _state;
+ final _RichTextFieldState _state;
@override
bool get onUserTapAlwaysCalled => _state.widget.onTapAlwaysCalled;
@@ -119,7 +123,7 @@ class _TextFieldSelectionGestureDetectorBuilder
/// If [decoration] is non-null (which is the default), the text field requires
/// one of its ancestors to be a [Material] widget.
///
-/// To integrate the [TextField] into a [Form] with other [FormField] widgets,
+/// To integrate the [RichTextField] into a [Form] with other [FormField] widgets,
/// consider using [TextFormField].
///
/// {@template flutter.material.textfield.wantKeepAlive}
@@ -129,7 +133,7 @@ class _TextFieldSelectionGestureDetectorBuilder
/// disposed.
/// {@endtemplate}
///
-/// Remember to call [TextEditingController.dispose] on the [TextEditingController]
+/// Remember to call [RichTextEditingController.dispose] on the [RichTextEditingController]
/// when it is no longer needed. This will ensure we discard any resources used
/// by the object.
///
@@ -141,7 +145,7 @@ class _TextFieldSelectionGestureDetectorBuilder
/// ## Obscured Input
///
/// {@tool dartpad}
-/// This example shows how to create a [TextField] that will obscure input. The
+/// This example shows how to create a [RichTextField] that will obscure input. The
/// [InputDecoration] surrounds the field in a border using [OutlineInputBorder]
/// and adds a label.
///
@@ -173,7 +177,7 @@ class _TextFieldSelectionGestureDetectorBuilder
/// callback.
///
/// Keep in mind you can also always read the current string from a TextField's
-/// [TextEditingController] using [TextEditingController.text].
+/// [RichTextEditingController] using [RichTextEditingController.text].
///
/// ## Handling emojis and other complex characters
/// {@macro flutter.widgets.EditableText.onChanged}
@@ -196,10 +200,10 @@ class _TextFieldSelectionGestureDetectorBuilder
///
/// ## Scrolling Considerations
///
-/// If this [TextField] is not a descendant of [Scaffold] and is being used
+/// If this [RichTextField] is not a descendant of [Scaffold] and is being used
/// within a [Scrollable] or nested [Scrollable]s, consider placing a
/// [ScrollNotificationObserver] above the root [Scrollable] that contains this
-/// [TextField] to ensure proper scroll coordination for [TextField] and its
+/// [RichTextField] to ensure proper scroll coordination for [RichTextField] and its
/// components like [TextSelectionOverlay].
///
/// See also:
@@ -208,7 +212,7 @@ class _TextFieldSelectionGestureDetectorBuilder
/// * [InputDecorator], which shows the labels and other visual elements that
/// surround the actual text editing widget.
/// * [EditableText], which is the raw text editing control at the heart of a
-/// [TextField]. The [EditableText] widget is rarely used directly unless
+/// [RichTextField]. The [EditableText] widget is rarely used directly unless
/// you are implementing an entirely different design language, such as
/// Cupertino.
/// *
@@ -216,7 +220,7 @@ class _TextFieldSelectionGestureDetectorBuilder
/// * Cookbook: [Handle changes to a text field](https://docs.flutter.dev/cookbook/forms/text-field-changes)
/// * Cookbook: [Retrieve the value of a text field](https://docs.flutter.dev/cookbook/forms/retrieve-input)
/// * Cookbook: [Focus and text fields](https://docs.flutter.dev/cookbook/forms/focus)
-class TextField extends StatefulWidget {
+class RichTextField extends StatefulWidget {
/// Creates a Material Design text field.
///
/// If [decoration] is non-null (which is the default), the text field requires
@@ -236,7 +240,7 @@ class TextField extends StatefulWidget {
/// field showing how many characters have been entered. If the value is
/// set to a positive integer it will also display the maximum allowed
/// number of characters to be entered. If the value is set to
- /// [TextField.noMaxLength] then only the current length is displayed.
+ /// [RichTextField.noMaxLength] then only the current length is displayed.
///
/// After [maxLength] characters have been input, additional input
/// is ignored, unless [maxLengthEnforcement] is set to
@@ -261,10 +265,10 @@ class TextField extends StatefulWidget {
///
/// * [maxLength], which discusses the precise meaning of "number of
/// characters" and how it may differ from the intuitive meaning.
- const TextField({
+ const RichTextField({
super.key,
this.groupId = EditableText,
- this.controller,
+ required this.controller,
this.focusNode,
this.undoController,
this.decoration = const InputDecoration(),
@@ -340,8 +344,6 @@ class TextField extends StatefulWidget {
this.canRequestFocus = true,
this.spellCheckConfiguration,
this.magnifierConfiguration,
- this.onDelAtUser,
- this.onMention,
}) : assert(obscuringCharacter.length == 1),
smartDashesType = smartDashesType ??
(obscureText ? SmartDashesType.disabled : SmartDashesType.enabled),
@@ -360,7 +362,7 @@ class TextField extends StatefulWidget {
assert(!obscureText || maxLines == 1,
'Obscured fields cannot be multiline.'),
assert(maxLength == null ||
- maxLength == TextField.noMaxLength ||
+ maxLength == RichTextField.noMaxLength ||
maxLength > 0),
// Assert the following instead of setting it directly to avoid surprising the user by silently changing the value they set.
assert(
@@ -374,10 +376,6 @@ class TextField extends StatefulWidget {
enableInteractiveSelection =
enableInteractiveSelection ?? (!readOnly || !obscureText);
- final VoidCallback? onMention;
-
- final ValueChanged? onDelAtUser;
-
/// The configuration for the magnifier of this text field.
///
/// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier]
@@ -398,8 +396,8 @@ class TextField extends StatefulWidget {
/// Controls the text being edited.
///
- /// If null, this widget will create its own [TextEditingController].
- final TextEditingController? controller;
+ /// If null, this widget will create its own [RichTextEditingController].
+ final RichTextEditingController controller;
/// Defines the keyboard focus for this widget.
///
@@ -570,7 +568,7 @@ class TextField extends StatefulWidget {
/// If set, a character counter will be displayed below the
/// field showing how many characters have been entered. If set to a number
/// greater than 0, it will also display the maximum number allowed. If set
- /// to [TextField.noMaxLength] then only the current character count is displayed.
+ /// to [RichTextField.noMaxLength] then only the current character count is displayed.
///
/// After [maxLength] characters have been input, additional input
/// is ignored, unless [maxLengthEnforcement] is set to
@@ -579,9 +577,9 @@ class TextField extends StatefulWidget {
/// The text field enforces the length with a [LengthLimitingTextInputFormatter],
/// which is evaluated after the supplied [inputFormatters], if any.
///
- /// This value must be either null, [TextField.noMaxLength], or greater than 0.
+ /// This value must be either null, [RichTextField.noMaxLength], or greater than 0.
/// If null (the default) then there is no limit to the number of characters
- /// that can be entered. If set to [TextField.noMaxLength], then no limit will
+ /// that can be entered. If set to [RichTextField.noMaxLength], then no limit will
/// be enforced, but the number of characters entered will still be displayed.
///
/// Whitespace characters (e.g. newline, space, tab) are included in the
@@ -740,7 +738,7 @@ class TextField extends StatefulWidget {
///
/// {@tool dartpad}
/// This example shows how to use a `TextFieldTapRegion` to wrap a set of
- /// "spinner" buttons that increment and decrement a value in the [TextField]
+ /// "spinner" buttons that increment and decrement a value in the [RichTextField]
/// without causing the text field to lose keyboard focus.
///
/// This example includes a generic `SpinnerField` class that you can copy
@@ -770,7 +768,7 @@ class TextField extends StatefulWidget {
///
/// If this property is null, [WidgetStateMouseCursor.textable] will be used.
///
- /// The [mouseCursor] is the only property of [TextField] that controls the
+ /// The [mouseCursor] is the only property of [RichTextField] that controls the
/// appearance of the mouse pointer. All other properties related to "cursor"
/// stand for the text cursor, which is usually a blinking vertical line at
/// the editing position.
@@ -903,7 +901,7 @@ class TextField extends StatefulWidget {
/// See also:
/// * [SpellCheckConfiguration.misspelledTextStyle], the style configured to
/// mark misspelled words with.
- /// * [CupertinoTextField.cupertinoMisspelledTextStyle], the style configured
+ /// * [CupertinoRichTextField.cupertinoMisspelledTextStyle], the style configured
/// to mark misspelled words with in the Cupertino style.
static const TextStyle materialMisspelledTextStyle = TextStyle(
decoration: TextDecoration.underline,
@@ -911,18 +909,18 @@ class TextField extends StatefulWidget {
decorationStyle: TextDecorationStyle.wavy,
);
- /// Default builder for [TextField]'s spell check suggestions toolbar.
+ /// Default builder for [RichTextField]'s spell check suggestions toolbar.
///
/// On Apple platforms, builds an iOS-style toolbar. Everywhere else, builds
/// an Android-style toolbar.
///
/// See also:
/// * [spellCheckConfiguration], where this is typically specified for
- /// [TextField].
+ /// [RichTextField].
/// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the
- /// parameter for which this is the default value for [TextField].
- /// * [CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder], which
- /// is like this but specifies the default for [CupertinoTextField].
+ /// parameter for which this is the default value for [RichTextField].
+ /// * [CupertinoRichTextField.defaultSpellCheckSuggestionsToolbarBuilder], which
+ /// is like this but specifies the default for [CupertinoRichTextField].
@visibleForTesting
static Widget defaultSpellCheckSuggestionsToolbarBuilder(
BuildContext context,
@@ -955,22 +953,22 @@ class TextField extends StatefulWidget {
}
return configuration.copyWith(
misspelledTextStyle: configuration.misspelledTextStyle ??
- TextField.materialMisspelledTextStyle,
+ RichTextField.materialMisspelledTextStyle,
spellCheckSuggestionsToolbarBuilder:
configuration.spellCheckSuggestionsToolbarBuilder ??
- TextField.defaultSpellCheckSuggestionsToolbarBuilder,
+ RichTextField.defaultSpellCheckSuggestionsToolbarBuilder,
);
}
@override
- State createState() => _TextFieldState();
+ State createState() => _RichTextFieldState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(
- DiagnosticsProperty('controller', controller,
+ DiagnosticsProperty('controller', controller,
defaultValue: null),
)
..add(DiagnosticsProperty('focusNode', focusNode,
@@ -1152,12 +1150,12 @@ class TextField extends StatefulWidget {
}
}
-class _TextFieldState extends State
+class _RichTextFieldState extends State
with RestorationMixin
implements TextSelectionGestureDetectorBuilderDelegate, AutofillClient {
- RestorableTextEditingController? _controller;
- TextEditingController get _effectiveController =>
- widget.controller ?? _controller!.value;
+ // RestorableRichTextEditingController? _controller;
+ RichTextEditingController get _effectiveController => widget.controller;
+ // widget.controller ?? _controller!.value;
FocusNode? _focusNode;
FocusNode get _effectiveFocusNode =>
@@ -1199,11 +1197,13 @@ class _TextFieldState extends State
bool get _hasIntrinsicError =>
widget.maxLength != null &&
widget.maxLength! > 0 &&
- (widget.controller == null
- ? !restorePending &&
- _effectiveController.value.text.characters.length >
- widget.maxLength!
- : _effectiveController.value.text.characters.length >
+ (
+ // widget.controller == null
+ // ? !restorePending &&
+ // _effectiveController.value.text.characters.length >
+ // widget.maxLength!
+ // :
+ _effectiveController.value.text.characters.length >
widget.maxLength!);
bool get _hasError =>
@@ -1295,9 +1295,9 @@ class _TextFieldState extends State
super.initState();
_selectionGestureDetectorBuilder =
_TextFieldSelectionGestureDetectorBuilder(state: this);
- if (widget.controller == null) {
- _createLocalController();
- }
+ // if (widget.controller == null) {
+ // _createLocalController();
+ // }
_effectiveFocusNode.canRequestFocus = widget.canRequestFocus && _isEnabled;
_effectiveFocusNode.addListener(_handleFocusChanged);
_initStatesController();
@@ -1319,15 +1319,15 @@ class _TextFieldState extends State
}
@override
- void didUpdateWidget(TextField oldWidget) {
+ void didUpdateWidget(RichTextField oldWidget) {
super.didUpdateWidget(oldWidget);
- if (widget.controller == null && oldWidget.controller != null) {
- _createLocalController(oldWidget.controller!.value);
- } else if (widget.controller != null && oldWidget.controller == null) {
- unregisterFromRestoration(_controller!);
- _controller!.dispose();
- _controller = null;
- }
+ // if (widget.controller == null && oldWidget.controller != null) {
+ // _createLocalController(oldWidget.controller!.value);
+ // } else if (widget.controller != null && oldWidget.controller == null) {
+ // unregisterFromRestoration(_controller!);
+ // _controller!.dispose();
+ // _controller = null;
+ // }
if (widget.focusNode != oldWidget.focusNode) {
(oldWidget.focusNode ?? _focusNode)?.removeListener(_handleFocusChanged);
@@ -1345,11 +1345,11 @@ class _TextFieldState extends State
}
if (widget.statesController == oldWidget.statesController) {
- _statesController.update(MaterialState.disabled, !_isEnabled);
- _statesController.update(MaterialState.hovered, _isHovering);
- _statesController.update(
- MaterialState.focused, _effectiveFocusNode.hasFocus);
- _statesController.update(MaterialState.error, _hasError);
+ _statesController
+ ..update(MaterialState.disabled, !_isEnabled)
+ ..update(MaterialState.hovered, _isHovering)
+ ..update(MaterialState.focused, _effectiveFocusNode.hasFocus)
+ ..update(MaterialState.error, _hasError);
} else {
oldWidget.statesController?.removeListener(_handleStatesControllerChange);
if (widget.statesController != null) {
@@ -1362,25 +1362,25 @@ class _TextFieldState extends State
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
- if (_controller != null) {
- _registerController();
- }
+ // if (_controller != null) {
+ // _registerController();
+ // }
}
- void _registerController() {
- assert(_controller != null);
- registerForRestoration(_controller!, 'controller');
- }
+ // void _registerController() {
+ // assert(_controller != null);
+ // registerForRestoration(_controller!, 'controller');
+ // }
- void _createLocalController([TextEditingValue? value]) {
- assert(_controller == null);
- _controller = value == null
- ? RestorableTextEditingController()
- : RestorableTextEditingController.fromValue(value);
- if (!restorePending) {
- _registerController();
- }
- }
+ // void _createLocalController([TextEditingValue? value]) {
+ // assert(_controller == null);
+ // _controller = value == null
+ // ? RestorableRichTextEditingController()
+ // : RestorableRichTextEditingController.fromValue(value);
+ // if (!restorePending) {
+ // _registerController();
+ // }
+ // }
@override
String? get restorationId => widget.restorationId;
@@ -1389,7 +1389,7 @@ class _TextFieldState extends State
void dispose() {
_effectiveFocusNode.removeListener(_handleFocusChanged);
_focusNode?.dispose();
- _controller?.dispose();
+ // _controller?.dispose();
_statesController.removeListener(_handleStatesControllerChange);
_internalStatesController?.dispose();
super.dispose();
@@ -1582,7 +1582,7 @@ class _TextFieldState extends State
).merge(providedStyle);
final Brightness keyboardAppearance =
widget.keyboardAppearance ?? theme.brightness;
- final TextEditingController controller = _effectiveController;
+ final RichTextEditingController controller = _effectiveController;
final FocusNode focusNode = _effectiveFocusNode;
final List formatters = [
...?widget.inputFormatters,
@@ -1601,14 +1601,15 @@ class _TextFieldState extends State
case TargetPlatform.iOS:
case TargetPlatform.macOS:
spellCheckConfiguration =
- CupertinoTextField.inferIOSSpellCheckConfiguration(
+ CupertinoRichTextField.inferIOSSpellCheckConfiguration(
widget.spellCheckConfiguration,
);
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
- spellCheckConfiguration = TextField.inferAndroidSpellCheckConfiguration(
+ spellCheckConfiguration =
+ RichTextField.inferAndroidSpellCheckConfiguration(
widget.spellCheckConfiguration,
);
}
@@ -1804,8 +1805,6 @@ class _TextFieldState extends State
spellCheckConfiguration: spellCheckConfiguration,
magnifierConfiguration: widget.magnifierConfiguration ??
TextMagnifier.adaptiveMagnifierConfiguration,
- onDelAtUser: widget.onDelAtUser,
- onMention: widget.onMention,
),
),
);
diff --git a/lib/common/widgets/text_field/text_selection.dart b/lib/common/widgets/text_field/text_selection.dart
index 683a5cef7..ef149f913 100644
--- a/lib/common/widgets/text_field/text_selection.dart
+++ b/lib/common/widgets/text_field/text_selection.dart
@@ -1,10 +1,13 @@
import 'dart:math' as math;
import 'dart:ui';
+import 'package:PiliPlus/common/widgets/text_field/controller.dart';
+import 'package:PiliPlus/common/widgets/text_field/editable.dart';
import 'package:PiliPlus/common/widgets/text_field/editable_text.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
-import 'package:flutter/rendering.dart';
+import 'package:flutter/material.dart' show kMinInteractiveDimension;
+import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' hide EditableText, EditableTextState;
@@ -1471,3 +1474,1732 @@ class _TextSelectionGestureDetectorState
);
}
}
+
+/// An object that manages a pair of text selection handles for a
+/// [RenderEditable].
+///
+/// This class is a wrapper of [SelectionOverlay] to provide APIs specific for
+/// [RenderEditable]s. To manage selection handles for custom widgets, use
+/// [SelectionOverlay] instead.
+class TextSelectionOverlay {
+ /// Creates an object that manages overlay entries for selection handles.
+ ///
+ /// The [context] must have an [Overlay] as an ancestor.
+ TextSelectionOverlay({
+ required TextEditingValue value,
+ required this.context,
+ Widget? debugRequiredFor,
+ required LayerLink toolbarLayerLink,
+ required LayerLink startHandleLayerLink,
+ required LayerLink endHandleLayerLink,
+ required this.renderObject,
+ this.selectionControls,
+ bool handlesVisible = false,
+ required this.selectionDelegate,
+ DragStartBehavior dragStartBehavior = DragStartBehavior.start,
+ VoidCallback? onSelectionHandleTapped,
+ ClipboardStatusNotifier? clipboardStatus,
+ this.contextMenuBuilder,
+ required TextMagnifierConfiguration magnifierConfiguration,
+ required this.controller,
+ }) : _handlesVisible = handlesVisible,
+ _value = value {
+ assert(debugMaybeDispatchCreated('widgets', 'TextSelectionOverlay', this));
+ renderObject.selectionStartInViewport
+ .addListener(_updateTextSelectionOverlayVisibilities);
+ renderObject.selectionEndInViewport
+ .addListener(_updateTextSelectionOverlayVisibilities);
+ _updateTextSelectionOverlayVisibilities();
+ _selectionOverlay = SelectionOverlay(
+ magnifierConfiguration: magnifierConfiguration,
+ context: context,
+ debugRequiredFor: debugRequiredFor,
+ // The metrics will be set when show handles.
+ startHandleType: TextSelectionHandleType.collapsed,
+ startHandlesVisible: _effectiveStartHandleVisibility,
+ lineHeightAtStart: 0.0,
+ onStartHandleDragStart: _handleSelectionStartHandleDragStart,
+ onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate,
+ onEndHandleDragEnd: _handleAnyDragEnd,
+ endHandleType: TextSelectionHandleType.collapsed,
+ endHandlesVisible: _effectiveEndHandleVisibility,
+ lineHeightAtEnd: 0.0,
+ onEndHandleDragStart: _handleSelectionEndHandleDragStart,
+ onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,
+ onStartHandleDragEnd: _handleAnyDragEnd,
+ toolbarVisible: _effectiveToolbarVisibility,
+ selectionEndpoints: const [],
+ selectionControls: selectionControls,
+ selectionDelegate: selectionDelegate,
+ clipboardStatus: clipboardStatus,
+ startHandleLayerLink: startHandleLayerLink,
+ endHandleLayerLink: endHandleLayerLink,
+ toolbarLayerLink: toolbarLayerLink,
+ onSelectionHandleTapped: onSelectionHandleTapped,
+ dragStartBehavior: dragStartBehavior,
+ toolbarLocation: renderObject.lastSecondaryTapDownPosition,
+ );
+ }
+
+ final RichTextEditingController controller;
+
+ /// {@template flutter.widgets.SelectionOverlay.context}
+ /// The context in which the selection UI should appear.
+ ///
+ /// This context must have an [Overlay] as an ancestor because this object
+ /// will display the text selection handles in that [Overlay].
+ /// {@endtemplate}
+ final BuildContext context;
+
+ // TODO(mpcomplete): what if the renderObject is removed or replaced, or
+ // moves? Not sure what cases I need to handle, or how to handle them.
+ /// The editable line in which the selected text is being displayed.
+ final RenderEditable renderObject;
+
+ /// {@macro flutter.widgets.SelectionOverlay.selectionControls}
+ final TextSelectionControls? selectionControls;
+
+ /// {@macro flutter.widgets.SelectionOverlay.selectionDelegate}
+ final TextSelectionDelegate selectionDelegate;
+
+ late final SelectionOverlay _selectionOverlay;
+
+ /// {@macro flutter.widgets.EditableText.contextMenuBuilder}
+ ///
+ /// If not provided, no context menu will be built.
+ final WidgetBuilder? contextMenuBuilder;
+
+ /// Retrieve current value.
+ @visibleForTesting
+ TextEditingValue get value => _value;
+
+ TextEditingValue _value;
+
+ TextSelection get _selection => _value.selection;
+
+ final ValueNotifier _effectiveStartHandleVisibility =
+ ValueNotifier(false);
+ final ValueNotifier _effectiveEndHandleVisibility =
+ ValueNotifier(false);
+ final ValueNotifier _effectiveToolbarVisibility =
+ ValueNotifier(false);
+
+ void _updateTextSelectionOverlayVisibilities() {
+ _effectiveStartHandleVisibility.value =
+ _handlesVisible && renderObject.selectionStartInViewport.value;
+ _effectiveEndHandleVisibility.value =
+ _handlesVisible && renderObject.selectionEndInViewport.value;
+ _effectiveToolbarVisibility.value =
+ renderObject.selectionStartInViewport.value ||
+ renderObject.selectionEndInViewport.value;
+ }
+
+ /// Whether selection handles are visible.
+ ///
+ /// Set to false if you want to hide the handles. Use this property to show or
+ /// hide the handle without rebuilding them.
+ ///
+ /// Defaults to false.
+ bool get handlesVisible => _handlesVisible;
+ bool _handlesVisible = false;
+ set handlesVisible(bool visible) {
+ if (_handlesVisible == visible) {
+ return;
+ }
+ _handlesVisible = visible;
+ _updateTextSelectionOverlayVisibilities();
+ }
+
+ /// {@macro flutter.widgets.SelectionOverlay.showHandles}
+ void showHandles() {
+ _updateSelectionOverlay();
+ _selectionOverlay.showHandles();
+ }
+
+ /// {@macro flutter.widgets.SelectionOverlay.hideHandles}
+ void hideHandles() => _selectionOverlay.hideHandles();
+
+ /// {@macro flutter.widgets.SelectionOverlay.showToolbar}
+ void showToolbar() {
+ _updateSelectionOverlay();
+
+ if (selectionControls != null &&
+ selectionControls is! TextSelectionHandleControls) {
+ _selectionOverlay.showToolbar();
+ return;
+ }
+
+ if (contextMenuBuilder == null) {
+ return;
+ }
+
+ assert(context.mounted);
+ _selectionOverlay.showToolbar(
+ context: context, contextMenuBuilder: contextMenuBuilder);
+ return;
+ }
+
+ /// Shows toolbar with spell check suggestions of misspelled words that are
+ /// available for click-and-replace.
+ void showSpellCheckSuggestionsToolbar(
+ WidgetBuilder spellCheckSuggestionsToolbarBuilder) {
+ _updateSelectionOverlay();
+ assert(context.mounted);
+ _selectionOverlay.showSpellCheckSuggestionsToolbar(
+ context: context,
+ builder: spellCheckSuggestionsToolbarBuilder,
+ );
+ hideHandles();
+ }
+
+ /// {@macro flutter.widgets.SelectionOverlay.showMagnifier}
+ void showMagnifier(Offset positionToShow) {
+ final TextPosition position =
+ renderObject.getPositionForPoint(positionToShow);
+ _updateSelectionOverlay();
+ _selectionOverlay.showMagnifier(
+ _buildMagnifier(
+ currentTextPosition: position,
+ globalGesturePosition: positionToShow,
+ renderEditable: renderObject,
+ ),
+ );
+ }
+
+ /// {@macro flutter.widgets.SelectionOverlay.updateMagnifier}
+ void updateMagnifier(Offset positionToShow) {
+ final TextPosition position =
+ renderObject.getPositionForPoint(positionToShow);
+ _updateSelectionOverlay();
+ _selectionOverlay.updateMagnifier(
+ _buildMagnifier(
+ currentTextPosition: position,
+ globalGesturePosition: positionToShow,
+ renderEditable: renderObject,
+ ),
+ );
+ }
+
+ /// {@macro flutter.widgets.SelectionOverlay.hideMagnifier}
+ void hideMagnifier() {
+ _selectionOverlay.hideMagnifier();
+ }
+
+ /// Updates the overlay after the selection has changed.
+ ///
+ /// If this method is called while the [SchedulerBinding.schedulerPhase] is
+ /// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or
+ /// paint phases (see [WidgetsBinding.drawFrame]), then the update is delayed
+ /// until the post-frame callbacks phase. Otherwise the update is done
+ /// synchronously. This means that it is safe to call during builds, but also
+ /// that if you do call this during a build, the UI will not update until the
+ /// next frame (i.e. many milliseconds later).
+ void update(TextEditingValue newValue) {
+ if (_value == newValue) {
+ return;
+ }
+ _value = newValue;
+ _updateSelectionOverlay();
+ // _updateSelectionOverlay may not rebuild the selection overlay if the
+ // text metrics and selection doesn't change even if the text has changed.
+ // This rebuild is needed for the toolbar to update based on the latest text
+ // value.
+ _selectionOverlay.markNeedsBuild();
+ }
+
+ void _updateSelectionOverlay() {
+ _selectionOverlay
+ // Update selection handle metrics.
+ ..startHandleType = _chooseType(
+ renderObject.textDirection,
+ TextSelectionHandleType.left,
+ TextSelectionHandleType.right,
+ )
+ ..lineHeightAtStart = _getStartGlyphHeight()
+ ..endHandleType = _chooseType(
+ renderObject.textDirection,
+ TextSelectionHandleType.right,
+ TextSelectionHandleType.left,
+ )
+ ..lineHeightAtEnd = _getEndGlyphHeight()
+ // Update selection toolbar metrics.
+ ..selectionEndpoints = renderObject.getEndpointsForSelection(_selection)
+ ..toolbarLocation = renderObject.lastSecondaryTapDownPosition;
+ }
+
+ /// Causes the overlay to update its rendering.
+ ///
+ /// This is intended to be called when the [renderObject] may have changed its
+ /// text metrics (e.g. because the text was scrolled).
+ void updateForScroll() {
+ _updateSelectionOverlay();
+ // This method may be called due to windows metrics changes. In that case,
+ // non of the properties in _selectionOverlay will change, but a rebuild is
+ // still needed.
+ _selectionOverlay.markNeedsBuild();
+ }
+
+ /// Whether the handles are currently visible.
+ bool get handlesAreVisible =>
+ _selectionOverlay._handles != null && handlesVisible;
+
+ /// {@macro flutter.widgets.SelectionOverlay.toolbarIsVisible}
+ ///
+ /// See also:
+ ///
+ /// * [spellCheckToolbarIsVisible], which is only whether the spell check menu
+ /// specifically is visible.
+ bool get toolbarIsVisible => _selectionOverlay.toolbarIsVisible;
+
+ /// Whether the magnifier is currently visible.
+ bool get magnifierIsVisible => _selectionOverlay._magnifierController.shown;
+
+ /// Whether the spell check menu is currently visible.
+ ///
+ /// See also:
+ ///
+ /// * [toolbarIsVisible], which is whether any toolbar is visible.
+ bool get spellCheckToolbarIsVisible =>
+ _selectionOverlay._spellCheckToolbarController.isShown;
+
+ /// {@macro flutter.widgets.SelectionOverlay.hide}
+ void hide() => _selectionOverlay.hide();
+
+ /// {@macro flutter.widgets.SelectionOverlay.hideToolbar}
+ void hideToolbar() => _selectionOverlay.hideToolbar();
+
+ /// {@macro flutter.widgets.SelectionOverlay.dispose}
+ void dispose() {
+ assert(debugMaybeDispatchDisposed(this));
+ _selectionOverlay.dispose();
+ renderObject.selectionStartInViewport
+ .removeListener(_updateTextSelectionOverlayVisibilities);
+ renderObject.selectionEndInViewport
+ .removeListener(_updateTextSelectionOverlayVisibilities);
+ _effectiveToolbarVisibility.dispose();
+ _effectiveStartHandleVisibility.dispose();
+ _effectiveEndHandleVisibility.dispose();
+ hideToolbar();
+ }
+
+ double _getStartGlyphHeight() {
+ final String currText = selectionDelegate.textEditingValue.text;
+ final int firstSelectedGraphemeExtent;
+ Rect? startHandleRect;
+ // Only calculate handle rects if the text in the previous frame
+ // is the same as the text in the current frame. This is done because
+ // widget.renderObject contains the renderEditable from the previous frame.
+ // If the text changed between the current and previous frames then
+ // widget.renderObject.getRectForComposingRange might fail. In cases where
+ // the current frame is different from the previous we fall back to
+ // renderObject.preferredLineHeight.
+ if (renderObject.plainText == currText &&
+ _selection.isValid &&
+ !_selection.isCollapsed) {
+ final String selectedGraphemes = _selection.textInside(currText);
+ firstSelectedGraphemeExtent = selectedGraphemes.characters.first.length;
+ startHandleRect = renderObject.getRectForComposingRange(
+ TextRange(
+ start: _selection.start,
+ end: _selection.start + firstSelectedGraphemeExtent),
+ );
+ }
+ return startHandleRect?.height ?? renderObject.preferredLineHeight;
+ }
+
+ double _getEndGlyphHeight() {
+ final String currText = selectionDelegate.textEditingValue.text;
+ final int lastSelectedGraphemeExtent;
+ Rect? endHandleRect;
+ // See the explanation in _getStartGlyphHeight.
+ if (renderObject.plainText == currText &&
+ _selection.isValid &&
+ !_selection.isCollapsed) {
+ final String selectedGraphemes = _selection.textInside(currText);
+ lastSelectedGraphemeExtent = selectedGraphemes.characters.last.length;
+ endHandleRect = renderObject.getRectForComposingRange(
+ TextRange(
+ start: _selection.end - lastSelectedGraphemeExtent,
+ end: _selection.end),
+ );
+ }
+ return endHandleRect?.height ?? renderObject.preferredLineHeight;
+ }
+
+ MagnifierInfo _buildMagnifier({
+ required RenderEditable renderEditable,
+ required Offset globalGesturePosition,
+ required TextPosition currentTextPosition,
+ }) {
+ final TextSelection lineAtOffset =
+ renderEditable.getLineAtOffset(currentTextPosition);
+ final TextPosition positionAtEndOfLine = TextPosition(
+ offset: lineAtOffset.extentOffset,
+ affinity: TextAffinity.upstream,
+ );
+
+ // Default affinity is downstream.
+ final TextPosition positionAtBeginningOfLine =
+ TextPosition(offset: lineAtOffset.baseOffset);
+
+ final Rect localLineBoundaries = Rect.fromPoints(
+ renderEditable.getLocalRectForCaret(positionAtBeginningOfLine).topCenter,
+ renderEditable.getLocalRectForCaret(positionAtEndOfLine).bottomCenter,
+ );
+ final RenderBox? overlay = Overlay.of(context, rootOverlay: true)
+ .context
+ .findRenderObject() as RenderBox?;
+ final Matrix4 transformToOverlay = renderEditable.getTransformTo(overlay);
+ final Rect overlayLineBoundaries = MatrixUtils.transformRect(
+ transformToOverlay,
+ localLineBoundaries,
+ );
+
+ final Rect localCaretRect =
+ renderEditable.getLocalRectForCaret(currentTextPosition);
+ final Rect overlayCaretRect =
+ MatrixUtils.transformRect(transformToOverlay, localCaretRect);
+
+ final Offset overlayGesturePosition =
+ overlay?.globalToLocal(globalGesturePosition) ?? globalGesturePosition;
+
+ return MagnifierInfo(
+ fieldBounds: MatrixUtils.transformRect(
+ transformToOverlay, renderEditable.paintBounds),
+ globalGesturePosition: overlayGesturePosition,
+ caretRect: overlayCaretRect,
+ currentLineBoundaries: overlayLineBoundaries,
+ );
+ }
+
+ // The contact position of the gesture at the current end handle location, in
+ // global coordinates. Updated when the handle moves.
+ late double _endHandleDragPosition;
+
+ // The distance from _endHandleDragPosition to the center of the line that it
+ // corresponds to, in global coordinates.
+ late double _endHandleDragTarget;
+
+ // The initial selection when a selection handle drag has started.
+ TextSelection? _dragStartSelection;
+
+ void _handleSelectionEndHandleDragStart(DragStartDetails details) {
+ if (!renderObject.attached) {
+ return;
+ }
+
+ _endHandleDragPosition = details.globalPosition.dy;
+
+ // Use local coordinates when dealing with line height. because in case of a
+ // scale transformation, the line height will also be scaled.
+ final double centerOfLineLocal =
+ _selectionOverlay.selectionEndpoints.last.point.dy -
+ renderObject.preferredLineHeight / 2;
+ final double centerOfLineGlobal =
+ renderObject.localToGlobal(Offset(0.0, centerOfLineLocal)).dy;
+ _endHandleDragTarget = centerOfLineGlobal - details.globalPosition.dy;
+ // Instead of finding the TextPosition at the handle's location directly,
+ // use the vertical center of the line that it points to. This is because
+ // selection handles typically hang above or below the line that they point
+ // to.
+ final TextPosition position = renderObject.getPositionForPoint(
+ Offset(details.globalPosition.dx, centerOfLineGlobal),
+ );
+ _dragStartSelection ??= _selection;
+
+ _selectionOverlay.showMagnifier(
+ _buildMagnifier(
+ currentTextPosition: position,
+ globalGesturePosition: details.globalPosition,
+ renderEditable: renderObject,
+ ),
+ );
+ }
+
+ /// Given a handle position and drag position, returns the position of handle
+ /// after the drag.
+ ///
+ /// The handle jumps instantly between lines when the drag reaches a full
+ /// line's height away from the original handle position. In other words, the
+ /// line jump happens when the contact point would be located at the same
+ /// place on the handle at the new line as when the gesture started, for both
+ /// directions.
+ ///
+ /// This is not the same as just maintaining an offset from the target and the
+ /// contact point. There is no point at which moving the drag up and down a
+ /// small sub-line-height distance will cause the cursor to jump up and down
+ /// between lines. The drag distance must be a full line height for the cursor
+ /// to change lines, for both directions.
+ ///
+ /// Both parameters must be in local coordinates because the untransformed
+ /// line height is used, and the return value is in local coordinates as well.
+ double _getHandleDy(double dragDy, double handleDy) {
+ final double distanceDragged = dragDy - handleDy;
+ final int dragDirection = distanceDragged < 0.0 ? -1 : 1;
+ final int linesDragged = dragDirection *
+ (distanceDragged.abs() / renderObject.preferredLineHeight).floor();
+ return handleDy + linesDragged * renderObject.preferredLineHeight;
+ }
+
+ void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) {
+ if (!renderObject.attached) {
+ return;
+ }
+ assert(_dragStartSelection != null);
+
+ // This is NOT the same as details.localPosition. That is relative to the
+ // selection handle, whereas this is relative to the RenderEditable.
+ final Offset localPosition =
+ renderObject.globalToLocal(details.globalPosition);
+
+ final double nextEndHandleDragPositionLocal = _getHandleDy(
+ localPosition.dy,
+ renderObject.globalToLocal(Offset(0.0, _endHandleDragPosition)).dy,
+ );
+ _endHandleDragPosition = renderObject
+ .localToGlobal(Offset(0.0, nextEndHandleDragPositionLocal))
+ .dy;
+
+ final Offset handleTargetGlobal = Offset(
+ details.globalPosition.dx,
+ _endHandleDragPosition + _endHandleDragTarget,
+ );
+
+ TextPosition position =
+ renderObject.getPositionForPoint(handleTargetGlobal);
+
+ // bggRGjQaUbCoE right drag
+ position = controller.dragOffset(position);
+
+ if (_dragStartSelection!.isCollapsed) {
+ _selectionOverlay.updateMagnifier(
+ _buildMagnifier(
+ currentTextPosition: position,
+ globalGesturePosition: details.globalPosition,
+ renderEditable: renderObject,
+ ),
+ );
+
+ final TextSelection currentSelection =
+ TextSelection.fromPosition(position);
+ _handleSelectionHandleChanged(currentSelection);
+ return;
+ }
+
+ final TextSelection newSelection;
+ switch (defaultTargetPlatform) {
+ // On Apple platforms, dragging the base handle makes it the extent.
+ case TargetPlatform.iOS:
+ case TargetPlatform.macOS:
+ // Use this instead of _dragStartSelection.isNormalized because TextRange.isNormalized
+ // always returns true for a TextSelection.
+ final bool dragStartSelectionNormalized =
+ _dragStartSelection!.extentOffset >=
+ _dragStartSelection!.baseOffset;
+ newSelection = TextSelection(
+ baseOffset: dragStartSelectionNormalized
+ ? _dragStartSelection!.baseOffset
+ : _dragStartSelection!.extentOffset,
+ extentOffset: position.offset,
+ );
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ newSelection = TextSelection(
+ baseOffset: _selection.baseOffset,
+ extentOffset: position.offset,
+ );
+ if (newSelection.baseOffset >= newSelection.extentOffset) {
+ return; // Don't allow order swapping.
+ }
+ }
+
+ _handleSelectionHandleChanged(newSelection);
+
+ _selectionOverlay.updateMagnifier(
+ _buildMagnifier(
+ currentTextPosition: newSelection.extent,
+ globalGesturePosition: details.globalPosition,
+ renderEditable: renderObject,
+ ),
+ );
+ }
+
+ // The contact position of the gesture at the current start handle location,
+ // in global coordinates. Updated when the handle moves.
+ late double _startHandleDragPosition;
+
+ // The distance from _startHandleDragPosition to the center of the line that
+ // it corresponds to, in global coordinates.
+ late double _startHandleDragTarget;
+
+ void _handleSelectionStartHandleDragStart(DragStartDetails details) {
+ if (!renderObject.attached) {
+ return;
+ }
+
+ _startHandleDragPosition = details.globalPosition.dy;
+
+ // Use local coordinates when dealing with line height. because in case of a
+ // scale transformation, the line height will also be scaled.
+ final double centerOfLineLocal =
+ _selectionOverlay.selectionEndpoints.first.point.dy -
+ renderObject.preferredLineHeight / 2;
+ final double centerOfLineGlobal =
+ renderObject.localToGlobal(Offset(0.0, centerOfLineLocal)).dy;
+ _startHandleDragTarget = centerOfLineGlobal - details.globalPosition.dy;
+ // Instead of finding the TextPosition at the handle's location directly,
+ // use the vertical center of the line that it points to. This is because
+ // selection handles typically hang above or below the line that they point
+ // to.
+ final TextPosition position = renderObject.getPositionForPoint(
+ Offset(details.globalPosition.dx, centerOfLineGlobal),
+ );
+ _dragStartSelection ??= _selection;
+
+ _selectionOverlay.showMagnifier(
+ _buildMagnifier(
+ currentTextPosition: position,
+ globalGesturePosition: details.globalPosition,
+ renderEditable: renderObject,
+ ),
+ );
+ }
+
+ void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) {
+ if (!renderObject.attached) {
+ return;
+ }
+ assert(_dragStartSelection != null);
+
+ // This is NOT the same as details.localPosition. That is relative to the
+ // selection handle, whereas this is relative to the RenderEditable.
+ final Offset localPosition =
+ renderObject.globalToLocal(details.globalPosition);
+ final double nextStartHandleDragPositionLocal = _getHandleDy(
+ localPosition.dy,
+ renderObject.globalToLocal(Offset(0.0, _startHandleDragPosition)).dy,
+ );
+ _startHandleDragPosition = renderObject
+ .localToGlobal(Offset(0.0, nextStartHandleDragPositionLocal))
+ .dy;
+ final Offset handleTargetGlobal = Offset(
+ details.globalPosition.dx,
+ _startHandleDragPosition + _startHandleDragTarget,
+ );
+ TextPosition position =
+ renderObject.getPositionForPoint(handleTargetGlobal);
+
+ // bggRGjQaUbCoE single drag, left drag
+ position = controller.dragOffset(position);
+
+ if (_dragStartSelection!.isCollapsed) {
+ _selectionOverlay.updateMagnifier(
+ _buildMagnifier(
+ currentTextPosition: position,
+ globalGesturePosition: details.globalPosition,
+ renderEditable: renderObject,
+ ),
+ );
+
+ final TextSelection currentSelection =
+ TextSelection.fromPosition(position);
+ _handleSelectionHandleChanged(currentSelection);
+ return;
+ }
+
+ final TextSelection newSelection;
+ switch (defaultTargetPlatform) {
+ // On Apple platforms, dragging the base handle makes it the extent.
+ case TargetPlatform.iOS:
+ case TargetPlatform.macOS:
+ // Use this instead of _dragStartSelection.isNormalized because TextRange.isNormalized
+ // always returns true for a TextSelection.
+ final bool dragStartSelectionNormalized =
+ _dragStartSelection!.extentOffset >=
+ _dragStartSelection!.baseOffset;
+ newSelection = TextSelection(
+ baseOffset: dragStartSelectionNormalized
+ ? _dragStartSelection!.extentOffset
+ : _dragStartSelection!.baseOffset,
+ extentOffset: position.offset,
+ );
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ newSelection = TextSelection(
+ baseOffset: position.offset,
+ extentOffset: _selection.extentOffset,
+ );
+ if (newSelection.baseOffset >= newSelection.extentOffset) {
+ return; // Don't allow order swapping.
+ }
+ }
+
+ _selectionOverlay.updateMagnifier(
+ _buildMagnifier(
+ currentTextPosition:
+ newSelection.extent.offset < newSelection.base.offset
+ ? newSelection.extent
+ : newSelection.base,
+ globalGesturePosition: details.globalPosition,
+ renderEditable: renderObject,
+ ),
+ );
+
+ _handleSelectionHandleChanged(newSelection);
+ }
+
+ void _handleAnyDragEnd(DragEndDetails details) {
+ if (!context.mounted) {
+ return;
+ }
+ _dragStartSelection = null;
+ if (selectionControls is! TextSelectionHandleControls) {
+ _selectionOverlay.hideMagnifier();
+ if (!_selection.isCollapsed) {
+ _selectionOverlay.showToolbar();
+ }
+ return;
+ }
+ _selectionOverlay.hideMagnifier();
+ if (!_selection.isCollapsed) {
+ _selectionOverlay.showToolbar(
+ context: context, contextMenuBuilder: contextMenuBuilder);
+ }
+ }
+
+ void _handleSelectionHandleChanged(TextSelection newSelection) {
+ selectionDelegate.userUpdateTextEditingValue(
+ _value.copyWith(selection: newSelection),
+ SelectionChangedCause.drag,
+ );
+ }
+
+ TextSelectionHandleType _chooseType(
+ TextDirection textDirection,
+ TextSelectionHandleType ltrType,
+ TextSelectionHandleType rtlType,
+ ) {
+ if (_selection.isCollapsed) {
+ return TextSelectionHandleType.collapsed;
+ }
+
+ return switch (textDirection) {
+ TextDirection.ltr => ltrType,
+ TextDirection.rtl => rtlType,
+ };
+ }
+}
+
+/// An object that manages a pair of selection handles and a toolbar.
+///
+/// The selection handles are displayed in the [Overlay] that most closely
+/// encloses the given [BuildContext].
+class SelectionOverlay {
+ /// Creates an object that manages overlay entries for selection handles.
+ ///
+ /// The [context] must have an [Overlay] as an ancestor.
+ SelectionOverlay({
+ required this.context,
+ this.debugRequiredFor,
+ required TextSelectionHandleType startHandleType,
+ required double lineHeightAtStart,
+ this.startHandlesVisible,
+ this.onStartHandleDragStart,
+ this.onStartHandleDragUpdate,
+ this.onStartHandleDragEnd,
+ required TextSelectionHandleType endHandleType,
+ required double lineHeightAtEnd,
+ this.endHandlesVisible,
+ this.onEndHandleDragStart,
+ this.onEndHandleDragUpdate,
+ this.onEndHandleDragEnd,
+ this.toolbarVisible,
+ required List selectionEndpoints,
+ required this.selectionControls,
+ @Deprecated(
+ 'Use `contextMenuBuilder` in `showToolbar` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
+ required this.selectionDelegate,
+ required this.clipboardStatus,
+ required this.startHandleLayerLink,
+ required this.endHandleLayerLink,
+ required this.toolbarLayerLink,
+ this.dragStartBehavior = DragStartBehavior.start,
+ this.onSelectionHandleTapped,
+ @Deprecated(
+ 'Use `contextMenuBuilder` in `showToolbar` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
+ Offset? toolbarLocation,
+ this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
+ }) : _startHandleType = startHandleType,
+ _lineHeightAtStart = lineHeightAtStart,
+ _endHandleType = endHandleType,
+ _lineHeightAtEnd = lineHeightAtEnd,
+ _selectionEndpoints = selectionEndpoints,
+ _toolbarLocation = toolbarLocation,
+ assert(debugCheckHasOverlay(context)) {
+ assert(debugMaybeDispatchCreated('widgets', 'SelectionOverlay', this));
+ }
+
+ /// {@macro flutter.widgets.SelectionOverlay.context}
+ final BuildContext context;
+
+ final ValueNotifier _magnifierInfo =
+ ValueNotifier(
+ MagnifierInfo.empty,
+ );
+
+ // [MagnifierController.show] and [MagnifierController.hide] should not be
+ // called directly, except from inside [showMagnifier] and [hideMagnifier]. If
+ // it is desired to show or hide the magnifier, call [showMagnifier] or
+ // [hideMagnifier]. This is because the magnifier needs to orchestrate with
+ // other properties in [SelectionOverlay].
+ final MagnifierController _magnifierController = MagnifierController();
+
+ /// The configuration for the magnifier.
+ ///
+ /// By default, [SelectionOverlay]'s [TextMagnifierConfiguration] is disabled.
+ ///
+ /// {@macro flutter.widgets.magnifier.intro}
+ final TextMagnifierConfiguration magnifierConfiguration;
+
+ /// {@template flutter.widgets.SelectionOverlay.toolbarIsVisible}
+ /// Whether the toolbar is currently visible.
+ ///
+ /// Includes both the text selection toolbar and the spell check menu.
+ /// {@endtemplate}
+ bool get toolbarIsVisible {
+ return selectionControls is TextSelectionHandleControls
+ ? _contextMenuController.isShown || _spellCheckToolbarController.isShown
+ : _toolbar != null || _spellCheckToolbarController.isShown;
+ }
+
+ /// {@template flutter.widgets.SelectionOverlay.showMagnifier}
+ /// Shows the magnifier, and hides the toolbar if it was showing when [showMagnifier]
+ /// was called. This is safe to call on platforms not mobile, since
+ /// a magnifierBuilder will not be provided, or the magnifierBuilder will return null
+ /// on platforms not mobile.
+ ///
+ /// This is NOT the source of truth for if the magnifier is up or not,
+ /// since magnifiers may hide themselves. If this info is needed, check
+ /// [MagnifierController.shown].
+ /// {@endtemplate}
+ void showMagnifier(MagnifierInfo initialMagnifierInfo) {
+ if (toolbarIsVisible) {
+ hideToolbar();
+ }
+
+ // Start from empty, so we don't utilize any remnant values.
+ _magnifierInfo.value = initialMagnifierInfo;
+
+ // Pre-build the magnifiers so we can tell if we've built something
+ // or not. If we don't build a magnifiers, then we should not
+ // insert anything in the overlay.
+ final Widget? builtMagnifier = magnifierConfiguration.magnifierBuilder(
+ context,
+ _magnifierController,
+ _magnifierInfo,
+ );
+
+ if (builtMagnifier == null) {
+ return;
+ }
+
+ _magnifierController.show(
+ context: context,
+ below: magnifierConfiguration.shouldDisplayHandlesInMagnifier
+ ? null
+ : _handles?.start,
+ builder: (_) => builtMagnifier,
+ );
+ }
+
+ /// {@template flutter.widgets.SelectionOverlay.hideMagnifier}
+ /// Hide the current magnifier.
+ ///
+ /// This does nothing if there is no magnifier.
+ /// {@endtemplate}
+ void hideMagnifier() {
+ // This cannot be a check on `MagnifierController.shown`, since
+ // it's possible that the magnifier is still in the overlay, but
+ // not shown in cases where the magnifier hides itself.
+ if (_magnifierController.overlayEntry == null) {
+ return;
+ }
+
+ _magnifierController.hide();
+ }
+
+ /// The type of start selection handle.
+ ///
+ /// Changing the value while the handles are visible causes them to rebuild.
+ TextSelectionHandleType get startHandleType => _startHandleType;
+ TextSelectionHandleType _startHandleType;
+ set startHandleType(TextSelectionHandleType value) {
+ if (_startHandleType == value) {
+ return;
+ }
+ _startHandleType = value;
+ markNeedsBuild();
+ }
+
+ /// The line height at the selection start.
+ ///
+ /// This value is used for calculating the size of the start selection handle.
+ ///
+ /// Changing the value while the handles are visible causes them to rebuild.
+ double get lineHeightAtStart => _lineHeightAtStart;
+ double _lineHeightAtStart;
+ set lineHeightAtStart(double value) {
+ if (_lineHeightAtStart == value) {
+ return;
+ }
+ _lineHeightAtStart = value;
+ markNeedsBuild();
+ }
+
+ bool _isDraggingStartHandle = false;
+
+ /// Whether the start handle is visible.
+ ///
+ /// If the value changes, the start handle uses [FadeTransition] to transition
+ /// itself on and off the screen.
+ ///
+ /// If this is null, the start selection handle will always be visible.
+ final ValueListenable? startHandlesVisible;
+
+ /// Called when the users start dragging the start selection handles.
+ final ValueChanged? onStartHandleDragStart;
+
+ void _handleStartHandleDragStart(DragStartDetails details) {
+ assert(!_isDraggingStartHandle);
+ // Calling OverlayEntry.remove may not happen until the following frame, so
+ // it's possible for the handles to receive a gesture after calling remove.
+ if (_handles == null) {
+ _isDraggingStartHandle = false;
+ return;
+ }
+ _isDraggingStartHandle = details.kind == PointerDeviceKind.touch;
+ onStartHandleDragStart?.call(details);
+ }
+
+ void _handleStartHandleDragUpdate(DragUpdateDetails details) {
+ // Calling OverlayEntry.remove may not happen until the following frame, so
+ // it's possible for the handles to receive a gesture after calling remove.
+ if (_handles == null) {
+ _isDraggingStartHandle = false;
+ return;
+ }
+ onStartHandleDragUpdate?.call(details);
+ }
+
+ /// Called when the users drag the start selection handles to new locations.
+ final ValueChanged? onStartHandleDragUpdate;
+
+ /// Called when the users lift their fingers after dragging the start selection
+ /// handles.
+ final ValueChanged? onStartHandleDragEnd;
+
+ void _handleStartHandleDragEnd(DragEndDetails details) {
+ _isDraggingStartHandle = false;
+ // Calling OverlayEntry.remove may not happen until the following frame, so
+ // it's possible for the handles to receive a gesture after calling remove.
+ if (_handles == null) {
+ return;
+ }
+ onStartHandleDragEnd?.call(details);
+ }
+
+ /// The type of end selection handle.
+ ///
+ /// Changing the value while the handles are visible causes them to rebuild.
+ TextSelectionHandleType get endHandleType => _endHandleType;
+ TextSelectionHandleType _endHandleType;
+ set endHandleType(TextSelectionHandleType value) {
+ if (_endHandleType == value) {
+ return;
+ }
+ _endHandleType = value;
+ markNeedsBuild();
+ }
+
+ /// The line height at the selection end.
+ ///
+ /// This value is used for calculating the size of the end selection handle.
+ ///
+ /// Changing the value while the handles are visible causes them to rebuild.
+ double get lineHeightAtEnd => _lineHeightAtEnd;
+ double _lineHeightAtEnd;
+ set lineHeightAtEnd(double value) {
+ if (_lineHeightAtEnd == value) {
+ return;
+ }
+ _lineHeightAtEnd = value;
+ markNeedsBuild();
+ }
+
+ bool _isDraggingEndHandle = false;
+
+ /// Whether the end handle is visible.
+ ///
+ /// If the value changes, the end handle uses [FadeTransition] to transition
+ /// itself on and off the screen.
+ ///
+ /// If this is null, the end selection handle will always be visible.
+ final ValueListenable? endHandlesVisible;
+
+ /// Called when the users start dragging the end selection handles.
+ final ValueChanged? onEndHandleDragStart;
+
+ void _handleEndHandleDragStart(DragStartDetails details) {
+ assert(!_isDraggingEndHandle);
+ // Calling OverlayEntry.remove may not happen until the following frame, so
+ // it's possible for the handles to receive a gesture after calling remove.
+ if (_handles == null) {
+ _isDraggingEndHandle = false;
+ return;
+ }
+ _isDraggingEndHandle = details.kind == PointerDeviceKind.touch;
+ onEndHandleDragStart?.call(details);
+ }
+
+ void _handleEndHandleDragUpdate(DragUpdateDetails details) {
+ // Calling OverlayEntry.remove may not happen until the following frame, so
+ // it's possible for the handles to receive a gesture after calling remove.
+ if (_handles == null) {
+ _isDraggingEndHandle = false;
+ return;
+ }
+ onEndHandleDragUpdate?.call(details);
+ }
+
+ /// Called when the users drag the end selection handles to new locations.
+ final ValueChanged? onEndHandleDragUpdate;
+
+ /// Called when the users lift their fingers after dragging the end selection
+ /// handles.
+ final ValueChanged? onEndHandleDragEnd;
+
+ void _handleEndHandleDragEnd(DragEndDetails details) {
+ _isDraggingEndHandle = false;
+ // Calling OverlayEntry.remove may not happen until the following frame, so
+ // it's possible for the handles to receive a gesture after calling remove.
+ if (_handles == null) {
+ return;
+ }
+ onEndHandleDragEnd?.call(details);
+ }
+
+ /// Whether the toolbar is visible.
+ ///
+ /// If the value changes, the toolbar uses [FadeTransition] to transition
+ /// itself on and off the screen.
+ ///
+ /// If this is null the toolbar will always be visible.
+ final ValueListenable? toolbarVisible;
+
+ /// The text selection positions of selection start and end.
+ List get selectionEndpoints => _selectionEndpoints;
+ List _selectionEndpoints;
+ set selectionEndpoints(List value) {
+ if (!listEquals(_selectionEndpoints, value)) {
+ markNeedsBuild();
+ if (_isDraggingEndHandle || _isDraggingStartHandle) {
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.android:
+ HapticFeedback.selectionClick();
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.iOS:
+ case TargetPlatform.linux:
+ case TargetPlatform.macOS:
+ case TargetPlatform.windows:
+ break;
+ }
+ }
+ }
+ _selectionEndpoints = value;
+ }
+
+ /// Debugging information for explaining why the [Overlay] is required.
+ final Widget? debugRequiredFor;
+
+ /// The object supplied to the [CompositedTransformTarget] that wraps the text
+ /// field.
+ final LayerLink toolbarLayerLink;
+
+ /// The objects supplied to the [CompositedTransformTarget] that wraps the
+ /// location of start selection handle.
+ final LayerLink startHandleLayerLink;
+
+ /// The objects supplied to the [CompositedTransformTarget] that wraps the
+ /// location of end selection handle.
+ final LayerLink endHandleLayerLink;
+
+ /// {@template flutter.widgets.SelectionOverlay.selectionControls}
+ /// Builds text selection handles and toolbar.
+ /// {@endtemplate}
+ final TextSelectionControls? selectionControls;
+
+ /// {@template flutter.widgets.SelectionOverlay.selectionDelegate}
+ /// The delegate for manipulating the current selection in the owning
+ /// text field.
+ /// {@endtemplate}
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
+ final TextSelectionDelegate? selectionDelegate;
+
+ /// Determines the way that drag start behavior is handled.
+ ///
+ /// If set to [DragStartBehavior.start], handle drag behavior will
+ /// begin at the position where the drag gesture won the arena. If set to
+ /// [DragStartBehavior.down] it will begin at the position where a down
+ /// event is first detected.
+ ///
+ /// In general, setting this to [DragStartBehavior.start] will make drag
+ /// animation smoother and setting it to [DragStartBehavior.down] will make
+ /// drag behavior feel slightly more reactive.
+ ///
+ /// By default, the drag start behavior is [DragStartBehavior.start].
+ ///
+ /// See also:
+ ///
+ /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for the different behaviors.
+ final DragStartBehavior dragStartBehavior;
+
+ /// {@template flutter.widgets.SelectionOverlay.onSelectionHandleTapped}
+ /// A callback that's optionally invoked when a selection handle is tapped.
+ ///
+ /// The [TextSelectionControls.buildHandle] implementation the text field
+ /// uses decides where the handle's tap "hotspot" is, or whether the
+ /// selection handle supports tap gestures at all. For instance,
+ /// [MaterialTextSelectionControls] calls [onSelectionHandleTapped] when the
+ /// selection handle's "knob" is tapped, while
+ /// [CupertinoTextSelectionControls] builds a handle that's not sufficiently
+ /// large for tapping (as it's not meant to be tapped) so it does not call
+ /// [onSelectionHandleTapped] even when tapped.
+ /// {@endtemplate}
+ // See https://github.com/flutter/flutter/issues/39376#issuecomment-848406415
+ // for provenance.
+ final VoidCallback? onSelectionHandleTapped;
+
+ /// Maintains the status of the clipboard for determining if its contents can
+ /// be pasted or not.
+ ///
+ /// Useful because the actual value of the clipboard can only be checked
+ /// asynchronously (see [Clipboard.getData]).
+ final ClipboardStatusNotifier? clipboardStatus;
+
+ /// The location of where the toolbar should be drawn in relative to the
+ /// location of [toolbarLayerLink].
+ ///
+ /// If this is null, the toolbar is drawn based on [selectionEndpoints] and
+ /// the rect of render object of [context].
+ ///
+ /// This is useful for displaying toolbars at the mouse right-click locations
+ /// in desktop devices.
+ @Deprecated(
+ 'Use the `contextMenuBuilder` parameter in `showToolbar` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
+ Offset? get toolbarLocation => _toolbarLocation;
+ Offset? _toolbarLocation;
+ set toolbarLocation(Offset? value) {
+ if (_toolbarLocation == value) {
+ return;
+ }
+ _toolbarLocation = value;
+ markNeedsBuild();
+ }
+
+ /// Controls the fade-in and fade-out animations for the toolbar and handles.
+ static const Duration fadeDuration = Duration(milliseconds: 150);
+
+ /// A pair of handles. If this is non-null, there are always 2, though the
+ /// second is hidden when the selection is collapsed.
+ ({OverlayEntry start, OverlayEntry end})? _handles;
+
+ /// A copy/paste toolbar.
+ OverlayEntry? _toolbar;
+
+ // Manages the context menu. Not necessarily visible when non-null.
+ final ContextMenuController _contextMenuController = ContextMenuController();
+
+ final ContextMenuController _spellCheckToolbarController =
+ ContextMenuController();
+
+ /// {@template flutter.widgets.SelectionOverlay.showHandles}
+ /// Builds the handles by inserting them into the [context]'s overlay.
+ /// {@endtemplate}
+ void showHandles() {
+ if (_handles != null) {
+ return;
+ }
+
+ final OverlayState overlay = Overlay.of(
+ context,
+ rootOverlay: true,
+ debugRequiredFor: debugRequiredFor,
+ );
+
+ final CapturedThemes capturedThemes = InheritedTheme.capture(
+ from: context,
+ to: overlay.context,
+ );
+
+ _handles = (
+ start: OverlayEntry(
+ builder: (BuildContext context) {
+ return capturedThemes.wrap(_buildStartHandle(context));
+ },
+ ),
+ end: OverlayEntry(
+ builder: (BuildContext context) {
+ return capturedThemes.wrap(_buildEndHandle(context));
+ },
+ ),
+ );
+ overlay.insertAll([_handles!.start, _handles!.end]);
+ }
+
+ /// {@template flutter.widgets.SelectionOverlay.hideHandles}
+ /// Destroys the handles by removing them from overlay.
+ /// {@endtemplate}
+ void hideHandles() {
+ if (_handles != null) {
+ _handles!.start.remove();
+ _handles!.start.dispose();
+ _handles!.end.remove();
+ _handles!.end.dispose();
+ _handles = null;
+ }
+ }
+
+ /// {@template flutter.widgets.SelectionOverlay.showToolbar}
+ /// Shows the toolbar by inserting it into the [context]'s overlay.
+ /// {@endtemplate}
+ void showToolbar({BuildContext? context, WidgetBuilder? contextMenuBuilder}) {
+ if (contextMenuBuilder == null) {
+ if (_toolbar != null) {
+ return;
+ }
+ _toolbar = OverlayEntry(builder: _buildToolbar);
+ Overlay.of(
+ this.context,
+ rootOverlay: true,
+ debugRequiredFor: debugRequiredFor,
+ ).insert(_toolbar!);
+ return;
+ }
+
+ if (context == null) {
+ return;
+ }
+
+ final RenderBox renderBox = context.findRenderObject()! as RenderBox;
+ _contextMenuController.show(
+ context: context,
+ contextMenuBuilder: (BuildContext context) {
+ return _SelectionToolbarWrapper(
+ visibility: toolbarVisible,
+ layerLink: toolbarLayerLink,
+ offset: -renderBox.localToGlobal(Offset.zero),
+ child: contextMenuBuilder(context),
+ );
+ },
+ );
+ }
+
+ /// Shows toolbar with spell check suggestions of misspelled words that are
+ /// available for click-and-replace.
+ void showSpellCheckSuggestionsToolbar(
+ {BuildContext? context, required WidgetBuilder builder}) {
+ if (context == null) {
+ return;
+ }
+
+ final RenderBox renderBox = context.findRenderObject()! as RenderBox;
+ _spellCheckToolbarController.show(
+ context: context,
+ contextMenuBuilder: (BuildContext context) {
+ return _SelectionToolbarWrapper(
+ layerLink: toolbarLayerLink,
+ offset: -renderBox.localToGlobal(Offset.zero),
+ child: builder(context),
+ );
+ },
+ );
+ }
+
+ bool _buildScheduled = false;
+
+ /// Rebuilds the selection toolbar or handles if they are present.
+ void markNeedsBuild() {
+ if (_handles == null && _toolbar == null) {
+ return;
+ }
+ // If we are in build state, it will be too late to update visibility.
+ // We will need to schedule the build in next frame.
+ if (SchedulerBinding.instance.schedulerPhase ==
+ SchedulerPhase.persistentCallbacks) {
+ if (_buildScheduled) {
+ return;
+ }
+ _buildScheduled = true;
+ SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
+ _buildScheduled = false;
+ _handles?.start.markNeedsBuild();
+ _handles?.end.markNeedsBuild();
+ _toolbar?.markNeedsBuild();
+ if (_contextMenuController.isShown) {
+ _contextMenuController.markNeedsBuild();
+ } else if (_spellCheckToolbarController.isShown) {
+ _spellCheckToolbarController.markNeedsBuild();
+ }
+ }, debugLabel: 'SelectionOverlay.markNeedsBuild');
+ } else {
+ if (_handles != null) {
+ _handles!.start.markNeedsBuild();
+ _handles!.end.markNeedsBuild();
+ }
+ _toolbar?.markNeedsBuild();
+ if (_contextMenuController.isShown) {
+ _contextMenuController.markNeedsBuild();
+ } else if (_spellCheckToolbarController.isShown) {
+ _spellCheckToolbarController.markNeedsBuild();
+ }
+ }
+ }
+
+ /// {@template flutter.widgets.SelectionOverlay.hide}
+ /// Hides the entire overlay including the toolbar and the handles.
+ /// {@endtemplate}
+ void hide() {
+ _magnifierController.hide();
+ hideHandles();
+ if (_toolbar != null ||
+ _contextMenuController.isShown ||
+ _spellCheckToolbarController.isShown) {
+ hideToolbar();
+ }
+ }
+
+ /// {@template flutter.widgets.SelectionOverlay.hideToolbar}
+ /// Hides the toolbar part of the overlay.
+ ///
+ /// To hide the whole overlay, see [hide].
+ /// {@endtemplate}
+ void hideToolbar() {
+ _contextMenuController.remove();
+ _spellCheckToolbarController.remove();
+ if (_toolbar == null) {
+ return;
+ }
+ _toolbar?.remove();
+ _toolbar?.dispose();
+ _toolbar = null;
+ }
+
+ /// {@template flutter.widgets.SelectionOverlay.dispose}
+ /// Disposes this object and release resources.
+ /// {@endtemplate}
+ void dispose() {
+ assert(debugMaybeDispatchDisposed(this));
+ hide();
+ _magnifierInfo.dispose();
+ }
+
+ Widget _buildStartHandle(BuildContext context) {
+ final Widget handle;
+ final TextSelectionControls? selectionControls = this.selectionControls;
+ if (selectionControls == null ||
+ (_startHandleType == TextSelectionHandleType.collapsed &&
+ _isDraggingEndHandle)) {
+ // Hide the start handle when dragging the end handle and collapsing
+ // the selection.
+ handle = const SizedBox.shrink();
+ } else {
+ handle = _SelectionHandleOverlay(
+ type: _startHandleType,
+ handleLayerLink: startHandleLayerLink,
+ onSelectionHandleTapped: onSelectionHandleTapped,
+ onSelectionHandleDragStart: _handleStartHandleDragStart,
+ onSelectionHandleDragUpdate: _handleStartHandleDragUpdate,
+ onSelectionHandleDragEnd: _handleStartHandleDragEnd,
+ selectionControls: selectionControls,
+ visibility: startHandlesVisible,
+ preferredLineHeight: _lineHeightAtStart,
+ dragStartBehavior: dragStartBehavior,
+ );
+ }
+ return TextFieldTapRegion(child: ExcludeSemantics(child: handle));
+ }
+
+ Widget _buildEndHandle(BuildContext context) {
+ final Widget handle;
+ final TextSelectionControls? selectionControls = this.selectionControls;
+ if (selectionControls == null ||
+ (_endHandleType == TextSelectionHandleType.collapsed &&
+ _isDraggingStartHandle) ||
+ (_endHandleType == TextSelectionHandleType.collapsed &&
+ !_isDraggingStartHandle &&
+ !_isDraggingEndHandle)) {
+ // Hide the end handle when dragging the start handle and collapsing the selection
+ // or when the selection is collapsed and no handle is being dragged.
+ handle = const SizedBox.shrink();
+ } else {
+ handle = _SelectionHandleOverlay(
+ type: _endHandleType,
+ handleLayerLink: endHandleLayerLink,
+ onSelectionHandleTapped: onSelectionHandleTapped,
+ onSelectionHandleDragStart: _handleEndHandleDragStart,
+ onSelectionHandleDragUpdate: _handleEndHandleDragUpdate,
+ onSelectionHandleDragEnd: _handleEndHandleDragEnd,
+ selectionControls: selectionControls,
+ visibility: endHandlesVisible,
+ preferredLineHeight: _lineHeightAtEnd,
+ dragStartBehavior: dragStartBehavior,
+ );
+ }
+ return TextFieldTapRegion(child: ExcludeSemantics(child: handle));
+ }
+
+ // Build the toolbar via TextSelectionControls.
+ Widget _buildToolbar(BuildContext context) {
+ if (selectionControls == null) {
+ return const SizedBox.shrink();
+ }
+ assert(
+ selectionDelegate != null,
+ 'If not using contextMenuBuilder, must pass selectionDelegate.',
+ );
+
+ final RenderBox renderBox = this.context.findRenderObject()! as RenderBox;
+
+ final Rect editingRegion = Rect.fromPoints(
+ renderBox.localToGlobal(Offset.zero),
+ renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero)),
+ );
+
+ final bool isMultiline =
+ selectionEndpoints.last.point.dy - selectionEndpoints.first.point.dy >
+ lineHeightAtEnd / 2;
+
+ // If the selected text spans more than 1 line, horizontally center the toolbar.
+ // Derived from both iOS and Android.
+ final double midX = isMultiline
+ ? editingRegion.width / 2
+ : (selectionEndpoints.first.point.dx +
+ selectionEndpoints.last.point.dx) /
+ 2;
+
+ final Offset midpoint = Offset(
+ midX,
+ // The y-coordinate won't be made use of most likely.
+ selectionEndpoints.first.point.dy - lineHeightAtStart,
+ );
+
+ return _SelectionToolbarWrapper(
+ visibility: toolbarVisible,
+ layerLink: toolbarLayerLink,
+ offset: -editingRegion.topLeft,
+ child: Builder(
+ builder: (BuildContext context) {
+ return selectionControls!.buildToolbar(
+ context,
+ editingRegion,
+ lineHeightAtStart,
+ midpoint,
+ selectionEndpoints,
+ selectionDelegate!,
+ clipboardStatus,
+ toolbarLocation,
+ );
+ },
+ ),
+ );
+ }
+
+ /// {@template flutter.widgets.SelectionOverlay.updateMagnifier}
+ /// Update the current magnifier with new selection data, so the magnifier
+ /// can respond accordingly.
+ ///
+ /// If the magnifier is not shown, this still updates the magnifier position
+ /// because the magnifier may have hidden itself and is looking for a cue to reshow
+ /// itself.
+ ///
+ /// If there is no magnifier in the overlay, this does nothing.
+ /// {@endtemplate}
+ void updateMagnifier(MagnifierInfo magnifierInfo) {
+ if (_magnifierController.overlayEntry == null) {
+ return;
+ }
+
+ _magnifierInfo.value = magnifierInfo;
+ }
+}
+
+// TODO(justinmc): Currently this fades in but not out on all platforms. It
+// should follow the correct fading behavior for the current platform, then be
+// made public and de-duplicated with widgets/selectable_region.dart.
+// https://github.com/flutter/flutter/issues/107732
+// Wrap the given child in the widgets common to both contextMenuBuilder and
+// TextSelectionControls.buildToolbar.
+class _SelectionToolbarWrapper extends StatefulWidget {
+ const _SelectionToolbarWrapper({
+ this.visibility,
+ required this.layerLink,
+ required this.offset,
+ required this.child,
+ });
+
+ final Widget child;
+ final Offset offset;
+ final LayerLink layerLink;
+ final ValueListenable? visibility;
+
+ @override
+ State<_SelectionToolbarWrapper> createState() =>
+ _SelectionToolbarWrapperState();
+}
+
+class _SelectionToolbarWrapperState extends State<_SelectionToolbarWrapper>
+ with SingleTickerProviderStateMixin {
+ late AnimationController _controller;
+ Animation get _opacity => _controller.view;
+
+ @override
+ void initState() {
+ super.initState();
+
+ _controller = AnimationController(
+ duration: SelectionOverlay.fadeDuration, vsync: this);
+
+ _toolbarVisibilityChanged();
+ widget.visibility?.addListener(_toolbarVisibilityChanged);
+ }
+
+ @override
+ void didUpdateWidget(_SelectionToolbarWrapper oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.visibility == widget.visibility) {
+ return;
+ }
+ oldWidget.visibility?.removeListener(_toolbarVisibilityChanged);
+ _toolbarVisibilityChanged();
+ widget.visibility?.addListener(_toolbarVisibilityChanged);
+ }
+
+ @override
+ void dispose() {
+ widget.visibility?.removeListener(_toolbarVisibilityChanged);
+ _controller.dispose();
+ super.dispose();
+ }
+
+ void _toolbarVisibilityChanged() {
+ if (widget.visibility?.value ?? true) {
+ _controller.forward();
+ } else {
+ _controller.reverse();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return TextFieldTapRegion(
+ child: Directionality(
+ textDirection: Directionality.of(this.context),
+ child: FadeTransition(
+ opacity: _opacity,
+ child: CompositedTransformFollower(
+ link: widget.layerLink,
+ showWhenUnlinked: false,
+ offset: widget.offset,
+ child: widget.child,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// This widget represents a single draggable selection handle.
+class _SelectionHandleOverlay extends StatefulWidget {
+ /// Create selection overlay.
+ const _SelectionHandleOverlay({
+ required this.type,
+ required this.handleLayerLink,
+ this.onSelectionHandleTapped,
+ this.onSelectionHandleDragStart,
+ this.onSelectionHandleDragUpdate,
+ this.onSelectionHandleDragEnd,
+ required this.selectionControls,
+ this.visibility,
+ required this.preferredLineHeight,
+ this.dragStartBehavior = DragStartBehavior.start,
+ });
+
+ final LayerLink handleLayerLink;
+ final VoidCallback? onSelectionHandleTapped;
+ final ValueChanged? onSelectionHandleDragStart;
+ final ValueChanged? onSelectionHandleDragUpdate;
+ final ValueChanged? onSelectionHandleDragEnd;
+ final TextSelectionControls selectionControls;
+ final ValueListenable? visibility;
+ final double preferredLineHeight;
+ final TextSelectionHandleType type;
+ final DragStartBehavior dragStartBehavior;
+
+ @override
+ State<_SelectionHandleOverlay> createState() =>
+ _SelectionHandleOverlayState();
+}
+
+class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay>
+ with SingleTickerProviderStateMixin {
+ late AnimationController _controller;
+ Animation get _opacity => _controller.view;
+
+ @override
+ void initState() {
+ super.initState();
+
+ _controller = AnimationController(
+ duration: SelectionOverlay.fadeDuration, vsync: this);
+
+ _handleVisibilityChanged();
+ widget.visibility?.addListener(_handleVisibilityChanged);
+ }
+
+ void _handleVisibilityChanged() {
+ if (widget.visibility?.value ?? true) {
+ _controller.forward();
+ } else {
+ _controller.reverse();
+ }
+ }
+
+ /// Returns the bounding [Rect] of the text selection handle in local
+ /// coordinates.
+ ///
+ /// When interacting with a text selection handle through a touch event, the
+ /// interactive area should be at least [kMinInteractiveDimension] square,
+ /// which this method does not consider.
+ Rect _getHandleRect(
+ TextSelectionHandleType type, double preferredLineHeight) {
+ final Size handleSize =
+ widget.selectionControls.getHandleSize(preferredLineHeight);
+ return Rect.fromLTWH(0.0, 0.0, handleSize.width, handleSize.height);
+ }
+
+ @override
+ void didUpdateWidget(_SelectionHandleOverlay oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ oldWidget.visibility?.removeListener(_handleVisibilityChanged);
+ _handleVisibilityChanged();
+ widget.visibility?.addListener(_handleVisibilityChanged);
+ }
+
+ @override
+ void dispose() {
+ widget.visibility?.removeListener(_handleVisibilityChanged);
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final Rect handleRect =
+ _getHandleRect(widget.type, widget.preferredLineHeight);
+
+ // Make sure the GestureDetector is big enough to be easily interactive.
+ final Rect interactiveRect = handleRect.expandToInclude(
+ Rect.fromCircle(
+ center: handleRect.center, radius: kMinInteractiveDimension / 2),
+ );
+ final RelativeRect padding = RelativeRect.fromLTRB(
+ math.max((interactiveRect.width - handleRect.width) / 2, 0),
+ math.max((interactiveRect.height - handleRect.height) / 2, 0),
+ math.max((interactiveRect.width - handleRect.width) / 2, 0),
+ math.max((interactiveRect.height - handleRect.height) / 2, 0),
+ );
+
+ final Offset handleAnchor = widget.selectionControls.getHandleAnchor(
+ widget.type,
+ widget.preferredLineHeight,
+ );
+
+ // Make sure a drag is eagerly accepted. This is used on iOS to match the
+ // behavior where a drag directly on a collapse handle will always win against
+ // other drag gestures.
+ final bool eagerlyAcceptDragWhenCollapsed =
+ widget.type == TextSelectionHandleType.collapsed &&
+ defaultTargetPlatform == TargetPlatform.iOS;
+
+ return CompositedTransformFollower(
+ link: widget.handleLayerLink,
+ // Put the handle's anchor point on the leader's anchor point.
+ offset: -handleAnchor - Offset(padding.left, padding.top),
+ showWhenUnlinked: false,
+ child: FadeTransition(
+ opacity: _opacity,
+ child: SizedBox(
+ width: interactiveRect.width,
+ height: interactiveRect.height,
+ child: Align(
+ alignment: Alignment.topLeft,
+ child: RawGestureDetector(
+ behavior: HitTestBehavior.translucent,
+ gestures: {
+ PanGestureRecognizer:
+ GestureRecognizerFactoryWithHandlers(
+ () => PanGestureRecognizer(
+ debugOwner: this,
+ // Mouse events select the text and do not drag the cursor.
+ supportedDevices: {
+ PointerDeviceKind.touch,
+ PointerDeviceKind.stylus,
+ PointerDeviceKind.unknown,
+ },
+ ),
+ (PanGestureRecognizer instance) {
+ instance
+ ..dragStartBehavior = widget.dragStartBehavior
+ ..gestureSettings = eagerlyAcceptDragWhenCollapsed
+ ? const DeviceGestureSettings(touchSlop: 1.0)
+ : null
+ ..onStart = widget.onSelectionHandleDragStart
+ ..onUpdate = widget.onSelectionHandleDragUpdate
+ ..onEnd = widget.onSelectionHandleDragEnd;
+ },
+ ),
+ },
+ child: Padding(
+ padding: EdgeInsets.only(
+ left: padding.left,
+ top: padding.top,
+ right: padding.right,
+ bottom: padding.bottom,
+ ),
+ child: widget.selectionControls.buildHandle(
+ context,
+ widget.type,
+ widget.preferredLineHeight,
+ widget.onSelectionHandleTapped,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/http/video.dart b/lib/http/video.dart
index b66f4c645..8de0c6450 100644
--- a/lib/http/video.dart
+++ b/lib/http/video.dart
@@ -510,7 +510,7 @@ class VideoHttp {
int? parent,
List? pictures,
bool? syncToDynamic,
- Map? atNameToMid,
+ Map? atNameToMid,
}) async {
if (message == '') {
return {'status': false, 'msg': '请输入评论内容'};
@@ -521,7 +521,7 @@ class VideoHttp {
if (root != null && root != 0) 'root': root,
if (parent != null && parent != 0) 'parent': parent,
'message': message,
- if (atNameToMid != null)
+ if (atNameToMid?.isNotEmpty == true)
'at_name_to_mid': jsonEncode(atNameToMid), // {"name":uid}
if (pictures != null) 'pictures': jsonEncode(pictures),
if (syncToDynamic == true) 'sync_to_dynamic': 1,
diff --git a/lib/pages/common/common_publish_page.dart b/lib/pages/common/common_publish_page.dart
deleted file mode 100644
index 326ded82b..000000000
--- a/lib/pages/common/common_publish_page.dart
+++ /dev/null
@@ -1,506 +0,0 @@
-import 'dart:async';
-import 'dart:io';
-import 'dart:math';
-
-import 'package:PiliPlus/common/widgets/button/icon_button.dart';
-import 'package:PiliPlus/http/msg.dart';
-import 'package:PiliPlus/models/common/image_preview_type.dart';
-import 'package:PiliPlus/models/common/publish_panel_type.dart';
-import 'package:PiliPlus/models_new/dynamic/dyn_mention/item.dart';
-import 'package:PiliPlus/models_new/emote/emote.dart';
-import 'package:PiliPlus/models_new/live/live_emote/emoticon.dart';
-import 'package:PiliPlus/models_new/upload_bfs/data.dart';
-import 'package:PiliPlus/pages/dynamics_mention/view.dart';
-import 'package:PiliPlus/utils/extension.dart';
-import 'package:PiliPlus/utils/feed_back.dart';
-import 'package:chat_bottom_container/chat_bottom_container.dart';
-import 'package:dio/dio.dart';
-import 'package:easy_debounce/easy_throttle.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
-import 'package:get/get.dart';
-import 'package:image_cropper/image_cropper.dart';
-import 'package:image_picker/image_picker.dart';
-
-abstract class CommonPublishPage extends StatefulWidget {
- const CommonPublishPage({
- super.key,
- this.initialValue,
- this.mentions,
- this.imageLengthLimit,
- this.onSave,
- this.autofocus = true,
- });
-
- final String? initialValue;
- final List? mentions;
- final int? imageLengthLimit;
- final ValueChanged<({String text, List? mentions})>? onSave;
- final bool autofocus;
-}
-
-abstract class CommonPublishPageState
- extends State with WidgetsBindingObserver {
- late final focusNode = FocusNode();
- late final controller = ChatBottomPanelContainerController();
- late final editController = TextEditingController(text: widget.initialValue);
-
- Rx panelType = PanelType.none.obs;
- late final RxBool readOnly = false.obs;
- late final RxBool enablePublish = false.obs;
-
- late final imagePicker = ImagePicker();
- late final RxList pathList = [].obs;
- int get limit => widget.imageLengthLimit ?? 9;
-
- List? mentions;
-
- @override
- void initState() {
- super.initState();
- WidgetsBinding.instance.addObserver(this);
-
- mentions = widget.mentions;
- if (widget.initialValue?.trim().isNotEmpty == true) {
- enablePublish.value = true;
- }
-
- if (widget.autofocus) {
- Future.delayed(const Duration(milliseconds: 300)).whenComplete(() {
- if (mounted) {
- focusNode.requestFocus();
- }
- });
- }
- }
-
- @override
- void dispose() {
- focusNode.dispose();
- editController.dispose();
- WidgetsBinding.instance.removeObserver(this);
- super.dispose();
- }
-
- Future _requestFocus() async {
- await Future.delayed(const Duration(microseconds: 200));
- focusNode.requestFocus();
- }
-
- @override
- void didChangeAppLifecycleState(AppLifecycleState state) {
- if (state == AppLifecycleState.resumed) {
- if (mounted &&
- widget.autofocus &&
- panelType.value == PanelType.keyboard) {
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (focusNode.hasFocus) {
- focusNode.unfocus();
- _requestFocus();
- } else {
- _requestFocus();
- }
- });
- }
- } else if (state == AppLifecycleState.paused) {
- controller.keepChatPanel();
- if (focusNode.hasFocus) {
- focusNode.unfocus();
- }
- }
- }
-
- void updatePanelType(PanelType type) {
- final isSwitchToKeyboard = PanelType.keyboard == type;
- final isSwitchToEmojiPanel = PanelType.emoji == type;
- bool isUpdated = false;
- switch (type) {
- case PanelType.keyboard:
- updateInputView(isReadOnly: false);
- break;
- case PanelType.emoji:
- isUpdated = updateInputView(isReadOnly: true);
- break;
- default:
- break;
- }
-
- void updatePanelTypeFunc() {
- controller.updatePanelType(
- isSwitchToKeyboard
- ? ChatBottomPanelType.keyboard
- : ChatBottomPanelType.other,
- data: type,
- forceHandleFocus: isSwitchToEmojiPanel
- ? ChatBottomHandleFocus.requestFocus
- : ChatBottomHandleFocus.none,
- );
- }
-
- if (isUpdated) {
- // Waiting for the input view to update.
- WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
- updatePanelTypeFunc();
- });
- } else {
- updatePanelTypeFunc();
- }
- }
-
- Future hidePanel() async {
- if (focusNode.hasFocus) {
- await Future.delayed(const Duration(milliseconds: 100));
- focusNode.unfocus();
- }
- updateInputView(isReadOnly: false);
- if (ChatBottomPanelType.none == controller.currentPanelType) return;
- controller.updatePanelType(ChatBottomPanelType.none);
- }
-
- bool updateInputView({
- required bool isReadOnly,
- }) {
- if (readOnly.value != isReadOnly) {
- readOnly.value = isReadOnly;
- return true;
- }
- return false;
- }
-
- Future onPublish() async {
- feedBack();
- List