aboutsummaryrefslogtreecommitdiffstats
path: root/desktop
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-23 15:03:41 +0200
committerDanilo M. <danix@danix.xyz>2026-10-23 15:03:41 +0200
commitfd7844f2f264c51db027b945031704c36169b4c6 (patch)
tree391eab7b1929668b910a11b2d03dde47182ae51a /desktop
parentb324159b0636180ae65d7dde4ed424b21ff02117 (diff)
downloadquickshell-fd7844f2f264c51db027b945031704c36169b4c6.tar.gz
quickshell-fd7844f2f264c51db027b945031704c36169b4c6.zip
companion: the window, her inputs and her drawing
Companion.qml is a Top-layer PanelWindow bottom-right of the focused monitor, masked to her body so every other click passes through. It gathers the inputs (Hyprland's custom typing and delete events, the assistant's state and replied signal, the balloons on her screen, three IdleMonitors, clicks and a hover), hands a snapshot to Mood.js, and speaks from Lines.js. Figure.qml crossfades frames, bobs, drops out of view and back, and plays the self-destruct. Figure.qml was planned as Sprite.qml, but QtQuick ships a Sprite type and an explicit import beats the directory's own file: the shell failed to load with "Cannot assign to non-existent property onSettled". Maximised windows report fullscreen 1 and most windows here are, so only 2 hides her. She only feels the pointer once it is on her, so the dodge waits 0.6 s and a quick click still lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop')
-rw-r--r--desktop/companion/Companion.qml369
-rw-r--r--desktop/companion/Figure.qml147
-rw-r--r--desktop/shell.qml5
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 {