forked from eclipse-platform/eclipse.platform.ui
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Provide sticky scrolling in source editors eclipse-platform#1719
With this change, sticky scrolling is introduced into Eclipse text editors. Sticky scrolling will keep certain source code lines visible and in a fixed position on the screen as the user scrolls down the page. This technique improves user experience by keeping information within reach at all times. The feature can be enabled via the TextEditor settings. Provides feature for eclipse-platform#1719 and eclipse-jdt/eclipse.jdt.ui#1364
- Loading branch information
1 parent
f981728
commit c3eb1fd
Showing
9 changed files
with
886 additions
and
173 deletions.
There are no files selected for viewing
91 changes: 68 additions & 23 deletions
91
...ui.editors/src/org/eclipse/ui/internal/editors/text/TextEditorDefaultsPreferencePage.java
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
26 changes: 26 additions & 0 deletions
26
...rg.eclipse.ui.editors/src/org/eclipse/ui/internal/texteditor/stickyscroll/StickyLine.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
/******************************************************************************* | ||
* Copyright (c) 2024 SAP SE. | ||
* | ||
* This program and the accompanying materials | ||
* are made available under the terms of the Eclipse Public License 2.0 | ||
* which accompanies this distribution, and is available at | ||
* https://www.eclipse.org/legal/epl-2.0/ | ||
* | ||
* SPDX-License-Identifier: EPL-2.0 | ||
* | ||
* Contributors: | ||
* SAP SE - initial API and implementation | ||
*******************************************************************************/ | ||
package org.eclipse.ui.internal.texteditor.stickyscroll; | ||
|
||
/** | ||
* | ||
* A record representing a sticky line, containing indentation, text, and line number. It serves as | ||
* an abstraction to represent sticky line for sticky scrolling. | ||
* | ||
* @param indentation The number of spaces in front of the line | ||
* @param text the text of the corresponding sticky line | ||
* @param lineNumber the specific line number of the sticky line | ||
*/ | ||
public record StickyLine(int indentation, String text, int lineNumber) { | ||
} |
348 changes: 348 additions & 0 deletions
348
...i.editors/src/org/eclipse/ui/internal/texteditor/stickyscroll/StickyScrollingControl.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,348 @@ | ||
/******************************************************************************* | ||
* Copyright (c) 2024 SAP SE. | ||
* | ||
* This program and the accompanying materials | ||
* are made available under the terms of the Eclipse Public License 2.0 | ||
* which accompanies this distribution, and is available at | ||
* https://www.eclipse.org/legal/epl-2.0/ | ||
* | ||
* SPDX-License-Identifier: EPL-2.0 | ||
* | ||
* Contributors: | ||
* SAP SE - initial API and implementation | ||
*******************************************************************************/ | ||
package org.eclipse.ui.internal.texteditor.stickyscroll; | ||
|
||
import java.util.ArrayList; | ||
import java.util.List; | ||
import java.util.StringJoiner; | ||
|
||
import org.eclipse.swt.SWT; | ||
import org.eclipse.swt.custom.StyleRange; | ||
import org.eclipse.swt.custom.StyledText; | ||
import org.eclipse.swt.events.ControlEvent; | ||
import org.eclipse.swt.events.ControlListener; | ||
import org.eclipse.swt.events.KeyAdapter; | ||
import org.eclipse.swt.events.KeyEvent; | ||
import org.eclipse.swt.events.KeyListener; | ||
import org.eclipse.swt.events.MouseAdapter; | ||
import org.eclipse.swt.events.MouseEvent; | ||
import org.eclipse.swt.events.MouseTrackAdapter; | ||
import org.eclipse.swt.graphics.Point; | ||
import org.eclipse.swt.graphics.Rectangle; | ||
import org.eclipse.swt.layout.GridData; | ||
import org.eclipse.swt.widgets.Canvas; | ||
import org.eclipse.swt.widgets.Composite; | ||
import org.eclipse.swt.widgets.Display; | ||
import org.eclipse.swt.widgets.Label; | ||
|
||
import org.eclipse.core.runtime.ICoreRunnable; | ||
import org.eclipse.core.runtime.jobs.Job; | ||
|
||
import org.eclipse.jface.layout.GridDataFactory; | ||
import org.eclipse.jface.layout.GridLayoutFactory; | ||
|
||
import org.eclipse.jface.text.ITextPresentationListener; | ||
import org.eclipse.jface.text.ITextViewerExtension; | ||
import org.eclipse.jface.text.ITextViewerExtension4; | ||
import org.eclipse.jface.text.ITextViewerExtension5; | ||
import org.eclipse.jface.text.source.ISourceViewer; | ||
import org.eclipse.jface.text.source.IVerticalRuler; | ||
|
||
/** | ||
* This class represents the StickyScrollingControl and is rendered on top of the given source | ||
* viewer. Sticky lines that are set via {@link #setStickyLines(List)} are always visible on top of | ||
* the source viewer. The {@link StickyLine#lineNumber()} is linked to to line number in the given | ||
* source viewer. | ||
* | ||
* The control takes care of the layout of the sticky lines, the styling of the sticky lines and the | ||
* navigation to the corresponding sticky line. | ||
* | ||
* Note: The dispose method should be called to clean up system resources associated with this | ||
* object when it's no longer needed. | ||
*/ | ||
public class StickyScrollingControl { | ||
|
||
private List<StickyLine> stickyLines; | ||
|
||
private ISourceViewer sourceViewer; | ||
|
||
private IVerticalRuler verticalRuler; | ||
|
||
private StickyScrollingControlSettings settings; | ||
|
||
private Canvas stickyLinesCanvas; | ||
|
||
private StyledText lineNumbers; | ||
|
||
private StyledText lineTexts; | ||
|
||
|
||
private ITextPresentationListener textPresentationListener; | ||
|
||
private KeyListener keyListener; | ||
|
||
private ControlListener controlListener; | ||
|
||
private Label bottomSeparator; | ||
|
||
public StickyScrollingControl(ISourceViewer sourceViewer, StickyScrollingControlSettings settings) { | ||
this(sourceViewer, null, settings); | ||
} | ||
|
||
public StickyScrollingControl(ISourceViewer sourceViewer, IVerticalRuler verticalRuler, StickyScrollingControlSettings settings) { | ||
this.stickyLines= new ArrayList<>(); | ||
this.sourceViewer= sourceViewer; | ||
this.verticalRuler= verticalRuler; | ||
this.settings= settings; | ||
|
||
createControls(); | ||
addSourceViewerListeners(); | ||
} | ||
|
||
public void setStickyLines(List<StickyLine> lines) { | ||
stickyLines= lines; | ||
updateStickyScrollingControls(); | ||
} | ||
|
||
public void setSettings(StickyScrollingControlSettings settings) { | ||
this.settings= settings; | ||
updateStickyScrollingControls(); | ||
} | ||
|
||
public void dispose() { | ||
if (sourceViewer instanceof ITextViewerExtension4 extension) { | ||
extension.removeTextPresentationListener(textPresentationListener); | ||
} | ||
if (sourceViewer.getTextWidget() != null) { | ||
sourceViewer.getTextWidget().removeKeyListener(keyListener); | ||
sourceViewer.getTextWidget().removeControlListener(controlListener); | ||
} | ||
this.stickyLinesCanvas.dispose(); | ||
} | ||
|
||
private void createControls() { | ||
Composite sourceViewerComposite= null; | ||
if (sourceViewer instanceof ITextViewerExtension extension) { | ||
sourceViewerComposite= (Composite) extension.getControl(); | ||
} else { | ||
sourceViewerComposite= sourceViewer.getTextWidget().getParent(); | ||
} | ||
|
||
stickyLinesCanvas= new Canvas(sourceViewerComposite, SWT.NONE); | ||
addMouseListeners(stickyLinesCanvas); | ||
GridLayoutFactory.fillDefaults().numColumns(3).spacing(0, 0).applyTo(stickyLinesCanvas); | ||
|
||
lineNumbers= new StyledText(stickyLinesCanvas, SWT.CENTER | SWT.WRAP); | ||
GridDataFactory.fillDefaults().grab(false, true).exclude(verticalRuler == null).applyTo(lineNumbers); | ||
lineNumbers.setVisible(verticalRuler != null); | ||
lineNumbers.setEnabled(false); | ||
|
||
lineTexts= new StyledText(stickyLinesCanvas, SWT.NONE); | ||
GridDataFactory.fillDefaults().grab(true, true).applyTo(lineTexts); | ||
lineTexts.setEnabled(false); | ||
|
||
Label rightSeparator= new Label(stickyLinesCanvas, SWT.SEPARATOR | SWT.SHADOW_OUT | SWT.VERTICAL); | ||
GridDataFactory.fillDefaults().grab(false, true).span(1, 2).applyTo(rightSeparator); | ||
rightSeparator.setEnabled(false); | ||
|
||
bottomSeparator= new Label(stickyLinesCanvas, SWT.SEPARATOR | SWT.SHADOW_OUT | SWT.HORIZONTAL); | ||
GridDataFactory.fillDefaults().grab(true, false).span(2, 1).applyTo(bottomSeparator); | ||
bottomSeparator.setEnabled(false); | ||
|
||
stickyLinesCanvas.moveAbove(null); | ||
} | ||
|
||
private void updateStickyScrollingControls() { | ||
StringJoiner sourceCodeJoiner= new StringJoiner(System.lineSeparator()); | ||
StringJoiner lineNumberJoiner= new StringJoiner(System.lineSeparator()); | ||
for (int i= 0; i < getNumberStickyLines(); i++) { | ||
StickyLine stickyLine= stickyLines.get(i); | ||
sourceCodeJoiner.add(stickyLine.text()); | ||
lineNumberJoiner.add(getMappedLineNumber(stickyLine)); | ||
} | ||
lineTexts.setText(sourceCodeJoiner.toString()); | ||
lineNumbers.setText(lineNumberJoiner.toString()); | ||
|
||
styleStickyLines(); | ||
layoutStickyLines(); | ||
} | ||
|
||
private String getMappedLineNumber(StickyLine stickyLine) { | ||
if (sourceViewer instanceof ITextViewerExtension5 extension) { | ||
int number= extension.widgetLine2ModelLine(stickyLine.lineNumber()) + 1; | ||
return String.valueOf(number); | ||
} else { | ||
int number= stickyLine.lineNumber() + 1; | ||
return String.valueOf(number); | ||
} | ||
} | ||
|
||
private void styleStickyLines() { | ||
StyledText textWidget= sourceViewer.getTextWidget(); | ||
|
||
int stickyLineOffset= 0; | ||
for (int i= 0; i < getNumberStickyLines(); i++) { | ||
StickyLine stickyLine= stickyLines.get(i); | ||
int lineStartOffset= textWidget.getOffsetAtLine(stickyLine.lineNumber()); | ||
StyleRange[] copiedStyleRanges= textWidget.getStyleRanges(lineStartOffset, stickyLine.text().length()); | ||
for (StyleRange copiedStyleRange : copiedStyleRanges) { | ||
copiedStyleRange.start= copiedStyleRange.start - lineStartOffset + stickyLineOffset; | ||
lineTexts.setStyleRange(copiedStyleRange); | ||
} | ||
stickyLineOffset+= stickyLine.text().length() + System.lineSeparator().length(); | ||
} | ||
|
||
lineTexts.setFont(textWidget.getFont()); | ||
lineTexts.setBackground(textWidget.getBackground()); | ||
lineTexts.setLineSpacing(textWidget.getLineSpacing()); | ||
|
||
lineNumbers.setFont(textWidget.getFont()); | ||
lineNumbers.setBackground(textWidget.getBackground()); | ||
lineNumbers.setStyleRange(new StyleRange(0, lineNumbers.getText().length(), settings.lineNumberColor(), null)); | ||
lineNumbers.setLineSpacing(textWidget.getLineSpacing()); | ||
} | ||
|
||
private void layoutStickyLines() { | ||
if (getNumberStickyLines() == 0) { | ||
stickyLinesCanvas.setVisible(false); | ||
return; | ||
} | ||
|
||
StyledText textWidget= sourceViewer.getTextWidget(); | ||
stickyLinesCanvas.setVisible(true); | ||
|
||
lineTexts.setLeftMargin(textWidget.getLeftMargin()); | ||
|
||
if (verticalRuler != null) { | ||
((GridData) lineNumbers.getLayoutData()).widthHint= verticalRuler.getWidth(); | ||
} | ||
|
||
stickyLinesCanvas.pack(); | ||
|
||
int numberStickyLines= getNumberStickyLines(); | ||
int lineHeight= lineTexts.getLineHeight() * numberStickyLines; | ||
int spacingHeight= lineTexts.getLineSpacing() * (numberStickyLines - 1); | ||
int separatorHeight= bottomSeparator.getBounds().height; | ||
|
||
Rectangle bounds= new Rectangle(0, 0, 0, 0); | ||
bounds.height= lineHeight + spacingHeight + separatorHeight; | ||
|
||
bounds.width= textWidget.getBounds().width + textWidget.getLeftMargin(); | ||
if (verticalRuler != null) { | ||
bounds.width+= verticalRuler.getWidth(); | ||
} | ||
if (textWidget.getVerticalBar() != null) { | ||
bounds.width-= textWidget.getVerticalBar().getSize().x; | ||
} | ||
|
||
stickyLinesCanvas.setBounds(bounds); | ||
} | ||
|
||
private void navigateToClickedLine(MouseEvent e) { | ||
int clickedStickyLineIndex= lineTexts.getLineIndex(e.y); | ||
StickyLine clickedStickyLine= stickyLines.get(clickedStickyLineIndex); | ||
|
||
enusreSourceViewerLineVisible(clickedStickyLine.lineNumber()); | ||
|
||
int offset= sourceViewer.getTextWidget().getOffsetAtLine(clickedStickyLine.lineNumber()); | ||
sourceViewer.getTextWidget().setSelection(offset); | ||
sourceViewer.getTextWidget().forceFocus(); | ||
} | ||
|
||
private void enusreSourceViewerLineVisible(int line) { | ||
StyledText textWidget= sourceViewer.getTextWidget(); | ||
if (line < textWidget.getTopIndex() + settings.maxCountStickyLines()) { | ||
int jumpTo= Math.max(0, line - settings.maxCountStickyLines()); | ||
if (sourceViewer instanceof ITextViewerExtension5 extension) { | ||
jumpTo= extension.widgetLine2ModelLine(jumpTo); | ||
} | ||
sourceViewer.setTopIndex(jumpTo); | ||
} | ||
} | ||
|
||
private int getNumberStickyLines() { | ||
return Math.min(settings.maxCountStickyLines(), this.stickyLines.size()); | ||
} | ||
|
||
/** | ||
* Add several listeners to the source viewer.<br> | ||
* | ||
* textPresentationListener in order to style the sticky lines when the source viewer styling | ||
* has changed.<br> | ||
* <br> | ||
* keyListener in order to scroll the source viewer so that the affected line is visible under | ||
* the sticky lines.<br> | ||
* controlListener in order to layout the sticky lines when the source viewer is | ||
* resized/moved.<br> | ||
*/ | ||
private void addSourceViewerListeners() { | ||
if (sourceViewer instanceof ITextViewerExtension4 extension) { | ||
textPresentationListener= e -> { | ||
Job.create("Update sticky lines styling", (ICoreRunnable) monitor -> { //$NON-NLS-1$ | ||
Display.getDefault().asyncExec(() -> { | ||
styleStickyLines(); | ||
}); | ||
}).schedule(); | ||
}; | ||
extension.addTextPresentationListener(textPresentationListener); | ||
} | ||
|
||
keyListener= new KeyAdapter() { | ||
@Override | ||
public void keyPressed(KeyEvent e) { | ||
StyledText textWidget= sourceViewer.getTextWidget(); | ||
Point selection= textWidget.getSelection(); | ||
int lineAtOffset= textWidget.getLineAtOffset(selection.x); | ||
enusreSourceViewerLineVisible(lineAtOffset); | ||
} | ||
}; | ||
sourceViewer.getTextWidget().addKeyListener(keyListener); | ||
|
||
controlListener= new ControlListener() { | ||
@Override | ||
public void controlResized(ControlEvent e) { | ||
layoutStickyLines(); | ||
} | ||
|
||
@Override | ||
public void controlMoved(ControlEvent e) { | ||
layoutStickyLines(); | ||
} | ||
}; | ||
sourceViewer.getTextWidget().addControlListener(controlListener); | ||
} | ||
|
||
/** | ||
* Sets the cursor on the canvas to {@link SWT#CURSOR_HAND} and adds several mouse listeners to | ||
* the canvas.<br> | ||
* <br> | ||
* mouseListener in order to navigate to the clicked sticky line<br> | ||
* mouseMoveListener in order to highlight the affected sticky line<br> | ||
* mouseTrackListener in order to remove the highlighting when the control is exit<br> | ||
*/ | ||
private void addMouseListeners(Canvas canvas) { | ||
canvas.setCursor(Display.getDefault().getSystemCursor(SWT.CURSOR_HAND)); | ||
|
||
canvas.addMouseListener(new MouseAdapter() { | ||
@Override | ||
public void mouseDown(MouseEvent e) { | ||
navigateToClickedLine(e); | ||
} | ||
}); | ||
|
||
canvas.addMouseMoveListener(e -> { | ||
int affectedStickyLineIndex= lineTexts.getLineIndex(e.y); | ||
lineTexts.setLineBackground(0, getNumberStickyLines(), null); | ||
lineTexts.setLineBackground(affectedStickyLineIndex, 1, settings.stickyLineHoverColor()); | ||
}); | ||
|
||
canvas.addMouseTrackListener(new MouseTrackAdapter() { | ||
@Override | ||
public void mouseExit(MouseEvent e) { | ||
lineTexts.setLineBackground(0, getNumberStickyLines(), null); | ||
} | ||
}); | ||
} | ||
|
||
} |
Oops, something went wrong.