Skip to content

Commit

Permalink
Provide sticky scrolling in source editors eclipse-platform#1719
Browse files Browse the repository at this point in the history
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
Christopher-Hermann committed May 28, 2024
1 parent 6990a01 commit f873dd1
Show file tree
Hide file tree
Showing 9 changed files with 886 additions and 173 deletions.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ private TextEditorMessages() {
public static String TextEditorDefaultsPreferencePage_showWhitespaceCharactersDialogInvalidInput;
public static String TextEditorDefaultsPreferencePage_showWhitespaceCharactersDialogTitle;
public static String TextEditorDefaultsPreferencePage_space;
public static String TextEditorDefaultsPreferencePage_stickyScrollingEnabled;
public static String TextEditorDefaultsPreferencePage_stickyScrollingMaximumCount;
public static String TextEditorDefaultsPreferencePage_stickyScrollingMaximumCount_description;
public static String TextEditorDefaultsPreferencePage_tab;
public static String TextEditorDefaultsPreferencePage_textDragAndDrop;
public static String TextEditorDefaultsPreferencePage_trailing;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ TextEditorDefaultsPreferencePage_showWhitespaceCharactersLinkText= (<a>configure
TextEditorDefaultsPreferencePage_showWhitespaceCharactersDialogInvalidInput=''{0}'' is not a valid input.
TextEditorDefaultsPreferencePage_showWhitespaceCharactersDialogTitle=Show Whitespace Characters
TextEditorDefaultsPreferencePage_space=Space ( \u00b7 )
TextEditorDefaultsPreferencePage_stickyScrollingEnabled=Enable sticky scrolling
TextEditorDefaultsPreferencePage_stickyScrollingMaximumCount=Sticky scroll maximum lines count
TextEditorDefaultsPreferencePage_stickyScrollingMaximumCount_description=Limits the number of shown sticky lines when scrolling
TextEditorDefaultsPreferencePage_tab=Tab ( \u00bb )
TextEditorDefaultsPreferencePage_textDragAndDrop= Enable drag and dro&p of text
TextEditorDefaultsPreferencePage_trailing=Trailing
Expand Down
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) {
}
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);
}
});
}

}
Loading

0 comments on commit f873dd1

Please sign in to comment.