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? placeholders, + ]) : _text = text ?? "", + spans = spans ?? AttributedSpans(), + placeholders = placeholders ?? {} { + 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 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 = {}; @@ -126,14 +261,14 @@ class AttributedText { /// Returns all spans in this [AttributedText] for the given [attributions]. Set getAttributionSpans(Set 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 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 = {}; + 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 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 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'; } } 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()), + ); + + // Index beyond length. + expect( + () => AttributedText("Hello, World!", null, { + 14: const _FakePlaceholder("bad-index"), + }), + throwsA(isA()), + ); + }); + }); + + 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; +}