From 85aeb9c873b5c8e83e6bc9908483b47c86d75d25 Mon Sep 17 00:00:00 2001 From: Vitaly Legchilkin Date: Wed, 5 Aug 2026 11:18:21 +0200 Subject: [PATCH] PY-91450 align '+ Add package' inlay with code baseline Use the platform InlayTextMetrics baseline instead of raw FontMetrics so the hint sits on the code baseline; render it in the smaller inlay font. Kept on the non-declarative inlay framework to preserve plain left-click. (cherry picked from commit 18a1215281264cff780d00343c72787c598bb7c8) IJ-MR-217097 GitOrigin-RevId: aa8ae963655ed27d90b813cefc279dc83e35459a --- .../PyDependencyGroupInlayHintsProvider.kt | 153 ++++++++---------- 1 file changed, 67 insertions(+), 86 deletions(-) diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/marker/PyDependencyGroupInlayHintsProvider.kt b/python/src/com/jetbrains/python/packaging/toolwindow/marker/PyDependencyGroupInlayHintsProvider.kt index a196ec58f39e..ed2140529003 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/marker/PyDependencyGroupInlayHintsProvider.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/marker/PyDependencyGroupInlayHintsProvider.kt @@ -8,13 +8,13 @@ import com.intellij.codeInsight.hints.InlayGroup import com.intellij.codeInsight.hints.InlayHintsCollector import com.intellij.codeInsight.hints.InlayHintsProvider import com.intellij.codeInsight.hints.InlayHintsSink +import com.intellij.codeInsight.hints.InlayHintsUtils import com.intellij.codeInsight.hints.NoSettings import com.intellij.codeInsight.hints.SettingsKey import com.intellij.codeInsight.hints.presentation.BasePresentation +import com.intellij.codeInsight.hints.presentation.InlayTextMetricsStorage import com.intellij.openapi.editor.DefaultLanguageHighlighterColors import com.intellij.openapi.editor.Editor -import com.intellij.openapi.editor.colors.EditorColors -import com.intellij.openapi.editor.colors.EditorFontType import com.intellij.openapi.editor.markup.TextAttributes import com.intellij.openapi.module.ModuleUtilCore import com.intellij.python.pyproject.PY_PROJECT_TOML @@ -33,7 +33,6 @@ import com.intellij.psi.PsiFile import com.intellij.psi.util.PsiTreeUtil import com.intellij.ui.JBColor import com.intellij.ui.dsl.builder.panel -import com.intellij.util.ui.JBUI import com.jetbrains.python.PyBundle import com.jetbrains.python.packaging.management.isDependencyGroupSupported import com.jetbrains.python.packaging.statistics.PyInstallDialogSource @@ -41,19 +40,22 @@ import com.jetbrains.python.packaging.statistics.PythonPackagesToolwindowStatist import com.jetbrains.python.packaging.toolwindow.ui.PyInstallPackageDialog import com.jetbrains.python.sdk.PythonSdkUtil import org.toml.lang.psi.TomlKeySegment -import java.awt.Cursor -import java.awt.Font import java.awt.Graphics2D -import java.awt.Point -import java.awt.Rectangle -import java.awt.event.MouseEvent import javax.swing.JComponent +import kotlin.math.max /** * Shows a grey, plain-text `+ Add package` inlay at the end of every dependency-group header line - * in `pyproject.toml`. Hovering paints the inlay in the link foreground colour; left-clicking - * (no modifier) opens the Install Package dialog with the owning module and the dependency group - * pre-selected. + * in `pyproject.toml`. Hovering paints the inlay in the link foreground colour; a plain left-click + * opens the Install Package dialog with the owning module and the dependency group pre-selected. + * + * This deliberately stays on the (non-declarative) [InlayHintsProvider] framework: it needs a plain + * left-click, whereas declarative-inlay action handlers only fire on Ctrl/Cmd+click + * ([com.intellij.codeInsight.hints.declarative.impl.interaction.DefaultInlayInteractionHandler]). + * Interaction (plain click, link colour on hover, hand cursor) is delegated to + * [com.intellij.codeInsight.hints.presentation.PresentationFactory.referenceOnHover]; text layout is + * delegated to the platform's [InlayTextMetricsStorage] and the same baseline formula the declarative + * renderer uses (see [AddPackagePresentation]). * * Format recognition is delegated to [PyDependencyGroupLocator] extensions. */ @@ -94,103 +96,82 @@ internal class PyDependencyGroupInlayHintsProvider : InlayHintsProvider().initForSdk(moduleSdk) - } - withContext(Dispatchers.EDT) { - PythonPackagesToolwindowStatisticsCollector.installDialogOpenedEvent.log(PyInstallDialogSource.INLAY_HINT) - PyInstallPackageDialog(project).show(preselectModuleName = preselectName, preselectGroupName = groupName) - } + val text = AddPackagePresentation(editor, metricsStorage, label) + val presentation = factory.referenceOnHover(text) { _, _ -> openInstallDialog(element, groupName) } + sink.addInlineElement(element.textRange.endOffset, relatesToPrecedingText = true, presentation = presentation, placeAtTheEndOfLine = true) + return true + } + + private fun openInstallDialog(element: PsiElement, groupName: String) { + val project = element.project + val module = ModuleUtilCore.findModuleForPsiElement(element) ?: return + val pyprojectVf = element.containingFile?.virtualFile + // Workspace members (uv) are indexed by `[project].name` from the member's pyproject.toml + // when present, otherwise by the IntelliJ module name. Prefer project.name so the dialog + // preselects the *clicked* member, not the SDK-owning root module. parseCached is + // suspend, so dispatch through the packaging coroutine scope and open the dialog back on + // EDT. + PyPackageCoroutine.launch(project) { + val preselectName = pyprojectVf + ?.let { PyProjectToml.parseCached(project, it) } + ?.project?.name + ?: module.name + // Bind the packaging service to the *clicked* module's SDK before the dialog opens. + // Without this, the dialog falls back to `findFirstPythonSdk()`, which in a multi-project + // workspace (e.g. poetry subprojects) may pick the wrong SDK — or the service may still + // be uninitialized, in which case the install click silently no-ops because + // `packagingService.currentSdk` is null (PY-91300). + val moduleSdk = readAction { PythonSdkUtil.findPythonSdk(module) } + if (moduleSdk != null) { + project.service().initForSdk(moduleSdk) + } + withContext(Dispatchers.EDT) { + PythonPackagesToolwindowStatisticsCollector.installDialogOpenedEvent.log(PyInstallDialogSource.INLAY_HINT) + PyInstallPackageDialog(project).show(preselectModuleName = preselectName, preselectGroupName = groupName) } } - val withCursor = factory.withCursorOnHover(presentation, Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)) - sink.addInlineElement(element.textRange.endOffset, relatesToPrecedingText = true, presentation = withCursor, placeAtTheEndOfLine = true) - return true } } } /** - * Custom presentation that paints `+` and `Add package` as two glyph runs separated by a tight - * pixel gap (instead of a full space character) and switches the foreground to the link colour on - * hover. Owns its own mouse handling so a single left-click without a modifier triggers - * [onClick]; bypasses `factory.text` / `factory.smallText` to avoid the default inlay background. + * Plain-text inlay whose glyphs sit on the editor's own text baseline. All layout comes from the + * platform [InlayTextMetricsStorage]; the baseline is computed with the same formula the declarative + * inlay renderer uses (`TextInlayPresentationEntry`) so the hint lines up with the surrounding code + * regardless of font size, zoom, or line spacing. + * + * Colour is attribute-driven so [com.intellij.codeInsight.hints.presentation.PresentationFactory.referenceOnHover] + * can recolour it on hover: the idle grey comes from `INLAY_TEXT_WITHOUT_BACKGROUND`, and when the + * reference wrapper injects a foreground (the link colour on hover) that colour wins. */ private class AddPackagePresentation( - editor: Editor, - label: String, - private val onClick: () -> Unit, + private val editor: Editor, + private val metricsStorage: InlayTextMetricsStorage, + private val text: String, ) : BasePresentation() { - private val font: Font = editor.colorsScheme.getFont(EditorFontType.PLAIN) private val idleColor = editor.colorsScheme .getAttributes(DefaultLanguageHighlighterColors.INLAY_TEXT_WITHOUT_BACKGROUND) ?.foregroundColor ?: JBColor.GRAY - private val hoverColor = editor.colorsScheme.getAttributes(EditorColors.REFERENCE_HYPERLINK_COLOR)?.foregroundColor - ?: JBUI.CurrentTheme.Link.Foreground.ENABLED - private val fontMetrics = editor.contentComponent.getFontMetrics(font) - private val rest: String = label.removePrefix("+").trimStart() - private val plusWidth = fontMetrics.stringWidth("+") - private val restWidth = fontMetrics.stringWidth(rest) - private val gap = JBUI.scale(3) + private fun metrics() = metricsStorage.getFontMetrics(small = true) - private var hovered = false - - override val width: Int = plusWidth + gap + restWidth - override val height: Int = fontMetrics.height + override val width: Int get() = metrics().getStringWidth(text) + override val height: Int get() = editor.lineHeight override fun paint(g: Graphics2D, attributes: TextAttributes) { - g.font = font - g.color = if (hovered) hoverColor else idleColor - g.drawString("+", 0, fontMetrics.ascent) - g.drawString(rest, plusWidth + gap, fontMetrics.ascent) + val metrics = metrics() + g.font = metrics.font + g.color = attributes.foregroundColor ?: idleColor + val baseline = max(editor.ascent, (height + metrics.ascent - metrics.descent) / 2) - 1 + g.drawString(text, 0, baseline) } - override fun mouseMoved(event: MouseEvent, translated: Point) { - if (!hovered) { - hovered = true - fireContentChanged(Rectangle(0, 0, width, height)) - } - } - - override fun mouseExited() { - if (hovered) { - hovered = false - fireContentChanged(Rectangle(0, 0, width, height)) - } - } - - override fun mouseClicked(event: MouseEvent, translated: Point) { - if (event.button == MouseEvent.BUTTON1) { - onClick() - event.consume() - } - } - - override fun toString(): String = "+ $rest" + override fun toString(): String = text }