diff --git a/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/jupyterToolbar/JupyterToolbar.kt b/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/jupyterToolbar/JupyterToolbar.kt index a197bbf90642..a51048212469 100644 --- a/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/jupyterToolbar/JupyterToolbar.kt +++ b/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/jupyterToolbar/JupyterToolbar.kt @@ -1,8 +1,10 @@ package org.jetbrains.plugins.notebooks.ui.jupyterToolbar +import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.ActionGroup import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.actionSystem.impl.ActionToolbarImpl +import com.intellij.openapi.actionSystem.toolbarLayout.ToolbarLayoutStrategy import com.intellij.openapi.editor.Editor import com.intellij.ui.JBColor import com.intellij.ui.NewUiValue @@ -14,23 +16,27 @@ import java.awt.Cursor import java.awt.Graphics import java.awt.Graphics2D import javax.swing.BorderFactory +import javax.swing.JComponent import javax.swing.SwingUtilities + /** * @See com.intellij.bigdatatools.visualization.inlays.components.FadingToolbar * PY-66455 */ -class JupyterToolbar(actionGroup: ActionGroup) : ActionToolbarImpl(ActionPlaces.EDITOR_INLAY, actionGroup, true) { +class JupyterToolbar(actionGroup: ActionGroup, target: JComponent) : + ActionToolbarImpl(ActionPlaces.EDITOR_INLAY, actionGroup, true) +{ init { val borderColor = when (NewUiValue.isEnabled()) { - true ->JBColor.LIGHT_GRAY - else -> JBColor.DARK_GRAY + true -> JBColor.LIGHT_GRAY + else -> JBColor.GRAY } - border = BorderFactory.createCompoundBorder(RoundedLineBorder(borderColor, TOOLBAR_ARC_SIZE, TOOLBAR_BORDER_THICKNESS), BorderFactory.createEmptyBorder(OUTER_PADDING, OUTER_PADDING, OUTER_PADDING, OUTER_PADDING)) isOpaque = false cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + targetComponent = target setSkipWindowAdjustments(false) } @@ -51,6 +57,7 @@ class JupyterToolbar(actionGroup: ActionGroup) : ActionToolbarImpl(ActionPlaces. if (!StartupUiUtil.isDarkTheme) { background = JBColor.WHITE } + layoutStrategy = ToolbarLayoutStrategy.NOWRAP_STRATEGY } fun getRespectiveLineNumberInEditor(editor: Editor): Int? { diff --git a/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/jupyterToolbar/JupyterToolbarManager.kt b/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/jupyterToolbar/JupyterToolbarManager.kt deleted file mode 100644 index 7ab318991601..000000000000 --- a/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/jupyterToolbar/JupyterToolbarManager.kt +++ /dev/null @@ -1,158 +0,0 @@ -package org.jetbrains.plugins.notebooks.ui.jupyterToolbar - -import com.intellij.ide.ui.customization.CustomActionsSchema -import com.intellij.openapi.actionSystem.ActionGroup -import com.intellij.openapi.editor.impl.EditorImpl -import com.intellij.ui.scale.JBUIScale -import java.awt.Dimension -import java.awt.MouseInfo -import java.awt.Point -import java.awt.Rectangle -import java.awt.event.* -import javax.swing.JPanel -import javax.swing.SwingUtilities -import javax.swing.Timer - -class JupyterToolbarManager( - private val editor: EditorImpl, - private val panel: JPanel, - private val actionGroupId: String -) { // See PY-66455 - private var toolbar: JupyterToolbar? = null - private var hideToolbarTimer = Timer(TOOLBAR_HIDE_DELAY) { conditionallyHideToolBar() } - - init { - initPanelMouseListeners() - addPanelComponentListener() - addEditorKeyListener() - addEditorComponentListener() - } - - private fun initPanelMouseListeners() { - val mouseAdapter = object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { - hideToolbarTimer.stop() - showToolbar() - } - - override fun mouseExited(e: MouseEvent) { - hideToolbarTimer.restart() - } - } - - panel.addMouseListener(mouseAdapter) - } - - private fun addPanelComponentListener() { - val componentListener = object : ComponentAdapter() { - override fun componentResized(e: ComponentEvent?) { - super.componentResized(e) - toolbar?.let { - it.bounds = calculateToolbarBounds(editor, panel, it.preferredSize) - panel.revalidate() - panel.repaint() - } - } - - override fun componentMoved(e: ComponentEvent?) { - super.componentMoved(e) - toolbar?.let { - it.bounds = calculateToolbarBounds(editor, panel, it.preferredSize) - panel.revalidate() - panel.repaint() - } - } - } - panel.addComponentListener(componentListener) - } - - private fun addEditorKeyListener() { - val keyAdapter = object : KeyAdapter() { - override fun keyTyped(e: KeyEvent) = hideToolBar() - override fun keyPressed(e: KeyEvent) = hideToolBar() - } - - editor.contentComponent.addKeyListener(keyAdapter) - } - - private fun addEditorComponentListener() { - editor.contentComponent.addComponentListener(object : ComponentAdapter() { - override fun componentResized(e: ComponentEvent?) = hideToolBar() - }) - } - - /** - * Checks if the mouse is over the toolbar before hiding it. - * This is necessary because mouseExited events on the panel can trigger even when the mouse - * is over the toolbar due to its position in the Z-stack. - * If the mouse is indeed over the toolbar, hiding is deferred to prevent an unintended disappearance, - * acting as an additional safeguard for better user experience. - */ - private fun conditionallyHideToolBar() { - toolbar?.let { tb -> - val mousePos = MouseInfo.getPointerInfo().location - SwingUtilities.convertPointFromScreen(mousePos, editor.contentComponent) - - if (tb.bounds.contains(mousePos)) { - hideToolbarTimer.restart() - } else { - hideToolBar() // mouse is not over the toolbar - we may hide it - } - } - } - - fun hideToolBar() { - toolbar?.let { - editor.contentComponent.remove(it) - editor.contentComponent.revalidate() - editor.contentComponent.repaint() - toolbar = null - JupyterToolbarVisibilityManager.notifyToolbarHidden(this) - } - } - - private fun showToolbar() { - if (toolbar == null) { - val actionGroup = createActionGroup() ?: return - toolbar = JupyterToolbar(actionGroup).apply { - targetComponent = editor.contentComponent - } - } - JupyterToolbarVisibilityManager.requestToolbarDisplay(this) - editor.contentComponent.add(toolbar, 0) - positionToolbar() - } - - private fun positionToolbar() { - toolbar?.let { tb -> - val bounds = calculateToolbarBounds(editor, panel, tb.preferredSize) - tb.bounds = bounds - } - } - - private fun createActionGroup(): ActionGroup? { - return CustomActionsSchema.getInstance().getCorrectedAction(actionGroupId) as? ActionGroup - } - - companion object { - private val DEFAULT_Y_OFFSET = JBUIScale.scale(-10) - private const val TOOLBAR_HIDE_DELAY = 800 - - fun calculateToolbarBounds( - editor: EditorImpl, - panel: JPanel, - toolbar: Dimension, - extraYOffset: Int = 0 - ) : Rectangle { - val xOffset = (panel.width - toolbar.width) / 2 - val yOffset = DEFAULT_Y_OFFSET + extraYOffset - - val editorComponent = editor.contentComponent - val panelLocationInEditor = SwingUtilities.convertPoint(panel, Point(0, 0), editorComponent) - - val xCoordinate = panelLocationInEditor.x + xOffset - val yCoordinate = panelLocationInEditor.y + yOffset - return Rectangle(xCoordinate, yCoordinate, toolbar.width, toolbar.height) - } - } -} \ No newline at end of file diff --git a/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/jupyterToolbar/JupyterToolbarPanelListeners.kt b/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/jupyterToolbar/JupyterToolbarPanelListeners.kt new file mode 100644 index 000000000000..942db9da96f2 --- /dev/null +++ b/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/jupyterToolbar/JupyterToolbarPanelListeners.kt @@ -0,0 +1,53 @@ +package org.jetbrains.plugins.notebooks.ui.jupyterToolbar + +import com.intellij.openapi.editor.impl.EditorImpl +import com.intellij.openapi.project.Project +import java.awt.event.ComponentEvent +import java.awt.event.ComponentListener +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JPanel + +/** + * Adds listeners for "above cell" panels to trigger toolbar-related functions in the [JupyterToolbarService]. + */ +class JupyterToolbarPanelListeners( // PY-66455 + private val panel: JPanel, + project: Project, + private val editor: EditorImpl +) : MouseAdapter(), ComponentListener { + + private val toolbarService = JupyterToolbarService.getInstance(project) + + init { + panel.addMouseListener(this) + panel.addComponentListener(this) + } + + override fun mouseEntered(e: MouseEvent?) { + toolbarService.requestToolbarDisplay(panel, editor) + } + + override fun mouseExited(e: MouseEvent?) { + toolbarService.requestToolbarHide() + } + + override fun mouseClicked(e: MouseEvent?) { + toolbarService.hideToolbarUnconditionally() + } + + override fun componentResized(e: ComponentEvent?) { + toolbarService.adjustToolbarPosition() + } + + override fun componentMoved(e: ComponentEvent?) { + toolbarService.adjustToolbarPosition() + } + + override fun componentShown(e: ComponentEvent?) { + toolbarService.hideToolbarUnconditionally() + } + override fun componentHidden(e: ComponentEvent?) { + toolbarService.hideToolbarUnconditionally() + } +} diff --git a/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/jupyterToolbar/JupyterToolbarService.kt b/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/jupyterToolbar/JupyterToolbarService.kt new file mode 100644 index 000000000000..605f832879a7 --- /dev/null +++ b/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/jupyterToolbar/JupyterToolbarService.kt @@ -0,0 +1,148 @@ +package org.jetbrains.plugins.notebooks.ui.jupyterToolbar + +import com.intellij.ide.ui.customization.CustomActionsSchema +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.components.Service +import com.intellij.openapi.editor.impl.EditorImpl +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.Disposer +import com.intellij.ui.awt.RelativePoint +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.plugins.notebooks.ui.visualization.DefaultNotebookEditorAppearanceSizes +import org.jetbrains.plugins.notebooks.ui.visualization.NotebookAboveCellDelimiterPanelNew +import java.awt.MouseInfo +import java.awt.Point +import javax.swing.JPanel +import javax.swing.SwingUtilities +import javax.swing.Timer + + +/** + * This class encapsulates a service that manages the display toggle action of a [JupyterToolbar] among multiple cells. + * + * The following rules apply: + * - The toolbar is always positioned beneath the last cell (not managed by this service) + * - Only one additional toolbar is allowed among cells (managed by this service) + * + * When a cursor hovers over a [NotebookAboveCellDelimiterPanelNew], a toolbar will appear. + * If a toolbar is already displayed above another panel, this older instance will be hidden. + */ +@ApiStatus.Internal +@Service(Service.Level.PROJECT) +class JupyterToolbarService { // PY-66455 + private var currentPanel: JPanel? = null + private var currentToolbar: JupyterToolbar? = null + private var currentToolbarPopup: JBPopup? = null + + private val hideToolbarTimer = Timer(TOOLBAR_HIDE_DELAY) { hideToolbarConditionally() } + private val actionGroup: ActionGroup? = createActionGroup() + + fun requestToolbarDisplay(panel: JPanel, editor: EditorImpl) { + val shouldDisplayToolbar = currentPanel != panel || currentToolbarPopup == null || currentToolbarPopup?.isDisposed == true + + if (shouldDisplayToolbar) { + hideToolbarUnconditionally() + currentPanel = panel + createAndShowToolbar(panel, editor) + } + } + + fun requestToolbarHide() = hideToolbarTimer.restart() + + private fun createAndShowToolbar(panel: JPanel, editor: EditorImpl) { + actionGroup ?: return + + if (currentToolbar == null) { + currentToolbar = JupyterToolbar(actionGroup, editor.contentComponent) + } + + if (currentToolbarPopup == null) { + currentToolbarPopup = JBPopupFactory.getInstance() + .createComponentPopupBuilder(currentToolbar!!.component, panel) + .setCancelOnClickOutside(true) + .setCancelKeyEnabled(true) + .setShowBorder(false) + .setShowShadow(false) + .createPopup().also { + val point = calculatePopupLocation() ?: return + it.show(RelativePoint(panel, point)) + } + } + hideToolbarTimer.stop() + adjustToolbarPosition() + } + + /** + * Checks if the mouse is over the toolbar before hiding it. + * This is necessary because mouseExited events on the panel can trigger even when the mouse + * is over the toolbar due to its position in the Z-stack. + * If the mouse is indeed over the toolbar, hiding is deferred to prevent an unintended disappearance, + * acting as an additional safeguard for better user experience. + */ + private fun hideToolbarConditionally() { + if (currentToolbar == null || currentToolbarPopup == null || currentToolbarPopup!!.isDisposed) return + + try { + val mousePos = MouseInfo.getPointerInfo().location + val content = currentToolbarPopup?.content + if (content != null) { + SwingUtilities.convertPointFromScreen(mousePos, content) + + if (currentToolbar?.bounds?.contains(mousePos) == true) { + hideToolbarTimer.restart() + return + } else { + hideToolbarUnconditionally() + } + } + } catch (e: IllegalStateException) { hideToolbarUnconditionally() } + } + + fun hideToolbarUnconditionally() { + currentToolbar = null + currentPanel = null + currentToolbarPopup?.let { p -> + p.cancel() + if (!p.isDisposed) { Disposer.dispose(p) } + currentToolbarPopup = null + } + } + + fun adjustToolbarPosition() { + val toolbarPopup = currentToolbarPopup ?: return + val panel = currentPanel ?: return + // I had to use this method to correctly evaluate the toolbar's width + currentToolbar?.updateActionsImmediately() ?: return + + calculatePopupLocation()?.let { + val relPoint = RelativePoint(panel, it) + toolbarPopup.setLocation(relPoint.screenPoint) + } ?: run { + hideToolbarUnconditionally() + return + } + } + + private fun calculatePopupLocation(): Point? { + val panelHeight = currentPanel?.height ?: return null + val panelWidth = currentPanel?.width ?: return null + val toolbarHeight = currentToolbar?.height ?: return null + val toolbarWidth = currentToolbar?.width ?: return null + + val xOffset = (panelWidth - toolbarWidth) / 2 + val yOffset = (panelHeight - (1.5 * DELIMITER_SIZE) - (toolbarHeight / 2)).toInt() + val result = Point(xOffset, yOffset) + return result + } + + private fun createActionGroup(): ActionGroup? = CustomActionsSchema.getInstance().getCorrectedAction(ACTION_GROUP_ID) as? ActionGroup + + companion object { + private const val ACTION_GROUP_ID = "Jupyter.AboveCellPanelNew" + private const val TOOLBAR_HIDE_DELAY = 600 + private val DELIMITER_SIZE = DefaultNotebookEditorAppearanceSizes.CELL_BORDER_HEIGHT / 2 + fun getInstance(project: Project): JupyterToolbarService = project.getService(JupyterToolbarService::class.java) + } +} diff --git a/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/jupyterToolbar/JupyterToolbarVisibilityManager.kt b/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/jupyterToolbar/JupyterToolbarVisibilityManager.kt deleted file mode 100644 index a9321737fea6..000000000000 --- a/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/jupyterToolbar/JupyterToolbarVisibilityManager.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.jetbrains.plugins.notebooks.ui.jupyterToolbar - -object JupyterToolbarVisibilityManager { - // simple singleton to keep only one active intercellular toolbar - // not sure whether a more complex observer solution is actually needed - private var currentActiveManager: JupyterToolbarManager? = null - - fun requestToolbarDisplay(manager: JupyterToolbarManager) { - if (currentActiveManager != manager) { - currentActiveManager?.hideToolBar() - currentActiveManager = manager - } - } - - fun notifyToolbarHidden(manager: JupyterToolbarManager) { - if (manager == currentActiveManager) { - currentActiveManager = null - } - } -} diff --git a/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/visualization/NotebookBelowLastCellPanelNew.kt b/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/visualization/NotebookBelowLastCellPanelNew.kt index f3cf2a058263..6d802f264873 100644 --- a/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/visualization/NotebookBelowLastCellPanelNew.kt +++ b/notebooks/notebook-ui/src/org/jetbrains/plugins/notebooks/ui/visualization/NotebookBelowLastCellPanelNew.kt @@ -4,14 +4,16 @@ import com.intellij.ide.ui.customization.CustomActionsSchema import com.intellij.openapi.actionSystem.ActionGroup import com.intellij.openapi.editor.EditorKind import com.intellij.openapi.editor.impl.EditorImpl +import com.intellij.ui.scale.JBUIScale import org.jetbrains.plugins.notebooks.ui.jupyterToolbar.JupyterToolbar -import org.jetbrains.plugins.notebooks.ui.jupyterToolbar.JupyterToolbarManager import java.awt.Dimension import java.awt.GridBagLayout +import java.awt.Point import java.awt.Rectangle import java.awt.event.ComponentAdapter import java.awt.event.ComponentEvent import javax.swing.JPanel +import javax.swing.SwingUtilities class NotebookBelowLastCellPanelNew(val editor: EditorImpl) : JPanel(GridBagLayout()) { private var toolbar: JupyterToolbar? = null @@ -26,17 +28,9 @@ class NotebookBelowLastCellPanelNew(val editor: EditorImpl) : JPanel(GridBagLayo fun initialize() { // this toolbar is special - persistent and unique val actionGroup = createActionGroup() ?: return - toolbar = JupyterToolbar(actionGroup).apply { - targetComponent = editor.contentComponent - } + toolbar = JupyterToolbar(actionGroup, editor.contentComponent) add(toolbar) - - toolbar?.let { - it.bounds = calculateToolbarBounds() - editor.contentComponent.revalidate() - editor.contentComponent.repaint() - } - + adjustToolbarBounds() addComponentListeners() } @@ -44,38 +38,38 @@ class NotebookBelowLastCellPanelNew(val editor: EditorImpl) : JPanel(GridBagLayo this.addComponentListener(object : ComponentAdapter() { override fun componentResized(e: ComponentEvent?) { super.componentResized(e) - toolbar?.let { - it.bounds = calculateToolbarBounds() - revalidate() - repaint() - } + adjustToolbarBounds() } override fun componentShown(e: ComponentEvent?) { super.componentShown(e) - toolbar?.let { - it.bounds = calculateToolbarBounds() - revalidate() - repaint() - } + adjustToolbarBounds() } }) } - private fun calculateToolbarBounds(): Rectangle { - val toolbarPreferredSize = toolbar?.preferredSize ?: Dimension(0, 0) - val newBounds = JupyterToolbarManager.calculateToolbarBounds(editor, - this, - toolbarPreferredSize, - extraYOffset = 30) - return newBounds - } - private fun createActionGroup(): ActionGroup? { return CustomActionsSchema.getInstance().getCorrectedAction(ACTION_GROUP_ID) as? ActionGroup } + private fun adjustToolbarBounds() { + toolbar?.let { tb -> + val toolbarPreferredSize = tb.preferredSize + val xOffset = (this.width - toolbarPreferredSize.width) / 2 + val editorComponent = editor.contentComponent + val panelLocationInEditor = SwingUtilities.convertPoint(this, Point(0, 0), editorComponent) + + val xCoordinate = panelLocationInEditor.x + xOffset + val yCoordinate = panelLocationInEditor.y + Y_OFFSET + + tb.bounds = Rectangle(xCoordinate, yCoordinate, toolbarPreferredSize.width, toolbarPreferredSize.height) + revalidate() + repaint() + } + } + companion object { + private val Y_OFFSET = JBUIScale.scale(30) const val ACTION_GROUP_ID = "Jupyter.BelowCellNewPanel" } } \ No newline at end of file