diff --git a/super_text_layout/lib/src/inline_widgets.dart b/super_text_layout/lib/src/inline_widgets.dart new file mode 100644 index 0000000000..8121a72875 --- /dev/null +++ b/super_text_layout/lib/src/inline_widgets.dart @@ -0,0 +1,31 @@ +/// A placeholder to be given to an `AttributedText`, and later replaced +/// within an inline network image. +class InlineNetworkImagePlaceholder { + const InlineNetworkImagePlaceholder(this.url); + + final String url; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is InlineNetworkImagePlaceholder && runtimeType == other.runtimeType && url == other.url; + + @override + int get hashCode => url.hashCode; +} + +/// A placeholder to be given to an `AttributedText`, and later replaced +/// within an inline asset image. +class InlineAssetImagePlaceholder { + const InlineAssetImagePlaceholder(this.assetPath); + + final String assetPath; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is InlineAssetImagePlaceholder && runtimeType == other.runtimeType && assetPath == other.assetPath; + + @override + int get hashCode => assetPath.hashCode; +} diff --git a/super_text_layout/lib/src/super_text_layout_with_selection.dart b/super_text_layout/lib/src/super_text_layout_with_selection.dart index ee8b704be6..2329f8eb49 100644 --- a/super_text_layout/lib/src/super_text_layout_with_selection.dart +++ b/super_text_layout/lib/src/super_text_layout_with_selection.dart @@ -246,7 +246,13 @@ class _RebuildOptimizedSuperTextWithSelectionState extends State<_RebuildOptimiz style: userSelection.caretStyle, blinkCaret: userSelection.blinkCaret, blinkTimingMode: userSelection.blinkTimingMode, - position: userSelection.selection.extent, + position: TextPosition( + offset: userSelection.selection.extent.offset, + affinity: TextAffinity.downstream, + ), + // ^ We force downstream, instead of upstream, to reduce the buggyness + // of caret sizing in Flutter when placed near an inline widget. + // Issue: https://github.com/flutter/flutter/issues/159932 caretTracker: userSelection.caretFollower, ), ], diff --git a/super_text_layout/lib/src/text_selection_layer.dart b/super_text_layout/lib/src/text_selection_layer.dart index fbaacb4d49..1da37c14e1 100644 --- a/super_text_layout/lib/src/text_selection_layer.dart +++ b/super_text_layout/lib/src/text_selection_layer.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/widgets.dart'; import 'text_layout.dart'; @@ -160,7 +162,10 @@ class TextSelectionPainter extends CustomPainter { return; } - final selectionBoxes = textLayout!.getBoxesForSelection(textSelection!); + final selectionBoxes = textLayout!.getBoxesForSelection( + textSelection!, + boxHeightStyle: BoxHeightStyle.max, + ); for (final box in selectionBoxes) { final rawRect = box.toRect(); diff --git a/super_text_layout/lib/super_text_layout.dart b/super_text_layout/lib/super_text_layout.dart index 658f1513a6..ea9366aa56 100644 --- a/super_text_layout/lib/super_text_layout.dart +++ b/super_text_layout/lib/super_text_layout.dart @@ -1,4 +1,5 @@ export 'src/caret_layer.dart'; +export 'src/inline_widgets.dart'; export 'src/super_text.dart'; export 'src/super_text_layout_with_selection.dart'; export 'src/text_layout.dart'; diff --git a/super_text_layout/test_goldens/goldens/SuperText-inline-widgets-alignment.png b/super_text_layout/test_goldens/goldens/SuperText-inline-widgets-alignment.png new file mode 100644 index 0000000000..fa272d289b Binary files /dev/null and b/super_text_layout/test_goldens/goldens/SuperText-inline-widgets-alignment.png differ diff --git a/super_text_layout/test_goldens/goldens/SuperText-inline-widgets-sizing.png b/super_text_layout/test_goldens/goldens/SuperText-inline-widgets-sizing.png new file mode 100644 index 0000000000..36d14a717f Binary files /dev/null and b/super_text_layout/test_goldens/goldens/SuperText-inline-widgets-sizing.png differ diff --git a/super_text_layout/test_goldens/goldens/SuperText-reference-render.png b/super_text_layout/test_goldens/goldens/SuperText-reference-render.png index b3f6242475..3386771d62 100644 Binary files a/super_text_layout/test_goldens/goldens/SuperText-reference-render.png and b/super_text_layout/test_goldens/goldens/SuperText-reference-render.png differ diff --git a/super_text_layout/test_goldens/goldens/SuperText-text-scale-factor.png b/super_text_layout/test_goldens/goldens/SuperText-text-scale-factor.png index ee185bcbd9..88cdac889b 100644 Binary files a/super_text_layout/test_goldens/goldens/SuperText-text-scale-factor.png and b/super_text_layout/test_goldens/goldens/SuperText-text-scale-factor.png differ diff --git a/super_text_layout/test_goldens/goldens/SuperText_layers_caret.png b/super_text_layout/test_goldens/goldens/SuperText_layers_caret.png index 1993a2d4df..988ac1a6df 100644 Binary files a/super_text_layout/test_goldens/goldens/SuperText_layers_caret.png and b/super_text_layout/test_goldens/goldens/SuperText_layers_caret.png differ diff --git a/super_text_layout/test_goldens/goldens/SuperText_layers_character-box-outlines.png b/super_text_layout/test_goldens/goldens/SuperText_layers_character-box-outlines.png index 56d3482ad4..56afb108b3 100644 Binary files a/super_text_layout/test_goldens/goldens/SuperText_layers_character-box-outlines.png and b/super_text_layout/test_goldens/goldens/SuperText_layers_character-box-outlines.png differ diff --git a/super_text_layout/test_goldens/goldens/SuperText_layers_character-boxes.png b/super_text_layout/test_goldens/goldens/SuperText_layers_character-boxes.png index 64fa6a9d65..97c60aca14 100644 Binary files a/super_text_layout/test_goldens/goldens/SuperText_layers_character-boxes.png and b/super_text_layout/test_goldens/goldens/SuperText_layers_character-boxes.png differ diff --git a/super_text_layout/test_goldens/goldens/SuperText_layers_line-boxes.png b/super_text_layout/test_goldens/goldens/SuperText_layers_line-boxes.png index da31fd7234..ab4740881b 100644 Binary files a/super_text_layout/test_goldens/goldens/SuperText_layers_line-boxes.png and b/super_text_layout/test_goldens/goldens/SuperText_layers_line-boxes.png differ diff --git a/super_text_layout/test_goldens/goldens/TextSelectionLayer_full-selection-border-radius.png b/super_text_layout/test_goldens/goldens/TextSelectionLayer_full-selection-border-radius.png index 30b8a9ff77..9ea64f69d2 100644 Binary files a/super_text_layout/test_goldens/goldens/TextSelectionLayer_full-selection-border-radius.png and b/super_text_layout/test_goldens/goldens/TextSelectionLayer_full-selection-border-radius.png differ diff --git a/super_text_layout/test_goldens/goldens/TextSelectionLayer_full-selection.png b/super_text_layout/test_goldens/goldens/TextSelectionLayer_full-selection.png index 518603f31d..9b361b517c 100644 Binary files a/super_text_layout/test_goldens/goldens/TextSelectionLayer_full-selection.png and b/super_text_layout/test_goldens/goldens/TextSelectionLayer_full-selection.png differ diff --git a/super_text_layout/test_goldens/goldens/TextSelectionLayer_partial-selection-border-radius.png b/super_text_layout/test_goldens/goldens/TextSelectionLayer_partial-selection-border-radius.png index 8adf2818f6..bf1a20fa58 100644 Binary files a/super_text_layout/test_goldens/goldens/TextSelectionLayer_partial-selection-border-radius.png and b/super_text_layout/test_goldens/goldens/TextSelectionLayer_partial-selection-border-radius.png differ diff --git a/super_text_layout/test_goldens/goldens/TextSelectionLayer_partial-selection.png b/super_text_layout/test_goldens/goldens/TextSelectionLayer_partial-selection.png index acfe7968b0..a7ade936ee 100644 Binary files a/super_text_layout/test_goldens/goldens/TextSelectionLayer_partial-selection.png and b/super_text_layout/test_goldens/goldens/TextSelectionLayer_partial-selection.png differ diff --git a/super_text_layout/test_goldens/goldens/TextUnderlineLayer_paints-underline.png b/super_text_layout/test_goldens/goldens/TextUnderlineLayer_paints-underline.png index bf248317e4..675b813036 100644 Binary files a/super_text_layout/test_goldens/goldens/TextUnderlineLayer_paints-underline.png and b/super_text_layout/test_goldens/goldens/TextUnderlineLayer_paints-underline.png differ diff --git a/super_text_layout/test_goldens/inline_widgets_test.dart b/super_text_layout/test_goldens/inline_widgets_test.dart new file mode 100644 index 0000000000..0a07c8e291 --- /dev/null +++ b/super_text_layout/test_goldens/inline_widgets_test.dart @@ -0,0 +1,339 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +import 'test_tools_goldens.dart'; + +void main() { + group("SuperText inline widgets >", () { + testGoldensOnAndroid("vertical alignments", (tester) async { + await tester.pumpWidget( + _buildScaffold( + // ignore: prefer_const_constructors + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SuperTextWithSelection.single( + richText: _allAlignmentsWithText, + userSelection: const UserSelection( + selection: TextSelection(baseOffset: 0, extentOffset: 69), + ), + ), + const SizedBox(height: 24), + SuperTextWithSelection.single( + richText: _allAlignmentsNoText, + userSelection: const UserSelection( + selection: TextSelection(baseOffset: 0, extentOffset: 6), + ), + ), + const SizedBox(height: 24), + SuperTextWithSelection.single( + richText: _allAlignmentsMultipleSizesSmallToLarge, + userSelection: const UserSelection( + selection: TextSelection(baseOffset: 0, extentOffset: 42), + ), + ), + const SizedBox(height: 24), + SuperTextWithSelection.single( + richText: _allAlignmentsMultipleSizesLargeToSmall, + userSelection: const UserSelection( + selection: TextSelection(baseOffset: 0, extentOffset: 42), + ), + ), + ], + ), + ), + ); + + await screenMatchesGolden(tester, "SuperText-inline-widgets-alignment"); + }); + + testGoldensOnAndroid("sizing", (tester) async { + // This test demonstrates the mechanism that we can use to make + // inline widgets the same height as the surrounding text (assuming + // the surrounding text uses the same text style). + final textPainter12 = TextPainter( + text: TextSpan( + text: 'a', + style: _testTextStyle.copyWith(fontSize: 12), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final textPainter18 = TextPainter( + text: TextSpan( + text: 'a', + style: _testTextStyle.copyWith(fontSize: 18), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final textPainter32 = TextPainter( + text: TextSpan( + text: 'a', + style: _testTextStyle.copyWith(fontSize: 32), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final textPainter64 = TextPainter( + text: TextSpan( + text: 'a', + style: _testTextStyle.copyWith(fontSize: 64), + ), + textDirection: TextDirection.ltr, + )..layout(); + + await tester.pumpWidget( + _buildScaffold( + // ignore: prefer_const_constructors + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SuperTextWithSelection.single( + richText: TextSpan( + text: '', + children: [ + const TextSpan(text: 'Hello '), + WidgetSpan( + child: _inlineSquare(textPainter12.height), + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic), + const TextSpan(text: 'World!'), + ], + style: _testTextStyle.copyWith( + fontSize: 12, + ), + ), + userSelection: const UserSelection( + selection: TextSelection(baseOffset: 0, extentOffset: 69), + ), + ), + const SizedBox(height: 24), + SuperTextWithSelection.single( + richText: TextSpan( + text: '', + children: [ + const TextSpan(text: 'Hello '), + WidgetSpan( + child: _inlineSquare(textPainter18.height), + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic), + const TextSpan(text: 'World!'), + ], + style: _testTextStyle.copyWith( + fontSize: 18, + ), + ), + userSelection: const UserSelection( + selection: TextSelection(baseOffset: 0, extentOffset: 69), + ), + ), + const SizedBox(height: 24), + SuperTextWithSelection.single( + richText: TextSpan( + text: '', + children: [ + const TextSpan(text: 'Hello '), + WidgetSpan( + child: _inlineSquare(textPainter32.height), + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic), + const TextSpan(text: 'World!'), + ], + style: _testTextStyle.copyWith( + fontSize: 32, + ), + ), + userSelection: const UserSelection( + selection: TextSelection(baseOffset: 0, extentOffset: 69), + ), + ), + const SizedBox(height: 24), + SuperTextWithSelection.single( + richText: TextSpan( + text: '', + children: [ + const TextSpan(text: 'Hello '), + WidgetSpan( + child: _inlineSquare(textPainter64.height), + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic), + const TextSpan(text: 'World!'), + ], + style: _testTextStyle.copyWith( + fontSize: 64, + ), + ), + userSelection: const UserSelection( + selection: TextSelection(baseOffset: 0, extentOffset: 69), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ); + + await screenMatchesGolden(tester, "SuperText-inline-widgets-sizing"); + }); + }); +} + +final _allAlignmentsWithText = TextSpan( + text: "", + children: [ + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.top), + const TextSpan( + text: "< Top", + ), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.middle), + const TextSpan( + text: "< Middle", + ), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.bottom), + const TextSpan( + text: "< Bottom", + ), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.aboveBaseline, baseline: TextBaseline.alphabetic), + const TextSpan( + text: "< Above Baseline", + ), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic), + const TextSpan( + text: "< Baseline", + ), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.belowBaseline, baseline: TextBaseline.alphabetic), + const TextSpan( + text: "< Below Baseline", + ), + ], + style: _testTextStyle, +); + +final _allAlignmentsNoText = TextSpan( + text: "", + children: [ + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.top), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.middle), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.bottom), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.aboveBaseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.belowBaseline, baseline: TextBaseline.alphabetic), + ], + style: _testTextStyle, +); + +final _allAlignmentsMultipleSizesSmallToLarge = TextSpan( + text: "", + children: [ + // Thin + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.top), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.middle), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.bottom), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.aboveBaseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.belowBaseline, baseline: TextBaseline.alphabetic), + const TextSpan( + text: "Hello World!", + ), + // ~Height of text + WidgetSpan(child: _inlineBlock(20), alignment: PlaceholderAlignment.top), + WidgetSpan(child: _inlineBlock(20), alignment: PlaceholderAlignment.middle), + WidgetSpan(child: _inlineBlock(20), alignment: PlaceholderAlignment.bottom), + WidgetSpan( + child: _inlineBlock(20), alignment: PlaceholderAlignment.aboveBaseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(20), alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic), + WidgetSpan( + child: _inlineBlock(20), alignment: PlaceholderAlignment.belowBaseline, baseline: TextBaseline.alphabetic), + const TextSpan( + text: "Hello World!", + ), + // Taller than text + WidgetSpan(child: _inlineBlock(40), alignment: PlaceholderAlignment.top), + WidgetSpan(child: _inlineBlock(40), alignment: PlaceholderAlignment.middle), + WidgetSpan(child: _inlineBlock(40), alignment: PlaceholderAlignment.bottom), + WidgetSpan( + child: _inlineBlock(40), alignment: PlaceholderAlignment.aboveBaseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(40), alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic), + WidgetSpan( + child: _inlineBlock(40), alignment: PlaceholderAlignment.belowBaseline, baseline: TextBaseline.alphabetic), + ], + style: _testTextStyle, +); + +final _allAlignmentsMultipleSizesLargeToSmall = TextSpan( + text: "", + children: [ + // Taller than text + WidgetSpan(child: _inlineBlock(40), alignment: PlaceholderAlignment.top), + WidgetSpan(child: _inlineBlock(40), alignment: PlaceholderAlignment.middle), + WidgetSpan(child: _inlineBlock(40), alignment: PlaceholderAlignment.bottom), + WidgetSpan( + child: _inlineBlock(40), alignment: PlaceholderAlignment.aboveBaseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(40), alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic), + WidgetSpan( + child: _inlineBlock(40), alignment: PlaceholderAlignment.belowBaseline, baseline: TextBaseline.alphabetic), + const TextSpan( + text: "Hello World!", + ), + // ~Height of text + WidgetSpan(child: _inlineBlock(20), alignment: PlaceholderAlignment.top), + WidgetSpan(child: _inlineBlock(20), alignment: PlaceholderAlignment.middle), + WidgetSpan(child: _inlineBlock(20), alignment: PlaceholderAlignment.bottom), + WidgetSpan( + child: _inlineBlock(20), alignment: PlaceholderAlignment.aboveBaseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(20), alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic), + WidgetSpan( + child: _inlineBlock(20), alignment: PlaceholderAlignment.belowBaseline, baseline: TextBaseline.alphabetic), + const TextSpan( + text: "Hello World!", + ), + // Thin + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.top), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.middle), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.bottom), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.aboveBaseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.belowBaseline, baseline: TextBaseline.alphabetic), + ], + style: _testTextStyle, +); + +Widget _inlineBlock([double height = 4]) => Container( + width: 24, + height: height, + margin: const EdgeInsets.symmetric(horizontal: 4), + color: Colors.black, + ); + +Widget _inlineSquare([double height = 4]) => Container( + height: height, + margin: const EdgeInsets.symmetric(horizontal: 4), + child: const AspectRatio( + aspectRatio: 1.0, + child: ColoredBox(color: Colors.black), + ), + ); + +const _testTextStyle = TextStyle( + color: Color(0xFF000000), + fontFamily: 'Roboto', + fontSize: 20, +); + +Widget _buildScaffold({ + required Widget child, +}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: child, + ), + ), + debugShowCheckedModeBanner: false, + ); +}