Repository: superlistapp/super_editor (dart)

Issue:
[attributed_text] - Add method that visits collapsed attributions
The visitor system currently built into `AttributedText` visits every character, reporting all open and closed markers at that character. This is a fine thing to do as a basis for processing markers and attributions. However, users might want to visit all attributions as if they're collapsed, only visiting the indices where the applied attributions change, such as when styling text.

We offer this visitation behavior in `super_editor` for styling purposes. I don't see any reason not to offer this same visitation in `attributed_text`, itself.

Pull request description:
Added collapsed span visitation in AttributedText, added tests for both versions of attribution visitors (Resolves #1215)

Patch:
diff --git a/attributed_text/lib/src/attributed_spans.dart b/attributed_text/lib/src/attributed_spans.dart
index 24edb308c3..ecd9117c1a 100644
--- a/attributed_text/lib/src/attributed_spans.dart
+++ b/attributed_text/lib/src/attributed_spans.dart
@@ -1157,6 +1157,18 @@ class MultiAttributionSpan {
 
   @override
   String toString() => '[MultiAttributionSpan] - attributions: $attributions, start: $start, end: $end';
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is MultiAttributionSpan &&
+          runtimeType == other.runtimeType &&
+          start == other.start &&
+          end == other.end &&
+          DeepCollectionEquality().equals(attributions, other.attributions);
+
+  @override
+  int get hashCode => attributions.hashCode ^ start.hashCode ^ end.hashCode;
 }
 
 /// Returns `true` when the given [candidate] [Attribution] matches the desired condition.
diff --git a/attributed_text/lib/src/attributed_text.dart b/attributed_text/lib/src/attributed_text.dart
index 7ec519cc89..c37b879844 100644
--- a/attributed_text/lib/src/attributed_text.dart
+++ b/attributed_text/lib/src/attributed_text.dart
@@ -321,6 +321,25 @@ class AttributedText {
     );
   }
 
+  /// Visits all attributions in this [AttributedText] by calling [visitor] whenever
+  /// an attribution begins or ends.
+  ///
+  /// If multiple attributions begin or end at the same index, then all of those attributions
+  /// are reported together.
+  ///
+  /// **Only Reports Beginnings and Endings:**
+  ///
+  /// This visitation method does not report all applied attributions at a given index. It
+  /// only reports attributions that begin or end at a specific index.
+  ///
+  /// For example:
+  ///
+  /// Bold:         |xxxxxxxxxxxx|
+  /// Italics:      |------xxxxxx|
+  ///
+  /// Bold is attributed throughout the range. Italics begins at index `6`. When [visitor]
+  /// is notified about italics beginning at `6`, visitor is NOT notified that bold applies
+  /// at that same index.
   void visitAttributions(AttributionVisitor visitor) {
     final startingAttributions = Set<Attribution>();
     final endingAttributions = Set<Attribution>();
@@ -357,6 +376,50 @@ class AttributedText {
     visitor.onVisitEnd();
   }
 
+  /// Visits attributions in this [AttributedText], reporting every changing group of
+  /// attributions to the given [visitor].
+  ///
+  /// See [computeAttributionSpans] for an example.
+  ///
+  /// See also:
+  ///
+  ///   * [visitAttributions], to visit attributions markers instead of attribution groups.
+  ///   * [computeAttributionSpans], to work with a list of [MultiAttributionSpan]s instead
+  ///     of visiting each span with a callback.
+  void visitAttributionSpans(AttributionSpanVisitor visitor) {
+    final collapsedSpans = computeAttributionSpans();
+    for (final span in collapsedSpans) {
+      visitor(span);
+    }
+  }
+
+  /// Collapses all attribution markers down into a series of attribution groups,
+  /// starting at the beginning of this [AttributedText], until the end.
+  ///
+  /// A new group of attributions begin wherever an attribution begins or ends.
+  ///
+  /// For example:
+  ///
+  /// Bold:         |----xxxxxxxxxxxx------------|
+  /// Italics:      |-------xxxxxxxxxxxxx--------|
+  /// Strikethru:   |-----------xxxxxxxxxxxxx----|
+  ///
+  /// Given the above attributions, the given [visitor] would be notified of the following
+  /// groups:
+  ///
+  ///  1. [0, 4]   - No attributions
+  ///  2. [5, 8]   - Bold
+  ///  3. [9, 12]  - Bold, Italics
+  ///  4. [13, 16] - Bold, Italics, Strikethru
+  ///  5. [17, 20] - Italics, Strikethru
+  ///  6. [21, 24] - Strikethru
+  ///  7. [25, 28] - No attributions
+  ///
+  /// Attribution groups are useful when computing all style variations for [AttributedText].
+  Iterable<MultiAttributionSpan> computeAttributionSpans() {
+    return spans.collapseSpans(contentLength: text.length);
+  }
+
   @override
   bool operator ==(Object other) {
     return identical(this, other) ||
@@ -372,6 +435,8 @@ class AttributedText {
   }
 }
 
+typedef AttributionSpanVisitor = void Function(MultiAttributionSpan span);
+
 /// Visits every [index] in the the given [AttributedText] which has at least
 /// one start or end marker, passing the attributions that start or end at the [index].
 ///


Test patch:
diff --git a/attributed_text/test/attributed_text_test.dart b/attributed_text/test/attributed_text_test.dart
index 3903f06278..b9b4316cb5 100644
--- a/attributed_text/test/attributed_text_test.dart
+++ b/attributed_text/test/attributed_text_test.dart
@@ -1,4 +1,5 @@
 import 'package:attributed_text/attributed_text.dart';
+import 'package:collection/collection.dart';
 import 'package:test/test.dart';
 
 void main() {
@@ -277,5 +278,269 @@ void main() {
         expect(range, SpanRange(start: 1, end: 3));
       });
     });
+
+    group("attribution visitation", () {
+      test("visits full-length attributions", () {
+        final attributedText = AttributedText(
+          'Hello world',
+          AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 10, markerType: SpanMarkerType.end),
+            ],
+          ),
+        );
+
+        final expectedVisits = [
+          _AttributionVisit(0, {ExpectedSpans.bold}, {}),
+          _AttributionVisit(10, {}, {ExpectedSpans.bold}),
+        ];
+
+        attributedText.visitAttributions(
+          CallbackAttributionVisitor(
+            visitAttributions: (
+              AttributedText fullText,
+              int index,
+              Set<Attribution> startingAttributions,
+              Set<Attribution> endingAttributions,
+            ) {
+              expect(_AttributionVisit(index, startingAttributions, endingAttributions), expectedVisits.first);
+              expectedVisits.removeAt(0);
+            },
+          ),
+        );
+      });
+
+      test("visits partial-length attributions", () {
+        final attributedText = AttributedText(
+          'Hello world',
+          AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 8, markerType: SpanMarkerType.end),
+            ],
+          ),
+        );
+
+        final expectedVisits = [
+          _AttributionVisit(2, {ExpectedSpans.bold}, {}),
+          _AttributionVisit(8, {}, {ExpectedSpans.bold}),
+        ];
+
+        attributedText.visitAttributions(
+          CallbackAttributionVisitor(
+            visitAttributions: (
+              AttributedText fullText,
+              int index,
+              Set<Attribution> startingAttributions,
+              Set<Attribution> endingAttributions,
+            ) {
+              expect(_AttributionVisit(index, startingAttributions, endingAttributions), expectedVisits.first);
+              expectedVisits.removeAt(0);
+            },
+          ),
+        );
+      });
+
+      test("visits overlapping attributions", () {
+        final attributedText = AttributedText(
+          'Hello world',
+          AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 6, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 4, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 10, markerType: SpanMarkerType.end),
+            ],
+          ),
+        );
+
+        final expectedVisits = [
+          _AttributionVisit(0, {ExpectedSpans.bold}, {}),
+          _AttributionVisit(4, {ExpectedSpans.italics}, {}),
+          _AttributionVisit(6, {}, {ExpectedSpans.bold}),
+          _AttributionVisit(10, {}, {ExpectedSpans.italics}),
+        ];
+
+        attributedText.visitAttributions(
+          CallbackAttributionVisitor(
+            visitAttributions: (
+              AttributedText fullText,
+              int index,
+              Set<Attribution> startingAttributions,
+              Set<Attribution> endingAttributions,
+            ) {
+              expect(_AttributionVisit(index, startingAttributions, endingAttributions), expectedVisits.first);
+              expectedVisits.removeAt(0);
+            },
+          ),
+        );
+      });
+
+      test("visits multiple starting and ending attributions", () {
+        final attributedText = AttributedText(
+          'Hello world',
+          AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 8, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 2, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 8, markerType: SpanMarkerType.end),
+            ],
+          ),
+        );
+
+        final expectedVisits = [
+          _AttributionVisit(2, {ExpectedSpans.bold, ExpectedSpans.italics}, {}),
+          _AttributionVisit(8, {}, {ExpectedSpans.bold, ExpectedSpans.italics}),
+        ];
+
+        attributedText.visitAttributions(
+          CallbackAttributionVisitor(
+            visitAttributions: (
+              AttributedText fullText,
+              int index,
+              Set<Attribution> startingAttributions,
+              Set<Attribution> endingAttributions,
+            ) {
+              expect(_AttributionVisit(index, startingAttributions, endingAttributions), expectedVisits.first);
+              expectedVisits.removeAt(0);
+            },
+          ),
+        );
+      });
+    });
+
+    group("attribution span visitation", () {
+      test("visits full-length attributions", () {
+        final attributedText = AttributedText(
+          'Hello world',
+          AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 10, markerType: SpanMarkerType.end),
+            ],
+          ),
+        );
+
+        final expectedVisits = [
+          MultiAttributionSpan(attributions: {ExpectedSpans.bold}, start: 0, end: 10),
+        ];
+
+        attributedText.visitAttributionSpans(
+          (span) {
+            expect(span, expectedVisits.first);
+            expectedVisits.removeAt(0);
+          },
+        );
+      });
+
+      test("visits partial-length attributions", () {
+        final attributedText = AttributedText(
+          'Hello world',
+          AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 8, markerType: SpanMarkerType.end),
+            ],
+          ),
+        );
+
+        final expectedVisits = [
+          MultiAttributionSpan(attributions: {}, start: 0, end: 1),
+          MultiAttributionSpan(attributions: {ExpectedSpans.bold}, start: 2, end: 8),
+          MultiAttributionSpan(attributions: {}, start: 9, end: 10),
+        ];
+
+        attributedText.visitAttributionSpans(
+          (span) {
+            expect(span, expectedVisits.first);
+            expectedVisits.removeAt(0);
+          },
+        );
+      });
+
+      test("visits overlapping attributions", () {
+        final attributedText = AttributedText(
+          'Hello world',
+          AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 6, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 4, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 10, markerType: SpanMarkerType.end),
+            ],
+          ),
+        );
+
+        final expectedVisits = [
+          MultiAttributionSpan(attributions: {ExpectedSpans.bold}, start: 0, end: 3),
+          MultiAttributionSpan(attributions: {ExpectedSpans.bold, ExpectedSpans.italics}, start: 4, end: 6),
+          MultiAttributionSpan(attributions: {ExpectedSpans.italics}, start: 7, end: 10),
+        ];
+
+        attributedText.visitAttributionSpans(
+          (span) {
+            expect(span, expectedVisits.first);
+            expectedVisits.removeAt(0);
+          },
+        );
+      });
+
+      test("visits multiple starting and ending attributions", () {
+        final attributedText = AttributedText(
+          'Hello world',
+          AttributedSpans(
+            attributions: [
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.bold, offset: 8, markerType: SpanMarkerType.end),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 2, markerType: SpanMarkerType.start),
+              SpanMarker(attribution: ExpectedSpans.italics, offset: 8, markerType: SpanMarkerType.end),
+            ],
+          ),
+        );
+
+        final expectedVisits = [
+          MultiAttributionSpan(attributions: {}, start: 0, end: 1),
+          MultiAttributionSpan(attributions: {ExpectedSpans.bold, ExpectedSpans.italics}, start: 2, end: 8),
+          MultiAttributionSpan(attributions: {}, start: 9, end: 10),
+        ];
+
+        attributedText.visitAttributionSpans(
+          (span) {
+            expect(span, expectedVisits.first);
+            expectedVisits.removeAt(0);
+          },
+        );
+      });
+    });
   });
 }
+
+class _AttributionVisit {
+  _AttributionVisit(
+    this.index,
+    this.startingAttributions,
+    this.endingAttributions,
+  );
+
+  final int index;
+  final Set<Attribution> startingAttributions;
+  final Set<Attribution> endingAttributions;
+
+  @override
+  String toString() =>
+      "[_AttributionVisit] - index: $index, starting: $startingAttributions, ending: $endingAttributions";
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is _AttributionVisit &&
+          runtimeType == other.runtimeType &&
+          index == other.index &&
+          DeepCollectionEquality().equals(startingAttributions, other.startingAttributions) &&
+          DeepCollectionEquality().equals(endingAttributions, other.endingAttributions);
+
+  @override
+  int get hashCode => index.hashCode ^ startingAttributions.hashCode ^ endingAttributions.hashCode;
+}