diff --git a/platform/editor-ui-api/src/com/intellij/openapi/editor/EditorSettings.java b/platform/editor-ui-api/src/com/intellij/openapi/editor/EditorSettings.java index d819352c07d5..3085d3e7a7c7 100644 --- a/platform/editor-ui-api/src/com/intellij/openapi/editor/EditorSettings.java +++ b/platform/editor-ui-api/src/com/intellij/openapi/editor/EditorSettings.java @@ -210,6 +210,9 @@ public interface EditorSettings { boolean isInsertParenthesesAutomatically(); + boolean isStickyLinesShown(); + int getStickyLinesLimit(); + enum LineNumerationType { ABSOLUTE, RELATIVE, diff --git a/platform/ide-core-impl/src/com/intellij/openapi/editor/ex/EditorSettingsExternalizable.java b/platform/ide-core-impl/src/com/intellij/openapi/editor/ex/EditorSettingsExternalizable.java index 76bdc4ab1783..318187e621c0 100644 --- a/platform/ide-core-impl/src/com/intellij/openapi/editor/ex/EditorSettingsExternalizable.java +++ b/platform/ide-core-impl/src/com/intellij/openapi/editor/ex/EditorSettingsExternalizable.java @@ -82,6 +82,8 @@ public class EditorSettingsExternalizable implements PersistentStateComponent mapLanguageBreadcrumbs = new HashMap<>(); + private final Map mapLanguageStickyLines = new HashMap<>(); + public Map getLanguageBreadcrumbsMap() { return mapLanguageBreadcrumbs; } + @SuppressWarnings("unused") + public Map getLanguageStickyLines() { + return mapLanguageStickyLines; + } + public OptionSet() { Application application = ApplicationManager.getApplication(); if (application != null) { @@ -149,6 +158,14 @@ public class EditorSettingsExternalizable implements PersistentStateComponent map) { + if (this.mapLanguageStickyLines != map) { + this.mapLanguageStickyLines.clear(); + this.mapLanguageStickyLines.putAll(map); + } + } } @State( @@ -422,6 +439,47 @@ public class EditorSettingsExternalizable implements PersistentStateComponent> = 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 { + return CachedValueProvider { + val collector = StickyLinesCollector(project, document) + val infos: MutableSet = collector.collectLines(vFile) + val alreadyExecuted = AtomicBoolean() + val runnable = Runnable { + if (alreadyExecuted.compareAndSet(false, true)) { + collector.applyLines(infos) + } + } + CachedValueProvider.Result.create(runnable, dependency) + } + } + } +} diff --git a/platform/lang-impl/src/com/intellij/codeInsight/stickyLines/StickyLinesPassFactory.kt b/platform/lang-impl/src/com/intellij/codeInsight/stickyLines/StickyLinesPassFactory.kt new file mode 100644 index 000000000000..3cfd27aadf12 --- /dev/null +++ b/platform/lang-impl/src/com/intellij/codeInsight/stickyLines/StickyLinesPassFactory.kt @@ -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 + } +} diff --git a/platform/platform-impl/src/com/intellij/openapi/editor/impl/EditorImpl.java b/platform/platform-impl/src/com/intellij/openapi/editor/impl/EditorImpl.java index 2b47806d8af7..6a62a4499d63 100644 --- a/platform/platform-impl/src/com/intellij/openapi/editor/impl/EditorImpl.java +++ b/platform/platform-impl/src/com/intellij/openapi/editor/impl/EditorImpl.java @@ -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)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; + } + } } diff --git a/platform/platform-impl/src/com/intellij/openapi/editor/impl/EditorSettingsState.kt b/platform/platform-impl/src/com/intellij/openapi/editor/impl/EditorSettingsState.kt index cb842da16d0c..10ac1e2b700c 100644 --- a/platform/platform-impl/src/com/intellij/openapi/editor/impl/EditorSettingsState.kt +++ b/platform/platform-impl/src/com/intellij/openapi/editor/impl/EditorSettingsState.kt @@ -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) diff --git a/platform/platform-impl/src/com/intellij/openapi/editor/impl/IndentsModelCaretListener.java b/platform/platform-impl/src/com/intellij/openapi/editor/impl/IndentsModelCaretListener.java index 36285b854f65..d96e9714d398 100644 --- a/platform/platform-impl/src/com/intellij/openapi/editor/impl/IndentsModelCaretListener.java +++ b/platform/platform-impl/src/com/intellij/openapi/editor/impl/IndentsModelCaretListener.java @@ -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) { diff --git a/platform/platform-impl/src/com/intellij/openapi/editor/impl/ScrollingModelImpl.java b/platform/platform-impl/src/com/intellij/openapi/editor/impl/ScrollingModelImpl.java index 5ef640764d81..09e743fad2ce 100644 --- a/platform/platform-impl/src/com/intellij/openapi/editor/impl/ScrollingModelImpl.java +++ b/platform/platform-impl/src/com/intellij/openapi/editor/impl/ScrollingModelImpl.java @@ -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) { diff --git a/platform/platform-impl/src/com/intellij/openapi/editor/impl/SettingsImpl.kt b/platform/platform-impl/src/com/intellij/openapi/editor/impl/SettingsImpl.kt index 6bb7a0a57355..5c4a0105d4e1 100644 --- a/platform/platform-impl/src/com/intellij/openapi/editor/impl/SettingsImpl.kt +++ b/platform/platform-impl/src/com/intellij/openapi/editor/impl/SettingsImpl.kt @@ -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 { diff --git a/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLine.kt b/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLine.kt new file mode 100644 index 000000000000..6160334b4f26 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLine.kt @@ -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 { + + /** + * 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 +} diff --git a/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLineComponent.kt b/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLineComponent.kt new file mode 100644 index 000000000000..003edb366de1 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLineComponent.kt @@ -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(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 + } + } +} diff --git a/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLineInfo.java b/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLineInfo.java new file mode 100644 index 000000000000..ea832024911f --- /dev/null +++ b/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLineInfo.java @@ -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}. + *

+ * 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; + } +} diff --git a/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLinesCollector.kt b/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLinesCollector.kt new file mode 100644 index 000000000000..3f76a894b332 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLinesCollector.kt @@ -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 { + ThreadingAssertions.assertReadAccess() + ThreadingAssertions.assertBackgroundThread() + val psiCollector = PsiFileBreadcrumbsCollector(project) + val infos: MutableSet = mutableSetOf() + val lineCount: Int = document.getLineCount() + for (line in 0 until lineCount) { + ProgressManager.checkCanceled() + val endOffset: Int = document.getLineEndOffset(line) + val psiElements: List = 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) { + ThreadingAssertions.assertEventDispatchThread() + val stickyModel: StickyLinesModel = StickyLinesModel.getModel(project, document) ?: return + val outdatedLines: List = 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): List { + val outdatedLines: MutableList = 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 + } +} diff --git a/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLinesManager.kt b/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLinesManager.kt new file mode 100644 index 000000000000..7b34e0322eed --- /dev/null +++ b/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLinesManager.kt @@ -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 { + 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 { + 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 = ArrayList(logicalLinesLimit) + stickyModel.processStickyLines(activeOffset) { stickyLine -> + if (activeLogicalLine <= stickyLine.scopeLine()) { + stickyLines.add(stickyLine) + } + return@processStickyLines stickyLines.size < logicalLinesLimit + } + return stickyLines.sorted() + } +} diff --git a/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLinesModel.java b/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLinesModel.java new file mode 100644 index 000000000000..16929009de3e --- /dev/null +++ b/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLinesModel.java @@ -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 STICKY_LINES_MODEL_KEY = Key.create("editor.sticky.lines.model"); + private static final Key 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 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 processor) { + processStickyLines(myMarkupModel.getDocument().getTextLength(), processor); + } + + void processStickyLines(int endOffset, @NotNull Processor 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() + ")"; + } + } +} diff --git a/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLinesPanel.kt b/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLinesPanel.kt new file mode 100644 index 000000000000..a910ca4c9e30 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLinesPanel.kt @@ -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() { + + // ui + private val layeredPane: JBLayeredPane = JBLayeredPane() + + // ui + state + private val stickyLinesComp: MutableList = mutableListOf() + + // state + private val stickyLines: MutableList = 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) { + if (clearPanelIfDisabled()) return + editorY = newEditorY + editorH = newEditorH + stickyLines.clear() + stickyLines.addAll(newStickyLines) + repaintLinesImpl() + } + + fun repaintLines(newStickyLines: Collection) { + 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 = getStickyLines() + lazyAllocateComponents(stickyLines, lineLimit) + updateComponentsState(stickyLines, lineLimit) + placeComponentsOnPanel() + } + + private fun getStickyLines(): List { + 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, 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, 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 + } + } + } +} diff --git a/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLinesSettingsHideAction.kt b/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLinesSettingsHideAction.kt new file mode 100644 index 000000000000..e6948cc3e92c --- /dev/null +++ b/platform/platform-impl/src/com/intellij/openapi/editor/impl/stickyLines/StickyLinesSettingsHideAction.kt @@ -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 + } +} diff --git a/platform/platform-impl/src/com/intellij/xml/breadcrumbs/BreadcrumbsUtilEx.java b/platform/platform-impl/src/com/intellij/xml/breadcrumbs/BreadcrumbsUtilEx.java index b2c4edd68fcc..f9e47928501b 100644 --- a/platform/platform-impl/src/com/intellij/xml/breadcrumbs/BreadcrumbsUtilEx.java +++ b/platform/platform-impl/src/com/intellij/xml/breadcrumbs/BreadcrumbsUtilEx.java @@ -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); + } } diff --git a/platform/platform-impl/src/com/intellij/xml/breadcrumbs/PsiFileBreadcrumbsCollector.java b/platform/platform-impl/src/com/intellij/xml/breadcrumbs/PsiFileBreadcrumbsCollector.java index 92d886adacbe..1e5677f8f603 100644 --- a/platform/platform-impl/src/com/intellij/xml/breadcrumbs/PsiFileBreadcrumbsCollector.java +++ b/platform/platform-impl/src/com/intellij/xml/breadcrumbs/PsiFileBreadcrumbsCollector.java @@ -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 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 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); diff --git a/platform/platform-resources-en/src/messages/ActionsBundle.properties b/platform/platform-resources-en/src/messages/ActionsBundle.properties index e3db44777d68..ae3d9f6d8534 100644 --- a/platform/platform-resources-en/src/messages/ActionsBundle.properties +++ b/platform/platform-resources-en/src/messages/ActionsBundle.properties @@ -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 diff --git a/platform/platform-resources/src/META-INF/LangExtensions.xml b/platform/platform-resources/src/META-INF/LangExtensions.xml index 982c6c30bc68..b7ed1b9778d0 100644 --- a/platform/platform-resources/src/META-INF/LangExtensions.xml +++ b/platform/platform-resources/src/META-INF/LangExtensions.xml @@ -1603,6 +1603,7 @@ + diff --git a/platform/platform-resources/src/idea/PlatformActions.xml b/platform/platform-resources/src/idea/PlatformActions.xml index 2c5785b1b054..cf84cbaa4c10 100644 --- a/platform/platform-resources/src/idea/PlatformActions.xml +++ b/platform/platform-resources/src/idea/PlatformActions.xml @@ -1586,6 +1586,11 @@ + + + + + diff --git a/platform/util/resources/misc/registry.properties b/platform/util/resources/misc/registry.properties index aef2a2d5cb0e..c1c43bc21d1a 100644 --- a/platform/util/resources/misc/registry.properties +++ b/platform/util/resources/misc/registry.properties @@ -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