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
|
// 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 Quickshell.Services.Mpris
import QtQuick
import ".."
import "../modules/sound"
import "Logic.js" as Logic
import "../assistant/Files.js" as Files
// 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
property QtObject asst: null // the assistant canvas, for dropped files
screen: Quickshell.screens.find(s => s.name === "DP-1") ?? Quickshell.screens[0]
anchors { top: true; left: true; right: true }
// Tall enough for the largest panel; the mask keeps everything outside
// the pills click-through.
implicitHeight: 480
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "keystone-island"
WlrLayershell.keyboardFocus: panel !== "" ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
// "", "sound", "calendar" or "obs".
property string panel: ""
// --- reveal and hide -------------------------------------------------
readonly property bool hovered: sliverHit.containsMouse || clock.hovered || status.hovered || media.hovered || obs.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 === "calendar") clock.focusPanel();
else 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 -------------------------------------------------------
// OBS quitting with its panel open would leave the island held open by a
// pill that no longer exists.
Connections {
target: Obs
function onConnectedChanged() { if (!Obs.connected && win.panel === "obs") win.panel = ""; }
}
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: if (!(win.panel === "calendar" && clock.back())) 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: receptor; intersection: Intersection.Combine }
Region { item: clock; intersection: Intersection.Combine }
Region { item: status; intersection: Intersection.Combine }
Region { item: media.visible ? media : null; intersection: Intersection.Combine }
Region { item: obs.visible ? obs : null; 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]
}
}
}
// Recording: a red pulse over the sliver, steady and dim while paused.
// Red alone would not say it: OBS's screen source already lights the
// privacy sliver's red segment whenever OBS is open.
Rectangle {
x: sliver.x
y: 0
width: sliver.width
height: sliver.height
radius: 2
color: Theme.red
visible: Obs.recording
property real pulse: 1
opacity: Obs.paused ? 0.4 : pulse
SequentialAnimation on pulse {
running: Obs.recording && !Obs.paused
loops: Animation.Infinite
NumberAnimation { to: 0.25; duration: 700; easing.type: Easing.InOutSine }
NumberAnimation { to: 1; duration: 700; easing.type: Easing.InOutSine }
}
}
MouseArea {
id: sliverHit
x: win.cx - width / 2
y: 0
width: 220
height: 10
hoverEnabled: true
}
// --- file drop ---------------------------------------------------------
// A file dragged onto the sliver opens a bubble to drop it on; the drop
// hands it to the assistant canvas. Wayland reports a drag only once it
// enters an input region, so the receptor is the sliver's width at rest
// and grows to hold the bubble while a drag is over it.
property bool dropHold: false
// Why the last drop was refused, shown in the bubble for a moment.
property string dropNote: ""
Timer { id: noteTimer; interval: 2500; onTriggered: win.dropNote = "" }
Item {
id: receptor
x: win.cx - width / 2
y: 0
z: 10
// The sliver's hit width at rest, so it takes no clicks beside it.
width: win.dropHold ? 260 : 220
height: win.dropHold ? 100 : 10
DropArea {
anchors.fill: parent
enabled: win.asst !== null
onEntered: drag => {
drag.accepted = drag.hasUrls && (drag.supportedActions & Qt.CopyAction);
if (drag.accepted) { win.dropHold = true; drag.accept(Qt.CopyAction); }
}
onExited: win.dropHold = false
onDropped: drop => {
win.dropHold = false;
const paths = drop.urls.map(u => Files.fromUrl(u)).filter(p => p !== "");
if (!paths.length) {
// Said, not silent: a source offering no local file://
// URL looked like a drop that did nothing. The file
// manager offers a trashed file as trash:, which has no
// path to read.
const schemes = drop.urls.map(u => String(u).split(":")[0]);
console.warn(`island: drop refused, no local file in ${drop.urls.length} URL(s): `
+ schemes.join(", ") + ` formats: ${drop.formats.join(", ")}`);
win.dropNote = schemes.includes("trash") ? "That is in the Trash: restore it first"
: "Not a local file";
noteTimer.restart();
drop.accepted = false;
return;
}
win.asst.attach(paths);
drop.accept(Qt.CopyAction);
}
}
Rectangle {
visible: win.dropHold || win.dropNote !== ""
anchors { horizontalCenter: parent.horizontalCenter; top: parent.top; topMargin: 14 }
width: parent.width
height: 72
radius: 16
color: Qt.alpha(Theme.base, 0.9)
border.width: 1
border.color: Qt.alpha(win.dropHold ? Theme.accent : Theme.red, 0.6)
Text {
anchors.centerIn: parent
text: win.dropHold ? "Drop for the assistant" : win.dropNote
color: win.dropHold ? Theme.text : Theme.red
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
}
}
}
// 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
panel: win.panel
onOpenCalendar: win.panel = win.panel === "calendar" ? "" : "calendar"
onCloseCalendar: win.panel = ""
}
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"); }
}
// While something plays or is paused; it sits left of the clock pill and
// follows it as the calendar widens it.
MediaPill {
id: media
visible: Player.playing || Player.current?.playbackState === MprisPlaybackState.Paused
x: win.cx - width / 2 - (win.gap * 1.5 + clock.width + width / 2) * win.reveal
y: -height + (win.top + height) * win.reveal
opacity: Math.min(1, win.reveal * 2)
}
// While OBS runs; right of the status pill, following it as the sound
// panel widens it.
ObsPill {
id: obs
visible: Obs.connected
panel: win.panel
onOpenPanel: win.panel = "obs"
x: win.cx - width / 2 + (win.gap * 1.5 + status.width + width / 2) * win.reveal
y: -height + (win.top + height) * win.reveal
opacity: Math.min(1, win.reveal * 2)
}
}
|