mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
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:
committed by
intellij-monorepo-bot
parent
4ecedc888b
commit
85aeb9c873
+67
-86
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user