Question
How do I get to programmatically remove the LineMarkers I added?
Once one of the proposed actions is run, and the lint warning is gone, I want to simply remove all the other MergedLineMarkers that were also associated with this PsiElement that was being highlighted.
For example, if the user select the “Add type explicitely” quick fix, then all the gutter icons I added for that warning should be removed.
Current implementation
Here is my current draft, which comes pretty close to my requirements, but still needs some modifications:
class GutterIconProvider : LineMarkerProviderDescriptor() {
override fun getIcon(): Icon = PluginIcons.MY_ICON
override fun getName(): String = "Our GutterIconProvider"
/** This traverses all PsiElement of the opened file. */
override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? = null
override fun collectSlowLineMarkers(
psiElements: MutableList<out PsiElement>,
result: MutableCollection<in LineMarkerInfo<*>>
) {
runReadAction {
if (psiElements.isEmpty()) return@runReadAction
/*
HighlightInfo contains metadata regarding highlighted lint info such as severity level,
highlighted code (and associated offsets), lint message, associated Quick Fix Action, etc.
*/
val warningsAndErrorsWithinOpenedDoc: MutableList<HighlightInfo> = mutableListOf()
// All elements are contained within the same file
val psiElement = psiElements.first()
val project = psiElement.project
val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return@runReadAction
val doc = psiElement.containingFile.viewProvider.document
/*
This seems to be the only way to get the same diagnostics as the ones displayed in the
"Problems" tool window (linked to the "TrafficLightRenderer") for the selected code.
*/
// Filling the list with results
DaemonCodeAnalyzerEx.processHighlights(
doc,
project,
HighlightSeverity.WEAK_WARNING, // this level, and above
0, // from start
doc.textLength, // to finish
Processors.cancelableCollectProcessor(warningsAndErrorsWithinOpenedDoc))
for (highlightInfo in warningsAndErrorsWithinOpenedDoc) {
val element =
psiElements.firstOrNull {
it.textRange.intersects(highlightInfo.startOffset, highlightInfo.endOffset)
} ?: continue // TODO: do for all elements that intersect
highlightInfo.findRegisteredQuickFix { descriptor, range ->
val action = descriptor.action
val otherMarker =
OtherLineMarkerInfo(
highlightInfo,
element,
"Run `${action.text}` to fix `${highlightInfo.description}`") { _, elt ->
runWriteAction {
CommandProcessor.getInstance().runUndoTransparentAction {
action.invoke(project, editor, elt.containingFile)
// TODO: remove all other gutter icons that were merged with this one
}
}
}
result.add(otherMarker)
}
val ourMarker =
OurCustomLineMarkerInfo(highlightInfo, element, "Fix `${highlightInfo.description}`") {
doSomething()
}
result.add(ourMarker) // added last to impose its custom icon in the gutter
}
}
}
}
The associated MergeableLineMarkerInfo
classes:
class OurCustomLineMarkerInfo(
highlightInfo: HighlightInfo,
element: PsiElement,
description: String,
onClick: Runnable,
) :
OurLineMarkerInfo(
highlightInfo,
element,
{ description },
{ description },
{ description },
{ _, _ -> onClick.run() },
PluginIcons.MY_ICON)
class OtherLineMarkerInfo(
highlightInfo: HighlightInfo,
element: PsiElement,
description: String,
navigationHandler: ((MouseEvent, PsiElement) -> Unit)?,
) :
OurLineMarkerInfo(
highlightInfo,
element,
{ description },
{ description },
{ description },
{ event, elt -> navigationHandler?.invoke(event, elt) },
PluginIcons.LIGHT_BULB)
abstract class OurLineMarkerInfo(
highlightInfo: HighlightInfo,
element: PsiElement,
tooltipProvider: (PsiElement) -> String, // hover description
presentationProvider: ((PsiElement) -> String), // action description in popup
accessibleNameProvider: (() -> String), // not sure where that's used...
navigationHandler: ((MouseEvent, PsiElement) -> Unit)?, // "onClick"
icon: Icon, // the last Marker inserted will determine the gutter icon
alignment: GutterIconRenderer.Alignment = GutterIconRenderer.Alignment.RIGHT
) :
MergeableLineMarkerInfo<PsiElement>(
element,
TextRange(highlightInfo.startOffset, highlightInfo.endOffset),
icon,
tooltipProvider,
presentationProvider,
{ event, elt -> navigationHandler?.invoke(event, elt) },
alignment,
accessibleNameProvider) {
override fun canMergeWith(info: MergeableLineMarkerInfo<*>): Boolean = info is OurLineMarkerInfo
override fun getCommonIcon(infos: MutableList<out MergeableLineMarkerInfo<*>>): Icon = icon
}
Alternate implementation
This one was falling short on a few requirements, so I stopped exploring it, but it seemed like it was more in line with some of my other expectations, so I’ll show it in here as well, for posterity, and to potentially help spark more discussions.
Among other things, I like how this approach is more responsive (it inserts icons in the gutter a lot faster), but unfortunately the icons appear as static images instead of as clickable icons (e.g. the cursor remains the same when you hover it).
class MyStartupActivity : ProjectActivity {
override suspend fun execute(project: Project) {
val msgBusConnection = project.messageBus.connect()
msgBusConnection.subscribe(
DaemonCodeAnalyzer.DAEMON_EVENT_TOPIC,
object : DaemonCodeAnalyzer.DaemonListener {
// TODO: can use all events to always update based on latest info, instead of delaying
override fun daemonFinished() {
injectIcons(project)
super.daemonFinished()
}
})
}
private fun injectIcons(project: Project) {
val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return
val doc = editor.document
val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(doc) ?: return
runReadAction {
val warningsAndErrorsWithinOpenedDoc: MutableList<HighlightInfo> = mutableListOf()
DaemonCodeAnalyzerEx.processHighlights(
doc,
project,
HighlightSeverity.WEAK_WARNING,
0,
doc.textLength,
Processors.cancelableCollectProcessor(warningsAndErrorsWithinOpenedDoc))
warningsAndErrorsWithinOpenedDoc.forEach { hi ->
// Track existing highlighters by their range
val existingHighlighters =
editor.markupModel.allHighlighters
.filter { it.gutterIconRenderer != null }
.associateBy { TextRange(it.startOffset, it.endOffset) }
// Remove existing one if it exists
val existingHighlighter = existingHighlighters[TextRange(hi.startOffset, hi.endOffset)]
existingHighlighter?.dispose()
editor.markupModel
.addRangeHighlighter(
hi.startOffset,
hi.endOffset,
0, // Layer (use appropriate layer for your use case)
null, // TextAttributes can be null if you only want gutter icon
HighlighterTargetArea.EXACT_RANGE)
.apply {
gutterIconRenderer =
object : GutterIconRenderer() {
override fun equals(other: Any?): Boolean {
return this === other ||
(other is GutterIconRenderer && this.icon == other.icon)
}
override fun hashCode(): Int = icon.hashCode()
override fun getIcon(): Icon = PluginIcons.MY_ICON
override fun getTooltipText(): String = "TEST"
override fun getClickAction(): AnAction? {
return object : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
println("Gutter icon clicked!")
this@apply.dispose() // removing the Highlighter that was just clicked
}
}
}
}
}
}
// This adds the icon, but also duplicates warnings
// UpdateHighlightersUtil.setHighlightersToEditor(
// project, doc, 0, doc.textLength, infosToAdd, editor.colorsScheme, 0)
}
}
}