Repository: superlistapp/super_editor (dart)

Issue:
[AttributedText] - Add single-character placeholders
We want to display small inline images in `SuperEditor`, `SuperReader`, and `SuperTextField`. This begins with a representation of such things in `AttributedText`. `AttributedText` doesn't know anything about rendering, so we don't want to insert any concept of `Widget`s, layout, etc. Instead, we probably want some concept of a "placeholder" which, in effect, is just an extra character whose content isn't chosen until rendering happen.

For this ticket, add the concept of a placeholder to `AttributedText`.

 * Length of `AttributedText` should include a +1 for each placeholder
 * I think accessing plain `text` should produce a string that doesn't have any extra characters for each placeholder. If needed, maybe we make it configurable to inject a desired character in place of each placeholder, but we can wait until that's a requirement.
   * One issue here is any code that has a text offset, and then accesses the character in `text` at that offset, might get the wrong character because the offset includes placeholders, but `text` doesn't include those placeholders, so there's an offset misalignment.
 * Placeholders need to be able to contain arbitrary data so that clients can store whatever is needed to render the eventual widgets.

Pull request description:
Adds placeholders to AttributedText (Resolves #2440)

We want to display inline widgets in `SuperEditor`. This requires a way to represent the existence of such widgets within the text model, which is `AttributedText`.

This PR adds placeholders to `AttributedText`. A placeholder is an `Object` that takes up the space of a single character. This spacing allows for text positions before and after the placeholder, but not within the placeholder.

Each placeholder is represented by some kind of `Object`. We currently don't place any restriction on what that is. Mapping from placeholders to widgets is handled by the visual system.

Patch:
diff --git a/attributed_text/lib/src/attributed_text.dart b/attributed_text/lib/src/attributed_text.dart
index 3bd988e0d5..3bac63cb54 100644
--- a/attributed_text/lib/src/attributed_text.dart
+++ b/attributed_text/lib/src/attributed_text.dart
@@ -1,3 +1,5 @@
+import 'package:collection/collection.dart';
+
 import 'attributed_spans.dart';
 import 'attribution.dart';
 import 'logging.dart';
@@ -17,25 +19,158 @@ final _log = attributionsLog;
 // TODO: there is a mixture of mutable and immutable behavior in this class.
 //       Pick one or the other, or offer 2 classes: mutable and immutable (#113)
 class AttributedText {
+  /// The default character that's inserted in place of placeholders when converting
+  /// an [AttributedText] to plain text.
+  ///
+  /// `\uFFFC` is the unicode character for "object replacement" and it looks
+  /// like a regular space.
+  ///
+  /// `\uFFFD` is a similar character - it's the unicode character for replacing
+  /// unknown characters, and looks like: �
+  static const placeholderCharacter = '\uFFFC';
+
+  /// Constructs an [AttributedText] whose content is comprised by a combination
+  /// of [text] and [placeholders], covered by the given attributed [spans].
+  ///
+  /// [placeholders] is a map from character indices to desired placeholder objects.
+  /// The character indices in [placeholders] refer to the final indices when the
+  /// placeholders have been combined with the [text].
+  ///
+  /// Example:
+  ///  - Full text: "�Hello � World!�"
+  ///  - text: "Hello  World!"
+  ///  - placeholders:
+  ///    - 0:  MyPlaceholder
+  ///    - 7:  MyPlaceholder
+  ///    - 15: MyPlaceholder
+  ///
+  /// Notice in the example above that the final placeholder index is greater
+  /// than the total length of the [text] `String`.
   AttributedText([
     String? text,
     AttributedSpans? spans,
-  ])  : text = text ?? "",
-        spans = spans ?? AttributedSpans();
+    Map<int, Object>? placeholders,
+  ])  : _text = text ?? "",
+        spans = spans ?? AttributedSpans(),
+        placeholders = placeholders ?? <int, Object>{} {
+    assert(() {
+      // ^ Run this in an assert with a callback so that the validation doesn't run in
+      //   production and cost processor cycles.
+      _validatePlaceholderIndices();
+      return true;
+    }());
+
+    if (this.placeholders.isEmpty) {
+      // There aren't any placeholders, so text with placeholders is the same as
+      // text without placeholders.
+      _textWithPlaceholders = _text;
+    } else {
+      // Create a 2nd plain text representation that includes stand-in characters
+      // for placeholders.
+      final buffer = StringBuffer();
+      int start = 0;
+      int insertedPlaceholders = 0;
+      for (final entry in this.placeholders.entries) {
+        final textSegment = _text.substring(start - insertedPlaceholders, entry.key - insertedPlaceholders);
+        buffer.write(textSegment);
+        start += textSegment.length;
+
+        buffer.write(placeholderCharacter);
+        start += 1;
+
+        insertedPlaceholders += 1;
+      }
+      if (start - insertedPlaceholders < _text.length) {
+        buffer.write(_text.substring(start - insertedPlaceholders, _text.length));
+      }
+
+      _textWithPlaceholders = buffer.toString();
+    }
+  }
+
+  void _validatePlaceholderIndices() {
+    // Ensure that none of the placeholders have negative indices.
+    assert(
+      placeholders.entries.where((entry) => entry.key < 0).isEmpty,
+      "All placeholders must have indices >= 0",
+    );
+
+    // Ensure that none of the placeholders sit beyond the end of the text and other
+    // placeholders.
+    int maxAllowableIndex = _text.length;
+    for (final entry in placeholders.entries) {
+      if (entry.key > maxAllowableIndex) {
+        throw AssertionError("Invalid placeholder index. The index is too large. ${entry.key} -> ${entry.value}.");
+      }
+
+      maxAllowableIndex += 1;
+    }
+  }
 
   void dispose() {
     _listeners.clear();
   }
 
   /// The text that this [AttributedText] attributes.
-  final String text;
+  @Deprecated("Use toPlainText() instead, so you can choose whether to include placeholder characters")
+  String get text => _text;
+  final String _text;
+
+  late final String _textWithPlaceholders;
 
-  /// Returns the `length` of this [AttributedText]'s [text] `String`.
+  /// Returns a plain-text version of this `AttributedText`.
+  ///
+  /// Plain text has no attributions or placeholder objects.
+  ///
+  /// If [includePlaceholders] is `true`, special characters will be inserted
+  /// at every text offset where there is currently a placeholder object. By
+  /// default, the special character is [placeholderCharacter]. To use a different
+  /// character, provide a [replacementCharacter].
   ///
-  /// This accessor is a convenience to avoid writing `myAttText.text.length`.
-  int get length => text.length;
+  /// if [includePlaceholders] is `false`, placeholders will be replaced
+  /// with nothing. In that case, the returned `String` will be shorter than
+  /// [length] with a difference equal to the number of placeholders in
+  /// this [AttributedText].
+  String toPlainText({
+    bool includePlaceholders = true,
+    String replacementCharacter = placeholderCharacter,
+  }) {
+    if (includePlaceholders) {
+      if (replacementCharacter != placeholderCharacter) {
+        // The caller wants to use a non-standard character to represent
+        // placeholders. Do a replace-all and return the result.
+        return _textWithPlaceholders.replaceAll(placeholderCharacter, replacementCharacter);
+      }
+
+      return _textWithPlaceholders;
+    }
 
-  /// The attributes applied to [text].
+    return _text;
+  }
+
+  /// Placeholders that represent non-text content, e.g., inline images, that
+  /// should appear in the rendered text.
+  ///
+  /// In terms of [length], each placeholder is treated as a single character.
+  final Map<int, Object> placeholders;
+
+  /// Returns the `length` of this [AttributedText], which includes the length
+  /// of the plain text `String`, and the number of [placeholders].
+  int get length => _text.length + placeholders.length;
+
+  /// Returns `true` if the [length] of this [AttributedText] is zero.
+  ///
+  /// `isEmpty` is `true` if and only if both the plain text and the
+  /// placeholders are empty.
+  bool get isEmpty => _text.isEmpty && placeholders.isEmpty;
+
+  /// Returns `true` if the [length] of this [AttributedText] is greater than zero.
+  ///
+  /// `isNotEmpty` is `true` if the plain text is non-empty, or if the
+  /// placeholders are non-empty, or both.
+  bool get isNotEmpty => _text.isNotEmpty || placeholders.isNotEmpty;
+
+  /// The attributes applied across the plain text and [placeholders].
   final AttributedSpans spans;
 
   final _listeners = <VoidCallback>{};
@@ -126,14 +261,14 @@ class AttributedText {
   /// Returns all spans in this [AttributedText] for the given [attributions].
   Set<AttributionSpan> getAttributionSpans(Set<Attribution> attributions) => getAttributionSpansInRange(
         attributionFilter: (a) => attributions.contains(a),
-        range: SpanRange(0, text.length),
+        range: SpanRange(0, _text.length),
       );
 
   /// Returns all spans in this [AttributedText], for attributions that are
   /// selected by the given [filter].
   Set<AttributionSpan> getAttributionSpansByFilter(AttributionFilter filter) => getAttributionSpansInRange(
         attributionFilter: filter,
-        range: SpanRange(0, text.length),
+        range: SpanRange(0, _text.length),
       );
 
   /// Returns spans for each attribution that (at least partially) appear
@@ -224,6 +359,16 @@ class AttributedText {
     _notifyListeners();
   }
 
+  /// Returns a copy of this [AttributedText], replacing the existing
+  /// [AttributedSpans] with the given [newSpans].
+  AttributedText replaceAttributions(AttributedSpans newSpans) {
+    return AttributedText(
+      _text,
+      newSpans,
+      Map.from(placeholders),
+    );
+  }
+
   /// Removes all attributions within the given [range].
   void clearAttributions(SpanRange range) {
     // TODO: implement this capability within AttributedSpans
@@ -253,37 +398,73 @@ class AttributedText {
   /// and returns them as a new [AttributedText].
   AttributedText copyTextInRange(SpanRange range) => copyText(range.start, range.end);
 
-  /// Copies all text and attributions from [startOffset] to
+  /// Copies all text, attributions, and placeholders from [startOffset] to
   /// [endOffset], exclusive, and returns them as a new [AttributedText].
   AttributedText copyText(int startOffset, [int? endOffset]) {
     _log.fine('start: $startOffset, end: $endOffset');
 
+    final placeholdersBeforeStartOffset = placeholders.entries.where((entry) => entry.key < startOffset);
+    final textStartCopyOffset = startOffset - placeholdersBeforeStartOffset.length;
+
+    final placeholdersAfterStartBeforeEndOffset = placeholders.entries.where(
+      (entry) => startOffset <= entry.key && entry.key < (endOffset ?? length),
+    );
+    final textEndCopyOffset =
+        (endOffset ?? length) - placeholdersBeforeStartOffset.length - placeholdersAfterStartBeforeEndOffset.length;
+
     // Note: -1 because copyText() uses an exclusive `start` and `end` but
     // _copyAttributionRegion() uses an inclusive `start` and `end`.
-    final startCopyOffset = startOffset < text.length ? startOffset : text.length - 1;
+    final startCopyOffset = startOffset < _text.length ? startOffset : _text.length - 1;
     int endCopyOffset;
     if (endOffset == startOffset) {
       endCopyOffset = startCopyOffset;
     } else if (endOffset != null) {
       endCopyOffset = endOffset - 1;
     } else {
-      endCopyOffset = text.length - 1;
+      endCopyOffset = _text.length - 1;
     }
     _log.fine('offsets, start: $startCopyOffset, end: $endCopyOffset');
 
+    // Create placeholders for the copied region. The indices of the placeholders
+    // need to be reduced based on the text/placeholders cut out from the
+    // beginning of this AttributedText.
+    final copiedPlaceholders = <int, Object>{};
+    for (final existingPlaceholder in placeholdersAfterStartBeforeEndOffset) {
+      copiedPlaceholders[existingPlaceholder.key - startOffset] = existingPlaceholder.value;
+    }
+
     return AttributedText(
-      text.substring(startOffset, endOffset),
+      _text.substring(textStartCopyOffset, textEndCopyOffset),
       spans.copyAttributionRegion(startCopyOffset, endCopyOffset),
+      copiedPlaceholders,
     );
   }
 
-  /// Returns a plain-text substring of [text], from [range.start] to [range.end] (exclusive).
+  /// Returns a plain-text substring, from [range.start] to [range.end] (exclusive).
+  ///
+  /// {@macro attributed_text_substring_range}
   String substringInRange(SpanRange range) => substring(range.start, range.end);
 
-  /// Returns a plain-text substring of [text], from [start] to [end] (exclusive), or the end of
-  /// [text] if [end] isn't provided.
+  /// Returns a plain-text substring, from [start] to [end] (exclusive), or the end of
+  /// this [AttributedText] if [end] isn't provided.
+  ///
+  /// {@template attributed_text_substring_range}
+  /// [AttributedText] can contain placeholders, each of which take up one character of length.
+  /// The given [range] is interpreted as a range within this [AttributedText]. If placeholders
+  /// appear within that range, then the length of the returned `String` will be less than the
+  /// length of the range.
+  /// {@endtemplate}
   String substring(int start, [int? end]) {
-    return text.substring(start, end);
+    final placeholdersBeforeStartOffset = placeholders.entries.where((entry) => entry.key < start);
+    final textStartCopyOffset = start - placeholdersBeforeStartOffset.length;
+
+    final placeholdersAfterStartBeforeEndOffset = placeholders.entries.where(
+      (entry) => start <= entry.key && entry.key < (end ?? length),
+    );
+    final textEndCopyOffset =
+        (end ?? length) - placeholdersBeforeStartOffset.length - placeholdersAfterStartBeforeEndOffset.length;
+
+    return _text.substring(textStartCopyOffset, textEndCopyOffset);
   }
 
   /// Returns a copy of this [AttributedText] with the [other] text
@@ -291,25 +472,32 @@ class AttributedText {
   AttributedText copyAndAppend(AttributedText other) {
     _log.fine('our attributions before pushing them:');
     _log.fine(spans.toString());
-    if (other.text.isEmpty) {
+
+    if (other.isEmpty) {
       _log.fine('`other` has no text. Returning a direct copy of ourselves.');
       return AttributedText(
-        text,
+        _text,
         spans.copy(),
+        Map.from(placeholders),
       );
     }
-    if (text.isEmpty) {
+
+    if (isEmpty) {
       _log.fine('our `text` is empty. Returning a direct copy of the `other` text.');
       return AttributedText(
-        other.text,
+        other._text,
         other.spans.copy(),
+        Map.from(other.placeholders),
       );
     }
 
-    final newSpans = spans.copy()..addAt(other: other.spans, index: text.length);
     return AttributedText(
-      text + other.text,
-      newSpans,
+      _text + other._text,
+      spans.copy()..addAt(other: other.spans, index: _text.length),
+      {
+        ...placeholders,
+        ...other.placeholders.map((offset, placeholder) => MapEntry(offset + length, placeholder)),
+      },
     );
   }
 
@@ -358,9 +546,29 @@ class AttributedText {
     return startText.copyAndAppend(insertedText).copyAndAppend(endText);
   }
 
-  /// Copies this [AttributedText] and removes  a region of text
-  /// and attributions from [startOffset], inclusive,
-  /// to [endOffset], exclusive.
+  AttributedText insertPlaceholders(Map<int, Object> placeholders) {
+    var finalText = this;
+    for (final entry in placeholders.entries) {
+      finalText = finalText.insertPlaceholder(entry.key, entry.value);
+    }
+    return finalText;
+  }
+
+  AttributedText insertPlaceholder(int index, Object placeholder) {
+    return AttributedText(_text, spans.copy(), {
+      // Insert existing placeholders that come before the new placeholder.
+      ...Map.fromEntries(placeholders.entries.where((entry) => entry.key < index)),
+      // Insert the new placeholder.
+      index: placeholder,
+      // Push back all later placeholders by 1 unit, because of the new placeholder.
+      ...Map.fromEntries(
+        placeholders.entries.where((entry) => entry.key >= index).map((entry) => MapEntry(entry.key + 1, entry.value)),
+      ),
+    });
+  }
+
+  /// Copies this [AttributedText] and removes  a region of text and attributions
+  /// from [startOffset], inclusive, to [endOffset], exclusive.
   AttributedText removeRegion({
     required int startOffset,
     required int endOffset,
@@ -368,8 +576,7 @@ class AttributedText {
     _log.fine('Removing text region from $startOffset to $endOffset');
     _log.fine('initial attributions:');
     _log.fine(spans.toString());
-    final reducedText = (startOffset > 0 ? text.substring(0, startOffset) : '') +
-        (endOffset < text.length ? text.substring(endOffset) : '');
+    final reducedText = substring(0, startOffset) + substring(endOffset, length);
 
     AttributedSpans contractedAttributions = spans.copy()
       ..contractAttributions(
@@ -383,6 +590,15 @@ class AttributedText {
     return AttributedText(
       reducedText,
       contractedAttributions,
+      Map.fromEntries(
+        placeholders.entries
+            .where((entry) => entry.key < startOffset || endOffset <= entry.key) //
+            .map(
+              (entry) => entry.key >= endOffset //
+                  ? MapEntry(entry.key - (endOffset - startOffset), entry.value)
+                  : entry,
+            ),
+      ),
     );
   }
 
@@ -482,29 +698,34 @@ class AttributedText {
   ///
   /// Attribution groups are useful when computing all style variations for [AttributedText].
   Iterable<MultiAttributionSpan> computeAttributionSpans() {
-    return spans.collapseSpans(contentLength: text.length);
+    return spans.collapseSpans(contentLength: _text.length);
   }
 
   /// Returns a copy of this [AttributedText].
   AttributedText copy() {
     return AttributedText(
-      text,
+      _text,
       spans.copy(),
+      Map.from(placeholders),
     );
   }
 
   @override
   bool operator ==(Object other) {
     return identical(this, other) ||
-        other is AttributedText && runtimeType == other.runtimeType && text == other.text && spans == other.spans;
+        other is AttributedText &&
+            runtimeType == other.runtimeType &&
+            _text == other._text &&
+            spans == other.spans &&
+            (const DeepCollectionEquality()).equals(placeholders, other.placeholders);
   }
 
   @override
-  int get hashCode => text.hashCode ^ spans.hashCode;
+  int get hashCode => _text.hashCode ^ spans.hashCode ^ placeholders.hashCode;
 
   @override
   String toString() {
-    return '[AttributedText] - "$text"\n$spans';
+    return '[AttributedText] - "$_text"\n$spans\n$placeholders';
   }
 }
 


Test patch:
diff --git a/attributed_text/test/attributed_text_placeholders_test.dart b/attributed_text/test/attributed_text_placeholders_test.dart
new file mode 100644
index 0000000000..915c9bcfb7
--- /dev/null
+++ b/attributed_text/test/attributed_text_placeholders_test.dart
@@ -0,0 +1,595 @@
+import 'package:attributed_text/attributed_text.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group("AttributedAttributed > placeholders >", () {
+    group("construction >", () {
+      test("reports invalid placeholder positions", () {
+        // Index less than zero.
+        expect(
+          () => AttributedText("Hello, World!", null, {
+            -1: const _FakePlaceholder("bad-index"),
+          }),
+          throwsA(isA<AssertionError>()),
+        );
+
+        // Index beyond length.
+        expect(
+          () => AttributedText("Hello, World!", null, {
+            14: const _FakePlaceholder("bad-index"),
+          }),
+          throwsA(isA<AssertionError>()),
+        );
+      });
+    });
+
+    group("length >", () {
+      test("only a single placeholder", () {
+        expect(
+          AttributedText(
+            "",
+            null,
+            {
+              0: const _FakePlaceholder("only"),
+            },
+          ).length,
+          1,
+        );
+      });
+
+      test("only multiple placeholders", () {
+        expect(
+          AttributedText(
+            "",
+            null,
+            {
+              0: const _FakePlaceholder("one"),
+              1: const _FakePlaceholder("two"),
+              2: const _FakePlaceholder("three"),
+            },
+          ).length,
+          3,
+        );
+      });
+
+      test("text with a single placeholder", () {
+        expect(
+          AttributedText(
+            "Hello, world! ",
+            null,
+            {
+              14: const _FakePlaceholder("trailing"),
+            },
+          ).length,
+          15,
+        );
+      });
+
+      test("text with multiple placeholders", () {
+        expect(
+          AttributedText(
+            "Hello, world! ",
+            null,
+            {
+              0: const _FakePlaceholder("leading"),
+              6: const _FakePlaceholder("middle"),
+              16: const _FakePlaceholder("trailing"),
+            },
+          ).length,
+          17,
+        );
+      });
+    });
+
+    test("reports plain text value", () {
+      expect(
+        AttributedText("", null, {
+          0: const _FakePlaceholder("only"),
+        }).toPlainText(replacementCharacter: "�"),
+        "�",
+      );
+
+      expect(
+        AttributedText("", null, {
+          0: const _FakePlaceholder("one"),
+          1: const _FakePlaceholder("two"),
+          2: const _FakePlaceholder("three"),
+        }).toPlainText(replacementCharacter: "�"),
+        "���",
+      );
+
+      expect(
+        AttributedText("HelloWorld", null, {
+          0: const _FakePlaceholder("one"),
+          6: const _FakePlaceholder("two"),
+          12: const _FakePlaceholder("three"),
+        }).toPlainText(replacementCharacter: "�"),
+        "�Hello�World�",
+      );
+    });
+
+    group("plain text substring >", () {
+      test("when placeholders are not in range", () {
+        expect(
+          AttributedText("Hello, world!", null, {
+            0: const _FakePlaceholder("leading"),
+            6: const _FakePlaceholder("middle"),
+            15: const _FakePlaceholder("trailing"),
+          }).substring(1, 6),
+          "Hello",
+        );
+      });
+
+      test("with placeholders in the range", () {
+        expect(
+          AttributedText("Hello, world!", null, {
+            0: const _FakePlaceholder("leading"),
+            6: const _FakePlaceholder("middle"),
+            15: const _FakePlaceholder("trailing"),
+          }).substring(0, 7),
+          "Hello",
+        );
+      });
+    });
+
+    group("equality >", () {
+      test("only a single placeholder", () {
+        expect(
+          AttributedText("", null, {
+            0: const _FakePlaceholder("only"),
+          }),
+          equals(
+            AttributedText("", null, {
+              0: const _FakePlaceholder("only"),
+            }),
+          ),
+        );
+
+        expect(
+          AttributedText("", null, {
+            0: const _FakePlaceholder("only"),
+          }),
+          isNot(
+            equals(
+              AttributedText("", null),
+            ),
+          ),
+        );
+
+        expect(
+          AttributedText("", null),
+          isNot(
+            equals(
+              AttributedText("", null, {
+                0: const _FakePlaceholder("only"),
+              }),
+            ),
+          ),
+        );
+      });
+
+      test("some of multiple placeholders", () {
+        expect(
+          AttributedText("", null, {
+            0: const _FakePlaceholder("one"),
+            1: const _FakePlaceholder("two"),
+            2: const _FakePlaceholder("three"),
+          }),
+          equals(
+            AttributedText("", null, {
+              0: const _FakePlaceholder("one"),
+              1: const _FakePlaceholder("two"),
+              2: const _FakePlaceholder("three"),
+            }),
+          ),
+        );
+
+        expect(
+          AttributedText("", null),
+          isNot(
+            equals(
+              AttributedText("", null, {
+                0: const _FakePlaceholder("one"),
+                1: const _FakePlaceholder("two"),
+                2: const _FakePlaceholder("three"),
+              }),
+            ),
+          ),
+        );
+
+        expect(
+          AttributedText("", null, {
+            0: const _FakePlaceholder("one"),
+            1: const _FakePlaceholder("two"),
+            2: const _FakePlaceholder("three"),
+          }),
+          isNot(
+            equals(
+              AttributedText("", null),
+            ),
+          ),
+        );
+      });
+
+      test("some text and a placeholder", () {
+        expect(
+          AttributedText("Hello, world!", null, {
+            5: const _FakePlaceholder("middle"),
+          }),
+          equals(
+            AttributedText("Hello, world!", null, {
+              5: const _FakePlaceholder("middle"),
+            }),
+          ),
+        );
+
+        expect(
+          AttributedText("Hello, world!", null),
+          isNot(
+            equals(
+              AttributedText("Hello, world!", null, {
+                5: const _FakePlaceholder("middle"),
+              }),
+            ),
+          ),
+        );
+
+        expect(
+          AttributedText("Hello, world!", null, {
+            5: const _FakePlaceholder("middle"),
+          }),
+          isNot(
+            equals(
+              AttributedText("Hello, world!", null),
+            ),
+          ),
+        );
+      });
+    });
+
+    group("full copy >", () {
+      test("only a single placeholder", () {
+        expect(
+          AttributedText(
+              "",
+              AttributedSpans(
+                attributions: const [
+                  SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.start),
+                  SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.end),
+                ],
+              ),
+              {
+                0: const _FakePlaceholder("only"),
+              }).copy(),
+          AttributedText(
+              "",
+              AttributedSpans(
+                attributions: const [
+                  SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.start),
+                  SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.end),
+                ],
+              ),
+              {
+                0: const _FakePlaceholder("only"),
+              }),
+        );
+      });
+
+      test("some of multiple placeholders", () {
+        expect(
+          AttributedText(
+              "",
+              AttributedSpans(
+                attributions: const [
+                  SpanMarker(attribution: _bold, offset: 1, markerType: SpanMarkerType.start),
+                  SpanMarker(attribution: _bold, offset: 2, markerType: SpanMarkerType.end),
+                ],
+              ),
+              {
+                0: const _FakePlaceholder("one"),
+                1: const _FakePlaceholder("two"),
+                2: const _FakePlaceholder("three"),
+              }).copy(),
+          AttributedText(
+              "",
+              AttributedSpans(
+                attributions: const [
+                  SpanMarker(attribution: _bold, offset: 1, markerType: SpanMarkerType.start),
+                  SpanMarker(attribution: _bold, offset: 2, markerType: SpanMarkerType.end),
+                ],
+              ),
+              {
+                0: const _FakePlaceholder("one"),
+                1: const _FakePlaceholder("two"),
+                2: const _FakePlaceholder("three"),
+              }),
+        );
+      });
+
+      test("some text and a placeholder", () {
+        expect(
+          AttributedText(
+              "Hello, world!",
+              AttributedSpans(
+                attributions: const [
+                  SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.start),
+                  SpanMarker(attribution: _bold, offset: 5, markerType: SpanMarkerType.end),
+                ],
+              ),
+              {
+                5: const _FakePlaceholder("middle"),
+              }).copy(),
+          AttributedText(
+              "Hello, world!",
+              AttributedSpans(
+                attributions: const [
+                  SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.start),
+                  SpanMarker(attribution: _bold, offset: 5, markerType: SpanMarkerType.end),
+                ],
+              ),
+              {
+                5: const _FakePlaceholder("middle"),
+              }),
+        );
+      });
+    });
+
+    group("copy span >", () {
+      test("only a single placeholder", () {
+        expect(
+          AttributedText("", null, {
+            0: const _FakePlaceholder("only"),
+          }).copyText(0, 1),
+          AttributedText("", null, {
+            0: const _FakePlaceholder("only"),
+          }),
+        );
+      });
+
+      test("some of multiple placeholders", () {
+        expect(
+          AttributedText("", null, {
+            0: const _FakePlaceholder("one"),
+            1: const _FakePlaceholder("two"),
+            2: const _FakePlaceholder("three"),
+          }).copyText(1, 3),
+          AttributedText("", null, {
+            0: const _FakePlaceholder("two"),
+            1: const _FakePlaceholder("three"),
+          }),
+        );
+      });
+
+      test("some text and a leading placeholder", () {
+        expect(
+          AttributedText("Hello, world!", null, {
+            0: const _FakePlaceholder("leading"),
+          }).copyText(0, 6),
+          AttributedText("Hello", null, {
+            0: const _FakePlaceholder("leading"),
+          }),
+        );
+      });
+
+      test("some text and a middle placeholder", () {
+        expect(
+          AttributedText("Hello, world!", null, {
+            5: const _FakePlaceholder("middle"),
+          }).copyText(0, 6),
+          AttributedText("Hello", null, {
+            5: const _FakePlaceholder("middle"),
+          }),
+        );
+      });
+
+      test("some text and a trailing placeholder", () {
+        expect(
+          AttributedText("Hello, world!", null, {
+            13: const _FakePlaceholder("trailing"),
+          }).copyText(7, 14),
+          AttributedText("world!", null, {
+            6: const _FakePlaceholder("trailing"),
+          }),
+        );
+      });
+    });
+
+    group("copy and append >", () {
+      test("only a single placeholder", () {
+        expect(
+          AttributedText("Hello").copyAndAppend(
+            AttributedText("", null, {
+              0: const _FakePlaceholder("only"),
+            }),
+          ),
+          AttributedText("Hello", null, {
+            5: const _FakePlaceholder("only"),
+          }),
+        );
+      });
+
+      test("some of multiple placeholders", () {
+        expect(
+          AttributedText("Hello").copyAndAppend(
+            AttributedText("", null, {
+              0: const _FakePlaceholder("one"),
+              1: const _FakePlaceholder("two"),
+              2: const _FakePlaceholder("three"),
+            }),
+          ),
+          AttributedText("Hello", null, {
+            5: const _FakePlaceholder("one"),
+            6: const _FakePlaceholder("two"),
+            7: const _FakePlaceholder("three"),
+          }),
+        );
+      });
+
+      test("some text and a leading placeholder", () {
+        expect(
+          AttributedText("Hello").copyAndAppend(
+            AttributedText(", world!", null, {
+              0: const _FakePlaceholder("middle"),
+            }),
+          ),
+          AttributedText("Hello, world!", null, {
+            5: const _FakePlaceholder("middle"),
+          }),
+        );
+      });
+
+      test("some text and a middle placeholder", () {
+        expect(
+          AttributedText("Hello").copyAndAppend(
+            AttributedText(", world!", null, {
+              2: const _FakePlaceholder("middle"),
+            }),
+          ),
+          AttributedText("Hello, world!", null, {
+            7: const _FakePlaceholder("middle"),
+          }),
+        );
+      });
+
+      test("some text and a trailing placeholder", () {
+        expect(
+          AttributedText("Hello").copyAndAppend(
+            AttributedText(", world!", null, {
+              8: const _FakePlaceholder("trailing"),
+            }),
+          ),
+          AttributedText("Hello, world!", null, {
+            13: const _FakePlaceholder("trailing"),
+          }),
+        );
+      });
+    });
+
+    test("insert attributed text >", () {
+      final empty = AttributedText("");
+      final hello = empty.insert(
+        textToInsert: AttributedText("Hello", null, {
+          5: const _FakePlaceholder("middle"),
+        }),
+        startOffset: 0,
+      );
+      final helloWorld = hello.insert(
+        textToInsert: AttributedText(", World!", null, {
+          8: const _FakePlaceholder("trailing"),
+        }),
+        startOffset: 6,
+      );
+
+      expect(
+        hello,
+        AttributedText("Hello", null, {
+          5: const _FakePlaceholder("middle"),
+        }),
+      );
+
+      expect(
+        helloWorld,
+        AttributedText("Hello, World!", null, {
+          5: const _FakePlaceholder("middle"),
+          14: const _FakePlaceholder("trailing"),
+        }),
+      );
+    });
+
+    group("insert placeholders >", () {
+      test("multiple placeholders", () {
+        expect(
+          AttributedText("Hello, World!").insertPlaceholders({
+            0: const _FakePlaceholder("leading"),
+            6: const _FakePlaceholder("middle"),
+            14: const _FakePlaceholder("trailing"),
+          }),
+          AttributedText("Hello, World!", null, {
+            0: const _FakePlaceholder("leading"),
+            6: const _FakePlaceholder("middle"),
+            14: const _FakePlaceholder("trailing"),
+          }),
+        );
+      });
+
+      test("individual placeholder", () {
+        expect(
+          AttributedText().insertPlaceholder(0, const _FakePlaceholder("only")),
+          AttributedText("", null, {
+            0: const _FakePlaceholder("only"),
+          }),
+        );
+
+        expect(
+          AttributedText("Hello").insertPlaceholder(5, const _FakePlaceholder("only")),
+          AttributedText("Hello", null, {
+            5: const _FakePlaceholder("only"),
+          }),
+        );
+      });
+    });
+
+    test("remove region >", () {
+      expect(
+        AttributedText(
+          "Hello, World!",
+          AttributedSpans(
+            attributions: const [
+              SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: _bold, offset: 4, markerType: SpanMarkerType.end),
+            ],
+          ),
+          {
+            5: const _FakePlaceholder("middle"),
+          },
+        ).removeRegion(startOffset: 0, endOffset: 5),
+        AttributedText(
+          ", World!",
+          null,
+          {
+            0: const _FakePlaceholder("middle"),
+          },
+        ),
+      );
+
+      expect(
+        AttributedText(
+          "Hello, World!",
+          AttributedSpans(
+            attributions: const [
+              SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: _bold, offset: 5, markerType: SpanMarkerType.end),
+            ],
+          ),
+          {
+            5: const _FakePlaceholder("middle"),
+          },
+        ).removeRegion(startOffset: 3, endOffset: 10),
+        AttributedText(
+          "Helrld!",
+          AttributedSpans(
+            attributions: const [
+              SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: _bold, offset: 2, markerType: SpanMarkerType.end),
+            ],
+          ),
+        ),
+      );
+    });
+  });
+}
+
+const _bold = NamedAttribution("bold");
+
+class _FakePlaceholder {
+  const _FakePlaceholder(this.name);
+
+  final String name;
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) || other is _FakePlaceholder && runtimeType == other.runtimeType && name == other.name;
+
+  @override
+  int get hashCode => name.hashCode;
+}