diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-23 15:03:41 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-23 15:03:41 +0200 |
| commit | fd7844f2f264c51db027b945031704c36169b4c6 (patch) | |
| tree | 391eab7b1929668b910a11b2d03dde47182ae51a /desktop/companion/Figure.qml | |
| parent | b324159b0636180ae65d7dde4ed424b21ff02117 (diff) | |
| download | quickshell-fd7844f2f264c51db027b945031704c36169b4c6.tar.gz quickshell-fd7844f2f264c51db027b945031704c36169b4c6.zip | |
companion: the window, her inputs and her drawing
Companion.qml is a Top-layer PanelWindow bottom-right of the focused
monitor, masked to her body so every other click passes through. It
gathers the inputs (Hyprland's custom typing and delete events, the
assistant's state and replied signal, the balloons on her screen,
three IdleMonitors, clicks and a hover), hands a snapshot to Mood.js,
and speaks from Lines.js. Figure.qml crossfades frames, bobs, drops
out of view and back, and plays the self-destruct.
Figure.qml was planned as Sprite.qml, but QtQuick ships a Sprite type
and an explicit import beats the directory's own file: the shell failed
to load with "Cannot assign to non-existent property onSettled".
Maximised windows report fullscreen 1 and most windows here are, so
only 2 hides her. She only feels the pointer once it is on her, so the
dodge waits 0.6 s and a quick click still lands.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/companion/Figure.qml')
| -rw-r--r-- | desktop/companion/Figure.qml | 147 |
1 files changed, 147 insertions, 0 deletions
diff --git a/desktop/companion/Figure.qml b/desktop/companion/Figure.qml new file mode 100644 index 0000000..0b3b671 --- /dev/null +++ b/desktop/companion/Figure.qml @@ -0,0 +1,147 @@ +// 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, 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) + + 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) + } + onOutChanged: { + dropAnim.stop(); + dropAnim.to = s.out ? s.height + 24 : 0; + dropAnim.easing.type = s.out ? Easing.InCubic : Easing.OutBack; + dropAnim.start(); + } + 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 + opacity: 1 - s.drop / (s.height + 24) + transform: Translate { 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 + opacity: s.flip ? 0 : 1 + Behavior on opacity { NumberAnimation { duration: 120 } } + 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 + opacity: s.flip ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: 120 } } + 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 } + } +} |
