diff --git a/src/main/kotlin/com/chylex/intellij/inspectionlens/InspectionLens.kt b/src/main/kotlin/com/chylex/intellij/inspectionlens/InspectionLens.kt index a6abbd8..6f2881a 100644 --- a/src/main/kotlin/com/chylex/intellij/inspectionlens/InspectionLens.kt +++ b/src/main/kotlin/com/chylex/intellij/inspectionlens/InspectionLens.kt @@ -1,6 +1,6 @@ package com.chylex.intellij.inspectionlens -import com.chylex.intellij.inspectionlens.editor.EditorInlayLensManager +import com.chylex.intellij.inspectionlens.editor.EditorLensManager import com.chylex.intellij.inspectionlens.editor.LensMarkupModelListener import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -36,7 +36,7 @@ internal object InspectionLens { */ fun uninstall() { forEachOpenEditor { - EditorInlayLensManager.remove(it.editor) + EditorLensManager.remove(it.editor) } } diff --git a/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/EditorInlayLensManager.kt b/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/EditorInlayLensManager.kt deleted file mode 100644 index 660055a..0000000 --- a/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/EditorInlayLensManager.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.chylex.intellij.inspectionlens.editor - -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.markup.RangeHighlighter -import com.intellij.openapi.util.Key - -/** - * Manages visible inspection lenses for an [Editor]. - */ -class EditorInlayLensManager private constructor(private val editor: Editor) { - companion object { - private val EDITOR_KEY = Key<EditorInlayLensManager>(EditorInlayLensManager::class.java.name) - - /** - * Highest allowed severity for the purposes of sorting multiple highlights at the same offset. - * The value is a little higher than the highest [com.intellij.lang.annotation.HighlightSeverity], in case severities with higher values are introduced in the future. - */ - private const val MAXIMUM_SEVERITY = 500 - private const val MAXIMUM_POSITION = ((Int.MAX_VALUE / MAXIMUM_SEVERITY) * 2) - 1 - - fun getOrCreate(editor: Editor): EditorInlayLensManager { - return editor.getUserData(EDITOR_KEY) ?: EditorInlayLensManager(editor).also { editor.putUserData(EDITOR_KEY, it) } - } - - fun remove(editor: Editor) { - val manager = editor.getUserData(EDITOR_KEY) - if (manager != null) { - manager.hideAll() - editor.putUserData(EDITOR_KEY, null) - } - } - - 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 - } - - internal fun getInlayHintPriority(position: Int, severity: Int): Int { - // Sorts highlights first by position on the line, then by severity. - val positionBucket = position.coerceIn(0, MAXIMUM_POSITION) * MAXIMUM_SEVERITY - // The multiplication can overflow, but subtracting overflowed result from Int.MAX_VALUE does not break continuity. - val positionFactor = Integer.MAX_VALUE - positionBucket - val severityFactor = severity.coerceIn(0, MAXIMUM_SEVERITY) - MAXIMUM_SEVERITY - return positionFactor + severityFactor - } - } - - private val inlays = mutableMapOf<RangeHighlighter, Inlay<LensRenderer>>() - - private fun show(highlighterWithInfo: HighlighterWithInfo) { - val (highlighter, info) = highlighterWithInfo - val currentInlay = inlays[highlighter] - if (currentInlay != null && currentInlay.isValid) { - currentInlay.renderer.setPropertiesFrom(info) - currentInlay.update() - } - else { - val offset = getInlayHintOffset(info) - val priority = getInlayHintPriority(info) - val renderer = LensRenderer(info) - val properties = InlayProperties().relatesToPrecedingText(true).disableSoftWrapping(true).priority(priority) - - editor.inlayModel.addAfterLineEndElement(offset, properties, renderer)?.let { - inlays[highlighter] = it - } - } - } - - fun showAll(highlightersWithInfo: Collection<HighlighterWithInfo>) { - executeInInlayBatchMode(highlightersWithInfo.size) { highlightersWithInfo.forEach(::show) } - } - - private fun hide(highlighter: RangeHighlighter) { - inlays.remove(highlighter)?.dispose() - } - - fun hideAll(highlighters: Collection<RangeHighlighter>) { - executeInInlayBatchMode(highlighters.size) { highlighters.forEach(::hide) } - } - - fun hideAll() { - if (inlays.isNotEmpty()) { - executeInInlayBatchMode(inlays.size) { inlays.values.forEach(Inlay<*>::dispose) } - inlays.clear() - } - } - - private fun getInlayHintPriority(info: HighlightInfo): Int { - val startOffset = info.actualStartOffset - val positionOnLine = startOffset - getLineStartOffset(startOffset) - return getInlayHintPriority(positionOnLine, info.severity.myVal) - } - - private fun getLineStartOffset(offset: Int): Int { - val position = editor.offsetToLogicalPosition(offset) - return editor.document.getLineStartOffset(position.line) - } - - private fun executeInInlayBatchMode(operations: Int, block: () -> Unit) { - editor.inlayModel.execute(operations > 1000, block) - } -} diff --git a/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/EditorLens.kt b/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/EditorLens.kt new file mode 100644 index 0000000..433dd35 --- /dev/null +++ b/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/EditorLens.kt @@ -0,0 +1,26 @@ +package com.chylex.intellij.inspectionlens.editor + +import com.intellij.codeInsight.daemon.impl.HighlightInfo +import com.intellij.openapi.editor.Editor + +internal class EditorLens private constructor(private var inlay: EditorLensInlay) { + fun update(info: HighlightInfo): Boolean { + val editor = inlay.editor + + if (!inlay.tryUpdate(info)) { + inlay = EditorLensInlay.show(editor, info) ?: return false + } + + return true + } + + fun hide() { + inlay.hide() + } + + companion object { + fun show(editor: Editor, info: HighlightInfo): EditorLens? { + return EditorLensInlay.show(editor, info)?.let(::EditorLens) + } + } +} diff --git a/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/EditorLensInlay.kt b/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/EditorLensInlay.kt new file mode 100644 index 0000000..3adb149 --- /dev/null +++ b/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/EditorLensInlay.kt @@ -0,0 +1,70 @@ +package com.chylex.intellij.inspectionlens.editor + +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 + +@JvmInline +internal value class EditorLensInlay(private val inlay: Inlay<LensRenderer>) { + val editor + get() = inlay.editor + + fun tryUpdate(info: HighlightInfo): Boolean { + if (!inlay.isValid) { + return false + } + + inlay.renderer.setPropertiesFrom(info) + inlay.update() + return true + } + + fun hide() { + inlay.dispose() + } + + companion object { + fun show(editor: Editor, info: HighlightInfo): EditorLensInlay? { + val offset = getInlayHintOffset(info) + val priority = getInlayHintPriority(editor, info) + + val renderer = LensRenderer(info) + val properties = InlayProperties().relatesToPrecedingText(true).disableSoftWrapping(true).priority(priority) + + return editor.inlayModel.addAfterLineEndElement(offset, properties, renderer)?.let(::EditorLensInlay) + } + + /** + * Highest allowed severity for the purposes of sorting multiple highlights at the same offset. + * The value is a little higher than the highest [com.intellij.lang.annotation.HighlightSeverity], in case severities with higher values are introduced in the future. + */ + private const val MAXIMUM_SEVERITY = 500 + private const val MAXIMUM_POSITION = ((Int.MAX_VALUE / MAXIMUM_SEVERITY) * 2) - 1 + + 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 + } + + fun getInlayHintPriority(position: Int, severity: Int): Int { + // Sorts highlights first by position on the line, then by severity. + val positionBucket = position.coerceIn(0, MAXIMUM_POSITION) * MAXIMUM_SEVERITY + // The multiplication can overflow, but subtracting overflowed result from Int.MAX_VALUE does not break continuity. + val positionFactor = Integer.MAX_VALUE - positionBucket + val severityFactor = severity.coerceIn(0, MAXIMUM_SEVERITY) - MAXIMUM_SEVERITY + return positionFactor + severityFactor + } + + private fun getInlayHintPriority(editor: Editor, info: HighlightInfo): Int { + val startOffset = info.actualStartOffset + val positionOnLine = startOffset - getLineStartOffset(editor, startOffset) + return getInlayHintPriority(positionOnLine, info.severity.myVal) + } + + private fun getLineStartOffset(editor: Editor, offset: Int): Int { + val position = editor.offsetToLogicalPosition(offset) + return editor.document.getLineStartOffset(position.line) + } + } +} diff --git a/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/EditorLensManager.kt b/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/EditorLensManager.kt new file mode 100644 index 0000000..99f48ef --- /dev/null +++ b/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/EditorLensManager.kt @@ -0,0 +1,83 @@ +package com.chylex.intellij.inspectionlens.editor + +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.markup.RangeHighlighter +import com.intellij.openapi.util.Key +import java.util.IdentityHashMap + +/** + * Manages visible inspection lenses for an [Editor]. + */ +class EditorLensManager private constructor(private val editor: Editor) { + companion object { + private val EDITOR_KEY = Key<EditorLensManager>(EditorLensManager::class.java.name) + + fun getOrCreate(editor: Editor): EditorLensManager { + return editor.getUserData(EDITOR_KEY) ?: EditorLensManager(editor).also { editor.putUserData(EDITOR_KEY, it) } + } + + fun remove(editor: Editor) { + val manager = editor.getUserData(EDITOR_KEY) + if (manager != null) { + manager.hideAll() + editor.putUserData(EDITOR_KEY, null) + } + } + } + + private val lenses = IdentityHashMap<RangeHighlighter, EditorLens>() + + private fun show(highlighterWithInfo: HighlighterWithInfo) { + val (highlighter, info) = highlighterWithInfo + + val existingLens = lenses[highlighter] + if (existingLens != null) { + if (existingLens.update(info)) { + return + } + + existingLens.hide() + } + + val newLens = EditorLens.show(editor, info) + if (newLens != null) { + lenses[highlighter] = newLens + } + else if (existingLens != null) { + lenses.remove(highlighter) + } + } + + fun show(highlightersWithInfo: Collection<HighlighterWithInfo>) { + executeInBatchMode(highlightersWithInfo.size) { + highlightersWithInfo.forEach(::show) + } + } + + private fun hide(highlighter: RangeHighlighter) { + lenses.remove(highlighter)?.hide() + } + + fun hide(highlighters: Collection<RangeHighlighter>) { + executeInBatchMode(highlighters.size) { + highlighters.forEach(::hide) + } + } + + fun hideAll() { + executeInBatchMode(lenses.size) { + lenses.values.forEach(EditorLens::hide) + lenses.clear() + } + } + + @Suppress("ConvertLambdaToReference") + private inline fun executeInBatchMode(operations: Int, crossinline action: () -> Unit) { + if (operations > 1000) { + editor.inlayModel.execute(true) { action() } + } + else { + action() + } + } +} diff --git a/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/LensMarkupModelListener.kt b/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/LensMarkupModelListener.kt index c9a91df..b368f59 100644 --- a/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/LensMarkupModelListener.kt +++ b/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/LensMarkupModelListener.kt @@ -14,13 +14,13 @@ import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.Key /** - * Listens for inspection highlights and reports them to [EditorInlayLensManager]. + * Listens for inspection highlights and reports them to [EditorLensManager]. */ internal class LensMarkupModelListener private constructor(editor: Editor) : MarkupModelListener { - private val lens = EditorInlayLensManager.getOrCreate(editor) + private val lensManager = EditorLensManager.getOrCreate(editor) - private val showOnDispatchThread = DebouncingInvokeOnDispatchThread(lens::showAll) - private val hideOnDispatchThread = DebouncingInvokeOnDispatchThread(lens::hideAll) + private val showOnDispatchThread = DebouncingInvokeOnDispatchThread(lensManager::show) + private val hideOnDispatchThread = DebouncingInvokeOnDispatchThread(lensManager::hide) override fun afterAdded(highlighter: RangeHighlighterEx) { showIfValid(highlighter) @@ -81,7 +81,7 @@ internal class LensMarkupModelListener private constructor(editor: Editor) : Mar } /** - * Attaches a new [LensMarkupModelListener] to the [Editor], and reports all existing inspection highlights to [EditorInlayLensManager]. + * Attaches a new [LensMarkupModelListener] to the [Editor], and reports all existing inspection highlights to [EditorLensManager]. */ fun register(editor: Editor, disposable: Disposable) { if (editor.getUserData(EDITOR_KEY) != null) { diff --git a/src/test/kotlin/com/chylex/intellij/inspectionlens/EditorInlayLensManagerTest.kt b/src/test/kotlin/com/chylex/intellij/inspectionlens/EditorLensTest.kt similarity index 64% rename from src/test/kotlin/com/chylex/intellij/inspectionlens/EditorInlayLensManagerTest.kt rename to src/test/kotlin/com/chylex/intellij/inspectionlens/EditorLensTest.kt index 5915aff..dbfc6e0 100644 --- a/src/test/kotlin/com/chylex/intellij/inspectionlens/EditorInlayLensManagerTest.kt +++ b/src/test/kotlin/com/chylex/intellij/inspectionlens/EditorLensTest.kt @@ -1,6 +1,6 @@ package com.chylex.intellij.inspectionlens -import com.chylex.intellij.inspectionlens.editor.EditorInlayLensManager +import com.chylex.intellij.inspectionlens.editor.EditorLensInlay import com.intellij.lang.annotation.HighlightSeverity import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Nested @@ -8,47 +8,47 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource -class EditorInlayLensManagerTest { +class EditorLensTest { @Nested inner class Priority { @ParameterizedTest(name = "positionAndSeverity = {0}") @ValueSource(ints = [0, -1, Int.MIN_VALUE]) fun minimumOffset(positionAndSeverity: Int) { - assertEquals(Int.MAX_VALUE, EditorInlayLensManager.getInlayHintPriority(positionAndSeverity, Int.MAX_VALUE)) + assertEquals(Int.MAX_VALUE, EditorLensInlay.getInlayHintPriority(positionAndSeverity, Int.MAX_VALUE)) } @ParameterizedTest(name = "position = {0}") @ValueSource(ints = [8_589_933, Int.MAX_VALUE]) fun maximumOffset(position: Int) { - assertEquals(Int.MIN_VALUE + 295, EditorInlayLensManager.getInlayHintPriority(position, Int.MIN_VALUE)) + assertEquals(Int.MIN_VALUE + 295, EditorLensInlay.getInlayHintPriority(position, Int.MIN_VALUE)) } @ParameterizedTest(name = "severity = {0}") @ValueSource(ints = [0, 1, 250, 499, 500]) fun firstPriorityBucket(severity: Int) { - assertEquals(Int.MAX_VALUE - 500 + severity, EditorInlayLensManager.getInlayHintPriority(0, severity)) + assertEquals(Int.MAX_VALUE - 500 + severity, EditorLensInlay.getInlayHintPriority(0, severity)) } @ParameterizedTest(name = "severity = {0}") @ValueSource(ints = [0, 1, 250, 499, 500]) fun secondPriorityBucket(severity: Int) { - assertEquals(Int.MAX_VALUE - 1000 + severity, EditorInlayLensManager.getInlayHintPriority(1, severity)) + assertEquals(Int.MAX_VALUE - 1000 + severity, EditorLensInlay.getInlayHintPriority(1, severity)) } @ParameterizedTest(name = "severity = {0}") @ValueSource(ints = [0, 1, 250, 499, 500]) fun middlePriorityBucket(severity: Int) { - assertEquals(-353 + severity, EditorInlayLensManager.getInlayHintPriority(4294967, severity)) + assertEquals(-353 + severity, EditorLensInlay.getInlayHintPriority(4294967, severity)) } @ParameterizedTest(name = "severity = {0}") @ValueSource(ints = [0, 1, 250, 499, 500]) fun penultimatePriorityBucket(severity: Int) { - assertEquals(Int.MIN_VALUE + 295 + 500 + severity, EditorInlayLensManager.getInlayHintPriority(8_589_932, severity)) + assertEquals(Int.MIN_VALUE + 295 + 500 + severity, EditorLensInlay.getInlayHintPriority(8_589_932, severity)) } /** - * If any of these change, re-evaluate [EditorInlayLensManager.MAXIMUM_SEVERITY] and the priority calculations. + * If any of these change, re-evaluate [EditorLensInlay.MAXIMUM_SEVERITY] and the priority calculations. */ @Nested inner class IdeaHighlightSeverityAssumptions {