270 lines
10 KiB
Lua
270 lines
10 KiB
Lua
-- Key Visualizer for Omarchy — shows the keys you press on screen.
|
|
--
|
|
-- This script runs inside the Hyprland Lua config, listens to the
|
|
-- compositor's `input.keyboard.key` event, and writes the currently
|
|
-- pressed combination to a small state file that the Quickshell plugin
|
|
-- (KeyVisualizer.qml) watches and renders.
|
|
--
|
|
-- Install: add one line to ~/.config/hypr/hyprland.lua (at the bottom):
|
|
--
|
|
-- dofile(os.getenv("HOME") .. "/.config/omarchy/plugins/felixzsh.key-visualizer/key-visualizer.lua")
|
|
--
|
|
-- then reload Hyprland (`hyprctl reload`). Requires Hyprland with Lua
|
|
-- config support (0.56+).
|
|
|
|
local function shell_quote(s) return "'" .. s:gsub("'", "'\\''") .. "'" end
|
|
|
|
local function effective_uid()
|
|
local f = io.open("/proc/self/status", "r")
|
|
if not f then return nil end
|
|
for line in f:lines() do
|
|
local uid = line:match("^Uid:%s+%d+%s+(%d+)")
|
|
if uid then
|
|
f:close()
|
|
return uid
|
|
end
|
|
end
|
|
f:close()
|
|
return nil
|
|
end
|
|
|
|
local function is_runtime_secure(r)
|
|
if not r or r == "" then return false end
|
|
if r == "/tmp" then return false end
|
|
if r:sub(1, 1) ~= "/" then return false end
|
|
-- /run/user is root-controlled, so accepting only this process's systemd
|
|
-- runtime path avoids shell probes that fail in Hyprland's Lua runtime.
|
|
local uid = effective_uid()
|
|
return uid ~= nil and r == "/run/user/" .. uid
|
|
end
|
|
|
|
local runtime = os.getenv("XDG_RUNTIME_DIR")
|
|
if not is_runtime_secure(runtime) then
|
|
if runtime and runtime ~= "" then
|
|
print("[key-visualizer] insecure XDG_RUNTIME_DIR, disabling capture: " .. tostring(runtime))
|
|
else
|
|
print("[key-visualizer] XDG_RUNTIME_DIR not set, disabling capture")
|
|
end
|
|
runtime = nil
|
|
end
|
|
local STATE_FILE = runtime and (runtime .. "/omarchy-key-visualizer.json") or nil
|
|
local SUPER_FLAG = runtime and (runtime .. "/omarchy-key-visualizer-super") or nil
|
|
|
|
local function secure_write(path, content)
|
|
if not runtime or (path ~= STATE_FILE and path ~= SUPER_FLAG) then return false end
|
|
-- FileView watches the existing inode. These are fixed paths inside the
|
|
-- current user's private runtime directory, so update them in place.
|
|
local f = io.open(path, "w")
|
|
if not f then return false end
|
|
f:write(content)
|
|
f:close()
|
|
os.execute("chmod 600 " .. shell_quote(path) .. " 2>/dev/null")
|
|
return true
|
|
end
|
|
|
|
-- Modifier names, keyed by xkb keycode (evdev + 8).
|
|
local MODS = {
|
|
[50] = "Shift", [62] = "Shift", -- Shift_L / Shift_R
|
|
[37] = "Ctrl", [105] = "Ctrl", -- Control_L / Control_R
|
|
[64] = "Alt", [108] = "Alt", -- Alt_L / Alt_R (AltGr on some layouts)
|
|
[133] = "Super", [134] = "Super", -- Super_L / Super_R
|
|
[135] = "Menu",
|
|
[109] = "AltGr", -- ISO_Level3_Shift
|
|
}
|
|
|
|
-- Display order for modifier chips.
|
|
local MOD_ORDER = { "Super", "Ctrl", "Alt", "Shift", "Menu", "AltGr" }
|
|
|
|
-- Keycap-style labels for keys that don't produce a printable character.
|
|
local KEYS = {
|
|
[9] = "Esc", [22] = "Backspace", [23] = "Tab", [36] = "Enter", [66] = "Caps",
|
|
[67] = "F1", [68] = "F2", [69] = "F3", [70] = "F4", [71] = "F5", [72] = "F6",
|
|
[73] = "F7", [74] = "F8", [75] = "F9", [76] = "F10", [95] = "F11", [96] = "F12",
|
|
[107] = "Print", [78] = "Scroll", [127] = "Pause",
|
|
[118] = "Ins", [110] = "Home", [112] = "PgUp", [119] = "Del", [115] = "End", [117] = "PgDn",
|
|
[111] = "Up", [113] = "Left", [116] = "Down", [114] = "Right",
|
|
[65] = "Space",
|
|
[77] = "Num", [106] = "KP/", [63] = "KP*", [82] = "KP-", [86] = "KP+",
|
|
[104] = "KP Enter", [125] = "KP=",
|
|
[79] = "KP7", [80] = "KP8", [81] = "KP9", [83] = "KP4", [84] = "KP5", [85] = "KP6",
|
|
[87] = "KP1", [88] = "KP2", [89] = "KP3", [90] = "KP0", [91] = "KP.",
|
|
[121] = "Mute", [122] = "Vol-", [123] = "Vol+",
|
|
[94] = "\\", [51] = "\\",
|
|
-- Extended function keys (F13-F24), common on macros/layers and
|
|
-- programmable keyboards.
|
|
[191] = "F13", [192] = "F14", [193] = "F15", [194] = "F16", [195] = "F17", [196] = "F18",
|
|
[197] = "F19", [198] = "F20", [199] = "F21", [200] = "F22", [201] = "F23", [202] = "F24",
|
|
-- Media and laptop keys.
|
|
[171] = "Next", [172] = "Play", [173] = "Prev", [174] = "Stop",
|
|
[232] = "Bright-", [233] = "Bright+", [256] = "Mic Mute",
|
|
[124] = "Power", [150] = "Sleep", [148] = "Calc", [163] = "Mail",
|
|
[166] = "Back", [167] = "Forward", [225] = "Search",
|
|
[158] = "WWW", [164] = "Bookmarks", [165] = "Computer", [162] = "Cycle Windows",
|
|
[152] = "File", [179] = "Config", [180] = "Home", [181] = "Refresh",
|
|
[169] = "Eject", [170] = "Eject Close", [236] = "Kbd Light", [237] = "Kbd Light-",
|
|
[238] = "Kbd Light+", [244] = "Battery", [245] = "Bluetooth", [246] = "WLAN",
|
|
[234] = "Media", [439] = "Display", [452] = "Notification",
|
|
[538] = "Touchpad", [539] = "Touchpad On", [540] = "Touchpad Off",
|
|
[599] = "Do Not Disturb",
|
|
}
|
|
|
|
-- Printable characters (US layout), unshifted.
|
|
local CHARS = {
|
|
[10] = "1", [11] = "2", [12] = "3", [13] = "4", [14] = "5", [15] = "6",
|
|
[16] = "7", [17] = "8", [18] = "9", [19] = "0", [20] = "-", [21] = "=",
|
|
[24] = "q", [25] = "w", [26] = "e", [27] = "r", [28] = "t", [29] = "y",
|
|
[30] = "u", [31] = "i", [32] = "o", [33] = "p", [34] = "[", [35] = "]",
|
|
[38] = "a", [39] = "s", [40] = "d", [41] = "f", [42] = "g", [43] = "h",
|
|
[44] = "j", [45] = "k", [46] = "l", [47] = ";", [48] = "'", [49] = "`",
|
|
[52] = "z", [53] = "x", [54] = "c", [55] = "v", [56] = "b", [57] = "n",
|
|
[58] = "m", [59] = ",", [60] = ".", [61] = "/",
|
|
}
|
|
|
|
-- Shifted counterparts for keys whose shifted form isn't just uppercase.
|
|
local SHIFTED = {
|
|
[10] = "!", [11] = "@", [12] = "#", [13] = "$", [14] = "%", [15] = "^",
|
|
[16] = "&", [17] = "*", [18] = "(", [19] = ")", [20] = "_", [21] = "+",
|
|
[34] = "{", [35] = "}", [47] = ":", [48] = '"', [49] = "~",
|
|
[59] = "<", [60] = ">", [61] = "?",
|
|
}
|
|
|
|
local pressed = {} -- keycode -> true
|
|
local combo = {} -- ordered keycodes of non-modifier keys currently down
|
|
|
|
local function shift_down()
|
|
return pressed[50] or pressed[62]
|
|
end
|
|
|
|
-- Modifiers other than Shift. A bare Shift folds into the character itself
|
|
-- (Shift + g renders as "G"), so it only shows as a chip alongside another
|
|
-- modifier (Super + Shift + G) or a non-character key (Shift + Enter).
|
|
local function non_shift_mods_down()
|
|
local seen = {}
|
|
for kc, down in pairs(pressed) do
|
|
local name = MODS[kc]
|
|
if down and name and name ~= "Shift" then seen[name] = true end
|
|
end
|
|
local out = {}
|
|
for _, name in ipairs(MOD_ORDER) do
|
|
if seen[name] then out[#out + 1] = name end
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function any_printable()
|
|
for _, kc in ipairs(combo) do
|
|
if CHARS[kc] then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Label for one pressed key. `binding` is true when a modifier other than
|
|
-- Shift is held: the display switches to keycap style (uppercase letters)
|
|
-- which is what you want to read for keybinding tutorials.
|
|
local function key_label(kc, binding)
|
|
local label = KEYS[kc]
|
|
if label then return label end
|
|
local ch = CHARS[kc]
|
|
-- Unknown key: show its evdev code instead of dropping it silently.
|
|
if not ch then return "Key " .. (kc - 8) end
|
|
if binding then return string.upper(ch) end
|
|
if shift_down() then
|
|
return SHIFTED[kc] or string.upper(ch)
|
|
end
|
|
return ch
|
|
end
|
|
|
|
local function labels()
|
|
local parts = {}
|
|
local ns = non_shift_mods_down()
|
|
for _, m in ipairs(ns) do parts[#parts + 1] = m end
|
|
local binding = #ns > 0
|
|
-- A bare Shift folds into the character (Shift + g renders as "G") and
|
|
-- only earns a chip next to another modifier or a non-character key.
|
|
if shift_down() and (binding or not any_printable()) then
|
|
parts[#parts + 1] = "Shift"
|
|
end
|
|
for _, kc in ipairs(combo) do
|
|
local label = key_label(kc, binding)
|
|
if label then parts[#parts + 1] = label end
|
|
end
|
|
return parts
|
|
end
|
|
|
|
local last_payload = ""
|
|
local function emit()
|
|
if not STATE_FILE then return end
|
|
local parts = labels()
|
|
local payload = '{"keys":['
|
|
if #parts > 0 then
|
|
payload = payload .. '"' .. table.concat(parts, '","') .. '"'
|
|
end
|
|
payload = payload .. '],"t":' .. os.time() .. '}'
|
|
if payload == last_payload then return end
|
|
last_payload = payload
|
|
secure_write(STATE_FILE, payload)
|
|
end
|
|
|
|
-- Super-held flag: the panel/display watches this to know when to capture the
|
|
-- SUPER+drag on the overlay (instead of a window underneath). Written only on
|
|
-- transitions so it does not spam the filesystem on every key.
|
|
local last_super = nil
|
|
local function super_down()
|
|
return pressed[133] or pressed[134]
|
|
end
|
|
local function emit_super()
|
|
if not SUPER_FLAG then return end
|
|
local down = super_down()
|
|
if down == last_super then return end
|
|
last_super = down
|
|
secure_write(SUPER_FLAG, down and "1" or "0")
|
|
end
|
|
|
|
-- Create stable files before Quickshell starts watching them and clear stale
|
|
-- state left by a compositor restart.
|
|
emit()
|
|
emit_super()
|
|
|
|
-- Combos: a combination of keys is treated as a unit. The display only
|
|
-- updates on key-down (the combo grows as you press) and when the last key
|
|
-- is released (the empty payload starts the panel's linger with the last
|
|
-- full combo). Intermediate releases never shrink the display, so the order
|
|
-- in which you let go of a shortcut doesn't matter: Ctrl+Shift+N stays
|
|
-- Ctrl+Shift+N whether you release Ctrl, Shift, or N first.
|
|
--
|
|
-- state: 0 = released, 1 = pressed, 2 = repeat (ignored).
|
|
hl.on("input.keyboard.key", function(keycode, timeMs, state)
|
|
if state == 2 then return end
|
|
|
|
if state == 1 then
|
|
pressed[keycode] = true
|
|
if not MODS[keycode] then
|
|
local found = false
|
|
for _, kc in ipairs(combo) do
|
|
if kc == keycode then found = true break end
|
|
end
|
|
if not found then combo[#combo + 1] = keycode end
|
|
end
|
|
emit_super()
|
|
emit()
|
|
else
|
|
pressed[keycode] = false
|
|
emit_super()
|
|
if not MODS[keycode] then
|
|
for i, kc in ipairs(combo) do
|
|
if kc == keycode then
|
|
table.remove(combo, i)
|
|
break
|
|
end
|
|
end
|
|
end
|
|
-- Only emit when the last key goes up: intermediate releases keep the
|
|
-- full combo on screen (the panel lingers it after the empty payload).
|
|
local any_down = false
|
|
for _, down in pairs(pressed) do
|
|
if down then any_down = true break end
|
|
end
|
|
if not any_down then emit() end
|
|
end
|
|
end)
|