diff options
Diffstat (limited to 'desktop/companion/Companion.qml')
| -rw-r--r-- | desktop/companion/Companion.qml | 577 |
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 } - } - } -} |
