From 0a4aa59ee1a388b68a7c797469f810304f7398a0 Mon Sep 17 00:00:00 2001 From: felixzsh Date: Wed, 12 Aug 2026 10:45:16 -0500 Subject: [PATCH] feat: add combo mode with combo counter, score and escalating effects A 'Combo mode' toggle turns the display into a game-style counter. Chords containing a modifier (Super/Ctrl/Alt/Shift/Menu/AltGr) are combos: they build a combo counter, apply a multiplier (x2 at 5, x3 at 10 ... x8 at 50) and escalate the effects. Plain characters and shifted chars typed alone are hits: +10 score only, they never touch the combo counter or its window. Scoring: hit = 10, combo = (20 + 15*(mods-1) + 5*(keys-1)) * multiplier. Counted once per physical chord on the Lua's empty payload (release), so the intermediate growing emits of a chord never double-count. The banner (COMBO 12 x3 - 3,450) sits below the history for bottom positions and above it for top positions, with a floating +N pop per press. Effects scale with modifiers and chain length: banner pulse, hue shift, screen shake (up to 5px), continuous hue cycling at 10+ combos and a constant subtle vibration at 20+. The combo resets after comboWindowMs (2s) without a new combo; the score accumulates for the shell session. Also fixes the README Behavior section: the history bullet was never applied (a failed multi-edit call), now restored alongside the combo mode docs. Manifest bumped to 1.7.0. --- KeyVisualizer.qml | 320 ++++++++++++++++++++++++++++++++++++++++++++-- Panel.qml | 34 ++++- README.md | 33 ++++- manifest.json | 2 +- 4 files changed, 378 insertions(+), 11 deletions(-) diff --git a/KeyVisualizer.qml b/KeyVisualizer.qml index e4f2ce2..db41e0b 100644 --- a/KeyVisualizer.qml +++ b/KeyVisualizer.qml @@ -12,7 +12,8 @@ import qs.Ui // and renders. No images, no animations: the combo appears while held and // lingers briefly after release (like keyviz's Duration), then vanishes. // With historyCount > 1 the last few combos stack as a fading history, -// keyviz-style. +// keyviz-style. Combo mode ("game mode") adds a score banner and effects +// on top of the same display. // // On first load the panel also appends a small guarded block to // ~/.config/hypr/hyprland.lua that dofiles the capture script, so install @@ -38,6 +39,25 @@ 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 + + // Combo mode — "game mode". Toggle in the panel. Chords that contain a + // modifier (Super/Ctrl/Alt/Shift/Menu/AltGr) are COMBOs: they build a + // combo counter, apply a multiplier and escalate the effects. Plain + // characters (and shifted chars typed alone, which the Lua folds into + // the character) are HITs: basic score only, they never touch the combo + // counter or its window. Counted when the chord completes (the Lua's + // empty payload), so one physical chord is exactly one press even though + // the Lua emits intermediate growing states while keys are added. + property bool comboMode: false + property int comboCount: 0 + property int comboScore: 0 + property int multiplier: 1 + property real comboHue: 0.12 + property real bannerScale: 1.0 + property real bannerPulseTo: 1.2 + property real shakeX: 0.0 + property real shakeY: 0.0 + property real popOffsetY: 0.0 // Drop state written more than this long ago (e.g. from a previous shell // session after a restart) so a stale combo never sticks on screen. readonly property int maxStateAgeMs: 1500 @@ -152,6 +172,134 @@ Item { return list } + // --------------------------------------------------- combo mode + + // Combo mode tuning — all adjustable: + readonly property int comboWindowMs: 2000 // max gap between combos before the counter resets + readonly property int hitPoints: 10 // score for a plain character (HIT) + readonly property int comboBasePoints: 20 // score for a 1-mod COMBO before the multiplier + readonly property int comboPerMod: 15 // extra points per additional modifier + readonly property int comboPerKey: 5 // extra points per key in the chord + readonly property int bannerPadX: Style.space(14) + readonly property int bannerPadY: Style.space(6) + readonly property int bannerGap: Style.space(8) + readonly property int bannerHeight: Math.ceil(bannerFontMetrics.height) + 2 * bannerPadY + + function multiplierFor(count) { + if (count >= 50) return 8 + if (count >= 40) return 7 + if (count >= 30) return 6 + if (count >= 20) return 5 + if (count >= 15) return 4 + if (count >= 10) return 3 + if (count >= 5) return 2 + return 1 + } + + function modCountOf(keys) { + var n = 0 + for (var i = 0; i < keys.length; i++) if (root.modLabels.indexOf(keys[i]) !== -1) n++ + return n + } + + function tierOf(count) { + if (count >= 20) return 3 + if (count >= 10) return 2 + if (count >= 5) return 1 + return 0 + } + + function formatScore(n) { + var s = String(n) + var out = "" + var c = 0 + for (var i = s.length - 1; i >= 0; i--) { + out = s[i] + out + c++ + if (c % 3 === 0 && i > 0) out = "," + out + } + return out + } + + // Called once per completed physical chord (see apply()). Classifies the + // chord as HIT (no modifiers) or COMBO (1+ modifiers) and scores it. + function pressCombo(keys) { + if (!root.comboMode) return + var mods = root.modCountOf(keys) + if (mods === 0) { + // HIT: plain characters never build the combo, they just score the + // basics — the "normal punch" of the game. + root.comboScore += root.hitPoints + root.bannerPulseTo = 1.08 + bannerPulseAnim.restart() + root.popScore("+" + root.hitPoints, -1) + return + } + // A chord made only of modifiers is not a real combo — modifiers of + // nothing. It scores zero and touches neither the counter nor the + // window. A valid combo always ends on a non-modifier key. + if (mods >= keys.length) return + // COMBO: modifiers present plus at least one real key — the real deal. + root.comboCount++ + root.multiplier = root.multiplierFor(root.comboCount) + var pts = (root.comboBasePoints + root.comboPerMod * (mods - 1) + + root.comboPerKey * (keys.length - 1)) * root.multiplier + root.comboScore += pts + comboWindowTimer.restart() + var tier = root.tierOf(root.comboCount) + // Hue shifts with modifiers and chain length; the colorTimer cycles it + // continuously once the combo is hot. + root.comboHue = (0.12 + mods * 0.06 + root.comboCount * 0.008) % 1 + root.bannerPulseTo = Math.min(1.6, 1.2 + mods * 0.06 + root.comboCount * 0.004) + bannerPulseAnim.restart() + root.popScore("+" + pts, root.comboHue) + root.triggerShake(mods, tier) + } + + // Floating "+N" pop above the banner. hue < 0 renders in the normal text + // color (hits); otherwise in the animated combo hue. + function popScore(text, hue) { + scorePop.text = text + scorePop.color = hue < 0 ? Color.popups.text : Qt.hsva(hue, 0.85, 1) + scorePop.visible = true + 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. + function triggerShake(mods, tier) { + if (tier >= 3) return + var amp = Math.min(5, 1 + mods * 0.8 + tier * 1.2) + shake1.to = amp + shake2.to = -amp + shake3.to = -amp + shake4.to = amp + shakeAnim.restart() + } + + function comboBannerText() { + if (root.comboCount > 0) { + var t = "COMBO " + root.comboCount + if (root.multiplier > 1) t += " ×" + root.multiplier + return t + " · " + root.formatScore(root.comboScore) + } + return "SCORE " + root.formatScore(root.comboScore) + } + + function comboBannerWidth() { + return Math.ceil(bannerFontMetrics.advanceWidth(root.comboBannerText())) + 2 * bannerPadX + } + + function comboBannerColor() { + if (root.comboCount <= 0) return Color.popups.text + return Qt.hsva(root.comboHue, 0.85, 1) + } + + function bannerVisible() { + return root.comboMode && root.comboScore > 0 + } + FontMetrics { id: chipFontMetrics font: chipFont @@ -163,6 +311,17 @@ Item { bold: true }) + readonly property var bannerFont: Qt.font({ + family: Style.font.family, + pixelSize: Math.round(Style.font.title * 1.35), + bold: true + }) + + FontMetrics { + id: bannerFontMetrics + font: bannerFont + } + // ------------------------------------------------------------- state function apply() { @@ -185,9 +344,14 @@ Item { var es = root.entries.slice() if (next.length === 0) { // All keys released: the newest combo enters its linger window; the - // history tick prunes it once lingerMs passes. + // history tick prunes it once lingerMs passes. This empty payload is + // also the chord-completion signal: the full combo that just ended is + // the one being pushed into its linger window, so count it exactly + // once here (never on the intermediate growing emits). if (es.length > 0 && es[0].releasedAt === 0) { + var completed = es[0].keys.slice() es[0] = { keys: es[0].keys, releasedAt: Date.now() } + root.pressCombo(completed) } } else if (es.length > 0 && root.sameKeys(es[0].keys, next)) { // Same combo re-pressed (or the state file re-fired): refresh it, @@ -222,9 +386,54 @@ Item { } root.entries = root.trimEntries(kept) root.opened = root.entries.length > 0 + // The linger window passed and the display cleared: the run is over, + // so the score resets with it (the banner hides again). + if (root.entries.length === 0 && root.comboScore !== 0) { + root.comboScore = 0 + } } } + // Combo window: when no new COMBO arrives in time, the counter resets + // (the score stays). Hits never touch this timer. + Timer { + id: comboWindowTimer + interval: root.comboWindowMs + onTriggered: { + root.comboCount = 0 + root.multiplier = 1 + root.shakeX = 0 + root.shakeY = 0 + } + } + + // Tier 4 (20+ combos): continuous subtle vibration while the combo is + // hot. Press shakes are skipped at this tier so they never fight. + Timer { + id: jitterTimer + interval: 80 + running: root.comboMode && root.comboCount >= 20 + onTriggered: { + root.shakeX = (Math.random() - 0.5) * 3 + root.shakeY = (Math.random() - 0.5) * 3 + } + } + + // Hot combos cycle the hue continuously instead of only on presses. + Timer { + id: colorTimer + interval: 60 + running: root.comboMode && root.comboCount >= 10 + onTriggered: root.comboHue = (root.comboHue + 0.012) % 1 + } + + onComboModeChanged: if (!root.comboMode) { + root.comboCount = 0 + root.multiplier = 1 + root.shakeX = 0 + root.shakeY = 0 + } + FileView { id: stateFile path: root.statePath @@ -279,6 +488,7 @@ Item { if (isFinite(cfg.margin) && cfg.margin >= 0) root.margin = Math.round(cfg.margin) 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 } function migrateConfig() { @@ -412,17 +622,29 @@ Item { height: card.borderTop + root.cardPad + root.contentHeight() + root.cardPad + card.borderBottom x: { var p = root.position - if (p.indexOf("left") !== -1) return root.margin - if (p.indexOf("right") !== -1) return parent.width - width - root.margin - return Math.round((parent.width - width) / 2) + 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 } // 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). + // 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. y: { var p = root.position - if (p.indexOf("top") !== -1) return root.margin - return parent.height - height - root.margin + 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 } color: Util.alpha(Color.popups.background, 0.97) borderSpec: Border.surfaceSpec("popups", "border", Color.popups.border, Math.max(1, Style.space(2))) @@ -468,5 +690,87 @@ Item { } } } + + // Combo mode banner — a separate visual stacked against the history + // card (below it for bottom positions, above it for top positions). + // Shows the combo counter, multiplier and running score; hue and + // border follow the combo color, and the whole banner pulses on each + // press. Shakes with the card via the shared shakeX/shakeY offsets. + BorderSurface { + id: banner + visible: root.bannerVisible() + width: root.comboBannerWidth() + height: root.bannerHeight + x: { + var bx = card.x - root.shakeX + (card.width - width) / 2 + return bx + root.shakeX + } + // 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 + return by + root.shakeY + } + color: Util.alpha(Color.popups.background, 0.97) + borderSpec: Border.surfaceSpec("popups", "border", root.comboBannerColor(), Math.max(1, Style.space(2))) + radius: Style.cornerRadius + scale: root.bannerScale + transformOrigin: Item.Center + + Text { + id: bannerText + anchors.centerIn: parent + text: root.comboBannerText() + font: root.bannerFont + color: root.comboBannerColor() + } + } + + // Transient "+N" pop of the points just scored, floating up from the + // banner. Reused for every press (hit or combo). + Text { + id: scorePop + visible: false + font: root.bannerFont + x: banner.x + (banner.width - width) / 2 + y: banner.y - height - Style.space(8) + opacity: 0 + transform: Translate { id: popTranslate; y: root.popOffsetY } + } + + // Banner pulse on each press (hits pulse small, combos grow with mods). + SequentialAnimation { + id: bannerPulseAnim + running: false + NumberAnimation { target: root; property: "bannerScale"; from: 1.0; to: root.bannerPulseTo; duration: 70 } + NumberAnimation { target: root; property: "bannerScale"; to: 1.0; duration: 220; easing.type: Easing.OutBack } + } + + // Press shake: a few quick offset steps around the base position. The + // amplitudes are set by triggerShake() before restarting. + SequentialAnimation { + id: shakeAnim + running: false + NumberAnimation { id: shake1; target: root; property: "shakeX"; to: 2; duration: 30 } + NumberAnimation { id: shake2; target: root; property: "shakeY"; to: -2; duration: 30 } + NumberAnimation { id: shake3; target: root; property: "shakeX"; to: -2; duration: 30 } + NumberAnimation { id: shake4; target: root; property: "shakeY"; to: 2; duration: 30 } + NumberAnimation { target: root; property: "shakeX"; to: 0; duration: 40 } + NumberAnimation { target: root; property: "shakeY"; to: 0; duration: 40 } + } + + // The +N score pop: snap in, then float up while fading and shrinking. + SequentialAnimation { + id: popAnim + running: false + ScriptAction { script: { scorePop.opacity = 1; scorePop.scale = 1.45; root.popOffsetY = 0 } } + ParallelAnimation { + NumberAnimation { target: scorePop; property: "opacity"; to: 0; duration: 500; easing.type: Easing.OutQuad } + NumberAnimation { target: scorePop; property: "scale"; to: 1.0; duration: 500; easing.type: Easing.OutQuad } + NumberAnimation { target: root; property: "popOffsetY"; to: -24; duration: 500; easing.type: Easing.OutQuad } + } + } } } diff --git a/Panel.qml b/Panel.qml index a018511..53d60da 100644 --- a/Panel.qml +++ b/Panel.qml @@ -29,6 +29,7 @@ Panel { property int margin: 67 property int lingerMs: 1000 property int historyCount: 1 + property bool comboMode: false // Config lives outside the plugin folder (the shell reloads all plugin // code on any file change under ~/.config/omarchy/plugins/), so editing it @@ -76,6 +77,7 @@ Panel { if (isFinite(cfg.margin) && cfg.margin >= 0) root.margin = Math.round(cfg.margin) 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 } // Round-trips every option so a menu edit never drops values the display @@ -86,12 +88,14 @@ Panel { position: root.position, margin: root.margin, lingerMs: root.lingerMs, - historyCount: root.historyCount + historyCount: root.historyCount, + comboMode: root.comboMode } 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 writeProc.command = ["sh", "-c", "printf '%s\\n' '" + JSON.stringify(cfg) + "' > " + Util.shellQuote(root.configPath)] writeProc.running = true @@ -168,6 +172,34 @@ Panel { } } + // Combo mode ----------------------------------------------------- + // Game mode: combo counter + score with escalating effects (pulse, + // hue, screen shake). Chords with modifiers build the combo; plain + // characters are hits that only add score. + Item { + width: parent.width + height: comboSwitch.implicitHeight + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "Combo mode" + font.family: Style.font.family + font.pixelSize: Style.font.body + color: Color.popups.text + } + + ToggleSwitch { + id: comboSwitch + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: root.comboMode + foreground: Color.popups.text + accent: Color.accent + onToggled: root.writeConfig({ comboMode: !root.comboMode }) + } + } + // Mode ---------------------------------------------------------- Item { width: parent.width diff --git a/README.md b/README.md index 59d0855..13f71d3 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,35 @@ plugin, not just this one. lingering combo is always the last full one, never a partial release state. `lingerMs: 0` means "always show": the last combo stays on screen until the next key replaces it. +- **History** (`historyCount`, 1–5, default 1) — the last few combos stack + on screen instead of vanishing. Each new combo pushes the previous one + down the stack; the current combo is fully opaque and every older entry + fades step by step, so a `Super + G` → `Super + Shift + G` sequence shows + both, with the older one visibly dimmer. The stack direction follows the + position: top positions grow downward (newest on top), bottom positions + grow upward (newest at the bottom edge) — not configurable, by design. + With `lingerMs: 0` entries never expire, so the stack keeps the last few + combos on screen until newer ones push them out. +- **Combo mode** (toggle in the panel, `comboMode` in config) — turns the + display into a game-style counter. Chords that contain a modifier + (`Super`, `Ctrl`, `Alt`, `Shift`, `Menu`, `AltGr`) are **combos**: they + build a combo counter, apply a multiplier (`×2` at 5, `×3` at 10, … up to + `×8` at 50) and escalate the effects. Plain characters (and shifted chars + typed alone, which render as the character itself) are **hits**: +10 + score only, they never touch the combo counter. Scoring: + `hit = +10`, `combo = (20 + 15×(mods−1) + 5×(keys−1)) × multiplier`. + The banner (`COMBO 12 ×3 · 3,450`) sits below the history for bottom + positions and above it for top positions, with a floating `+N` pop per + press. Effects scale with modifiers and chain length: banner pulse, + hue shift, screen shake (up to 5px) and, at 10+ combos, continuous hue + cycling — at 20+, a constant subtle vibration while the combo is hot. + The combo counter resets after `comboWindowMs` (2s) without a new combo, + and when the linger window then passes (the display clears) the **score + resets with it**, so every run starts from zero. A chord made only of + modifiers (`Super` alone, `Super+Ctrl+Shift`) is not a valid combo — + modifiers of nothing — and scores zero; a valid combo always ends on a + non-modifier key. Counted once + per physical chord (on release), so growing key presses never double-count. - In `mode: "bindings"` only combos with a modifier are shown; plain typing stays off screen. @@ -142,7 +171,8 @@ the plugin on every edit. Editing this file updates the display live: "position": "bottom-center", "margin": 67, "lingerMs": 1000, - "historyCount": 1 + "historyCount": 1, + "comboMode": false } ``` @@ -153,6 +183,7 @@ the plugin on every edit. Editing this file updates the display live: | `margin` | px from the screen edge | `67` | | `lingerMs` | ms a released combo stays; `0` keeps it until the next key (keyviz defaults to `5000`) | `1000` | | `historyCount` | how many combos stack on screen (1–5); older entries fade | `1` | +| `comboMode` | `true`/`false` — game-style combo counter, score and effects | `false` | Deeper tweaks still live in the QML/Lua sources: diff --git a/manifest.json b/manifest.json index fb3c153..dd57297 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "felixzsh.key-visualizer", "name": "Key Visualizer", - "version": "1.6.0", + "version": "1.7.0", "author": "felixzsh", "description": "Shows the keys you press on screen. Great for keybinding tutorials, demos, and screencasts.", "kinds": [