From 5c4faedd561ce90b0522861db84c27e4e586a1a6 Mon Sep 17 00:00:00 2001 From: zmtzawqlp Date: Sun, 14 May 2023 22:50:01 +0800 Subject: [PATCH] Make refactor code runnable --- analysis_options.yaml | 4 - example/analysis_options.yaml | 4 - example/ios/Runner.xcodeproj/project.pbxproj | 1 + example/lib/pages/simple/no_keyboard.dart | 8 +- example/macos/Podfile | 2 +- example/macos/Podfile.lock | 8 +- .../macos/Runner.xcodeproj/project.pbxproj | 9 +- .../spell_check_suggestions_toolbar.dart | 167 + .../spell_check_suggestions_toolbar.dart | 269 ++ lib/src/extended/rendering/editable.dart | 7 +- lib/src/extended/widgets/editable_text.dart | 208 +- lib/src/extended/widgets/spell_check.dart | 85 + lib/src/extended/widgets/text_field.dart | 206 +- lib/src/official/rendering/editable.dart | 2 +- lib/src/official/widgets/editable_text.dart | 116 +- lib/src/official/widgets/spell_check.dart | 418 +++ lib/src/official/widgets/text_field.dart | 30 +- lib/src/official/widgets/text_selection.dart | 3053 +++++++++++++++++ 18 files changed, 4462 insertions(+), 135 deletions(-) create mode 100644 lib/src/extended/cupertino/spell_check_suggestions_toolbar.dart create mode 100644 lib/src/extended/material/spell_check_suggestions_toolbar.dart create mode 100644 lib/src/extended/widgets/spell_check.dart create mode 100644 lib/src/official/widgets/spell_check.dart create mode 100644 lib/src/official/widgets/text_selection.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index a995197..46bd6c7 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -19,9 +19,6 @@ # Android Studio, and the `flutter analyze` command. analyzer: - strong-mode: - implicit-casts: false - implicit-dynamic: false errors: # treat missing required parameters as a warning (not a hint) missing_required_param: warning @@ -141,7 +138,6 @@ linter: # - prefer_constructors_over_static_methods # not yet tested - prefer_contains # - prefer_double_quotes # opposite of prefer_single_quotes - - prefer_equal_for_default_values # - prefer_expression_function_bodies # conflicts with https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#consider-using--for-short-functions-and-methods - prefer_final_fields - prefer_final_in_for_each diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 4656aa0..28b4850 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -19,9 +19,6 @@ # Android Studio, and the `flutter analyze` command. analyzer: - strong-mode: - implicit-casts: false - implicit-dynamic: false errors: # treat missing required parameters as a warning (not a hint) missing_required_param: warning @@ -141,7 +138,6 @@ linter: # - prefer_constructors_over_static_methods # not yet tested - prefer_contains # - prefer_double_quotes # opposite of prefer_single_quotes - - prefer_equal_for_default_values # - prefer_expression_function_bodies # conflicts with https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#consider-using--for-short-functions-and-methods - prefer_final_fields - prefer_final_in_for_each diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 9184c9e..f5c19d1 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -227,6 +227,7 @@ files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( diff --git a/example/lib/pages/simple/no_keyboard.dart b/example/lib/pages/simple/no_keyboard.dart index 73a4398..4497e33 100644 --- a/example/lib/pages/simple/no_keyboard.dart +++ b/example/lib/pages/simple/no_keyboard.dart @@ -42,10 +42,10 @@ class NoSystemKeyboardDemo extends StatelessWidget { padding: EdgeInsets.all(8.0), child: Column(children: [ ExtendedTextField1(), - Text('ExtendedTextField'), - ExtendedTextFieldCase(), - Text('CustomTextField'), - TextFieldCase(), + // Text('ExtendedTextField'), + // ExtendedTextFieldCase(), + // Text('CustomTextField'), + // TextFieldCase(), ]), ), ), diff --git a/example/macos/Podfile b/example/macos/Podfile index dade8df..049abe2 100644 --- a/example/macos/Podfile +++ b/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.11' +platform :osx, '10.14' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index 1b2e896..eb85594 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -14,9 +14,9 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: - FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 - url_launcher_macos: 45af3d61de06997666568a7149c1be98b41c95d4 + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + url_launcher_macos: c04e4fa86382d4f94f6b38f14625708be3ae52e2 -PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c +PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 -COCOAPODS: 1.10.0 +COCOAPODS: 1.11.2 diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index cfa0bab..ce99738 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ @@ -256,6 +256,7 @@ /* Begin PBXShellScriptBuildPhase section */ 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -404,7 +405,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -483,7 +484,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -530,7 +531,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/lib/src/extended/cupertino/spell_check_suggestions_toolbar.dart b/lib/src/extended/cupertino/spell_check_suggestions_toolbar.dart new file mode 100644 index 0000000..6b47473 --- /dev/null +++ b/lib/src/extended/cupertino/spell_check_suggestions_toolbar.dart @@ -0,0 +1,167 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:extended_text_field/src/extended/widgets/text_field.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart' + show SelectionChangedCause, SuggestionSpan; + +/// iOS only shows 3 spell check suggestions in the toolbar. +const int _kMaxSuggestions = 3; + +/// The default spell check suggestions toolbar for iOS. +/// +/// Tries to position itself below the [anchors], but if it doesn't fit, then it +/// readjusts to fit above bottom view insets. +/// +/// See also: +/// * [SpellCheckSuggestionsToolbar], which is similar but for both the +/// Material and Cupertino libraries. +/// [CupertinoSpellCheckSuggestionsToolbar] +class ExtendedCupertinoSpellCheckSuggestionsToolbar extends StatelessWidget { + /// Constructs a [ExtendedCupertinoSpellCheckSuggestionsToolbar]. + /// + /// [buttonItems] must not contain more than three items. + const ExtendedCupertinoSpellCheckSuggestionsToolbar({ + super.key, + required this.anchors, + required this.buttonItems, + }) : assert(buttonItems.length <= _kMaxSuggestions); + + /// Constructs a [ExtendedCupertinoSpellCheckSuggestionsToolbar] with the default + /// children for an [EditableText]. + /// + /// See also: + /// * [SpellCheckSuggestionsToolbar.editableText], which is similar but + /// builds an Android-style toolbar. + ExtendedCupertinoSpellCheckSuggestionsToolbar.editableText({ + super.key, + // zmtzawqlp + required ExtendedEditableTextState editableTextState, + }) : buttonItems = + buildButtonItems(editableTextState) ?? [], + anchors = editableTextState.contextMenuAnchors; + + /// The location on which to anchor the menu. + final TextSelectionToolbarAnchors anchors; + + /// The [ContextMenuButtonItem]s that will be turned into the correct button + /// widgets and displayed in the spell check suggestions toolbar. + /// + /// Must not contain more than three items. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar.buttonItems], the list of + /// [ContextMenuButtonItem]s that are used to build the buttons of the + /// text selection toolbar. + /// * [SpellCheckSuggestionsToolbar.buttonItems], the list of + /// [ContextMenuButtonItem]s used to build the Material style spell check + /// suggestions toolbar. + final List buttonItems; + + /// Builds the button items for the toolbar based on the available + /// spell check suggestions. + static List? buildButtonItems( + ExtendedEditableTextState editableTextState, + ) { + // Determine if composing region is misspelled. + final SuggestionSpan? spanAtCursorIndex = + editableTextState.findSuggestionSpanAtCursorIndex( + editableTextState.currentTextEditingValue.selection.baseOffset, + ); + + if (spanAtCursorIndex == null) { + return null; + } + if (spanAtCursorIndex.suggestions.isEmpty) { + assert(debugCheckHasCupertinoLocalizations(editableTextState.context)); + final CupertinoLocalizations localizations = + CupertinoLocalizations.of(editableTextState.context); + return [ + ContextMenuButtonItem( + onPressed: () {}, + label: localizations.noSpellCheckReplacementsLabel, + ) + ]; + } + + final List buttonItems = []; + + // Build suggestion buttons. + for (final String suggestion + in spanAtCursorIndex.suggestions.take(_kMaxSuggestions)) { + buttonItems.add(ContextMenuButtonItem( + onPressed: () { + if (!editableTextState.mounted) { + return; + } + _replaceText( + editableTextState, + suggestion, + spanAtCursorIndex.range, + ); + }, + label: suggestion, + )); + } + return buttonItems; + } + + // zmtzawqlp + static void _replaceText(ExtendedEditableTextState editableTextState, + String text, TextRange replacementRange) { + // Replacement cannot be performed if the text is read only or obscured. + assert(!editableTextState.widget.readOnly && + !editableTextState.widget.obscureText); + + final TextEditingValue newValue = editableTextState.textEditingValue + .replaced( + replacementRange, + text, + ) + .copyWith( + selection: TextSelection.collapsed( + offset: replacementRange.start + text.length, + ), + ); + editableTextState.userUpdateTextEditingValue( + newValue, SelectionChangedCause.toolbar); + + // Schedule a call to bringIntoView() after renderEditable updates. + SchedulerBinding.instance.addPostFrameCallback((Duration duration) { + if (editableTextState.mounted) { + editableTextState + .bringIntoView(editableTextState.textEditingValue.selection.extent); + } + }); + editableTextState.hideToolbar(); + } + + /// Builds the toolbar buttons based on the [buttonItems]. + List _buildToolbarButtons(BuildContext context) { + return buttonItems.map((ContextMenuButtonItem buttonItem) { + return CupertinoTextSelectionToolbarButton.buttonItem( + buttonItem: buttonItem, + ); + }).toList(); + } + + @override + Widget build(BuildContext context) { + if (buttonItems.isEmpty) { + return const SizedBox.shrink(); + } + + final List children = _buildToolbarButtons(context); + return CupertinoTextSelectionToolbar( + anchorAbove: anchors.primaryAnchor, + anchorBelow: anchors.secondaryAnchor == null + ? anchors.primaryAnchor + : anchors.secondaryAnchor!, + children: children, + ); + } +} diff --git a/lib/src/extended/material/spell_check_suggestions_toolbar.dart b/lib/src/extended/material/spell_check_suggestions_toolbar.dart new file mode 100644 index 0000000..faadc5b --- /dev/null +++ b/lib/src/extended/material/spell_check_suggestions_toolbar.dart @@ -0,0 +1,269 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:extended_text_field/src/extended/widgets/text_field.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart' + show SelectionChangedCause, SuggestionSpan; + +// The default height of the SpellCheckSuggestionsToolbar, which +// assumes there are the maximum number of spell check suggestions available, 3. +// Size eyeballed on Pixel 4 emulator running Android API 31. +const double _kDefaultToolbarHeight = 193.0; + +/// The maximum number of suggestions in the toolbar is 3, plus a delete button. +const int _kMaxSuggestions = 3; + +/// The default spell check suggestions toolbar for Android. +/// +/// Tries to position itself below the [anchor], but if it doesn't fit, then it +/// readjusts to fit above bottom view insets. +/// +/// See also: +/// +/// * [CupertinoSpellCheckSuggestionsToolbar], which is similar but builds an +/// iOS-style spell check toolbar. +/// [SpellCheckSuggestionsToolbar] +class ExtendedSpellCheckSuggestionsToolbar extends StatelessWidget { + /// Constructs a [ExtendedSpellCheckSuggestionsToolbar]. + /// + /// [buttonItems] must not contain more than four items, generally three + /// suggestions and one delete button. + const ExtendedSpellCheckSuggestionsToolbar({ + super.key, + required this.anchor, + required this.buttonItems, + }) : assert(buttonItems.length <= _kMaxSuggestions + 1); + + /// Constructs a [ExtendedSpellCheckSuggestionsToolbar] with the default children for + /// an [EditableText]. + /// + /// See also: + /// * [CupertinoSpellCheckSuggestionsToolbar.editableText], which is similar + /// but builds an iOS-style toolbar. + ExtendedSpellCheckSuggestionsToolbar.editableText({ + super.key, + // zmtzawqlp + required ExtendedEditableTextState editableTextState, + }) : buttonItems = + buildButtonItems(editableTextState) ?? [], + anchor = getToolbarAnchor(editableTextState.contextMenuAnchors); + + /// {@template flutter.material.SpellCheckSuggestionsToolbar.anchor} + /// The focal point below which the toolbar attempts to position itself. + /// {@endtemplate} + final Offset anchor; + + /// The [ContextMenuButtonItem]s that will be turned into the correct button + /// widgets and displayed in the spell check suggestions toolbar. + /// + /// Must not contain more than four items, typically three suggestions and a + /// delete button. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar.buttonItems], the list of + /// [ContextMenuButtonItem]s that are used to build the buttons of the + /// text selection toolbar. + /// * [CupertinoSpellCheckSuggestionsToolbar.buttonItems], the list of + /// [ContextMenuButtonItem]s used to build the Cupertino style spell check + /// suggestions toolbar. + final List buttonItems; + + /// Builds the button items for the toolbar based on the available + /// spell check suggestions. + static List? buildButtonItems( + ExtendedEditableTextState editableTextState, + ) { + // Determine if composing region is misspelled. + final SuggestionSpan? spanAtCursorIndex = + editableTextState.findSuggestionSpanAtCursorIndex( + editableTextState.currentTextEditingValue.selection.baseOffset, + ); + + if (spanAtCursorIndex == null) { + return null; + } + + final List buttonItems = []; + + // Build suggestion buttons. + for (final String suggestion + in spanAtCursorIndex.suggestions.take(_kMaxSuggestions)) { + buttonItems.add(ContextMenuButtonItem( + onPressed: () { + if (!editableTextState.mounted) { + return; + } + _replaceText( + editableTextState, + suggestion, + spanAtCursorIndex.range, + ); + }, + label: suggestion, + )); + } + + // Build delete button. + final ContextMenuButtonItem deleteButton = ContextMenuButtonItem( + onPressed: () { + if (!editableTextState.mounted) { + return; + } + _replaceText( + editableTextState, + '', + editableTextState.currentTextEditingValue.composing, + ); + }, + type: ContextMenuButtonType.delete, + ); + buttonItems.add(deleteButton); + + return buttonItems; + } + + // zmtzawqlp + static void _replaceText(ExtendedEditableTextState editableTextState, + String text, TextRange replacementRange) { + // Replacement cannot be performed if the text is read only or obscured. + assert(!editableTextState.widget.readOnly && + !editableTextState.widget.obscureText); + + final TextEditingValue newValue = + editableTextState.textEditingValue.replaced( + replacementRange, + text, + ); + editableTextState.userUpdateTextEditingValue( + newValue, SelectionChangedCause.toolbar); + + // Schedule a call to bringIntoView() after renderEditable updates. + SchedulerBinding.instance.addPostFrameCallback((Duration duration) { + if (editableTextState.mounted) { + editableTextState + .bringIntoView(editableTextState.textEditingValue.selection.extent); + } + }); + editableTextState.hideToolbar(); + } + + /// Determines the Offset that the toolbar will be anchored to. + static Offset getToolbarAnchor(TextSelectionToolbarAnchors anchors) { + // Since this will be positioned below the anchor point, use the secondary + // anchor by default. + return anchors.secondaryAnchor == null + ? anchors.primaryAnchor + : anchors.secondaryAnchor!; + } + + /// Builds the toolbar buttons based on the [buttonItems]. + List _buildToolbarButtons(BuildContext context) { + return buttonItems.map((ContextMenuButtonItem buttonItem) { + final TextSelectionToolbarTextButton button = + TextSelectionToolbarTextButton( + padding: const EdgeInsets.fromLTRB(20, 0, 0, 0), + onPressed: buttonItem.onPressed, + alignment: Alignment.centerLeft, + child: Text( + AdaptiveTextSelectionToolbar.getButtonLabel(context, buttonItem), + style: buttonItem.type == ContextMenuButtonType.delete + ? const TextStyle(color: Colors.blue) + : null, + ), + ); + + if (buttonItem.type != ContextMenuButtonType.delete) { + return button; + } + return DecoratedBox( + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: Colors.grey))), + child: button, + ); + }).toList(); + } + + @override + Widget build(BuildContext context) { + if (buttonItems.isEmpty) { + return const SizedBox.shrink(); + } + + // Adjust toolbar height if needed. + final double spellCheckSuggestionsToolbarHeight = + _kDefaultToolbarHeight - (48.0 * (4 - buttonItems.length)); + // Incorporate the padding distance between the content and toolbar. + final MediaQueryData mediaQueryData = MediaQuery.of(context); + final double softKeyboardViewInsetsBottom = + mediaQueryData.viewInsets.bottom; + final double paddingAbove = mediaQueryData.padding.top + + CupertinoTextSelectionToolbar.kToolbarScreenPadding; + // Makes up for the Padding. + final Offset localAdjustment = Offset( + CupertinoTextSelectionToolbar.kToolbarScreenPadding, + paddingAbove, + ); + + return Padding( + padding: EdgeInsets.fromLTRB( + CupertinoTextSelectionToolbar.kToolbarScreenPadding, + paddingAbove, + CupertinoTextSelectionToolbar.kToolbarScreenPadding, + CupertinoTextSelectionToolbar.kToolbarScreenPadding + + softKeyboardViewInsetsBottom, + ), + child: CustomSingleChildLayout( + delegate: SpellCheckSuggestionsToolbarLayoutDelegate( + anchor: anchor - localAdjustment, + ), + child: AnimatedSize( + // This duration was eyeballed on a Pixel 2 emulator running Android + // API 28 for the Material TextSelectionToolbar. + duration: const Duration(milliseconds: 140), + child: _SpellCheckSuggestionsToolbarContainer( + height: spellCheckSuggestionsToolbarHeight, + children: [..._buildToolbarButtons(context)], + ), + ), + ), + ); + } +} + +/// The Material-styled toolbar outline for the spell check suggestions +/// toolbar. +class _SpellCheckSuggestionsToolbarContainer extends StatelessWidget { + const _SpellCheckSuggestionsToolbarContainer({ + required this.height, + required this.children, + }); + + final double height; + final List children; + + @override + Widget build(BuildContext context) { + return Material( + // This elevation was eyeballed on a Pixel 4 emulator running Android + // API 31 for the SpellCheckSuggestionsToolbar. + elevation: 2.0, + type: MaterialType.card, + child: SizedBox( + // This width was eyeballed on a Pixel 4 emulator running Android + // API 31 for the SpellCheckSuggestionsToolbar. + width: 165.0, + height: height, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ), + ); + } +} diff --git a/lib/src/extended/rendering/editable.dart b/lib/src/extended/rendering/editable.dart index dd3a507..202a962 100644 --- a/lib/src/extended/rendering/editable.dart +++ b/lib/src/extended/rendering/editable.dart @@ -1,4 +1,4 @@ -part of 'package:extended_text_field/src/extended/widgets/editable_text.dart'; +part of 'package:extended_text_field/src/extended/widgets/text_field.dart'; /// [RenderEditable] class ExtendedRenderEditable extends _RenderEditable { @@ -47,9 +47,4 @@ class ExtendedRenderEditable extends _RenderEditable { super.foregroundPainter, super.children, }); - - @override - void adoptChild(covariant RenderObject child) { - super.adoptChild(child); - } } diff --git a/lib/src/extended/widgets/editable_text.dart b/lib/src/extended/widgets/editable_text.dart index db2dbfc..fb7d389 100644 --- a/lib/src/extended/widgets/editable_text.dart +++ b/lib/src/extended/widgets/editable_text.dart @@ -1,17 +1,4 @@ -import 'dart:async'; -import 'dart:collection'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter/services.dart'; -import 'dart:ui' as ui; -import 'dart:math' as math; - -part 'package:extended_text_field/src/official/widgets/editable_text.dart'; -part 'package:extended_text_field/src/official/rendering/editable.dart'; -part 'package:extended_text_field/src/extended/rendering/editable.dart'; +part of 'package:extended_text_field/src/extended/widgets/text_field.dart'; /// Signature for a widget builder that builds a context menu for the given /// [EditableTextState]. @@ -27,7 +14,7 @@ typedef ExtendedEditableTextContextMenuBuilder = Widget Function( /// [EditableText] /// -class ExtendedEditableText extends EditableText { +class ExtendedEditableText extends _EditableText { ExtendedEditableText({ super.key, required super.controller, @@ -99,12 +86,64 @@ class ExtendedEditableText extends EditableText { super.scribbleEnabled = true, super.enableIMEPersonalizedLearning = true, super.contentInsertionConfiguration, - super.contextMenuBuilder, - super.spellCheckConfiguration, + // super.contextMenuBuilder, + // super.spellCheckConfiguration, + this.extendedContextMenuBuilder, + this.extendedSpellCheckConfiguration, super.magnifierConfiguration = TextMagnifierConfiguration.disabled, super.undoController, }); + /// {@template flutter.widgets.EditableText.contextMenuBuilder} + /// Builds the text selection toolbar when requested by the user. + /// + /// `primaryAnchor` is the desired anchor position for the context menu, while + /// `secondaryAnchor` is the fallback location if the menu doesn't fit. + /// + /// `buttonItems` represents the buttons that would be built by default for + /// this widget. + /// + /// {@tool dartpad} + /// This example shows how to customize the menu, in this case by keeping the + /// default buttons for the platform but modifying their appearance. + /// + /// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart ** + /// {@end-tool} + /// + /// {@tool dartpad} + /// This example shows how to show a custom button only when an email address + /// is currently selected. + /// + /// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.1.dart ** + /// {@end-tool} + /// + /// See also: + /// * [AdaptiveTextSelectionToolbar], which builds the default text selection + /// toolbar for the current platform, but allows customization of the + /// buttons. + /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the + /// button Widgets for the current platform given + /// [ContextMenuButtonItem]s. + /// * [BrowserContextMenu], which allows the browser's context menu on web + /// to be disabled and Flutter-rendered context menus to appear. + /// {@endtemplate} + /// + /// If not provided, no context menu will be shown. + final ExtendedEditableTextContextMenuBuilder? extendedContextMenuBuilder; + + /// {@template flutter.widgets.EditableText.spellCheckConfiguration} + /// Configuration that details how spell check should be performed. + /// + /// Specifies the [SpellCheckService] used to spell check text input and the + /// [TextStyle] used to style text with misspelled words. + /// + /// If the [SpellCheckService] is left null, spell check is disabled by + /// default unless the [DefaultSpellCheckService] is supported, in which case + /// it is used. It is currently supported only on Android and iOS. + /// + /// If this configuration is left null, then spell check is disabled by default. + /// {@endtemplate} + final ExtendedSpellCheckConfiguration? extendedSpellCheckConfiguration; @override _EditableTextState createState() { return ExtendedEditableTextState(); @@ -112,6 +151,64 @@ class ExtendedEditableText extends EditableText { } class ExtendedEditableTextState extends _EditableTextState { + ExtendedEditableText get extendedEditableText => + widget as ExtendedEditableText; + ExtendedSpellCheckConfiguration get extendedSpellCheckConfiguration => + _spellCheckConfiguration as ExtendedSpellCheckConfiguration; + + // State lifecycle: + + @override + void initState() { + super.initState(); + _spellCheckConfiguration = _inferSpellCheckConfiguration( + extendedEditableText.extendedSpellCheckConfiguration); + } + + /// Infers the [_SpellCheckConfiguration] used to perform spell check. + /// + /// If spell check is enabled, this will try to infer a value for + /// the [SpellCheckService] if left unspecified. + static _SpellCheckConfiguration _inferSpellCheckConfiguration( + ExtendedSpellCheckConfiguration? configuration) { + final SpellCheckService? spellCheckService = + configuration?.spellCheckService; + final bool spellCheckAutomaticallyDisabled = configuration == null || + configuration == const ExtendedSpellCheckConfiguration.disabled(); + final bool spellCheckServiceIsConfigured = spellCheckService != null || + spellCheckService == null && + WidgetsBinding + .instance.platformDispatcher.nativeSpellCheckServiceDefined; + if (spellCheckAutomaticallyDisabled || !spellCheckServiceIsConfigured) { + // Only enable spell check if a non-disabled configuration is provided + // and if that configuration does not specify a spell check service, + // a native spell checker must be supported. + assert(() { + if (!spellCheckAutomaticallyDisabled && + !spellCheckServiceIsConfigured) { + FlutterError.reportError( + FlutterErrorDetails( + exception: FlutterError( + 'Spell check was enabled with spellCheckConfiguration, but the ' + 'current platform does not have a supported spell check ' + 'service, and none was provided. Consider disabling spell ' + 'check for this platform or passing a SpellCheckConfiguration ' + 'with a specified spell check service.', + ), + library: 'widget library', + stack: StackTrace.current, + ), + ); + } + return true; + }()); + return const ExtendedSpellCheckConfiguration.disabled(); + } + + return configuration.copyWith( + spellCheckService: spellCheckService ?? DefaultSpellCheckService()); + } + @override Widget build(BuildContext context) { super.build(context); @@ -249,7 +346,8 @@ class ExtendedEditableTextState extends _EditableTextState { selectionHeightStyle: widget.selectionHeightStyle, selectionWidthStyle: widget.selectionWidthStyle, paintCursorAboveText: widget.paintCursorAboveText, - enableInteractiveSelection: _userSelectionEnabled, + enableInteractiveSelection: + widget._userSelectionEnabled, textSelectionDelegate: this, devicePixelRatio: _devicePixelRatio, promptRectRange: _currentPromptRectRange, @@ -268,6 +366,80 @@ class ExtendedEditableTextState extends _EditableTextState { ), ); } + + /// Shows toolbar with spell check suggestions of misspelled words that are + /// available for click-and-replace. + @override + bool showSpellCheckSuggestionsToolbar() { + // Spell check suggestions toolbars are intended to be shown on non-web + // platforms. Additionally, the Cupertino style toolbar can't be drawn on + // the web with the HTML renderer due to + // https://github.com/flutter/flutter/issues/123560. + final bool platformNotSupported = kIsWeb && BrowserContextMenu.enabled; + if (!spellCheckEnabled || + platformNotSupported || + widget.readOnly || + _selectionOverlay == null || + !_spellCheckResultsReceived || + findSuggestionSpanAtCursorIndex( + textEditingValue.selection.extentOffset) == + null) { + // Only attempt to show the spell check suggestions toolbar if there + // is a toolbar specified and spell check suggestions available to show. + return false; + } + + assert( + _spellCheckConfiguration.spellCheckSuggestionsToolbarBuilder != null, + 'spellCheckSuggestionsToolbarBuilder must be defined in ' + 'SpellCheckConfiguration to show a toolbar with spell check ' + 'suggestions', + ); + + // zmtzawqlp + _selectionOverlay!.showSpellCheckSuggestionsToolbar( + (BuildContext context) { + // zmtzawqlp + return extendedSpellCheckConfiguration + .extendedSpellCheckSuggestionsToolbarBuilder!( + context, + this, + ); + }, + ); + return true; + } + + @override + _TextSelectionOverlay _createSelectionOverlay() { + final _TextSelectionOverlay selectionOverlay = _TextSelectionOverlay( + clipboardStatus: clipboardStatus, + context: context, + value: _value, + debugRequiredFor: widget, + toolbarLayerLink: _toolbarLayerLink, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + renderObject: renderEditable, + selectionControls: widget.selectionControls, + selectionDelegate: this, + dragStartBehavior: widget.dragStartBehavior, + onSelectionHandleTapped: widget.onSelectionHandleTapped, + // zmtzawqlp + contextMenuBuilder: + extendedEditableText.extendedContextMenuBuilder == null + ? null + : (BuildContext context) { + return extendedEditableText.extendedContextMenuBuilder!( + context, + this, + ); + }, + magnifierConfiguration: widget.magnifierConfiguration, + ); + + return selectionOverlay; + } } class _ExtendedEditable extends _Editable { diff --git a/lib/src/extended/widgets/spell_check.dart b/lib/src/extended/widgets/spell_check.dart new file mode 100644 index 0000000..07083d9 --- /dev/null +++ b/lib/src/extended/widgets/spell_check.dart @@ -0,0 +1,85 @@ +part of 'package:extended_text_field/src/extended/widgets/text_field.dart'; + +class ExtendedSpellCheckConfiguration extends _SpellCheckConfiguration { + /// Creates a configuration that specifies the service and suggestions handler + /// for spell check. + const ExtendedSpellCheckConfiguration({ + super.spellCheckService, + super.misspelledSelectionColor, + super.misspelledTextStyle, + // super.spellCheckSuggestionsToolbarBuilder, + this.extendedSpellCheckSuggestionsToolbarBuilder, + }); + + const ExtendedSpellCheckConfiguration.disabled() + : extendedSpellCheckSuggestionsToolbarBuilder = null, + super.disabled(); + + /// {@template flutter.widgets.EditableText.contextMenuBuilder} + /// Builds the text selection toolbar when requested by the user. + /// + /// `primaryAnchor` is the desired anchor position for the context menu, while + /// `secondaryAnchor` is the fallback location if the menu doesn't fit. + /// + /// `buttonItems` represents the buttons that would be built by default for + /// this widget. + /// + /// {@tool dartpad} + /// This example shows how to customize the menu, in this case by keeping the + /// default buttons for the platform but modifying their appearance. + /// + /// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart ** + /// {@end-tool} + /// + /// {@tool dartpad} + /// This example shows how to show a custom button only when an email address + /// is currently selected. + /// + /// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.1.dart ** + /// {@end-tool} + /// + /// See also: + /// * [AdaptiveTextSelectionToolbar], which builds the default text selection + /// toolbar for the current platform, but allows customization of the + /// buttons. + /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the + /// button Widgets for the current platform given + /// [ContextMenuButtonItem]s. + /// * [BrowserContextMenu], which allows the browser's context menu on web + /// to be disabled and Flutter-rendered context menus to appear. + /// {@endtemplate} + /// + /// If not provided, no context menu will be shown. + final ExtendedEditableTextContextMenuBuilder? + extendedSpellCheckSuggestionsToolbarBuilder; + + /// Returns a copy of the current [_SpellCheckConfiguration] instance with + /// specified overrides. + @override + _SpellCheckConfiguration copyWith({ + SpellCheckService? spellCheckService, + Color? misspelledSelectionColor, + TextStyle? misspelledTextStyle, + EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder, + ExtendedEditableTextContextMenuBuilder? + extendedSpellCheckSuggestionsToolbarBuilder, + }) { + if (!_spellCheckEnabled) { + // A new configuration should be constructed to enable spell check. + return const _SpellCheckConfiguration.disabled(); + } + + return ExtendedSpellCheckConfiguration( + spellCheckService: spellCheckService ?? this.spellCheckService, + misspelledSelectionColor: + misspelledSelectionColor ?? this.misspelledSelectionColor, + misspelledTextStyle: misspelledTextStyle ?? this.misspelledTextStyle, + extendedSpellCheckSuggestionsToolbarBuilder: + extendedSpellCheckSuggestionsToolbarBuilder ?? + this.extendedSpellCheckSuggestionsToolbarBuilder, + // spellCheckSuggestionsToolbarBuilder: + // spellCheckSuggestionsToolbarBuilder ?? + // this.spellCheckSuggestionsToolbarBuilder, + ); + } +} diff --git a/lib/src/extended/widgets/text_field.dart b/lib/src/extended/widgets/text_field.dart index fbfc8e1..36cf6f4 100644 --- a/lib/src/extended/widgets/text_field.dart +++ b/lib/src/extended/widgets/text_field.dart @@ -1,14 +1,26 @@ -import 'package:extended_text_field/extended_text_field.dart'; -import 'package:extended_text_field/src/extended/widgets/editable_text.dart'; +import 'dart:async'; +import 'dart:collection'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:extended_text_field/src/extended/cupertino/spell_check_suggestions_toolbar.dart'; +import 'package:extended_text_field/src/extended/material/spell_check_suggestions_toolbar.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'dart:ui' as ui; - +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; +part 'package:extended_text_field/src/extended/rendering/editable.dart'; +part 'package:extended_text_field/src/extended/widgets/editable_text.dart'; +part 'package:extended_text_field/src/extended/widgets/spell_check.dart'; +part 'package:extended_text_field/src/official/rendering/editable.dart'; +part 'package:extended_text_field/src/official/widgets/editable_text.dart'; part 'package:extended_text_field/src/official/widgets/text_field.dart'; +part 'package:extended_text_field/src/official/widgets/text_selection.dart'; +part 'package:extended_text_field/src/official/widgets/spell_check.dart'; class ExtendedTextField1 extends _TextField { const ExtendedTextField1({ @@ -74,9 +86,14 @@ class ExtendedTextField1 extends _TextField { super.restorationId, super.scribbleEnabled = true, super.enableIMEPersonalizedLearning = true, - super.contextMenuBuilder = _defaultContextMenuBuilder, + // zmtzawqlp + // TODO + // super.contextMenuBuilder = _defaultContextMenuBuilder, + this.extendedContextMenuBuilder, super.canRequestFocus = true, - super.spellCheckConfiguration, + // zmtzawqlp + // super.spellCheckConfiguration, + this.extendedSpellCheckConfiguration, }); static Widget _defaultContextMenuBuilder( @@ -86,6 +103,159 @@ class ExtendedTextField1 extends _TextField { ); } + /// {@template flutter.widgets.EditableText.contextMenuBuilder} + /// Builds the text selection toolbar when requested by the user. + /// + /// `primaryAnchor` is the desired anchor position for the context menu, while + /// `secondaryAnchor` is the fallback location if the menu doesn't fit. + /// + /// `buttonItems` represents the buttons that would be built by default for + /// this widget. + /// + /// {@tool dartpad} + /// This example shows how to customize the menu, in this case by keeping the + /// default buttons for the platform but modifying their appearance. + /// + /// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart ** + /// {@end-tool} + /// + /// {@tool dartpad} + /// This example shows how to show a custom button only when an email address + /// is currently selected. + /// + /// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.1.dart ** + /// {@end-tool} + /// + /// See also: + /// * [AdaptiveTextSelectionToolbar], which builds the default text selection + /// toolbar for the current platform, but allows customization of the + /// buttons. + /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the + /// button Widgets for the current platform given + /// [ContextMenuButtonItem]s. + /// * [BrowserContextMenu], which allows the browser's context menu on web + /// to be disabled and Flutter-rendered context menus to appear. + /// {@endtemplate} + /// + /// If not provided, no context menu will be shown. + final ExtendedEditableTextContextMenuBuilder? extendedContextMenuBuilder; + + /// {@template flutter.widgets.EditableText.spellCheckConfiguration} + /// Configuration that details how spell check should be performed. + /// + /// Specifies the [SpellCheckService] used to spell check text input and the + /// [TextStyle] used to style text with misspelled words. + /// + /// If the [SpellCheckService] is left null, spell check is disabled by + /// default unless the [DefaultSpellCheckService] is supported, in which case + /// it is used. It is currently supported only on Android and iOS. + /// + /// If this configuration is left null, then spell check is disabled by default. + /// {@endtemplate} + final ExtendedSpellCheckConfiguration? extendedSpellCheckConfiguration; + + /// Returns a new [SpellCheckConfiguration] where the given configuration has + /// had any missing values replaced with their defaults for the Android + /// platform. + static ExtendedSpellCheckConfiguration inferAndroidSpellCheckConfiguration( + ExtendedSpellCheckConfiguration? configuration, + ) { + if (configuration == null || + configuration == const ExtendedSpellCheckConfiguration.disabled()) { + return const ExtendedSpellCheckConfiguration.disabled(); + } + return configuration.copyWith( + misspelledTextStyle: configuration.misspelledTextStyle ?? + TextField.materialMisspelledTextStyle, + extendedSpellCheckSuggestionsToolbarBuilder: + configuration.extendedSpellCheckSuggestionsToolbarBuilder ?? + ExtendedTextField1.defaultSpellCheckSuggestionsToolbarBuilder, + // spellCheckSuggestionsToolbarBuilder: + // configuration.spellCheckSuggestionsToolbarBuilder ?? + // TextField.defaultSpellCheckSuggestionsToolbarBuilder, + ) as ExtendedSpellCheckConfiguration; + } + + /// Default builder for [TextField]'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]. + /// * [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]. + /// [TextField.defaultSpellCheckSuggestionsToolbarBuilder] + @visibleForTesting + static Widget defaultSpellCheckSuggestionsToolbarBuilder( + BuildContext context, + ExtendedEditableTextState editableTextState, + ) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return ExtendedCupertinoSpellCheckSuggestionsToolbar.editableText( + editableTextState: editableTextState, + ); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return ExtendedSpellCheckSuggestionsToolbar.editableText( + editableTextState: editableTextState, + ); + } + } + + /// Returns a new [SpellCheckConfiguration] where the given configuration has + /// had any missing values replaced with their defaults for the iOS platform. + static ExtendedSpellCheckConfiguration inferIOSSpellCheckConfiguration( + ExtendedSpellCheckConfiguration? configuration, + ) { + if (configuration == null || + configuration == const ExtendedSpellCheckConfiguration.disabled()) { + return const ExtendedSpellCheckConfiguration.disabled(); + } + + return configuration.copyWith( + misspelledTextStyle: configuration.misspelledTextStyle ?? + CupertinoTextField.cupertinoMisspelledTextStyle, + misspelledSelectionColor: configuration.misspelledSelectionColor ?? + // ignore: invalid_use_of_visible_for_testing_member + CupertinoTextField.kMisspelledSelectionColor, + extendedSpellCheckSuggestionsToolbarBuilder: + configuration.extendedSpellCheckSuggestionsToolbarBuilder ?? + defaultIosSpellCheckSuggestionsToolbarBuilder, + // spellCheckSuggestionsToolbarBuilder: + // configuration.spellCheckSuggestionsToolbarBuilder + // ?? CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder, + ) as ExtendedSpellCheckConfiguration; + } + + /// Default builder for the spell check suggestions toolbar in the Cupertino + /// style. + /// + /// See also: + /// * [spellCheckConfiguration], where this is typically specified for + /// [CupertinoTextField]. + /// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the + /// parameter for which this is the default value for [CupertinoTextField]. + /// * [TextField.defaultSpellCheckSuggestionsToolbarBuilder], which is like + /// this but specifies the default for [CupertinoTextField]. + /// [CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder] + @visibleForTesting + static Widget defaultIosSpellCheckSuggestionsToolbarBuilder( + BuildContext context, + ExtendedEditableTextState editableTextState, + ) { + return ExtendedCupertinoSpellCheckSuggestionsToolbar.editableText( + editableTextState: editableTextState, + ); + } + @override State<_TextField> createState() { return _ExtendedTextFieldState(); @@ -94,9 +264,6 @@ class ExtendedTextField1 extends _TextField { class _ExtendedTextFieldState extends _TextFieldState { ExtendedTextField1 get extenedTextField => widget as ExtendedTextField1; - // @override - // final GlobalKey editableTextKey = - // GlobalKey(); @override Widget build(BuildContext context) { @@ -134,21 +301,25 @@ class _ExtendedTextFieldState extends _TextFieldState { // Set configuration as disabled if not otherwise specified. If specified, // ensure that configuration uses the correct style for misspelled words for // the current platform, unless a custom style is specified. - final SpellCheckConfiguration spellCheckConfiguration; + // zmtzawqlp + final ExtendedSpellCheckConfiguration spellCheckConfiguration; switch (defaultTargetPlatform) { case TargetPlatform.iOS: case TargetPlatform.macOS: + // zmtzawqlp spellCheckConfiguration = - CupertinoTextField.inferIOSSpellCheckConfiguration( - widget.spellCheckConfiguration, + ExtendedTextField1.inferIOSSpellCheckConfiguration( + extenedTextField.extendedSpellCheckConfiguration, ); break; case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: - spellCheckConfiguration = TextField.inferAndroidSpellCheckConfiguration( - widget.spellCheckConfiguration, + // zmtzawqlp + spellCheckConfiguration = + ExtendedTextField1.inferAndroidSpellCheckConfiguration( + extenedTextField.extendedSpellCheckConfiguration, ); break; } @@ -321,8 +492,11 @@ class _ExtendedTextFieldState extends _TextFieldState { scribbleEnabled: widget.scribbleEnabled, enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, contentInsertionConfiguration: widget.contentInsertionConfiguration, - contextMenuBuilder: widget.contextMenuBuilder, - spellCheckConfiguration: spellCheckConfiguration, + // contextMenuBuilder: widget.contextMenuBuilder, + // spellCheckConfiguration: spellCheckConfiguration, + extendedContextMenuBuilder: + extenedTextField.extendedContextMenuBuilder, + extendedSpellCheckConfiguration: spellCheckConfiguration, magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration, ), diff --git a/lib/src/official/rendering/editable.dart b/lib/src/official/rendering/editable.dart index 3b3dd24..06c9753 100644 --- a/lib/src/official/rendering/editable.dart +++ b/lib/src/official/rendering/editable.dart @@ -1,4 +1,4 @@ -part of 'package:extended_text_field/src/extended/widgets/editable_text.dart'; +part of 'package:extended_text_field/src/extended/widgets/text_field.dart'; const double _kCaretGap = 1.0; // pixels const double _kCaretHeightOffset = 2.0; // pixels diff --git a/lib/src/official/widgets/editable_text.dart b/lib/src/official/widgets/editable_text.dart index 8ac9816..046135c 100644 --- a/lib/src/official/widgets/editable_text.dart +++ b/lib/src/official/widgets/editable_text.dart @@ -1,4 +1,4 @@ -part of 'package:extended_text_field/src/extended/widgets/editable_text.dart'; +part of 'package:extended_text_field/src/extended/widgets/text_field.dart'; // Signature for a function that determines the target location of the given // [TextPosition] after applying the given [TextBoundary]. @@ -461,7 +461,7 @@ class _EditableText extends StatefulWidget { assert( spellCheckConfiguration == null || spellCheckConfiguration == - const SpellCheckConfiguration.disabled() || + const _SpellCheckConfiguration.disabled() || spellCheckConfiguration.misspelledTextStyle != null, 'spellCheckConfiguration must specify a misspelledTextStyle if spell check behavior is desired', ), @@ -1417,7 +1417,7 @@ class _EditableText extends StatefulWidget { /// /// If this configuration is left null, then spell check is disabled by default. /// {@endtemplate} - final SpellCheckConfiguration? spellCheckConfiguration; + final _SpellCheckConfiguration? spellCheckConfiguration; /// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.intro} /// @@ -1705,7 +1705,7 @@ class _EditableText extends StatefulWidget { properties.add(DiagnosticsProperty( 'undoController', undoController, defaultValue: null)); - properties.add(DiagnosticsProperty( + properties.add(DiagnosticsProperty<_SpellCheckConfiguration>( 'spellCheckConfiguration', spellCheckConfiguration, defaultValue: null)); properties.add(DiagnosticsProperty>('contentCommitMimeTypes', @@ -1718,19 +1718,14 @@ class _EditableText extends StatefulWidget { /// State for a [_EditableText]. /// zmtzawqlp -class _EditableTextState extends EditableTextState -// with -// AutomaticKeepAliveClientMixin<_EditableText>, -// WidgetsBindingObserver, -// TickerProviderStateMixin<_EditableText>, -// TextSelectionDelegate, -// TextInputClient -// implements AutofillClient -{ - // zmtzawqlp - bool get _userSelectionEnabled => - widget.enableInteractiveSelection && - (!widget.readOnly || !widget.obscureText); +class _EditableTextState extends State<_EditableText> + with + AutomaticKeepAliveClientMixin<_EditableText>, + WidgetsBindingObserver, + TickerProviderStateMixin<_EditableText>, + TextSelectionDelegate, + TextInputClient + implements AutofillClient { Timer? _cursorTimer; AnimationController get _cursorBlinkOpacityController { return _backingCursorBlinkOpacityController ??= AnimationController( @@ -1752,7 +1747,7 @@ class _EditableTextState extends EditableTextState TextInputConnection? _textInputConnection; bool get _hasInputConnection => _textInputConnection?.attached ?? false; - TextSelectionOverlay? _selectionOverlay; + _TextSelectionOverlay? _selectionOverlay; final GlobalKey _scrollableKey = GlobalKey(); ScrollController? _internalScrollController; @@ -1772,7 +1767,7 @@ class _EditableTextState extends EditableTextState AutofillClient get _effectiveAutofillClient => widget.autofillClient ?? this; - late SpellCheckConfiguration _spellCheckConfiguration; + late _SpellCheckConfiguration _spellCheckConfiguration; late TextStyle _style; /// Configuration that determines how spell check will be performed. @@ -1783,12 +1778,12 @@ class _EditableTextState extends EditableTextState /// See also: /// * [DefaultSpellCheckService], the spell check service used by default. @visibleForTesting - SpellCheckConfiguration get spellCheckConfiguration => + _SpellCheckConfiguration get spellCheckConfiguration => _spellCheckConfiguration; /// Whether or not spell check is enabled. /// - /// Spell check is enabled when a [SpellCheckConfiguration] has been specified + /// Spell check is enabled when a [_SpellCheckConfiguration] has been specified /// for the widget. bool get spellCheckEnabled => _spellCheckConfiguration.spellCheckEnabled; @@ -2086,16 +2081,16 @@ class _EditableTextState extends EditableTextState return null; } - /// Infers the [SpellCheckConfiguration] used to perform spell check. + /// Infers the [_SpellCheckConfiguration] used to perform spell check. /// /// If spell check is enabled, this will try to infer a value for /// the [SpellCheckService] if left unspecified. - static SpellCheckConfiguration _inferSpellCheckConfiguration( - SpellCheckConfiguration? configuration) { + static _SpellCheckConfiguration _inferSpellCheckConfiguration( + _SpellCheckConfiguration? configuration) { final SpellCheckService? spellCheckService = configuration?.spellCheckService; final bool spellCheckAutomaticallyDisabled = configuration == null || - configuration == const SpellCheckConfiguration.disabled(); + configuration == const _SpellCheckConfiguration.disabled(); final bool spellCheckServiceIsConfigured = spellCheckService != null || spellCheckService == null && WidgetsBinding @@ -2123,7 +2118,7 @@ class _EditableTextState extends EditableTextState } return true; }()); - return const SpellCheckConfiguration.disabled(); + return const _SpellCheckConfiguration.disabled(); } return configuration.copyWith( @@ -2288,8 +2283,9 @@ class _EditableTextState extends EditableTextState widget.focusNode.addListener(_handleFocusChanged); _scrollController.addListener(_onEditableScroll); _cursorVisibilityNotifier.value = widget.showCursor; - _spellCheckConfiguration = - _inferSpellCheckConfiguration(widget.spellCheckConfiguration); + // zmtzawqlp + // _spellCheckConfiguration = + // _inferSpellCheckConfiguration(widget.spellCheckConfiguration); } // Whether `TickerMode.of(context)` is true and animations (like blinking the @@ -2357,7 +2353,7 @@ class _EditableTextState extends EditableTextState } @override - void didUpdateWidget(EditableText oldWidget) { + void didUpdateWidget(_EditableText oldWidget) { super.didUpdateWidget(oldWidget); if (widget.controller != oldWidget.controller) { oldWidget.controller.removeListener(_didChangeTextEditingValue); @@ -3058,8 +3054,8 @@ class _EditableTextState extends EditableTextState _scribbleCacheKey = null; } - TextSelectionOverlay _createSelectionOverlay() { - final TextSelectionOverlay selectionOverlay = TextSelectionOverlay( + _TextSelectionOverlay _createSelectionOverlay() { + final _TextSelectionOverlay selectionOverlay = _TextSelectionOverlay( clipboardStatus: clipboardStatus, context: context, value: _value, @@ -3072,14 +3068,15 @@ class _EditableTextState extends EditableTextState selectionDelegate: this, dragStartBehavior: widget.dragStartBehavior, onSelectionHandleTapped: widget.onSelectionHandleTapped, - contextMenuBuilder: widget.contextMenuBuilder == null - ? null - : (BuildContext context) { - return widget.contextMenuBuilder!( - context, - this, - ); - }, + // zmtzawqlp + // contextMenuBuilder: widget.contextMenuBuilder == null + // ? null + // : (BuildContext context) { + // return widget.contextMenuBuilder!( + // context, + // this, + // ); + // }, magnifierConfiguration: widget.magnifierConfiguration, ); @@ -3417,8 +3414,10 @@ class _EditableTextState extends EditableTextState Duration get cursorBlinkInterval => _kCursorBlinkHalfPeriod; /// The current status of the text selection handles. - @visibleForTesting - TextSelectionOverlay? get selectionOverlay => _selectionOverlay; + // @override + // @visibleForTesting + // zmtzawqlp + // TextSelectionOverlay? get selectionOverlay => _selectionOverlay; int _obscureShowCharTicksPending = 0; int? _obscureLatestCharIndex; @@ -3687,11 +3686,11 @@ class _EditableTextState extends EditableTextState /// /// This property is typically used to notify the renderer of input gestures /// when [RenderEditable.ignorePointer] is true. - // late final RenderEditable renderEditable = - // _editableKey.currentContext!.findRenderObject()! as RenderEditable; - // zmtzawqlp - late final _RenderEditable _renderEditable = + late final _RenderEditable renderEditable = _editableKey.currentContext!.findRenderObject()! as _RenderEditable; + // // zmtzawqlp + // late final _RenderEditable _renderEditable = + // _editableKey.currentContext!.findRenderObject()! as _RenderEditable; @override TextEditingValue get textEditingValue => _value; @@ -3769,7 +3768,7 @@ class _EditableTextState extends EditableTextState /// Toggles the visibility of the toolbar. void toggleToolbar([bool hideHandles = true]) { - final TextSelectionOverlay selectionOverlay = + final _TextSelectionOverlay selectionOverlay = _selectionOverlay ??= _createSelectionOverlay(); if (selectionOverlay.toolbarIsVisible) { @@ -3807,14 +3806,15 @@ class _EditableTextState extends EditableTextState 'suggestions', ); - _selectionOverlay!.showSpellCheckSuggestionsToolbar( - (BuildContext context) { - return _spellCheckConfiguration.spellCheckSuggestionsToolbarBuilder!( - context, - this, - ); - }, - ); + // TODO + // _selectionOverlay!.showSpellCheckSuggestionsToolbar( + // (BuildContext context) { + // return _spellCheckConfiguration.spellCheckSuggestionsToolbarBuilder!( + // context, + // this, + // ); + // }, + // ); return true; } @@ -3918,7 +3918,7 @@ class _EditableTextState extends EditableTextState smartDashesType: widget.smartDashesType, smartQuotesType: widget.smartQuotesType, enableSuggestions: widget.enableSuggestions, - enableInteractiveSelection: _userSelectionEnabled, + enableInteractiveSelection: widget._userSelectionEnabled, inputAction: widget.textInputAction ?? (widget.keyboardType == TextInputType.multiline ? TextInputAction.newline @@ -4520,7 +4520,8 @@ class _EditableTextState extends EditableTextState selectionHeightStyle: widget.selectionHeightStyle, selectionWidthStyle: widget.selectionWidthStyle, paintCursorAboveText: widget.paintCursorAboveText, - enableInteractiveSelection: _userSelectionEnabled, + enableInteractiveSelection: + widget._userSelectionEnabled, textSelectionDelegate: this, devicePixelRatio: _devicePixelRatio, promptRectRange: _currentPromptRectRange, @@ -5225,8 +5226,7 @@ class _UpdateTextSelectionVerticallyAction< } final VerticalCaretMovementRun currentRun = _verticalMovementRun ?? - // zmtzawqlp - state._renderEditable + state.renderEditable .startVerticalCaretMovement(state.renderEditable.selection!.extent); final bool shouldMove = intent diff --git a/lib/src/official/widgets/spell_check.dart b/lib/src/official/widgets/spell_check.dart new file mode 100644 index 0000000..ade95af --- /dev/null +++ b/lib/src/official/widgets/spell_check.dart @@ -0,0 +1,418 @@ +part of 'package:extended_text_field/src/extended/widgets/text_field.dart'; + +/// Controls how spell check is performed for text input. +/// +/// This configuration determines the [SpellCheckService] used to fetch the +/// [List] spell check results and the [TextStyle] used to +/// mark misspelled words within text input. +@immutable +class _SpellCheckConfiguration { + /// Creates a configuration that specifies the service and suggestions handler + /// for spell check. + const _SpellCheckConfiguration({ + this.spellCheckService, + this.misspelledSelectionColor, + this.misspelledTextStyle, + this.spellCheckSuggestionsToolbarBuilder, + }) : _spellCheckEnabled = true; + + /// Creates a configuration that disables spell check. + const _SpellCheckConfiguration.disabled() + : _spellCheckEnabled = false, + spellCheckService = null, + spellCheckSuggestionsToolbarBuilder = null, + misspelledTextStyle = null, + misspelledSelectionColor = null; + + /// The service used to fetch spell check results for text input. + final SpellCheckService? spellCheckService; + + /// The color the paint the selection highlight when spell check is showing + /// suggestions for a misspelled word. + /// + /// For example, on iOS, the selection appears red while the spell check menu + /// is showing. + final Color? misspelledSelectionColor; + + /// Style used to indicate misspelled words. + /// + /// This is nullable to allow style-specific wrappers of [EditableText] + /// to infer this, but this must be specified if this configuration is + /// provided directly to [EditableText] or its construction will fail with an + /// assertion error. + final TextStyle? misspelledTextStyle; + + /// Builds the toolbar used to display spell check suggestions for misspelled + /// words. + final EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder; + + final bool _spellCheckEnabled; + + /// Whether or not the configuration should enable or disable spell check. + bool get spellCheckEnabled => _spellCheckEnabled; + + /// Returns a copy of the current [_SpellCheckConfiguration] instance with + /// specified overrides. + _SpellCheckConfiguration copyWith( + {SpellCheckService? spellCheckService, + Color? misspelledSelectionColor, + TextStyle? misspelledTextStyle, + EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder}) { + if (!_spellCheckEnabled) { + // A new configuration should be constructed to enable spell check. + return const _SpellCheckConfiguration.disabled(); + } + + return _SpellCheckConfiguration( + spellCheckService: spellCheckService ?? this.spellCheckService, + misspelledSelectionColor: + misspelledSelectionColor ?? this.misspelledSelectionColor, + misspelledTextStyle: misspelledTextStyle ?? this.misspelledTextStyle, + spellCheckSuggestionsToolbarBuilder: + spellCheckSuggestionsToolbarBuilder ?? + this.spellCheckSuggestionsToolbarBuilder, + ); + } + + @override + String toString() { + return ''' + spell check enabled : $_spellCheckEnabled + spell check service : $spellCheckService + misspelled text style : $misspelledTextStyle + spell check suggestions toolbar builder: $spellCheckSuggestionsToolbarBuilder +''' + .trim(); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + return other is _SpellCheckConfiguration && + other.spellCheckService == spellCheckService && + other.misspelledTextStyle == misspelledTextStyle && + other.spellCheckSuggestionsToolbarBuilder == + spellCheckSuggestionsToolbarBuilder && + other._spellCheckEnabled == _spellCheckEnabled; + } + + @override + int get hashCode => Object.hash(spellCheckService, misspelledTextStyle, + spellCheckSuggestionsToolbarBuilder, _spellCheckEnabled); +} + +// Methods for displaying spell check results: + +/// Adjusts spell check results to correspond to [newText] if the only results +/// that the handler has access to are the [results] corresponding to +/// [resultsText]. +/// +/// Used in the case where the request for the spell check results of the +/// [newText] is lagging in order to avoid display of incorrect results. +List _correctSpellCheckResults( + String newText, String resultsText, List results) { + final List correctedSpellCheckResults = []; + int spanPointer = 0; + int offset = 0; + + // Assumes that the order of spans has not been jumbled for optimization + // purposes, and will only search since the previously found span. + int searchStart = 0; + + while (spanPointer < results.length) { + final SuggestionSpan currentSpan = results[spanPointer]; + final String currentSpanText = + resultsText.substring(currentSpan.range.start, currentSpan.range.end); + final int spanLength = currentSpan.range.end - currentSpan.range.start; + + // Try finding SuggestionSpan from resultsText in new text. + final RegExp currentSpanTextRegexp = RegExp('\\b$currentSpanText\\b'); + final int foundIndex = + newText.substring(searchStart).indexOf(currentSpanTextRegexp); + + // Check whether word was found exactly where expected or elsewhere in the newText. + final bool currentSpanFoundExactly = + currentSpan.range.start == foundIndex + searchStart; + final bool currentSpanFoundExactlyWithOffset = + currentSpan.range.start + offset == foundIndex + searchStart; + final bool currentSpanFoundElsewhere = foundIndex >= 0; + + if (currentSpanFoundExactly || currentSpanFoundExactlyWithOffset) { + // currentSpan was found at the same index in newText and resutsText + // or at the same index with the previously calculated adjustment by + // the offset value, so apply it to new text by adding it to the list of + // corrected results. + final SuggestionSpan adjustedSpan = SuggestionSpan( + TextRange( + start: currentSpan.range.start + offset, + end: currentSpan.range.end + offset, + ), + currentSpan.suggestions, + ); + + // Start search for the next misspelled word at the end of currentSpan. + searchStart = currentSpan.range.end + 1 + offset; + correctedSpellCheckResults.add(adjustedSpan); + } else if (currentSpanFoundElsewhere) { + // Word was pushed forward but not modified. + final int adjustedSpanStart = searchStart + foundIndex; + final int adjustedSpanEnd = adjustedSpanStart + spanLength; + final SuggestionSpan adjustedSpan = SuggestionSpan( + TextRange(start: adjustedSpanStart, end: adjustedSpanEnd), + currentSpan.suggestions, + ); + + // Start search for the next misspelled word at the end of the + // adjusted currentSpan. + searchStart = adjustedSpanEnd + 1; + // Adjust offset to reflect the difference between where currentSpan + // was positioned in resultsText versus in newText. + offset = adjustedSpanStart - currentSpan.range.start; + correctedSpellCheckResults.add(adjustedSpan); + } + spanPointer++; + } + return correctedSpellCheckResults; +} + +/// Builds the [TextSpan] tree given the current state of the text input and +/// spell check results. +/// +/// The [value] is the current [TextEditingValue] requested to be rendered +/// by a text input widget. The [composingWithinCurrentTextRange] value +/// represents whether or not there is a valid composing region in the +/// [value]. The [style] is the [TextStyle] to render the [value]'s text with, +/// and the [misspelledTextStyle] is the [TextStyle] to render misspelled +/// words within the [value]'s text with. The [spellCheckResults] are the +/// results of spell checking the [value]'s text. +TextSpan buildTextSpanWithSpellCheckSuggestions( + TextEditingValue value, + bool composingWithinCurrentTextRange, + TextStyle? style, + TextStyle misspelledTextStyle, + SpellCheckResults spellCheckResults) { + List spellCheckResultsSpans = + spellCheckResults.suggestionSpans; + final String spellCheckResultsText = spellCheckResults.spellCheckedText; + + if (spellCheckResultsText != value.text) { + spellCheckResultsSpans = _correctSpellCheckResults( + value.text, spellCheckResultsText, spellCheckResultsSpans); + } + + // We will draw the TextSpan tree based on the composing region, if it is + // available. + // TODO(camsim99): The two separate stratgies for building TextSpan trees + // based on the availability of a composing region should be merged: + // https://github.com/flutter/flutter/issues/124142. + final bool shouldConsiderComposingRegion = + defaultTargetPlatform == TargetPlatform.android; + if (shouldConsiderComposingRegion) { + return TextSpan( + style: style, + children: _buildSubtreesWithComposingRegion( + spellCheckResultsSpans, + value, + style, + misspelledTextStyle, + composingWithinCurrentTextRange, + ), + ); + } + + return TextSpan( + style: style, + children: _buildSubtreesWithoutComposingRegion( + spellCheckResultsSpans, + value, + style, + misspelledTextStyle, + value.selection.baseOffset, + ), + ); +} + +/// Builds the [TextSpan] tree for spell check without considering the composing +/// region. Instead, uses the cursor to identify the word that's actively being +/// edited and shouldn't be spell checked. This is useful for platforms and IMEs +/// that don't use the composing region for the active word. +List _buildSubtreesWithoutComposingRegion( + List? spellCheckSuggestions, + TextEditingValue value, + TextStyle? style, + TextStyle misspelledStyle, + int cursorIndex, +) { + final List textSpanTreeChildren = []; + + int textPointer = 0; + int currentSpanPointer = 0; + int endIndex; + final String text = value.text; + final TextStyle misspelledJointStyle = + style?.merge(misspelledStyle) ?? misspelledStyle; + bool cursorInCurrentSpan = false; + + // Add text interwoven with any misspelled words to the tree. + if (spellCheckSuggestions != null) { + while (textPointer < text.length && + currentSpanPointer < spellCheckSuggestions.length) { + final SuggestionSpan currentSpan = + spellCheckSuggestions[currentSpanPointer]; + + if (currentSpan.range.start > textPointer) { + endIndex = currentSpan.range.start < text.length + ? currentSpan.range.start + : text.length; + textSpanTreeChildren.add(TextSpan( + style: style, + text: text.substring(textPointer, endIndex), + )); + textPointer = endIndex; + } else { + endIndex = currentSpan.range.end < text.length + ? currentSpan.range.end + : text.length; + cursorInCurrentSpan = currentSpan.range.start <= cursorIndex && + currentSpan.range.end >= cursorIndex; + textSpanTreeChildren.add(TextSpan( + style: cursorInCurrentSpan ? style : misspelledJointStyle, + text: text.substring(currentSpan.range.start, endIndex), + )); + + textPointer = endIndex; + currentSpanPointer++; + } + } + } + + // Add any remaining text to the tree if applicable. + if (textPointer < text.length) { + textSpanTreeChildren.add(TextSpan( + style: style, + text: text.substring(textPointer, text.length), + )); + } + + return textSpanTreeChildren; +} + +/// Builds [TextSpan] subtree for text with misspelled words with logic based on +/// a valid composing region. +List _buildSubtreesWithComposingRegion( + List? spellCheckSuggestions, + TextEditingValue value, + TextStyle? style, + TextStyle misspelledStyle, + bool composingWithinCurrentTextRange) { + final List textSpanTreeChildren = []; + + int textPointer = 0; + int currentSpanPointer = 0; + int endIndex; + SuggestionSpan currentSpan; + final String text = value.text; + final TextRange composingRegion = value.composing; + final TextStyle composingTextStyle = + style?.merge(const TextStyle(decoration: TextDecoration.underline)) ?? + const TextStyle(decoration: TextDecoration.underline); + final TextStyle misspelledJointStyle = + style?.merge(misspelledStyle) ?? misspelledStyle; + bool textPointerWithinComposingRegion = false; + bool currentSpanIsComposingRegion = false; + + // Add text interwoven with any misspelled words to the tree. + if (spellCheckSuggestions != null) { + while (textPointer < text.length && + currentSpanPointer < spellCheckSuggestions.length) { + currentSpan = spellCheckSuggestions[currentSpanPointer]; + + if (currentSpan.range.start > textPointer) { + endIndex = currentSpan.range.start < text.length + ? currentSpan.range.start + : text.length; + textPointerWithinComposingRegion = + composingRegion.start >= textPointer && + composingRegion.end <= endIndex && + !composingWithinCurrentTextRange; + + if (textPointerWithinComposingRegion) { + _addComposingRegionTextSpans(textSpanTreeChildren, text, textPointer, + composingRegion, style, composingTextStyle); + textSpanTreeChildren.add(TextSpan( + style: style, + text: text.substring(composingRegion.end, endIndex), + )); + } else { + textSpanTreeChildren.add(TextSpan( + style: style, + text: text.substring(textPointer, endIndex), + )); + } + + textPointer = endIndex; + } else { + endIndex = currentSpan.range.end < text.length + ? currentSpan.range.end + : text.length; + currentSpanIsComposingRegion = textPointer >= composingRegion.start && + endIndex <= composingRegion.end && + !composingWithinCurrentTextRange; + textSpanTreeChildren.add(TextSpan( + style: currentSpanIsComposingRegion + ? composingTextStyle + : misspelledJointStyle, + text: text.substring(currentSpan.range.start, endIndex), + )); + + textPointer = endIndex; + currentSpanPointer++; + } + } + } + + // Add any remaining text to the tree if applicable. + if (textPointer < text.length) { + if (textPointer < composingRegion.start && + !composingWithinCurrentTextRange) { + _addComposingRegionTextSpans(textSpanTreeChildren, text, textPointer, + composingRegion, style, composingTextStyle); + + if (composingRegion.end != text.length) { + textSpanTreeChildren.add(TextSpan( + style: style, + text: text.substring(composingRegion.end, text.length), + )); + } + } else { + textSpanTreeChildren.add(TextSpan( + style: style, + text: text.substring(textPointer, text.length), + )); + } + } + + return textSpanTreeChildren; +} + +/// Helper method to create [TextSpan] tree children for specified range of +/// text up to and including the composing region. +void _addComposingRegionTextSpans( + List treeChildren, + String text, + int start, + TextRange composingRegion, + TextStyle? style, + TextStyle composingTextStyle) { + treeChildren.add(TextSpan( + style: style, + text: text.substring(start, composingRegion.start), + )); + treeChildren.add(TextSpan( + style: composingTextStyle, + text: text.substring(composingRegion.start, composingRegion.end), + )); +} diff --git a/lib/src/official/widgets/text_field.dart b/lib/src/official/widgets/text_field.dart index 4e831c7..6b60102 100644 --- a/lib/src/official/widgets/text_field.dart +++ b/lib/src/official/widgets/text_field.dart @@ -2,7 +2,7 @@ part of 'package:extended_text_field/src/extended/widgets/text_field.dart'; /// [_TextField] class _TextFieldSelectionGestureDetectorBuilder - extends TextSelectionGestureDetectorBuilder { + extends _TextSelectionGestureDetectorBuilder { _TextFieldSelectionGestureDetectorBuilder({ required _TextFieldState state, }) : _state = state, @@ -748,14 +748,14 @@ class _TextField extends StatefulWidget { /// {@macro flutter.widgets.EditableText.spellCheckConfiguration} /// - /// If [SpellCheckConfiguration.misspelledTextStyle] is not specified in this + /// If [_SpellCheckConfiguration.misspelledTextStyle] is not specified in this /// configuration, then [materialMisspelledTextStyle] is used by default. final SpellCheckConfiguration? spellCheckConfiguration; /// The [TextStyle] used to indicate misspelled words in the Material style. /// /// See also: - /// * [SpellCheckConfiguration.misspelledTextStyle], the style configured to + /// * [_SpellCheckConfiguration.misspelledTextStyle], the style configured to /// mark misspelled words with. /// * [CupertinoTextField.cupertinoMisspelledTextStyle], the style configured /// to mark misspelled words with in the Cupertino style. @@ -773,7 +773,7 @@ class _TextField extends StatefulWidget { /// See also: /// * [spellCheckConfiguration], where this is typically specified for /// [_TextField]. - /// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the + /// * [_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]. @@ -798,15 +798,15 @@ class _TextField extends StatefulWidget { } } - /// Returns a new [SpellCheckConfiguration] where the given configuration has + /// Returns a new [_SpellCheckConfiguration] where the given configuration has /// had any missing values replaced with their defaults for the Android /// platform. - static SpellCheckConfiguration inferAndroidSpellCheckConfiguration( - SpellCheckConfiguration? configuration, + static _SpellCheckConfiguration inferAndroidSpellCheckConfiguration( + _SpellCheckConfiguration? configuration, ) { if (configuration == null || - configuration == const SpellCheckConfiguration.disabled()) { - return const SpellCheckConfiguration.disabled(); + configuration == const _SpellCheckConfiguration.disabled()) { + return const _SpellCheckConfiguration.disabled(); } return configuration.copyWith( misspelledTextStyle: configuration.misspelledTextStyle ?? @@ -932,7 +932,7 @@ class _TextField extends StatefulWidget { class _TextFieldState extends State<_TextField> with RestorationMixin - implements TextSelectionGestureDetectorBuilderDelegate, AutofillClient { + implements _TextSelectionGestureDetectorBuilderDelegate, AutofillClient { RestorableTextEditingController? _controller; TextEditingController get _effectiveController => widget.controller ?? _controller!.value; @@ -962,9 +962,10 @@ class _TextFieldState extends State<_TextField> @override late bool forcePressEnabled; + // zmtzawqlp @override - final GlobalKey editableTextKey = - GlobalKey(); + final GlobalKey<_EditableTextState> editableTextKey = + GlobalKey<_EditableTextState>(); @override bool get selectionEnabled => widget.selectionEnabled; @@ -1152,7 +1153,7 @@ class _TextFieldState extends State<_TextField> super.dispose(); } - EditableTextState? get _editableText => editableTextKey.currentState; + _EditableTextState? get _editableText => editableTextKey.currentState; void _requestKeyboard() { _editableText?.requestKeyboard(); @@ -1342,8 +1343,7 @@ class _TextFieldState extends State<_TextField> case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: - spellCheckConfiguration = - _TextField.inferAndroidSpellCheckConfiguration( + spellCheckConfiguration = TextField.inferAndroidSpellCheckConfiguration( widget.spellCheckConfiguration, ); break; diff --git a/lib/src/official/widgets/text_selection.dart b/lib/src/official/widgets/text_selection.dart new file mode 100644 index 0000000..6456a25 --- /dev/null +++ b/lib/src/official/widgets/text_selection.dart @@ -0,0 +1,3053 @@ +part of 'package:extended_text_field/src/extended/widgets/text_field.dart'; + +/// 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 not be null and 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, + }) : _handlesVisible = handlesVisible, + _value = value { + 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, + ); + } + + /// {@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; + + /// Controls the fade-in and fade-out animations for the toolbar and handles. + @Deprecated('Use `SelectionOverlay.fadeDuration` instead. ' + 'This feature was deprecated after v2.12.0-4.1.pre.') + static const Duration fadeDuration = _SelectionOverlay.fadeDuration; + + // 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. + // zmtzawqlp + 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 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; + + /// Whether the toolbar is currently visible. + /// + /// Includes both the text selection toolbar and the spell check menu. + /// + /// 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() { + _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({ + // zmtzawqlp + required _RenderEditable renderEditable, + required Offset globalGesturePosition, + required TextPosition currentTextPosition, + }) { + final Offset globalRenderEditableTopLeft = + renderEditable.localToGlobal(Offset.zero); + final Rect localCaretRect = + renderEditable.getLocalRectForCaret(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 lineBoundaries = Rect.fromPoints( + renderEditable.getLocalRectForCaret(positionAtBeginningOfLine).topCenter, + renderEditable.getLocalRectForCaret(positionAtEndOfLine).bottomCenter, + ); + + return MagnifierInfo( + fieldBounds: globalRenderEditableTopLeft & renderEditable.size, + globalGesturePosition: globalGesturePosition, + caretRect: localCaretRect.shift(globalRenderEditableTopLeft), + currentLineBoundaries: lineBoundaries.shift(globalRenderEditableTopLeft), + ); + } + + // The contact position of the gesture at the current end handle location. + // Updated when the handle moves. + late double _endHandleDragPosition; + + // The distance from _endHandleDragPosition to the center of the line that it + // corresponds to. + late double _endHandleDragPositionToCenterOfLine; + + void _handleSelectionEndHandleDragStart(DragStartDetails details) { + if (!renderObject.attached) { + return; + } + + // This adjusts for the fact that the selection handles may not + // perfectly cover the TextPosition that they correspond to. + _endHandleDragPosition = details.globalPosition.dy; + final Offset endPoint = renderObject + .localToGlobal(_selectionOverlay.selectionEndpoints.last.point); + final double centerOfLine = + endPoint.dy - renderObject.preferredLineHeight / 2; + _endHandleDragPositionToCenterOfLine = + centerOfLine - _endHandleDragPosition; + final TextPosition position = renderObject.getPositionForPoint( + Offset( + details.globalPosition.dx, + centerOfLine, + ), + ); + + _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. + 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; + } + + _endHandleDragPosition = + _getHandleDy(details.globalPosition.dy, _endHandleDragPosition); + final Offset adjustedOffset = Offset( + details.globalPosition.dx, + _endHandleDragPosition + _endHandleDragPositionToCenterOfLine, + ); + + final TextPosition position = + renderObject.getPositionForPoint(adjustedOffset); + + if (_selection.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: + newSelection = TextSelection( + extentOffset: position.offset, + baseOffset: _selection.start, + ); + if (position.offset <= _selection.start) { + return; // Don't allow order swapping. + } + break; + 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. + // Updated when the handle moves. + late double _startHandleDragPosition; + + // The distance from _startHandleDragPosition to the center of the line that + // it corresponds to. + late double _startHandleDragPositionToCenterOfLine; + + void _handleSelectionStartHandleDragStart(DragStartDetails details) { + if (!renderObject.attached) { + return; + } + + // This adjusts for the fact that the selection handles may not + // perfectly cover the TextPosition that they correspond to. + _startHandleDragPosition = details.globalPosition.dy; + final Offset startPoint = renderObject + .localToGlobal(_selectionOverlay.selectionEndpoints.first.point); + final double centerOfLine = + startPoint.dy - renderObject.preferredLineHeight / 2; + _startHandleDragPositionToCenterOfLine = + centerOfLine - _startHandleDragPosition; + final TextPosition position = renderObject.getPositionForPoint( + Offset( + details.globalPosition.dx, + centerOfLine, + ), + ); + + _selectionOverlay.showMagnifier( + _buildMagnifier( + currentTextPosition: position, + globalGesturePosition: details.globalPosition, + renderEditable: renderObject, + ), + ); + } + + void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) { + if (!renderObject.attached) { + return; + } + + _startHandleDragPosition = + _getHandleDy(details.globalPosition.dy, _startHandleDragPosition); + final Offset adjustedOffset = Offset( + details.globalPosition.dx, + _startHandleDragPosition + _startHandleDragPositionToCenterOfLine, + ); + final TextPosition position = + renderObject.getPositionForPoint(adjustedOffset); + + if (_selection.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: + newSelection = TextSelection( + extentOffset: position.offset, + baseOffset: _selection.end, + ); + if (newSelection.extentOffset >= _selection.end) { + return; // Don't allow order swapping. + } + break; + 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; + } + 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; + } + + switch (textDirection) { + case TextDirection.ltr: + return ltrType; + case TextDirection.rtl: + return 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 not be null and 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)); + + /// {@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(); + + /// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.intro} + /// + /// {@macro flutter.widgets.magnifier.intro} + /// + /// By default, [_SelectionOverlay]'s [TextMagnifierConfiguration] is disabled. + /// + /// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.details} + final TextMagnifierConfiguration magnifierConfiguration; + + 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?.first, + 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); + _isDraggingStartHandle = details.kind == PointerDeviceKind.touch; + onStartHandleDragStart?.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; + 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); + _isDraggingEndHandle = details.kind == PointerDeviceKind.touch; + onEndHandleDragStart?.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; + 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(); + break; + 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. + List? _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; + } + + _handles = [ + OverlayEntry(builder: _buildStartHandle), + OverlayEntry(builder: _buildEndHandle), + ]; + Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor) + .insertAll(_handles!); + } + + /// {@template flutter.widgets.SelectionOverlay.hideHandles} + /// Destroys the handles by removing them from overlay. + /// {@endtemplate} + void hideHandles() { + if (_handles != null) { + _handles![0].remove(); + _handles![1].remove(); + _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( + 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; + if (_handles != null) { + _handles![0].markNeedsBuild(); + _handles![1].markNeedsBuild(); + } + _toolbar?.markNeedsBuild(); + if (_contextMenuController.isShown) { + _contextMenuController.markNeedsBuild(); + } else if (_spellCheckToolbarController.isShown) { + _spellCheckToolbarController.markNeedsBuild(); + } + }); + } else { + if (_handles != null) { + _handles![0].markNeedsBuild(); + _handles![1].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(); + if (_handles != null) { + _handles![0].remove(); + _handles![1].remove(); + _handles = null; + } + 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 = null; + } + + /// {@template flutter.widgets.SelectionOverlay.dispose} + /// Disposes this object and release resources. + /// {@endtemplate} + void dispose() { + hide(); + } + + Widget _buildStartHandle(BuildContext context) { + final Widget handle; + final TextSelectionControls? selectionControls = this.selectionControls; + if (selectionControls == null) { + handle = const SizedBox.shrink(); + } else { + handle = _SelectionHandleOverlay( + type: _startHandleType, + handleLayerLink: startHandleLayerLink, + onSelectionHandleTapped: onSelectionHandleTapped, + onSelectionHandleDragStart: _handleStartHandleDragStart, + onSelectionHandleDragUpdate: onStartHandleDragUpdate, + 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 || + _startHandleType == TextSelectionHandleType.collapsed) { + // Hide the second handle when collapsed. + handle = const SizedBox.shrink(); + } else { + handle = _SelectionHandleOverlay( + type: _endHandleType, + handleLayerLink: endHandleLayerLink, + onSelectionHandleTapped: onSelectionHandleTapped, + onSelectionHandleDragStart: _handleEndHandleDragStart, + onSelectionHandleDragUpdate: onEndHandleDragUpdate, + 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(); + } + } + + @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 Offset handleAnchor = widget.selectionControls.getHandleAnchor( + widget.type, + widget.preferredLineHeight, + ); + final Size handleSize = widget.selectionControls.getHandleSize( + widget.preferredLineHeight, + ); + + final Rect handleRect = Rect.fromLTWH( + -handleAnchor.dx, + -handleAnchor.dy, + handleSize.width, + handleSize.height, + ); + + // 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), + ); + + return CompositedTransformFollower( + link: widget.handleLayerLink, + offset: interactiveRect.topLeft, + showWhenUnlinked: false, + child: FadeTransition( + opacity: _opacity, + child: Container( + alignment: Alignment.topLeft, + width: interactiveRect.width, + height: interactiveRect.height, + 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 + ..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, + ), + ), + ), + ), + ), + ); + } +} + +/// Builds a [TextSelectionGestureDetector] to wrap an [EditableText]. +/// +/// The class implements sensible defaults for many user interactions +/// with an [EditableText] (see the documentation of the various gesture handler +/// methods, e.g. [onTapDown], [onForcePressStart], etc.). Subclasses of +/// [_TextSelectionGestureDetectorBuilder] can change the behavior performed in +/// responds to these gesture events by overriding the corresponding handler +/// methods of this class. +/// +/// The resulting [TextSelectionGestureDetector] to wrap an [EditableText] is +/// obtained by calling [buildGestureDetector]. +/// +/// See also: +/// +/// * [TextField], which uses a subclass to implement the Material-specific +/// gesture logic of an [EditableText]. +/// * [CupertinoTextField], which uses a subclass to implement the +/// Cupertino-specific gesture logic of an [EditableText]. +class _TextSelectionGestureDetectorBuilder { + /// Creates a [_TextSelectionGestureDetectorBuilder]. + /// + /// The [delegate] must not be null. + _TextSelectionGestureDetectorBuilder({ + required this.delegate, + }); + + /// The delegate for this [_TextSelectionGestureDetectorBuilder]. + /// + /// The delegate provides the builder with information about what actions can + /// currently be performed on the text field. Based on this, the builder adds + /// the correct gesture handlers to the gesture detector. + @protected + final _TextSelectionGestureDetectorBuilderDelegate delegate; + + /// Returns true if lastSecondaryTapDownPosition was on selection. + bool get _lastSecondaryTapWasOnSelection { + assert(renderEditable.lastSecondaryTapDownPosition != null); + if (renderEditable.selection == null) { + return false; + } + + final TextPosition textPosition = renderEditable.getPositionForPoint( + renderEditable.lastSecondaryTapDownPosition!, + ); + + return renderEditable.selection!.start <= textPosition.offset && + renderEditable.selection!.end >= textPosition.offset; + } + + bool _positionWasOnSelectionExclusive(TextPosition textPosition) { + final TextSelection? selection = renderEditable.selection; + if (selection == null) { + return false; + } + + return selection.start < textPosition.offset && + selection.end > textPosition.offset; + } + + bool _positionWasOnSelectionInclusive(TextPosition textPosition) { + final TextSelection? selection = renderEditable.selection; + if (selection == null) { + return false; + } + + return selection.start <= textPosition.offset && + selection.end >= textPosition.offset; + } + + /// Returns true if position was on selection. + bool _positionOnSelection(Offset position, TextSelection? targetSelection) { + if (targetSelection == null) { + return false; + } + + final TextPosition textPosition = + renderEditable.getPositionForPoint(position); + + return targetSelection.start <= textPosition.offset && + targetSelection.end >= textPosition.offset; + } + + /// Returns true if shift left or right is contained in the given set. + static bool _containsShift(Set keysPressed) { + return keysPressed.any({ + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight + }.contains); + } + + // Expand the selection to the given global position. + // + // Either base or extent will be moved to the last tapped position, whichever + // is closest. The selection will never shrink or pivot, only grow. + // + // If fromSelection is given, will expand from that selection instead of the + // current selection in renderEditable. + // + // See also: + // + // * [_extendSelection], which is similar but pivots the selection around + // the base. + void _expandSelection(Offset offset, SelectionChangedCause cause, + [TextSelection? fromSelection]) { + assert(renderEditable.selection?.baseOffset != null); + + final TextPosition tappedPosition = + renderEditable.getPositionForPoint(offset); + final TextSelection selection = fromSelection ?? renderEditable.selection!; + final bool baseIsCloser = + (tappedPosition.offset - selection.baseOffset).abs() < + (tappedPosition.offset - selection.extentOffset).abs(); + final TextSelection nextSelection = selection.copyWith( + baseOffset: baseIsCloser ? selection.extentOffset : selection.baseOffset, + extentOffset: tappedPosition.offset, + ); + + editableText.userUpdateTextEditingValue( + editableText.textEditingValue.copyWith( + selection: nextSelection, + ), + cause, + ); + } + + // Extend the selection to the given global position. + // + // Holds the base in place and moves the extent. + // + // See also: + // + // * [_expandSelection], which is similar but always increases the size of + // the selection. + void _extendSelection(Offset offset, SelectionChangedCause cause) { + assert(renderEditable.selection?.baseOffset != null); + + final TextPosition tappedPosition = + renderEditable.getPositionForPoint(offset); + final TextSelection selection = renderEditable.selection!; + final TextSelection nextSelection = selection.copyWith( + extentOffset: tappedPosition.offset, + ); + + editableText.userUpdateTextEditingValue( + editableText.textEditingValue.copyWith( + selection: nextSelection, + ), + cause, + ); + } + + /// Whether to show the selection toolbar. + /// + /// It is based on the signal source when a [onTapDown] is called. This getter + /// will return true if current [onTapDown] event is triggered by a touch or + /// a stylus. + bool get shouldShowSelectionToolbar => _shouldShowSelectionToolbar; + bool _shouldShowSelectionToolbar = true; + + /// The [State] of the [EditableText] for which the builder will provide a + /// [TextSelectionGestureDetector]. + @protected + _EditableTextState get editableText => delegate.editableTextKey.currentState!; + + /// The [RenderObject] of the [EditableText] for which the builder will + /// provide a [TextSelectionGestureDetector]. + @protected + // zmtzawqlp + _RenderEditable get renderEditable => editableText.renderEditable; + + /// The viewport offset pixels of any [Scrollable] containing the + /// [RenderEditable] at the last drag start. + double _dragStartScrollOffset = 0.0; + + /// The viewport offset pixels of the [RenderEditable] at the last drag start. + double _dragStartViewportOffset = 0.0; + + double get _scrollPosition { + final ScrollableState? scrollableState = + delegate.editableTextKey.currentContext == null + ? null + : Scrollable.maybeOf(delegate.editableTextKey.currentContext!); + return scrollableState == null ? 0.0 : scrollableState.position.pixels; + } + + // For a shift + tap + drag gesture, the TextSelection at the point of the + // tap. Mac uses this value to reset to the original selection when an + // inversion of the base and offset happens. + TextSelection? _dragStartSelection; + + // For tap + drag gesture on iOS, whether the position where the drag started + // was on the previous TextSelection. iOS uses this value to determine if + // the cursor should move on drag update. + // + // If the drag started on the previous selection then the cursor will move on + // drag update. If the drag did not start on the previous selection then the + // cursor will not move on drag update. + bool? _dragBeganOnPreviousSelection; + + // For iOS long press behavior when the field is not focused. iOS uses this value + // to determine if a long press began on a field that was not focused. + // + // If the field was not focused when the long press began, a long press will select + // the word and a long press move will select word-by-word. If the field was + // focused, the cursor moves to the long press position. + bool _longPressStartedWithoutFocus = false; + + /// Handler for [TextSelectionGestureDetector.onTapDown]. + /// + /// By default, it forwards the tap to [RenderEditable.handleTapDown] and sets + /// [shouldShowSelectionToolbar] to true if the tap was initiated by a finger or stylus. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onTapDown], which triggers this callback. + @protected + void onTapDown(TapDragDownDetails details) { + if (!delegate.selectionEnabled) { + return; + } + // TODO(Renzo-Olivares): Migrate text selection gestures away from saving state + // in renderEditable. The gesture callbacks can use the details objects directly + // in callbacks variants that provide them [TapGestureRecognizer.onSecondaryTap] + // vs [TapGestureRecognizer.onSecondaryTapUp] instead of having to track state in + // renderEditable. When this migration is complete we should remove this hack. + // See https://github.com/flutter/flutter/issues/115130. + renderEditable + .handleTapDown(TapDownDetails(globalPosition: details.globalPosition)); + // The selection overlay should only be shown when the user is interacting + // through a touch screen (via either a finger or a stylus). A mouse shouldn't + // trigger the selection overlay. + // For backwards-compatibility, we treat a null kind the same as touch. + final PointerDeviceKind? kind = details.kind; + // TODO(justinmc): Should a desktop platform show its selection toolbar when + // receiving a tap event? Say a Windows device with a touchscreen. + // https://github.com/flutter/flutter/issues/106586 + _shouldShowSelectionToolbar = kind == null || + kind == PointerDeviceKind.touch || + kind == PointerDeviceKind.stylus; + + // Handle shift + click selection if needed. + final bool isShiftPressed = _containsShift(details.keysPressedOnDown); + // It is impossible to extend the selection when the shift key is pressed, if the + // renderEditable.selection is invalid. + final bool isShiftPressedValid = + isShiftPressed && renderEditable.selection?.baseOffset != null; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + // On mobile platforms the selection is set on tap up. + break; + case TargetPlatform.macOS: + // On macOS, a shift-tapped unfocused field expands from 0, not from the + // previous selection. + if (isShiftPressedValid) { + final TextSelection? fromSelection = renderEditable.hasFocus + ? null + : const TextSelection.collapsed(offset: 0); + _expandSelection( + details.globalPosition, + SelectionChangedCause.tap, + fromSelection, + ); + return; + } + // On macOS, a tap/click places the selection in a precise position. + // This differs from iOS/iPadOS, where if the gesture is done by a touch + // then the selection moves to the closest word edge, instead of a + // precise position. + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + break; + case TargetPlatform.linux: + case TargetPlatform.windows: + if (isShiftPressedValid) { + _extendSelection(details.globalPosition, SelectionChangedCause.tap); + return; + } + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + break; + } + } + + /// Handler for [TextSelectionGestureDetector.onForcePressStart]. + /// + /// By default, it selects the word at the position of the force press, + /// if selection is enabled. + /// + /// This callback is only applicable when force press is enabled. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onForcePressStart], which triggers this + /// callback. + @protected + void onForcePressStart(ForcePressDetails details) { + assert(delegate.forcePressEnabled); + _shouldShowSelectionToolbar = true; + if (delegate.selectionEnabled) { + renderEditable.selectWordsInRange( + from: details.globalPosition, + cause: SelectionChangedCause.forcePress, + ); + } + } + + /// Handler for [TextSelectionGestureDetector.onForcePressEnd]. + /// + /// By default, it selects words in the range specified in [details] and shows + /// toolbar if it is necessary. + /// + /// This callback is only applicable when force press is enabled. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onForcePressEnd], which triggers this + /// callback. + @protected + void onForcePressEnd(ForcePressDetails details) { + assert(delegate.forcePressEnabled); + renderEditable.selectWordsInRange( + from: details.globalPosition, + cause: SelectionChangedCause.forcePress, + ); + if (shouldShowSelectionToolbar) { + editableText.showToolbar(); + } + } + + /// Handler for [TextSelectionGestureDetector.onSingleTapUp]. + /// + /// By default, it selects word edge if selection is enabled. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onSingleTapUp], which triggers + /// this callback. + @protected + void onSingleTapUp(TapDragUpDetails details) { + if (delegate.selectionEnabled) { + // Handle shift + click selection if needed. + final bool isShiftPressed = _containsShift(details.keysPressedOnDown); + // It is impossible to extend the selection when the shift key is pressed, if the + // renderEditable.selection is invalid. + final bool isShiftPressedValid = + isShiftPressed && renderEditable.selection?.baseOffset != null; + switch (defaultTargetPlatform) { + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + editableText.hideToolbar(); + break; + // On desktop platforms the selection is set on tap down. + case TargetPlatform.android: + editableText.hideToolbar(); + if (isShiftPressedValid) { + _extendSelection(details.globalPosition, SelectionChangedCause.tap); + return; + } + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + editableText.showSpellCheckSuggestionsToolbar(); + break; + case TargetPlatform.fuchsia: + editableText.hideToolbar(); + if (isShiftPressedValid) { + _extendSelection(details.globalPosition, SelectionChangedCause.tap); + return; + } + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + break; + case TargetPlatform.iOS: + if (isShiftPressedValid) { + // On iOS, a shift-tapped unfocused field expands from 0, not from + // the previous selection. + final TextSelection? fromSelection = renderEditable.hasFocus + ? null + : const TextSelection.collapsed(offset: 0); + _expandSelection( + details.globalPosition, + SelectionChangedCause.tap, + fromSelection, + ); + return; + } + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + // TODO(camsim99): Determine spell check toolbar behavior in these cases: + // https://github.com/flutter/flutter/issues/119573. + // Precise devices should place the cursor at a precise position if the + // word at the text position is not misspelled. + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + break; + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + // If the word that was tapped is misspelled, select the word and show the spell check suggestions + // toolbar once. If additional taps are made on a misspelled word, toggle the toolbar. If the word + // is not misspelled, default to the following behavior: + // + // Toggle the toolbar if the `previousSelection` is collapsed, the tap is on the selection, the + // TextAffinity remains the same, and the editable is focused. The TextAffinity is important when the + // cursor is on the boundary of a line wrap, if the affinity is different (i.e. it is downstream), the + // selection should move to the following line and not toggle the toolbar. + // + // Toggle the toolbar when the tap is exclusively within the bounds of a non-collapsed `previousSelection`, + // and the editable is focused. + // + // Selects the word edge closest to the tap when the editable is not focused, or if the tap was neither exclusively + // or inclusively on `previousSelection`. If the selection remains the same after selecting the word edge, then we + // toggle the toolbar. If the selection changes then we hide the toolbar. + final TextSelection previousSelection = + renderEditable.selection ?? + editableText.textEditingValue.selection; + final TextPosition textPosition = + renderEditable.getPositionForPoint(details.globalPosition); + final bool isAffinityTheSame = + textPosition.affinity == previousSelection.affinity; + final bool wordAtCursorIndexIsMisspelled = editableText + .findSuggestionSpanAtCursorIndex(textPosition.offset) != + null; + + if (wordAtCursorIndexIsMisspelled) { + renderEditable.selectWord(cause: SelectionChangedCause.tap); + if (previousSelection != + editableText.textEditingValue.selection) { + editableText.showSpellCheckSuggestionsToolbar(); + } else { + editableText.toggleToolbar(false); + } + } else if (((_positionWasOnSelectionExclusive(textPosition) && + !previousSelection.isCollapsed) || + (_positionWasOnSelectionInclusive(textPosition) && + previousSelection.isCollapsed && + isAffinityTheSame)) && + renderEditable.hasFocus) { + editableText.toggleToolbar(false); + } else { + renderEditable.selectWordEdge(cause: SelectionChangedCause.tap); + if (previousSelection == + editableText.textEditingValue.selection && + renderEditable.hasFocus) { + editableText.toggleToolbar(false); + } else { + editableText.hideToolbar(false); + } + } + } + } + } + } + + /// Handler for [TextSelectionGestureDetector.onSingleTapCancel]. + /// + /// By default, it services as place holder to enable subclass override. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onSingleTapCancel], which triggers + /// this callback. + @protected + void onSingleTapCancel() { + /* Subclass should override this method if needed. */ + } + + /// Handler for [TextSelectionGestureDetector.onSingleLongTapStart]. + /// + /// By default, it selects text position specified in [details] if selection + /// is enabled. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onSingleLongTapStart], which triggers + /// this callback. + @protected + void onSingleLongTapStart(LongPressStartDetails details) { + if (delegate.selectionEnabled) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + if (!renderEditable.hasFocus) { + _longPressStartedWithoutFocus = true; + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + } else { + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + } + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + break; + } + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + editableText.showMagnifier(details.globalPosition); + break; + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + break; + } + + _dragStartViewportOffset = renderEditable.offset.pixels; + _dragStartScrollOffset = _scrollPosition; + } + } + + /// Handler for [TextSelectionGestureDetector.onSingleLongTapMoveUpdate]. + /// + /// By default, it updates the selection location specified in [details] if + /// selection is enabled. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onSingleLongTapMoveUpdate], which + /// triggers this callback. + @protected + void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { + if (delegate.selectionEnabled) { + // Adjust the drag start offset for possible viewport offset changes. + final Offset editableOffset = renderEditable.maxLines == 1 + ? Offset(renderEditable.offset.pixels - _dragStartViewportOffset, 0.0) + : Offset( + 0.0, renderEditable.offset.pixels - _dragStartViewportOffset); + final Offset scrollableOffset = Offset( + 0.0, + _scrollPosition - _dragStartScrollOffset, + ); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + if (_longPressStartedWithoutFocus) { + renderEditable.selectWordsInRange( + from: details.globalPosition - + details.offsetFromOrigin - + editableOffset - + scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + } else { + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + } + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + renderEditable.selectWordsInRange( + from: details.globalPosition - + details.offsetFromOrigin - + editableOffset - + scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + } + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + editableText.showMagnifier(details.globalPosition); + break; + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + break; + } + } + } + + /// Handler for [TextSelectionGestureDetector.onSingleLongTapEnd]. + /// + /// By default, it shows toolbar if necessary. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onSingleLongTapEnd], which triggers this + /// callback. + @protected + void onSingleLongTapEnd(LongPressEndDetails details) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + editableText.hideMagnifier(); + break; + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + break; + } + if (shouldShowSelectionToolbar) { + editableText.showToolbar(); + } + _longPressStartedWithoutFocus = false; + _dragStartViewportOffset = 0.0; + _dragStartScrollOffset = 0.0; + } + + /// Handler for [TextSelectionGestureDetector.onSecondaryTap]. + /// + /// By default, selects the word if possible and shows the toolbar. + @protected + void onSecondaryTap() { + if (!delegate.selectionEnabled) { + return; + } + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + if (!_lastSecondaryTapWasOnSelection || !renderEditable.hasFocus) { + renderEditable.selectWord(cause: SelectionChangedCause.tap); + } + if (shouldShowSelectionToolbar) { + editableText.hideToolbar(); + editableText.showToolbar(); + } + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + if (!renderEditable.hasFocus) { + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + } + editableText.toggleToolbar(); + } + } + + /// Handler for [TextSelectionGestureDetector.onSecondaryTapDown]. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onSecondaryTapDown], which triggers this + /// callback. + /// * [onSecondaryTap], which is typically called after this. + @protected + void onSecondaryTapDown(TapDownDetails details) { + // TODO(Renzo-Olivares): Migrate text selection gestures away from saving state + // in renderEditable. The gesture callbacks can use the details objects directly + // in callbacks variants that provide them [TapGestureRecognizer.onSecondaryTap] + // vs [TapGestureRecognizer.onSecondaryTapUp] instead of having to track state in + // renderEditable. When this migration is complete we should remove this hack. + // See https://github.com/flutter/flutter/issues/115130. + renderEditable.handleSecondaryTapDown( + TapDownDetails(globalPosition: details.globalPosition)); + _shouldShowSelectionToolbar = true; + } + + /// Handler for [TextSelectionGestureDetector.onDoubleTapDown]. + /// + /// By default, it selects a word through [RenderEditable.selectWord] if + /// selectionEnabled and shows toolbar if necessary. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onDoubleTapDown], which triggers this + /// callback. + @protected + void onDoubleTapDown(TapDragDownDetails details) { + if (delegate.selectionEnabled) { + renderEditable.selectWord(cause: SelectionChangedCause.doubleTap); + if (shouldShowSelectionToolbar) { + editableText.showToolbar(); + } + } + } + + // Selects the set of paragraphs in a document that intersect a given range of + // global positions. + void _selectParagraphsInRange( + {required Offset from, Offset? to, SelectionChangedCause? cause}) { + final TextBoundary paragraphBoundary = + ParagraphBoundary(editableText.textEditingValue.text); + _selectTextBoundariesInRange( + boundary: paragraphBoundary, from: from, to: to, cause: cause); + } + + // Selects the set of lines in a document that intersect a given range of + // global positions. + void _selectLinesInRange( + {required Offset from, Offset? to, SelectionChangedCause? cause}) { + final TextBoundary lineBoundary = LineBoundary(renderEditable); + _selectTextBoundariesInRange( + boundary: lineBoundary, from: from, to: to, cause: cause); + } + + // Returns the closest boundary location to `extent` but not including `extent` + // itself. + TextRange _moveBeyondTextBoundary( + TextPosition extent, TextBoundary textBoundary) { + assert(extent.offset >= 0); + // if x is a boundary defined by `textBoundary`, most textBoundaries (except + // LineBreaker) guarantees `x == textBoundary.getLeadingTextBoundaryAt(x)`. + // Use x - 1 here to make sure we don't get stuck at the fixed point x. + final int start = + textBoundary.getLeadingTextBoundaryAt(extent.offset - 1) ?? 0; + final int end = textBoundary.getTrailingTextBoundaryAt(extent.offset) ?? + editableText.textEditingValue.text.length; + return TextRange(start: start, end: end); + } + + // Selects the set of text boundaries in a document that intersect a given + // range of global positions. + // + // The set of text boundaries 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 text boundary respectively. + void _selectTextBoundariesInRange( + {required TextBoundary boundary, + required Offset from, + Offset? to, + SelectionChangedCause? cause}) { + final TextPosition fromPosition = renderEditable.getPositionForPoint(from); + final TextRange fromRange = _moveBeyondTextBoundary(fromPosition, boundary); + final TextPosition toPosition = + to == null ? fromPosition : renderEditable.getPositionForPoint(to); + final TextRange toRange = toPosition == fromPosition + ? fromRange + : _moveBeyondTextBoundary(toPosition, boundary); + final bool isFromBoundaryBeforeToBoundary = fromRange.start < toRange.end; + + final TextSelection newSelection = isFromBoundaryBeforeToBoundary + ? TextSelection(baseOffset: fromRange.start, extentOffset: toRange.end) + : TextSelection(baseOffset: fromRange.end, extentOffset: toRange.start); + + editableText.userUpdateTextEditingValue( + editableText.textEditingValue.copyWith(selection: newSelection), + cause, + ); + } + + /// Handler for [TextSelectionGestureDetector.onTripleTapDown]. + /// + /// By default, it selects a paragraph if + /// [TextSelectionGestureDetectorBuilderDelegate.selectionEnabled] is true + /// and shows the toolbar if necessary. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onTripleTapDown], which triggers this + /// callback. + @protected + void onTripleTapDown(TapDragDownDetails details) { + if (!delegate.selectionEnabled) { + return; + } + if (renderEditable.maxLines == 1) { + editableText.selectAll(SelectionChangedCause.tap); + } else { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.windows: + _selectParagraphsInRange( + from: details.globalPosition, cause: SelectionChangedCause.tap); + break; + case TargetPlatform.linux: + _selectLinesInRange( + from: details.globalPosition, cause: SelectionChangedCause.tap); + } + } + if (shouldShowSelectionToolbar) { + editableText.showToolbar(); + } + } + + /// Handler for [TextSelectionGestureDetector.onDragSelectionStart]. + /// + /// By default, it selects a text position specified in [details]. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onDragSelectionStart], which triggers + /// this callback. + @protected + void onDragSelectionStart(TapDragStartDetails details) { + if (!delegate.selectionEnabled) { + return; + } + final PointerDeviceKind? kind = details.kind; + _shouldShowSelectionToolbar = kind == null || + kind == PointerDeviceKind.touch || + kind == PointerDeviceKind.stylus; + + _dragStartSelection = renderEditable.selection; + _dragStartScrollOffset = _scrollPosition; + _dragStartViewportOffset = renderEditable.offset.pixels; + + if (_TextSelectionGestureDetectorState._getEffectiveConsecutiveTapCount( + details.consecutiveTapCount) > + 1) { + // Do not set the selection on a consecutive tap and drag. + return; + } + + final bool isShiftPressed = _containsShift(details.keysPressedOnDown); + + if (isShiftPressed && + renderEditable.selection != null && + renderEditable.selection!.isValid) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + _expandSelection(details.globalPosition, SelectionChangedCause.drag); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + _extendSelection(details.globalPosition, SelectionChangedCause.drag); + } + } else { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + break; + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + // For Android, Fucshia, and iOS platforms, a touch drag + // does not initiate unless the editable has focus. + if (renderEditable.hasFocus) { + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + } + break; + case null: + break; + } + break; + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + } + } + } + + /// Handler for [TextSelectionGestureDetector.onDragSelectionUpdate]. + /// + /// By default, it updates the selection location specified in the provided + /// details objects. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onDragSelectionUpdate], which triggers + /// this callback./lib/src/material/text_field.dart + @protected + void onDragSelectionUpdate(TapDragUpdateDetails details) { + if (!delegate.selectionEnabled) { + return; + } + + final bool isShiftPressed = _containsShift(details.keysPressedOnDown); + + if (!isShiftPressed) { + // Adjust the drag start offset for possible viewport offset changes. + final Offset editableOffset = renderEditable.maxLines == 1 + ? Offset(renderEditable.offset.pixels - _dragStartViewportOffset, 0.0) + : Offset( + 0.0, renderEditable.offset.pixels - _dragStartViewportOffset); + final Offset scrollableOffset = Offset( + 0.0, + _scrollPosition - _dragStartScrollOffset, + ); + final Offset dragStartGlobalPosition = + details.globalPosition - details.offsetFromOrigin; + + // Select word by word. + if (_TextSelectionGestureDetectorState._getEffectiveConsecutiveTapCount( + details.consecutiveTapCount) == + 2) { + return renderEditable.selectWordsInRange( + from: dragStartGlobalPosition - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + } + + // Select paragraph-by-paragraph. + if (_TextSelectionGestureDetectorState._getEffectiveConsecutiveTapCount( + details.consecutiveTapCount) == + 3) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + return _selectParagraphsInRange( + from: dragStartGlobalPosition - + editableOffset - + scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + case null: + // Triple tap to drag is not present on these platforms when using + // non-precise pointer devices at the moment. + break; + } + return; + case TargetPlatform.linux: + return _selectLinesInRange( + from: dragStartGlobalPosition - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + case TargetPlatform.windows: + case TargetPlatform.macOS: + return _selectParagraphsInRange( + from: dragStartGlobalPosition - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + } + } + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + // With a touch device, nothing should happen, unless there was a double tap, or + // there was a collapsed selection, and the tap/drag position is at the collapsed selection. + // In that case the caret should move with the drag position. + // + // With a mouse device, a drag should select the range from the origin of the drag + // to the current position of the drag. + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + return renderEditable.selectPositionAt( + from: + dragStartGlobalPosition - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + _dragBeganOnPreviousSelection ??= _positionOnSelection( + dragStartGlobalPosition, _dragStartSelection); + assert(_dragBeganOnPreviousSelection != null); + if (renderEditable.hasFocus && + _dragStartSelection!.isCollapsed && + _dragBeganOnPreviousSelection!) { + return renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + } + break; + case null: + break; + } + return; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + // With a precise pointer device, such as a mouse, trackpad, or stylus, + // the drag will select the text spanning the origin of the drag to the end of the drag. + // With a touch device, the cursor should move with the drag. + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + return renderEditable.selectPositionAt( + from: + dragStartGlobalPosition - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + if (renderEditable.hasFocus) { + return renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + } + break; + case null: + break; + } + return; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + return renderEditable.selectPositionAt( + from: dragStartGlobalPosition - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + } + } + + if (_dragStartSelection!.isCollapsed || + (defaultTargetPlatform != TargetPlatform.iOS && + defaultTargetPlatform != TargetPlatform.macOS)) { + return _extendSelection( + details.globalPosition, SelectionChangedCause.drag); + } + + // If the drag inverts the selection, Mac and iOS revert to the initial + // selection. + final TextSelection selection = editableText.textEditingValue.selection; + final TextPosition nextExtent = + renderEditable.getPositionForPoint(details.globalPosition); + final bool isShiftTapDragSelectionForward = + _dragStartSelection!.baseOffset < _dragStartSelection!.extentOffset; + final bool isInverted = isShiftTapDragSelectionForward + ? nextExtent.offset < _dragStartSelection!.baseOffset + : nextExtent.offset > _dragStartSelection!.baseOffset; + if (isInverted && selection.baseOffset == _dragStartSelection!.baseOffset) { + editableText.userUpdateTextEditingValue( + editableText.textEditingValue.copyWith( + selection: TextSelection( + baseOffset: _dragStartSelection!.extentOffset, + extentOffset: nextExtent.offset, + ), + ), + SelectionChangedCause.drag, + ); + } else if (!isInverted && + nextExtent.offset != _dragStartSelection!.baseOffset && + selection.baseOffset != _dragStartSelection!.baseOffset) { + editableText.userUpdateTextEditingValue( + editableText.textEditingValue.copyWith( + selection: TextSelection( + baseOffset: _dragStartSelection!.baseOffset, + extentOffset: nextExtent.offset, + ), + ), + SelectionChangedCause.drag, + ); + } else { + _extendSelection(details.globalPosition, SelectionChangedCause.drag); + } + } + + /// Handler for [TextSelectionGestureDetector.onDragSelectionEnd]. + /// + /// By default, it cleans up the state used for handling certain + /// built-in behaviors. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onDragSelectionEnd], which triggers this + /// callback. + @protected + void onDragSelectionEnd(TapDragEndDetails details) { + final bool isShiftPressed = _containsShift(details.keysPressedOnDown); + _dragBeganOnPreviousSelection = null; + + if (isShiftPressed) { + _dragStartSelection = null; + } + } + + /// Returns a [TextSelectionGestureDetector] configured with the handlers + /// provided by this builder. + /// + /// The [child] or its subtree should contain [EditableText]. + Widget buildGestureDetector({ + Key? key, + HitTestBehavior? behavior, + required Widget child, + }) { + return TextSelectionGestureDetector( + key: key, + onTapDown: onTapDown, + onForcePressStart: delegate.forcePressEnabled ? onForcePressStart : null, + onForcePressEnd: delegate.forcePressEnabled ? onForcePressEnd : null, + onSecondaryTap: onSecondaryTap, + onSecondaryTapDown: onSecondaryTapDown, + onSingleTapUp: onSingleTapUp, + onSingleTapCancel: onSingleTapCancel, + onSingleLongTapStart: onSingleLongTapStart, + onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate, + onSingleLongTapEnd: onSingleLongTapEnd, + onDoubleTapDown: onDoubleTapDown, + onTripleTapDown: onTripleTapDown, + onDragSelectionStart: onDragSelectionStart, + onDragSelectionUpdate: onDragSelectionUpdate, + onDragSelectionEnd: onDragSelectionEnd, + behavior: behavior, + child: child, + ); + } +} + +/// Delegate interface for the [TextSelectionGestureDetectorBuilder]. +/// +/// The interface is usually implemented by text field implementations wrapping +/// [EditableText], that use a [TextSelectionGestureDetectorBuilder] to build a +/// [TextSelectionGestureDetector] for their [EditableText]. The delegate provides +/// the builder with information about the current state of the text field. +/// Based on these information, the builder adds the correct gesture handlers +/// to the gesture detector. +/// +/// See also: +/// +/// * [TextField], which implements this delegate for the Material text field. +/// * [CupertinoTextField], which implements this delegate for the Cupertino +/// text field. +abstract class _TextSelectionGestureDetectorBuilderDelegate { + /// [GlobalKey] to the [EditableText] for which the + /// [TextSelectionGestureDetectorBuilder] will build a [TextSelectionGestureDetector]. + GlobalKey<_EditableTextState> get editableTextKey; + + /// Whether the text field should respond to force presses. + bool get forcePressEnabled; + + /// Whether the user may select text in the text field. + bool get selectionEnabled; +} + +/// A gesture detector to respond to non-exclusive event chains for a text field. +/// +/// An ordinary [GestureDetector] configured to handle events like tap and +/// double tap will only recognize one or the other. This widget detects both: +/// the first tap and then any subsequent taps that occurs within a time limit +/// after the first. +/// +/// See also: +/// +/// * [TextField], a Material text field which uses this gesture detector. +/// * [CupertinoTextField], a Cupertino text field which uses this gesture +/// detector. +class TextSelectionGestureDetector extends StatefulWidget { + /// Create a [TextSelectionGestureDetector]. + /// + /// Multiple callbacks can be called for one sequence of input gesture. + /// The [child] parameter must not be null. + const TextSelectionGestureDetector({ + super.key, + this.onTapDown, + this.onForcePressStart, + this.onForcePressEnd, + this.onSecondaryTap, + this.onSecondaryTapDown, + this.onSingleTapUp, + this.onSingleTapCancel, + this.onSingleLongTapStart, + this.onSingleLongTapMoveUpdate, + this.onSingleLongTapEnd, + this.onDoubleTapDown, + this.onTripleTapDown, + this.onDragSelectionStart, + this.onDragSelectionUpdate, + this.onDragSelectionEnd, + this.behavior, + required this.child, + }); + + /// Called for every tap down including every tap down that's part of a + /// double click or a long press, except touches that include enough movement + /// to not qualify as taps (e.g. pans and flings). + final GestureTapDragDownCallback? onTapDown; + + /// Called when a pointer has tapped down and the force of the pointer has + /// just become greater than [ForcePressGestureRecognizer.startPressure]. + final GestureForcePressStartCallback? onForcePressStart; + + /// Called when a pointer that had previously triggered [onForcePressStart] is + /// lifted off the screen. + final GestureForcePressEndCallback? onForcePressEnd; + + /// Called for a tap event with the secondary mouse button. + final GestureTapCallback? onSecondaryTap; + + /// Called for a tap down event with the secondary mouse button. + final GestureTapDownCallback? onSecondaryTapDown; + + /// Called for the first tap in a series of taps, consecutive taps do not call + /// this method. + /// + /// For example, if the detector was configured with [onTapDown] and + /// [onDoubleTapDown], three quick taps would be recognized as a single tap + /// down, followed by a tap up, then a double tap down, followed by a single tap down. + final GestureTapDragUpCallback? onSingleTapUp; + + /// Called for each touch that becomes recognized as a gesture that is not a + /// short tap, such as a long tap or drag. It is called at the moment when + /// another gesture from the touch is recognized. + final GestureCancelCallback? onSingleTapCancel; + + /// Called for a single long tap that's sustained for longer than + /// [kLongPressTimeout] but not necessarily lifted. Not called for a + /// double-tap-hold, which calls [onDoubleTapDown] instead. + final GestureLongPressStartCallback? onSingleLongTapStart; + + /// Called after [onSingleLongTapStart] when the pointer is dragged. + final GestureLongPressMoveUpdateCallback? onSingleLongTapMoveUpdate; + + /// Called after [onSingleLongTapStart] when the pointer is lifted. + final GestureLongPressEndCallback? onSingleLongTapEnd; + + /// Called after a momentary hold or a short tap that is close in space and + /// time (within [kDoubleTapTimeout]) to a previous short tap. + final GestureTapDragDownCallback? onDoubleTapDown; + + /// Called after a momentary hold or a short tap that is close in space and + /// time (within [kDoubleTapTimeout]) to a previous double-tap. + final GestureTapDragDownCallback? onTripleTapDown; + + /// Called when a mouse starts dragging to select text. + final GestureTapDragStartCallback? onDragSelectionStart; + + /// Called repeatedly as a mouse moves while dragging. + final GestureTapDragUpdateCallback? onDragSelectionUpdate; + + /// Called when a mouse that was previously dragging is released. + final GestureTapDragEndCallback? onDragSelectionEnd; + + /// How this gesture detector should behave during hit testing. + /// + /// This defaults to [HitTestBehavior.deferToChild]. + final HitTestBehavior? behavior; + + /// Child below this widget. + final Widget child; + + @override + State createState() => _TextSelectionGestureDetectorState(); +} + +class _TextSelectionGestureDetectorState + extends State { + // Converts the details.consecutiveTapCount from a TapAndDrag*Details object, + // which can grow to be infinitely large, to a value between 1 and 3. The value + // that the raw count is converted to is based on the default observed behavior + // on the native platforms. + // + // This method should be used in all instances when details.consecutiveTapCount + // would be used. + static int _getEffectiveConsecutiveTapCount(int rawCount) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + // From observation, these platform's reset their tap count to 0 when + // the number of consecutive taps exceeds 3. For example on Debian Linux + // with GTK, when going past a triple click, on the fourth click the + // selection is moved to the precise click position, on the fifth click + // the word at the position is selected, and on the sixth click the + // paragraph at the position is selected. + return rawCount <= 3 + ? rawCount + : (rawCount % 3 == 0 ? 3 : rawCount % 3); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + // From observation, these platform's either hold their tap count at 3. + // For example on macOS, when going past a triple click, the selection + // should be retained at the paragraph that was first selected on triple + // click. + return math.min(rawCount, 3); + case TargetPlatform.windows: + // From observation, this platform's consecutive tap actions alternate + // between double click and triple click actions. For example, after a + // triple click has selected a paragraph, on the next click the word at + // the clicked position will be selected, and on the next click the + // paragraph at the position is selected. + return rawCount < 2 ? rawCount : 2 + rawCount % 2; + } + } + + @override + void dispose() { + super.dispose(); + } + + // The down handler is force-run on success of a single tap and optimistically + // run before a long press success. + void _handleTapDown(TapDragDownDetails details) { + widget.onTapDown?.call(details); + // This isn't detected as a double tap gesture in the gesture recognizer + // because it's 2 single taps, each of which may do different things depending + // on whether it's a single tap, the first tap of a double tap, the second + // tap held down, a clean double tap etc. + if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 2) { + return widget.onDoubleTapDown?.call(details); + } + + if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 3) { + return widget.onTripleTapDown?.call(details); + } + } + + void _handleTapUp(TapDragUpDetails details) { + if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 1) { + widget.onSingleTapUp?.call(details); + } + } + + void _handleTapCancel() { + widget.onSingleTapCancel?.call(); + } + + void _handleDragStart(TapDragStartDetails details) { + widget.onDragSelectionStart?.call(details); + } + + void _handleDragUpdate(TapDragUpdateDetails details) { + widget.onDragSelectionUpdate?.call(details); + } + + void _handleDragEnd(TapDragEndDetails details) { + widget.onDragSelectionEnd?.call(details); + } + + void _forcePressStarted(ForcePressDetails details) { + widget.onForcePressStart?.call(details); + } + + void _forcePressEnded(ForcePressDetails details) { + widget.onForcePressEnd?.call(details); + } + + void _handleLongPressStart(LongPressStartDetails details) { + if (widget.onSingleLongTapStart != null) { + widget.onSingleLongTapStart!(details); + } + } + + void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { + if (widget.onSingleLongTapMoveUpdate != null) { + widget.onSingleLongTapMoveUpdate!(details); + } + } + + void _handleLongPressEnd(LongPressEndDetails details) { + if (widget.onSingleLongTapEnd != null) { + widget.onSingleLongTapEnd!(details); + } + } + + @override + Widget build(BuildContext context) { + final Map gestures = + {}; + + gestures[TapGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this), + (TapGestureRecognizer instance) { + instance + ..onSecondaryTap = widget.onSecondaryTap + ..onSecondaryTapDown = widget.onSecondaryTapDown; + }, + ); + + if (widget.onSingleLongTapStart != null || + widget.onSingleLongTapMoveUpdate != null || + widget.onSingleLongTapEnd != null) { + gestures[LongPressGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer( + debugOwner: this, + supportedDevices: {PointerDeviceKind.touch}), + (LongPressGestureRecognizer instance) { + instance + ..onLongPressStart = _handleLongPressStart + ..onLongPressMoveUpdate = _handleLongPressMoveUpdate + ..onLongPressEnd = _handleLongPressEnd; + }, + ); + } + + if (widget.onDragSelectionStart != null || + widget.onDragSelectionUpdate != null || + widget.onDragSelectionEnd != null) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + gestures[TapAndHorizontalDragGestureRecognizer] = + GestureRecognizerFactoryWithHandlers< + TapAndHorizontalDragGestureRecognizer>( + () => TapAndHorizontalDragGestureRecognizer(debugOwner: this), + (TapAndHorizontalDragGestureRecognizer instance) { + instance + // Text selection should start from the position of the first pointer + // down event. + ..dragStartBehavior = DragStartBehavior.down + ..onTapDown = _handleTapDown + ..onDragStart = _handleDragStart + ..onDragUpdate = _handleDragUpdate + ..onDragEnd = _handleDragEnd + ..onTapUp = _handleTapUp + ..onCancel = _handleTapCancel; + }, + ); + break; + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + gestures[TapAndPanGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => TapAndPanGestureRecognizer(debugOwner: this), + (TapAndPanGestureRecognizer instance) { + instance + // Text selection should start from the position of the first pointer + // down event. + ..dragStartBehavior = DragStartBehavior.down + ..onTapDown = _handleTapDown + ..onDragStart = _handleDragStart + ..onDragUpdate = _handleDragUpdate + ..onDragEnd = _handleDragEnd + ..onTapUp = _handleTapUp + ..onCancel = _handleTapCancel; + }, + ); + break; + } + } + + if (widget.onForcePressStart != null || widget.onForcePressEnd != null) { + gestures[ForcePressGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => ForcePressGestureRecognizer(debugOwner: this), + (ForcePressGestureRecognizer instance) { + instance + ..onStart = + widget.onForcePressStart != null ? _forcePressStarted : null + ..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null; + }, + ); + } + + return RawGestureDetector( + gestures: gestures, + excludeFromSemantics: true, + behavior: widget.behavior, + child: widget.child, + ); + } +}