Files
omarchy-key-visualizer/KeyVisualizer.qml
T
felixzsh 5670aca888 Move config out of the plugin dir so edits don't reload the plugin
The shell watches every file under ~/.config/omarchy/plugins/ with
inotify (no extension filter) and reloads all plugin code on any change,
so config.json there made every edit tear down and rebuild the panel and
bar widget. Config now lives at ~/.config/omarchy/key-visualizer.json, which
is not watched: edits update the display live. The panel migrates the
old plugin-dir config (values carried over, file removed) on first load
after the update.
2026-08-11 13:42:24 -05:00

368 lines
13 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.
//
// 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
property var keys: []
// 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.
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 bottom-center (default) or any top/bottom + left/center/
// right combination, keyviz-style.
// margin distance from the screen edge in px (default 67).
// lingerMs how long a released combo stays (default 1000).
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 chipPadX: Style.space(9)
readonly property int chipPadY: Style.space(4)
readonly property int chipHeight: Math.ceil(chipTextMetrics.height) + 2 * chipPadY
function chipWidth(label) {
chipMetrics.text = String(label)
return Math.ceil(chipMetrics.advanceWidth) + 2 * chipPadX
}
function contentWidth() {
var w = 0
for (var i = 0; i < root.keys.length; i++) w += chipWidth(root.keys[i])
return w + Math.max(0, root.keys.length - 1) * chipGap
}
TextMetrics {
id: chipMetrics
font: chipFont
}
FontMetrics {
id: chipTextMetrics
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 = []
}
if (next.length === 0) {
// Keys were released: keep the last combo on screen for the linger
// window, then clear it. A fresh press restarts the timer below.
hideTimer.restart()
} else {
hideTimer.stop()
root.keys = next
root.opened = true
}
}
Timer {
id: hideTimer
interval: root.lingerMs
onTriggered: {
root.keys = []
root.opened = false
}
}
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) {
hideTimer.stop()
root.keys = []
root.opened = false
}
function setPaused(p) {
if (p === root.paused) return
var cmd = p
? "printf 1 > " + Util.shellQuote(root.pausePath)
: "rm -f " + 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) root.position = cfg.position
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)
}
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}'
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.keys.length > 0
width: card.borderLeft + root.cardPad + root.contentWidth() + root.cardPad + card.borderRight
height: card.borderTop + root.cardPad + root.chipHeight + 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)
}
y: {
var p = root.position
if (p.indexOf("top") !== -1) return root.margin
if (p.indexOf("bottom") !== -1) return parent.height - height - root.margin
return Math.round((parent.height - height) / 2)
}
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
Row {
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.chipGap
Repeater {
model: root.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
}
}
}
}
}
}
}