qt: phase 1.3b — move clipboard + closeSurface callbacks to GhosttyApp

Final piece of phase 1: the four remaining libghostty runtime
callbacks (onReadClipboard, onConfirmReadClipboard, onWriteClipboard,
onCloseSurface) plus the surfaceAlive predicate that gates them all
move from MainWindow to GhosttyApp.

  - All four callback bodies move verbatim. The only edits are
    `surfaceAlive(...)` → `instance().surfaceAlive(...)` (the
    predicate is now a member of the singleton).
  - surfaceAlive itself iterates m_windows on the singleton, calling
    a new public MainWindow::ownsSurface(GhosttySurface*) accessor
    instead of directly poking the private m_surfaces field.
  - ensureInitialized now registers GhosttyApp::onReadClipboard et
    al. with libghostty's runtime config bundle.

After this commit, MainWindow::onAction is the only libghostty
runtime callback still living on MainWindow; phase 2 retires it for
an ActionDispatcher.

Phase 1 closeout summary:
  - Phase 1.0: libghostty handles + bring-up/teardown
  - Phase 1.1: window registry (s_windows)
  - Phase 1.2: frame timer + quit timer + onWakeup + tickPending
  - Phase 1.3a: quickTerminal pointer + visibility / quick-term toggles
  - Phase 1.3b: read/confirm/write clipboard + closeSurface

The s_app / s_config / s_needsPremultiply / s_quitDelayMs mirrors on
MainWindow are deliberately retained — they're read by ~50 call
sites across MainWindow.cpp and retiring them is a focused phase 1.4
follow-up that can land independently.

Build verification: not run on this Mac. Needs a docker compile
before merge.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
ntomsic 2026-05-23 11:55:15 -05:00
parent 7ea8c87a63
commit f669aa0d7a
4 changed files with 151 additions and 133 deletions

View File

@ -742,7 +742,7 @@ void MainWindow::showTabContextMenu(int index, const QPoint &globalPos) {
QAction *aRename = menu.addAction(QStringLiteral("Rename Tab…"));
QAction *chosen = menu.exec(globalPos);
if (!chosen || !surfaceAlive(src)) return;
if (!chosen || !GhosttyApp::instance().surfaceAlive(src)) return;
if (chosen == aClose)
closeTabsByMode(src, GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS);
else if (chosen == aOther)
@ -1952,13 +1952,8 @@ void MainWindow::toggleSplitZoom(GhosttySurface *surface) {
}
// --- libghostty runtime callbacks ------------------------------------
bool MainWindow::surfaceAlive(GhosttySurface *s) {
if (!s) return false;
for (MainWindow *w : GhosttyApp::instance().windows())
if (w->m_surfaces.contains(s)) return true;
return false;
}
// All five non-action callbacks (onWakeup + the clipboard / close
// quartet) live on GhosttyApp now. onAction stays here until phase 2.
// Map a libghostty mouse shape to the nearest Qt cursor.
static Qt::CursorShape mouseShapeToCursor(ghostty_action_mouse_shape_e s) {
@ -2743,104 +2738,5 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target,
}
}
bool MainWindow::onReadClipboard(void *ud, ghostty_clipboard_e loc,
void *state) {
// surface userdata. Called synchronously by libghostty when a
// surface needs clipboard contents (paste). This runs on the GUI
// thread by construction: every libghostty entry point that
// surfaces a paste lives behind ghostty_app_tick, which the
// process-wide frame timer drives — and that timer is on the GUI
// thread. QClipboard is GUI-thread-only, so reading directly here
// is safe; surfaceAlive still validates the pointer in case a
// surface is mid-destruction on this same thread.
auto *surface = static_cast<GhosttySurface *>(ud);
if (!surfaceAlive(surface) || !surface->surface()) return false;
const QClipboard::Mode mode = loc == GHOSTTY_CLIPBOARD_SELECTION
? QClipboard::Selection
: QClipboard::Clipboard;
const QByteArray text = QGuiApplication::clipboard()->text(mode).toUtf8();
ghostty_surface_complete_clipboard_request(surface->surface(),
text.constData(), state, true);
return true;
}
void MainWindow::onConfirmReadClipboard(void *ud, const char *str, void *state,
ghostty_clipboard_request_e) {
// libghostty asks for confirmation when a paste looks unsafe. The
// dialog MUST be deferred: this callback runs inside libghostty, and a
// modal dialog here spins a nested event loop that re-enters libghostty
// through the render tick — a crash/freeze. `state` is a completion
// token valid until used; `str` is not, so copy it.
auto *surface = static_cast<GhosttySurface *>(ud);
if (!surfaceAlive(surface) || !surface->surface()) return;
QPointer<GhosttySurface> sp(surface);
const QByteArray content(str);
QMetaObject::invokeMethod(
surface->owner(),
[sp, content, state]() {
if (!sp || !sp->surface()) return;
QString preview = QString::fromUtf8(content);
// Truncate by code unit but back off to a non-surrogate boundary
// so we don't slice a surrogate pair half.
if (preview.size() > 200) {
int cut = 200;
while (cut > 0 && preview.at(cut - 1).isHighSurrogate()) --cut;
preview = preview.left(cut) + QStringLiteral("");
}
// Destructive Paste / Cancel buttons, default Cancel —
// mirrors the close-confirmation styling.
QMessageBox box(sp->owner());
box.setIcon(QMessageBox::Warning);
box.setWindowTitle(QStringLiteral("Confirm Paste"));
box.setText(QStringLiteral("The text being pasted may be unsafe."));
box.setInformativeText(preview);
QPushButton *paste = box.addButton(QStringLiteral("Paste"),
QMessageBox::DestructiveRole);
QPushButton *cancel = box.addButton(QStringLiteral("Cancel"),
QMessageBox::RejectRole);
box.setDefaultButton(cancel);
box.exec();
ghostty_surface_complete_clipboard_request(
sp->surface(), content.constData(), state,
box.clickedButton() == paste);
},
Qt::QueuedConnection);
}
void MainWindow::onWriteClipboard(void *ud, ghostty_clipboard_e loc,
const ghostty_clipboard_content_s *content,
size_t n, bool) {
if (n == 0 || !content[0].data) return;
auto *surface = static_cast<GhosttySurface *>(ud);
if (!surfaceAlive(surface)) return;
const QClipboard::Mode mode = loc == GHOSTTY_CLIPBOARD_SELECTION
? QClipboard::Selection
: QClipboard::Clipboard;
const QString text = QString::fromUtf8(content[0].data);
// The clipboard is process-global; route via qApp so a window dying
// mid-flight does not strand the write.
QMetaObject::invokeMethod(
qApp,
[text, mode]() { QGuiApplication::clipboard()->setText(text, mode); },
Qt::QueuedConnection);
}
void MainWindow::onCloseSurface(void *ud, bool) {
// surface userdata. Deferred out of this callback so the confirm
// dialog cannot spin a nested event loop back into libghostty.
auto *surface = static_cast<GhosttySurface *>(ud);
if (!surfaceAlive(surface)) return;
MainWindow *self = surface->owner();
QPointer<MainWindow> selfp(self);
QPointer<GhosttySurface> sp(surface);
QMetaObject::invokeMethod(
self,
[selfp, sp]() {
if (!selfp || !sp) return;
if (selfp->confirmCloseSurfaces({sp})) selfp->removeSurface(sp);
},
Qt::QueuedConnection);
}
// onReadClipboard / onConfirmReadClipboard / onWriteClipboard /
// onCloseSurface all moved to GhosttyApp in phase 1.3b.

View File

@ -116,6 +116,13 @@ public:
// private m_surfaces member.
const QList<GhosttySurface *> &surfaces() const { return m_surfaces; }
// Whether `s` is one of this window's surfaces. Used by
// GhosttyApp::surfaceAlive to validate libghostty userdata pointers
// against a destruction race on worker-thread callbacks.
bool ownsSurface(GhosttySurface *s) const {
return m_surfaces.contains(s);
}
protected:
bool event(QEvent *) override;
void showEvent(QShowEvent *) override;
@ -232,24 +239,14 @@ private:
// / back into the splitter tree.
void toggleSplitZoom(GhosttySurface *surface);
// Runtime callbacks dispatched by libghostty. action is app-level
// (routed via the target surface or the GhosttyApp window
// registry); clipboard/close carry the surface userdata. wakeup
// moved to GhosttyApp::onWakeup in phase 1.2.
// The libghostty action callback. Stays here because its switch
// body still needs private MainWindow access; phase 2 retires it
// for an ActionDispatcher. The other five runtime callbacks
// (onWakeup + clipboard quartet) live on GhosttyApp.
static bool onAction(ghostty_app_t, ghostty_target_s, ghostty_action_s);
static bool onReadClipboard(void *ud, ghostty_clipboard_e, void *state);
static void onConfirmReadClipboard(void *ud, const char *, void *state,
ghostty_clipboard_request_e);
static void onWriteClipboard(void *ud, ghostty_clipboard_e,
const ghostty_clipboard_content_s *, size_t,
bool);
static void onCloseSurface(void *ud, bool process_active);
// True if `s` is still owned by some live MainWindow. The surface
// userdata callbacks above use this to validate a libghostty-supplied
// pointer before dereferencing — a worker-thread callback can race
// the GhosttySurface destructor.
static bool surfaceAlive(GhosttySurface *s);
// surfaceAlive moved to GhosttyApp::surfaceAlive (it iterates the
// live window registry, which is owned by the singleton).
TabWidget *m_tabs = nullptr;
QList<GhosttySurface *> m_surfaces; // every live surface in this window

View File

@ -4,11 +4,16 @@
#include <QApplication>
#include <QByteArray>
#include <QClipboard>
#include <QCoreApplication>
#include <QDir>
#include <QEvent>
#include <QFile>
#include <QGuiApplication>
#include <QMessageBox>
#include <QMetaObject>
#include <QPointer>
#include <QPushButton>
#include <QString>
#include <QTimer>
@ -73,15 +78,14 @@ bool GhosttyApp::ensureInitialized() {
// surface, and app-level actions via the GhosttyApp window registry.
rt.userdata = nullptr;
rt.supports_selection_clipboard = true;
// onWakeup migrated in phase 1.2; the rest still live on
// MainWindow and migrate alongside the action dispatcher in
// phase 1.3+.
// onAction stays on MainWindow until phase 2 introduces the
// ActionDispatcher; the rest are owned by GhosttyApp.
rt.wakeup_cb = GhosttyApp::onWakeup;
rt.action_cb = MainWindow::onAction;
rt.read_clipboard_cb = MainWindow::onReadClipboard;
rt.confirm_read_clipboard_cb = MainWindow::onConfirmReadClipboard;
rt.write_clipboard_cb = MainWindow::onWriteClipboard;
rt.close_surface_cb = MainWindow::onCloseSurface;
rt.read_clipboard_cb = GhosttyApp::onReadClipboard;
rt.confirm_read_clipboard_cb = GhosttyApp::onConfirmReadClipboard;
rt.write_clipboard_cb = GhosttyApp::onWriteClipboard;
rt.close_surface_cb = GhosttyApp::onCloseSurface;
m_app = ghostty_app_new(&rt, m_config);
if (!m_app) {
@ -260,3 +264,113 @@ void GhosttyApp::teardown() {
}
m_needsPremultiply = false;
}
bool GhosttyApp::surfaceAlive(GhosttySurface *s) const {
if (!s) return false;
for (MainWindow *w : m_windows)
if (w->ownsSurface(s)) return true;
return false;
}
bool GhosttyApp::onReadClipboard(void *ud, ghostty_clipboard_e loc,
void *state) {
// surface userdata. Called synchronously by libghostty when a
// surface needs clipboard contents (paste). This runs on the GUI
// thread by construction: every libghostty entry point that
// surfaces a paste lives behind ghostty_app_tick, which the
// process-wide frame timer drives — and that timer is on the GUI
// thread. QClipboard is GUI-thread-only, so reading directly here
// is safe; surfaceAlive still validates the pointer in case a
// surface is mid-destruction on this same thread.
auto *surface = static_cast<GhosttySurface *>(ud);
if (!instance().surfaceAlive(surface) || !surface->surface()) return false;
const QClipboard::Mode mode = loc == GHOSTTY_CLIPBOARD_SELECTION
? QClipboard::Selection
: QClipboard::Clipboard;
const QByteArray text = QGuiApplication::clipboard()->text(mode).toUtf8();
ghostty_surface_complete_clipboard_request(surface->surface(),
text.constData(), state, true);
return true;
}
void GhosttyApp::onConfirmReadClipboard(void *ud, const char *str,
void *state,
ghostty_clipboard_request_e) {
// libghostty asks for confirmation when a paste looks unsafe. The
// dialog MUST be deferred: this callback runs inside libghostty,
// and a modal dialog here spins a nested event loop that re-enters
// libghostty through the render tick — a crash/freeze. `state` is
// a completion token valid until used; `str` is not, so copy it.
auto *surface = static_cast<GhosttySurface *>(ud);
if (!instance().surfaceAlive(surface) || !surface->surface()) return;
QPointer<GhosttySurface> sp(surface);
const QByteArray content(str);
QMetaObject::invokeMethod(
surface->owner(),
[sp, content, state]() {
if (!sp || !sp->surface()) return;
QString preview = QString::fromUtf8(content);
// Truncate by code unit but back off to a non-surrogate
// boundary so we don't slice a surrogate pair half.
if (preview.size() > 200) {
int cut = 200;
while (cut > 0 && preview.at(cut - 1).isHighSurrogate()) --cut;
preview = preview.left(cut) + QStringLiteral("");
}
// Destructive Paste / Cancel buttons, default Cancel —
// mirrors the close-confirmation styling.
QMessageBox box(sp->owner());
box.setIcon(QMessageBox::Warning);
box.setWindowTitle(QStringLiteral("Confirm Paste"));
box.setText(QStringLiteral("The text being pasted may be unsafe."));
box.setInformativeText(preview);
QPushButton *paste = box.addButton(QStringLiteral("Paste"),
QMessageBox::DestructiveRole);
QPushButton *cancel = box.addButton(QStringLiteral("Cancel"),
QMessageBox::RejectRole);
box.setDefaultButton(cancel);
box.exec();
ghostty_surface_complete_clipboard_request(
sp->surface(), content.constData(), state,
box.clickedButton() == paste);
},
Qt::QueuedConnection);
}
void GhosttyApp::onWriteClipboard(void *ud, ghostty_clipboard_e loc,
const ghostty_clipboard_content_s *content,
size_t n, bool) {
if (n == 0 || !content[0].data) return;
auto *surface = static_cast<GhosttySurface *>(ud);
if (!instance().surfaceAlive(surface)) return;
const QClipboard::Mode mode = loc == GHOSTTY_CLIPBOARD_SELECTION
? QClipboard::Selection
: QClipboard::Clipboard;
const QString text = QString::fromUtf8(content[0].data);
// The clipboard is process-global; route via qApp so a window
// dying mid-flight does not strand the write.
QMetaObject::invokeMethod(
qApp,
[text, mode]() { QGuiApplication::clipboard()->setText(text, mode); },
Qt::QueuedConnection);
}
void GhosttyApp::onCloseSurface(void *ud, bool) {
// surface userdata. Deferred out of this callback so the confirm
// dialog cannot spin a nested event loop back into libghostty.
auto *surface = static_cast<GhosttySurface *>(ud);
if (!instance().surfaceAlive(surface)) return;
MainWindow *self = surface->owner();
QPointer<MainWindow> selfp(self);
QPointer<GhosttySurface> sp(surface);
QMetaObject::invokeMethod(
self,
[selfp, sp]() {
if (!selfp || !sp) return;
if (selfp->confirmCloseSurfaces({sp})) selfp->removeSurface(sp);
},
Qt::QueuedConnection);
}

View File

@ -90,8 +90,19 @@ public:
void setQuitDelayMs(int ms) { m_quitDelayMs = ms; }
// ---- libghostty runtime callbacks (registered in ensureInitialized).
// Phase 1.2 ports onWakeup; the others still live on MainWindow.
static void onWakeup(void *ud);
static bool onReadClipboard(void *ud, ghostty_clipboard_e, void *state);
static void onConfirmReadClipboard(void *ud, const char *, void *state,
ghostty_clipboard_request_e);
static void onWriteClipboard(void *ud, ghostty_clipboard_e,
const ghostty_clipboard_content_s *, size_t,
bool);
static void onCloseSurface(void *ud, bool process_active);
// True if the surface pointer (a libghostty userdata) is still owned
// by a live MainWindow. Worker-thread callbacks use this to gate
// against a destruction race.
bool surfaceAlive(GhosttySurface *s) const;
private:
GhosttyApp() = default;