diff --git a/src/OneWare.Essentials/EditorExtensions/TextModificationService.cs b/src/OneWare.Essentials/EditorExtensions/TextModificationService.cs index 28f60c1d..3765e6ca 100644 --- a/src/OneWare.Essentials/EditorExtensions/TextModificationService.cs +++ b/src/OneWare.Essentials/EditorExtensions/TextModificationService.cs @@ -45,12 +45,19 @@ public void SetModification(string key, params TextModificationSegment[] segment if (s.StartOffset >= f.StartOffset) { if (s.StartOffset <= f.EndOffset) continue; //Completely overlapped - if (s.EndOffset > f.EndOffset + 1) s.StartOffset = f.EndOffset + 1; + if (s.EndOffset > f.EndOffset + 1) + { + var endOffset = s.EndOffset; + s.StartOffset = f.EndOffset + 1; + s.EndOffset = endOffset; + } } if (s.StartOffset < f.StartOffset) if (s.EndOffset > f.EndOffset - 1) s.EndOffset = f.EndOffset - 1; + + if (s.Length <= 0) continue; } m.Add(s); @@ -67,13 +74,21 @@ protected override void ColorizeLine(DocumentLine line) foreach (var overlap in overlaps) { - if (overlap.EndOffset > line.EndOffset) overlap.EndOffset = line.EndOffset; - if (overlap.StartOffset < line.Offset) overlap.StartOffset = line.Offset; - ChangeLinePart(overlap.StartOffset, overlap.EndOffset, x => ApplyChanges(x, overlap)); + if (!TryClampToLine(overlap, line, out var startOffset, out var endOffset)) continue; + ChangeLinePart(startOffset, endOffset, x => ApplyChanges(x, overlap)); } } } + // Must not mutate the segment: it is shared by all lines it spans, and setting + // TextSegment.StartOffset moves the segment (keeps its length) instead of trimming it. + internal static bool TryClampToLine(ISegment segment, DocumentLine line, out int startOffset, out int endOffset) + { + startOffset = Math.Max(segment.Offset, line.Offset); + endOffset = Math.Min(segment.EndOffset, line.EndOffset); + return endOffset > startOffset; + } + private void ApplyChanges(VisualLineElement element, TextModificationSegment segment) { if (segment.Foreground != null) element.TextRunProperties.SetForegroundBrush(segment.Foreground); diff --git a/src/OneWare.Essentials/OneWare.Essentials.csproj b/src/OneWare.Essentials/OneWare.Essentials.csproj index e9f74ef0..78efcdc2 100644 --- a/src/OneWare.Essentials/OneWare.Essentials.csproj +++ b/src/OneWare.Essentials/OneWare.Essentials.csproj @@ -40,4 +40,8 @@ + + + + diff --git a/tests/OneWare.Essentials.UnitTests/TextModificationServiceTests.cs b/tests/OneWare.Essentials.UnitTests/TextModificationServiceTests.cs new file mode 100644 index 00000000..3a6b2f94 --- /dev/null +++ b/tests/OneWare.Essentials.UnitTests/TextModificationServiceTests.cs @@ -0,0 +1,38 @@ +using AvaloniaEdit.Document; +using OneWare.Essentials.EditorExtensions; +using Xunit; + +namespace OneWare.Essentials.UnitTests; + +public class TextModificationServiceTests +{ + [Fact] + public void TryClampToLine_MultiLineSegment_StaysWithinEachLineAndDoesNotMutateSegment() + { + var document = new TextDocument { Text = "first\nsecond line\nthird" }; + var segment = new TextModificationSegment(2, document.TextLength - 2); + var originalStart = segment.StartOffset; + var originalEnd = segment.EndOffset; + + foreach (var line in document.Lines) + { + Assert.True(TextModificationService.TryClampToLine(segment, line, out var start, out var end)); + Assert.InRange(start, line.Offset, line.EndOffset); + Assert.InRange(end, line.Offset, line.EndOffset); + Assert.True(end > start); + } + + Assert.Equal(originalStart, segment.StartOffset); + Assert.Equal(originalEnd, segment.EndOffset); + } + + [Fact] + public void TryClampToLine_SegmentOnlyTouchingLineDelimiter_ReturnsFalse() + { + var document = new TextDocument { Text = "ab\ncd" }; + var firstLine = document.GetLineByNumber(1); + var segment = new TextModificationSegment(firstLine.EndOffset, firstLine.EndOffset + 1); + + Assert.False(TextModificationService.TryClampToLine(segment, firstLine, out _, out _)); + } +}