[platform] IJPL-192110 add hover effect to links in the editor/console/terminal

As a side effect, `EditorHyperlinkSupport.linkFollowed` method is more performant now: no need to iterate over all hyperlinks to change the followed link.

GitOrigin-RevId: e83e8f1c0727277e60abcdd95d2c1436d85fe347
This commit is contained in:
Sergey Simonchik
2025-06-16 19:10:40 +02:00
committed by intellij-monorepo-bot
parent 7f2fe200c3
commit 2c5f4e42c2
5 changed files with 163 additions and 38 deletions

View File

@@ -14,6 +14,7 @@ import com.intellij.openapi.util.TextRange;
import com.intellij.util.ui.NamedColorUtil;
import kotlin.Lazy;
import kotlin.LazyKt;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -46,7 +47,17 @@ public interface Filter extends PossiblyDumbAware {
final @Nullable HyperlinkInfo hyperlinkInfo,
final @Nullable TextAttributes highlightAttributes,
final @Nullable TextAttributes followedHyperlinkAttributes) {
super(highlightStartOffset, highlightEndOffset, hyperlinkInfo, highlightAttributes, followedHyperlinkAttributes);
this(highlightStartOffset, highlightEndOffset, hyperlinkInfo, highlightAttributes, followedHyperlinkAttributes, null);
}
@ApiStatus.Internal
public Result(final int highlightStartOffset,
final int highlightEndOffset,
final @Nullable HyperlinkInfo hyperlinkInfo,
final @Nullable TextAttributes highlightAttributes,
final @Nullable TextAttributes followedHyperlinkAttributes,
final @Nullable TextAttributes hoveredHyperlinkAttributes) {
super(highlightStartOffset, highlightEndOffset, hyperlinkInfo, highlightAttributes, followedHyperlinkAttributes, hoveredHyperlinkAttributes);
myResultItems = null;
}
@@ -154,6 +165,7 @@ public interface Filter extends PossiblyDumbAware {
private final @Nullable HyperlinkInfo hyperlinkInfo;
private final TextAttributes myFollowedHyperlinkAttributes;
private final TextAttributes myHoveredHyperlinkAttributes;
public ResultItem(final int highlightStartOffset, final int highlightEndOffset, final @Nullable HyperlinkInfo hyperlinkInfo) {
this(highlightStartOffset, highlightEndOffset, hyperlinkInfo, null, null);
@@ -180,12 +192,23 @@ public interface Filter extends PossiblyDumbAware {
final @Nullable HyperlinkInfo hyperlinkInfo,
final @Nullable TextAttributes highlightAttributes,
final @Nullable TextAttributes followedHyperlinkAttributes) {
this(highlightStartOffset, highlightEndOffset, hyperlinkInfo, highlightAttributes, followedHyperlinkAttributes, null);
}
@ApiStatus.Internal
public ResultItem(final int highlightStartOffset,
final int highlightEndOffset,
final @Nullable HyperlinkInfo hyperlinkInfo,
final @Nullable TextAttributes highlightAttributes,
final @Nullable TextAttributes followedHyperlinkAttributes,
final @Nullable TextAttributes hoveredHyperlinkAttributes) {
this.highlightStartOffset = highlightStartOffset;
this.highlightEndOffset = highlightEndOffset;
TextRange.assertProperRange(highlightStartOffset, highlightEndOffset, "");
this.hyperlinkInfo = hyperlinkInfo;
this.highlightAttributes = highlightAttributes;
myFollowedHyperlinkAttributes = followedHyperlinkAttributes;
myHoveredHyperlinkAttributes = hoveredHyperlinkAttributes;
}
public int getHighlightStartOffset() {
@@ -204,6 +227,11 @@ public interface Filter extends PossiblyDumbAware {
return myFollowedHyperlinkAttributes;
}
@ApiStatus.Internal
public @Nullable TextAttributes getHoveredHyperlinkAttributes() {
return myHoveredHyperlinkAttributes;
}
public @Nullable HyperlinkInfo getHyperlinkInfo() {
return hyperlinkInfo;
}

View File

@@ -93,7 +93,7 @@ public class CompositeFilter implements Filter, FilterMixin, DumbAware {
if (resultItems.size() == 1) {
ResultItem resultItem = resultItems.get(0);
return new Result(resultItem.getHighlightStartOffset(), resultItem.getHighlightEndOffset(), resultItem.getHyperlinkInfo(),
resultItem.getHighlightAttributes(), resultItem.getFollowedHyperlinkAttributes()) {
resultItem.getHighlightAttributes(), resultItem.getFollowedHyperlinkAttributes(), resultItem.getHoveredHyperlinkAttributes()) {
@Override
public int getHighlighterLayer() {
return resultItem.getHighlighterLayer();

View File

@@ -1253,7 +1253,7 @@ open class ConsoleViewImpl protected constructor(
return null
}
return EditorHyperlinkSupport.getNextOccurrence(editor, delta) { next: RangeHighlighter ->
return EditorHyperlinkSupport.get(editor).getNextOccurrence(delta) { next: RangeHighlighter ->
val offset = next.startOffset
scrollTo(offset)
val hyperlinkInfo = EditorHyperlinkSupport.getHyperlinkInfo(next)

View File

@@ -0,0 +1,72 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.execution.impl
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.ex.RangeHighlighterEx
import com.intellij.openapi.editor.markup.RangeHighlighter
import com.intellij.openapi.editor.markup.TextAttributes
import com.intellij.util.concurrency.ThreadingAssertions
import com.intellij.util.concurrency.annotations.RequiresEdt
internal class EditorHyperlinkEffectSupport(private val editor: Editor) {
private var followedLinkWrapper: ChangedAttrsLinkWrapper? = null
private var hoveredLinkWrapper: ChangedAttrsLinkWrapper? = null
val followedLink: RangeHighlighter?
@RequiresEdt(generateAssertion = false)
get() = followedLinkWrapper?.linkRangeHighlighter
@RequiresEdt(generateAssertion = false)
fun linkFollowed(link: RangeHighlighter) {
if (followedLinkWrapper?.isSame(link) == true) return
followedLinkWrapper?.restoreOriginalAttrs()
followedLinkWrapper = null
// When a link is followed, remove any hovered links.
hoveredLinkWrapper?.restoreOriginalAttrs()
hoveredLinkWrapper = null
if (link is RangeHighlighterEx) {
followedLinkWrapper = ChangedAttrsLinkWrapper(link, EditorHyperlinkSupport.getFollowedHyperlinkAttributes(link))
}
}
@RequiresEdt(generateAssertion = false)
fun linkHovered(link: RangeHighlighter?) {
ThreadingAssertions.assertEventDispatchThread()
if (link == null) {
hoveredLinkWrapper?.restoreOriginalAttrs()
hoveredLinkWrapper = null
return
}
if (hoveredLinkWrapper?.isSame(link) == true) {
return // the link is already shown as hovered
}
if (followedLinkWrapper?.isSame(link) == true) {
return // if the link is shown as followed already, no hover effect should be applied
}
hoveredLinkWrapper?.restoreOriginalAttrs()
hoveredLinkWrapper = null
if (link is RangeHighlighterEx) {
val hoveredLinkAttrs = EditorHyperlinkSupport.getHoveredHyperlinkAttributes(link)
if (hoveredLinkAttrs != null) {
hoveredLinkWrapper = ChangedAttrsLinkWrapper(link, hoveredLinkAttrs)
}
}
}
private inner class ChangedAttrsLinkWrapper(val linkRangeHighlighter: RangeHighlighterEx, newTextAttrs: TextAttributes) {
private val originalTextAttrs: TextAttributes? = linkRangeHighlighter.getTextAttributes(editor.getColorsScheme())
init {
linkRangeHighlighter.setTextAttributes(newTextAttrs)
}
fun isSame(linkRangeHighlighter: RangeHighlighter): Boolean = this.linkRangeHighlighter === linkRangeHighlighter
fun restoreOriginalAttrs() {
if (linkRangeHighlighter.isValid()) {
linkRangeHighlighter.setTextAttributes(originalTextAttrs)
}
}
}
}

View File

@@ -26,21 +26,22 @@ import com.intellij.openapi.util.*;
import com.intellij.pom.NavigatableAdapter;
import com.intellij.ui.awt.RelativePoint;
import com.intellij.util.*;
import com.intellij.util.containers.ContainerUtil;
import kotlin.Unit;
import org.jetbrains.annotations.*;
import java.awt.*;
import java.awt.event.MouseEvent;
import java.util.*;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
import java.util.function.Function;
public final class EditorHyperlinkSupport {
private static final Logger LOG = Logger.getInstance(EditorHyperlinkSupport.class);
private static final Key<TextAttributes> OLD_HYPERLINK_TEXT_ATTRIBUTES = Key.create("OLD_HYPERLINK_TEXT_ATTRIBUTES");
private static final Key<HyperlinkInfoTextAttributes> HYPERLINK = Key.create("EDITOR_HYPERLINK_SUPPORT_HYPERLINK");
private static final Key<Unit> HIGHLIGHTING = Key.create("EDITOR_HYPERLINK_SUPPORT_HIGHLIGHTING");
private static final Key<Unit> INLAY = Key.create("EDITOR_HYPERLINK_SUPPORT_INLAY");
@@ -49,6 +50,7 @@ public final class EditorHyperlinkSupport {
private final EditorEx myEditor;
private final @NotNull Project myProject;
private final EditorHyperlinkEffectSupport myLinkEffectSupport;
private final AsyncFilterRunner myFilterRunner;
private final CopyOnWriteArrayList<EditorHyperlinkListener> myHyperlinkListeners = new CopyOnWriteArrayList<>();
@@ -63,6 +65,7 @@ public final class EditorHyperlinkSupport {
private EditorHyperlinkSupport(@NotNull Editor editor, @NotNull Project project, boolean trackChangesManually) {
myEditor = (EditorEx)editor;
myProject = project;
myLinkEffectSupport = new EditorHyperlinkEffectSupport(myEditor);
myFilterRunner = new AsyncFilterRunner(this, myEditor, trackChangesManually);
editor.addEditorMouseListener(new EditorMouseListener() {
@@ -93,17 +96,29 @@ public final class EditorHyperlinkSupport {
}
}
}
@Override
public void mouseExited(@NotNull EditorMouseEvent event) {
myLinkEffectSupport.linkHovered(null);
}
});
editor.addEditorMouseMotionListener(new EditorMouseMotionListener() {
@Override
public void mouseMoved(@NotNull EditorMouseEvent e) {
if (e.getArea() != EditorMouseEventArea.EDITING_AREA) return;
HyperlinkInfo info = getHyperlinkInfoByEvent(e);
RangeHighlighter range = findLinkAt(e);
HyperlinkInfo info = range == null ? null : getHyperlinkInfo(range);
myEditor.setCustomCursor(EditorHyperlinkSupport.class, info == null ? null : Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
myLinkEffectSupport.linkHovered(range);
}
});
}
private @Nullable RangeHighlighter findLinkAt(@NotNull EditorMouseEvent e) {
if (e.getArea() == EditorMouseEventArea.EDITING_AREA && e.isOverText()) {
return findLinkRangeAt(e.getOffset());
}
);
return null;
}
@ApiStatus.Internal
@@ -213,7 +228,7 @@ public final class EditorHyperlinkSupport {
else {
hyperlinkInfo.navigate(myProject);
}
linkFollowed(myEditor, getHyperlinks(0, myEditor.getDocument().getTextLength(),myEditor), range);
linkFollowed(range);
fireListeners(hyperlinkInfo);
};
}
@@ -314,14 +329,14 @@ public final class EditorHyperlinkSupport {
}
public void createHyperlink(@NotNull RangeHighlighter highlighter, @NotNull HyperlinkInfo hyperlinkInfo) {
associateHyperlink(highlighter, hyperlinkInfo, null, true);
associateHyperlink(highlighter, hyperlinkInfo, null, null, true);
}
public @NotNull RangeHighlighter createHyperlink(int highlightStartOffset,
int highlightEndOffset,
@Nullable TextAttributes highlightAttributes,
@NotNull HyperlinkInfo hyperlinkInfo) {
return createHyperlink(highlightStartOffset, highlightEndOffset, highlightAttributes, hyperlinkInfo, null,
return createHyperlink(highlightStartOffset, highlightEndOffset, highlightAttributes, hyperlinkInfo, null, null,
HighlighterLayer.HYPERLINK);
}
@@ -330,6 +345,7 @@ public final class EditorHyperlinkSupport {
@Nullable TextAttributes highlightAttributes,
@NotNull HyperlinkInfo hyperlinkInfo,
@Nullable TextAttributes followedHyperlinkAttributes,
@Nullable TextAttributes hoveredHyperlinkAttributes,
int layer) {
return myEditor.getMarkupModel().addRangeHighlighterAndChangeAttributes(CodeInsightColors.HYPERLINK_ATTRIBUTES,
highlightStartOffset,
@@ -340,15 +356,16 @@ public final class EditorHyperlinkSupport {
if (highlightAttributes != null) {
ex.setTextAttributes(highlightAttributes);
}
associateHyperlink(ex, hyperlinkInfo, followedHyperlinkAttributes, false);
associateHyperlink(ex, hyperlinkInfo, followedHyperlinkAttributes, hoveredHyperlinkAttributes, false);
});
}
private static void associateHyperlink(@NotNull RangeHighlighter highlighter,
@NotNull HyperlinkInfo hyperlinkInfo,
@Nullable TextAttributes followedHyperlinkAttributes,
@Nullable TextAttributes hoveredHyperlinkAttributes,
boolean fireChanged) {
HyperlinkInfoTextAttributes attributes = new HyperlinkInfoTextAttributes(hyperlinkInfo, followedHyperlinkAttributes);
HyperlinkInfoTextAttributes attributes = new HyperlinkInfoTextAttributes(hyperlinkInfo, followedHyperlinkAttributes, hoveredHyperlinkAttributes);
highlighter.putUserData(HYPERLINK, attributes);
if (fireChanged) {
((RangeHighlighterEx)highlighter).fireChanged(false, false, false);
@@ -416,6 +433,7 @@ public final class EditorHyperlinkSupport {
}
else if (resultItem.getHyperlinkInfo() != null) {
createHyperlink(start, end, attributes, resultItem.getHyperlinkInfo(), resultItem.getFollowedHyperlinkAttributes(),
resultItem.getHoveredHyperlinkAttributes(),
resultItem.getHighlighterLayer());
}
else if (attributes != null) {
@@ -450,7 +468,7 @@ public final class EditorHyperlinkSupport {
}
}
private static @NotNull TextAttributes getFollowedHyperlinkAttributes(@NotNull RangeHighlighter range) {
static @NotNull TextAttributes getFollowedHyperlinkAttributes(@NotNull RangeHighlighter range) {
HyperlinkInfoTextAttributes attrs = range.getUserData(HYPERLINK);
TextAttributes result = attrs == null ? null : attrs.followedHyperlinkAttributes();
if (result == null) {
@@ -459,14 +477,31 @@ public final class EditorHyperlinkSupport {
return result;
}
static @Nullable TextAttributes getHoveredHyperlinkAttributes(@NotNull RangeHighlighter range) {
HyperlinkInfoTextAttributes attrs = range.getUserData(HYPERLINK);
return attrs == null ? null : attrs.hoveredHyperlinkAttributes();
}
/**
* @deprecated use {@link EditorHyperlinkSupport#getNextOccurrence(int, Consumer)} instead
*/
@Deprecated
public static @Nullable OccurenceNavigator.OccurenceInfo getNextOccurrence(@NotNull Editor editor,
int delta,
@NotNull Consumer<? super RangeHighlighter> action) {
List<RangeHighlighter> ranges = getHyperlinks(0, editor.getDocument().getTextLength(),editor);
@NotNull com.intellij.util.Consumer<? super RangeHighlighter> action) {
return get(editor).getNextOccurrence(delta, action);
}
@ApiStatus.Internal
public @Nullable OccurenceNavigator.OccurenceInfo getNextOccurrence(
int delta,
@NotNull Consumer<? super RangeHighlighter> action
) {
List<RangeHighlighter> ranges = getHyperlinks(0, myEditor.getDocument().getTextLength(), myEditor);
if (ranges.isEmpty()) {
return null;
}
int i = ContainerUtil.indexOf(ranges, range -> range.getUserData(OLD_HYPERLINK_TEXT_ATTRIBUTES) != null);
int i = ranges.indexOf(myLinkEffectSupport.getFollowedLink());
if (i == -1) {
i = 0;
}
@@ -477,13 +512,13 @@ public final class EditorHyperlinkSupport {
HyperlinkInfo info = getHyperlinkInfo(next);
assert info != null;
if (info.includeInOccurenceNavigation()) {
boolean inCollapsedRegion = editor.getFoldingModel().getCollapsedRegionAtOffset(next.getStartOffset()) != null;
boolean inCollapsedRegion = myEditor.getFoldingModel().getCollapsedRegionAtOffset(next.getStartOffset()) != null;
if (!inCollapsedRegion) {
return new OccurenceNavigator.OccurenceInfo(new NavigatableAdapter() {
@Override
public void navigate(boolean requestFocus) {
action.consume(next);
linkFollowed(editor, ranges, next);
action.accept(next);
linkFollowed(next);
}
}, newIndex + 1, ranges.size());
}
@@ -495,24 +530,10 @@ public final class EditorHyperlinkSupport {
return null;
}
private static void linkFollowed(@NotNull Editor editor, @NotNull Collection<? extends RangeHighlighter> ranges, @NotNull RangeHighlighter link) {
MarkupModelEx markupModel = (MarkupModelEx)editor.getMarkupModel();
for (RangeHighlighter range : ranges) {
TextAttributes oldAttr = range.getUserData(OLD_HYPERLINK_TEXT_ATTRIBUTES);
if (oldAttr != null) {
markupModel.setRangeHighlighterAttributes(range, oldAttr);
range.putUserData(OLD_HYPERLINK_TEXT_ATTRIBUTES, null);
}
if (range == link) {
range.putUserData(OLD_HYPERLINK_TEXT_ATTRIBUTES, range.getTextAttributes(editor.getColorsScheme()));
markupModel.setRangeHighlighterAttributes(range, getFollowedHyperlinkAttributes(range));
}
}
//refresh highlighter text attributes
markupModel.addRangeHighlighter(CodeInsightColors.HYPERLINK_ATTRIBUTES, 0, 0, link.getLayer(), HighlighterTargetArea.EXACT_RANGE).dispose();
private void linkFollowed(@NotNull RangeHighlighter link) {
myLinkEffectSupport.linkFollowed(link);
}
public static @NotNull String getLineText(@NotNull Document document, int lineNumber, boolean includeEol) {
return getLineSequence(document, lineNumber, includeEol).toString();
}
@@ -525,6 +546,10 @@ public final class EditorHyperlinkSupport {
return document.getImmutableCharSequence().subSequence(document.getLineStartOffset(lineNumber), endOffset);
}
private record HyperlinkInfoTextAttributes(@NotNull HyperlinkInfo hyperlinkInfo, @Nullable TextAttributes followedHyperlinkAttributes) {
private record HyperlinkInfoTextAttributes(
@NotNull HyperlinkInfo hyperlinkInfo,
@Nullable TextAttributes followedHyperlinkAttributes,
@Nullable TextAttributes hoveredHyperlinkAttributes
) {
}
}