Rename to TNCat, add chip chase, G-codes and a Wayland layout

The project is now TNCat, after HEIDENHAIN's TNC controls. Binary, QML module
URI, settings path, bundle id and .desktop entry follow; the environment
switches are TNCAT_*.

Entertainment: a curled steel chip skitters in every minute or two, the cat
runs it down and bats it away a few times before sitting back looking pleased.
Mood changes flash the matching machine code above its head, G00 for a sprint,
G01 for a stroll, M08 while it washes, G28 for a stretch, M30 for a nap.

Window handling was split into two layouts. X11 and macOS keep one small window
per object, positioned by the app. Wayland gets a screen-filling overlay whose
input region is masked down to the cat, since a Wayland client cannot place its
own window. Overlay mode is automatic there and available elsewhere via
TNCAT_OVERLAY=1.

Also adds a ground-line setting so the cat can walk above a softkey row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JulianandClaude Opus 5 committed 2026-09-10 22:53:40 +02:00
1 parent acef398f49
commit 0f1df75a1d
19 files changed
+817 -185

No files matched your search

+69 -6
View File
@@ -13,6 +13,16 @@ Item {
property bool lifted: false
property bool petted: false
property point look: Qt.point(0, 0)
property bool showCodes: true
// 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"
: ""
readonly property bool dark: coat.hslLightness < 0.45
readonly property color coatShade: Qt.darker(coat, 1.12)
@@ -23,7 +33,7 @@ Item {
readonly property color blush: "#f77d95"
readonly property bool walking: mood === "walking"
readonly property bool running: mood === "running"
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" || petted
@@ -299,8 +309,8 @@ Item {
id: eyeBall
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
width: eye.width * (cat.mood === "alert" ? 1.15 : 1)
height: eye.height * eye.open * (cat.mood === "alert" ? 1.15 : 1)
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
@@ -449,14 +459,17 @@ Item {
// speech bubble: "!" / "z" / "♥"
Text {
id: bubble
readonly property string glyph: cat.mood === "alert" ? "!" : cat.sleeping ? "z" : (cat.mood === "loving" || cat.petted) ? "♥" : ""
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" ? 22 : 16
font.pixelSize: cat.mood === "alert" || cat.mood === "batting" ? 22 : 16
font.bold: true
font.family: "Helvetica Neue"
color: cat.mood === "alert" ? "#f2a33c" : (glyph === "♥" ? "#f25f7a" : cat.ink)
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
@@ -474,6 +487,50 @@ Item {
}
}
// 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()
states: [
State {
name: "running"
@@ -509,6 +566,12 @@ Item {
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: "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
+13
View File
@@ -0,0 +1,13 @@
pragma Singleton
import QtQuick
import TNCat
// Maps CatController::Mood onto the strings Cat.qml understands.
QtObject {
readonly property var names: ["walking", "running", "sitting", "grooming", "stretching",
"sleeping", "loving", "alert", "chasing", "batting"]
function name(mood) {
return names[mood] !== undefined ? names[mood] : "sitting"
}
}
+79
View File
@@ -0,0 +1,79 @@
pragma ComponentBehavior: Bound
import QtQuick
import TNCat
// One small window that is exactly the cat. Used wherever a client may place
// its own window, which is everything except Wayland.
Window {
id: win
required property CatController cat
required property AppSettings settings
signal settingsRequested()
width: settings.catSize
height: Math.round(settings.catSize * 0.9)
x: cat.x
y: Screen.desktopAvailableHeight - height - settings.bottomMargin
color: Platform.opaqueBackground ? settings.coat : "transparent"
flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool
| Qt.NoDropShadowWindowHint | Qt.WindowDoesNotAcceptFocus
| (Platform.bypassWindowManager ? Qt.X11BypassWindowManagerHint : 0)
visible: true
title: qsTr("TNCat")
Component.onCompleted: {
cat.stageWidth = Screen.desktopAvailableWidth
cat.catWidth = width
}
onWidthChanged: cat.catWidth = width
Screen.onDesktopAvailableWidthChanged: cat.stageWidth = Screen.desktopAvailableWidth
// The eyes need to know where the face sits on the desktop.
Binding {
target: win.cat
property: "catCenter"
value: Qt.point(win.x + win.width / 2, win.y + win.height / 2)
}
Cat {
anchors.fill: parent
coat: win.settings.coat
facingRight: win.cat.facingRight
mood: CatMoods.name(win.cat.mood)
lifted: win.cat.dragging
petted: win.cat.hovered && !win.cat.dragging
look: win.cat.look
showCodes: win.settings.showCodes
Accessible.role: Accessible.Button
Accessible.name: qsTr("TNCat")
}
HoverHandler {
onHoveredChanged: win.cat.hovered = hovered
}
DragHandler {
target: null
onActiveChanged: win.cat.dragging = active
onCentroidChanged: {
if (active)
win.cat.x = Math.max(0, Math.min(win.cat.stageWidth - win.width,
win.x + centroid.position.x - centroid.pressPosition.x))
}
}
TapHandler {
acceptedButtons: Qt.LeftButton
onTapped: win.cat.pet()
onDoubleTapped: win.settingsRequested()
onLongPressed: win.settingsRequested()
}
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: win.settingsRequested()
}
}
+70
View File
@@ -0,0 +1,70 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Shapes
// A curled steel chip, the kind that comes off a milling cutter.
// Design space is 60 x 44 units.
Item {
id: chip
property int kick: 0
readonly property real unit: width / 60
implicitWidth: 60
implicitHeight: 44
Item {
width: 60
height: 44
transformOrigin: Item.TopLeft
scale: chip.unit
Rectangle { // ground shadow
x: 14; y: 36
width: 32; height: 6
radius: 3
color: "#000000"
opacity: 0.18
antialiasing: true
}
Item {
id: curl
x: 30; y: 22
width: 0; height: 0
Shape {
x: -30; y: -22
width: 60; height: 44
preferredRendererType: Shape.CurveRenderer
ShapePath { // the curl
strokeColor: "#8d99ae"
strokeWidth: 5
capStyle: ShapePath.RoundCap
fillColor: "transparent"
startX: 12; startY: 32
PathCubic { control1X: 2; control1Y: 20; control2X: 14; control2Y: 6; x: 30; y: 10 }
PathCubic { control1X: 44; control1Y: 14; control2X: 48; control2Y: 30; x: 38; y: 34 }
PathCubic { control1X: 30; control1Y: 37; control2X: 26; control2Y: 28; x: 32; y: 24 }
}
ShapePath { // highlight along the outer edge
strokeColor: "#dfe6ee"
strokeWidth: 1.6
capStyle: ShapePath.RoundCap
fillColor: "transparent"
startX: 13; startY: 29
PathCubic { control1X: 5; control1Y: 19; control2X: 16; control2Y: 8; x: 30; y: 12 }
}
}
RotationAnimation on rotation {
running: true
loops: Animation.Infinite
from: 0; to: 360
duration: 2600
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
pragma ComponentBehavior: Bound
import QtQuick
import TNCat
// The chip gets its own little click-through window so it can skitter away
// from the cat across the whole screen.
Window {
id: win
required property CatController cat
required property AppSettings settings
readonly property int chipSize: Math.round(settings.catSize * 0.45)
width: chipSize
height: Math.round(chipSize * 0.73)
x: cat.chipX - width / 2
y: Screen.desktopAvailableHeight - height - settings.bottomMargin
color: "transparent"
flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool
| Qt.NoDropShadowWindowHint | Qt.WindowDoesNotAcceptFocus
| Qt.WindowTransparentForInput
| (Platform.bypassWindowManager ? Qt.X11BypassWindowManagerHint : 0)
visible: cat.chipVisible && !Platform.opaqueBackground
Behavior on x {
NumberAnimation { duration: 320; easing.type: Easing.OutQuad }
}
Chip {
anchors.fill: parent
kick: win.cat.chipKick
}
}
+24 -69
View File
@@ -1,87 +1,42 @@
pragma ComponentBehavior: Bound
import QtQuick
import Workcat
import TNCat
Window {
id: root
// Root is not a window: which windows exist depends on the platform.
QtObject {
id: app
required property CatController cat
required property AppSettings settings
readonly property var moodNames: ["walking", "running", "sitting", "grooming", "stretching", "sleeping", "loving", "alert"]
readonly property real floorY: Screen.desktopAvailableHeight - height
function openSettings() {
settingsLoader.active = true
if (settingsLoader.status === Loader.Ready)
(settingsLoader.item as SettingsWindow).show()
settingsWindow.show()
settingsWindow.raise()
settingsWindow.requestActivate()
}
width: settings.catSize
height: Math.round(settings.catSize * 0.9)
x: cat.x
y: floorY
color: Platform.opaqueBackground ? settings.coat : "transparent"
flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool
| Qt.NoDropShadowWindowHint | Qt.WindowDoesNotAcceptFocus
| (Platform.bypassWindowManager ? Qt.X11BypassWindowManagerHint : 0)
visible: true
title: qsTr("Workcat")
Component.onCompleted: {
cat.screenWidth = Screen.desktopAvailableWidth
cat.catWidth = width
}
onWidthChanged: cat.catWidth = width
Screen.onDesktopAvailableWidthChanged: cat.screenWidth = Screen.desktopAvailableWidth
Cat {
id: sprite
anchors.fill: parent
coat: root.settings.coat
facingRight: root.cat.facingRight
mood: root.moodNames[root.cat.mood]
lifted: root.cat.dragging
petted: root.cat.hovered && !root.cat.dragging
look: root.cat.look
Accessible.role: Accessible.Button
Accessible.name: qsTr("Workcat")
readonly property CatWindow catWindow: CatWindow {
cat: app.cat
settings: app.settings
visible: !Platform.overlayMode
onSettingsRequested: app.openSettings()
}
HoverHandler {
onHoveredChanged: root.cat.hovered = hovered
readonly property ChipWindow chipWindow: ChipWindow {
cat: app.cat
settings: app.settings
visible: !Platform.overlayMode && app.cat.chipVisible
}
DragHandler {
target: null
onActiveChanged: root.cat.dragging = active
onCentroidChanged: {
if (active)
root.cat.x = Math.max(0, Math.min(root.cat.screenWidth - root.width,
root.x + centroid.position.x - centroid.pressPosition.x))
}
readonly property OverlayWindow overlayWindow: OverlayWindow {
cat: app.cat
settings: app.settings
active: Platform.overlayMode
onSettingsRequested: app.openSettings()
}
TapHandler {
acceptedButtons: Qt.LeftButton
onTapped: root.cat.pet()
onDoubleTapped: root.openSettings()
}
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: root.openSettings()
}
Loader {
id: settingsLoader
active: false
sourceComponent: SettingsWindow {
cat: root.cat
settings: root.settings
onClosing: settingsLoader.active = false
}
onLoaded: (item as SettingsWindow).show()
readonly property SettingsWindow settingsWindow: SettingsWindow {
cat: app.cat
settings: app.settings
}
}
+119
View File
@@ -0,0 +1,119 @@
pragma ComponentBehavior: Bound
import QtQuick
import TNCat
// One screen-filling, click-through window that holds the cat and the chip.
// Wayland does not let a client place its own window, so there the cat moves
// inside this overlay instead of the window moving across the desktop.
Window {
id: win
required property CatController cat
required property AppSettings settings
signal settingsRequested()
// Fullscreen is a visibility, not a visible flag, so the mode switch has to
// go through visibility or the two fight each other.
property bool active: false
readonly property int catW: settings.catSize
readonly property int catH: Math.round(settings.catSize * 0.9)
readonly property real groundY: height - catH - settings.bottomMargin
// Only the cat itself should swallow clicks; everything else falls through.
function refreshInputRegion() {
Platform.setInputRegion(win, Qt.rect(Math.round(cat.x), Math.round(groundY), catW, catH))
}
visibility: active ? Window.FullScreen : Window.Hidden
color: "transparent"
flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool
| Qt.NoDropShadowWindowHint | Qt.WindowDoesNotAcceptFocus
title: qsTr("TNCat")
onActiveChanged: {
if (!active)
return
cat.stageWidth = width
cat.catWidth = catW
refreshInputRegion()
}
onWidthChanged: cat.stageWidth = width
onCatWChanged: cat.catWidth = catW
Binding {
target: win.cat
property: "catCenter"
value: Qt.point(win.x + win.cat.x + win.catW / 2, win.y + win.groundY + win.catH / 2)
}
// Coalesce mask updates: one X11 round trip per frame of walking is plenty.
Timer {
interval: 33
running: win.active
repeat: true
onTriggered: win.refreshInputRegion()
}
Chip {
x: win.cat.chipX - width / 2
y: win.height - height - win.settings.bottomMargin
width: Math.round(win.settings.catSize * 0.45)
height: Math.round(width * 0.73)
visible: win.cat.chipVisible
kick: win.cat.chipKick
Behavior on x {
NumberAnimation { duration: 320; easing.type: Easing.OutQuad }
}
}
Item {
id: catItem
x: win.cat.x
y: win.groundY
width: win.catW
height: win.catH
Cat {
anchors.fill: parent
coat: win.settings.coat
facingRight: win.cat.facingRight
mood: CatMoods.name(win.cat.mood)
lifted: win.cat.dragging
petted: win.cat.hovered && !win.cat.dragging
look: win.cat.look
showCodes: win.settings.showCodes
Accessible.role: Accessible.Button
Accessible.name: qsTr("TNCat")
}
HoverHandler {
onHoveredChanged: win.cat.hovered = hovered
}
DragHandler {
target: null
onActiveChanged: win.cat.dragging = active
onCentroidChanged: {
if (active)
win.cat.x = Math.max(0, Math.min(win.cat.stageWidth - win.catW,
catItem.x + centroid.position.x - centroid.pressPosition.x))
}
}
TapHandler {
acceptedButtons: Qt.LeftButton
onTapped: win.cat.pet()
onDoubleTapped: win.settingsRequested()
onLongPressed: win.settingsRequested()
}
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: win.settingsRequested()
}
}
}
+45 -10
View File
@@ -2,7 +2,7 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
import Workcat
import TNCat
Window {
id: win
@@ -20,10 +20,10 @@ Window {
]
width: 440
height: 600
height: 700
minimumWidth: 380
minimumHeight: 520
title: qsTr("Workcat")
minimumHeight: 600
title: qsTr("TNCat")
color: "#faf7f2"
flags: Qt.Dialog | Qt.WindowStaysOnTopHint
@@ -35,20 +35,24 @@ Window {
RowLayout {
Layout.fillWidth: true
spacing: 16
Cat {
Loader {
Layout.preferredWidth: 110
Layout.preferredHeight: 99
coat: win.settings.coat
mood: "sitting"
petted: previewHover.hovered
HoverHandler { id: previewHover }
active: win.visible
sourceComponent: Cat {
coat: win.settings.coat
mood: "sitting"
showCodes: false
petted: previewHover.hovered
HoverHandler { id: previewHover }
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Label {
Layout.fillWidth: true
text: qsTr("Workcat")
text: qsTr("TNCat")
font.pixelSize: 26
font.bold: true
color: "#2f2a36"
@@ -122,6 +126,22 @@ Window {
onMoved: win.settings.walkSpeed = value
}
Label { text: qsTr("Ground line") }
RowLayout {
Layout.fillWidth: true
Slider {
id: marginSlider
Layout.fillWidth: true
from: 0; to: 600; stepSize: 10
value: win.settings.bottomMargin
onMoved: win.settings.bottomMargin = value
}
Label {
Layout.preferredWidth: 54
text: qsTr("%1 px").arg(Math.round(marginSlider.value))
}
}
Label { text: qsTr("Eyes") }
Switch {
text: qsTr("Follow the mouse")
@@ -129,6 +149,20 @@ Window {
onToggled: win.settings.followCursor = checked
}
Label { text: qsTr("Codes") }
Switch {
text: qsTr("Show G-code above the cat")
checked: win.settings.showCodes
onToggled: win.settings.showCodes = checked
}
Label { text: qsTr("Chips") }
Switch {
text: qsTr("Let a chip skitter past now and then")
checked: win.settings.chipsEnabled
onToggled: win.settings.chipsEnabled = checked
}
Label { text: qsTr("Cat") }
Switch {
text: checked ? qsTr("Napping") : qsTr("Awake")
@@ -148,6 +182,7 @@ Window {
Button { text: qsTr("Stretch"); onClicked: win.cat.doTrick(CatController.Stretching) }
Button { text: qsTr("Dash"); onClicked: win.cat.doTrick(CatController.Running) }
Button { text: qsTr("Nap"); onClicked: win.cat.doTrick(CatController.Sleeping) }
Button { text: qsTr("Toss a chip"); onClicked: win.cat.tossChip() }
}
}