From acef398f4922bc69cbf5eda787f66c34141c3c1b Mon Sep 17 00:00:00 2001 From: Julian Date: Thu, 10 Sep 2026 22:20:35 +0200 Subject: [PATCH] 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 --- .gitignore | 3 + CMakeLists.txt | 64 +++++ Info.plist.in | 18 ++ README.md | 77 ++++++ linux/workcat.desktop | 8 + qml/Cat.qml | 529 +++++++++++++++++++++++++++++++++++++++++ qml/Main.qml | 87 +++++++ qml/SettingsWindow.qml | 169 +++++++++++++ src/appsettings.cpp | 71 ++++++ src/appsettings.h | 47 ++++ src/catcontroller.cpp | 217 +++++++++++++++++ src/catcontroller.h | 101 ++++++++ src/main.cpp | 47 ++++ src/platforminfo.cpp | 11 + src/platforminfo.h | 39 +++ src/traycontroller.cpp | 71 ++++++ src/traycontroller.h | 25 ++ 17 files changed, 1584 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 Info.plist.in create mode 100644 README.md create mode 100644 linux/workcat.desktop create mode 100644 qml/Cat.qml create mode 100644 qml/Main.qml create mode 100644 qml/SettingsWindow.qml create mode 100644 src/appsettings.cpp create mode 100644 src/appsettings.h create mode 100644 src/catcontroller.cpp create mode 100644 src/catcontroller.h create mode 100644 src/main.cpp create mode 100644 src/platforminfo.cpp create mode 100644 src/platforminfo.h create mode 100644 src/traycontroller.cpp create mode 100644 src/traycontroller.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5e0c6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build*/ +CMakeLists.txt.user +.DS_Store diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..6cac787 --- /dev/null +++ b/CMakeLists.txt @@ -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}) diff --git a/Info.plist.in b/Info.plist.in new file mode 100644 index 0000000..707e12d --- /dev/null +++ b/Info.plist.in @@ -0,0 +1,18 @@ + + + + + CFBundleDevelopmentRegion English + CFBundleExecutable ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleIdentifier ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleInfoDictionaryVersion 6.0 + CFBundleName ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundlePackageType APPL + CFBundleShortVersionString ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundleVersion ${MACOSX_BUNDLE_BUNDLE_VERSION} + LSMinimumSystemVersion 12.0 + LSUIElement + NSHighResolutionCapable + NSSupportsAutomaticGraphicsSwitching + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..179d792 --- /dev/null +++ b/README.md @@ -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 | diff --git a/linux/workcat.desktop b/linux/workcat.desktop new file mode 100644 index 0000000..4de955b --- /dev/null +++ b/linux/workcat.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=Workcat +Comment=A cute desktop cat +Exec=workcat +Icon=workcat +Categories=Utility;Amusement; +StartupNotify=false diff --git a/qml/Cat.qml b/qml/Cat.qml new file mode 100644 index 0000000..22149aa --- /dev/null +++ b/qml/Cat.qml @@ -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" +} diff --git a/qml/Main.qml b/qml/Main.qml new file mode 100644 index 0000000..7ede109 --- /dev/null +++ b/qml/Main.qml @@ -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() + } +} diff --git a/qml/SettingsWindow.qml b/qml/SettingsWindow.qml new file mode 100644 index 0000000..b169973 --- /dev/null +++ b/qml/SettingsWindow.qml @@ -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() + } + } + } +} diff --git a/src/appsettings.cpp b/src/appsettings.cpp new file mode 100644 index 0000000..19ee101 --- /dev/null +++ b/src/appsettings.cpp @@ -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(); +} + +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(); +} diff --git a/src/appsettings.h b/src/appsettings.h new file mode 100644 index 0000000..29056b9 --- /dev/null +++ b/src/appsettings.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include + +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; +}; diff --git a/src/catcontroller.cpp b/src/catcontroller.cpp new file mode 100644 index 0000000..e57c656 --- /dev/null +++ b/src/catcontroller.cpp @@ -0,0 +1,217 @@ +#include "catcontroller.h" +#include "appsettings.h" + +#include +#include +#include +#include + +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(); + bool ok = false; + const int value = me.keyToValue(pose.constData(), &ok); + if (ok) { + m_frozen = true; + setMood(static_cast(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)); + } +} diff --git a/src/catcontroller.h b/src/catcontroller.h new file mode 100644 index 0000000..b569347 --- /dev/null +++ b/src/catcontroller.h @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include +#include +#include + +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; +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..16fb246 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,47 @@ +#include +#include +#include +#include +#include + +#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(engine.rootObjects().value(0))) { + cat.setWindow(root); + QObject::connect(&tray, &TrayController::settingsRequested, root, + [root] { QMetaObject::invokeMethod(root, "openSettings"); }); + } + + return app.exec(); +} diff --git a/src/platforminfo.cpp b/src/platforminfo.cpp new file mode 100644 index 0000000..1f0b348 --- /dev/null +++ b/src/platforminfo.cpp @@ -0,0 +1,11 @@ +#include "platforminfo.h" + +#include + +PlatformInfo::PlatformInfo(QObject *parent) + : QObject(parent) + , m_name(QGuiApplication::platformName()) + , m_bypass(qEnvironmentVariableIntValue("WORKCAT_X11_BYPASS") != 0) + , m_opaque(qEnvironmentVariableIntValue("WORKCAT_OPAQUE") != 0) +{ +} diff --git a/src/platforminfo.h b/src/platforminfo.h new file mode 100644 index 0000000..6f863f2 --- /dev/null +++ b/src/platforminfo.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +// 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; +}; diff --git a/src/traycontroller.cpp b/src/traycontroller.cpp new file mode 100644 index 0000000..2b11698 --- /dev/null +++ b/src/traycontroller.cpp @@ -0,0 +1,71 @@ +#include "traycontroller.h" +#include "catcontroller.h" + +#include +#include +#include +#include + +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; +} diff --git a/src/traycontroller.h b/src/traycontroller.h new file mode 100644 index 0000000..9c27fd1 --- /dev/null +++ b/src/traycontroller.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include + +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; +};