Compare commits

...

3 Commits

8 changed files with 166 additions and 30 deletions

View File

@ -4,7 +4,7 @@ import com.intellij.codeInsight.daemon.impl.HighlightInfo
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.Inlay
import com.intellij.openapi.editor.InlayProperties
import com.intellij.openapi.editor.ex.RangeHighlighterEx
import com.intellij.openapi.editor.markup.RangeHighlighter
import com.intellij.openapi.util.Key
/**
@ -18,28 +18,32 @@ class EditorInlayLensManager private constructor(private val editor: Editor) {
return editor.getUserData(KEY) ?: EditorInlayLensManager(editor).also { editor.putUserData(KEY, it) }
}
private fun updateRenderer(renderer: LensRenderer, info: HighlightInfo) {
renderer.text = info.description.takeIf(String::isNotBlank)?.let(::addMissingPeriod) ?: " "
renderer.severity = LensSeverity.from(info.severity)
fun remove(editor: Editor) {
val manager = editor.getUserData(KEY)
if (manager != null) {
manager.hideAll()
editor.putUserData(KEY, null)
}
}
private fun addMissingPeriod(text: String): String {
return if (text.endsWith('.')) text else "$text."
private fun getInlayHintOffset(info: HighlightInfo): Int {
// Ensures a highlight at the end of a line does not overflow to the next line.
return info.actualEndOffset - 1
}
}
private val inlays = mutableMapOf<RangeHighlighterEx, Inlay<LensRenderer>>()
private val inlays = mutableMapOf<RangeHighlighter, Inlay<LensRenderer>>()
fun show(highlighter: RangeHighlighterEx, info: HighlightInfo) {
fun show(highlighter: RangeHighlighter, info: HighlightInfo) {
val currentInlay = inlays[highlighter]
if (currentInlay != null && currentInlay.isValid) {
updateRenderer(currentInlay.renderer, info)
currentInlay.renderer.setPropertiesFrom(info)
currentInlay.update()
}
else {
val offset = info.actualEndOffset - 1
val renderer = LensRenderer().also { updateRenderer(it, info) }
val properties = InlayProperties().relatesToPrecedingText(true).priority(-offset)
val offset = getInlayHintOffset(info)
val renderer = LensRenderer(info)
val properties = InlayProperties().relatesToPrecedingText(true).disableSoftWrapping(true).priority(-offset)
editor.inlayModel.addAfterLineEndElement(offset, properties, renderer)?.let {
inlays[highlighter] = it
@ -47,7 +51,12 @@ class EditorInlayLensManager private constructor(private val editor: Editor) {
}
}
fun hide(highlighter: RangeHighlighterEx) {
fun hide(highlighter: RangeHighlighter) {
inlays.remove(highlighter)?.dispose()
}
fun hideAll() {
inlays.values.forEach(Inlay<*>::dispose)
inlays.clear()
}
}

View File

@ -0,0 +1,10 @@
package com.chylex.intellij.inspectionlens
import com.intellij.openapi.Disposable
/**
* Gets automatically disposed when the plugin is unloaded.
*/
class InspectionLensPluginDisposableService : Disposable {
override fun dispose() {}
}

View File

@ -0,0 +1,43 @@
package com.chylex.intellij.inspectionlens
import com.intellij.ide.plugins.DynamicPluginListener
import com.intellij.ide.plugins.IdeaPluginDescriptor
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.TextEditor
import com.intellij.openapi.project.ProjectManager
/**
* Handles dynamic plugin loading.
*
* On load, it installs the [LensMarkupModelListener] to all open editors.
* On unload, it removes all lenses from all open editors.
*/
class InspectionLensPluginListener : DynamicPluginListener {
companion object {
private const val PLUGIN_ID = "com.chylex.intellij.inspectionlens"
private inline fun ProjectManager.forEachEditor(action: (TextEditor) -> Unit) {
for (project in this.openProjects.filterNot { it.isDisposed }) {
val fileEditorManager = FileEditorManager.getInstance(project)
for (editor in fileEditorManager.allEditors.filterIsInstance<TextEditor>()) {
action(editor)
}
}
}
}
override fun pluginLoaded(pluginDescriptor: IdeaPluginDescriptor) {
if (pluginDescriptor.pluginId.idString == PLUGIN_ID) {
ProjectManager.getInstanceIfCreated()?.forEachEditor(LensMarkupModelListener.Companion::install)
}
}
override fun beforePluginUnload(pluginDescriptor: IdeaPluginDescriptor, isUpdate: Boolean) {
if (pluginDescriptor.pluginId.idString == PLUGIN_ID) {
ProjectManager.getInstanceIfCreated()?.forEachEditor {
EditorInlayLensManager.remove(it.editor)
}
}
}
}

View File

@ -1,7 +1,5 @@
package com.chylex.intellij.inspectionlens
import com.intellij.openapi.editor.ex.MarkupModelEx
import com.intellij.openapi.editor.impl.DocumentMarkupModel
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.FileEditorManagerListener
import com.intellij.openapi.fileEditor.TextEditor
@ -9,18 +7,14 @@ import com.intellij.openapi.fileEditor.ex.FileEditorWithProvider
import com.intellij.openapi.vfs.VirtualFile
/**
* Listens for newly opened editors, and attaches a [LensMarkupModelListener] to their document model.
* Listens for newly opened editors, and installs a [LensMarkupModelListener] on them.
*/
class LensFileEditorListener : FileEditorManagerListener {
override fun fileOpenedSync(source: FileEditorManager, file: VirtualFile, editorsWithProviders: MutableList<FileEditorWithProvider>) {
for (editorWrapper in editorsWithProviders) {
val fileEditor = editorWrapper.fileEditor
if (fileEditor is TextEditor) {
val editor = fileEditor.editor
val markupModel = DocumentMarkupModel.forDocument(editor.document, editor.project, false)
if (markupModel is MarkupModelEx) {
markupModel.addMarkupModelListener(fileEditor, LensMarkupModelListener(editor))
}
LensMarkupModelListener.install(fileEditor)
}
}
}

View File

@ -1,16 +1,22 @@
package com.chylex.intellij.inspectionlens
import com.chylex.intellij.inspectionlens.util.MultiParentDisposable
import com.intellij.codeInsight.daemon.impl.AsyncDescriptionSupplier
import com.intellij.codeInsight.daemon.impl.HighlightInfo
import com.intellij.lang.annotation.HighlightSeverity
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.ex.MarkupModelEx
import com.intellij.openapi.editor.ex.RangeHighlighterEx
import com.intellij.openapi.editor.impl.DocumentMarkupModel
import com.intellij.openapi.editor.impl.event.MarkupModelListener
import com.intellij.openapi.editor.markup.RangeHighlighter
import com.intellij.openapi.fileEditor.TextEditor
/**
* Listens for inspection highlights and reports them to [EditorInlayLensManager].
*/
class LensMarkupModelListener(editor: Editor) : MarkupModelListener {
class LensMarkupModelListener private constructor(editor: Editor) : MarkupModelListener {
private val lens = EditorInlayLensManager.getOrCreate(editor)
override fun afterAdded(highlighter: RangeHighlighterEx) {
@ -25,7 +31,7 @@ class LensMarkupModelListener(editor: Editor) : MarkupModelListener {
lens.hide(highlighter)
}
private fun showIfValid(highlighter: RangeHighlighterEx) {
private fun showIfValid(highlighter: RangeHighlighter) {
if (!highlighter.isValid) {
return
}
@ -47,9 +53,35 @@ class LensMarkupModelListener(editor: Editor) : MarkupModelListener {
}
}
private fun showIfNonNullDescription(highlighter: RangeHighlighterEx, info: HighlightInfo) {
private fun showIfNonNullDescription(highlighter: RangeHighlighter, info: HighlightInfo) {
if (info.description != null) {
lens.show(highlighter, info)
}
}
companion object {
/**
* Attaches a new [LensMarkupModelListener] to the document model of the provided [TextEditor], and reports all existing inspection highlights to [EditorInlayLensManager].
*
* The [LensMarkupModelListener] will be disposed when either the [TextEditor] is disposed, or via [InspectionLensPluginDisposableService] when the plugin is unloaded.
*/
fun install(textEditor: TextEditor) {
val editor = textEditor.editor
val markupModel = DocumentMarkupModel.forDocument(editor.document, editor.project, false)
if (markupModel is MarkupModelEx) {
val pluginDisposable = ApplicationManager.getApplication().getService(InspectionLensPluginDisposableService::class.java)
val listenerDisposable = MultiParentDisposable()
listenerDisposable.registerWithParent(textEditor)
listenerDisposable.registerWithParent(pluginDisposable)
val listener = LensMarkupModelListener(editor)
markupModel.addMarkupModelListener(listenerDisposable.self, listener)
for (highlighter in markupModel.allHighlighters) {
listener.showIfValid(highlighter)
}
}
}
}
}

View File

@ -1,5 +1,6 @@
package com.chylex.intellij.inspectionlens
import com.intellij.codeInsight.daemon.impl.HighlightInfo
import com.intellij.codeInsight.daemon.impl.HintRenderer
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.Inlay
@ -11,23 +12,44 @@ import java.awt.Rectangle
/**
* Renders the text of an inspection lens.
*/
class LensRenderer : HintRenderer(null) {
private companion object {
private val ATTRIBUTES = TextAttributes(null, null, null, null, Font.ITALIC)
class LensRenderer(info: HighlightInfo) : HintRenderer(null) {
private lateinit var severity: LensSeverity
init {
setPropertiesFrom(info)
}
var severity = LensSeverity.OTHER
fun setPropertiesFrom(info: HighlightInfo) {
text = getValidDescriptionText(info.description)
severity = LensSeverity.from(info.severity)
}
override fun paint(inlay: Inlay<*>, g: Graphics, r: Rectangle, textAttributes: TextAttributes) {
r.y += 1
fixBaselineForTextRendering(r)
super.paint(inlay, g, r, textAttributes)
}
override fun getTextAttributes(editor: Editor): TextAttributes {
return ATTRIBUTES.also { it.foregroundColor = severity.getColor(editor) }
return ATTRIBUTES_SINGLETON.also { it.foregroundColor = severity.getColor(editor) }
}
override fun useEditorFont(): Boolean {
return true
}
private companion object {
private val ATTRIBUTES_SINGLETON = TextAttributes(null, null, null, null, Font.ITALIC)
private fun getValidDescriptionText(text: String?): String {
return if (text.isNullOrBlank()) " " else addMissingPeriod(text)
}
private fun addMissingPeriod(text: String): String {
return if (text.endsWith('.')) text else "$text."
}
private fun fixBaselineForTextRendering(rect: Rectangle) {
rect.y += 1
}
}
}

View File

@ -0,0 +1,18 @@
package com.chylex.intellij.inspectionlens.util
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import java.lang.ref.WeakReference
/**
* A [Disposable] that can have multiple parents, and will be disposed when any parent is disposed.
* A [WeakReference] and a lambda will remain in memory for every parent that is not disposed.
*/
class MultiParentDisposable {
val self = Disposer.newDisposable()
fun registerWithParent(parent: Disposable) {
val weakSelfReference = WeakReference(self)
Disposer.register(parent) { weakSelfReference.get()?.let(Disposer::dispose) }
}
}

View File

@ -11,6 +11,14 @@
<depends>com.intellij.modules.platform</depends>
<extensions defaultExtensionNs="com.intellij">
<applicationService serviceImplementation="com.chylex.intellij.inspectionlens.InspectionLensPluginDisposableService" />
</extensions>
<applicationListeners>
<listener class="com.chylex.intellij.inspectionlens.InspectionLensPluginListener" topic="com.intellij.ide.plugins.DynamicPluginListener" />
</applicationListeners>
<projectListeners>
<listener class="com.chylex.intellij.inspectionlens.LensFileEditorListener" topic="com.intellij.openapi.fileEditor.FileEditorManagerListener" />
</projectListeners>