d6c608374c
The last historyCount combos (1-5, default 1) stack on screen instead of vanishing: each new combo pushes the previous one down the stack and every older entry fades step by step (entryOpacity gradient). The stack direction follows the position by default — top positions grow downward (newest on top), bottom positions grow upward (newest at the bottom edge) — and is not configurable. The panel gains a History NumberField for the count. Middle positions (center-left/center-right/center-center) were removed from the dropdown and any leftover config value is folded into the bottom row. With lingerMs 0 entries never expire, so the stack keeps the last few combos on screen until newer ones push them out. Manifest bumped to 1.6.0.
473 lines
18 KiB
QML
473 lines
18 KiB
QML
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import Quickshell.Wayland
|
|
import qs.Commons
|
|
import qs.Ui
|
|
|
|
// Key Visualizer — shows the keys you press as small chips at the bottom of
|
|
// the screen. The capture side is key-visualizer.lua (Hyprland Lua): it listens
|
|
// to the compositor's key events and writes the current combination to
|
|
// $XDG_RUNTIME_DIR/omarchy-key-visualizer.json. This panel watches that file
|
|
// 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.
|
|
//
|
|
// On first load the panel also appends a small guarded block to
|
|
// ~/.config/hypr/hyprland.lua that dofiles the capture script, so install
|
|
// is just add + enable; Hyprland auto-reloads its config on save. The
|
|
// block no-ops if the plugin folder is later removed.
|
|
Item {
|
|
id: root
|
|
|
|
property bool opened: false
|
|
// History of recent combinations, newest first. Each entry is
|
|
// { keys: [...], releasedAt: 0|ms }: 0 while it is the combo currently
|
|
// being pressed; an epoch ms once a newer combo or a release superseded
|
|
// it. The history tick prunes entries whose linger window passed. With
|
|
// historyCount 1 this is exactly "the current combo, lingering".
|
|
property var entries: []
|
|
// How many combos stay on screen (1..5, default 1). Older entries fade
|
|
// out via the entryOpacity() gradient; a count of 1 is the classic
|
|
// current-combo-only display.
|
|
property int historyCount: 1
|
|
// How long the last combination stays on screen after the keys are
|
|
// released (keyviz's "Duration"; keyviz defaults to 5000ms). The combo
|
|
// lingers intact, then vanishes in one frame — no fade. 0 means "always
|
|
// show": entries never expire, so the stack only shrinks when newer
|
|
// combos push old ones out of the history window.
|
|
property int lingerMs: 1000
|
|
// 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
|
|
|
|
// Options read from config.json in the plugin folder (created with
|
|
// defaults on first run, hot-reloaded on save):
|
|
// mode "all" | "bindings" — bindings only shows combos with a
|
|
// modifier, ignoring plain typing (tutorial mode).
|
|
// position one of the six corners/edges: top/bottom + left/center/right.
|
|
// Middle positions were dropped — the stack anchors to the top
|
|
// (grows down) or the bottom (grows up) edge.
|
|
// margin distance from the screen edge in px (default 67).
|
|
// lingerMs how long a released combo stays (default 1000).
|
|
// historyCount how many combos stack on screen (1..5, default 1).
|
|
property string mode: "all"
|
|
property string position: "bottom-center"
|
|
property int margin: Style.space(67)
|
|
readonly property var modLabels: ["Super", "Ctrl", "Alt", "Shift", "Menu", "AltGr"]
|
|
|
|
// Options live at ~/.config/omarchy/key-visualizer.json rather than inside the
|
|
// plugin folder on purpose: the shell watches every file under
|
|
// ~/.config/omarchy/plugins/ and reloads all plugin code on any change, so
|
|
// a config edit there would tear down and rebuild the panel. Editing this
|
|
// file updates the display live.
|
|
readonly property string configPath: Quickshell.env("HOME") + "/.config/omarchy/key-visualizer.json"
|
|
// Pre-1.3.1 config lived in the watched plugin dir; migrated once on first
|
|
// load after the move.
|
|
readonly property string legacyConfigPath: Quickshell.env("HOME") + "/.config/omarchy/plugins/felixzsh.key-visualizer/config.json"
|
|
|
|
readonly property string statePath: {
|
|
var runtime = Quickshell.env("XDG_RUNTIME_DIR")
|
|
return (runtime && runtime.length > 0 ? runtime : "/tmp") + "/omarchy-key-visualizer.json"
|
|
}
|
|
|
|
// Pause flag shared with the bar widget: while the file holds "1" the
|
|
// display is frozen (keys are ignored). The bar button writes/removes the
|
|
// file; both sides watch it, so a click on any monitor updates all of them.
|
|
property bool paused: false
|
|
readonly property string pausePath: {
|
|
var runtime = Quickshell.env("XDG_RUNTIME_DIR")
|
|
return (runtime && runtime.length > 0 ? runtime : "/tmp") + "/omarchy-key-visualizer.paused"
|
|
}
|
|
|
|
// ------------------------------------------------------------- layout
|
|
|
|
readonly property int cardPad: Style.space(10)
|
|
readonly property int chipGap: Style.space(8)
|
|
readonly property int entryGap: Style.space(6)
|
|
readonly property int chipPadX: Style.space(9)
|
|
readonly property int chipPadY: Style.space(4)
|
|
readonly property int chipHeight: Math.ceil(chipFontMetrics.height) + 2 * chipPadY
|
|
|
|
// Stateless measurement: FontMetrics.advanceWidth(text) returns the
|
|
// width for the given string directly. The previous shared TextMetrics
|
|
// (text set imperatively inside the width bindings) went stale from the
|
|
// third chip onwards, collapsing every container to single-char width.
|
|
function chipWidth(label) {
|
|
return Math.ceil(chipFontMetrics.advanceWidth(String(label))) + 2 * chipPadX
|
|
}
|
|
|
|
function rowWidth(keys) {
|
|
var w = 0
|
|
for (var i = 0; i < keys.length; i++) w += chipWidth(keys[i])
|
|
return w + Math.max(0, keys.length - 1) * chipGap
|
|
}
|
|
|
|
// The card sizes to the widest history row, not the current one, so a
|
|
// wider older entry never clips.
|
|
function contentWidth() {
|
|
var w = 0
|
|
for (var i = 0; i < root.entries.length; i++) w = Math.max(w, rowWidth(root.entries[i].keys))
|
|
return w
|
|
}
|
|
|
|
function contentHeight() {
|
|
if (root.entries.length === 0) return 0
|
|
return root.entries.length * root.chipHeight + (root.entries.length - 1) * root.entryGap
|
|
}
|
|
|
|
// 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.
|
|
function entryOpacity(pos) {
|
|
return Math.max(0.25, 1 - pos * 0.22)
|
|
}
|
|
|
|
function sameKeys(a, b) {
|
|
if (a.length !== b.length) return false
|
|
var sa = a.slice().sort()
|
|
var sb = b.slice().sort()
|
|
for (var i = 0; i < sa.length; i++) if (sa[i] !== sb[i]) return false
|
|
return true
|
|
}
|
|
|
|
function trimEntries(list) {
|
|
while (list.length > root.historyCount) list.pop()
|
|
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.
|
|
function displayModel() {
|
|
var list = []
|
|
var n = root.entries.length
|
|
if (root.position.indexOf("bottom") !== -1) {
|
|
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 })
|
|
}
|
|
return list
|
|
}
|
|
|
|
FontMetrics {
|
|
id: chipFontMetrics
|
|
font: chipFont
|
|
}
|
|
|
|
readonly property var chipFont: Qt.font({
|
|
family: Style.font.family,
|
|
pixelSize: Style.font.title,
|
|
bold: true
|
|
})
|
|
|
|
// ------------------------------------------------------------- state
|
|
|
|
function apply() {
|
|
var next = []
|
|
if (!root.paused) {
|
|
try {
|
|
var parsed = JSON.parse(stateFile.text())
|
|
if (parsed && Array.isArray(parsed.keys)) {
|
|
var age = Math.floor(Date.now() / 1000) - (parsed.t || 0)
|
|
if (age <= Math.ceil(root.maxStateAgeMs / 1000)) next = parsed.keys
|
|
}
|
|
} catch (e) {}
|
|
} if (next.length > 0 && root.mode === "bindings") {
|
|
var hasMod = false
|
|
for (var i = 0; i < next.length; i++) {
|
|
if (root.modLabels.indexOf(next[i]) !== -1) { hasMod = true; break }
|
|
}
|
|
if (!hasMod) next = []
|
|
}
|
|
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.
|
|
if (es.length > 0 && es[0].releasedAt === 0) {
|
|
es[0] = { keys: es[0].keys, releasedAt: Date.now() }
|
|
}
|
|
} else if (es.length > 0 && root.sameKeys(es[0].keys, next)) {
|
|
// Same combo re-pressed (or the state file re-fired): refresh it,
|
|
// no duplicate history entry.
|
|
es[0] = { keys: es[0].keys, releasedAt: 0 }
|
|
} else {
|
|
// A new combo arrived: the previous combo becomes a history entry
|
|
// (it keeps lingering) and the new one takes the top of the stack.
|
|
if (es.length > 0 && es[0].releasedAt === 0) {
|
|
es[0] = { keys: es[0].keys, releasedAt: Date.now() }
|
|
}
|
|
es.unshift({ keys: next, releasedAt: 0 })
|
|
}
|
|
root.entries = root.trimEntries(es)
|
|
root.opened = root.entries.length > 0
|
|
}
|
|
|
|
// Prunes entries whose linger window passed and caps the stack at
|
|
// historyCount, keyviz's tick-style. With lingerMs 0 entries never
|
|
// expire: the stack only shrinks when newer combos push old ones out.
|
|
Timer {
|
|
id: historyTick
|
|
interval: 250
|
|
repeat: true
|
|
running: root.entries.length > 0
|
|
onTriggered: {
|
|
var now = Date.now()
|
|
var kept = []
|
|
for (var i = 0; i < root.entries.length; i++) {
|
|
var e = root.entries[i]
|
|
if (e.releasedAt === 0 || root.lingerMs <= 0 || now - e.releasedAt < root.lingerMs) kept.push(e)
|
|
}
|
|
root.entries = root.trimEntries(kept)
|
|
root.opened = root.entries.length > 0
|
|
}
|
|
}
|
|
|
|
FileView {
|
|
id: stateFile
|
|
path: root.statePath
|
|
watchChanges: true
|
|
printErrors: false
|
|
onLoaded: root.apply()
|
|
onFileChanged: reload()
|
|
}
|
|
|
|
FileView {
|
|
id: pauseFile
|
|
path: root.pausePath
|
|
watchChanges: true
|
|
printErrors: false
|
|
onLoaded: root.paused = (text() === "1")
|
|
onFileChanged: reload()
|
|
}
|
|
|
|
onPausedChanged: if (root.paused) {
|
|
root.entries = []
|
|
root.opened = false
|
|
}
|
|
|
|
function setPaused(p) {
|
|
if (p === root.paused) return
|
|
// Always rewrite the flag with "0" or "1", never delete it: the
|
|
// FileView watcher fires on content changes but not on deletion.
|
|
var cmd = p
|
|
? "printf 1 > " + Util.shellQuote(root.pausePath)
|
|
: "printf 0 > " + Util.shellQuote(root.pausePath)
|
|
pauseToggleProc.command = ["sh", "-c", cmd]
|
|
pauseToggleProc.running = true
|
|
}
|
|
|
|
Process {
|
|
id: pauseToggleProc
|
|
}
|
|
|
|
// --------------------------------------------------------------- options
|
|
|
|
function applyConfig(raw) {
|
|
var cfg = {}
|
|
try { cfg = JSON.parse(raw || "{}") } catch (e) {}
|
|
root.mode = cfg.mode === "bindings" ? "bindings" : "all"
|
|
if (typeof cfg.position === "string" && cfg.position.length > 0) {
|
|
// Pre-history versions had middle positions ("center-left" etc.);
|
|
// they were dropped, so fold any leftover into the bottom row.
|
|
var pos = cfg.position
|
|
if (pos.indexOf("center") === 0 || pos.indexOf("middle") === 0) pos = "bottom" + pos.slice(pos.indexOf("-"))
|
|
root.position = pos
|
|
}
|
|
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)))
|
|
}
|
|
|
|
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}'
|
|
migrateProc.command = ["sh", "-c",
|
|
"if [ -f " + Util.shellQuote(root.legacyConfigPath) + " ]; then "
|
|
+ "cp " + Util.shellQuote(root.legacyConfigPath) + " " + Util.shellQuote(root.configPath) + "; "
|
|
+ "rm -f " + Util.shellQuote(root.legacyConfigPath) + "; "
|
|
+ "else printf '%s\\n' '" + defaults + "' > " + Util.shellQuote(root.configPath) + "; fi"]
|
|
migrateProc.running = true
|
|
}
|
|
|
|
Process {
|
|
id: migrateProc
|
|
}
|
|
|
|
property bool configSeeded: false
|
|
|
|
FileView {
|
|
id: configFile
|
|
path: root.configPath
|
|
watchChanges: true
|
|
printErrors: false
|
|
onLoaded: {
|
|
root.applyConfig(text())
|
|
// First run: write the defaults so the file is discoverable and the
|
|
// options can be tuned without hunting for them. Guarded because the
|
|
// shell injects `manifest` after instantiation, which re-fires this.
|
|
if (!root.configSeeded) {
|
|
root.configSeeded = true
|
|
if (root.configPath !== "" && text() === "") root.migrateConfig()
|
|
}
|
|
}
|
|
onFileChanged: reload()
|
|
}
|
|
|
|
// ------------------------------------------------- capture hook injection
|
|
//
|
|
// The capture script (key-visualizer.lua) must run inside Hyprland's Lua
|
|
// config, but the plugin cannot register itself there — the user owns
|
|
// hyprland.lua. On first load we append a small guarded block that
|
|
// dofiles the script; Hyprland auto-reloads its config on save, so the
|
|
// whole install is: add + enable. The block is idempotent and no-ops if
|
|
// the plugin folder is later removed, so uninstalling never breaks the
|
|
// config.
|
|
|
|
readonly property string captureMarker: "-- [key-visualizer] capture hook"
|
|
readonly property string captureBlock: {
|
|
var lines = [
|
|
"",
|
|
"-- [key-visualizer] capture hook (managed by the plugin; safe to remove)",
|
|
'local kc_path = os.getenv("HOME") .. "/.config/omarchy/plugins/felixzsh.key-visualizer/key-visualizer.lua"',
|
|
'local kc_file = io.open(kc_path, "r")',
|
|
'if kc_file then kc_file:close(); dofile(kc_path) end',
|
|
""
|
|
]
|
|
return lines.join("\n")
|
|
}
|
|
|
|
function maybeInjectCapture(raw) {
|
|
if (!raw) return
|
|
if (raw.indexOf(root.captureMarker) !== -1) return
|
|
var kept = []
|
|
var lines = raw.split("\n")
|
|
for (var i = 0; i < lines.length; i++) {
|
|
// Drop an older plain dofile line (manual installs, previous versions)
|
|
// so the block below is the only reference and stays upgradeable.
|
|
if (lines[i].indexOf("key-visualizer.lua") !== -1) continue
|
|
kept.push(lines[i])
|
|
}
|
|
console.log("key-visualizer: injecting capture hook into hyprland.lua")
|
|
hyprConfFile.setText(kept.join("\n") + root.captureBlock)
|
|
injectReloadTimer.start()
|
|
}
|
|
|
|
Timer {
|
|
id: injectReloadTimer
|
|
interval: 400
|
|
onTriggered: reloadProc.running = true
|
|
}
|
|
|
|
Process {
|
|
id: reloadProc
|
|
command: ["hyprctl", "reload"]
|
|
}
|
|
|
|
FileView {
|
|
id: hyprConfFile
|
|
path: Quickshell.env("HOME") + "/.config/hypr/hyprland.lua"
|
|
watchChanges: true
|
|
printErrors: false
|
|
onLoaded: root.maybeInjectCapture(text())
|
|
onFileChanged: reload()
|
|
}
|
|
|
|
// Lifecycle required for panel plugins: summoning is a no-op because the
|
|
// display is driven by the state file; hiding closes the window.
|
|
function open(payloadJson) {}
|
|
function close() { root.opened = false }
|
|
|
|
IpcHandler {
|
|
target: "key-visualizer"
|
|
function ping(): string { return "ok" }
|
|
function state(): string { return root.opened ? "open" : "closed" }
|
|
function paused(): string { return root.paused ? "true" : "false" }
|
|
function pause(): string { root.setPaused(true); return "ok" }
|
|
function resume(): string { root.setPaused(false); return "ok" }
|
|
function toggle(): string { root.setPaused(!root.paused); return "ok" }
|
|
}
|
|
|
|
// ------------------------------------------------------------- display
|
|
|
|
PanelWindow {
|
|
id: panel
|
|
visible: root.opened
|
|
anchors { top: true; bottom: true; left: true; right: true }
|
|
color: "transparent"
|
|
WlrLayershell.namespace: "key-visualizer"
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
|
exclusionMode: ExclusionMode.Ignore
|
|
// Visual-only surface: never block clicks to the desktop below.
|
|
mask: Region {}
|
|
|
|
BorderSurface {
|
|
id: card
|
|
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
|
|
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)
|
|
}
|
|
// 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).
|
|
y: {
|
|
var p = root.position
|
|
if (p.indexOf("top") !== -1) return root.margin
|
|
return parent.height - height - root.margin
|
|
}
|
|
color: Util.alpha(Color.popups.background, 0.97)
|
|
borderSpec: Border.surfaceSpec("popups", "border", Color.popups.border, Math.max(1, Style.space(2)))
|
|
radius: Style.cornerRadius
|
|
|
|
Column {
|
|
anchors.fill: parent
|
|
anchors.topMargin: card.borderTop + root.cardPad
|
|
anchors.leftMargin: card.borderLeft + root.cardPad
|
|
anchors.bottomMargin: card.borderBottom + root.cardPad
|
|
anchors.rightMargin: card.borderRight + root.cardPad
|
|
spacing: root.entryGap
|
|
|
|
Repeater {
|
|
model: root.displayModel()
|
|
|
|
delegate: Row {
|
|
required property var modelData
|
|
spacing: root.chipGap
|
|
opacity: root.entryOpacity(modelData.pos)
|
|
|
|
Repeater {
|
|
model: modelData.entry.keys
|
|
|
|
delegate: Rectangle {
|
|
required property string modelData
|
|
width: root.chipWidth(modelData)
|
|
height: root.chipHeight
|
|
radius: Math.max(3, Style.cornerRadius - 1)
|
|
color: Util.alpha(Color.popups.text, 0.10)
|
|
border.color: Util.alpha(Color.popups.text, 0.35)
|
|
border.width: 1
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: parent.modelData
|
|
font: root.chipFont
|
|
color: Color.popups.text
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|