diff --git a/platform/platform-impl/src/com/intellij/ui/GotItTooltip.kt b/platform/platform-impl/src/com/intellij/ui/GotItTooltip.kt
index 21e0f9aaf99b..a1bb48f9b978 100644
--- a/platform/platform-impl/src/com/intellij/ui/GotItTooltip.kt
+++ b/platform/platform-impl/src/com/intellij/ui/GotItTooltip.kt
@@ -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
}
diff --git a/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/resources/intellij.pycharm.community.ide.impl.promotion.xml b/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/resources/intellij.pycharm.community.ide.impl.promotion.xml
index 732601e56b42..f8585d957f5e 100644
--- a/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/resources/intellij.pycharm.community.ide.impl.promotion.xml
+++ b/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/resources/intellij.pycharm.community.ide.impl.promotion.xml
@@ -82,7 +82,10 @@
bundle="messages.PyCharmCommunityCustomizationBundle"
key="promo.configurable.jupyter"/>
-
+
+
()
+ fun getInstance(): PyCharmCommunityToUnifiedScheduleService = service()
+ }
+
+ private var remindLaterJob: Job? = null
+
+ fun scheduleFallbackModal() {
+ val promoService = service()
+ // 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)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/src/com/intellij/pycharm/community/ide/impl/promotion/communityToUnified/PyCommunityToUnifiedPromoDialog.kt b/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/src/com/intellij/pycharm/community/ide/impl/promotion/communityToUnified/PyCommunityToUnifiedPromoDialog.kt
new file mode 100644
index 000000000000..e3ce4f3881de
--- /dev/null
+++ b/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/src/com/intellij/pycharm/community/ide/impl/promotion/communityToUnified/PyCommunityToUnifiedPromoDialog.kt
@@ -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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/src/com/intellij/pycharm/community/ide/impl/promotion/communityToUnified/PyCommunityToUnifiedShowPromoActivity.kt b/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/src/com/intellij/pycharm/community/ide/impl/promotion/communityToUnified/PyCommunityToUnifiedShowPromoActivity.kt
new file mode 100644
index 000000000000..dde6999abe95
--- /dev/null
+++ b/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/src/com/intellij/pycharm/community/ide/impl/promotion/communityToUnified/PyCommunityToUnifiedShowPromoActivity.kt
@@ -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()
+ 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()
+
+ 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().onRemindMeLaterClicked()
+ PyCharmCommunityToUnifiedScheduleService.getInstance().scheduleFallbackModal()
+ }
+ else {
+ launchUpdateDialog(project)
+ }
+ }
+ }
+
+ fun launchUpdateDialog(project: Project?) {
+ val promoService = service()
+ 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()
+ 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()
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/src/com/intellij/pycharm/community/ide/impl/promotion/communityToUnified/PyCommunityToUnifiedTooltip.kt b/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/src/com/intellij/pycharm/community/ide/impl/promotion/communityToUnified/PyCommunityToUnifiedTooltip.kt
new file mode 100644
index 000000000000..1ea725da2720
--- /dev/null
+++ b/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/src/com/intellij/pycharm/community/ide/impl/promotion/communityToUnified/PyCommunityToUnifiedTooltip.kt
@@ -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 = "$this"
+
+ 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()
+ }
+}
\ No newline at end of file
diff --git a/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/src/com/intellij/pycharm/community/ide/impl/promotion/communityToUnified/PyCommunityToUnifiedWelcomeScreenBanner.kt b/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/src/com/intellij/pycharm/community/ide/impl/promotion/communityToUnified/PyCommunityToUnifiedWelcomeScreenBanner.kt
new file mode 100644
index 000000000000..d849cc212449
--- /dev/null
+++ b/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/src/com/intellij/pycharm/community/ide/impl/promotion/communityToUnified/PyCommunityToUnifiedWelcomeScreenBanner.kt
@@ -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()
+ 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()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/src/com/intellij/pycharm/community/ide/impl/promotion/communityToUnified/PyPromoSharedComponents.kt b/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/src/com/intellij/pycharm/community/ide/impl/promotion/communityToUnified/PyPromoSharedComponents.kt
new file mode 100644
index 000000000000..156c2b63f08a
--- /dev/null
+++ b/python/ide/impl/intellij.pycharm.community.ide.impl.promotion/src/com/intellij/pycharm/community/ide/impl/promotion/communityToUnified/PyPromoSharedComponents.kt
@@ -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")
+}
\ No newline at end of file