Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 65 additions & 25 deletions lib/app/components/custom_text_editing_controllers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import "package:languagetool_textfield/core/enums/mistake_type.dart";
import 'package:languagetool_textfield/languagetool_textfield.dart';
import "package:languagetool_textfield/utils/closed_range.dart";
import "package:languagetool_textfield/utils/keep_latest_response_service.dart";
import 'package:tuple/tuple.dart';
import 'package:bluebubbles/utils/logger/logger.dart';

class Mentionable {
Expand All @@ -41,6 +40,70 @@ class Mentionable {
String toString() => displayName;
}

/// Repairs the annotation partition after keyboards or IMEs report an editing
/// delta that does not line up exactly with the previous selection.
///
/// Composer annotations are expected to cover the text once, without gaps or
/// overlaps. Preserve valid formatting, clip overlaps, and fill uncovered text
/// with a plain annotation.
void normalizeComposerAnnotationCoverage(
List<Annotation> annotations, int textLength) {
if (textLength <= 0) {
annotations.clear();
return;
}

final candidates = annotations.where((annotation) {
if (annotation.range.length < 2) return false;
final start = annotation.range[0].clamp(0, textLength).toInt();
final end = annotation.range[1].clamp(0, textLength).toInt();
annotation.range = [start, end];
return end > start;
}).toList()
..sort((a, b) {
final startComparison = a.range[0].compareTo(b.range[0]);
return startComparison != 0
? startComparison
: a.range[1].compareTo(b.range[1]);
});

final repaired = <Annotation>[];
var pointer = 0;

void append(Annotation annotation) {
final last = repaired.lastOrNull;
if (last != null &&
last.range[1] == annotation.range[0] &&
last.eqUnranged(annotation)) {
last.range[1] = annotation.range[1];
} else {
repaired.add(annotation);
}
}

for (final annotation in candidates) {
final end = annotation.range[1];
if (end <= pointer) continue;

final start = max(pointer, annotation.range[0]);
if (start > pointer) {
append(Annotation(range: [pointer, start]));
}

annotation.range = [start, end];
append(annotation);
pointer = end;
}

if (pointer < textLength) {
append(Annotation(range: [pointer, textLength]));
}

annotations
..clear()
..addAll(repaired);
}

class SpellCheckTextEditingController extends TextEditingController {
SpellCheckTextEditingController({super.text, this.focusNode}) {
assert(focusNode != null || !(kIsDesktop || kIsWeb));
Expand Down Expand Up @@ -509,30 +572,7 @@ class MentionTextEditingController extends SpellCheckTextEditingController {
bool changeLock = false;

void validateRange() {
annotations.sort((a, b) => a.range[0].compareTo(b.range[0]));
if (text.isEmpty) {
assert(annotations.isEmpty);
}
Annotation? lastAnnotation;
var pointer = 0;
while (pointer < text.length) {
var annotation = annotations.firstWhere((a) => a.range[0] == pointer);
// do we overlap with any other annotation?
assert(!annotations.any((a) => ((a.range[0] >= annotation.range[0] && a.range[0] < annotation.range[1]) ||
(a.range[1] > annotation.range[0] && a.range[1] <= annotation.range[1])) && annotation != a));
// we cannot have zero length
assert(annotation.range[0] != annotation.range[1]);
pointer = annotation.range[1];

if (lastAnnotation?.eqUnranged(annotation) ?? false) {
lastAnnotation!.range[1] = annotation.range[1]; // merge equal annotations
annotations.remove(annotation);
} else {
lastAnnotation = annotation;
}
}
assert(pointer == text.length);
assert(lastAnnotation == annotations.lastOrNull);
normalizeComposerAnnotationCoverage(annotations, text.length);
}

List<Annotation> annotationsForRange(TextSelection range) {
Expand Down
58 changes: 58 additions & 0 deletions test/components/custom_text_editing_controllers_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import 'package:bluebubbles/app/components/custom_text_editing_controllers.dart';
import 'package:bluebubbles/database/global/message_part.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('normalizeComposerAnnotationCoverage', () {
test('fills a transient gap without discarding valid formatting', () {
final annotations = [
Annotation(range: [0, 3], bold: true),
Annotation(range: [5, 8], italic: true),
];

normalizeComposerAnnotationCoverage(annotations, 10);

expect(annotations.map((annotation) => annotation.range), [
[0, 3],
[3, 5],
[5, 8],
[8, 10],
]);
expect(annotations.first.bold, isTrue);
expect(annotations[2].italic, isTrue);
});

test('clips overlaps into a single complete partition', () {
final annotations = [
Annotation(range: [-2, 4], bold: true),
Annotation(range: [2, 8], italic: true),
Annotation(range: [8, 20]),
];

normalizeComposerAnnotationCoverage(annotations, 10);

expect(annotations.map((annotation) => annotation.range), [
[0, 4],
[4, 8],
[8, 10],
]);
expect(annotations.first.bold, isTrue);
expect(annotations[1].italic, isTrue);
});

test('merges adjacent plain ranges and clears empty text', () {
final annotations = [
Annotation(range: [0, 2]),
Annotation(range: [2, 4]),
];

normalizeComposerAnnotationCoverage(annotations, 4);
expect(annotations.map((annotation) => annotation.range), [
[0, 4],
]);

normalizeComposerAnnotationCoverage(annotations, 0);
expect(annotations, isEmpty);
});
});
}