Skip to content
82 changes: 31 additions & 51 deletions packages/fleather/lib/src/widgets/editor_input_client_mixin.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import 'dart:ui' as ui;

import 'package:fleather/util.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
Expand All @@ -10,7 +9,7 @@ import '../rendering/editor.dart';
import 'editor.dart';

mixin RawEditorStateTextInputClientMixin on EditorState
implements TextInputClient {
implements DeltaTextInputClient {
TextInputConnection? _textInputConnection;
TextEditingValue? _lastKnownRemoteTextEditingValue;

Expand All @@ -29,12 +28,6 @@ mixin RawEditorStateTextInputClientMixin on EditorState
/// - Changing the selection using a physical keyboard.
bool get shouldCreateInputConnection => kIsWeb || !widget.readOnly;

void _remoteValueChanged(
int start, String deleted, String inserted, TextSelection selection) {
widget.controller
.replaceText(start, deleted.length, inserted, selection: selection);
}

/// Returns `true` if there is open input connection.
bool get hasConnection =>
_textInputConnection != null && _textInputConnection!.attached;
Expand Down Expand Up @@ -64,6 +57,7 @@ mixin RawEditorStateTextInputClientMixin on EditorState
readOnly: widget.readOnly,
obscureText: false,
autocorrect: false,
enableDeltaModel: true,
inputAction: TextInputAction.newline,
keyboardAppearance: widget.keyboardAppearance,
textCapitalization: widget.textCapitalization,
Expand Down Expand Up @@ -120,51 +114,33 @@ mixin RawEditorStateTextInputClientMixin on EditorState
AutofillScope? get currentAutofillScope => null;

@override
void updateEditingValue(TextEditingValue value) {
if (!shouldCreateInputConnection) {
return;
}

if (_lastKnownRemoteTextEditingValue == value) {
// There is no difference between this value and the last known value.
return;
}

// Check if only composing range changed.
if (_lastKnownRemoteTextEditingValue!.text == value.text &&
_lastKnownRemoteTextEditingValue!.selection == value.selection) {
// This update only modifies composing range. Since we don't keep track
// of composing range in Zefyr we just need to update last known value
// here.
// This check fixes an issue on Android when it sends
// composing updates separately from regular changes for text and
// selection.
_lastKnownRemoteTextEditingValue = value;
return;
void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {
if (!shouldCreateInputConnection || textEditingDeltas.isEmpty) return;

for (final textEditingDelta in textEditingDeltas) {
int start = 0, length = 0;
String data = '';
if (textEditingDelta is TextEditingDeltaInsertion) {
start = textEditingDelta.insertionOffset;
data = textEditingDelta.textInserted;
} else if (textEditingDelta is TextEditingDeltaDeletion) {
start = textEditingDelta.deletedRange.start;
length = textEditingDelta.deletedRange.length;
} else if (textEditingDelta is TextEditingDeltaReplacement) {
start = textEditingDelta.replacedRange.start;
length = textEditingDelta.replacedRange.length;
data = textEditingDelta.replacementText;
}
_lastKnownRemoteTextEditingValue =
textEditingDelta.apply(_lastKnownRemoteTextEditingValue!);
widget.controller.replaceText(start, length, data,
selection: textEditingDelta.selection);
}
}

// Note Flutter (unintentionally?) silences errors occurred during
// text input update, so we have to report it ourselves.
// For more details see https://github.com/flutter/flutter/issues/19191
// TODO: remove try-catch when/if Flutter stops silencing these errors.
try {
final effectiveLastKnownValue = _lastKnownRemoteTextEditingValue!;
_lastKnownRemoteTextEditingValue = value;
final oldText = effectiveLastKnownValue.text;
final text = value.text;
final cursorPosition = value.selection.extentOffset;
final diff = fastDiff(oldText, text, cursorPosition);
_remoteValueChanged(
diff.start, diff.deleted, diff.inserted, value.selection);
} catch (e, trace) {
FlutterError.reportError(FlutterErrorDetails(
exception: e,
stack: trace,
library: 'Fleather',
context: ErrorSummary('while updating editing value'),
));
rethrow;
}
@override
void updateEditingValue(TextEditingValue value) {
// no-op
}

@override
Expand Down Expand Up @@ -318,3 +294,7 @@ mixin RawEditorStateTextInputClientMixin on EditorState
}
}
}

extension on TextRange {
int get length => end - start;
}
1 change: 1 addition & 0 deletions packages/fleather/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.1
mocktail: ^0.3.0
5 changes: 5 additions & 0 deletions packages/fleather/test/testing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// BSD-style license that can be found in the LICENSE file.

import 'package:fleather/fleather.dart';
import 'package:fleather/src/widgets/editor_input_client_mixin.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:quill_delta/quill_delta.dart';
Expand Down Expand Up @@ -201,3 +202,7 @@ class TestUpdateWidgetState extends State<TestUpdateWidget> {
],
);
}

RawEditorStateTextInputClientMixin getInputClient() =>
(find.byType(RawEditor).evaluate().single as StatefulElement).state
as RawEditorStateTextInputClientMixin;
131 changes: 118 additions & 13 deletions packages/fleather/test/widgets/editable_text_test.dart
Original file line number Diff line number Diff line change
@@ -1,21 +1,37 @@
// Copyright (c) 2018, the Zefyr project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:flutter/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

import '../testing.dart';

void main() {
group('FleatherEditableText', () {
testWidgets('user input', (tester) async {
testWidgets('user input inserts text', (tester) async {
final editor = EditorSandBox(tester: tester);
await editor.pumpAndTap();
final currentValue = editor.document.toPlainText();
await enterText(tester, 'Added $currentValue');
await insertText(tester, 'Added ', inText: currentValue);
expect(editor.document.toPlainText(), 'Added This House Is A Circus\n');
});

testWidgets('user input deletes text', (tester) async {
final editor = EditorSandBox(tester: tester);
await editor.pumpAndTap();
final currentValue = editor.document.toPlainText();
await deleteText(tester, nbCharacters: 5, inText: currentValue);
expect(editor.document.toPlainText(), 'House Is A Circus\n');
});

testWidgets('user input replaced text', (tester) async {
final editor = EditorSandBox(tester: tester);
await editor.pumpAndTap();
final currentValue = editor.document.toPlainText();
await replaceText(tester,
inText: currentValue,
range: const TextRange(start: 5, end: 5 + 'House'.length),
withText: 'Place');
expect(editor.document.toPlainText(), 'This Place Is A Circus\n');
});

testWidgets('autofocus', (tester) async {
final editor = EditorSandBox(tester: tester, autofocus: true);
await editor.pump();
Expand All @@ -30,14 +46,103 @@ void main() {
});
}

Future<void> enterText(WidgetTester tester, String text) async {
Future<void> insertText(WidgetTester tester, String textInserted,
{int atOffset = 0, String inText = ''}) async {
return TestAsyncUtils.guard(() async {
tester.testTextInput.updateEditingValue(
TextEditingValue(
text: text,
selection: const TextSelection.collapsed(offset: 6),
),
);
updateDeltaEditingValue(TextEditingDeltaInsertion(
oldText: inText,
textInserted: textInserted,
insertionOffset: atOffset,
selection: const TextSelection.collapsed(offset: 0),
composing: TextRange.empty));
await tester.idle();
});
}

Future<void> deleteText(WidgetTester tester,
{required int nbCharacters, int at = 0, required String inText}) {
return TestAsyncUtils.guard(() async {
updateDeltaEditingValue(TextEditingDeltaDeletion(
oldText: inText,
deletedRange: TextRange(start: at, end: at + nbCharacters),
selection: const TextSelection.collapsed(offset: 0),
composing: TextRange.empty));
await tester.idle();
});
}

Future<void> replaceText(WidgetTester tester,
{required TextRange range,
required String withText,
required String inText}) {
return TestAsyncUtils.guard(() async {
updateDeltaEditingValue(TextEditingDeltaReplacement(
oldText: inText,
replacedRange: range,
replacementText: withText,
selection: const TextSelection.collapsed(offset: 0),
composing: TextRange.empty));
await tester.idle();
});
}

void updateDeltaEditingValue(TextEditingDelta delta, {int? client}) {
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger
.handlePlatformMessage(
SystemChannels.textInput.name,
SystemChannels.textInput.codec.encodeMethodCall(
MethodCall(
'TextInputClient.updateEditingStateWithDeltas',
<dynamic>[
client ?? -1,
{
'deltas': [delta.toJSON()]
}
],
),
),
(ByteData? data) {
/* ignored */
},
);
}

extension DeltaJson on TextEditingDelta {
Map<String, dynamic> toJSON() {
final json = <String, dynamic>{};
json['composingBase'] = composing.start;
json['composingExtent'] = composing.end;

json['selectionBase'] = selection.baseOffset;
json['selectionExtent'] = selection.extentOffset;
json['selectionAffinity'] = selection.affinity.name;
json['selectionIsDirectional'] = selection.isDirectional;

json['oldText'] = oldText;

if (this is TextEditingDeltaInsertion) {
final insertion = this as TextEditingDeltaInsertion;
json['deltaStart'] = insertion.insertionOffset;
// Assumes no replacement, simply insertion here
json['deltaEnd'] = insertion.insertionOffset;
json['deltaText'] = insertion.textInserted;
}

if (this is TextEditingDeltaDeletion) {
final deletion = this as TextEditingDeltaDeletion;
json['deltaStart'] = deletion.deletedRange.start;
// Assumes no replacement, simply insertion here
json['deltaEnd'] = deletion.deletedRange.end;
json['deltaText'] = '';
}

if (this is TextEditingDeltaReplacement) {
final replacement = this as TextEditingDeltaReplacement;
json['deltaStart'] = replacement.replacedRange.start;
// Assumes no replacement, simply insertion here
json['deltaEnd'] = replacement.replacedRange.end;
json['deltaText'] = replacement.replacementText;
}
return json;
}
}
Loading