diff options
Diffstat (limited to 'assistant/Assistant.qml')
| -rw-r--r-- | assistant/Assistant.qml | 255 |
1 files changed, 255 insertions, 0 deletions
diff --git a/assistant/Assistant.qml b/assistant/Assistant.qml new file mode 100644 index 0000000..4117882 --- /dev/null +++ b/assistant/Assistant.qml @@ -0,0 +1,255 @@ +// 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 + } + + Socket { + id: sock + 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) } + } + + // The assistant may start after this shell, or restart: keep trying. + Timer { + interval: 2000 + repeat: true + running: !sock.connected + onTriggered: sock.connected = 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 + } + } + } + } + } + } +} |
