Repository navigation
Expand file tree
/
Copy pathkey-visualizer.lua
More file actions
288 lines (267 loc) · 10.9 KB
/
Copy pathkey-visualizer.lua
File metadata and controls
288 lines (267 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
-- 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 = ""
-- Publishes the current label set. `finished` marks the payload as the end of
-- a chord: its keys travel as `chord` next to the currently held keys, and
-- the panel counts the combination exactly once. Without it, the payload is
-- a plain live-state update.
local function emit(finished)
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 finished and #finished > 0 then
payload = payload .. ',"done":true,"chord":["' .. table.concat(finished, '","') .. '"]'
end
payload = payload .. '}'
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 chord
-- completes: the release of its last non-modifier key (the done payload).
-- 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()
local finished = nil
if not MODS[keycode] then
for i, kc in ipairs(combo) do
if kc == keycode then
-- This is the chord's last non-modifier key: capture the full
-- chord before dropping it. It is what the done payload describes.
if #combo == 1 then finished = labels() end
table.remove(combo, i)
break
end
end
end
if finished then
-- The chord's last non-modifier key went up. Emit the completion even
-- if a modifier is still held (Super held while tapping 1, 2, 3...),
-- so the panel counts each tap as its own combo instead of waiting
-- for every key to be released. Intermediate releases (another
-- non-modifier key still down) keep the full combo on screen.
emit(finished)
else
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
end)