Repository navigation
Expand file tree
/
Copy pathService.qml
More file actions
556 lines (498 loc) · 23.9 KB
/
Copy pathService.qml
File metadata and controls
556 lines (498 loc) · 23.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
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
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Hyprland
import qs.Commons
import qs.Ui
import "logic.js" as Logic
Item {
id: root
readonly property string shortAppId: "ewweberlin.quickswitch"
property bool open: false
property var windows: []
property var groups: []
property int selected: 0
property bool sawKeyEvent: false
property bool modsHeld: false
property string pendingAddr: ""
property real lastRefreshTime: 0
function next() { if (windows.length) selected = (selected + 1) % windows.length }
function prev() { if (windows.length) selected = (selected + windows.length - 1) % windows.length }
function openSwitcher() {
console.log("[task-switch] openSwitcher called")
clientsProc.running = true
}
function close(doFocus) {
if (!open) return
const entry = (doFocus && windows.length) ? windows[selected] : null
open = false
if (!entry) return
pendingAddr = entry.address
focusTimer.start()
}
Timer {
id: focusTimer
interval: 110
onTriggered: {
if (root.pendingAddr)
Hyprland.dispatch('hl.dsp.focus({ window = "address:' + root.pendingAddr + '" })')
root.pendingAddr = ""
}
}
// SUPER+Q quits the highlighted app but keeps the switcher open (SUPER is
// still held). We close via the Lua dispatcher (matches Omarchy's own
// close-all helper) and then re-fetch the window list so the closed window
// disappears; focus is only switched on SUPER release.
function quitSelected() {
const entry = windows.length ? windows[selected] : null
if (!entry || !entry.address) return
const addr = entry.address
console.log("[task-switch] quitSelected addr=", addr, "cls=", entry.cls)
root.lastRefreshTime = Date.now()
quitProc.command = ["hyprctl", "dispatch",
'hl.dsp.window.close({ window = "address:' + addr + '" })']
quitProc.running = true
}
Process {
id: quitProc
stdout: StdioCollector { onStreamFinished: console.log("[task-switch] quit stdout:", text.trim()) }
stderr: StdioCollector { onStreamFinished: console.log("[task-switch] quit stderr:", text.trim()) }
onExited: {
console.log("[task-switch] quitProc exited code=", exitCode)
refreshAfterQuit.running = true
}
}
// Keep the overlay mounted (open stays true) and refresh the list.
Timer {
id: refreshAfterQuit
interval: 220
onTriggered: {
if (root.open) root.openSwitcher()
}
}
// Keep the Qt-side toplevel model in sync with the compositor so that
// reloaded/reopened windows map to a current (fresh) wayland handle. Without
// a refresh, Hyprland.toplevels still holds the sealed toplevel of the
// pre-reload window, so its new address isn't found and the preview reuses
// a stale (dead) handle.
Connections {
target: Hyprland
function onRawEvent(event) {
switch (event.name) {
case "openwindow":
case "closewindow":
case "changefloatingmode":
case "movewindow":
Hyprland.refreshToplevels()
break
}
}
}
GlobalShortcut {
appid: root.shortAppId
name: "next"
onPressed: {
if (root.open) {
root.sawKeyEvent = true
root.modsHeld = true
root.next()
} else {
root.openSwitcher()
}
}
}
// Arrow keys (SUPER+Left/Right/Up/Down). The default Omarchy tiling
// bindings consume these before they reach the overlay's exclusive
// keyboard grab, so we intercept them here and re-dispatch: while the
// switcher is open they cycle prev/next; when closed they restore the
// original "focus adjacent window" behavior.
GlobalShortcut {
appid: root.shortAppId
name: "focus-left"
onPressed: {
if (root.open) { root.sawKeyEvent = true; root.modsHeld = true; root.prev() }
else { Hyprland.dispatch('hl.dsp.focus({ direction = "l" })') }
}
}
GlobalShortcut {
appid: root.shortAppId
name: "focus-right"
onPressed: {
if (root.open) { root.sawKeyEvent = true; root.modsHeld = true; root.next() }
else { Hyprland.dispatch('hl.dsp.focus({ direction = "r" })') }
}
}
GlobalShortcut {
appid: root.shortAppId
name: "focus-up"
onPressed: {
if (root.open) { root.sawKeyEvent = true; root.modsHeld = true; root.prev() }
else { Hyprland.dispatch('hl.dsp.focus({ direction = "u" })') }
}
}
GlobalShortcut {
appid: root.shortAppId
name: "focus-down"
onPressed: {
if (root.open) { root.sawKeyEvent = true; root.modsHeld = true; root.next() }
else { Hyprland.dispatch('hl.dsp.focus({ direction = "d" })') }
}
}
Process {
id: clientsProc
command: ["hyprctl", "-j", "clients"]
stdout: StdioCollector {
onStreamFinished: {
let clients = []
try { clients = JSON.parse(text) } catch (e) { return }
fillWindows(clients)
}
}
}
function fillWindows(clients) {
console.log("[task-switch] fillWindows called, n=", Array.isArray(clients) ? clients.length : "not-array")
if (!Array.isArray(clients)) return
const ordered = Logic.orderClients(clients)
if (!ordered.length) return
const byAddr = {}
for (const tl of (Hyprland.toplevels.values || [])) {
const ipc = tl.lastIpcObject
if (ipc && ipc.address) byAddr[ipc.address] = tl
}
const cache = {}
for (const w of root.windows) cache[w.address] = w
const records = []
for (const c of ordered) {
const addr = c.address
const tl = byAddr[addr]
const prev = cache[addr]
const rec = prev && !prev.dead ? prev : {
address: addr,
cls: c.class || c.initialClass || "",
title: c.title || "-",
workspaceId: c.workspace ? c.workspace.id : -1,
workspaceName: c.workspace ? c.workspace.name : "-",
fhid: c.focusHistoryID === undefined ? 0 : c.focusHistoryID,
dead: false,
handle: null
}
rec.handle = tl ? tl.wayland : null
rec.title = c.title || "-"
rec.cls = c.class || c.initialClass || ""
rec.workspaceId = c.workspace ? c.workspace.id : rec.workspaceId
rec.workspaceName = c.workspace ? c.workspace.name : rec.workspaceName
records.push(rec)
}
root.windows = records
root.groups = Logic.groupByWorkspace(ordered)
root.selected = 0
root.sawKeyEvent = false
root.modsHeld = false
root.open = true
}
function iconPathFor(cls, title) {
return Logic.iconPathFor(DesktopEntries, Quickshell, cls, title)
}
LazyLoader {
id: loader
active: root.open
PanelWindow {
id: panel
property bool mouseInside: true
property bool hoverArmed: false
property point initialPos: Qt.point(-1, -1)
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
WlrLayershell.namespace: root.shortAppId
exclusionMode: ExclusionMode.Ignore
color: "transparent"
anchors { top: true; bottom: true; left: true; right: true }
function activate() { root.close(panel.mouseInside) }
Timer {
id: noKeyTimer
interval: 150
repeat: true
running: true
onTriggered: {
if (root.sawKeyEvent) {
if (root.modsHeld) stop()
else panel.activate()
} else {
modCheck.running = true
}
}
}
Process {
id: modCheck
command: ["hyprctl", "eval",
'error(tostring(hl.is_key_down("Super_L") or hl.is_key_down("Super_R")))']
stdout: StdioCollector {
onStreamFinished: {
if (!root.open || root.sawKeyEvent) return
if (!text.trim().endsWith("true"))
panel.activate()
}
}
}
Rectangle {
anchors.fill: parent
color: Color.menu.scrim
}
// Clicking empty space dismisses without switching focus.
MouseArea {
anchors.fill: parent
onClicked: root.close(false)
}
// Centered horizontal strip (macOS app-switcher style).
Item {
id: stripWrap
anchors.centerIn: parent
width: strip.width
height: strip.height
focus: true
Keys.priority: Keys.BeforeItem
BorderSurface {
id: stripBg
anchors.fill: strip
radius: Style.cornerRadius
color: Color.popups.background
borderSpec: Border.surfaceSpec("popups", "border", Color.popups.border, 2)
}
Flow {
id: strip
anchors.centerIn: parent
padding: Style.spacing.panelPadding
spacing: Style.spacing.panelPadding
z: 2
readonly property real contentWidth: {
const n = root.windows.length
if (!n) return 0
const spacing = Style.spacing.panelPadding
const cardW = 180
const maxCards = Math.floor((panel.width * 0.85 - 2 * padding + spacing) / (cardW + spacing))
return 2 * padding + Math.min(n, maxCards) * cardW + Math.max(0, Math.min(n, maxCards) - 1) * spacing
}
width: contentWidth
Repeater {
model: root.windows
delegate: Item {
required property int index
readonly property var win: root.windows[index]
readonly property bool isSelected: root.selected === index
width: 180
height: 130
scale: (isSelected || hover.hovered) ? 1.05 : 1.0
opacity: isSelected ? 1.0 : 0.85
transformOrigin: Item.Center
Behavior on scale {
NumberAnimation { duration: 90; easing.type: Easing.OutQuad }
}
Behavior on opacity {
NumberAnimation { duration: 90; easing.type: Easing.OutQuad }
}
BorderSurface {
id: frame
anchors.fill: parent
radius: Style.cornerRadius
color: Color.popups.background
clip: true
borderSpec: Border.surfaceSpec(
"popups", "border",
isSelected || hover.hovered ? Color.menu.selectedBorder : Color.popups.border,
2)
// Omarchy selection/hover affordance: a subtle
// fill highlight instead of a hard-coded border.
Rectangle {
anchors.fill: parent
visible: isSelected || hover.hovered
color: Color.menu.selectedBackground
}
// Snapshot taken while the switcher opens. The
// screencopy recording context takes a moment to
// establish, and its first delivered buffer is
// often empty/uninitialized. So: keep requesting
// frames while open, and once the first content
// frame arrives keep grabbing a short "settle"
// burst so a real (populated) frame replaces the
// blank one, then freeze. Cards are recreated
// fresh on every open (the panel is torn down when
// closed), so there is no cache across opens.
ScreencopyView {
id: thumb
anchors.fill: parent
anchors.margins: 2
z: 3
captureSource: win ? win.handle : null
live: false
paintCursor: false
visible: win && win.handle && hasContent
// Whether a real snapshot has been frozen for
// this open. Reset automatically because the
// card is recreated fresh on each open.
property bool frozen: false
property int settleTicks: 0
// Grab a frame as soon as a source is set
// (kicks off context setup immediately).
onCaptureSourceChanged: {
if (thumb.captureSource) {
thumb.captureFrame()
thumb.settleTicks = 0
thumb.frozen = false
}
}
// Runs whenever the switcher is open and a
// source exists — driver via a bound
// `running`, so a freshly-added window whose
// onCaptureSourceChanged may not fire still
// gets captured. Stops after a settle burst
// once real content is present.
Timer {
id: grabber
interval: 90
repeat: true
running: root.open && !!thumb.captureSource && !thumb.frozen
onTriggered: {
thumb.captureFrame()
if (thumb.hasContent) {
thumb.settleTicks += 1
if (thumb.settleTicks >= 4) {
thumb.frozen = true
thumb.settleTicks = 0
}
}
}
}
}
// Fallback while content loads / when a window
// can't be captured: dim area + centered icon.
Rectangle {
anchors.fill: parent
color: Qt.alpha(Color.foreground, 0.05)
}
Rectangle {
id: captureFallback
anchors.fill: parent
visible: !thumb.visible
color: Color.popups.background
Image {
anchors.centerIn: parent
width: 64
height: 64
sourceSize.width: 64
sourceSize.height: 64
fillMode: Image.PreserveAspectFit
source: win ? root.iconPathFor(win.cls, win.title) : ""
}
}
Rectangle {
id: iconBadge
anchors.left: parent.left
anchors.top: parent.top
anchors.margins: Style.spacing.md
width: 56
height: 56
radius: Style.cornerRadius
color: "transparent"
z: 4
Image {
anchors.fill: parent
anchors.margins: 4
sourceSize.width: 48
sourceSize.height: 48
fillMode: Image.PreserveAspectFit
source: win ? root.iconPathFor(win.cls, win.title) : ""
}
}
}
HoverHandler {
id: hover
onHoveredChanged: {
if (hovered) root.selected = index
}
}
TapHandler {
onTapped: {
root.selected = index
root.close(true)
}
}
}
}
}
Text {
id: titleLabel
anchors.horizontalCenter: strip.horizontalCenter
anchors.top: strip.bottom
anchors.topMargin: Style.spacing.xl
width: Math.min(strip.width, 640)
maximumLineCount: 1
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
text: root.windows.length ? root.windows[root.selected].title : ""
color: Color.popups.text
font.family: Style.font.family
font.pixelSize: Style.font.body
font.weight: Font.DemiBold
}
// Keyboard navigation. The exclusive keyboard grab keeps
// keyboard events flowing here while SUPER is held. Any key
// reaching here means SUPER is still held (otherwise the
// release/noKeyTimer path would have already closed it), so
// mark the modifier as held for every key press.
Keys.onPressed: (event) => {
root.sawKeyEvent = true
root.modsHeld = true
if (event.key === Qt.Key_Escape) {
root.close(false)
event.accepted = true
return
}
// SUPER+Q quits the highlighted app. Being inside the
// exclusive grab already means SUPER is held, so don't
// rely on event.modifiers (which isn't reliably set).
if (event.key === Qt.Key_Q || event.key === Qt.Key_q) {
root.quitSelected()
event.accepted = true
return
}
if (event.key === Qt.Key_Tab || event.key === Qt.Key_Right || event.key === Qt.Key_Down) {
root.next()
event.accepted = true
} else if (event.key === Qt.Key_Backtab || event.key === Qt.Key_Left || event.key === Qt.Key_Up) {
root.prev()
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
panel.activate()
event.accepted = true
}
}
Keys.onReleased: (event) => {
root.sawKeyEvent = true
const superReleasing = event.key === Qt.Key_Meta
|| event.key === Qt.Key_Super_L || event.key === Qt.Key_Super_R
// event.modifiers is NOT reliably set through the
// exclusive grab (see the onPressed handler), so it
// must not gate closing. Releasing a non-SUPER key
// (Q, TAB, arrows) while the grab is active means
// SUPER is still held — keep the switcher open. Only
// an actual SUPER key release closes it.
if (superReleasing) {
// Closing a window can briefly disrupt the keyboard
// grab and deliver a synthetic SUPER release; ignore
// it right after a quit so the strip survives.
if (Date.now() - root.lastRefreshTime < 200) return
root.modsHeld = false
panel.activate()
} else {
root.modsHeld = true
}
}
HoverHandler {
onHoveredChanged: panel.mouseInside = hovered
}
Component.onCompleted: forceActiveFocus()
}
}
}
}