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
+813 -181

No files matched your search

+22 -20
View File
@@ -1,17 +1,17 @@
cmake_minimum_required(VERSION 3.21) cmake_minimum_required(VERSION 3.21)
project(Workcat project(TNCat
VERSION 0.2.0 VERSION 0.3.0
DESCRIPTION "A cute desktop cat that wanders along the bottom of your screen" DESCRIPTION "A cute desktop cat that wanders along the bottom of your screen"
LANGUAGES CXX) LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Quick QuickControls2 Widgets) find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Quick QuickControls2 Widgets)
qt_standard_project_setup(REQUIRES 6.8) qt_standard_project_setup(REQUIRES 6.5)
qt_add_executable(workcat qt_add_executable(tncat
src/main.cpp src/main.cpp
src/catcontroller.h src/catcontroller.cpp src/catcontroller.h src/catcontroller.cpp
src/traycontroller.h src/traycontroller.cpp src/traycontroller.h src/traycontroller.cpp
@@ -19,44 +19,46 @@ qt_add_executable(workcat
src/platforminfo.h src/platforminfo.cpp src/platforminfo.h src/platforminfo.cpp
) )
target_include_directories(workcat PRIVATE src) target_include_directories(tncat PRIVATE src)
# QML files live in qml/ but register at the module root (Workcat.Main etc.) # QML files live in qml/ but register at the module root (TNCat.Main etc.)
set(WORKCAT_QML Main Cat SettingsWindow) set(TNCAT_QML Main Cat Chip CatWindow ChipWindow OverlayWindow SettingsWindow CatMoods)
foreach(qmlfile IN LISTS WORKCAT_QML) foreach(qmlfile IN LISTS TNCAT_QML)
set_source_files_properties(qml/${qmlfile}.qml PROPERTIES QT_RESOURCE_ALIAS ${qmlfile}.qml) set_source_files_properties(qml/${qmlfile}.qml PROPERTIES QT_RESOURCE_ALIAS ${qmlfile}.qml)
list(APPEND WORKCAT_QML_FILES qml/${qmlfile}.qml) list(APPEND TNCAT_QML_FILES qml/${qmlfile}.qml)
endforeach() endforeach()
qt_add_qml_module(workcat set_source_files_properties(qml/CatMoods.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
URI Workcat
qt_add_qml_module(tncat
URI TNCat
VERSION 1.0 VERSION 1.0
QML_FILES ${WORKCAT_QML_FILES} QML_FILES ${TNCAT_QML_FILES}
) )
target_link_libraries(workcat PRIVATE target_link_libraries(tncat PRIVATE
Qt6::Core Qt6::Gui Qt6::Quick Qt6::QuickControls2 Qt6::Widgets) Qt6::Core Qt6::Gui Qt6::Quick Qt6::QuickControls2 Qt6::Widgets)
set_target_properties(workcat PROPERTIES set_target_properties(tncat PROPERTIES
MACOSX_BUNDLE TRUE MACOSX_BUNDLE TRUE
MACOSX_BUNDLE_INFO_PLIST ${CMAKE_SOURCE_DIR}/Info.plist.in MACOSX_BUNDLE_INFO_PLIST ${CMAKE_SOURCE_DIR}/Info.plist.in
MACOSX_BUNDLE_GUI_IDENTIFIER app.workcat.example MACOSX_BUNDLE_GUI_IDENTIFIER app.tncat.example
MACOSX_BUNDLE_BUNDLE_NAME Workcat MACOSX_BUNDLE_BUNDLE_NAME TNCat
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION} MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR} MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}
WIN32_EXECUTABLE TRUE) WIN32_EXECUTABLE TRUE)
include(GNUInstallDirs) include(GNUInstallDirs)
install(TARGETS workcat install(TARGETS tncat
BUNDLE DESTINATION . BUNDLE DESTINATION .
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
if(UNIX AND NOT APPLE) if(UNIX AND NOT APPLE)
install(FILES linux/workcat.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) install(FILES linux/tncat.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
endif() endif()
qt_generate_deploy_qml_app_script( qt_generate_deploy_qml_app_script(
TARGET workcat TARGET tncat
OUTPUT_SCRIPT deploy_script OUTPUT_SCRIPT deploy_script
MACOS_BUNDLE_POST_BUILD MACOS_BUNDLE_POST_BUILD
NO_UNSUPPORTED_PLATFORM_ERROR NO_UNSUPPORTED_PLATFORM_ERROR
+79 -31
View File
@@ -1,29 +1,41 @@
# Workcat (Qt Quick / CMake example) # TNCat
A little cat that wanders along the bottom edge of your screen, sits down, A little cat that wanders along the bottom edge of the screen, sits down, grooms
grooms itself, stretches, dashes off and takes naps. Drag it around, click to itself, stretches, dashes off and takes naps. Every now and then a curled steel
pet it, and it purrs. It does nothing else — no window closing, no monitoring. chip skitters past and the cat goes after it. Drag it around, tap it to pet it,
and it purrs.
Inspired by [workcat.app](https://workcat.app/en/), rebuilt as a plain Qt 6 / It announces what it is doing in the machine's own language: `G00` when it
QML project. Primary target is **Linux / X11**; development happens on macOS and sprints, `G01` when it strolls, `M08` while it washes, `G28` when it stretches
both are supported by the same code. back to reference, `M30` when it goes to sleep.
The name is `TNC` plus `cat`, because the intended home is a HEIDENHAIN control
(TNC7, TNC 640). Primary target is therefore **Linux / X11**; development happens
on macOS and both are supported by the same code.
Inspired by [workcat.app](https://workcat.app/en/), rebuilt from scratch as a
plain Qt 6 / QML project.
## Layout ## Layout
| Path | Purpose | | Path | Purpose |
|---|---| |---|---|
| `CMakeLists.txt` | `qt_add_executable` + `qt_add_qml_module` (URI `Workcat`), macOS bundle, `.desktop` file on Linux | | `CMakeLists.txt` | `qt_add_executable` + `qt_add_qml_module` (URI `TNCat`), macOS bundle, `.desktop` file on Linux |
| `src/main.cpp` | Wires `AppSettings` and `CatController` into the QML engine, warns on Wayland | | `src/main.cpp` | Wires `AppSettings` and `CatController` into the QML engine, warns on Wayland |
| `src/catcontroller.*` | Mood machine (walk, run, sit, groom, stretch, sleep, love, alert), movement tick, cursor tracking | | `src/catcontroller.*` | Mood machine (walk, run, sit, groom, stretch, sleep, love, alert, chase, bat), movement tick, chip chase, cursor tracking |
| `src/appsettings.*` | `QSettings`-backed coat, size, pace, eye tracking, pet counter | | `src/appsettings.*` | `QSettings`-backed coat, size, pace, ground line, eye tracking, pet counter |
| `src/platforminfo.*` | QML singleton `Platform`: platform name plus the two X11 escape hatches | | `src/platforminfo.*` | QML singleton `Platform`: platform name plus the X11 escape hatches |
| `src/traycontroller.*` | Tray icon and menu, silently skipped when the desktop has no tray | | `src/traycontroller.*` | Tray icon and menu, silently skipped when the desktop has no tray |
| `qml/Main.qml` | Frameless, transparent, always-on-top window that *is* the cat | | `qml/Main.qml` | Non-visual root that picks the window layout for the platform |
| `qml/CatWindow.qml`, `qml/ChipWindow.qml` | One small window each, positioned by the app. X11, macOS, Windows |
| `qml/OverlayWindow.qml` | One screen-filling click-through window holding both. Wayland |
| `qml/Cat.qml` | The drawing: rounded primitives plus `Shape` paths for tail, ears and mouth | | `qml/Cat.qml` | The drawing: rounded primitives plus `Shape` paths for tail, ears and mouth |
| `qml/SettingsWindow.qml` | Coat swatches, size, pace, eye tracking, trick buttons | | `qml/Chip.qml` | The curled chip, a stroked `Shape` spiral |
| `qml/SettingsWindow.qml` | Coat swatches, size, pace, ground line, eye tracking, codes, chips, tricks |
The cat is drawn in a fixed 100 x 90 design space and scaled by `width / 100`, The cat is drawn in a fixed 100 x 90 design space and scaled by `width / 100`,
so every size setting stays crisp. There are no image assets. so every size setting stays crisp. There are no image assets, which keeps the
whole thing to one binary plus Qt.
## Build ## Build
@@ -43,34 +55,70 @@ cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build cmake --build build
``` ```
Needs Qt 6.8 or newer for `Shape.CurveRenderer`. Required modules: Core, Gui, Needs Qt 6.5 or newer. Required modules: Core, Gui, Quick, QuickControls2,
Quick, QuickControls2, Widgets. Widgets is only there because `QSystemTrayIcon` Widgets. Widgets is only there because `QSystemTrayIcon` needs it for its
needs it for its context menu. context menu.
## Running on X11 ## Environment switches
The cat is a frameless, transparent, always-on-top `Qt::Tool` window that places
itself. Two environment variables cover the cases where a window manager
disagrees:
| Variable | Effect | | Variable | Effect |
|---|---| |---|---|
| `WORKCAT_X11_BYPASS=1` | Adds `Qt::X11BypassWindowManagerHint`, making an override-redirect window. Use when the WM refuses to keep the cat on top or shows it in the taskbar. | | `TNCAT_X11_BYPASS=1` | Adds `Qt::X11BypassWindowManagerHint`, making an override-redirect window. Use when the window manager refuses to keep the cat on top or shows it in the taskbar. |
| `WORKCAT_OPAQUE=1` | Paints a solid background instead of relying on transparency. Use when no compositor runs and the cat sits in a black box. | | `TNCAT_OVERLAY=1` | Forces the screen-filling overlay layout that Wayland needs, for testing it on X11. |
| `WORKCAT_POSE=Sitting` | Freezes the cat in one mood. Handy for screenshots. Accepts any `CatController::Mood` name. | | `TNCAT_OPAQUE=1` | Paints a solid background instead of relying on transparency. Use when no compositor runs and the cat sits in a black box. |
| `TNCAT_POSE=Sitting` | Freezes the cat in one mood. Handy for screenshots. Accepts any `CatController::Mood` name. |
| `QT_QUICK_BACKEND=software` | Qt Quick's software rasteriser, for machines without a usable OpenGL driver. `Shape` falls back to its geometry renderer on its own. |
Wayland cannot let a client position its own window, so the cat would stay ## Two window layouts
wherever the compositor puts it. The app prints a warning and suggests
`QT_QPA_PLATFORM=xcb`.
Desktops without a status-notifier host get no tray icon. The cat itself stays On X11, macOS and Windows the cat and the chip each get their own small,
the entry point: right-click or double-click opens the settings window. frameless, always-on-top window, and the app moves those windows around. Nothing
else on screen is covered and no clicks are intercepted.
Wayland does not let a client place its own window, so that layout cannot work
there. In overlay mode the app instead opens one screen-filling transparent
window, moves the cat inside it, and restricts the window's input region to the
cat's rectangle with `QWindow::setMask`, so every click outside the cat lands on
whatever is behind. The mask is refreshed about 30 times a second while the cat
walks.
Overlay mode switches on automatically on Wayland. `TNCAT_OVERLAY=1` turns it on
anywhere for testing. It is not the default elsewhere because macOS ignores the
input mask, which would make the overlay swallow every click on the desktop.
## Running on a HEIDENHAIN control
Untested on real hardware so far. What the code already does for that target:
- **X11 today, Wayland later.** Both layouts are in the build; the platform picks
one at startup.
- **No tray required.** If the desktop has no status-notifier host, the tray icon
is skipped and the cat itself stays the entry point: long-press or right-click
opens the settings window.
- **Touch works without hover.** Tap pets the cat, drag picks it up. The blush on
hover is a mouse-only extra, not a requirement.
- **Ground line setting.** The cat normally stands on the bottom of the available
screen area. On a control whose lower edge carries softkeys, raise the ground
line in the settings so the cat walks above them.
What still needs checking on the actual machine:
- **Qt version and libraries on HEROS.** Build against what the control has, or
ship a self-contained bundle with the Qt libraries next to the binary. Building
on a newer distribution than the control runs will fail at load time on glibc.
- **Graphics stack.** If Qt Quick cannot get an OpenGL context, start with
`QT_QUICK_BACKEND=software`. The drawing is simple enough for that path.
- **Window management.** The NC user interface runs fullscreen. Try the default
flags first, then `TNCAT_X11_BYPASS=1` if the cat disappears behind it.
- **Installing anything on a control is subject to your site's rules.** Keep the
cat away from anything the operator needs to read, and clear it with whoever
administers the machine before it runs on production hardware.
## Interaction ## Interaction
| Input | Result | | Input | Result |
|---|---| |---|---|
| Click | Pet the cat, it closes its eyes and purrs | | Click or tap | Pet the cat, it closes its eyes and purrs |
| Drag | Pick the cat up and put it somewhere else | | Drag | Pick the cat up and put it somewhere else |
| Hover | Blush and purr wiggle | | Hover | Blush and purr wiggle |
| Double-click or right-click | Settings window | | Double-click or right-click | Settings window |
@@ -1,8 +1,8 @@
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=Workcat Name=TNCat
Comment=A cute desktop cat Comment=A cute desktop cat
Exec=workcat Exec=tncat
Icon=workcat Icon=tncat
Categories=Utility;Amusement; Categories=Utility;Amusement;
StartupNotify=false StartupNotify=false
+69 -6
View File
@@ -13,6 +13,16 @@ Item {
property bool lifted: false property bool lifted: false
property bool petted: false property bool petted: false
property point look: Qt.point(0, 0) 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 bool dark: coat.hslLightness < 0.45
readonly property color coatShade: Qt.darker(coat, 1.12) readonly property color coatShade: Qt.darker(coat, 1.12)
@@ -23,7 +33,7 @@ Item {
readonly property color blush: "#f77d95" readonly property color blush: "#f77d95"
readonly property bool walking: mood === "walking" 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 moving: (walking || running) && !lifted
readonly property bool sleeping: mood === "sleeping" readonly property bool sleeping: mood === "sleeping"
readonly property bool happy: mood === "loving" || mood === "grooming" || mood === "stretching" || petted readonly property bool happy: mood === "loving" || mood === "grooming" || mood === "stretching" || petted
@@ -299,8 +309,8 @@ Item {
id: eyeBall id: eyeBall
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: eye.width * (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" ? 1.15 : 1) height: eye.height * eye.open * (cat.mood === "alert" || cat.mood === "batting" ? 1.15 : 1)
radius: width / 2 radius: width / 2
color: cat.eyeColor color: cat.eyeColor
visible: eye.open > 0.08 visible: eye.open > 0.08
@@ -449,14 +459,17 @@ Item {
// speech bubble: "!" / "z" / "♥" // speech bubble: "!" / "z" / "♥"
Text { Text {
id: bubble 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 x: 82; y: 4
text: glyph text: glyph
visible: glyph.length > 0 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.bold: true
font.family: "Helvetica Neue" 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 } transform: Scale { xScale: cat.facingRight ? 1 : -1; origin.x: bubble.width / 2 }
Accessible.ignored: true 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: [ states: [
State { State {
name: "running" name: "running"
@@ -509,6 +566,12 @@ Item {
PropertyChanges { head.y: 24; head.x: 44; head.rotation: 4 } 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 } 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 { State {
name: "lifted" name: "lifted"
when: cat.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 pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Workcat import TNCat
Window { // Root is not a window: which windows exist depends on the platform.
id: root QtObject {
id: app
required property CatController cat required property CatController cat
required property AppSettings settings 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() { function openSettings() {
settingsLoader.active = true settingsWindow.show()
if (settingsLoader.status === Loader.Ready) settingsWindow.raise()
(settingsLoader.item as SettingsWindow).show() settingsWindow.requestActivate()
} }
width: settings.catSize readonly property CatWindow catWindow: CatWindow {
height: Math.round(settings.catSize * 0.9) cat: app.cat
x: cat.x settings: app.settings
y: floorY visible: !Platform.overlayMode
color: Platform.opaqueBackground ? settings.coat : "transparent" onSettingsRequested: app.openSettings()
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")
} }
HoverHandler { readonly property ChipWindow chipWindow: ChipWindow {
onHoveredChanged: root.cat.hovered = hovered cat: app.cat
settings: app.settings
visible: !Platform.overlayMode && app.cat.chipVisible
} }
DragHandler { readonly property OverlayWindow overlayWindow: OverlayWindow {
target: null cat: app.cat
onActiveChanged: root.cat.dragging = active settings: app.settings
onCentroidChanged: { active: Platform.overlayMode
if (active) onSettingsRequested: app.openSettings()
root.cat.x = Math.max(0, Math.min(root.cat.screenWidth - root.width,
root.x + centroid.position.x - centroid.pressPosition.x))
}
} }
TapHandler { readonly property SettingsWindow settingsWindow: SettingsWindow {
acceptedButtons: Qt.LeftButton cat: app.cat
onTapped: root.cat.pet() settings: app.settings
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()
} }
} }
+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()
}
}
}
+41 -6
View File
@@ -2,7 +2,7 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Controls.Basic import QtQuick.Controls.Basic
import QtQuick.Layouts import QtQuick.Layouts
import Workcat import TNCat
Window { Window {
id: win id: win
@@ -20,10 +20,10 @@ Window {
] ]
width: 440 width: 440
height: 600 height: 700
minimumWidth: 380 minimumWidth: 380
minimumHeight: 520 minimumHeight: 600
title: qsTr("Workcat") title: qsTr("TNCat")
color: "#faf7f2" color: "#faf7f2"
flags: Qt.Dialog | Qt.WindowStaysOnTopHint flags: Qt.Dialog | Qt.WindowStaysOnTopHint
@@ -35,20 +35,24 @@ Window {
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 16 spacing: 16
Cat { Loader {
Layout.preferredWidth: 110 Layout.preferredWidth: 110
Layout.preferredHeight: 99 Layout.preferredHeight: 99
active: win.visible
sourceComponent: Cat {
coat: win.settings.coat coat: win.settings.coat
mood: "sitting" mood: "sitting"
showCodes: false
petted: previewHover.hovered petted: previewHover.hovered
HoverHandler { id: previewHover } HoverHandler { id: previewHover }
} }
}
ColumnLayout { ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 4 spacing: 4
Label { Label {
Layout.fillWidth: true Layout.fillWidth: true
text: qsTr("Workcat") text: qsTr("TNCat")
font.pixelSize: 26 font.pixelSize: 26
font.bold: true font.bold: true
color: "#2f2a36" color: "#2f2a36"
@@ -122,6 +126,22 @@ Window {
onMoved: win.settings.walkSpeed = value 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") } Label { text: qsTr("Eyes") }
Switch { Switch {
text: qsTr("Follow the mouse") text: qsTr("Follow the mouse")
@@ -129,6 +149,20 @@ Window {
onToggled: win.settings.followCursor = checked 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") } Label { text: qsTr("Cat") }
Switch { Switch {
text: checked ? qsTr("Napping") : qsTr("Awake") text: checked ? qsTr("Napping") : qsTr("Awake")
@@ -148,6 +182,7 @@ Window {
Button { text: qsTr("Stretch"); onClicked: win.cat.doTrick(CatController.Stretching) } Button { text: qsTr("Stretch"); onClicked: win.cat.doTrick(CatController.Stretching) }
Button { text: qsTr("Dash"); onClicked: win.cat.doTrick(CatController.Running) } Button { text: qsTr("Dash"); onClicked: win.cat.doTrick(CatController.Running) }
Button { text: qsTr("Nap"); onClicked: win.cat.doTrick(CatController.Sleeping) } Button { text: qsTr("Nap"); onClicked: win.cat.doTrick(CatController.Sleeping) }
Button { text: qsTr("Toss a chip"); onClicked: win.cat.tossChip() }
} }
} }
+40
View File
@@ -59,6 +59,46 @@ void AppSettings::setFollowCursor(bool follow)
emit followCursorChanged(); emit followCursorChanged();
} }
int AppSettings::bottomMargin() const
{
return m_settings.value("bottomMargin", 0).toInt();
}
void AppSettings::setBottomMargin(int pixels)
{
pixels = qBound(0, pixels, 600);
if (pixels == bottomMargin())
return;
m_settings.setValue("bottomMargin", pixels);
emit bottomMarginChanged();
}
bool AppSettings::showCodes() const
{
return m_settings.value("showCodes", true).toBool();
}
void AppSettings::setShowCodes(bool show)
{
if (show == showCodes())
return;
m_settings.setValue("showCodes", show);
emit showCodesChanged();
}
bool AppSettings::chipsEnabled() const
{
return m_settings.value("chipsEnabled", true).toBool();
}
void AppSettings::setChipsEnabled(bool enabled)
{
if (enabled == chipsEnabled())
return;
m_settings.setValue("chipsEnabled", enabled);
emit chipsEnabledChanged();
}
int AppSettings::petCount() const int AppSettings::petCount() const
{ {
return m_settings.value("petCount", 0).toInt(); return m_settings.value("petCount", 0).toInt();
+15
View File
@@ -15,6 +15,9 @@ class AppSettings : public QObject
Q_PROPERTY(int catSize READ catSize WRITE setCatSize NOTIFY catSizeChanged) Q_PROPERTY(int catSize READ catSize WRITE setCatSize NOTIFY catSizeChanged)
Q_PROPERTY(qreal walkSpeed READ walkSpeed WRITE setWalkSpeed NOTIFY walkSpeedChanged) Q_PROPERTY(qreal walkSpeed READ walkSpeed WRITE setWalkSpeed NOTIFY walkSpeedChanged)
Q_PROPERTY(bool followCursor READ followCursor WRITE setFollowCursor NOTIFY followCursorChanged) Q_PROPERTY(bool followCursor READ followCursor WRITE setFollowCursor NOTIFY followCursorChanged)
Q_PROPERTY(int bottomMargin READ bottomMargin WRITE setBottomMargin NOTIFY bottomMarginChanged)
Q_PROPERTY(bool showCodes READ showCodes WRITE setShowCodes NOTIFY showCodesChanged)
Q_PROPERTY(bool chipsEnabled READ chipsEnabled WRITE setChipsEnabled NOTIFY chipsEnabledChanged)
Q_PROPERTY(int petCount READ petCount NOTIFY petCountChanged) Q_PROPERTY(int petCount READ petCount NOTIFY petCountChanged)
public: public:
@@ -32,6 +35,15 @@ public:
bool followCursor() const; bool followCursor() const;
void setFollowCursor(bool follow); void setFollowCursor(bool follow);
int bottomMargin() const;
void setBottomMargin(int pixels);
bool showCodes() const;
void setShowCodes(bool show);
bool chipsEnabled() const;
void setChipsEnabled(bool enabled);
int petCount() const; int petCount() const;
void incrementPetCount(); void incrementPetCount();
@@ -40,6 +52,9 @@ signals:
void catSizeChanged(); void catSizeChanged();
void walkSpeedChanged(); void walkSpeedChanged();
void followCursorChanged(); void followCursorChanged();
void bottomMarginChanged();
void showCodesChanged();
void chipsEnabledChanged();
void petCountChanged(); void petCountChanged();
private: private:
+128 -18
View File
@@ -4,7 +4,12 @@
#include <QCursor> #include <QCursor>
#include <QMetaEnum> #include <QMetaEnum>
#include <QRandomGenerator> #include <QRandomGenerator>
#include <QWindow>
namespace {
constexpr qreal kChipReach = 26.0; // how close the nose has to get
constexpr int kChipMinDelayMs = 45000;
constexpr int kChipMaxDelayMs = 150000;
}
CatController::CatController(AppSettings *settings, QObject *parent) CatController::CatController(AppSettings *settings, QObject *parent)
: QObject(parent) : QObject(parent)
@@ -23,8 +28,11 @@ CatController::CatController(AppSettings *settings, QObject *parent)
connect(&m_cursorTimer, &QTimer::timeout, this, &CatController::trackCursor); connect(&m_cursorTimer, &QTimer::timeout, this, &CatController::trackCursor);
m_cursorTimer.start(); m_cursorTimer.start();
// WORKCAT_POSE=Sleeping freezes the cat in one mood (handy for screenshots). m_chipTimer.setSingleShot(true);
const QByteArray pose = qgetenv("WORKCAT_POSE"); connect(&m_chipTimer, &QTimer::timeout, this, &CatController::tossChip);
// TNCAT_POSE=Sleeping freezes the cat in one mood (handy for screenshots).
const QByteArray pose = qgetenv("TNCAT_POSE");
if (!pose.isEmpty()) { if (!pose.isEmpty()) {
const QMetaEnum me = QMetaEnum::fromType<Mood>(); const QMetaEnum me = QMetaEnum::fromType<Mood>();
bool ok = false; bool ok = false;
@@ -34,13 +42,15 @@ CatController::CatController(AppSettings *settings, QObject *parent)
setMood(static_cast<Mood>(value)); setMood(static_cast<Mood>(value));
} }
} }
if (!m_frozen) if (!m_frozen) {
pickNextMood(); pickNextMood();
scheduleChip();
}
} }
void CatController::setWindow(QWindow *window) qreal CatController::catNose() const
{ {
m_window = window; return m_facingRight ? m_x + m_catWidth * 0.78 : m_x + m_catWidth * 0.22;
} }
void CatController::setX(qreal x) void CatController::setX(qreal x)
@@ -51,12 +61,12 @@ void CatController::setX(qreal x)
emit xChanged(); emit xChanged();
} }
void CatController::setScreenWidth(qreal width) void CatController::setStageWidth(qreal width)
{ {
if (qFuzzyCompare(width, m_screenWidth)) if (qFuzzyCompare(width, m_stageWidth))
return; return;
m_screenWidth = width; m_stageWidth = width;
emit screenWidthChanged(); emit stageWidthChanged();
} }
void CatController::setCatWidth(qreal width) void CatController::setCatWidth(qreal width)
@@ -67,6 +77,14 @@ void CatController::setCatWidth(qreal width)
emit catWidthChanged(); emit catWidthChanged();
} }
void CatController::setCatCenter(const QPointF &center)
{
if (center == m_catCenter)
return;
m_catCenter = center;
emit catCenterChanged();
}
void CatController::setPaused(bool paused) void CatController::setPaused(bool paused)
{ {
if (paused == m_paused) if (paused == m_paused)
@@ -75,9 +93,12 @@ void CatController::setPaused(bool paused)
emit pausedChanged(); emit pausedChanged();
if (paused) { if (paused) {
m_moodTimer.stop(); m_moodTimer.stop();
m_chipTimer.stop();
endChase();
setMood(Mood::Sleeping); setMood(Mood::Sleeping);
} else { } else {
pickNextMood(); pickNextMood();
scheduleChip();
} }
} }
@@ -110,6 +131,7 @@ void CatController::pet()
if (m_paused || m_frozen) if (m_paused || m_frozen)
return; return;
m_settings->incrementPetCount(); m_settings->incrementPetCount();
endChase();
holdMood(Mood::Loving, 1800); holdMood(Mood::Loving, 1800);
} }
@@ -118,6 +140,7 @@ void CatController::doTrick(Mood mood)
if (m_paused) if (m_paused)
return; return;
m_frozen = false; m_frozen = false;
endChase();
holdMood(mood, mood == Mood::Sleeping ? 15000 : 3500); holdMood(mood, mood == Mood::Sleeping ? 15000 : 3500);
} }
@@ -127,17 +150,85 @@ void CatController::holdMood(Mood mood, int ms)
m_moodTimer.start(ms); m_moodTimer.start(ms);
} }
// A curled metal chip skitters in from one side and the cat goes after it.
void CatController::tossChip()
{
if (m_paused || m_frozen || m_chipVisible)
return;
if (!m_settings->chipsEnabled()) {
scheduleChip();
return;
}
auto *rng = QRandomGenerator::global();
const bool fromLeft = rng->bounded(2) == 0;
const qreal margin = qMin(200.0, m_stageWidth * 0.2);
setChipX(fromLeft ? margin : m_stageWidth - margin);
m_batsLeft = rng->bounded(2, 5);
m_chipVisible = true;
emit chipVisibleChanged();
m_moodTimer.stop();
setMood(Mood::Chasing);
}
void CatController::batChip()
{
auto *rng = QRandomGenerator::global();
++m_chipKick;
emit chipKickChanged();
const qreal distance = rng->bounded(140, 380);
qreal next = m_chipX + (m_facingRight ? distance : -distance);
const qreal edge = 40.0;
if (next < edge || next > m_stageWidth - edge) {
next = qBound(edge, next, m_stageWidth - edge);
--m_batsLeft; // a chip against the wall is nearly done for
}
setChipX(next);
if (--m_batsLeft <= 0) {
endChase();
holdMood(Mood::Sitting, 2500); // pleased with itself
scheduleChip();
} else {
holdMood(Mood::Batting, 420);
}
}
void CatController::endChase()
{
if (!m_chipVisible)
return;
m_chipVisible = false;
m_batsLeft = 0;
emit chipVisibleChanged();
}
void CatController::scheduleChip()
{
m_chipTimer.start(QRandomGenerator::global()->bounded(kChipMinDelayMs, kChipMaxDelayMs));
}
void CatController::setChipX(qreal x)
{
if (qFuzzyCompare(x, m_chipX))
return;
m_chipX = x;
emit chipXChanged();
}
void CatController::trackCursor() void CatController::trackCursor()
{ {
if (!m_window || !m_settings->followCursor()) { if (m_catCenter.isNull() || !m_settings->followCursor()) {
if (!m_look.isNull()) { if (!m_look.isNull()) {
m_look = {}; m_look = {};
emit lookChanged(); emit lookChanged();
} }
return; return;
} }
const QPointF center = m_window->geometry().center(); QPointF delta = QCursor::pos() - m_catCenter;
QPointF delta = QCursor::pos() - center;
if (!m_facingRight) if (!m_facingRight)
delta.setX(-delta.x()); delta.setX(-delta.x());
const qreal range = 400.0; const qreal range = 400.0;
@@ -154,14 +245,28 @@ void CatController::tick()
const qreal dt = qMin(now - m_lastTickMs, qint64(100)) / 1000.0; const qreal dt = qMin(now - m_lastTickMs, qint64(100)) / 1000.0;
m_lastTickMs = now; m_lastTickMs = now;
const bool moving = m_mood == Mood::Walking || m_mood == Mood::Running; if (m_dragging || m_paused || m_frozen)
if (!moving || m_dragging || m_paused || m_frozen) return;
const qreal maxX = qMax(0.0, m_stageWidth - m_catWidth);
if (m_mood == Mood::Chasing) {
const qreal delta = m_chipX - catNose();
setFacingRight(delta >= 0);
if (qAbs(delta) <= kChipReach) {
batChip();
return;
}
const qreal step = m_settings->walkSpeed() * 4.0 * dt;
setX(qBound(0.0, m_x + (delta > 0 ? step : -step), maxX));
return;
}
if (m_mood != Mood::Walking && m_mood != Mood::Running)
return; return;
const qreal speed = m_settings->walkSpeed() * (m_mood == Mood::Running ? 4.5 : 1.0); const qreal speed = m_settings->walkSpeed() * (m_mood == Mood::Running ? 4.5 : 1.0);
const qreal step = speed * dt; qreal next = m_x + (m_facingRight ? speed * dt : -speed * dt);
qreal next = m_x + (m_facingRight ? step : -step);
const qreal maxX = qMax(0.0, m_screenWidth - m_catWidth);
if (next >= maxX) { if (next >= maxX) {
next = maxX; next = maxX;
setFacingRight(false); setFacingRight(false);
@@ -196,6 +301,11 @@ void CatController::pickNextMood()
{ {
if (m_paused || m_dragging || m_frozen) if (m_paused || m_dragging || m_frozen)
return; return;
if (m_chipVisible) { // a chase is still on, keep after the chip
setMood(Mood::Chasing);
return;
}
auto *rng = QRandomGenerator::global(); auto *rng = QRandomGenerator::global();
const int roll = rng->bounded(100); const int roll = rng->bounded(100);
+38 -12
View File
@@ -7,10 +7,9 @@
#include <QPointF> #include <QPointF>
class AppSettings; class AppSettings;
class QWindow;
// Drives the cat: wanders along the bottom of the screen, occasionally dashes, // Drives the cat: wanders along its ground line, sits, grooms, stretches and
// sits, grooms, stretches or naps, and reacts to petting and dragging. // naps, chases the odd metal chip, and reacts to petting and dragging.
class CatController : public QObject class CatController : public QObject
{ {
Q_OBJECT Q_OBJECT
@@ -18,8 +17,9 @@ class CatController : public QObject
QML_UNCREATABLE("Provided by C++") QML_UNCREATABLE("Provided by C++")
Q_PROPERTY(qreal x READ x WRITE setX NOTIFY xChanged) Q_PROPERTY(qreal x READ x WRITE setX NOTIFY xChanged)
Q_PROPERTY(qreal screenWidth READ screenWidth WRITE setScreenWidth NOTIFY screenWidthChanged) Q_PROPERTY(qreal stageWidth READ stageWidth WRITE setStageWidth NOTIFY stageWidthChanged)
Q_PROPERTY(qreal catWidth READ catWidth WRITE setCatWidth NOTIFY catWidthChanged) Q_PROPERTY(qreal catWidth READ catWidth WRITE setCatWidth NOTIFY catWidthChanged)
Q_PROPERTY(QPointF catCenter READ catCenter WRITE setCatCenter NOTIFY catCenterChanged)
Q_PROPERTY(bool facingRight READ facingRight NOTIFY facingRightChanged) Q_PROPERTY(bool facingRight READ facingRight NOTIFY facingRightChanged)
Q_PROPERTY(Mood mood READ mood NOTIFY moodChanged) Q_PROPERTY(Mood mood READ mood NOTIFY moodChanged)
Q_PROPERTY(bool paused READ paused WRITE setPaused NOTIFY pausedChanged) Q_PROPERTY(bool paused READ paused WRITE setPaused NOTIFY pausedChanged)
@@ -27,23 +27,29 @@ class CatController : public QObject
Q_PROPERTY(bool hovered READ hovered WRITE setHovered NOTIFY hoveredChanged) Q_PROPERTY(bool hovered READ hovered WRITE setHovered NOTIFY hoveredChanged)
Q_PROPERTY(QPointF look READ look NOTIFY lookChanged) Q_PROPERTY(QPointF look READ look NOTIFY lookChanged)
Q_PROPERTY(bool chipVisible READ chipVisible NOTIFY chipVisibleChanged)
Q_PROPERTY(qreal chipX READ chipX NOTIFY chipXChanged)
Q_PROPERTY(int chipKick READ chipKick NOTIFY chipKickChanged)
public: public:
enum class Mood { Walking, Running, Sitting, Grooming, Stretching, Sleeping, Loving, Alert }; enum class Mood { Walking, Running, Sitting, Grooming, Stretching, Sleeping, Loving, Alert, Chasing, Batting };
Q_ENUM(Mood) Q_ENUM(Mood)
explicit CatController(AppSettings *settings, QObject *parent = nullptr); explicit CatController(AppSettings *settings, QObject *parent = nullptr);
void setWindow(QWindow *window);
qreal x() const { return m_x; } qreal x() const { return m_x; }
void setX(qreal x); void setX(qreal x);
qreal screenWidth() const { return m_screenWidth; } qreal stageWidth() const { return m_stageWidth; }
void setScreenWidth(qreal width); void setStageWidth(qreal width);
qreal catWidth() const { return m_catWidth; } qreal catWidth() const { return m_catWidth; }
void setCatWidth(qreal width); void setCatWidth(qreal width);
// Where the cat's face is on the desktop, so the eyes can find the cursor.
QPointF catCenter() const { return m_catCenter; }
void setCatCenter(const QPointF &center);
bool facingRight() const { return m_facingRight; } bool facingRight() const { return m_facingRight; }
Mood mood() const { return m_mood; } Mood mood() const { return m_mood; }
@@ -58,39 +64,54 @@ public:
QPointF look() const { return m_look; } QPointF look() const { return m_look; }
bool chipVisible() const { return m_chipVisible; }
qreal chipX() const { return m_chipX; }
int chipKick() const { return m_chipKick; }
Q_INVOKABLE void pet(); Q_INVOKABLE void pet();
Q_INVOKABLE void doTrick(Mood mood); Q_INVOKABLE void doTrick(Mood mood);
Q_INVOKABLE void tossChip();
signals: signals:
void xChanged(); void xChanged();
void screenWidthChanged(); void stageWidthChanged();
void catWidthChanged(); void catWidthChanged();
void catCenterChanged();
void facingRightChanged(); void facingRightChanged();
void moodChanged(); void moodChanged();
void pausedChanged(); void pausedChanged();
void draggingChanged(); void draggingChanged();
void hoveredChanged(); void hoveredChanged();
void lookChanged(); void lookChanged();
void chipVisibleChanged();
void chipXChanged();
void chipKickChanged();
private: private:
void tick(); void tick();
void trackCursor(); void trackCursor();
void setMood(Mood mood); void setMood(Mood mood);
void setFacingRight(bool right); void setFacingRight(bool right);
void setChipX(qreal x);
void pickNextMood(); void pickNextMood();
void holdMood(Mood mood, int ms); void holdMood(Mood mood, int ms);
void batChip();
void endChase();
void scheduleChip();
qreal catNose() const;
AppSettings *m_settings; AppSettings *m_settings;
QWindow *m_window = nullptr;
QTimer m_tickTimer; QTimer m_tickTimer;
QTimer m_moodTimer; QTimer m_moodTimer;
QTimer m_cursorTimer; QTimer m_cursorTimer;
QTimer m_chipTimer;
QElapsedTimer m_clock; QElapsedTimer m_clock;
qint64 m_lastTickMs = 0; qint64 m_lastTickMs = 0;
qreal m_x = 120; qreal m_x = 120;
qreal m_screenWidth = 1440; qreal m_stageWidth = 1440;
qreal m_catWidth = 120; qreal m_catWidth = 120;
QPointF m_catCenter;
bool m_facingRight = true; bool m_facingRight = true;
Mood m_mood = Mood::Walking; Mood m_mood = Mood::Walking;
bool m_paused = false; bool m_paused = false;
@@ -98,4 +119,9 @@ private:
bool m_hovered = false; bool m_hovered = false;
bool m_frozen = false; bool m_frozen = false;
QPointF m_look; QPointF m_look;
bool m_chipVisible = false;
qreal m_chipX = 0;
int m_chipKick = 0;
int m_batsLeft = 0;
}; };
+5 -8
View File
@@ -1,7 +1,6 @@
#include <QApplication> #include <QApplication>
#include <QQmlApplicationEngine> #include <QQmlApplicationEngine>
#include <QQuickStyle> #include <QQuickStyle>
#include <QQuickWindow>
#include <QLoggingCategory> #include <QLoggingCategory>
#include "appsettings.h" #include "appsettings.h"
@@ -10,9 +9,8 @@
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
QCoreApplication::setOrganizationName("workcat"); QCoreApplication::setOrganizationName("TNCat");
QCoreApplication::setOrganizationDomain("workcat.app"); QCoreApplication::setApplicationName("TNCat");
QCoreApplication::setApplicationName("Workcat");
QApplication::setQuitOnLastWindowClosed(false); QApplication::setQuitOnLastWindowClosed(false);
// QApplication (not QGuiApplication) so QSystemTrayIcon can show a menu. // QApplication (not QGuiApplication) so QSystemTrayIcon can show a menu.
@@ -24,7 +22,7 @@ int main(int argc, char *argv[])
TrayController tray(&cat); TrayController tray(&cat);
if (QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) { if (QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) {
qWarning("Workcat: Wayland does not let a client place its own window. " qWarning("TNCat: Wayland does not let a client place its own window. "
"Run with QT_QPA_PLATFORM=xcb for the cat to walk."); "Run with QT_QPA_PLATFORM=xcb for the cat to walk.");
} }
@@ -35,10 +33,9 @@ int main(int argc, char *argv[])
}); });
QObject::connect(&engine, &QQmlApplicationEngine::objectCreationFailed, &app, QObject::connect(&engine, &QQmlApplicationEngine::objectCreationFailed, &app,
[] { QCoreApplication::exit(1); }, Qt::QueuedConnection); [] { QCoreApplication::exit(1); }, Qt::QueuedConnection);
engine.loadFromModule("Workcat", "Main"); engine.loadFromModule("TNCat", "Main");
if (auto *root = qobject_cast<QQuickWindow *>(engine.rootObjects().value(0))) { if (QObject *root = engine.rootObjects().value(0)) {
cat.setWindow(root);
QObject::connect(&tray, &TrayController::settingsRequested, root, QObject::connect(&tray, &TrayController::settingsRequested, root,
[root] { QMetaObject::invokeMethod(root, "openSettings"); }); [root] { QMetaObject::invokeMethod(root, "openSettings"); });
} }
+11 -2
View File
@@ -1,11 +1,20 @@
#include "platforminfo.h" #include "platforminfo.h"
#include <QGuiApplication> #include <QGuiApplication>
#include <QRegion>
PlatformInfo::PlatformInfo(QObject *parent) PlatformInfo::PlatformInfo(QObject *parent)
: QObject(parent) : QObject(parent)
, m_name(QGuiApplication::platformName()) , m_name(QGuiApplication::platformName())
, m_bypass(qEnvironmentVariableIntValue("WORKCAT_X11_BYPASS") != 0) , m_overlay(qEnvironmentVariableIntValue("TNCAT_OVERLAY") != 0)
, m_opaque(qEnvironmentVariableIntValue("WORKCAT_OPAQUE") != 0) , m_bypass(qEnvironmentVariableIntValue("TNCAT_X11_BYPASS") != 0)
, m_opaque(qEnvironmentVariableIntValue("TNCAT_OPAQUE") != 0)
{ {
} }
void PlatformInfo::setInputRegion(QQuickWindow *window, const QRectF &region)
{
if (!window)
return;
window->setMask(QRegion(region.toAlignedRect()));
}
+18 -3
View File
@@ -2,9 +2,12 @@
#include <QObject> #include <QObject>
#include <QQmlEngine> #include <QQmlEngine>
#include <QRectF>
#include <QString> #include <QString>
#include <QQuickWindow>
// Small facts about the host that QML needs in order to pick window flags. // Small facts about the host that QML needs in order to pick window flags,
// plus the input-region call that makes an overlay window click-through.
class PlatformInfo : public QObject class PlatformInfo : public QObject
{ {
Q_OBJECT Q_OBJECT
@@ -14,6 +17,7 @@ class PlatformInfo : public QObject
Q_PROPERTY(QString name READ name CONSTANT) Q_PROPERTY(QString name READ name CONSTANT)
Q_PROPERTY(bool isX11 READ isX11 CONSTANT) Q_PROPERTY(bool isX11 READ isX11 CONSTANT)
Q_PROPERTY(bool isWayland READ isWayland CONSTANT) Q_PROPERTY(bool isWayland READ isWayland CONSTANT)
Q_PROPERTY(bool overlayMode READ overlayMode CONSTANT)
Q_PROPERTY(bool bypassWindowManager READ bypassWindowManager CONSTANT) Q_PROPERTY(bool bypassWindowManager READ bypassWindowManager CONSTANT)
Q_PROPERTY(bool opaqueBackground READ opaqueBackground CONSTANT) Q_PROPERTY(bool opaqueBackground READ opaqueBackground CONSTANT)
@@ -24,16 +28,27 @@ public:
bool isX11() const { return m_name == QLatin1String("xcb"); } bool isX11() const { return m_name == QLatin1String("xcb"); }
bool isWayland() const { return m_name.startsWith(QLatin1String("wayland")); } bool isWayland() const { return m_name.startsWith(QLatin1String("wayland")); }
// WORKCAT_X11_BYPASS=1 turns the cat into an override-redirect window. // Wayland forbids a client from placing its own window, so there the cat
// lives inside one screen-filling overlay and only moves within it.
// TNCAT_OVERLAY=1 forces the same mode elsewhere.
bool overlayMode() const { return m_overlay || isWayland(); }
// TNCAT_X11_BYPASS=1 turns the cat into an override-redirect window.
// Useful on window managers that refuse to keep a Qt::Tool window on top. // Useful on window managers that refuse to keep a Qt::Tool window on top.
bool bypassWindowManager() const { return m_bypass; } bool bypassWindowManager() const { return m_bypass; }
// WORKCAT_OPAQUE=1 paints a solid background instead of relying on a // TNCAT_OPAQUE=1 paints a solid background instead of relying on a
// compositor. Without compositing X11 renders transparency as black. // compositor. Without compositing X11 renders transparency as black.
bool opaqueBackground() const { return m_opaque; } bool opaqueBackground() const { return m_opaque; }
// Restricts the window's input region to one rectangle so clicks land on
// whatever is behind the rest of the overlay. Implemented by the X11 and
// Wayland plugins; a no-op on platforms that ignore the mask.
Q_INVOKABLE void setInputRegion(QQuickWindow *window, const QRectF &region);
private: private:
QString m_name; QString m_name;
bool m_overlay = false;
bool m_bypass = false; bool m_bypass = false;
bool m_opaque = false; bool m_opaque = false;
}; };
+5 -3
View File
@@ -23,6 +23,8 @@ TrayController::TrayController(CatController *cat, QObject *parent)
tricks->addAction(tr(e.label), this, [this, mood] { m_cat->doTrick(mood); }); tricks->addAction(tr(e.label), this, [this, mood] { m_cat->doTrick(mood); });
} }
m_menu.addAction(tr("Toss a chip"), m_cat, &CatController::tossChip);
m_pauseAction = m_menu.addAction(tr("Let the cat sleep")); m_pauseAction = m_menu.addAction(tr("Let the cat sleep"));
m_pauseAction->setCheckable(true); m_pauseAction->setCheckable(true);
connect(m_pauseAction, &QAction::toggled, m_cat, &CatController::setPaused); connect(m_pauseAction, &QAction::toggled, m_cat, &CatController::setPaused);
@@ -32,10 +34,10 @@ TrayController::TrayController(CatController *cat, QObject *parent)
m_menu.addAction(tr("Settings…"), this, &TrayController::settingsRequested); m_menu.addAction(tr("Settings…"), this, &TrayController::settingsRequested);
m_menu.addSeparator(); m_menu.addSeparator();
m_menu.addAction(tr("Quit Workcat"), qApp, &QCoreApplication::quit); m_menu.addAction(tr("Quit TNCat"), qApp, &QCoreApplication::quit);
m_tray.setIcon(makeIcon()); m_tray.setIcon(makeIcon());
m_tray.setToolTip(tr("Workcat")); m_tray.setToolTip(tr("TNCat"));
m_tray.setContextMenu(&m_menu); m_tray.setContextMenu(&m_menu);
// Not every X11 desktop runs a status-notifier host. Without a tray the // Not every X11 desktop runs a status-notifier host. Without a tray the
@@ -43,7 +45,7 @@ TrayController::TrayController(CatController *cat, QObject *parent)
if (QSystemTrayIcon::isSystemTrayAvailable()) if (QSystemTrayIcon::isSystemTrayAvailable())
m_tray.show(); m_tray.show();
else else
qWarning("Workcat: no system tray on this desktop. Right-click the cat for settings."); qWarning("TNCat: no system tray on this desktop. Right-click the cat for settings.");
} }
QIcon TrayController::makeIcon() QIcon TrayController::makeIcon()