From d685bf492ad0b318442e5644328662d9f495c06c Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 11 Sep 2026 09:49:51 +0200 Subject: [PATCH] Shape the window when there is no compositor On Xfce with compositing off, X11 has no alpha to blend against, so the transparent background came out as a black box around the cat. When a window comes up without an alpha channel, TNCat now cuts the window down to the pixels the drawing covers, using the X11 shape extension, and refreshes that 15 times a second so it follows the animation. A sitting cat in a 120 x 106 window comes out as 96 rectangles over 29 percent of the window, so most of the box is gone. Edges are harder than with a compositor, one bit per pixel, and the log says so and names the Xfce setting that gives smooth ones instead. The image-to-rectangles step is platform independent and folds every device row into its window row, which both halves the rectangle count on a scaled display and keeps a pixel that is opaque in only one of them. xcb and xcb-shape are picked up through pkg-config on Unix and the build still works without them. Opaque mode now paints a neutral card rather than the coat colour. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 19 ++++++ README.md | 18 +++++- qml/CatWindow.qml | 15 ++++- qml/ChipWindow.qml | 8 +++ qml/CupWindow.qml | 8 +++ src/platforminfo.cpp | 32 ++++++++-- src/platforminfo.h | 19 +++++- src/windowshape.cpp | 143 +++++++++++++++++++++++++++++++++++++++++++ src/windowshape.h | 33 ++++++++++ 9 files changed, 284 insertions(+), 11 deletions(-) create mode 100644 src/windowshape.cpp create mode 100644 src/windowshape.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 1238574..63ebcba 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,6 +17,7 @@ qt_add_executable(tncat src/catcontroller.h src/catcontroller.cpp src/appsettings.h src/appsettings.cpp src/platforminfo.h src/platforminfo.cpp + src/windowshape.h src/windowshape.cpp ) target_include_directories(tncat PRIVATE src) @@ -38,6 +39,24 @@ qt_add_qml_module(tncat target_link_libraries(tncat PRIVATE Qt6::Core Qt6::Gui Qt6::Quick Qt6::QuickShapes) +# Without a compositor, X11 renders a transparent background as black. The +# shape extension is the way out: it cuts the window down to the cat itself. +# Both libraries ship with X11 and Qt's own xcb plugin already uses them. +if(UNIX AND NOT APPLE) + find_package(PkgConfig QUIET) + if(PkgConfig_FOUND) + pkg_check_modules(TNCAT_XCB IMPORTED_TARGET xcb xcb-shape) + endif() + if(TARGET PkgConfig::TNCAT_XCB) + target_link_libraries(tncat PRIVATE PkgConfig::TNCAT_XCB) + target_compile_definitions(tncat PRIVATE TNCAT_HAVE_XCB_SHAPE) + else() + message(STATUS "TNCat: xcb-shape not found. Without a compositor the cat " + "will sit in a black box; install libxcb-shape0-dev or " + "xcb-util-dev and reconfigure.") + endif() +endif() + set_target_properties(tncat PROPERTIES MACOSX_BUNDLE TRUE MACOSX_BUNDLE_INFO_PLIST ${CMAKE_SOURCE_DIR}/Info.plist.in diff --git a/README.md b/README.md index 03b4e4a..057d196 100644 --- a/README.md +++ b/README.md @@ -102,9 +102,21 @@ This is the main target. Two things to know. **Transparency needs a compositor.** Without one, X11 has no alpha channel to composite against and the transparent window background renders as a black box around the cat. In Xfce it is one checkbox: Settings, Window Manager Tweaks, -Compositor, Enable display compositing. TNCat prints a warning naming that -setting when its window comes up without an alpha channel. `TNCAT_OPAQUE=1` -paints a solid background instead, which is usable but not pretty. +Compositor, Enable display compositing. That is the best answer, because alpha +gives smooth edges. + +When TNCat sees a window come up without an alpha channel it says so and falls +back to shaping the window with the X11 shape extension: the window is cut down +to the pixels the cat covers, so there is no box left to be black. The shape is +one bit per pixel, so edges are harder than with a compositor, and it is +refreshed 15 times a second to follow the animation. Measured on a sitting cat +in a 120 x 106 window, the shape is 96 rectangles covering 29 percent of the +window. + +Shaping needs `xcb` and `xcb-shape` at build time (`libxcb-shape0-dev` on +Debian and Ubuntu, `xcb-util-devel` on SUSE). CMake says so if they are +missing, and the build still works without them. `TNCAT_OPAQUE=1` skips the +whole question and paints a solid background. **Keeping the cat on top.** The default is a frameless `Qt::Tool` window that stays on top. If the window manager puts it behind other windows or shows it in diff --git a/qml/CatWindow.qml b/qml/CatWindow.qml index d5799ab..7245fcc 100644 --- a/qml/CatWindow.qml +++ b/qml/CatWindow.qml @@ -17,7 +17,9 @@ Window { height: Math.round(width * sprite.implicitHeight / sprite.implicitWidth) x: cat.x y: Screen.desktopAvailableHeight - height - settings.bottomMargin - color: Platform.opaqueBackground ? settings.coat : "transparent" + // A neutral card reads better than the coat colour when the background + // cannot be transparent at all. + color: Platform.opaqueBackground ? "#f2f0ec" : "transparent" flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool | Qt.NoDropShadowWindowHint | Qt.WindowDoesNotAcceptFocus | (Platform.bypassWindowManager ? Qt.X11BypassWindowManagerHint : 0) @@ -48,6 +50,17 @@ Window { } Screen.onDesktopAvailableWidthChanged: cat.stageWidth = Screen.desktopAvailableWidth + // Without a compositor the window is cut to the cat's own outline. The + // drawing animates, so the shape has to be refreshed; 15 Hz is enough for + // a swinging leg and cheap enough for a window this small. + Timer { + interval: 66 + repeat: true + running: Platform.shapeWindows && win.visible + triggeredOnStart: true + onTriggered: Platform.updateWindowShape(win) + } + // The eyes need to know where the face sits on the desktop. Binding { target: win.cat diff --git a/qml/ChipWindow.qml b/qml/ChipWindow.qml index 4a63f3e..3a7955c 100644 --- a/qml/ChipWindow.qml +++ b/qml/ChipWindow.qml @@ -27,6 +27,14 @@ Window { NumberAnimation { duration: 320; easing.type: Easing.OutQuad } } + Timer { + interval: 66 + repeat: true + running: Platform.shapeWindows && win.visible + triggeredOnStart: true + onTriggered: Platform.updateWindowShape(win) + } + Chip { id: curl anchors.fill: parent diff --git a/qml/CupWindow.qml b/qml/CupWindow.qml index 654b85b..f9ef506 100644 --- a/qml/CupWindow.qml +++ b/qml/CupWindow.qml @@ -22,6 +22,14 @@ Window { | (Platform.bypassWindowManager ? Qt.X11BypassWindowManagerHint : 0) visible: cat.cupVisible + Timer { + interval: 66 + repeat: true + running: Platform.shapeWindows && win.visible + triggeredOnStart: true + onTriggered: Platform.updateWindowShape(win) + } + Cup { id: mug anchors.fill: parent diff --git a/src/platforminfo.cpp b/src/platforminfo.cpp index a235614..0f5fc47 100644 --- a/src/platforminfo.cpp +++ b/src/platforminfo.cpp @@ -1,5 +1,7 @@ #include "platforminfo.h" +#include "windowshape.h" + #include #include @@ -18,14 +20,34 @@ void PlatformInfo::checkTranslucency(QQuickWindow *window) if (!window || m_opaque || m_warnedTranslucency) return; if (window->format().alphaBufferSize() > 0) - return; + return; // a compositor is doing the blending, nothing to fix m_warnedTranslucency = true; + + if (WindowShape::supported()) { + m_shapeWindows = true; + emit shapeWindowsChanged(); + qInfo("TNCat: no alpha channel on this window, which means no compositor " + "is running. Falling back to a shaped window. Edges will be a " + "little hard, because a shape is one bit per pixel. Turning the " + "compositor on gives smooth edges: in Xfce, Settings, Window " + "Manager Tweaks, Compositor, Enable display compositing."); + return; + } + qWarning("TNCat: this window has no alpha channel, so the transparent " - "background will render as a black box. On X11 that means no " - "compositor is running. In Xfce turn on Settings, Window Manager " - "Tweaks, Compositor, Enable display compositing. Or start TNCat " - "with TNCAT_OPAQUE=1 to paint a solid background instead."); + "background renders as a black box, and window shaping is not " + "available either. On X11 that means no compositor is running. In " + "Xfce turn on Settings, Window Manager Tweaks, Compositor, Enable " + "display compositing. Or start TNCat with TNCAT_OPAQUE=1 to paint " + "a solid background instead."); +} + +void PlatformInfo::updateWindowShape(QQuickWindow *window) +{ + if (!m_shapeWindows || !window || !window->isVisible()) + return; + WindowShape::applyFromImage(window, window->grabWindow()); } void PlatformInfo::setInputRegion(QQuickWindow *window, const QRectF ®ion) diff --git a/src/platforminfo.h b/src/platforminfo.h index 8840da1..acbf612 100644 --- a/src/platforminfo.h +++ b/src/platforminfo.h @@ -21,6 +21,7 @@ class PlatformInfo : public QObject Q_PROPERTY(bool bypassWindowManager READ bypassWindowManager CONSTANT) Q_PROPERTY(bool opaqueBackground READ opaqueBackground CONSTANT) Q_PROPERTY(bool debug READ debug CONSTANT) + Q_PROPERTY(bool shapeWindows READ shapeWindows NOTIFY shapeWindowsChanged) public: explicit PlatformInfo(QObject *parent = nullptr); @@ -46,10 +47,23 @@ public: // window that a window manager or a bad binding collapsed. bool debug() const { return m_debug; } - // Warns once if the window came up without an alpha channel, which is what - // a missing compositor looks like: transparency renders as black. + // True once a window has been seen without an alpha channel and the X11 + // shape extension is there to make up for it. QML then keeps the window + // shaped to the cat instead of leaving a black box around it. + bool shapeWindows() const { return m_shapeWindows; } + + // Looks at the window's format. Turns shaping on, or warns if there is no + // way to avoid the black box. Q_INVOKABLE void checkTranslucency(QQuickWindow *window); + // Grabs the window and cuts it down to the pixels the drawing covers. + Q_INVOKABLE void updateWindowShape(QQuickWindow *window); + +signals: + void shapeWindowsChanged(); + +public: + // 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. @@ -62,4 +76,5 @@ private: bool m_opaque = false; bool m_debug = false; bool m_warnedTranslucency = false; + bool m_shapeWindows = false; }; diff --git a/src/windowshape.cpp b/src/windowshape.cpp new file mode 100644 index 0000000..60f4572 --- /dev/null +++ b/src/windowshape.cpp @@ -0,0 +1,143 @@ +#include "windowshape.h" + +#include +#include + +#ifdef TNCAT_HAVE_XCB_SHAPE +#include +#include +#include +#include +#endif + +namespace { + +// Anything this opaque counts as cat. Low on purpose: it keeps the soft edge +// of an antialiased outline inside the shape instead of biting into it. +constexpr int kAlphaThreshold = 24; + +} // namespace + +namespace WindowShape { + +// One rectangle per run of opaque pixels in a row. Neighbouring rows are not +// merged: an X server copes fine with a few hundred rectangles at this size, +// and merging them would cost more than it saves. +QList opaqueRuns(const QImage &image, qreal scale) +{ + QList rects; + if (image.isNull()) + return rects; + if (scale <= 0) + scale = 1.0; + + const QImage rgba = image.format() == QImage::Format_ARGB32 + ? image : image.convertToFormat(QImage::Format_ARGB32); + + // The grab is in device pixels, the shape has to be in window coordinates. + // Every device row that falls into a window row is folded into it, so a + // pixel that is opaque in either of them keeps its place in the shape. + const int cols = qMax(1, qCeil(rgba.width() / scale)); + const int rows = qMax(1, qCeil(rgba.height() / scale)); + QList row(cols); + + for (int ly = 0; ly < rows; ++ly) { + row.fill(false); + const int first = qFloor(ly * scale); + const int last = qMin(rgba.height(), qCeil((ly + 1) * scale)); + for (int y = first; y < last; ++y) { + const QRgb *line = reinterpret_cast(rgba.constScanLine(y)); + for (int x = 0; x < rgba.width(); ++x) { + if (qAlpha(line[x]) >= kAlphaThreshold) + row[qMin(cols - 1, int(x / scale))] = true; + } + } + + int runStart = -1; + for (int lx = 0; lx <= cols; ++lx) { + const bool opaque = lx < cols && row[lx]; + if (opaque && runStart < 0) + runStart = lx; + else if (!opaque && runStart >= 0) { + rects.append(QRect(runStart, ly, lx - runStart, 1)); + runStart = -1; + } + } + } + return rects; +} + +#ifdef TNCAT_HAVE_XCB_SHAPE + +namespace { + +xcb_connection_t *connection() +{ + const auto *x11 = qGuiApp->nativeInterface(); + return x11 ? x11->connection() : nullptr; +} + +bool haveShapeExtension(xcb_connection_t *conn) +{ + static int cached = -1; + if (cached >= 0) + return cached == 1; + + const xcb_query_extension_reply_t *ext = xcb_get_extension_data(conn, &xcb_shape_id); + cached = (ext && ext->present) ? 1 : 0; + return cached == 1; +} + +} // namespace + +bool supported() +{ + xcb_connection_t *conn = connection(); + return conn && haveShapeExtension(conn); +} + +bool applyFromImage(QQuickWindow *window, const QImage &image) +{ + xcb_connection_t *conn = connection(); + if (!window || !conn || !haveShapeExtension(conn)) + return false; + + const QList runs = opaqueRuns(image, image.devicePixelRatio()); + if (runs.isEmpty()) + return false; + + QList rects; + rects.reserve(runs.size()); + for (const QRect &r : runs) { + rects.append({ static_cast(r.x()), static_cast(r.y()), + static_cast(r.width()), static_cast(r.height()) }); + } + + xcb_shape_rectangles(conn, XCB_SHAPE_SO_SET, XCB_SHAPE_SK_BOUNDING, + XCB_CLIP_ORDERING_UNSORTED, + static_cast(window->winId()), 0, 0, + rects.size(), rects.constData()); + xcb_flush(conn); + return true; +} + +void clear(QQuickWindow *window) +{ + xcb_connection_t *conn = connection(); + if (!window || !conn || !haveShapeExtension(conn)) + return; + + xcb_shape_mask(conn, XCB_SHAPE_SO_SET, XCB_SHAPE_SK_BOUNDING, + static_cast(window->winId()), 0, 0, XCB_PIXMAP_NONE); + xcb_flush(conn); +} + +#else // no xcb-shape at build time + +bool supported() { return false; } +bool applyFromImage(QQuickWindow *, const QImage &) { return false; } +void clear(QQuickWindow *) {} + +#endif + +} // namespace WindowShape diff --git a/src/windowshape.h b/src/windowshape.h new file mode 100644 index 0000000..99650c2 --- /dev/null +++ b/src/windowshape.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +class QQuickWindow; + +// Cuts the window down to the pixels the cat actually covers, using the X11 +// shape extension. Without a compositor X11 has no alpha to blend against and +// a transparent background comes out black, so the only way to lose the box +// around the cat is to make the window itself that shape. +// +// Everywhere else this is a no-op: with a compositor, plain alpha is both +// cheaper and smoother, since a shape is one bit per pixel and cannot +// antialias an edge. +namespace WindowShape { + +// Runs of opaque pixels, one rectangle per run, in window coordinates. +// Platform independent so it can be tested without an X server. +QList opaqueRuns(const QImage &image, qreal scale); + +// True when the platform can shape windows and the build has the pieces. +bool supported(); + +// Shapes the window to the opaque pixels of the image, which is expected to be +// a grab of that same window. Returns false if nothing was applied. +bool applyFromImage(QQuickWindow *window, const QImage &image); + +// Drops the shape again, leaving a plain rectangular window. +void clear(QQuickWindow *window); + +} // namespace WindowShape