From 0f1df75a1dfd06e1f998d67dc0b8295cc222bf7f Mon Sep 17 00:00:00 2001 From: Julian Date: Thu, 10 Sep 2026 22:53:40 +0200 Subject: [PATCH] Rename to TNCat, add chip chase, G-codes and a Wayland layout The project is now TNCat, after HEIDENHAIN's TNC controls. Binary, QML module URI, settings path, bundle id and .desktop entry follow; the environment switches are TNCAT_*. Entertainment: a curled steel chip skitters in every minute or two, the cat runs it down and bats it away a few times before sitting back looking pleased. Mood changes flash the matching machine code above its head, G00 for a sprint, G01 for a stroll, M08 while it washes, G28 for a stretch, M30 for a nap. Window handling was split into two layouts. X11 and macOS keep one small window per object, positioned by the app. Wayland gets a screen-filling overlay whose input region is masked down to the cat, since a Wayland client cannot place its own window. Overlay mode is automatic there and available elsewhere via TNCAT_OVERLAY=1. Also adds a ground-line setting so the cat can walk above a softkey row. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 42 +++---- README.md | 110 ++++++++++++----- linux/{workcat.desktop => tncat.desktop} | 6 +- qml/Cat.qml | 75 +++++++++++- qml/CatMoods.qml | 13 ++ qml/CatWindow.qml | 79 ++++++++++++ qml/Chip.qml | 70 +++++++++++ qml/ChipWindow.qml | 34 ++++++ qml/Main.qml | 93 ++++----------- qml/OverlayWindow.qml | 119 ++++++++++++++++++ qml/SettingsWindow.qml | 55 +++++++-- src/appsettings.cpp | 40 +++++++ src/appsettings.h | 15 +++ src/catcontroller.cpp | 146 ++++++++++++++++++++--- src/catcontroller.h | 50 ++++++-- src/main.cpp | 13 +- src/platforminfo.cpp | 13 +- src/platforminfo.h | 21 +++- src/traycontroller.cpp | 8 +- 19 files changed, 817 insertions(+), 185 deletions(-) rename linux/{workcat.desktop => tncat.desktop} (73%) create mode 100644 qml/CatMoods.qml create mode 100644 qml/CatWindow.qml create mode 100644 qml/Chip.qml create mode 100644 qml/ChipWindow.qml create mode 100644 qml/OverlayWindow.qml diff --git a/CMakeLists.txt b/CMakeLists.txt index 6cac787..cc2d837 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,17 +1,17 @@ cmake_minimum_required(VERSION 3.21) -project(Workcat - VERSION 0.2.0 +project(TNCat + VERSION 0.3.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) +find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Quick QuickControls2 Widgets) +qt_standard_project_setup(REQUIRES 6.5) -qt_add_executable(workcat +qt_add_executable(tncat src/main.cpp src/catcontroller.h src/catcontroller.cpp src/traycontroller.h src/traycontroller.cpp @@ -19,44 +19,46 @@ qt_add_executable(workcat src/platforminfo.h src/platforminfo.cpp ) -target_include_directories(workcat PRIVATE src) +target_include_directories(tncat PRIVATE src) -# QML files live in qml/ but register at the module root (Workcat.Main etc.) -set(WORKCAT_QML Main Cat SettingsWindow) -foreach(qmlfile IN LISTS WORKCAT_QML) +# QML files live in qml/ but register at the module root (TNCat.Main etc.) +set(TNCAT_QML Main Cat Chip CatWindow ChipWindow OverlayWindow SettingsWindow CatMoods) +foreach(qmlfile IN LISTS TNCAT_QML) set_source_files_properties(qml/${qmlfile}.qml PROPERTIES QT_RESOURCE_ALIAS ${qmlfile}.qml) - list(APPEND WORKCAT_QML_FILES qml/${qmlfile}.qml) + list(APPEND TNCAT_QML_FILES qml/${qmlfile}.qml) endforeach() -qt_add_qml_module(workcat - URI Workcat +set_source_files_properties(qml/CatMoods.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE) + +qt_add_qml_module(tncat + URI TNCat VERSION 1.0 - QML_FILES ${WORKCAT_QML_FILES} + QML_FILES ${TNCAT_QML_FILES} ) -target_link_libraries(workcat PRIVATE +target_link_libraries(tncat PRIVATE Qt6::Core Qt6::Gui Qt6::Quick Qt6::QuickControls2 Qt6::Widgets) -set_target_properties(workcat PROPERTIES +set_target_properties(tncat 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_GUI_IDENTIFIER app.tncat.example + MACOSX_BUNDLE_BUNDLE_NAME TNCat 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 +install(TARGETS tncat BUNDLE DESTINATION . RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) if(UNIX AND NOT APPLE) - install(FILES linux/workcat.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) + install(FILES linux/tncat.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) endif() qt_generate_deploy_qml_app_script( - TARGET workcat + TARGET tncat OUTPUT_SCRIPT deploy_script MACOS_BUNDLE_POST_BUILD NO_UNSUPPORTED_PLATFORM_ERROR diff --git a/README.md b/README.md index 179d792..ba57890 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,41 @@ -# Workcat (Qt Quick / CMake example) +# TNCat -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. +A little cat that wanders along the bottom edge of the screen, sits down, grooms +itself, stretches, dashes off and takes naps. Every now and then a curled steel +chip skitters past and the cat goes after it. Drag it around, tap it to pet it, +and it purrs. -Inspired by [workcat.app](https://workcat.app/en/), rebuilt as a plain Qt 6 / -QML project. Primary target is **Linux / X11**; development happens on macOS and -both are supported by the same code. +It announces what it is doing in the machine's own language: `G00` when it +sprints, `G01` when it strolls, `M08` while it washes, `G28` when it stretches +back to reference, `M30` when it goes to sleep. + +The name is `TNC` plus `cat`, because the intended home is a HEIDENHAIN control +(TNC7, TNC 640). Primary target is therefore **Linux / X11**; development happens +on macOS and both are supported by the same code. + +Inspired by [workcat.app](https://workcat.app/en/), rebuilt from scratch as a +plain Qt 6 / QML project. ## Layout | Path | Purpose | |---|---| -| `CMakeLists.txt` | `qt_add_executable` + `qt_add_qml_module` (URI `Workcat`), macOS bundle, `.desktop` file on Linux | +| `CMakeLists.txt` | `qt_add_executable` + `qt_add_qml_module` (URI `TNCat`), macOS bundle, `.desktop` file on Linux | | `src/main.cpp` | Wires `AppSettings` and `CatController` into the QML engine, warns on Wayland | -| `src/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/catcontroller.*` | Mood machine (walk, run, sit, groom, stretch, sleep, love, alert, chase, bat), movement tick, chip chase, cursor tracking | +| `src/appsettings.*` | `QSettings`-backed coat, size, pace, ground line, eye tracking, pet counter | +| `src/platforminfo.*` | QML singleton `Platform`: platform name plus the X11 escape hatches | | `src/traycontroller.*` | Tray icon and menu, silently skipped when the desktop has no tray | -| `qml/Main.qml` | Frameless, transparent, always-on-top window that *is* the cat | +| `qml/Main.qml` | Non-visual root that picks the window layout for the platform | +| `qml/CatWindow.qml`, `qml/ChipWindow.qml` | One small window each, positioned by the app. X11, macOS, Windows | +| `qml/OverlayWindow.qml` | One screen-filling click-through window holding both. Wayland | | `qml/Cat.qml` | The drawing: rounded primitives plus `Shape` paths for tail, ears and mouth | -| `qml/SettingsWindow.qml` | Coat swatches, size, pace, eye tracking, trick buttons | +| `qml/Chip.qml` | The curled chip, a stroked `Shape` spiral | +| `qml/SettingsWindow.qml` | Coat swatches, size, pace, ground line, eye tracking, codes, chips, tricks | The cat is drawn in a fixed 100 x 90 design space and scaled by `width / 100`, -so every size setting stays crisp. There are no image assets. +so every size setting stays crisp. There are no image assets, which keeps the +whole thing to one binary plus Qt. ## Build @@ -43,34 +55,70 @@ cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release 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. +Needs Qt 6.5 or newer. 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: +## Environment switches | 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. | +| `TNCAT_X11_BYPASS=1` | Adds `Qt::X11BypassWindowManagerHint`, making an override-redirect window. Use when the window manager refuses to keep the cat on top or shows it in the taskbar. | +| `TNCAT_OVERLAY=1` | Forces the screen-filling overlay layout that Wayland needs, for testing it on X11. | +| `TNCAT_OPAQUE=1` | Paints a solid background instead of relying on transparency. Use when no compositor runs and the cat sits in a black box. | +| `TNCAT_POSE=Sitting` | Freezes the cat in one mood. Handy for screenshots. Accepts any `CatController::Mood` name. | +| `QT_QUICK_BACKEND=software` | Qt Quick's software rasteriser, for machines without a usable OpenGL driver. `Shape` falls back to its geometry renderer on its own. | -Wayland cannot let a client position its own window, so the cat would stay -wherever the compositor puts it. The app prints a warning and suggests -`QT_QPA_PLATFORM=xcb`. +## Two window layouts -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. +On X11, macOS and Windows the cat and the chip each get their own small, +frameless, always-on-top window, and the app moves those windows around. Nothing +else on screen is covered and no clicks are intercepted. + +Wayland does not let a client place its own window, so that layout cannot work +there. In overlay mode the app instead opens one screen-filling transparent +window, moves the cat inside it, and restricts the window's input region to the +cat's rectangle with `QWindow::setMask`, so every click outside the cat lands on +whatever is behind. The mask is refreshed about 30 times a second while the cat +walks. + +Overlay mode switches on automatically on Wayland. `TNCAT_OVERLAY=1` turns it on +anywhere for testing. It is not the default elsewhere because macOS ignores the +input mask, which would make the overlay swallow every click on the desktop. + +## Running on a HEIDENHAIN control + +Untested on real hardware so far. What the code already does for that target: + +- **X11 today, Wayland later.** Both layouts are in the build; the platform picks + one at startup. +- **No tray required.** If the desktop has no status-notifier host, the tray icon + is skipped and the cat itself stays the entry point: long-press or right-click + opens the settings window. +- **Touch works without hover.** Tap pets the cat, drag picks it up. The blush on + hover is a mouse-only extra, not a requirement. +- **Ground line setting.** The cat normally stands on the bottom of the available + screen area. On a control whose lower edge carries softkeys, raise the ground + line in the settings so the cat walks above them. + +What still needs checking on the actual machine: + +- **Qt version and libraries on HEROS.** Build against what the control has, or + ship a self-contained bundle with the Qt libraries next to the binary. Building + on a newer distribution than the control runs will fail at load time on glibc. +- **Graphics stack.** If Qt Quick cannot get an OpenGL context, start with + `QT_QUICK_BACKEND=software`. The drawing is simple enough for that path. +- **Window management.** The NC user interface runs fullscreen. Try the default + flags first, then `TNCAT_X11_BYPASS=1` if the cat disappears behind it. +- **Installing anything on a control is subject to your site's rules.** Keep the + cat away from anything the operator needs to read, and clear it with whoever + administers the machine before it runs on production hardware. ## Interaction | Input | Result | |---|---| -| Click | Pet the cat, it closes its eyes and purrs | +| Click or tap | Pet the cat, it closes its eyes and purrs | | Drag | Pick the cat up and put it somewhere else | | Hover | Blush and purr wiggle | | Double-click or right-click | Settings window | diff --git a/linux/workcat.desktop b/linux/tncat.desktop similarity index 73% rename from linux/workcat.desktop rename to linux/tncat.desktop index 4de955b..43e8942 100644 --- a/linux/workcat.desktop +++ b/linux/tncat.desktop @@ -1,8 +1,8 @@ [Desktop Entry] Type=Application -Name=Workcat +Name=TNCat Comment=A cute desktop cat -Exec=workcat -Icon=workcat +Exec=tncat +Icon=tncat Categories=Utility;Amusement; StartupNotify=false diff --git a/qml/Cat.qml b/qml/Cat.qml index 22149aa..a2ffb75 100644 --- a/qml/Cat.qml +++ b/qml/Cat.qml @@ -13,6 +13,16 @@ Item { property bool lifted: false property bool petted: false property point look: Qt.point(0, 0) + property bool showCodes: true + + // The cat announces what it is doing in the machine's own language. + readonly property string moodCode: mood === "running" || mood === "chasing" ? "G00" + : mood === "walking" ? "G01" + : mood === "sitting" ? "M00" + : mood === "grooming" ? "M08" + : mood === "stretching" ? "G28" + : mood === "sleeping" ? "M30" + : "" readonly property bool dark: coat.hslLightness < 0.45 readonly property color coatShade: Qt.darker(coat, 1.12) @@ -23,7 +33,7 @@ Item { readonly property color blush: "#f77d95" readonly property bool walking: mood === "walking" - readonly property bool running: mood === "running" + readonly property bool running: mood === "running" || mood === "chasing" readonly property bool moving: (walking || running) && !lifted readonly property bool sleeping: mood === "sleeping" readonly property bool happy: mood === "loving" || mood === "grooming" || mood === "stretching" || petted @@ -299,8 +309,8 @@ Item { id: eyeBall anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter - width: eye.width * (cat.mood === "alert" ? 1.15 : 1) - height: eye.height * eye.open * (cat.mood === "alert" ? 1.15 : 1) + width: eye.width * (cat.mood === "alert" || cat.mood === "batting" ? 1.15 : 1) + height: eye.height * eye.open * (cat.mood === "alert" || cat.mood === "batting" ? 1.15 : 1) radius: width / 2 color: cat.eyeColor visible: eye.open > 0.08 @@ -449,14 +459,17 @@ Item { // speech bubble: "!" / "z" / "♥" Text { id: bubble - readonly property string glyph: cat.mood === "alert" ? "!" : cat.sleeping ? "z" : (cat.mood === "loving" || cat.petted) ? "♥" : "" + readonly property string glyph: cat.mood === "alert" || cat.mood === "batting" ? "!" + : cat.sleeping ? "z" + : (cat.mood === "loving" || cat.petted) ? "♥" : "" x: 82; y: 4 text: glyph visible: glyph.length > 0 - font.pixelSize: cat.mood === "alert" ? 22 : 16 + font.pixelSize: cat.mood === "alert" || cat.mood === "batting" ? 22 : 16 font.bold: true font.family: "Helvetica Neue" - color: cat.mood === "alert" ? "#f2a33c" : (glyph === "♥" ? "#f25f7a" : cat.ink) + color: cat.mood === "alert" || cat.mood === "batting" ? "#f2a33c" + : (glyph === "♥" ? "#ff5b7f" : cat.ink) transform: Scale { xScale: cat.facingRight ? 1 : -1; origin.x: bubble.width / 2 } Accessible.ignored: true @@ -474,6 +487,50 @@ Item { } } + // Machine code tag above the head, flashed whenever the mood changes + Item { + id: codeTag + x: 30; y: 2 + width: codeBg.width + height: codeBg.height + opacity: 0 + visible: cat.showCodes && opacity > 0.01 + transformOrigin: Item.Center + transform: Scale { xScale: cat.facingRight ? 1 : -1; origin.x: codeTag.width / 2 } + z: 10 + scale: cat.unit + Accessible.ignored: true + + Rectangle { + id: codeBg + width: codeText.implicitWidth + 10 + height: codeText.implicitHeight + 6 + radius: 4 + color: "#242833" + opacity: 0.92 + antialiasing: true + + Text { + id: codeText + anchors.centerIn: parent + text: cat.moodCode + color: "#8fd6ff" + font.pixelSize: 11 + font.bold: true + font.family: "Menlo" + } + } + + SequentialAnimation { + id: codeFlash + NumberAnimation { target: codeTag; property: "opacity"; to: 1; duration: 160 } + PauseAnimation { duration: 1500 } + NumberAnimation { target: codeTag; property: "opacity"; to: 0; duration: 500 } + } + } + + onMoodCodeChanged: if (showCodes && moodCode.length > 0) codeFlash.restart() + states: [ State { name: "running" @@ -509,6 +566,12 @@ Item { PropertyChanges { head.y: 24; head.x: 44; head.rotation: 4 } PropertyChanges { cat.tailC1X: 24; cat.tailC1Y: 86; cat.tailC2X: 60; cat.tailC2Y: 90; cat.tailX: 76; cat.tailY: 78 } }, + State { + name: "batting" + when: cat.mood === "batting" && !cat.lifted + PropertyChanges { body.rotation: -10; body.x: 26 } + PropertyChanges { head.y: 10; head.x: 50 } + }, State { name: "lifted" when: cat.lifted diff --git a/qml/CatMoods.qml b/qml/CatMoods.qml new file mode 100644 index 0000000..f8575b0 --- /dev/null +++ b/qml/CatMoods.qml @@ -0,0 +1,13 @@ +pragma Singleton +import QtQuick +import TNCat + +// Maps CatController::Mood onto the strings Cat.qml understands. +QtObject { + readonly property var names: ["walking", "running", "sitting", "grooming", "stretching", + "sleeping", "loving", "alert", "chasing", "batting"] + + function name(mood) { + return names[mood] !== undefined ? names[mood] : "sitting" + } +} diff --git a/qml/CatWindow.qml b/qml/CatWindow.qml new file mode 100644 index 0000000..cc960d6 --- /dev/null +++ b/qml/CatWindow.qml @@ -0,0 +1,79 @@ +pragma ComponentBehavior: Bound +import QtQuick +import TNCat + +// One small window that is exactly the cat. Used wherever a client may place +// its own window, which is everything except Wayland. +Window { + id: win + + required property CatController cat + required property AppSettings settings + + signal settingsRequested() + + width: settings.catSize + height: Math.round(settings.catSize * 0.9) + x: cat.x + y: Screen.desktopAvailableHeight - height - settings.bottomMargin + color: Platform.opaqueBackground ? settings.coat : "transparent" + flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool + | Qt.NoDropShadowWindowHint | Qt.WindowDoesNotAcceptFocus + | (Platform.bypassWindowManager ? Qt.X11BypassWindowManagerHint : 0) + visible: true + title: qsTr("TNCat") + + Component.onCompleted: { + cat.stageWidth = Screen.desktopAvailableWidth + cat.catWidth = width + } + onWidthChanged: cat.catWidth = width + Screen.onDesktopAvailableWidthChanged: cat.stageWidth = Screen.desktopAvailableWidth + + // The eyes need to know where the face sits on the desktop. + Binding { + target: win.cat + property: "catCenter" + value: Qt.point(win.x + win.width / 2, win.y + win.height / 2) + } + + Cat { + anchors.fill: parent + coat: win.settings.coat + facingRight: win.cat.facingRight + mood: CatMoods.name(win.cat.mood) + lifted: win.cat.dragging + petted: win.cat.hovered && !win.cat.dragging + look: win.cat.look + showCodes: win.settings.showCodes + + Accessible.role: Accessible.Button + Accessible.name: qsTr("TNCat") + } + + HoverHandler { + onHoveredChanged: win.cat.hovered = hovered + } + + DragHandler { + target: null + onActiveChanged: win.cat.dragging = active + onCentroidChanged: { + if (active) + win.cat.x = Math.max(0, Math.min(win.cat.stageWidth - win.width, + win.x + centroid.position.x - centroid.pressPosition.x)) + } + } + + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: win.cat.pet() + onDoubleTapped: win.settingsRequested() + onLongPressed: win.settingsRequested() + } + + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: win.settingsRequested() + } +} diff --git a/qml/Chip.qml b/qml/Chip.qml new file mode 100644 index 0000000..f02a001 --- /dev/null +++ b/qml/Chip.qml @@ -0,0 +1,70 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Shapes + +// A curled steel chip, the kind that comes off a milling cutter. +// Design space is 60 x 44 units. +Item { + id: chip + + property int kick: 0 + + readonly property real unit: width / 60 + + implicitWidth: 60 + implicitHeight: 44 + + Item { + width: 60 + height: 44 + transformOrigin: Item.TopLeft + scale: chip.unit + + Rectangle { // ground shadow + x: 14; y: 36 + width: 32; height: 6 + radius: 3 + color: "#000000" + opacity: 0.18 + antialiasing: true + } + + Item { + id: curl + x: 30; y: 22 + width: 0; height: 0 + + Shape { + x: -30; y: -22 + width: 60; height: 44 + preferredRendererType: Shape.CurveRenderer + + ShapePath { // the curl + strokeColor: "#8d99ae" + strokeWidth: 5 + capStyle: ShapePath.RoundCap + fillColor: "transparent" + startX: 12; startY: 32 + PathCubic { control1X: 2; control1Y: 20; control2X: 14; control2Y: 6; x: 30; y: 10 } + PathCubic { control1X: 44; control1Y: 14; control2X: 48; control2Y: 30; x: 38; y: 34 } + PathCubic { control1X: 30; control1Y: 37; control2X: 26; control2Y: 28; x: 32; y: 24 } + } + ShapePath { // highlight along the outer edge + strokeColor: "#dfe6ee" + strokeWidth: 1.6 + capStyle: ShapePath.RoundCap + fillColor: "transparent" + startX: 13; startY: 29 + PathCubic { control1X: 5; control1Y: 19; control2X: 16; control2Y: 8; x: 30; y: 12 } + } + } + + RotationAnimation on rotation { + running: true + loops: Animation.Infinite + from: 0; to: 360 + duration: 2600 + } + } + } +} diff --git a/qml/ChipWindow.qml b/qml/ChipWindow.qml new file mode 100644 index 0000000..a7edb4c --- /dev/null +++ b/qml/ChipWindow.qml @@ -0,0 +1,34 @@ +pragma ComponentBehavior: Bound +import QtQuick +import TNCat + +// The chip gets its own little click-through window so it can skitter away +// from the cat across the whole screen. +Window { + id: win + + required property CatController cat + required property AppSettings settings + + readonly property int chipSize: Math.round(settings.catSize * 0.45) + + width: chipSize + height: Math.round(chipSize * 0.73) + x: cat.chipX - width / 2 + y: Screen.desktopAvailableHeight - height - settings.bottomMargin + color: "transparent" + flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool + | Qt.NoDropShadowWindowHint | Qt.WindowDoesNotAcceptFocus + | Qt.WindowTransparentForInput + | (Platform.bypassWindowManager ? Qt.X11BypassWindowManagerHint : 0) + visible: cat.chipVisible && !Platform.opaqueBackground + + Behavior on x { + NumberAnimation { duration: 320; easing.type: Easing.OutQuad } + } + + Chip { + anchors.fill: parent + kick: win.cat.chipKick + } +} diff --git a/qml/Main.qml b/qml/Main.qml index 7ede109..d5ccaa7 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -1,87 +1,42 @@ pragma ComponentBehavior: Bound import QtQuick -import Workcat +import TNCat -Window { - id: root +// Root is not a window: which windows exist depends on the platform. +QtObject { + id: app required property CatController cat required property AppSettings settings - readonly property var moodNames: ["walking", "running", "sitting", "grooming", "stretching", "sleeping", "loving", "alert"] - readonly property real floorY: Screen.desktopAvailableHeight - height - function openSettings() { - settingsLoader.active = true - if (settingsLoader.status === Loader.Ready) - (settingsLoader.item as SettingsWindow).show() + settingsWindow.show() + settingsWindow.raise() + settingsWindow.requestActivate() } - width: settings.catSize - height: Math.round(settings.catSize * 0.9) - x: cat.x - y: floorY - color: Platform.opaqueBackground ? settings.coat : "transparent" - flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool - | Qt.NoDropShadowWindowHint | Qt.WindowDoesNotAcceptFocus - | (Platform.bypassWindowManager ? Qt.X11BypassWindowManagerHint : 0) - visible: true - title: qsTr("Workcat") - - Component.onCompleted: { - cat.screenWidth = Screen.desktopAvailableWidth - cat.catWidth = width - } - onWidthChanged: cat.catWidth = width - Screen.onDesktopAvailableWidthChanged: cat.screenWidth = Screen.desktopAvailableWidth - - Cat { - id: sprite - anchors.fill: parent - coat: root.settings.coat - facingRight: root.cat.facingRight - mood: root.moodNames[root.cat.mood] - lifted: root.cat.dragging - petted: root.cat.hovered && !root.cat.dragging - look: root.cat.look - - Accessible.role: Accessible.Button - Accessible.name: qsTr("Workcat") + readonly property CatWindow catWindow: CatWindow { + cat: app.cat + settings: app.settings + visible: !Platform.overlayMode + onSettingsRequested: app.openSettings() } - HoverHandler { - onHoveredChanged: root.cat.hovered = hovered + readonly property ChipWindow chipWindow: ChipWindow { + cat: app.cat + settings: app.settings + visible: !Platform.overlayMode && app.cat.chipVisible } - DragHandler { - target: null - onActiveChanged: root.cat.dragging = active - onCentroidChanged: { - if (active) - root.cat.x = Math.max(0, Math.min(root.cat.screenWidth - root.width, - root.x + centroid.position.x - centroid.pressPosition.x)) - } + readonly property OverlayWindow overlayWindow: OverlayWindow { + cat: app.cat + settings: app.settings + active: Platform.overlayMode + onSettingsRequested: app.openSettings() } - TapHandler { - acceptedButtons: Qt.LeftButton - onTapped: root.cat.pet() - onDoubleTapped: root.openSettings() - } - - TapHandler { - acceptedButtons: Qt.RightButton - onTapped: root.openSettings() - } - - Loader { - id: settingsLoader - active: false - sourceComponent: SettingsWindow { - cat: root.cat - settings: root.settings - onClosing: settingsLoader.active = false - } - onLoaded: (item as SettingsWindow).show() + readonly property SettingsWindow settingsWindow: SettingsWindow { + cat: app.cat + settings: app.settings } } diff --git a/qml/OverlayWindow.qml b/qml/OverlayWindow.qml new file mode 100644 index 0000000..36c3a17 --- /dev/null +++ b/qml/OverlayWindow.qml @@ -0,0 +1,119 @@ +pragma ComponentBehavior: Bound +import QtQuick +import TNCat + +// One screen-filling, click-through window that holds the cat and the chip. +// Wayland does not let a client place its own window, so there the cat moves +// inside this overlay instead of the window moving across the desktop. +Window { + id: win + + required property CatController cat + required property AppSettings settings + + signal settingsRequested() + + // Fullscreen is a visibility, not a visible flag, so the mode switch has to + // go through visibility or the two fight each other. + property bool active: false + + readonly property int catW: settings.catSize + readonly property int catH: Math.round(settings.catSize * 0.9) + readonly property real groundY: height - catH - settings.bottomMargin + + // Only the cat itself should swallow clicks; everything else falls through. + function refreshInputRegion() { + Platform.setInputRegion(win, Qt.rect(Math.round(cat.x), Math.round(groundY), catW, catH)) + } + + visibility: active ? Window.FullScreen : Window.Hidden + color: "transparent" + flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool + | Qt.NoDropShadowWindowHint | Qt.WindowDoesNotAcceptFocus + title: qsTr("TNCat") + + onActiveChanged: { + if (!active) + return + cat.stageWidth = width + cat.catWidth = catW + refreshInputRegion() + } + onWidthChanged: cat.stageWidth = width + onCatWChanged: cat.catWidth = catW + + Binding { + target: win.cat + property: "catCenter" + value: Qt.point(win.x + win.cat.x + win.catW / 2, win.y + win.groundY + win.catH / 2) + } + + // Coalesce mask updates: one X11 round trip per frame of walking is plenty. + Timer { + interval: 33 + running: win.active + repeat: true + onTriggered: win.refreshInputRegion() + } + + Chip { + x: win.cat.chipX - width / 2 + y: win.height - height - win.settings.bottomMargin + width: Math.round(win.settings.catSize * 0.45) + height: Math.round(width * 0.73) + visible: win.cat.chipVisible + kick: win.cat.chipKick + + Behavior on x { + NumberAnimation { duration: 320; easing.type: Easing.OutQuad } + } + } + + Item { + id: catItem + x: win.cat.x + y: win.groundY + width: win.catW + height: win.catH + + Cat { + anchors.fill: parent + coat: win.settings.coat + facingRight: win.cat.facingRight + mood: CatMoods.name(win.cat.mood) + lifted: win.cat.dragging + petted: win.cat.hovered && !win.cat.dragging + look: win.cat.look + showCodes: win.settings.showCodes + + Accessible.role: Accessible.Button + Accessible.name: qsTr("TNCat") + } + + HoverHandler { + onHoveredChanged: win.cat.hovered = hovered + } + + DragHandler { + target: null + onActiveChanged: win.cat.dragging = active + onCentroidChanged: { + if (active) + win.cat.x = Math.max(0, Math.min(win.cat.stageWidth - win.catW, + catItem.x + centroid.position.x - centroid.pressPosition.x)) + } + } + + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: win.cat.pet() + onDoubleTapped: win.settingsRequested() + onLongPressed: win.settingsRequested() + } + + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: win.settingsRequested() + } + } +} diff --git a/qml/SettingsWindow.qml b/qml/SettingsWindow.qml index b169973..60c6598 100644 --- a/qml/SettingsWindow.qml +++ b/qml/SettingsWindow.qml @@ -2,7 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Controls.Basic import QtQuick.Layouts -import Workcat +import TNCat Window { id: win @@ -20,10 +20,10 @@ Window { ] width: 440 - height: 600 + height: 700 minimumWidth: 380 - minimumHeight: 520 - title: qsTr("Workcat") + minimumHeight: 600 + title: qsTr("TNCat") color: "#faf7f2" flags: Qt.Dialog | Qt.WindowStaysOnTopHint @@ -35,20 +35,24 @@ Window { RowLayout { Layout.fillWidth: true spacing: 16 - Cat { + Loader { Layout.preferredWidth: 110 Layout.preferredHeight: 99 - coat: win.settings.coat - mood: "sitting" - petted: previewHover.hovered - HoverHandler { id: previewHover } + active: win.visible + sourceComponent: Cat { + coat: win.settings.coat + mood: "sitting" + showCodes: false + petted: previewHover.hovered + HoverHandler { id: previewHover } + } } ColumnLayout { Layout.fillWidth: true spacing: 4 Label { Layout.fillWidth: true - text: qsTr("Workcat") + text: qsTr("TNCat") font.pixelSize: 26 font.bold: true color: "#2f2a36" @@ -122,6 +126,22 @@ Window { onMoved: win.settings.walkSpeed = value } + Label { text: qsTr("Ground line") } + RowLayout { + Layout.fillWidth: true + Slider { + id: marginSlider + Layout.fillWidth: true + from: 0; to: 600; stepSize: 10 + value: win.settings.bottomMargin + onMoved: win.settings.bottomMargin = value + } + Label { + Layout.preferredWidth: 54 + text: qsTr("%1 px").arg(Math.round(marginSlider.value)) + } + } + Label { text: qsTr("Eyes") } Switch { text: qsTr("Follow the mouse") @@ -129,6 +149,20 @@ Window { onToggled: win.settings.followCursor = checked } + Label { text: qsTr("Codes") } + Switch { + text: qsTr("Show G-code above the cat") + checked: win.settings.showCodes + onToggled: win.settings.showCodes = checked + } + + Label { text: qsTr("Chips") } + Switch { + text: qsTr("Let a chip skitter past now and then") + checked: win.settings.chipsEnabled + onToggled: win.settings.chipsEnabled = checked + } + Label { text: qsTr("Cat") } Switch { text: checked ? qsTr("Napping") : qsTr("Awake") @@ -148,6 +182,7 @@ Window { Button { text: qsTr("Stretch"); onClicked: win.cat.doTrick(CatController.Stretching) } Button { text: qsTr("Dash"); onClicked: win.cat.doTrick(CatController.Running) } Button { text: qsTr("Nap"); onClicked: win.cat.doTrick(CatController.Sleeping) } + Button { text: qsTr("Toss a chip"); onClicked: win.cat.tossChip() } } } diff --git a/src/appsettings.cpp b/src/appsettings.cpp index 19ee101..32dac54 100644 --- a/src/appsettings.cpp +++ b/src/appsettings.cpp @@ -59,6 +59,46 @@ void AppSettings::setFollowCursor(bool follow) emit followCursorChanged(); } +int AppSettings::bottomMargin() const +{ + return m_settings.value("bottomMargin", 0).toInt(); +} + +void AppSettings::setBottomMargin(int pixels) +{ + pixels = qBound(0, pixels, 600); + if (pixels == bottomMargin()) + return; + m_settings.setValue("bottomMargin", pixels); + emit bottomMarginChanged(); +} + +bool AppSettings::showCodes() const +{ + return m_settings.value("showCodes", true).toBool(); +} + +void AppSettings::setShowCodes(bool show) +{ + if (show == showCodes()) + return; + m_settings.setValue("showCodes", show); + emit showCodesChanged(); +} + +bool AppSettings::chipsEnabled() const +{ + return m_settings.value("chipsEnabled", true).toBool(); +} + +void AppSettings::setChipsEnabled(bool enabled) +{ + if (enabled == chipsEnabled()) + return; + m_settings.setValue("chipsEnabled", enabled); + emit chipsEnabledChanged(); +} + int AppSettings::petCount() const { return m_settings.value("petCount", 0).toInt(); diff --git a/src/appsettings.h b/src/appsettings.h index 29056b9..21072ef 100644 --- a/src/appsettings.h +++ b/src/appsettings.h @@ -15,6 +15,9 @@ class AppSettings : public QObject 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 bottomMargin READ bottomMargin WRITE setBottomMargin NOTIFY bottomMarginChanged) + Q_PROPERTY(bool showCodes READ showCodes WRITE setShowCodes NOTIFY showCodesChanged) + Q_PROPERTY(bool chipsEnabled READ chipsEnabled WRITE setChipsEnabled NOTIFY chipsEnabledChanged) Q_PROPERTY(int petCount READ petCount NOTIFY petCountChanged) public: @@ -32,6 +35,15 @@ public: bool followCursor() const; void setFollowCursor(bool follow); + int bottomMargin() const; + void setBottomMargin(int pixels); + + bool showCodes() const; + void setShowCodes(bool show); + + bool chipsEnabled() const; + void setChipsEnabled(bool enabled); + int petCount() const; void incrementPetCount(); @@ -40,6 +52,9 @@ signals: void catSizeChanged(); void walkSpeedChanged(); void followCursorChanged(); + void bottomMarginChanged(); + void showCodesChanged(); + void chipsEnabledChanged(); void petCountChanged(); private: diff --git a/src/catcontroller.cpp b/src/catcontroller.cpp index e57c656..1c91ca4 100644 --- a/src/catcontroller.cpp +++ b/src/catcontroller.cpp @@ -4,7 +4,12 @@ #include #include #include -#include + +namespace { +constexpr qreal kChipReach = 26.0; // how close the nose has to get +constexpr int kChipMinDelayMs = 45000; +constexpr int kChipMaxDelayMs = 150000; +} CatController::CatController(AppSettings *settings, QObject *parent) : QObject(parent) @@ -23,8 +28,11 @@ CatController::CatController(AppSettings *settings, QObject *parent) 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"); + m_chipTimer.setSingleShot(true); + connect(&m_chipTimer, &QTimer::timeout, this, &CatController::tossChip); + + // TNCAT_POSE=Sleeping freezes the cat in one mood (handy for screenshots). + const QByteArray pose = qgetenv("TNCAT_POSE"); if (!pose.isEmpty()) { const QMetaEnum me = QMetaEnum::fromType(); bool ok = false; @@ -34,13 +42,15 @@ CatController::CatController(AppSettings *settings, QObject *parent) setMood(static_cast(value)); } } - if (!m_frozen) + if (!m_frozen) { pickNextMood(); + scheduleChip(); + } } -void CatController::setWindow(QWindow *window) +qreal CatController::catNose() const { - m_window = window; + return m_facingRight ? m_x + m_catWidth * 0.78 : m_x + m_catWidth * 0.22; } void CatController::setX(qreal x) @@ -51,12 +61,12 @@ void CatController::setX(qreal x) emit xChanged(); } -void CatController::setScreenWidth(qreal width) +void CatController::setStageWidth(qreal width) { - if (qFuzzyCompare(width, m_screenWidth)) + if (qFuzzyCompare(width, m_stageWidth)) return; - m_screenWidth = width; - emit screenWidthChanged(); + m_stageWidth = width; + emit stageWidthChanged(); } void CatController::setCatWidth(qreal width) @@ -67,6 +77,14 @@ void CatController::setCatWidth(qreal width) emit catWidthChanged(); } +void CatController::setCatCenter(const QPointF ¢er) +{ + if (center == m_catCenter) + return; + m_catCenter = center; + emit catCenterChanged(); +} + void CatController::setPaused(bool paused) { if (paused == m_paused) @@ -75,9 +93,12 @@ void CatController::setPaused(bool paused) emit pausedChanged(); if (paused) { m_moodTimer.stop(); + m_chipTimer.stop(); + endChase(); setMood(Mood::Sleeping); } else { pickNextMood(); + scheduleChip(); } } @@ -110,6 +131,7 @@ void CatController::pet() if (m_paused || m_frozen) return; m_settings->incrementPetCount(); + endChase(); holdMood(Mood::Loving, 1800); } @@ -118,6 +140,7 @@ void CatController::doTrick(Mood mood) if (m_paused) return; m_frozen = false; + endChase(); holdMood(mood, mood == Mood::Sleeping ? 15000 : 3500); } @@ -127,17 +150,85 @@ void CatController::holdMood(Mood mood, int ms) m_moodTimer.start(ms); } +// A curled metal chip skitters in from one side and the cat goes after it. +void CatController::tossChip() +{ + if (m_paused || m_frozen || m_chipVisible) + return; + if (!m_settings->chipsEnabled()) { + scheduleChip(); + return; + } + + auto *rng = QRandomGenerator::global(); + const bool fromLeft = rng->bounded(2) == 0; + const qreal margin = qMin(200.0, m_stageWidth * 0.2); + setChipX(fromLeft ? margin : m_stageWidth - margin); + + m_batsLeft = rng->bounded(2, 5); + m_chipVisible = true; + emit chipVisibleChanged(); + + m_moodTimer.stop(); + setMood(Mood::Chasing); +} + +void CatController::batChip() +{ + auto *rng = QRandomGenerator::global(); + ++m_chipKick; + emit chipKickChanged(); + + const qreal distance = rng->bounded(140, 380); + qreal next = m_chipX + (m_facingRight ? distance : -distance); + const qreal edge = 40.0; + if (next < edge || next > m_stageWidth - edge) { + next = qBound(edge, next, m_stageWidth - edge); + --m_batsLeft; // a chip against the wall is nearly done for + } + setChipX(next); + + if (--m_batsLeft <= 0) { + endChase(); + holdMood(Mood::Sitting, 2500); // pleased with itself + scheduleChip(); + } else { + holdMood(Mood::Batting, 420); + } +} + +void CatController::endChase() +{ + if (!m_chipVisible) + return; + m_chipVisible = false; + m_batsLeft = 0; + emit chipVisibleChanged(); +} + +void CatController::scheduleChip() +{ + m_chipTimer.start(QRandomGenerator::global()->bounded(kChipMinDelayMs, kChipMaxDelayMs)); +} + +void CatController::setChipX(qreal x) +{ + if (qFuzzyCompare(x, m_chipX)) + return; + m_chipX = x; + emit chipXChanged(); +} + void CatController::trackCursor() { - if (!m_window || !m_settings->followCursor()) { + if (m_catCenter.isNull() || !m_settings->followCursor()) { if (!m_look.isNull()) { m_look = {}; emit lookChanged(); } return; } - const QPointF center = m_window->geometry().center(); - QPointF delta = QCursor::pos() - center; + QPointF delta = QCursor::pos() - m_catCenter; if (!m_facingRight) delta.setX(-delta.x()); const qreal range = 400.0; @@ -154,14 +245,28 @@ void CatController::tick() 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) + if (m_dragging || m_paused || m_frozen) + return; + + const qreal maxX = qMax(0.0, m_stageWidth - m_catWidth); + + if (m_mood == Mood::Chasing) { + const qreal delta = m_chipX - catNose(); + setFacingRight(delta >= 0); + if (qAbs(delta) <= kChipReach) { + batChip(); + return; + } + const qreal step = m_settings->walkSpeed() * 4.0 * dt; + setX(qBound(0.0, m_x + (delta > 0 ? step : -step), maxX)); + return; + } + + if (m_mood != Mood::Walking && m_mood != Mood::Running) return; 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); + qreal next = m_x + (m_facingRight ? speed * dt : -speed * dt); if (next >= maxX) { next = maxX; setFacingRight(false); @@ -196,6 +301,11 @@ void CatController::pickNextMood() { if (m_paused || m_dragging || m_frozen) return; + if (m_chipVisible) { // a chase is still on, keep after the chip + setMood(Mood::Chasing); + return; + } + auto *rng = QRandomGenerator::global(); const int roll = rng->bounded(100); diff --git a/src/catcontroller.h b/src/catcontroller.h index b569347..eb5132d 100644 --- a/src/catcontroller.h +++ b/src/catcontroller.h @@ -7,10 +7,9 @@ #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. +// Drives the cat: wanders along its ground line, sits, grooms, stretches and +// naps, chases the odd metal chip, and reacts to petting and dragging. class CatController : public QObject { Q_OBJECT @@ -18,8 +17,9 @@ class CatController : public QObject 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 stageWidth READ stageWidth WRITE setStageWidth NOTIFY stageWidthChanged) Q_PROPERTY(qreal catWidth READ catWidth WRITE setCatWidth NOTIFY catWidthChanged) + Q_PROPERTY(QPointF catCenter READ catCenter WRITE setCatCenter NOTIFY catCenterChanged) Q_PROPERTY(bool facingRight READ facingRight NOTIFY facingRightChanged) Q_PROPERTY(Mood mood READ mood NOTIFY moodChanged) Q_PROPERTY(bool paused READ paused WRITE setPaused NOTIFY pausedChanged) @@ -27,23 +27,29 @@ class CatController : public QObject Q_PROPERTY(bool hovered READ hovered WRITE setHovered NOTIFY hoveredChanged) Q_PROPERTY(QPointF look READ look NOTIFY lookChanged) + Q_PROPERTY(bool chipVisible READ chipVisible NOTIFY chipVisibleChanged) + Q_PROPERTY(qreal chipX READ chipX NOTIFY chipXChanged) + Q_PROPERTY(int chipKick READ chipKick NOTIFY chipKickChanged) + public: - enum class Mood { Walking, Running, Sitting, Grooming, Stretching, Sleeping, Loving, Alert }; + enum class Mood { Walking, Running, Sitting, Grooming, Stretching, Sleeping, Loving, Alert, Chasing, Batting }; Q_ENUM(Mood) 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 stageWidth() const { return m_stageWidth; } + void setStageWidth(qreal width); qreal catWidth() const { return m_catWidth; } void setCatWidth(qreal width); + // Where the cat's face is on the desktop, so the eyes can find the cursor. + QPointF catCenter() const { return m_catCenter; } + void setCatCenter(const QPointF ¢er); + bool facingRight() const { return m_facingRight; } Mood mood() const { return m_mood; } @@ -58,39 +64,54 @@ public: QPointF look() const { return m_look; } + bool chipVisible() const { return m_chipVisible; } + qreal chipX() const { return m_chipX; } + int chipKick() const { return m_chipKick; } + Q_INVOKABLE void pet(); Q_INVOKABLE void doTrick(Mood mood); + Q_INVOKABLE void tossChip(); signals: void xChanged(); - void screenWidthChanged(); + void stageWidthChanged(); void catWidthChanged(); + void catCenterChanged(); void facingRightChanged(); void moodChanged(); void pausedChanged(); void draggingChanged(); void hoveredChanged(); void lookChanged(); + void chipVisibleChanged(); + void chipXChanged(); + void chipKickChanged(); private: void tick(); void trackCursor(); void setMood(Mood mood); void setFacingRight(bool right); + void setChipX(qreal x); void pickNextMood(); void holdMood(Mood mood, int ms); + void batChip(); + void endChase(); + void scheduleChip(); + qreal catNose() const; AppSettings *m_settings; - QWindow *m_window = nullptr; QTimer m_tickTimer; QTimer m_moodTimer; QTimer m_cursorTimer; + QTimer m_chipTimer; QElapsedTimer m_clock; qint64 m_lastTickMs = 0; qreal m_x = 120; - qreal m_screenWidth = 1440; + qreal m_stageWidth = 1440; qreal m_catWidth = 120; + QPointF m_catCenter; bool m_facingRight = true; Mood m_mood = Mood::Walking; bool m_paused = false; @@ -98,4 +119,9 @@ private: bool m_hovered = false; bool m_frozen = false; QPointF m_look; + + bool m_chipVisible = false; + qreal m_chipX = 0; + int m_chipKick = 0; + int m_batsLeft = 0; }; diff --git a/src/main.cpp b/src/main.cpp index 16fb246..fba0790 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,7 +1,6 @@ #include #include #include -#include #include #include "appsettings.h" @@ -10,9 +9,8 @@ int main(int argc, char *argv[]) { - QCoreApplication::setOrganizationName("workcat"); - QCoreApplication::setOrganizationDomain("workcat.app"); - QCoreApplication::setApplicationName("Workcat"); + QCoreApplication::setOrganizationName("TNCat"); + QCoreApplication::setApplicationName("TNCat"); QApplication::setQuitOnLastWindowClosed(false); // QApplication (not QGuiApplication) so QSystemTrayIcon can show a menu. @@ -24,7 +22,7 @@ int main(int argc, char *argv[]) TrayController tray(&cat); if (QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) { - qWarning("Workcat: Wayland does not let a client place its own window. " + qWarning("TNCat: Wayland does not let a client place its own window. " "Run with QT_QPA_PLATFORM=xcb for the cat to walk."); } @@ -35,10 +33,9 @@ int main(int argc, char *argv[]) }); QObject::connect(&engine, &QQmlApplicationEngine::objectCreationFailed, &app, [] { QCoreApplication::exit(1); }, Qt::QueuedConnection); - engine.loadFromModule("Workcat", "Main"); + engine.loadFromModule("TNCat", "Main"); - if (auto *root = qobject_cast(engine.rootObjects().value(0))) { - cat.setWindow(root); + if (QObject *root = engine.rootObjects().value(0)) { QObject::connect(&tray, &TrayController::settingsRequested, root, [root] { QMetaObject::invokeMethod(root, "openSettings"); }); } diff --git a/src/platforminfo.cpp b/src/platforminfo.cpp index 1f0b348..3c70e56 100644 --- a/src/platforminfo.cpp +++ b/src/platforminfo.cpp @@ -1,11 +1,20 @@ #include "platforminfo.h" #include +#include PlatformInfo::PlatformInfo(QObject *parent) : QObject(parent) , m_name(QGuiApplication::platformName()) - , m_bypass(qEnvironmentVariableIntValue("WORKCAT_X11_BYPASS") != 0) - , m_opaque(qEnvironmentVariableIntValue("WORKCAT_OPAQUE") != 0) + , m_overlay(qEnvironmentVariableIntValue("TNCAT_OVERLAY") != 0) + , m_bypass(qEnvironmentVariableIntValue("TNCAT_X11_BYPASS") != 0) + , m_opaque(qEnvironmentVariableIntValue("TNCAT_OPAQUE") != 0) { } + +void PlatformInfo::setInputRegion(QQuickWindow *window, const QRectF ®ion) +{ + if (!window) + return; + window->setMask(QRegion(region.toAlignedRect())); +} diff --git a/src/platforminfo.h b/src/platforminfo.h index 6f863f2..a245fbf 100644 --- a/src/platforminfo.h +++ b/src/platforminfo.h @@ -2,9 +2,12 @@ #include #include +#include #include +#include -// Small facts about the host that QML needs in order to pick window flags. +// Small facts about the host that QML needs in order to pick window flags, +// plus the input-region call that makes an overlay window click-through. class PlatformInfo : public QObject { Q_OBJECT @@ -14,6 +17,7 @@ class PlatformInfo : public QObject Q_PROPERTY(QString name READ name CONSTANT) Q_PROPERTY(bool isX11 READ isX11 CONSTANT) Q_PROPERTY(bool isWayland READ isWayland CONSTANT) + Q_PROPERTY(bool overlayMode READ overlayMode CONSTANT) Q_PROPERTY(bool bypassWindowManager READ bypassWindowManager CONSTANT) Q_PROPERTY(bool opaqueBackground READ opaqueBackground CONSTANT) @@ -24,16 +28,27 @@ public: 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. + // Wayland forbids a client from placing its own window, so there the cat + // lives inside one screen-filling overlay and only moves within it. + // TNCAT_OVERLAY=1 forces the same mode elsewhere. + bool overlayMode() const { return m_overlay || isWayland(); } + + // TNCAT_X11_BYPASS=1 turns the cat into an override-redirect window. // Useful on window managers that refuse to keep a Qt::Tool window on top. bool bypassWindowManager() const { return m_bypass; } - // WORKCAT_OPAQUE=1 paints a solid background instead of relying on a + // TNCAT_OPAQUE=1 paints a solid background instead of relying on a // compositor. Without compositing X11 renders transparency as black. bool opaqueBackground() const { return m_opaque; } + // Restricts the window's input region to one rectangle so clicks land on + // whatever is behind the rest of the overlay. Implemented by the X11 and + // Wayland plugins; a no-op on platforms that ignore the mask. + Q_INVOKABLE void setInputRegion(QQuickWindow *window, const QRectF ®ion); + private: QString m_name; + bool m_overlay = false; bool m_bypass = false; bool m_opaque = false; }; diff --git a/src/traycontroller.cpp b/src/traycontroller.cpp index 2b11698..a223e73 100644 --- a/src/traycontroller.cpp +++ b/src/traycontroller.cpp @@ -23,6 +23,8 @@ TrayController::TrayController(CatController *cat, QObject *parent) tricks->addAction(tr(e.label), this, [this, mood] { m_cat->doTrick(mood); }); } + m_menu.addAction(tr("Toss a chip"), m_cat, &CatController::tossChip); + m_pauseAction = m_menu.addAction(tr("Let the cat sleep")); m_pauseAction->setCheckable(true); connect(m_pauseAction, &QAction::toggled, m_cat, &CatController::setPaused); @@ -32,10 +34,10 @@ TrayController::TrayController(CatController *cat, QObject *parent) m_menu.addAction(tr("Settings…"), this, &TrayController::settingsRequested); m_menu.addSeparator(); - m_menu.addAction(tr("Quit Workcat"), qApp, &QCoreApplication::quit); + m_menu.addAction(tr("Quit TNCat"), qApp, &QCoreApplication::quit); m_tray.setIcon(makeIcon()); - m_tray.setToolTip(tr("Workcat")); + m_tray.setToolTip(tr("TNCat")); m_tray.setContextMenu(&m_menu); // Not every X11 desktop runs a status-notifier host. Without a tray the @@ -43,7 +45,7 @@ TrayController::TrayController(CatController *cat, QObject *parent) if (QSystemTrayIcon::isSystemTrayAvailable()) m_tray.show(); else - qWarning("Workcat: no system tray on this desktop. Right-click the cat for settings."); + qWarning("TNCat: no system tray on this desktop. Right-click the cat for settings."); } QIcon TrayController::makeIcon()