PY-82344 Implement main promo components and project activities

GitOrigin-RevId: 5fec5e7f2b88743a97d10b7f0d27f9173c8424fd
This commit is contained in:
Daniil Kalinin
2025-10-13 15:11:47 +00:00
committed by intellij-monorepo-bot
parent 2c3577596c
commit 0ffc947c7f
8 changed files with 483 additions and 2 deletions
@@ -123,7 +123,7 @@ class GotItTooltip @ApiStatus.Internal constructor(@NonNls val id: String,
/**
* Add an optional image above the header or description
*/
private fun withImage(image: Icon, withBorder: Boolean = true): GotItTooltip {
fun withImage(image: Icon, withBorder: Boolean = true): GotItTooltip {
gotItBuilder.withImage(image, withBorder)
return this
}
@@ -82,7 +82,10 @@
bundle="messages.PyCharmCommunityCustomizationBundle"
key="promo.configurable.jupyter"/>
<startPagePromoter
implementation="com.intellij.pycharm.community.ide.impl.promotion.communityToUnified.PyCommunityToUnifiedWelcomeScreenBanner"/>
<postStartupActivity
implementation="com.intellij.pycharm.community.ide.impl.promotion.communityToUnified.PyCommunityToUnifiedShowPromoActivity"/>
<!--TODO delete later-->
<registryKey key="py.promo.use.test.update"
defaultValue="false"
@@ -0,0 +1,45 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.pycharm.community.ide.impl.promotion.communityToUnified
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.project.ProjectManager
import kotlinx.coroutines.*
import org.jetbrains.annotations.ApiStatus
import kotlin.coroutines.cancellation.CancellationException
@Service(Service.Level.APP)
@ApiStatus.Internal
class PyCharmCommunityToUnifiedScheduleService(val serviceScope: CoroutineScope) {
companion object {
val LOG: Logger = logger<PyCharmCommunityToUnifiedScheduleService>()
fun getInstance(): PyCharmCommunityToUnifiedScheduleService = service<PyCharmCommunityToUnifiedScheduleService>()
}
private var remindLaterJob: Job? = null
fun scheduleFallbackModal() {
val promoService = service<PyCommunityToUnifiedPromoService>()
// cancel previous scheduled task if any
remindLaterJob?.cancel()
val delayMs = promoService.getPromoIntervalMillis()
remindLaterJob = serviceScope.launch(Dispatchers.Default) {
try {
delay(delayMs)
// Only proceed if conditions still require showing modal
if (!promoService.shouldShowModal()) return@launch
val project = ProjectManager.getInstance().openProjects.firstOrNull() ?: run {
LOG.info("Skipping promo modal fallback: no open projects")
return@launch
}
PyCommunityToUnifiedShowPromoActivity.showModalPromo(project)
}
catch (t: Throwable) {
if (t is CancellationException) throw t
LOG.warn("Error in promo modal fallback task", t)
}
}
}
}
@@ -0,0 +1,110 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.pycharm.community.ide.impl.promotion.communityToUnified
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.DialogWrapper
import com.intellij.ui.PopupBorder
import com.intellij.ui.WindowRoundedCornersManager
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.panels.BackgroundRoundedPanel
import com.intellij.ui.dsl.builder.EmptySpacingConfiguration
import com.intellij.ui.dsl.builder.panel
import com.intellij.ui.dsl.gridLayout.UnscaledGaps
import com.intellij.ui.dsl.gridLayout.UnscaledGapsY
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Dimension
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.JRootPane
import javax.swing.border.Border
internal class PyCommunityToUnifiedPromoDialog(project: Project?) :
DialogWrapper(project, null, false,
IdeModalityType.IDE, true, false) {
private val imageIcon = PyPromoSharedComponents.popUpImg
override fun createContentPaneBorder(): Border {
return JBUI.Borders.empty()
}
override fun getStyle(): DialogStyle {
return super.getStyle()
}
init {
setUndecorated(true)
rootPane.windowDecorationStyle = JRootPane.NONE
rootPane.border = PopupBorder.Factory.create(true, true)
WindowRoundedCornersManager.configure(this)
init()
}
override fun createSouthPanel(): JComponent? {
return panel {
row {
button(PyPromoSharedComponents.updateNow) {
close(OK_EXIT_CODE)
}.focused()
.applyToComponent {
putClientProperty("gotItButton", true)
putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true)
this@PyCommunityToUnifiedPromoDialog.rootPane.defaultButton = this
}.customize(customGaps = UnscaledGaps(right = 10))
link(PyPromoSharedComponents.skip) {
close(CANCEL_EXIT_CODE)
}
}
}.also {
setSize(imageIcon.iconWidth, it.preferredSize.height)
it.border = JBUI.Borders.empty(0, 28, 28, 28)
}
}
override fun createCenterPanel(): JComponent {
val mainPanel = BackgroundRoundedPanel(16, BorderLayout())
val imagePanel = createImagePanel()
val infoPanel = createInfoPanel()
mainPanel.add(imagePanel, BorderLayout.NORTH)
mainPanel.add(infoPanel, BorderLayout.SOUTH)
mainPanel.preferredSize = Dimension(imageIcon.iconWidth, 200)
pack()
return mainPanel
}
private fun createImagePanel(): JPanel {
val imagePanel = JPanel(BorderLayout())
val imageLabel = JBLabel(imageIcon)
imagePanel.add(imageLabel, BorderLayout.CENTER)
return imagePanel
}
private fun createInfoPanel(): JPanel {
return panel {
customizeSpacingConfiguration(EmptySpacingConfiguration()) {
row {
label(PyPromoSharedComponents.headerTitle)
.applyToComponent {
font = JBFont.h2().asBold()
}
}
row {
text(PyPromoSharedComponents.mainText, maxLineLength = 40)
.applyToComponent {
foreground = PyPromoSharedComponents.infoFontColor
}
}.customize(customRowGaps = UnscaledGapsY(top = 4))
row {
browserLink(PyPromoSharedComponents.learnMore, PyPromoSharedComponents.LEARN_MORE_URL)
}.customize(customRowGaps = UnscaledGapsY(top = 4, bottom = 12))
}
}.also {
setSize(imageIcon.iconWidth, it.preferredSize.height)
it.border = JBUI.Borders.empty(28, 28, 0, 28)
}
}
}
@@ -0,0 +1,95 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.pycharm.community.ide.impl.promotion.communityToUnified
import com.intellij.openapi.application.EDT
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
import com.intellij.openapi.updateSettings.impl.PlatformUpdateDialog
import com.intellij.openapi.updateSettings.impl.PlatformUpdates
import com.intellij.openapi.updateSettings.impl.UpdateSettings
import com.intellij.util.PlatformUtils
import com.intellij.util.application
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class PyCommunityToUnifiedShowPromoActivity : ProjectActivity {
override suspend fun execute(project: Project) {
showPromoIfNeeded(project)
}
suspend fun showPromoIfNeeded(project: Project) {
if (project.isDisposed || application.isHeadlessEnvironment || !PlatformUtils.isPyCharmCommunity()) return
val promoService = service<PyCommunityToUnifiedPromoService>()
val update = promoService.getAvailableUpdate()
if (update == null) return
if (promoService.shouldShowTooltip()) {
showTooltip(project)
}
else if (promoService.shouldShowModal()) {
showModalPromo(project)
}
}
companion object {
val LOG: Logger = logger<PyCommunityToUnifiedShowPromoActivity>()
suspend fun showTooltip(project: Project) {
withContext(Dispatchers.EDT) {
PyCommunityToUnifiedTooltip.showTooltip(project)
}
}
suspend fun showModalPromo(project: Project) {
withContext(Dispatchers.EDT) {
val result = PyCommunityToUnifiedPromoDialog(project).showAndGet()
if (!result) {
service<PyCommunityToUnifiedPromoService>().onRemindMeLaterClicked()
PyCharmCommunityToUnifiedScheduleService.getInstance().scheduleFallbackModal()
}
else {
launchUpdateDialog(project)
}
}
}
fun launchUpdateDialog(project: Project?) {
val promoService = service<PyCommunityToUnifiedPromoService>()
promoService.serviceScope.launch {
val update = promoService.getAvailableUpdate()
if (update != null) {
showModalUpdateDialog(project, update)
}
else {
LOG.warn("Update dialog was launched but no update was found")
}
}
}
private suspend fun showModalUpdateDialog(project: Project?, update: PlatformUpdates.Loaded) {
withContext(Dispatchers.EDT) {
val result = PlatformUpdateDialog(project, update, true, emptyList(), emptyList()).showAndGet()
val promoService = service<PyCommunityToUnifiedPromoService>()
if (!result) {
val buildNum = update.newBuild.number.asStringWithoutProductCode()
// ignore this update was clicked
if (UpdateSettings.getInstance().ignoredBuildNumbers.contains(buildNum)) {
LOG.info("Update declined for: $buildNum")
promoService.setUpdateDeclined()
}
else {
LOG.info("Remind me later clicked for: $buildNum")
promoService.onRemindMeLaterClicked()
PyCharmCommunityToUnifiedScheduleService.getInstance().scheduleFallbackModal()
}
}
}
}
}
}
@@ -0,0 +1,61 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.pycharm.community.ide.impl.promotion.communityToUnified
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.impl.ActionButton
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.WindowManager
import com.intellij.ui.GotItTooltip
import com.intellij.util.IconUtil
import com.intellij.util.ui.UIUtil
import org.jetbrains.annotations.Nls
object PyCommunityToUnifiedTooltip {
private const val TOOLTIP_ID: String = "pycharm.community.to.unified.tooltip.id"
fun showTooltip(project: Project) {
UIUtil.invokeLaterIfNeeded {
val frame = WindowManager.getInstance().getIdeFrame(project) ?: return@invokeLaterIfNeeded
val root = frame.component
val actionId = "SettingsEntryPoint"
val targetAction: AnAction? = ActionManager.getInstance().getAction(actionId)
if (targetAction == null) return@invokeLaterIfNeeded
val settingsButton: ActionButton? = UIUtil.uiTraverser(root)
.filter(ActionButton::class.java)
.firstOrNull { it.action == targetAction }
if (settingsButton != null) {
val tooltip = constructToolTip(project)
// TODO won't show properly in the corner :(
tooltip.show(settingsButton, GotItTooltip.BOTTOM_LEFT)
}
}
}
private fun constructToolTip(project: Project): GotItTooltip {
val image = IconUtil.scale(PyPromoSharedComponents.popUpImg, null, 0.7f)
fun @Nls String.h3(): String = "<html><b><span style='font-size:1.17em'>$this</span></b></html>"
return GotItTooltip(TOOLTIP_ID, PyPromoSharedComponents.mainText)
.withImage(image)
.withHeader(PyPromoSharedComponents.headerTitle.h3())
.withButtonLabel(PyPromoSharedComponents.updateNow)
.withLink(PyPromoSharedComponents.learnMore) {
PyPromoSharedComponents.learnMoreBrowserAction.invoke()
}
.withSecondaryButton(PyPromoSharedComponents.skip) {
PyCommunityToUnifiedPromoService.getInstance().onRemindMeLaterClicked()
PyCharmCommunityToUnifiedScheduleService.getInstance().scheduleFallbackModal()
}
.withGotItButtonAction {
PyCommunityToUnifiedShowPromoActivity.launchUpdateDialog(project)
}
.withShowCount(Int.MAX_VALUE)
.withFocus()
}
}
@@ -0,0 +1,140 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.pycharm.community.ide.impl.promotion.communityToUnified
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
import com.intellij.openapi.application.EDT
import com.intellij.openapi.components.service
import com.intellij.openapi.wm.BannerStartPagePromoter
import com.intellij.openapi.wm.StartPagePromoter
import com.intellij.pycharm.community.ide.impl.promotion.icons.PycharmCommunityIdeImplPromotionIcons
import com.intellij.ui.components.panels.BackgroundRoundedPanel
import com.intellij.ui.components.panels.Wrapper
import com.intellij.ui.dsl.builder.EmptySpacingConfiguration
import com.intellij.ui.dsl.builder.TopGap
import com.intellij.ui.dsl.builder.panel
import com.intellij.ui.dsl.gridLayout.UnscaledGaps
import com.intellij.ui.dsl.gridLayout.UnscaledGapsY
import com.intellij.util.IconUtil
import com.intellij.util.PlatformUtils
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.Nls
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.geom.RoundRectangle2D
import javax.swing.BoxLayout
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JPanel
import kotlin.math.min
@ApiStatus.Internal
internal class PyCommunityToUnifiedWelcomeScreenBanner : BannerStartPagePromoter() {
override val headerLabel: @Nls String = ""
override val actionLabel: @Nls String = ""
override val description: @Nls String = ""
override val promoImage: Icon = PycharmCommunityIdeImplPromotionIcons.Backgrounds.Promotion_bg
private val mainBannerPanel = createMainPanel()
val container: Wrapper = Wrapper(mainBannerPanel).apply { isVisible = false }
override fun getPromotion(isEmptyState: Boolean): JComponent {
val service = service<PyCommunityToUnifiedPromoService>()
service.serviceScope.launch {
val shouldShowBanner = service.shouldShowWelcomeScreenBanner()
if (shouldShowBanner) {
withContext(Dispatchers.EDT) {
container.setContent(mainBannerPanel)
container.isVisible = true
container.revalidate()
container.repaint()
}
}
}
return container
}
override fun runAction() {}
override fun canCreatePromo(isEmptyState: Boolean): Boolean {
return PlatformUtils.isPyCharmCommunity()
}
override fun getPriorityLevel(): Int = StartPagePromoter.PRIORITY_LEVEL_HIGH
private fun createMainPanel(): JPanel {
val panel = panel {
customizeSpacingConfiguration(EmptySpacingConfiguration()) {
row {
label(PyPromoSharedComponents.headerTitle)
.applyToComponent {
font = JBFont.h3().asBold()
}
}.customize(customRowGaps = UnscaledGapsY(top = 12))
row {
text(PyPromoSharedComponents.mainText)
.applyToComponent {
foreground = PyPromoSharedComponents.infoFontColor
}
}.customize(customRowGaps = UnscaledGapsY(top = 4, bottom = 8))
}
row {
button(PyPromoSharedComponents.updateNow) {
PyCommunityToUnifiedShowPromoActivity.launchUpdateDialog(null)
}.applyToComponent { putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) }
.focused()
.customize(customGaps = UnscaledGaps(right = 10))
browserLink(PyPromoSharedComponents.learnMore, PyPromoSharedComponents.LEARN_MORE_URL)
}.topGap(TopGap.NONE)
}.apply {
isOpaque = false
border = JBUI.Borders.empty(0, 12, 16, 0)
}
return wrapIntoSvgBackgroundPanel(panel, JBUI.scale(16), promoImage)
}
private fun wrapIntoSvgBackgroundPanel(panel: JPanel, arc: Int, bgImage: Icon): JPanel {
val svgPanel = SvgBackgroundRightAnchoredPanel(arc, bgImage).apply {
layout = BoxLayout(this, BoxLayout.X_AXIS)
}
svgPanel.add(panel)
return svgPanel
}
private class SvgBackgroundRightAnchoredPanel(
private val arc: Int,
private val bgImage: Icon,
) : BackgroundRoundedPanel(arc) {
override fun paintComponent(g: Graphics) {
super.paintComponent(g)
if (width <= 0 || height <= 0) return
val iw = bgImage.iconWidth
val ih = bgImage.iconHeight
if (iw <= 0 || ih <= 0) return
val g2 = g.create() as Graphics2D
try {
g2.clip(RoundRectangle2D.Float(0f, 0f, width.toFloat(), height.toFloat(), arc.toFloat(), arc.toFloat()))
val sx = width.toFloat() / iw
val sy = height.toFloat() / ih
val scale = min(sx, sy)
val scaled = IconUtil.scale(bgImage, this, scale)
// Right-anchored (no padding)
val x = width - scaled.iconWidth
val y = 0
scaled.paintIcon(this, g2, x, y)
}
finally {
g2.dispose()
}
}
}
}
@@ -0,0 +1,27 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.pycharm.community.ide.impl.promotion.communityToUnified
import com.intellij.ide.BrowserUtil
import com.intellij.pycharm.community.ide.impl.PyCharmCommunityCustomizationBundle
import com.intellij.pycharm.community.ide.impl.promotion.icons.PycharmCommunityIdeImplPromotionIcons
import com.intellij.ui.JBColor
import java.awt.Color
internal object PyPromoSharedComponents {
val popUpImg = PycharmCommunityIdeImplPromotionIcons.Images.Popup_img
val infoFontColor = JBColor(Color.decode("#6C707E"), Color.decode("#A8ADBD"))
// URLs used by promo-related messages
const val LEARN_MORE_URL: String = "https://blog.jetbrains.com/pycharm/2025/04/unified-pycharm/"
// Actions
val learnMoreBrowserAction = { BrowserUtil.browse(LEARN_MORE_URL) }
// Localized strings from PyCharmCommunityCustomizationBundle
val mainText = PyCharmCommunityCustomizationBundle.message("promotion.update.to.unified.info.text")
val learnMore = PyCharmCommunityCustomizationBundle.message("promotion.update.to.unified.learn.more.hyper.link")
val updateNow = PyCharmCommunityCustomizationBundle.message("promotion.update.to.unified.update.now.button.label")
val skip = PyCharmCommunityCustomizationBundle.message("promotion.update.to.unified.skip.button.label")
val headerTitle = PyCharmCommunityCustomizationBundle.message("promotion.update.to.unified.banner.header.title")
}