diff --git a/engine/src/main/kotlin/io/helicase/engine/Engine.kt b/engine/src/main/kotlin/io/helicase/engine/Engine.kt index 6d70ccf..1151cd3 100644 --- a/engine/src/main/kotlin/io/helicase/engine/Engine.kt +++ b/engine/src/main/kotlin/io/helicase/engine/Engine.kt @@ -611,6 +611,11 @@ class Engine { if (nl < 0) maxOf(0, text.length - 1) else maxOf(0, nl - 1) } } + val gotoWord = Command("goto_word", "Jump to word via label overlay") { ctx -> + // The engine doesn't draw overlays; just emit the effect for the + // plugin to handle. State is otherwise unchanged. + ctx.state.copy(pendingAction = PendingAction.GotoWord) + } val gotoFirstNonWs = Command("goto_first_non_whitespace", "Go to first non-ws") { ctx -> val text = ctx.state.text moveAllRanges(ctx.state, ctx.extend) { r -> @@ -760,6 +765,7 @@ class Engine { KeyEvent.Char('h') to KeyMap.Leaf(commands.gotoLineStart), KeyEvent.Char('l') to KeyMap.Leaf(commands.gotoLineEnd), KeyEvent.Char('s') to KeyMap.Leaf(commands.gotoFirstNonWs), + KeyEvent.Char('w') to KeyMap.Leaf(commands.gotoWord), ), description = "goto navigation", ), diff --git a/engine/src/main/kotlin/io/helicase/engine/Types.kt b/engine/src/main/kotlin/io/helicase/engine/Types.kt index 28e2020..ffa40de 100644 --- a/engine/src/main/kotlin/io/helicase/engine/Types.kt +++ b/engine/src/main/kotlin/io/helicase/engine/Types.kt @@ -101,6 +101,21 @@ data class EditorState( * [Clipboard] hook on CommandContext, so they reach the host clipboard. */ val registers: Registers = Registers(), + /** + * One-shot effect emitted by certain engine commands whose behavior + * involves more than a pure state transform — `gw` (jump-to-word + * overlay) is the first example. The plugin reads this field after + * `engine.apply`, kicks off the corresponding UI, and clears the + * field on its next write. Keeping the engine pure: this is just a + * tagged enum the plugin inspects; no IntelliJ code touches the engine. + */ + val pendingAction: PendingAction? = null, ) enum class Mode { Normal, Insert, Select } + +/** Side-effect markers emitted by the engine for the plugin to handle. */ +sealed class PendingAction { + /** `gw` — show a jump-label overlay across visible words. */ + data object GotoWord : PendingAction() +} diff --git a/plugin/src/main/kotlin/io/helicase/plugin/HelicaseActionWrapper.kt b/plugin/src/main/kotlin/io/helicase/plugin/HelicaseActionWrapper.kt index 83d5c6d..94474fd 100644 --- a/plugin/src/main/kotlin/io/helicase/plugin/HelicaseActionWrapper.kt +++ b/plugin/src/main/kotlin/io/helicase/plugin/HelicaseActionWrapper.kt @@ -59,7 +59,9 @@ class HelicaseActionWrapper( val applyStart = System.nanoTime() val result = engine.applyWithStatus(before, keys, syntax) val applyNs = System.nanoTime() - applyStart - EditorBridge.write(editor, result.state, state) + val pendingEffect = result.state.pendingAction + val outState = if (pendingEffect != null) result.state.copy(pendingAction = null) else result.state + EditorBridge.write(editor, outState, state) state.pendingKeys = if (result.pending) keys else "" HelicaseStatus.refresh(editor) HelicaseInfoPopup.refresh(editor) @@ -73,5 +75,12 @@ class HelicaseActionWrapper( field("pending", result.pending) field("apply_us", applyNs / 1000) } + if (pendingEffect == io.helicase.engine.PendingAction.GotoWord) { + val overlay = JumpLabelOverlay(editor, HelicaseSettings.jumpLabelAlphabet) + if (overlay.start()) { + state.jumpOverlay = overlay + state.jumpPendingInput = "" + } + } } } diff --git a/plugin/src/main/kotlin/io/helicase/plugin/HelicaseChordDispatcher.kt b/plugin/src/main/kotlin/io/helicase/plugin/HelicaseChordDispatcher.kt index c773257..3324d5f 100644 --- a/plugin/src/main/kotlin/io/helicase/plugin/HelicaseChordDispatcher.kt +++ b/plugin/src/main/kotlin/io/helicase/plugin/HelicaseChordDispatcher.kt @@ -67,6 +67,52 @@ class HelicaseChordDispatcher(private val engine: Engine) : IdeEventQueue.EventD val chord = Chord.from(keyEvent) ?: return false val printable = isPrintable(keyEvent) + // Jump-label overlay captures all keys until matched / cancelled. + val overlay = state.jumpOverlay + if (overlay != null) { + if (chord == "") { + overlay.dismiss() + state.jumpOverlay = null + state.jumpPendingInput = "" + keyEvent.consume() + return true + } + // Non-alphabet / multi-char chord (Ctrl-…, F-key, etc.) → dismiss + // without jumping, fall through so the user can keep working. + if (chord.length != 1) { + overlay.dismiss() + state.jumpOverlay = null + state.jumpPendingInput = "" + if (printable) suppressNextTyped = true + keyEvent.consume() + return true + } + val result = overlay.handleKey(chord[0], state.jumpPendingInput) + when (result) { + is JumpLabelOverlay.Result.Pending -> { + state.jumpPendingInput = result.input + } + is JumpLabelOverlay.Result.Jump -> { + overlay.dismiss() + state.jumpOverlay = null + state.jumpPendingInput = "" + editor.caretModel.moveToOffset(result.offset) + editor.scrollingModel.scrollToCaret( + com.intellij.openapi.editor.ScrollType.RELATIVE, + ) + } + JumpLabelOverlay.Result.NoMatch, + JumpLabelOverlay.Result.Dismiss -> { + overlay.dismiss() + state.jumpOverlay = null + state.jumpPendingInput = "" + } + } + if (printable) suppressNextTyped = true + keyEvent.consume() + return true + } + val pending = state.pendingMinorMode if (pending != null) { // Esc explicitly cancels the mode at any nesting depth. diff --git a/plugin/src/main/kotlin/io/helicase/plugin/HelicaseCommand.kt b/plugin/src/main/kotlin/io/helicase/plugin/HelicaseCommand.kt index 3c10e0d..3b341a9 100644 --- a/plugin/src/main/kotlin/io/helicase/plugin/HelicaseCommand.kt +++ b/plugin/src/main/kotlin/io/helicase/plugin/HelicaseCommand.kt @@ -57,7 +57,9 @@ object HelicaseCommand { // Reload keymap from disk — useful while iterating on config.toml // without restarting the IDE. "reload" to Spec("Reload Helicase config") { _ -> - HelicaseChordTable.load(HelicaseConfig.loadDefault()) + val config = HelicaseConfig.loadDefault() + HelicaseChordTable.load(config) + HelicaseSettings.load(config) }, // `` minor mode bindings. Names match Helix's command names // where possible; each delegates to the IntelliJ action that does diff --git a/plugin/src/main/kotlin/io/helicase/plugin/HelicaseConfig.kt b/plugin/src/main/kotlin/io/helicase/plugin/HelicaseConfig.kt index 4687415..18329b8 100644 --- a/plugin/src/main/kotlin/io/helicase/plugin/HelicaseConfig.kt +++ b/plugin/src/main/kotlin/io/helicase/plugin/HelicaseConfig.kt @@ -37,6 +37,13 @@ data class HelicaseConfig( val normal: Map, val insert: Map, val select: Map, + /** + * `[editor].jump-label-alphabet` — characters drawn as labels by `gw`. + * Null → use Helix's default `"abcdefghijklmnopqrstuvwxyz"`. Order + * matters: the first char doubled (`alphabet[0]+alphabet[0]`) is the + * label of the word closest to the cursor. + */ + val jumpLabelAlphabet: String? = null, ) { companion object { val EMPTY = HelicaseConfig(emptyMap(), emptyMap(), emptyMap()) @@ -45,7 +52,15 @@ data class HelicaseConfig( System.getenv("HELICASE_CONFIG")?.let { return Paths.get(it) } val xdg = System.getenv("XDG_CONFIG_HOME") val base = xdg ?: "${System.getProperty("user.home")}/.config" - return Paths.get(base, "helicase", "config.toml") + // Prefer Helicase's own config if it exists, but fall back to + // Helix's so users don't have to duplicate their config across + // two files. Either way the [keys.*] and [editor] sections we + // care about have the same shape. + val helicasePath = Paths.get(base, "helicase", "config.toml") + if (Files.exists(helicasePath)) return helicasePath + val helixPath = Paths.get(base, "helix", "config.toml") + if (Files.exists(helixPath)) return helixPath + return helicasePath } fun loadDefault(): HelicaseConfig { @@ -78,6 +93,7 @@ data class HelicaseConfig( normal = bindingTable(parsed.getTable("keys.normal"), "normal"), insert = bindingTable(parsed.getTable("keys.insert"), "insert"), select = bindingTable(parsed.getTable("keys.select"), "select"), + jumpLabelAlphabet = parsed.getString("editor.jump-label-alphabet"), ) } diff --git a/plugin/src/main/kotlin/io/helicase/plugin/HelicaseSettings.kt b/plugin/src/main/kotlin/io/helicase/plugin/HelicaseSettings.kt new file mode 100644 index 0000000..14bf9d9 --- /dev/null +++ b/plugin/src/main/kotlin/io/helicase/plugin/HelicaseSettings.kt @@ -0,0 +1,25 @@ +package io.helicase.plugin + +/** + * Application-scoped Helicase settings derived from the user's TOML + * config — currently just the jump-label alphabet, but the natural home + * for anything else from `[editor]` (tab-width, smart-tab, etc.) as we + * add it. + * + * Loaded by [HelicaseStartup] at app start (and again when `:reload` + * fires). Held statically because every editor reads the same value and + * the data is tiny. + */ +object HelicaseSettings { + /** Helix's default jump-label alphabet (a..z). */ + const val DEFAULT_JUMP_LABEL_ALPHABET: String = "abcdefghijklmnopqrstuvwxyz" + + @Volatile + var jumpLabelAlphabet: String = DEFAULT_JUMP_LABEL_ALPHABET + private set + + fun load(config: HelicaseConfig) { + jumpLabelAlphabet = config.jumpLabelAlphabet?.takeIf { it.isNotEmpty() } + ?: DEFAULT_JUMP_LABEL_ALPHABET + } +} diff --git a/plugin/src/main/kotlin/io/helicase/plugin/HelicaseStartup.kt b/plugin/src/main/kotlin/io/helicase/plugin/HelicaseStartup.kt index eb27391..ed2f96e 100644 --- a/plugin/src/main/kotlin/io/helicase/plugin/HelicaseStartup.kt +++ b/plugin/src/main/kotlin/io/helicase/plugin/HelicaseStartup.kt @@ -22,10 +22,13 @@ import io.helicase.engine.Engine */ class HelicaseStartup : AppLifecycleListener { override fun appStarted() { - // User keymap from ~/.config/helicase/config.toml (Helix-format). - // Merges over built-in defaults, so unspecified chords keep working - // and explicit binds replace them. - HelicaseChordTable.load(HelicaseConfig.loadDefault()) + // User config from ~/.config/helicase/config.toml (Helix-format). + // Keymap merges over built-in defaults (unspecified chords keep + // working). Editor settings (jump-label alphabet etc.) populate + // HelicaseSettings. + val config = HelicaseConfig.loadDefault() + HelicaseChordTable.load(config) + HelicaseSettings.load(config) // Probe every IDE-action-delegating `:`-command against the running // IDE's ActionManager. Logs a warning if any IDs don't exist (e.g. diff --git a/plugin/src/main/kotlin/io/helicase/plugin/HelicaseState.kt b/plugin/src/main/kotlin/io/helicase/plugin/HelicaseState.kt index 8980578..89aebab 100644 --- a/plugin/src/main/kotlin/io/helicase/plugin/HelicaseState.kt +++ b/plugin/src/main/kotlin/io/helicase/plugin/HelicaseState.kt @@ -62,6 +62,15 @@ class HelicaseState( var pendingMinorMode: Map? = null, /** Display name of [pendingMinorMode] for the status bar / popup. */ var pendingMinorModeName: String? = null, + /** + * Active jump-label overlay (`gw`). When non-null, the chord dispatcher + * routes the next keystrokes through the overlay's match table instead + * of normal dispatch. Cleared by Esc, by matching a label, or by + * pressing a key that isn't in the alphabet. + */ + var jumpOverlay: JumpLabelOverlay? = null, + /** Buffered first char while waiting for the second key of a 2-char label. */ + var jumpPendingInput: String = "", ) object HelicaseStateHolder { diff --git a/plugin/src/main/kotlin/io/helicase/plugin/HelicaseTypedHandler.kt b/plugin/src/main/kotlin/io/helicase/plugin/HelicaseTypedHandler.kt index 7289d82..8dad8ae 100644 --- a/plugin/src/main/kotlin/io/helicase/plugin/HelicaseTypedHandler.kt +++ b/plugin/src/main/kotlin/io/helicase/plugin/HelicaseTypedHandler.kt @@ -65,7 +65,12 @@ class HelicaseTypedHandler(private val previous: TypedActionHandler) : TypedActi val applyStart = System.nanoTime() val result = engine.applyWithStatus(before, keys, syntax) val applyNs = System.nanoTime() - applyStart - EditorBridge.write(editor, result.state, state) + // Strip the one-shot `pendingAction` before writing — the plugin + // handles those side-effects below; the engine state itself shouldn't + // carry the flag forward. + val pendingEffect = result.state.pendingAction + val outState = if (pendingEffect != null) result.state.copy(pendingAction = null) else result.state + EditorBridge.write(editor, outState, state) state.pendingKeys = if (result.pending) keys else "" HelicaseStatus.refresh(editor) HelicaseInfoPopup.refresh(editor) @@ -79,6 +84,28 @@ class HelicaseTypedHandler(private val previous: TypedActionHandler) : TypedActi field("pending", result.pending) field("apply_us", applyNs / 1000) } + handlePendingAction(editor, state, pendingEffect) + } + + private fun handlePendingAction( + editor: Editor, + state: HelicaseState, + action: io.helicase.engine.PendingAction?, + ) { + when (action) { + io.helicase.engine.PendingAction.GotoWord -> { + val overlay = JumpLabelOverlay(editor, HelicaseSettings.jumpLabelAlphabet) + if (overlay.start()) { + state.jumpOverlay = overlay + state.jumpPendingInput = "" + HelicaseLog.event { + field("evt", "gw_overlay") + field("alphabet", HelicaseSettings.jumpLabelAlphabet) + } + } + } + null -> { /* no-op */ } + } } private fun openSearchPrompt(editor: Editor, state: HelicaseState) { diff --git a/plugin/src/main/kotlin/io/helicase/plugin/JumpLabelOverlay.kt b/plugin/src/main/kotlin/io/helicase/plugin/JumpLabelOverlay.kt new file mode 100644 index 0000000..63ad5cf --- /dev/null +++ b/plugin/src/main/kotlin/io/helicase/plugin/JumpLabelOverlay.kt @@ -0,0 +1,207 @@ +package io.helicase.plugin + +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.colors.EditorFontType +import com.intellij.openapi.wm.IdeGlassPaneUtil +import java.awt.Color +import java.awt.Font +import java.awt.Graphics +import java.awt.Point +import javax.swing.JComponent +import javax.swing.SwingUtilities + +/** + * Helix-style `gw` jump-label overlay. + * + * On [start], scans the visible viewport for 2+ char words, assigns + * 2-char labels from the configured alphabet (closest-to-cursor word + * gets the first label `alphabet[0]+alphabet[0]`), and renders the + * labels as opaque [JumpLabelTag] components placed on the IDE's + * **glass pane** — the topmost layer of the IDE window, painted over + * the editor itself. Earlier attempts at this used a + * `CustomHighlighterRenderer` and then a `JComponent` child of + * `editor.contentComponent`; in both cases the editor's text-fragment + * paint pass overdrew our labels, leaving glyphs ghosting through. + * + * Algorithm and label-distribution semantics ported from Helix + * (`helix-term/src/commands.rs::jump_to_word`). + */ +class JumpLabelOverlay( + private val editor: Editor, + private val alphabet: String, +) { + private val tagByLabel = LinkedHashMap() + private val labels = LinkedHashMap() // label -> word start offset + private var glassPane: JComponent? = null + + /** True if any labels were placed (false on empty buffer / no visible words). */ + fun start(): Boolean { + if (alphabet.isEmpty()) return false + val text = editor.document.charsSequence.toString() + if (text.isEmpty()) return false + + val (viewStart, viewEnd) = visibleCharRange() + val cursor = editor.caretModel.offset + + val allWords = findWords(text, viewStart, viewEnd) + if (allWords.isEmpty()) return false + + val ordered = orderByDistance(allWords, cursor) + val limit = alphabet.length * alphabet.length + val candidates = ordered.take(limit) + + val n = alphabet.length + candidates.forEachIndexed { i, off -> + val label = "${alphabet[i / n]}${alphabet[i % n]}" + labels[label] = off + } + + val gp = IdeGlassPaneUtil.find(editor.contentComponent) as? JComponent ?: return false + glassPane = gp + renderTags(text, gp) + gp.repaint() + return true + } + + /** + * Accept one keystroke. See [Result] for the dispatch's interpretation. + * + * After a successful first character, also prunes tags whose label + * doesn't start with that character — the user sees only the labels + * still in play, making the second key obvious at a glance. + */ + fun handleKey(char: Char, pendingInput: String): Result { + val newInput = pendingInput + char + if (char !in alphabet) return Result.Dismiss + if (newInput.length == 1) { + pruneNonMatching(newInput) + return Result.Pending(newInput) + } + val target = labels[newInput] ?: return Result.NoMatch + return Result.Jump(target) + } + + fun dismiss() { + val gp = glassPane ?: return + for (tag in tagByLabel.values) gp.remove(tag) + tagByLabel.clear() + labels.clear() + gp.repaint() + glassPane = null + } + + /** Remove tags whose label doesn't start with [prefix]; repaint. */ + private fun pruneNonMatching(prefix: String) { + val gp = glassPane ?: return + val iter = tagByLabel.iterator() + while (iter.hasNext()) { + val (label, tag) = iter.next() + if (!label.startsWith(prefix)) { + gp.remove(tag) + iter.remove() + } + } + gp.repaint() + } + + sealed class Result { + data class Pending(val input: String) : Result() + data class Jump(val offset: Int) : Result() + object NoMatch : Result() + object Dismiss : Result() + } + + // ── Internals ────────────────────────────────────────────────────────── + + private fun visibleCharRange(): Pair { + val area = editor.scrollingModel.visibleArea + val firstLine = editor.xyToLogicalPosition(Point(0, area.y)).line + .coerceIn(0, editor.document.lineCount - 1) + val lastLine = editor.xyToLogicalPosition(Point(0, area.y + area.height)).line + .coerceIn(0, editor.document.lineCount - 1) + val start = editor.document.getLineStartOffset(firstLine) + val end = editor.document.getLineEndOffset(lastLine) + return start to end + } + + private fun findWords(text: String, start: Int, end: Int): List { + val out = mutableListOf() + var i = start + val cap = end.coerceAtMost(text.length) + while (i < cap) { + val isStart = isWordChar(text[i]) && (i == 0 || !isWordChar(text[i - 1])) + if (isStart) { + var j = i + while (j < cap && isWordChar(text[j])) j++ + if (j - i >= 2) out += i + i = j + } else { + i++ + } + } + return out + } + + private fun orderByDistance(words: List, cursor: Int): List = + words.sortedWith(compareBy({ kotlin.math.abs(it - cursor) }, { -it })) + + private fun renderTags(text: String, glassPane: JComponent) { + val font = editor.colorsScheme.getFont(EditorFontType.PLAIN) + val lineHeight = editor.lineHeight + val src = editor.contentComponent + // Editor's baseline within a line cell. Drawn label glyphs need to + // sit at the same baseline as the underlying text to look aligned; + // glass-pane Graphics doesn't always agree with the JComponent's + // FontMetrics on ascent, so we measure off the editor directly. + val ascent = src.getFontMetrics(font).ascent + for ((label, off) in labels) { + val xy = editor.offsetToXY(off) + val endOff = (off + 2).coerceAtMost(text.length) + val afterXY = editor.offsetToXY(endOff) + val width = afterXY.x - xy.x + if (width <= 0) continue + val glassPoint = SwingUtilities.convertPoint(src, xy, glassPane) + val tag = JumpLabelTag(label, font, ascent) + tag.setBounds(glassPoint.x, glassPoint.y, width, lineHeight) + glassPane.add(tag) + tagByLabel[label] = tag + } + } + + private fun isWordChar(c: Char): Boolean = c.isLetterOrDigit() || c == '_' +} + +/** + * Opaque 2-char label. Added to the IDE's glass pane (the topmost layer + * of the window), so Swing paints it over the editor unconditionally. + */ +private class JumpLabelTag( + private val text: String, + labelFont: Font, + /** + * Baseline Y measured from the editor's own font metrics (not from the + * glass-pane Graphics, which has occasionally disagreed by a pixel and + * thrown alignment off). + */ + private val ascent: Int, +) : JComponent() { + + init { + isOpaque = true + background = LABEL_BG + foreground = Color.BLACK + font = labelFont + } + + override fun paintComponent(g: Graphics) { + g.color = LABEL_BG + g.fillRect(0, 0, width, height) + g.color = foreground + g.font = font + g.drawString(text, 0, ascent) + } + + companion object { + private val LABEL_BG = Color(0xFF, 0xEB, 0x3B) + } +}