qt/wayland: scaffold subsurface presenter, no behavior change

New wayland::SubsurfacePresenter creates one wl_subsurface per
GhosttySurface, parented to the QWindow's native wl_surface. Set
desync so the renderer thread's frame cadence doesn't get held
hostage by Qt's paint cycle. Position (0,0); no buffer ever attached
in this commit — per protocol an uncommitted subsurface with no
buffer contributes nothing to the parent's display, so the existing
presentVulkanDmabuf mmap+memcpy+QPainter path keeps producing pixels
exactly as before.

Registry discovery follows the WindowBlur.cpp idiom: private event
queue + roundtrip, then bound proxies moved back to Qt's default
queue so the main dispatch drives them. Wayland-only (Qt frontend
already is) — non-Wayland QPA returns nullptr from tryCreate, which
Phase 2 silently tolerates and Phase 3 will treat as fatal.

Lifecycle: lazy-init on first QEvent::Show when windowHandle() is
non-null (sets WA_NativeWindow first); cached for the widget's
lifetime so tab switches don't churn the wl_subsurface. unique_ptr
destruct in ~GhosttySurface handles teardown.

Verified on NVIDIA RTX 2080: "SubsurfacePresenter: subsurface ready"
logs once per surface, no wl_display protocol errors, rendering
identical to pre-commit. Sets up Phase 3 to wire dmabuf frames
through the subsurface via zwp_linux_dmabuf_v1.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
Nathan 2026-05-24 22:33:16 -05:00
parent 07b27921d4
commit 4a890b96bd
5 changed files with 274 additions and 2 deletions

View File

@ -147,6 +147,7 @@ add_executable(ghastty
src/TabWidget.cpp
src/undo/UndoStack.cpp
src/Util.cpp
src/wayland/SubsurfacePresenter.cpp
src/WindowBlur.cpp
src/XkbTracker.cpp
"${BLUR_CODE}"

View File

@ -9,6 +9,7 @@
#include "TabWidget.h"
#include "Util.h"
#include "vulkan/Host.h"
#include "wayland/SubsurfacePresenter.h"
#include <algorithm>
#include <cerrno>
@ -306,10 +307,30 @@ bool GhosttySurface::event(QEvent *e) {
// via parent hide / tab switch on QTabWidget. The GLArea-style
// map/unmap signals are the same semantic.
if (m_surface) {
if (e->type() == QEvent::Show)
if (e->type() == QEvent::Show) {
ghostty_surface_set_occlusion(m_surface, true);
else if (e->type() == QEvent::Hide)
// First successful Show is also when our native QWindow exists
// and we can safely look up the Wayland parent wl_surface.
// Lazy-init the subsurface presenter once and keep it for the
// widget's lifetime — tying it to Show/Hide would churn the
// wl_subsurface on every tab switch.
//
// Phase 2 (current): scaffolding only. The presenter creates a
// wl_subsurface but never attaches a buffer; the existing
// `presentVulkanDmabuf` + `paintEvent` QPainter path is the
// one producing pixels. Phase 3 will route frames through the
// subsurface and retire the QPainter blit.
if (!m_subsurfacePresenter) {
// WA_NativeWindow ensures windowHandle() is non-null even if
// GhosttySurface is embedded in a non-native parent.
setAttribute(Qt::WA_NativeWindow);
if (auto *h = windowHandle())
m_subsurfacePresenter =
wayland::SubsurfacePresenter::tryCreate(h);
}
} else if (e->type() == QEvent::Hide) {
ghostty_surface_set_occlusion(m_surface, false);
}
}
return QWidget::event(e);
}

View File

@ -1,6 +1,7 @@
#pragma once
#include <atomic>
#include <memory>
#include <QImage>
#include <QMutex>
@ -12,6 +13,10 @@
#include "ghostty.h"
namespace wayland {
class SubsurfacePresenter;
}
class MainWindow;
class QContextMenuEvent;
class QDragEnterEvent;
@ -307,4 +312,11 @@ private:
// first PWD notification (libghostty fires one at spawn from the
// inherited config, then on every cwd change).
QString m_pwd;
// Wayland subsurface for the GPU-direct present path. Lazily
// created on first `QEvent::Show` once the native QWindow exists;
// null until then, null forever if creation fails (Phase 2 keeps
// working in that case because nothing yet depends on it). Phase 3
// will use this to attach dmabuf-backed `wl_buffer`s.
std::unique_ptr<wayland::SubsurfacePresenter> m_subsurfacePresenter;
};

View File

@ -0,0 +1,171 @@
#include "SubsurfacePresenter.h"
#include <cstdio>
#include <cstring>
#include <QGuiApplication>
#include <QLatin1String>
#include <QWindow>
#include <qpa/qplatformnativeinterface.h>
#include <wayland-client.h>
namespace wayland {
namespace {
// Process-wide bindings for the Wayland globals the presenter needs.
// Lazily discovered on first `tryCreate`, mirrors the `blurManager`
// pattern in `qt/src/WindowBlur.cpp` — registry roundtrip happens on
// a private event queue so we never dispatch Qt's own Wayland events.
struct PresenterGlobals {
wl_compositor *compositor = nullptr;
wl_subcompositor *subcompositor = nullptr;
bool searched = false;
};
void registryGlobal(void *data, wl_registry *registry, uint32_t name,
const char *interface, uint32_t /*version*/) {
auto *g = static_cast<PresenterGlobals *>(data);
if (std::strcmp(interface, wl_compositor_interface.name) == 0) {
g->compositor = static_cast<wl_compositor *>(
wl_registry_bind(registry, name, &wl_compositor_interface, 1));
} else if (std::strcmp(interface, wl_subcompositor_interface.name) == 0) {
g->subcompositor = static_cast<wl_subcompositor *>(
wl_registry_bind(registry, name, &wl_subcompositor_interface, 1));
}
}
void registryGlobalRemove(void *, wl_registry *, uint32_t) {}
const wl_registry_listener kRegistryListener = {
registryGlobal,
registryGlobalRemove,
};
PresenterGlobals *discoverGlobals(wl_display *display) {
static PresenterGlobals globals;
if (globals.searched) return &globals;
globals.searched = true;
wl_event_queue *queue = wl_display_create_queue(display);
wl_registry *registry = wl_display_get_registry(display);
wl_proxy_set_queue(reinterpret_cast<wl_proxy *>(registry), queue);
wl_registry_add_listener(registry, &kRegistryListener, &globals);
wl_display_roundtrip_queue(display, queue);
wl_registry_destroy(registry);
// Move the bound proxies back to the default queue so Qt's main
// dispatch drives subsequent events on them, then drop the private
// queue. (Same lifecycle dance as `blurManager`.)
if (globals.compositor)
wl_proxy_set_queue(reinterpret_cast<wl_proxy *>(globals.compositor),
nullptr);
if (globals.subcompositor)
wl_proxy_set_queue(reinterpret_cast<wl_proxy *>(globals.subcompositor),
nullptr);
wl_event_queue_destroy(queue);
return &globals;
}
} // namespace
std::unique_ptr<SubsurfacePresenter>
SubsurfacePresenter::tryCreate(QWindow *parent) {
if (!parent) return nullptr;
// The Qt frontend is Wayland-only; if we're not on Wayland, the
// native-interface lookups below would return null anyway, but
// bail explicitly so the log message is useful.
if (!QGuiApplication::platformName().startsWith(QLatin1String("wayland"))) {
std::fprintf(stderr,
"[ghastty] SubsurfacePresenter: not on Wayland QPA\n");
return nullptr;
}
QPlatformNativeInterface *native = QGuiApplication::platformNativeInterface();
if (!native) return nullptr;
auto *display = static_cast<wl_display *>(
native->nativeResourceForIntegration("wl_display"));
auto *parentSurface = static_cast<wl_surface *>(
native->nativeResourceForWindow("surface", parent));
if (!display || !parentSurface) {
std::fprintf(stderr,
"[ghastty] SubsurfacePresenter: missing wl_display or "
"parent wl_surface (display=%p surface=%p)\n",
static_cast<void *>(display),
static_cast<void *>(parentSurface));
return nullptr;
}
PresenterGlobals *g = discoverGlobals(display);
if (!g->compositor || !g->subcompositor) {
std::fprintf(stderr,
"[ghastty] SubsurfacePresenter: compositor lacks "
"wl_compositor or wl_subcompositor (compositor=%p "
"subcompositor=%p)\n",
static_cast<void *>(g->compositor),
static_cast<void *>(g->subcompositor));
return nullptr;
}
wl_surface *child = wl_compositor_create_surface(g->compositor);
if (!child) return nullptr;
wl_subsurface *sub =
wl_subcompositor_get_subsurface(g->subcompositor, child, parentSurface);
if (!sub) {
wl_surface_destroy(child);
return nullptr;
}
// Independent frame pacing: the renderer's present cadence is
// driven by libghostty's render thread, not the GUI thread's paint
// cycle, so we don't want our wl_subsurface state changes to wait
// for the parent's next commit. `set_desync` is what allows that.
wl_subsurface_set_desync(sub);
// Subsurface covers the parent at the origin. Phase 3 will keep
// this in sync on resize; for Phase 2 it doesn't matter because
// we never attach a buffer.
wl_subsurface_set_position(sub, 0, 0);
// Flush so the compositor sees the subsurface creation. We do NOT
// commit the child surface — per protocol an uncommitted subsurface
// with no attached buffer contributes nothing to the parent's
// display, which is exactly the no-behavior-change state we want
// for Phase 2.
wl_display_flush(display);
if (int err = wl_display_get_error(display); err != 0) {
std::fprintf(stderr,
"[ghastty] SubsurfacePresenter: wl_display error %d after "
"subsurface creation\n",
err);
wl_subsurface_destroy(sub);
wl_surface_destroy(child);
return nullptr;
}
std::fprintf(stderr,
"[ghastty] SubsurfacePresenter: subsurface ready (parent=%p "
"child=%p sub=%p)\n",
static_cast<void *>(parentSurface),
static_cast<void *>(child), static_cast<void *>(sub));
return std::unique_ptr<SubsurfacePresenter>(
new SubsurfacePresenter(display, child, sub));
}
SubsurfacePresenter::SubsurfacePresenter(wl_display *display, wl_surface *child,
wl_subsurface *sub)
: m_display(display), m_childSurface(child), m_subsurface(sub) {}
SubsurfacePresenter::~SubsurfacePresenter() {
if (m_subsurface) wl_subsurface_destroy(m_subsurface);
if (m_childSurface) wl_surface_destroy(m_childSurface);
if (m_display) wl_display_flush(m_display);
}
} // namespace wayland

View File

@ -0,0 +1,67 @@
// Wayland subsurface presenter for `GhosttySurface`.
//
// Scaffolding for the GPU-direct present path (issue: Phase 2 of the
// dmabuf-as-importable-surface rework). This class owns one
// `wl_subsurface` parented to the `GhosttySurface`'s native
// `wl_surface`. Its eventual job is to receive dmabuf fds from
// libghostty's renderer, wrap each one in a `wl_buffer` via
// `zwp_linux_dmabuf_v1`, and attach it to the subsurface so the
// compositor scans it out directly — bypassing the current mmap +
// memcpy + QImage + QPainter pipeline.
//
// In Phase 2 (this commit) the presenter only creates and tears down
// the subsurface. No buffer is ever attached; the existing
// `presentVulkanDmabuf` path keeps running unchanged. The proof this
// scaffolding works is that `ghastty-vulkan` still launches and
// renders identically with no Wayland protocol errors.
//
// Wayland-only by project decision (the Qt frontend is Wayland-only;
// see `feedback-qt-no-x11` memory). If the host isn't on a Wayland
// QPA platform or the compositor lacks `wl_subcompositor`,
// `tryCreate` returns nullptr — Phase 2 silently ignores that
// because nothing consumes the presenter yet; Phase 3 will treat it
// as fatal.
#pragma once
#include <memory>
struct wl_display;
struct wl_subsurface;
struct wl_surface;
class QWindow;
namespace wayland {
class SubsurfacePresenter {
public:
// Build a subsurface parented to `parent`'s native `wl_surface`.
// Returns nullptr if any prerequisite is missing (non-Wayland QPA,
// null `wl_display`, `wl_subcompositor` unbindable, etc.).
//
// Forces `Qt::WA_NativeWindow` on the caller is the *caller's*
// responsibility — `tryCreate` only reads `parent->surfaceHandle`.
static std::unique_ptr<SubsurfacePresenter> tryCreate(QWindow *parent);
~SubsurfacePresenter();
// Phase-3 accessors: when the present path moves to dmabuf-attach,
// the caller will need the child `wl_surface` to attach buffers to
// and the `wl_display` to flush. Exposed now so the API surface
// doesn't churn between phases.
wl_surface *childSurface() const { return m_childSurface; }
wl_display *display() const { return m_display; }
SubsurfacePresenter(const SubsurfacePresenter &) = delete;
SubsurfacePresenter &operator=(const SubsurfacePresenter &) = delete;
private:
SubsurfacePresenter(wl_display *display, wl_surface *child,
wl_subsurface *sub);
wl_display *m_display;
wl_surface *m_childSurface;
wl_subsurface *m_subsurface;
};
} // namespace wayland