diff options
| -rw-r--r-- | desktop/companion/Companion.qml | 369 | ||||
| -rw-r--r-- | desktop/companion/Figure.qml | 147 | ||||
| -rw-r--r-- | desktop/shell.qml | 5 |
3 files changed, 521 insertions, 0 deletions
diff --git a/desktop/companion/Companion.qml b/desktop/companion/Companion.qml new file mode 100644 index 0000000..7f740a3 --- /dev/null +++ b/desktop/companion/Companion.qml @@ -0,0 +1,369 @@ +// 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 ".." +import "../NotifyConfig.js" as NC +import "Mood.js" as Mood +import "Lines.js" as Lines + +// The desktop companion: a chibi 2B in the bottom-right corner of the +// focused monitor. Inputs and timers live here, the decision in Mood.js, +// the drawing in Figure.qml. See README.md here and +// docs/superpowers/specs/2026-10-23-companion-design.md. +PanelWindow { + id: root + + required property var asst + + readonly property string art: Quickshell.env("HOME") + "/.local/share/keystone/companion/2b/frames" + + // SUPER+\, through the IPC below. + property bool present: true + + // The monitor she is on trails the focused one: a change plays her exit + // here first, and only then moves the window (settled, below). + readonly property string focused: Hyprland.focusedMonitor?.name ?? "" + property string here: "" + property bool hopping: false + onFocusedChanged: { + if (root.here === "") root.here = root.focused; + else if (root.focused !== root.here) root.hopping = true; + } + Component.onCompleted: root.here = root.focused + + screen: Quickshell.screens.find(s => s.name === root.here) ?? Quickshell.screens[0] + anchors { right: true; bottom: true } + margins { right: 24; bottom: 16 } + implicitWidth: 760 + implicitHeight: 260 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.namespace: "keystone-companion" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + // Her body only: clicks anywhere else, the bubble included, reach the + // window below. Out of view the region is empty. + mask: Region { item: hit } + + // --- inputs --------------------------------------------------------- + + property double now: Date.now() + Timer { interval: 200; repeat: true; running: root.present; onTriggered: root.now = Date.now() } + + property var clicks: ({ count: 0, at: 0 }) + property double destructAt: 0 + property bool dodging: false + property double repliedAt: 0 + property double mockAt: 0 + property double typingAt: 0 + property double wakeAt: 0 + property double lastLineAt: 0 + property double lastMonologueAt: 0 + property bool monologue: false + + // Bored, yawning, lying down at 1, 3 and 5 minutes. Inhibitors count, + // so presentation mode keeps her awake. enabled is set explicitly: left + // out, it read false on creation. + IdleMonitor { id: idle1; enabled: true; respectInhibitors: true; timeout: 60 } + IdleMonitor { id: idle2; enabled: true; respectInhibitors: true; timeout: 180 } + IdleMonitor { id: idle3; enabled: true; respectInhibitors: true; timeout: 300 } + readonly property int idle: idle3.isIdle ? 3 : idle2.isIdle ? 2 : idle1.isIdle ? 1 : 0 + property int wasIdle: 0 + onIdleChanged: { + const t = Date.now(); + if (root.idle === 0 && root.wasIdle > 0) { + root.wakeAt = t; + root.say("wake"); + } else if (root.idle === 1 && root.wasIdle === 0) { + if (Mood.monologue("bored", root.lastMonologueAt, t, Math.random())) root.startMonologue(); + else root.say("bored"); + } + root.wasIdle = root.idle; + } + + // 2 is real fullscreen; 1 is maximised, which is most windows here and + // must not hide her. lastIpcObject is only current after a refresh, and + // the island measured that it settles a moment after the event. + property int fullscreen: 0 + Timer { + id: fsSettle + interval: 60 + onTriggered: root.fullscreen = Hyprland.activeToplevel?.lastIpcObject?.fullscreen ?? 0 + } + + Connections { + target: Hyprland + function onRawEvent(event) { + if (event.name === "custom") { + // From hypr-theme's sections/companion.lua. + const what = event.data.trim(); + const t = Date.now(); + if (what === "keystone-typing") { + root.typingAt = t; + } else if (what === "keystone-delete") { + root.typingAt = t; + if (Mood.mockAllowed(root.mockAt, t)) { + root.mockAt = t; + root.say("mock"); + } + } + } else if (["fullscreen", "activewindowv2", "workspacev2"].includes(event.name)) { + Hyprland.refreshToplevels(); + fsSettle.restart(); + } + } + } + + Connections { + target: root.asst + function onReplied() { + root.repliedAt = Date.now(); + root.say("ready"); + } + } + + // Balloons stack bottom-right of their screen. When that is her corner, + // any balloon on screen shoves her left past the stack. + readonly property bool shoved: { + const place = Notify.config.balloons; + if (place.screen !== root.here || place.corner !== "bottom-right") return false; + return (Notify.queue || []).some(n => NC.shown(Notify.config, n, Notify.dnd, + Notify.drawerOpen, Notify.snoozeUntil, root.now)); + } + onShovedChanged: if (root.shoved) root.say("shoved") + + // --- state ---------------------------------------------------------- + + readonly property var mood: Mood.pick({ + now: root.now, destructAt: root.destructAt, dodging: root.dodging, shoved: root.shoved, + recording: root.asst.recording, busy: root.asst.busy, repliedAt: root.repliedAt, + clicks: root.clicks, mockAt: root.mockAt, typingAt: root.typingAt, wakeAt: root.wakeAt, + monologue: root.monologue, idle: root.idle, + }) + readonly property string outfit: Mood.outfit(root.destructAt, root.now) + // Anything above the monologue cuts it short. + onMoodChanged: if (root.monologue && root.mood.state !== "muse") root.stopMonologue() + + // Hidden: no sprite, no bubble. Away adds the dodge, which hides the + // sprite but lets its line show. + readonly property bool hidden: !root.present || root.fullscreen === 2 || root.hopping + readonly property bool away: root.hidden || root.dodging + + // --- speech --------------------------------------------------------- + + function say(kind, force) { + const t = Date.now(); + if (root.hidden || (!force && !Mood.canSpeak(root.lastLineAt, t))) return; + const line = Lines.pick(kind, Math.random()); + if (line === "") return; + root.lastLineAt = t; + bubble.show(line, 3000); + } + + // The opening monologue: about 15 characters a second, one sentence + // per bubble, 1.5 s between sentences, the last held 4 s. + property int monoLine: 0 + function startMonologue() { + if (root.hidden) return; + root.monologue = true; + root.lastMonologueAt = Date.now(); + root.lastLineAt = root.lastMonologueAt; + root.monoLine = 0; + typer.begin(Lines.monologue[0]); + } + function stopMonologue() { + root.monologue = false; + typer.stop(); + typerHold.stop(); + bubble.hide(); + } + Timer { + id: typer + property string full: "" + interval: 66 + repeat: true + function begin(text) { typer.full = text; bubble.typed(""); typer.restart(); } + onTriggered: { + const n = bubble.text.length + 1; + bubble.typed(typer.full.slice(0, n)); + if (n < typer.full.length) return; + typer.stop(); + typerHold.interval = root.monoLine === Lines.monologue.length - 1 ? 4000 : 1500; + typerHold.restart(); + } + } + Timer { + id: typerHold + onTriggered: { + root.monoLine++; + if (root.monoLine >= Lines.monologue.length) root.stopMonologue(); + else typer.begin(Lines.monologue[root.monoLine]); + } + } + + // --- clicks and the dodge ------------------------------------------- + + function clicked() { + const t = Date.now(); + root.now = t; + root.clicks = Mood.click(root.clicks, t); + if (root.clicks.count >= 7) { + root.clicks = { count: 0, at: 0 }; + root.stopMonologue(); + root.destructAt = t; + sprite.explode(); + root.say("destruct", true); + aftermath.restart(); + } else if (root.clicks.count === 1) { + root.say("click", true); + } else if (root.clicks.count === 4) { + root.say("blush", true); + } + } + Timer { id: aftermath; interval: Mood.T.destruct + 400; onTriggered: root.say("aftermath", true) } + + // She cannot see the pointer coming, only feel it on her: Quickshell has + // no global pointer and Hyprland sends no motion events. A click before + // the 0.6 s still reaches her. + Timer { + id: hover + interval: 600 + onTriggered: { + if (Math.random() < 0.5) root.say("dodge"); + root.dodging = true; + dodgeBack.restart(); + } + } + Timer { id: dodgeBack; interval: 5000; onTriggered: root.dodging = false } + + // --- showing and hiding ---------------------------------------------- + + function appear() { + if (root.present) return; + root.present = true; + root.destructAt = 0; // the skirt is back + root.now = Date.now(); + greet.restart(); + } + function dismiss() { + if (!root.present) return; + root.stopMonologue(); + bubble.hide(); + root.present = false; + } + // After the drop-in has landed. + Timer { + id: greet + interval: 450 + onTriggered: { + if (Mood.monologue("shown", root.lastMonologueAt, Date.now(), Math.random())) root.startMonologue(); + else root.say("shown", true); + } + } + + // qs -p <desktop> ipc call companion toggle -> SUPER+\ + IpcHandler { + target: "companion" + function toggle() { if (root.present) root.dismiss(); else root.appear(); } + function show() { root.appear(); } + function hide() { root.dismiss(); } + } + + // The exit has played on the old monitor: move, and the entrance plays. + function settled(out) { + if (out && root.hopping) { + root.here = root.focused; + root.hopping = false; + } + } + + // --- drawing -------------------------------------------------------- + + // Where she stands: the bottom-right of the window, or left of the + // balloon stack. 336 = the stack's 340 px plus its 12 px margin + // (notifications/Balloons.qml), minus this window's 24 px right margin, + // plus 8 px of air. + Item { + id: seat + width: sprite.width + height: sprite.height + anchors.bottom: parent.bottom + x: root.width - width - (root.shoved ? 336 : 0) + Behavior on x { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } } + } + + Figure { + id: sprite + x: seat.x + y: seat.y + source: `file://${root.art}/${root.outfit}/${root.mood.frame}.png` + fallback: `file://${root.art}/${root.outfit}/base.png` + out: root.away + onSettled: out => root.settled(out) + } + + Item { + id: hit + x: sprite.x + y: sprite.y + width: root.away ? 0 : sprite.width + height: root.away ? 0 : sprite.height + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: hover.restart() + onExited: hover.stop() + onClicked: { hover.stop(); root.clicked(); } + } + } + + // Up and to the left of her head, anchored to where she stands rather + // than to the sprite, so a dodge line stays where she was. + Rectangle { + id: bubble + property alias text: label.text + function show(t, ms) { label.text = t; bubble.opacity = 1; hideTimer.interval = ms; hideTimer.restart(); } + function typed(t) { label.text = t; bubble.opacity = 1; hideTimer.stop(); } + function hide() { hideTimer.stop(); bubble.opacity = 0; } + + visible: !root.hidden && opacity > 0 && label.text !== "" + anchors { right: seat.left; rightMargin: -14; bottom: seat.top; bottomMargin: -22 } + width: Math.min(260, label.implicitWidth + 24) + height: label.implicitHeight + 16 + radius: 12 + color: Qt.alpha(Theme.base, 0.92) + border { width: 1; color: Qt.alpha(Theme.text, 0.25) } + opacity: 0 + Behavior on opacity { NumberAnimation { duration: 180 } } + + Timer { id: hideTimer; onTriggered: bubble.opacity = 0 } + + Text { + id: label + anchors.centerIn: parent + width: Math.min(236, implicitWidth) + wrapMode: Text.WordWrap + color: Theme.text + font { family: Theme.fontFamily; pixelSize: 13 } + } + Rectangle { + width: 10; height: 10; rotation: 45 + color: bubble.color + anchors { right: parent.right; rightMargin: 18; bottom: parent.bottom; bottomMargin: -5 } + } + } +} diff --git a/desktop/companion/Figure.qml b/desktop/companion/Figure.qml new file mode 100644 index 0000000..0b3b671 --- /dev/null +++ b/desktop/companion/Figure.qml @@ -0,0 +1,147 @@ +// 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 QtQuick +import ".." + +// One frame of the companion, drawn at half its pixel size and standing on +// its bottom edge: a crossfade between frames, a slow bob, the drop out of +// view and back, and the self-destruct flash. Which frame is Mood.js's +// business; this only draws it. +Item { + id: s + + // The frame, and what to show when it is missing: the same outfit's + // base, never the other outfit's, so a gap in the art cannot put the + // skirt back mid-scene. + property string source + property string fallback + // Dropped below the bottom edge and faded out. + property bool out: false + signal settled(bool out) + + // Two images: the front one stays until the back one has loaded, then + // they swap, so a frame change never flashes empty. + property bool flip: false + readonly property Image front: s.flip ? b : a + width: s.front.implicitWidth / 2 + height: s.front.implicitHeight / 2 + + function load(url) { + const front = s.flip ? b : a, back = s.flip ? a : b; + if (String(front.source) === url) { back.source = ""; return; } + if (String(back.source) === url && back.status === Image.Ready) { s.flip = !s.flip; return; } + back.source = url; + } + onSourceChanged: s.load(s.source) + Component.onCompleted: s.load(s.source) + + function ready(img) { + if (img.status === Image.Error && String(img.source) !== s.fallback) { + img.source = s.fallback; + return; + } + if (img.status === Image.Ready && img !== s.front) s.flip = !s.flip; + } + + property real drop: 0 + property real bob: 0 + NumberAnimation { + id: dropAnim + target: s; property: "drop"; duration: 320 + onFinished: s.settled(s.out) + } + onOutChanged: { + dropAnim.stop(); + dropAnim.to = s.out ? s.height + 24 : 0; + dropAnim.easing.type = s.out ? Easing.InCubic : Easing.OutBack; + dropAnim.start(); + } + SequentialAnimation on bob { + running: !s.out + loops: Animation.Infinite + NumberAnimation { to: 2; duration: 1900; easing.type: Easing.InOutSine } + NumberAnimation { to: 0; duration: 2300; easing.type: Easing.InOutSine } + } + + Item { + anchors.fill: parent + opacity: 1 - s.drop / (s.height + 24) + transform: Translate { y: s.drop - s.bob } + + Image { + id: a + width: implicitWidth / 2; height: implicitHeight / 2 + anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter } + smooth: true; mipmap: true; asynchronous: true + opacity: s.flip ? 0 : 1 + Behavior on opacity { NumberAnimation { duration: 120 } } + onStatusChanged: s.ready(a) + } + Image { + id: b + width: implicitWidth / 2; height: implicitHeight / 2 + anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter } + smooth: true; mipmap: true; asynchronous: true + opacity: s.flip ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: 120 } } + onStatusChanged: s.ready(b) + } + } + + // --- self-destruct: the glow swells into a flash, then a ring and + // sparks burst out. About 1.1 s, matching Mood.T.destruct, during which + // the frame swaps to the leotard under the flash. + + function explode() { boom.restart(); } + + property real burst: 0 + Rectangle { + id: flash + anchors.centerIn: parent + width: s.height * 1.6; height: width; radius: width / 2 + color: "white"; opacity: 0; scale: 0 + } + Rectangle { + id: ring + anchors.centerIn: parent + width: s.height * 2; height: width; radius: width / 2 + color: "transparent" + border { width: 3; color: Theme.peach } + opacity: 0; scale: 0 + } + Repeater { + model: 8 + Rectangle { + required property int index + readonly property real angle: index * Math.PI / 4 + width: 6; height: 6; radius: 3 + color: index % 2 ? Theme.peach : Theme.yellow + x: s.width / 2 - 3 + Math.cos(angle) * s.burst * s.height + y: s.height / 2 - 3 + Math.sin(angle) * s.burst * s.height + opacity: s.burst > 0 && s.burst < 1 ? 1 - s.burst : 0 + } + } + SequentialAnimation { + id: boom + ParallelAnimation { + NumberAnimation { target: flash; property: "scale"; from: 0; to: 1; duration: 600; easing.type: Easing.InQuad } + NumberAnimation { target: flash; property: "opacity"; from: 0; to: 0.95; duration: 600 } + } + ParallelAnimation { + NumberAnimation { target: flash; property: "opacity"; to: 0; duration: 400 } + NumberAnimation { target: ring; property: "scale"; from: 0.2; to: 1.4; duration: 400; easing.type: Easing.OutCubic } + NumberAnimation { target: ring; property: "opacity"; from: 1; to: 0; duration: 400 } + NumberAnimation { target: s; property: "burst"; from: 0; to: 1; duration: 500; easing.type: Easing.OutCubic } + } + PropertyAction { target: s; property: "burst"; value: 0 } + } +} diff --git a/desktop/shell.qml b/desktop/shell.qml index 8579709..de190e8 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -40,6 +40,7 @@ import "modules/user" import "modules/vm" import "island" import "assistant" +import "companion" ShellRoot { // Quickshell exits once no window is visible, and the drawer is closed @@ -125,6 +126,10 @@ ShellRoot { // socket. See assistant/README.md. Assistant { id: assistant; swapping: asstSettings.loading; hears: asstSettings.hears } + // The desktop companion, bottom-right of the focused monitor, on SUPER+\. + // She reads the assistant's state. See companion/README.md. + Companion { asst: assistant } + // Opening the assistant loads its preferred model if another one is in // VRAM. See modules/assistant/README.md. Connections { |
