// Copyright (C) 2026 Danilo M. // // 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+\ (the IPC below) and the Companion settings page, through Prefs, // so it survives a restart. readonly property bool present: Prefs.ready && Prefs.companion onPresentChanged: { if (root.present) { root.destructAt = 0; // the skirt is back root.now = Date.now(); greet.restart(); } else { root.stopMonologue(); } } // 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 // Overlay, so she can sit above the other overlays (see remap below). WlrLayershell.layer: WlrLayer.Overlay 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 pulseAt: 0 property double wakeAt: 0 property double admireAt: 0 property double lastLineAt: 0 property double lastMonologueAt: 0 property bool monologue: false property string appClass: "" // Bored, yawning, lying down at 20, 40 and 70% of the wscreensavers idle // timeout, so she has lain down a while before the saver covers her (1, // 2 and 3.5 minutes at its 5). Read live, so a change in its settings // follows; 5 minutes when the file is missing or unreadable. // Inhibitors are ignored, as wscreensavers ignores them: No lock holds // one for as long as it is on, which kept her awake for hours. // Presentation (and gaming) mode keeps her awake instead, by name. // enabled is set explicitly: left out, it read false on creation. FileView { id: saverConf path: Quickshell.env("HOME") + "/.config/wscreensavers/config.json" watchChanges: true onFileChanged: reload() printErrors: false } readonly property int saverIdle: { try { const m = JSON.parse(saverConf.text()).idleTimeoutMinutes; return m > 0 ? m * 60 : 300; } catch (e) { return 300; } } IdleMonitor { id: idle1; enabled: true; respectInhibitors: false; timeout: Math.round(root.saverIdle * 0.2) } IdleMonitor { id: idle2; enabled: true; respectInhibitors: false; timeout: Math.round(root.saverIdle * 0.4) } IdleMonitor { id: idle3; enabled: true; respectInhibitors: false; timeout: Math.round(root.saverIdle * 0.7) } readonly property int idle: Status.presentation ? 0 : 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") { // Pulses come at most once a second, so a lone key (a // shortcut, an arrow) is one pulse and typing is a run // of them: she watches from the second within 2.5 s. if (t - root.pulseAt < 2500) root.typingAt = t; root.pulseAt = t; } else if (what === "keystone-delete") { root.typingAt = t; if (Mood.mockAllowed(root.mockAt, t)) { root.mockAt = t; root.say("mock"); } } } else if (event.name === "openlayer" && event.data.trim() !== "keystone-companion") { // Something mapped above her: the assistant's dim, rofi, // the keybind reminder. Map again, after it. root.remapping = true; remap.restart(); } else if (event.name === "activewindow") { // "class,title". A remark now and then when focus moves to a // different program, under the usual 30 s line cooldown. // ponytail: one line per class, no frames of her own; add // per-app frames or title matching (a site in firefox) if // lines alone feel thin. const cls = event.data.split(",")[0].trim().toLowerCase(); if (cls !== "" && cls !== root.appClass && Math.random() < 1 / 3) root.say("app:" + cls); root.appClass = cls; } else if (["fullscreen", "activewindowv2", "workspacev2", "closewindow"].includes(event.name)) { Hyprland.refreshToplevels(); fsSettle.restart(); admireSettle.restart(); } } } Connections { target: root.asst function onReplied() { root.repliedAt = Date.now(); root.say("ready"); } } // A listed wallpaper on an empty workspace: she turns to it and blushes. // Checked after the events above (switching to an empty workspace, // closing its last window) and whenever she stops being away: shown, // back from a remap, a hop, a dodge or fullscreen. Toplevels settle a // moment after the event, as fullscreen does. onAwayChanged: if (!root.away) admireSettle.restart() Timer { id: admireSettle interval: 100 onTriggered: { const t = Date.now(); if (root.away || root.here !== root.focused || !Mood.admireAllowed(root.admireAt, t)) return; if ((Hyprland.focusedWorkspace?.toplevels.values.length ?? 1) !== 0) return; if (!Admired.listed(Admired.on(root.here))) return; root.admireAt = t; root.now = t; admireLine.restart(); } } // The line comes with the blush, not the turn. Timer { id: admireLine; interval: Mood.T.admireLook; onTriggered: root.say("wallpaper") } // Hyprland stacks a later surface above an earlier one on the same layer // and 0.56.2 has no layer rule to order them, so the assistant canvas's // full-screen dim covered her and her think and wave were never seen. // Whenever another layer surface opens (openlayer, below), she unmaps // and maps again, after it. Unmapped she counts as away, so her figure // drops out unseen and plays its entrance once she is back: a vanish // and a hop in, rather than a flicker. property bool remapping: false visible: !root.remapping Timer { id: remap; interval: 400; onTriggered: root.remapping = false } // 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)); } property double shovedAt: 0 onShovedChanged: { root.shovedAt = root.shoved ? Date.now() : 0; 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, shovedAt: root.shovedAt, recording: root.asst.recording, busy: root.asst.busy, repliedAt: root.repliedAt, clicks: root.clicks, admireAt: root.admireAt, 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, Prefs.companionSkirtless * 1000) // 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 || root.remapping // --- 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(), Prefs.companionApps); 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 ---------------------------------------------- // After the drop-in has landed, on every appearance, the one at login // included. 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 ipc call companion toggle -> SUPER+\ IpcHandler { target: "companion" function toggle() { Prefs.setCompanion(!Prefs.companion); } function show() { Prefs.setCompanion(true); } function hide() { Prefs.setCompanion(false); } } // 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 trembling: root.mood.state === "admire" && root.mood.frame === "blush3" 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 } } } }