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
|
// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 2 as
// published by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
import QtQuick
import ".."
import "Logic.js" as Logic
// The island: a sliver at the top centre of DP-1 that the two pills slide
// out of. The window is a fixed overlay strip that never resizes; the pills
// move inside it and the input mask follows them, so the rest of the strip
// is click-through.
PanelWindow {
id: win
required property QtObject sound // the sound module's Service
required property QtObject drawer // Drawer, for the sound page link
screen: Quickshell.screens.find(s => s.name === "DP-1") ?? Quickshell.screens[0]
anchors { top: true; left: true; right: true }
implicitHeight: 360
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "quickshell-island"
WlrLayershell.keyboardFocus: panel !== "" ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
// "" or "sound".
property string panel: ""
// --- reveal and hide -------------------------------------------------
readonly property bool hovered: sliverHit.containsMouse || clock.hovered || status.hovered
property bool shown: false
// A tray menu is a separate popup: moving onto it un-hovers the pills,
// and the hide timer would then hide the island under the open menu. The
// menu cannot report its own close through display(), so the hold ends
// the next time the pointer comes back to the island.
// ponytail: if the menu is dismissed elsewhere, the island may stay shown
// until the next hover.
property bool menuHeld: false
onHoveredChanged: {
if (hovered) { hideTimer.stop(); shown = true; menuHeld = false; }
else if (panel === "" && !menuHeld) hideTimer.restart();
}
onPanelChanged: {
if (panel !== "") keys.forceActiveFocus();
else if (!hovered) hideTimer.restart();
}
Timer {
id: hideTimer
interval: 400
onTriggered: if (!win.hovered && win.panel === "" && !win.menuHeld) win.shown = false
}
// 0 hidden, 1 revealed. One amount drives the split, so an interrupted
// reveal turns around from wherever it is.
property real reveal: shown ? 1 : 0
Behavior on reveal { NumberAnimation { duration: 280; easing.type: Easing.OutCubic } }
// The clock pill also drops alone for a flick while the island is hidden.
property real clockDrop: (shown || flicking) ? 1 : 0
Behavior on clockDrop { NumberAnimation { duration: 280; easing.type: Easing.OutCubic } }
// --- flick -----------------------------------------------------------
// Hyprland reports the initial state as events at startup.
property bool armed: false
Timer { interval: 1500; running: true; onTriggered: win.armed = true }
property bool flicking: false
property string flickWs: ""
property string flickApp: ""
function flick(ws, app) {
flickWs = ws;
flickApp = app;
flicking = true;
flickTimer.restart();
}
Timer { id: flickTimer; interval: 1500; onTriggered: win.flicking = false }
// activeToplevel settles a moment after workspacev2 arrives.
Timer {
id: settle
interval: 60
onTriggered: {
const ws = Hyprland.focusedWorkspace;
const top = Hyprland.activeToplevel;
const app = top && top.workspace === ws ? (top.wayland?.appId ?? "") : "";
win.flick(ws?.name ?? "", app);
}
}
Connections {
target: Hyprland
function onRawEvent(event) {
if (!win.armed) return;
if (event.name === "workspacev2") {
settle.restart();
} else if (event.name === "openwindow") {
const w = Logic.parseOpenWindow(event.data);
if (!w.workspace.startsWith("special:")) win.flick(w.workspace, w.appClass);
}
}
}
// --- dismissal -------------------------------------------------------
HyprlandFocusGrab {
windows: [win]
active: win.panel !== ""
onCleared: win.panel = ""
}
// Key events reach a focused item, not the window (AGENTS.md).
Item {
id: keys
Keys.onEscapePressed: win.panel = ""
}
// --- geometry --------------------------------------------------------
readonly property real gap: 10
readonly property real top: 8
readonly property real cx: width / 2
mask: Region {
item: sliverHit
Region { item: clock; intersection: Intersection.Combine }
Region { item: status; intersection: Intersection.Combine }
}
// The visible sliver: neutral at rest, the capture colours while
// something captures, one equal segment per active kind. Not the accent
// at rest: udt snaps the accent to a palette colour, and when it lands on
// green an idle sliver read exactly like a live microphone.
Row {
id: sliver
x: win.cx - width / 2
y: 0
width: 120
height: 4
// A handle, so it gives way to the pills it reveals. Not while
// something captures: then it is the only privacy indicator there is.
opacity: Privacy.roles.length ? 1 : 1 - win.reveal
Repeater {
model: Privacy.roles.length ? Privacy.roles : ["overlay"]
delegate: Rectangle {
required property string modelData
width: sliver.width / Math.max(1, Privacy.roles.length)
height: sliver.height
radius: 2
color: Theme[modelData]
}
}
}
MouseArea {
id: sliverHit
x: win.cx - width / 2
y: 0
width: 220
height: 10
hoverEnabled: true
}
// Both pills start overlapped at the sliver, above the screen edge, and
// travel down and apart. At reveal 1 the clock ends gap/2 left of the
// centre and the status gap/2 right of it; the status pill's left edge
// stays put while its panel widens it.
ClockPill {
id: clock
x: win.cx - width / 2 - (win.gap / 2 + width / 2) * win.reveal
y: -height + (win.top + height) * win.clockDrop
opacity: Math.min(1, win.clockDrop * 2)
flicking: win.flicking
flickWs: win.flickWs
flickApp: win.flickApp
}
StatusPill {
id: status
x: win.cx - width / 2 + (win.gap / 2 + width / 2) * win.reveal
y: -height + (win.top + height) * win.reveal
opacity: Math.min(1, win.reveal * 2)
sound: win.sound
window: win
panel: win.panel
onMenuShown: win.menuHeld = true
onOpenSound: win.panel = win.panel === "sound" ? "" : "sound"
onOpenDrawer: { win.panel = ""; win.drawer.show("sound"); }
}
}
|