diff --git a/super_editor/lib/src/super_textfield/android/android_textfield.dart b/super_editor/lib/src/super_textfield/android/android_textfield.dart index 6275f1fa8..4aa64c705 100644 --- a/super_editor/lib/src/super_textfield/android/android_textfield.dart +++ b/super_editor/lib/src/super_textfield/android/android_textfield.dart @@ -1,3 +1,4 @@ +import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; @@ -35,6 +36,7 @@ class SuperAndroidTextField extends StatefulWidget { this.textController, this.textAlign, this.textStyleBuilder = defaultTextFieldStyleBuilder, + this.inlineWidgetBuilders = const [], this.hintBehavior = HintBehavior.displayHintUntilFocus, this.hintBuilder, this.minLines, @@ -73,6 +75,9 @@ class SuperAndroidTextField extends StatefulWidget { /// [textController] based on the attributions in that content. final AttributionStyleBuilder textStyleBuilder; + /// {@macro super_text_field_inline_widget_builders} + final InlineWidgetBuilderChain inlineWidgetBuilders; + /// Policy for when the hint should be displayed. final HintBehavior hintBehavior; @@ -631,7 +636,7 @@ class SuperAndroidTextFieldState extends State Widget _buildSelectableText() { final textSpan = _textEditingController.text.isNotEmpty - ? _textEditingController.text.computeTextSpan(widget.textStyleBuilder) + ? _textEditingController.text.computeInlineSpan(context, widget.textStyleBuilder, widget.inlineWidgetBuilders) : TextSpan(text: "", style: widget.textStyleBuilder({})); return Directionality( diff --git a/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart index 00685894a..97c0a4f56 100644 --- a/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart +++ b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart @@ -51,7 +51,8 @@ class SuperDesktopTextField extends StatefulWidget { this.tapRegionGroupId, this.textController, this.textStyleBuilder = defaultTextFieldStyleBuilder, - this.textAlign, + this.inlineWidgetBuilders = const [], + this.textAlign = TextAlign.left, this.hintBehavior = HintBehavior.displayHintUntilFocus, this.hintBuilder, this.selectionHighlightStyle = const SelectionHighlightStyle( @@ -92,6 +93,9 @@ class SuperDesktopTextField extends StatefulWidget { /// [textController] based on the attributions in that content. final AttributionStyleBuilder textStyleBuilder; + /// {@macro super_text_field_inline_widget_builders} + final InlineWidgetBuilderChain inlineWidgetBuilders; + /// Policy for when the hint should be displayed. final HintBehavior hintBehavior; @@ -525,7 +529,7 @@ class SuperDesktopTextFieldState extends State implements textDirection: _textDirection, child: SuperText( key: _textKey, - richText: _controller.text.computeTextSpan(widget.textStyleBuilder), + richText: _controller.text.computeInlineSpan(context, widget.textStyleBuilder, widget.inlineWidgetBuilders), textAlign: _textAlign, textDirection: _textDirection, textScaler: _textScaler, diff --git a/super_editor/lib/src/super_textfield/infrastructure/attributed_text_editing_controller.dart b/super_editor/lib/src/super_textfield/infrastructure/attributed_text_editing_controller.dart index b027a6ba6..94f100010 100644 --- a/super_editor/lib/src/super_textfield/infrastructure/attributed_text_editing_controller.dart +++ b/super_editor/lib/src/super_textfield/infrastructure/attributed_text_editing_controller.dart @@ -655,6 +655,7 @@ class AttributedTextEditingController with ChangeNotifier { notifyListeners(); } + @Deprecated('Use text.computeInlineSpan() instead, which adds support for inline widgets.') TextSpan buildTextSpan(AttributionStyleBuilder styleBuilder) { return text.computeTextSpan(styleBuilder); } diff --git a/super_editor/lib/src/super_textfield/ios/ios_textfield.dart b/super_editor/lib/src/super_textfield/ios/ios_textfield.dart index be00a479a..08f992843 100644 --- a/super_editor/lib/src/super_textfield/ios/ios_textfield.dart +++ b/super_editor/lib/src/super_textfield/ios/ios_textfield.dart @@ -42,7 +42,8 @@ class SuperIOSTextField extends StatefulWidget { this.tapHandlers = const [], this.textController, this.textStyleBuilder = defaultTextFieldStyleBuilder, - this.textAlign, + this.inlineWidgetBuilders = const [], + this.textAlign = TextAlign.left, this.padding, this.hintBehavior = HintBehavior.displayHintUntilFocus, this.hintBuilder, @@ -83,6 +84,9 @@ class SuperIOSTextField extends StatefulWidget { /// [textController] based on the attributions in that content. final AttributionStyleBuilder textStyleBuilder; + /// {@macro super_text_field_inline_widget_builders} + final InlineWidgetBuilderChain inlineWidgetBuilders; + /// Padding placed around the text content of this text field, but within the /// scrollable viewport. final EdgeInsets? padding; @@ -630,9 +634,8 @@ class SuperIOSTextFieldState extends State } Widget _buildSelectableText() { - final textSpan = _textEditingController.text.isNotEmpty - ? _textEditingController.text.computeTextSpan(widget.textStyleBuilder) - : AttributedText().computeTextSpan(widget.textStyleBuilder); + final textSpan = _textEditingController.text // + .computeInlineSpan(context, widget.textStyleBuilder, widget.inlineWidgetBuilders); CaretStyle caretStyle = widget.caretStyle; diff --git a/super_editor/lib/src/super_textfield/super_textfield.dart b/super_editor/lib/src/super_textfield/super_textfield.dart index 37ff87fa8..887de51a8 100644 --- a/super_editor/lib/src/super_textfield/super_textfield.dart +++ b/super_editor/lib/src/super_textfield/super_textfield.dart @@ -62,6 +62,7 @@ class SuperTextField extends StatefulWidget { this.textController, this.textAlign, this.textStyleBuilder = defaultTextFieldStyleBuilder, + this.inlineWidgetBuilders = const [], this.hintBehavior = HintBehavior.displayHintUntilFocus, this.hintBuilder, this.controlsColor, @@ -107,6 +108,14 @@ class SuperTextField extends StatefulWidget { /// [textController] based on the attributions in that content. final AttributionStyleBuilder textStyleBuilder; + /// {@template super_text_field_inline_widget_builders} + /// A Chain of Responsibility that's used to build inline widgets. + /// + /// The first builder in the chain to return a non-null `Widget` will be + /// used for a given inline placeholder. + /// {@endtemplate} + final InlineWidgetBuilderChain inlineWidgetBuilders; + /// Policy for when the hint should be displayed. final HintBehavior hintBehavior; @@ -364,6 +373,7 @@ class SuperTextFieldState extends State implements ImeInputOwner textController: _controller, textAlign: widget.textAlign, textStyleBuilder: widget.textStyleBuilder, + inlineWidgetBuilders: widget.inlineWidgetBuilders, hintBehavior: widget.hintBehavior, hintBuilder: widget.hintBuilder, selectionHighlightStyle: SelectionHighlightStyle( @@ -398,6 +408,7 @@ class SuperTextFieldState extends State implements ImeInputOwner textController: _controller, textAlign: widget.textAlign, textStyleBuilder: widget.textStyleBuilder, + inlineWidgetBuilders: widget.inlineWidgetBuilders, hintBehavior: widget.hintBehavior, hintBuilder: widget.hintBuilder, caretStyle: widget.caretStyle ?? @@ -427,6 +438,7 @@ class SuperTextFieldState extends State implements ImeInputOwner textController: _controller, textAlign: widget.textAlign, textStyleBuilder: widget.textStyleBuilder, + inlineWidgetBuilders: widget.inlineWidgetBuilders, padding: widget.padding, hintBehavior: widget.hintBehavior, hintBuilder: widget.hintBuilder, diff --git a/super_editor/test/super_textfield/super_textfield_attributions_test.dart b/super_editor/test/super_textfield/super_textfield_attributions_test.dart index bf810f752..d95e26525 100644 --- a/super_editor/test/super_textfield/super_textfield_attributions_test.dart +++ b/super_editor/test/super_textfield/super_textfield_attributions_test.dart @@ -31,10 +31,12 @@ void main() { ); // Ensure the text is colored orange. - expect( - SuperTextFieldInspector.findRichText().style!.color, - Colors.orange, - ); + for (int i = 0; i <= 9; i++) { + expect( + SuperTextFieldInspector.findRichText().getSpanForPosition(TextPosition(offset: i))!.style!.color, + Colors.orange, + ); + } }); testWidgetsOnAllPlatforms("to partial text", (tester) async { diff --git a/super_editor/test/super_textfield/super_textfield_inline_widgets_test.dart b/super_editor/test/super_textfield/super_textfield_inline_widgets_test.dart new file mode 100644 index 000000000..7c09c7901 --- /dev/null +++ b/super_editor/test/super_textfield/super_textfield_inline_widgets_test.dart @@ -0,0 +1,412 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; + +import 'super_textfield_inspector.dart'; +import 'super_textfield_robot.dart'; + +void main() { + group('SuperTextField > inline widgets >', () { + testWidgetsOnAllPlatforms('renders single inline widget at beginning of the text', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'Hello', + null, + { + 0: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Ensure the widget was rendered. + expect( + find.byPlaceholderName('1'), + findsOneWidget, + ); + + // Ensure the inline widget was rendered at the beginning of the textfield. + final inlineWidgetRect = tester.getRect(find.byPlaceholderName('1')); + expect( + inlineWidgetRect.left, + tester.getTopLeft(find.byType(SuperTextField)).dx, + ); + }); + + testWidgetsOnAllPlatforms('renders single inline widget at middle of the text', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'inline', + null, + { + 3: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Ensure the inline widget was rendered between characters at offsets + // 2 and 3 of the original string. + final inlineWidgetRect = tester.getRect(find.byPlaceholderName('1')); + final (beforeInlineWidget, afterInlineWidget) = _getOffsetsAroundPosition( + tester, + const TextPosition(offset: 3), + ); + expect(inlineWidgetRect.left, greaterThan(beforeInlineWidget.dx)); + expect(inlineWidgetRect.left, lessThan(afterInlineWidget.dx)); + }); + + testWidgetsOnAllPlatforms('renders single inline widget at end of the text', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'Hello', + null, + { + 5: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Ensure the widget was rendered. + expect( + find.byPlaceholderName('1'), + findsOneWidget, + ); + + // Ensure the inline widget was rendered after the last character. + final inlineWidgetRect = tester.getRect(find.byPlaceholderName('1')); + expect( + inlineWidgetRect.left, + greaterThan(_getOffsetAtPosition(tester, const TextPosition(offset: 4)).dx), + ); + }); + + testWidgetsOnAllPlatforms('renders multiple inline widgets', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'Hello', + null, + { + 0: const _NamedPlaceHolder('1'), + 6: const _NamedPlaceHolder('2'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Ensure the first widget was rendered. + expect( + find.byPlaceholderName('1'), + findsOneWidget, + ); + + // Ensure the first inline widget was rendered at the beginning of the textfield. + final firstInlineWidgetRect = tester.getRect(find.byPlaceholderName('1')); + expect( + firstInlineWidgetRect.left, + tester.getTopLeft(find.byType(SuperTextField)).dx, + ); + + // Ensure the second widget was rendered. + expect( + find.byPlaceholderName('2'), + findsOneWidget, + ); + + // Ensure the second inline widget was rendered at the end of the textfield. + final secondInlineWidgetRect = tester.getRect(find.byPlaceholderName('2')); + expect( + secondInlineWidgetRect.left, + _getOffsetAtPosition(tester, const TextPosition(offset: 6)).dx, + ); + }); + + testWidgetsOnAllPlatforms('places caret when tapping on inline widget', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'inline', + null, + { + 3: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Tap on the inline widget. + await tester.tapAt(tester.getTopLeft(find.byPlaceholderName('1'))); + await tester.pump(kDoubleTapTimeout); + + // Ensure the caret is placed just before the inline widget. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 3), + ); + }); + + testWidgetsOnDesktop('navigates using arrow keys', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'inline', + null, + { + 3: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Place caret at "in|line". + await tester.placeCaretInSuperTextField(2); + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 2), + ); + + // Place RIGHT ARROW twice to move the caret to the position + // immediately after the inline widget. + await tester.pressRightArrow(); + await tester.pressRightArrow(); + + // Ensure that the caret moved. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 4), + ); + + // Place LEFT ARROW to move the caret back to the position + // immediately before the inline widget. + await tester.pressLeftArrow(); + + // Ensure that the caret moved. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 3), + ); + }); + + testWidgetsOnDesktop('deletes inline widget with backspace', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'inline', + null, + { + 3: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Ensure the widget is present. + expect( + find.byPlaceholderName('1'), + findsOneWidget, + ); + + // Place the caret at the position immediately after the inline widget. + await tester.placeCaretInSuperTextField(4); + + // Press backspace to remove the inline widget. + await tester.pressBackspace(); + + // Ensure the widget was not rendered. + expect( + find.byPlaceholderName('1'), + findsNothing, + ); + + // Ensure the original text remains unmodified. + expect( + SuperTextFieldInspector.findText().toPlainText(), + 'inline', + ); + }); + + testWidgetsOnDesktop('deletes inline widget with delete', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'inline', + null, + { + 3: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Ensure the widget is present. + expect( + find.byPlaceholderName('1'), + findsOneWidget, + ); + + // Place the caret before the inline widget. + await tester.placeCaretInSuperTextField(3); + + // Press delete to remove the inline widget. + await tester.pressDelete(); + + // Ensure the widget was not rendered. + expect( + find.byPlaceholderName('1'), + findsNothing, + ); + + // Ensure the original text remains unmodified. + expect( + SuperTextFieldInspector.findText().toPlainText(), + 'inline', + ); + }); + + testWidgetsOnDesktop('deletes inline widget inside expanded selection', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'before inline after', + null, + { + 10: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Ensure the widget is present. + expect( + find.byPlaceholderName('1'), + findsOneWidget, + ); + + // Place caret at "|inline". + await tester.placeCaretInSuperTextField(7); + + // Press shift + right arrow to expand the selection to "|inl�ine|", + // where "�" means the inline widget. + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + + // Press backspace to remove the selected content. + await tester.pressBackspace(); + + // Ensure the widget was not rendered. + expect( + find.byPlaceholderName('1'), + findsNothing, + ); + + // Ensure the text was updated. + expect( + SuperTextFieldInspector.findText().toPlainText(), + 'before after', + ); + }); + }); +} + +/// Pump a test app with a [SuperTextField] that renders a [ColoredBox] for each +/// [_NamedPlaceHolder] in the text. +Future _pumpTestApp( + WidgetTester tester, { + required AttributedTextEditingController controller, +}) { + return tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + child: SuperTextField( + textController: controller, + inlineWidgetBuilders: const [ + _boxPlaceHolderBuilder, + ], + ), + ), + ), + ), + ); +} + +/// A builder that renders a [ColoredBox] for a [_NamedPlaceHolder]. +Widget? _boxPlaceHolderBuilder(BuildContext context, TextStyle textStyle, Object placeholder) { + if (placeholder is! _NamedPlaceHolder) { + return null; + } + + return KeyedSubtree( + key: ValueKey('placeholder-${placeholder.name}'), + child: LineHeight( + style: textStyle, + child: const SizedBox( + width: 24, + child: ColoredBox( + color: Colors.yellow, + ), + ), + ), + ); +} + +/// Returns the [Offset] of the given [textPosition] in the [SuperTextField], +/// in global coordinates. +Offset _getOffsetAtPosition(WidgetTester tester, TextPosition textPosition) { + final renderBox = tester.renderObject(find.byType(SuperTextField)) as RenderBox; + final textLayout = SuperTextFieldInspector.findProseTextLayout(); + + return renderBox.localToGlobal(textLayout.getOffsetAtPosition(textPosition)); +} + +/// Returns the [Offset]s of the positions before and after the given [textPosition] +/// in the [SuperTextField], in global coordinates. +/// +/// For example, for the text "world" and the position 2, this method will return +/// the offsets for the letters "o" and "l". +/// +/// This method assumes that there are characters before and after the given position. +(Offset offsetBefore, Offset offsetAfter) _getOffsetsAroundPosition(WidgetTester tester, TextPosition textPosition) { + final renderBox = tester.renderObject(find.byType(SuperTextField)) as RenderBox; + final textLayout = SuperTextFieldInspector.findProseTextLayout(); + + final offsetBefore = textLayout.getOffsetAtPosition(TextPosition(offset: textPosition.offset - 1)); + final offsetAfter = textLayout.getOffsetAtPosition(TextPosition(offset: textPosition.offset + 1)); + + return (renderBox.localToGlobal(offsetBefore), renderBox.localToGlobal(offsetAfter)); +} + +/// A placeholder that is identified by a name. +class _NamedPlaceHolder { + const _NamedPlaceHolder(this.name); + + final String name; + + @override + bool operator ==(Object other) => + identical(this, other) || other is _NamedPlaceHolder && runtimeType == other.runtimeType && name == other.name; + + @override + int get hashCode => name.hashCode; +} + +extension _WidgetForPlaceholderFinder on CommonFinders { + /// Finds a widget that represents a placeholder with the given name. + Finder byPlaceholderName(String name) { + return byKey(ValueKey('placeholder-$name')); + } +} diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_caret_downstream.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_caret_downstream.png new file mode 100644 index 000000000..44cc35cd0 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_caret_downstream.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_caret_upstream.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_caret_upstream.png new file mode 100644 index 000000000..218ed68a3 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_caret_upstream.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_downstream.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_downstream.png new file mode 100644 index 000000000..509ff0bbf Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_downstream.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_over.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_over.png new file mode 100644 index 000000000..b0d3cf4f5 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_over.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_single.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_single.png new file mode 100644 index 000000000..e1493f67d Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_single.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_upstream.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_upstream.png new file mode 100644 index 000000000..966f7e82a Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_upstream.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_caret_downstream.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_caret_downstream.png new file mode 100644 index 000000000..89b669097 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_caret_downstream.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_caret_upstream.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_caret_upstream.png new file mode 100644 index 000000000..b7694d788 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_caret_upstream.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_downstream.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_downstream.png new file mode 100644 index 000000000..7af715cad Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_downstream.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_over.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_over.png new file mode 100644 index 000000000..08380e0f2 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_over.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_single.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_single.png new file mode 100644 index 000000000..337825a5d Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_single.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_upstream.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_upstream.png new file mode 100644 index 000000000..606e54660 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_upstream.png differ diff --git a/super_editor/test_goldens/super_textfield/super_textfield_inline_widgets_test.dart b/super_editor/test_goldens/super_textfield/super_textfield_inline_widgets_test.dart new file mode 100644 index 000000000..248dad281 --- /dev/null +++ b/super_editor/test_goldens/super_textfield/super_textfield_inline_widgets_test.dart @@ -0,0 +1,280 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; + +import '../../test/super_textfield/super_textfield_robot.dart'; +import '../test_tools_goldens.dart'; + +Future main() async { + await loadAppFonts(); + + group('SuperTextField > inline widgets >', () { + group('single line >', () { + testGoldensOnMac( + 'displays caret at upstream side of inline widget', + (tester) async { + await _pumpSingleLineTestApp(tester); + + // Place the caret at the upstream side of the inline widget. + await tester.placeCaretInSuperTextField(7); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_single_line_caret_upstream'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays caret at downstream side of inline widget', + (tester) async { + await _pumpSingleLineTestApp(tester); + + // Place the caret at the downstream side of the inline widget. + await tester.placeCaretInSuperTextField(8); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_single_line_caret_downstream'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays selection box when selecting inline widget', + (tester) async { + await _pumpSingleLineTestApp( + tester, + initialSelection: const TextSelection(baseOffset: 7, extentOffset: 8), + ); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_single_line_selection_box_single'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays selection box upstream near inline widget', + (tester) async { + await _pumpSingleLineTestApp( + tester, + initialSelection: const TextSelection(baseOffset: 0, extentOffset: 7), + ); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_single_line_selection_box_upstream'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays selection box downstream near inline widget', + (tester) async { + await _pumpSingleLineTestApp( + tester, + initialSelection: const TextSelection(baseOffset: 8, extentOffset: 14), + ); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_single_line_selection_box_downstream'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays selection box when selecting over inline widget', + (tester) async { + await _pumpSingleLineTestApp( + tester, + initialSelection: const TextSelection(baseOffset: 0, extentOffset: 14), + ); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_single_line_selection_box_over'); + }, + windowSize: goldenSizeSmall, + ); + }); + + group('multi line >', () { + testGoldensOnMac( + 'displays caret at upstream side of inline widget', + (tester) async { + await _pumpMultiLineTestApp(tester); + + // Place the caret at the upstream side of the inline widget. + await tester.placeCaretInSuperTextField(27); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_multi_line_caret_upstream'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays caret at downstream side of inline widget', + (tester) async { + await _pumpMultiLineTestApp(tester); + + // Place the caret at the downstream side of the inline widget. + await tester.placeCaretInSuperTextField(28); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_multi_line_caret_downstream'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays selection box when selecting inline widget', + (tester) async { + await _pumpMultiLineTestApp( + tester, + initialSelection: const TextSelection(baseOffset: 27, extentOffset: 28), + ); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_multi_line_selection_box_single'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays selection box upstream near inline widget', + (tester) async { + await _pumpMultiLineTestApp( + tester, + initialSelection: const TextSelection(baseOffset: 0, extentOffset: 27), + ); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_multi_line_selection_box_upstream'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays selection box downstream near inline widget', + (tester) async { + await _pumpMultiLineTestApp( + tester, + initialSelection: const TextSelection(baseOffset: 28, extentOffset: 53), + ); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_multi_line_selection_box_downstream'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays selection box when selecting over inline widget', + (tester) async { + await _pumpMultiLineTestApp( + tester, + initialSelection: const TextSelection(baseOffset: 0, extentOffset: 53), + ); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_multi_line_selection_box_over'); + }, + windowSize: goldenSizeSmall, + ); + }); + }); +} + +/// Pump a test app with a [SuperTextField] that renders a [ColoredBox] for each +/// [_NamedPlaceHolder] in the text, with an inline widget at offset 7. +Future _pumpSingleLineTestApp( + WidgetTester tester, { + TextSelection? initialSelection, +}) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'before after', + null, + { + 7: const _NamedPlaceHolder('1'), + }, + ), + selection: initialSelection, + ); + await _pumpTestApp(tester, controller: controller); +} + +/// Pump a test app with a [SuperTextField] that renders a [ColoredBox] for each +/// [_NamedPlaceHolder] in the text, with an inline widget at offset 27. +Future _pumpMultiLineTestApp( + WidgetTester tester, { + TextSelection? initialSelection, +}) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'first line of text \nbefore after\nthird line of text', + null, + { + 27: const _NamedPlaceHolder('1'), + }, + ), + selection: initialSelection, + ); + await _pumpTestApp(tester, controller: controller); +} + +/// Pump a test app with a [SuperTextField] that renders a [ColoredBox] for each +/// [_NamedPlaceHolder] in the text. +Future _pumpTestApp( + WidgetTester tester, { + required AttributedTextEditingController controller, +}) async { + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(4.0), + child: SizedBox( + height: 300, + child: SuperTextField( + textController: controller, + textStyleBuilder: (attributions) => const TextStyle( + // Use Roboto so that goldens show real text. + fontFamily: 'Roboto', + fontSize: 18, + color: Colors.black, + ), + inlineWidgetBuilders: const [ + _boxPlaceHolderBuilder, + ], + ), + ), + ), + ), + ), + ), + ); +} + +/// A builder that renders a [ColoredBox] for a [_NamedPlaceHolder]. +Widget? _boxPlaceHolderBuilder(BuildContext context, TextStyle textStyle, Object placeholder) { + if (placeholder is! _NamedPlaceHolder) { + return null; + } + + return KeyedSubtree( + key: ValueKey('placeholder-${placeholder.name}'), + child: LineHeight( + style: textStyle, + child: const SizedBox( + width: 24, + child: ColoredBox( + color: Colors.yellow, + ), + ), + ), + ); +} + +// A placeholder that is identified by a name. +class _NamedPlaceHolder { + const _NamedPlaceHolder(this.name); + + final String name; + + @override + bool operator ==(Object other) => + identical(this, other) || other is _NamedPlaceHolder && runtimeType == other.runtimeType && name == other.name; + + @override + int get hashCode => name.hashCode; +}