From 3824ee19efcbc6c19010cbabb543bc125eddfc2a Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Thu, 23 Jun 2022 15:09:42 -0700 Subject: [PATCH 01/14] Squashed scribble mixin --- packages/flutter/lib/services.dart | 1 + .../flutter/lib/src/services/binding.dart | 3 +- .../flutter/lib/src/services/scribble.dart | 228 +++++++++++++++++ .../lib/src/services/system_channels.dart | 32 +++ .../flutter/lib/src/services/text_input.dart | 157 ------------ .../lib/src/widgets/editable_text.dart | 166 +++++++----- .../flutter/test/services/autofill_test.dart | 17 -- .../test/services/delta_text_input_test.dart | 16 -- .../flutter/test/services/scribble_test.dart | 241 ++++++++++++++++++ .../test/services/text_input_test.dart | 158 ------------ .../test/services/text_input_utils.dart | 17 +- .../test/widgets/editable_text_test.dart | 11 +- 12 files changed, 632 insertions(+), 415 deletions(-) create mode 100644 packages/flutter/lib/src/services/scribble.dart create mode 100644 packages/flutter/test/services/scribble_test.dart diff --git a/packages/flutter/lib/services.dart b/packages/flutter/lib/services.dart index bcf7988a58084..9747618cb0523 100644 --- a/packages/flutter/lib/services.dart +++ b/packages/flutter/lib/services.dart @@ -37,6 +37,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/scribble.dart'; export 'src/services/system_channels.dart'; export 'src/services/system_chrome.dart'; export 'src/services/system_navigator.dart'; diff --git a/packages/flutter/lib/src/services/binding.dart b/packages/flutter/lib/src/services/binding.dart index 9c04cb2c0da6b..7bfb769d2a580 100644 --- a/packages/flutter/lib/src/services/binding.dart +++ b/packages/flutter/lib/src/services/binding.dart @@ -16,6 +16,7 @@ import 'hardware_keyboard.dart'; import 'message_codec.dart'; import 'raw_keyboard.dart'; import 'restoration.dart'; +import 'scribble.dart'; import 'system_channels.dart'; import 'text_input.dart'; @@ -38,6 +39,7 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { SystemChannels.lifecycle.setMessageHandler(_handleLifecycleMessage); SystemChannels.platform.setMethodCallHandler(_handlePlatformMessage); TextInput.ensureInitialized(); + Scribble.ensureInitialized(); readInitialLifecycleStateFromNativeWindow(); } @@ -315,7 +317,6 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { void setSystemUiChangeCallback(SystemUiChangeCallback? callback) { _systemUiChangeCallback = callback; } - } /// Signature for listening to changes in the [SystemUiMode]. diff --git a/packages/flutter/lib/src/services/scribble.dart b/packages/flutter/lib/src/services/scribble.dart new file mode 100644 index 0000000000000..0f80028738b14 --- /dev/null +++ b/packages/flutter/lib/src/services/scribble.dart @@ -0,0 +1,228 @@ +// 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'; + +import 'package:flutter/foundation.dart'; + +import 'message_codec.dart'; +import 'platform_channel.dart'; +import 'system_channels.dart'; + +/// An interface into system-level handwriting text input. +/// +/// This is typically used by implemeting the methods in [ScribbleClient] in a +/// class, usually a [State], and setting an instance of it to [client]. The +/// relevant methods on [ScribbleClient] will be called in response to method +/// channel calls on [SystemChannels.scribble]. +/// +/// Currently, handwriting input is supported in the iOS embedder with the Apple +/// Pencil. +/// +/// [EditableText] uses this class via [ScribbleClient] to automatically support +/// handwriting input when [EditableText.scribbleEnabled] is set to true. +/// +/// See also: +/// +/// * [SystemChannels.scribble], which is the [MethodChannel] used by this +/// class, and which has a list of the methods that this class handles. +class Scribble { + Scribble._() { + _channel = SystemChannels.scribble; + _channel.setMethodCallHandler(_handleScribbleInvocation); + } + + /// Ensure that a [Scribble] instance has been set up so that the platform + /// can handle messages on the scribble method channel. + static void ensureInitialized() { + _instance; // ignore: unnecessary_statements + } + + /// Set the [MethodChannel] used to communicate with the system's text input + /// control. + /// + /// This is only meant for testing within the Flutter SDK. Changing this + /// will break the ability to do handwriting input. This has no effect if + /// asserts are disabled. + @visibleForTesting + static void setChannel(MethodChannel newChannel) { + assert(() { + _instance._channel = newChannel..setMethodCallHandler(_instance._handleScribbleInvocation); + return true; + }()); + } + + static final Scribble _instance = Scribble._(); + + /// Set the given [ScribbleClient] as the single active client. + /// + /// This is usually based on the [ScribbleClient] receiving focus. + static set client(ScribbleClient? client) { + _instance._client = client; + } + + /// Return the [ScribbleClient] that was most recently given focus. + static ScribbleClient? get client => _instance._client; + + ScribbleClient? _client; + + late MethodChannel _channel; + + final Map _scribbleClients = {}; + bool _scribbleInProgress = false; + + /// Used for testing within the Flutter SDK to get the currently registered [ScribbleClient] list. + @visibleForTesting + static Map get scribbleClients => Scribble._instance._scribbleClients; + + /// Returns true if a scribble interaction is currently happening. + static bool get scribbleInProgress => _instance._scribbleInProgress; + + Future _handleScribbleInvocation(MethodCall methodCall) async { + final String method = methodCall.method; + if (method == 'Scribble.focusElement') { + final List args = methodCall.arguments as List; + _scribbleClients[args[0]]?.onScribbleFocus(Offset((args[1] as num).toDouble(), (args[2] as num).toDouble())); + return; + } else if (method == 'Scribble.requestElementsInRect') { + final List args = (methodCall.arguments as List).cast().map((num value) => value.toDouble()).toList(); + return _scribbleClients.keys.where((String elementIdentifier) { + final Rect rect = Rect.fromLTWH(args[0], args[1], args[2], args[3]); + if (!(_scribbleClients[elementIdentifier]?.isInScribbleRect(rect) ?? false)) { + return false; + } + final Rect bounds = _scribbleClients[elementIdentifier]?.bounds ?? Rect.zero; + return !(bounds == Rect.zero || bounds.hasNaN || bounds.isInfinite); + }).map((String elementIdentifier) { + final Rect bounds = _scribbleClients[elementIdentifier]!.bounds; + return [elementIdentifier, ...[bounds.left, bounds.top, bounds.width, bounds.height]]; + }).toList(); + } else if (method == 'Scribble.scribbleInteractionBegan') { + _scribbleInProgress = true; + return; + } else if (method == 'Scribble.scribbleInteractionFinished') { + _scribbleInProgress = false; + return; + } + + final List args = methodCall.arguments as List; + switch (method) { + case 'Scribble.showToolbar': + _client!.showToolbar(); + break; + case 'Scribble.insertTextPlaceholder': + _client!.insertTextPlaceholder(Size((args[1] as num).toDouble(), (args[2] as num).toDouble())); + break; + case 'Scribble.removeTextPlaceholder': + _client!.removeTextPlaceholder(); + break; + default: + throw MissingPluginException(); + } + } + + /// Registers a [ScribbleClient] with [elementIdentifier] that can be focused + /// by the engine. + /// + /// For example, the registered [ScribbleClient] list is used to respond to + /// UIIndirectScribbleInteraction on an iPad. + static void registerScribbleElement(String elementIdentifier, ScribbleClient scribbleClient) { + _instance._scribbleClients[elementIdentifier] = scribbleClient; + } + + /// Unregisters a [ScribbleClient] with [elementIdentifier]. + static void unregisterScribbleElement(String elementIdentifier) { + _instance._scribbleClients.remove(elementIdentifier); + } + + List _cachedSelectionRects = []; + + /// Send the bounding boxes of the current selected glyphs in the client to + /// the platform's text input plugin. + /// + /// These are used by the engine during a UIDirectScribbleInteraction. + static void setSelectionRects(List selectionRects) { + if (!listEquals(_instance._cachedSelectionRects, selectionRects)) { + _instance._cachedSelectionRects = selectionRects; + _instance._channel.invokeMethod( + 'Scribble.setSelectionRects', + selectionRects.map((SelectionRect rect) { + return [rect.bounds.left, rect.bounds.top, rect.bounds.width, rect.bounds.height, rect.position]; + }).toList(), + ); + } + } +} + +/// An interface to receive focus from the engine. +/// +/// This is currently only used to handle UIIndirectScribbleInteraction. +mixin ScribbleClient { + /// A unique identifier for this element. + String get elementIdentifier; + + /// Called by the engine when the [ScribbleClient] should receive focus. + /// + /// For example, this method is called during a UIIndirectScribbleInteraction. + void onScribbleFocus(Offset offset); + + /// Tests whether the [ScribbleClient] overlaps the given rectangle bounds. + bool isInScribbleRect(Rect rect); + + /// The current bounds of the [ScribbleClient]. + Rect get bounds; + + /// Requests that the client show the editing toolbar, for example when the + /// platform changes the selection through a non-flutter method such as + /// scribble. + void showToolbar(); + + /// Requests that the client add a text placeholder to reserve visual space + /// in the text. + /// + /// For example, this is called when responding to UIKit requesting + /// a text placeholder be added at the current selection, such as when + /// requesting additional writing space with iPadOS14 Scribble. + void insertTextPlaceholder(Size size); + + /// Requests that the client remove the text placeholder. + void removeTextPlaceholder(); +} + +/// Represents a selection rect for a character and it's position in the text. +/// +/// This is used to report the current text selection rect and position data +/// to the engine for Scribble support on iPadOS 14. +@immutable +class SelectionRect { + /// Constructor for creating a [SelectionRect] from a text [position] and + /// [bounds]. + const SelectionRect({required this.position, required this.bounds}); + + /// The position of this selection rect within the text String. + final int position; + + /// The rectangle representing the bounds of this selection rect within the + /// currently focused [RenderEditable]'s coordinate space. + final Rect bounds; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (runtimeType != other.runtimeType) { + return false; + } + return other is SelectionRect + && other.position == position + && other.bounds == bounds; + } + + @override + int get hashCode => Object.hash(position, bounds); + + @override + String toString() => 'SelectionRect($position, $bounds)'; +} diff --git a/packages/flutter/lib/src/services/system_channels.dart b/packages/flutter/lib/src/services/system_channels.dart index 91233761809ab..3abf0cff78a07 100644 --- a/packages/flutter/lib/src/services/system_channels.dart +++ b/packages/flutter/lib/src/services/system_channels.dart @@ -220,6 +220,38 @@ class SystemChannels { JSONMethodCodec(), ); + /// A JSON [MethodChannel] for handling handwriting input. + /// + /// This method channel is used by iPadOS 14's Scribble feature where writing + /// with an Apple Pencil on top of a text field inserts text into the field. + /// + /// The following methods are defined for this channel: + /// + /// * `Scribble.focusElement`: Indicates that focus is requested at the given + /// [Offset]. + /// + /// * `Scribble.requestElementsInRect`: Returns a List of identifiers and + /// bounds for the [ScribbleClient]s that lie within the given Rect. + /// + /// * `Scribble.scribbleInteractionBegan`: Indicates that handwriting input + /// has started. + /// + /// * `Scribble.scribbleInteractionFinished`: Indicates that handwriting input + /// has ended. + /// + /// * `Scribble.showToolbar`: Requests that the toolbar be shown, such as + /// when selection is changed by handwriting. + /// + /// * `Scribble.insertTextPlaceholder`: Requests that visual writing space is + /// reserved. + /// + /// * `Scribble.removeTextPlaceholder`: Requests that any placeholder writing + /// space is removed. + static const MethodChannel scribble = OptionalMethodChannel( + 'flutter/scribble', + JSONMethodCodec(), + ); + /// A JSON [BasicMessageChannel] for keyboard events. /// /// Each incoming message received on this channel (registered using diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 85a3244107915..f1c13f0a1ff9f 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -1143,78 +1143,6 @@ abstract class TextInputClient { /// /// [TextInputClient] should cleanup its connection and finalize editing. void connectionClosed(); - - /// Requests that the client show the editing toolbar, for example when the - /// platform changes the selection through a non-flutter method such as - /// scribble. - void showToolbar() {} - - /// Requests that the client add a text placeholder to reserve visual space - /// in the text. - /// - /// For example, this is called when responding to UIKit requesting - /// a text placeholder be added at the current selection, such as when - /// requesting additional writing space with iPadOS14 Scribble. - void insertTextPlaceholder(Size size) {} - - /// Requests that the client remove the text placeholder. - void removeTextPlaceholder() {} -} - -/// An interface to receive focus from the engine. -/// -/// This is currently only used to handle UIIndirectScribbleInteraction. -abstract class ScribbleClient { - /// A unique identifier for this element. - String get elementIdentifier; - - /// Called by the engine when the [ScribbleClient] should receive focus. - /// - /// For example, this method is called during a UIIndirectScribbleInteraction. - void onScribbleFocus(Offset offset); - - /// Tests whether the [ScribbleClient] overlaps the given rectangle bounds. - bool isInScribbleRect(Rect rect); - - /// The current bounds of the [ScribbleClient]. - Rect get bounds; -} - -/// Represents a selection rect for a character and it's position in the text. -/// -/// This is used to report the current text selection rect and position data -/// to the engine for Scribble support on iPadOS 14. -@immutable -class SelectionRect { - /// Constructor for creating a [SelectionRect] from a text [position] and - /// [bounds]. - const SelectionRect({required this.position, required this.bounds}); - - /// The position of this selection rect within the text String. - final int position; - - /// The rectangle representing the bounds of this selection rect within the - /// currently focused [RenderEditable]'s coordinate space. - final Rect bounds; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - if (runtimeType != other.runtimeType) { - return false; - } - return other is SelectionRect - && other.position == position - && other.bounds == bounds; - } - - @override - int get hashCode => Object.hash(position, bounds); - - @override - String toString() => 'SelectionRect($position, $bounds)'; } /// An interface to receive granular information from [TextInput]. @@ -1271,7 +1199,6 @@ class TextInputConnection { Matrix4? _cachedTransform; Rect? _cachedRect; Rect? _cachedCaretRect; - List _cachedSelectionRects = []; static int _nextId = 1; final int _id; @@ -1294,12 +1221,6 @@ class TextInputConnection { /// Whether this connection is currently interacting with the text input control. bool get attached => TextInput._instance._currentConnection == this; - /// Whether there is currently a Scribble interaction in progress. - /// - /// This is used to make sure selection handles are shown when UIKit changes - /// the selection during a Scribble interaction. - bool get scribbleInProgress => TextInput._instance.scribbleInProgress; - /// Requests that the text input control become visible. void show() { assert(attached); @@ -1400,19 +1321,6 @@ class TextInputConnection { ); } - /// Send the bounding boxes of the current selected glyphs in the client to - /// the platform's text input plugin. - /// - /// These are used by the engine during a UIDirectScribbleInteraction. - void setSelectionRects(List selectionRects) { - if (!listEquals(_cachedSelectionRects, selectionRects)) { - _cachedSelectionRects = selectionRects; - TextInput._instance._setSelectionRects(selectionRects.map((SelectionRect rect) { - return [rect.bounds.left, rect.bounds.top, rect.bounds.width, rect.bounds.height, rect.position]; - }).toList()); - } - } - /// Send text styling information. /// /// This information is used by the Flutter Web Engine to change the style @@ -1683,16 +1591,6 @@ class TextInput { TextInputConnection? _currentConnection; late TextInputConfiguration _currentConfiguration; - final Map _scribbleClients = {}; - bool _scribbleInProgress = false; - - /// Used for testing within the Flutter SDK to get the currently registered [ScribbleClient] list. - @visibleForTesting - static Map get scribbleClients => TextInput._instance._scribbleClients; - - /// Returns true if a scribble interaction is currently happening. - bool get scribbleInProgress => _scribbleInProgress; - Future _loudlyHandleTextInputInvocation(MethodCall call) async { try { return await _handleTextInputInvocation(call); @@ -1709,33 +1607,8 @@ class TextInput { rethrow; } } - Future _handleTextInputInvocation(MethodCall methodCall) async { final String method = methodCall.method; - if (method == 'TextInputClient.focusElement') { - final List args = methodCall.arguments as List; - _scribbleClients[args[0]]?.onScribbleFocus(Offset((args[1] as num).toDouble(), (args[2] as num).toDouble())); - return; - } else if (method == 'TextInputClient.requestElementsInRect') { - final List args = (methodCall.arguments as List).cast().map((num value) => value.toDouble()).toList(); - return _scribbleClients.keys.where((String elementIdentifier) { - final Rect rect = Rect.fromLTWH(args[0], args[1], args[2], args[3]); - if (!(_scribbleClients[elementIdentifier]?.isInScribbleRect(rect) ?? false)) { - return false; - } - final Rect bounds = _scribbleClients[elementIdentifier]?.bounds ?? Rect.zero; - return !(bounds == Rect.zero || bounds.hasNaN || bounds.isInfinite); - }).map((String elementIdentifier) { - final Rect bounds = _scribbleClients[elementIdentifier]!.bounds; - return [elementIdentifier, ...[bounds.left, bounds.top, bounds.width, bounds.height]]; - }).toList(); - } else if (method == 'TextInputClient.scribbleInteractionBegan') { - _scribbleInProgress = true; - return; - } else if (method == 'TextInputClient.scribbleInteractionFinished') { - _scribbleInProgress = false; - return; - } if (_currentConnection == null) { return; } @@ -1832,15 +1705,6 @@ class TextInput { case 'TextInputClient.showAutocorrectionPromptRect': _currentConnection!._client.showAutocorrectionPromptRect(args[1] as int, args[2] as int); break; - case 'TextInputClient.showToolbar': - _currentConnection!._client.showToolbar(); - break; - case 'TextInputClient.insertTextPlaceholder': - _currentConnection!._client.insertTextPlaceholder(Size((args[1] as num).toDouble(), (args[2] as num).toDouble())); - break; - case 'TextInputClient.removeTextPlaceholder': - _currentConnection!._client.removeTextPlaceholder(); - break; default: throw MissingPluginException(); } @@ -1916,13 +1780,6 @@ class TextInput { ); } - void _setSelectionRects(List> args) { - _channel.invokeMethod( - 'TextInput.setSelectionRects', - args, - ); - } - void _setStyle(Map args) { _channel.invokeMethod( 'TextInput.setStyle', @@ -1985,18 +1842,4 @@ class TextInput { shouldSave, ); } - - /// Registers a [ScribbleClient] with [elementIdentifier] that can be focused - /// by the engine. - /// - /// For example, the registered [ScribbleClient] list is used to respond to - /// UIIndirectScribbleInteraction on an iPad. - static void registerScribbleElement(String elementIdentifier, ScribbleClient scribbleClient) { - TextInput._instance._scribbleClients[elementIdentifier] = scribbleClient; - } - - /// Unregisters a [ScribbleClient] with [elementIdentifier]. - static void unregisterScribbleElement(String elementIdentifier) { - TextInput._instance._scribbleClients.remove(elementIdentifier); - } } diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 640a7c1573a41..10327683f2ce8 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -2081,7 +2081,12 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (value.text == _value.text && value.composing == _value.composing) { // `selection` is the only change. - _handleSelectionChanged(value.selection, (_textInputConnection?.scribbleInProgress ?? false) ? SelectionChangedCause.scribble : SelectionChangedCause.keyboard); + _handleSelectionChanged( + value.selection, + Scribble.scribbleInProgress + ? SelectionChangedCause.scribble + : SelectionChangedCause.keyboard, + ); } else { hideToolbar(); _currentPromptRectRange = null; @@ -2976,7 +2981,9 @@ class EditableTextState extends State with AutomaticKeepAliveClien } return true; }).map((SelectionRect? selectionRect) => selectionRect!).toList(); - _textInputConnection!.setSelectionRects(rects); + // TODO(justinmc): Is there a cleaner way to call this? + //_textInputConnection!.setSelectionRects(rects); + Scribble.setSelectionRects(rects); } } @@ -2988,7 +2995,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien _updateSelectionRects(); SchedulerBinding.instance.addPostFrameCallback((Duration _) => _updateSizeAndTransform()); } else if (_placeholderLocation != -1) { - removeTextPlaceholder(); + _removeTextPlaceholder(); } } @@ -3081,7 +3088,6 @@ class EditableTextState extends State with AutomaticKeepAliveClien /// /// Returns `false` if a toolbar couldn't be shown, such as when the toolbar /// is already shown, or when no text selection currently exists. - @override bool showToolbar() { // Web is using native dom elements to enable clipboard functionality of the // toolbar: copy, paste, select, cut. It might also provide additional @@ -3120,39 +3126,6 @@ class EditableTextState extends State with AutomaticKeepAliveClien } } - // Tracks the location a [_ScribblePlaceholder] should be rendered in the - // text. - // - // A value of -1 indicates there should be no placeholder, otherwise the - // value should be between 0 and the length of the text, inclusive. - int _placeholderLocation = -1; - - @override - void insertTextPlaceholder(Size size) { - if (!widget.scribbleEnabled) { - return; - } - - if (!widget.controller.selection.isValid) { - return; - } - - setState(() { - _placeholderLocation = _value.text.length - widget.controller.selection.end; - }); - } - - @override - void removeTextPlaceholder() { - if (!widget.scribbleEnabled) { - return; - } - - setState(() { - _placeholderLocation = -1; - }); - } - @override String get autofillId => 'EditableText-$hashCode'; @@ -3409,6 +3382,36 @@ class EditableTextState extends State with AutomaticKeepAliveClien return Actions.invoke(context, intent); } + // Tracks the location a [_ScribblePlaceholder] should be rendered in the + // text. + // + // A value of -1 indicates there should be no placeholder, otherwise the + // value should be between 0 and the length of the text, inclusive. + int _placeholderLocation = -1; + + void _onPlaceholderLocationChanged(int location) { + setState(() { + _placeholderLocation = location; + }); + } + + void _onScribbleFocus(Offset offset) { + widget.focusNode.requestFocus(); + renderEditable.selectPositionAt(from: offset, cause: SelectionChangedCause.scribble); + _openInputConnection(); + _updateSelectionRects(force: true); + } + + void _removeTextPlaceholder() { + if (!widget.scribbleEnabled) { + return; + } + + setState(() { + _placeholderLocation = -1; + }); + } + late final Map> _actions = >{ DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false), ReplaceTextIntent: _replaceTextAction, @@ -3481,13 +3484,12 @@ class EditableTextState extends State with AutomaticKeepAliveClien onCut: _semanticsOnCut(controls), onPaste: _semanticsOnPaste(controls), child: _ScribbleFocusable( - focusNode: widget.focusNode, - editableKey: _editableKey, enabled: widget.scribbleEnabled, - updateSelectionRects: () { - _openInputConnection(); - _updateSelectionRects(force: true); - }, + onPlaceholderLocationChanged: _onPlaceholderLocationChanged, + onScribbleFocus: _onScribbleFocus, + onShowToolbar: showToolbar, + readOnly: widget.readOnly, + value: _value, child: _Editable( key: _editableKey, startHandleLayerLink: _startHandleLayerLink, @@ -3782,33 +3784,47 @@ class _Editable extends MultiChildRenderObjectWidget { } } +/// A function that that takes a placeholder location as an int offset into some +/// text. +typedef _PlaceholderLocationCallback = void Function(int location); + +/// A function that takes the Offset at which focus is requested. +typedef _ScribbleFocusCallback = void Function(Offset offset); + +/// A widget that provides the ability to receive handwriting input from +/// [Scribble]. class _ScribbleFocusable extends StatefulWidget { const _ScribbleFocusable({ required this.child, - required this.focusNode, - required this.editableKey, - required this.updateSelectionRects, required this.enabled, + required this.onPlaceholderLocationChanged, + required this.onScribbleFocus, + required this.onShowToolbar, + required this.readOnly, + required this.value, }); final Widget child; - final FocusNode focusNode; - final GlobalKey editableKey; - final VoidCallback updateSelectionRects; final bool enabled; + final _PlaceholderLocationCallback onPlaceholderLocationChanged; + final _ScribbleFocusCallback onScribbleFocus; + final VoidCallback onShowToolbar; + final bool readOnly; + final TextEditingValue value; @override _ScribbleFocusableState createState() => _ScribbleFocusableState(); } -class _ScribbleFocusableState extends State<_ScribbleFocusable> implements ScribbleClient { +class _ScribbleFocusableState extends State<_ScribbleFocusable> with ScribbleClient { _ScribbleFocusableState(): _elementIdentifier = (_nextElementIdentifier++).toString(); @override void initState() { super.initState(); + Scribble.client = this; if (widget.enabled) { - TextInput.registerScribbleElement(elementIdentifier, this); + Scribble.registerScribbleElement(elementIdentifier, this); } } @@ -3816,21 +3832,21 @@ class _ScribbleFocusableState extends State<_ScribbleFocusable> implements Scrib void didUpdateWidget(_ScribbleFocusable oldWidget) { super.didUpdateWidget(oldWidget); if (!oldWidget.enabled && widget.enabled) { - TextInput.registerScribbleElement(elementIdentifier, this); + Scribble.registerScribbleElement(elementIdentifier, this); } if (oldWidget.enabled && !widget.enabled) { - TextInput.unregisterScribbleElement(elementIdentifier); + Scribble.unregisterScribbleElement(elementIdentifier); } } @override void dispose() { - TextInput.unregisterScribbleElement(elementIdentifier); + Scribble.unregisterScribbleElement(elementIdentifier); super.dispose(); } - RenderEditable? get renderEditable => widget.editableKey.currentContext?.findRenderObject() as RenderEditable?; + // Start ScribbleClient. static int _nextElementIdentifier = 1; final String _elementIdentifier; @@ -3840,15 +3856,42 @@ class _ScribbleFocusableState extends State<_ScribbleFocusable> implements Scrib @override void onScribbleFocus(Offset offset) { - widget.focusNode.requestFocus(); - renderEditable?.selectPositionAt(from: offset, cause: SelectionChangedCause.scribble); - widget.updateSelectionRects(); + return widget.onScribbleFocus(offset); + } + + @override + void insertTextPlaceholder(Size size) { + if (!widget.enabled) { + return; + } + + if (!widget.value.selection.isValid) { + return; + } + + widget.onPlaceholderLocationChanged( + widget.value.text.length - widget.value.selection.end, + ); + } + + @override + void removeTextPlaceholder() { + if (!widget.enabled) { + return; + } + + widget.onPlaceholderLocationChanged(-1); + } + + @override + void showToolbar() { + widget.onShowToolbar(); } @override bool isInScribbleRect(Rect rect) { final Rect calculatedBounds = bounds; - if (renderEditable?.readOnly ?? false) { + if (widget.readOnly) { return false; } if (calculatedBounds == Rect.zero) { @@ -3860,7 +3903,8 @@ class _ScribbleFocusableState extends State<_ScribbleFocusable> implements Scrib final Rect intersection = calculatedBounds.intersect(rect); final HitTestResult result = HitTestResult(); WidgetsBinding.instance.hitTest(result, intersection.center); - return result.path.any((HitTestEntry entry) => entry.target == renderEditable); + final RenderObject? renderObject = context.findRenderObject(); + return result.path.any((HitTestEntry entry) => entry.target == renderObject); } @override @@ -3873,6 +3917,8 @@ class _ScribbleFocusableState extends State<_ScribbleFocusable> implements Scrib return MatrixUtils.transformRect(transform, Rect.fromLTWH(0, 0, box.size.width, box.size.height)); } + // End ScribbleClient. + @override Widget build(BuildContext context) { return widget.child; diff --git a/packages/flutter/test/services/autofill_test.dart b/packages/flutter/test/services/autofill_test.dart index e463a08fc607d..11fdcd93eab5c 100644 --- a/packages/flutter/test/services/autofill_test.dart +++ b/packages/flutter/test/services/autofill_test.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:ui'; - import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -143,21 +141,6 @@ class FakeAutofillClient implements TextInputClient, AutofillClient { @override void autofill(TextEditingValue newEditingValue) => updateEditingValue(newEditingValue); - - @override - void showToolbar() { - latestMethodCall = 'showToolbar'; - } - - @override - void insertTextPlaceholder(Size size) { - latestMethodCall = 'insertTextPlaceholder'; - } - - @override - void removeTextPlaceholder() { - latestMethodCall = 'removeTextPlaceholder'; - } } class FakeAutofillScope with AutofillScopeMixin implements AutofillScope { diff --git a/packages/flutter/test/services/delta_text_input_test.dart b/packages/flutter/test/services/delta_text_input_test.dart index 330c46188185b..1560e47c97f90 100644 --- a/packages/flutter/test/services/delta_text_input_test.dart +++ b/packages/flutter/test/services/delta_text_input_test.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:convert' show jsonDecode; -import 'dart:ui'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -115,20 +114,5 @@ class FakeDeltaTextInputClient implements DeltaTextInputClient { latestMethodCall = 'showAutocorrectionPromptRect'; } - @override - void insertTextPlaceholder(Size size) { - latestMethodCall = 'insertTextPlaceholder'; - } - - @override - void removeTextPlaceholder() { - latestMethodCall = 'removeTextPlaceholder'; - } - - @override - void showToolbar() { - latestMethodCall = 'showToolbar'; - } - TextInputConfiguration get configuration => const TextInputConfiguration(enableDeltaModel: true); } diff --git a/packages/flutter/test/services/scribble_test.dart b/packages/flutter/test/services/scribble_test.dart new file mode 100644 index 0000000000000..1f2db989d51df --- /dev/null +++ b/packages/flutter/test/services/scribble_test.dart @@ -0,0 +1,241 @@ +// 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'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'text_input_utils.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Scribble interactions', () { + tearDown(() { + // TODO(justinmc): Remove. + TextInputConnection.debugResetId(); + }); + + test('ScribbleClient showToolbar method is called', () async { + final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); + Scribble.client = targetElement; + + expect(targetElement.latestMethodCall, isEmpty); + + // Send showToolbar message. + final ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [1, 0, 1], + 'method': 'Scribble.showToolbar', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + expect(targetElement.latestMethodCall, 'showToolbar'); + }); + + test('ScribbleClient removeTextPlaceholder method is called', () async { + final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); + Scribble.client = targetElement; + + expect(targetElement.latestMethodCall, isEmpty); + + // Send removeTextPlaceholder message. + final ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [1, 0, 1], + 'method': 'Scribble.removeTextPlaceholder', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + expect(targetElement.latestMethodCall, 'removeTextPlaceholder'); + }); + + test('ScribbleClient insertTextPlaceholder method is called', () async { + final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); + Scribble.client = targetElement; + + expect(targetElement.latestMethodCall, isEmpty); + + // Send insertTextPlaceholder message. + final ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [1, 0, 1], + 'method': 'Scribble.insertTextPlaceholder', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + expect(targetElement.latestMethodCall, 'insertTextPlaceholder'); + }); + + test('ScribbleClient scribbleInteractionBegan and scribbleInteractionFinished', () async { + // Assemble a TextInputConnection so we can verify its change in state. + //final FakeScribbleClient client = FakeScribbleClient(); + //const TextInputConfiguration configuration = TextInputConfiguration(); + //final TextInputConnection connection = TextInput.attach(client, configuration); + Scribble.ensureInitialized(); + + expect(Scribble.scribbleInProgress, isFalse); + + // Send scribbleInteractionBegan message. + ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [1, 0, 1], + 'method': 'Scribble.scribbleInteractionBegan', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + expect(Scribble.scribbleInProgress, isTrue); + + // Send scribbleInteractionFinished message. + messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [1, 0, 1], + 'method': 'Scribble.scribbleInteractionFinished', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + expect(Scribble.scribbleInProgress, isFalse); + }); + + test('ScribbleClient focusElement', () async { + // Assemble a TextInputConnection so we can verify its change in state. + /* + final FakeScribbleClient client = FakeScribbleClient(TextEditingValue.empty); + const TextInputConfiguration configuration = TextInputConfiguration(); + TextInput.attach(client, configuration); + */ + + final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); + Scribble.registerScribbleElement(targetElement.elementIdentifier, targetElement); + final FakeScribbleElement otherElement = FakeScribbleElement(elementIdentifier: 'other'); + Scribble.registerScribbleElement(otherElement.elementIdentifier, otherElement); + + expect(targetElement.latestMethodCall, isEmpty); + expect(otherElement.latestMethodCall, isEmpty); + + // Send focusElement message. + final ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [targetElement.elementIdentifier, 0.0, 0.0], + 'method': 'Scribble.focusElement', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + Scribble.unregisterScribbleElement(targetElement.elementIdentifier); + Scribble.unregisterScribbleElement(otherElement.elementIdentifier); + + expect(targetElement.latestMethodCall, 'onScribbleFocus'); + expect(otherElement.latestMethodCall, isEmpty); + }); + + test('ScribbleClient requestElementsInRect', () async { + // Assemble a TextInputConnection so we can verify its change in state. + /* + final FakeScribbleClient client = FakeScribbleClient(TextEditingValue.empty); + const TextInputConfiguration configuration = TextInputConfiguration(); + TextInput.attach(client, configuration); + */ + + final List targetElements = [ + FakeScribbleElement(elementIdentifier: 'target1', bounds: const Rect.fromLTWH(0.0, 0.0, 100.0, 100.0)), + FakeScribbleElement(elementIdentifier: 'target2', bounds: const Rect.fromLTWH(0.0, 100.0, 100.0, 100.0)), + ]; + final List otherElements = [ + FakeScribbleElement(elementIdentifier: 'other1', bounds: const Rect.fromLTWH(100.0, 0.0, 100.0, 100.0)), + FakeScribbleElement(elementIdentifier: 'other2', bounds: const Rect.fromLTWH(100.0, 100.0, 100.0, 100.0)), + ]; + + void registerElements(FakeScribbleElement element) => Scribble.registerScribbleElement(element.elementIdentifier, element); + void unregisterElements(FakeScribbleElement element) => Scribble.unregisterScribbleElement(element.elementIdentifier); + + [...targetElements, ...otherElements].forEach(registerElements); + + // Send requestElementsInRect message. + final ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [0.0, 50.0, 50.0, 100.0], + 'method': 'Scribble.requestElementsInRect', + }); + ByteData? responseBytes; + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? response) { + responseBytes = response; + }, + ); + + [...targetElements, ...otherElements].forEach(unregisterElements); + + final List> responses = (const JSONMessageCodec().decodeMessage(responseBytes) as List).cast>(); + expect(responses.first.length, 2); + expect(responses.first.first, containsAllInOrder([targetElements.first.elementIdentifier, 0.0, 0.0, 100.0, 100.0])); + expect(responses.first.last, containsAllInOrder([targetElements.last.elementIdentifier, 0.0, 100.0, 100.0, 100.0])); + }); + }); +} + +class FakeScribbleClient implements ScribbleClient { + FakeScribbleClient(); + + String latestMethodCall = ''; + + @override + String get elementIdentifier => ''; + + @override + void onScribbleFocus(Offset offset) { + latestMethodCall = 'onScribbleFocus'; + } + + @override + bool isInScribbleRect(Rect rect) { + latestMethodCall = 'isInScribbleRect'; + return false; + } + + @override + Rect get bounds => Rect.zero; + + @override + void showToolbar() { + latestMethodCall = 'showToolbar'; + } + + @override + void insertTextPlaceholder(Size size) { + latestMethodCall = 'insertTextPlaceholder'; + } + + @override + void removeTextPlaceholder() { + latestMethodCall = 'removeTextPlaceholder'; + } +} diff --git a/packages/flutter/test/services/text_input_test.dart b/packages/flutter/test/services/text_input_test.dart index 3e53b67ec608c..77af170b61393 100644 --- a/packages/flutter/test/services/text_input_test.dart +++ b/packages/flutter/test/services/text_input_test.dart @@ -4,7 +4,6 @@ import 'dart:convert' show jsonDecode; -import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -529,148 +528,6 @@ void main() { expect(client.latestMethodCall, 'showAutocorrectionPromptRect'); }); - - test('TextInputClient showToolbar method is called', () async { - // Assemble a TextInputConnection so we can verify its change in state. - final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty); - const TextInputConfiguration configuration = TextInputConfiguration(); - TextInput.attach(client, configuration); - - expect(client.latestMethodCall, isEmpty); - - // Send showToolbar message. - final ByteData? messageBytes = - const JSONMessageCodec().encodeMessage({ - 'args': [1, 0, 1], - 'method': 'TextInputClient.showToolbar', - }); - await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( - 'flutter/textinput', - messageBytes, - (ByteData? _) {}, - ); - - expect(client.latestMethodCall, 'showToolbar'); - }); - }); - - group('Scribble interactions', () { - tearDown(() { - TextInputConnection.debugResetId(); - }); - - test('TextInputClient scribbleInteractionBegan and scribbleInteractionFinished', () async { - // Assemble a TextInputConnection so we can verify its change in state. - final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty); - const TextInputConfiguration configuration = TextInputConfiguration(); - final TextInputConnection connection = TextInput.attach(client, configuration); - - expect(connection.scribbleInProgress, false); - - // Send scribbleInteractionBegan message. - ByteData? messageBytes = - const JSONMessageCodec().encodeMessage({ - 'args': [1, 0, 1], - 'method': 'TextInputClient.scribbleInteractionBegan', - }); - await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( - 'flutter/textinput', - messageBytes, - (ByteData? _) {}, - ); - - expect(connection.scribbleInProgress, true); - - // Send scribbleInteractionFinished message. - messageBytes = - const JSONMessageCodec().encodeMessage({ - 'args': [1, 0, 1], - 'method': 'TextInputClient.scribbleInteractionFinished', - }); - await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( - 'flutter/textinput', - messageBytes, - (ByteData? _) {}, - ); - - expect(connection.scribbleInProgress, false); - }); - - test('TextInputClient focusElement', () async { - // Assemble a TextInputConnection so we can verify its change in state. - final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty); - const TextInputConfiguration configuration = TextInputConfiguration(); - TextInput.attach(client, configuration); - - final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); - TextInput.registerScribbleElement(targetElement.elementIdentifier, targetElement); - final FakeScribbleElement otherElement = FakeScribbleElement(elementIdentifier: 'other'); - TextInput.registerScribbleElement(otherElement.elementIdentifier, otherElement); - - expect(targetElement.latestMethodCall, isEmpty); - expect(otherElement.latestMethodCall, isEmpty); - - // Send focusElement message. - final ByteData? messageBytes = - const JSONMessageCodec().encodeMessage({ - 'args': [targetElement.elementIdentifier, 0.0, 0.0], - 'method': 'TextInputClient.focusElement', - }); - await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( - 'flutter/textinput', - messageBytes, - (ByteData? _) {}, - ); - - TextInput.unregisterScribbleElement(targetElement.elementIdentifier); - TextInput.unregisterScribbleElement(otherElement.elementIdentifier); - - expect(targetElement.latestMethodCall, 'onScribbleFocus'); - expect(otherElement.latestMethodCall, isEmpty); - }); - - test('TextInputClient requestElementsInRect', () async { - // Assemble a TextInputConnection so we can verify its change in state. - final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty); - const TextInputConfiguration configuration = TextInputConfiguration(); - TextInput.attach(client, configuration); - - final List targetElements = [ - FakeScribbleElement(elementIdentifier: 'target1', bounds: const Rect.fromLTWH(0.0, 0.0, 100.0, 100.0)), - FakeScribbleElement(elementIdentifier: 'target2', bounds: const Rect.fromLTWH(0.0, 100.0, 100.0, 100.0)), - ]; - final List otherElements = [ - FakeScribbleElement(elementIdentifier: 'other1', bounds: const Rect.fromLTWH(100.0, 0.0, 100.0, 100.0)), - FakeScribbleElement(elementIdentifier: 'other2', bounds: const Rect.fromLTWH(100.0, 100.0, 100.0, 100.0)), - ]; - - void registerElements(FakeScribbleElement element) => TextInput.registerScribbleElement(element.elementIdentifier, element); - void unregisterElements(FakeScribbleElement element) => TextInput.unregisterScribbleElement(element.elementIdentifier); - - [...targetElements, ...otherElements].forEach(registerElements); - - // Send requestElementsInRect message. - final ByteData? messageBytes = - const JSONMessageCodec().encodeMessage({ - 'args': [0.0, 50.0, 50.0, 100.0], - 'method': 'TextInputClient.requestElementsInRect', - }); - ByteData? responseBytes; - await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( - 'flutter/textinput', - messageBytes, - (ByteData? response) { - responseBytes = response; - }, - ); - - [...targetElements, ...otherElements].forEach(unregisterElements); - - final List> responses = (const JSONMessageCodec().decodeMessage(responseBytes) as List).cast>(); - expect(responses.first.length, 2); - expect(responses.first.first, containsAllInOrder([targetElements.first.elementIdentifier, 0.0, 0.0, 100.0, 100.0])); - expect(responses.first.last, containsAllInOrder([targetElements.last.elementIdentifier, 0.0, 100.0, 100.0, 100.0])); - }); }); test('TextEditingValue.isComposingRangeValid', () async { @@ -742,20 +599,5 @@ class FakeTextInputClient implements TextInputClient { latestMethodCall = 'showAutocorrectionPromptRect'; } - @override - void showToolbar() { - latestMethodCall = 'showToolbar'; - } - TextInputConfiguration get configuration => const TextInputConfiguration(); - - @override - void insertTextPlaceholder(Size size) { - latestMethodCall = 'insertTextPlaceholder'; - } - - @override - void removeTextPlaceholder() { - latestMethodCall = 'removeTextPlaceholder'; - } } diff --git a/packages/flutter/test/services/text_input_utils.dart b/packages/flutter/test/services/text_input_utils.dart index 2598c098a8501..49eaa3c1c7945 100644 --- a/packages/flutter/test/services/text_input_utils.dart +++ b/packages/flutter/test/services/text_input_utils.dart @@ -66,7 +66,7 @@ class FakeTextChannel implements MethodChannel { } } -class FakeScribbleElement implements ScribbleClient { +class FakeScribbleElement with ScribbleClient { FakeScribbleElement({required String elementIdentifier, Rect bounds = Rect.zero}) : _elementIdentifier = elementIdentifier, _bounds = bounds; @@ -90,4 +90,19 @@ class FakeScribbleElement implements ScribbleClient { void onScribbleFocus(Offset offset) { latestMethodCall = 'onScribbleFocus'; } + + @override + void insertTextPlaceholder(Size size) { + latestMethodCall = 'insertTextPlaceholder'; + } + + @override + void removeTextPlaceholder() { + latestMethodCall = 'removeTextPlaceholder'; + } + + @override + void showToolbar() { + latestMethodCall = 'showToolbar'; + } } diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 55acff909ff5c..108a71dc4e157 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -2138,6 +2138,7 @@ void main() { final TextEditingController controller = TextEditingController(text: 'Lorem ipsum dolor sit amet'); late SelectionChangedCause selectionCause; + Scribble.ensureInitialized(); await tester.pumpWidget( MaterialApp( @@ -2157,7 +2158,7 @@ void main() { ), ); - await tester.testTextInput.scribbleFocusElement(TextInput.scribbleClients.keys.first, Offset.zero); + await tester.testTextInput.scribbleFocusElement(Scribble.scribbleClients.keys.first, Offset.zero); expect(focusNode.hasFocus, true); expect(selectionCause, SelectionChangedCause.scribble); @@ -2183,7 +2184,7 @@ void main() { ), ); - final List elementEntry = [TextInput.scribbleClients.keys.first, 0.0, 0.0, 800.0, 600.0]; + final List elementEntry = [Scribble.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)); @@ -4507,7 +4508,7 @@ void main() { testWidgets('selection rects are sent when they change', (WidgetTester tester) async { final List log = []; - SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async { + SystemChannels.scribble.setMockMethodCallHandler((MethodCall methodCall) async { log.add(methodCall); }); @@ -4538,8 +4539,8 @@ void main() { await tester.showKeyboard(find.byKey(ValueKey(controller.text))); // There should be a new platform message updating the selection rects. - final MethodCall methodCall = log.firstWhere((MethodCall m) => m.method == 'TextInput.setSelectionRects'); - expect(methodCall.method, 'TextInput.setSelectionRects'); + final MethodCall methodCall = log.firstWhere((MethodCall m) => m.method == 'Scribble.setSelectionRects'); + expect(methodCall.method, 'Scribble.setSelectionRects'); expect((methodCall.arguments as List).length, 5); // On web, we should rely on the browser's implementation of Scribble, so we will not send selection rects. From 1289290fe6469e363ac167bdfd8d3bf2f43562d8 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Fri, 24 Jun 2022 10:01:33 -0700 Subject: [PATCH 02/14] Fix references to scribble methods in flutter_test --- .../flutter_test/lib/src/test_text_input.dart | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/flutter_test/lib/src/test_text_input.dart b/packages/flutter_test/lib/src/test_text_input.dart index c0fb1148687ba..4aa01d829a3e9 100644 --- a/packages/flutter_test/lib/src/test_text_input.dart +++ b/packages/flutter_test/lib/src/test_text_input.dart @@ -277,10 +277,10 @@ class TestTextInput { Future startScribbleInteraction() async { assert(isRegistered); await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( - SystemChannels.textInput.name, - SystemChannels.textInput.codec.encodeMethodCall( + SystemChannels.scribble.name, + SystemChannels.scribble.codec.encodeMethodCall( MethodCall( - 'TextInputClient.scribbleInteractionBegan', + 'Scribble.scribbleInteractionBegan', [_client ?? -1,] ), ), @@ -292,10 +292,10 @@ class TestTextInput { Future scribbleFocusElement(String elementIdentifier, Offset offset) async { assert(isRegistered); await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( - SystemChannels.textInput.name, - SystemChannels.textInput.codec.encodeMethodCall( + SystemChannels.scribble.name, + SystemChannels.scribble.codec.encodeMethodCall( MethodCall( - 'TextInputClient.focusElement', + 'Scribble.focusElement', [elementIdentifier, offset.dx, offset.dy] ), ), @@ -308,15 +308,15 @@ class TestTextInput { assert(isRegistered); List> response = >[]; await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( - SystemChannels.textInput.name, - SystemChannels.textInput.codec.encodeMethodCall( + SystemChannels.scribble.name, + SystemChannels.scribble.codec.encodeMethodCall( MethodCall( - 'TextInputClient.requestElementsInRect', + 'Scribble.requestElementsInRect', [rect.left, rect.top, rect.width, rect.height] ), ), (ByteData? data) { - response = (SystemChannels.textInput.codec.decodeEnvelope(data!) as List).map((dynamic element) => element as List).toList(); + response = (SystemChannels.scribble.codec.decodeEnvelope(data!) as List).map((dynamic element) => element as List).toList(); }, ); @@ -327,10 +327,10 @@ class TestTextInput { Future scribbleInsertPlaceholder() async { assert(isRegistered); await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( - SystemChannels.textInput.name, - SystemChannels.textInput.codec.encodeMethodCall( + SystemChannels.scribble.name, + SystemChannels.scribble.codec.encodeMethodCall( MethodCall( - 'TextInputClient.insertTextPlaceholder', + 'Scribble.insertTextPlaceholder', [_client ?? -1, 0.0, 0.0] ), ), @@ -342,10 +342,10 @@ class TestTextInput { Future scribbleRemovePlaceholder() async { assert(isRegistered); await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.handlePlatformMessage( - SystemChannels.textInput.name, - SystemChannels.textInput.codec.encodeMethodCall( + SystemChannels.scribble.name, + SystemChannels.scribble.codec.encodeMethodCall( MethodCall( - 'TextInputClient.removeTextPlaceholder', + 'Scribble.removeTextPlaceholder', [_client ?? -1] ), ), From d1ff4c70f0789e09d789ac73f8aaf3ad0ebe899c Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Thu, 30 Jun 2022 11:22:19 -0700 Subject: [PATCH 03/14] No longer any need to initialize TextInput early --- packages/flutter/lib/src/services/binding.dart | 2 -- packages/flutter/lib/src/services/text_input.dart | 4 ++++ packages/flutter/test/services/binding_test.dart | 7 ------- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/flutter/lib/src/services/binding.dart b/packages/flutter/lib/src/services/binding.dart index 7bfb769d2a580..e13cb05e1e122 100644 --- a/packages/flutter/lib/src/services/binding.dart +++ b/packages/flutter/lib/src/services/binding.dart @@ -18,7 +18,6 @@ import 'raw_keyboard.dart'; import 'restoration.dart'; import 'scribble.dart'; import 'system_channels.dart'; -import 'text_input.dart'; /// Listens for platform messages and directs them to the [defaultBinaryMessenger]. /// @@ -38,7 +37,6 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { SystemChannels.system.setMessageHandler((dynamic message) => handleSystemMessage(message as Object)); SystemChannels.lifecycle.setMessageHandler(_handleLifecycleMessage); SystemChannels.platform.setMethodCallHandler(_handlePlatformMessage); - TextInput.ensureInitialized(); Scribble.ensureInitialized(); readInitialLifecycleStateFromNativeWindow(); } diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index f1c13f0a1ff9f..28a8ce05ebaf8 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -1523,6 +1523,10 @@ class TextInput { /// Ensure that a [TextInput] instance has been set up so that the platform /// can handle messages on the text input method channel. + @Deprecated( + 'Use Scribble.ensureInitialized instead. ' + 'This feature was deprecated after v3.1.0-9.0.pre.' + ) static void ensureInitialized() { _instance; // ignore: unnecessary_statements } diff --git a/packages/flutter/test/services/binding_test.dart b/packages/flutter/test/services/binding_test.dart index fbdd4e31fe00b..dfc06b655d9d1 100644 --- a/packages/flutter/test/services/binding_test.dart +++ b/packages/flutter/test/services/binding_test.dart @@ -106,11 +106,4 @@ void main() { await rootBundle.loadString('test_asset2'); expect(flutterAssetsCallCount, 4); }); - - test('initInstances sets a default method call handler for SystemChannels.textInput', () async { - final ByteData message = const JSONMessageCodec().encodeMessage({'method': 'TextInput.requestElementsInRect', 'args': null})!; - await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage('flutter/textinput', message, (ByteData? data) { - expect(data, isNotNull); - }); - }); } From 8db1a32dbff01dc478b1aa7fcafa3a670f8c1fc7 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Thu, 30 Jun 2022 12:56:53 -0700 Subject: [PATCH 04/14] Code review: docs improvements --- .../flutter/lib/src/services/scribble.dart | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/flutter/lib/src/services/scribble.dart b/packages/flutter/lib/src/services/scribble.dart index 0f80028738b14..2a299f0274ac6 100644 --- a/packages/flutter/lib/src/services/scribble.dart +++ b/packages/flutter/lib/src/services/scribble.dart @@ -155,9 +155,13 @@ class Scribble { } } -/// An interface to receive focus from the engine. +/// An interface to interact with the engine for handwriting text input. /// -/// This is currently only used to handle UIIndirectScribbleInteraction. +/// This is currently only used to handle +/// [UIIndirectScribbleInteraction](https://developer.apple.com/documentation/uikit/uiindirectscribbleinteraction), +/// which is responsible for manually receiving handwritten text input in UIKit. +/// The Flutter engine uses this to receive handwriting input on Flutter text +/// input fields. mixin ScribbleClient { /// A unique identifier for this element. String get elementIdentifier; @@ -165,17 +169,22 @@ mixin ScribbleClient { /// Called by the engine when the [ScribbleClient] should receive focus. /// /// For example, this method is called during a UIIndirectScribbleInteraction. + /// + /// The [Offset] indicates the location where the focus event happened, which + /// is typically where the cursor should be placed. void onScribbleFocus(Offset offset); - /// Tests whether the [ScribbleClient] overlaps the given rectangle bounds. + /// Tests whether the [ScribbleClient] overlaps the given rectangle bounds, + /// where the rectangle bounds are in global coordinates. bool isInScribbleRect(Rect rect); /// The current bounds of the [ScribbleClient]. Rect get bounds; - /// Requests that the client show the editing toolbar, for example when the - /// platform changes the selection through a non-flutter method such as - /// scribble. + /// Requests that the client show the editing toolbar. + /// + /// This is used when the platform changes the selection during scribble + /// input. void showToolbar(); /// Requests that the client add a text placeholder to reserve visual space From 0d11c7c11035c8141df6d54179daaa14c7aac2bd Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Thu, 30 Jun 2022 12:57:36 -0700 Subject: [PATCH 05/14] Code review: should set client on focus --- .../lib/src/widgets/editable_text.dart | 25 ++++++- .../test/widgets/editable_text_test.dart | 70 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 10327683f2ce8..0ad27166669df 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -3485,6 +3485,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien onPaste: _semanticsOnPaste(controls), child: _ScribbleFocusable( enabled: widget.scribbleEnabled, + focusNode: widget.focusNode, onPlaceholderLocationChanged: _onPlaceholderLocationChanged, onScribbleFocus: _onScribbleFocus, onShowToolbar: showToolbar, @@ -3797,6 +3798,7 @@ class _ScribbleFocusable extends StatefulWidget { const _ScribbleFocusable({ required this.child, required this.enabled, + required this.focusNode, required this.onPlaceholderLocationChanged, required this.onScribbleFocus, required this.onShowToolbar, @@ -3806,6 +3808,7 @@ class _ScribbleFocusable extends StatefulWidget { final Widget child; final bool enabled; + final FocusNode focusNode; final _PlaceholderLocationCallback onPlaceholderLocationChanged; final _ScribbleFocusCallback onScribbleFocus; final VoidCallback onShowToolbar; @@ -3819,10 +3822,23 @@ class _ScribbleFocusable extends StatefulWidget { class _ScribbleFocusableState extends State<_ScribbleFocusable> with ScribbleClient { _ScribbleFocusableState(): _elementIdentifier = (_nextElementIdentifier++).toString(); + void _onFocusChange() { + _updateClient(widget.focusNode.hasFocus); + } + + void _updateClient(bool hasFocus) { + if (hasFocus && Scribble.client != this) { + Scribble.client = this; + } else { + Scribble.client = null; + } + } + @override void initState() { super.initState(); - Scribble.client = this; + _updateClient(widget.focusNode.hasFocus); + widget.focusNode.addListener(_onFocusChange); if (widget.enabled) { Scribble.registerScribbleElement(elementIdentifier, this); } @@ -3831,6 +3847,11 @@ class _ScribbleFocusableState extends State<_ScribbleFocusable> with ScribbleCli @override void didUpdateWidget(_ScribbleFocusable oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.focusNode != widget.focusNode) { + oldWidget.focusNode.removeListener(_onFocusChange); + widget.focusNode.addListener(_onFocusChange); + _updateClient(widget.focusNode.hasFocus); + } if (!oldWidget.enabled && widget.enabled) { Scribble.registerScribbleElement(elementIdentifier, this); } @@ -3843,6 +3864,8 @@ class _ScribbleFocusableState extends State<_ScribbleFocusable> with ScribbleCli @override void dispose() { Scribble.unregisterScribbleElement(elementIdentifier); + widget.focusNode.removeListener(_onFocusChange); + Scribble.client = null; super.dispose(); } diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 108a71dc4e157..108a1da66166c 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -4585,6 +4585,76 @@ void main() { // 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('scribble client is set based on most recent focus', (WidgetTester tester) async { + final List log = []; + SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + }); + + final TextEditingController controller = TextEditingController(); + controller.text = 'Text1'; + + final GlobalKey key1 = GlobalKey(); + final GlobalKey key2 = GlobalKey(); + + final FocusNode focusNode1 = FocusNode(); + final FocusNode focusNode2 = FocusNode(); + + Scribble.client = null; + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + EditableText( + key: key1, + controller: TextEditingController(), + focusNode: focusNode1, + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + scribbleEnabled: false, + ), + EditableText( + key: key2, + controller: TextEditingController(), + focusNode: focusNode2, + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + scribbleEnabled: false, + ), + ], + ), + ), + ), + ); + + expect(Scribble.client, isNull); + + focusNode1.requestFocus(); + await tester.pump(); + + expect(Scribble.client, isNotNull); + final ScribbleClient client1 = Scribble.client!; + + focusNode2.requestFocus(); + await tester.pump(); + + expect(Scribble.client, isNot(client1)); + expect(Scribble.client, isNotNull); + + focusNode2.unfocus(); + await tester.pump(); + + expect(Scribble.client, isNull); + + // On web, we should rely on the browser's implementation of Scribble. + }, skip: kIsWeb); // [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 { From 78a4442525ae711dade32b7d913f505b741486d3 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Mon, 19 Sep 2022 11:28:29 -0700 Subject: [PATCH 06/14] Analyzer fix --- packages/flutter/test/services/scribble_test.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/flutter/test/services/scribble_test.dart b/packages/flutter/test/services/scribble_test.dart index 1f2db989d51df..7021ca12ac643 100644 --- a/packages/flutter/test/services/scribble_test.dart +++ b/packages/flutter/test/services/scribble_test.dart @@ -2,9 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. - -import 'dart:ui'; - import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; From 6c0d28833cca80482226d5363543acaf49e26120 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Mon, 19 Sep 2022 11:48:59 -0700 Subject: [PATCH 07/14] Code review --- packages/flutter/lib/src/services/scribble.dart | 6 ++++++ .../flutter/lib/src/widgets/editable_text.dart | 14 ++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/flutter/lib/src/services/scribble.dart b/packages/flutter/lib/src/services/scribble.dart index 2a299f0274ac6..7f2723981e255 100644 --- a/packages/flutter/lib/src/services/scribble.dart +++ b/packages/flutter/lib/src/services/scribble.dart @@ -106,6 +106,12 @@ class Scribble { return; } + // The methods below are only valid when a client exists, i.e. when a field + // is focused. + if (_client == null) { + return; + } + final List args = methodCall.arguments as List; switch (method) { case 'Scribble.showToolbar': diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index e2bbb2590a001..4996d15020b5b 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -3185,8 +3185,6 @@ class EditableTextState extends State with AutomaticKeepAliveClien } return true; }).map((SelectionRect? selectionRect) => selectionRect!).toList(); - // TODO(justinmc): Is there a cleaner way to call this? - //_textInputConnection!.setSelectionRects(rects); Scribble.setSelectionRects(rects); } } @@ -4132,9 +4130,11 @@ class _ScribbleFocusableState extends State<_ScribbleFocusable> with ScribbleCli } void _updateClient(bool hasFocus) { - if (hasFocus && Scribble.client != this) { - Scribble.client = this; - } else { + if (hasFocus) { + if (Scribble.client != this) { + Scribble.client = this; + } + } else if (Scribble.client == this) { Scribble.client = null; } } @@ -4170,7 +4170,9 @@ class _ScribbleFocusableState extends State<_ScribbleFocusable> with ScribbleCli void dispose() { Scribble.unregisterScribbleElement(elementIdentifier); widget.focusNode.removeListener(_onFocusChange); - Scribble.client = null; + if (Scribble.client == this) { + Scribble.client = null; + } super.dispose(); } From 8c35f585a41dfe4b91991ef0827528603a485c6b Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Mon, 19 Sep 2022 11:55:44 -0700 Subject: [PATCH 08/14] Clean up test --- .../flutter/test/services/scribble_test.dart | 323 ++++++++---------- 1 file changed, 149 insertions(+), 174 deletions(-) diff --git a/packages/flutter/test/services/scribble_test.dart b/packages/flutter/test/services/scribble_test.dart index 7021ca12ac643..0bc4fe6ef696f 100644 --- a/packages/flutter/test/services/scribble_test.dart +++ b/packages/flutter/test/services/scribble_test.dart @@ -10,192 +10,167 @@ import 'text_input_utils.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - group('Scribble interactions', () { - tearDown(() { - // TODO(justinmc): Remove. - TextInputConnection.debugResetId(); - }); + test('ScribbleClient showToolbar method is called', () async { + final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); + Scribble.client = targetElement; + + expect(targetElement.latestMethodCall, isEmpty); - test('ScribbleClient showToolbar method is called', () async { - final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); - Scribble.client = targetElement; - - expect(targetElement.latestMethodCall, isEmpty); - - // Send showToolbar message. - final ByteData? messageBytes = - const JSONMessageCodec().encodeMessage({ - 'args': [1, 0, 1], - 'method': 'Scribble.showToolbar', - }); - await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( - 'flutter/scribble', - messageBytes, - (ByteData? _) {}, - ); - - expect(targetElement.latestMethodCall, 'showToolbar'); + // Send showToolbar message. + final ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [1, 0, 1], + 'method': 'Scribble.showToolbar', }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + expect(targetElement.latestMethodCall, 'showToolbar'); + }); + + test('ScribbleClient removeTextPlaceholder method is called', () async { + final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); + Scribble.client = targetElement; + + expect(targetElement.latestMethodCall, isEmpty); - test('ScribbleClient removeTextPlaceholder method is called', () async { - final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); - Scribble.client = targetElement; - - expect(targetElement.latestMethodCall, isEmpty); - - // Send removeTextPlaceholder message. - final ByteData? messageBytes = - const JSONMessageCodec().encodeMessage({ - 'args': [1, 0, 1], - 'method': 'Scribble.removeTextPlaceholder', - }); - await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( - 'flutter/scribble', - messageBytes, - (ByteData? _) {}, - ); - - expect(targetElement.latestMethodCall, 'removeTextPlaceholder'); + // Send removeTextPlaceholder message. + final ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [1, 0, 1], + 'method': 'Scribble.removeTextPlaceholder', }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); - test('ScribbleClient insertTextPlaceholder method is called', () async { - final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); - Scribble.client = targetElement; - - expect(targetElement.latestMethodCall, isEmpty); - - // Send insertTextPlaceholder message. - final ByteData? messageBytes = - const JSONMessageCodec().encodeMessage({ - 'args': [1, 0, 1], - 'method': 'Scribble.insertTextPlaceholder', - }); - await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( - 'flutter/scribble', - messageBytes, - (ByteData? _) {}, - ); - - expect(targetElement.latestMethodCall, 'insertTextPlaceholder'); + expect(targetElement.latestMethodCall, 'removeTextPlaceholder'); + }); + + test('ScribbleClient insertTextPlaceholder method is called', () async { + final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); + Scribble.client = targetElement; + + expect(targetElement.latestMethodCall, isEmpty); + + // Send insertTextPlaceholder message. + final ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [1, 0, 1], + 'method': 'Scribble.insertTextPlaceholder', }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + expect(targetElement.latestMethodCall, 'insertTextPlaceholder'); + }); + + test('ScribbleClient scribbleInteractionBegan and scribbleInteractionFinished', () async { + Scribble.ensureInitialized(); + + expect(Scribble.scribbleInProgress, isFalse); - test('ScribbleClient scribbleInteractionBegan and scribbleInteractionFinished', () async { - // Assemble a TextInputConnection so we can verify its change in state. - //final FakeScribbleClient client = FakeScribbleClient(); - //const TextInputConfiguration configuration = TextInputConfiguration(); - //final TextInputConnection connection = TextInput.attach(client, configuration); - Scribble.ensureInitialized(); - - expect(Scribble.scribbleInProgress, isFalse); - - // Send scribbleInteractionBegan message. - ByteData? messageBytes = - const JSONMessageCodec().encodeMessage({ - 'args': [1, 0, 1], - 'method': 'Scribble.scribbleInteractionBegan', - }); - await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( - 'flutter/scribble', - messageBytes, - (ByteData? _) {}, - ); - - expect(Scribble.scribbleInProgress, isTrue); - - // Send scribbleInteractionFinished message. - messageBytes = - const JSONMessageCodec().encodeMessage({ - 'args': [1, 0, 1], - 'method': 'Scribble.scribbleInteractionFinished', - }); - await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( - 'flutter/scribble', - messageBytes, - (ByteData? _) {}, - ); - - expect(Scribble.scribbleInProgress, isFalse); + // Send scribbleInteractionBegan message. + ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [1, 0, 1], + 'method': 'Scribble.scribbleInteractionBegan', }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + expect(Scribble.scribbleInProgress, isTrue); + + // Send scribbleInteractionFinished message. + messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [1, 0, 1], + 'method': 'Scribble.scribbleInteractionFinished', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + expect(Scribble.scribbleInProgress, isFalse); + }); - test('ScribbleClient focusElement', () async { - // Assemble a TextInputConnection so we can verify its change in state. - /* - final FakeScribbleClient client = FakeScribbleClient(TextEditingValue.empty); - const TextInputConfiguration configuration = TextInputConfiguration(); - TextInput.attach(client, configuration); - */ - - final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); - Scribble.registerScribbleElement(targetElement.elementIdentifier, targetElement); - final FakeScribbleElement otherElement = FakeScribbleElement(elementIdentifier: 'other'); - Scribble.registerScribbleElement(otherElement.elementIdentifier, otherElement); - - expect(targetElement.latestMethodCall, isEmpty); - expect(otherElement.latestMethodCall, isEmpty); - - // Send focusElement message. - final ByteData? messageBytes = - const JSONMessageCodec().encodeMessage({ - 'args': [targetElement.elementIdentifier, 0.0, 0.0], - 'method': 'Scribble.focusElement', - }); - await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( - 'flutter/scribble', - messageBytes, - (ByteData? _) {}, - ); - - Scribble.unregisterScribbleElement(targetElement.elementIdentifier); - Scribble.unregisterScribbleElement(otherElement.elementIdentifier); - - expect(targetElement.latestMethodCall, 'onScribbleFocus'); - expect(otherElement.latestMethodCall, isEmpty); + test('ScribbleClient focusElement', () async { + final FakeScribbleElement targetElement = FakeScribbleElement(elementIdentifier: 'target'); + Scribble.registerScribbleElement(targetElement.elementIdentifier, targetElement); + final FakeScribbleElement otherElement = FakeScribbleElement(elementIdentifier: 'other'); + Scribble.registerScribbleElement(otherElement.elementIdentifier, otherElement); + + expect(targetElement.latestMethodCall, isEmpty); + expect(otherElement.latestMethodCall, isEmpty); + + // Send focusElement message. + final ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [targetElement.elementIdentifier, 0.0, 0.0], + 'method': 'Scribble.focusElement', }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? _) {}, + ); + + Scribble.unregisterScribbleElement(targetElement.elementIdentifier); + Scribble.unregisterScribbleElement(otherElement.elementIdentifier); + + expect(targetElement.latestMethodCall, 'onScribbleFocus'); + expect(otherElement.latestMethodCall, isEmpty); + }); - test('ScribbleClient requestElementsInRect', () async { - // Assemble a TextInputConnection so we can verify its change in state. - /* - final FakeScribbleClient client = FakeScribbleClient(TextEditingValue.empty); - const TextInputConfiguration configuration = TextInputConfiguration(); - TextInput.attach(client, configuration); - */ - - final List targetElements = [ - FakeScribbleElement(elementIdentifier: 'target1', bounds: const Rect.fromLTWH(0.0, 0.0, 100.0, 100.0)), - FakeScribbleElement(elementIdentifier: 'target2', bounds: const Rect.fromLTWH(0.0, 100.0, 100.0, 100.0)), - ]; - final List otherElements = [ - FakeScribbleElement(elementIdentifier: 'other1', bounds: const Rect.fromLTWH(100.0, 0.0, 100.0, 100.0)), - FakeScribbleElement(elementIdentifier: 'other2', bounds: const Rect.fromLTWH(100.0, 100.0, 100.0, 100.0)), - ]; - - void registerElements(FakeScribbleElement element) => Scribble.registerScribbleElement(element.elementIdentifier, element); - void unregisterElements(FakeScribbleElement element) => Scribble.unregisterScribbleElement(element.elementIdentifier); - - [...targetElements, ...otherElements].forEach(registerElements); - - // Send requestElementsInRect message. - final ByteData? messageBytes = - const JSONMessageCodec().encodeMessage({ - 'args': [0.0, 50.0, 50.0, 100.0], - 'method': 'Scribble.requestElementsInRect', - }); - ByteData? responseBytes; - await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( - 'flutter/scribble', - messageBytes, - (ByteData? response) { - responseBytes = response; - }, - ); - - [...targetElements, ...otherElements].forEach(unregisterElements); - - final List> responses = (const JSONMessageCodec().decodeMessage(responseBytes) as List).cast>(); - expect(responses.first.length, 2); - expect(responses.first.first, containsAllInOrder([targetElements.first.elementIdentifier, 0.0, 0.0, 100.0, 100.0])); - expect(responses.first.last, containsAllInOrder([targetElements.last.elementIdentifier, 0.0, 100.0, 100.0, 100.0])); + test('ScribbleClient requestElementsInRect', () async { + final List targetElements = [ + FakeScribbleElement(elementIdentifier: 'target1', bounds: const Rect.fromLTWH(0.0, 0.0, 100.0, 100.0)), + FakeScribbleElement(elementIdentifier: 'target2', bounds: const Rect.fromLTWH(0.0, 100.0, 100.0, 100.0)), + ]; + final List otherElements = [ + FakeScribbleElement(elementIdentifier: 'other1', bounds: const Rect.fromLTWH(100.0, 0.0, 100.0, 100.0)), + FakeScribbleElement(elementIdentifier: 'other2', bounds: const Rect.fromLTWH(100.0, 100.0, 100.0, 100.0)), + ]; + + void registerElements(FakeScribbleElement element) => Scribble.registerScribbleElement(element.elementIdentifier, element); + void unregisterElements(FakeScribbleElement element) => Scribble.unregisterScribbleElement(element.elementIdentifier); + + [...targetElements, ...otherElements].forEach(registerElements); + + // Send requestElementsInRect message. + final ByteData? messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [0.0, 50.0, 50.0, 100.0], + 'method': 'Scribble.requestElementsInRect', }); + ByteData? responseBytes; + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/scribble', + messageBytes, + (ByteData? response) { + responseBytes = response; + }, + ); + + [...targetElements, ...otherElements].forEach(unregisterElements); + + final List> responses = (const JSONMessageCodec().decodeMessage(responseBytes) as List).cast>(); + expect(responses.first.length, 2); + expect(responses.first.first, containsAllInOrder([targetElements.first.elementIdentifier, 0.0, 0.0, 100.0, 100.0])); + expect(responses.first.last, containsAllInOrder([targetElements.last.elementIdentifier, 0.0, 100.0, 100.0, 100.0])); }); } From 3976e14d8408a4f0b34860d110d281e3b17e6ebd Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Wed, 5 Oct 2022 11:48:28 -0700 Subject: [PATCH 09/14] Code review --- packages/flutter/lib/src/services/scribble.dart | 2 +- packages/flutter/lib/src/widgets/editable_text.dart | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/flutter/lib/src/services/scribble.dart b/packages/flutter/lib/src/services/scribble.dart index 7f2723981e255..ddeb18b83b01a 100644 --- a/packages/flutter/lib/src/services/scribble.dart +++ b/packages/flutter/lib/src/services/scribble.dart @@ -232,7 +232,7 @@ class SelectionRect { } return other is SelectionRect && other.position == position - && other.bounds == bounds; + && other.bounds == bounds; } @override diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 1063302ea2300..8335cdf331bdc 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -4202,11 +4202,7 @@ class _ScribbleFocusableState extends State<_ScribbleFocusable> with ScribbleCli @override void insertTextPlaceholder(Size size) { - if (!widget.enabled) { - return; - } - - if (!widget.value.selection.isValid) { + if (!widget.enabled || !widget.value.selection.isValid) { return; } From c11a596f61f0598948f04ce68c0109fe7bb9e10e Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Wed, 5 Oct 2022 16:31:15 -0700 Subject: [PATCH 10/14] Fix logic for insertTextPlaceholder when readOnly is true --- packages/flutter/lib/src/widgets/editable_text.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 8335cdf331bdc..c3a91594f1309 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -4202,7 +4202,7 @@ class _ScribbleFocusableState extends State<_ScribbleFocusable> with ScribbleCli @override void insertTextPlaceholder(Size size) { - if (!widget.enabled || !widget.value.selection.isValid) { + if (!widget.enabled || !widget.value.selection.isValid || widget.readOnly) { return; } From 719bdd92c3f075d8ecc11f8e1cb87510e45c5056 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Mon, 24 Oct 2022 09:57:51 -0700 Subject: [PATCH 11/14] Code review --- packages/flutter/lib/src/services/scribble.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/flutter/lib/src/services/scribble.dart b/packages/flutter/lib/src/services/scribble.dart index ddeb18b83b01a..3548868cb7ce3 100644 --- a/packages/flutter/lib/src/services/scribble.dart +++ b/packages/flutter/lib/src/services/scribble.dart @@ -29,7 +29,6 @@ import 'system_channels.dart'; /// class, and which has a list of the methods that this class handles. class Scribble { Scribble._() { - _channel = SystemChannels.scribble; _channel.setMethodCallHandler(_handleScribbleInvocation); } @@ -62,12 +61,12 @@ class Scribble { _instance._client = client; } - /// Return the [ScribbleClient] that was most recently given focus. + /// Return the current active [ScribbleClient], or null if none. static ScribbleClient? get client => _instance._client; ScribbleClient? _client; - late MethodChannel _channel; + MethodChannel _channel = SystemChannels.scribble; final Map _scribbleClients = {}; bool _scribbleInProgress = false; @@ -108,20 +107,21 @@ class Scribble { // The methods below are only valid when a client exists, i.e. when a field // is focused. - if (_client == null) { + final ScribbleClient? client = _client; + if (client == null) { return; } final List args = methodCall.arguments as List; switch (method) { case 'Scribble.showToolbar': - _client!.showToolbar(); + client.showToolbar(); break; case 'Scribble.insertTextPlaceholder': - _client!.insertTextPlaceholder(Size((args[1] as num).toDouble(), (args[2] as num).toDouble())); + client.insertTextPlaceholder(Size((args[1] as num).toDouble(), (args[2] as num).toDouble())); break; case 'Scribble.removeTextPlaceholder': - _client!.removeTextPlaceholder(); + client.removeTextPlaceholder(); break; default: throw MissingPluginException(); From c3c7c7b57fdbf235c88b42421674164cf711ed50 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Mon, 24 Oct 2022 10:01:27 -0700 Subject: [PATCH 12/14] Analyzer fixes. I think I brought back these methods on TIC due to a bad merge accidentally --- .../flutter/lib/src/services/text_input.dart | 16 ---------------- .../flutter/lib/src/widgets/editable_text.dart | 1 + 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 51c7bdf8faec8..4b9534240a262 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -1150,22 +1150,6 @@ mixin TextInputClient { /// [TextInputClient] should cleanup its connection and finalize editing. void connectionClosed(); - /// Requests that the client show the editing toolbar, for example when the - /// platform changes the selection through a non-flutter method such as - /// scribble. - void showToolbar() {} - - /// Requests that the client add a text placeholder to reserve visual space - /// in the text. - /// - /// For example, this is called when responding to UIKit requesting - /// a text placeholder be added at the current selection, such as when - /// requesting additional writing space with iPadOS14 Scribble. - void insertTextPlaceholder(Size size) {} - - /// Requests that the client remove the text placeholder. - void removeTextPlaceholder() {} - /// Performs the specified MacOS-specific selector from the /// `NSStandardKeyBindingResponding` protocol or user-specified selector /// from `DefaultKeyBinding.Dict`. diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index fc8ac56473dae..84aca736e3773 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -3286,6 +3286,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien /// /// Returns `false` if a toolbar couldn't be shown, such as when the toolbar /// is already shown, or when no text selection currently exists. + @override bool showToolbar() { // Web is using native dom elements to enable clipboard functionality of the // toolbar: copy, paste, select, cut. It might also provide additional From e611ce563c1d57ef5d4f8387c2e173533bbfdc80 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Mon, 24 Oct 2022 10:29:32 -0700 Subject: [PATCH 13/14] Analyzer --- .../lib/src/widgets/editable_text.dart | 1 - .../test/services/text_input_test.dart | 21 ------------------- 2 files changed, 22 deletions(-) diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index d993f1c89e1bf..3da0cf7b303de 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -3294,7 +3294,6 @@ class EditableTextState extends State with AutomaticKeepAliveClien /// /// Returns `false` if a toolbar couldn't be shown, such as when the toolbar /// is already shown, or when no text selection currently exists. - @override bool showToolbar() { // Web is using native dom elements to enable clipboard functionality of the // toolbar: copy, paste, select, cut. It might also provide additional diff --git a/packages/flutter/test/services/text_input_test.dart b/packages/flutter/test/services/text_input_test.dart index e095ff2eeb284..d5118f5181f6d 100644 --- a/packages/flutter/test/services/text_input_test.dart +++ b/packages/flutter/test/services/text_input_test.dart @@ -764,12 +764,6 @@ void main() { expect(fakeTextChannel.outgoingCalls.length, 6); expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.setEditableSizeAndTransform'); - connection.setSelectionRects(const [SelectionRect(position: 0, bounds: Rect.zero)]); - expectedMethodCalls.add('setSelectionRects'); - expect(control.methodCalls, expectedMethodCalls); - expect(fakeTextChannel.outgoingCalls.length, 7); - expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.setSelectionRects'); - connection.setStyle( fontFamily: null, fontSize: null, @@ -863,16 +857,6 @@ class FakeTextInputClient with TextInputClient { latestMethodCall = 'didChangeInputControl'; } - @override - void insertTextPlaceholder(Size size) { - latestMethodCall = 'insertTextPlaceholder'; - } - - @override - void removeTextPlaceholder() { - latestMethodCall = 'removeTextPlaceholder'; - } - @override void performSelector(String selectorName) { latestMethodCall = 'performSelector'; @@ -931,11 +915,6 @@ class FakeTextInputControl with TextInputControl { methodCalls.add('setEditableSizeAndTransform'); } - @override - void setSelectionRects(List selectionRects) { - methodCalls.add('setSelectionRects'); - } - @override void setStyle({ required String? fontFamily, From 92676e9eab4e5d558b513585a3241b6704ea7e08 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Mon, 24 Oct 2022 11:25:06 -0700 Subject: [PATCH 14/14] Fix wrong call count on test after I removed a scribble call --- packages/flutter/test/services/text_input_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/flutter/test/services/text_input_test.dart b/packages/flutter/test/services/text_input_test.dart index d5118f5181f6d..2e84cee168e2b 100644 --- a/packages/flutter/test/services/text_input_test.dart +++ b/packages/flutter/test/services/text_input_test.dart @@ -773,20 +773,20 @@ void main() { ); expectedMethodCalls.add('setStyle'); expect(control.methodCalls, expectedMethodCalls); - expect(fakeTextChannel.outgoingCalls.length, 8); + expect(fakeTextChannel.outgoingCalls.length, 7); expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.setStyle'); connection.close(); expectedMethodCalls.add('detach'); expect(control.methodCalls, expectedMethodCalls); - expect(fakeTextChannel.outgoingCalls.length, 9); + expect(fakeTextChannel.outgoingCalls.length, 8); expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.clearClient'); expectedMethodCalls.add('hide'); final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); await binding.runAsync(() async {}); await expectLater(control.methodCalls, expectedMethodCalls); - expect(fakeTextChannel.outgoingCalls.length, 10); + expect(fakeTextChannel.outgoingCalls.length, 9); expect(fakeTextChannel.outgoingCalls.last.method, 'TextInput.hide'); });