Repository navigation
Expand file tree
/
Copy pathjumpy.lua
More file actions
372 lines (321 loc) · 12.3 KB
/
Copy pathjumpy.lua
File metadata and controls
372 lines (321 loc) · 12.3 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
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
-- Jumpy: the Alt+Tab switcher for the Omarchy shell.
--
-- Load it from ~/.config/hypr/bindings.lua:
--
-- dofile(os.getenv("HOME") .. "/.config/omarchy/plugins/de.gransoftware.jumpy/jumpy.lua")
--
-- This half owns all state and all keys; Jumpy.qml only draws what it is told,
-- over `omarchy-shell jumpy show|hide`.
--
-- Alt+Tab, release flip to the last window (no list is drawn)
-- hold Alt, tap Tab walk the list, most recently used first
-- hold Alt, type letters fuzzy filter by app name or title
-- Alt+Ctrl+J / Alt+Ctrl+K move down / up (letters alone are the filter)
-- Alt+Ctrl+H delete a letter (as does Alt+Backspace)
-- Alt+Ctrl+U clear the filter
-- Alt+Delete, Alt+Ctrl+W close the selected window, list stays open
-- Alt+` same, but only windows of the current app
-- Alt+Escape cancel
--
-- While the list is up, Hyprland is in the "jumpy" submap, whose catchall
-- swallows every key Jumpy does not bind, so nothing leaks into the window
-- underneath. Three things end the submap so it can never trap the keyboard:
-- the Alt release, a poll that notices Alt is up, and a hard timeout.
local jumpy = {
windows = {}, -- frozen snapshot, most recent first (plain tables)
shown = {}, -- the filtered view of `windows`
index = 1,
filter = "",
mode = "all", -- all | app
active = false,
started = 0,
alt_down = false,
}
local SUBMAP = "jumpy"
local ALT_KEYCODES = { [64] = true, [108] = true } -- Alt_L, Alt_R
local TIMEOUT_MS = 20000
local function shell_quote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
local function send(method, argument)
local command = "omarchy-shell -q jumpy " .. method
if argument then command = command .. " " .. shell_quote(argument) end
hl.exec_cmd(command)
end
-- Out of process on purpose: a dispatch from inside a key callback does not
-- settle until the next input event, so a focus change would look dead.
local function dispatch_later(expression)
hl.exec_cmd("hyprctl dispatch " .. shell_quote(expression))
end
local function json_string(value)
local escaped = tostring(value or "")
:gsub("\\", "\\\\")
:gsub('"', '\\"')
:gsub("[%c]", function(c) return string.format("\\u%04x", c:byte()) end)
return '"' .. escaped .. '"'
end
-- "com.mitchellh.ghostty" -> "ghostty", "brave-browser" stays as is.
local function app_name(class)
local name = tostring(class or "")
return (name:match("([^.]+)$") or name):lower()
end
-- Filtering ------------------------------------------------------------------
local function initials(text)
local out = {}
for word in text:gmatch("[%w]+") do out[#out + 1] = word:sub(1, 1) end
return table.concat(out)
end
local function is_subsequence(needle, haystack)
local at = 1
for i = 1, #needle do
at = haystack:find(needle:sub(i, i), at, true)
if not at then return false end
at = at + 1
end
return true
end
-- Higher is better; nil means no match. The app name wins over the title, and
-- an acronym ("vsc", "gc") counts almost as much as a prefix.
local function score(window, query)
local app = window.app
local title = window.title:lower()
local all = app .. " " .. title
if app:sub(1, #query) == query then return 5 end
local acronym = initials(all):find(query, 1, true)
if acronym == 1 then return 4 end
-- Titles often lead with a file or page name ("main.go - api - Visual
-- Studio Code"), so an acronym later in the title still counts.
if acronym and #query > 1 then return 3 end
local at = all:find(query, 1, true)
if at then
local prev = at > 1 and all:sub(at - 1, at - 1) or " "
return prev:match("[%w]") and 2 or 3
end
if is_subsequence(query, all) then return 1 end
return nil
end
local function refilter()
local query = jumpy.filter:lower()
if query == "" then
jumpy.shown = jumpy.windows
return
end
local scored = {}
for order, window in ipairs(jumpy.windows) do
local s = score(window, query)
if s then scored[#scored + 1] = { window = window, score = s, order = order } end
end
-- Recency breaks ties, so the window you used last wins among equals.
table.sort(scored, function(a, b)
if a.score ~= b.score then return a.score > b.score end
return a.order < b.order
end)
jumpy.shown = {}
for _, entry in ipairs(scored) do jumpy.shown[#jumpy.shown + 1] = entry.window end
-- You are already on the window you are leaving. Push it to the end, so a
-- query that matches it and something else lands on the something else.
if #jumpy.shown > 1 and jumpy.shown[1].current then
table.insert(jumpy.shown, table.remove(jumpy.shown, 1))
end
end
-- Snapshot -------------------------------------------------------------------
local function snapshot(mode)
local active = hl.get_active_window()
local active_address = active and active.address
local active_app = active and app_name(active.class)
local raw = {}
for _, window in ipairs(hl.get_windows()) do
local workspace = window.workspace
if window.mapped and workspace and not workspace.special then
raw[#raw + 1] = window
end
end
table.sort(raw, function(a, b) return a.focus_history_id < b.focus_history_id end)
-- Copy out what we need. Window handles do not survive past the callback,
-- and reading one later throws inside a key handler, losing the switch.
local windows = {}
for _, window in ipairs(raw) do
local app = app_name(window.class)
if mode ~= "app" or app == active_app then
windows[#windows + 1] = {
address = window.address,
title = window.title or "",
class = window.class or "",
app = app,
workspace = window.workspace.name or "",
current = window.address == active_address,
}
end
end
return windows
end
-- Panel ----------------------------------------------------------------------
local function payload()
local rows = {}
for _, w in ipairs(jumpy.shown) do
rows[#rows + 1] = string.format(
'{"title":%s,"appClass":%s,"workspace":%s,"current":%s}',
json_string(w.title), json_string(w.class), json_string(w.workspace),
w.current and "true" or "false"
)
end
return string.format(
'{"windows":[%s],"index":%d,"filter":%s,"mode":%s,"total":%d}',
table.concat(rows, ","), jumpy.index - 1, json_string(jumpy.filter),
json_string(jumpy.mode), #jumpy.windows
)
end
local function redraw()
send("show", payload())
end
-- Lifecycle ------------------------------------------------------------------
local poll -- created at the bottom, once the functions it calls exist
local function teardown()
if not jumpy.active then return end
jumpy.active = false
jumpy.windows, jumpy.shown, jumpy.filter = {}, {}, ""
if poll then poll:set_enabled(false) end
hl.dispatch(hl.dsp.submap("reset"))
send("hide")
end
local function commit()
if not jumpy.active then return end
local target = jumpy.shown[jumpy.index]
local address = target and target.address
teardown()
if address then
dispatch_later(string.format('hl.dsp.focus({ window = "address:%s" })', address))
end
end
local function start(mode, delta)
local windows = snapshot(mode)
-- Nothing to switch to. Alt+` still jumps to a lone window of this app when
-- another app has focus.
local lone_elsewhere = mode == "app" and #windows == 1 and not windows[1].current
if #windows < 2 and not lone_elsewhere then return end
jumpy.windows = windows
jumpy.shown = windows
jumpy.mode = mode
jumpy.filter = ""
jumpy.active = true
jumpy.alt_down = true -- the bind that got us here needs Alt held
jumpy.started = os.time()
-- Entry 1 is where you are, so one tap lands on entry 2 and one back-tap
-- wraps to the oldest.
jumpy.index = delta % #jumpy.shown + 1
hl.dispatch(hl.dsp.submap(SUBMAP))
if poll then poll:set_enabled(true) end
redraw()
end
local function step(delta, mode)
if not jumpy.active then
start(mode or "all", delta)
return
end
if #jumpy.shown == 0 then return end
jumpy.index = (jumpy.index - 1 + delta) % #jumpy.shown + 1
redraw()
end
local function set_filter(text)
jumpy.filter = text
refilter()
-- With no filter the list is back in recency order, where row 1 is the
-- window you are on; the cursor goes to row 2, as it did at the start.
jumpy.index = (text == "" and #jumpy.shown > 1) and 2 or 1
redraw()
end
local function type_char(char)
if jumpy.active then set_filter(jumpy.filter .. char) end
end
local function backspace()
if not jumpy.active or jumpy.filter == "" then return end
set_filter(jumpy.filter:sub(1, -2))
end
local function clear_filter()
if jumpy.active and jumpy.filter ~= "" then set_filter("") end
end
local function close_selected()
if not jumpy.active then return end
local target = jumpy.shown[jumpy.index]
if not target then return end
dispatch_later(string.format('hl.dsp.window.close({ window = "address:%s" })', target.address))
for i, w in ipairs(jumpy.windows) do
if w.address == target.address then table.remove(jumpy.windows, i) break end
end
refilter()
if #jumpy.shown == 0 and jumpy.filter == "" then
teardown()
return
end
if jumpy.index > #jumpy.shown then jumpy.index = math.max(1, #jumpy.shown) end
redraw()
end
local function valid_row(row)
return jumpy.active and row >= 0 and row < #jumpy.shown
end
-- The panel's own watchdog calls this if it ever outlives a switch.
_G.__jumpy_cancel = teardown
-- The pointer, from the panel. Rows arrive zero-based. Hovering moves the
-- cursor (the panel has already drawn it, so there is nothing to send back);
-- a click switches to that window.
_G.__jumpy_point = function(row)
if valid_row(row) then jumpy.index = row + 1 end
end
_G.__jumpy_pick = function(row)
if not valid_row(row) then return end
jumpy.index = row + 1
commit()
end
-- Keys -----------------------------------------------------------------------
for _, keys in ipairs({ "ALT + TAB", "ALT + SHIFT + TAB", "ALT + ESCAPE", "ALT + grave" }) do
hl.unbind(keys)
end
hl.bind("ALT + TAB", function() step(1) end, { description = "Jumpy: switch window" })
hl.bind("ALT + SHIFT + TAB", function() step(-1) end, { description = "Jumpy: switch window (reverse)" })
hl.bind("ALT + grave", function() step(1, "app") end, { description = "Jumpy: switch window of this app" })
hl.define_submap(SUBMAP, function()
hl.bind("ALT + TAB", function() step(1) end)
hl.bind("ALT + SHIFT + TAB", function() step(-1) end)
hl.bind("ALT + grave", function() step(1) end)
hl.bind("ALT + SHIFT + grave", function() step(-1) end)
hl.bind("ALT + DOWN", function() step(1) end, { repeating = true })
hl.bind("ALT + UP", function() step(-1) end, { repeating = true })
hl.bind("ALT + RETURN", commit)
hl.bind("ALT + ESCAPE", teardown)
hl.bind("ESCAPE", teardown)
hl.bind("ALT + BACKSPACE", backspace, { repeating = true })
hl.bind("ALT + CTRL + j", function() step(1) end, { repeating = true })
hl.bind("ALT + CTRL + k", function() step(-1) end, { repeating = true })
hl.bind("ALT + CTRL + h", backspace, { repeating = true })
hl.bind("ALT + CTRL + u", clear_filter)
hl.bind("ALT + DELETE", close_selected)
hl.bind("ALT + CTRL + w", close_selected)
for code = string.byte("a"), string.byte("z") do
local char = string.char(code)
hl.bind("ALT + " .. char, function() type_char(char) end)
end
for key, char in pairs({ space = " ", minus = "-", period = "." }) do
hl.bind("ALT + " .. key, function() type_char(char) end)
end
-- Everything else is swallowed while the list is up.
hl.bind("catchall", hl.dsp.no_op())
end)
-- A release bind on a modifier only fires when it is tapped alone, and Tab in
-- between cancels it, so the raw key stream is read instead. This runs for
-- every key on the system: two compares unless a switch is up.
hl.on("input.keyboard.key", function(keycode, _, state)
if not ALT_KEYCODES[keycode] then return end
jumpy.alt_down = state ~= 0
if state == 0 and jumpy.active then commit() end
end)
-- Belt and braces: if the release event is ever missed, or the list has been
-- up far too long, end the switch rather than hold the keyboard in the submap.
poll = hl.timer(function()
if not jumpy.active then return end
if not jumpy.alt_down then
commit()
elseif (os.time() - jumpy.started) * 1000 > TIMEOUT_MS then
teardown()
end
end, { timeout = 250, type = "repeat" })
poll:set_enabled(false)