Workcat: cute desktop cat, no window closing

Reworks the example into a pure desktop-pet: the cat wanders, sits, grooms,
stretches, dashes and naps, reacts to petting and dragging, and follows the
cursor with its eyes. The window-scanning and Accessibility code is gone.

The cat is drawn from rounded primitives plus Shape paths in a fixed 100x90
design space, so it scales cleanly to any size.

Linux/X11 is the primary target: the tray is optional, Wayland gets a warning,
and WORKCAT_X11_BYPASS / WORKCAT_OPAQUE cover uncooperative window managers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JulianandClaude Opus 5 committed 2026-09-10 22:20:35 +02:00
commit acef398f49
17 files changed
+1584

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
build*/
CMakeLists.txt.user
.DS_Store
+64
View File
@@ -0,0 +1,64 @@
cmake_minimum_required(VERSION 3.21)
project(Workcat
VERSION 0.2.0
DESCRIPTION "A cute desktop cat that wanders along the bottom of your screen"
LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Quick QuickControls2 Widgets)
qt_standard_project_setup(REQUIRES 6.8)
qt_add_executable(workcat
src/main.cpp
src/catcontroller.h src/catcontroller.cpp
src/traycontroller.h src/traycontroller.cpp
src/appsettings.h src/appsettings.cpp
src/platforminfo.h src/platforminfo.cpp
)
target_include_directories(workcat PRIVATE src)
# QML files live in qml/ but register at the module root (Workcat.Main etc.)
set(WORKCAT_QML Main Cat SettingsWindow)
foreach(qmlfile IN LISTS WORKCAT_QML)
set_source_files_properties(qml/${qmlfile}.qml PROPERTIES QT_RESOURCE_ALIAS ${qmlfile}.qml)
list(APPEND WORKCAT_QML_FILES qml/${qmlfile}.qml)
endforeach()
qt_add_qml_module(workcat
URI Workcat
VERSION 1.0
QML_FILES ${WORKCAT_QML_FILES}
)
target_link_libraries(workcat PRIVATE
Qt6::Core Qt6::Gui Qt6::Quick Qt6::QuickControls2 Qt6::Widgets)
set_target_properties(workcat PROPERTIES
MACOSX_BUNDLE TRUE
MACOSX_BUNDLE_INFO_PLIST ${CMAKE_SOURCE_DIR}/Info.plist.in
MACOSX_BUNDLE_GUI_IDENTIFIER app.workcat.example
MACOSX_BUNDLE_BUNDLE_NAME Workcat
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}
WIN32_EXECUTABLE TRUE)
include(GNUInstallDirs)
install(TARGETS workcat
BUNDLE DESTINATION .
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
if(UNIX AND NOT APPLE)
install(FILES linux/workcat.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
endif()
qt_generate_deploy_qml_app_script(
TARGET workcat
OUTPUT_SCRIPT deploy_script
MACOS_BUNDLE_POST_BUILD
NO_UNSUPPORTED_PLATFORM_ERROR
DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM)
install(SCRIPT ${deploy_script})
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key> <string>English</string>
<key>CFBundleExecutable</key> <string>${MACOSX_BUNDLE_EXECUTABLE_NAME}</string>
<key>CFBundleIdentifier</key> <string>${MACOSX_BUNDLE_GUI_IDENTIFIER}</string>
<key>CFBundleInfoDictionaryVersion</key> <string>6.0</string>
<key>CFBundleName</key> <string>${MACOSX_BUNDLE_BUNDLE_NAME}</string>
<key>CFBundlePackageType</key> <string>APPL</string>
<key>CFBundleShortVersionString</key> <string>${MACOSX_BUNDLE_SHORT_VERSION_STRING}</string>
<key>CFBundleVersion</key> <string>${MACOSX_BUNDLE_BUNDLE_VERSION}</string>
<key>LSMinimumSystemVersion</key> <string>12.0</string>
<key>LSUIElement</key> <true/>
<key>NSHighResolutionCapable</key> <true/>
<key>NSSupportsAutomaticGraphicsSwitching</key> <true/>
</dict>
</plist>
+77
View File
@@ -0,0 +1,77 @@
# Workcat (Qt Quick / CMake example)
A little cat that wanders along the bottom edge of your screen, sits down,
grooms itself, stretches, dashes off and takes naps. Drag it around, click to
pet it, and it purrs. It does nothing else — no window closing, no monitoring.
Inspired by [workcat.app](https://workcat.app/en/), rebuilt as a plain Qt 6 /
QML project. Primary target is **Linux / X11**; development happens on macOS and
both are supported by the same code.
## Layout
| Path | Purpose |
|---|---|
| `CMakeLists.txt` | `qt_add_executable` + `qt_add_qml_module` (URI `Workcat`), macOS bundle, `.desktop` file on Linux |
| `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/appsettings.*` | `QSettings`-backed coat, size, pace, eye tracking, pet counter |
| `src/platforminfo.*` | QML singleton `Platform`: platform name plus the two X11 escape hatches |
| `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/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 |
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.
## Build
macOS with the Qt online installer:
```bash
~/Qt/6.11.1/macos/bin/qt-cmake -S . -B build -G Ninja -DCMAKE_MAKE_PROGRAM=$HOME/Qt/Tools/Ninja/ninja
```
Linux with distribution Qt packages:
```bash
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
```
```bash
cmake --build build
```
Needs Qt 6.8 or newer for `Shape.CurveRenderer`. Required modules: Core, Gui,
Quick, QuickControls2, Widgets. Widgets is only there because `QSystemTrayIcon`
needs it for its context menu.
## Running on X11
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 |
|---|---|
| `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. |
| `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. |
| `WORKCAT_POSE=Sitting` | Freezes the cat in one mood. Handy for screenshots. Accepts any `CatController::Mood` name. |
Wayland cannot let a client position its own window, so the cat would stay
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
the entry point: right-click or double-click opens the settings window.
## Interaction
| Input | Result |
|---|---|
| Click | Pet the cat, it closes its eyes and purrs |
| Drag | Pick the cat up and put it somewhere else |
| Hover | Blush and purr wiggle |
| Double-click or right-click | Settings window |
| Tray menu | Tricks, nap toggle, settings, quit |
+8
View File
@@ -0,0 +1,8 @@
[Desktop Entry]
Type=Application
Name=Workcat
Comment=A cute desktop cat
Exec=workcat
Icon=workcat
Categories=Utility;Amusement;
StartupNotify=false
+529
View File
@@ -0,0 +1,529 @@
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)
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"
readonly property bool moving: (walking || running) && !lifted
readonly property bool sleeping: mood === "sleeping"
readonly property bool happy: mood === "loving" || mood === "grooming" || mood === "stretching" || petted
readonly property real unit: width / 100
// tail pose (design units); swish is added on top by the idle animation
property real tailC1X: 14
property real tailC1Y: 66
property real tailC2X: 8
property real tailC2Y: 46
property real tailX: 13
property real tailY: 34
property real tailSwish: 0
Behavior on tailC1X { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
Behavior on tailC1Y { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
Behavior on tailC2X { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
Behavior on tailC2Y { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
Behavior on tailX { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
Behavior on tailY { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
implicitWidth: 100
implicitHeight: 90
transform: Scale {
origin.x: cat.width / 2
xScale: cat.facingRight ? 1 : -1
}
Item {
id: art
width: 100
height: 90
transformOrigin: Item.TopLeft
scale: cat.unit
// ground shadow
Rectangle {
id: shadow
x: 24; y: 79
width: 56; height: 8
radius: 4
color: "#000000"
opacity: 0.16
antialiasing: true
scale: 1 - torso.y * -0.02
}
Item {
id: torso
width: 100
height: 90
// tail
Shape {
id: tailShape
z: -1
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
id: tail
strokeColor: cat.coatShade
strokeWidth: 7
capStyle: ShapePath.RoundCap
fillColor: "transparent"
startX: 30; startY: 60
PathCubic {
control1X: cat.tailC1X; control1Y: cat.tailC1Y
control2X: cat.tailC2X + cat.tailSwish * 0.5; control2Y: cat.tailC2Y
x: cat.tailX + cat.tailSwish; y: cat.tailY
}
}
SequentialAnimation {
running: !cat.sleeping && !cat.lifted
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { target: cat; property: "tailSwish"; to: 6; duration: 900; easing.type: Easing.InOutSine }
NumberAnimation { target: cat; property: "tailSwish"; to: -4; duration: 900; easing.type: Easing.InOutSine }
}
}
// legs — the far pair sits behind the body, the near pair in front
Repeater {
id: legs
model: 4
Rectangle {
id: leg
required property int index
readonly property bool front: index >= 2
readonly property bool far: index % 2 === 0
readonly property real swing: cat.running ? 38 : 24
readonly property real restRotation: front ? (cat.mood === "stretching" ? (far ? 48 : 40)
: cat.lifted ? (far ? -12 : -18) : 0)
: (cat.mood === "stretching" ? -6 : 0)
z: far ? 0 : 2
x: front ? (cat.seated && !cat.lifted ? 60 : 56) + (far ? 0 : 8)
: (cat.seated && !cat.lifted ? 36 : 30) + (far ? 0 : 8)
y: cat.sleeping && !cat.lifted ? 74
: (front ? (cat.seated && !cat.lifted ? 60 : 63)
: (cat.seated && !cat.lifted ? 72 : 63))
width: 10
height: cat.sleeping && !cat.lifted ? 8
: (front ? (cat.seated && !cat.lifted ? 24 : 20)
: (cat.seated && !cat.lifted ? 12 : 20))
radius: 5
color: far ? cat.coatShade : cat.coat
antialiasing: true
transformOrigin: Item.Top
Behavior on x { NumberAnimation { duration: 400; easing.type: Easing.InOutQuad } }
Behavior on y { NumberAnimation { duration: 400; easing.type: Easing.InOutQuad } }
Behavior on height { NumberAnimation { duration: 400; easing.type: Easing.InOutQuad } }
// paw
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
width: parent.width
height: 6.5
radius: 3.25
color: cat.coatLight
antialiasing: true
Rectangle { // toe line
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 1.2
width: 4.5
height: 1
radius: 0.5
color: cat.coatShade
opacity: 0.7
visible: !leg.far && !cat.sleeping
}
}
// While walking the gait animation owns rotation; otherwise the pose does.
states: State {
name: "resting"
when: !cat.moving
PropertyChanges { leg.rotation: leg.restRotation }
}
transitions: Transition {
NumberAnimation { property: "rotation"; duration: 400; easing.type: Easing.OutBack }
}
SequentialAnimation on rotation {
running: cat.moving
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { to: leg.far ? leg.swing : -leg.swing; duration: cat.running ? 130 : 240; easing.type: Easing.InOutSine }
NumberAnimation { to: leg.far ? -leg.swing : leg.swing; duration: cat.running ? 130 : 240; easing.type: Easing.InOutSine }
}
}
}
// body
Rectangle {
id: body
z: 1
x: 24; y: 46
width: 50; height: 32
radius: 16
color: cat.coat
antialiasing: true
Behavior on x { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on y { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on width { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on height { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on rotation { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Rectangle { // belly
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 3
width: parent.width * 0.5
height: parent.height * 0.42
radius: height / 2
color: cat.coatLight
antialiasing: true
}
}
// head
Item {
id: head
z: 3
x: 46; y: 12
width: 50; height: 44
transformOrigin: Item.Bottom
Behavior on x { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on y { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on rotation { NumberAnimation { duration: 450; easing.type: Easing.InOutBack } }
// ears
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath { // left ear
fillColor: cat.coat
strokeColor: "transparent"
startX: 6; startY: 14
PathQuad { controlX: 2; controlY: -4; x: 8; y: -4 }
PathQuad { controlX: 14; controlY: -2; x: 24; y: 5 }
PathLine { x: 6; y: 14 }
}
ShapePath { // left inner
fillColor: cat.pink
strokeColor: "transparent"
startX: 9; startY: 11
PathQuad { controlX: 6; controlY: 2; x: 9; y: 1 }
PathQuad { controlX: 14; controlY: 3; x: 19; y: 6 }
PathLine { x: 9; y: 11 }
}
ShapePath { // right ear
fillColor: cat.coat
strokeColor: "transparent"
startX: 44; startY: 14
PathQuad { controlX: 48; controlY: -4; x: 42; y: -4 }
PathQuad { controlX: 36; controlY: -2; x: 26; y: 5 }
PathLine { x: 44; y: 14 }
}
ShapePath { // right inner
fillColor: cat.pink
strokeColor: "transparent"
startX: 41; startY: 11
PathQuad { controlX: 44; controlY: 2; x: 41; y: 1 }
PathQuad { controlX: 36; controlY: 3; x: 31; y: 6 }
PathLine { x: 41; y: 11 }
}
}
Rectangle { // face
x: 0; y: 4
width: 50; height: 40
radius: 21
color: cat.coat
antialiasing: true
}
// cheeks
Repeater {
model: 2
Rectangle {
required property int index
x: index === 0 ? 5 : 35
y: 30
width: 10; height: 5.5
radius: 3
color: cat.blush
opacity: cat.happy ? 0.75 : 0.4
antialiasing: true
Behavior on opacity { NumberAnimation { duration: 300 } }
}
}
// eyes
Repeater {
model: 2
Item {
id: eye
required property int index
property real open: 1
x: (index === 0 ? 12 : 29) + cat.look.x * 2.2
y: 17 + cat.look.y * 1.6
width: 9.5; height: 12
Behavior on open { NumberAnimation { duration: 110 } }
Rectangle {
id: eyeBall
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
width: eye.width * (cat.mood === "alert" ? 1.15 : 1)
height: eye.height * eye.open * (cat.mood === "alert" ? 1.15 : 1)
radius: width / 2
color: cat.eyeColor
visible: eye.open > 0.08
antialiasing: true
Rectangle { // highlight
x: parent.width * 0.5; y: parent.height * 0.14
width: 3.4; height: 3.4 * eye.open
radius: 1.7
color: "white"
antialiasing: true
}
Rectangle {
x: parent.width * 0.22; y: parent.height * 0.6
width: 1.6; height: 1.6 * eye.open
radius: 0.8
color: "white"
antialiasing: true
}
}
Shape { // closed eye: happy arch or sleepy curve
anchors.fill: parent
visible: eye.open <= 0.08
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: cat.ink
strokeWidth: 1.8
capStyle: ShapePath.RoundCap
fillColor: "transparent"
startX: 0.5; startY: 7
PathQuad {
controlX: 4.75
controlY: cat.happy ? 2 : 10
x: 9; y: 7
}
}
}
Timer { // blink
interval: 2600 + Math.random() * 3800
running: !cat.sleeping && !cat.happy && cat.mood !== "alert"
repeat: true
onTriggered: { interval = 2600 + Math.random() * 3800; blinkAnim.start() }
}
SequentialAnimation {
id: blinkAnim
NumberAnimation { target: eye; property: "open"; to: 0; duration: 80 }
NumberAnimation { target: eye; property: "open"; to: 1; duration: 120 }
}
states: State {
name: "closed"
when: cat.sleeping || cat.happy
PropertyChanges { eye.open: 0 }
}
}
}
// nose
Rectangle {
x: 22.5; y: 28
width: 5; height: 3.6
radius: 1.8
color: cat.pink
antialiasing: true
}
// mouth "ω"
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: cat.ink
strokeWidth: 1.1
capStyle: ShapePath.RoundCap
fillColor: "transparent"
startX: 20.5; startY: 32
PathQuad { controlX: 22.5; controlY: 35.2; x: 25; y: 32.4 }
PathQuad { controlX: 27.5; controlY: 35.2; x: 29.5; y: 32 }
}
}
// whiskers
Repeater {
model: 4
Rectangle {
required property int index
readonly property bool leftSide: index < 2
x: leftSide ? -7 : 45
y: 25 + (index % 2) * 4.5
width: 12
height: 1
radius: 0.5
color: cat.ink
opacity: 0.55
rotation: (leftSide ? 1 : -1) * ((index % 2) === 0 ? 7 : -7)
antialiasing: true
}
}
// raised paw while grooming
Rectangle {
x: 8; y: 26
width: 10; height: 16
radius: 5
color: cat.coat
antialiasing: true
visible: cat.mood === "grooming"
transformOrigin: Item.Bottom
SequentialAnimation on rotation {
running: cat.mood === "grooming"
loops: Animation.Infinite
NumberAnimation { to: 14; duration: 260; easing.type: Easing.InOutSine }
NumberAnimation { to: -6; duration: 260; easing.type: Easing.InOutSine }
}
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
y: 0
width: parent.width; height: 6
radius: 3
color: cat.coatLight
}
}
}
// walking bob
SequentialAnimation on y {
running: cat.moving
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { to: cat.running ? -4 : -2; duration: cat.running ? 130 : 240; easing.type: Easing.InOutSine }
NumberAnimation { to: 0; duration: cat.running ? 130 : 240; easing.type: Easing.InOutSine }
}
// purr wiggle while petted / loving
SequentialAnimation on rotation {
running: cat.petted || cat.mood === "loving"
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { to: 2.5; duration: 180; easing.type: Easing.InOutSine }
NumberAnimation { to: -2.5; duration: 180; easing.type: Easing.InOutSine }
}
}
// speech bubble: "!" / "z" / "♥"
Text {
id: bubble
readonly property string glyph: cat.mood === "alert" ? "!" : cat.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.bold: true
font.family: "Helvetica Neue"
color: cat.mood === "alert" ? "#f2a33c" : (glyph === "♥" ? "#f25f7a" : 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 }
}
}
}
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: "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"
}
+87
View File
@@ -0,0 +1,87 @@
pragma ComponentBehavior: Bound
import QtQuick
import Workcat
Window {
id: root
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()
}
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")
}
HoverHandler {
onHoveredChanged: root.cat.hovered = hovered
}
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))
}
}
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()
}
}
+169
View File
@@ -0,0 +1,169 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
import Workcat
Window {
id: win
required property CatController cat
required property AppSettings settings
readonly property var coats: [
{ name: qsTr("Apricot"), color: "#f4b183" },
{ name: qsTr("Ivory"), color: "#f3ede2" },
{ name: qsTr("Grey"), color: "#a4a6b3" },
{ name: qsTr("Charcoal"), color: "#3c3b46" },
{ name: qsTr("Sage"), color: "#a9bfa0" },
{ name: qsTr("Plum"), color: "#9a7bb0" }
]
width: 440
height: 600
minimumWidth: 380
minimumHeight: 520
title: qsTr("Workcat")
color: "#faf7f2"
flags: Qt.Dialog | Qt.WindowStaysOnTopHint
ColumnLayout {
anchors.fill: parent
anchors.margins: 24
spacing: 18
RowLayout {
Layout.fillWidth: true
spacing: 16
Cat {
Layout.preferredWidth: 110
Layout.preferredHeight: 99
coat: win.settings.coat
mood: "sitting"
petted: previewHover.hovered
HoverHandler { id: previewHover }
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Label {
Layout.fillWidth: true
text: qsTr("Workcat")
font.pixelSize: 26
font.bold: true
color: "#2f2a36"
}
Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
text: qsTr("A little cat that wanders along the bottom of your screen. Drag it around, pet it with a click, and it will purr.")
color: "#7a7484"
}
}
}
GroupBox {
Layout.fillWidth: true
title: qsTr("Coat")
Flow {
anchors.fill: parent
spacing: 10
Repeater {
model: win.coats
Column {
id: swatch
required property var modelData
readonly property bool selected: Qt.colorEqual(win.settings.coat, modelData.color)
spacing: 4
Rectangle {
width: 40; height: 40
radius: 20
color: swatch.modelData.color
border.width: swatch.selected ? 3 : 1
border.color: swatch.selected ? "#f25f7a" : "#d8d2ca"
antialiasing: true
Accessible.role: Accessible.RadioButton
Accessible.name: swatch.modelData.name
Accessible.checked: swatch.selected
activeFocusOnTab: true
Keys.onSpacePressed: win.settings.coat = swatch.modelData.color
TapHandler { onTapped: win.settings.coat = swatch.modelData.color }
}
Label {
anchors.horizontalCenter: parent.horizontalCenter
text: swatch.modelData.name
font.pixelSize: 11
color: "#7a7484"
}
}
}
}
}
GridLayout {
Layout.fillWidth: true
columns: 2
columnSpacing: 14
rowSpacing: 12
Label { text: qsTr("Size") }
Slider {
Layout.fillWidth: true
from: 72; to: 260; stepSize: 4
value: win.settings.catSize
onMoved: win.settings.catSize = value
}
Label { text: qsTr("Pace") }
Slider {
Layout.fillWidth: true
from: 10; to: 200; stepSize: 5
value: win.settings.walkSpeed
onMoved: win.settings.walkSpeed = value
}
Label { text: qsTr("Eyes") }
Switch {
text: qsTr("Follow the mouse")
checked: win.settings.followCursor
onToggled: win.settings.followCursor = checked
}
Label { text: qsTr("Cat") }
Switch {
text: checked ? qsTr("Napping") : qsTr("Awake")
checked: win.cat.paused
onToggled: win.cat.paused = checked
}
}
GroupBox {
Layout.fillWidth: true
title: qsTr("Tricks")
Flow {
anchors.fill: parent
spacing: 8
Button { text: qsTr("Sit"); onClicked: win.cat.doTrick(CatController.Sitting) }
Button { text: qsTr("Groom"); onClicked: win.cat.doTrick(CatController.Grooming) }
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) }
}
}
Item { Layout.fillHeight: true }
RowLayout {
Layout.fillWidth: true
Label {
Layout.fillWidth: true
text: qsTr("%n pet(s) so far.", "", win.settings.petCount)
color: "#7a7484"
}
Button {
text: qsTr("Quit")
onClicked: Qt.quit()
}
}
}
}
+71
View File
@@ -0,0 +1,71 @@
#include "appsettings.h"
AppSettings::AppSettings(QObject *parent)
: QObject(parent)
{
}
QColor AppSettings::coat() const
{
return m_settings.value("coat", QColor("#f4b183")).value<QColor>();
}
void AppSettings::setCoat(const QColor &color)
{
if (color == coat())
return;
m_settings.setValue("coat", color);
emit coatChanged();
}
int AppSettings::catSize() const
{
return m_settings.value("catSize", 120).toInt();
}
void AppSettings::setCatSize(int pixels)
{
pixels = qBound(72, pixels, 260);
if (pixels == catSize())
return;
m_settings.setValue("catSize", pixels);
emit catSizeChanged();
}
qreal AppSettings::walkSpeed() const
{
return m_settings.value("walkSpeed", 45.0).toReal();
}
void AppSettings::setWalkSpeed(qreal pixelsPerSecond)
{
pixelsPerSecond = qBound(10.0, pixelsPerSecond, 200.0);
if (qFuzzyCompare(pixelsPerSecond, walkSpeed()))
return;
m_settings.setValue("walkSpeed", pixelsPerSecond);
emit walkSpeedChanged();
}
bool AppSettings::followCursor() const
{
return m_settings.value("followCursor", true).toBool();
}
void AppSettings::setFollowCursor(bool follow)
{
if (follow == followCursor())
return;
m_settings.setValue("followCursor", follow);
emit followCursorChanged();
}
int AppSettings::petCount() const
{
return m_settings.value("petCount", 0).toInt();
}
void AppSettings::incrementPetCount()
{
m_settings.setValue("petCount", petCount() + 1);
emit petCountChanged();
}
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <QSettings>
#include <QColor>
class AppSettings : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Provided by C++")
Q_PROPERTY(QColor coat READ coat WRITE setCoat NOTIFY coatChanged)
Q_PROPERTY(int catSize READ catSize WRITE setCatSize NOTIFY catSizeChanged)
Q_PROPERTY(qreal walkSpeed READ walkSpeed WRITE setWalkSpeed NOTIFY walkSpeedChanged)
Q_PROPERTY(bool followCursor READ followCursor WRITE setFollowCursor NOTIFY followCursorChanged)
Q_PROPERTY(int petCount READ petCount NOTIFY petCountChanged)
public:
explicit AppSettings(QObject *parent = nullptr);
QColor coat() const;
void setCoat(const QColor &color);
int catSize() const;
void setCatSize(int pixels);
qreal walkSpeed() const;
void setWalkSpeed(qreal pixelsPerSecond);
bool followCursor() const;
void setFollowCursor(bool follow);
int petCount() const;
void incrementPetCount();
signals:
void coatChanged();
void catSizeChanged();
void walkSpeedChanged();
void followCursorChanged();
void petCountChanged();
private:
QSettings m_settings;
};
+217
View File
@@ -0,0 +1,217 @@
#include "catcontroller.h"
#include "appsettings.h"
#include <QCursor>
#include <QMetaEnum>
#include <QRandomGenerator>
#include <QWindow>
CatController::CatController(AppSettings *settings, QObject *parent)
: QObject(parent)
, m_settings(settings)
{
m_clock.start();
m_tickTimer.setInterval(16);
connect(&m_tickTimer, &QTimer::timeout, this, &CatController::tick);
m_tickTimer.start();
m_moodTimer.setSingleShot(true);
connect(&m_moodTimer, &QTimer::timeout, this, &CatController::pickNextMood);
m_cursorTimer.setInterval(40);
connect(&m_cursorTimer, &QTimer::timeout, this, &CatController::trackCursor);
m_cursorTimer.start();
// WORKCAT_POSE=Sleeping freezes the cat in one mood (handy for screenshots).
const QByteArray pose = qgetenv("WORKCAT_POSE");
if (!pose.isEmpty()) {
const QMetaEnum me = QMetaEnum::fromType<Mood>();
bool ok = false;
const int value = me.keyToValue(pose.constData(), &ok);
if (ok) {
m_frozen = true;
setMood(static_cast<Mood>(value));
}
}
if (!m_frozen)
pickNextMood();
}
void CatController::setWindow(QWindow *window)
{
m_window = window;
}
void CatController::setX(qreal x)
{
if (qFuzzyCompare(x, m_x))
return;
m_x = x;
emit xChanged();
}
void CatController::setScreenWidth(qreal width)
{
if (qFuzzyCompare(width, m_screenWidth))
return;
m_screenWidth = width;
emit screenWidthChanged();
}
void CatController::setCatWidth(qreal width)
{
if (qFuzzyCompare(width, m_catWidth))
return;
m_catWidth = width;
emit catWidthChanged();
}
void CatController::setPaused(bool paused)
{
if (paused == m_paused)
return;
m_paused = paused;
emit pausedChanged();
if (paused) {
m_moodTimer.stop();
setMood(Mood::Sleeping);
} else {
pickNextMood();
}
}
void CatController::setDragging(bool dragging)
{
if (dragging == m_dragging)
return;
m_dragging = dragging;
emit draggingChanged();
if (m_frozen)
return;
if (dragging) {
m_moodTimer.stop();
setMood(Mood::Alert);
} else if (!m_paused) {
holdMood(Mood::Sitting, 1500);
}
}
void CatController::setHovered(bool hovered)
{
if (hovered == m_hovered)
return;
m_hovered = hovered;
emit hoveredChanged();
}
void CatController::pet()
{
if (m_paused || m_frozen)
return;
m_settings->incrementPetCount();
holdMood(Mood::Loving, 1800);
}
void CatController::doTrick(Mood mood)
{
if (m_paused)
return;
m_frozen = false;
holdMood(mood, mood == Mood::Sleeping ? 15000 : 3500);
}
void CatController::holdMood(Mood mood, int ms)
{
setMood(mood);
m_moodTimer.start(ms);
}
void CatController::trackCursor()
{
if (!m_window || !m_settings->followCursor()) {
if (!m_look.isNull()) {
m_look = {};
emit lookChanged();
}
return;
}
const QPointF center = m_window->geometry().center();
QPointF delta = QCursor::pos() - center;
if (!m_facingRight)
delta.setX(-delta.x());
const qreal range = 400.0;
const QPointF look(qBound(-1.0, delta.x() / range, 1.0), qBound(-1.0, delta.y() / range, 1.0));
if ((look - m_look).manhattanLength() > 0.005) {
m_look = look;
emit lookChanged();
}
}
void CatController::tick()
{
const qint64 now = m_clock.elapsed();
const qreal dt = qMin(now - m_lastTickMs, qint64(100)) / 1000.0;
m_lastTickMs = now;
const bool moving = m_mood == Mood::Walking || m_mood == Mood::Running;
if (!moving || m_dragging || m_paused || m_frozen)
return;
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 ? step : -step);
const qreal maxX = qMax(0.0, m_screenWidth - m_catWidth);
if (next >= maxX) {
next = maxX;
setFacingRight(false);
if (m_mood == Mood::Running)
holdMood(Mood::Sitting, 2000);
} else if (next <= 0) {
next = 0;
setFacingRight(true);
if (m_mood == Mood::Running)
holdMood(Mood::Sitting, 2000);
}
setX(next);
}
void CatController::setMood(Mood mood)
{
if (mood == m_mood)
return;
m_mood = mood;
emit moodChanged();
}
void CatController::setFacingRight(bool right)
{
if (right == m_facingRight)
return;
m_facingRight = right;
emit facingRightChanged();
}
void CatController::pickNextMood()
{
if (m_paused || m_dragging || m_frozen)
return;
auto *rng = QRandomGenerator::global();
const int roll = rng->bounded(100);
if (roll < 45) {
if (rng->bounded(100) < 20)
setFacingRight(!m_facingRight);
holdMood(Mood::Walking, rng->bounded(3000, 9000));
} else if (roll < 55) {
holdMood(Mood::Running, rng->bounded(1200, 2600));
} else if (roll < 72) {
holdMood(Mood::Sitting, rng->bounded(2500, 6000));
} else if (roll < 82) {
holdMood(Mood::Grooming, rng->bounded(2500, 4500));
} else if (roll < 90) {
holdMood(Mood::Stretching, rng->bounded(1800, 3000));
} else {
holdMood(Mood::Sleeping, rng->bounded(8000, 20000));
}
}
+101
View File
@@ -0,0 +1,101 @@
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <QTimer>
#include <QElapsedTimer>
#include <QPointF>
class AppSettings;
class QWindow;
// Drives the cat: wanders along the bottom of the screen, occasionally dashes,
// sits, grooms, stretches or naps, and reacts to petting and dragging.
class CatController : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Provided by C++")
Q_PROPERTY(qreal x READ x WRITE setX NOTIFY xChanged)
Q_PROPERTY(qreal screenWidth READ screenWidth WRITE setScreenWidth NOTIFY screenWidthChanged)
Q_PROPERTY(qreal catWidth READ catWidth WRITE setCatWidth NOTIFY catWidthChanged)
Q_PROPERTY(bool facingRight READ facingRight NOTIFY facingRightChanged)
Q_PROPERTY(Mood mood READ mood NOTIFY moodChanged)
Q_PROPERTY(bool paused READ paused WRITE setPaused NOTIFY pausedChanged)
Q_PROPERTY(bool dragging READ dragging WRITE setDragging NOTIFY draggingChanged)
Q_PROPERTY(bool hovered READ hovered WRITE setHovered NOTIFY hoveredChanged)
Q_PROPERTY(QPointF look READ look NOTIFY lookChanged)
public:
enum class Mood { Walking, Running, Sitting, Grooming, Stretching, Sleeping, Loving, Alert };
Q_ENUM(Mood)
explicit CatController(AppSettings *settings, QObject *parent = nullptr);
void setWindow(QWindow *window);
qreal x() const { return m_x; }
void setX(qreal x);
qreal screenWidth() const { return m_screenWidth; }
void setScreenWidth(qreal width);
qreal catWidth() const { return m_catWidth; }
void setCatWidth(qreal width);
bool facingRight() const { return m_facingRight; }
Mood mood() const { return m_mood; }
bool paused() const { return m_paused; }
void setPaused(bool paused);
bool dragging() const { return m_dragging; }
void setDragging(bool dragging);
bool hovered() const { return m_hovered; }
void setHovered(bool hovered);
QPointF look() const { return m_look; }
Q_INVOKABLE void pet();
Q_INVOKABLE void doTrick(Mood mood);
signals:
void xChanged();
void screenWidthChanged();
void catWidthChanged();
void facingRightChanged();
void moodChanged();
void pausedChanged();
void draggingChanged();
void hoveredChanged();
void lookChanged();
private:
void tick();
void trackCursor();
void setMood(Mood mood);
void setFacingRight(bool right);
void pickNextMood();
void holdMood(Mood mood, int ms);
AppSettings *m_settings;
QWindow *m_window = nullptr;
QTimer m_tickTimer;
QTimer m_moodTimer;
QTimer m_cursorTimer;
QElapsedTimer m_clock;
qint64 m_lastTickMs = 0;
qreal m_x = 120;
qreal m_screenWidth = 1440;
qreal m_catWidth = 120;
bool m_facingRight = true;
Mood m_mood = Mood::Walking;
bool m_paused = false;
bool m_dragging = false;
bool m_hovered = false;
bool m_frozen = false;
QPointF m_look;
};
+47
View File
@@ -0,0 +1,47 @@
#include <QApplication>
#include <QQmlApplicationEngine>
#include <QQuickStyle>
#include <QQuickWindow>
#include <QLoggingCategory>
#include "appsettings.h"
#include "catcontroller.h"
#include "traycontroller.h"
int main(int argc, char *argv[])
{
QCoreApplication::setOrganizationName("workcat");
QCoreApplication::setOrganizationDomain("workcat.app");
QCoreApplication::setApplicationName("Workcat");
QApplication::setQuitOnLastWindowClosed(false);
// QApplication (not QGuiApplication) so QSystemTrayIcon can show a menu.
QApplication app(argc, argv);
QQuickStyle::setStyle("Basic");
AppSettings settings;
CatController cat(&settings);
TrayController tray(&cat);
if (QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) {
qWarning("Workcat: Wayland does not let a client place its own window. "
"Run with QT_QPA_PLATFORM=xcb for the cat to walk.");
}
QQmlApplicationEngine engine;
engine.setInitialProperties({
{ "cat", QVariant::fromValue(&cat) },
{ "settings", QVariant::fromValue(&settings) },
});
QObject::connect(&engine, &QQmlApplicationEngine::objectCreationFailed, &app,
[] { QCoreApplication::exit(1); }, Qt::QueuedConnection);
engine.loadFromModule("Workcat", "Main");
if (auto *root = qobject_cast<QQuickWindow *>(engine.rootObjects().value(0))) {
cat.setWindow(root);
QObject::connect(&tray, &TrayController::settingsRequested, root,
[root] { QMetaObject::invokeMethod(root, "openSettings"); });
}
return app.exec();
}
+11
View File
@@ -0,0 +1,11 @@
#include "platforminfo.h"
#include <QGuiApplication>
PlatformInfo::PlatformInfo(QObject *parent)
: QObject(parent)
, m_name(QGuiApplication::platformName())
, m_bypass(qEnvironmentVariableIntValue("WORKCAT_X11_BYPASS") != 0)
, m_opaque(qEnvironmentVariableIntValue("WORKCAT_OPAQUE") != 0)
{
}
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <QString>
// Small facts about the host that QML needs in order to pick window flags.
class PlatformInfo : public QObject
{
Q_OBJECT
QML_NAMED_ELEMENT(Platform)
QML_SINGLETON
Q_PROPERTY(QString name READ name CONSTANT)
Q_PROPERTY(bool isX11 READ isX11 CONSTANT)
Q_PROPERTY(bool isWayland READ isWayland CONSTANT)
Q_PROPERTY(bool bypassWindowManager READ bypassWindowManager CONSTANT)
Q_PROPERTY(bool opaqueBackground READ opaqueBackground CONSTANT)
public:
explicit PlatformInfo(QObject *parent = nullptr);
QString name() const { return m_name; }
bool isX11() const { return m_name == QLatin1String("xcb"); }
bool isWayland() const { return m_name.startsWith(QLatin1String("wayland")); }
// WORKCAT_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.
bool bypassWindowManager() const { return m_bypass; }
// WORKCAT_OPAQUE=1 paints a solid background instead of relying on a
// compositor. Without compositing X11 renders transparency as black.
bool opaqueBackground() const { return m_opaque; }
private:
QString m_name;
bool m_bypass = false;
bool m_opaque = false;
};
+71
View File
@@ -0,0 +1,71 @@
#include "traycontroller.h"
#include "catcontroller.h"
#include <QCoreApplication>
#include <QPainter>
#include <QPainterPath>
#include <QLoggingCategory>
TrayController::TrayController(CatController *cat, QObject *parent)
: QObject(parent)
, m_cat(cat)
{
QMenu *tricks = m_menu.addMenu(tr("Tricks"));
const struct { const char *label; CatController::Mood mood; } entries[] = {
{ QT_TR_NOOP("Sit"), CatController::Mood::Sitting },
{ QT_TR_NOOP("Groom"), CatController::Mood::Grooming },
{ QT_TR_NOOP("Stretch"), CatController::Mood::Stretching },
{ QT_TR_NOOP("Dash"), CatController::Mood::Running },
{ QT_TR_NOOP("Nap"), CatController::Mood::Sleeping },
};
for (const auto &e : entries) {
const auto mood = e.mood;
tricks->addAction(tr(e.label), this, [this, mood] { m_cat->doTrick(mood); });
}
m_pauseAction = m_menu.addAction(tr("Let the cat sleep"));
m_pauseAction->setCheckable(true);
connect(m_pauseAction, &QAction::toggled, m_cat, &CatController::setPaused);
connect(m_cat, &CatController::pausedChanged, this, [this] {
m_pauseAction->setChecked(m_cat->paused());
});
m_menu.addAction(tr("Settings…"), this, &TrayController::settingsRequested);
m_menu.addSeparator();
m_menu.addAction(tr("Quit Workcat"), qApp, &QCoreApplication::quit);
m_tray.setIcon(makeIcon());
m_tray.setToolTip(tr("Workcat"));
m_tray.setContextMenu(&m_menu);
// Not every X11 desktop runs a status-notifier host. Without a tray the
// cat itself is the only entry point, so say so instead of failing mutely.
if (QSystemTrayIcon::isSystemTrayAvailable())
m_tray.show();
else
qWarning("Workcat: no system tray on this desktop. Right-click the cat for settings.");
}
QIcon TrayController::makeIcon()
{
QPixmap pm(44, 44);
pm.fill(Qt::transparent);
QPainter p(&pm);
p.setRenderHint(QPainter::Antialiasing);
p.setPen(Qt::NoPen);
p.setBrush(Qt::black);
QPainterPath path;
path.addRoundedRect(QRectF(5, 12, 34, 28), 14, 14);
path.moveTo(8, 22); path.lineTo(9, 5); path.lineTo(21, 14); path.closeSubpath();
path.moveTo(36, 22); path.lineTo(35, 5); path.lineTo(23, 14); path.closeSubpath();
p.drawPath(path);
p.setBrush(Qt::white);
p.drawEllipse(QRectF(13, 22, 6, 7));
p.drawEllipse(QRectF(25, 22, 6, 7));
QIcon icon(pm);
icon.setIsMask(true);
return icon;
}
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <QObject>
#include <QSystemTrayIcon>
#include <QMenu>
class CatController;
class TrayController : public QObject
{
Q_OBJECT
public:
explicit TrayController(CatController *cat, QObject *parent = nullptr);
signals:
void settingsRequested();
private:
static QIcon makeIcon();
CatController *m_cat;
QSystemTrayIcon m_tray;
QMenu m_menu;
QAction *m_pauseAction = nullptr;
};