Files
TNCcat/qml/Cat.qml
T
JulianandClaude Opus 5 403ac30b3a Add a coffee break
Every so often a mug appears at the cat's nose, it sits down, dips its head in
and takes a few slow sips while steam rises. M01, optional stop.

The mug lives in its own click-through window like the chip, or inside the
overlay on Wayland. It goes away as soon as the cat is petted, dragged, sent to
sleep or moves on to something else, so no mug is ever left standing.

Also renames OverlayWindow's mode flag, which shadowed QWindow::active.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 23:00:22 +02:00

615 lines
26 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Shapes
// A chibi cat drawn from primitives and vector paths.
// Design space is 100 x 90 units, ground line at y = 84, cat faces right.
Item {
id: cat
property color coat: "#f4b183"
property bool facingRight: true
property string mood: "walking" // walking running sitting grooming stretching sleeping loving alert
property bool lifted: false
property bool petted: false
property point look: Qt.point(0, 0)
property bool showCodes: true
property real sipDip: 0
// The cat announces what it is doing in the machine's own language.
readonly property string moodCode: mood === "running" || mood === "chasing" ? "G00"
: mood === "walking" ? "G01"
: mood === "sitting" ? "M00"
: mood === "grooming" ? "M08"
: mood === "stretching" ? "G28"
: mood === "sleeping" ? "M30"
: mood === "drinking" ? "M01"
: ""
readonly property bool dark: coat.hslLightness < 0.45
readonly property color coatShade: Qt.darker(coat, 1.12)
readonly property color coatLight: Qt.lighter(coat, dark ? 1.35 : 1.16)
readonly property color ink: dark ? Qt.lighter(coat, 1.9) : Qt.darker(coat, 1.7)
readonly property color eyeColor: dark ? "#f7d67c" : "#2f2a36"
readonly property color pink: "#f2929f"
readonly property color blush: "#f77d95"
readonly property bool walking: mood === "walking"
readonly property bool running: mood === "running" || mood === "chasing"
readonly property bool moving: (walking || running) && !lifted
readonly property bool sleeping: mood === "sleeping"
readonly property bool happy: mood === "loving" || mood === "grooming" || mood === "stretching"
|| mood === "drinking" || petted
readonly property real unit: width / 100
// tail pose (design units); swish is added on top by the idle animation
property real tailC1X: 14
property real tailC1Y: 66
property real tailC2X: 8
property real tailC2Y: 46
property real tailX: 13
property real tailY: 34
property real tailSwish: 0
Behavior on tailC1X { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
Behavior on tailC1Y { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
Behavior on tailC2X { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
Behavior on tailC2Y { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
Behavior on tailX { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
Behavior on tailY { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
implicitWidth: 100
implicitHeight: 90
transform: Scale {
origin.x: cat.width / 2
xScale: cat.facingRight ? 1 : -1
}
Item {
id: art
width: 100
height: 90
transformOrigin: Item.TopLeft
scale: cat.unit
// ground shadow
Rectangle {
id: shadow
x: 24; y: 79
width: 56; height: 8
radius: 4
color: "#000000"
opacity: 0.16
antialiasing: true
scale: 1 - torso.y * -0.02
}
Item {
id: torso
width: 100
height: 90
// tail
Shape {
id: tailShape
z: -1
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
id: tail
strokeColor: cat.coatShade
strokeWidth: 7
capStyle: ShapePath.RoundCap
fillColor: "transparent"
startX: 30; startY: 60
PathCubic {
control1X: cat.tailC1X; control1Y: cat.tailC1Y
control2X: cat.tailC2X + cat.tailSwish * 0.5; control2Y: cat.tailC2Y
x: cat.tailX + cat.tailSwish; y: cat.tailY
}
}
SequentialAnimation {
running: !cat.sleeping && !cat.lifted
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { target: cat; property: "tailSwish"; to: 6; duration: 900; easing.type: Easing.InOutSine }
NumberAnimation { target: cat; property: "tailSwish"; to: -4; duration: 900; easing.type: Easing.InOutSine }
}
}
// legs — the far pair sits behind the body, the near pair in front
Repeater {
id: legs
model: 4
Rectangle {
id: leg
required property int index
readonly property bool front: index >= 2
readonly property bool far: index % 2 === 0
readonly property real swing: cat.running ? 38 : 24
readonly property real restRotation: front ? (cat.mood === "stretching" ? (far ? 48 : 40)
: cat.lifted ? (far ? -12 : -18) : 0)
: (cat.mood === "stretching" ? -6 : 0)
z: far ? 0 : 2
x: front ? (cat.seated && !cat.lifted ? 60 : 56) + (far ? 0 : 8)
: (cat.seated && !cat.lifted ? 36 : 30) + (far ? 0 : 8)
y: cat.sleeping && !cat.lifted ? 74
: (front ? (cat.seated && !cat.lifted ? 60 : 63)
: (cat.seated && !cat.lifted ? 72 : 63))
width: 10
height: cat.sleeping && !cat.lifted ? 8
: (front ? (cat.seated && !cat.lifted ? 24 : 20)
: (cat.seated && !cat.lifted ? 12 : 20))
radius: 5
color: far ? cat.coatShade : cat.coat
antialiasing: true
transformOrigin: Item.Top
Behavior on x { NumberAnimation { duration: 400; easing.type: Easing.InOutQuad } }
Behavior on y { NumberAnimation { duration: 400; easing.type: Easing.InOutQuad } }
Behavior on height { NumberAnimation { duration: 400; easing.type: Easing.InOutQuad } }
// paw
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
width: parent.width
height: 6.5
radius: 3.25
color: cat.coatLight
antialiasing: true
Rectangle { // toe line
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 1.2
width: 4.5
height: 1
radius: 0.5
color: cat.coatShade
opacity: 0.7
visible: !leg.far && !cat.sleeping
}
}
// While walking the gait animation owns rotation; otherwise the pose does.
states: State {
name: "resting"
when: !cat.moving
PropertyChanges { leg.rotation: leg.restRotation }
}
transitions: Transition {
NumberAnimation { property: "rotation"; duration: 400; easing.type: Easing.OutBack }
}
SequentialAnimation on rotation {
running: cat.moving
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { to: leg.far ? leg.swing : -leg.swing; duration: cat.running ? 130 : 240; easing.type: Easing.InOutSine }
NumberAnimation { to: leg.far ? -leg.swing : leg.swing; duration: cat.running ? 130 : 240; easing.type: Easing.InOutSine }
}
}
}
// body
Rectangle {
id: body
z: 1
x: 24; y: 46
width: 50; height: 32
radius: 16
color: cat.coat
antialiasing: true
Behavior on x { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on y { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on width { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on height { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on rotation { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Rectangle { // belly
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 3
width: parent.width * 0.5
height: parent.height * 0.42
radius: height / 2
color: cat.coatLight
antialiasing: true
}
}
// head
Item {
id: head
z: 3
x: 46; y: 12
width: 50; height: 44
transformOrigin: Item.Bottom
Behavior on x { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on y { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on rotation { NumberAnimation { duration: 450; easing.type: Easing.InOutBack } }
// ears
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath { // left ear
fillColor: cat.coat
strokeColor: "transparent"
startX: 6; startY: 14
PathQuad { controlX: 2; controlY: -4; x: 8; y: -4 }
PathQuad { controlX: 14; controlY: -2; x: 24; y: 5 }
PathLine { x: 6; y: 14 }
}
ShapePath { // left inner
fillColor: cat.pink
strokeColor: "transparent"
startX: 9; startY: 11
PathQuad { controlX: 6; controlY: 2; x: 9; y: 1 }
PathQuad { controlX: 14; controlY: 3; x: 19; y: 6 }
PathLine { x: 9; y: 11 }
}
ShapePath { // right ear
fillColor: cat.coat
strokeColor: "transparent"
startX: 44; startY: 14
PathQuad { controlX: 48; controlY: -4; x: 42; y: -4 }
PathQuad { controlX: 36; controlY: -2; x: 26; y: 5 }
PathLine { x: 44; y: 14 }
}
ShapePath { // right inner
fillColor: cat.pink
strokeColor: "transparent"
startX: 41; startY: 11
PathQuad { controlX: 44; controlY: 2; x: 41; y: 1 }
PathQuad { controlX: 36; controlY: 3; x: 31; y: 6 }
PathLine { x: 41; y: 11 }
}
}
Rectangle { // face
x: 0; y: 4
width: 50; height: 40
radius: 21
color: cat.coat
antialiasing: true
}
// cheeks
Repeater {
model: 2
Rectangle {
required property int index
x: index === 0 ? 5 : 35
y: 30
width: 10; height: 5.5
radius: 3
color: cat.blush
opacity: cat.happy ? 0.75 : 0.4
antialiasing: true
Behavior on opacity { NumberAnimation { duration: 300 } }
}
}
// eyes
Repeater {
model: 2
Item {
id: eye
required property int index
property real open: 1
x: (index === 0 ? 12 : 29) + cat.look.x * 2.2
y: 17 + cat.look.y * 1.6
width: 9.5; height: 12
Behavior on open { NumberAnimation { duration: 110 } }
Rectangle {
id: eyeBall
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
width: eye.width * (cat.mood === "alert" || cat.mood === "batting" ? 1.15 : 1)
height: eye.height * eye.open * (cat.mood === "alert" || cat.mood === "batting" ? 1.15 : 1)
radius: width / 2
color: cat.eyeColor
visible: eye.open > 0.08
antialiasing: true
Rectangle { // highlight
x: parent.width * 0.5; y: parent.height * 0.14
width: 3.4; height: 3.4 * eye.open
radius: 1.7
color: "white"
antialiasing: true
}
Rectangle {
x: parent.width * 0.22; y: parent.height * 0.6
width: 1.6; height: 1.6 * eye.open
radius: 0.8
color: "white"
antialiasing: true
}
}
Shape { // closed eye: happy arch or sleepy curve
anchors.fill: parent
visible: eye.open <= 0.08
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: cat.ink
strokeWidth: 1.8
capStyle: ShapePath.RoundCap
fillColor: "transparent"
startX: 0.5; startY: 7
PathQuad {
controlX: 4.75
controlY: cat.happy ? 2 : 10
x: 9; y: 7
}
}
}
Timer { // blink
interval: 2600 + Math.random() * 3800
running: !cat.sleeping && !cat.happy && cat.mood !== "alert"
repeat: true
onTriggered: { interval = 2600 + Math.random() * 3800; blinkAnim.start() }
}
SequentialAnimation {
id: blinkAnim
NumberAnimation { target: eye; property: "open"; to: 0; duration: 80 }
NumberAnimation { target: eye; property: "open"; to: 1; duration: 120 }
}
states: State {
name: "closed"
when: cat.sleeping || cat.happy
PropertyChanges { eye.open: 0 }
}
}
}
// nose
Rectangle {
x: 22.5; y: 28
width: 5; height: 3.6
radius: 1.8
color: cat.pink
antialiasing: true
}
// mouth "ω"
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: cat.ink
strokeWidth: 1.1
capStyle: ShapePath.RoundCap
fillColor: "transparent"
startX: 20.5; startY: 32
PathQuad { controlX: 22.5; controlY: 35.2; x: 25; y: 32.4 }
PathQuad { controlX: 27.5; controlY: 35.2; x: 29.5; y: 32 }
}
}
// whiskers
Repeater {
model: 4
Rectangle {
required property int index
readonly property bool leftSide: index < 2
x: leftSide ? -7 : 45
y: 25 + (index % 2) * 4.5
width: 12
height: 1
radius: 0.5
color: cat.ink
opacity: 0.55
rotation: (leftSide ? 1 : -1) * ((index % 2) === 0 ? 7 : -7)
antialiasing: true
}
}
// raised paw while grooming
Rectangle {
x: 8; y: 26
width: 10; height: 16
radius: 5
color: cat.coat
antialiasing: true
visible: cat.mood === "grooming"
transformOrigin: Item.Bottom
SequentialAnimation on rotation {
running: cat.mood === "grooming"
loops: Animation.Infinite
NumberAnimation { to: 14; duration: 260; easing.type: Easing.InOutSine }
NumberAnimation { to: -6; duration: 260; easing.type: Easing.InOutSine }
}
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
y: 0
width: parent.width; height: 6
radius: 3
color: cat.coatLight
}
}
}
// walking bob
SequentialAnimation on y {
running: cat.moving
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { to: cat.running ? -4 : -2; duration: cat.running ? 130 : 240; easing.type: Easing.InOutSine }
NumberAnimation { to: 0; duration: cat.running ? 130 : 240; easing.type: Easing.InOutSine }
}
// purr wiggle while petted / loving
SequentialAnimation on rotation {
running: cat.petted || cat.mood === "loving"
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { to: 2.5; duration: 180; easing.type: Easing.InOutSine }
NumberAnimation { to: -2.5; duration: 180; easing.type: Easing.InOutSine }
}
}
// speech bubble: "!" / "z" / "♥"
Text {
id: bubble
readonly property string glyph: cat.mood === "alert" || cat.mood === "batting" ? "!"
: cat.sleeping ? "z"
: (cat.mood === "loving" || cat.petted) ? "♥" : ""
x: 82; y: 4
text: glyph
visible: glyph.length > 0
font.pixelSize: cat.mood === "alert" || cat.mood === "batting" ? 22 : 16
font.bold: true
font.family: "Helvetica Neue"
color: cat.mood === "alert" || cat.mood === "batting" ? "#f2a33c"
: (glyph === "♥" ? "#ff5b7f" : cat.ink)
transform: Scale { xScale: cat.facingRight ? 1 : -1; origin.x: bubble.width / 2 }
Accessible.ignored: true
SequentialAnimation on y {
running: bubble.visible
loops: Animation.Infinite
NumberAnimation { from: 10; to: -8; duration: cat.sleeping ? 1800 : 900; easing.type: Easing.OutQuad }
}
SequentialAnimation on opacity {
running: bubble.visible
loops: Animation.Infinite
NumberAnimation { from: 0; to: 1; duration: cat.sleeping ? 500 : 250 }
NumberAnimation { to: 0; duration: cat.sleeping ? 1300 : 650 }
}
}
}
// Machine code tag above the head, flashed whenever the mood changes
Item {
id: codeTag
x: 30; y: 2
width: codeBg.width
height: codeBg.height
opacity: 0
visible: cat.showCodes && opacity > 0.01
transformOrigin: Item.Center
transform: Scale { xScale: cat.facingRight ? 1 : -1; origin.x: codeTag.width / 2 }
z: 10
scale: cat.unit
Accessible.ignored: true
Rectangle {
id: codeBg
width: codeText.implicitWidth + 10
height: codeText.implicitHeight + 6
radius: 4
color: "#242833"
opacity: 0.92
antialiasing: true
Text {
id: codeText
anchors.centerIn: parent
text: cat.moodCode
color: "#8fd6ff"
font.pixelSize: 11
font.bold: true
font.family: "Menlo"
}
}
SequentialAnimation {
id: codeFlash
NumberAnimation { target: codeTag; property: "opacity"; to: 1; duration: 160 }
PauseAnimation { duration: 1500 }
NumberAnimation { target: codeTag; property: "opacity"; to: 0; duration: 500 }
}
}
onMoodCodeChanged: if (showCodes && moodCode.length > 0) codeFlash.restart()
// Head dips into the mug, holds, comes back up.
SequentialAnimation {
running: cat.mood === "drinking" && !cat.lifted
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { target: cat; property: "sipDip"; to: 13; duration: 700; easing.type: Easing.InOutQuad }
PauseAnimation { duration: 900 }
NumberAnimation { target: cat; property: "sipDip"; to: 0; duration: 600; easing.type: Easing.InOutQuad }
PauseAnimation { duration: 1400 }
}
states: [
State {
name: "running"
when: cat.running && !cat.lifted
PropertyChanges { body.rotation: -5; head.y: 15; head.x: 48 }
PropertyChanges { cat.tailC1X: 8; cat.tailC1Y: 60; cat.tailC2X: 4; cat.tailC2Y: 56; cat.tailX: 0; cat.tailY: 52 }
},
State {
name: "sitting"
when: (cat.mood === "sitting" || cat.mood === "loving" || cat.mood === "alert") && !cat.lifted
PropertyChanges { body.x: 34; body.y: 40; body.width: 40; body.height: 40 }
PropertyChanges { head.y: 8; head.x: 40 }
PropertyChanges { cat.tailC1X: 26; cat.tailC1Y: 84; cat.tailC2X: 40; cat.tailC2Y: 88; cat.tailX: 52; cat.tailY: 80 }
},
State {
name: "grooming"
when: cat.mood === "grooming" && !cat.lifted
PropertyChanges { body.x: 34; body.y: 40; body.width: 40; body.height: 40 }
PropertyChanges { head.y: 10; head.x: 38; head.rotation: -10 }
PropertyChanges { cat.tailC1X: 26; cat.tailC1Y: 84; cat.tailC2X: 40; cat.tailC2Y: 88; cat.tailX: 52; cat.tailY: 80 }
},
State {
name: "stretching"
when: cat.mood === "stretching" && !cat.lifted
PropertyChanges { body.x: 16; body.y: 50; body.width: 64; body.height: 26; body.rotation: -8 }
PropertyChanges { head.y: 32; head.x: 52; head.rotation: 7 }
PropertyChanges { cat.tailC1X: 10; cat.tailC1Y: 48; cat.tailC2X: 6; cat.tailC2Y: 30; cat.tailX: 14; cat.tailY: 22 }
},
State {
name: "sleeping"
when: cat.sleeping && !cat.lifted
PropertyChanges { body.x: 22; body.y: 54; body.width: 56; body.height: 26 }
PropertyChanges { head.y: 24; head.x: 44; head.rotation: 4 }
PropertyChanges { cat.tailC1X: 24; cat.tailC1Y: 86; cat.tailC2X: 60; cat.tailC2Y: 90; cat.tailX: 76; cat.tailY: 78 }
},
State {
name: "drinking"
when: cat.mood === "drinking" && !cat.lifted
PropertyChanges { body.x: 32; body.y: 42; body.width: 42; body.height: 38 }
PropertyChanges { head.x: 50; head.y: 20 + cat.sipDip; head.rotation: 16 }
PropertyChanges { cat.tailC1X: 26; cat.tailC1Y: 84; cat.tailC2X: 40; cat.tailC2Y: 88; cat.tailX: 52; cat.tailY: 80 }
},
State {
name: "batting"
when: cat.mood === "batting" && !cat.lifted
PropertyChanges { body.rotation: -10; body.x: 26 }
PropertyChanges { head.y: 10; head.x: 50 }
},
State {
name: "lifted"
when: cat.lifted
PropertyChanges { torso.rotation: 8; torso.y: -6 }
PropertyChanges { body.x: 28; body.y: 40; body.width: 42; body.height: 40 }
PropertyChanges { head.y: 8; head.x: 40 }
PropertyChanges { cat.tailC1X: 22; cat.tailC1Y: 74; cat.tailC2X: 18; cat.tailC2Y: 86; cat.tailX: 24; cat.tailY: 92 }
PropertyChanges { shadow.opacity: 0.06; shadow.scale: 0.7 }
}
]
transitions: Transition {
NumberAnimation { target: torso; properties: "rotation,y"; duration: 260; easing.type: Easing.OutBack }
NumberAnimation { target: shadow; properties: "opacity,scale"; duration: 260 }
}
readonly property bool seated: mood === "sitting" || mood === "loving" || mood === "alert"
|| mood === "grooming" || mood === "drinking"
}