Kate Lovett 9d96df2364
Modernize framework lints (#179089)
WIP

Commits separated as follows:
- Update lints in analysis_options files
- Run `dart fix --apply`
- Clean up leftover analysis issues 
- Run `dart format .` in the right places.

Local analysis and testing passes. Checking CI now.

Part of https://github.com/flutter/flutter/issues/178827
- Adoption of flutter_lints in examples/api coming in a separate change
(cc @loic-sharma)

## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
2025-11-26 01:10:39 +00:00

2179 lines
79 KiB
Dart

// 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:math' as math;
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/src/services/text_input.dart';
import 'package:flutter_test/flutter_test.dart';
import 'rendering_tester.dart';
double _caretMarginOf(RenderEditable renderEditable) {
return renderEditable.cursorWidth + 1.0;
}
void _applyParentData(List<RenderBox> inlineRenderBoxes, InlineSpan span) {
var index = 0;
RenderBox? previousBox;
span.visitChildren((InlineSpan span) {
if (span is! WidgetSpan) {
return true;
}
final RenderBox box = inlineRenderBoxes[index];
box.parentData = TextParentData()
..span = span
..previousSibling = previousBox;
(previousBox?.parentData as TextParentData?)?.nextSibling = box;
index += 1;
previousBox = box;
return true;
});
}
class _FakeEditableTextState with TextSelectionDelegate {
@override
TextEditingValue textEditingValue = TextEditingValue.empty;
TextSelection? selection;
@override
void hideToolbar([bool hideHandles = true]) {}
@override
void userUpdateTextEditingValue(TextEditingValue value, SelectionChangedCause cause) {
selection = value.selection;
}
@override
void bringIntoView(TextPosition position) {}
@override
void cutSelection(SelectionChangedCause cause) {}
@override
Future<void> pasteText(SelectionChangedCause cause) {
return Future<void>.value();
}
@override
void selectAll(SelectionChangedCause cause) {}
@override
void copySelection(SelectionChangedCause cause) {}
}
void main() {
TestRenderingFlutterBinding.ensureInitialized();
test('RenderEditable respects clipBehavior', () {
const viewport = BoxConstraints(maxHeight: 100.0, maxWidth: 1.0);
final String longString = 'a' * 10000;
for (final clip in <Clip?>[null, ...Clip.values]) {
final context = TestClipPaintingContext();
final RenderEditable editable;
switch (clip) {
case Clip.none:
case Clip.hardEdge:
case Clip.antiAlias:
case Clip.antiAliasWithSaveLayer:
editable = RenderEditable(
text: TextSpan(text: longString),
textDirection: TextDirection.ltr,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
offset: ViewportOffset.zero(),
textSelectionDelegate: _FakeEditableTextState(),
selection: const TextSelection(baseOffset: 0, extentOffset: 0),
clipBehavior: clip!,
);
case null:
editable = RenderEditable(
text: TextSpan(text: longString),
textDirection: TextDirection.ltr,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
offset: ViewportOffset.zero(),
textSelectionDelegate: _FakeEditableTextState(),
selection: const TextSelection(baseOffset: 0, extentOffset: 0),
);
}
layout(
editable,
constraints: viewport,
phase: EnginePhase.composite,
onErrors: expectNoFlutterErrors,
);
context.paintChild(editable, Offset.zero);
// By default, clipBehavior is Clip.hardEdge.
expect(context.clipBehavior, equals(clip ?? Clip.hardEdge), reason: 'for $clip');
}
});
test('Reports the real height when maxLines is 1', () {
const InlineSpan tallSpan = TextSpan(
style: TextStyle(fontSize: 10),
children: <InlineSpan>[TextSpan(text: 'TALL', style: TextStyle(fontSize: 100))],
);
final constraints = BoxConstraints.loose(const Size(600, 600));
final editable = RenderEditable(
textDirection: TextDirection.ltr,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
offset: ViewportOffset.zero(),
textSelectionDelegate: _FakeEditableTextState(),
text: tallSpan,
);
layout(editable, constraints: constraints);
expect(editable.size.height, 100);
});
test('has default semantics input type', () {
const InlineSpan text = TextSpan(text: 'text');
final editable = RenderEditable(
textDirection: TextDirection.ltr,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
offset: ViewportOffset.zero(),
textSelectionDelegate: _FakeEditableTextState(),
text: text,
);
final config = SemanticsConfiguration();
editable.describeSemanticsConfiguration(config);
expect(config.inputType, SemanticsInputType.text);
});
test('Reports the height of the first line when maxLines is 1', () {
final InlineSpan multilineSpan = TextSpan(
text: 'liiiiines\n' * 10,
style: const TextStyle(fontSize: 10),
);
final constraints = BoxConstraints.loose(const Size(600, 600));
final editable = RenderEditable(
textDirection: TextDirection.ltr,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
offset: ViewportOffset.zero(),
textSelectionDelegate: _FakeEditableTextState(),
text: multilineSpan,
);
layout(editable, constraints: constraints);
expect(editable.size.height, 10);
});
test('Editable respect clipBehavior in describeApproximatePaintClip', () {
final String longString = 'a' * 10000;
final editable = RenderEditable(
text: TextSpan(text: longString),
textDirection: TextDirection.ltr,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
offset: ViewportOffset.zero(),
textSelectionDelegate: _FakeEditableTextState(),
selection: const TextSelection(baseOffset: 0, extentOffset: 0),
clipBehavior: Clip.none,
);
layout(editable);
var visited = false;
editable.visitChildren((RenderObject child) {
visited = true;
expect(editable.describeApproximatePaintClip(child), null);
});
expect(visited, true);
});
test('RenderEditable.paint respects offset argument', () {
const viewport = BoxConstraints(maxHeight: 1000.0, maxWidth: 1000.0);
final context = TestPushLayerPaintingContext();
const paintOffset = Offset(100, 200);
const fontSize = 20.0;
const endpoint = Offset(0.0, fontSize);
final editable = RenderEditable(
text: const TextSpan(
text: 'text',
style: TextStyle(fontSize: fontSize, height: 1.0),
),
textDirection: TextDirection.ltr,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
offset: ViewportOffset.zero(),
textSelectionDelegate: _FakeEditableTextState(),
selection: const TextSelection(baseOffset: 0, extentOffset: 0),
);
layout(editable, constraints: viewport, phase: EnginePhase.composite);
editable.paint(context, paintOffset);
final List<LeaderLayer> leaderLayers = context.pushedLayers.whereType<LeaderLayer>().toList();
expect(leaderLayers, hasLength(2), reason: '_paintHandleLayers will paint LeaderLayers');
expect(
leaderLayers.first.offset,
endpoint + paintOffset,
reason: 'offset should respect paintOffset',
);
expect(
leaderLayers.last.offset,
endpoint + paintOffset,
reason: 'offset should respect paintOffset',
);
});
// Test that clipping will be used even when the text fits within the visible
// region if the start position of the text is offset (e.g. during scrolling
// animation).
test('correct clipping', () {
final TextSelectionDelegate delegate = _FakeEditableTextState();
final editable = RenderEditable(
text: const TextSpan(style: TextStyle(height: 1.0, fontSize: 10.0), text: 'A'),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
textDirection: TextDirection.ltr,
locale: const Locale('en', 'US'),
offset: ViewportOffset.fixed(10.0),
textSelectionDelegate: delegate,
selection: const TextSelection.collapsed(offset: 0),
);
layout(editable, constraints: BoxConstraints.loose(const Size(500.0, 500.0)));
// Prepare for painting after layout.
pumpFrame(phase: EnginePhase.compositingBits);
expect(
(Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
paints..clipRect(rect: const Rect.fromLTRB(0.0, 0.0, 500.0, 10.0)),
);
});
test('Can change cursor color, radius, visibility', () {
final TextSelectionDelegate delegate = _FakeEditableTextState();
final showCursor = ValueNotifier<bool>(true);
EditableText.debugDeterministicCursor = true;
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
textDirection: TextDirection.ltr,
cursorColor: const Color.fromARGB(0xFF, 0xFF, 0x00, 0x00),
offset: ViewportOffset.zero(),
textSelectionDelegate: delegate,
text: const TextSpan(text: 'test', style: TextStyle(height: 1.0, fontSize: 10.0)),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
selection: const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
);
layout(editable);
editable.layout(BoxConstraints.loose(const Size(100, 100)));
// Prepare for painting after layout.
pumpFrame(phase: EnginePhase.compositingBits);
expect(
editable,
// Draw no cursor by default.
paintsExactlyCountTimes(#drawRect, 0),
);
editable.showCursor = showCursor;
pumpFrame(phase: EnginePhase.compositingBits);
expect(
editable,
paints..rect(
color: const Color.fromARGB(0xFF, 0xFF, 0x00, 0x00),
rect: const Rect.fromLTWH(40, 0, 1, 10),
),
);
// Now change to a rounded caret.
editable.cursorColor = const Color.fromARGB(0xFF, 0x00, 0x00, 0xFF);
editable.cursorWidth = 4;
editable.cursorRadius = const Radius.circular(3);
pumpFrame(phase: EnginePhase.compositingBits);
expect(
editable,
paints..rrect(
color: const Color.fromARGB(0xFF, 0x00, 0x00, 0xFF),
rrect: RRect.fromRectAndRadius(const Rect.fromLTWH(40, 0, 4, 10), const Radius.circular(3)),
),
);
editable.textScaler = const TextScaler.linear(2.0);
pumpFrame(phase: EnginePhase.compositingBits);
// Now the caret height is much bigger due to the bigger font scale.
expect(
editable,
paints..rrect(
color: const Color.fromARGB(0xFF, 0x00, 0x00, 0xFF),
rrect: RRect.fromRectAndRadius(const Rect.fromLTWH(80, 0, 4, 20), const Radius.circular(3)),
),
);
// Can turn off caret.
showCursor.value = false;
pumpFrame(phase: EnginePhase.compositingBits);
expect(editable, paintsExactlyCountTimes(#drawRRect, 0));
});
test('Can change textAlign', () {
final TextSelectionDelegate delegate = _FakeEditableTextState();
final editable = RenderEditable(
textDirection: TextDirection.ltr,
offset: ViewportOffset.zero(),
textSelectionDelegate: delegate,
text: const TextSpan(text: 'test'),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
);
layout(editable);
editable.layout(BoxConstraints.loose(const Size(100, 100)));
expect(editable.textAlign, TextAlign.start);
expect(editable.debugNeedsLayout, isFalse);
editable.textAlign = TextAlign.center;
expect(editable.textAlign, TextAlign.center);
expect(editable.debugNeedsLayout, isTrue);
});
test('Can read plain text', () {
final TextSelectionDelegate delegate = _FakeEditableTextState();
final editable = RenderEditable(
maxLines: null,
textDirection: TextDirection.ltr,
offset: ViewportOffset.zero(),
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
);
expect(editable.plainText, '');
editable.text = const TextSpan(text: '123');
expect(editable.plainText, '123');
editable.text = const TextSpan(
children: <TextSpan>[
TextSpan(text: 'abc', style: TextStyle(fontSize: 12)),
TextSpan(text: 'def', style: TextStyle(fontSize: 10)),
],
);
expect(editable.plainText, 'abcdef');
editable.layout(const BoxConstraints.tightFor(width: 200));
expect(editable.plainText, 'abcdef');
});
test('Cursor with ideographic script', () {
final TextSelectionDelegate delegate = _FakeEditableTextState();
final showCursor = ValueNotifier<bool>(true);
EditableText.debugDeterministicCursor = true;
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
textDirection: TextDirection.ltr,
cursorColor: const Color.fromARGB(0xFF, 0xFF, 0x00, 0x00),
offset: ViewportOffset.zero(),
textSelectionDelegate: delegate,
text: const TextSpan(
text: '中文测试文本是否正确',
style: TextStyle(fontSize: 10.0, fontFamily: 'FlutterTest'),
),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
selection: const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
);
layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
pumpFrame(phase: EnginePhase.compositingBits);
expect(
editable,
// Draw no cursor by default.
paintsExactlyCountTimes(#drawRect, 0),
);
editable.showCursor = showCursor;
pumpFrame(phase: EnginePhase.compositingBits);
expect(
editable,
paints..rect(
color: const Color.fromARGB(0xFF, 0xFF, 0x00, 0x00),
rect: const Rect.fromLTWH(40, 0, 1, 10),
),
);
// Now change to a rounded caret.
editable.cursorColor = const Color.fromARGB(0xFF, 0x00, 0x00, 0xFF);
editable.cursorWidth = 4;
editable.cursorRadius = const Radius.circular(3);
pumpFrame(phase: EnginePhase.compositingBits);
expect(
editable,
paints..rrect(
color: const Color.fromARGB(0xFF, 0x00, 0x00, 0xFF),
rrect: RRect.fromRectAndRadius(const Rect.fromLTWH(40, 0, 4, 10), const Radius.circular(3)),
),
);
editable.textScaler = const TextScaler.linear(2.0);
pumpFrame(phase: EnginePhase.compositingBits);
// Now the caret height is much bigger due to the bigger font scale.
expect(
editable,
paints..rrect(
color: const Color.fromARGB(0xFF, 0x00, 0x00, 0xFF),
rrect: RRect.fromRectAndRadius(const Rect.fromLTWH(80, 0, 4, 20), const Radius.circular(3)),
),
);
// Can turn off caret.
showCursor.value = false;
pumpFrame(phase: EnginePhase.compositingBits);
expect(editable, paintsExactlyCountTimes(#drawRRect, 0));
});
test('text is painted above selection', () {
final TextSelectionDelegate delegate = _FakeEditableTextState();
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: ViewportOffset.zero(),
textSelectionDelegate: delegate,
text: const TextSpan(text: 'test', style: TextStyle(height: 1.0, fontSize: 10.0)),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
selection: const TextSelection(
baseOffset: 0,
extentOffset: 3,
affinity: TextAffinity.upstream,
),
);
layout(editable);
expect(
editable,
paints
// Check that it's the black selection box, not the red cursor.
..rect(color: Colors.black)
..paragraph(),
);
// There is exactly one rect paint (1 selection, 0 cursor).
expect(editable, paintsExactlyCountTimes(#drawRect, 1));
});
test('cursor can paint above or below the text', () {
final TextSelectionDelegate delegate = _FakeEditableTextState();
final showCursor = ValueNotifier<bool>(true);
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
paintCursorAboveText: true,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
showCursor: showCursor,
offset: ViewportOffset.zero(),
textSelectionDelegate: delegate,
text: const TextSpan(text: 'test', style: TextStyle(height: 1.0, fontSize: 10.0)),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
selection: const TextSelection.collapsed(offset: 2, affinity: TextAffinity.upstream),
);
layout(editable);
expect(
editable,
paints
..paragraph()
// Red collapsed cursor is painted, not a selection box.
..rect(color: Colors.red[500]),
);
// There is exactly one rect paint (0 selection, 1 cursor).
expect(editable, paintsExactlyCountTimes(#drawRect, 1));
editable.paintCursorAboveText = false;
pumpFrame(phase: EnginePhase.compositingBits);
expect(
editable,
// The paint order is now flipped.
paints
..rect(color: Colors.red[500])
..paragraph(),
);
expect(editable, paintsExactlyCountTimes(#drawRect, 1));
});
test('does not paint the caret when selection is null or invalid', () async {
final TextSelectionDelegate delegate = _FakeEditableTextState();
final showCursor = ValueNotifier<bool>(true);
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
paintCursorAboveText: true,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
showCursor: showCursor,
offset: ViewportOffset.zero(),
textSelectionDelegate: delegate,
text: const TextSpan(text: 'test', style: TextStyle(height: 1.0, fontSize: 10.0)),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
selection: const TextSelection.collapsed(offset: 2, affinity: TextAffinity.upstream),
);
layout(editable);
expect(
editable,
paints
..paragraph()
// Red collapsed cursor is painted, not a selection box.
..rect(color: Colors.red[500]),
);
// Let the RenderEditable paint again. Setting the selection to null should
// prevent the caret from being painted.
editable.selection = null;
// Still paints the paragraph.
expect(editable, paints..paragraph());
// No longer paints the caret.
expect(editable, isNot(paints..rect(color: Colors.red[500])));
// Reset.
editable.selection = const TextSelection.collapsed(offset: 0);
expect(editable, paints..paragraph());
expect(editable, paints..rect(color: Colors.red[500]));
// Invalid cursor position.
editable.selection = const TextSelection.collapsed(offset: -1);
// Still paints the paragraph.
expect(editable, paints..paragraph());
// No longer paints the caret.
expect(editable, isNot(paints..rect(color: Colors.red[500])));
});
test('selects correct place with offsets', () {
const text = 'test\ntest';
final delegate = _FakeEditableTextState()
..textEditingValue = const TextEditingValue(text: text);
final viewportOffset = ViewportOffset.zero();
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
// This makes the scroll axis vertical.
maxLines: 2,
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: const TextSpan(text: text, style: TextStyle(height: 1.0, fontSize: 10.0)),
selection: const TextSelection.collapsed(offset: 4),
);
layout(editable);
expect(editable, paints..paragraph(offset: Offset.zero));
editable.selectPositionAt(from: const Offset(0, 2), cause: SelectionChangedCause.tap);
pumpFrame();
expect(delegate.selection!.isCollapsed, true);
expect(delegate.selection!.baseOffset, 0);
viewportOffset.correctBy(10);
pumpFrame(phase: EnginePhase.compositingBits);
expect(editable, paints..paragraph(offset: const Offset(0, -10)));
// Tap the same place. But because the offset is scrolled up, the second line
// gets tapped instead.
editable.selectPositionAt(from: const Offset(0, 2), cause: SelectionChangedCause.tap);
pumpFrame();
expect(delegate.selection!.isCollapsed, true);
expect(delegate.selection!.baseOffset, 5);
// Test the other selection methods.
// Move over by one character.
editable.handleTapDown(TapDownDetails(globalPosition: const Offset(10, 2)));
pumpFrame();
editable.selectPosition(cause: SelectionChangedCause.tap);
pumpFrame();
expect(delegate.selection!.isCollapsed, true);
expect(delegate.selection!.baseOffset, 6);
editable.handleTapDown(TapDownDetails(globalPosition: const Offset(20, 2)));
pumpFrame();
editable.selectWord(cause: SelectionChangedCause.longPress);
pumpFrame();
expect(delegate.selection!.isCollapsed, false);
expect(delegate.selection!.baseOffset, 5);
expect(delegate.selection!.extentOffset, 9);
// Select one more character down but since it's still part of the same
// word, the same word is selected.
editable.selectWordsInRange(from: const Offset(30, 2), cause: SelectionChangedCause.longPress);
pumpFrame();
expect(delegate.selection!.isCollapsed, false);
expect(delegate.selection!.baseOffset, 5);
expect(delegate.selection!.extentOffset, 9);
});
test('selects readonly renderEditable matches native behavior for android', () {
// Regression test for https://github.com/flutter/flutter/issues/79166.
final TargetPlatform? previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.android;
const text = ' test';
final delegate = _FakeEditableTextState()
..textEditingValue = const TextEditingValue(text: text);
final viewportOffset = ViewportOffset.zero();
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
readOnly: true,
offset: viewportOffset,
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: const TextSpan(text: text, style: TextStyle(height: 1.0, fontSize: 10.0)),
selection: const TextSelection.collapsed(offset: 4),
);
layout(editable);
// Select the second white space, where the text position = 1.
editable.selectWordsInRange(from: const Offset(10, 2), cause: SelectionChangedCause.longPress);
pumpFrame();
expect(delegate.selection!.isCollapsed, false);
expect(delegate.selection!.baseOffset, 1);
expect(delegate.selection!.extentOffset, 2);
debugDefaultTargetPlatformOverride = previousPlatform;
});
test('selects renderEditable matches native behavior for iOS case 1', () {
// Regression test for https://github.com/flutter/flutter/issues/79166.
final TargetPlatform? previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
const text = ' test';
final delegate = _FakeEditableTextState()
..textEditingValue = const TextEditingValue(text: text);
final viewportOffset = ViewportOffset.zero();
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: const TextSpan(text: text, style: TextStyle(height: 1.0, fontSize: 10.0)),
selection: const TextSelection.collapsed(offset: 4),
);
layout(editable);
// Select the second white space, where the text position = 1.
editable.selectWordsInRange(from: const Offset(10, 2), cause: SelectionChangedCause.longPress);
pumpFrame();
expect(delegate.selection!.isCollapsed, false);
expect(delegate.selection!.baseOffset, 1);
expect(delegate.selection!.extentOffset, 6);
debugDefaultTargetPlatformOverride = previousPlatform;
});
test('selects renderEditable matches native behavior for iOS case 2', () {
// Regression test for https://github.com/flutter/flutter/issues/79166.
final TargetPlatform? previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
const text = ' ';
final delegate = _FakeEditableTextState()
..textEditingValue = const TextEditingValue(text: text);
final viewportOffset = ViewportOffset.zero();
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: const TextSpan(text: text, style: TextStyle(height: 1.0, fontSize: 10.0)),
selection: const TextSelection.collapsed(offset: 4),
);
layout(editable);
// Select the second white space, where the text position = 1.
editable.selectWordsInRange(from: const Offset(10, 2), cause: SelectionChangedCause.longPress);
pumpFrame();
expect(delegate.selection!.isCollapsed, true);
expect(delegate.selection!.baseOffset, 1);
expect(delegate.selection!.extentOffset, 1);
debugDefaultTargetPlatformOverride = previousPlatform;
});
test('selects correct place when offsets are flipped', () {
const text = 'abc def ghi';
final delegate = _FakeEditableTextState()
..textEditingValue = const TextEditingValue(text: text);
final viewportOffset = ViewportOffset.zero();
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
text: const TextSpan(text: text, style: TextStyle(height: 1.0, fontSize: 10.0)),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
);
layout(editable);
editable.selectPositionAt(
from: const Offset(30, 2),
to: const Offset(10, 2),
cause: SelectionChangedCause.drag,
);
pumpFrame();
expect(delegate.selection!.isCollapsed, isFalse);
expect(delegate.selection!.baseOffset, 3);
expect(delegate.selection!.extentOffset, 1);
});
test('promptRect disappears when promptRectColor is set to null', () {
const promptRectColor = Color(0x12345678);
final TextSelectionDelegate delegate = _FakeEditableTextState();
final editable = RenderEditable(
text: const TextSpan(style: TextStyle(height: 1.0, fontSize: 10.0), text: 'ABCDEFG'),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
textDirection: TextDirection.ltr,
locale: const Locale('en', 'US'),
offset: ViewportOffset.fixed(10.0),
textSelectionDelegate: delegate,
selection: const TextSelection.collapsed(offset: 0),
promptRectColor: promptRectColor,
promptRectRange: const TextRange(start: 0, end: 1),
);
layout(editable, constraints: BoxConstraints.loose(const Size(1000.0, 1000.0)));
pumpFrame(phase: EnginePhase.compositingBits);
expect(
(Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
paints..rect(color: promptRectColor),
);
editable.promptRectColor = null;
editable.layout(BoxConstraints.loose(const Size(1000.0, 1000.0)));
pumpFrame(phase: EnginePhase.compositingBits);
expect(editable.promptRectColor, null);
expect(
(Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
isNot(paints..rect(color: promptRectColor)),
);
});
test('editable hasFocus correctly initialized', () {
// Regression test for https://github.com/flutter/flutter/issues/21640
final TextSelectionDelegate delegate = _FakeEditableTextState();
final editable = RenderEditable(
text: const TextSpan(style: TextStyle(height: 1.0, fontSize: 10.0), text: '12345'),
textDirection: TextDirection.ltr,
locale: const Locale('en', 'US'),
offset: ViewportOffset.zero(),
textSelectionDelegate: delegate,
hasFocus: true,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
);
expect(editable.hasFocus, true);
editable.hasFocus = false;
expect(editable.hasFocus, false);
});
test('has correct maxScrollExtent', () {
final TextSelectionDelegate delegate = _FakeEditableTextState();
EditableText.debugDeterministicCursor = true;
final editable = RenderEditable(
maxLines: 2,
backgroundCursorColor: Colors.grey,
textDirection: TextDirection.ltr,
cursorColor: const Color.fromARGB(0xFF, 0xFF, 0x00, 0x00),
offset: ViewportOffset.zero(),
textSelectionDelegate: delegate,
text: const TextSpan(
text:
'撒地方加咖啡哈金凤凰卡号方式剪坏算法发挥福建垃\nasfjafjajfjaslfjaskjflasjfksajf撒分开建安路口附近拉设\n计费可使肌肤撒附近埃里克圾房卡设计费"',
style: TextStyle(height: 1.0, fontSize: 10.0, fontFamily: 'Roboto'),
),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
selection: const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
);
editable.layout(BoxConstraints.loose(const Size(100.0, 1000.0)));
expect(editable.size, equals(const Size(100, 20)));
expect(editable.maxLines, equals(2));
expect(editable.maxScrollExtent, equals(90));
editable.layout(BoxConstraints.loose(const Size(150.0, 1000.0)));
expect(editable.maxScrollExtent, equals(50));
editable.layout(BoxConstraints.loose(const Size(200.0, 1000.0)));
expect(editable.maxScrollExtent, equals(40));
editable.layout(BoxConstraints.loose(const Size(500.0, 1000.0)));
expect(editable.maxScrollExtent, equals(10));
editable.layout(BoxConstraints.loose(const Size(1000.0, 1000.0)));
expect(editable.maxScrollExtent, equals(10));
});
test('getEndpointsForSelection handles empty characters', () {
final TextSelectionDelegate delegate = _FakeEditableTextState();
final editable = RenderEditable(
// This is a Unicode left-to-right mark character that will not render
// any glyphs.
text: const TextSpan(text: '\u200e'),
textDirection: TextDirection.ltr,
offset: ViewportOffset.zero(),
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
);
editable.layout(BoxConstraints.loose(const Size(100, 100)));
final List<TextSelectionPoint> endpoints = editable.getEndpointsForSelection(
const TextSelection(baseOffset: 0, extentOffset: 1),
);
expect(endpoints[0].point.dx, 0);
});
test('TextSelectionPoint can compare', () {
// ignore: prefer_const_constructors
final first = TextSelectionPoint(Offset(1, 2), TextDirection.ltr);
// ignore: prefer_const_constructors
final second = TextSelectionPoint(Offset(1, 2), TextDirection.ltr);
expect(first == second, isTrue);
expect(first.hashCode == second.hashCode, isTrue);
// ignore: prefer_const_constructors
final different = TextSelectionPoint(Offset(2, 2), TextDirection.ltr);
expect(first == different, isFalse);
expect(first.hashCode == different.hashCode, isFalse);
});
group('getRectForComposingRange', () {
const emptyTextSpan = TextSpan(text: '\u200e');
final TextSelectionDelegate delegate = _FakeEditableTextState();
final editable = RenderEditable(
maxLines: null,
textDirection: TextDirection.ltr,
offset: ViewportOffset.zero(),
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
);
test('returns null when no composing range', () {
editable.text = const TextSpan(text: '123');
editable.layout(const BoxConstraints.tightFor(width: 200));
// Invalid range.
expect(editable.getRectForComposingRange(const TextRange(start: -1, end: 2)), isNull);
// Collapsed range.
expect(editable.getRectForComposingRange(const TextRange.collapsed(2)), isNull);
// Empty Editable.
editable.text = emptyTextSpan;
editable.layout(const BoxConstraints.tightFor(width: 200));
expect(
editable.getRectForComposingRange(const TextRange(start: 0, end: 1)),
// On web this evaluates to a zero-width Rect.
anyOf(isNull, (Rect rect) => rect.width == 0),
);
});
test('more than 1 run on the same line', () {
const tinyText = TextStyle(fontSize: 1);
const normalText = TextStyle(fontSize: 10);
editable.text = TextSpan(
children: <TextSpan>[
const TextSpan(text: 'A', style: tinyText),
TextSpan(text: 'A' * 20, style: normalText),
const TextSpan(text: 'A', style: tinyText),
],
);
// Give it a width that forces the editable to wrap.
editable.layout(const BoxConstraints.tightFor(width: 200));
final Rect composingRect = editable.getRectForComposingRange(
const TextRange(start: 0, end: 20 + 2),
)!;
// Since the range covers an entire line, the Rect should also be almost
// as wide as the entire paragraph (give or take 1 character).
expect(composingRect.width, greaterThan(200 - 10));
});
});
group('custom painters', () {
final TextSelectionDelegate delegate = _FakeEditableTextState();
final editable = _TestRenderEditable(
textDirection: TextDirection.ltr,
offset: ViewportOffset.zero(),
textSelectionDelegate: delegate,
text: const TextSpan(text: 'test', style: TextStyle(height: 1.0, fontSize: 10.0)),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
selection: const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
);
setUp(() {
EditableText.debugDeterministicCursor = true;
});
tearDown(() {
EditableText.debugDeterministicCursor = false;
editable.foregroundPainter = null;
editable.painter = null;
editable.paintCount = 0;
final RenderObject? parent = editable.parent;
if (parent is RenderConstrainedBox) {
parent.child = null;
}
});
test('paints in the correct order', () {
layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
// Prepare for painting after layout.
// Foreground painter.
editable.foregroundPainter = _TestRenderEditablePainter();
pumpFrame(phase: EnginePhase.compositingBits);
expect(
(Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
paints
..paragraph()
..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: const Color(0x12345678)),
);
// Background painter.
editable.foregroundPainter = null;
editable.painter = _TestRenderEditablePainter();
expect(
(Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
paints
..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: const Color(0x12345678))
..paragraph(),
);
editable.foregroundPainter = _TestRenderEditablePainter();
editable.painter = _TestRenderEditablePainter();
expect(
(Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
paints
..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: const Color(0x12345678))
..paragraph()
..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: const Color(0x12345678)),
);
});
test('changing foreground painter', () {
layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
// Prepare for painting after layout.
var currentPainter = _TestRenderEditablePainter();
// Foreground painter.
editable.foregroundPainter = currentPainter;
pumpFrame(phase: EnginePhase.paint);
expect(currentPainter.paintCount, 1);
editable.foregroundPainter = currentPainter = _TestRenderEditablePainter()..repaint = false;
pumpFrame(phase: EnginePhase.paint);
expect(currentPainter.paintCount, 0);
editable.foregroundPainter = currentPainter = _TestRenderEditablePainter()..repaint = true;
pumpFrame(phase: EnginePhase.paint);
expect(currentPainter.paintCount, 1);
});
test('changing background painter', () {
layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
// Prepare for painting after layout.
var currentPainter = _TestRenderEditablePainter();
// Foreground painter.
editable.painter = currentPainter;
pumpFrame(phase: EnginePhase.paint);
expect(currentPainter.paintCount, 1);
editable.painter = currentPainter = _TestRenderEditablePainter()..repaint = false;
pumpFrame(phase: EnginePhase.paint);
expect(currentPainter.paintCount, 0);
editable.painter = currentPainter = _TestRenderEditablePainter()..repaint = true;
pumpFrame(phase: EnginePhase.paint);
expect(currentPainter.paintCount, 1);
});
test('swapping painters', () {
layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
final painter1 = _TestRenderEditablePainter(color: const Color(0x01234567));
final painter2 = _TestRenderEditablePainter(color: const Color(0x76543210));
editable.painter = painter1;
editable.foregroundPainter = painter2;
expect(
(Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
paints
..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: painter1.color)
..paragraph()
..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: painter2.color),
);
editable.painter = painter2;
editable.foregroundPainter = painter1;
expect(
(Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
paints
..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: painter2.color)
..paragraph()
..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: painter1.color),
);
});
test('reusing the same painter', () {
layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
final painter = _TestRenderEditablePainter();
FlutterErrorDetails? errorDetails;
editable.painter = painter;
editable.foregroundPainter = painter;
pumpFrame(
phase: EnginePhase.paint,
onErrors: () {
errorDetails = TestRenderingFlutterBinding.instance.takeFlutterErrorDetails();
},
);
expect(errorDetails, isNull);
expect(painter.paintCount, 2);
expect(
(Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
paints
..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: const Color(0x12345678))
..paragraph()
..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: const Color(0x12345678)),
);
});
test('does not repaint the render editable when custom painters need repaint', () {
layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
final painter = _TestRenderEditablePainter();
editable.painter = painter;
pumpFrame(phase: EnginePhase.paint);
editable.paintCount = 0;
painter.paintCount = 0;
painter.markNeedsPaint();
pumpFrame(phase: EnginePhase.paint);
expect(editable.paintCount, 0);
expect(painter.paintCount, 1);
});
test('repaints when its RenderEditable repaints', () {
layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
final painter = _TestRenderEditablePainter();
editable.painter = painter;
pumpFrame(phase: EnginePhase.paint);
editable.paintCount = 0;
painter.paintCount = 0;
editable.markNeedsPaint();
pumpFrame(phase: EnginePhase.paint);
expect(editable.paintCount, 1);
expect(painter.paintCount, 1);
});
test('correct coordinate space', () {
layout(editable, constraints: BoxConstraints.loose(const Size(100, 100)));
final painter = _TestRenderEditablePainter();
editable.painter = painter;
editable.offset = ViewportOffset.fixed(1000);
pumpFrame(phase: EnginePhase.compositingBits);
expect(
(Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero),
paints
..rect(rect: const Rect.fromLTRB(1, 1, 1, 1), color: const Color(0x12345678))
..paragraph(),
);
});
group('hit testing', () {
final TextSelectionDelegate delegate = _FakeEditableTextState();
test('Basic TextSpan Hit testing', () {
final textSpanA = TextSpan(text: 'A' * 10);
const textSpanBC = TextSpan(text: 'BC', style: TextStyle(letterSpacing: 26.0));
final text = TextSpan(
text: '',
style: const TextStyle(fontSize: 10.0),
children: <InlineSpan>[textSpanA, textSpanBC],
);
final renderEditable = RenderEditable(
text: text,
maxLines: null,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
textDirection: TextDirection.ltr,
offset: ViewportOffset.fixed(0.0),
textSelectionDelegate: delegate,
selection: const TextSelection.collapsed(offset: 0),
);
layout(
renderEditable,
constraints: BoxConstraints.tightFor(width: 100.0 + _caretMarginOf(renderEditable)),
);
BoxHitTestResult result;
// Hit-testing the first line
// First A
expect(
renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(5.0, 5.0)),
isTrue,
);
expect(
result.path
.map((HitTestEntry<HitTestTarget> entry) => entry.target)
.whereType<TextSpan>(),
<TextSpan>[textSpanA],
);
// The last A.
expect(
renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)),
isTrue,
);
expect(
result.path
.map((HitTestEntry<HitTestTarget> entry) => entry.target)
.whereType<TextSpan>(),
<TextSpan>[textSpanA],
);
// Far away from the line.
expect(
renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(200.0, 5.0)),
isFalse,
);
expect(
result.path
.map((HitTestEntry<HitTestTarget> entry) => entry.target)
.whereType<TextSpan>(),
<TextSpan>[],
);
// Hit-testing the second line
// Tapping on B (startX = letter-spacing / 2 = 13.0).
expect(
renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(18.0, 15.0)),
isTrue,
);
expect(
result.path
.map((HitTestEntry<HitTestTarget> entry) => entry.target)
.whereType<TextSpan>(),
<TextSpan>[textSpanBC],
);
// Between B and C, with large letter-spacing.
expect(
renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(31.0, 15.0)),
isTrue,
);
expect(
result.path
.map((HitTestEntry<HitTestTarget> entry) => entry.target)
.whereType<TextSpan>(),
<TextSpan>[textSpanBC],
);
// On C.
expect(
renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(54.0, 15.0)),
isTrue,
);
expect(
result.path
.map((HitTestEntry<HitTestTarget> entry) => entry.target)
.whereType<TextSpan>(),
<TextSpan>[textSpanBC],
);
// After C.
expect(
renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(100.0, 15.0)),
isTrue,
);
expect(
result.path
.map((HitTestEntry<HitTestTarget> entry) => entry.target)
.whereType<TextSpan>(),
<TextSpan>[],
);
// Not even remotely close.
expect(
renderEditable.hitTest(
result = BoxHitTestResult(),
position: const Offset(9999.0, 9999.0),
),
isFalse,
);
expect(
result.path
.map((HitTestEntry<HitTestTarget> entry) => entry.target)
.whereType<TextSpan>(),
<TextSpan>[],
);
});
test('TextSpan Hit testing with text justification', () {
const textSpanA = TextSpan(text: 'A '); // The space is a word break.
const textSpanB = TextSpan(
text: 'B\u200B',
); // The zero-width space is used as a line break.
final textSpanC = TextSpan(
text: 'C' * 10,
); // The third span starts a new line since it's too long for the first line.
// The text should look like:
// A B
// CCCCCCCCCC
final text = TextSpan(
text: '',
style: const TextStyle(fontSize: 10.0),
children: <InlineSpan>[textSpanA, textSpanB, textSpanC],
);
final renderEditable = RenderEditable(
text: text,
maxLines: null,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
textDirection: TextDirection.ltr,
textAlign: TextAlign.justify,
offset: ViewportOffset.fixed(0.0),
textSelectionDelegate: delegate,
selection: const TextSelection.collapsed(offset: 0),
);
layout(
renderEditable,
constraints: BoxConstraints.tightFor(width: 100.0 + _caretMarginOf(renderEditable)),
);
BoxHitTestResult result;
// Tapping on A.
expect(
renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(5.0, 5.0)),
isTrue,
);
expect(
result.path
.map((HitTestEntry<HitTestTarget> entry) => entry.target)
.whereType<TextSpan>(),
<TextSpan>[textSpanA],
);
// Between A and B.
expect(
renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(50.0, 5.0)),
isTrue,
);
expect(
result.path
.map((HitTestEntry<HitTestTarget> entry) => entry.target)
.whereType<TextSpan>(),
<TextSpan>[textSpanA],
);
// On B.
expect(
renderEditable.hitTest(result = BoxHitTestResult(), position: const Offset(95.0, 5.0)),
isTrue,
);
expect(
result.path
.map((HitTestEntry<HitTestTarget> entry) => entry.target)
.whereType<TextSpan>(),
<TextSpan>[textSpanB],
);
});
test('hits correct TextSpan when not scrolled', () {
final editable = RenderEditable(
text: const TextSpan(
style: TextStyle(height: 1.0, fontSize: 10.0),
children: <InlineSpan>[
TextSpan(text: 'A'),
TextSpan(text: 'B'),
],
),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
textDirection: TextDirection.ltr,
offset: ViewportOffset.fixed(0.0),
textSelectionDelegate: delegate,
selection: const TextSelection.collapsed(offset: 0),
);
layout(editable, constraints: BoxConstraints.loose(const Size(500.0, 500.0)));
// Prepare for painting after layout.
pumpFrame(phase: EnginePhase.compositingBits);
var result = BoxHitTestResult();
editable.hitTest(result, position: Offset.zero);
// We expect two hit test entries in the path because the RenderEditable
// will add itself as well.
expect(result.path, hasLength(2));
HitTestTarget target = result.path.first.target;
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, 'A');
// Only testing the RenderEditable entry here once, not anymore below.
expect(result.path.last.target, isA<RenderEditable>());
result = BoxHitTestResult();
editable.hitTest(result, position: const Offset(15.0, 0.0));
expect(result.path, hasLength(2));
target = result.path.first.target;
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, 'B');
});
test('hits correct TextSpan when scrolled vertically', () {
final TextSelectionDelegate delegate = _FakeEditableTextState();
final editable = RenderEditable(
text: const TextSpan(
style: TextStyle(height: 1.0, fontSize: 10.0),
children: <InlineSpan>[
TextSpan(text: 'A'),
TextSpan(text: 'B\n'),
TextSpan(text: 'C'),
],
),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
textDirection: TextDirection.ltr,
// Given maxLines of null and an offset of 5, the editable will be
// scrolled vertically by 5 pixels.
maxLines: null,
offset: ViewportOffset.fixed(5.0),
textSelectionDelegate: delegate,
selection: const TextSelection.collapsed(offset: 0),
);
layout(editable, constraints: BoxConstraints.loose(const Size(500.0, 500.0)));
// Prepare for painting after layout.
pumpFrame(phase: EnginePhase.compositingBits);
var result = BoxHitTestResult();
editable.hitTest(result, position: Offset.zero);
expect(result.path, hasLength(2));
HitTestTarget target = result.path.first.target;
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, 'A');
result = BoxHitTestResult();
editable.hitTest(result, position: const Offset(15.0, 0.0));
expect(result.path, hasLength(2));
target = result.path.first.target;
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, 'B\n');
result = BoxHitTestResult();
// When we hit at y=6 and are scrolled by -5 vertically, we expect "C"
// to be hit because the font size is 10.
editable.hitTest(result, position: const Offset(0.0, 6.0));
expect(result.path, hasLength(2));
target = result.path.first.target;
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, 'C');
});
test('hits correct TextSpan when scrolled horizontally', () {
final TextSelectionDelegate delegate = _FakeEditableTextState();
final editable = RenderEditable(
text: const TextSpan(
style: TextStyle(height: 1.0, fontSize: 10.0),
children: <InlineSpan>[
TextSpan(text: 'A'),
TextSpan(text: 'B'),
],
),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
textDirection: TextDirection.ltr,
// Given maxLines of 1 and an offset of 5, the editable will be
// scrolled by 5 pixels to the left.
offset: ViewportOffset.fixed(5.0),
textSelectionDelegate: delegate,
selection: const TextSelection.collapsed(offset: 0),
);
layout(editable, constraints: BoxConstraints.loose(const Size(500.0, 500.0)));
// Prepare for painting after layout.
pumpFrame(phase: EnginePhase.compositingBits);
final result = BoxHitTestResult();
// At x=6, we should hit "B" as we are scrolled to the left by 6
// pixels.
editable.hitTest(result, position: const Offset(6.0, 0));
expect(result.path, hasLength(2));
final HitTestTarget target = result.path.first.target;
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, 'B');
});
});
});
group('WidgetSpan support', () {
test('able to render basic WidgetSpan', () async {
final TextSelectionDelegate delegate = _FakeEditableTextState()
..textEditingValue = const TextEditingValue(
text: 'test',
selection: TextSelection.collapsed(offset: 3),
);
final renderBoxes = <RenderBox>[
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
];
final viewportOffset = ViewportOffset.zero();
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: TextSpan(
style: const TextStyle(height: 1.0, fontSize: 10.0),
children: <InlineSpan>[
const TextSpan(text: 'test'),
WidgetSpan(child: Container(width: 10, height: 10, color: Colors.blue)),
],
),
selection: const TextSelection.collapsed(offset: 3),
children: renderBoxes,
);
_applyParentData(renderBoxes, editable.text!);
layout(editable);
editable.hasFocus = true;
pumpFrame();
final Rect composingRect = editable.getRectForComposingRange(
const TextRange(start: 4, end: 5),
)!;
expect(composingRect, const Rect.fromLTRB(40.0, 0.0, 54.0, 14.0));
});
test('able to render multiple WidgetSpans', () async {
final TextSelectionDelegate delegate = _FakeEditableTextState()
..textEditingValue = const TextEditingValue(
text: 'test',
selection: TextSelection.collapsed(offset: 3),
);
final renderBoxes = <RenderBox>[
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'd'), textDirection: TextDirection.ltr),
];
final viewportOffset = ViewportOffset.zero();
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: TextSpan(
style: const TextStyle(height: 1.0, fontSize: 10.0),
children: <InlineSpan>[
const TextSpan(text: 'test'),
WidgetSpan(child: Container(width: 10, height: 10, color: Colors.blue)),
WidgetSpan(child: Container(width: 10, height: 10, color: Colors.blue)),
WidgetSpan(child: Container(width: 10, height: 10, color: Colors.blue)),
],
),
selection: const TextSelection.collapsed(offset: 3),
children: renderBoxes,
);
_applyParentData(renderBoxes, editable.text!);
layout(editable);
editable.hasFocus = true;
pumpFrame();
final Rect composingRect = editable.getRectForComposingRange(
const TextRange(start: 4, end: 7),
)!;
expect(composingRect, const Rect.fromLTRB(40.0, 0.0, 82.0, 14.0));
});
test('able to render WidgetSpans with line wrap', () async {
final TextSelectionDelegate delegate = _FakeEditableTextState()
..textEditingValue = const TextEditingValue(
text: 'test',
selection: TextSelection.collapsed(offset: 3),
);
final renderBoxes = <RenderBox>[
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'd'), textDirection: TextDirection.ltr),
];
final viewportOffset = ViewportOffset.zero();
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: const TextSpan(
style: TextStyle(height: 1.0, fontSize: 10.0),
children: <InlineSpan>[
TextSpan(text: 'test'),
WidgetSpan(child: Text('b')),
WidgetSpan(child: Text('c')),
WidgetSpan(child: Text('d')),
],
),
selection: const TextSelection.collapsed(offset: 3),
maxLines: 2,
minLines: 2,
children: renderBoxes,
);
// Force a line wrap
_applyParentData(renderBoxes, editable.text!);
layout(editable, constraints: const BoxConstraints(maxWidth: 75));
editable.hasFocus = true;
pumpFrame();
Rect composingRect = editable.getRectForComposingRange(const TextRange(start: 4, end: 6))!;
expect(composingRect, const Rect.fromLTRB(40.0, 0.0, 68.0, 14.0));
composingRect = editable.getRectForComposingRange(const TextRange(start: 6, end: 7))!;
expect(composingRect, const Rect.fromLTRB(0.0, 14.0, 14.0, 28.0));
});
test('able to render WidgetSpans with line wrap alternating spans', () async {
final TextSelectionDelegate delegate = _FakeEditableTextState()
..textEditingValue = const TextEditingValue(
text: 'test',
selection: TextSelection.collapsed(offset: 3),
);
final renderBoxes = <RenderBox>[
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'd'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'e'), textDirection: TextDirection.ltr),
];
final viewportOffset = ViewportOffset.zero();
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: const TextSpan(
style: TextStyle(height: 1.0, fontSize: 10.0),
children: <InlineSpan>[
TextSpan(text: 'test'),
WidgetSpan(child: Text('b')),
WidgetSpan(child: Text('c')),
WidgetSpan(child: Text('d')),
TextSpan(text: 'HI'),
WidgetSpan(child: Text('e')),
],
),
selection: const TextSelection.collapsed(offset: 3),
maxLines: 2,
minLines: 2,
children: renderBoxes,
);
// Force a line wrap
_applyParentData(renderBoxes, editable.text!);
layout(editable, constraints: const BoxConstraints(maxWidth: 75));
editable.hasFocus = true;
pumpFrame();
Rect composingRect = editable.getRectForComposingRange(const TextRange(start: 4, end: 6))!;
expect(composingRect, const Rect.fromLTRB(40.0, 0.0, 68.0, 14.0));
composingRect = editable.getRectForComposingRange(const TextRange(start: 6, end: 7))!;
expect(composingRect, const Rect.fromLTRB(0.0, 14.0, 14.0, 28.0));
composingRect = editable.getRectForComposingRange(const TextRange(start: 7, end: 8))!; // H
expect(composingRect, const Rect.fromLTRB(14.0, 14.0, 24.0, 28.0));
composingRect = editable.getRectForComposingRange(const TextRange(start: 8, end: 9))!; // I
expect(composingRect, const Rect.fromLTRB(24.0, 14.0, 34.0, 28.0));
composingRect = editable.getRectForComposingRange(const TextRange(start: 9, end: 10))!;
expect(composingRect, const Rect.fromLTRB(34.0, 14.0, 48.0, 28.0));
});
test('able to render WidgetSpans nested spans', () async {
final TextSelectionDelegate delegate = _FakeEditableTextState()
..textEditingValue = const TextEditingValue(
text: 'test',
selection: TextSelection.collapsed(offset: 3),
);
final renderBoxes = <RenderBox>[
RenderParagraph(const TextSpan(text: 'a'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr),
];
final viewportOffset = ViewportOffset.zero();
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: const TextSpan(
style: TextStyle(height: 1.0, fontSize: 10.0),
children: <InlineSpan>[
TextSpan(text: 'test'),
WidgetSpan(child: Text('a')),
TextSpan(
children: <InlineSpan>[
WidgetSpan(child: Text('b')),
WidgetSpan(child: Text('c')),
],
),
],
),
selection: const TextSelection.collapsed(offset: 3),
maxLines: 2,
minLines: 2,
children: renderBoxes,
);
_applyParentData(renderBoxes, editable.text!);
// Force a line wrap
layout(editable, constraints: const BoxConstraints(maxWidth: 75));
editable.hasFocus = true;
pumpFrame();
Rect? composingRect = editable.getRectForComposingRange(const TextRange(start: 4, end: 5));
expect(composingRect, const Rect.fromLTRB(40.0, 0.0, 54.0, 14.0));
composingRect = editable.getRectForComposingRange(const TextRange(start: 5, end: 6));
expect(composingRect, const Rect.fromLTRB(54.0, 0.0, 68.0, 14.0));
composingRect = editable.getRectForComposingRange(const TextRange(start: 6, end: 7));
expect(composingRect, const Rect.fromLTRB(0.0, 14.0, 14.0, 28.0));
composingRect = editable.getRectForComposingRange(const TextRange(start: 7, end: 8));
expect(composingRect, null);
});
test('WidgetSpan render box is painted at correct offset when scrolled', () async {
final TextSelectionDelegate delegate = _FakeEditableTextState()
..textEditingValue = const TextEditingValue(
text: 'test',
selection: TextSelection.collapsed(offset: 3),
);
final renderBoxes = <RenderBox>[
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
];
final viewportOffset = ViewportOffset.fixed(100.0);
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
maxLines: null,
text: TextSpan(
style: const TextStyle(height: 1.0, fontSize: 10.0),
children: <InlineSpan>[
const TextSpan(text: 'test'),
WidgetSpan(child: Container(width: 10, height: 10, color: Colors.blue)),
],
),
selection: const TextSelection.collapsed(offset: 3),
children: renderBoxes,
);
_applyParentData(renderBoxes, editable.text!);
layout(editable);
editable.hasFocus = true;
pumpFrame();
final Rect composingRect = editable.getRectForComposingRange(
const TextRange(start: 4, end: 5),
)!;
expect(composingRect, const Rect.fromLTRB(40.0, -100.0, 54.0, -86.0));
});
test('can compute IntrinsicWidth for WidgetSpans', () {
// Regression test for https://github.com/flutter/flutter/issues/59316
const screenWidth = 1000.0;
const fixedHeight = 1000.0;
const sentence = 'one two';
final TextSelectionDelegate delegate = _FakeEditableTextState()
..textEditingValue = const TextEditingValue(
text: 'test',
selection: TextSelection.collapsed(offset: 3),
);
final renderBoxes = <RenderBox>[
RenderParagraph(const TextSpan(text: sentence), textDirection: TextDirection.ltr),
];
final viewportOffset = ViewportOffset.zero();
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
cursorWidth: 0.0,
offset: viewportOffset,
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: const TextSpan(
style: TextStyle(height: 1.0, fontSize: 10.0),
children: <InlineSpan>[
TextSpan(text: 'test'),
WidgetSpan(child: Text('a')),
],
),
selection: const TextSelection.collapsed(offset: 3),
maxLines: 2,
minLines: 2,
textScaler: const TextScaler.linear(2.0),
children: renderBoxes,
);
_applyParentData(renderBoxes, editable.text!);
// Intrinsics can be computed without doing layout.
expect(
editable.computeMaxIntrinsicWidth(fixedHeight),
2.0 * 10.0 * 4 + 14.0 * 7 + 1.0,
reason:
"intrinsic width = scale factor * width of 'test' + width of 'one two' + _caretMargin",
);
expect(
editable.computeMinIntrinsicWidth(fixedHeight),
math.max(math.max(2.0 * 10.0 * 4, 14.0 * 3), 14.0 * 3),
reason:
"intrinsic width = max(scale factor * width of 'test', width of 'one', width of 'two')",
);
expect(editable.computeMaxIntrinsicHeight(fixedHeight), 40.0);
expect(editable.computeMinIntrinsicHeight(fixedHeight), 40.0);
layout(editable, constraints: const BoxConstraints(maxWidth: screenWidth));
// Intrinsics can be computed after layout.
expect(
editable.computeMaxIntrinsicWidth(fixedHeight),
2.0 * 10.0 * 4 + 14.0 * 7 + 1.0,
reason:
"intrinsic width = scale factor * width of 'test' + width of 'one two' + _caretMargin",
);
});
test('hits correct WidgetSpan when not scrolled', () {
final TextSelectionDelegate delegate = _FakeEditableTextState()
..textEditingValue = const TextEditingValue(
text: 'test',
selection: TextSelection.collapsed(offset: 3),
);
final renderBoxes = <RenderBox>[
RenderParagraph(const TextSpan(text: 'a'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr),
];
final editable = RenderEditable(
text: const TextSpan(
style: TextStyle(height: 1.0, fontSize: 10.0),
children: <InlineSpan>[
TextSpan(text: 'test'),
WidgetSpan(child: Text('a')),
TextSpan(
children: <InlineSpan>[
WidgetSpan(child: Text('b')),
WidgetSpan(child: Text('c')),
],
),
],
),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
textDirection: TextDirection.ltr,
offset: ViewportOffset.fixed(0.0),
textSelectionDelegate: delegate,
selection: const TextSelection.collapsed(offset: 0),
children: renderBoxes,
);
_applyParentData(renderBoxes, editable.text!);
layout(editable, constraints: BoxConstraints.loose(const Size(500.0, 500.0)));
// Prepare for painting after layout.
pumpFrame(phase: EnginePhase.compositingBits);
var result = BoxHitTestResult();
// The WidgetSpans have a height of 14.0, so "test" has a y offset of 4.0.
editable.hitTest(result, position: const Offset(1.0, 5.0));
// We expect two hit test entries in the path because the RenderEditable
// will add itself as well.
expect(result.path, hasLength(2));
HitTestTarget target = result.path.first.target;
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, 'test');
// Only testing the RenderEditable entry here once, not anymore below.
expect(result.path.last.target, isA<RenderEditable>());
result = BoxHitTestResult();
editable.hitTest(result, position: const Offset(15.0, 5.0));
expect(result.path, hasLength(2));
target = result.path.first.target;
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, 'test');
result = BoxHitTestResult();
editable.hitTest(result, position: const Offset(41.0, 0.0));
expect(result.path, hasLength(3));
target = result.path.first.target;
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, 'a');
result = BoxHitTestResult();
editable.hitTest(result, position: const Offset(55.0, 0.0));
expect(result.path, hasLength(3));
target = result.path.first.target;
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, 'b');
result = BoxHitTestResult();
editable.hitTest(result, position: const Offset(69.0, 5.0));
expect(result.path, hasLength(3));
target = result.path.first.target;
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, 'c');
result = BoxHitTestResult();
editable.hitTest(result, position: const Offset(5.0, 15.0));
expect(result.path, hasLength(0));
});
test('hits correct WidgetSpan when scrolled', () {
final text = '${"\n" * 10}test';
final TextSelectionDelegate delegate = _FakeEditableTextState()
..textEditingValue = TextEditingValue(
text: text,
selection: const TextSelection.collapsed(offset: 13),
);
final renderBoxes = <RenderBox>[
RenderParagraph(const TextSpan(text: 'a'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'b'), textDirection: TextDirection.ltr),
RenderParagraph(const TextSpan(text: 'c'), textDirection: TextDirection.ltr),
];
final editable = RenderEditable(
maxLines: null,
text: TextSpan(
style: const TextStyle(height: 1.0, fontSize: 10.0),
children: <InlineSpan>[
TextSpan(text: text),
const WidgetSpan(child: Text('a')),
const TextSpan(
children: <InlineSpan>[
WidgetSpan(child: Text('b')),
WidgetSpan(child: Text('c')),
],
),
],
),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
textDirection: TextDirection.ltr,
offset: ViewportOffset.fixed(100.0), // equal to the height of the 10 empty lines
textSelectionDelegate: delegate,
selection: const TextSelection.collapsed(offset: 0),
children: renderBoxes,
);
_applyParentData(renderBoxes, editable.text!);
layout(editable, constraints: BoxConstraints.loose(const Size(500.0, 500.0)));
// Prepare for painting after layout.
pumpFrame(phase: EnginePhase.compositingBits);
var result = BoxHitTestResult();
// The WidgetSpans have a height of 14.0, so "test" has a y offset of 4.0.
editable.hitTest(result, position: const Offset(0.0, 4.0));
// We expect two hit test entries in the path because the RenderEditable
// will add itself as well.
expect(result.path, hasLength(2));
HitTestTarget target = result.path.first.target;
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, text);
// Only testing the RenderEditable entry here once, not anymore below.
expect(result.path.last.target, isA<RenderEditable>());
result = BoxHitTestResult();
editable.hitTest(result, position: const Offset(15.0, 4.0));
expect(result.path, hasLength(2));
target = result.path.first.target;
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, text);
result = BoxHitTestResult();
// "test" is 40 pixel wide.
editable.hitTest(result, position: const Offset(41.0, 0.0));
expect(result.path, hasLength(3));
target = result.path.first.target;
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, 'a');
result = BoxHitTestResult();
editable.hitTest(result, position: const Offset(55.0, 0.0));
expect(result.path, hasLength(3));
target = result.path.first.target;
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, 'b');
result = BoxHitTestResult();
editable.hitTest(result, position: const Offset(69.0, 5.0));
expect(result.path, hasLength(3));
target = result.path.first.target;
expect(target, isA<TextSpan>());
expect((target as TextSpan).text, 'c');
result = BoxHitTestResult();
editable.hitTest(result, position: const Offset(5.0, 15.0));
expect(result.path, hasLength(1)); // Only the RenderEditable.
});
});
test('does not skip TextPainter.layout because of invalid cache', () {
// Regression test for https://github.com/flutter/flutter/issues/84896.
final TextSelectionDelegate delegate = _FakeEditableTextState();
const constraints = BoxConstraints(minWidth: 100, maxWidth: 500);
final editable = RenderEditable(
text: const TextSpan(style: TextStyle(height: 1.0, fontSize: 10.0), text: 'A'),
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
textDirection: TextDirection.ltr,
locale: const Locale('en', 'US'),
offset: ViewportOffset.fixed(10.0),
textSelectionDelegate: delegate,
selection: const TextSelection.collapsed(offset: 0),
cursorColor: const Color(0xFFFFFFFF),
showCursor: ValueNotifier<bool>(true),
);
layout(editable, constraints: constraints);
// ignore: invalid_use_of_protected_member
final double initialWidth = editable.computeDryLayout(constraints).width;
expect(initialWidth, 500);
// Turn off forceLine. Now the width should be significantly smaller.
editable.forceLine = false;
// ignore: invalid_use_of_protected_member
expect(editable.computeDryLayout(constraints).width, lessThan(initialWidth));
});
test('Floating cursor position is independent of viewport offset', () {
final TextSelectionDelegate delegate = _FakeEditableTextState();
final showCursor = ValueNotifier<bool>(true);
EditableText.debugDeterministicCursor = true;
const cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00);
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
textDirection: TextDirection.ltr,
cursorColor: cursorColor,
offset: ViewportOffset.zero(),
textSelectionDelegate: delegate,
text: const TextSpan(text: 'test', style: TextStyle(height: 1.0, fontSize: 10.0)),
maxLines: 3,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
selection: const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
);
layout(editable);
editable.layout(BoxConstraints.loose(const Size(100, 100)));
// Prepare for painting after layout.
pumpFrame(phase: EnginePhase.compositingBits);
expect(
editable,
// Draw no cursor by default.
paintsExactlyCountTimes(#drawRect, 0),
);
editable.showCursor = showCursor;
editable.setFloatingCursor(
FloatingCursorDragState.Start,
const Offset(50, 50),
const TextPosition(offset: 4, affinity: TextAffinity.upstream),
);
pumpFrame(phase: EnginePhase.compositingBits);
final expectedRRect = RRect.fromRectAndRadius(
const Rect.fromLTWH(49.5, 51, 2, 8),
const Radius.circular(1),
);
expect(editable, paints..rrect(color: cursorColor.withOpacity(0.75), rrect: expectedRRect));
// Change the text viewport offset.
editable.offset = ViewportOffset.fixed(200);
// Floating cursor should be drawn in the same position.
editable.setFloatingCursor(
FloatingCursorDragState.Start,
const Offset(50, 50),
const TextPosition(offset: 4, affinity: TextAffinity.upstream),
);
pumpFrame(phase: EnginePhase.compositingBits);
expect(editable, paints..rrect(color: cursorColor.withOpacity(0.75), rrect: expectedRRect));
});
test('getWordAtOffset with a negative position', () {
const text = 'abc';
final delegate = _FakeEditableTextState()
..textEditingValue = const TextEditingValue(text: text);
final viewportOffset = ViewportOffset.zero();
final editable = RenderEditable(
backgroundCursorColor: Colors.grey,
selectionColor: Colors.black,
textDirection: TextDirection.ltr,
cursorColor: Colors.red,
offset: viewportOffset,
textSelectionDelegate: delegate,
startHandleLayerLink: LayerLink(),
endHandleLayerLink: LayerLink(),
text: const TextSpan(text: text, style: TextStyle(height: 1.0, fontSize: 10.0)),
);
layout(editable, onErrors: expectNoFlutterErrors);
// Cause text metrics to be computed.
editable.computeDistanceToActualBaseline(TextBaseline.alphabetic);
final TextSelection selection;
try {
selection = editable.getWordAtOffset(
const TextPosition(offset: -1, affinity: TextAffinity.upstream),
);
} catch (error) {
// In debug mode, negative offsets are caught by an assertion.
expect(error, isA<AssertionError>());
return;
}
// Web's Paragraph.getWordBoundary behaves differently for a negative
// position.
if (kIsWeb) {
expect(selection, const TextSelection.collapsed(offset: 0));
} else {
expect(selection, const TextSelection.collapsed(offset: text.length));
}
});
}
class _TestRenderEditable extends RenderEditable {
_TestRenderEditable({
required super.textDirection,
required super.offset,
required super.textSelectionDelegate,
TextSpan? super.text,
required super.startHandleLayerLink,
required super.endHandleLayerLink,
super.selection,
});
int paintCount = 0;
@override
void paint(PaintingContext context, Offset offset) {
super.paint(context, offset);
paintCount += 1;
}
}
class _TestRenderEditablePainter extends RenderEditablePainter {
_TestRenderEditablePainter({this.color = const Color(0x12345678)});
final Color color;
bool repaint = true;
int paintCount = 0;
@override
void paint(Canvas canvas, Size size, RenderEditable renderEditable) {
paintCount += 1;
canvas.drawRect(const Rect.fromLTRB(1, 1, 1, 1), Paint()..color = color);
}
@override
bool shouldRepaint(RenderEditablePainter? oldDelegate) => repaint;
void markNeedsPaint() {
notifyListeners();
}
@override
String toString() => '_TestRenderEditablePainter#${shortHash(this)}';
}