IJPL-449 Sticky lines

GitOrigin-RevId: ffe8c135084017a1a31142ba2bdbce9ece9ba94e
This commit is contained in:
Aleksandr Trushev
2024-01-22 19:38:41 +00:00
committed by intellij-monorepo-bot
parent 60972ac2df
commit 3ee9a9f75c
26 changed files with 1334 additions and 6 deletions
@@ -210,6 +210,9 @@ public interface EditorSettings {
boolean isInsertParenthesesAutomatically();
boolean isStickyLinesShown();
int getStickyLinesLimit();
enum LineNumerationType {
ABSOLUTE,
RELATIVE,
@@ -82,6 +82,8 @@ public class EditorSettingsExternalizable implements PersistentStateComponent<Ed
public boolean IS_FOLDING_ENDINGS_SHOWN = false; //is not used in old UI
public boolean SHOW_BREADCRUMBS_ABOVE = false;
public boolean SHOW_BREADCRUMBS = true;
public boolean SHOW_STICKY_LINES = false;
public int STICKY_LINES_LIMIT = 5;
public boolean ENABLE_RENDERED_DOC = false;
public boolean SHOW_INTENTION_PREVIEW = true;
public boolean USE_EDITOR_FONT_IN_INLAYS = false;
@@ -128,10 +130,17 @@ public class EditorSettingsExternalizable implements PersistentStateComponent<Ed
private final Map<String, Boolean> mapLanguageBreadcrumbs = new HashMap<>();
private final Map<String, Boolean> mapLanguageStickyLines = new HashMap<>();
public Map<String, Boolean> getLanguageBreadcrumbsMap() {
return mapLanguageBreadcrumbs;
}
@SuppressWarnings("unused")
public Map<String, Boolean> getLanguageStickyLines() {
return mapLanguageStickyLines;
}
public OptionSet() {
Application application = ApplicationManager.getApplication();
if (application != null) {
@@ -149,6 +158,14 @@ public class EditorSettingsExternalizable implements PersistentStateComponent<Ed
this.mapLanguageBreadcrumbs.putAll(map);
}
}
@SuppressWarnings("unused")
public void setLanguageStickyLines(Map<String, Boolean> map) {
if (this.mapLanguageStickyLines != map) {
this.mapLanguageStickyLines.clear();
this.mapLanguageStickyLines.putAll(map);
}
}
}
@State(
@@ -422,6 +439,47 @@ public class EditorSettingsExternalizable implements PersistentStateComponent<Ed
return newValue;
}
public boolean isStickyLinesShown() {
return myOptions.SHOW_STICKY_LINES;
}
public boolean setStickyLinesShown(boolean value) {
boolean old = myOptions.SHOW_STICKY_LINES;
if (old == value) return false;
myOptions.SHOW_STICKY_LINES = value;
myPropertyChangeSupport.firePropertyChange(PropNames.PROP_SHOW_STICKY_LINES, old, value);
return true;
}
public boolean isStickyLinesShownFor(String languageID) {
Boolean visible = myOptions.mapLanguageStickyLines.get(languageID);
if (visible == null) {
// enabled for all languages by default
return true;
}
return visible;
}
public boolean setStickyLinesShownFor(String languageID, boolean value) {
Boolean visible = myOptions.mapLanguageStickyLines.put(languageID, value);
boolean newValue = (visible == null || visible) != value;
if (newValue) {
myPropertyChangeSupport.firePropertyChange(PropNames.PROP_SHOW_STICKY_LINES_PER_LANGUAGE, visible, (Boolean)value);
}
return newValue;
}
public int getStickyLineLimit() {
return myOptions.STICKY_LINES_LIMIT;
}
public void setStickyLineLimit(int limit) {
int old = myOptions.STICKY_LINES_LIMIT;
if (old == limit) return;
myOptions.STICKY_LINES_LIMIT = limit;
myPropertyChangeSupport.firePropertyChange(PropNames.PROP_STICKY_LINES_LIMIT, old, limit);
}
public boolean isDocCommentRenderingEnabled() {
return myOptions.ENABLE_RENDERED_DOC;
}
@@ -1046,6 +1104,9 @@ public class EditorSettingsExternalizable implements PersistentStateComponent<Ed
public static final @NonNls String PROP_IS_FOLDING_ENDINGS_SHOWN = "isFoldingEndingsShown";
public static final @NonNls String PROP_SHOW_BREADCRUMBS_ABOVE = "showBreadcrumbsAbove";
public static final @NonNls String PROP_SHOW_BREADCRUMBS = "showBreadcrumbs";
public static final @NonNls String PROP_SHOW_STICKY_LINES = "showStickyLines";
public static final @NonNls String PROP_SHOW_STICKY_LINES_PER_LANGUAGE = "showStickyLinesPerLanguage";
public static final @NonNls String PROP_STICKY_LINES_LIMIT = "stickyLinesLimit";
// inconsistent name for backward compatibility because such constants are inlined at compilation stage
public static final @NonNls String PROP_ENABLE_RENDERED_DOC = "DocCommentRendering";
public static final @NonNls String PROP_SHOW_INTENTION_PREVIEW = "showIntentionPreview";
@@ -271,6 +271,8 @@ radio.show.breadcrumbs.above=Top
radio.show.breadcrumbs.below=Bottom
label.breadcrumbs.placement=Placement:
label.breadcrumbs.languages=Languages:
checkbox.show.sticky.lines=Show sticky lines while scrolling:
editbox.sticky.lines.limit=From 1 to 30
checkbox.right.margin=Show hard wrap and visual guides (configured in Code Style options)
checkbox.use.block.caret=Use block caret
checkbox.use.full.line.height.caret=Use full line height caret
@@ -6,6 +6,7 @@ import com.intellij.codeInsight.daemon.DaemonCodeAnalyzerSettings
import com.intellij.codeInsight.documentation.render.DocRenderManager
import com.intellij.ide.IdeBundle
import com.intellij.ide.ui.LafManager
import com.intellij.ide.ui.UINumericRange
import com.intellij.ide.ui.UISettings
import com.intellij.openapi.application.ApplicationBundle
import com.intellij.openapi.application.ApplicationManager
@@ -33,6 +34,7 @@ private val myCbBlockCursor get() = CheckboxDescriptor
private val myCbFullLineHeightCursor get() = CheckboxDescriptor(ApplicationBundle.message("checkbox.use.full.line.height.caret"), model::isFullLineHeightCursor, model::setFullLineHeightCursor)
private val myCbRightMargin get() = CheckboxDescriptor(ApplicationBundle.message("checkbox.right.margin"), model::isRightMarginShown, model::setRightMarginShown)
private val myCbShowLineNumbers get() = CheckboxDescriptor(ApplicationBundle.message("checkbox.show.line.numbers"), model::isLineNumbersShown, model::setLineNumbersShown)
private val mbCbShowStickyLines get() = CheckboxDescriptor(ApplicationBundle.message("checkbox.show.sticky.lines"), model::isStickyLinesShown, model::setStickyLinesShown)
private val myCbShowMethodSeparators get() = CheckboxDescriptor(if (PlatformUtils.isDataGrip()) ApplicationBundle.message("checkbox.show.method.separators.DataGrip") else ApplicationBundle.message("checkbox.show.method.separators"), DaemonCodeAnalyzerSettings.getInstance()::SHOW_METHOD_SEPARATORS)
private val myWhitespacesCheckbox get() = CheckboxDescriptor(ApplicationBundle.message("checkbox.show.whitespaces"), model::isWhitespacesShown, model::setWhitespacesShown)
private val myLeadingWhitespacesCheckBox get() = CheckboxDescriptor(ApplicationBundle.message("checkbox.show.leading.whitespaces"), model::isLeadingWhitespacesShown, model::setLeadingWhitespacesShown)
@@ -87,6 +89,15 @@ internal class EditorAppearanceConfigurable : BoundCompositeSearchableConfigurab
}
).bindItem(model::getLineNumeration, model::setLineNumeration)
}
row {
val cbShowSticky = checkBox(mbCbShowStickyLines)
.gap(RightGap.SMALL)
intTextField(UINumericRange(5, 1, 30).asRange())
.bindIntText(model::getStickyLineLimit, model::setStickyLineLimit)
.columns(4)
.enabledIf(cbShowSticky.selected)
comment(ApplicationBundle.message("editbox.sticky.lines.limit"))
}
row {
checkBox(myCbShowMethodSeparators)
}
@@ -19,6 +19,7 @@ import com.intellij.openapi.editor.ex.util.HighlighterIteratorWrapper;
import com.intellij.openapi.editor.ex.util.LexerEditorHighlighter;
import com.intellij.openapi.editor.highlighter.EditorHighlighter;
import com.intellij.openapi.editor.highlighter.HighlighterIterator;
import com.intellij.openapi.editor.impl.EditorImpl;
import com.intellij.openapi.editor.markup.*;
import com.intellij.openapi.fileEditor.FileEditorManager;
import com.intellij.openapi.fileTypes.FileType;
@@ -343,6 +344,9 @@ public final class BraceHighlightingHandler {
Document document = editor.getDocument();
int line1 = document.getLineNumber(range.getStartOffset());
int line2 = document.getLineNumber(range.getEndOffset());
if (editor instanceof EditorImpl editorImpl && editorImpl.shouldSuppressEditorFragmentHint(line1)) {
return;
}
line1 = Math.max(line1, line2 - EditorFragmentComponent.getAvailableVisualLinesAboveEditor(editor) + 1);
range = new TextRange(document.getLineStartOffset(line1), range.getEndOffset());
LightweightHint hint = EditorFragmentComponent.showEditorFragmentHint(editor, range, true, true);
@@ -0,0 +1,68 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.codeInsight.stickyLines
import com.intellij.codeHighlighting.TextEditorHighlightingPass
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.impl.stickyLines.StickyLineInfo
import com.intellij.openapi.editor.impl.stickyLines.StickyLinesCollector
import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Key
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.PsiFile
import com.intellij.psi.util.CachedValue
import com.intellij.psi.util.CachedValueProvider
import com.intellij.psi.util.CachedValuesManager
import java.util.concurrent.atomic.AtomicBoolean
private val CACHED_LINES_KEY: Key<CachedValue<Runnable>> = Key.create("editor.sticky.lines.cached")
internal class StickyLinesPass(project: Project, document: Document, private val vFile: VirtualFile, private val psiFile: PsiFile)
: TextEditorHighlightingPass(project, document), DumbAware {
private var updater: Runnable? = null
override fun doCollectInformation(progress: ProgressIndicator) {
updater = getUpdater()
}
override fun doApplyInformationToEditor() {
updater?.run()
updater = null
}
private fun getUpdater(): Runnable {
val cachedUpdater = psiFile.getUserData(CACHED_LINES_KEY)?.upToDateOrNull
if (cachedUpdater != null) {
return cachedUpdater.get()
}
return CachedValuesManager.getManager(myProject).getCachedValue(
psiFile,
CACHED_LINES_KEY,
getValueProvider(myProject, myDocument, vFile, dependency = psiFile),
false
)
}
companion object {
private fun getValueProvider(
project: Project,
document: Document,
vFile: VirtualFile,
dependency: Any
): CachedValueProvider<Runnable> {
return CachedValueProvider {
val collector = StickyLinesCollector(project, document)
val infos: MutableSet<StickyLineInfo> = collector.collectLines(vFile)
val alreadyExecuted = AtomicBoolean()
val runnable = Runnable {
if (alreadyExecuted.compareAndSet(false, true)) {
collector.applyLines(infos)
}
}
CachedValueProvider.Result.create(runnable, dependency)
}
}
}
}
@@ -0,0 +1,27 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.codeInsight.stickyLines
import com.intellij.codeHighlighting.TextEditorHighlightingPass
import com.intellij.codeHighlighting.TextEditorHighlightingPassFactory
import com.intellij.codeHighlighting.TextEditorHighlightingPassFactoryRegistrar
import com.intellij.codeHighlighting.TextEditorHighlightingPassRegistrar
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiFile
internal class StickyLinesPassFactory : TextEditorHighlightingPassFactoryRegistrar, TextEditorHighlightingPassFactory, DumbAware {
override fun registerHighlightingPassFactory(registrar: TextEditorHighlightingPassRegistrar, project: Project) {
registrar.registerTextEditorHighlightingPass(this, null, null, false, -1)
}
override fun createHighlightingPass(psiFile: PsiFile, editor: Editor): TextEditorHighlightingPass? {
val project = editor.project ?: return null
val virtualFile = editor.virtualFile ?: return null
if (editor.settings.isStickyLinesShown) {
return StickyLinesPass(project, editor.document, virtualFile, psiFile)
}
return null
}
}
@@ -36,6 +36,8 @@ import com.intellij.openapi.editor.ex.util.EmptyEditorHighlighter;
import com.intellij.openapi.editor.highlighter.EditorHighlighter;
import com.intellij.openapi.editor.highlighter.HighlighterClient;
import com.intellij.openapi.editor.impl.event.MarkupModelListener;
import com.intellij.openapi.editor.impl.stickyLines.StickyLinesPanel;
import com.intellij.openapi.editor.impl.stickyLines.StickyLinesManager;
import com.intellij.openapi.editor.impl.view.EditorView;
import com.intellij.openapi.editor.markup.GutterDraggableObject;
import com.intellij.openapi.editor.markup.GutterIconRenderer;
@@ -155,6 +157,7 @@ public final class EditorImpl extends UserDataHolderBase implements EditorEx, Hi
private final @NotNull MyScrollPane myScrollPane;
private final @NotNull EditorComponentImpl myEditorComponent;
private final @NotNull EditorGutterComponentImpl myGutterComponent;
private final @Nullable StickyLinesPanel myStickyLinesPanel;
private final TraceableDisposable myTraceableDisposable = new TraceableDisposable(true);
private final FocusModeModel myFocusModeModel;
private volatile long myLastTypedActionTimestamp = -1;
@@ -232,6 +235,7 @@ public final class EditorImpl extends UserDataHolderBase implements EditorEx, Hi
@MouseSelectionState
private int myMouseSelectionState;
private @Nullable FoldRegion myMouseSelectedRegion;
private PanelWithFloatingToolbar myLayeredPane;
@MagicConstant(intValues = {MOUSE_SELECTION_STATE_NONE, MOUSE_SELECTION_STATE_LINE_SELECTED, MOUSE_SELECTION_STATE_WORD_SELECTED})
private @interface MouseSelectionState {
@@ -431,6 +435,13 @@ public final class EditorImpl extends UserDataHolderBase implements EditorEx, Hi
myGutterComponent.putClientProperty(ColorKey.FUNCTION_KEY, (Function<ColorKey, Color>)key -> getColorsScheme().getColor(key));
initComponent();
myStickyLinesPanel = createStickyLinesPanel();
if (myStickyLinesPanel != null) {
// remove the status component from the scroll pane and place it on the layered pane where the sticky panel lives,
// otherwise the sticky panel would overlap the status
myScrollPane.placeStatusOnTopOfStickyPanel();
}
myView = new EditorView(this);
myView.reinitSettings();
@@ -1105,14 +1116,14 @@ public final class EditorImpl extends UserDataHolderBase implements EditorEx, Hi
myEditorComponent.setTransferHandler(new MyTransferHandler());
myEditorComponent.setAutoscrolls(false); // we have our own auto-scrolling code
JLayeredPane layeredPane = new PanelWithFloatingToolbar();
layeredPane.add(myScrollPane, JLayeredPane.DEFAULT_LAYER);
this.myLayeredPane = new PanelWithFloatingToolbar();
myLayeredPane.add(myScrollPane, JLayeredPane.DEFAULT_LAYER);
UiNotifyConnector.doWhenFirstShown(myPanel, () -> {
if (mayShowToolbar()) {
layeredPane.add(new EditorFloatingToolbar(this), JLayeredPane.POPUP_LAYER);
myLayeredPane.add(new EditorFloatingToolbar(this), JLayeredPane.POPUP_LAYER);
}
}, getDisposable());
myPanel.add(layeredPane, BorderLayout.CENTER);
myPanel.add(myLayeredPane, BorderLayout.CENTER);
myEditorComponent.addKeyListener(new KeyListener() {
@Override
@@ -1663,6 +1674,9 @@ public final class EditorImpl extends UserDataHolderBase implements EditorEx, Hi
int height = visualLineToYRange(endVisualLine)[1] + 2 - yStart;
myEditorComponent.repaintEditorComponent(visibleArea.x, yStart, visibleArea.x + visibleArea.width, height);
myGutterComponent.repaint(0, yStart, myGutterComponent.getWidth(), height);
if (myStickyLinesPanel != null) {
myStickyLinesPanel.repaintLinesInRange(startVisualLine, endVisualLine);
}
}
private void bulkUpdateStarted() {
@@ -3549,6 +3563,14 @@ public final class EditorImpl extends UserDataHolderBase implements EditorEx, Hi
return myVerticalScrollBar;
}
/**
* @deprecated try to avoid impl api, probably width management can be implemented in a more transparent way
*/
@Deprecated
public int getStickyLinesPanelWidth() {
return myPanel.getWidth() - myVerticalScrollBar.getWidth();
}
@MouseSelectionState
private int getMouseSelectionState() {
return myMouseSelectionState;
@@ -5359,6 +5381,8 @@ public final class EditorImpl extends UserDataHolderBase implements EditorEx, Hi
@DirtyUI
private final class MyScrollPane extends JBScrollPane {
private JComponent myStatusComponent;
private MyScrollPane() {
super(0);
setupCorners();
@@ -5431,6 +5455,42 @@ public final class EditorImpl extends UserDataHolderBase implements EditorEx, Hi
super.setupCorners();
setBorder(new TablessBorder());
}
void placeStatusOnTopOfStickyPanel() {
setStatusComponent(super.getStatusComponent());
}
@Override
public JComponent getStatusComponent() {
if (myStickyLinesPanel == null) {
return super.getStatusComponent();
}
else {
return myStatusComponent;
}
}
@Override
public void setStatusComponent(JComponent statusComponent) {
if (myStickyLinesPanel == null) {
super.setStatusComponent(statusComponent);
}
else {
JComponent oldStatus = super.getStatusComponent();
if (oldStatus != null) {
super.setStatusComponent(null);
}
if (statusComponent != null) {
myLayeredPane.add(statusComponent, Integer.valueOf(300));
} else if (myStatusComponent != null) {
myLayeredPane.remove(myStatusComponent);
}
firePropertyChange("statusComponent", myStatusComponent, statusComponent);
myStatusComponent = statusComponent;
revalidate();
repaint();
}
}
}
public void adjustGlobalFontSize(float size) {
@@ -5549,11 +5609,15 @@ public final class EditorImpl extends UserDataHolderBase implements EditorEx, Hi
// Main scroll panel: MyScrollPane
c.setBounds(0, 0, r.width, r.height);
}
else {
else if (c instanceof EditorFloatingToolbar){
// Floating toolbar: EditorFloatingToolbar
Dimension d = c.getPreferredSize();
int rightInsets = getVerticalScrollBar().getWidth() + (isMirrored() ? myGutterComponent.getWidth() : 0);
c.setBounds(r.width - d.width - rightInsets - 20, 20, d.width, d.height);
} else if (!(c instanceof StickyLinesPanel)) {
// Status component
int statusComponentX = myPanel.getWidth() - c.getWidth();
c.setLocation(statusComponentX, 0);
}
}
}
@@ -5563,4 +5627,28 @@ public final class EditorImpl extends UserDataHolderBase implements EditorEx, Hi
return myScrollPane.getPreferredSize();
}
}
@Nullable StickyLinesPanel getStickyLinesPanel() {
return myStickyLinesPanel;
}
/**
* There is no point to show the editor fragment hint if the sticky panel shows the same line.
*/
public boolean shouldSuppressEditorFragmentHint(int hintStartLogicalLine) {
if (myStickyLinesPanel != null) {
return myStickyLinesPanel.suppressHintForLine(hintStartLogicalLine);
}
return false;
}
private @Nullable StickyLinesPanel createStickyLinesPanel() {
if (myProject != null && myKind == EditorKind.MAIN_EDITOR && !isMirrored()) {
StickyLinesManager stickyManager = new StickyLinesManager(this, myDocumentMarkupModel, myDisposable);
myLayeredPane.add(stickyManager.getStickyPanel(), Integer.valueOf(200));
return stickyManager.getStickyPanel();
} else {
return null;
}
}
}
@@ -172,6 +172,18 @@ class EditorSettingsState(private val editor: EditorImpl?,
var myIsCamelWords: Boolean by property { EditorSettingsExternalizable.getInstance().isCamelWords }
var myLineNumeration: EditorSettings.LineNumerationType by property { EditorSettingsExternalizable.getInstance().lineNumeration }
var myIsStickyLinesShown: Boolean by property { EditorSettingsExternalizable.getInstance().isStickyLinesShown }
var myIsStickyLinesShownForLanguage: Boolean by property {
this.language?.let {
EditorSettingsExternalizable.getInstance().isStickyLinesShownFor(it.id)
}
// Return true to avoid late appearance of the sticky panel.
// Even if the actual value for the language is false,
// the panel won't be shown because breadcrumbs' provider respects the settings
?: true
}
var myStickyLinesLimit: Int by property { EditorSettingsExternalizable.getInstance().stickyLineLimit }
// These come from AdvancedSettings
var showingSpecialCharacters: Boolean by property { AdvancedSettings.getBoolean(EDITOR_SHOW_SPECIAL_CHARS) }
@@ -237,6 +249,9 @@ class EditorSettingsState(private val editor: EditorImpl?,
EditorSettingsExternalizable.PropNames.PROP_SHOW_INTENTION_BULB -> refresh(::myShowIntentionBulb)
EditorSettingsExternalizable.PropNames.PROP_IS_CAMEL_WORDS -> refresh(::myIsCamelWords)
EditorSettingsExternalizable.PropNames.PROP_LINE_NUMERATION -> refresh(::myLineNumeration)
EditorSettingsExternalizable.PropNames.PROP_SHOW_STICKY_LINES -> refresh(::myIsStickyLinesShown)
EditorSettingsExternalizable.PropNames.PROP_SHOW_STICKY_LINES_PER_LANGUAGE -> refresh(::myIsStickyLinesShownForLanguage)
EditorSettingsExternalizable.PropNames.PROP_STICKY_LINES_LIMIT -> refresh(::myStickyLinesLimit)
}
}, editor.disposable)
@@ -49,7 +49,8 @@ public class IndentsModelCaretListener implements CaretListener {
private boolean shouldShowHint(@NotNull IndentGuideDescriptor descriptor) {
final Rectangle visibleArea = myEditor.getScrollingModel().getVisibleArea();
return myEditor.logicalLineToY(descriptor.startLine) < visibleArea.y;
boolean isLineBeforeVisibleArea = myEditor.logicalLineToY(descriptor.startLine) < visibleArea.y;
return isLineBeforeVisibleArea && !myEditor.shouldSuppressEditorFragmentHint(descriptor.startLine);
}
protected @Nullable IndentGuideDescriptor getCaretIndentGuide(final @NotNull CaretEvent event) {
@@ -136,10 +136,29 @@ public final class ScrollingModelImpl implements ScrollingModelEx {
private void scrollTo(@NotNull Point targetLocation, @NotNull ScrollType scrollType) {
AnimatedScrollingRunnable canceledThread = cancelAnimatedScrolling(false);
Rectangle viewRect = canceledThread == null ? getVisibleArea() : canceledThread.getTargetVisibleArea();
// the model knows nothing about the sticky panel rendering on top of the editor
// we should adjust target and view rectangle to avoid hidden caret by the sticky panel
targetLocation = stickyPanelAdjust(targetLocation, viewRect);
Point p = calcOffsetsToScroll(targetLocation, scrollType, viewRect);
scroll(p.x, p.y);
}
private @NotNull Point stickyPanelAdjust(@NotNull Point targetLocation, @NotNull Rectangle viewRect) {
if (mySupplier.getEditor() instanceof EditorImpl editor) {
var panel = editor.getStickyLinesPanel();
if (panel != null && panel.isStickyEnabled()) {
int height = panel.getHeight();
if (height > 0) {
viewRect.height -= height;
return new Point(targetLocation.x, targetLocation.y - height);
}
}
}
return targetLocation;
}
@Override
@RequiresEdt
public void scrollTo(@NotNull LogicalPosition logicalPosition, @NotNull ScrollType scrollType) {
@@ -621,6 +621,14 @@ class SettingsImpl internal constructor(private val editor: EditorImpl?, kind: E
return EditorSettingsExternalizable.getInstance().isInsertParenthesesAutomatically
}
override fun isStickyLinesShown(): Boolean {
return state.myIsStickyLinesShown && state.myIsStickyLinesShownForLanguage
}
override fun getStickyLinesLimit(): Int {
return state.myStickyLinesLimit
}
@ApiStatus.Internal
@ApiStatus.Experimental
fun getState(): EditorSettingsState {
@@ -0,0 +1,59 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.openapi.editor.impl.stickyLines
import com.intellij.openapi.util.TextRange
/**
* Represents scope of class or function.
*
* For example,
* ```java
* @Override
* public void run() { // <- primary line
* // ^ navigation offset
* // ...
* } // <- scope line
* ```
*/
internal interface StickyLine : Comparable<StickyLine> {
/**
* The first logical line of scope used to pin corresponding editor's line on sticky panel.
*
* Usually it is `lineOf(psiElement.textOffset)`
*/
fun primaryLine(): Int
/**
* The last logical line of scope used to unpin corresponding editor's line from sticky panel.
*
* Usually it is `lineOf(psiElement.endOffset)`
*/
fun scopeLine(): Int
/**
* Offset where editor's caret put on mouse click.
*
* Usually it is `psiElement.textOffset`
*/
fun navigateOffset(): Int
/**
* Range between primary line and scope line.
*
* Usually it is `TextRange(psiElement.textOffset, psiElement.endOffset)`
*/
fun textRange(): TextRange
/**
* Short text of psi element representing the sticky line.
*
* Usually it is `psiElement.toString`. Maybe null if debug mode is disabled
*/
fun debugText(): String?
/**
* Compares lines according scope order
*/
override fun compareTo(other: StickyLine): Int
}
@@ -0,0 +1,172 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.openapi.editor.impl.stickyLines
import com.intellij.codeInsight.hint.EditorFragmentComponent
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.DefaultActionGroup
import com.intellij.openapi.editor.ScrollType
import com.intellij.openapi.editor.VisualPosition
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.editor.ex.EditorGutterComponentEx
import com.intellij.openapi.editor.ex.util.EditorUIUtil
import com.intellij.openapi.editor.impl.EditorImpl
import com.intellij.util.ui.MouseEventAdapter
import com.intellij.util.ui.MouseEventHandler
import com.intellij.util.ui.StartupUiUtil
import com.intellij.util.ui.UIUtil
import java.awt.Graphics
import java.awt.event.MouseEvent
import java.awt.event.MouseListener
import java.awt.image.BufferedImage
import javax.swing.JComponent
import javax.swing.JPopupMenu
import kotlin.math.max
/**
* Represents one editor's line (gutter + line text)
*
* TODO: mouse events handling
* - the line should change background color on mouse hover
* - sticky gutter should forward clicks/hovers with adjusted coordinates to the actual gutter component
*/
internal class StickyLineComponent(private val editor: EditorEx) : JComponent() {
var primaryVisualLine: Int = -1
var scopeVisualLine: Int = -1
private var offsetOnClick: Int = -1
init {
border = null
addMouseListener(MyMouseEventHandler())
addMouseListener(GutterMouseEventHandler())
}
fun setLine(primaryVisualLine: VisualPosition, scopeVisualLine: VisualPosition, offsetOnClick: Int) {
this.primaryVisualLine = primaryVisualLine.line
this.scopeVisualLine = scopeVisualLine.line
this.offsetOnClick = offsetOnClick
}
fun resetLine() {
this.primaryVisualLine = -1
this.scopeVisualLine = -1
this.offsetOnClick = -1
}
fun isEmpty(): Boolean {
return primaryVisualLine == -1 || scopeVisualLine == -1 || offsetOnClick == -1
}
override fun paintComponent(g: Graphics) {
assert(!isEmpty()) { "sticky panel should mark this line as not visible" }
val stickyLine = primaryVisualLine
val stickyLineY = editor.visualLineToY(stickyLine)
val lineHeight = lineHeight()
val lineWidth = lineWidth()
// draw gutter
val gutterComp = editor.gutterComponentEx
val gutterWidth = max(1, gutterComp.width)
val gutterImage: BufferedImage = UIUtil.createImage(editor.component, gutterWidth, lineHeight, BufferedImage.TYPE_INT_RGB)
val gutterGraphics = gutterImage.graphics
EditorUIUtil.setupAntialiasing(gutterGraphics)
gutterGraphics.translate(0, -stickyLineY)
gutterGraphics.setClip(0, stickyLineY, gutterComp.width, lineHeight)
gutterGraphics.color = EditorFragmentComponent.getBackgroundColor(editor)
gutterGraphics.fillRect(0, stickyLineY, gutterComp.width, lineHeight)
gutterComp.paint(gutterGraphics) // TODO: hide icons (override, annotations, etc)
// draw text
val textWidth = lineWidth - gutterWidth
val textImage: BufferedImage = UIUtil.createImage(editor.contentComponent, textWidth, lineHeight, BufferedImage.TYPE_INT_RGB)
val textGraphics = textImage.graphics
EditorUIUtil.setupAntialiasing(textGraphics)
textGraphics.translate(0, -stickyLineY)
textGraphics.setClip(0, stickyLineY, textWidth, lineHeight)
val wasVisible = editor.setCaretVisible(false)
try {
editor.contentComponent.paint(textGraphics) // TODO: hide caret line background
} finally {
if (wasVisible) {
editor.setCaretVisible(true)
}
}
// apply images
StartupUiUtil.drawImage(g, gutterImage, 0, 0, null)
StartupUiUtil.drawImage(g, textImage, gutterComp.width, 0, null)
}
private fun lineWidth(): Int {
return (editor as EditorImpl).stickyLinesPanelWidth
}
private fun lineHeight(): Int {
return editor.lineHeight
}
inner class MyMouseEventHandler : MouseEventHandler() {
private val popMenu: JPopupMenu
private var isPopup = false
init {
val actionManager = ActionManager.getInstance()
val actionGroup = actionManager.getAction("EditorStickyLinesSettings") as DefaultActionGroup
popMenu = actionManager.createActionPopupMenu("StickyLine", actionGroup).component
}
override fun handle(event: MouseEvent) {
if (event.isConsumed) return
if (isGutterEvent(event)) {
handleGutter(event)
} else {
handleLine(event)
}
}
private fun handleLine(event: MouseEvent) {
when (event.id) {
MouseEvent.MOUSE_PRESSED -> {
isPopup = event.isPopupTrigger
if (isPopup) {
popMenu.show(event.component, event.x, event.y)
}
}
MouseEvent.MOUSE_CLICKED -> {
if (!isPopup && !isEmpty()) {
editor.caretModel.moveToOffset(offsetOnClick)
editor.scrollingModel.scrollToCaret(ScrollType.RELATIVE)
}
}
MouseEvent.MOUSE_RELEASED,
MouseEvent.MOUSE_ENTERED,
MouseEvent.MOUSE_EXITED,
MouseEvent.MOUSE_DRAGGED,
MouseEvent.MOUSE_MOVED, -> {}
}
event.consume()
}
private fun handleGutter(event: MouseEvent) {
// TODO: folding region collapsing/expanding
}
private fun isGutterEvent(event: MouseEvent): Boolean {
return event.x <= editor.gutterComponentEx.width
}
}
inner class GutterMouseEventHandler : MouseEventAdapter<EditorGutterComponentEx>(editor.gutterComponentEx) {
override fun convert(event: MouseEvent): MouseEvent {
return convert(event, editor.gutterComponentEx)
}
override fun getMouseListener(adapter: EditorGutterComponentEx): MouseListener? {
if (adapter is MouseListener && adapter.isShowing) {
return adapter
}
return null
}
}
}
@@ -0,0 +1,33 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.openapi.editor.impl.stickyLines;
import com.intellij.openapi.util.TextRange;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import static org.jetbrains.annotations.ApiStatus.Internal;
/**
* Represents information about sticky line collected by {@link StickyLinesCollector}.
* <p>
* See also {@link StickyLine}
*/
@Internal
public record StickyLineInfo(int textOffset, int endOffset, @Nullable String debugText) {
StickyLineInfo(@NotNull TextRange textRange) {
this(textRange.getStartOffset(), textRange.getEndOffset(), null);
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof StickyLineInfo info)) return false;
return endOffset == info.endOffset && textOffset == info.textOffset;
}
@Override
public int hashCode() {
return textOffset + 31 * endOffset;
}
}
@@ -0,0 +1,96 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.openapi.editor.impl.stickyLines
import com.intellij.openapi.editor.Document
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.registry.Registry
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.PsiElement
import com.intellij.util.concurrency.ThreadingAssertions
import com.intellij.xml.breadcrumbs.PsiFileBreadcrumbsCollector
import org.jetbrains.annotations.ApiStatus.Internal
/**
* Responsible for collecting sticky lines based on psi file structure.
* The implementation relies on PsiFileBreadcrumbsCollector to collect psi elements at specific document line.
*/
@Internal
class StickyLinesCollector(private val project: Project, private val document: Document) {
fun collectLines(vFile: VirtualFile): MutableSet<StickyLineInfo> {
ThreadingAssertions.assertReadAccess()
ThreadingAssertions.assertBackgroundThread()
val psiCollector = PsiFileBreadcrumbsCollector(project)
val infos: MutableSet<StickyLineInfo> = mutableSetOf()
val lineCount: Int = document.getLineCount()
for (line in 0 until lineCount) {
ProgressManager.checkCanceled()
val endOffset: Int = document.getLineEndOffset(line)
val psiElements: List<PsiElement> = psiCollector.computePsiElements(vFile, document, endOffset)
for (element: PsiElement in psiElements) {
if (!isInBanList(element)) {
infos.add(StickyLineInfo(
element.textOffset,
element.textRange.endOffset,
debugText(element)
))
}
}
}
return infos
}
fun applyLines(linesToAdd: MutableSet<StickyLineInfo>) {
ThreadingAssertions.assertEventDispatchThread()
val stickyModel: StickyLinesModel = StickyLinesModel.getModel(project, document) ?: return
val outdatedLines: List<StickyLine> = findOutdatedLines(stickyModel, linesToAdd) // mutates linesToAdd
for (toRemove: StickyLine in outdatedLines) {
stickyModel.removeStickyLine(toRemove)
}
for (toAdd: StickyLineInfo in linesToAdd) {
stickyModel.addStickyLine(toAdd.textOffset, toAdd.endOffset, toAdd.debugText)
}
stickyModel.notifyListeners()
}
private fun findOutdatedLines(stickyModel: StickyLinesModel, linesToAdd: MutableSet<StickyLineInfo>): List<StickyLine> {
val outdatedLines: MutableList<StickyLine> = mutableListOf()
stickyModel.processStickyLines { line: StickyLine ->
val existingLine = StickyLineInfo(line.textRange())
val keepExisting = linesToAdd.remove(existingLine)
if (!keepExisting) {
outdatedLines.add(line)
}
true
}
return outdatedLines
}
private fun debugText(element: PsiElement): String? {
return if (Registry.`is`("editor.show.sticky.lines.debug")) {
element.toString()
} else {
null
}
}
private fun isInBanList(element: PsiElement): Boolean {
// TODO: provide extension point for suppressing/including psi elements
if (element.language.displayName == "Kotlin") {
// special handling for kotlin to exclude if/else, try/catch
// see org.jetbrains.kotlin.idea.codeInsight.KotlinBreadcrumbsInfoProvider.Holder,
// org.jetbrains.kotlin.KtNodeTypes.THEN, org.jetbrains.kotlin.KtNodeTypes.ELSE, etc
val debugName = element.toString()
return when (debugName) {
"THEN", "ELSE", "WHEN", "FINALLY" -> true
"BLOCK", "BODY" -> when (element.parent?.toString()) {
"TRY", "FOR", "DO_WHILE", "WHEN_ENTRY" -> true
else -> false
}
else -> false
}
}
return false
}
}
@@ -0,0 +1,132 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.openapi.editor.impl.stickyLines
import com.intellij.ide.ui.UISettingsListener
import com.intellij.openapi.Disposable
import com.intellij.openapi.editor.event.VisibleAreaEvent
import com.intellij.openapi.editor.event.VisibleAreaListener
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.editor.ex.MarkupModelEx
import com.intellij.openapi.fileTypes.FileTypeEvent
import com.intellij.openapi.fileTypes.FileTypeListener
import com.intellij.openapi.fileTypes.FileTypeManager
import com.intellij.openapi.util.Disposer
import java.awt.Point
import java.awt.Rectangle
/**
* Responsible for updating state of the sticky panel e.g., when the editor is scrolled or resized.
*/
internal class StickyLinesManager(
private val editor: EditorEx,
markupModel: MarkupModelEx,
parentDisposable: Disposable,
) : VisibleAreaListener, Disposable, StickyLinesModel.Listener {
private val stickyModel: StickyLinesModel = StickyLinesModel.getModel(markupModel)
val stickyPanel: StickyLinesPanel = StickyLinesPanel(editor)
init {
Disposer.register(parentDisposable, this)
editor.scrollingModel.addVisibleAreaListener(this, this)
stickyModel.addListener(this)
editor.project!!.messageBus.connect(this).subscribe(
UISettingsListener.TOPIC,
UISettingsListener { recalculateLinesAndRepaint() }
)
}
private var activeVisualLine: Int = -1
private var activeEditorY: Int = -1
private var activeEditorH: Int = -1
override fun visibleAreaChanged(event: VisibleAreaEvent) {
if (stickyPanel.isStickyEnabled && isAreaChanged(event)) {
activeEditorY = event.newRectangle.y
activeEditorH = event.newRectangle.height
if (event.oldRectangle == null || isLineChanged(event)) {
// recalculate sticky lines and repaint
stickyPanel.repaintLines(activeEditorY, activeEditorH, getStickyLines(activeEditorY))
} else if (isYChanged(event) || isSizeChanged(event)) {
// just repaint
stickyPanel.repaintLines(activeEditorY, activeEditorH)
}
}
}
override fun modelChanged() {
recalculateLinesAndRepaint()
}
override fun dispose() {
stickyModel.removeListener(this)
}
private fun recalculateLinesAndRepaint() {
if (activeVisualLine != -1 && activeEditorY != -1 && activeEditorH != -1) {
stickyPanel.repaintLines(activeEditorY, activeEditorH, getStickyLines(activeEditorY))
}
}
private fun isAreaChanged(event: VisibleAreaEvent): Boolean {
val oldRectangle: Rectangle? = event.oldRectangle
return oldRectangle == null ||
oldRectangle.y != event.newRectangle.y ||
oldRectangle.height != event.newRectangle.height ||
oldRectangle.width != event.newRectangle.width
}
private fun isLineChanged(event: VisibleAreaEvent): Boolean {
val editorY: Int = event.newRectangle.y
val newVisualLine: Int = editor.yToVisualLine(activeY(editorY))
if (activeVisualLine != newVisualLine) {
activeVisualLine = newVisualLine
return true
}
return false
}
private fun isYChanged(event: VisibleAreaEvent): Boolean {
return event.oldRectangle.y != event.newRectangle.y
}
private fun isSizeChanged(event: VisibleAreaEvent): Boolean {
return event.oldRectangle.width != event.newRectangle.width ||
event.oldRectangle.height != event.newRectangle.height
}
private fun activeY(editorY: Int): Int {
return editorY + stickyPanel.height + /*border*/ 1
}
private fun getStickyLines(editorY: Int): List<StickyLine> {
return if (editorY <3) {
// special case when the document starts with a sticky line
// small visual jump is better than stickied line for good
emptyList()
} else {
val activeY: Int = activeY(editorY)
val activeLogicalLine: Int = editor.xyToLogicalPosition(Point(0, activeY)).line
val activeOffset: Int = editor.document.getLineEndOffset(activeLogicalLine)
collectStickyLines(activeOffset, activeLogicalLine)
}
}
private fun collectStickyLines(activeOffset: Int, activeLogicalLine: Int): List<StickyLine> {
val visualLinesLimit: Int = editor.settings.stickyLinesLimit
// The panel removes visual duplicates so extra logical lines should be collected.
// We could collect lines limitless here if it were efficient.
// The consequence of this optimization is that in the worst case (when all logical lines are the same visual one),
// the sticky panel will consist of only one line instead of a line limit number.
// This optimization should be removed if the worst case is not rare.
val logicalLinesLimit: Int = visualLinesLimit + 5
val stickyLines: MutableList<StickyLine> = ArrayList(logicalLinesLimit)
stickyModel.processStickyLines(activeOffset) { stickyLine ->
if (activeLogicalLine <= stickyLine.scopeLine()) {
stickyLines.add(stickyLine)
}
return@processStickyLines stickyLines.size < logicalLinesLimit
}
return stickyLines.sorted()
}
}
@@ -0,0 +1,184 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.openapi.editor.impl.stickyLines;
import com.intellij.openapi.editor.Document;
import com.intellij.openapi.editor.RangeMarker;
import com.intellij.openapi.editor.colors.TextAttributesKey;
import com.intellij.openapi.editor.ex.MarkupModelEx;
import com.intellij.openapi.editor.impl.DocumentMarkupModel;
import com.intellij.openapi.editor.markup.HighlighterLayer;
import com.intellij.openapi.editor.markup.HighlighterTargetArea;
import com.intellij.openapi.editor.markup.MarkupModel;
import com.intellij.openapi.editor.markup.RangeHighlighter;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.Key;
import com.intellij.openapi.util.TextRange;
import com.intellij.util.Processor;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
import static org.jetbrains.annotations.ApiStatus.Internal;
/**
* Represents a storage of sticky lines.
*/
@Internal
final class StickyLinesModel {
private static final Key<StickyLinesModel> STICKY_LINES_MODEL_KEY = Key.create("editor.sticky.lines.model");
private static final Key<StickyLineImpl> STICKY_LINE_IMPL_KEY = Key.create("editor.sticky.line.impl");
private static final TextAttributesKey STICKY_LINE_ATTRIBUTE = TextAttributesKey.createTextAttributesKey(
"STICKY_LINE_MARKER"
);
static @Nullable StickyLinesModel getModel(@NotNull Project project, @NotNull Document document) {
if (project.isDisposed()) return null;
MarkupModel markupModel = DocumentMarkupModel.forDocument(document, project, false);
if (markupModel == null) return null;
return getModel(markupModel);
}
static @NotNull StickyLinesModel getModel(@NotNull MarkupModel markupModel) {
StickyLinesModel stickyModel = markupModel.getUserData(STICKY_LINES_MODEL_KEY);
if (stickyModel == null) {
stickyModel = new StickyLinesModel((MarkupModelEx) markupModel);
markupModel.putUserData(STICKY_LINES_MODEL_KEY, stickyModel);
}
return stickyModel;
}
private final MarkupModelEx myMarkupModel;
private final List<Listener> myListeners;
private StickyLinesModel(MarkupModelEx markupModel) {
myMarkupModel = markupModel;
myListeners = new ArrayList<>();
}
void addStickyLine(int textOffset, int endOffset, @Nullable String debugText) {
if (textOffset >= endOffset) {
throw new IllegalArgumentException(String.format(
"sticky line endOffset %s should be less than textOffset %s", textOffset, endOffset
));
}
RangeHighlighter highlighter = myMarkupModel.addRangeHighlighter(
STICKY_LINE_ATTRIBUTE,
textOffset,
endOffset,
HighlighterLayer.SYNTAX,
HighlighterTargetArea.EXACT_RANGE
);
StickyLineImpl stickyLine = new StickyLineImpl(highlighter.getDocument(), highlighter, debugText);
highlighter.putUserData(STICKY_LINE_IMPL_KEY, stickyLine);
}
void removeStickyLine(@NotNull StickyLine stickyLine) {
RangeMarker rangeMarker = ((StickyLineImpl)stickyLine).rangeMarker();
myMarkupModel.removeHighlighter((RangeHighlighter) rangeMarker);
}
void processStickyLines(@NotNull Processor<? super StickyLine> processor) {
processStickyLines(myMarkupModel.getDocument().getTextLength(), processor);
}
void processStickyLines(int endOffset, @NotNull Processor<? super StickyLine> processor) {
myMarkupModel.processRangeHighlightersOverlappingWith(
0,
endOffset,
highlighter -> {
if (highlighter.getTextAttributesKey() == STICKY_LINE_ATTRIBUTE) {
StickyLineImpl stickyLine = highlighter.getUserData(STICKY_LINE_IMPL_KEY);
if (stickyLine == null) {
stickyLine = new StickyLineImpl(highlighter.getDocument(), highlighter, null);
}
return processor.process(stickyLine);
} else {
return true;
}
}
);
}
interface Listener {
void modelChanged();
}
void addListener(Listener listener) {
myListeners.add(listener);
}
void removeListener(Listener listener) {
myListeners.remove(listener);
}
void notifyListeners() {
for (Listener listener : myListeners) {
listener.modelChanged();
}
}
private record StickyLineImpl(
@NotNull Document document,
@NotNull RangeMarker rangeMarker,
@Nullable String debugText
) implements StickyLine {
@Override
public int primaryLine() {
return document.getLineNumber(rangeMarker.getStartOffset());
}
@Override
public int scopeLine() {
return document.getLineNumber(rangeMarker.getEndOffset());
}
@Override
public int navigateOffset() {
return rangeMarker.getStartOffset();
}
@Override
public @NotNull TextRange textRange() {
return rangeMarker.getTextRange();
}
@Override
public @Nullable String debugText() {
return debugText;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof StickyLineImpl impl)) return false;
return textRange().equals(impl.textRange());
}
@Override
public int hashCode() {
return textRange().hashCode();
}
@Override
public int compareTo(@NotNull StickyLine other) {
TextRange range = textRange();
TextRange otherRange = other.textRange();
int compare = Integer.compare(range.getStartOffset(), otherRange.getStartOffset());
if (compare != 0) {
return compare;
}
// reverse order
return Integer.compare(otherRange.getEndOffset(), range.getEndOffset());
}
@Override
public @NotNull String toString() {
String prefix = debugText == null ? "" : debugText;
return prefix + "(" + primaryLine() + ", " + scopeLine() + ")";
}
}
}
@@ -0,0 +1,251 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.openapi.editor.impl.stickyLines
import com.intellij.openapi.editor.LogicalPosition
import com.intellij.openapi.editor.VisualPosition
import com.intellij.openapi.editor.colors.EditorColors
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.editor.impl.EditorImpl
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.ui.ClientProperty
import com.intellij.ui.SideBorder
import com.intellij.ui.components.JBLayeredPane
import com.intellij.ui.components.JBPanel
import java.awt.Color
import javax.swing.BoxLayout
import kotlin.math.min
internal class StickyLinesPanel(private val editor: EditorEx) : JBPanel<StickyLinesPanel>() {
// ui
private val layeredPane: JBLayeredPane = JBLayeredPane()
// ui + state
private val stickyLinesComp: MutableList<StickyLineComponent> = mutableListOf()
// state
private val stickyLines: MutableList<StickyLine> = mutableListOf()
private var editorY: Int = 0
private var editorH: Int = 0
private var panelW: Int = 0
private var panelH: Int = 0
// settings
private val scopeThreshold: Int = 3
private val lineLimit: Int get() = editor.settings.stickyLinesLimit
val isStickyEnabled: Boolean get() = editor.settings.isStickyLinesShown
init {
border = bottomBorder()
layout = BoxLayout(this, BoxLayout.Y_AXIS)
layeredPane.layout = null
add(layeredPane)
}
// ------------------------------------------- API -------------------------------------------
fun repaintLinesInRange(startVisualLine: Int, endVisualLine: Int) {
if (clearPanelIfDisabled()) return
for (lineComp: StickyLineComponent in stickyLinesComp) {
if (lineComp.isEmpty()) {
break
}
if (lineComp.primaryVisualLine in startVisualLine..endVisualLine) {
lineComp.repaint()
}
}
}
fun repaintLines(newEditorY: Int, newEditorH: Int) {
if (clearPanelIfDisabled()) return
editorY = newEditorY
editorH = newEditorH
repaintLinesImpl()
}
fun repaintLines(newEditorY: Int, newEditorH: Int, newStickyLines: Collection<StickyLine>) {
if (clearPanelIfDisabled()) return
editorY = newEditorY
editorH = newEditorH
stickyLines.clear()
stickyLines.addAll(newStickyLines)
repaintLinesImpl()
}
fun repaintLines(newStickyLines: Collection<StickyLine>) {
if (clearPanelIfDisabled()) return
stickyLines.clear()
stickyLines.addAll(newStickyLines)
repaintLinesImpl()
}
fun suppressHintForLine(logicalLine: Int): Boolean {
for (lineComp: StickyLineComponent in stickyLinesComp) {
if (lineComp.isEmpty()) {
break
}
val stickyVisualPos = VisualPosition(lineComp.primaryVisualLine, 0)
val stickyLogicalLine: Int = editor.visualToLogicalPosition(stickyVisualPos).line
if (logicalLine == stickyLogicalLine) {
return true
}
if (logicalLine == stickyLogicalLine - 1) {
// case when lineOf(psiElement.startOffset) != lineOf(psiElement.textOffset)
// e.g., when a method starts with @Override
// "stickyLogicalLine - 1" won't cover all the cases, but the fair approach looks difficult to implement
return true
}
}
return false
}
// ------------------------------------------- Impl -------------------------------------------
private fun repaintLinesImpl() {
val lineLimit: Int = lineLimit // avoid reading settings several times while painting
val stickyLines: List<StickyLine> = getStickyLines()
lazyAllocateComponents(stickyLines, lineLimit)
updateComponentsState(stickyLines, lineLimit)
placeComponentsOnPanel()
}
private fun getStickyLines(): List<StickyLine> {
return stickyLines
// TODO: why are range markers not valid even if the document is not changed?
// .filter { it.getRangeMarker().isValid }
}
private fun lazyAllocateComponents(stickyLines: List<StickyLine>, lineLimit: Int) {
while (stickyLinesComp.size < min(stickyLines.size, lineLimit)) {
val lineComp = StickyLineComponent(editor)
layeredPane.add(lineComp, (200 - stickyLinesComp.size) as Any)
stickyLinesComp.add(lineComp)
}
}
private fun updateComponentsState(stickyLines: List<StickyLine>, lineLimit: Int) {
var compIndex = 0
for (stickyLine: StickyLine in stickyLines) {
val primaryVisual: VisualPosition = toVisualPos(stickyLine.primaryLine())
val scopeVisual: VisualPosition = toVisualPos(stickyLine.scopeLine())
if (isScopeNotNarrow(primaryVisual, scopeVisual) && isLineNotDuplicate(compIndex, primaryVisual)) {
stickyLinesComp[compIndex].setLine(primaryVisual, scopeVisual, stickyLine.navigateOffset())
compIndex++
if (compIndex == lineLimit) {
break
}
}
}
while (compIndex < stickyLinesComp.size) {
stickyLinesComp[compIndex].resetLine()
compIndex++
}
}
private fun placeComponentsOnPanel() {
val lineHeight: Int = editor.lineHeight
var panelHeight = 0
val panelWidth = (editor as EditorImpl).stickyLinesPanelWidth
for (lineComp: StickyLineComponent in stickyLinesComp) {
if (lineComp.isEmpty() || isPanelTooBig(panelHeight, lineHeight)) {
lineComp.isVisible = false
} else {
val addedHeight: Int = placeComponentOnPanel(lineComp, panelWidth, panelHeight, lineHeight)
// 0 <= addedHeight <= lineHeight
panelHeight += addedHeight
}
}
if (isPanelSizeChanged(panelWidth, panelHeight)) {
setSize(panelWidth, if (panelHeight == 0) 0 else panelHeight + /*border*/ 1)
this.panelW = panelWidth
this.panelH = panelHeight
layeredPane.setSize(panelWidth, panelHeight)
revalidate()
}
repaint()
}
private fun placeComponentOnPanel(
lineComp: StickyLineComponent,
panelWidth: Int,
panelHeight: Int,
lineHeight: Int,
): Int {
val startY1: Int = editor.visualLineToY(lineComp.primaryVisualLine)
val startY2: Int = startY1 + lineHeight
val endY1: Int = editor.visualLineToY(lineComp.scopeVisualLine)
val endY2: Int = endY1 + lineHeight
val stickyY: Int = editorY + panelHeight + lineHeight
if (stickyY in startY2..endY2) {
val lineYShift = if (stickyY <= endY1) 0 else stickyY - endY1
lineComp.isVisible = true
lineComp.setBounds(0, panelHeight - lineYShift, panelWidth, lineHeight)
return lineHeight - lineYShift
} else {
return 0
}
}
// ------------------------------------------- Utils -------------------------------------------
private fun toVisualPos(logicalLine: Int): VisualPosition {
return editor.logicalToVisualPosition(LogicalPosition(logicalLine, 0))
}
private fun isScopeNotNarrow(primaryVisual: VisualPosition, scopeVisual: VisualPosition): Boolean {
return scopeVisual.line - primaryVisual.line + 1 >= scopeThreshold
}
private fun isLineNotDuplicate(compIndex: Int, primaryVisual: VisualPosition): Boolean {
if (compIndex == 0) return true
val visualLine: Int = primaryVisual.line
val prevVisualLine = stickyLinesComp[compIndex - 1].primaryVisualLine
assert(prevVisualLine <= visualLine) {
"sticky lines list sorted not properly, wrong order of visual lines: $prevVisualLine, $visualLine"
}
return prevVisualLine != visualLine
}
private fun isPanelTooBig(panelHeight: Int, lineHeight: Int): Boolean {
return panelHeight + 2 * lineHeight > editorH / 2
}
private fun isPanelSizeChanged(panelWidth: Int, panelHeight: Int): Boolean {
return this.panelW != panelWidth || this.panelH != panelHeight
}
private fun clearPanelIfDisabled(): Boolean {
val isEnabled = isStickyEnabled
if (!isEnabled && stickyLinesComp.isNotEmpty()) {
layeredPane.removeAll()
stickyLinesComp.clear()
stickyLines.clear()
editorY = 0
editorH = 0
panelW = 0
panelH = 0
setSize(0, 0)
revalidate()
repaint()
}
return !isEnabled
}
private fun bottomBorder(): SideBorder {
return object : SideBorder(null, BOTTOM) {
override fun getLineColor(): Color {
val borderColor = ClientProperty.get(
this@StickyLinesPanel,
FileEditorManager.SEPARATOR_COLOR
)
if (borderColor != null) {
return borderColor
}
val scheme = EditorColorsManager.getInstance().globalScheme
return scheme.getColor(EditorColors.TEARLINE_COLOR) ?: scheme.defaultBackground
}
}
}
}
@@ -0,0 +1,35 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.openapi.editor.impl.stickyLines
import com.intellij.ide.ui.UISettings
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.actionSystem.ToggleAction
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.ex.EditorSettingsExternalizable
import com.intellij.openapi.project.DumbAware
import com.intellij.xml.breadcrumbs.BreadcrumbsUtilEx
// don't show action
internal class StickyLinesSettingsHideAction : ToggleAction(), DumbAware {
override fun isSelected(event: AnActionEvent): Boolean {
val settings = EditorSettingsExternalizable.getInstance()
// true -> "don't show"
return !settings.isStickyLinesShown
}
override fun setSelected(event: AnActionEvent, hide: Boolean) {
val settings = EditorSettingsExternalizable.getInstance()
val shown = settings.isStickyLinesShown
if (shown == hide) {
settings.isStickyLinesShown = !shown
UISettings.getInstance().fireUISettingsChanged()
}
}
override fun getActionUpdateThread(): ActionUpdateThread {
return ActionUpdateThread.BGT
}
}
@@ -9,6 +9,7 @@ import com.intellij.psi.FileViewProvider;
import com.intellij.psi.PsiManager;
import com.intellij.ui.breadcrumbs.BreadcrumbsProvider;
import com.intellij.ui.breadcrumbs.BreadcrumbsUtil;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -66,4 +67,29 @@ public final class BreadcrumbsUtilEx {
}
return language.getID();
}
@ApiStatus.Internal
public static @Nullable BreadcrumbsProvider findProviderForSticky(@NotNull FileViewProvider viewProvider) {
EditorSettingsExternalizable settings = EditorSettingsExternalizable.getInstance();
if (!settings.isStickyLinesShown()) return null;
Language baseLang = viewProvider.getBaseLanguage();
if (!isStickyLinesShownFor(settings, baseLang)) return null;
BreadcrumbsProvider provider = BreadcrumbsUtil.getInfoProvider(baseLang);
if (provider == null) {
for (Language language : viewProvider.getLanguages()) {
if (isStickyLinesShownFor(settings, language)) {
provider = BreadcrumbsUtil.getInfoProvider(language);
if (provider != null) break;
}
}
}
return provider;
}
private static boolean isStickyLinesShownFor(@NotNull EditorSettingsExternalizable settings, Language language) {
String id = findLanguageWithBreadcrumbSettings(language);
return settings.isStickyLinesShownFor(id);
}
}
@@ -28,6 +28,7 @@ import com.intellij.ui.breadcrumbs.BreadcrumbsUtil;
import com.intellij.ui.components.breadcrumbs.Crumb;
import com.intellij.util.containers.ContainerUtil;
import com.intellij.util.text.CharArrayUtil;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -101,6 +102,24 @@ public final class PsiFileBreadcrumbsCollector extends FileBreadcrumbsCollector
return result;
}
@ApiStatus.Internal
public @NotNull List<PsiElement> computePsiElements(@NotNull VirtualFile file, @NotNull Document document, int offset) {
boolean checkSettings = false;
BreadcrumbsProvider defaultInfoProvider = findProvider(file, myProject, true);
PsiElement element = findStartElement(document, offset, file, myProject, defaultInfoProvider, checkSettings); // TODO: check settings?
if (element == null) return Collections.emptyList();
ArrayList<PsiElement> result = new ArrayList<>();
while (element != null) {
BreadcrumbsProvider provider = findProviderForElement(element, defaultInfoProvider, checkSettings);
if (provider != null && provider.acceptElement(element)) {
result.add(element);
}
element = getParent(element, provider);
if (element instanceof PsiDirectory) break;
}
return result;
}
private static CrumbPresentation @Nullable [] getCrumbPresentations(final PsiElement[] elements) {
for (BreadcrumbsPresentationProvider provider : BreadcrumbsPresentationProvider.EP_NAME.getExtensionList()) {
final CrumbPresentation[] presentations = provider.getCrumbPresentations(elements);
@@ -1864,6 +1864,11 @@ action.EditorBreadcrumbsShowBelow.description=Show the breadcrumbs bar below the
action.EditorBreadcrumbsHideBoth.text=Don't Show
action.EditorBreadcrumbsHideBoth.description=Hide the breadcrumbs bar
group.EditorStickyLinesSettings.text=Sticky Lines
group.EditorStickyLinesSettings.description=Toggle sticky lines panel settings
action.EditorStickyLinesHide.text=Disable Sticky Lines
action.EditorStickyLinesHide.description=Hide sticky lines panel
group.MarkFileAs.text=Mark File as
action.OverrideFileTypeAction.text=Override File Type
action.OverrideFileTypeAction.description=Treat current file as a file of different file type
@@ -1603,6 +1603,7 @@
<highlightingPassFactory implementation="com.intellij.codeInsight.daemon.impl.LocalInspectionsPassFactory"/>
<highlightingPassFactory implementation="com.intellij.codeInsight.daemon.impl.ShowAutoImportPassFactory"/>
<highlightingPassFactory implementation="com.intellij.codeInsight.documentation.render.DocRenderPassFactory"/>
<highlightingPassFactory implementation="com.intellij.codeInsight.stickyLines.StickyLinesPassFactory"/>
<highlightingPassFactory implementation="com.intellij.codeInsight.daemon.impl.WholeFileLocalInspectionsPassFactory"/>
<highlightingPassFactory implementation="com.intellij.codeInsight.daemon.impl.ExternalToolPassFactory"/>
@@ -1586,6 +1586,11 @@
<add-to-group group-id="EditorGutterPopupMenu.Appearance" relative-to-action="EditorGutterToggleGlobalIndentLines" anchor="after"/>
</group>
<group id="EditorStickyLinesSettings" popup="true">
<action id="EditorStickyLinesHide" class="com.intellij.openapi.editor.impl.stickyLines.StickyLinesSettingsHideAction"/>
<add-to-group group-id="EditorGutterPopupMenu.Appearance" relative-to-action="EditorBreadcrumbsSettings" anchor="after"/>
</group>
<action class="com.intellij.ide.plugins.InstallFromDiskAction" id="plugin.InstallFromDiskAction"/>
<action class="com.intellij.openapi.editor.actions.ShowFontsUsedByEditorAction" id="ShowFontsUsedByEditor"/>
@@ -2436,5 +2436,8 @@ coroutine.scope.model.description=Use the new experimental coroutine-based model
ide.treat.project.modality.as.application=true
ide.treat.project.modality.as.application.description=Treat DialogWrapper.IdeModalityType.PROJECT as Dialog.ModalityType.APPLICATION_MODAL
editor.show.sticky.lines.debug=false
editor.show.sticky.lines.debug.description=Show editor sticky lines in debug mode
# please leave this note as last line
# TODO please use EP com.intellij.registryKey for plugin/product specific keys