diff --git a/bundles/org.eclipse.jface.text/projection/org/eclipse/jface/text/source/projection/ProjectionViewer.java b/bundles/org.eclipse.jface.text/projection/org/eclipse/jface/text/source/projection/ProjectionViewer.java index 400c8e2021a..60d9911d752 100644 --- a/bundles/org.eclipse.jface.text/projection/org/eclipse/jface/text/source/projection/ProjectionViewer.java +++ b/bundles/org.eclipse.jface.text/projection/org/eclipse/jface/text/source/projection/ProjectionViewer.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2000, 2018 IBM Corporation and others. + * Copyright (c) 2000, 2025 IBM Corporation and others. * * This program and the accompanying materials * are made available under the terms of the Eclipse Public License 2.0 @@ -33,6 +33,9 @@ import org.eclipse.swt.widgets.Display; import org.eclipse.core.runtime.Assert; +import org.eclipse.core.runtime.ILog; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; import org.eclipse.jface.internal.text.SelectionProcessor; @@ -272,6 +275,32 @@ private void computeExpectedExecutionCosts() { } } + /** + * An {@link IDocumentListener} that makes sure that {@link #fVisibleRegionDuringProjection} is + * updated when the document changes and ensures that the collapsed region after the visible + * region is recreated appropriately. + */ + private final class UpdateDocumentListener implements IDocumentListener { + @Override + public void documentChanged(DocumentEvent event) { + if (fVisibleRegionDuringProjection == null) { + return; + } + int oldLength= event.getLength(); + int newLength= event.getText().length(); + int oldVisibleRegionEnd= fVisibleRegionDuringProjection.getOffset() + fVisibleRegionDuringProjection.getLength(); + if (event.getOffset() < fVisibleRegionDuringProjection.getOffset()) { + fVisibleRegionDuringProjection= new Region(fVisibleRegionDuringProjection.getOffset() + newLength - oldLength, fVisibleRegionDuringProjection.getLength()); + } else if (event.getOffset() + oldLength <= oldVisibleRegionEnd) { + fVisibleRegionDuringProjection= new Region(fVisibleRegionDuringProjection.getOffset(), fVisibleRegionDuringProjection.getLength() + newLength - oldLength); + } + } + + @Override + public void documentAboutToBeChanged(DocumentEvent event) { + } + } + /** The projection annotation model used by this viewer. */ private ProjectionAnnotationModel fProjectionAnnotationModel; /** The annotation model listener */ @@ -292,6 +321,11 @@ private void computeExpectedExecutionCosts() { private IDocument fReplaceVisibleDocumentExecutionTrigger; /** true if projection was on the last time we switched to segmented mode. */ private boolean fWasProjectionEnabled; + /** + * The region set by {@link #setVisibleRegion(int, int)} during projection or null + * if not in a projection + */ + private IRegion fVisibleRegionDuringProjection; /** The queue of projection commands used to assess the costs of projection changes. */ private ProjectionCommandQueue fCommandQueue; /** @@ -301,6 +335,8 @@ private void computeExpectedExecutionCosts() { */ private int fDeletedLines; + private UpdateDocumentListener fUpdateDocumentListener; + /** * Creates a new projection source viewer. @@ -313,6 +349,7 @@ private void computeExpectedExecutionCosts() { */ public ProjectionViewer(Composite parent, IVerticalRuler ruler, IOverviewRuler overviewRuler, boolean showsAnnotationOverview, int styles) { super(parent, ruler, overviewRuler, showsAnnotationOverview, styles); + fUpdateDocumentListener= new UpdateDocumentListener(); } /** @@ -510,6 +547,14 @@ public final void disableProjection() { fProjectionAnnotationModel.removeAllAnnotations(); fFindReplaceDocumentAdapter= null; fireProjectionDisabled(); + if (fVisibleRegionDuringProjection != null) { + super.setVisibleRegion(fVisibleRegionDuringProjection.getOffset(), fVisibleRegionDuringProjection.getLength()); + fVisibleRegionDuringProjection= null; + } + IDocument document= getDocument(); + if (document != null) { + document.removeDocumentListener(fUpdateDocumentListener); + } } } @@ -521,6 +566,15 @@ public final void enableProjection() { addProjectionAnnotationModel(getVisualAnnotationModel()); fFindReplaceDocumentAdapter= null; fireProjectionEnabled(); + IDocument document= getDocument(); + if (document == null) { + return; + } + IRegion visibleRegion= getVisibleRegion(); + if (visibleRegion != null && (visibleRegion.getOffset() != 0 || visibleRegion.getLength() != 0) && visibleRegion.getLength() < document.getLength()) { + setVisibleRegion(visibleRegion.getOffset(), visibleRegion.getLength()); + } + document.addDocumentListener(fUpdateDocumentListener); } } @@ -529,6 +583,10 @@ private void expandAll() { IDocument doc= getDocument(); int length= doc == null ? 0 : doc.getLength(); if (isProjectionMode()) { + if (fVisibleRegionDuringProjection != null) { + offset= fVisibleRegionDuringProjection.getOffset(); + length= fVisibleRegionDuringProjection.getLength(); + } fProjectionAnnotationModel.expandAll(offset, length); } } @@ -683,9 +741,70 @@ private int toLineStart(IDocument document, int offset, boolean testLastLine) th @Override public void setVisibleRegion(int start, int length) { - fWasProjectionEnabled= isProjectionMode(); - disableProjection(); - super.setVisibleRegion(start, length); + if (!isProjectionMode()) { + super.setVisibleRegion(start, length); + return; + } + IDocument document= getDocument(); + if (document == null) { + return; + } + try { + // If the visible region changes, make sure collapsed regions outside of the old visible regions are expanded + // and collapse everything outside the new visible region + int end= computeEndOfVisibleRegion(start, length, document); + expandOutsideCurrentVisibleRegion(document); + collapseOutsideOfNewVisibleRegion(start, end, document); + fVisibleRegionDuringProjection= new Region(start, end - start - 1); + } catch (BadLocationException e) { + ILog log= ILog.of(getClass()); + log.log(new Status(IStatus.WARNING, getClass(), IStatus.OK, null, e)); + } + } + + private void expandOutsideCurrentVisibleRegion(IDocument document) throws BadLocationException { + if (fVisibleRegionDuringProjection != null) { + expand(0, fVisibleRegionDuringProjection.getOffset(), false); + int oldEnd= fVisibleRegionDuringProjection.getOffset() + fVisibleRegionDuringProjection.getLength(); + int length= document.getLength() - oldEnd; + if (length > 0) { + expand(oldEnd, length, false); + } + } + } + + private void collapseOutsideOfNewVisibleRegion(int start, int end, IDocument document) throws BadLocationException { + int documentLength= document.getLength(); + collapse(0, start, true); + + int endInvisibleRegionLength= documentLength - end; + if (endInvisibleRegionLength > 0) { + collapse(end, endInvisibleRegionLength, true); + } + } + + private static int computeEndOfVisibleRegion(int start, int length, IDocument document) throws BadLocationException { + int documentLength= document.getLength(); + int end= start + length + 1; + // ensure that trailing whitespace is included + // In this case, the line break needs to be included as well + boolean visibleRegionEndsWithTrailingWhitespace= end < documentLength && isWhitespaceButNotNewline(document.getChar(end - 1)); + while (end < documentLength && isWhitespaceButNotNewline(document.getChar(end))) { + end++; + visibleRegionEndsWithTrailingWhitespace= true; + } + if (visibleRegionEndsWithTrailingWhitespace && end < documentLength && isLineBreak(document.getChar(end))) { + end++; + } + return end; + } + + private static boolean isWhitespaceButNotNewline(char c) { + return Character.isWhitespace(c) && !isLineBreak(c); + } + + private static boolean isLineBreak(char c) { + return c == '\n' || c == '\r'; } @Override @@ -710,7 +829,9 @@ public void resetVisibleRegion() { @Override public IRegion getVisibleRegion() { - disableProjection(); + if (fVisibleRegionDuringProjection != null) { + return fVisibleRegionDuringProjection; + } IRegion visibleRegion= getModelCoverage(); if (visibleRegion == null) visibleRegion= new Region(0, 0); diff --git a/tests/org.eclipse.jface.text.tests/src/org/eclipse/jface/text/tests/ProjectionViewerTest.java b/tests/org.eclipse.jface.text.tests/src/org/eclipse/jface/text/tests/ProjectionViewerTest.java index 03c3773b542..f0ebe1b11dd 100644 --- a/tests/org.eclipse.jface.text.tests/src/org/eclipse/jface/text/tests/ProjectionViewerTest.java +++ b/tests/org.eclipse.jface.text.tests/src/org/eclipse/jface/text/tests/ProjectionViewerTest.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2022 Red Hat, Inc. and others. + * Copyright (c) 2022, 2025 Red Hat, Inc. and others. * * This program and the accompanying materials * are made available under the terms of the Eclipse Public License 2.0 @@ -18,6 +18,7 @@ import org.eclipse.swt.dnd.Clipboard; import org.eclipse.swt.dnd.TextTransfer; import org.eclipse.swt.layout.FillLayout; +import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Shell; import org.eclipse.jface.text.BadLocationException; @@ -29,12 +30,28 @@ import org.eclipse.jface.text.Position; import org.eclipse.jface.text.Region; import org.eclipse.jface.text.source.AnnotationModel; +import org.eclipse.jface.text.source.IOverviewRuler; +import org.eclipse.jface.text.source.IVerticalRuler; import org.eclipse.jface.text.source.projection.IProjectionPosition; import org.eclipse.jface.text.source.projection.ProjectionAnnotation; import org.eclipse.jface.text.source.projection.ProjectionViewer; public class ProjectionViewerTest { + /** + * A {@link ProjectionViewer} that provides access to {@link #getVisibleDocument()}. + */ + private final class TestProjectionViewer extends ProjectionViewer { + private TestProjectionViewer(Composite parent, IVerticalRuler ruler, IOverviewRuler overviewRuler, boolean showsAnnotationOverview, int styles) { + super(parent, ruler, overviewRuler, showsAnnotationOverview, styles); + } + + @Override + public IDocument getVisibleDocument() { + return super.getVisibleDocument(); + } + } + private static final class ProjectionPosition extends Position implements IProjectionPosition { public ProjectionPosition(IDocument document) { @@ -75,4 +92,281 @@ public void testCopyPaste() { shell.dispose(); } } + + @Test + public void testVisibleRegionDoesNotChangeWithProjections() { + Shell shell= new Shell(); + shell.setLayout(new FillLayout()); + ProjectionViewer viewer= new ProjectionViewer(shell, null, null, false, SWT.NONE); + String documentContent= """ + Hello + World + 123 + 456 + """; + Document document= new Document(documentContent); + viewer.setDocument(document, new AnnotationModel()); + int regionLength= documentContent.indexOf('\n'); + viewer.setVisibleRegion(0, regionLength); + viewer.enableProjection(); + viewer.getProjectionAnnotationModel().addAnnotation(new ProjectionAnnotation(false), new ProjectionPosition(document)); + shell.setVisible(true); + try { + assertEquals(0, viewer.getVisibleRegion().getOffset()); + assertEquals(regionLength, viewer.getVisibleRegion().getLength()); + + viewer.getTextOperationTarget().doOperation(ProjectionViewer.COLLAPSE_ALL); + assertEquals(0, viewer.getVisibleRegion().getOffset()); + assertEquals(regionLength, viewer.getVisibleRegion().getLength()); + } finally { + shell.dispose(); + } + } + + @Test + public void testVisibleRegionProjectionCannotBeExpanded() { + Shell shell= new Shell(); + shell.setLayout(new FillLayout()); + TestProjectionViewer viewer= new TestProjectionViewer(shell, null, null, false, SWT.NONE); + String documentContent= """ + Hello + World + 123 + 456 + """; + Document document= new Document(documentContent); + viewer.setDocument(document, new AnnotationModel()); + int secondLineStart= documentContent.indexOf("World"); + int secondLineEnd= documentContent.indexOf('\n', secondLineStart); + viewer.setVisibleRegion(secondLineStart, secondLineEnd - secondLineStart); + viewer.enableProjection(); + shell.setVisible(true); + try { + assertEquals("World", viewer.getVisibleDocument().get()); + viewer.getTextOperationTarget().doOperation(ProjectionViewer.EXPAND_ALL); + assertEquals("World", viewer.getVisibleDocument().get()); + } finally { + shell.dispose(); + } + } + + @Test + public void testVisibleRegionAddsProjectionAnnotationsIfProjectionsEnabled() { + testProjectionAnnotationsFromVisibleRegion(true); + } + + @Test + public void testEnableProjectionAddsProjectionAnnotationsIfVisibleRegionEnabled() { + testProjectionAnnotationsFromVisibleRegion(false); + } + + private void testProjectionAnnotationsFromVisibleRegion(boolean enableProjectionFirst) { + Shell shell= new Shell(); + shell.setLayout(new FillLayout()); + TestProjectionViewer viewer= new TestProjectionViewer(shell, null, null, false, SWT.NONE); + String documentContent= """ + Hello + World + 123 + 456 + """; + Document document= new Document(documentContent); + viewer.setDocument(document, new AnnotationModel()); + int secondLineStart= documentContent.indexOf("World"); + int secondLineEnd= documentContent.indexOf('\n', secondLineStart); + + shell.setVisible(true); + if (enableProjectionFirst) { + viewer.enableProjection(); + viewer.setVisibleRegion(secondLineStart, secondLineEnd - secondLineStart); + } else { + viewer.setVisibleRegion(secondLineStart, secondLineEnd - secondLineStart); + viewer.enableProjection(); + } + + try { + assertEquals("World", viewer.getVisibleDocument().get().trim()); + } finally { + shell.dispose(); + } + } + + @Test + public void testInsertIntoVisibleRegion() throws BadLocationException { + Shell shell= new Shell(); + shell.setLayout(new FillLayout()); + TestProjectionViewer viewer= new TestProjectionViewer(shell, null, null, false, SWT.NONE); + String documentContent= """ + Hello + World + 123 + 456 + """; + Document document= new Document(documentContent); + viewer.setDocument(document, new AnnotationModel()); + int secondLineStart= documentContent.indexOf("World"); + int secondLineEnd= documentContent.indexOf('\n', secondLineStart); + + shell.setVisible(true); + + try { + viewer.setVisibleRegion(secondLineStart, secondLineEnd - secondLineStart); + viewer.enableProjection(); + + assertEquals("World", viewer.getVisibleDocument().get()); + + viewer.getDocument().replace(documentContent.indexOf("rld"), 0, "---"); + + assertEquals("Wo---rld", viewer.getVisibleDocument().get()); + } finally { + shell.dispose(); + } + } + + @Test + public void testRemoveVisibleRegionEnd() throws BadLocationException { + testReplaceVisibleRegionEnd(""); + } + + @Test + public void testReplaceVisibleRegionEnd() throws BadLocationException { + testReplaceVisibleRegionEnd("---"); + } + + + private void testReplaceVisibleRegionEnd(String toReplaceWith) throws BadLocationException { + Shell shell= new Shell(); + shell.setLayout(new FillLayout()); + TestProjectionViewer viewer= new TestProjectionViewer(shell, null, null, false, SWT.NONE); + String documentContent= """ + Hello + World + 123 + 456 + """; + Document document= new Document(documentContent); + viewer.setDocument(document, new AnnotationModel()); + int secondLineStart= documentContent.indexOf("World"); + int secondLineEnd= documentContent.indexOf('\n', secondLineStart); + + shell.setVisible(true); + + try { + viewer.setVisibleRegion(secondLineStart, secondLineEnd - secondLineStart); + viewer.enableProjection(); + + assertEquals("World", viewer.getVisibleDocument().get()); + + viewer.getDocument().replace(documentContent.indexOf("d\n1"), 3, toReplaceWith); + + assertEquals("Worl" + toReplaceWith, viewer.getVisibleDocument().get()); + } finally { + shell.dispose(); + } + } + + @Test + public void testRemoveVisibleRegionStart() throws BadLocationException { + testReplaceVisibleRegionStart(""); + } + + @Test + public void testReplaceVisibleRegionStart() throws BadLocationException { + testReplaceVisibleRegionStart("---"); + } + + + private void testReplaceVisibleRegionStart(String toReplaceWith) throws BadLocationException { + Shell shell= new Shell(); + shell.setLayout(new FillLayout()); + TestProjectionViewer viewer= new TestProjectionViewer(shell, null, null, false, SWT.NONE); + String documentContent= """ + Hello + World + 123 + 456 + """; + Document document= new Document(documentContent); + viewer.setDocument(document, new AnnotationModel()); + int secondLineStart= documentContent.indexOf("World"); + int secondLineEnd= documentContent.indexOf('\n', secondLineStart); + + shell.setVisible(true); + + try { + viewer.setVisibleRegion(secondLineStart, secondLineEnd - secondLineStart); + viewer.enableProjection(); + + assertEquals("World", viewer.getVisibleDocument().get()); + + viewer.getDocument().replace(documentContent.indexOf("o\nW"), 3, toReplaceWith); + + assertEquals(toReplaceWith + "orld", viewer.getVisibleDocument().get()); + } finally { + shell.dispose(); + } + } + + @Test + public void testVisibleRegionEndsWithWhitespace() { + Shell shell= new Shell(); + shell.setLayout(new FillLayout()); + TestProjectionViewer viewer= new TestProjectionViewer(shell, null, null, false, SWT.NONE); + String documentContent= """ + Hello + World\t\t + 123 + 456 + """; + Document document= new Document(documentContent); + viewer.setDocument(document, new AnnotationModel()); + int secondLineStart= documentContent.indexOf("World"); + int secondLineTextEnd= documentContent.indexOf('\n', secondLineStart); + int secondLineEnd= documentContent.indexOf('\n', secondLineStart); + + shell.setVisible(true); + + try { + viewer.setVisibleRegion(secondLineStart, secondLineEnd - secondLineStart); + viewer.enableProjection(); + + assertEquals("World\t\t", viewer.getVisibleDocument().get()); + + viewer.setVisibleRegion(secondLineStart, secondLineTextEnd - secondLineStart); + + assertEquals("World\t\t\n", viewer.getVisibleDocument().get()); + + + } finally { + shell.dispose(); + } + } + + @Test + public void testRemoveEntireVisibleRegion() throws BadLocationException { + Shell shell= new Shell(); + shell.setLayout(new FillLayout()); + TestProjectionViewer viewer= new TestProjectionViewer(shell, null, null, false, SWT.NONE); + String documentContent= """ + Hello + World + 123 + 456 + """; + Document document= new Document(documentContent); + viewer.setDocument(document, new AnnotationModel()); + int secondLineStart= documentContent.indexOf("World"); + int secondLineEnd= documentContent.indexOf('\n', secondLineStart); + viewer.setVisibleRegion(secondLineStart, secondLineEnd - secondLineStart); + viewer.enableProjection(); + shell.setVisible(true); + try { + document.replace(secondLineStart, secondLineEnd - secondLineStart, ""); + assertEquals("", viewer.getVisibleDocument().get()); + assertEquals(new Region(secondLineStart, 0), viewer.getVisibleRegion()); + } finally { + shell.dispose(); + } + } + }