Files
TNCcat/qml/Cat.qml
T
JulianandClaude Opus 5 6b9553f6d4 Stop clipping the cat at the window edge
The cat was drawn in a 100 x 90 box that was exactly the window, so anything
reaching past it was cut off: the right whiskers always, and more of them in
poses that push the head forward, up to art x 109 while stretching.

The window box is now 120 x 106 with the drawing inset at (7, 14). The slack is
symmetric on purpose, because facing left mirrors the art about the box centre,
so asymmetric slack would clip the other side instead.

The mood-name mapper grew the shared aspect ratio and became CatDefs, so every
window sizes the cat the same way. Nose and mug offsets follow the new inset.

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

624 lines
27 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
// The cat is drawn in a 100 x 90 box. The window box is larger so whiskers,
// tail and the glyphs above the head stay inside it in every pose. The slack
// has to be symmetric: facing left mirrors the art about the box centre.
// Widest reach is the right whiskers while stretching, art x 109.
readonly property real designWidth: 120
readonly property real designHeight: 106
readonly property real artX: 7
readonly property real artY: 14
readonly property real unit: width / designWidth
// 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: designWidth
implicitHeight: designHeight
transform: Scale {
origin.x: cat.width / 2
xScale: cat.facingRight ? 1 : -1
}
Item {
id: art
x: cat.artX * cat.unit
y: cat.artY * cat.unit
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 }
}
}
// Machine code tag, flashed in the headroom whenever the mood changes
Item {
id: codeTag
x: 16; y: -12
width: codeBg.width
height: codeBg.height
opacity: 0
visible: cat.showCodes && opacity > 0.01
transform: Scale { xScale: cat.facingRight ? 1 : -1; origin.x: codeTag.width / 2 }
z: 10
Accessible.ignored: true
Rectangle {
id: codeBg
width: codeText.implicitWidth + 8
height: codeText.implicitHeight + 4
radius: 3
color: "#242833"
opacity: 0.92
antialiasing: true
Text {
id: codeText
anchors.centerIn: parent
text: cat.moodCode
color: "#8fd6ff"
font.pixelSize: 10
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 }
}
}
// 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 }
}
}
}
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"
}