qt/quickterm: real per-window fade via wp_alpha_modifier_v1

QtWayland's QPA plugin has no implementation for
QWindow::setOpacity — every call spams
"This plugin does not support setting window opacity" to stderr,
and the QuickTerminal's QPropertyAnimation on windowOpacity
fired it once per animation tick (~10-50 times per fade).

Replace the QPropertyAnimation + setWindowOpacity path with the
wp_alpha_modifier_v1 staging Wayland protocol, which lets the
compositor multiply per-surface alpha directly. Supported by
KWin (KDE 6+), wlroots ≥0.17, Hyprland; not yet on mutter/GNOME.
For non-supporting compositors the AlphaModifier::setOpacity
call returns false and the animation visibly does nothing —
acceptable degradation (window still shows/hides, just without
the fade) versus throwing the warning storm.

Pieces:

  - qt/protocols/alpha-modifier-v1.xml — vendor the upstream
    wayland-protocols staging XML.

  - qt/CMakeLists.txt — wire it through the existing
    `ghastty_wayland_protocol(...)` helper.

  - qt/src/wayland/AlphaModifier.{h,cpp} — process-wide manager
    binding (lazy init, std::call_once), per-wl_surface cache so
    animation ticks don't re-roundtrip get_surface, set_multiplier
    + wl_surface.commit + wl_display_flush per call. Migrates the
    bound manager onto the default queue before destroying the
    private registry queue (same gotcha that produced the exit-
    time SIGSEGV in XkbTracker — caught it preemptively here).

  - qt/src/quickterm/QuickTerminal.cpp — animateIn/animateOut now
    drive a QVariantAnimation whose valueChanged routes through
    AlphaModifier::setOpacity, instead of a QPropertyAnimation on
    the windowOpacity property.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
Nathan 2026-05-26 17:47:38 -05:00
parent c1a55b2576
commit 5a24a90f4e
5 changed files with 422 additions and 14 deletions

View File

@ -108,6 +108,14 @@ ghastty_wayland_protocol(blur BLUR_HEADER BLUR_CODE)
ghastty_wayland_protocol(linux-dmabuf-v1 DMABUF_HEADER DMABUF_CODE)
ghastty_wayland_protocol(viewporter VIEWPORTER_HEADER VIEWPORTER_CODE)
ghastty_wayland_protocol(fractional-scale-v1 FRACSCALE_HEADER FRACSCALE_CODE)
# - `alpha-modifier-v1` (`wp_alpha_modifier_v1`)
# compositor-side per-surface alpha multiplier. QtWayland has no
# built-in setWindowOpacity equivalent (the QPA plugin warns
# "This plugin does not support setting window opacity" on every
# call), so QuickTerminal's fade-in/out drives this protocol
# directly. Supported on KWin, wlroots 0.17, Hyprland; NOT yet
# on mutter/GNOME.
ghastty_wayland_protocol(alpha-modifier-v1 ALPHAMOD_HEADER ALPHAMOD_CODE)
# libghostty is built out-of-tree by Zig.
get_filename_component(GHOSTTY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/.." ABSOLUTE)
@ -199,6 +207,7 @@ add_executable(ghastty
src/TabWidget.cpp
src/undo/UndoStack.cpp
src/Util.cpp
src/wayland/AlphaModifier.cpp
src/wayland/SubsurfacePresenter.cpp
src/WindowBlur.cpp
src/XkbTracker.cpp
@ -210,6 +219,8 @@ add_executable(ghastty
"${VIEWPORTER_HEADER}"
"${FRACSCALE_CODE}"
"${FRACSCALE_HEADER}"
"${ALPHAMOD_CODE}"
"${ALPHAMOD_HEADER}"
)
# Vulkan host glue is variant-only. Adding it to the OpenGL build

View File

@ -0,0 +1,118 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="alpha_modifier_v1">
<copyright>
Copyright 2023 Xaver Hugl
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
</copyright>
<description summary="surface alpha modifier">
This interface allows a client to set a factor for the alpha values on a
surface, which can be used to offload such operations to the compositor,
which can in turn for example offload them to KMS.
Warning! The protocol described in this file is currently in the testing
phase. Backward compatible changes may be added together with the
corresponding interface version bump. Backward incompatible changes can
only be done by creating a new major version of the extension.
</description>
<interface name="wp_alpha_modifier_v1" version="1">
<description summary="surface alpha modifier manager">
This interface allows a client to set a factor for the alpha values on
a surface, which can be used to offload such operations to the
compositor. The default factor is UINT32_MAX.
This interface can be used to set an arbitrary alpha value for the
surface, allowing it to be made fully transparent by setting the factor
to 0, fully opaque by setting it to UINT32_MAX, or any value in
between.
Warning! The protocol described in this file is currently in the
testing phase. Backward compatible changes may be added together with
the corresponding interface version bump. Backward incompatible changes
can only be done by creating a new major version of the extension.
</description>
<request name="destroy" type="destructor">
<description summary="destroy the alpha modifier manager object">
Destroy the alpha modifier manager. This doesn't destroy objects
created with the manager.
</description>
</request>
<request name="get_surface">
<description summary="create a new alpha modifier surface object">
Create a new alpha modifier surface object associated with the given
wl_surface. If there is already such an object associated with the
wl_surface, the already_constructed error will be raised.
</description>
<arg name="id" type="new_id" interface="wp_alpha_modifier_surface_v1"/>
<arg name="surface" type="object" interface="wl_surface"/>
</request>
<enum name="error">
<entry name="already_constructed" value="0"
summary="wl_surface already has a alpha modifier object associated"/>
</enum>
</interface>
<interface name="wp_alpha_modifier_surface_v1" version="1">
<description summary="modifier object for a surface">
This interface allows the client to set a factor for the alpha values on
a surface, which can be used to offload such operations to the
compositor. Multiple alpha modifiers can be attached to the same
surface, in which case the resulting alpha will be the product of all
the multiplicative factors.
The default factor is UINT32_MAX.
</description>
<request name="destroy" type="destructor">
<description summary="remove the alpha modifier from the surface">
This destroys the object, and is equivalent to set_multiplier with
a value of UINT32_MAX, with the same double-buffered semantics as
set_multiplier.
</description>
</request>
<request name="set_multiplier">
<description summary="set the alpha multiplier">
Sets the alpha multiplier for the surface. The alpha multiplier is
double-buffered state, see wl_surface.commit for details.
The default factor is UINT32_MAX.
This factor is applied in the compositor's blending space, as an
additional step after the processing of per-pixel alpha values for
the surface. It allows to set an arbitrary alpha value for the
surface, including making the surface partially transparent even when
all the pixels are fully opaque, or fully transparent even when the
pixels are not.
</description>
<arg name="factor" type="uint" summary="the new alpha multiplier for the surface"/>
</request>
<enum name="error">
<entry name="no_surface" value="0"
summary="wl_surface was destroyed"/>
</enum>
</interface>
</protocol>

View File

@ -6,17 +6,18 @@
#include <QCursor>
#include <QEasingCurve>
#include <QGuiApplication>
#include <QPropertyAnimation>
#include <QScreen>
#include <QSize>
#include <QString>
#include <QStringLiteral>
#include <QVariantAnimation>
#include <QWidget>
#include <QWindow>
#include <LayerShellQt/window.h>
#include "../config/Config.h"
#include "../wayland/AlphaModifier.h"
#include "ghostty.h"
namespace quickterm {
@ -43,14 +44,36 @@ int animationMs() {
return std::clamp(static_cast<int>(secs * 1000.0), 1, 1000);
}
// Apply opacity to the window. Uses wp_alpha_modifier_v1 when the
// compositor supports it (real per-surface alpha multiplier on the
// compositor side); otherwise falls through to a no-op (the
// animation still runs but the window just appears at the end —
// previously this called QWindow::setOpacity which spammed
// "This plugin does not support setting window opacity" warnings
// on every animation tick because QtWayland's QPA plugin has no
// implementation).
void applyOpacity(QWidget *window, double opacity) {
QWindow *handle = window->windowHandle();
if (!handle) return;
wayland::AlphaModifier::setOpacity(handle, opacity);
}
// Lazily fetch (or build) the per-window opacity animation, parented
// to `window` so its lifetime tracks the widget's.
QPropertyAnimation *animFor(QWidget *window) {
auto *existing = window->property(kAnimProperty).value<QPropertyAnimation *>();
// to `window` so its lifetime tracks the widget's. We use
// QVariantAnimation (not QPropertyAnimation on windowOpacity) so
// the per-tick value is delivered to our applyOpacity handler
// instead of QWindow::setOpacity (which QtWayland's QPA plugin
// doesn't implement — see applyOpacity comment).
QVariantAnimation *animFor(QWidget *window) {
auto *existing = window->property(kAnimProperty).value<QVariantAnimation *>();
if (existing) return existing;
auto *anim = new QPropertyAnimation(window, "windowOpacity", window);
auto *anim = new QVariantAnimation(window);
QObject::connect(anim, &QVariantAnimation::valueChanged, window,
[window](const QVariant &v) {
applyOpacity(window, v.toDouble());
});
window->setProperty(kAnimProperty,
QVariant::fromValue<QPropertyAnimation *>(anim));
QVariant::fromValue<QVariantAnimation *>(anim));
return anim;
}
@ -167,25 +190,33 @@ void setupLayerShell(QWidget *window) {
}
void animateIn(QWidget *window) {
window->setWindowOpacity(0.0);
// Show with opacity 0 first so the compositor never paints a
// fully-opaque frame before the animation kicks in. The
// QVariantAnimation valueChanged → applyOpacity path needs the
// wl_surface to exist, which means after show(). We call
// applyOpacity twice on either side of show() — once at 0.0 as
// a best-effort pre-show (no-op if wl_surface isn't up yet),
// once at 0.0 immediately after to lock in the start state.
applyOpacity(window, 0.0);
window->show();
window->raise();
window->activateWindow();
applyOpacity(window, 0.0);
const int ms = animationMs();
if (ms <= 0) {
window->setWindowOpacity(1.0);
applyOpacity(window, 1.0);
return;
}
// Stop any running fade so toggling rapidly doesn't stack
// animations.
QPropertyAnimation *anim = animFor(window);
QVariantAnimation *anim = animFor(window);
anim->stop();
// animateOut leaves a `finished -> hide()` handler attached to the
// shared animation object. If a fade-out was interrupted by this
// fade-in (rapid out/in cycle), the leftover handler would fire at
// the end of the in-fade and silently hide the just-revealed
// window — clear it before starting.
QObject::disconnect(anim, &QPropertyAnimation::finished, window, nullptr);
QObject::disconnect(anim, &QVariantAnimation::finished, window, nullptr);
anim->setDuration(ms);
anim->setStartValue(0.0);
anim->setEndValue(1.0);
@ -199,17 +230,21 @@ void animateOut(QWidget *window) {
window->hide();
return;
}
QPropertyAnimation *anim = animFor(window);
QVariantAnimation *anim = animFor(window);
anim->stop();
anim->setDuration(ms);
anim->setStartValue(window->windowOpacity());
// Start from the animation's last delivered value if we have one
// (a rapid in-then-out cycle interrupts at some intermediate
// alpha); otherwise assume the window was fully visible.
const QVariant cur = anim->currentValue();
anim->setStartValue(cur.isValid() ? cur.toDouble() : 1.0);
anim->setEndValue(0.0);
anim->setEasingCurve(QEasingCurve::InCubic);
// Disconnect any previous handler before reconnecting; otherwise a
// toggle-out-then-in cycle accumulates handlers that all fire on
// the next out.
QObject::disconnect(anim, &QPropertyAnimation::finished, window, nullptr);
QObject::connect(anim, &QPropertyAnimation::finished, window,
QObject::disconnect(anim, &QVariantAnimation::finished, window, nullptr);
QObject::connect(anim, &QVariantAnimation::finished, window,
[window]() { window->hide(); });
anim->start();
}

View File

@ -0,0 +1,193 @@
#include "AlphaModifier.h"
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <mutex>
#include <unordered_map>
#include <QGuiApplication>
#include <QWindow>
#include <qpa/qplatformnativeinterface.h>
#include <wayland-client.h>
#include "alpha-modifier-v1-client-protocol.h"
namespace wayland {
namespace {
// Process-wide binding. Lazily initialised on first supported()/
// setOpacity() call, then read lock-free via the atomic-by-fence
// guarantee of `std::call_once`. Once bound it lives for the
// process lifetime — there's no clean teardown path on Wayland
// global teardown that would matter for a manager-style global.
struct GlobalState {
wl_display *display = nullptr;
wp_alpha_modifier_v1 *manager = nullptr; // null if compositor lacks it
bool ready = false; // call_once fired (success or failure)
};
GlobalState &globalState() {
static GlobalState g;
return g;
}
// Listener: discover wp_alpha_modifier_v1 in the registry. The
// scoped wl_event_queue we use here is destroyed before the
// listener data goes out of scope, so the registry's child
// proxies (none survive past this binding pass) are safe.
void onRegistryGlobal(void *data, wl_registry *registry, uint32_t name,
const char *interface, uint32_t /*version*/) {
auto *g = static_cast<GlobalState *>(data);
if (std::strcmp(interface, wp_alpha_modifier_v1_interface.name) != 0)
return;
// Version 1 is the only version of this staging protocol so far.
g->manager = static_cast<wp_alpha_modifier_v1 *>(
wl_registry_bind(registry, name, &wp_alpha_modifier_v1_interface, 1));
}
void onRegistryGlobalRemove(void *, wl_registry *, uint32_t) {}
const wl_registry_listener kRegistryListener = {
&onRegistryGlobal,
&onRegistryGlobalRemove,
};
// Bind the manager global lazily on first use. Idempotent under
// std::call_once. Mirrors the private-queue pattern in
// XkbTracker — and like that, we migrate the bound proxy onto
// the default queue before destroying the private queue, so
// future calls (set_multiplier, get_surface) dispatch on Qt's
// event loop instead of a dangling queue.
void initOnce() {
static std::once_flag once;
std::call_once(once, []() {
auto &g = globalState();
QPlatformNativeInterface *native =
QGuiApplication::platformNativeInterface();
if (!native) {
g.ready = true;
return;
}
g.display = static_cast<wl_display *>(
native->nativeResourceForIntegration("wl_display"));
if (!g.display) {
g.ready = true;
return;
}
wl_event_queue *queue = wl_display_create_queue(g.display);
wl_registry *registry = wl_display_get_registry(g.display);
wl_proxy_set_queue(reinterpret_cast<wl_proxy *>(registry), queue);
wl_registry_add_listener(registry, &kRegistryListener, &g);
wl_display_roundtrip_queue(g.display, queue);
wl_registry_destroy(registry);
// Migrate the manager onto the default queue BEFORE destroying
// the private one — otherwise compositor-side messages for the
// manager (none expected for this protocol, but cleanliness
// matters and Qt's event queue is the dispatch target we want
// anyway) would target a destroyed queue, the same footgun that
// produced the exit-time SIGSEGV in XkbTracker.
if (g.manager) {
wl_proxy_set_queue(reinterpret_cast<wl_proxy *>(g.manager), nullptr);
}
wl_event_queue_destroy(queue);
g.ready = true;
});
}
// Per-wl_surface alpha modifier object cache. Cached so animation
// ticks don't re-roundtrip get_surface every frame.
//
// Keyed by wl_surface* — that's stable for the wl_surface's
// lifetime, and we explicitly drop on detach(). If a QWindow is
// destroyed without detach() being called the wl_surface gets
// destroyed by Qt; the cached wp_alpha_modifier_surface_v1 would
// then be invalid on next get_surface, so callers MUST detach()
// from the QWindow's destruction path. Map access is from the
// GUI thread only.
struct Cache {
std::unordered_map<wl_surface *, wp_alpha_modifier_surface_v1 *> entries;
};
Cache &cache() {
static Cache c;
return c;
}
wl_surface *surfaceFor(QWindow *window) {
if (!window) return nullptr;
QPlatformNativeInterface *native =
QGuiApplication::platformNativeInterface();
if (!native) return nullptr;
return static_cast<wl_surface *>(
native->nativeResourceForWindow("surface", window));
}
wp_alpha_modifier_surface_v1 *getOrCreate(wl_surface *surface) {
auto &c = cache();
auto it = c.entries.find(surface);
if (it != c.entries.end()) return it->second;
auto *manager = globalState().manager;
if (!manager) return nullptr;
auto *obj = wp_alpha_modifier_v1_get_surface(manager, surface);
if (!obj) return nullptr;
c.entries.emplace(surface, obj);
return obj;
}
} // namespace
bool AlphaModifier::supported() {
initOnce();
return globalState().manager != nullptr;
}
bool AlphaModifier::setOpacity(QWindow *window, double opacity) {
initOnce();
auto &g = globalState();
if (!g.manager) return false;
wl_surface *surface = surfaceFor(window);
if (!surface) return false;
auto *mod = getOrCreate(surface);
if (!mod) return false;
// Convert [0.0, 1.0] → [0, UINT32_MAX]. Clamp first; lround
// gives the closest integer, matching what users expect at the
// endpoints (1.0 → fully opaque, 0.0 → fully transparent) without
// off-by-one rounding drift at intermediate values.
const double clamped = std::clamp(opacity, 0.0, 1.0);
const uint32_t factor = static_cast<uint32_t>(
std::lround(clamped * static_cast<double>(UINT32_MAX)));
wp_alpha_modifier_surface_v1_set_multiplier(mod, factor);
// Alpha multiplier is double-buffered on the wl_surface; the
// change applies on the next wl_surface.commit. Commit here so
// the caller doesn't need to know about Wayland's double-buffer
// semantics. For Qt-managed top-level windows we don't have a
// clean Qt API to force a parent commit, so we wl_surface.commit
// the surface directly — same trick used elsewhere in this code
// for subsurface state changes.
wl_surface_commit(surface);
// And flush so the commit reaches the compositor immediately
// rather than sitting in libwayland-client's send buffer until
// Qt's next event-loop iteration. Otherwise rapid animation
// ticks would coalesce into one frame at the end of the tick
// cycle, defeating the smooth fade.
wl_display_flush(g.display);
return true;
}
void AlphaModifier::detach(QWindow *window) {
wl_surface *surface = surfaceFor(window);
if (!surface) return;
auto &c = cache();
auto it = c.entries.find(surface);
if (it == c.entries.end()) return;
wp_alpha_modifier_surface_v1_destroy(it->second);
c.entries.erase(it);
}
} // namespace wayland

View File

@ -0,0 +1,51 @@
// Per-window alpha multiplier via wp_alpha_modifier_v1.
//
// QtWayland's QPA plugin doesn't implement QWindow::setOpacity (it
// logs "This plugin does not support setting window opacity" on
// every call). For the QuickTerminal fade-in/out we need real
// per-surface alpha, so we drive the wp_alpha_modifier_v1 staging
// Wayland protocol ourselves.
//
// Compositor support (as of 2026-05): KWin (KDE 6+), wlroots
// (≥0.17), Hyprland — yes. mutter/GNOME — no. If the protocol
// isn't advertised, `setOpacity` returns false and the caller can
// either skip the animation or fall back to instant show/hide.
//
// Wayland-only by project decision (see feedback-qt-no-x11 memory).
#pragma once
struct wp_alpha_modifier_v1;
struct wp_alpha_modifier_surface_v1;
class QWindow;
namespace wayland {
class AlphaModifier {
public:
// Returns true if the compositor advertises wp_alpha_modifier_v1
// and we've successfully bound it. Cheap after the first call
// (the binding is cached process-wide). Use this to decide
// whether to drive an opacity animation or fall through to
// instant show/hide.
static bool supported();
// Set the window's alpha multiplier in [0.0, 1.0]. Must be
// called on the GUI thread (the thread that owns wl_display
// dispatch). Returns false if `window`'s native wl_surface
// isn't available yet (e.g. before first show), or if the
// compositor doesn't support the protocol.
//
// The wp_alpha_modifier_surface_v1 object is created lazily per
// wl_surface and cached for the surface's lifetime — repeated
// calls during an animation just emit set_multiplier + commit.
static bool setOpacity(QWindow *window, double opacity);
// Release the per-surface alpha modifier object for this window.
// Call when the window is being destroyed (or before re-creating
// its native surface). Equivalent to set_multiplier(UINT32_MAX)
// followed by destroy on the surface object.
static void detach(QWindow *window);
};
} // namespace wayland