addAttribution method
- required Attribution newAttribution,
- required int start,
- required int end,
- bool autoMerge = true,
- bool overwriteConflictingSpans = true,
Applies the newAttribution
from start
to end
, inclusive.
If start
is less than 0
, nothing happens.
AttributedSpans doesn't have any knowledge about content length, so end
can
take any value that's desired. However, users of AttributedSpans should take
care to avoid values for end
that exceed the content length.
The effect of adding an attribution is straight forward when the text doesn't
contain any other attributions with the same ID. However, there are various
situations where newAttribution
can't necessarily co-exist with other
attribution spans that already exist in the text.
Attribution overlaps can take one of two forms: mergeable or conflicting.
Mergeable Attribution Spans
An example of a mergeable overlap is where two bold spans overlap each other. All bold attributions are interchangeable, so when two bold spans overlap, those spans can be merged together into a single span.
However, mergeable overlapping spans are not automatically merged. Instead,
this decision is left to the user of this class. If you want AttributedSpans to
merge overlapping mergeable spans, pass true
for autoMerge
. Otherwise,
if autoMerge
is false
, an exception is thrown when two mergeable spans
overlap each other.
Conflicting Attribution Spans
An example of a conflicting overlap is where a black text color overlaps a red text color. Text is either black, OR red, but never both. Therefore, the black attribution cannot co-exist with the red attribution. Something must be done to resolve this.
There are two possible ways to handle conflicting overlaps. The new attribution
can overwrite the existing attribution where they overlap. Or, an exception can be
thrown. To overwrite the existing attribution with the new attribution, pass true
for overwriteConflictingSpans
. Otherwise, if overwriteConflictingSpans
is false
, an exception is thrown.
Implementation
void addAttribution({
required Attribution newAttribution,
required int start,
required int end,
bool autoMerge = true,
bool overwriteConflictingSpans = true,
}) {
if (start < 0 || start > end) {
_log.warning("Tried to add an attribution ($newAttribution) at an invalid start/end: $start -> $end");
return;
}
_log.info("Adding attribution ($newAttribution) from $start to $end");
_log.finer("Has ${_markers.length} markers before addition");
final conflicts = <_AttributionConflict>[];
// Check if conflicting attributions overlap the new attribution.
final matchingAttributions = getMatchingAttributionsWithin(attributions: {newAttribution}, start: start, end: end);
if (matchingAttributions.isNotEmpty) {
for (final matchingAttribution in matchingAttributions) {
bool areAttributionsMergeable = newAttribution.canMergeWith(matchingAttribution);
if (!areAttributionsMergeable || !autoMerge) {
int? conflictStart;
int? conflictEnd;
for (int i = start; i <= end; ++i) {
if (hasAttributionAt(i, attribution: matchingAttribution)) {
conflictStart ??= i;
conflictEnd = i;
if (areAttributionsMergeable) {
// Both attributions are mergeable, but the caller doesn't want to merge them.
throw IncompatibleOverlappingAttributionsException(
existingAttribution: matchingAttribution,
newAttribution: newAttribution,
conflictStart: conflictStart,
);
}
} else if (conflictStart != null) {
// We found the end of the conflict.
conflicts.add(_AttributionConflict(
newAttribution: newAttribution,
existingAttribution: matchingAttribution,
conflictStart: conflictStart,
conflictEnd: conflictEnd!,
));
// Reset so we can find the next conflict.
conflictStart = null;
conflictEnd = null;
}
}
if (conflictStart != null && conflictEnd != null) {
// We found a conflict that extends to the end of the range.
conflicts.add(_AttributionConflict(
newAttribution: newAttribution,
existingAttribution: matchingAttribution,
conflictStart: conflictStart,
conflictEnd: conflictEnd,
));
}
}
}
if (conflicts.isNotEmpty && !overwriteConflictingSpans) {
// We found conflicting attributions and we are configured not to overwrite them.
// For example, the user tried to apply a blue color attribution to a range of text
// that already has another color attribution.
throw IncompatibleOverlappingAttributionsException(
existingAttribution: conflicts.first.existingAttribution,
newAttribution: newAttribution,
conflictStart: conflicts.first.conflictStart,
);
}
}
// Removes any conflicting attributions. For example, consider the following text,
// with a blue color attribution that spans the entire text:
//
// one two three
// |bbbbbbbbbbbbb|
//
// We can't apply a green color attribution to the word "two", because it's already
// attributed with blue. So, we need to remove the blue attribution from the word "two",
// which results in the following text:
//
// one two three
// |bbbb---bbbbbb|
//
// After that, we can apply the desired attribution, because there isn't a conflicting attribution
// in this range anymore.
for (final conflict in conflicts) {
removeAttribution(
attributionToRemove: conflict.existingAttribution,
start: conflict.conflictStart,
end: conflict.conflictEnd,
);
}
if (!autoMerge) {
// We don't want to merge this new attribution with any other nearby attribution.
// Therefore, we can blindly create the new attribution range without any
// further adjustments, and then be done.
_insertMarker(SpanMarker(
attribution: newAttribution,
offset: start,
markerType: SpanMarkerType.start,
));
_insertMarker(SpanMarker(
attribution: newAttribution,
offset: end,
markerType: SpanMarkerType.end,
));
return;
}
// Start the new span, either by expanding an existing span, or by
// inserting a new start marker for the new span.
final endMarkerJustBefore =
SpanMarker(attribution: newAttribution, offset: start - 1, markerType: SpanMarkerType.end);
final endMarkerAtNewStart = SpanMarker(attribution: newAttribution, offset: start, markerType: SpanMarkerType.end);
if (_markers.contains(endMarkerJustBefore)) {
// A compatible span ends immediately before this new span begins.
// Remove the end marker so that the existing span flows into the new span.
_log.fine('A compatible span already exists immediately before the new span range. Combining the spans.');
_markers.remove(endMarkerJustBefore);
} else if (!hasAttributionAt(start, attribution: newAttribution)) {
// The desired attribution does not yet exist at `start`, and no compatible
// span sits immediately upstream. Therefore, we need to start a new span
// for the given `newAttribution`.
_log.fine('Adding start marker for new span at: $start');
_insertMarker(SpanMarker(
attribution: newAttribution,
offset: start,
markerType: SpanMarkerType.start,
));
} else if (_markers.contains(endMarkerAtNewStart)) {
// There's an end marker for this span at the same place where
// the new span wants to begin. Remove the end marker so that the
// existing span flows into the new span.
_log.fine('Removing existing end marker at $start because the new span should merge with an existing span');
_markers.remove(endMarkerAtNewStart);
}
// Delete all markers of the same type between `range.start`
// and `range.end`.
final markersToDelete = _markers
.where((attribution) => attribution.attribution == newAttribution)
.where((attribution) => attribution.offset > start)
.where((attribution) => attribution.offset <= end)
.toList();
_log.fine('Removing ${markersToDelete.length} markers between $start and $end');
_markers.removeWhere((element) => markersToDelete.contains(element));
final lastDeletedMarker = markersToDelete.isNotEmpty ? markersToDelete.last : null;
if (lastDeletedMarker == null || lastDeletedMarker.markerType == SpanMarkerType.end) {
// If we didn't delete any markers, the span that began at
// `range.start` or before needs to be capped off.
//
// If we deleted some markers, but the last marker was an
// `end` marker, we still have an open-ended span and we
// need to cap it off.
_log.fine('Inserting ending marker at: $end');
_insertMarker(SpanMarker(
attribution: newAttribution,
offset: end,
markerType: SpanMarkerType.end,
));
}
// Else, `range.end` is in the middle of larger span and
// doesn't need to be inserted.
assert(() {
// Only run this loop in debug mode to avoid unnecessary iteration
// in a release build (when logging should be turned off, anyway).
_log.fine('All attributions after:');
_markers.where((element) => element.attribution == newAttribution).forEach((element) {
_log.fine('$element');
});
return true;
}());
}