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:
commit
acef398f49
17 files changed
+1584
No files matched your search
@@ -0,0 +1,3 @@
|
||||
build*/
|
||||
CMakeLists.txt.user
|
||||
.DS_Store
|
||||
@@ -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})
|
||||
@@ -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>
|
||||
@@ -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 |
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user