Last active
May 26, 2025 15:38
-
-
Save dkandalov/54839566c1de9e6012c93bcb87309306 to your computer and use it in GitHub Desktop.
Mini plugin for IntelliJ to show keyboard shortcuts similar to Presentation Assistant (to be used via LivePlugin)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import com.intellij.ide.ui.UISettings | |
import com.intellij.openapi.Disposable | |
import com.intellij.openapi.actionSystem.ActionManager | |
import com.intellij.openapi.actionSystem.AnAction | |
import com.intellij.openapi.actionSystem.AnActionEvent | |
import com.intellij.openapi.actionSystem.IdeActions | |
import com.intellij.openapi.actionSystem.ex.AnActionListener | |
import com.intellij.openapi.application.ApplicationManager | |
import com.intellij.openapi.project.Project | |
import com.intellij.openapi.ui.popup.Balloon | |
import com.intellij.openapi.ui.popup.JBPopupFactory | |
import com.intellij.platform.ide.impl.presentationAssistant.getWinKeyText | |
import com.intellij.platform.ide.impl.presentationAssistant.getWinModifiersText | |
import com.intellij.ui.BalloonImpl | |
import com.intellij.ui.JBColor | |
import com.intellij.ui.awt.RelativePoint | |
import liveplugin.currentEditor | |
import liveplugin.show | |
import java.awt.Point | |
import java.awt.event.KeyEvent | |
import javax.swing.KeyStroke | |
// See https://gist.github.com/dkandalov/54839566c1de9e6012c93bcb87309306 | |
// Not using built-in Presentation Assistant because: | |
// - it shows shortcuts from the keymap, not the actual shortcuts | |
// - it shows shortcuts for actions which were not invoked via keyboard | |
// - there is no way exclude shortcuts, e.g. up/down in project tree view | |
// - there is no way include shortcuts, e.g. text editor navigation | |
// - displayed text cannot be customised, e.g. remove "Move Caret " prefix | |
ShortcutsPresenter(NotificationBalloon(pluginDisposable), pluginDisposable).init() | |
class ShortcutsPresenter( | |
private val notificationBalloon: NotificationBalloon, | |
private val parentDisposable: Disposable, | |
private val showActionId: Boolean = false | |
) { | |
private val actionManager = ActionManager.getInstance()!! | |
fun init() = apply { | |
ApplicationManager.getApplication().messageBus.connect(parentDisposable) | |
.subscribe(AnActionListener.TOPIC, object : AnActionListener { | |
override fun beforeActionPerformed(action: AnAction, event: AnActionEvent) { | |
val project = event.project ?: return | |
val lastKeyStroke = (event.inputEvent as? KeyEvent)?.let(KeyStroke::getKeyStrokeForEvent) ?: return | |
val actionId = actionManager.getId(action) | |
val actionDescription = event.presentation.text | |
?.replace("Move Caret to ", "") | |
?.replace("Move Caret ", "") | |
?.replace(" in Text Component", "") | |
if (actionId in excludedActions || actionDescription == null) return | |
if (showActionId) show(actionId) | |
notificationBalloon.showShortcut(lastKeyStroke.toPresentableString(), actionDescription, project) | |
} | |
}) | |
} | |
// Not using: MacKeymapUtil.getKeyStrokeText(this, "+", false) | |
// because Windows key rendering (e.g. "Meta+F12") is easier to read. | |
private fun KeyStroke.toPresentableString() = | |
listOfNotNull(if (modifiers > 0) getWinModifiersText(modifiers) else null, getWinKeyText(keyCode)) | |
.filter { it.isNotEmpty() } | |
.joinToString("+") | |
.trim() | |
.replace("Meta", "Cmd") | |
private val movingActions = setOf( | |
// "EditorLeft", "EditorRight", "EditorDown", "EditorUp", | |
// "EditorLineStart", "EditorLineEnd", "EditorPageUp", "EditorPageDown", | |
// "EditorPreviousWord", "EditorNextWord", | |
// "EditorScrollUp", "EditorScrollDown", | |
// "EditorTextStart", "EditorTextEnd", | |
// "EditorDownWithSelection", "EditorUpWithSelection", | |
// "EditorRightWithSelection", "EditorLeftWithSelection", | |
// "EditorLineStartWithSelection", "EditorLineEndWithSelection", | |
// "EditorPageDownWithSelection", "EditorPageUpWithSelection", | |
// "EditorNextWordWithSelection", "EditorPreviousWordWithSelection", | |
// "ijkl.MoveCaretLeftWithSelectionAction", | |
"Tree-selectNext", "Tree-selectPrevious", "Tree-selectParent", "Tree-selectChild", | |
"List-selectNextColumn", "List-selectPreviousColumn", | |
"NavBar-selectLeft", "NavBar-selectRight", "NavBar-selectDown", "NavBar-selectUp", | |
) | |
private val editorActions = setOf( | |
IdeActions.ACTION_EDITOR_BACKSPACE, | |
IdeActions.ACTION_EDITOR_ENTER, | |
IdeActions.ACTION_EDITOR_NEXT_TEMPLATE_VARIABLE, | |
"\$Undo", "\$Redo", | |
// "EditorCut", "EditorCopy", "EditorPaste", | |
// "EditorDelete", | |
// "EditorEscape", | |
"EditorChooseLookupItem" | |
) | |
private val otherActions = setOf( | |
"CloseFirstNotification", "CloseAllNotifications", | |
"Remove Top Editor Component", | |
"Toggle refactorings counter", | |
"Increment refactorings counter", | |
"Decrement refactorings counter", | |
"NextWindow", | |
// "ActivateProjectToolWindow", | |
// "ActivateVersionControlToolWindow", | |
// "ActivateRunToolWindow", | |
) | |
private val excludedActions = movingActions + editorActions + otherActions | |
} | |
class NotificationBalloon(private val parentDisposable: Disposable) { | |
private var balloon1: Balloon? = null | |
private var balloon2: Balloon? = null | |
private var balloonText: String? = null | |
fun showShortcut(keyStroke: String, actionDescription: String, project: Project) { | |
val text = "$keyStroke - $actionDescription" | |
if (balloon1 == null || balloon1!!.wasFadedOut()) { | |
balloon1 = createBalloon(actionDescription).showIn(project) | |
balloon2 = createBalloon(keyStroke).showIn(project, balloon1) | |
balloonText = text | |
} else if (balloonText == text) { | |
return | |
} else { | |
balloon1!!.hide() | |
balloon2!!.hide() | |
balloon1 = createBalloon(actionDescription).showIn(project) | |
balloon2 = createBalloon(keyStroke).showIn(project, balloon1) | |
balloonText = text | |
} | |
} | |
private val blue4 = JBColor.namedColor("ColorPalette.Blue4") | |
private val backgroundColor = JBColor.background() | |
private val borderColor = JBColor.gray | |
private val fontSize = 24 | |
private fun createBalloon(text: String) = | |
JBPopupFactory.getInstance() | |
.createHtmlTextBalloonBuilder( | |
/* htmlContent = */ "<span style='font-size: ${fontSize}pt; font-weight:bold'>$text</span>", | |
/* icon = */ null, | |
/* fillColor = */ backgroundColor, | |
/* listener = */ null | |
) | |
.setBorderColor(borderColor) | |
.setFadeoutTime(3000) | |
.setAnimationCycle(50) | |
.setCloseButtonEnabled(false) | |
.setHideOnClickOutside(false) | |
.setDisposable(parentDisposable) | |
.setHideOnFrameResize(false) | |
.setHideOnKeyOutside(false) | |
.setBlockClicksThroughBalloon(true) | |
.setHideOnAction(false) | |
.setShowCallout(false) | |
.setCornerRadius(15) | |
.createBalloon() | |
private fun Balloon.showIn(project: Project, relativeToBalloon: Balloon? = null) = apply { | |
if (relativeToBalloon == null) { | |
val component = project.currentEditor?.component ?: return@apply | |
val point = Point(component.width - preferredSize.width / 2, 100) | |
show(RelativePoint(component, point), Balloon.Position.atLeft) | |
} else { | |
val component = (relativeToBalloon as BalloonImpl).component ?: return@apply | |
val gap = if (UISettings.getInstance().presentationMode) 60 else 40 | |
val point = Point(-preferredSize.width / 2 + gap, component.height / 2) | |
show(RelativePoint(component, point), Balloon.Position.atLeft) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment