Rename to TNCat, add chip chase, G-codes and a Wayland layout

The project is now TNCat, after HEIDENHAIN's TNC controls. Binary, QML module
URI, settings path, bundle id and .desktop entry follow; the environment
switches are TNCAT_*.

Entertainment: a curled steel chip skitters in every minute or two, the cat
runs it down and bats it away a few times before sitting back looking pleased.
Mood changes flash the matching machine code above its head, G00 for a sprint,
G01 for a stroll, M08 while it washes, G28 for a stretch, M30 for a nap.

Window handling was split into two layouts. X11 and macOS keep one small window
per object, positioned by the app. Wayland gets a screen-filling overlay whose
input region is masked down to the cat, since a Wayland client cannot place its
own window. Overlay mode is automatic there and available elsewhere via
TNCAT_OVERLAY=1.

Also adds a ground-line setting so the cat can walk above a softkey row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JulianandClaude Opus 5 committed 2026-09-10 22:53:40 +02:00
1 parent acef398f49
commit 0f1df75a1d
19 files changed
+817 -185

No files matched your search

+40
View File
@@ -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();
+15
View File
@@ -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:
+128 -18
View File
@@ -4,7 +4,12 @@
#include <QCursor>
#include <QMetaEnum>
#include <QRandomGenerator>
#include <QWindow>
namespace {
constexpr qreal kChipReach = 26.0; // how close the nose has to get
constexpr int kChipMinDelayMs = 45000;
constexpr int kChipMaxDelayMs = 150000;
}
CatController::CatController(AppSettings *settings, QObject *parent)
: 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<Mood>();
bool ok = false;
@@ -34,13 +42,15 @@ CatController::CatController(AppSettings *settings, QObject *parent)
setMood(static_cast<Mood>(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 &center)
{
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);
+38 -12
View File
@@ -7,10 +7,9 @@
#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.
// 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 &center);
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;
};
+5 -8
View File
@@ -1,7 +1,6 @@
#include <QApplication>
#include <QQmlApplicationEngine>
#include <QQuickStyle>
#include <QQuickWindow>
#include <QLoggingCategory>
#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<QQuickWindow *>(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"); });
}
+11 -2
View File
@@ -1,11 +1,20 @@
#include "platforminfo.h"
#include <QGuiApplication>
#include <QRegion>
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 &region)
{
if (!window)
return;
window->setMask(QRegion(region.toAlignedRect()));
}
+18 -3
View File
@@ -2,9 +2,12 @@
#include <QObject>
#include <QQmlEngine>
#include <QRectF>
#include <QString>
#include <QQuickWindow>
// Small facts about the host that QML needs in order to pick window flags.
// Small facts about the host that QML needs in order to pick window flags,
// plus the input-region call that makes an overlay window click-through.
class PlatformInfo : public QObject
{
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 &region);
private:
QString m_name;
bool m_overlay = false;
bool m_bypass = false;
bool m_opaque = false;
};
+5 -3
View File
@@ -23,6 +23,8 @@ TrayController::TrayController(CatController *cat, QObject *parent)
tricks->addAction(tr(e.label), this, [this, mood] { m_cat->doTrick(mood); });
}
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()