From e0742ebb24a3b81736501551fac9598a1094a88a Mon Sep 17 00:00:00 2001 From: Camille Simon <43054281+camsim99@users.noreply.github.com> Date: Tue, 20 Dec 2022 11:56:12 -0800 Subject: [PATCH] [Android] Add spell check suggestions toolbar (#114460) * Add spell check suggestions toolbar * Fix test and move menu * Cleanup * Cleanup and fix bug * More cleanup * Make height dynamic and use localized delete * Begin adding tests * Create var checking for results * Add tests * Fix analyze (sorta) * Add back hideToolbar call for testing * Add back hidetoolbar in ts and delete one in et * Remove unecessary calls to hidToolbar * Fix analyze and docs * Test fix * Fix container issue * Clean up * Fix analyze * Move delegate * Fix typos * Start addressing review * Continue addressing review * Add assert * Some refactoring * Add test for button behavior * Undo test change * Make spell check results public * Rearrange test * Add comment * Address review * Finish addressing review * remove unused imports * Address nits * Address review * Fix formatting * Refactor findsuggestionspanatcursorindex and textselectiontoolbar constraints * Fix analyze: --- packages/flutter/lib/material.dart | 2 + .../text_selection_toolbar_button.dart | 1 + .../adaptive_text_selection_toolbar.dart | 2 + .../spell_check_suggestions_toolbar.dart | 221 +++++++++++++ ...k_suggestions_toolbar_layout_delegate.dart | 50 +++ .../flutter/lib/src/material/text_field.dart | 33 +- .../src/material/text_selection_toolbar.dart | 40 ++- .../text_selection_toolbar_text_button.dart | 27 ++ .../src/widgets/context_menu_button_item.dart | 3 + .../lib/src/widgets/editable_text.dart | 113 ++++++- .../flutter/lib/src/widgets/spell_check.dart | 18 +- .../lib/src/widgets/text_selection.dart | 46 +++ ...ext_selection_toolbar_layout_delegate.dart | 8 +- ...gestions_toolbar_layout_delegate_test.dart | 53 +++ .../spell_check_suggestions_toolbar_test.dart | 87 +++++ .../material/text_selection_toolbar_test.dart | 8 +- .../test/widgets/editable_text_test.dart | 313 +++++++++++++++++- .../test/widgets/text_selection_test.dart | 28 ++ 18 files changed, 998 insertions(+), 55 deletions(-) create mode 100644 packages/flutter/lib/src/material/spell_check_suggestions_toolbar.dart create mode 100644 packages/flutter/lib/src/material/spell_check_suggestions_toolbar_layout_delegate.dart create mode 100644 packages/flutter/test/material/spell_check_suggestions_toolbar_layout_delegate_test.dart create mode 100644 packages/flutter/test/material/spell_check_suggestions_toolbar_test.dart diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 99b9a67f03c4..19433798420e 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -159,6 +159,8 @@ export 'src/material/slider.dart'; export 'src/material/slider_theme.dart'; export 'src/material/snack_bar.dart'; export 'src/material/snack_bar_theme.dart'; +export 'src/material/spell_check_suggestions_toolbar.dart'; +export 'src/material/spell_check_suggestions_toolbar_layout_delegate.dart'; export 'src/material/stepper.dart'; export 'src/material/switch.dart'; export 'src/material/switch_list_tile.dart'; diff --git a/packages/flutter/lib/src/cupertino/text_selection_toolbar_button.dart b/packages/flutter/lib/src/cupertino/text_selection_toolbar_button.dart index 6cc1cc40e93c..6f36acc9133b 100644 --- a/packages/flutter/lib/src/cupertino/text_selection_toolbar_button.dart +++ b/packages/flutter/lib/src/cupertino/text_selection_toolbar_button.dart @@ -97,6 +97,7 @@ class CupertinoTextSelectionToolbarButton extends StatelessWidget { return localizations.pasteButtonLabel; case ContextMenuButtonType.selectAll: return localizations.selectAllButtonLabel; + case ContextMenuButtonType.delete: case ContextMenuButtonType.custom: return ''; } diff --git a/packages/flutter/lib/src/material/adaptive_text_selection_toolbar.dart b/packages/flutter/lib/src/material/adaptive_text_selection_toolbar.dart index d0ec3eb7eadf..e87a038c88d6 100644 --- a/packages/flutter/lib/src/material/adaptive_text_selection_toolbar.dart +++ b/packages/flutter/lib/src/material/adaptive_text_selection_toolbar.dart @@ -211,6 +211,8 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget { return localizations.pasteButtonLabel; case ContextMenuButtonType.selectAll: return localizations.selectAllButtonLabel; + case ContextMenuButtonType.delete: + return localizations.deleteButtonTooltip.toUpperCase(); case ContextMenuButtonType.custom: return ''; } diff --git a/packages/flutter/lib/src/material/spell_check_suggestions_toolbar.dart b/packages/flutter/lib/src/material/spell_check_suggestions_toolbar.dart new file mode 100644 index 000000000000..9951579690ef --- /dev/null +++ b/packages/flutter/lib/src/material/spell_check_suggestions_toolbar.dart @@ -0,0 +1,221 @@ +// 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:flutter/services.dart' show SuggestionSpan; +import 'package:flutter/widgets.dart'; + +import 'adaptive_text_selection_toolbar.dart'; +import 'colors.dart'; +import 'material.dart'; +import 'spell_check_suggestions_toolbar_layout_delegate.dart'; +import 'text_selection_toolbar.dart'; +import 'text_selection_toolbar_text_button.dart'; + +// 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 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. +class SpellCheckSuggestionsToolbar extends StatelessWidget { + /// Constructs a [SpellCheckSuggestionsToolbar]. + const SpellCheckSuggestionsToolbar({ + super.key, + required this.anchor, + required this.buttonItems, + }) : assert(buttonItems != null); + + /// {@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. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar.buttonItems], the list of + /// [ContextMenuButtonItem]s that are used to build the buttons of the + /// text selection toolbar. + final List buttonItems; + + /// Padding between the toolbar and the anchor. Eyeballed on Pixel 4 emulator + /// running Android API 31. + static const double kToolbarContentDistanceBelow = TextSelectionToolbar.kHandleSize - 3.0; + + /// Builds the default Android Material spell check suggestions toolbar. + static Widget _spellCheckSuggestionsToolbarBuilder(BuildContext context, Widget child) { + return _SpellCheckSuggestionsToolbarContainer( + child: child, + ); + } + + /// Builds the button items for the toolbar based on the available + /// spell check suggestions. + static List? buildButtonItems( + BuildContext context, + EditableTextState 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) { + buttonItems.add(ContextMenuButtonItem( + onPressed: () { + editableTextState + .replaceComposingRegion( + SelectionChangedCause.toolbar, + suggestion, + ); + }, + label: suggestion, + )); + } + + // Build delete button. + final ContextMenuButtonItem deleteButton = + ContextMenuButtonItem( + onPressed: () { + editableTextState.replaceComposingRegion( + SelectionChangedCause.toolbar, + '', + ); + }, + type: ContextMenuButtonType.delete, + ); + buttonItems.add(deleteButton); + + return buttonItems; + } + + /// Determines the Offset that the toolbar will be anchored to. + static Offset getToolbarAnchor(TextSelectionToolbarAnchors anchors) { + 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) { + // Adjust toolbar height if needed. + final double spellCheckSuggestionsToolbarHeight = + _kDefaultToolbarHeight - (48.0 * (4 - buttonItems.length)); + // Incorporate the padding distance between the content and toolbar. + final Offset anchorPadded = + anchor + const Offset(0.0, kToolbarContentDistanceBelow); + final MediaQueryData mediaQueryData = MediaQuery.of(context); + final double softKeyboardViewInsetsBottom = mediaQueryData.viewInsets.bottom; + final double paddingAbove = mediaQueryData.padding.top + TextSelectionToolbar.kToolbarScreenPadding; + // Makes up for the Padding. + final Offset localAdjustment = Offset(TextSelectionToolbar.kToolbarScreenPadding, paddingAbove); + + return Padding( + padding: EdgeInsets.fromLTRB( + TextSelectionToolbar.kToolbarScreenPadding, + kToolbarContentDistanceBelow, + TextSelectionToolbar.kToolbarScreenPadding, + TextSelectionToolbar.kToolbarScreenPadding + softKeyboardViewInsetsBottom, + ), + child: CustomSingleChildLayout( + delegate: SpellCheckSuggestionsToolbarLayoutDelegate( + anchor: anchorPadded - 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: _spellCheckSuggestionsToolbarBuilder(context, _SpellCheckSuggestsionsToolbarItemsLayout( + height: spellCheckSuggestionsToolbarHeight, + children: [..._buildToolbarButtons(context)], + )), + ), + ), + ); + } +} + +/// The Material-styled toolbar outline for the spell check suggestions +/// toolbar. +class _SpellCheckSuggestionsToolbarContainer extends StatelessWidget { + const _SpellCheckSuggestionsToolbarContainer({ + required this.child, + }); + + final Widget child; + + @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: child, + ); + } +} + +/// Renders the spell check suggestions toolbar items in the correct positions +/// in the menu. +class _SpellCheckSuggestsionsToolbarItemsLayout extends StatelessWidget { + const _SpellCheckSuggestsionsToolbarItemsLayout({ + required this.height, + required this.children, + }); + + final double height; + + final List children; + + @override + Widget build(BuildContext context) { + return SizedBox( + // This width was eyeballed on a Pixel 4 emulator running Android + // API 31 for the SpellCheckSuggestionsToolbar. + width: 165, + height: height, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ); + } +} diff --git a/packages/flutter/lib/src/material/spell_check_suggestions_toolbar_layout_delegate.dart b/packages/flutter/lib/src/material/spell_check_suggestions_toolbar_layout_delegate.dart new file mode 100644 index 000000000000..05dd0b45db84 --- /dev/null +++ b/packages/flutter/lib/src/material/spell_check_suggestions_toolbar_layout_delegate.dart @@ -0,0 +1,50 @@ +// 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:flutter/rendering.dart'; +import 'package:flutter/widgets.dart' show TextSelectionToolbarLayoutDelegate; + +/// Positions the toolbar below [anchor] or adjusts it higher to fit above +/// the bottom view insets, if applicable. +/// +/// See also: +/// +/// * [SpellCheckSuggestionsToolbar], which uses this to position itself. +class SpellCheckSuggestionsToolbarLayoutDelegate extends SingleChildLayoutDelegate { + /// Creates an instance of [SpellCheckSuggestionsToolbarLayoutDelegate]. + SpellCheckSuggestionsToolbarLayoutDelegate({ + required this.anchor, + }); + + /// {@macro flutter.material.SpellCheckSuggestionsToolbar.anchor} + /// + /// Should be provided in local coordinates. + final Offset anchor; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return constraints.loosen(); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + return Offset( + TextSelectionToolbarLayoutDelegate.centerOn( + anchor.dx, + childSize.width, + size.width, + ), + // Positions child (of childSize) just enough upwards to fit within size + // if it otherwise does not fit below the anchor. + anchor.dy + childSize.height > size.height + ? size.height - childSize.height + : anchor.dy, + ); + } + + @override + bool shouldRelayout(SpellCheckSuggestionsToolbarLayoutDelegate oldDelegate) { + return anchor != oldDelegate.anchor; + } +} diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 4da189a647e9..c2627bd6a8a0 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -20,6 +20,7 @@ import 'magnifier.dart'; import 'material_localizations.dart'; import 'material_state.dart'; import 'selectable_text.dart' show iOSHorizontalOffset; +import 'spell_check_suggestions_toolbar.dart'; import 'text_selection.dart'; import 'theme.dart'; @@ -800,6 +801,32 @@ class TextField extends StatefulWidget { decorationStyle: TextDecorationStyle.wavy, ); + /// Default builder for the spell check suggestions toolbar in the Material + /// style. + /// + /// See also: + /// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the + // builder configured to show a spell check suggestions toolbar. + @visibleForTesting + static Widget defaultSpellCheckSuggestionsToolbarBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + final Offset anchor = + SpellCheckSuggestionsToolbar.getToolbarAnchor(editableTextState.contextMenuAnchors); + final List? buttonItems = + SpellCheckSuggestionsToolbar.buildButtonItems(context, editableTextState); + + if (buttonItems == null){ + return const SizedBox.shrink(); + } + + return SpellCheckSuggestionsToolbar( + anchor: anchor, + buttonItems: buttonItems, + ); + } + @override State createState() => _TextFieldState(); @@ -1192,7 +1219,11 @@ class _TextFieldState extends State with RestorationMixin implements widget.spellCheckConfiguration != const SpellCheckConfiguration.disabled() ? widget.spellCheckConfiguration!.copyWith( misspelledTextStyle: widget.spellCheckConfiguration!.misspelledTextStyle - ?? TextField.materialMisspelledTextStyle) + ?? TextField.materialMisspelledTextStyle, + spellCheckSuggestionsToolbarBuilder: + widget.spellCheckConfiguration!.spellCheckSuggestionsToolbarBuilder + ?? TextField.defaultSpellCheckSuggestionsToolbarBuilder + ) : const SpellCheckConfiguration.disabled(); TextSelectionControls? textSelectionControls = widget.selectionControls; diff --git a/packages/flutter/lib/src/material/text_selection_toolbar.dart b/packages/flutter/lib/src/material/text_selection_toolbar.dart index 19bf219dffa2..92730cf9a605 100644 --- a/packages/flutter/lib/src/material/text_selection_toolbar.dart +++ b/packages/flutter/lib/src/material/text_selection_toolbar.dart @@ -14,15 +14,7 @@ import 'icons.dart'; import 'material.dart'; import 'material_localizations.dart'; -// Minimal padding from all edges of the selection toolbar to all edges of the -// viewport. -const double _kToolbarScreenPadding = 8.0; const double _kToolbarHeight = 44.0; - -const double _kHandleSize = 22.0; - -// Padding between the toolbar and the anchor. -const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0; const double _kToolbarContentDistance = 8.0; /// A fully-functional Material-style text selection toolbar. @@ -84,6 +76,26 @@ class TextSelectionToolbar extends StatelessWidget { /// {@endtemplate} final ToolbarBuilder toolbarBuilder; + /// Minimal padding from all edges of the selection toolbar to all edges of the + /// viewport. + /// + /// See also: + /// + /// * [SpellCheckSuggestionsToolbar], which uses this same value for its + /// padding from the edges of the viewport. + static const double kToolbarScreenPadding = 8.0; + + /// The size of the text selection handles. + /// + /// See also: + /// + /// * [SpellCheckSuggestionsToolbar], which references this value to calculate + /// the padding between the toolbar and anchor. + static const double kHandleSize = 22.0; + + /// Padding between the toolbar and the anchor. + static const double kToolbarContentDistanceBelow = kHandleSize - 2.0; + // Build the default Android Material text selection menu toolbar. static Widget _defaultToolbarBuilder(BuildContext context, Widget child) { return _TextSelectionToolbarContainer( @@ -97,21 +109,21 @@ class TextSelectionToolbar extends StatelessWidget { final Offset anchorAbovePadded = anchorAbove - const Offset(0.0, _kToolbarContentDistance); final Offset anchorBelowPadded = - anchorBelow + const Offset(0.0, _kToolbarContentDistanceBelow); + anchorBelow + const Offset(0.0, kToolbarContentDistanceBelow); final double paddingAbove = MediaQuery.paddingOf(context).top - + _kToolbarScreenPadding; + + kToolbarScreenPadding; final double availableHeight = anchorAbovePadded.dy - _kToolbarContentDistance - paddingAbove; final bool fitsAbove = _kToolbarHeight <= availableHeight; // Makes up for the Padding above the Stack. - final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove); + final Offset localAdjustment = Offset(kToolbarScreenPadding, paddingAbove); return Padding( padding: EdgeInsets.fromLTRB( - _kToolbarScreenPadding, + kToolbarScreenPadding, paddingAbove, - _kToolbarScreenPadding, - _kToolbarScreenPadding, + kToolbarScreenPadding, + kToolbarScreenPadding, ), child: CustomSingleChildLayout( delegate: TextSelectionToolbarLayoutDelegate( diff --git a/packages/flutter/lib/src/material/text_selection_toolbar_text_button.dart b/packages/flutter/lib/src/material/text_selection_toolbar_text_button.dart index d8b7d79dc545..ca7e3c0ab291 100644 --- a/packages/flutter/lib/src/material/text_selection_toolbar_text_button.dart +++ b/packages/flutter/lib/src/material/text_selection_toolbar_text_button.dart @@ -31,6 +31,7 @@ class TextSelectionToolbarTextButton extends StatelessWidget { required this.child, required this.padding, this.onPressed, + this.alignment, }); // These values were eyeballed to match the native text selection menu on a @@ -62,6 +63,15 @@ class TextSelectionToolbarTextButton extends StatelessWidget { /// * [ButtonStyle.padding], which is where this padding is applied. final EdgeInsets padding; + /// The alignment of the button's child. + /// + /// By default, this will be [Alignment.center]. + /// + /// See also: + /// + /// * [ButtonStyle.alignment], which is where this alignment is applied. + final AlignmentGeometry? alignment; + /// Returns the standard padding for a button at index out of a total number /// of buttons. /// @@ -104,6 +114,22 @@ class TextSelectionToolbarTextButton extends StatelessWidget { return _TextSelectionToolbarItemPosition.middle; } + /// Returns a copy of the current [TextSelectionToolbarTextButton] instance + /// with specific overrides. + TextSelectionToolbarTextButton copyWith({ + Widget? child, + VoidCallback? onPressed, + EdgeInsets? padding, + AlignmentGeometry? alignment, + }) { + return TextSelectionToolbarTextButton( + onPressed: onPressed ?? this.onPressed, + padding: padding ?? this.padding, + alignment: alignment ?? this.alignment, + child: child ?? this.child, + ); + } + @override Widget build(BuildContext context) { // TODO(hansmuller): Should be colorScheme.onSurface @@ -117,6 +143,7 @@ class TextSelectionToolbarTextButton extends StatelessWidget { shape: const RoundedRectangleBorder(), minimumSize: const Size(kMinInteractiveDimension, kMinInteractiveDimension), padding: padding, + alignment: alignment, ), onPressed: onPressed, child: child, diff --git a/packages/flutter/lib/src/widgets/context_menu_button_item.dart b/packages/flutter/lib/src/widgets/context_menu_button_item.dart index 2c5e723078e6..c89bda3147bc 100644 --- a/packages/flutter/lib/src/widgets/context_menu_button_item.dart +++ b/packages/flutter/lib/src/widgets/context_menu_button_item.dart @@ -23,6 +23,9 @@ enum ContextMenuButtonType { /// A button that selects all the contents of the focused text field. selectAll, + /// A button that deletes the current text selection. + delete, + /// Anything other than the default button types. custom, } diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 94a66ee6a3f2..990574f18ba4 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -1947,7 +1947,9 @@ class EditableTextState extends State with AutomaticKeepAliveClien /// These results will be updated via calls to spell check through a /// [SpellCheckService] and used by this widget to build the [TextSpan] tree /// for text input and menus for replacement suggestions of misspelled words. - SpellCheckResults? _spellCheckResults; + SpellCheckResults? spellCheckResults; + + bool get _spellCheckResultsReceived => spellCheckEnabled && spellCheckResults != null && spellCheckResults!.suggestionSpans.isNotEmpty; /// Whether to create an input connection with the platform for text editing /// or not. @@ -2190,6 +2192,63 @@ class EditableTextState extends State with AutomaticKeepAliveClien } } + /// Replace composing region with specified text. + void replaceComposingRegion(SelectionChangedCause cause, String text) { + // Replacement cannot be performed if the text is read only or obscured. + assert(!widget.readOnly && !widget.obscureText); + + _replaceText(ReplaceTextIntent(textEditingValue, text, textEditingValue.composing, cause)); + + if (cause == SelectionChangedCause.toolbar) { + // Schedule a call to bringIntoView() after renderEditable updates. + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + bringIntoView(textEditingValue.selection.extent); + } + }); + hideToolbar(); + } + } + + /// Finds specified [SuggestionSpan] that matches the provided index using + /// binary search. + /// + /// See also: + /// + /// * [SpellCheckSuggestionsToolbar], the Material style spell check + /// suggestions toolbar that uses this method to render the correct + /// suggestions in the toolbar for a misspelled word. + SuggestionSpan? findSuggestionSpanAtCursorIndex(int cursorIndex) { + if (!_spellCheckResultsReceived + || spellCheckResults!.suggestionSpans.last.range.end < cursorIndex) { + // No spell check results have been recieved or the cursor index is out + // of range that suggestionSpans covers. + return null; + } + + final List suggestionSpans = spellCheckResults!.suggestionSpans; + int leftIndex = 0; + int rightIndex = suggestionSpans.length - 1; + int midIndex = 0; + + while (leftIndex <= rightIndex) { + midIndex = ((leftIndex + rightIndex) / 2).floor(); + final int currentSpanStart = suggestionSpans[midIndex].range.start; + final int currentSpanEnd = suggestionSpans[midIndex].range.end; + + if (cursorIndex <= currentSpanEnd && cursorIndex >= currentSpanStart) { + return suggestionSpans[midIndex]; + } + else if (cursorIndex <= currentSpanStart) { + rightIndex = midIndex - 1; + } + else { + leftIndex = midIndex + 1; + } + } + return null; + } + /// Infers the [SpellCheckConfiguration] used to perform spell check. /// /// If spell check is enabled, this will try to infer a value for @@ -2562,9 +2621,12 @@ class EditableTextState extends State with AutomaticKeepAliveClien // `selection` is the only change. _handleSelectionChanged(value.selection, (_textInputConnection?.scribbleInProgress ?? false) ? SelectionChangedCause.scribble : SelectionChangedCause.keyboard); } else { - // Only hide the toolbar overlay, the selection handle's visibility will be handled - // by `_handleSelectionChanged`. https://github.com/flutter/flutter/issues/108673 - hideToolbar(false); + if (value.text != _value.text) { + // Hide the toolbar if the text was changed, but only hide the toolbar + // overlay; the selection handle's visibility will be handled + // by `_handleSelectionChanged`. https://github.com/flutter/flutter/issues/108673 + hideToolbar(false); + } _currentPromptRectRange = null; final bool revealObscuredInput = _hasInputConnection @@ -3256,17 +3318,17 @@ class EditableTextState extends State with AutomaticKeepAliveClien 'Locale must be specified in widget or Localization widget must be in scope', ); - final List? spellCheckResults = await + final List? suggestions = await _spellCheckConfiguration .spellCheckService! .fetchSpellCheckSuggestions(localeForSpellChecking!, text); - if (spellCheckResults == null) { + if (suggestions == null) { // The request to fetch spell check suggestions was canceled due to ongoing request. return; } - _spellCheckResults = SpellCheckResults(text, spellCheckResults); + spellCheckResults = SpellCheckResults(text, suggestions); renderEditable.text = buildTextSpan(); } catch (exception, stack) { FlutterError.reportError(FlutterErrorDetails( @@ -3665,6 +3727,38 @@ class EditableTextState extends State with AutomaticKeepAliveClien } } + /// Shows toolbar with spell check suggestions of misspelled words that are + /// available for click-and-replace. + bool showSpellCheckSuggestionsToolbar() { + if (!spellCheckEnabled + || widget.readOnly + || _selectionOverlay == null + || !_spellCheckResultsReceived) { + // 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', + ); + + _selectionOverlay! + .showSpellCheckSuggestionsToolbar( + (BuildContext context) { + return _spellCheckConfiguration + .spellCheckSuggestionsToolbarBuilder!( + context, + this, + ); + }, + ); + return true; + } + /// Shows the magnifier at the position given by `positionToShow`, /// if there is no magnifier visible. /// @@ -4321,9 +4415,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien ], ); } - final bool spellCheckResultsReceived = spellCheckEnabled && _spellCheckResults != null; final bool withComposing = !widget.readOnly && _hasFocus; - if (spellCheckResultsReceived) { + if (_spellCheckResultsReceived) { // If the composing range is out of range for the current text, ignore it to // preserve the tree integrity, otherwise in release mode a RangeError will // be thrown and this EditableText will be built with a broken subtree. @@ -4336,7 +4429,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien composingRegionOutOfRange, widget.style, _spellCheckConfiguration.misspelledTextStyle!, - _spellCheckResults!, + spellCheckResults!, ); } diff --git a/packages/flutter/lib/src/widgets/spell_check.dart b/packages/flutter/lib/src/widgets/spell_check.dart index 6ebefb6d3a11..341ee70e7176 100644 --- a/packages/flutter/lib/src/widgets/spell_check.dart +++ b/packages/flutter/lib/src/widgets/spell_check.dart @@ -2,11 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:flutter/services.dart' show SpellCheckResults, SpellCheckService, SuggestionSpan, TextEditingValue; +import 'editable_text.dart' show EditableTextContextMenuBuilder; +import 'framework.dart' show immutable; + /// Controls how spell check is performed for text input. /// /// This configuration determines the [SpellCheckService] used to fetch the @@ -19,12 +21,14 @@ class SpellCheckConfiguration { const SpellCheckConfiguration({ this.spellCheckService, this.misspelledTextStyle, + this.spellCheckSuggestionsToolbarBuilder, }) : _spellCheckEnabled = true; /// Creates a configuration that disables spell check. const SpellCheckConfiguration.disabled() : _spellCheckEnabled = false, spellCheckService = null, + spellCheckSuggestionsToolbarBuilder = null, misspelledTextStyle = null; /// The service used to fetch spell check results for text input. @@ -38,6 +42,10 @@ class SpellCheckConfiguration { /// 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. @@ -47,7 +55,8 @@ class SpellCheckConfiguration { /// specified overrides. SpellCheckConfiguration copyWith({ SpellCheckService? spellCheckService, - TextStyle? misspelledTextStyle}) { + TextStyle? misspelledTextStyle, + EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder}) { if (!_spellCheckEnabled) { // A new configuration should be constructed to enable spell check. return const SpellCheckConfiguration.disabled(); @@ -56,6 +65,7 @@ class SpellCheckConfiguration { return SpellCheckConfiguration( spellCheckService: spellCheckService ?? this.spellCheckService, misspelledTextStyle: misspelledTextStyle ?? this.misspelledTextStyle, + spellCheckSuggestionsToolbarBuilder : spellCheckSuggestionsToolbarBuilder ?? this.spellCheckSuggestionsToolbarBuilder, ); } @@ -65,6 +75,7 @@ class SpellCheckConfiguration { spell check enabled : $_spellCheckEnabled spell check service : $spellCheckService misspelled text style : $misspelledTextStyle + spell check suggesstions toolbar builder: $spellCheckSuggestionsToolbarBuilder ''' .trim(); } @@ -78,11 +89,12 @@ class SpellCheckConfiguration { return other is SpellCheckConfiguration && other.spellCheckService == spellCheckService && other.misspelledTextStyle == misspelledTextStyle + && other.spellCheckSuggestionsToolbarBuilder == spellCheckSuggestionsToolbarBuilder && other._spellCheckEnabled == _spellCheckEnabled; } @override - int get hashCode => Object.hash(spellCheckService, misspelledTextStyle, _spellCheckEnabled); + int get hashCode => Object.hash(spellCheckService, misspelledTextStyle, spellCheckSuggestionsToolbarBuilder, _spellCheckEnabled); } // Methods for displaying spell check results: diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 733c2e287c07..8812ec5b5fb0 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -469,6 +469,20 @@ class TextSelectionOverlay { 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, + ); + } + /// {@macro flutter.widgets.SelectionOverlay.showMagnifier} void showMagnifier(Offset positionToShow) { final TextPosition position = renderObject.getPositionForPoint(positionToShow); @@ -1347,6 +1361,29 @@ class SelectionOverlay { ); } + /// 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; + _contextMenuController.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. @@ -2124,6 +2161,15 @@ class TextSelectionGestureDetectorBuilder { } break; case TargetPlatform.android: + editableText.hideToolbar(); + editableText.showSpellCheckSuggestionsToolbar(); + if (isShiftPressedValid) { + _isShiftTapping = true; + _extendSelection(details.globalPosition, SelectionChangedCause.tap); + return; + } + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + break; case TargetPlatform.fuchsia: editableText.hideToolbar(); if (isShiftPressedValid) { diff --git a/packages/flutter/lib/src/widgets/text_selection_toolbar_layout_delegate.dart b/packages/flutter/lib/src/widgets/text_selection_toolbar_layout_delegate.dart index 70ccb966e95e..5b00babdf953 100644 --- a/packages/flutter/lib/src/widgets/text_selection_toolbar_layout_delegate.dart +++ b/packages/flutter/lib/src/widgets/text_selection_toolbar_layout_delegate.dart @@ -41,9 +41,9 @@ class TextSelectionToolbarLayoutDelegate extends SingleChildLayoutDelegate { /// If not provided, it will be calculated. final bool? fitsAbove; - // Return the value that centers width as closely as possible to position - // while fitting inside of min and max. - static double _centerOn(double position, double width, double max) { + /// Return the value that centers width as closely as possible to position + /// while fitting inside of min and max. + static double centerOn(double position, double width, double max) { // If it overflows on the left, put it as far left as possible. if (position - width / 2.0 < 0.0) { return 0.0; @@ -69,7 +69,7 @@ class TextSelectionToolbarLayoutDelegate extends SingleChildLayoutDelegate { final Offset anchor = fitsAbove ? anchorAbove : anchorBelow; return Offset( - _centerOn( + centerOn( anchor.dx, childSize.width, size.width, diff --git a/packages/flutter/test/material/spell_check_suggestions_toolbar_layout_delegate_test.dart b/packages/flutter/test/material/spell_check_suggestions_toolbar_layout_delegate_test.dart new file mode 100644 index 000000000000..234651365126 --- /dev/null +++ b/packages/flutter/test/material/spell_check_suggestions_toolbar_layout_delegate_test.dart @@ -0,0 +1,53 @@ +// 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:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('positions itself at anchorAbove if it fits and shifts up when not', (WidgetTester tester) async { + late StateSetter setState; + const double toolbarOverlap = 100; + const double height = 500; + double anchorY = 200.0; + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return CustomSingleChildLayout( + delegate: SpellCheckSuggestionsToolbarLayoutDelegate( + anchor: Offset(50.0, anchorY), + ), + child: Container( + width: 200.0, + height: height, + color: const Color(0xffff0000), + ), + ); + }, + ), + ), + ), + ); + + // When the toolbar doesn't fit below anchor, it positions itself such that + // it can just fit. + double toolbarY = tester.getTopLeft(find.byType(Container)).dy; + // Total height available is 600. + expect(toolbarY, equals(toolbarOverlap)); + + // When it does fit below anchor, it positions itself there. + setState(() { + anchorY = anchorY - toolbarOverlap; + }); + await tester.pump(); + toolbarY = tester.getTopLeft(find.byType(Container)).dy; + expect(toolbarY, equals(anchorY)); + }); +} diff --git a/packages/flutter/test/material/spell_check_suggestions_toolbar_test.dart b/packages/flutter/test/material/spell_check_suggestions_toolbar_test.dart new file mode 100644 index 000000000000..e504c8e25007 --- /dev/null +++ b/packages/flutter/test/material/spell_check_suggestions_toolbar_test.dart @@ -0,0 +1,87 @@ +// 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:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Vertical position at which to anchor the toolbar for testing. +const double _kAnchor = 200; +// Amount for toolbar to overlap bottom padding for testing. +const double _kTestToolbarOverlap = 10; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + /// Builds test button items for each of the suggestions provided. + List buildSuggestionButtons(List suggestions) { + final List buttonItems = []; + + for (final String suggestion in suggestions) { + buttonItems.add(ContextMenuButtonItem( + onPressed: () {}, + label: suggestion, + )); + } + + final ContextMenuButtonItem deleteButton = + ContextMenuButtonItem( + onPressed: () {}, + type: ContextMenuButtonType.delete, + label: 'DELETE', + ); + buttonItems.add(deleteButton); + + return buttonItems; + } + + /// Finds the container of the [SpellCheckSuggestionsToolbar] so that + /// the position of the toolbar itself may be determined. + Finder findSpellCheckSuggestionsToolbar() { + return find.descendant( + of: find.byType(MaterialApp), + matching: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_SpellCheckSuggestionsToolbarContainer'), + ); + } + + testWidgets('positions toolbar below anchor when it fits above bottom view padding', (WidgetTester tester) async { + // We expect the toolbar to be positioned right below the anchor with padding accounted for. + const double expectedToolbarY = + _kAnchor + (2 * SpellCheckSuggestionsToolbar.kToolbarContentDistanceBelow) - TextSelectionToolbar.kToolbarScreenPadding; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SpellCheckSuggestionsToolbar( + anchor: const Offset(0.0, _kAnchor), + buttonItems: buildSuggestionButtons(['hello', 'yellow', 'yell']), + ), + ), + ), + ); + + final double toolbarY = tester.getTopLeft(findSpellCheckSuggestionsToolbar()).dy; + expect(toolbarY, equals(expectedToolbarY)); + }); + + testWidgets('re-positions toolbar higher below anchor when it does not fit above bottom view padding', (WidgetTester tester) async { + // We expect the toolbar to be positioned _kTestToolbarOverlap pixels above the anchor with padding accounted for. + const double expectedToolbarY = + _kAnchor + (2 * SpellCheckSuggestionsToolbar.kToolbarContentDistanceBelow) - TextSelectionToolbar.kToolbarScreenPadding - _kTestToolbarOverlap; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SpellCheckSuggestionsToolbar( + anchor: const Offset(0.0, _kAnchor - _kTestToolbarOverlap), + buttonItems: buildSuggestionButtons(['hello', 'yellow', 'yell']), + ), + ), + ), + ); + + final double toolbarY = tester.getTopLeft(findSpellCheckSuggestionsToolbar()).dy; + expect(toolbarY, equals(expectedToolbarY)); + }); +} diff --git a/packages/flutter/test/material/text_selection_toolbar_test.dart b/packages/flutter/test/material/text_selection_toolbar_test.dart index 0274d1c30901..683210e09fd6 100644 --- a/packages/flutter/test/material/text_selection_toolbar_test.dart +++ b/packages/flutter/test/material/text_selection_toolbar_test.dart @@ -8,8 +8,6 @@ import 'package:flutter_test/flutter_test.dart'; import '../widgets/editable_text_utils.dart' show textOffsetToPosition; -const double _kHandleSize = 22.0; -const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0; const double _kToolbarContentDistance = 8.0; // A custom text selection menu that just displays a single custom button. @@ -35,7 +33,7 @@ class _CustomMaterialTextSelectionControls extends MaterialTextSelectionControls ); final Offset anchorBelow = Offset( globalEditableRegion.left + selectionMidpoint.dx, - globalEditableRegion.top + endTextSelectionPoint.point.dy + _kToolbarContentDistanceBelow, + globalEditableRegion.top + endTextSelectionPoint.point.dy + TextSelectionToolbar.kToolbarContentDistanceBelow, ); return TextSelectionToolbar( @@ -155,7 +153,7 @@ void main() { // When the toolbar doesn't fit above aboveAnchor, it positions itself below // belowAnchor. double toolbarY = tester.getTopLeft(findToolbar()).dy; - expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistanceBelow)); + expect(toolbarY, equals(anchorBelowY + TextSelectionToolbar.kToolbarContentDistanceBelow)); // Even when it barely doesn't fit. setState(() { @@ -163,7 +161,7 @@ void main() { }); await tester.pump(); toolbarY = tester.getTopLeft(findToolbar()).dy; - expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistanceBelow)); + expect(toolbarY, equals(anchorBelowY + TextSelectionToolbar.kToolbarContentDistanceBelow)); // When it does fit above aboveAnchor, it positions itself there. setState(() { diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 818b298f5158..00f86f8e4370 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -14020,27 +14020,305 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async testWidgets( 'Error thrown when spell check enabled but no default spell check service available', (WidgetTester tester) async { - tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue = - false; + tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue = + false; - await tester.pumpWidget( - EditableText( - controller: TextEditingController(text: 'A'), - focusNode: FocusNode(), - style: const TextStyle(), - cursorColor: Colors.blue, - backgroundCursorColor: Colors.grey, - cursorOpacityAnimates: true, - autofillHints: null, - spellCheckConfiguration: - const SpellCheckConfiguration( - misspelledTextStyle: TextField.materialMisspelledTextStyle, - ), - )); + await tester.pumpWidget( + EditableText( + controller: TextEditingController(text: 'A'), + focusNode: FocusNode(), + style: const TextStyle(), + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + cursorOpacityAnimates: true, + autofillHints: null, + spellCheckConfiguration: + const SpellCheckConfiguration( + misspelledTextStyle: TextField.materialMisspelledTextStyle, + ), + )); expect(tester.takeException(), isA()); tester.binding.platformDispatcher.clearNativeSpellCheckServiceDefined(); }); + + testWidgets( + 'findSuggestionSpanAtCursorIndex finds correct span with cursor in middle of a word', + (WidgetTester tester) async { + tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue = + true; + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: TextEditingController(text: 'A'), + focusNode: FocusNode(), + style: const TextStyle(), + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + cursorOpacityAnimates: true, + autofillHints: null, + spellCheckConfiguration: + const SpellCheckConfiguration( + misspelledTextStyle: TextField.materialMisspelledTextStyle, + ), + ), + ), + ); + final EditableTextState state = + tester.state(find.byType(EditableText)); + + const int cursorIndex = 21; + const SuggestionSpan expectedSpan = SuggestionSpan(TextRange(start: 20, end: 23), ['Hey', 'He']); + const List suggestionSpans = + [ + SuggestionSpan( + TextRange(start: 13, end: 18), ['world', 'word', 'old']), + expectedSpan, + SuggestionSpan( + TextRange(start: 25, end: 30), ['green', 'grey', 'great']), + ]; + + // Omitting actual text in results for brevity. Same for following tests that test the findSuggestionSpanAtCursorIndex method. + state.spellCheckResults = const SpellCheckResults('', suggestionSpans); + final SuggestionSpan? suggestionSpan = state.findSuggestionSpanAtCursorIndex(cursorIndex); + + expect(suggestionSpan, equals(expectedSpan)); + }); + + testWidgets( + 'findSuggestionSpanAtCursorIndex finds correct span with cursor on edge of a word', + (WidgetTester tester) async { + tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue = + true; + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: TextEditingController(text: 'A'), + focusNode: FocusNode(), + style: const TextStyle(), + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + cursorOpacityAnimates: true, + autofillHints: null, + spellCheckConfiguration: + const SpellCheckConfiguration( + misspelledTextStyle: TextField.materialMisspelledTextStyle, + ), + ), + ), + ); + final EditableTextState state = + tester.state(find.byType(EditableText)); + + const int cursorIndex = 23; + const SuggestionSpan expectedSpan = SuggestionSpan(TextRange(start: 20, end: 23), ['Hey', 'He']); + const List suggestionSpans = + [ + SuggestionSpan( + TextRange(start: 13, end: 18), ['world', 'word', 'old']), + expectedSpan, + SuggestionSpan( + TextRange(start: 25, end: 30), ['green', 'grey', 'great']), + ]; + + state.spellCheckResults = const SpellCheckResults('', suggestionSpans); + final SuggestionSpan? suggestionSpan = state.findSuggestionSpanAtCursorIndex(cursorIndex); + + expect(suggestionSpan, equals(expectedSpan)); + }); + + testWidgets( + 'findSuggestionSpanAtCursorIndex finds no span when cursor out of range of spans', + (WidgetTester tester) async { + tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue = + true; + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: TextEditingController(text: 'A'), + focusNode: FocusNode(), + style: const TextStyle(), + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + cursorOpacityAnimates: true, + autofillHints: null, + spellCheckConfiguration: + const SpellCheckConfiguration( + misspelledTextStyle: TextField.materialMisspelledTextStyle, + ), + ), + ), + ); + final EditableTextState state = + tester.state(find.byType(EditableText)); + + const int cursorIndex = 33; + const SuggestionSpan expectedSpan = SuggestionSpan(TextRange(start: 20, end: 23), ['Hey', 'He']); + const List suggestionSpans = + [ + SuggestionSpan( + TextRange(start: 13, end: 18), ['world', 'word', 'old']), + expectedSpan, + SuggestionSpan( + TextRange(start: 25, end: 30), ['green', 'grey', 'great']), + ]; + + state.spellCheckResults = const SpellCheckResults('', suggestionSpans); + final SuggestionSpan? suggestionSpan = state.findSuggestionSpanAtCursorIndex(cursorIndex); + + expect(suggestionSpan, isNull); + }); + + testWidgets( + 'findSuggestionSpanAtCursorIndex finds no span when word correctly spelled', + (WidgetTester tester) async { + tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue = + true; + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: TextEditingController(text: 'A'), + focusNode: FocusNode(), + style: const TextStyle(), + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + cursorOpacityAnimates: true, + autofillHints: null, + spellCheckConfiguration: + const SpellCheckConfiguration( + misspelledTextStyle: TextField.materialMisspelledTextStyle, + ), + ), + ), + ); + final EditableTextState state = + tester.state(find.byType(EditableText)); + + const int cursorIndex = 5; + const SuggestionSpan expectedSpan = SuggestionSpan(TextRange(start: 20, end: 23), ['Hey', 'He']); + const List suggestionSpans = + [ + SuggestionSpan( + TextRange(start: 13, end: 18), ['world', 'word', 'old']), + expectedSpan, + SuggestionSpan( + TextRange(start: 25, end: 30), ['green', 'grey', 'great']), + ]; + + state.spellCheckResults = const SpellCheckResults('', suggestionSpans); + final SuggestionSpan? suggestionSpan = state.findSuggestionSpanAtCursorIndex(cursorIndex); + + expect(suggestionSpan, isNull); + }); + + testWidgets('can show spell check suggestions toolbar when there are spell check results', (WidgetTester tester) async { + tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue = + true; + const TextEditingValue value = TextEditingValue( + text: 'tset test test', + selection: TextSelection(affinity: TextAffinity.upstream, baseOffset: 0, extentOffset: 4), + ); + controller.value = value; + await tester.pumpWidget( + MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + spellCheckConfiguration: + const SpellCheckConfiguration( + misspelledTextStyle: TextField.materialMisspelledTextStyle, + spellCheckSuggestionsToolbarBuilder: TextField.defaultSpellCheckSuggestionsToolbarBuilder, + ), + ), + ), + ); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + + // Can't show the toolbar when there's no focus. + expect(state.showSpellCheckSuggestionsToolbar(), false); + await tester.pumpAndSettle(); + expect(find.text('DELETE'), findsNothing); + + // Can't show the toolbar when there are no spell check results. + expect(state.showSpellCheckSuggestionsToolbar(), false); + await tester.pumpAndSettle(); + expect(find.text('test'), findsNothing); + expect(find.text('sets'), findsNothing); + expect(find.text('set'), findsNothing); + expect(find.text('DELETE'), findsNothing); + + // Can show the toolbar when there are spell check results. + state.spellCheckResults = const SpellCheckResults('test tset test', [SuggestionSpan(TextRange(start: 0, end: 4), ['test', 'sets', 'set'])]); + state.renderEditable.selectWordsInRange( + from: Offset.zero, + cause: SelectionChangedCause.tap, + ); + await tester.pumpAndSettle(); + expect(state.showSpellCheckSuggestionsToolbar(), true); + await tester.pumpAndSettle(); + expect(find.text('test'), findsOneWidget); + expect(find.text('sets'), findsOneWidget); + expect(find.text('set'), findsOneWidget); + expect(find.text('DELETE'), findsOneWidget); + }); + + testWidgets('spell check suggestions toolbar buttons correctly change the composing region', (WidgetTester tester) async { + tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue = + true; + const TextEditingValue value = TextEditingValue( + text: 'tset test test', + composing: TextRange(start: 0, end: 4), + selection: TextSelection(affinity: TextAffinity.upstream, baseOffset: 0, extentOffset: 4), + ); + controller.value = value; + await tester.pumpWidget( + MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + spellCheckConfiguration: + const SpellCheckConfiguration( + misspelledTextStyle: TextField.materialMisspelledTextStyle, + spellCheckSuggestionsToolbarBuilder: TextField.defaultSpellCheckSuggestionsToolbarBuilder, + ), + ), + ), + ); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + state.spellCheckResults = const SpellCheckResults('tset test test', [SuggestionSpan(TextRange(start: 0, end: 4), ['test', 'sets', 'set'])]); + state.renderEditable.selectWordsInRange( + from: Offset.zero, + cause: SelectionChangedCause.tap, + ); + await tester.pumpAndSettle(); + + // Test misspelled word replacement buttons. + state.showSpellCheckSuggestionsToolbar(); + await tester.pumpAndSettle(); + expect(find.text('sets'), findsOneWidget); + await tester.tap(find.text('sets')); + await tester.pumpAndSettle(); + expect(state.currentTextEditingValue.text, equals('sets test test')); + + // Test delete button. + state.showSpellCheckSuggestionsToolbar(); + await tester.pumpAndSettle(); + await tester.tap(find.text('DELETE')); + await tester.pumpAndSettle(); + expect(state.currentTextEditingValue.text, equals(' test test')); + }); }); group('magnifier', () { @@ -14205,7 +14483,6 @@ class _CustomTextSelectionControls extends TextSelectionControls { this.onCut, }); - static const double _kToolbarContentDistanceBelow = 20.0; static const double _kToolbarContentDistance = 8.0; final VoidCallback? onPaste; @@ -14233,7 +14510,7 @@ class _CustomTextSelectionControls extends TextSelectionControls { ); final Offset anchorBelow = Offset( globalEditableRegion.left + selectionMidpoint.dx, - globalEditableRegion.top + endTextSelectionPoint.point.dy + _kToolbarContentDistanceBelow, + globalEditableRegion.top + endTextSelectionPoint.point.dy + TextSelectionToolbar.kToolbarContentDistanceBelow, ); return _CustomTextSelectionToolbar( anchorAbove: anchorAbove, diff --git a/packages/flutter/test/widgets/text_selection_test.dart b/packages/flutter/test/widgets/text_selection_test.dart index 505c05a84898..f8508114870e 100644 --- a/packages/flutter/test/widgets/text_selection_test.dart +++ b/packages/flutter/test/widgets/text_selection_test.dart @@ -618,6 +618,27 @@ void main() { } }, variant: TargetPlatformVariant.all()); + + testWidgets('test TextSelectionGestureDetectorBuilder shows spell check toolbar on single tap on Android', (WidgetTester tester) async { + await pumpTextSelectionGestureDetectorBuilder(tester); + + final FakeEditableTextState state = tester.state(find.byType(FakeEditableText)); + final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable)); + expect(state.showSpellCheckSuggestionsToolbarCalled, isFalse); + renderEditable.selection = const TextSelection(baseOffset: 2, extentOffset: 6); + renderEditable.hasFocus = true; + + final TestGesture gesture = await tester.startGesture( + const Offset(25.0, 200.0), + pointer: 0, + ); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(state.showSpellCheckSuggestionsToolbarCalled, isTrue); + + }, variant: const TargetPlatformVariant({ TargetPlatform.android })); + testWidgets('test TextSelectionGestureDetectorBuilder double tap', (WidgetTester tester) async { await pumpTextSelectionGestureDetectorBuilder(tester); final TestGesture gesture = await tester.startGesture( @@ -1568,6 +1589,7 @@ class FakeEditableTextState extends EditableTextState { final GlobalKey _editableKey = GlobalKey(); bool showToolbarCalled = false; bool toggleToolbarCalled = false; + bool showSpellCheckSuggestionsToolbarCalled = false; @override RenderEditable get renderEditable => _editableKey.currentContext!.findRenderObject()! as RenderEditable; @@ -1584,6 +1606,12 @@ class FakeEditableTextState extends EditableTextState { return; } + @override + bool showSpellCheckSuggestionsToolbar() { + showSpellCheckSuggestionsToolbarCalled = true; + return true; + } + @override Widget build(BuildContext context) { super.build(context);