diff --git a/lib/app/components/custom_text_editing_controllers.dart b/lib/app/components/custom_text_editing_controllers.dart index 30934ea600..e57b6c0c90 100644 --- a/lib/app/components/custom_text_editing_controllers.dart +++ b/lib/app/components/custom_text_editing_controllers.dart @@ -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 { @@ -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 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 = []; + 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)); @@ -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 annotationsForRange(TextSelection range) { diff --git a/test/components/custom_text_editing_controllers_test.dart b/test/components/custom_text_editing_controllers_test.dart new file mode 100644 index 0000000000..33dbdeb1b3 --- /dev/null +++ b/test/components/custom_text_editing_controllers_test.dart @@ -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); + }); + }); +}