From 0a7db3b48cce5f064c20f280e345a8bf1feb5ecb Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 29 Oct 2026 10:12:17 +0100 Subject: companion: remove from the tree, archived as tag archive/companion The chibi 2B companion is retired for now. Its last state is kept in the signed tag archive/companion (the commit before this one), so it can be revisited with `git checkout archive/companion`; hypr-theme carries a tag of the same name for its compositor hooks (typing/delete events and the SUPER+\ bind). Removed with it, since nothing else used them: the Companion, phrases and artists settings pages, the five companion prefs in Prefs.qml and Prefs.ready, the wallpaper heart (Admired.qml's list), the assistant's replied() signal and cardTop, and the keybinds reminder's name for the code:49 bind. The design specs and plans under docs/ stay as history. Co-Authored-By: Claude Opus 5.5 --- desktop/companion/Companion.qml | 577 ---------------------------------------- 1 file changed, 577 deletions(-) delete mode 100644 desktop/companion/Companion.qml (limited to 'desktop/companion/Companion.qml') 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. -// -// 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 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 } - } - } -} -- cgit v1.2.3