aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/companion/Companion.qml
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/companion/Companion.qml')
-rw-r--r--desktop/companion/Companion.qml577
1 files changed, 0 insertions, 577 deletions
diff --git a/desktop/companion/Companion.qml b/desktop/companion/Companion.qml
deleted file mode 100644
index dfdc474..0000000
--- a/desktop/companion/Companion.qml
+++ /dev/null
@@ -1,577 +0,0 @@
-// 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.Bluetooth
-import Quickshell.Hyprland
-import QtQuick
-import ".."
-import "../modules/sound"
-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
- // A hide plays a goodbye first: the wave frames, then the drop.
- // Re-showing her cancels it and greets as usual.
- property bool leaving: false
- property int waveIndex: 0
- readonly property var waveCycle: ["wave", "wave-l", "wave", "wave-r"]
- onPresentChanged: {
- if (root.present) {
- root.leaving = false;
- root.destructAt = 0; // the skirt is back
- root.now = Date.now();
- greet.restart();
- } else {
- root.stopMonologue();
- // Nothing to wave at when she is already out of view.
- if (root.fullscreen !== 2 && !root.hopping) {
- root.leaving = true;
- root.waveIndex = 0;
- root.now = Date.now();
- // A line one hide in four, like the monologue's roll, and not
- // forced, so the usual 30 s line cooldown still applies. A
- // silent wave is only as long as it takes to read nothing.
- const spoke = Math.random() < 0.25 && root.say("bye");
- goodbyeTimer.interval = spoke ? Mood.T.goodbye : Mood.T.goodbyeSilent;
- goodbyeTimer.start();
- }
- }
- }
-
- // Where she goes: the focused monitor's corner, or seated on the
- // assistant's tall card. here and sitting are where the window is now
- // and trail the goal: a change plays her exit first, and only then
- // moves the window (settled, below).
- readonly property string focused: Hyprland.focusedMonitor?.name ?? ""
- readonly property bool seated: root.asst.open && root.asst.tall
- readonly property string goal: root.seated ? root.asst.monitor : root.focused
- property string here: ""
- property bool sitting: false
- property bool hopping: false
- // No focused monitor (outputs gone over a suspend) is not a destination.
- // Already out of view, there is no exit to wait for: settled would never
- // fire, hopping would stick and hide her for good, so move at once.
- // Mid-hop, settled takes the latest goal; moving now would jump the
- // window before the exit has played (hopping counts as away).
- function move() {
- if (root.hopping || root.goal === "" || (root.goal === root.here && root.seated === root.sitting)) return;
- if (root.here === "" || root.away) {
- root.here = root.goal;
- root.sitting = root.seated;
- } else {
- root.hopping = true;
- }
- }
- onGoalChanged: root.move()
- onSeatedChanged: root.move()
- Component.onCompleted: root.move()
-
- screen: Quickshell.screens.find(s => s.name === root.here) ?? Quickshell.screens[0]
- anchors { right: true; bottom: true }
- // Seated, her right edge is 90 px right of the screen's centre, which
- // puts her in the gap in the card's header, and her feet 35 px below the
- // card's top. The right edge rather than her centre, because frames
- // differ in width and the window would shift with every frame.
- margins {
- right: root.sitting ? Math.round(root.asst.screenObj.width / 2 - 90) : 24
- bottom: root.sitting ? root.asst.screenObj.height - root.asst.cardTop - 35 : 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 singAt: 0
- property double djAt: 0
- property string groove: ""
- 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) {
- root.pickGroove();
- if (Player.playing) root.say("groove");
- else 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");
- }
- }
-
- // Sony headphones connecting, by the island's rule (island/StatusPill.qml):
- // recognised by their model prefix, since BlueZ gives no class that says
- // "headphones". A connection makes her sing for Mood.T.singHold. Bluetooth
- // reads empty for about 2 s after launch, so a shell start with them
- // already on is not a connection: btReady waits past that.
- readonly property bool phonesConnected: (Bluetooth.devices?.values ?? [])
- .some(d => d.connected && /^(WH|WF)-|^LinkBuds/.test(d.deviceName))
- property bool btReady: false
- // shortcut: a genuine connection in the first 4 s after a shell start is
- // missed along with the startup populate; tighten if that bites.
- Timer { interval: 4000; running: true; onTriggered: root.btReady = true }
- onPhonesConnectedChanged: {
- if (!root.phonesConnected || !root.btReady) return;
- root.singAt = Date.now();
- root.now = root.singAt;
- root.say("headphones");
- }
-
- // What is playing, from the sound module's player (it already drops the
- // playerctld and browser-integration duplicates). A new artist+title
- // while playing is a track start or change: she DJs for Mood.T.djHold
- // and says the artist's line if she knows them. Paused reads "", and
- // resuming the same track matches lastTrack, so it does not DJ again.
- readonly property string track: Player.playing ? Player.artist + "\n" + Player.title : ""
- property string lastTrack: ""
- onTrackChanged: {
- if (root.track === "" || root.track === root.lastTrack) return;
- root.lastTrack = root.track;
- root.djAt = Date.now();
- root.now = root.djAt;
- root.say("artist:" + Player.artist);
- }
- // Music starting while she is already bored needs a groove too.
- readonly property bool music: Player.playing
- onMusicChanged: if (root.music && root.idle === 1) root.pickGroove()
- function pickGroove() {
- const g = ["whistle", "headbang", "airguitar"];
- root.groove = g[Math.floor(Math.random() * g.length)];
- }
-
- // 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.sitting || 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. Seated on the
- // assistant's card she is nowhere near them.
- readonly property bool shoved: {
- if (root.sitting) return false;
- 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({
- leaving: root.leaving,
- 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,
- singAt: root.singAt, djAt: root.djAt, music: root.music, groove: root.groove,
- 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. While waving goodbye she is still on
- // screen, so a hidden present does not hide her until the wave is done.
- readonly property bool hidden: (!root.present && !root.leaving) || 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 false;
- const line = Lines.pick(kind, Math.random(), Prefs.companionApps, Prefs.companionArtists);
- if (line === "") return false;
- root.lastLineAt = t;
- bubble.show(line, 3000);
- return true;
- }
-
- // 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);
- }
- }
-
- // The goodbye: she waves, then leaves. With a line she holds long enough
- // to read it. The hand sways side to side while it lasts.
- Timer { id: goodbyeTimer; onTriggered: root.leaving = false }
- Timer {
- id: waveFlip
- interval: 250
- repeat: true
- running: root.leaving
- onTriggered: root.waveIndex = (root.waveIndex + 1) % root.waveCycle.length
- }
-
- // Frames Mood names that are really a loop: the frame names its cycle
- // and how fast it turns. Everything else shows as named.
- readonly property var cycles: ({
- dj: { frames: ["dj", "dj-scratch"], ms: 300 },
- headbang: { frames: ["headbang-up", "headbang-down"], ms: 220 },
- })
- property int cycleIndex: 0
- readonly property var cycle: root.cycles[root.mood.frame] ?? null
- onCycleChanged: root.cycleIndex = 0
- Timer {
- interval: root.cycle?.ms ?? 1000
- repeat: true
- running: root.cycle !== null && !root.away
- onTriggered: root.cycleIndex = (root.cycleIndex + 1) % root.cycle.frames.length
- }
- readonly property string standing: root.leaving ? root.waveCycle[root.waveIndex]
- : root.cycle ? root.cycle.frames[root.cycleIndex % root.cycle.frames.length] : root.mood.frame
- // Seated, the sitting version of it (Mood.seatFrame).
- readonly property string frame: root.sitting ? Mood.seatFrame(root.standing) : root.standing
-
- // qs -p <desktop> ipc call companion toggle -> SUPER+\
- IpcHandler {
- target: "companion"
- function toggle() { Prefs.setCompanion(!Prefs.companion); }
- function open() { Prefs.setCompanion(true); }
- function hide() { Prefs.setCompanion(false); }
- }
-
- // The exit has played: move, and the entrance plays. The goal may have
- // changed during the exit; the latest one wins.
- function settled(out) {
- if (out && root.hopping) {
- root.here = root.goal;
- root.sitting = root.seated;
- 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.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 }
- }
- }
-}