[inline-completion] IJPL-161906: support 'inline shortcut hint' element

(cherry picked from commit 75e23acede488ac6cc44680699a30c04745da326)

IJ-MR-147762

GitOrigin-RevId: d2214712c2fbe180498c4bba13d175dab1dd371d
This commit is contained in:
Kirill.Karnaukhov
2024-10-28 14:25:23 +00:00
committed by intellij-monorepo-bot
parent 3dd22640ca
commit 2ae85bffdc
11 changed files with 623 additions and 0 deletions
@@ -14,5 +14,7 @@
<orderEntry type="module" module-name="intellij.platform.ide.core.impl" />
<orderEntry type="module" module-name="intellij.platform.editor" />
<orderEntry type="module" module-name="intellij.platform.ide.impl" />
<orderEntry type="module" module-name="intellij.platform.lang" />
<orderEntry type="module" module-name="intellij.platform.core.ui" />
</component>
</module>
@@ -4,4 +4,12 @@
description="Use Rhizome DB for inline completion implementation in RemDev"
defaultValue="false"/>
</extensions>
<extensionPoints>
<extensionPoint
qualifiedName="com.intellij.inline.completion.shortcutHintListener"
interface="com.intellij.codeInsight.inline.completion.shortcut.InlineCompletionShortcutHintListener"
dynamic="true"
/>
</extensionPoints>
</idea-plugin>
@@ -0,0 +1,4 @@
inline.completion.shortcut.hint.insert.text=to complete
inline.completion.shortcut.hint.insert.word.text=to complete word
inline.completion.shortcut.hint.insert.line.text=to complete line
inline.completion.shortcut.choose.shortcut.hint.text=Choose key for completion:
@@ -0,0 +1,17 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.codeInsight.inline.completion
import com.intellij.DynamicBundle
import org.jetbrains.annotations.Nls
import org.jetbrains.annotations.NotNull
import org.jetbrains.annotations.PropertyKey
internal object MessageBundle {
private const val BUNDLE = "messages.InlineCompletionBundle"
private val INSTANCE = DynamicBundle(MessageBundle::class.java, BUNDLE)
@JvmStatic
fun message(@NotNull @PropertyKey(resourceBundle = BUNDLE) key: String, @NotNull vararg params: Any): @Nls String {
return INSTANCE.getMessage(key, *params)
}
}
@@ -0,0 +1,272 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.codeInsight.inline.completion.shortcut
import com.intellij.codeInsight.hints.presentation.InputHandler
import com.intellij.codeInsight.inline.completion.MessageBundle
import com.intellij.codeInsight.inline.completion.elements.InlineCompletionElement
import com.intellij.codeInsight.inline.completion.tooltip.ChangeToCustomInlineCompletionAction
import com.intellij.codeInsight.inline.completion.tooltip.InplaceChangeInlineCompletionShortcutAction
import com.intellij.codeInsight.inline.hint.InlineShortcutHintRendererBase
import com.intellij.ide.IdeBundle
import com.intellij.openapi.actionSystem.*
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.Inlay
import com.intellij.openapi.editor.impl.EditorImpl
import com.intellij.openapi.editor.markup.TextAttributes
import com.intellij.openapi.observable.util.whenDisposed
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.NlsActions
import com.intellij.ui.awt.RelativePoint
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import org.jetbrains.annotations.ApiStatus
import java.awt.Cursor
import java.awt.Graphics
import java.awt.Point
import java.awt.Rectangle
import java.awt.event.MouseEvent
@ApiStatus.Internal
class InlineCompletionChooseShortcutElement(val lineNumber: Int) : InlineCompletionElement {
override val text: String = ""
init {
InlineCompletionShortcutHintListener.choiceShown()
}
override fun toPresentable(): InlineCompletionElement.Presentable {
return Presentable(this)
}
class Presentable(override val element: InlineCompletionChooseShortcutElement) : InlineCompletionElement.Presentable {
private var shortcutInlay: Inlay<ShortcutRenderer>? = null
private var textInlay: Inlay<TextRenderer>? = null
private var offset: Int? = null
override fun isVisible(): Boolean {
return getBounds() != null
}
override fun getBounds(): Rectangle? {
if (!choiceShouldRender()) {
return null
}
val shortcutBounds = shortcutInlay?.bounds ?: return null
val suffixBounds = textInlay?.bounds ?: return null
return shortcutBounds.union(suffixBounds)
}
override fun startOffset(): Int? = offset
override fun endOffset(): Int? = offset
override fun dispose() {
shortcutInlay?.let(Disposer::dispose)
textInlay?.let(Disposer::dispose)
shortcutInlay = null
textInlay = null
offset = null
}
override fun render(editor: Editor, offset: Int) {
InlineCompletionShortcutChangeListener.whenInsertShortcutChanged(disposable = this) {
shortcutInlay?.update()
textInlay?.update()
}
if (editor !is EditorImpl || !InlineShortcutHintRendererBase.isAvailableForLine(editor, element.lineNumber)) {
return
}
try {
val caretOffset = editor.caretModel.offset
val hintState = HintState(false)
val textBefore = MessageBundle.message("inline.completion.shortcut.choose.shortcut.hint.text")
val textRenderer = TextRenderer(textBefore, hintState, element.lineNumber)
val shortcutRenderer = ShortcutRenderer("Tab " + '\u25BE', hintState, element.lineNumber)
textInlay = editor.inlayModel.addAfterLineEndElement(caretOffset, true, textRenderer).apply {
renderer.initialize(this)
}
shortcutInlay = editor.inlayModel.addAfterLineEndElement(caretOffset, true, shortcutRenderer).apply {
renderer.initialize(this)
}
this.offset = offset
}
catch (e: Exception) {
LOG.error("Could not render ML completion in-editor choice hint.", e)
}
}
companion object {
private val LOG = logger<InlineCompletionChooseShortcutElement>()
}
}
}
private class HintState(var isHovered: Boolean) {
private val updates = mutableListOf<() -> Unit>()
fun whenUpdated(block: () -> Unit) {
updates += block
}
fun update() {
updates.forEach { it() }
}
}
private abstract class RendererBase(
text: String?,
val hintState: HintState,
private val lineNumber: Int
) : InlineCompletionShortcutHintRendererBase(text), InputHandler {
private lateinit var myInlay: Inlay<*>
override fun getTextAttributes(editor: Editor): TextAttributes? {
return super.getTextAttributes(editor)?.updateIfHovering()
}
override fun calcWidthInPixels(inlay: Inlay<*>): Int {
return if (!choiceShouldRender()) 1 else super.calcWidthInPixels(inlay)
}
fun initialize(inlay: Inlay<*>) {
check(!::myInlay.isInitialized)
myInlay = inlay
inlay.whenDisposed {
updateState(false)
}
hintState.whenUpdated {
inlay.update()
}
}
fun TextAttributes.updateIfHovering(): TextAttributes {
return if (hintState.isHovered) {
clone().apply {
foregroundColor = JBUI.CurrentTheme.Link.Foreground.ENABLED
}
}
else this
}
override fun isEnabledAdditional(editor: Editor): Boolean = choiceShouldRender()
override fun mousePressed(event: MouseEvent, translated: Point) {
if (choiceShouldRender()) {
event.consume()
}
}
override fun mouseReleased(event: MouseEvent, translated: Point) {
if (choiceShouldRender()) {
showChooseShortcutPopup(myInlay.editor, guessPopupPoint(event), lineNumber)
}
}
override fun mouseMoved(event: MouseEvent, translated: Point) {
if (choiceShouldRender()) {
updateState(true)
}
}
override fun mouseExited() {
if (choiceShouldRender()) {
updateState(false)
}
}
private fun updateState(isHovered: Boolean) {
hintState.isHovered = isHovered
if (myInlay.isValid) {
updateCursor(isHovered)
hintState.update()
}
}
private fun updateCursor(isHovered: Boolean) {
val cursor = when (isHovered) {
true -> Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
false -> Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR)
}
val contentComponent = myInlay.editor.contentComponent
if (contentComponent.cursor != cursor) {
UIUtil.setCursor(contentComponent, cursor)
}
}
private fun guessPopupPoint(event: MouseEvent): RelativePoint {
val shortcutInlay = myInlay.editor.inlayModel.getAfterLineEndElementsForLogicalLine(lineNumber).firstOrNull {
it.renderer is ShortcutRenderer
}
val point = shortcutInlay?.bounds?.let { Point(it.x, it.y) } ?: event.point
return RelativePoint(event.component, Point(point.x, point.y + myInlay.editor.lineHeight))
}
}
private class ShortcutRenderer(
text: String?,
hoveringState: HintState,
lineNumber: Int
) : RendererBase(text, hoveringState, lineNumber), InputHandler {
override fun paintIfEnabled(inlay: Inlay<*>, g: Graphics, r: Rectangle, textAttributes: TextAttributes) {
paintHint(inlay, g, r, textAttributes.updateIfHovering().clearEffects())
}
}
private class TextRenderer(
text: String?,
hoveringState: HintState,
lineNumber: Int
) : RendererBase(text, hoveringState, lineNumber), InputHandler {
override fun paintIfEnabled(inlay: Inlay<*>, g: Graphics, r: Rectangle, textAttributes: TextAttributes) {
paintLabel(g, inlay.editor as EditorImpl, r, text, textAttributes) { it.updateIfHovering() }
}
}
private fun choiceShouldRender(): Boolean {
return InlineCompletionShortcutHintState.getState() == InlineCompletionShortcutHintState.SHOW_CHOICE
}
private fun showChooseShortcutPopup(editor: Editor, relativePoint: RelativePoint, lineNumber: Int) {
val predefinedShortcuts = listOf(
"Tab" to KeyboardShortcut.fromString("TAB"),
"→" to KeyboardShortcut.fromString("RIGHT"),
"Enter" to KeyboardShortcut.fromString("ENTER"),
"Shift →" to KeyboardShortcut.fromString("shift pressed RIGHT")
).map { (name, shortcut) ->
ChangeShortcutAction(name, shortcut, editor, lineNumber)
}
val defaultGroup = DefaultActionGroup().apply {
addAll(predefinedShortcuts)
add(ChangeToCustomInlineCompletionAction())
}
val popup = JBPopupFactory.getInstance().createActionGroupPopup(
IdeBundle.message("inline.completion.tooltip.shortcuts.header"),
defaultGroup,
DataContext.EMPTY_CONTEXT,
true,
null,
10
)
popup.show(relativePoint)
}
private class ChangeShortcutAction(
@NlsActions.ActionText text: String,
shortcut: Shortcut,
private val editor: Editor,
private val lineNumber: Int
) : InplaceChangeInlineCompletionShortcutAction(text, shortcut) {
override fun actionPerformed(e: AnActionEvent) {
super.actionPerformed(e)
InlineCompletionShortcutHintListener.choiceMade()
val inlaysAfterLine = editor.inlayModel.getAfterLineEndElementsForLogicalLine(lineNumber)
val relatedInlays = inlaysAfterLine.filter { it.renderer is InlineCompletionShortcutHintRendererBase }
relatedInlays.forEach { it.update() }
}
}
@@ -0,0 +1,32 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.codeInsight.inline.completion.shortcut
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.keymap.Keymap
import com.intellij.openapi.keymap.KeymapManagerListener
import com.intellij.util.application
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.NonNls
@ApiStatus.Internal
abstract class InlineCompletionShortcutChangeListener : KeymapManagerListener {
protected abstract fun insertShortcutChanged()
final override fun shortcutsChanged(keymap: Keymap, actionIds: @NonNls MutableCollection<String>, fromSettings: Boolean) {
val expectedActionId = IdeActions.ACTION_INSERT_INLINE_COMPLETION
if (actionIds.contains(expectedActionId) && keymap.getShortcuts(expectedActionId).isNotEmpty()) {
insertShortcutChanged()
}
}
companion object {
fun whenInsertShortcutChanged(disposable: Disposable, handler: () -> Unit) {
val listener = object : InlineCompletionShortcutChangeListener() {
override fun insertShortcutChanged() = handler()
}
application.messageBus.connect(disposable).subscribe(KeymapManagerListener.TOPIC, listener)
}
}
}
@@ -0,0 +1,186 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.codeInsight.inline.completion.shortcut
import com.intellij.codeInsight.inline.completion.MessageBundle
import com.intellij.codeInsight.inline.completion.elements.InlineCompletionElement
import com.intellij.codeInsight.inline.completion.shortcut.InlineCompletionShortcutHint.entries
import com.intellij.codeInsight.inline.hint.InlineShortcutHintRendererBase
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.actionSystem.Shortcut
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.Inlay
import com.intellij.openapi.editor.impl.EditorImpl
import com.intellij.openapi.editor.markup.TextAttributes
import com.intellij.openapi.keymap.KeymapUtil
import com.intellij.openapi.util.Disposer
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.Nls
import java.awt.Graphics
import java.awt.Rectangle
import kotlin.random.Random
@ApiStatus.Internal
class InlineCompletionShortcutHintElement(val lineNumber: Int, val isMultiline: Boolean) : InlineCompletionElement {
override val text: String = ""
private val hint = InlineCompletionShortcutHint.random(isMultiline)
override fun toPresentable(): InlineCompletionElement.Presentable {
return Presentable(this, hint)
}
class Presentable(
override val element: InlineCompletionShortcutHintElement,
private val hint: InlineCompletionShortcutHint
) : InlineCompletionElement.Presentable {
private var shortcutInlay: Inlay<InlineCompletionShortcutHintRendererBase>? = null
private var suffixInlay: Inlay<InlineCompletionShortcutHintRendererBase>? = null
private var offset: Int? = null
private var currentShortcut = getShortcutRepresentation()
init {
InlineCompletionShortcutChangeListener.whenInsertShortcutChanged(disposable = this) {
rerender()
}
}
override fun isVisible(): Boolean {
return getBounds() != null
}
override fun getBounds(): Rectangle? {
if (!hintShouldRender()) {
return null
}
val shortcutBounds = shortcutInlay?.bounds ?: return null
val suffixBounds = suffixInlay?.bounds ?: return null
return shortcutBounds.union(suffixBounds)
}
override fun startOffset(): Int? = offset
override fun endOffset(): Int? = offset
override fun dispose() {
shortcutInlay?.let(Disposer::dispose)
suffixInlay?.let(Disposer::dispose)
shortcutInlay = null
suffixInlay = null
offset = null
}
override fun render(editor: Editor, offset: Int) {
if (editor !is EditorImpl || currentShortcut == null || !InlineShortcutHintRendererBase.isAvailableForLine(editor, element.lineNumber)) {
return
}
try {
val caretOffset = editor.caretModel.offset
shortcutInlay = editor.inlayModel.addAfterLineEndElement(caretOffset, true, getShortcutRenderer())
suffixInlay = editor.inlayModel.addAfterLineEndElement(caretOffset, true, getSuffixRenderer(editor))
this.offset = offset
}
catch (e: Exception) {
LOG.error("Could not render Full Line in-editor hint.", e)
}
}
private fun getSuffixRenderer(editor: EditorImpl): InlineCompletionShortcutHintRendererBase {
return object : InlineCompletionShortcutHintRendererBase(hint.suffixText) {
// We need to check it here to be able to re-draw if a user changes the setting during a session
override fun isEnabledAdditional(editor: Editor): Boolean = hintShouldRender()
override fun paintIfEnabled(inlay: Inlay<*>, g: Graphics, r: Rectangle, textAttributes: TextAttributes) {
paintLabel(g, editor, r, text, textAttributes.clearEffects())
}
}
}
private fun getShortcutRenderer(): InlineCompletionShortcutHintRendererBase {
return object : InlineCompletionShortcutHintRendererBase(currentShortcut) {
// We need to check it here to be able to re-draw if a user changes the setting during a session
override fun isEnabledAdditional(editor: Editor): Boolean = hintShouldRender()
override fun paintIfEnabled(inlay: Inlay<*>, g: Graphics, r: Rectangle, textAttributes: TextAttributes) {
paintHint(inlay, g, r, textAttributes.clearEffects())
}
}
}
private fun rerender() {
currentShortcut = getShortcutRepresentation()
shortcutInlay?.renderer?.text = currentShortcut
shortcutInlay?.update()
suffixInlay?.update()
}
private fun getShortcutRepresentation(): String? {
val shortcut = KeymapUtil.getPrimaryShortcut(hint.actionId)
return shortcut?.asString()
}
private fun Shortcut.asString(): String {
return when (toString()) {
"[pressed TAB]" -> "Tab"
"[pressed ENTER]" -> "Enter"
"[shift pressed RIGHT]" -> "Shift →"
else -> KeymapUtil.getShortcutText(this)
}
}
private fun hintShouldRender(): Boolean {
return currentShortcut != null && InlineCompletionShortcutHintState.getState() == InlineCompletionShortcutHintState.SHOW_HINT
}
companion object {
private val LOG = thisLogger()
}
}
}
@ApiStatus.Internal
enum class InlineCompletionShortcutHint {
INSERT {
override val actionId: String
get() = IdeActions.ACTION_INSERT_INLINE_COMPLETION
override val suffixText: String
get() = MessageBundle.message("inline.completion.shortcut.hint.insert.text")
override val priority: Int
get() = 4
},
INSERT_WORD {
override val actionId: String
get() = IdeActions.ACTION_INSERT_INLINE_COMPLETION_WORD
override val suffixText: String
get() = MessageBundle.message("inline.completion.shortcut.hint.insert.word.text")
override val priority: Int
get() = 1
},
INSERT_LINE {
override val actionId: String
get() = IdeActions.ACTION_INSERT_INLINE_COMPLETION_LINE
override val suffixText: String
get() = MessageBundle.message("inline.completion.shortcut.hint.insert.line.text")
override val priority: Int
get() = 3
};
abstract val actionId: String
abstract val suffixText: @Nls String
protected abstract val priority: Int
companion object {
fun random(isMultiline: Boolean): InlineCompletionShortcutHint {
val entries = if (isMultiline) entries else entries.filter { it !== INSERT_LINE }
var randomPoint = Random.nextInt(entries.sumOf { it.priority }) + 1
return entries.firstOrNull {
randomPoint -= it.priority
randomPoint <= 0
} ?: entries.last()
}
}
}
@@ -0,0 +1,47 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.codeInsight.inline.completion.shortcut
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.extensions.ExtensionPointName
import org.jetbrains.annotations.ApiStatus
@ApiStatus.Internal
@ApiStatus.NonExtendable
interface InlineCompletionShortcutHintListener {
fun choiceShown()
fun choiceMade()
fun shouldShowChoice(): Boolean
fun shouldHideHint(editor: Editor): Boolean
fun isHintDisabled(): Boolean
companion object {
private val EP_NAME = ExtensionPointName.create<InlineCompletionShortcutHintListener>(
"com.intellij.inline.completion.shortcutHintListener"
)
fun choiceShown() {
EP_NAME.forEachExtensionSafe { it.choiceShown() }
}
fun choiceMade() {
EP_NAME.forEachExtensionSafe { it.choiceMade() }
}
fun shouldShowChoice(): Boolean {
return EP_NAME.extensionList.any { it.shouldShowChoice() }
}
fun shouldHideHint(editor: Editor): Boolean {
return EP_NAME.extensionList.any { it.shouldHideHint(editor) }
}
fun isHintDisabled(): Boolean {
return EP_NAME.extensionList.any { it.isHintDisabled() }
}
}
}
@@ -0,0 +1,16 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.codeInsight.inline.completion.shortcut
import com.intellij.codeInsight.inline.hint.InlineShortcutHintRendererBase
import com.intellij.openapi.editor.Editor
import org.jetbrains.annotations.ApiStatus
@ApiStatus.Internal
abstract class InlineCompletionShortcutHintRendererBase(text: String?) : InlineShortcutHintRendererBase(text) {
protected abstract fun isEnabledAdditional(editor: Editor): Boolean
final override fun isEnabled(editor: Editor): Boolean {
return isEnabledAdditional(editor) && !InlineCompletionShortcutHintListener.shouldHideHint(editor)
}
}
@@ -0,0 +1,33 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.codeInsight.inline.completion.shortcut
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.actionSystem.Shortcut
import com.intellij.openapi.keymap.KeymapUtil
import com.intellij.util.application
import org.jetbrains.annotations.ApiStatus
@ApiStatus.Internal
enum class InlineCompletionShortcutHintState {
SHOW_HINT,
SHOW_CHOICE,
SHOW_NOTHING;
companion object {
fun getState(): InlineCompletionShortcutHintState {
val primaryShortcut = KeymapUtil.getPrimaryShortcut(IdeActions.ACTION_INSERT_INLINE_COMPLETION)
if (primaryShortcut?.isTab() == false) {
InlineCompletionShortcutHintListener.choiceMade()
}
if (primaryShortcut == null || InlineCompletionShortcutHintListener.isHintDisabled()) {
return SHOW_NOTHING
}
return if (InlineCompletionShortcutHintListener.shouldShowChoice() && !application.isUnitTestMode) SHOW_CHOICE else SHOW_HINT
}
private fun Shortcut.isTab(): Boolean {
return toString().lowercase() == "[pressed tab]"
}
}
}
@@ -3,6 +3,9 @@ package com.intellij.codeInsight.inline.hint
import com.intellij.codeInsight.daemon.impl.HintRenderer
import com.intellij.codeInsight.inline.completion.InlineCompletionFontUtils
import com.intellij.codeWithMe.ClientId
import com.intellij.codeWithMe.ClientId.Companion.isLocal
import com.intellij.idea.AppMode
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.Inlay
import com.intellij.openapi.editor.impl.EditorImpl
@@ -72,6 +75,9 @@ abstract class InlineShortcutHintRendererBase(text: String?) : HintRenderer(text
companion object {
@JvmStatic
fun isAvailableForLine(editor: Editor, lineNumber: Int): Boolean {
if (!ClientId.currentOrNull.isLocal || AppMode.isRemoteDevHost()) {
return false
}
// Debugger may suggest something on the right, so inlays will mix up
val inlaysAfterLine = editor.inlayModel.getAfterLineEndElementsForLogicalLine(lineNumber)
return inlaysAfterLine.all { it.renderer is InlineShortcutHintRendererBase }