diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-29 10:12:17 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-29 10:12:17 +0100 |
| commit | 0a7db3b48cce5f064c20f280e345a8bf1feb5ecb (patch) | |
| tree | eaf9c4be1a832893acf327019ef71a352d9d6647 | |
| parent | 28beb8a9600b6e74cab454401cc94c664d90bfe5 (diff) | |
| download | quickshell-0a7db3b48cce5f064c20f280e345a8bf1feb5ecb.tar.gz quickshell-0a7db3b48cce5f064c20f280e345a8bf1feb5ecb.zip | |
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 <noreply@anthropic.com>
25 files changed, 8 insertions, 2489 deletions
@@ -20,7 +20,6 @@ modules. island/ top-centre pills on DP-1: clock, volume, tray, layout, privacy, calendar, OBS assistant/ the assistant canvas on SUPER+Home, a view over desktop-assistant - companion/ a chibi 2B, bottom-right of the focused monitor, on SUPER+\ window-switcher/ expose: open windows scattered as live previews, on ALT+TAB or the top-right hot corner of DP-1 keybinds/ live keybind reminder, on SUPER+k @@ -243,9 +242,9 @@ changing that component. The ones that generalise: that imports Controls builds its buttons from a `Rectangle` instead. - **Plain `import QtQuick` shadows a directory's own `Sprite.qml` too.** QtQuick ships a `Sprite` type (for `AnimatedSprite`), and an explicit - import beats the implicit directory one, so the companion's `Sprite { + import beats the implicit directory one, so the former companion's `Sprite { onSettled: ... }` failed with `Cannot assign to non-existent property - "onSettled"` and took the whole desktop shell down on restart. It is + "onSettled"` and took the whole desktop shell down on restart. It became `Figure.qml`. Before naming a component, check it is not a QtQuick type. - **`FloatingWindow.maximized` works under Hyprland.** Setting it fills the monitor and clearing it restores the old size, checked by eye; the settings @@ -584,7 +583,7 @@ back. running one, which is how `SUPER+v` opens the VM drawer. An `IpcHandler` function named after a `qs ipc` subcommand (`show`, `call`, -`wait`, `listen`, `prop`) cannot be called from the CLI: `ipc call companion +`wait`, `listen`, `prop`) cannot be called from the CLI: `ipc call assistant show` runs `ipc show` instead, prints the target list and exits 0. Three handlers had a `show()` nobody could reach; they are `open()` now. diff --git a/desktop/Prefs.qml b/desktop/Prefs.qml index 610e004..0ed6ee9 100644 --- a/desktop/Prefs.qml +++ b/desktop/Prefs.qml @@ -45,66 +45,6 @@ Singleton { root.write(Object.assign({}, root.doc, { assistant: a })); } - // Whether the desktop companion is shown: SUPER+\ and the Companion - // settings page both set it, so it survives a restart. ready is false - // until the file has been read, so she does not show and then vanish. - property bool companion: true - property bool ready: false - function setCompanion(on) { - if (on === root.companion) return; - root.companion = on; - root.write(Object.assign({}, root.doc, { companion: on })); - } - - // Seconds the companion stays in her leotard after the self-destruct, - // 0 for until she is hidden and shown again. - property int companionSkirtless: 60 - function setCompanionSkirtless(s) { - if (s === root.companionSkirtless) return; - root.companionSkirtless = s; - root.write(Object.assign({}, root.doc, { companionSkirtless: s })); - } - - // The companion's wallpaper list, "" for the default in - // companion/Admired.qml. - property string companionBlushFile: "" - function setCompanionBlushFile(p) { - p = p.trim(); - if (p === root.companionBlushFile) return; - root.companionBlushFile = p; - root.write(Object.assign({}, root.doc, { companionBlushFile: p })); - } - - // The companion's app lines as saved: a map of lowercased window class - // to phrases, or null for the defaults in companion/Lines.js. The - // Companion phrases page edits it. - property var companionApps: null - function setCompanionApps(map) { - root.companionApps = map; - root.write(Object.assign({}, root.doc, { companionApps: map })); - } - function resetCompanionApps() { - root.companionApps = null; - const d = Object.assign({}, root.doc); - delete d.companionApps; - root.write(d); - } - - // The companion's artist lines as saved, keyed by lowercased artist, or - // null for the defaults in companion/Lines.js. The Companion artists - // page edits it. - property var companionArtists: null - function setCompanionArtists(map) { - root.companionArtists = map; - root.write(Object.assign({}, root.doc, { companionArtists: map })); - } - function resetCompanionArtists() { - root.companionArtists = null; - const d = Object.assign({}, root.doc); - delete d.companionArtists; - root.write(d); - } - // The settings window's sidebar width, dragged by its edge. readonly property int sidebarMin: 160 readonly property int sidebarMax: 420 @@ -163,15 +103,6 @@ Singleton { const w = parsed.settings ? parsed.settings.sidebar : undefined; root.settingsSidebar = typeof w === "number" && w >= root.sidebarMin && w <= root.sidebarMax ? Math.round(w) : 200; - root.companion = parsed.companion !== false; - const sk = parsed.companionSkirtless; - root.companionSkirtless = Number.isInteger(sk) && sk >= 0 ? sk : 60; - root.companionBlushFile = typeof parsed.companionBlushFile === "string" ? parsed.companionBlushFile : ""; - const ca = parsed.companionApps; - root.companionApps = ca && typeof ca === "object" && !Array.isArray(ca) ? ca : null; - const cr = parsed.companionArtists; - root.companionArtists = cr && typeof cr === "object" && !Array.isArray(cr) ? cr : null; - root.ready = true; } FileView { @@ -186,6 +117,6 @@ Singleton { onFileChanged: reload() onLoaded: root.reparse() // Missing file: the defaults, and nothing written. - onLoadFailed: { root.doc = {}; root.pinnedIp = ""; root.presets = null; root.settingsSidebar = 200; root.assistantModel = ""; root.companion = true; root.companionBlushFile = ""; root.companionSkirtless = 60; root.companionApps = null; root.companionArtists = null; root.ready = true; } + onLoadFailed: { root.doc = {}; root.pinnedIp = ""; root.presets = null; root.settingsSidebar = 200; root.assistantModel = ""; } } } diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml index 4144910..a4fda72 100644 --- a/desktop/assistant/Assistant.qml +++ b/desktop/assistant/Assistant.qml @@ -86,9 +86,6 @@ Scope { property var convs: [] property var results: [] readonly property bool busy: root.state !== "idle" - // A reply finished (not stopped). The canvas notifies through notifyDone; - // the desktop companion waves. - signal replied() // A daemon gone mid-reply sends no idle; its hello on return redraws. onConnectedChanged: if (!root.connected) { @@ -113,10 +110,9 @@ Scope { Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] // The tall card (a chat, or a panel) is fullHeight and centred, so its - // top only moves on entering or leaving tall. The companion sits there. + // top only moves on entering or leaving tall. readonly property bool tall: root.mode === "chat" || root.historyOpen || root.pickerOpen || root.modelsOpen readonly property int fullHeight: Math.round(root.screenObj.height * 0.7) - readonly property int cardTop: Math.round((root.screenObj.height - root.fullHeight) / 2) function toggle() { if (root.open) root.close(); else root.show(); } @@ -451,10 +447,7 @@ Scope { } if (!e.stopped) root.convId = e.id; root.lastActive = Date.now(); - if (!e.stopped && i >= 0) { - root.notifyDone(turns.get(i).reply); - root.replied(); - } + if (!e.stopped && i >= 0) root.notifyDone(turns.get(i).reply); break; case "progress": if (root.opDaemon) root.opChars = e.chars; break; case "written": diff --git a/desktop/companion/Admired.qml b/desktop/companion/Admired.qml deleted file mode 100644 index eee76d1..0000000 --- a/desktop/companion/Admired.qml +++ /dev/null @@ -1,101 +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. - -pragma Singleton - -import Quickshell -import Quickshell.Io -import QtQuick -import ".." -import "ListFile.js" as LF - -// The wallpapers the companion blushes at, and what wallp has on each -// monitor. The companion reads both; the wallpaper picker toggles entries. -// What is on each monitor is read from wallp's own files here rather than -// through the appearance module's Wallpapers, which scans every wallpaper -// on creation, and the companion runs all session. -Singleton { - id: root - - readonly property string home: Quickshell.env("HOME") - readonly property string defaultPath: - (Quickshell.env("XDG_CONFIG_HOME") || root.home + "/.config") + "/keystone/companion-blush.txt" - readonly property string path: - Prefs.companionBlushFile !== "" ? Prefs.companionBlushFile.replace(/^~(?=\/|$)/, root.home) : root.defaultPath - - property string listText: "" - readonly property var paths: LF.parse(root.listText) - function listed(p) { return p !== "" && root.paths.indexOf(p) >= 0; } - - // The wallpaper on a monitor, by connector name; "" for one wallp does - // not set. - property var outputs: ({}) - property string wallH: "" - property string wallV: "" - function on(monitor) { - const w = root.outputs[monitor]; - return w === "H" ? root.wallH : w === "V" ? root.wallV : ""; - } - - // ponytail: a list file created by hand after start may not be watched - // until a restart (the watch is on a path that did not exist); the - // heart writes through the process below, which sets listText itself. - FileView { - path: root.path - watchChanges: true - printErrors: false - onFileChanged: reload() - onLoaded: root.listText = text() - onLoadFailed: root.listText = "" - } - FileView { - path: root.home + "/.config/wallp/wallp.conf" - watchChanges: true - printErrors: false - onFileChanged: reload() - onLoaded: root.outputs = LF.outputs(text()) - } - FileView { - path: root.home + "/.config/wallp/wall_h" - watchChanges: true - printErrors: false - onFileChanged: reload() - onLoaded: root.wallH = text().trim() - } - FileView { - path: root.home + "/.config/wallp/wall_v" - watchChanges: true - printErrors: false - onFileChanged: reload() - onLoaded: root.wallV = text().trim() - } - - // Through cat rather than FileView.setText, because the list can become - // empty and setText("") on a path the view has not loaded writes no - // file (AGENTS.md). - function toggle(p) { - writer.data = LF.toggle(root.listText, p); - root.listText = writer.data; - writer.command = ["sh", "-c", 'mkdir -p "$(dirname "$1")" && cat > "$1"', "sh", root.path]; - writer.stdinEnabled = true; - writer.running = false; - writer.running = true; - } - Process { - id: writer - property string data: "" - onStarted: { - writer.write(writer.data); - writer.stdinEnabled = false; - } - onExited: code => { if (code !== 0) console.warn("Admired: writing", root.path, "failed"); } - } -} 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 } - } - } -} diff --git a/desktop/companion/Figure.qml b/desktop/companion/Figure.qml deleted file mode 100644 index 285e2cb..0000000 --- a/desktop/companion/Figure.qml +++ /dev/null @@ -1,173 +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 QtQuick -import ".." - -// One frame of the companion, drawn at half its pixel size and standing on -// its bottom edge: a crossfade between frames, a slow bob, a tremble, the -// drop out of view and back, and the self-destruct flash. Which frame is Mood.js's -// business; this only draws it. -Item { - id: s - - // The frame, and what to show when it is missing: the same outfit's - // base, never the other outfit's, so a gap in the art cannot put the - // skirt back mid-scene. - property string source - property string fallback - // Dropped below the bottom edge and faded out. - property bool out: false - signal settled(bool out) - - // Two images: the front one stays until the back one has loaded, then - // they swap, so a frame change never flashes empty. - property bool flip: false - readonly property Image front: s.flip ? b : a - width: s.front.implicitWidth / 2 - height: s.front.implicitHeight / 2 - - function load(url) { - const front = s.flip ? b : a, back = s.flip ? a : b; - if (String(front.source) === url) { back.source = ""; return; } - if (String(back.source) === url && back.status === Image.Ready) { s.flip = !s.flip; return; } - back.source = url; - } - onSourceChanged: s.load(s.source) - Component.onCompleted: s.load(s.source) - - // The incoming frame fades in on top while the outgoing one stays - // opaque under it. Fading both at once left each near half opacity - // mid-swap, and she blinked see-through on every turn of the dj loop. - property real fade: 1 - NumberAnimation { id: fadeIn; target: s; property: "fade"; from: 0; to: 1; duration: 120 } - onFlipChanged: fadeIn.restart() - - function ready(img) { - if (img.status === Image.Error && String(img.source) !== s.fallback) { - img.source = s.fallback; - return; - } - if (img.status === Image.Ready && img !== s.front) s.flip = !s.flip; - } - - property real drop: 0 - property real bob: 0 - NumberAnimation { - id: dropAnim - target: s; property: "drop"; duration: 320 - onFinished: s.settled(s.out) - } - // Out from creation (a shell started with her hidden) never ran the drop, - // since a starting value fires no onOutChanged, and she was drawn in place - // with her clock stopped, frozen on whatever frame came next. The body - // below hides her until she is shown, and the first entrance starts from - // below like any other. - onOutChanged: { - dropAnim.stop(); - if (!s.out && s.drop === 0) s.drop = s.height + 24; - dropAnim.to = s.out ? s.height + 24 : 0; - dropAnim.easing.type = s.out ? Easing.InCubic : Easing.OutBack; - dropAnim.start(); - } - // A quick side to side shiver, for the wallpaper blush. Stopping can - // leave it mid-swing, so it is put back to rest. - property bool trembling: false - property real shake: 0 - onTremblingChanged: if (!s.trembling) s.shake = 0 - SequentialAnimation on shake { - running: s.trembling && !s.out - loops: Animation.Infinite - NumberAnimation { to: 1.5; duration: 40 } - NumberAnimation { to: -1.5; duration: 80 } - NumberAnimation { to: 0; duration: 40 } - } - SequentialAnimation on bob { - running: !s.out - loops: Animation.Infinite - NumberAnimation { to: 2; duration: 1900; easing.type: Easing.InOutSine } - NumberAnimation { to: 0; duration: 2300; easing.type: Easing.InOutSine } - } - - Item { - anchors.fill: parent - visible: !s.out || dropAnim.running - opacity: 1 - s.drop / (s.height + 24) - transform: Translate { x: s.shake; y: s.drop - s.bob } - - Image { - id: a - width: implicitWidth / 2; height: implicitHeight / 2 - anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter } - smooth: true; mipmap: true; asynchronous: true - z: s.front === a ? 1 : 0 - opacity: s.front === a ? s.fade : s.fade < 1 ? 1 : 0 - onStatusChanged: s.ready(a) - } - Image { - id: b - width: implicitWidth / 2; height: implicitHeight / 2 - anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter } - smooth: true; mipmap: true; asynchronous: true - z: s.front === b ? 1 : 0 - opacity: s.front === b ? s.fade : s.fade < 1 ? 1 : 0 - onStatusChanged: s.ready(b) - } - } - - // --- self-destruct: the glow swells into a flash, then a ring and - // sparks burst out. About 1.1 s, matching Mood.T.destruct, during which - // the frame swaps to the leotard under the flash. - - function explode() { boom.restart(); } - - property real burst: 0 - Rectangle { - id: flash - anchors.centerIn: parent - width: s.height * 1.6; height: width; radius: width / 2 - color: "white"; opacity: 0; scale: 0 - } - Rectangle { - id: ring - anchors.centerIn: parent - width: s.height * 2; height: width; radius: width / 2 - color: "transparent" - border { width: 3; color: Theme.peach } - opacity: 0; scale: 0 - } - Repeater { - model: 8 - Rectangle { - required property int index - readonly property real angle: index * Math.PI / 4 - width: 6; height: 6; radius: 3 - color: index % 2 ? Theme.peach : Theme.yellow - x: s.width / 2 - 3 + Math.cos(angle) * s.burst * s.height - y: s.height / 2 - 3 + Math.sin(angle) * s.burst * s.height - opacity: s.burst > 0 && s.burst < 1 ? 1 - s.burst : 0 - } - } - SequentialAnimation { - id: boom - ParallelAnimation { - NumberAnimation { target: flash; property: "scale"; from: 0; to: 1; duration: 600; easing.type: Easing.InQuad } - NumberAnimation { target: flash; property: "opacity"; from: 0; to: 0.95; duration: 600 } - } - ParallelAnimation { - NumberAnimation { target: flash; property: "opacity"; to: 0; duration: 400 } - NumberAnimation { target: ring; property: "scale"; from: 0.2; to: 1.4; duration: 400; easing.type: Easing.OutCubic } - NumberAnimation { target: ring; property: "opacity"; from: 1; to: 0; duration: 400 } - NumberAnimation { target: s; property: "burst"; from: 0; to: 1; duration: 500; easing.type: Easing.OutCubic } - } - PropertyAction { target: s; property: "burst"; value: 0 } - } -} diff --git a/desktop/companion/Lines.js b/desktop/companion/Lines.js deleted file mode 100644 index 77e7a83..0000000 --- a/desktop/companion/Lines.js +++ /dev/null @@ -1,146 +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. - -.pragma library - -// What she says, in English, grouped by the reaction that says it. -// Companion.qml picks one at random per reaction and keeps to one line in -// 30 s, except for clicks and the self-destruct (Mood.canSpeak). - -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?"], - shoved: ["Hey.", "Rude.", "I was standing here."], - ready: ["Answer's ready.", "Your reply came in."], - headphones: ["...Good song.", "Music. Finally.", "I'll allow this one.", "Louder."], - groove: ["This one's good.", "...Don't tell 9S.", "Again. Play it again."], - click: ["...Yes?", "Can I help you?"], - blush: ["S-stop that.", "...Operator."], - wallpaper: ["...!", "W-what is this?", "I wasn't staring."], - dodge: ["...Fine.", "I'll move."], - destruct: ["Self-destruct sequence initiated."], - aftermath: ["...That was unpleasant."], -}; - -// The lines for a program she recognizes, by lowercased window class: one is -// said at random when focus moves to it. The settings window's Companion -// phrases page edits them into Prefs.companionApps, which then replaces this -// whole set; a class with no entry says nothing. -var apps = { - firefox: ["Researching again?", "Don't open too many tabs.", "The Bunker's network is faster."], - kitty: ["A terminal. Serious work.", "Careful with that rm.", "I'll watch your commands."], - discord: ["Talking to the Resistance?", "Don't tell them where we are."], - signal: ["Encrypted. Good."], - steam: ["Training simulation?", "Don't play all night."], - gimp: ["Making art?"], - "com.obsproject.studio": ["Are we recording?", "I'll stay out of frame."], - "md.obsidian.obsidian": ["Writing a report?", "Taking notes. Good habit."], - sublime_text: ["Writing code?", "Mind the typos."], - qtmaildir: ["Any orders from Command?", "Mail. Anything urgent?"], - "virt-manager": ["More machines?", "Androids running androids."], - audacious: ["Music. I like this."], - llamachat: ["Talking to Pod?"], - qtpass: ["I'm not looking."], -}; - -// The lines for an artist she knows, keyed by a lowercased name matched as -// a substring of the reported artist, so "daft punk" also catches -// collaborations. The Companion artists page edits them into -// Prefs.companionArtists, which then replaces this whole set. -var artists = { - "linkin park": ["In the end, it doesn't even matter.", "Crawling in my skin... I know this one."], - "evanescence": ["Wake me up inside. ...Operator, don't laugh."], - "gigi d'agostino": ["L'amour toujours. I'll fly with you.", "...My feet are moving on their own."], - "eminem": ["Lose yourself. Understood.", "He talks faster than Pod."], -}; - -// The game's opening, one sentence per bubble. -var monologue = [ - "Everything that lives is designed to end.", - "We are perpetually trapped in a never-ending spiral of life and death.", - "Is this a curse?", - "Or some kind of punishment?", - "I often think about the god who blessed us with this cryptic puzzle...and wonder if we'll ever get the chance to kill him.", -]; - -// A set in effect: saved when it is a plain object, the defaults (apps -// unless given) otherwise. A malformed saved value must not leave her mute. -function appMap(saved, defaults) { - return saved && typeof saved === "object" && !Array.isArray(saved) ? saved : (defaults || apps); -} - -// A set as an ordered list of {cls, lines}, for the settings pages; cls is -// the key, a window class or an artist. -function appList(saved, defaults) { - const m = appMap(saved, defaults); - const keys = Object.keys(m); - const out = []; - for (let i = 0; i < keys.length; i++) - out.push({ cls: keys[i], lines: (m[keys[i]] || []).slice() }); - return out; -} - -function nonEmpty(all) { - const l = []; - for (let i = 0; i < (all || []).length; i++) - if (typeof all[i] === "string" && all[i].trim() !== "") l.push(all[i]); - return l; -} - -function at(l, roll) { return l.length ? l[Math.min(l.length - 1, Math.floor(roll * l.length))] : ""; } - -// A line for an app class, empty strings skipped; "" when the class has none. -function pickApp(cls, saved, roll) { return at(nonEmpty(appMap(saved)[cls]), roll); } - -// A line for the playing artist: the first key that is a case-insensitive -// substring of it and has a non-empty line. ponytail: substring, so a -// short key like "air" also matches "Airbourne"; whole words if that bites. -function pickArtist(artist, saved, roll) { - const a = String(artist || "").toLowerCase(); - if (a === "") return ""; - const m = appMap(saved, artists); - const keys = Object.keys(m); - for (let i = 0; i < keys.length; i++) { - const l = nonEmpty(m[keys[i]]); - if (keys[i] !== "" && l.length && a.indexOf(keys[i].toLowerCase()) !== -1) return at(l, roll); - } - return ""; -} - -// A line for a reaction kind, or, with an "app:" or "artist:" prefix, for -// that class or artist. -function pick(kind, roll, saved, savedArtists) { - if (kind.slice(0, 4) === "app:") return pickApp(kind.slice(4), saved, roll); - if (kind.slice(0, 7) === "artist:") return pickArtist(kind.slice(7), savedArtists, roll); - return at(lines[kind] || [], roll); -} - -// The page's list back to a map for Prefs: empty classes and phrases dropped, -// the class lowercased (Hyprland reports it that way), a repeated class -// keeping its last entry. -function toMap(list) { - const m = {}; - for (let i = 0; i < list.length; i++) { - const cls = String(list[i].cls || "").trim().toLowerCase(); - if (cls === "") continue; - const src = list[i].lines || []; - const ls = []; - for (let j = 0; j < src.length; j++) { - const s = String(src[j] || "").trim(); - if (s !== "") ls.push(s); - } - if (ls.length) m[cls] = ls; - } - return m; -} diff --git a/desktop/companion/ListFile.js b/desktop/companion/ListFile.js deleted file mode 100644 index d074c80..0000000 --- a/desktop/companion/ListFile.js +++ /dev/null @@ -1,44 +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. - -.pragma library - -// The wallpapers the companion blushes at, as a plain text file: one -// absolute path per line, blank lines and lines starting with # skipped. -// Pure, so test_mood.js drives it; Admired.qml reads and writes the file. - -function parse(text) { - const out = []; - for (const l of text.split("\n")) { - const p = l.trim(); - if (p !== "" && p[0] !== "#") out.push(p); - } - return out; -} - -// text with path's line removed if it is listed, else appended. Comments -// and every other line stay as they are. -function toggle(text, path) { - const lines = text === "" ? [] : text.replace(/\n$/, "").split("\n"); - const kept = lines.filter(l => l.trim() !== path); - if (kept.length === lines.length) kept.push(path); - return kept.length ? kept.join("\n") + "\n" : ""; -} - -// wallp.conf's OUTPUT_H and OUTPUT_V, as { connector: "H" | "V" }. An exec -// loop, because QML's engine has no String.matchAll. -function outputs(conf) { - const out = {}; - const re = /^\s*OUTPUT_([HV])\s*=\s*(\S+)/gm; - let m; - while ((m = re.exec(conf)) !== null) out[m[2]] = m[1]; - return out; -} diff --git a/desktop/companion/Mood.js b/desktop/companion/Mood.js deleted file mode 100644 index be514d7..0000000 --- a/desktop/companion/Mood.js +++ /dev/null @@ -1,123 +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. - -.pragma library - -// Which state the companion is in, from a snapshot of her inputs. Pure, so -// test_mood.js drives it with plain objects and a fake clock. Companion.qml -// owns the inputs and the timers; this only decides. Times in milliseconds. - -var T = { - burstGap: 1500, // a click burst ends after this long without a click - clickHold: 2000, // a click reaction lasts this long after the last click - mockHold: 2500, - shoveHold: 500, // knocked off balance, before she turns mad at the balloon - mockCooldown: 20000, - typingHold: 2000, - waveHold: 3000, - singHold: 4000, // her singing reaction to the headphones connecting - djHold: 6000, // behind the decks when a track starts or changes - wakeHold: 1500, // the stretch after idling - lineCooldown: 30000, - 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, -}; - -function st(state, frame) { return { state: state, frame: frame }; } - -// The burst after a click at now. -function click(prev, now) { - if (prev.count > 0 && now - prev.at <= T.burstGap) return { count: prev.count + 1, at: now }; - return { count: 1, at: now }; -} - -// Click 1 smiles; 2 to 6 blush harder, in three steps. The 7th is the -// self-destruct, which Companion.qml starts rather than shows. -function clickFrame(count) { - if (count <= 1) return "smile"; - if (count <= 3) return "blush1"; - if (count <= 5) return "blush2"; - return "blush3"; -} - -// 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"); - if (s.dodging) return st("dodge", "base"); - // shovedAt 0 is the instant before Companion.qml stamps it. - if (s.shoved) return st("shoved", s.shovedAt && s.now - s.shovedAt >= T.shoveHold ? "mad" : "shoved"); - if (s.recording) return st("listen", "listen"); - if (s.busy) return st("think", "think"); - if (s.now - s.repliedAt < T.waveHold) return st("wave", "wave"); - if (s.now - s.singAt < T.singHold) return st("sing", "sing"); - if (s.now - s.djAt < T.djHold) return st("dj", "dj"); - if (s.clicks.count > 0 && s.now - s.clicks.at < T.clickHold) - return st("clicks", clickFrame(s.clicks.count)); - if (s.now - s.admireAt < T.admireLook + T.admireBlush) - return st("admire", s.now - s.admireAt < T.admireLook ? "watch" : "blush3"); - if (s.now - s.mockAt < T.mockHold) - return st("mock", s.now - s.mockAt < T.mockHold / 2 ? "point" : "smirk"); - if (s.now - s.typingAt < T.typingHold) return st("watch", "watch"); - if (s.now - s.wakeAt < T.wakeHold) return st("stretch", "stretch"); - if (s.monologue) return st("muse", "muse"); - if (s.idle >= 3) return st("lying", "lying"); - if (s.idle === 2) return st("yawn", "yawn"); - // Music turns boredom into a groove picked by Companion.qml; the - // deeper idle stages still win, so music does not keep her awake. - if (s.idle === 1) return s.music ? st("groove", s.groove || "whistle") : st("bored", "sleepy"); - return st("base", "base"); -} - -// The self-destruct takes the skirt off once the flash is over, for the -// scorched moment and skirtless ms after it (Prefs.companionSkirtless), -// or with 0 until she is shown again, which sets destructAt back to 0 and -// also restores it early. -function outfit(destructAt, now, skirtless) { - if (!destructAt) return "dress"; - const since = now - destructAt; - if (since < T.destruct) return "dress"; - return skirtless === 0 || since < T.destruct + T.scorched + skirtless ? "leotard" : "dress"; -} - -function mockAllowed(lastMockAt, now) { return now - lastMockAt >= T.mockCooldown; } - -function admireAllowed(lastAt, now) { return now - lastAt >= T.admireCooldown; } - -function canSpeak(lastLineAt, now) { return now - lastLineAt >= T.lineCooldown; } - -// Seated on the assistant's tall card (Companion.qml, sitting): the frames -// with a sitting version show it, everything else the plain sit. Typing's -// watch becomes the lean over the card, reading. -var SEAT = { - watch: "sit-read", point: "sit-point", smirk: "sit-smirk", think: "sit-think", - listen: "sit-listen", wave: "sit-wave", "wave-l": "sit-wave", "wave-r": "sit-wave", -}; -function seatFrame(frame) { return SEAT[frame] || "sit"; } - -// Whether the opening monologue plays: a chance per trigger, and never -// twice within the hour. roll is Math.random(), passed in for the test. -function monologue(trigger, lastAt, now, roll) { - if (now - lastAt < T.monologueCooldown) return false; - const chance = trigger === "bored" ? 1 / 4 : trigger === "shown" ? 1 / 3 : 0; - return roll < chance; -} diff --git a/desktop/companion/README.md b/desktop/companion/README.md deleted file mode 100644 index 2bd6c4e..0000000 --- a/desktop/companion/README.md +++ /dev/null @@ -1,117 +0,0 @@ -# companion - -A chibi 2B in the bottom-right corner of the focused monitor, on SUPER+\. -The design, with what was measured on the way, is -`docs/superpowers/specs/2026-10-23-companion-design.md`. - -## Files - -| File | | -| --- | --- | -| `Companion.qml` | the window, inputs, timers, speech, IPC (`companion toggle/show/hide`) | -| `Figure.qml` | draws a frame: crossfade, bob, drop out and back, explosion | -| `Mood.js` | which state wins, from a snapshot; all the timings are its `T` | -| `Lines.js` | her reaction, per-app and per-artist lines, and the opening monologue | -| `Admired.qml` | the wallpaper list she blushes at, and what wallp has on each monitor | -| `ListFile.js` | that list as plain text: parse and toggle | -| `prep-frames` | generated images to display frames | -| `gen-frames`, `frames.txt` | the generated images, through sd-server | - -`Figure.qml` is not `Sprite.qml` because QtQuick has a `Sprite` type, and -it wins over the directory's own file (see AGENTS.md). - -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. -It lives in `~/.local/share/keystone/companion/2b/`: - - dress/<name>.png, leotard/<name>.png generated, 1024x1024 on white - frames/<outfit>/<name>.png what she shows, from prep-frames - recipe.txt how each was made, and what failed - -Run `prep-frames` after adding or replacing any generated image. A missing -frame falls back to its outfit's `base`. - -## Inputs - -- Typing and BackSpace come from `sections/companion.lua` in the - hypr-theme repo, as `custom>>keystone-typing` and - `custom>>keystone-delete` on socket2. Nothing else about the keyboard - reaches her. The BackSpace bind is `non_consuming`; checked by hand that - it still deletes in a terminal and in the browser. -- The typing pulse skips modifiers and any key pressed with Ctrl, Alt or - Super held, so a shortcut is not typing; Shift and AltGr still count. - She watches only from the second pulse within 2.5 s, so a lone key is - not typing either. -- She is on the Overlay layer and remaps whenever another layer surface - opens (Hyprland's `openlayer` event: the assistant's dim, rofi, the - keybind reminder), vanishing and hopping back in, because Hyprland - stacks a later surface above an earlier one on a layer, and 0.56.2 has - no layer rule to order them (no `order` key in the binary; an unknown - key raises no config error). -- The assistant's tall card (a chat, or its history, picker or models - panel) seats her on its top edge, right of centre, through the monitor - hop. `Assistant.qml` gives `tall` and `cardTop`; seated she ignores - balloons and shows `Mood.seatFrame` of her frame, the `sit-*` set. The - bar and the file card leave her in the corner: standing frames face - out, not down at the text. Typing and BackSpace still reach her under - the canvas's exclusive keyboard (checked on socket2). -- The program in front of her is Hyprland's `activewindow` event, - `class,title`. It also fires on every title change, so she reacts - only when the class changes, a third of the time, under the 30 s line - cooldown. Lines are by lowercased class in `Lines.js`; a class with - none says nothing. Settings > Companion > Companion phrases edits them - into `Prefs.companionApps`, which then replaces the defaults. -- A listed wallpaper (Settings > Companion for the file, the - heart in the wallpaper picker for entries) on an empty workspace: she - turns to it with `watch` and blushes with `blush3`, at most every 3 - minutes. Checked on `workspacev2` and `closewindow`, and whenever she - stops being away. Her monitor's wallpaper comes from wallp's - `wallp.conf` outputs and `wall_h`/`wall_v`. -- Sony headphones connecting are the island's rule (`island/StatusPill.qml`: - `WH-`/`WF-`/`LinkBuds` by name, since BlueZ has no headphones class): she - sings once for `Mood.T.singHold`, 4 s, and says a line if the usual 30 s - cooldown allows it. A shell start with them already on is not a connection: - Bluetooth reads empty for ~2 s, then populates. -- What is playing is `modules/sound/Player.qml`, which already drops the - playerctld and browser-integration duplicates. A new artist+title while - playing makes her DJ for `Mood.T.djHold`, 6 s (`dj` and `dj-scratch` - cycling), and say the artist's line if one matches: lowercased keys in - `Lines.artists`, matched as a substring, edited on Settings > Companion - artists into `Prefs.companionArtists`. Pause and resume of the same - track is not a change; a shell start with music on DJs once. While - music plays, the first idle stage is a groove instead of bored: - `whistle`, `headbang` (`headbang-up`/`-down` cycling) or `airguitar`, - picked at random, with a `groove` line; yawning and lying down are - unchanged. -- She only feels the pointer while it is over her: Quickshell has no - global pointer and Hyprland sends no motion events. Hence the 0.6 s - dodge rather than moving away as the pointer approaches. -- Idle stages are 20, 40 and 70% of wscreensavers' `idleTimeoutMinutes`, - read live from its config, so she lies down before the saver starts. -- `IdleMonitor.enabled` read false on creation when left out, so each of - the three sets it. It also reads false at `Component.onCompleted` even - when set, and true a moment later; do not trust that first reading. -- The monitors ignore inhibitors. No lock holds an idle inhibitor for as - long as it is on, and with `respectInhibitors: true` she never idled - (measured: a 5 s monitor respecting them never fired, one ignoring them - did). Presentation and gaming mode keep her awake by name instead. -- Fullscreen is `lastIpcObject.fullscreen === 2`. Maximised is 1, and - most windows here are maximised. -- The lock screen covers every layer, so locking needs no code. - -## Check - - node desktop/companion/test_mood.js - node desktop/companion/test_lines.js diff --git a/desktop/companion/frames.txt b/desktop/companion/frames.txt deleted file mode 100644 index c7ea934..0000000 --- a/desktop/companion/frames.txt +++ /dev/null @@ -1,160 +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. -# -# The companion's frames, for gen-frames. One frame per line: -# -# <output> <- <reference> : <prompt> -# -# Both are paths in the art directory (~/.local/share/keystone/companion/2b) -# without .png. The reference is the image FLUX.2 klein edits; the output is -# written over. Lines run top to bottom, so a frame made from another comes -# after it. ${NAME} expands a NAME = ... line defined above it. Blank lines -# and lines starting with # are skipped. -# -# Two frames are not made here, both recorded in recipe.txt: dress/base, a -# klein edit of base-sitting.png, and dress/sit-read, a masked inpaint of -# dress/sit. The other standing dress frames are made from base, the seated -# ones from sit, and every leotard frame from its own dress frame, so the -# pose carries over. - -KEEP = She keeps her black gloves on both hands, her puffy black juliet sleeves, her black dress with its skirt, her black thighhigh boots, her black blindfold and hairband, her lips and the small mole under her mouth. Same character, chibi proportions, art style and white background. -POSE = keep her pose exactly, standing, arms relaxed at her sides. -# Strong expressions made klein turn the blindfold into an eye mask with the -# eyes showing through (mad, smirk, blush3, sleepy). The ones that read -# through her eyes take it off instead (BARE); the rest name it (EYES). -BARE = She has taken her blindfold off: both eyes are uncovered and show clearly, pale blue-grey, and carry the expression; no blindfold, no mask, nothing over her eyes; her black hairband stays. -KEEP_BARE = She keeps her black gloves on both hands, her puffy black juliet sleeves, her black dress with its skirt, her black thighhigh boots, her black hairband, her lips and the small mole under her mouth. Same character, chibi proportions, art style and white background. -EYES = Her black blindfold stays on as one solid band of cloth fully covering both eyes: no eyes and no eyeholes show; the expression is in her mouth, cheeks and brows above the band. -# After the self-destruct the blindfold is gone with the skirt: the whole -# leotard set shows her eyes. -LEOTARD = Her skirt is gone, revealing a plain white leotard underneath, and her blindfold is gone too: both eyes uncovered and clearly visible, pale blue-grey, matching her expression. Everything else stays exactly the same: pose, expression, black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband, lips, the small mole under her mouth, chibi proportions, art style and white background. -# Singing along to whatever is playing: headphones on, eyes shut. The -# blindfold is gone, but BARE does not fit because it says the eyes show. -SING = Her black blindfold is gone: nothing over her eyes, no blindfold, no mask; her black hairband stays. Both her eyes are closed, softly shut. Her mouth is open wide as if she is singing loudly. She wears a pair of black over-ear headphones over her head and ears. A couple of small black musical notes float in the air beside her head. -# Seated on the assistant's card: the original sitting rest pose, legs -# dangling over the edge, from base-sitting (the image dress/base came from). -# Naming a ledge drew one, a grey bar across the whole image: she sits on -# nothing, and the card's edge in the shell is the ledge. -SIT = keep her sitting pose exactly: sitting, front view, legs dangling down, as if perched on an invisible edge. Nothing is drawn under or behind her: no ledge, no bar, no bench, no wall, no floor, no shadow; the background is plain white all around her. - -# --- expressions -dress/smile <- dress/base : Change only her expression to a small warm closed-mouth smile; ${POSE} ${EYES} ${KEEP} -dress/mad <- dress/base : Change only her expression to annoyed and angry, a frown and a puffed cheek; ${POSE} ${BARE} ${KEEP_BARE} -dress/smirk <- dress/base : Change only her expression to a teasing one-sided smirk; ${POSE} Both eyes fully visible and uncovered; the blindfold is nowhere on her head or face, not pushed up, not over one eye. ${BARE} ${KEEP_BARE} -dress/blush1 <- dress/base : Change only her expression: a light pink blush on her cheeks and a shy small smile; ${POSE} ${EYES} ${KEEP} -dress/blush2 <- dress/base : Change only her expression: a strong red blush across her cheeks and a flustered wavy mouth; ${POSE} ${EYES} ${KEEP} -dress/blush3 <- dress/base : Change only her expression: her whole face bright red, small steam puffs above her head, very flustered; ${POSE} ${BARE} ${KEEP_BARE} -dress/sleepy <- dress/base : Change only her expression to sleepy and drowsy, head tilted slightly, mouth relaxed; ${POSE} ${BARE} ${KEEP_BARE} - -# --- 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. -dress/point <- dress/base : She points with the hand on the left side of the image toward the viewer, with a teasing smirk; her other hand rests on her hip. ${KEEP} -dress/yawn <- dress/base : A big yawn, mouth wide open, one gloved hand covering her mouth. ${KEEP} -dress/lying <- dress/base : Make her lie on her side on the ground, asleep, her head resting on her folded arms. ${KEEP} -dress/listen <- dress/base : She cups the hand on the left side of the image behind her ear, listening attentively. ${KEEP} -dress/think <- dress/base : One gloved hand at her chin, thinking, head tilted slightly. ${KEEP} -# The monologue's frame: the pose of her official character art, chibi. -# refs/2b-official-white is that art flattened on white, used only for the -# pose. A Virtuous Treaty beside her was tried and dropped: klein draws a -# generic longsword, nothing like the game's, and would not move it. -dress/muse <- dress/base + refs/2b-official-white : Redraw the chibi character from the first image, keeping her exact chibi proportions, face, outfit and art style, in the standing pose of the woman in the second image: body turned three-quarters, one arm hanging out low to the side with the hand open, the other hand raised near her shoulder, weight on one leg, head slightly lowered, calm and solemn. No weapon, nothing in her hands. ${KEEP} -# She stays frontal: a three-quarter turn, from the reference or as its own -# edit, only bent her knees. -dress/stretch <- dress/base : She stretches both arms up above her head, waking up. ${KEEP} -# A balloon shoved her out of the corner. Standing versions read as walking -# in four seeds, so this one is airborne, edited from the attempt whose hands -# and face were right: refs/shoved-7.png, made from dress/base with -# --seed 7 by the earlier standing prompt (see generated.log), kept in refs/. -dress/shoved <- refs/shoved-7 : Keep her face, her scared expression, her uncovered eyes and both raised hands exactly as they are. Change her body and legs: she is in the air, lifted completely off the ground, knocked flying toward the left side of the image by a strong blast from the right; both feet off the ground, legs trailing behind her toward the right side, her body tilted, her hair and skirt blown toward the left. No ground, no blast effect drawn. ${KEEP_BARE} -# Headphones connecting: a few seconds of her singing along. -dress/sing <- dress/base : ${SING} ${POSE} ${KEEP_BARE} -# Music: behind the decks on a track start or change, and the grooves -# that replace boredom while music plays. -DECK = A small black DJ booth stands in front of her: two turntables and a mixer on top at her waist height, and a solid black front panel reaching down to the ground, so her legs and boots are hidden behind it and only her upper body shows above it. No table legs. -dress/dj <- dress/base : ${DECK} Both gloved hands rest on the turntables, head nodding, a small smile. Her black blindfold is gone: nothing over her eyes, no blindfold, no mask, no band; her black hairband stays. Both her eyes are closed, softly shut, enjoying the music. She wears a pair of black over-ear headphones over her head and ears. ${KEEP_BARE} -dress/dj-scratch <- dress/dj : Change only her arms: the hand on the left side of the image scratches a record, the other holds one headphone cup to her ear. Everything else exactly the same. ${KEEP} -dress/whistle <- dress/base : Change only her expression: lips puckered, whistling, a couple of small black musical notes floating beside her mouth. Her black blindfold is gone: nothing over her eyes, no blindfold, no mask, no band; her black hairband stays. Both her eyes are closed, softly shut, relaxed. ${POSE} ${KEEP_BARE} -dress/headbang-up <- dress/base : She rocks out: her upper body leans far back, her face tipped up to the sky, mouth wide open shouting, eyes squeezed shut, one fist raised high above her head making the horns hand sign with index and little finger up, the other fist clenched at her side, her hair flying back. Her black blindfold is gone: nothing over her eyes, no blindfold, no mask, no band; her black hairband stays. ${KEEP_BARE} -# headbang-down: the kept result is --seed 7. -dress/headbang-down <- dress/base : She headbangs hard: seen from the front, her whole upper body is bent forward at the waist by about 45 degrees, her head thrown down so the top of her head faces the viewer, her white hair flung forward and hanging down over her face, both fists clenched in front of her chest, knees slightly bent. Her face is hidden by her hair. Her black blindfold is gone: nothing over her eyes, no blindfold, no mask, no band; her black hairband stays. ${KEEP_BARE} -dress/airguitar <- dress/base : She plays an electric guitar with a glowing neon blue body and a black neck, held across her hips: one hand strumming at the body, the other fretting the neck to the side, knees bent, eyes shut, mouth open in a big grin. Her black blindfold is gone: nothing over her eyes, no blindfold, no mask, no band; her black hairband stays. ${KEEP_BARE} - -# --- seated, on the assistant's chat card (Mood.seatFrame) -dress/sit <- base-sitting : ${SIT} Both gloved hands resting palms down beside her hips, a calm closed mouth. ${EYES} ${KEEP} -# dress/sit-read is hand made, like dress/base: no edit would bend her -# forward, so it is a masked inpaint of dress/sit, seed 13, in recipe.txt. -# No ${SIT} on the posed frames: its "keep her sitting pose exactly" kept -# her arms down, and sit-point came out as sit with a smirk. Seed 42 then -# left her old hand on her hip as well; the kept result is --seed 7. -dress/sit-point <- dress/sit : She raises the hand on the left side of the image and points down past her knees with her index finger, at something below her feet, with a teasing smirk; her other hand stays palms down beside her hip. She keeps sitting with her legs dangling; nothing is drawn under her. ${KEEP} -dress/sit-smirk <- dress/sit : Change only her expression to a teasing one-sided smirk. ${SIT} Both eyes fully visible and uncovered; the blindfold is nowhere on her head or face. ${BARE} ${KEEP_BARE} -# sit-think: seed 42 drew a third hand in both sets; kept dress --seed 7, -# leotard --seed 13. -dress/sit-think <- dress/sit : One gloved hand at her chin, thinking, head tilted slightly; her other hand palms down beside her hip. She keeps sitting with her legs dangling; nothing is drawn under her. ${KEEP} -# sit-listen: seed 42 drew a third hand; kept dress --seed 13. -dress/sit-listen <- dress/sit : She cups the hand on the left side of the image behind her ear, listening attentively; her other hand palms down beside her hip. She keeps sitting with her legs dangling; nothing is drawn under her. ${KEEP} -# sit-wave: seed 42 drew a third hand; kept dress --seed 7. -dress/sit-wave <- dress/sit : Raise only the hand on the left side of the image beside her head, waving, with a small smile; her other hand stays palms down beside her hip. She keeps sitting with her legs dangling; nothing is drawn under her. ${KEEP} - -# --- the leotard set: the same frames, after the self-destruct -leotard/base <- dress/base : ${LEOTARD} -leotard/smile <- dress/smile : ${LEOTARD} -# The generic line let the new eyes go soft: mad read sad, and yawn, whose -# dress frame has no eyes to copy, read as laughing. Name the mood they carry. -leotard/mad <- dress/mad : ${LEOTARD} Her expression stays angry and annoyed: furrowed brows, a sharp glaring stare, a frown and a puffed cheek; not sad, not tearful. -leotard/smirk <- dress/smirk : ${LEOTARD} -leotard/blush1 <- dress/blush1 : ${LEOTARD} -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. -leotard/watch <- leotard/base : Turn her completely around into a back view: we see her from behind, the back of her head with her white bob crossed only by her black hairband over the top, her back, the back of her black bodice, the white leotard and the backs of her legs, as if she is facing a screen and watching it; arms relaxed at her sides. No blindfold. Same character, black gloves, puffy black juliet sleeves, black thighhigh boots, chibi proportions, art style and white background. -leotard/point <- dress/point : ${LEOTARD} -leotard/yawn <- dress/yawn : ${LEOTARD} It is a big sleepy yawn, not a laugh: eyes squeezed shut and a little teary, brows relaxed and drowsy, her gloved hand over her wide open mouth. -# Lying face down and the back view have no eyes to uncover, so the generic -# line left the blindfold on them: say it is gone, band and knot. -# Asked to remove it from dress/lying, klein kept the band and only drew an -# eye under it; from leotard/base, which has none, it is the usual pose edit. -leotard/lying <- leotard/base : Make her lie on her side on the ground, asleep, her head resting on her folded arms, eyes closed. She keeps her plain white leotard, black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband, lips and the small mole under her mouth, and no blindfold. Same character, chibi proportions, art style and white background. -leotard/listen <- dress/listen : ${LEOTARD} -leotard/think <- dress/think : ${LEOTARD} -leotard/muse <- dress/muse : ${LEOTARD} -leotard/stretch <- dress/stretch : ${LEOTARD} -leotard/shoved <- dress/shoved : ${LEOTARD} -leotard/sing <- dress/sing : Her skirt is gone, revealing a plain white leotard underneath. Change nothing else: she keeps her closed eyes, her black over-ear headphones, the musical notes beside her head, her pose, black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband, lips and the small mole under her mouth. Same character, chibi proportions, art style and white background. -leotard/scorched <- leotard/base : Right after an explosion: her clothes lightly scorched and smudged with soot, a few wisps of smoke rising from her, hair slightly messy, a dazed flat expression; standing, arms at her sides. Her eyes stay uncovered, no blindfold. Her top stays black: the black bodice with its chest cutout and the puffy black juliet sleeves, exactly as in the image; the plain white leotard shows only below her waist, where the skirt was. She keeps her black gloves, black thighhigh boots, black hairband, lips and the small mole under her mouth. Same character, chibi proportions, art style and white background. -leotard/dj <- dress/dj : ${LEOTARD} -leotard/dj-scratch <- dress/dj-scratch : ${LEOTARD} -leotard/whistle <- dress/whistle : ${LEOTARD} -leotard/headbang-up <- dress/headbang-up : ${LEOTARD} -leotard/headbang-down <- dress/headbang-down : Her skirt is gone, revealing a plain white leotard underneath. Change nothing else: her head stays lowered with her white hair hanging down over her whole face, hiding it completely, no eyes showing, no blindfold; her fists stay clenched in front of her chest; she keeps her black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband and pose. Same character, chibi proportions, art style and white background. -leotard/airguitar <- dress/airguitar : ${LEOTARD} -leotard/sit <- dress/sit : ${LEOTARD} -# ${LEOTARD} uncovers her eyes and they looked at the viewer, losing the -# read; standalone, as leotard/headbang-down, with the gaze named. -leotard/sit-read <- dress/sit-read : Her skirt is gone, revealing a plain white leotard underneath, and her blindfold is gone: both eyes uncovered, pale blue-grey, lowered and looking down at something just below her feet, as if reading it; her eyelids half lowered, not looking at the viewer. Change nothing else: her hunched pose with her head sunk low, black gloves palms down beside her hips, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband, lips, the small mole under her mouth, chibi proportions, art style and white background. -leotard/sit-point <- dress/sit-point : ${LEOTARD} -leotard/sit-smirk <- dress/sit-smirk : ${LEOTARD} -leotard/sit-think <- dress/sit-think : ${LEOTARD} -leotard/sit-listen <- dress/sit-listen : ${LEOTARD} -leotard/sit-wave <- dress/sit-wave : ${LEOTARD} diff --git a/desktop/companion/gen-frames b/desktop/companion/gen-frames deleted file mode 100755 index df78cb9..0000000 --- a/desktop/companion/gen-frames +++ /dev/null @@ -1,150 +0,0 @@ -#!/bin/bash -# -# 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. -# -# gen-frames: make the companion's frames listed in frames.txt. -# -# gen-frames [--seed N] [--list] [OUTPUT...] -# -# With no OUTPUT, makes every frame whose image does not exist yet. Naming -# outputs (dress/mad, leotard/mad) remakes those, existing or not; a dress -# frame remade leaves its leotard frame stale, so name both. --seed changes -# the seed (default 42) to reroll a result; --list prints what would be made. -# Then runs prep-frames. -# -# It drives a running sd-server rather than sd-cli, which would reload the -# model for every frame: start sd-webui and pick FLUX.2 klein first. sd-webui -# also unloads llama-server's model, which otherwise leaves too little VRAM. -# The model is checked from sd-server's command line, since its API does not -# name it. - -set -euo pipefail - -here=$(cd "$(dirname "$0")" && pwd) -art=${ART_DIR:-$HOME/.local/share/keystone/companion/2b} -url=${SD_URL:-http://127.0.0.1:7860} -seed=42 -list=0 -want=() -while (($#)); do - case $1 in - --seed) seed=$2; shift 2 ;; - --list) list=1; shift ;; - -h|--help) sed -n '14,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; - *) want+=("$1"); shift ;; - esac -done - -# frames.txt to "output<TAB>references<TAB>prompt" lines, variables expanded. -# References are joined with + and no spaces: the first is the image edited, -# any after it are extra images the prompt can point at ("the second image"). -parse() { - local line name out ref prompt - local frame='^[[:space:]]*([a-z0-9/_-]+)[[:space:]]*<-[[:space:]]*([a-z0-9/_-]+([[:space:]]*\+[[:space:]]*[a-z0-9/_-]+)*)[[:space:]]*:[[:space:]]*(.*)$' - declare -A vars=() - while IFS= read -r line || [[ -n $line ]]; do - [[ $line =~ ^[[:space:]]*(#|$) ]] && continue - if [[ $line =~ ^([A-Z_]+)[[:space:]]*=[[:space:]]*(.*)$ ]]; then - vars[${BASH_REMATCH[1]}]=${BASH_REMATCH[2]} - continue - fi - if [[ ! $line =~ $frame ]]; then - echo "gen-frames: frames.txt: cannot read: $line" >&2 - exit 1 - fi - out=${BASH_REMATCH[1]} ref=${BASH_REMATCH[2]// /} prompt=${BASH_REMATCH[4]} - for name in "${!vars[@]}"; do prompt=${prompt//\$\{$name\}/${vars[$name]}}; done - if [[ $prompt == *'${'* ]]; then - echo "gen-frames: frames.txt: $out: undefined variable in: $prompt" >&2 - exit 1 - fi - printf '%s\t%s\t%s\n' "$out" "$ref" "$prompt" - done <"$here/frames.txt" -} - -# What to make: the named outputs, else every missing one, in file order. -# (No grep -q in a pipe: under pipefail its early exit fails the printf.) -todo=() -rows=$(parse) || exit 1 -mapfile -t rows <<<"$rows" -declare -A known=() wanted=() -for row in "${rows[@]}"; do known[${row%%$'\t'*}]=1; done -for name in "${want[@]}"; do - [[ -n ${known[$name]:-} ]] || { echo "gen-frames: $name is not in frames.txt" >&2; exit 1; } - wanted[$name]=1 -done -for row in "${rows[@]}"; do - out=${row%%$'\t'*} - if ((${#want[@]})); then - [[ -n ${wanted[$out]:-} ]] && todo+=("$row") - elif [[ ! -e $art/$out.png ]]; then - todo+=("$row") - fi -done -if ((list)); then - for row in "${todo[@]}"; do printf '%s <- %s\n' "${row%%$'\t'*}" "$(cut -f2 <<<"$row")"; done - exit 0 -fi -if ((${#todo[@]} == 0)); then - echo "gen-frames: nothing to make" - exec "$here/prep-frames" "$art" -fi - -pgrep -ax sd-server | grep -q klein || - { echo "gen-frames: sd-server is not running FLUX.2 klein; start sd-webui and pick it" >&2; exit 1; } - -tmp=$(mktemp -d) -trap 'rm -rf "$tmp"' EXIT - -made=0 -for row in "${todo[@]}"; do - IFS=$'\t' read -r out ref prompt <<<"$row" - IFS=+ read -ra refs <<<"$ref" - missing="" - for r in "${refs[@]}"; do [[ -e $art/$r.png ]] || missing=$r; done - if [[ -n $missing ]]; then - echo "gen-frames: $out: its reference $missing.png does not exist yet, skipped" >&2 - continue - fi - echo "gen-frames: $out <- ${ref//+/ + }" - # One JSON string per reference, as data URLs, slurped into the array. - for r in "${refs[@]}"; do - { printf 'data:image/png;base64,'; base64 -w0 "$art/$r.png"; } | jq -Rs . - done >"$tmp/refs.json" - # The settings the frames were tuned with in sd-webui: recipe.txt. - jq -n --arg prompt "$prompt" --slurpfile refs "$tmp/refs.json" --argjson seed "$seed" '{ - prompt: $prompt, negative_prompt: "", width: 1024, height: 1024, seed: $seed, - batch_count: 1, ref_images: $refs, auto_resize_ref_image: true, - increase_ref_index: false, lora: [], output_format: "png", output_compression: 100, - sample_params: { sample_steps: 4, sample_method: "euler", scheduler: "default", - guidance: { txt_cfg: 1.0 } } - }' >"$tmp/body" - id=$(curl -sf -m 30 -X POST "$url/sdcpp/v1/img_gen" -H 'Content-Type: application/json' \ - --data-binary @"$tmp/body" | jq -r '.id // empty') || id="" - [[ -n $id ]] || { echo "gen-frames: $out: sd-server refused the job" >&2; exit 1; } - while :; do - curl -sf -m 30 "$url/sdcpp/v1/jobs/$id" >"$tmp/job" || { echo "gen-frames: $out: lost the job" >&2; exit 1; } - status=$(jq -r '.status' "$tmp/job") - case $status in - queued|generating) sleep 2 ;; - completed) break ;; - *) echo "gen-frames: $out: $status: $(jq -r '.error.message // "no message"' "$tmp/job")" >&2; exit 1 ;; - esac - done - mkdir -p "$art/$(dirname "$out")" - jq -r '.result.images[0].b64_json' "$tmp/job" | base64 -d >"$tmp/out.png" - mv "$tmp/out.png" "$art/$out.png" - printf '%s seed %s %s <- %s\n' "$(date +%F)" "$seed" "$out" "$ref" >>"$art/generated.log" - made=$((made + 1)) -done -echo "gen-frames: made $made" -exec "$here/prep-frames" "$art" diff --git a/desktop/companion/prep-frames b/desktop/companion/prep-frames deleted file mode 100755 index 462657e..0000000 --- a/desktop/companion/prep-frames +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash -# -# 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. -# -# prep-frames: the companion's generated images to the frames she shows. -# -# prep-frames [ART_DIR] default: ~/.local/share/keystone/companion/2b -# -# Reads ART_DIR/dress/*.png and ART_DIR/leotard/*.png, 1024x1024 on white, -# and writes ART_DIR/frames/<outfit>/<name>.png: background removed, trimmed -# to her, scaled by a quarter. One scale for every frame keeps her the same -# size whatever the pose, which scaling each to a fixed height would not: -# lying down would come out huge. Sprite.qml draws them at half size and -# stands each on its bottom edge. -# -# The background goes by flood fill from the four corners: the dark line art -# stops the fill, so the white hair and the white leotard inside it stay. - -set -euo pipefail - -art=${1:-$HOME/.local/share/keystone/companion/2b} -n=0 -for outfit in dress leotard; do - [[ -d $art/$outfit ]] || continue - mkdir -p "$art/frames/$outfit" - for src in "$art/$outfit"/*.png; do - [[ -e $src ]] || continue - read -r w h < <(magick identify -format '%w %h\n' "$src") - magick "$src" -alpha set -fuzz 6% -fill none \ - -draw "color 0,0 floodfill" -draw "color $((w - 1)),0 floodfill" \ - -draw "color 0,$((h - 1)) floodfill" -draw "color $((w - 1)),$((h - 1)) floodfill" \ - -trim +repage -resize 25% "$art/frames/$outfit/$(basename "$src")" - n=$((n + 1)) - done -done -echo "prep-frames: $n frames in $art/frames" diff --git a/desktop/companion/test_lines.js b/desktop/companion/test_lines.js deleted file mode 100644 index 72addf2..0000000 --- a/desktop/companion/test_lines.js +++ /dev/null @@ -1,79 +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. - -// Run: node desktop/companion/test_lines.js -// Lines.js is a QML library, so its .pragma line is stripped before node -// evaluates it. - -const fs = require("fs"); -const assert = require("assert"); - -const src = fs.readFileSync(__dirname + "/Lines.js", "utf8") - .replace(/^\.pragma library$/m, ""); -const L = new Function(src + - "\nreturn { lines, apps, artists, monologue, appMap, appList, pickApp, pickArtist, pick, toMap };")(); - -// The defaults are what she says until a set is saved. -assert.strictEqual(L.pickApp("firefox", null, 0), L.apps.firefox[0]); -assert.strictEqual(L.pickApp("firefox", null, 0.99), L.apps.firefox[L.apps.firefox.length - 1]); -assert.strictEqual(L.pickApp("nope", null, 0), ""); -assert.strictEqual(L.appMap(null), L.apps); -// A hand edit with a typo reads as the defaults, not as silence. -assert.strictEqual(L.appMap("typo"), L.apps); -assert.strictEqual(L.appMap(["not", "a", "map"]), L.apps); - -// A saved set replaces the defaults, class by class. -assert.strictEqual(L.pickApp("firefox", { firefox: ["hi"] }, 0), "hi"); -assert.strictEqual(L.pickApp("kitty", { firefox: ["hi"] }, 0), ""); -assert.strictEqual(L.pick("app:firefox", 0, { firefox: ["custom"] }), "custom"); -assert.strictEqual(L.pick("app:firefox", 0), L.apps.firefox[0]); - -// Empty phrases are skipped when picking; a class with only empty ones says -// nothing. -assert.strictEqual(L.pickApp("firefox", { firefox: ["", " ", "only"] }, 0), "only"); -assert.strictEqual(L.pickApp("firefox", { firefox: ["", " "] }, 0), ""); - -// Reactions still come from lines. -assert.strictEqual(L.pick("shown", 0), L.lines.shown[0]); -assert.strictEqual(L.pick("nope", 0), ""); - -// The saved set as an ordered list, for the settings page. -const list = L.appList(null); -assert.strictEqual(list.length, Object.keys(L.apps).length); -assert.deepStrictEqual(list[0], { cls: "firefox", lines: L.apps.firefox.slice() }); -assert.deepStrictEqual(L.appList({ kitty: ["a"] }), [{ cls: "kitty", lines: ["a"] }]); - -// Back to a map: trim, lowercase the class, drop empty classes and empty -// phrases, and a repeated class keeps its last entry. -assert.deepStrictEqual(L.toMap([ - { cls: " Firefox ", lines: [" one ", "", " ", "two"] }, - { cls: "", lines: ["dropped"] }, - { cls: "kitty", lines: ["", " "] }, - { cls: "firefox", lines: ["last"] }, -]), { firefox: ["last"] }); - -// Artists: a case-insensitive substring of what MPRIS reports, first -// matching key wins, empty phrases skipped. -assert.strictEqual(L.pickArtist("Linkin Park", null, 0), L.artists["linkin park"][0]); -assert.strictEqual(L.pickArtist("LINKIN PARK, Jay-Z", null, 0), L.artists["linkin park"][0]); -assert.strictEqual(L.pickArtist("Nobody", null, 0), ""); -assert.strictEqual(L.pickArtist("", null, 0), ""); -assert.strictEqual(L.pickArtist("Air, Beck", { air: ["a"], beck: ["b"] }, 0), "a"); -assert.strictEqual(L.pickArtist("Airbourne", { air: ["", "a"] }, 0), "a"); -assert.strictEqual(L.pick("artist:Eminem", 0, null, null), L.artists.eminem[0]); -assert.strictEqual(L.pick("artist:Eminem", 0, null, { eminem: ["mine"] }), "mine"); -assert.strictEqual(L.pick("groove", 0), L.lines.groove[0]); - -// The same list helpers serve the artist set. -assert.strictEqual(L.appMap(null, L.artists), L.artists); -assert.strictEqual(L.appList(null, L.artists).length, Object.keys(L.artists).length); -assert.deepStrictEqual(L.toMap(L.appList(null, L.artists)), L.artists); -console.log("ok"); diff --git a/desktop/companion/test_mood.js b/desktop/companion/test_mood.js deleted file mode 100644 index 3dd3dba..0000000 --- a/desktop/companion/test_mood.js +++ /dev/null @@ -1,191 +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. - -// Run: node desktop/companion/test_mood.js -// Mood.js is a QML library, so its .pragma line is stripped before node -// evaluates it. - -const fs = require("fs"); -const assert = require("assert"); - -const src = fs.readFileSync(__dirname + "/Mood.js", "utf8") - .replace(/^\.pragma library$/m, ""); -const M = new Function(src + - "\nreturn { T, click, clickFrame, pick, outfit, mockAllowed, canSpeak, monologue, admireAllowed, seatFrame };")(); -const lsrc = fs.readFileSync(__dirname + "/ListFile.js", "utf8") - .replace(/^\.pragma library$/m, ""); -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({ - 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, singAt: 0, - djAt: 0, music: false, groove: "", - monologue: false, idle: 0, -}, over); -const state = over => M.pick(calm(over)).state; -const frame = over => M.pick(calm(over)).frame; - -// Nothing going on: the rest pose. -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 }], - ["shoved", { shoved: true }], - ["listen", { recording: true }], - ["think", { busy: true }], - ["wave", { repliedAt: NOW - 10 }], - ["sing", { singAt: NOW - 10 }], - ["dj", { djAt: NOW - 10 }], - ["clicks", { clicks: { count: 2, at: NOW - 10 } }], - ["admire", { admireAt: NOW - 10 }], - ["mock", { mockAt: NOW - 10 }], - ["watch", { typingAt: NOW - 10 }], - ["stretch", { wakeAt: NOW - 10 }], - ["muse", { monologue: true }], - ["lying", { idle: 3 }], -]; -for (let i = 0; i < ladder.length; i++) { - const all = Object.assign({}, ...ladder.slice(i).map(l => l[1]).reverse()); - 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" }); -assert.deepStrictEqual(M.pick(calm({ shoved: true, shovedAt: NOW - M.T.shoveHold })), { state: "shoved", frame: "mad" }); -assert.strictEqual(frame({ shoved: true, shovedAt: 0 }), "shoved"); - -// Idle levels and their frames. -assert.deepStrictEqual(M.pick(calm({ idle: 1 })), { state: "bored", frame: "sleepy" }); -assert.deepStrictEqual(M.pick(calm({ idle: 2 })), { state: "yawn", frame: "yawn" }); -assert.deepStrictEqual(M.pick(calm({ idle: 3 })), { state: "lying", frame: "lying" }); - -// Music while bored: she grooves with the picked frame instead, whistling -// when nothing was picked; yawning and lying down are unchanged. -assert.deepStrictEqual(M.pick(calm({ idle: 1, music: true, groove: "headbang" })), { state: "groove", frame: "headbang" }); -assert.deepStrictEqual(M.pick(calm({ idle: 1, music: true })), { state: "groove", frame: "whistle" }); -assert.deepStrictEqual(M.pick(calm({ idle: 1, groove: "headbang" })), { state: "bored", frame: "sleepy" }); -assert.strictEqual(state({ idle: 2, music: true }), "yawn"); -assert.strictEqual(state({ idle: 3, music: true }), "lying"); -// Music while awake is the rest pose; the DJ is only the burst. -assert.strictEqual(state({ music: true }), "base"); -assert.deepStrictEqual(M.pick(calm({ djAt: NOW - 10 })), { state: "dj", frame: "dj" }); - -// Holds end on time. -assert.strictEqual(state({ repliedAt: NOW - M.T.waveHold }), "base"); -assert.strictEqual(state({ singAt: NOW - M.T.singHold }), "base"); -assert.strictEqual(state({ djAt: NOW - M.T.djHold }), "base"); -assert.strictEqual(state({ typingAt: NOW - M.T.typingHold }), "base"); -assert.strictEqual(state({ wakeAt: NOW - M.T.wakeHold }), "base"); -assert.strictEqual(state({ clicks: { count: 3, at: NOW - M.T.clickHold } }), "base"); -assert.strictEqual(state({ destructAt: NOW - M.T.destruct - M.T.scorched }), "base"); -// The mock points first, then smirks. -assert.strictEqual(frame({ mockAt: NOW - 10 }), "point"); -assert.strictEqual(frame({ mockAt: NOW - M.T.mockHold / 2 - 10 }), "smirk"); -assert.strictEqual(state({ mockAt: NOW - M.T.mockHold }), "base"); - -// A click burst: within burstGap it grows, past it it starts over. -let c = { count: 0, at: 0 }; -c = M.click(c, 1000); -assert.deepStrictEqual(c, { count: 1, at: 1000 }); -c = M.click(c, 1000 + M.T.burstGap); -assert.strictEqual(c.count, 2); -c = M.click(c, c.at + M.T.burstGap + 1); -assert.strictEqual(c.count, 1); -c = { count: 0, at: 0 }; -for (let i = 0; i < 7; i++) c = M.click(c, 5000 + i * 300); -assert.strictEqual(c.count, 7); -// 1 smiles, 2-6 blush harder. -assert.deepStrictEqual([1, 2, 3, 4, 5, 6].map(M.clickFrame), - ["smile", "blush1", "blush1", "blush2", "blush2", "blush3"]); - -// Outfit: the dress until the flash is over, then the leotard for -// scorched + skirtless, then the dress again. -const SK = 60000; -assert.strictEqual(M.outfit(0, NOW, SK), "dress"); -assert.strictEqual(M.outfit(NOW - 10, NOW, SK), "dress"); -assert.strictEqual(M.outfit(NOW - M.T.destruct, NOW, SK), "leotard"); -const bare = M.T.destruct + M.T.scorched + SK; -assert.strictEqual(M.outfit(NOW - bare + 10, NOW, SK), "leotard"); -assert.strictEqual(M.outfit(NOW - bare, NOW, SK), "dress"); -// A skirtless time of 0 keeps the leotard until she is shown again, which -// resets destructAt. -assert.strictEqual(M.outfit(NOW - 10, NOW, 0), "dress"); -assert.strictEqual(M.outfit(NOW - 3600000, NOW, 0), "leotard"); -assert.strictEqual(M.outfit(0, NOW, 0), "dress"); - -// Cooldowns. -assert.strictEqual(M.mockAllowed(0, NOW), true); -assert.strictEqual(M.mockAllowed(NOW - M.T.mockCooldown + 1, NOW), false); -assert.strictEqual(M.mockAllowed(NOW - M.T.mockCooldown, NOW), true); -assert.strictEqual(M.canSpeak(0, NOW), true); -assert.strictEqual(M.canSpeak(NOW - M.T.lineCooldown + 1, NOW), false); -assert.strictEqual(M.canSpeak(NOW - M.T.lineCooldown, NOW), true); - -// The monologue: 1 in 4 on boredom, 1 in 3 on being shown, once an hour. -assert.strictEqual(M.monologue("bored", 0, NOW, 0.24), true); -assert.strictEqual(M.monologue("bored", 0, NOW, 0.25), false); -assert.strictEqual(M.monologue("shown", 0, NOW, 0.33), true); -assert.strictEqual(M.monologue("shown", 0, NOW, 0.34), false); -assert.strictEqual(M.monologue("other", 0, NOW, 0), false); -assert.strictEqual(M.monologue("bored", NOW - M.T.monologueCooldown + 1, NOW, 0), false); -assert.strictEqual(M.monologue("bored", NOW - M.T.monologueCooldown, NOW, 0), true); - -// Admiring a wallpaper: turned to it, then blushing, then done. -assert.deepStrictEqual(M.pick(calm({ admireAt: NOW - 10 })), { state: "admire", frame: "watch" }); -assert.strictEqual(frame({ admireAt: NOW - M.T.admireLook }), "blush3"); -assert.strictEqual(state({ admireAt: NOW - M.T.admireLook - M.T.admireBlush }), "base"); -assert.strictEqual(M.admireAllowed(0, NOW), true); -assert.strictEqual(M.admireAllowed(NOW - M.T.admireCooldown + 1, NOW), false); -assert.strictEqual(M.admireAllowed(NOW - M.T.admireCooldown, NOW), true); - -// The wallpaper list: paths, blank lines and # comments skipped. -assert.deepStrictEqual(L.parse(""), []); -assert.deepStrictEqual(L.parse("# mine\n/a.png\n\n /b.png \n#/c.png\n"), ["/a.png", "/b.png"]); -// Toggling adds a missing path and removes a listed one, leaving -// comments and every other line as they were. -assert.strictEqual(L.toggle("", "/a.png"), "/a.png\n"); -assert.strictEqual(L.toggle("# mine\n/b.png", "/a.png"), "# mine\n/b.png\n/a.png\n"); -assert.strictEqual(L.toggle("# mine\n/a.png\n/b.png\n", "/a.png"), "# mine\n/b.png\n"); -assert.strictEqual(L.toggle("/a.png\n", "/a.png"), ""); -// A commented-out path is not listed, so toggling it adds a live line. -assert.strictEqual(L.toggle("#/a.png\n", "/a.png"), "#/a.png\n/a.png\n"); -// wallp.conf's outputs, by connector. -assert.deepStrictEqual(L.outputs("# x\nOUTPUT_H=DP-1\nOUTPUT_V=DP-3\nDEFAULT_H=~/h.png\n"), - { "DP-1": "H", "DP-3": "V" }); -assert.deepStrictEqual(L.outputs(""), {}); - -// Seated on the assistant's card: each standing frame to its sitting one, -// the goodbye's sway to the one wave, and anything else to the plain sit. -assert.strictEqual(M.seatFrame("watch"), "sit-read"); -assert.strictEqual(M.seatFrame("point"), "sit-point"); -assert.strictEqual(M.seatFrame("smirk"), "sit-smirk"); -assert.strictEqual(M.seatFrame("think"), "sit-think"); -assert.strictEqual(M.seatFrame("listen"), "sit-listen"); -assert.strictEqual(M.seatFrame("wave"), "sit-wave"); -assert.strictEqual(M.seatFrame("wave-l"), "sit-wave"); -assert.strictEqual(M.seatFrame("wave-r"), "sit-wave"); -assert.strictEqual(M.seatFrame("dj-scratch"), "sit"); -assert.strictEqual(M.seatFrame("base"), "sit"); - -console.log("ok"); diff --git a/desktop/modules/appearance/WallpaperTab.qml b/desktop/modules/appearance/WallpaperTab.qml index d9876ed..b204c6c 100644 --- a/desktop/modules/appearance/WallpaperTab.qml +++ b/desktop/modules/appearance/WallpaperTab.qml @@ -12,7 +12,6 @@ import Quickshell import QtQuick import "../.." -import "../../companion" // Wallpapers by folder, staged per screen and set together with Apply, beside // a mock of the two screens above them. Sized by its host: the thumbnail list scrolls @@ -340,25 +339,6 @@ Column { } } - // The companion blushes at the hearted ones - // (companion/Admired.qml). After the pick - // MouseArea, so it takes the click. Outlined, - // so it reads on a light image too. - Text { - readonly property bool on: Admired.listed(modelData) - anchors { top: parent.top; right: parent.right; margins: 10 } - visible: on || thumbHover.hovered - text: on ? "\u{F02D1}" : "\u{F02D5}" - color: on ? Theme.red : Theme.text - style: Text.Outline - styleColor: Qt.alpha(Theme.base, 0.6) - font { family: Theme.iconFamily; pixelSize: Theme.fontSize + 8 } - MouseArea { - anchors { fill: parent; margins: -4 } - cursorShape: Qt.PointingHandCursor - onClicked: Admired.toggle(modelData) - } - } } } } diff --git a/desktop/modules/companion/ArtistsModule.qml b/desktop/modules/companion/ArtistsModule.qml deleted file mode 100644 index a0d23f4..0000000 --- a/desktop/modules/companion/ArtistsModule.qml +++ /dev/null @@ -1,67 +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 QtQuick -import "../.." -import "../../companion/Lines.js" as Lines - -// The lines the companion says when a track by an artist she knows starts, -// matched as a case-insensitive substring of the player's artist. Same -// draft, Save and Reset as PhrasesModule, on Prefs.companionArtists and -// Lines.artists; the page's cls field holds the artist. -Module { - id: am - name: "artists" - label: "Companion artists" - // nf-fa-music, present in Inconsolata Nerd Font. - icon: "\uf001" - settings: true - group: "Companion" - keywords: ["companion", "artist", "music", "song", "track", "line", "say", "2b"] - searchItems: am.shown.map((a, i) => ({ text: a.cls + ": " + a.lines.join(" / "), key: "app:" + i })) - - property var draft: null - property bool resetting: false - readonly property var shown: am.draft ?? Lines.appList(Prefs.companionArtists, Lines.artists) - readonly property bool valid: am.shown.every(a => String(a.cls).trim() !== "" - && (a.lines || []).some(l => String(l).trim() !== "")) - dirty: am.draft !== null - - function edit(list) { am.draft = list; am.resetting = false; } - function reset() { am.draft = Lines.appList(null, Lines.artists); am.resetting = true; } - function save() { - if (am.resetting) Prefs.resetCompanionArtists(); - else Prefs.setCompanionArtists(Lines.toMap(am.draft)); - am.discard(); - } - function discard() { am.draft = null; am.resetting = false; } - - page: Component { - Page { - title: "Companion artists" - actions: Component { - SaveBar { dirty: am.dirty; valid: am.valid; note: "every artist needs a name and a phrase" - onSave: am.save(); onDiscard: am.discard() } - } - PhrasesPage { - width: parent.width - owner: am - intro: "When a track by an artist she knows starts or changes, she says one of their lines at " - + "random, under her 30 s line cooldown. The name matches any part of the player's artist, " - + "ignoring case, so \"daft punk\" also catches \"Daft Punk, Pharrell Williams\". " - + "Save applies at once." - keyHint: "artist, e.g. linkin park" - addText: "Add artist" - saved: Prefs.companionArtists - } - } - } -} diff --git a/desktop/modules/companion/CompanionModule.qml b/desktop/modules/companion/CompanionModule.qml deleted file mode 100644 index 8d6a68d..0000000 --- a/desktop/modules/companion/CompanionModule.qml +++ /dev/null @@ -1,131 +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 QtQuick -import "../.." -import "../appearance" -import "../../companion" - -// The desktop companion's settings: whether she is shown, the same -// preference SUPER+\ toggles, so the switch follows the key; and where her -// wallpaper list is, marked with the heart in the wallpaper picker. -Module { - name: "companion" - label: "Companion" - // nf-md-account_heart, present in Inconsolata Nerd Font. - icon: "\u{F0899}" - settings: true - group: "Companion" - keywords: ["companion", "2b", "mascot", "character", "show", "hide", "wallpaper", "blush", "leotard", "timeout"] - - page: Component { - Page { - title: "Companion" - Column { - width: parent.width - spacing: 6 - - Item { - width: parent.width - height: 34 - Text { - id: lbl - anchors { left: parent.left; verticalCenter: parent.verticalCenter } - width: 160 - text: "Shown" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - Switch { - id: sw - anchors { left: lbl.right; leftMargin: 8; verticalCenter: parent.verticalCenter } - checked: Prefs.companion - onToggled: on => Prefs.setCompanion(on) - } - Text { - anchors { left: sw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter } - elide: Text.ElideRight - text: "Also SUPER+\\. Kept across restarts." - color: Theme.overlay - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } - } - } - // After the self-destruct. Off keeps the leotard until she is - // hidden and shown again; turning it back on starts from the - // seconds in the field. - Item { - width: parent.width - height: 34 - Text { - id: lblSk - anchors { left: parent.left; verticalCenter: parent.verticalCenter } - width: 160 - text: "Leotard timeout" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - Switch { - id: swSk - anchors { left: lblSk.right; leftMargin: 8; verticalCenter: parent.verticalCenter } - checked: Prefs.companionSkirtless > 0 - onToggled: on => Prefs.setCompanionSkirtless(on ? (parseInt(fSk.value) || 60) : 0) - } - Field { - id: fSk - anchors { left: swSk.right; leftMargin: 10; verticalCenter: parent.verticalCenter } - width: 60 - enabled: swSk.checked - opacity: enabled ? 1 : 0.4 - value: String(Prefs.companionSkirtless || 60) - placeholder: "60" - onEdited: { - const n = parseInt(value); - if (n >= 1 && String(n) === value.trim()) Prefs.setCompanionSkirtless(n); - else value = String(Prefs.companionSkirtless || 60); - } - } - Text { - anchors { left: fSk.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter } - elide: Text.ElideRight - text: swSk.checked ? "seconds" : "until she is hidden and shown again" - color: Theme.overlay - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } - } - } - Item { - width: parent.width - height: 34 - Text { - id: lbl2 - anchors { left: parent.left; verticalCenter: parent.verticalCenter } - width: 160 - text: "Wallpaper list" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - } - Field { - anchors { left: lbl2.right; leftMargin: 8; right: parent.right; verticalCenter: parent.verticalCenter } - value: Prefs.companionBlushFile - placeholder: Admired.defaultPath.replace(Admired.home, "~") - onEdited: Prefs.setCompanionBlushFile(value) - } - } - Text { - width: parent.width - wrapMode: Text.WordWrap - text: "One path per line. She blushes at these on an empty workspace; the heart in Wallpaper adds and removes them." - color: Theme.overlay - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } - } - } - } - } -} diff --git a/desktop/modules/companion/PhrasesModule.qml b/desktop/modules/companion/PhrasesModule.qml deleted file mode 100644 index 7e4895a..0000000 --- a/desktop/modules/companion/PhrasesModule.qml +++ /dev/null @@ -1,64 +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 QtQuick -import "../.." -import "../../companion/Lines.js" as Lines - -// The lines the companion says when focus moves to a program she -// recognizes, by its lowercased window class. They live in Prefs and she -// reads them live, so a save needs no restart. The page edits a draft here; -// Save hands it to Prefs, Reset drops the saved set so Lines.js' defaults -// show again. -Module { - id: pm - name: "phrases" - label: "Companion phrases" - // nf-fa-comment, present in Inconsolata Nerd Font. - icon: "\uf075" - settings: true - group: "Companion" - keywords: ["companion", "phrase", "line", "say", "speak", "app", "program", - "window", "class", "firefox", "2b"] - searchItems: pm.shown.map((a, i) => ({ text: a.cls + ": " + a.lines.join(" / "), key: "app:" + i })) - - // null while there are no edits: the page then shows the saved set, or - // Lines.js' defaults when nothing is saved. - property var draft: null - // Reset was pressed: Save drops the saved set rather than saving a copy - // of the defaults, so later default changes still arrive. - property bool resetting: false - readonly property var shown: pm.draft ?? Lines.appList(Prefs.companionApps) - // Every app needs a class and at least one non-empty phrase; Save waits. - readonly property bool valid: pm.shown.every(a => String(a.cls).trim() !== "" - && (a.lines || []).some(l => String(l).trim() !== "")) - dirty: pm.draft !== null - - function edit(list) { pm.draft = list; pm.resetting = false; } - function reset() { pm.draft = Lines.appList(null); pm.resetting = true; } - function save() { - if (pm.resetting) Prefs.resetCompanionApps(); - else Prefs.setCompanionApps(Lines.toMap(pm.draft)); - pm.discard(); - } - function discard() { pm.draft = null; pm.resetting = false; } - - page: Component { - Page { - title: "Companion phrases" - actions: Component { - SaveBar { dirty: pm.dirty; valid: pm.valid; note: "every app needs a class and a phrase" - onSave: pm.save(); onDiscard: pm.discard() } - } - PhrasesPage { width: parent.width; owner: pm } - } - } -} diff --git a/desktop/modules/companion/PhrasesPage.qml b/desktop/modules/companion/PhrasesPage.qml deleted file mode 100644 index 1971171..0000000 --- a/desktop/modules/companion/PhrasesPage.qml +++ /dev/null @@ -1,195 +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 QtQuick -import "../.." - -// One card per recognized program: its window class and the lines she says -// when focus moves to it. Every change edits the module's draft; the -// header's Save hands the set to Prefs. Reset fills the draft with the -// built-in set from Lines.js. -Column { - id: page - spacing: 10 - - // The module, holding the draft. Not named pm, its id (it would bind to - // itself, AGENTS.md). - required property QtObject owner - // What the cards are keyed by; the defaults are the app set's, so - // PhrasesModule passes nothing. - property string intro: "When focus moves to a program she recognizes, she says one of its lines at random, a third " - + "of the time and under her 30 s line cooldown. The class is Hyprland's window class, " - + "lowercased (hyprctl activewindow shows it). Save applies at once." - property string keyHint: "window class, e.g. firefox" - property string addText: "Add app" - // The saved set, for Reset's enabled state; null means the defaults. - property var saved: Prefs.companionApps - readonly property var list: owner.shown - // The app card awaiting a second click on Remove. - property int confirming: -1 - - // Saves a copy of the list with app i replaced by fn(app), or removed - // when fn returns null. - function edit(i, fn) { - const out = []; - for (let j = 0; j < page.list.length; j++) { - const a = page.list[j]; - const c = { cls: a.cls, lines: a.lines.slice() }; - if (j !== i) { out.push(c); continue; } - const r = fn(c); - if (r) out.push(r); - } - page.confirming = -1; - page.owner.edit(out); - } - - function addApp() { page.owner.edit(page.list.concat([{ cls: "", lines: [""] }])); } - function removeApp(i) { page.edit(i, () => null); } - function setCls(i, t) { page.edit(i, a => Object.assign(a, { cls: t })); } - function addLine(i) { page.edit(i, a => Object.assign(a, { lines: a.lines.concat([""]) })); } - function setLine(i, j, t) { - page.edit(i, a => { - const ls = a.lines.slice(); - ls[j] = t; - return Object.assign(a, { lines: ls }); - }); - } - function removeLine(i, j) { page.edit(i, a => Object.assign(a, { lines: a.lines.filter((_, k) => k !== j) })); } - - component Small: Text { - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - } - - // Reports every keystroke into the draft. Not bound to value, which - // would reset the cursor; it follows the value only when the draft moves - // under it (Discard, Reset). Empty is outlined, since Save refuses it. - component Field: Rectangle { - id: field - property string value: "" - property string placeholder: "" - signal edited(string text) - height: 32 - radius: 8 - color: Qt.alpha(Theme.base, 0.6) - border.width: ti.text.trim() === "" ? 1 : 0 - border.color: Theme.red - onValueChanged: if (ti.text !== field.value) ti.text = field.value - TextInput { - activeFocusOnTab: true - id: ti - anchors { fill: parent; leftMargin: 10; rightMargin: 10 } - verticalAlignment: TextInput.AlignVCenter - clip: true - selectByMouse: true - color: Theme.text - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - Component.onCompleted: text = field.value - onTextEdited: field.edited(text) - Text { - anchors.verticalCenter: parent.verticalCenter - visible: parent.text === "" - text: field.placeholder - color: Theme.overlay - font: parent.font - } - } - } - - Small { - width: page.width - wrapMode: Text.Wrap - text: page.intro - } - - Repeater { - // A count, not the list: a new list on every keystroke would rebuild - // every card and drop the focus of the field being typed in. - model: page.list.length - - delegate: Rectangle { - id: card - required property int index - readonly property var modelData: page.list[index] ?? { cls: "", lines: [] } - objectName: "app:" + index - width: page.width - height: body.implicitHeight + 20 - radius: 10 - color: Qt.alpha(Theme.surface, 0.45) - - Column { - id: body - anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 } - spacing: 8 - - Item { - width: parent.width - height: 32 - Field { - anchors { left: parent.left; right: del.left; rightMargin: 8; top: parent.top; bottom: parent.bottom } - value: card.modelData.cls - placeholder: page.keyHint - onEdited: t => page.setCls(card.index, t) - } - Button { - id: del - anchors { right: parent.right; verticalCenter: parent.verticalCenter } - danger: true - text: page.confirming === card.index ? "Confirm" : "Remove" - onClicked: { - if (page.confirming === card.index) page.removeApp(card.index); - else page.confirming = card.index; - } - } - } - - Repeater { - model: card.modelData.lines.length - delegate: Row { - id: phraseRow - required property int index - width: body.width - spacing: 6 - Field { - width: phraseRow.width - removeBtn.width - phraseRow.spacing - value: card.modelData.lines[phraseRow.index] ?? "" - placeholder: "a line she says" - onEdited: t => page.setLine(card.index, phraseRow.index, t) - } - Button { - id: removeBtn - // nf-fa-xmark, present in Inconsolata Nerd Font. - icon: "\uf00d" - danger: true - onClicked: page.removeLine(card.index, phraseRow.index) - } - } - } - - Button { - text: "Add phrase" - onClicked: page.addLine(card.index) - } - } - } - } - - Row { - spacing: 8 - Button { text: page.addText; onClicked: page.addApp() } - Button { - text: "Reset to defaults" - danger: true - enabled: !page.owner.resetting && (page.saved !== null || page.owner.dirty) - onClicked: page.owner.reset() - } - } -} diff --git a/desktop/shell.qml b/desktop/shell.qml index 8d9ca2e..c5e44ad 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -26,7 +26,6 @@ import "modules/sbbuilder" import "modules/shells" import "modules/bluetooth" import "modules/clipboard" -import "modules/companion" import "modules/fonts" import "modules/git" import "modules/hyprland" @@ -44,7 +43,6 @@ import "modules/user" import "modules/vm" import "island" import "assistant" -import "companion" ShellRoot { // Quickshell exits once no window is visible, and the drawer is closed @@ -91,9 +89,6 @@ ShellRoot { FontsModule {}, SunsetModule {}, IdleModule {}, - CompanionModule {}, - PhrasesModule {}, - ArtistsModule {}, NotificationsModule {}, AutostartModule {}, ShellsModule {}, @@ -138,10 +133,6 @@ ShellRoot { Assistant { id: assistant; swapping: asstSettings.loading; hears: asstSettings.hears preferred: Prefs.assistantModel } - // The desktop companion, bottom-right of the focused monitor, on SUPER+\. - // She reads the assistant's state. See companion/README.md. - Companion { asst: assistant } - // Opening the assistant, or picking another model for its chat, loads // that model if it is a local one and another is in VRAM. See // modules/assistant/README.md. diff --git a/keybinds/Binds.js b/keybinds/Binds.js index 604a53d..eb337c1 100644 --- a/keybinds/Binds.js +++ b/keybinds/Binds.js @@ -42,15 +42,8 @@ const NAMES = Object.assign(Object.create(null), { "XF86Search": "Fn+F11", }); -// Hyprland 0.56.2 reports a Lua `code:N` bind with key "" and keycode 0, so -// such a bind is named here by its description. -// ponytail: one entry per keycode bind; a new `code:` bind needs a line here. -const UNNAMED = Object.assign(Object.create(null), { - "Desktop: Companion": "\\", // code:49, left of 1 -}); - function keys(bind) { - const k = bind.key || UNNAMED[bind.description] || "?"; + const k = bind.key || "?"; // a `code:N` bind reports key "" (see AGENTS.md) return mods(bind.modmask).concat([NAMES[k] ?? k]).join(" + "); } diff --git a/keybinds/test_binds.js b/keybinds/test_binds.js index 7b2289a..df1e4b5 100644 --- a/keybinds/test_binds.js +++ b/keybinds/test_binds.js @@ -27,8 +27,6 @@ assert.strictEqual(B.keys({ modmask: 69, key: "F1" }), "Win + CTRL + SHIFT + F1" assert.strictEqual(B.keys({ modmask: 0, key: "XF86AudioMute" }), "Mute"); assert.strictEqual(B.keys({ modmask: 64, key: "mouse:272" }), "Win + LMB"); assert.strictEqual(B.keys({ modmask: 0, key: "F5" }), "F5"); -// A keycode bind reports no key at all; it is named by its description. -assert.strictEqual(B.keys({ modmask: 64, key: "", keycode: 0, description: "Desktop: Companion" }), "Win + \\"); // A key named like an Object.prototype member is not renamed. assert.strictEqual(B.keys({ modmask: 0, key: "constructor" }), "constructor"); diff --git a/shared/NotifyConfig.js b/shared/NotifyConfig.js index 6fc98e6..47fa0f6 100644 --- a/shared/NotifyConfig.js +++ b/shared/NotifyConfig.js @@ -73,8 +73,6 @@ function withheld(cfg, n, dnd) { // Whether a queued notification's balloon is on screen at `now`: the drawer // and snooze withhold everything, then mute and DND, then its own expiry. -// The balloon shell and the desktop companion both ask this, so they cannot -// disagree about what is showing. function shown(cfg, n, dnd, drawerOpen, snoozeUntil, now) { if (drawerOpen || snoozeUntil > now) return false; if (withheld(cfg, n, dnd)) return false; diff --git a/shared/test_notify_config.js b/shared/test_notify_config.js index 4e8db23..bb44521 100644 --- a/shared/test_notify_config.js +++ b/shared/test_notify_config.js @@ -64,8 +64,7 @@ assert.strictEqual(C.soundFor(sc, sn("critical", "/app.oga", true)), ""); // An old daemon publishes neither field. assert.strictEqual(C.soundFor(sc, { app: "x", urgency: "critical" }), "/c.oga"); -// On screen: the drawer, snooze, mute, DND and expiry, in one check that -// the balloons and the desktop companion share. +// On screen: the drawer, snooze, mute, DND and expiry, in one check. const live = { app: "ok", urgency: "normal", expires: 0 }; assert.strictEqual(C.shown(cfg, live, false, false, 0, 5000), true); assert.strictEqual(C.shown(cfg, live, false, true, 0, 5000), false); // drawer open |
