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
|
// 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.Io
import Quickshell.Wayland
import Quickshell.Hyprland
import QtQuick
import "Place.js" as Place
// The voice assistant's overlay. desktop-assistant broadcasts its state,
// context use and audio levels as JSON lines on a Unix socket; this only
// listens, and keeps reconnecting while the assistant is down.
Scope {
id: root
// Replaced by the assistant's hello; these render only before it.
property string name: "Assistant"
property string position: "center"
property real hideDelay: 3
property string userColor: "accent"
property string assistantColor: "green"
property int ctxUsed: 0
property int ctxMax: 0
property string state: "idle"
property string tool: ""
property real level: 0 // latest loudness, 0..1
property string who: "user" // whose voice that was
property real heardAt: 0 // when it arrived, ms
property bool shown: false
property var shownScreen: Quickshell.screens[0]
// Faded, not switched: the window stays loaded until the fade has run.
property real fade: shown ? 1 : 0
Behavior on fade { NumberAnimation { duration: 200 } }
// Colour roles are palette names from udt, so a theme change recolours
// the waves live. An unknown name falls back to the accent.
function colorOf(role) { return Theme[role] ?? Theme.accent; }
function handle(line) {
let e;
try { e = JSON.parse(line); } catch (err) { return; }
if (e.type === "hello") {
root.name = e.name;
root.position = e.position;
root.hideDelay = e.hide_delay;
root.userColor = e.user_color;
root.assistantColor = e.assistant_color;
root.ctxMax = e.ctx_max;
root.setState(e.state, e.tool ?? "");
} else if (e.type === "state") {
root.setState(e.state, e.tool ?? "");
} else if (e.type === "ctx") {
root.ctxUsed = e.used;
root.ctxMax = e.max;
} else if (e.type === "level") {
root.level = e.value;
root.who = e.who;
root.heardAt = Date.now();
}
}
function setState(s, t) {
root.state = s;
root.tool = t;
if (s === "listening" && !root.shown) {
// The monitor in focus when it appears, not wherever it last was.
const focused = Hyprland.focusedMonitor?.name;
root.shownScreen = Quickshell.screens.find(x => x.name === focused) ?? Quickshell.screens[0];
root.level = 0;
root.shown = true;
}
if (s === "idle") hideTimer.restart(); else hideTimer.stop();
}
Timer {
id: hideTimer
interval: root.hideDelay * 1000
onTriggered: root.shown = false
}
// The assistant may start after this shell, or restart: keep trying with
// a fresh Socket each time. Quickshell 0.3.1 keeps the QLocalSocket of a
// failed connect, so setting `connected` again on the same Socket never
// retries: an overlay started before the assistant stayed dark for good.
LazyLoader {
id: link
active: true
Socket {
path: `${Quickshell.env("XDG_RUNTIME_DIR") || "/tmp"}/desktop-assistant.sock`
connected: true
onConnectionStateChanged: if (!connected) root.shown = false
parser: SplitParser { onRead: line => root.handle(line) }
}
}
Timer {
interval: 2000
repeat: true
running: !(link.item?.connected ?? false)
onTriggered: { link.active = false; link.active = true; }
}
// A config with no visible window exits, reporting nothing. This 1x1
// click-through window holds the shell open while the overlay is hidden.
PanelWindow {
anchors { top: true; left: true }
implicitWidth: 1
implicitHeight: 1
color: "transparent"
exclusionMode: ExclusionMode.Ignore
mask: Region {}
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
}
LazyLoader {
active: root.fade > 0
PanelWindow {
screen: root.shownScreen
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
exclusionMode: ExclusionMode.Ignore
mask: Region {}
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "quickshell-assistant"
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
Item {
id: orb
// Every size below scales from this one value.
readonly property real zoom: 1.3
readonly property int size: 360 * zoom
readonly property var spot: Place.at(root.position, parent.width, parent.height, size, 24)
x: spot.x
y: spot.y
width: size
height: size
opacity: root.fade
// The waves: three sine ripples round the circle, drifting at
// different speeds, their height following the loudness and
// their colour whoever is talking. Silence settles them into a
// thin flat ring.
Canvas {
id: waves
anchors.fill: parent
renderTarget: Canvas.FramebufferObject
property real amp: 0
property real phase: 0
// One repaint per display frame (vsync, not a timer: a
// 16 ms Timer drifted against the refresh and stuttered).
// Motion scales by the frame's real duration, so a late
// frame does not jump. The level stream only sets the
// height; a level older than 150 ms means the audio
// stopped, so the height eases back to zero.
FrameAnimation {
running: root.fade > 0
onTriggered: {
const target = Date.now() - root.heardAt < 150 ? root.level : 0;
waves.amp += (target - waves.amp) * (1 - Math.exp(-frameTime * 15));
waves.phase += frameTime * 3;
waves.requestPaint();
}
}
onPaint: {
const ctx = getContext("2d");
ctx.reset();
const c = width / 2, r0 = 86 * orb.zoom, reach = 70 * orb.zoom, n = 128;
ctx.strokeStyle = `${root.colorOf(root.who === "user" ? root.userColor : root.assistantColor)}`;
ctx.lineWidth = 2.5 * orb.zoom;
[[3, 1.3, 0.9], [5, -0.9, 0.55], [7, 2.1, 0.35]].forEach(([k, speed, alpha]) => {
ctx.globalAlpha = alpha;
ctx.beginPath();
for (let i = 0; i <= n; i++) {
const a = i / n * 2 * Math.PI;
const r = r0 + 4 + amp * reach * (0.55 + 0.45 * Math.sin(k * a + phase * speed));
const x = c + r * Math.cos(a), y = c + r * Math.sin(a);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
});
}
}
Rectangle {
anchors.centerIn: parent
width: 160 * orb.zoom
height: width
radius: width / 2
color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.8)
border.color: root.colorOf(root.assistantColor)
border.width: 2
Column {
anchors.centerIn: parent
spacing: 4
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: root.name
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: (Theme.fontSize + 2) * orb.zoom
font.bold: true
}
// Working without sound (transcribing, thinking):
// three dots pulsing in turn, on the waves' clock.
Row {
anchors.horizontalCenter: parent.horizontalCenter
visible: root.state === "thinking" || root.state === "transcribing"
spacing: 6 * orb.zoom
Repeater {
model: 3
Rectangle {
required property int index
width: 7 * orb.zoom
height: width
radius: width / 2
color: root.colorOf(root.assistantColor)
opacity: 0.25 + 0.75 * (0.5 + 0.5 * Math.sin(waves.phase * 2 - index * 0.9))
}
}
}
// nf-fa-wrench, present in Inconsolata Nerd Font.
Text {
anchors.horizontalCenter: parent.horizontalCenter
visible: root.state === "tool"
text: ""
color: Theme.peach
font.family: Theme.iconFamily
font.pixelSize: 22 * orb.zoom
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: root.state === "tool" ? root.tool
: root.ctxMax > 0 ? `${root.ctxUsed} / ${root.ctxMax}`
: `${root.ctxUsed}`
color: Theme.subtext
font.family: Theme.fontFamily
font.pixelSize: (Theme.fontSize - 3) * orb.zoom
}
}
}
}
}
}
}
|