From deec43c0ff2bd58b4342e7a03366a4998b603896 Mon Sep 17 00:00:00 2001 From: felixzsh Date: Wed, 19 Aug 2026 11:46:41 -0500 Subject: [PATCH] feat: add D-pad fine-tune nudges below the position selector Add unit-by-unit manual position control via a four-button D-pad (left, up/down together, right) under the position dropdown. Nudges persist as offsetX/offsetY in the shared config and are silently clamped to the screen edges, so a move that would cross a border is ignored. Selecting a preset position resets the manual offset. While the display is hidden, a nudge flashes a directional arrow preview so the move direction is visible even with no keys on screen. Co-Authored-By: opencode --- KeyVisualizer.qml | 143 ++++++++++++++++++++++++++++++++---------- Panel.qml | 156 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 264 insertions(+), 35 deletions(-) diff --git a/KeyVisualizer.qml b/KeyVisualizer.qml index 67568fe..d35ee75 100644 --- a/KeyVisualizer.qml +++ b/KeyVisualizer.qml @@ -39,6 +39,10 @@ Item { // show": entries never expire, so the stack only shrinks when newer // combos push old ones out of the history window. property int lingerMs: 1000 + // Manual fine-tune offsets (px) applied on top of the preset position. + // Written by the panel D-pad buttons; reset when a dropdown preset is chosen. + property int offsetX: 0 + property int offsetY: 0 // Combo mode — "game mode". Toggle in the panel. Chords that contain a // modifier (Super/Ctrl/Alt/Shift/Menu/AltGr) are COMBOs: they build a @@ -136,6 +140,73 @@ Item { return root.entries.length * root.chipHeight + (root.entries.length - 1) * root.entryGap } + // ---- geometry helpers for card/banner group clamp ---- + + function clamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)) + } + + function groupWidth() { + return card.width + } + + function groupHeight() { + if (root.bannerVisible()) return card.height + root.bannerHeight + root.bannerGap + return card.height + } + + function groupLeftOffset() { + if (!root.bannerVisible()) return 0 + var mode = root.sideMode() + if (mode === "left") return 0 + if (mode === "right") return Math.min(0, card.width - banner.width) + return Math.min(0, (card.width - banner.width) / 2) + } + + function groupRightOffset() { + if (!root.bannerVisible()) return card.width + var mode = root.sideMode() + if (mode === "left") return Math.max(card.width, banner.width) + if (mode === "right") return card.width + return Math.max(card.width, (card.width + banner.width) / 2) + } + + // Returns the y of the topmost element of the visual group (banner for + // top positions, card for bottom), clamped so the whole group stays on screen. + function groupY0() { + var gH = root.groupHeight() + var top = root.position.indexOf("top") !== -1 + var raw = top ? root.margin + root.offsetY : panel.height - gH - root.margin + root.offsetY + return root.clamp(raw, 0, panel.height - gH) + } + + // Combo-banner horizontal anchor, computed from the card's *target* position + // (preset + offset, pre-clamp) so it never feeds back into the clamp. The + // banner anchors to the card's outward edge and grows toward the screen + // center: "left" grows right, "right" grows left, "center" stays centered + // (the look for centered presets, e.g. bottom-center by default). + function sideMode() { + if (!root.bannerVisible()) return "center" + var base = 0 + var p = root.position + if (p.indexOf("left") !== -1) base = root.margin + else if (p.indexOf("right") !== -1) base = panel.width - root.groupWidth() - root.margin + else base = Math.round((panel.width - root.groupWidth()) / 2) + var target = base + root.offsetX + var distLeft = target + var distRight = panel.width - (target + root.groupWidth()) + if (distLeft < distRight) return "left" + if (distRight < distLeft) return "right" + return "center" + } + + // History stacks downward (newest at the top) when the card sits in the top + // half of the screen, upward when it sits in the bottom half. Recomputed from + // the live card position so it follows D-pad nudges. + function stackDown() { + return card.y + card.height / 2 < panel.height / 2 + } + // Stack fade: the current combo is fully opaque and every older entry // steps down in opacity (tunable here). Clamped so the oldest row of a // 5-deep stack stays readable. @@ -167,14 +238,14 @@ Item { return list } - // Row order for the card. Top positions stack the history downward with - // the newest combo on top; bottom positions stack it upward with the - // newest on the bottom edge. Each item carries its original index so the - // fade always measures distance from the newest combo. + // Row order for the card. When the card sits in the bottom half the history + // stacks upward with the newest on the bottom edge; in the top half it + // stacks downward with the newest on top. Each item carries its original + // index so the fade always measures distance from the newest combo. function displayModel() { var list = [] var n = root.entries.length - if (root.position.indexOf("bottom") !== -1) { + if (!root.stackDown()) { for (var i = n - 1; i >= 0; i--) list.push({ entry: root.entries[i], pos: i }) } else { for (var j = 0; j < n; j++) list.push({ entry: root.entries[j], pos: j }) @@ -275,6 +346,8 @@ Item { popAnim.restart() } + + // Screen shake on combo presses. Amplitude grows with modifiers and the // combo tier; at tier 3 (20+ combos) the continuous jitterTimer takes // over instead so the two never fight. @@ -512,12 +585,14 @@ Item { if (isFinite(cfg.lingerMs) && cfg.lingerMs >= 0) root.lingerMs = Math.round(cfg.lingerMs) if (isFinite(cfg.historyCount)) root.historyCount = Math.max(1, Math.min(5, Math.round(cfg.historyCount))) root.comboMode = cfg.comboMode === true + if (isFinite(cfg.offsetX)) root.offsetX = Math.round(root.clamp(cfg.offsetX, -2000, 2000)) + if (isFinite(cfg.offsetY)) root.offsetY = Math.round(root.clamp(cfg.offsetY, -2000, 2000)) } function migrateConfig() { // First load with the new location: carry over values from the old // plugin-dir config (if any) and remove it, or seed the defaults. - var defaults = '{"mode": "all", "position": "bottom-center", "margin": 67, "lingerMs": 1000, "historyCount": 1}' + var defaults = '{"mode": "all", "position": "bottom-center", "margin": 67, "lingerMs": 1000, "historyCount": 1, "comboMode": false, "offsetX": 0, "offsetY": 0}' migrateProc.command = ["sh", "-c", "if [ -f " + Util.shellQuote(root.legacyConfigPath) + " ]; then " + "cp " + Util.shellQuote(root.legacyConfigPath) + " " + Util.shellQuote(root.configPath) + "; " @@ -643,31 +718,29 @@ Item { visible: root.entries.length > 0 width: card.borderLeft + root.cardPad + root.contentWidth() + root.cardPad + card.borderRight height: card.borderTop + root.cardPad + root.contentHeight() + root.cardPad + card.borderBottom + // Preset base position + manual offset, clamped so the whole visual + // group (card and the combo banner) stays on screen. A nudge that + // would cross a screen edge is silently ignored. x: { var p = root.position - var bx = 0 - if (p.indexOf("left") !== -1) bx = root.margin - else if (p.indexOf("right") !== -1) bx = panel.width - width - root.margin - else bx = Math.round((panel.width - width) / 2) - return bx + root.shakeX + var gW = root.groupWidth() + var lo = -root.groupLeftOffset() + var hi = panel.width - root.groupRightOffset() + var base = 0 + if (p.indexOf("left") !== -1) base = root.margin + else if (p.indexOf("right") !== -1) base = panel.width - gW - root.margin + else base = Math.round((panel.width - gW) / 2) + return root.clamp(base + root.offsetX, lo, hi) + root.shakeX } - // Top positions anchor the stack's first row at the top edge (the - // history grows downward); bottom positions anchor the last row at - // the bottom edge (the history grows upward). In combo mode the - // banner sits below the card for bottom positions and above it for - // top positions, and the card makes room for it. + // The group's top Y (banner for top positions, card for bottom) + // follows the preset + offset and is clamped on screen; the card + // sits below the banner on top positions, at the group top on bottom. y: { var p = root.position - var by = 0 - if (root.bannerVisible()) { - if (p.indexOf("top") !== -1) by = root.margin + root.bannerHeight + root.bannerGap - else by = panel.height - root.margin - root.bannerHeight - root.bannerGap - height - } else if (p.indexOf("top") !== -1) { - by = root.margin - } else { - by = panel.height - height - root.margin - } - return by + root.shakeY + var y0 = root.groupY0() + var y = y0 + if (root.bannerVisible() && p.indexOf("top") !== -1) y = y0 + root.bannerHeight + root.bannerGap + return y + root.shakeY } color: Util.alpha(Color.popups.background, 0.97) borderSpec: Border.surfaceSpec("popups", "border", Color.popups.border, Math.max(1, Style.space(2))) @@ -724,16 +797,20 @@ Item { visible: root.bannerVisible() width: root.comboBannerWidth() height: root.bannerHeight + // The banner anchors to the card's outward edge so it grows toward the + // screen center (left-anchored when the card is on the left, right- + // anchored when on the right, centered otherwise); the group clamp on + // the card keeps it on screen. Vertically it tracks the group Y0 so it + // stays stacked with the card as the offset moves. x: { - var bx = card.x - root.shakeX + (card.width - width) / 2 - return bx + root.shakeX + var mode = root.sideMode() + if (mode === "left") return card.x + if (mode === "right") return card.x + card.width - width + return card.x + (card.width - width) / 2 } - // Bottom positions: banner at the bottom edge, card above it. - // Top positions: banner at the top edge, card below it. y: { - var by = root.position.indexOf("top") !== -1 - ? root.margin - : panel.height - height - root.margin + var y0 = root.groupY0() + var by = root.position.indexOf("top") !== -1 ? y0 : y0 + card.height + root.bannerGap return by + root.shakeY } color: Util.alpha(Color.popups.background, 0.97) diff --git a/Panel.qml b/Panel.qml index 4d55447..a3d7559 100644 --- a/Panel.qml +++ b/Panel.qml @@ -30,6 +30,18 @@ Panel { property int lingerMs: 1000 property int historyCount: 1 property bool comboMode: false + // Manual fine-tune offsets (px) written by the D-pad; reset when a preset + // position is chosen from the dropdown. + property int offsetX: 0 + property int offsetY: 0 + // How many pixels a D-pad click moves the card. + readonly property int nudgeStep: 4 + // The display's state file: the D-pad injects a virtual arrow key here so + // the visualizer renders the move like a real keypress. + readonly property string statePath: { + var runtime = Quickshell.env("XDG_RUNTIME_DIR") + return (runtime && runtime.length > 0 ? runtime : "/tmp") + "/omarchy-key-visualizer.json" + } // Config lives outside the plugin folder (the shell reloads all plugin // code on any file change under ~/.config/omarchy/plugins/), so editing it @@ -77,6 +89,8 @@ Panel { if (isFinite(cfg.lingerMs) && cfg.lingerMs >= 0) root.lingerMs = Math.round(cfg.lingerMs) if (isFinite(cfg.historyCount)) root.historyCount = Math.max(1, Math.min(5, Math.round(cfg.historyCount))) root.comboMode = cfg.comboMode === true + if (isFinite(cfg.offsetX)) root.offsetX = Math.round(cfg.offsetX) + if (isFinite(cfg.offsetY)) root.offsetY = Math.round(cfg.offsetY) } // Round-trips every option so a menu edit never drops values the display @@ -88,18 +102,88 @@ Panel { margin: root.margin, lingerMs: root.lingerMs, historyCount: root.historyCount, - comboMode: root.comboMode + comboMode: root.comboMode, + offsetX: root.offsetX, + offsetY: root.offsetY } for (var k in update) cfg[k] = update[k] if (update.mode !== undefined) root.mode = update.mode if (update.position !== undefined) root.position = update.position if (update.historyCount !== undefined) root.historyCount = update.historyCount if (update.comboMode !== undefined) root.comboMode = update.comboMode + if (update.offsetX !== undefined) root.offsetX = update.offsetX + if (update.offsetY !== undefined) root.offsetY = update.offsetY writeProc.command = ["sh", "-c", "printf '%s\\n' '" + JSON.stringify(cfg) + "' > " + Util.shellQuote(root.configPath)] writeProc.running = true } + // Nudge the card by one step and feed a virtual arrow key to the visualizer + // through its state file, so the move shows up in the history like a real + // keypress: we write the arrow down, then release it a moment later. + function nudge(dx, dy) { + var nx = Math.max(-2000, Math.min(2000, root.offsetX + dx * root.nudgeStep)) + var ny = Math.max(-2000, Math.min(2000, root.offsetY + dy * root.nudgeStep)) + root.writeConfig({ offsetX: nx, offsetY: ny }) + var arrow = dx < 0 ? "←" : dx > 0 ? "→" : dy < 0 ? "↑" : "↓" + var now = Math.floor(Date.now() / 1000) + nudgeKeyProc.command = ["sh", "-c", + "printf '%s' '" + JSON.stringify({ keys: [arrow], t: now }) + "' > " + Util.shellQuote(root.statePath)] + nudgeKeyProc.running = true + nudgeReleaseTimer.interval = 120 + nudgeReleaseTimer.restart() + } + + // Press-and-hold helpers for the D-pad: a tap moves once; holding starts the + // repeat timer so the card keeps moving (and the arrow keeps feeding the + // visualizer) until the button is released. + function startNudge(dx, dy) { + root.nudge(dx, dy) + nudgeHoldTimer.dx = dx + nudgeHoldTimer.dy = dy + nudgeHoldTimer.interval = 280 + nudgeHoldTimer.repeat = false + nudgeHoldTimer.restart() + } + + function stopNudge() { + nudgeHoldTimer.interval = 280 + nudgeHoldTimer.repeat = false + nudgeHoldTimer.stop() + } + + Process { + id: nudgeKeyProc + } + + Timer { + id: nudgeReleaseTimer + interval: 120 + repeat: false + onTriggered: { + var now = Math.floor(Date.now() / 1000) + nudgeKeyProc.command = ["sh", "-c", + "printf '%s' '" + JSON.stringify({ keys: [], t: now }) + "' > " + Util.shellQuote(root.statePath)] + nudgeKeyProc.running = true + } + } + + // While a D-pad button is held: the first move happens on press (in + // startNudge), then after a short delay this timer repeats every 60ms. + Timer { + id: nudgeHoldTimer + interval: 280 + repeat: false + property int dx: 0 + property int dy: 0 + onTriggered: { + root.nudge(dx, dy) + interval = 60 + repeat = true + restart() + } + } + Process { id: writeProc } @@ -250,7 +334,75 @@ Panel { { value: "bottom-center", label: "Bottom center" }, { value: "bottom-right", label: "Bottom right" } ] - onChanged: function(v) { root.writeConfig({ position: v }) } + onChanged: function(v) { root.writeConfig({ position: v, offsetX: 0, offsetY: 0 }) } + } + + // Fine-tune D-pad: 4px per click; holding a button keeps moving (the + // move repeats after a short delay). Each nudge also feeds a virtual + // arrow key to the visualizer, so the move shows up in its history. + // The Button draws the look; a transparent MouseArea on top captures + // press/release so a held button can repeat. + RowLayout { + width: parent.width + spacing: Style.spacing.xs + + Item { + Layout.fillWidth: true + Layout.preferredHeight: Style.spacing.controlHeight + Button { + anchors.fill: parent + text: "←" + tooltipText: "Move left" + } + MouseArea { + anchors.fill: parent + hoverEnabled: false + onPressed: root.startNudge(-1, 0) + onReleased: root.stopNudge() + } + } + + ColumnLayout { + spacing: Style.spacing.xxs + Layout.fillWidth: true + Repeater { + model: [ + { dx: 0, dy: -1, label: "↑", tip: "Move up" }, + { dx: 0, dy: 1, label: "↓", tip: "Move down" } + ] + delegate: Item { + Layout.fillWidth: true + Layout.preferredHeight: Math.round(Style.spacing.controlHeight / 2) + Button { + anchors.fill: parent + text: modelData.label + tooltipText: modelData.tip + } + MouseArea { + anchors.fill: parent + hoverEnabled: false + onPressed: root.startNudge(modelData.dx, modelData.dy) + onReleased: root.stopNudge() + } + } + } + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: Style.spacing.controlHeight + Button { + anchors.fill: parent + text: "→" + tooltipText: "Move right" + } + MouseArea { + anchors.fill: parent + hoverEnabled: false + onPressed: root.startNudge(1, 0) + onReleased: root.stopNudge() + } + } } // Linger --------------------------------------------------------