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
This commit is contained in:
Vitaly Legchilkin
2026-08-05 14:24:09 +00:00
committed by intellij-monorepo-bot
parent 4ecedc888b
commit 85aeb9c873
@@ -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<NoSettin
}
private class Collector(editor: Editor) : FactoryInlayHintsCollector(editor) {
private val metricsStorage = InlayHintsUtils.getTextMetricStorage(editor)
override fun collect(element: PsiElement, editor: Editor, sink: InlayHintsSink): Boolean {
if (element !is TomlKeySegment) return true
val groupName = resolveDependencyGroupName(element) ?: return true
val label = PyBundle.message("INLAY.py.packaging.group.add")
val presentation = AddPackagePresentation(editor, label) {
val project = element.project
val module = ModuleUtilCore.findModuleForPsiElement(element) ?: return@AddPackagePresentation
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<PyPackagingToolWindowService>().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<PyPackagingToolWindowService>().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
}