// 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(); const cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00); final calls = []; var 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 editableTextKey = GlobalKey(); final delegate = FakeTextSelectionGestureDetectorBuilderDelegate( editableTextKey: editableTextKey, forcePressEnabled: forcePressEnabled, selectionEnabled: selectionEnabled, ); final 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, // [intended] variant: const TargetPlatformVariant({TargetPlatform.android}), ); 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, // [intended] variant: const TargetPlatformVariant({TargetPlatform.android}), ); 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, // [intended] variant: const TargetPlatformVariant({TargetPlatform.android}), ); 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, // [intended] variant: const TargetPlatformVariant({TargetPlatform.android}), ); 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, // [intended] variant: const TargetPlatformVariant({TargetPlatform.android}), ); } 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; }