diff --git a/packages/flutter/lib/services.dart b/packages/flutter/lib/services.dart index 4516577f8f..acad47fa01 100644 --- a/packages/flutter/lib/services.dart +++ b/packages/flutter/lib/services.dart @@ -44,6 +44,7 @@ export 'src/services/raw_keyboard_macos.dart'; export 'src/services/raw_keyboard_web.dart'; export 'src/services/raw_keyboard_windows.dart'; export 'src/services/restoration.dart'; +export 'src/services/scribe.dart'; export 'src/services/service_extensions.dart'; export 'src/services/spell_check.dart'; export 'src/services/system_channels.dart'; diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index 1527397c34..c151a8da50 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -295,7 +295,12 @@ class CupertinoTextField extends StatefulWidget { this.contentInsertionConfiguration, this.clipBehavior = Clip.hardEdge, this.restorationId, + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) this.scribbleEnabled = true, + this.stylusHandwritingEnabled = EditableText.defaultStylusHandwritingEnabled, this.enableIMEPersonalizedLearning = true, this.contextMenuBuilder = _defaultContextMenuBuilder, this.spellCheckConfiguration, @@ -425,7 +430,12 @@ class CupertinoTextField extends StatefulWidget { this.contentInsertionConfiguration, this.clipBehavior = Clip.hardEdge, this.restorationId, + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) this.scribbleEnabled = true, + this.stylusHandwritingEnabled = true, this.enableIMEPersonalizedLearning = true, this.contextMenuBuilder = _defaultContextMenuBuilder, this.spellCheckConfiguration, @@ -762,8 +772,15 @@ class CupertinoTextField extends StatefulWidget { final String? restorationId; /// {@macro flutter.widgets.editableText.scribbleEnabled} + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) final bool scribbleEnabled; + /// {@macro flutter.widgets.editableText.stylusHandwritingEnabled} + final bool stylusHandwritingEnabled; + /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} final bool enableIMEPersonalizedLearning; @@ -895,6 +912,7 @@ class CupertinoTextField extends StatefulWidget { properties.add(EnumProperty('textDirection', textDirection, defaultValue: null)); properties.add(DiagnosticsProperty('clipBehavior', clipBehavior, defaultValue: Clip.hardEdge)); properties.add(DiagnosticsProperty('scribbleEnabled', scribbleEnabled, defaultValue: true)); + properties.add(DiagnosticsProperty('stylusHandwritingEnabled', stylusHandwritingEnabled, defaultValue: EditableText.defaultStylusHandwritingEnabled)); properties.add(DiagnosticsProperty('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true)); properties.add(DiagnosticsProperty('spellCheckConfiguration', spellCheckConfiguration, defaultValue: null)); properties.add(DiagnosticsProperty>('contentCommitMimeTypes', contentInsertionConfiguration?.allowedMimeTypes ?? const [], defaultValue: contentInsertionConfiguration == null ? const [] : kDefaultContentInsertionMimeTypes)); @@ -1447,6 +1465,7 @@ class _CupertinoTextFieldState extends State with Restoratio clipBehavior: widget.clipBehavior, restorationId: 'editable', scribbleEnabled: widget.scribbleEnabled, + stylusHandwritingEnabled: widget.stylusHandwritingEnabled, enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, contentInsertionConfiguration: widget.contentInsertionConfiguration, contextMenuBuilder: widget.contextMenuBuilder, diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 034a197eda..18095fdd97 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -301,7 +301,12 @@ class TextField extends StatefulWidget { this.contentInsertionConfiguration, this.clipBehavior = Clip.hardEdge, this.restorationId, + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) this.scribbleEnabled = true, + this.stylusHandwritingEnabled = EditableText.defaultStylusHandwritingEnabled, this.enableIMEPersonalizedLearning = true, this.contextMenuBuilder = _defaultContextMenuBuilder, this.canRequestFocus = true, @@ -797,8 +802,15 @@ class TextField extends StatefulWidget { final String? restorationId; /// {@macro flutter.widgets.editableText.scribbleEnabled} + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) final bool scribbleEnabled; + /// {@macro flutter.widgets.editableText.stylusHandwritingEnabled} + final bool stylusHandwritingEnabled; + /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} final bool enableIMEPersonalizedLearning; @@ -948,6 +960,7 @@ class TextField extends StatefulWidget { properties.add(DiagnosticsProperty('scrollPhysics', scrollPhysics, defaultValue: null)); properties.add(DiagnosticsProperty('clipBehavior', clipBehavior, defaultValue: Clip.hardEdge)); properties.add(DiagnosticsProperty('scribbleEnabled', scribbleEnabled, defaultValue: true)); + properties.add(DiagnosticsProperty('stylusHandwritingEnabled', stylusHandwritingEnabled, defaultValue: EditableText.defaultStylusHandwritingEnabled)); properties.add(DiagnosticsProperty('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true)); properties.add(DiagnosticsProperty('spellCheckConfiguration', spellCheckConfiguration, defaultValue: null)); properties.add(DiagnosticsProperty>('contentCommitMimeTypes', contentInsertionConfiguration?.allowedMimeTypes ?? const [], defaultValue: contentInsertionConfiguration == null ? const [] : kDefaultContentInsertionMimeTypes)); @@ -1514,6 +1527,7 @@ class _TextFieldState extends State with RestorationMixin implements clipBehavior: widget.clipBehavior, restorationId: 'editable', scribbleEnabled: widget.scribbleEnabled, + stylusHandwritingEnabled: widget.stylusHandwritingEnabled, enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, contentInsertionConfiguration: widget.contentInsertionConfiguration, contextMenuBuilder: widget.contextMenuBuilder, diff --git a/packages/flutter/lib/src/material/text_form_field.dart b/packages/flutter/lib/src/material/text_form_field.dart index 2e2760a7d8..eccabddd3c 100644 --- a/packages/flutter/lib/src/material/text_form_field.dart +++ b/packages/flutter/lib/src/material/text_form_field.dart @@ -182,7 +182,12 @@ class TextFormField extends FormField { ContentInsertionConfiguration? contentInsertionConfiguration, MaterialStatesController? statesController, Clip clipBehavior = Clip.hardEdge, + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) bool scribbleEnabled = true, + bool stylusHandwritingEnabled = EditableText.defaultStylusHandwritingEnabled, bool canRequestFocus = true, }) : assert(initialValue == null || controller == null), assert(obscuringCharacter.length == 1), @@ -279,6 +284,7 @@ class TextFormField extends FormField { contentInsertionConfiguration: contentInsertionConfiguration, clipBehavior: clipBehavior, scribbleEnabled: scribbleEnabled, + stylusHandwritingEnabled: stylusHandwritingEnabled, canRequestFocus: canRequestFocus, ), ); diff --git a/packages/flutter/lib/src/services/scribe.dart b/packages/flutter/lib/src/services/scribe.dart new file mode 100644 index 0000000000..4a17271e95 --- /dev/null +++ b/packages/flutter/lib/src/services/scribe.dart @@ -0,0 +1,143 @@ +// 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/foundation.dart'; + +import 'message_codec.dart'; +import 'system_channels.dart'; + +/// An interface into Android's stylus handwriting text input. +/// +/// Allows handwriting directly on top of a text input using a stylus. +/// +/// See also: +/// +/// * [EditableText.stylusHandwritingEnabled], which controls whether Flutter's +/// built-in text fields support handwriting input. +/// * [SystemChannels.scribe], which is the [MethodChannel] used by this +/// class, and which has a list of the methods that this class handles. +/// * , +/// which is the Android documentation explaining the Scribe feature. +abstract final class Scribe { + static const MethodChannel _channel = SystemChannels.scribe; + + /// A convenience method to check if the device currently supports Scribe + /// stylus handwriting input. + /// + /// Call this each time before calling [startStylusHandwriting] to make sure + /// it's available. + /// + /// {@tool snippet} + /// This example shows using [isFeatureAvailable] to confirm that + /// [startStylusHandwriting] can be called. + /// + /// ```dart + /// if (!await Scribe.isFeatureAvailable()) { + /// // The device doesn't support stylus input right now, or maybe at all. + /// return; + /// } + /// + /// // Scribe is supported, so start it. + /// Scribe.startStylusHandwriting(); + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [isStylusHandwritingAvailable], which is similar, but throws an error + /// when called by an unsupported API level. It directly corresponds to the + /// underlying Android API + /// . + /// * [EditableText.stylusHandwritingEnabled], which controls whether + /// Flutter's built-in text fields support handwriting input. + static Future isFeatureAvailable() async { + final bool? result = await _channel.invokeMethod( + 'Scribe.isFeatureAvailable', + ); + + if (result == null) { + throw FlutterError('MethodChannel.invokeMethod unexpectedly returned null.'); + } + + return result; + } + + /// Returns true if the InputMethodManager supports Scribe stylus handwriting + /// input, false otherwise. + /// + /// Call this each time before calling [startStylusHandwriting] to make sure + /// it's available. + /// + /// Supported on Android API 34 and above. If called by an unsupported API + /// level, a [PlatformException] will be thrown. To avoid error handling, use + /// the convenience method [isFeatureAvailable] instead. + /// + /// {@tool snippet} + /// This example shows using [isStylusHandwritingAvailable] to confirm that + /// [startStylusHandwriting] can be called. + /// + /// ```dart + /// try { + /// if (!await Scribe.isStylusHandwritingAvailable()) { + /// // If isStylusHandwritingAvailable returns false then the device's API level + /// // supports Scribe, but for some other reason it's not able to accept stylus + /// // input right now. + /// return; + /// } + /// } on PlatformException catch (exception) { + /// if (exception.message == 'Requires API level 34 or higher.') { + /// // The device's API level is too low to support Scribe. + /// return; + /// } + /// // Any other exception is unexpected and should not be caught here. + /// rethrow; + /// } + /// + /// // Scribe is supported, so start it. + /// Scribe.startStylusHandwriting(); + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * , + /// which is the corresponding API on Android that this method attempts to + /// mirror. + static Future isStylusHandwritingAvailable() async { + final bool? result = await _channel.invokeMethod( + 'Scribe.isStylusHandwritingAvailable', + ); + + if (result == null) { + throw FlutterError('MethodChannel.invokeMethod unexpectedly returned null.'); + } + + return result; + } + + /// Tell Android to begin receiving stylus handwriting input. + /// + /// This is typically called after detecting a [PointerDownEvent] from a + /// [PointerDeviceKind.stylus] on an active text field, indicating the start + /// of stylus handwriting input. If there is no active [TextInputConnection], + /// the call will be ignored. + /// + /// Call [isFeatureAvailable] each time before calling this to make sure that + /// stylus handwriting input is supported and available. + /// + /// Supported on Android API 33 and above. + /// + /// See also: + /// + /// * , + /// which is the corresponding API on Android that this method attempts to + /// mirror. + /// * [EditableText.stylusHandwritingEnabled], which controls whether + /// Flutter's built-in text fields support handwriting input. + static Future startStylusHandwriting() { + return _channel.invokeMethod( + 'Scribe.startStylusHandwriting', + ); + } +} diff --git a/packages/flutter/lib/src/services/system_channels.dart b/packages/flutter/lib/src/services/system_channels.dart index d310abbb6d..14bb8ae463 100644 --- a/packages/flutter/lib/src/services/system_channels.dart +++ b/packages/flutter/lib/src/services/system_channels.dart @@ -277,6 +277,35 @@ abstract final class SystemChannels { JSONMethodCodec(), ); + /// A [MethodChannel] for handling Android Scribe stylus handwriting input. + /// + /// Android's Scribe feature allows writing directly on top of a text input + /// using a stylus. + /// + /// The following methods are defined for this channel: + /// + /// * `Scribe.startStylusHandwriting`: Indicates that stylus input has been + /// detected and Android should start handwriting input. + /// * `Scribe.isStylusHandwritingAvailable`: Returns a boolean representing + /// whether or not the device currently supports accepting stylus handwriting + /// input. Throws if the device's API level is not sufficient. + /// * `Scribe.isFeatureAvailable`: Returns a boolean representing whether or + /// not the device currently supports accepting stylus handwriting input. + /// Returns false and does not throw if the device's API level is not + /// sufficient. + /// + /// See also: + /// + /// * [Scribe], which uese this channel. + /// * [ScribbleClient], which implements the iOS version of this feature, + /// [Scribble](https://support.apple.com/guide/ipad/enter-text-with-scribble-ipad355ab2a7/ipados). + /// * , + /// which is the Android documentation explaining the Scribe feature. + static const MethodChannel scribe = OptionalMethodChannel( + 'flutter/scribe', + JSONMethodCodec(), + ); + /// A [MethodChannel] for handling spell check for text input. /// /// This channel exposes the spell check framework for supported platforms. diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 890b4933f4..a7f7337ccd 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -1066,7 +1066,12 @@ enum SelectionChangedCause { /// of text. drag, - /// The user used iPadOS 14+ Scribble to change the selection. + // TODO(justinmc): Rename this to stylusHandwriting. + // https://github.com/flutter/flutter/issues/159223 + /// The user used stylus handwriting to change the selection. + /// + /// Currently, this is only supported on iPadOS 14+ via the Scribble feature, + /// or on Android API 34+ via the Scribe feature. scribble, } @@ -1262,9 +1267,13 @@ mixin TextInputClient { void performSelector(String selectorName) {} } -/// An interface to receive focus from the engine. +/// An interface into iOS's stylus hadnwriting text input. /// -/// This is currently only used to handle UIIndirectScribbleInteraction. +/// See also: +/// +/// * [Scribe], which provides similar functionality for Anroid. +/// * [UIIndirectScribbleInteraction](https://developer.apple.com/documentation/uikit/uiindirectscribbleinteraction), +/// which is iOS's API for Scribble. abstract class ScribbleClient { /// A unique identifier for this element. String get elementIdentifier; diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index a9f88c1dbb..d99544c466 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -20,7 +20,7 @@ import 'dart:ui' as ui hide TextStyle; import 'package:characters/characters.dart' show CharacterRange, StringCharacters; import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; @@ -880,7 +880,12 @@ class EditableText extends StatefulWidget { this.clipBehavior = Clip.hardEdge, this.restorationId, this.scrollBehavior, + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) this.scribbleEnabled = true, + this.stylusHandwritingEnabled = defaultStylusHandwritingEnabled, this.enableIMEPersonalizedLearning = true, this.contentInsertionConfiguration, this.contextMenuBuilder, @@ -1735,8 +1740,33 @@ class EditableText extends StatefulWidget { /// /// Defaults to true. /// {@endtemplate} + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) final bool scribbleEnabled; + /// {@template flutter.widgets.editableText.stylusHandwritingEnabled} + /// Whether this input supports stylus handwriting, where the user can write + /// directly on top of a field. + /// + /// Currently only the following devices are supported: + /// + /// * iPads running iOS 14 and above using an Apple Pencil. + /// * Android devices running API 34 and above and using an active stylus. + /// {@endtemplate} + /// + /// On Android, Scribe gestures are detected outside of [EditableText], + /// typically by [TextSelectionGestureDetectorBuilder]. This is handled + /// automatically in [TextField]. + /// + /// See also: + /// + /// * [ScribbleClient], which can be mixed into an arbirtrary widget to + /// provide iOS Scribble functionality. + /// * [Scribe], which can be used to interact with Android Scribe directly. + final bool stylusHandwritingEnabled; + /// {@template flutter.widgets.editableText.selectionEnabled} /// Same as [enableInteractiveSelection]. /// @@ -1975,6 +2005,9 @@ class EditableText extends StatefulWidget { /// {@macro flutter.widgets.magnifier.intro} final TextMagnifierConfiguration magnifierConfiguration; + /// The default value for [stylusHandwritingEnabled]. + static const bool defaultStylusHandwritingEnabled = true; + bool get _userSelectionEnabled => enableInteractiveSelection && (!readOnly || !obscureText); /// Returns the [ContextMenuButtonItem]s representing the buttons in this @@ -2242,6 +2275,7 @@ class EditableText extends StatefulWidget { properties.add(DiagnosticsProperty>('autofillHints', autofillHints, defaultValue: null)); properties.add(DiagnosticsProperty('textHeightBehavior', textHeightBehavior, defaultValue: null)); properties.add(DiagnosticsProperty('scribbleEnabled', scribbleEnabled, defaultValue: true)); + properties.add(DiagnosticsProperty('stylusHandwritingEnabled', stylusHandwritingEnabled, defaultValue: defaultStylusHandwritingEnabled)); properties.add(DiagnosticsProperty('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true)); properties.add(DiagnosticsProperty('enableInteractiveSelection', enableInteractiveSelection, defaultValue: true)); properties.add(DiagnosticsProperty('undoController', undoController, defaultValue: null)); @@ -2363,6 +2397,15 @@ class EditableTextState extends State with AutomaticKeepAliveClien Orientation? _lastOrientation; + bool get _stylusHandwritingEnabled { + // During the deprecation period, respect scribbleEnabled being explicitly + // set. + if (!widget.scribbleEnabled) { + return widget.scribbleEnabled; + } + return widget.stylusHandwritingEnabled; + } + late final AppLifecycleListener _appLifecycleListener; bool _justResumed = false; @@ -4464,7 +4507,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien _ScribbleCacheKey? _scribbleCacheKey; void _updateSelectionRects({bool force = false}) { - if (!widget.scribbleEnabled || defaultTargetPlatform != TargetPlatform.iOS) { + if (!_stylusHandwritingEnabled || defaultTargetPlatform != TargetPlatform.iOS) { return; } @@ -4755,7 +4798,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien @override void insertTextPlaceholder(Size size) { - if (!widget.scribbleEnabled) { + if (!_stylusHandwritingEnabled) { return; } @@ -4770,7 +4813,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien @override void removeTextPlaceholder() { - if (!widget.scribbleEnabled || _placeholderLocation == -1) { + if (!_stylusHandwritingEnabled || _placeholderLocation == -1) { return; } @@ -5284,9 +5327,9 @@ class EditableTextState extends State with AutomaticKeepAliveClien onCut: _semanticsOnCut(controls), onPaste: _semanticsOnPaste(controls), child: _ScribbleFocusable( - focusNode: widget.focusNode, editableKey: _editableKey, - enabled: widget.scribbleEnabled, + enabled: _stylusHandwritingEnabled, + focusNode: widget.focusNode, updateSelectionRects: () { _openInputConnection(); _updateSelectionRects(force: true); diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index d7087c9bc9..ce92fdcfc6 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -1857,6 +1857,24 @@ class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with S } } + /// Returns the bounding [Rect] of the text selection handle in local + /// coordinates. + /// + /// When interacting with a text seletion handle through a touch event, the + /// interactive area should be at least [kMinInteractiveDimension] square, + /// which this method does not consider. + Rect _getHandleRect(TextSelectionHandleType type, double preferredLineHeight) { + final Size handleSize = widget.selectionControls.getHandleSize( + preferredLineHeight, + ); + return Rect.fromLTWH( + 0.0, + 0.0, + handleSize.width, + handleSize.height, + ); + } + @override void didUpdateWidget(_SelectionHandleOverlay oldWidget) { super.didUpdateWidget(oldWidget); @@ -1874,20 +1892,10 @@ class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with S @override Widget build(BuildContext context) { - final Offset handleAnchor = widget.selectionControls.getHandleAnchor( + final Rect handleRect = _getHandleRect( 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( @@ -1900,6 +1908,11 @@ class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with S math.max((interactiveRect.height - handleRect.height) / 2, 0), ); + final Offset handleAnchor = widget.selectionControls.getHandleAnchor( + widget.type, + widget.preferredLineHeight, + ); + // Make sure a drag is eagerly accepted. This is used on iOS to match the // behavior where a drag directly on a collapse handle will always win against // other drag gestures. @@ -1907,7 +1920,8 @@ class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with S return CompositedTransformFollower( link: widget.handleLayerLink, - offset: interactiveRect.topLeft, + // Put the handle's anchor point on the leader's anchor point. + offset: -handleAnchor - Offset(padding.left, padding.top), showWhenUnlinked: false, child: FadeTransition( opacity: _opacity, @@ -2246,6 +2260,7 @@ class TextSelectionGestureDetectorBuilder { 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] @@ -2270,6 +2285,22 @@ class TextSelectionGestureDetectorBuilder { final bool isShiftPressedValid = _isShiftPressed && renderEditable.selection?.baseOffset != null; switch (defaultTargetPlatform) { case TargetPlatform.android: + if (editableText.widget.stylusHandwritingEnabled) { + final bool stylusEnabled = switch (kind) { + PointerDeviceKind.stylus + || PointerDeviceKind.invertedStylus => + editableText.widget.stylusHandwritingEnabled, + _ => false, + }; + if (stylusEnabled) { + Scribe.isFeatureAvailable().then((bool isAvailable) { + if (isAvailable) { + renderEditable.selectPosition(cause: SelectionChangedCause.scribble); + Scribe.startStylusHandwriting(); + } + }); + } + } case TargetPlatform.fuchsia: case TargetPlatform.iOS: // On mobile platforms the selection is set on tap up. diff --git a/packages/flutter/test/services/scribe_test.dart b/packages/flutter/test/services/scribe_test.dart new file mode 100644 index 0000000000..73ad0497b8 --- /dev/null +++ b/packages/flutter/test/services/scribe_test.dart @@ -0,0 +1,84 @@ +// 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/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); + + test('when receiving an unsupported message', () async { + final ByteData? messageBytes = const JSONMessageCodec().encodeMessage({ + 'method': 'Scribe.unsupportedMessage', + }); + + final ByteData? response = await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribe', + messageBytes, + null, + ); + + // When a MissingPluginException is thrown, it is caught and a null response + // is returned. + expect(response, isNull); + }, skip: kIsWeb); // [intended] + + for (final bool? returnValue in [false, true, null]) { + test('Scribe.isStylusHandwritingAvailable calls through to platform channel', () async { + final List calls = []; + binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.scribe, (MethodCall methodCall) { + calls.add(methodCall); + return Future.value(returnValue); + }); + + if (returnValue == null) { + expect(() async { + await Scribe.isStylusHandwritingAvailable(); + }, throwsA(isA())); + } else { + expect(await Scribe.isStylusHandwritingAvailable(), returnValue); + } + + expect(calls, hasLength(1)); + expect(calls.first.method, 'Scribe.isStylusHandwritingAvailable'); + }, skip: kIsWeb); // [intended] + } + + for (final bool? returnValue in [false, true, null]) { + test('Scribe.isFeatureAvailable calls through to platform channel', () async { + final List calls = []; + binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.scribe, (MethodCall methodCall) { + calls.add(methodCall); + return Future.value(returnValue); + }); + + if (returnValue == null) { + expect(() async { + await Scribe.isFeatureAvailable(); + }, throwsA(isA())); + } else { + expect(await Scribe.isFeatureAvailable(), returnValue); + } + + expect(calls, hasLength(1)); + expect(calls.first.method, 'Scribe.isFeatureAvailable'); + }, skip: kIsWeb); // [intended] + } + + test('Scribe.startStylusHandwriting calls through to platform channel', () async { + final List calls = []; + binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.scribe, (MethodCall methodCall) { + calls.add(methodCall); + return Future.value(); + }); + + Scribe.startStylusHandwriting(); + expect(calls, hasLength(1)); + expect(calls.first.method, 'Scribe.startStylusHandwriting'); + }, skip: kIsWeb); // [intended] +} diff --git a/packages/flutter/test/widgets/editable_text_scribble_test.dart b/packages/flutter/test/widgets/editable_text_scribble_test.dart new file mode 100644 index 0000000000..384790c8eb --- /dev/null +++ b/packages/flutter/test/widgets/editable_text_scribble_test.dart @@ -0,0 +1,686 @@ +// 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/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'editable_text_utils.dart'; + +void main() { + const TextStyle textStyle = TextStyle(); + const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00); + late TextEditingController controller; + late FocusNode focusNode; + + setUp(() async { + controller = TextEditingController(); + focusNode = FocusNode(debugLabel: 'EditableText Node'); + }); + + tearDown(() { + controller.dispose(); + focusNode.dispose(); + }); + + testWidgets('selection rects re-sent when refocused', (WidgetTester tester) async { + final List> log = >[]; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) async { + if (methodCall.method == 'TextInput.setSelectionRects') { + final List args = methodCall.arguments as List; + final List selectionRects = []; + for (final dynamic rect in args) { + selectionRects.add(SelectionRect( + position: (rect as List)[4] as int, + bounds: Rect.fromLTWH(rect[0] as double, rect[1] as double, rect[2] as double, rect[3] as double), + )); + } + log.add(selectionRects); + } + return null; + }); + + final ScrollController scrollController = ScrollController(); + addTearDown(scrollController.dispose); + controller.text = 'Text1'; + + Future pumpEditableText({ double? width, double? height, TextAlign textAlign = TextAlign.start }) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: width, + height: height, + child: EditableText( + controller: controller, + textAlign: textAlign, + scrollController: scrollController, + maxLines: null, + focusNode: focusNode, + cursorWidth: 0, + style: Typography.material2018().black.titleMedium!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + ), + ), + ), + ), + ), + ); + } + + const List expectedRects = [ + SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)), + SelectionRect(position: 1, bounds: Rect.fromLTRB(14.0, 0.0, 28.0, 14.0)), + SelectionRect(position: 2, bounds: Rect.fromLTRB(28.0, 0.0, 42.0, 14.0)), + SelectionRect(position: 3, bounds: Rect.fromLTRB(42.0, 0.0, 56.0, 14.0)), + SelectionRect(position: 4, bounds: Rect.fromLTRB(56.0, 0.0, 70.0, 14.0)) + ]; + + await pumpEditableText(); + expect(log, isEmpty); + + await tester.showKeyboard(find.byType(EditableText)); + // First update. + expect(log.single, expectedRects); + log.clear(); + + await tester.pumpAndSettle(); + expect(log, isEmpty); + + focusNode.unfocus(); + await tester.pumpAndSettle(); + expect(log, isEmpty); + + focusNode.requestFocus(); + //await tester.showKeyboard(find.byType(EditableText)); + await tester.pumpAndSettle(); + // Should re-receive the same rects. + expect(log.single, expectedRects); + log.clear(); + + // On web, we should rely on the browser's implementation of Scribble, so we will not send selection rects. + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] + + testWidgets('Selection changes during Scribble interaction should have the scribble cause', (WidgetTester tester) async { + controller.text = 'Lorem ipsum dolor sit amet'; + late SelectionChangedCause selectionCause; + + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + onSelectionChanged: (TextSelection selection, SelectionChangedCause? cause) { + if (cause != null) { + selectionCause = cause; + } + }, + ), + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + + // A normal selection update from the framework has 'keyboard' as the cause. + tester.testTextInput.updateEditingValue(TextEditingValue( + text: controller.text, + selection: const TextSelection(baseOffset: 2, extentOffset: 3), + )); + await tester.pumpAndSettle(); + + expect(selectionCause, SelectionChangedCause.keyboard); + + // A selection update during a scribble interaction has 'scribble' as the cause. + await tester.testTextInput.startScribbleInteraction(); + tester.testTextInput.updateEditingValue(TextEditingValue( + text: controller.text, + selection: const TextSelection(baseOffset: 3, extentOffset: 4), + )); + await tester.pumpAndSettle(); + + expect(selectionCause, SelectionChangedCause.scribble); + + await tester.testTextInput.finishScribbleInteraction(); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); + + testWidgets('Requests focus and changes the selection when onScribbleFocus is called', (WidgetTester tester) async { + controller.text = 'Lorem ipsum dolor sit amet'; + late SelectionChangedCause selectionCause; + + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + onSelectionChanged: (TextSelection selection, SelectionChangedCause? cause) { + if (cause != null) { + selectionCause = cause; + } + }, + ), + ), + ); + + await tester.testTextInput.scribbleFocusElement(TextInput.scribbleClients.keys.first, Offset.zero); + + expect(focusNode.hasFocus, true); + expect(selectionCause, SelectionChangedCause.scribble); + + // On web, we should rely on the browser's implementation of Scribble, so the selection changed cause + // will never be SelectionChangedCause.scribble. + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] + + testWidgets('Declares itself for Scribble interaction if the bounds overlap the scribble rect and the widget is touchable', (WidgetTester tester) async { + controller.text = 'Lorem ipsum dolor sit amet'; + + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + final List elementEntry = [TextInput.scribbleClients.keys.first, 0.0, 0.0, 800.0, 600.0]; + + List> elements = await tester.testTextInput.scribbleRequestElementsInRect(const Rect.fromLTWH(0, 0, 1, 1)); + expect(elements.first, containsAll(elementEntry)); + + // Touch is outside the bounds of the widget. + elements = await tester.testTextInput.scribbleRequestElementsInRect(const Rect.fromLTWH(-1, -1, 1, 1)); + expect(elements.length, 0); + + // Widget is read only. + await tester.pumpWidget( + MaterialApp( + home: EditableText( + readOnly: true, + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + elements = await tester.testTextInput.scribbleRequestElementsInRect(const Rect.fromLTWH(0, 0, 1, 1)); + expect(elements.length, 0); + + // Widget is not touchable. + await tester.pumpWidget( + MaterialApp( + home: Stack(children: [ + EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + Positioned( + left: 0, + top: 0, + right: 0, + bottom: 0, + child: Container(color: Colors.black), + ), + ], + ), + ), + ); + + elements = await tester.testTextInput.scribbleRequestElementsInRect(const Rect.fromLTWH(0, 0, 1, 1)); + expect(elements.length, 0); + + // Widget has scribble disabled. + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + stylusHandwritingEnabled: false, + ), + ), + ); + + elements = await tester.testTextInput.scribbleRequestElementsInRect(const Rect.fromLTWH(0, 0, 1, 1)); + expect(elements.length, 0); + + + // On web, we should rely on the browser's implementation of Scribble, so the engine will + // never request the scribble elements. + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] + + testWidgets('single line Scribble fields can show a horizontal placeholder', (WidgetTester tester) async { + controller.text = 'Lorem ipsum dolor sit amet'; + + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + + tester.testTextInput.updateEditingValue(TextEditingValue( + text: controller.text, + selection: const TextSelection(baseOffset: 5, extentOffset: 5), + )); + await tester.pumpAndSettle(); + + await tester.testTextInput.scribbleInsertPlaceholder(); + await tester.pumpAndSettle(); + + TextSpan textSpan = findRenderEditable(tester).text! as TextSpan; + expect(textSpan.children!.length, 3); + expect((textSpan.children![0] as TextSpan).text, 'Lorem'); + expect(textSpan.children![1] is WidgetSpan, true); + expect((textSpan.children![2] as TextSpan).text, ' ipsum dolor sit amet'); + + await tester.testTextInput.scribbleRemovePlaceholder(); + await tester.pumpAndSettle(); + + textSpan = findRenderEditable(tester).text! as TextSpan; + expect(textSpan.children, null); + expect(textSpan.text, 'Lorem ipsum dolor sit amet'); + + // Widget has scribble disabled. + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + stylusHandwritingEnabled: false, + ), + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + + tester.testTextInput.updateEditingValue(TextEditingValue( + text: controller.text, + selection: const TextSelection(baseOffset: 5, extentOffset: 5), + )); + await tester.pumpAndSettle(); + + await tester.testTextInput.scribbleInsertPlaceholder(); + await tester.pumpAndSettle(); + + textSpan = findRenderEditable(tester).text! as TextSpan; + expect(textSpan.children, null); + expect(textSpan.text, 'Lorem ipsum dolor sit amet'); + + // On web, we should rely on the browser's implementation of Scribble, so the framework + // will not handle placeholders. + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] + + testWidgets('multiline Scribble fields can show a vertical placeholder', (WidgetTester tester) async { + controller.text = 'Lorem ipsum dolor sit amet'; + + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + maxLines: 2, + ), + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + + tester.testTextInput.updateEditingValue(TextEditingValue( + text: controller.text, + selection: const TextSelection(baseOffset: 5, extentOffset: 5), + )); + await tester.pumpAndSettle(); + + await tester.testTextInput.scribbleInsertPlaceholder(); + await tester.pumpAndSettle(); + + TextSpan textSpan = findRenderEditable(tester).text! as TextSpan; + expect(textSpan.children!.length, 4); + expect((textSpan.children![0] as TextSpan).text, 'Lorem'); + expect(textSpan.children![1] is WidgetSpan, true); + expect(textSpan.children![2] is WidgetSpan, true); + expect((textSpan.children![3] as TextSpan).text, ' ipsum dolor sit amet'); + + await tester.testTextInput.scribbleRemovePlaceholder(); + await tester.pumpAndSettle(); + + textSpan = findRenderEditable(tester).text! as TextSpan; + expect(textSpan.children, null); + expect(textSpan.text, 'Lorem ipsum dolor sit amet'); + + // Widget has scribble disabled. + await tester.pumpWidget( + MaterialApp( + home: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + maxLines: 2, + stylusHandwritingEnabled: false, + ), + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + + tester.testTextInput.updateEditingValue(TextEditingValue( + text: controller.text, + selection: const TextSelection(baseOffset: 5, extentOffset: 5), + )); + await tester.pumpAndSettle(); + + await tester.testTextInput.scribbleInsertPlaceholder(); + await tester.pumpAndSettle(); + + textSpan = findRenderEditable(tester).text! as TextSpan; + expect(textSpan.children, null); + expect(textSpan.text, 'Lorem ipsum dolor sit amet'); + + // On web, we should rely on the browser's implementation of Scribble, so the framework + // will not handle placeholders. + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] + + testWidgets('selection rects are sent when they change', (WidgetTester tester) async { + addTearDown(tester.view.reset); + // Ensure selection rects are sent on iPhone (using SE 3rd gen size) + tester.view.physicalSize = const Size(750.0, 1334.0); + + final List> log = >[]; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) { + if (methodCall.method == 'TextInput.setSelectionRects') { + final List args = methodCall.arguments as List; + final List selectionRects = []; + for (final dynamic rect in args) { + selectionRects.add(SelectionRect( + position: (rect as List)[4] as int, + bounds: Rect.fromLTWH(rect[0] as double, rect[1] as double, rect[2] as double, rect[3] as double), + )); + } + log.add(selectionRects); + } + return null; + }); + + final ScrollController scrollController = ScrollController(); + addTearDown(scrollController.dispose); + controller.text = 'Text1'; + + Future pumpEditableText({ double? width, double? height, TextAlign textAlign = TextAlign.start }) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: width, + height: height, + child: EditableText( + controller: controller, + textAlign: textAlign, + scrollController: scrollController, + maxLines: null, + focusNode: focusNode, + cursorWidth: 0, + style: Typography.material2018().black.titleMedium!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + ), + ), + ), + ), + ), + ); + } + + await pumpEditableText(); + expect(log, isEmpty); + + await tester.showKeyboard(find.byType(EditableText)); + // First update. + expect(log.single, const [ + SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)), + SelectionRect(position: 1, bounds: Rect.fromLTRB(14.0, 0.0, 28.0, 14.0)), + SelectionRect(position: 2, bounds: Rect.fromLTRB(28.0, 0.0, 42.0, 14.0)), + SelectionRect(position: 3, bounds: Rect.fromLTRB(42.0, 0.0, 56.0, 14.0)), + SelectionRect(position: 4, bounds: Rect.fromLTRB(56.0, 0.0, 70.0, 14.0)) + ]); + log.clear(); + + await tester.pumpAndSettle(); + expect(log, isEmpty); + + await pumpEditableText(); + expect(log, isEmpty); + + // Change the width such that each character occupies a line. + await pumpEditableText(width: 20); + expect(log.single, const [ + SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)), + SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)), + SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)), + SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)), + SelectionRect(position: 4, bounds: Rect.fromLTRB(0.0, 56.0, 14.0, 70.0)) + ]); + log.clear(); + + await tester.enterText(find.byType(EditableText), 'Text1👨‍👩‍👦'); + await tester.pump(); + expect(log.single, const [ + SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)), + SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)), + SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)), + SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)), + SelectionRect(position: 4, bounds: Rect.fromLTRB(0.0, 56.0, 14.0, 70.0)), + SelectionRect(position: 5, bounds: Rect.fromLTRB(0.0, 70.0, 42.0, 84.0)), + ]); + log.clear(); + + // The 4th line will be partially visible. + await pumpEditableText(width: 20, height: 45); + expect(log.single, const [ + SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)), + SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)), + SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)), + SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)), + ]); + log.clear(); + + await pumpEditableText(width: 20, height: 45, textAlign: TextAlign.right); + // This is 1px off from being completely right-aligned. The 1px width is + // reserved for caret. + expect(log.single, const [ + SelectionRect(position: 0, bounds: Rect.fromLTRB(5.0, 0.0, 19.0, 14.0)), + SelectionRect(position: 1, bounds: Rect.fromLTRB(5.0, 14.0, 19.0, 28.0)), + SelectionRect(position: 2, bounds: Rect.fromLTRB(5.0, 28.0, 19.0, 42.0)), + SelectionRect(position: 3, bounds: Rect.fromLTRB(5.0, 42.0, 19.0, 56.0)), + // These 2 lines will be out of bounds. + // SelectionRect(position: 4, bounds: Rect.fromLTRB(5.0, 56.0, 19.0, 70.0)), + // SelectionRect(position: 5, bounds: Rect.fromLTRB(-23.0, 70.0, 19.0, 84.0)), + ]); + log.clear(); + + expect(scrollController.offset, 0); + + // Scrolling also triggers update. + scrollController.jumpTo(14); + await tester.pumpAndSettle(); + expect(log.single, const [ + SelectionRect(position: 0, bounds: Rect.fromLTRB(5.0, -14.0, 19.0, 0.0)), + SelectionRect(position: 1, bounds: Rect.fromLTRB(5.0, 0.0, 19.0, 14.0)), + SelectionRect(position: 2, bounds: Rect.fromLTRB(5.0, 14.0, 19.0, 28.0)), + SelectionRect(position: 3, bounds: Rect.fromLTRB(5.0, 28.0, 19.0, 42.0)), + SelectionRect(position: 4, bounds: Rect.fromLTRB(5.0, 42.0, 19.0, 56.0)), + // This line is skipped because it's below the bottom edge of the render + // object. + // SelectionRect(position: 5, bounds: Rect.fromLTRB(5.0, 56.0, 47.0, 70.0)), + ]); + log.clear(); + + // On web, we should rely on the browser's implementation of Scribble, so we will not send selection rects. + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] + + testWidgets('selection rects are not sent if stylusHandwritingEnabled is false', (WidgetTester tester) async { + final List log = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) async { + log.add(methodCall); + return null; + }); + + controller.text = 'Text1'; + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + EditableText( + key: ValueKey(controller.text), + controller: controller, + focusNode: focusNode, + style: Typography.material2018().black.titleMedium!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + stylusHandwritingEnabled: false, + ), + ], + ), + ), + ), + ); + await tester.showKeyboard(find.byKey(ValueKey(controller.text))); + + // There should be a new platform message updating the selection rects. + expect(log.where((MethodCall m) => m.method == 'TextInput.setSelectionRects').length, 0); + + // On web, we should rely on the browser's implementation of Scribble, so we will not send selection rects. + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] + + testWidgets('selection rects sent even when character corners are outside of paintBounds', (WidgetTester tester) async { + final List> log = >[]; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) { + if (methodCall.method == 'TextInput.setSelectionRects') { + final List args = methodCall.arguments as List; + final List selectionRects = []; + for (final dynamic rect in args) { + selectionRects.add(SelectionRect( + position: (rect as List)[4] as int, + bounds: Rect.fromLTWH(rect[0] as double, rect[1] as double, rect[2] as double, rect[3] as double), + )); + } + log.add(selectionRects); + } + return null; + }); + + final ScrollController scrollController = ScrollController(); + addTearDown(scrollController.dispose); + controller.text = 'Text1'; + + final GlobalKey editableTextKey = GlobalKey(); + + Future pumpEditableText({ double? width, double? height, TextAlign textAlign = TextAlign.start }) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: width, + height: height, + child: EditableText( + controller: controller, + textAlign: textAlign, + scrollController: scrollController, + maxLines: null, + focusNode: focusNode, + cursorWidth: 0, + key: editableTextKey, + style: Typography.material2018().black.titleMedium!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + ), + ), + ), + ), + ), + ); + } + + // Set height to 1 pixel less than full height. + await pumpEditableText(height: 13); + expect(log, isEmpty); + + // Scroll so that the top of each character is above the top of the renderEditable + // and the bottom of each character is below the bottom of the renderEditable. + final ViewportOffset offset = ViewportOffset.fixed(0.5); + addTearDown(offset.dispose); + editableTextKey.currentState!.renderEditable.offset = offset; + + await tester.showKeyboard(find.byType(EditableText)); + // We should get all the rects. + expect(log.single, const [ + SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, -0.5, 14.0, 13.5)), + SelectionRect(position: 1, bounds: Rect.fromLTRB(14.0, -0.5, 28.0, 13.5)), + SelectionRect(position: 2, bounds: Rect.fromLTRB(28.0, -0.5, 42.0, 13.5)), + SelectionRect(position: 3, bounds: Rect.fromLTRB(42.0, -0.5, 56.0, 13.5)), + SelectionRect(position: 4, bounds: Rect.fromLTRB(56.0, -0.5, 70.0, 13.5)) + ]); + log.clear(); + + // On web, we should rely on the browser's implementation of Scribble, so we will not send selection rects. + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] +} diff --git a/packages/flutter/test/widgets/editable_text_scribe_test.dart b/packages/flutter/test/widgets/editable_text_scribe_test.dart new file mode 100644 index 0000000000..7b4adb99aa --- /dev/null +++ b/packages/flutter/test/widgets/editable_text_scribe_test.dart @@ -0,0 +1,229 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); + + const TextStyle textStyle = TextStyle(); + const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00); + final List calls = []; + bool isFeatureAvailableReturnValue = true; + late TextEditingController controller; + late FocusNode focusNode; + + setUp(() async { + calls.clear(); + isFeatureAvailableReturnValue = true; + binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.scribe, (MethodCall methodCall) { + calls.add(methodCall); + + return switch (methodCall.method) { + 'Scribe.isFeatureAvailable' => Future.value(isFeatureAvailableReturnValue), + 'Scribe.startStylusHandwriting' => Future.value(), + _=> throw FlutterError('Unexpected method call: ${methodCall.method}'), + }; + }); + + controller = TextEditingController( + text: 'Lorem ipsum dolor sit amet', + ); + focusNode = FocusNode(debugLabel: 'EditableText Node'); + }); + + tearDown(() { + controller.dispose(); + focusNode.dispose(); + }); + + Future pumpTextSelectionGestureDetectorBuilder( + WidgetTester tester, { + bool forcePressEnabled = true, + bool selectionEnabled = true, + }) async { + final GlobalKey editableTextKey = GlobalKey(); + final FakeTextSelectionGestureDetectorBuilderDelegate delegate = FakeTextSelectionGestureDetectorBuilderDelegate( + editableTextKey: editableTextKey, + forcePressEnabled: forcePressEnabled, + selectionEnabled: selectionEnabled, + ); + + final TextSelectionGestureDetectorBuilder provider = + TextSelectionGestureDetectorBuilder(delegate: delegate); + + await tester.pumpWidget( + MaterialApp( + home: provider.buildGestureDetector( + behavior: HitTestBehavior.translucent, + child: EditableText( + key: editableTextKey, + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + showSelectionHandles: true, + ), + ), + ), + ); + } + + testWidgets('when Scribe is available, starts handwriting on tap down', (WidgetTester tester) async { + isFeatureAvailableReturnValue = true; + + await pumpTextSelectionGestureDetectorBuilder(tester); + + expect(focusNode.hasFocus, isFalse); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.stylus, pointer: 1); + await gesture.down(tester.getCenter(find.byType(EditableText))); + + // Wait for the gesture arena. + await tester.pumpAndSettle(); + + expect(calls, hasLength(2)); + expect(calls.first.method, 'Scribe.isFeatureAvailable'); + expect(calls[1].method, 'Scribe.startStylusHandwriting'); + + await gesture.up(); + expect(focusNode.hasFocus, isTrue); + + // On web, let the browser handle handwriting input. + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.android })); // [intended] + + testWidgets('when Scribe is unavailable, does not start handwriting on tap down', (WidgetTester tester) async { + isFeatureAvailableReturnValue = false; + + await pumpTextSelectionGestureDetectorBuilder(tester); + + expect(focusNode.hasFocus, isFalse); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.stylus, pointer: 1); + await gesture.down(tester.getCenter(find.byType(EditableText))); + + // Wait for the gesture arena. + await tester.pumpAndSettle(); + + expect(calls, hasLength(1)); + expect(calls.first.method, 'Scribe.isFeatureAvailable'); + + await gesture.up(); + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.android })); // [intended] + + testWidgets('tap down event must be from a stylus in order to start handwriting', (WidgetTester tester) async { + isFeatureAvailableReturnValue = true; + + await pumpTextSelectionGestureDetectorBuilder(tester); + + expect(focusNode.hasFocus, isFalse); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1); + await gesture.down(tester.getCenter(find.byType(EditableText))); + + expect(calls, isEmpty); + + await gesture.up(); + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.android })); // [intended] + + testWidgets('tap down event on a collapsed selection handle is handled by the handle and does not start handwriting', (WidgetTester tester) async { + isFeatureAvailableReturnValue = true; + + await pumpTextSelectionGestureDetectorBuilder(tester); + + expect(focusNode.hasFocus, isFalse); + expect(find.byType(CompositedTransformFollower), findsNothing); + + // Tap to show the collapsed selection handle. + final Offset fieldOffset = tester.getTopLeft(find.byType(EditableText)); + await tester.tapAt(fieldOffset + const Offset(20.0, 10.0)); + await tester.pump(); + expect(find.byType(CompositedTransformFollower), findsOneWidget); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.stylus, pointer: 1); + final Finder handleFinder = find.descendant( + of: find.byType(CompositedTransformFollower), + matching: find.byType(CustomPaint), + ); + await gesture.down(tester.getCenter(handleFinder)); + + // Wait for the gesture arena. + await tester.pumpAndSettle(); + + expect(calls, hasLength(0)); + expect(controller.selection.isCollapsed, isTrue); + final int cursorStart = controller.selection.start; + + // Dragging on top of the handle moves it like normal. + await gesture.moveBy(const Offset(20.0, 0.0)); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.start, greaterThan(cursorStart)); + expect(calls, hasLength(0)); + + await gesture.up(); + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.android })); // [intended] + + testWidgets('tap down event on the end selection handle is handled by the handle and does not start handwriting', (WidgetTester tester) async { + isFeatureAvailableReturnValue = true; + + await pumpTextSelectionGestureDetectorBuilder(tester); + + expect(focusNode.hasFocus, isFalse); + expect(find.byType(CompositedTransformFollower), findsNothing); + + // Long press to select the first word and show both handles. + final Offset fieldOffset = tester.getTopLeft(find.byType(EditableText)); + await tester.longPressAt(fieldOffset + const Offset(20.0, 10.0)); + await tester.pump(); + expect(find.byType(CompositedTransformFollower), findsNWidgets(2)); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.stylus, pointer: 1); + final Finder endHandleFinder = find.descendant( + of: find.byType(CompositedTransformFollower).at(1), + matching: find.byType(CustomPaint), + ); + await gesture.down(tester.getCenter(endHandleFinder)); + + // Wait for the gesture arena. + await tester.pumpAndSettle(); + + expect(calls, isEmpty); + expect(controller.selection.isCollapsed, isFalse); + final TextSelection selectionStart = controller.selection; + + // Dragging on top of the handle extends selection like normal. + await gesture.moveBy(const Offset(20.0, 0.0)); + expect(controller.selection.isCollapsed, isFalse); + expect(controller.selection.start, equals(selectionStart.start)); + expect(controller.selection.end, greaterThan(selectionStart.end)); + expect(calls, isEmpty); + + await gesture.up(); + }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.android })); // [intended] +} + +class FakeTextSelectionGestureDetectorBuilderDelegate implements TextSelectionGestureDetectorBuilderDelegate { + FakeTextSelectionGestureDetectorBuilderDelegate({ + required this.editableTextKey, + required this.forcePressEnabled, + required this.selectionEnabled, + }); + + @override + final GlobalKey editableTextKey; + + @override + final bool forcePressEnabled; + + @override + final bool selectionEnabled; +} diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 84e4d1923b..9a4dcc467b 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -1094,88 +1094,6 @@ void main() { expect(focusNode.hasFocus, isFalse); }); - testWidgets('selection rects re-sent when refocused', (WidgetTester tester) async { - final List> log = >[]; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) async { - if (methodCall.method == 'TextInput.setSelectionRects') { - final List args = methodCall.arguments as List; - final List selectionRects = []; - for (final dynamic rect in args) { - selectionRects.add(SelectionRect( - position: (rect as List)[4] as int, - bounds: Rect.fromLTWH(rect[0] as double, rect[1] as double, rect[2] as double, rect[3] as double), - )); - } - log.add(selectionRects); - } - return null; - }); - - final ScrollController scrollController = ScrollController(); - addTearDown(scrollController.dispose); - controller.text = 'Text1'; - - Future pumpEditableText({ double? width, double? height, TextAlign textAlign = TextAlign.start }) async { - await tester.pumpWidget( - MediaQuery( - data: const MediaQueryData(), - child: Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: SizedBox( - width: width, - height: height, - child: EditableText( - controller: controller, - textAlign: textAlign, - scrollController: scrollController, - maxLines: null, - focusNode: focusNode, - cursorWidth: 0, - style: Typography.material2018().black.titleMedium!, - cursorColor: Colors.blue, - backgroundCursorColor: Colors.grey, - ), - ), - ), - ), - ), - ); - } - - const List expectedRects = [ - SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)), - SelectionRect(position: 1, bounds: Rect.fromLTRB(14.0, 0.0, 28.0, 14.0)), - SelectionRect(position: 2, bounds: Rect.fromLTRB(28.0, 0.0, 42.0, 14.0)), - SelectionRect(position: 3, bounds: Rect.fromLTRB(42.0, 0.0, 56.0, 14.0)), - SelectionRect(position: 4, bounds: Rect.fromLTRB(56.0, 0.0, 70.0, 14.0)) - ]; - - await pumpEditableText(); - expect(log, isEmpty); - - await tester.showKeyboard(find.byType(EditableText)); - // First update. - expect(log.single, expectedRects); - log.clear(); - - await tester.pumpAndSettle(); - expect(log, isEmpty); - - focusNode.unfocus(); - await tester.pumpAndSettle(); - expect(log, isEmpty); - - focusNode.requestFocus(); - //await tester.showKeyboard(find.byType(EditableText)); - await tester.pumpAndSettle(); - // Should re-receive the same rects. - expect(log.single, expectedRects); - log.clear(); - - // On web, we should rely on the browser's implementation of Scribble, so we will not send selection rects. - }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] - testWidgets('EditableText does not derive selection color from DefaultSelectionStyle', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/103341. const TextEditingValue value = TextEditingValue( @@ -3027,327 +2945,6 @@ void main() { } }); - testWidgets('Selection changes during Scribble interaction should have the scribble cause', (WidgetTester tester) async { - controller.text = 'Lorem ipsum dolor sit amet'; - late SelectionChangedCause selectionCause; - - await tester.pumpWidget( - MaterialApp( - home: EditableText( - controller: controller, - backgroundCursorColor: Colors.grey, - focusNode: focusNode, - style: textStyle, - cursorColor: cursorColor, - selectionControls: materialTextSelectionControls, - onSelectionChanged: (TextSelection selection, SelectionChangedCause? cause) { - if (cause != null) { - selectionCause = cause; - } - }, - ), - ), - ); - - await tester.showKeyboard(find.byType(EditableText)); - - // A normal selection update from the framework has 'keyboard' as the cause. - tester.testTextInput.updateEditingValue(TextEditingValue( - text: controller.text, - selection: const TextSelection(baseOffset: 2, extentOffset: 3), - )); - await tester.pumpAndSettle(); - - expect(selectionCause, SelectionChangedCause.keyboard); - - // A selection update during a scribble interaction has 'scribble' as the cause. - await tester.testTextInput.startScribbleInteraction(); - tester.testTextInput.updateEditingValue(TextEditingValue( - text: controller.text, - selection: const TextSelection(baseOffset: 3, extentOffset: 4), - )); - await tester.pumpAndSettle(); - - expect(selectionCause, SelectionChangedCause.scribble); - - await tester.testTextInput.finishScribbleInteraction(); - }, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); - - testWidgets('Requests focus and changes the selection when onScribbleFocus is called', (WidgetTester tester) async { - controller.text = 'Lorem ipsum dolor sit amet'; - late SelectionChangedCause selectionCause; - - await tester.pumpWidget( - MaterialApp( - home: EditableText( - controller: controller, - backgroundCursorColor: Colors.grey, - focusNode: focusNode, - style: textStyle, - cursorColor: cursorColor, - selectionControls: materialTextSelectionControls, - onSelectionChanged: (TextSelection selection, SelectionChangedCause? cause) { - if (cause != null) { - selectionCause = cause; - } - }, - ), - ), - ); - - await tester.testTextInput.scribbleFocusElement(TextInput.scribbleClients.keys.first, Offset.zero); - - expect(focusNode.hasFocus, true); - expect(selectionCause, SelectionChangedCause.scribble); - - // On web, we should rely on the browser's implementation of Scribble, so the selection changed cause - // will never be SelectionChangedCause.scribble. - }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] - - testWidgets('Declares itself for Scribble interaction if the bounds overlap the scribble rect and the widget is touchable', (WidgetTester tester) async { - controller.text = 'Lorem ipsum dolor sit amet'; - - await tester.pumpWidget( - MaterialApp( - home: EditableText( - controller: controller, - backgroundCursorColor: Colors.grey, - focusNode: focusNode, - style: textStyle, - cursorColor: cursorColor, - selectionControls: materialTextSelectionControls, - ), - ), - ); - - final List elementEntry = [TextInput.scribbleClients.keys.first, 0.0, 0.0, 800.0, 600.0]; - - List> elements = await tester.testTextInput.scribbleRequestElementsInRect(const Rect.fromLTWH(0, 0, 1, 1)); - expect(elements.first, containsAll(elementEntry)); - - // Touch is outside the bounds of the widget. - elements = await tester.testTextInput.scribbleRequestElementsInRect(const Rect.fromLTWH(-1, -1, 1, 1)); - expect(elements.length, 0); - - // Widget is read only. - await tester.pumpWidget( - MaterialApp( - home: EditableText( - readOnly: true, - controller: controller, - backgroundCursorColor: Colors.grey, - focusNode: focusNode, - style: textStyle, - cursorColor: cursorColor, - selectionControls: materialTextSelectionControls, - ), - ), - ); - - elements = await tester.testTextInput.scribbleRequestElementsInRect(const Rect.fromLTWH(0, 0, 1, 1)); - expect(elements.length, 0); - - // Widget is not touchable. - await tester.pumpWidget( - MaterialApp( - home: Stack(children: [ - EditableText( - controller: controller, - backgroundCursorColor: Colors.grey, - focusNode: focusNode, - style: textStyle, - cursorColor: cursorColor, - selectionControls: materialTextSelectionControls, - ), - Positioned( - left: 0, - top: 0, - right: 0, - bottom: 0, - child: Container(color: Colors.black), - ), - ], - ), - ), - ); - - elements = await tester.testTextInput.scribbleRequestElementsInRect(const Rect.fromLTWH(0, 0, 1, 1)); - expect(elements.length, 0); - - // Widget has scribble disabled. - await tester.pumpWidget( - MaterialApp( - home: EditableText( - controller: controller, - backgroundCursorColor: Colors.grey, - focusNode: focusNode, - style: textStyle, - cursorColor: cursorColor, - selectionControls: materialTextSelectionControls, - scribbleEnabled: false, - ), - ), - ); - - elements = await tester.testTextInput.scribbleRequestElementsInRect(const Rect.fromLTWH(0, 0, 1, 1)); - expect(elements.length, 0); - - - // On web, we should rely on the browser's implementation of Scribble, so the engine will - // never request the scribble elements. - }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] - - testWidgets('single line Scribble fields can show a horizontal placeholder', (WidgetTester tester) async { - controller.text = 'Lorem ipsum dolor sit amet'; - - await tester.pumpWidget( - MaterialApp( - home: EditableText( - controller: controller, - backgroundCursorColor: Colors.grey, - focusNode: focusNode, - style: textStyle, - cursorColor: cursorColor, - selectionControls: materialTextSelectionControls, - ), - ), - ); - - await tester.showKeyboard(find.byType(EditableText)); - - tester.testTextInput.updateEditingValue(TextEditingValue( - text: controller.text, - selection: const TextSelection(baseOffset: 5, extentOffset: 5), - )); - await tester.pumpAndSettle(); - - await tester.testTextInput.scribbleInsertPlaceholder(); - await tester.pumpAndSettle(); - - TextSpan textSpan = findRenderEditable(tester).text! as TextSpan; - expect(textSpan.children!.length, 3); - expect((textSpan.children![0] as TextSpan).text, 'Lorem'); - expect(textSpan.children![1] is WidgetSpan, true); - expect((textSpan.children![2] as TextSpan).text, ' ipsum dolor sit amet'); - - await tester.testTextInput.scribbleRemovePlaceholder(); - await tester.pumpAndSettle(); - - textSpan = findRenderEditable(tester).text! as TextSpan; - expect(textSpan.children, null); - expect(textSpan.text, 'Lorem ipsum dolor sit amet'); - - // Widget has scribble disabled. - await tester.pumpWidget( - MaterialApp( - home: EditableText( - controller: controller, - backgroundCursorColor: Colors.grey, - focusNode: focusNode, - style: textStyle, - cursorColor: cursorColor, - selectionControls: materialTextSelectionControls, - scribbleEnabled: false, - ), - ), - ); - - await tester.showKeyboard(find.byType(EditableText)); - - tester.testTextInput.updateEditingValue(TextEditingValue( - text: controller.text, - selection: const TextSelection(baseOffset: 5, extentOffset: 5), - )); - await tester.pumpAndSettle(); - - await tester.testTextInput.scribbleInsertPlaceholder(); - await tester.pumpAndSettle(); - - textSpan = findRenderEditable(tester).text! as TextSpan; - expect(textSpan.children, null); - expect(textSpan.text, 'Lorem ipsum dolor sit amet'); - - // On web, we should rely on the browser's implementation of Scribble, so the framework - // will not handle placeholders. - }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] - - testWidgets('multiline Scribble fields can show a vertical placeholder', (WidgetTester tester) async { - controller.text = 'Lorem ipsum dolor sit amet'; - - await tester.pumpWidget( - MaterialApp( - home: EditableText( - controller: controller, - backgroundCursorColor: Colors.grey, - focusNode: focusNode, - style: textStyle, - cursorColor: cursorColor, - selectionControls: materialTextSelectionControls, - maxLines: 2, - ), - ), - ); - - await tester.showKeyboard(find.byType(EditableText)); - - tester.testTextInput.updateEditingValue(TextEditingValue( - text: controller.text, - selection: const TextSelection(baseOffset: 5, extentOffset: 5), - )); - await tester.pumpAndSettle(); - - await tester.testTextInput.scribbleInsertPlaceholder(); - await tester.pumpAndSettle(); - - TextSpan textSpan = findRenderEditable(tester).text! as TextSpan; - expect(textSpan.children!.length, 4); - expect((textSpan.children![0] as TextSpan).text, 'Lorem'); - expect(textSpan.children![1] is WidgetSpan, true); - expect(textSpan.children![2] is WidgetSpan, true); - expect((textSpan.children![3] as TextSpan).text, ' ipsum dolor sit amet'); - - await tester.testTextInput.scribbleRemovePlaceholder(); - await tester.pumpAndSettle(); - - textSpan = findRenderEditable(tester).text! as TextSpan; - expect(textSpan.children, null); - expect(textSpan.text, 'Lorem ipsum dolor sit amet'); - - // Widget has scribble disabled. - await tester.pumpWidget( - MaterialApp( - home: EditableText( - controller: controller, - backgroundCursorColor: Colors.grey, - focusNode: focusNode, - style: textStyle, - cursorColor: cursorColor, - selectionControls: materialTextSelectionControls, - maxLines: 2, - scribbleEnabled: false, - ), - ), - ); - - await tester.showKeyboard(find.byType(EditableText)); - - tester.testTextInput.updateEditingValue(TextEditingValue( - text: controller.text, - selection: const TextSelection(baseOffset: 5, extentOffset: 5), - )); - await tester.pumpAndSettle(); - - await tester.testTextInput.scribbleInsertPlaceholder(); - await tester.pumpAndSettle(); - - textSpan = findRenderEditable(tester).text! as TextSpan; - expect(textSpan.children, null); - expect(textSpan.text, 'Lorem ipsum dolor sit amet'); - - // On web, we should rely on the browser's implementation of Scribble, so the framework - // will not handle placeholders. - }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] - testWidgets('Sends "updateConfig" when read-only flag is flipped', (WidgetTester tester) async { bool readOnly = true; late StateSetter setState; @@ -5593,261 +5190,6 @@ void main() { ); }); - testWidgets('selection rects are sent when they change', (WidgetTester tester) async { - addTearDown(tester.view.reset); - // Ensure selection rects are sent on iPhone (using SE 3rd gen size) - tester.view.physicalSize = const Size(750.0, 1334.0); - - final List> log = >[]; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) { - if (methodCall.method == 'TextInput.setSelectionRects') { - final List args = methodCall.arguments as List; - final List selectionRects = []; - for (final dynamic rect in args) { - selectionRects.add(SelectionRect( - position: (rect as List)[4] as int, - bounds: Rect.fromLTWH(rect[0] as double, rect[1] as double, rect[2] as double, rect[3] as double), - )); - } - log.add(selectionRects); - } - return null; - }); - - final ScrollController scrollController = ScrollController(); - addTearDown(scrollController.dispose); - controller.text = 'Text1'; - - Future pumpEditableText({ double? width, double? height, TextAlign textAlign = TextAlign.start }) async { - await tester.pumpWidget( - MediaQuery( - data: const MediaQueryData(), - child: Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: SizedBox( - width: width, - height: height, - child: EditableText( - controller: controller, - textAlign: textAlign, - scrollController: scrollController, - maxLines: null, - focusNode: focusNode, - cursorWidth: 0, - style: Typography.material2018().black.titleMedium!, - cursorColor: Colors.blue, - backgroundCursorColor: Colors.grey, - ), - ), - ), - ), - ), - ); - } - - await pumpEditableText(); - expect(log, isEmpty); - - await tester.showKeyboard(find.byType(EditableText)); - // First update. - expect(log.single, const [ - SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)), - SelectionRect(position: 1, bounds: Rect.fromLTRB(14.0, 0.0, 28.0, 14.0)), - SelectionRect(position: 2, bounds: Rect.fromLTRB(28.0, 0.0, 42.0, 14.0)), - SelectionRect(position: 3, bounds: Rect.fromLTRB(42.0, 0.0, 56.0, 14.0)), - SelectionRect(position: 4, bounds: Rect.fromLTRB(56.0, 0.0, 70.0, 14.0)) - ]); - log.clear(); - - await tester.pumpAndSettle(); - expect(log, isEmpty); - - await pumpEditableText(); - expect(log, isEmpty); - - // Change the width such that each character occupies a line. - await pumpEditableText(width: 20); - expect(log.single, const [ - SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)), - SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)), - SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)), - SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)), - SelectionRect(position: 4, bounds: Rect.fromLTRB(0.0, 56.0, 14.0, 70.0)) - ]); - log.clear(); - - await tester.enterText(find.byType(EditableText), 'Text1👨‍👩‍👦'); - await tester.pump(); - expect(log.single, const [ - SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)), - SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)), - SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)), - SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)), - SelectionRect(position: 4, bounds: Rect.fromLTRB(0.0, 56.0, 14.0, 70.0)), - SelectionRect(position: 5, bounds: Rect.fromLTRB(0.0, 70.0, 42.0, 84.0)), - ]); - log.clear(); - - // The 4th line will be partially visible. - await pumpEditableText(width: 20, height: 45); - expect(log.single, const [ - SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, 0.0, 14.0, 14.0)), - SelectionRect(position: 1, bounds: Rect.fromLTRB(0.0, 14.0, 14.0, 28.0)), - SelectionRect(position: 2, bounds: Rect.fromLTRB(0.0, 28.0, 14.0, 42.0)), - SelectionRect(position: 3, bounds: Rect.fromLTRB(0.0, 42.0, 14.0, 56.0)), - ]); - log.clear(); - - await pumpEditableText(width: 20, height: 45, textAlign: TextAlign.right); - // This is 1px off from being completely right-aligned. The 1px width is - // reserved for caret. - expect(log.single, const [ - SelectionRect(position: 0, bounds: Rect.fromLTRB(5.0, 0.0, 19.0, 14.0)), - SelectionRect(position: 1, bounds: Rect.fromLTRB(5.0, 14.0, 19.0, 28.0)), - SelectionRect(position: 2, bounds: Rect.fromLTRB(5.0, 28.0, 19.0, 42.0)), - SelectionRect(position: 3, bounds: Rect.fromLTRB(5.0, 42.0, 19.0, 56.0)), - // These 2 lines will be out of bounds. - // SelectionRect(position: 4, bounds: Rect.fromLTRB(5.0, 56.0, 19.0, 70.0)), - // SelectionRect(position: 5, bounds: Rect.fromLTRB(-23.0, 70.0, 19.0, 84.0)), - ]); - log.clear(); - - expect(scrollController.offset, 0); - - // Scrolling also triggers update. - scrollController.jumpTo(14); - await tester.pumpAndSettle(); - expect(log.single, const [ - SelectionRect(position: 0, bounds: Rect.fromLTRB(5.0, -14.0, 19.0, 0.0)), - SelectionRect(position: 1, bounds: Rect.fromLTRB(5.0, 0.0, 19.0, 14.0)), - SelectionRect(position: 2, bounds: Rect.fromLTRB(5.0, 14.0, 19.0, 28.0)), - SelectionRect(position: 3, bounds: Rect.fromLTRB(5.0, 28.0, 19.0, 42.0)), - SelectionRect(position: 4, bounds: Rect.fromLTRB(5.0, 42.0, 19.0, 56.0)), - // This line is skipped because it's below the bottom edge of the render - // object. - // SelectionRect(position: 5, bounds: Rect.fromLTRB(5.0, 56.0, 47.0, 70.0)), - ]); - log.clear(); - - // On web, we should rely on the browser's implementation of Scribble, so we will not send selection rects. - }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] - - testWidgets('selection rects are not sent if scribbleEnabled is false', (WidgetTester tester) async { - final List log = []; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) async { - log.add(methodCall); - return null; - }); - - controller.text = 'Text1'; - - await tester.pumpWidget( - MediaQuery( - data: const MediaQueryData(), - child: Directionality( - textDirection: TextDirection.ltr, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - EditableText( - key: ValueKey(controller.text), - controller: controller, - focusNode: focusNode, - style: Typography.material2018().black.titleMedium!, - cursorColor: Colors.blue, - backgroundCursorColor: Colors.grey, - scribbleEnabled: false, - ), - ], - ), - ), - ), - ); - await tester.showKeyboard(find.byKey(ValueKey(controller.text))); - - // There should be a new platform message updating the selection rects. - expect(log.where((MethodCall m) => m.method == 'TextInput.setSelectionRects').length, 0); - - // On web, we should rely on the browser's implementation of Scribble, so we will not send selection rects. - }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] - - testWidgets('selection rects sent even when character corners are outside of paintBounds', (WidgetTester tester) async { - final List> log = >[]; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) { - if (methodCall.method == 'TextInput.setSelectionRects') { - final List args = methodCall.arguments as List; - final List selectionRects = []; - for (final dynamic rect in args) { - selectionRects.add(SelectionRect( - position: (rect as List)[4] as int, - bounds: Rect.fromLTWH(rect[0] as double, rect[1] as double, rect[2] as double, rect[3] as double), - )); - } - log.add(selectionRects); - } - return null; - }); - - final ScrollController scrollController = ScrollController(); - addTearDown(scrollController.dispose); - controller.text = 'Text1'; - - final GlobalKey editableTextKey = GlobalKey(); - - Future pumpEditableText({ double? width, double? height, TextAlign textAlign = TextAlign.start }) async { - await tester.pumpWidget( - MediaQuery( - data: const MediaQueryData(), - child: Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: SizedBox( - width: width, - height: height, - child: EditableText( - controller: controller, - textAlign: textAlign, - scrollController: scrollController, - maxLines: null, - focusNode: focusNode, - cursorWidth: 0, - key: editableTextKey, - style: Typography.material2018().black.titleMedium!, - cursorColor: Colors.blue, - backgroundCursorColor: Colors.grey, - ), - ), - ), - ), - ), - ); - } - - // Set height to 1 pixel less than full height. - await pumpEditableText(height: 13); - expect(log, isEmpty); - - // Scroll so that the top of each character is above the top of the renderEditable - // and the bottom of each character is below the bottom of the renderEditable. - final ViewportOffset offset = ViewportOffset.fixed(0.5); - addTearDown(offset.dispose); - editableTextKey.currentState!.renderEditable.offset = offset; - - await tester.showKeyboard(find.byType(EditableText)); - // We should get all the rects. - expect(log.single, const [ - SelectionRect(position: 0, bounds: Rect.fromLTRB(0.0, -0.5, 14.0, 13.5)), - SelectionRect(position: 1, bounds: Rect.fromLTRB(14.0, -0.5, 28.0, 13.5)), - SelectionRect(position: 2, bounds: Rect.fromLTRB(28.0, -0.5, 42.0, 13.5)), - SelectionRect(position: 3, bounds: Rect.fromLTRB(42.0, -0.5, 56.0, 13.5)), - SelectionRect(position: 4, bounds: Rect.fromLTRB(56.0, -0.5, 70.0, 13.5)) - ]); - log.clear(); - - // On web, we should rely on the browser's implementation of Scribble, so we will not send selection rects. - }, skip: kIsWeb, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); // [intended] - testWidgets('text styling info is sent on show keyboard', (WidgetTester tester) async { final List log = []; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall methodCall) async {