diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-23 19:32:50 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-23 19:32:50 +0200 |
| commit | 540aa76b6088025dfca284a731002cec6b6c607c (patch) | |
| tree | 83bde744f14c8368b6ceee11e1d57ca2fa9c40a8 /desktop/companion | |
| parent | 588d78a19cac279b240588f47c26442d5abf3a12 (diff) | |
| download | quickshell-540aa76b6088025dfca284a731002cec6b6c607c.tar.gz quickshell-540aa76b6088025dfca284a731002cec6b6c607c.zip | |
companion: wave goodbye on hide
SUPER+\ now plays a short goodbye instead of dropping her at once: she cycles wave-l and wave-r around the existing wave for a sway, then the normal drop. One hide in four she also says a bye line, non-forced so the usual 30 s cooldown applies; the hold matches what showed, 2 s with a line and 1 s silent.
The sway is klein edits of dress/wave rather than base, so the arm stays up and only the hand moves; the leotard twins come from their dress frames. The leotard rerolled to seed 7 because the first seed left a faint alpha fringe that widened the canvas and made the flip jitter.
Fullscreen, the lock screen and the monitor hop keep their own exits and do not wave.
Also carries the pending onFocusedChanged guard: with no focused monitor, or already away, she moves at once rather than waiting for an exit that will never settle.
Diffstat (limited to 'desktop/companion')
| -rw-r--r-- | desktop/companion/Companion.qml | 50 | ||||
| -rw-r--r-- | desktop/companion/Lines.js | 1 | ||||
| -rw-r--r-- | desktop/companion/Mood.js | 5 | ||||
| -rw-r--r-- | desktop/companion/README.md | 7 | ||||
| -rw-r--r-- | desktop/companion/frames.txt | 6 | ||||
| -rw-r--r-- | desktop/companion/test_mood.js | 8 |
6 files changed, 69 insertions, 8 deletions
diff --git a/desktop/companion/Companion.qml b/desktop/companion/Companion.qml index fe2d751..6eb7370 100644 --- a/desktop/companion/Companion.qml +++ b/desktop/companion/Companion.qml @@ -33,13 +33,31 @@ PanelWindow { // 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(); + } } } @@ -48,9 +66,13 @@ PanelWindow { readonly property string focused: Hyprland.focusedMonitor?.name ?? "" property string here: "" 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. onFocusedChanged: { - if (root.here === "") root.here = root.focused; - else if (root.focused !== root.here) root.hopping = true; + if (root.focused === "" || root.focused === root.here) return; + if (root.here === "" || root.away) root.here = root.focused; + else root.hopping = true; } Component.onCompleted: root.here = root.focused @@ -238,6 +260,7 @@ PanelWindow { // --- 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, @@ -249,19 +272,21 @@ PanelWindow { 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 + // 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; + if (root.hidden || (!force && !Mood.canSpeak(root.lastLineAt, t))) return false; const line = Lines.pick(kind, Math.random(), Prefs.companionApps); - if (line === "") return; + if (line === "") return false; root.lastLineAt = t; bubble.show(line, 3000); + return true; } // The opening monologue: about 15 characters a second, one sentence @@ -353,6 +378,17 @@ PanelWindow { } } + // 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 + } + // qs -p <desktop> ipc call companion toggle -> SUPER+\ IpcHandler { target: "companion" @@ -388,7 +424,7 @@ PanelWindow { id: sprite x: seat.x y: seat.y - source: `file://${root.art}/${root.outfit}/${root.mood.frame}.png` + source: `file://${root.art}/${root.outfit}/${root.leaving ? root.waveCycle[root.waveIndex] : root.mood.frame}.png` fallback: `file://${root.art}/${root.outfit}/base.png` out: root.away trembling: root.mood.state === "admire" && root.mood.frame === "blush3" diff --git a/desktop/companion/Lines.js b/desktop/companion/Lines.js index 8ba5173..6609315 100644 --- a/desktop/companion/Lines.js +++ b/desktop/companion/Lines.js @@ -17,6 +17,7 @@ var lines = { shown: ["Glory to mankind.", "2B, reporting.", "...Back on duty."], + bye: ["2B, signing off.", "Rest while you can.", "Duty complete."], bored: ["...Operator, I'm bored.", "Nothing to report.", "Awaiting orders."], wake: ["I'm awake.", "Systems online."], mock: ["Typo again?", "Delete, delete, delete.", "Were you aiming for that key?"], diff --git a/desktop/companion/Mood.js b/desktop/companion/Mood.js index 409a958..e7aaea1 100644 --- a/desktop/companion/Mood.js +++ b/desktop/companion/Mood.js @@ -28,6 +28,8 @@ var T = { monologueCooldown: 3600000, destruct: 1000, // the glow and the flash; the sprite is hidden under it scorched: 3000, + goodbye: 2000, // waving goodbye with a line, enough to read it + goodbyeSilent: 1000, // waving goodbye with nothing to read admireLook: 1500, // turned to the wallpaper, before the blush admireBlush: 3000, admireCooldown: 180000, @@ -53,6 +55,9 @@ function clickFrame(count) { // The spec's priority table, top to bottom: the first that applies wins. // idle is 0 awake, 1 bored, 2 yawning, 3 lying down. function pick(s) { + // The user hid her: the goodbye outranks whatever she was doing. The + // frame here is a placeholder; Companion.qml cycles the wave frames. + if (s.leaving) return st("bye", "wave"); const since = s.destructAt ? s.now - s.destructAt : Infinity; if (since < T.destruct) return st("destruct", "base"); if (since < T.destruct + T.scorched) return st("scorched", "scorched"); diff --git a/desktop/companion/README.md b/desktop/companion/README.md index 4fd54c4..a1ec12d 100644 --- a/desktop/companion/README.md +++ b/desktop/companion/README.md @@ -24,6 +24,13 @@ Whether she is shown is `companion` in `Prefs.qml`, so it survives a restart: SUPER+\ and the switch on Settings > Companion (`desktop/modules/companion/`) both set it. +A hide by SUPER+\ or that switch plays a goodbye first: she waves (`wave`, +`wave-l` and `wave-r` cycling) and then drops out. One hide in four she also +says a `bye` line, under the usual 30 s line cooldown; with a line she holds +`Mood.T.goodbye` (2 s, enough to read it), silently only +`Mood.T.goodbyeSilent` (0.5 s). Fullscreen, the lock screen and the monitor +hop keep their own exits and do not wave. + ## Art Not in the repo: the character is Square Enix's and this repo is GPLv2. diff --git a/desktop/companion/frames.txt b/desktop/companion/frames.txt index 0019db1..a3e97db 100644 --- a/desktop/companion/frames.txt +++ b/desktop/companion/frames.txt @@ -46,6 +46,10 @@ dress/sleepy <- dress/base : Change only her expression to sleepy and drowsy, h # --- poses dress/wave <- dress/base : Raise only the hand on the left side of the image beside her head, waving, with a small smile; her other arm stays at her side. ${KEEP} +# The goodbye wave sways side to side. Edited from wave, the centre of the +# sway, so klein keeps the arm up and moves only the hand. +dress/wave-l <- dress/wave : Change only the hand she holds up: bend it at the wrist toward the left side of the image, fingers pointing left, as if in the middle of a goodbye wave; keep the arm raised beside her head, her small smile, and everything else exactly the same. ${KEEP} +dress/wave-r <- dress/wave : Change only the hand she holds up: bend it at the wrist toward the right side of the image, fingers pointing right, as if in the middle of a goodbye wave; keep the arm raised beside her head, her small smile, and everything else exactly the same. ${KEEP} # Shown while typing: she faces into the screen, away from the viewer, as if # reading over the user's shoulder. A profile read as posing, not watching. dress/watch <- dress/base : Turn her completely around into a back view: we see her from behind, the back of her head with her white bob, crossed by her black hairband and the small black cloth knot of her black blindfold (black, not white, no bow), her back, the back of her dress and the backs of her legs, as if she is facing a screen and watching it; arms relaxed at her sides. Same character, black gloves, puffy black juliet sleeves, black dress with its skirt, black thighhigh boots, black hairband, chibi proportions, art style and white background. @@ -80,6 +84,8 @@ leotard/blush2 <- dress/blush2 : ${LEOTARD} leotard/blush3 <- dress/blush3 : ${LEOTARD} leotard/sleepy <- dress/sleepy : ${LEOTARD} leotard/wave <- dress/wave : ${LEOTARD} +leotard/wave-l <- dress/wave-l : ${LEOTARD} +leotard/wave-r <- dress/wave-r : ${LEOTARD} # From dress/watch, klein kept the blindfold's band at the back of her head # through three prompts, by name and by position; from leotard/base, which # has no blindfold, the back view is the usual pose edit. diff --git a/desktop/companion/test_mood.js b/desktop/companion/test_mood.js index 37af2ea..105648c 100644 --- a/desktop/companion/test_mood.js +++ b/desktop/companion/test_mood.js @@ -27,7 +27,7 @@ const L = new Function(lsrc + "\nreturn { parse, toggle, outputs };")(); const NOW = 10000000; // A snapshot with nothing going on, overridden per case. const calm = over => Object.assign({ - now: NOW, destructAt: 0, dodging: false, shoved: false, shovedAt: 0, recording: false, busy: false, + leaving: false, now: NOW, destructAt: 0, dodging: false, shoved: false, shovedAt: 0, recording: false, busy: false, repliedAt: 0, clicks: { count: 0, at: 0 }, mockAt: 0, typingAt: 0, wakeAt: 0, admireAt: 0, monologue: false, idle: 0, }, over); @@ -40,6 +40,7 @@ assert.deepStrictEqual(M.pick(calm({})), { state: "base", frame: "base" }); // The priority table, top to bottom: with every trigger from a row down // active at once, that row wins. Merged so a higher row's values win. const ladder = [ + ["bye", { leaving: true }], ["destruct", { destructAt: NOW - 10 }], ["scorched", { destructAt: NOW - M.T.destruct - 10 }], ["dodge", { dodging: true }], @@ -60,6 +61,11 @@ for (let i = 0; i < ladder.length; i++) { assert.strictEqual(state(all), ladder[i][0], "everything from " + ladder[i][0] + " down"); } +// A hide: she waves goodbye, above everything else, whatever she was doing. +assert.deepStrictEqual( + M.pick(calm({ leaving: true, idle: 3, clicks: { count: 3, at: NOW - 10 } })), + { state: "bye", frame: "wave" }); + // Shoved: knocked off balance for a moment, then mad beside the balloon // for as long as it stays. assert.deepStrictEqual(M.pick(calm({ shoved: true, shovedAt: NOW - 10 })), { state: "shoved", frame: "shoved" }); |
