Merge pull request #10 from fuddlesworth/qt-modularize

qt: phase 1 — extract GhosttyApp singleton from MainWindow
pull/12846/head
Nathan 2026-05-23 12:49:37 -05:00 committed by GitHub
commit f652ed8a23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 713 additions and 435 deletions

View File

@ -98,6 +98,7 @@ add_custom_target(ghostty_link DEPENDS "${GHOSTTY_LINK_SO}")
add_executable(ghastty
src/main.cpp
src/app/GhosttyApp.cpp
src/CommandPalette.cpp
src/GhosttySurface.cpp
src/GlobalShortcuts.cpp

View File

@ -54,6 +54,7 @@
#include <LayerShellQt/window.h>
#include "app/GhosttyApp.h"
#include "CommandPalette.h"
#include "GhosttySurface.h"
#include "TabWidget.h"
@ -86,16 +87,17 @@ static QIcon bellAttentionIcon() {
return cached;
}
// Process-shared libghostty state — see MainWindow.h.
ghostty_app_t MainWindow::s_app = nullptr;
ghostty_config_t MainWindow::s_config = nullptr;
bool MainWindow::s_needsPremultiply = false;
QList<MainWindow *> MainWindow::s_windows;
QTimer *MainWindow::s_quitTimer = nullptr;
int MainWindow::s_quitDelayMs = 0;
MainWindow *MainWindow::s_quickTerminal = nullptr;
QTimer *MainWindow::s_frameTimer = nullptr;
std::atomic<bool> MainWindow::s_tickPending{false};
// All process-shared libghostty state lives on GhosttyApp::instance().
// MainWindow's config() and needsPremultiply() forward there so
// external consumers (GhosttySurface, InspectorWindow) don't have to
// take a dependency on app/GhosttyApp.h.
ghostty_config_t MainWindow::config() const {
return GhosttyApp::instance().config();
}
bool MainWindow::needsPremultiply() const {
return GhosttyApp::instance().needsPremultiply();
}
MainWindow::MainWindow() {
setWindowTitle(QStringLiteral("Ghastty"));
@ -156,8 +158,9 @@ MainWindow::MainWindow() {
}
MainWindow::~MainWindow() {
s_windows.removeOne(this);
if (this == s_quickTerminal) s_quickTerminal = nullptr;
// unregisterWindow also clears GhosttyApp's quick-terminal pointer
// if this was it.
GhosttyApp::instance().unregisterWindow(this);
// Destroy this window's surfaces (freeing their ghostty_surface_t)
// before any app teardown; Qt's own child cleanup runs after this body.
@ -170,68 +173,22 @@ MainWindow::~MainWindow() {
// so the delay can run), so without this the process would stay
// alive forever after closing the final window via the WM.
// Mirrors GTK's application.zig:820-862 startQuitTimer wiring.
if (s_windows.isEmpty() && s_quitDelayMs > 0) {
handleQuitTimer(true);
return; // keep s_app + s_config alive until the timer fires
const bool wasLast = GhosttyApp::instance().windows().isEmpty();
if (wasLast && GhosttyApp::instance().quitDelayMs() > 0) {
GhosttyApp::instance().handleQuitTimer(true);
return; // keep the app + config alive until the timer fires
}
// The shared app and config outlive every window but the last.
if (s_windows.isEmpty()) {
if (s_frameTimer) {
// The timer is parented to qApp; stop it so a final tick can't
// fire after s_app is freed below. The QObject destructor
// unparents it from qApp.
s_frameTimer->stop();
delete s_frameTimer;
s_frameTimer = nullptr;
}
if (s_quitTimer) {
delete s_quitTimer;
s_quitTimer = nullptr;
}
// Drain qApp-targeted MetaCalls posted by worker-thread libghostty
// callbacks (closeAllWindows, refreshChrome, OPEN_URL, postProgress,
// handleQuitTimer, NEW_WINDOW, CONFIG_CHANGE, ...) — these are the
// ones that can still touch s_app/s_config after their original
// window has gone. Lambdas posted to per-window/per-surface
// receivers are auto-cancelled by Qt when those receivers were
// deleted above (qDeleteAll above and the broader Qt object tree
// teardown), so they don't need draining.
//
// sendPostedEvents only drains the named receiver, not its
// children — which is exactly what we want here.
QCoreApplication::sendPostedEvents(qApp, QEvent::MetaCall);
if (s_app) {
ghostty_app_free(s_app);
s_app = nullptr;
}
if (s_config) {
ghostty_config_free(s_config);
s_config = nullptr;
}
if (wasLast) {
// GhosttyApp::teardown stops + frees the frame and quit timers,
// drains qApp-targeted MetaCalls (so worker callbacks can't touch
// a freed app), and ghostty_app_frees + ghostty_config_frees the
// live handles.
GhosttyApp::instance().teardown();
}
}
// Whether the Ghostty config enables a custom shader. libghostty does
// not expose this through ghostty_config_get (`custom-shader` is a
// repeatable path), so scan the primary config file directly.
static bool configHasCustomShader() {
QString dir = qEnvironmentVariable("XDG_CONFIG_HOME");
if (dir.isEmpty()) dir = QDir::homePath() + QStringLiteral("/.config");
QFile f(dir + QStringLiteral("/ghostty/config"));
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return false;
while (!f.atEnd()) {
const QByteArray line = f.readLine().trimmed();
if (!line.startsWith("custom-shader")) continue;
// Require a non-empty value: `custom-shader =` alone clears it.
const int eq = line.indexOf('=');
if (eq >= 0 && !line.mid(eq + 1).trimmed().isEmpty()) return true;
}
return false;
}
// Parse a libghostty duration string into nanoseconds. The format is
// concatenated `<n><unit>` segments per Config.zig's Duration.parseCLI:
// y w d h m s ms µs us ns
@ -421,37 +378,16 @@ static void openUrlByKind(const QString &url,
}
bool MainWindow::initialize() {
s_windows.append(this);
// First-call: build libghostty app + config via the singleton.
if (!GhosttyApp::instance().ensureInitialized()) return false;
// The first window builds the shared libghostty app and config; every
// later window reuses them.
if (!s_app) {
// Load configuration in the same order as the reference apprt.
s_config = ghostty_config_new();
ghostty_config_load_default_files(s_config);
ghostty_config_load_cli_args(s_config);
ghostty_config_load_recursive_files(s_config);
ghostty_config_finalize(s_config);
s_needsPremultiply = configHasCustomShader();
ghostty_runtime_config_s rt = {};
// No app userdata: actions are routed to a window via their target
// surface, and app-level actions via the s_windows registry.
rt.userdata = nullptr;
rt.supports_selection_clipboard = true;
rt.wakeup_cb = onWakeup;
rt.action_cb = onAction;
rt.read_clipboard_cb = onReadClipboard;
rt.confirm_read_clipboard_cb = onConfirmReadClipboard;
rt.write_clipboard_cb = onWriteClipboard;
rt.close_surface_cb = onCloseSurface;
s_app = ghostty_app_new(&rt, s_config);
if (!s_app) {
std::fprintf(stderr, "[ghastty] ghostty_app_new failed\n");
return false;
}
GhosttyApp::instance().registerWindow(this);
// First window also caches the quit-after-last-window-closed state.
// Subsequent windows skip it (the singleton already holds the live
// value via its config; only the QApplication quit strategy is set
// once here).
if (GhosttyApp::instance().windows().size() == 1) {
// quit-after-last-window-closed: Qt's native "quit on last window"
// covers the common (no-delay) case; a configured delay is honored
// through the libghostty quit_timer action (see handleQuitTimer).
@ -462,10 +398,11 @@ bool MainWindow::initialize() {
const uint64_t delayNs = parseDurationNs(
configValue(QStringLiteral("quit-after-last-window-closed-delay")), 0);
const uint64_t delayMs = delayNs / 1000000ULL;
s_quitDelayMs = quitAfter
const int delayMsInt = quitAfter
? static_cast<int>(std::min(delayMs, uint64_t(INT_MAX)))
: 0;
QApplication::setQuitOnLastWindowClosed(quitAfter && s_quitDelayMs == 0);
GhosttyApp::instance().setQuitDelayMs(delayMsInt);
QApplication::setQuitOnLastWindowClosed(quitAfter && delayMsInt == 0);
}
// Per-window startup window state, applied before show(). None of it
@ -487,17 +424,9 @@ bool MainWindow::initialize() {
// Tab-bar policy and colour scheme.
applyWindowConfig();
// Process-wide 60fps frame timer (created on the first window): a
// backstop tick plus rendering. onWakeup drives extra ticks between
// frames for input responsiveness. One timer covers every window —
// N windows would otherwise produce N ticks per 16ms for the same
// shared ghostty_app_t.
if (!s_frameTimer) {
s_frameTimer = new QTimer(qApp);
QObject::connect(s_frameTimer, &QTimer::timeout, qApp,
&MainWindow::frame);
s_frameTimer->start(16);
}
// Process-wide 60fps frame timer + libghostty wakeup coalescing
// both live on GhosttyApp now.
GhosttyApp::instance().ensureFrameTimer();
// The first tab is created in showEvent, not here: see below.
return true;
@ -507,8 +436,9 @@ MainWindow *MainWindow::newWindow(ghostty_surface_t parent) {
// If the natural-close quit timer is running (because the last
// window was closed and we're inside the configured delay), cancel
// it now: the process is no longer headless. macOS/GTK do the
// same.
if (s_quitTimer) handleQuitTimer(false);
// same. handleQuitTimer is a no-op when no delay is configured, so
// calling it unconditionally is safe.
GhosttyApp::instance().handleQuitTimer(false);
auto *w = new MainWindow;
w->setAttribute(Qt::WA_DeleteOnClose); // self-destruct when closed
@ -533,14 +463,17 @@ MainWindow *MainWindow::newWindow(ghostty_surface_t parent) {
// window at the same origin — macOS does this via
// NSWindow.cascadeTopLeft. Wayland compositors typically ignore
// window placement requests; this is a hint at most.
ghostty_config_t cfg = GhosttyApp::instance().config();
int16_t posX = 0, posY = 0;
const bool haveX = configGet(s_config, &posX, "window-position-x");
const bool haveY = configGet(s_config, &posY, "window-position-y");
const bool haveX = configGet(cfg, &posX, "window-position-x");
const bool haveY = configGet(cfg, &posY, "window-position-y");
if (haveX && haveY) {
w->move(posX, posY);
} else if (s_windows.size() > 1) {
if (MainWindow *prev = s_windows.value(s_windows.size() - 2)) {
w->move(prev->pos() + QPoint(30, 30));
} else {
const QList<MainWindow *> &all = GhosttyApp::instance().windows();
if (all.size() > 1) {
if (MainWindow *prev = all.value(all.size() - 2))
w->move(prev->pos() + QPoint(30, 30));
}
}
@ -555,7 +488,7 @@ MainWindow *MainWindow::newWindow(ghostty_surface_t parent) {
if (!s_initialWindowConsumed) {
s_initialWindowConsumed = true;
bool initialWindow = true;
configGet(s_config, &initialWindow, "initial-window");
configGet(cfg, &initialWindow, "initial-window");
wantsShow = initialWindow;
}
if (wantsShow) w->show();
@ -596,7 +529,7 @@ void MainWindow::createFirstTab() {
}
GhosttySurface *MainWindow::newTab(ghostty_surface_t parent) {
auto *surface = new GhosttySurface(s_app, this, parent);
auto *surface = new GhosttySurface(GhosttyApp::instance().app(), this, parent);
m_surfaces.append(surface);
// The tab page hosts the tab's split tree (initially one surface).
@ -629,7 +562,7 @@ GhosttySurface *MainWindow::splitSurface(
const bool newAfter = dir == GHOSTTY_SPLIT_DIRECTION_RIGHT ||
dir == GHOSTTY_SPLIT_DIRECTION_DOWN;
auto *surface = new GhosttySurface(s_app, this, target->surface());
auto *surface = new GhosttySurface(GhosttyApp::instance().app(), this, target->surface());
auto *splitter =
new QSplitter(horizontal ? Qt::Horizontal : Qt::Vertical);
splitter->setChildrenCollapsible(false);
@ -795,7 +728,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)
@ -894,33 +827,6 @@ void MainWindow::copyTitleToClipboard(GhosttySurface *src) {
if (!title.isEmpty()) QGuiApplication::clipboard()->setText(title);
}
void MainWindow::frame() {
if (!s_app) return;
ghostty_app_tick(s_app);
// Rendering happens only here, so a flood of RENDER actions cannot
// saturate the GUI thread — each surface renders at most once a
// frame. One pass across every window: the shared ghostty_app_t
// was already ticked once above.
//
// Iterate via QPointer snapshots so a render-driven close
// (renderer-unhealthy chain, child-exited press, etc.) that
// destroys a window or surface mid-frame can't UAF the iterator
// or the inner-loop receiver.
QList<QPointer<MainWindow>> windows;
windows.reserve(s_windows.size());
for (MainWindow *w : s_windows) windows.append(w);
for (const QPointer<MainWindow> &wp : windows) {
if (!wp) continue;
QList<QPointer<GhosttySurface>> surfaces;
surfaces.reserve(wp->m_surfaces.size());
for (GhosttySurface *s : wp->m_surfaces) surfaces.append(s);
for (const QPointer<GhosttySurface> &sp : surfaces) {
if (!wp || !sp) continue;
sp->renderIfDirty();
}
}
}
void MainWindow::onTabCloseRequested(int index) {
if (!confirmCloseSurfaces(surfacesInTab(index))) return;
closeTab(index);
@ -989,12 +895,14 @@ void MainWindow::closeAllWindows(bool thenQuit) {
// + Cancel default — same style as confirmCloseSurfaces. Title /
// verb track whether this is a Quit (process ends) or a
// Close All Windows (process may stay alive).
if (s_app && ghostty_app_needs_confirm_quit(s_app)) {
ghostty_app_t app = GhosttyApp::instance().app();
if (app && ghostty_app_needs_confirm_quit(app)) {
const QString title = thenQuit ? QStringLiteral("Quit")
: QStringLiteral("Close All Windows");
const QString verb = thenQuit ? QStringLiteral("Quit")
: QStringLiteral("Close All");
QMessageBox box(s_windows.isEmpty() ? nullptr : s_windows.first());
const QList<MainWindow *> &live = GhosttyApp::instance().windows();
QMessageBox box(live.isEmpty() ? nullptr : live.first());
box.setIcon(QMessageBox::Warning);
box.setWindowTitle(title);
box.setText(QStringLiteral("There are still running processes."));
@ -1007,8 +915,8 @@ void MainWindow::closeAllWindows(bool thenQuit) {
box.exec();
if (box.clickedButton() != go) return;
}
// Copy: each close() may delete the window and mutate s_windows.
const QList<MainWindow *> windows = s_windows;
// Copy: each close() may delete the window and mutate the live list.
const QList<MainWindow *> windows = GhosttyApp::instance().windows();
for (MainWindow *w : windows) {
w->m_skipCloseConfirm = true;
w->close();
@ -1025,8 +933,9 @@ void MainWindow::closeAllWindows(bool thenQuit) {
// Qt's quitOnLastWindowClosed terminates as the last window's
// close event runs. We read both decisions off the *cached*
// QApplication state so they stay consistent: refreshChrome
// sets quitOnLastWindowClosed and s_quitDelayMs together.
if (QApplication::quitOnLastWindowClosed() && s_quitDelayMs == 0) {
// sets quitOnLastWindowClosed and the singleton's delay together.
if (QApplication::quitOnLastWindowClosed() &&
GhosttyApp::instance().quitDelayMs() == 0) {
qApp->quit();
}
// Else: the close loop above already triggered the natural-close
@ -1035,45 +944,17 @@ void MainWindow::closeAllWindows(bool thenQuit) {
}
}
void MainWindow::toggleVisibility() {
// If anything is showing, hide everything; otherwise reveal it all.
bool anyVisible = false;
for (MainWindow *w : s_windows)
if (w->isVisible()) {
anyVisible = true;
break;
}
for (MainWindow *w : s_windows) {
if (anyVisible) {
w->hide();
} else {
w->show();
w->raise();
w->activateWindow();
}
}
}
void MainWindow::toggleQuickTerminal() {
if (s_quickTerminal) {
if (s_quickTerminal->isVisible()) {
s_quickTerminal->animateQuickTerminalOut();
} else {
s_quickTerminal->animateQuickTerminalIn();
}
return;
}
// First use: build the dedicated quick-terminal window.
MainWindow *MainWindow::makeQuickTerminal() {
auto *w = new MainWindow;
w->m_quickTerminal = true;
w->setAttribute(Qt::WA_DeleteOnClose);
if (!w->initialize()) {
delete w;
return;
return nullptr;
}
s_quickTerminal = w;
w->setupLayerShell();
w->animateQuickTerminalIn();
return w;
}
// Read quick-terminal-animation-duration (seconds) and convert to ms.
@ -1092,7 +973,7 @@ void MainWindow::animateQuickTerminalIn() {
show();
raise();
activateWindow();
const int ms = quickTerminalAnimationMs(s_config);
const int ms = quickTerminalAnimationMs(GhosttyApp::instance().config());
if (ms <= 0) {
setWindowOpacity(1.0);
return;
@ -1110,7 +991,7 @@ void MainWindow::animateQuickTerminalIn() {
}
void MainWindow::animateQuickTerminalOut() {
const int ms = quickTerminalAnimationMs(s_config);
const int ms = quickTerminalAnimationMs(GhosttyApp::instance().config());
if (ms <= 0) {
hide();
return;
@ -1192,7 +1073,7 @@ void MainWindow::setupLayerShell() {
// quick-terminal-size: primary is the edge-perpendicular extent.
ghostty_config_quick_terminal_size_s qsz = {};
configGet(s_config, &qsz, "quick-terminal-size");
configGet(GhosttyApp::instance().config(), &qsz, "quick-terminal-size");
const auto toPx = [](const ghostty_quick_terminal_size_s &s, int dim,
int fallback) -> int {
switch (s.tag) {
@ -1247,25 +1128,6 @@ void MainWindow::changeEvent(QEvent *e) {
QWidget::changeEvent(e);
}
void MainWindow::handleQuitTimer(bool start) {
// Only meaningful when a delay is configured; otherwise Qt's
// quitOnLastWindowClosed already handles the quit.
if (s_quitDelayMs <= 0) return;
if (start) {
if (!s_quitTimer) {
// Parent to qApp for consistency with s_frameTimer; the dtor
// still deletes it explicitly when the last window closes.
s_quitTimer = new QTimer(qApp);
s_quitTimer->setSingleShot(true);
QObject::connect(s_quitTimer, &QTimer::timeout, qApp,
&QApplication::quit);
}
s_quitTimer->start(s_quitDelayMs);
} else if (s_quitTimer) {
s_quitTimer->stop();
}
}
void MainWindow::onCurrentChanged(int index) {
GhosttySurface *s = surfaceAt(index);
if (!s) return;
@ -1342,7 +1204,7 @@ void MainWindow::gotoSplit(GhosttySurface *from,
// `bool`==1) under-sized the buffer and corrupted adjacent stack;
// read into c_uint and mask the bits.
unsigned int pzBits = 0;
configGet(s_config, &pzBits, "split-preserve-zoom");
configGet(GhosttyApp::instance().config(), &pzBits, "split-preserve-zoom");
const bool preserveZoom = (pzBits & 0x1) != 0 && m_zoomed == from;
const auto centerOf = [](GhosttySurface *s) {
@ -1506,7 +1368,7 @@ void MainWindow::ringBell(GhosttySurface *surface) {
// If config-get succeeds with features=0, the user explicitly opted
// out of every bell feature and we honor that.
unsigned int features = 0;
if (!configGet(s_config, &features, "bell-features")) {
if (!configGet(GhosttyApp::instance().config(), &features, "bell-features")) {
features = BellAttention;
}
if (features & BellAttention) QApplication::alert(this);
@ -1563,7 +1425,7 @@ void MainWindow::playBellAudio() {
m_bellPlayer->play();
}
// Refresh every window's chrome from the current s_config: tab-bar
// Refresh every window's chrome from the current GhosttyApp config: tab-bar
// policy, colour scheme, blur — plus window-level state that
// previously only applied at startup (window-decoration, fullscreen,
// maximize) and the quit-after-last-window-closed delay.
@ -1571,20 +1433,21 @@ void MainWindow::refreshChrome() {
// Refresh app-scoped state. quit-after-last-window-closed[-delay]
// can change the delay or the quitOnLastWindowClosed strategy at
// runtime; mirrors the calculation in initialize().
if (s_config) {
if (ghostty_config_t cfg = GhosttyApp::instance().config()) {
bool quitAfter = true;
configGet(s_config, &quitAfter, "quit-after-last-window-closed");
configGet(cfg, &quitAfter, "quit-after-last-window-closed");
// Same Duration-decode workaround as initialize().
const uint64_t delayNs = parseDurationNs(
configValue(QStringLiteral("quit-after-last-window-closed-delay")), 0);
const uint64_t delayMs = delayNs / 1000000ULL;
s_quitDelayMs = quitAfter
const int delayMsInt = quitAfter
? static_cast<int>(std::min(delayMs, uint64_t(INT_MAX)))
: 0;
QApplication::setQuitOnLastWindowClosed(quitAfter && s_quitDelayMs == 0);
GhosttyApp::instance().setQuitDelayMs(delayMsInt);
QApplication::setQuitOnLastWindowClosed(quitAfter && delayMsInt == 0);
}
for (MainWindow *w : s_windows) {
for (MainWindow *w : GhosttyApp::instance().windows()) {
w->applyWindowConfig();
w->applyBlur();
@ -1633,7 +1496,7 @@ void MainWindow::refreshChrome() {
void MainWindow::reloadConfig() { reloadConfigGlobal(); }
void MainWindow::reloadConfigGlobal() {
if (!s_app) return;
if (!GhosttyApp::instance().app()) return;
// Re-read the config from disk in the same order as initialize().
ghostty_config_t config = ghostty_config_new();
ghostty_config_load_default_files(config);
@ -1644,14 +1507,12 @@ void MainWindow::reloadConfigGlobal() {
// Push to libghostty. App.updateConfig propagates the config to every
// surface and fires CONFIG_CHANGE back at us — which only refreshes
// chrome, never re-pushes, so this does not loop.
ghostty_app_update_config(s_app, config);
ghostty_app_update_config(GhosttyApp::instance().app(), config);
// Adopt the new config. libghostty keeps borrowed references to it
// (the surface message queue), so it must outlive this call — which
// it does, as the live s_config.
if (s_config && s_config != config) ghostty_config_free(s_config);
s_config = config;
s_needsPremultiply = configHasCustomShader();
// Hand the new config to the singleton, which frees the previous one
// (in the right order — libghostty borrows the previous until update
// completes) and recomputes needsPremultiply.
GhosttyApp::instance().replaceConfig(config);
refreshChrome();
@ -1665,7 +1526,7 @@ void MainWindow::reloadConfigGlobal() {
// forward compatibility — Qt doesn't currently post a copy
// toast, but a future one will pick up the same gate.
unsigned int notifBits = 0;
const bool notifOk = configGet(s_config, &notifBits, "app-notifications");
const bool notifOk = configGet(GhosttyApp::instance().config(), &notifBits, "app-notifications");
// configGet failure → defaults (both bits set) so the feature
// still works as documented.
if (!notifOk) notifBits = 0x3;
@ -1676,16 +1537,17 @@ void MainWindow::reloadConfigGlobal() {
}
QString MainWindow::configString(const char *key) const {
ghostty_config_t cfg = GhosttyApp::instance().config();
const char *value = nullptr;
if (!s_config ||
!ghostty_config_get(s_config, &value, key, qstrlen(key)) || !value)
if (!cfg || !ghostty_config_get(cfg, &value, key, qstrlen(key)) || !value)
return {};
return QString::fromUtf8(value);
}
bool MainWindow::configBool(const char *key, bool fallback) const {
bool value = fallback; // ghostty_config_get leaves it untouched if absent
if (s_config) ghostty_config_get(s_config, &value, key, qstrlen(key));
if (ghostty_config_t cfg = GhosttyApp::instance().config())
ghostty_config_get(cfg, &value, key, qstrlen(key));
return value;
}
@ -1703,18 +1565,20 @@ void MainWindow::presentTerminal(GhosttySurface *surface) {
if (surface) surface->setFocus();
}
// Cycle through s_windows. The libghostty target picks a starting
// window (the one whose surface fired the action); GOTO_WINDOW_NEXT
// goes forward, PREVIOUS goes backward, wrapping at the ends.
// Cycle through the live window list. The libghostty target picks a
// starting window (the one whose surface fired the action);
// GOTO_WINDOW_NEXT goes forward, PREVIOUS goes backward, wrapping at
// the ends.
void MainWindow::gotoWindow(MainWindow *from,
ghostty_action_goto_window_e dir) {
const int n = s_windows.size();
const QList<MainWindow *> &live = GhosttyApp::instance().windows();
const int n = live.size();
if (n <= 1) return;
const int idx = from ? s_windows.indexOf(from) : 0;
const int idx = from ? live.indexOf(from) : 0;
if (idx < 0) return;
const int step = (dir == GHOSTTY_GOTO_WINDOW_NEXT) ? 1 : -1;
const int next = (idx + step + n) % n;
if (MainWindow *w = s_windows.value(next)) w->presentTerminal(nullptr);
if (MainWindow *w = live.value(next)) w->presentTerminal(nullptr);
}
// FLOAT_WINDOW: keep this window above other windows (Qt::
@ -1839,16 +1703,17 @@ void MainWindow::undoLastClose() {
// The active window picks the new tab's parent surface for cwd
// inheritance. Skip the quick terminal — it doesn't push undo
// entries and isn't a meaningful target. Fall back to the most
// recent regular window in s_windows order.
// recent regular window in registration order.
auto isUndoTarget = [](MainWindow *w) {
return w && !w->m_quickTerminal;
};
MainWindow *active = qobject_cast<MainWindow *>(qApp->activeWindow());
if (!isUndoTarget(active)) {
active = nullptr;
for (int i = s_windows.size() - 1; i >= 0; --i) {
if (isUndoTarget(s_windows.at(i))) {
active = s_windows.at(i);
const QList<MainWindow *> &live = GhosttyApp::instance().windows();
for (int i = live.size() - 1; i >= 0; --i) {
if (isUndoTarget(live.at(i))) {
active = live.at(i);
break;
}
}
@ -1911,7 +1776,10 @@ void MainWindow::redoLastClose() {
UndoEntry e = s_redoStack.takeLast();
MainWindow *active = qobject_cast<MainWindow *>(qApp->activeWindow());
if (!active && !s_windows.isEmpty()) active = s_windows.last();
if (!active) {
const QList<MainWindow *> &live = GhosttyApp::instance().windows();
if (!live.isEmpty()) active = live.last();
}
if (!active) {
// No window to act on — restore the entry so the user can retry.
s_redoStack.append(std::move(e));
@ -2004,7 +1872,7 @@ void MainWindow::applyWindowConfig() {
scheme = Qt::ColorScheme::Light;
} else if (theme == QLatin1String("ghostty")) {
ghostty_config_color_s bg{};
if (configGet(s_config, &bg, "background")) {
if (configGet(GhosttyApp::instance().config(), &bg, "background")) {
const double luma = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b;
scheme = luma < 128.0 ? Qt::ColorScheme::Dark : Qt::ColorScheme::Light;
}
@ -2022,7 +1890,7 @@ void MainWindow::applyBlur() {
// macOS-only negatives) means off, a positive radius means on. KWin
// uses its own configured radius, so only on/off matters here.
short blur = 0;
configGet(s_config, &blur, "background-blur");
configGet(GhosttyApp::instance().config(), &blur, "background-blur");
applyWindowBlur(this, blur > 0);
}
@ -2069,30 +1937,8 @@ void MainWindow::toggleSplitZoom(GhosttySurface *surface) {
}
// --- libghostty runtime callbacks ------------------------------------
void MainWindow::onWakeup(void *) {
// Coalesce: queue a shared-app tick only when one is not already
// pending, so a chatty surface cannot flood the event loop. May be
// called off-thread, so it marshals onto qApp (always alive) rather
// than any particular window. The s_app check inside the lambda
// guards against the last window being destroyed (which frees s_app)
// between this wakeup and the queued tick draining.
if (s_tickPending.exchange(true)) return;
QMetaObject::invokeMethod(
qApp,
[]() {
s_tickPending.store(false);
if (s_app) ghostty_app_tick(s_app);
},
Qt::QueuedConnection);
}
bool MainWindow::surfaceAlive(GhosttySurface *s) {
if (!s) return false;
for (MainWindow *w : s_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) {
@ -2155,8 +2001,9 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target,
// *cross*-captured pointers (e.g. `src` when posting to `win`) are
// wrapped in QPointer so they're checked at lambda-execution time —
// a multi-window + tear-off + close race could otherwise UAF.
const QList<MainWindow *> &live = GhosttyApp::instance().windows();
MainWindow *win = src ? src->owner()
: (s_windows.isEmpty() ? nullptr : s_windows.first());
: (live.isEmpty() ? nullptr : live.first());
QPointer<MainWindow> winp(win);
QPointer<GhosttySurface> srcp(src);
@ -2234,9 +2081,10 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target,
// global keybind can rename even when no surface is the action's
// explicit target. Mirrors macOS NSApp.mainWindow promotion.
GhosttySurface *target = src;
if (!target && !s_windows.isEmpty()) {
const QList<MainWindow *> &allWindows = GhosttyApp::instance().windows();
if (!target && !allWindows.isEmpty()) {
MainWindow *active = qobject_cast<MainWindow *>(qApp->activeWindow());
if (!active) active = s_windows.first();
if (!active) active = allWindows.first();
if (active) target = active->surfaceAt(active->m_tabs->currentIndex());
}
if (!target) return false;
@ -2360,7 +2208,8 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target,
case GHOSTTY_ACTION_QUIT_TIMER: {
const bool start =
action.action.quit_timer == GHOSTTY_QUIT_TIMER_START;
post(qApp, [start]() { MainWindow::handleQuitTimer(start); });
post(qApp,
[start]() { GhosttyApp::instance().handleQuitTimer(start); });
return true;
}
@ -2374,7 +2223,7 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target,
// abnormal threshold (default 250ms). Banner = "the process
// died unexpectedly," not "the process exited."
uint32_t threshold = 250;
configGet(s_config, &threshold, "abnormal-command-exit-runtime");
configGet(GhosttyApp::instance().config(), &threshold, "abnormal-command-exit-runtime");
if (ce.runtime_ms < threshold) return true;
const int code = static_cast<int>(ce.exit_code);
post(src, [srcp, code]() {
@ -2533,7 +2382,7 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target,
// "configGet failed; nothing to do" semantics.
unsigned int actBits = 0;
const bool actOk =
configGet(s_config, &actBits, "notify-on-command-finish-action");
configGet(GhosttyApp::instance().config(), &actBits, "notify-on-command-finish-action");
// configGet failure → fall back to the documented defaults
// (bell=true, notify=false) so the feature still works.
if (!actOk) actBits = 0x1;
@ -2556,7 +2405,7 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target,
case GHOSTTY_ACTION_MOVE_TAB: {
// Surface-target only: an app-target MOVE_TAB has no meaningful
// window to apply to (we'd just pick s_windows.first() arbitrarily).
// window to apply to (we'd just pick the first live one arbitrarily).
// macOS returns false here — performable falls through to the
// running terminal on no live window.
if (target.tag != GHOSTTY_TARGET_SURFACE || !src) return false;
@ -2622,11 +2471,11 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target,
}
case GHOSTTY_ACTION_TOGGLE_VISIBILITY:
post(qApp, []() { MainWindow::toggleVisibility(); });
post(qApp, []() { GhosttyApp::instance().toggleVisibility(); });
return true;
case GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL:
post(qApp, []() { MainWindow::toggleQuickTerminal(); });
post(qApp, []() { GhosttyApp::instance().toggleQuickTerminal(); });
return true;
case GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE:
@ -2700,7 +2549,7 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target,
case GHOSTTY_ACTION_GOTO_WINDOW: {
// Performable: return false on a single window so the chord
// falls through to the terminal.
if (s_windows.size() <= 1) return false;
if (GhosttyApp::instance().windows().size() <= 1) return false;
const ghostty_action_goto_window_e dir = action.action.goto_window;
post(qApp,
[winp, dir]() { MainWindow::gotoWindow(winp.data(), dir); });
@ -2874,104 +2723,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

@ -1,7 +1,5 @@
#pragma once
#include <atomic>
#include <QList>
#include <QRect>
#include <QSize>
@ -23,7 +21,8 @@ class GhosttySurface;
// A top-level window presenting terminal surfaces as tabs; each tab may
// be subdivided into splits. The libghostty app and config are shared
// process-wide across every window (the static s_* members below).
// process-wide via GhosttyApp::instance(); MainWindow's config() and
// needsPremultiply() forward there.
//
// Widget tree: QTabWidget -> tab page (QWidget) -> split tree, where a
// node is either a GhosttySurface (a QOpenGLWidget) or a QSplitter of
@ -43,18 +42,18 @@ public:
// tab whose surface inherits from `parent` (may be null).
static MainWindow *newWindow(ghostty_surface_t parent);
// Show or hide every window at once (TOGGLE_VISIBILITY).
static void toggleVisibility();
// Show/hide the dropdown quick terminal, creating it on first use
// (TOGGLE_QUICK_TERMINAL). There is at most one per process.
static void toggleQuickTerminal();
// Build the process's single quick-terminal MainWindow on demand:
// a layer-shell dropdown anchored to a screen edge, faded in
// immediately. Called from GhosttyApp::toggleQuickTerminal on first
// use. Returns nullptr on init failure.
static MainWindow *makeQuickTerminal();
// Quick-terminal slide/fade animation per quick-terminal-animation-
// duration. Implemented as a windowOpacity fade because Qt's layer-
// shell doesn't expose a usable position-based slide API.
void animateQuickTerminalIn();
void animateQuickTerminalOut();
bool isQuickTerminal() const { return m_quickTerminal; }
// Open a new tab. `parent` (may be null) is the surface whose working
// directory etc. the new surface should inherit.
@ -71,8 +70,11 @@ public:
// Update the tab label and window title for `surface`.
void setSurfaceTitle(GhosttySurface *surface, const QString &title);
// The live libghostty config (for keybind lookups, etc.).
ghostty_config_t config() const { return s_config; }
// The live libghostty config (for keybind lookups, etc.). Forwards
// to GhosttyApp::instance().config(); kept on MainWindow as a thin
// shim so external callers (GhosttySurface, InspectorWindow) don't
// need to take a dependency on app/GhosttyApp.h.
ghostty_config_t config() const;
// UNDO / REDO close-tab/window. The libghostty actions carry no
// payload — the apprt is responsible for tracking what was closed
@ -87,7 +89,7 @@ public:
// PRESENT_TERMINAL: bring this window to front and focus the surface.
void presentTerminal(GhosttySurface *surface);
// GOTO_WINDOW: cycle to the previous/next window in s_windows order.
// GOTO_WINDOW: cycle to the previous/next window in registration order.
static void gotoWindow(MainWindow *from,
ghostty_action_goto_window_e dir);
// FLOAT_WINDOW / TOGGLE_WINDOW_DECORATIONS / TOGGLE_BACKGROUND_OPACITY:
@ -105,12 +107,25 @@ public:
// Whether a custom shader is configured. With one, libghostty's final
// framebuffer is non-premultiplied and surfaces must premultiply it
// before Qt composites (see GhosttySurface::premultiplyFramebuffer).
bool needsPremultiply() const { return s_needsPremultiply; }
// Forwards to GhosttyApp::instance().needsPremultiply().
bool needsPremultiply() const;
// Whether `focus-follows-mouse` is enabled — a GhosttySurface grabs
// focus when the pointer enters it.
bool focusFollowsMouse() const;
// Live surface list owned by this window. Read by GhosttyApp::frame
// to walk every surface for renderIfDirty without exposing the
// 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;
@ -125,14 +140,16 @@ private slots:
void onCurrentChanged(int index);
private:
// GhosttyApp::onCloseSurface needs to call confirmCloseSurfaces /
// removeSurface (both private) on the target window from a deferred
// queued slot. Phase 2's ActionDispatcher refactor will replace
// this with public predicates on the per-window API.
friend class GhosttyApp;
// Create the first tab once the device pixel ratio has settled.
void createFirstTab();
// 60fps frame timer body. Static because there is only one timer
// per process — N windows pointing at the same shared ghostty_app_t.
// Ticks libghostty once and renders any dirty surface across every
// window.
static void frame();
// The frame-timer body lives on GhosttyApp::frame (process-wide).
void closeTab(int index);
// Honor close-tab-mode (THIS / OTHER / RIGHT) from libghostty.
@ -177,10 +194,11 @@ private:
// Rebuild the config from disk and push it to libghostty.
void reloadConfig();
// App-scoped reload entry point. The config is process-wide (statics
// in this class), so reload from any window has the same effect; the
// RELOAD_CONFIG action posts to qApp via this static so the reload
// can't be cancelled by the source window closing mid-dispatch.
// App-scoped reload entry point. The config is process-wide (held
// by GhosttyApp), so a reload from any window has the same effect;
// the RELOAD_CONFIG action posts to qApp via this static so the
// reload can't be cancelled by the source window closing
// mid-dispatch.
static void reloadConfigGlobal();
// Refresh every window's chrome from the current config (used after a
// reload and on the CONFIG_CHANGE notification).
@ -219,32 +237,21 @@ private:
// matching macOS where close-all and quit are distinct.
static void closeAllWindows(bool thenQuit);
// Wire the libghostty quit_timer action to a delayed QApplication
// quit, gated on `quit-after-last-window-closed`.
static void handleQuitTimer(bool start);
// The quit-after-last-window-closed timer lives on
// GhosttyApp::handleQuitTimer.
// Toggle a split pane filling its tab. Re-parents the surface out of
// / back into the splitter tree.
void toggleSplitZoom(GhosttySurface *surface);
// Runtime callbacks dispatched by libghostty. wakeup/action are
// app-level (routed via the target surface or s_windows); clipboard/
// close carry the surface userdata.
static void onWakeup(void *ud);
// 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
@ -272,20 +279,9 @@ private:
// of `background-opacity`).
bool m_opacityForcedOpaque = false;
// Process-shared libghostty state: one app and config drive every
// window. Created by the first initialize(), freed with the last
// window. s_windows tracks every live window.
static ghostty_app_t s_app;
static ghostty_config_t s_config;
static bool s_needsPremultiply; // a custom shader is configured
static QList<MainWindow *> s_windows;
static QTimer *s_quitTimer; // delayed quit-after-last-window
static int s_quitDelayMs; // 0 = no delay configured
static MainWindow *s_quickTerminal; // the one quick terminal, if any
// Process-wide 60Hz frame timer. Replaces a per-window timer, so N
// windows do not produce N ghostty_app_tick calls every 16ms for the
// same shared app.
static QTimer *s_frameTimer;
// The libghostty app + config + derived state all live on
// GhosttyApp::instance(). MainWindow's config() / needsPremultiply()
// accessors forward to it.
// Snapshot of a closed tab or window for undo/redo. `pageTitles`
// holds each tab's last-known title (window snapshots have N tabs;
@ -315,9 +311,7 @@ private:
// single Window entry; called from closeAllWindows / closeEvent.
void pushWindowUndo();
// Coalesces wakeup-driven ticks: a tick is queued at most once at a
// time, so a busy surface can't flood the event loop.
static std::atomic<bool> s_tickPending;
// Wakeup tick coalescing lives on GhosttyApp::m_tickPending.
// Split-zoom state: the surface temporarily filling its tab, the
// splitter it came from, its index there, and the stashed tree root.

387
qt/src/app/GhosttyApp.cpp Normal file
View File

@ -0,0 +1,387 @@
#include "GhosttyApp.h"
#include <cstdio>
#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>
#include "../GhosttySurface.h"
#include "../MainWindow.h"
// Process-wide libghostty state and the runtime callbacks libghostty
// hands back. onAction stays on MainWindow until phase 2 introduces
// the ActionDispatcher; everything else is here. The undo/redo stack
// stays on MainWindow as well.
// Whether the Ghostty config enables a custom shader. libghostty does
// not expose this through ghostty_config_get (`custom-shader` is a
// repeatable path), so scan the primary config file directly. Same
// implementation MainWindow had before — moved here because
// needsPremultiply is now an app-level fact.
static bool configHasCustomShader() {
QString dir = qEnvironmentVariable("XDG_CONFIG_HOME");
if (dir.isEmpty()) dir = QDir::homePath() + QStringLiteral("/.config");
QFile f(dir + QStringLiteral("/ghostty/config"));
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return false;
while (!f.atEnd()) {
const QByteArray line = f.readLine().trimmed();
if (!line.startsWith("custom-shader")) continue;
// Require a non-empty value: `custom-shader =` alone clears it.
const int eq = line.indexOf('=');
if (eq >= 0 && !line.mid(eq + 1).trimmed().isEmpty()) return true;
}
return false;
}
GhosttyApp &GhosttyApp::instance() {
// Static-local singleton: deterministic destruction at process exit
// (after Qt has already torn down QObject children). Construction
// is deferred until the first call so QApplication exists by then.
static GhosttyApp self;
return self;
}
GhosttyApp::~GhosttyApp() {
// Process-exit ordering: this static-local destructor runs AFTER
// main() returns and the stack-allocated QApplication has been
// destroyed, so qApp is null and the Qt event system is gone.
// Calling teardown() here would invoke
// QCoreApplication::sendPostedEvents on a dangling qApp.
//
// The normal shutdown path runs teardown() from the last
// MainWindow's dtor (while QApplication is still alive); by the
// time we get here, m_app / m_config / m_frameTimer / m_quitTimer
// are already null. A second teardown() would be redundant and
// unsafe. The early-exit case (ghostty_init failure in main, before
// any window is constructed) never calls instance(), so this
// destructor never runs in that path either.
}
bool GhosttyApp::ensureInitialized() {
if (m_app) return true;
// Load configuration in the same order as the reference apprt.
m_config = ghostty_config_new();
ghostty_config_load_default_files(m_config);
ghostty_config_load_cli_args(m_config);
ghostty_config_load_recursive_files(m_config);
ghostty_config_finalize(m_config);
m_needsPremultiply = configHasCustomShader();
ghostty_runtime_config_s rt = {};
// No app userdata: actions are routed to a window via their target
// surface, and app-level actions via the GhosttyApp window registry.
rt.userdata = nullptr;
rt.supports_selection_clipboard = true;
// 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 = 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) {
std::fprintf(stderr, "[ghastty] ghostty_app_new failed\n");
ghostty_config_free(m_config);
m_config = nullptr;
return false;
}
return true;
}
void GhosttyApp::replaceConfig(ghostty_config_t new_config) {
// PRECONDITION: the caller has already pushed `new_config` to
// libghostty via ghostty_app_update_config. libghostty's surface
// message queue may still hold borrowed references to the old
// config until that update completes — by the time we get here,
// the queue has adopted the new config and the old is safe to free.
if (m_config && m_config != new_config) ghostty_config_free(m_config);
m_config = new_config;
m_needsPremultiply = configHasCustomShader();
}
void GhosttyApp::registerWindow(MainWindow *w) {
m_windows.append(w);
}
void GhosttyApp::unregisterWindow(MainWindow *w) {
m_windows.removeOne(w);
if (m_quickTerminal == w) m_quickTerminal = nullptr;
}
void GhosttyApp::toggleVisibility() {
// If anything is showing, hide everything; otherwise reveal it all.
bool anyVisible = false;
for (MainWindow *w : m_windows)
if (w->isVisible()) {
anyVisible = true;
break;
}
for (MainWindow *w : m_windows) {
if (anyVisible) {
w->hide();
} else {
w->show();
w->raise();
w->activateWindow();
}
}
}
void GhosttyApp::toggleQuickTerminal() {
if (m_quickTerminal) {
if (m_quickTerminal->isVisible())
m_quickTerminal->animateQuickTerminalOut();
else
m_quickTerminal->animateQuickTerminalIn();
return;
}
// First use: build the dedicated quick-terminal window. It registers
// itself via the standard registerWindow path; we additionally
// remember it as the singleton dropdown so a second toggle-call
// animates rather than building another window.
m_quickTerminal = MainWindow::makeQuickTerminal();
}
void GhosttyApp::ensureFrameTimer() {
if (m_frameTimer) return;
// Process-wide 60fps frame timer: a backstop tick plus rendering.
// onWakeup drives extra ticks between frames for input
// responsiveness. One timer covers every window — N windows would
// otherwise produce N ticks per 16ms for the same shared
// ghostty_app_t.
m_frameTimer = new QTimer(qApp);
QObject::connect(m_frameTimer, &QTimer::timeout, qApp,
[this]() { frame(); });
m_frameTimer->start(16);
}
void GhosttyApp::handleQuitTimer(bool start) {
// Only meaningful when a delay is configured; otherwise Qt's
// quitOnLastWindowClosed already handles the quit.
if (m_quitDelayMs <= 0) return;
if (start) {
if (!m_quitTimer) {
// Parent to qApp for consistency with m_frameTimer; teardown()
// still deletes it explicitly when the last window closes.
m_quitTimer = new QTimer(qApp);
m_quitTimer->setSingleShot(true);
QObject::connect(m_quitTimer, &QTimer::timeout, qApp,
&QApplication::quit);
}
m_quitTimer->start(m_quitDelayMs);
} else if (m_quitTimer) {
m_quitTimer->stop();
}
}
void GhosttyApp::frame() {
if (!m_app) return;
ghostty_app_tick(m_app);
// Rendering happens only here, so a flood of RENDER actions cannot
// saturate the GUI thread — each surface renders at most once a
// frame. One pass across every window: the shared ghostty_app_t
// was already ticked once above.
//
// Iterate via QPointer snapshots so a render-driven close
// (renderer-unhealthy chain, child-exited press, etc.) that
// destroys a window or surface mid-frame can't UAF the iterator
// or the inner-loop receiver.
QList<QPointer<MainWindow>> windows;
windows.reserve(m_windows.size());
for (MainWindow *w : m_windows) windows.append(w);
for (const QPointer<MainWindow> &wp : windows) {
if (!wp) continue;
QList<QPointer<GhosttySurface>> surfaces;
const QList<GhosttySurface *> &surfList = wp->surfaces();
surfaces.reserve(surfList.size());
for (GhosttySurface *s : surfList) surfaces.append(s);
for (const QPointer<GhosttySurface> &sp : surfaces) {
if (!wp || !sp) continue;
sp->renderIfDirty();
}
}
}
void GhosttyApp::onWakeup(void *) {
// Coalesce: queue a shared-app tick only when one is not already
// pending, so a chatty surface cannot flood the event loop. May be
// called off-thread, so it marshals onto qApp (always alive) rather
// than any particular window. The m_app check inside the lambda
// guards against the last window being destroyed (which calls
// teardown and frees m_app) between this wakeup and the queued
// tick draining.
GhosttyApp &self = instance();
if (self.m_tickPending.exchange(true)) return;
QMetaObject::invokeMethod(
qApp,
[]() {
GhosttyApp &s = instance();
s.m_tickPending.store(false);
if (s.m_app) ghostty_app_tick(s.m_app);
},
Qt::QueuedConnection);
}
void GhosttyApp::teardown() {
// Stop and free the timers BEFORE draining queued events: a final
// frame timeout could otherwise dispatch through the queue and
// tick the about-to-be-freed app.
if (m_frameTimer) {
m_frameTimer->stop();
delete m_frameTimer;
m_frameTimer = nullptr;
}
if (m_quitTimer) {
delete m_quitTimer;
m_quitTimer = nullptr;
}
// Drain qApp-targeted MetaCalls posted by worker-thread libghostty
// callbacks (closeAllWindows, refreshChrome, OPEN_URL, postProgress,
// handleQuitTimer, NEW_WINDOW, CONFIG_CHANGE, ...) — these are the
// ones that can still touch m_app / m_config after their original
// window has gone. Lambdas posted to per-window/per-surface
// receivers are auto-cancelled by Qt when those receivers are
// deleted, so they don't need draining.
//
// sendPostedEvents only drains the named receiver, not its
// children — which is exactly what we want here.
QCoreApplication::sendPostedEvents(qApp, QEvent::MetaCall);
if (m_app) {
ghostty_app_free(m_app);
m_app = nullptr;
}
if (m_config) {
ghostty_config_free(m_config);
m_config = nullptr;
}
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);
}

145
qt/src/app/GhosttyApp.h Normal file
View File

@ -0,0 +1,145 @@
#pragma once
#include <atomic>
#include <QList>
#include "ghostty.h"
class GhosttySurface;
class MainWindow;
class QTimer;
// Process-wide libghostty integration.
//
// Owns the single ghostty_app_t and ghostty_config_t instances that
// drive every window in the process, plus the derived needsPremultiply
// flag that the surfaces' renderer reads when blitting frames. Hosts
// the live MainWindow registry, the 60Hz frame timer, the
// quit-after-last-window-closed timer, the wakeup tick coalescer, and
// every libghostty runtime callback except onAction (which still
// lives on MainWindow for private-member access until phase 2's
// ActionDispatcher).
//
// Singleton — there is never more than one libghostty app per
// process. Construction is deferred to the first instance() call so
// QApplication can exist before the singleton is built.
class GhosttyApp {
public:
static GhosttyApp &instance();
// libghostty handles. Null until ensureInitialized() succeeds.
ghostty_app_t app() const { return m_app; }
ghostty_config_t config() const { return m_config; }
bool needsPremultiply() const { return m_needsPremultiply; }
// Builds the libghostty config + app the first time it's called,
// wiring the runtime callback bundle that MainWindow currently
// hosts (onWakeup / onAction / onReadClipboard / ... — all still
// implemented on MainWindow during phase 1.0).
//
// Re-entrant: subsequent calls early-return true.
// Returns false on libghostty init failure.
bool ensureInitialized();
// Refresh m_config + m_needsPremultiply from disk (called from
// MainWindow::reloadConfigGlobal). The caller is responsible for
// pushing the new config to libghostty (ghostty_app_update_config)
// and refreshing window chrome — those iterate the window list,
// which still lives on MainWindow during phase 1.0.
void replaceConfig(ghostty_config_t new_config);
// Free m_app + m_config. Called from MainWindow::~MainWindow when
// the last window goes away. Idempotent.
void teardown();
// ---- window registry --------------------------------------------
//
// Every live MainWindow registers itself here at construction and
// removes itself at destruction. Replaces the MainWindow::s_windows
// static.
void registerWindow(MainWindow *w);
void unregisterWindow(MainWindow *w);
const QList<MainWindow *> &windows() const { return m_windows; }
// The dropdown quick terminal, if it exists. There is at most one
// per process. Owned by Qt (WA_DeleteOnClose); GhosttyApp holds a
// non-owning pointer so toggleQuickTerminal can find it.
MainWindow *quickTerminal() const { return m_quickTerminal; }
// App-scoped show/hide of every regular window. Replaces
// MainWindow::toggleVisibility().
void toggleVisibility();
// Show/hide the dropdown, creating it on first use. Replaces
// MainWindow::toggleQuickTerminal().
void toggleQuickTerminal();
// ---- frame + quit timers ----------------------------------------
// Build the process-wide 60Hz frame timer if not already running.
// Idempotent. Called from MainWindow::initialize() on first window.
void ensureFrameTimer();
// Start / stop the natural-close quit timer per
// quit-after-last-window-closed-delay. No-op when delay is 0.
void handleQuitTimer(bool start);
// quit-after-last-window-closed-delay (ms). 0 means no delay.
int quitDelayMs() const { return m_quitDelayMs; }
void setQuitDelayMs(int ms) { m_quitDelayMs = ms; }
// ---- libghostty runtime callbacks (registered in ensureInitialized).
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;
~GhosttyApp();
GhosttyApp(const GhosttyApp &) = delete;
GhosttyApp &operator=(const GhosttyApp &) = delete;
// Frame-timer body: ticks libghostty once and renders every dirty
// surface across every window. Process-wide so N windows don't
// produce N ticks per 16ms for the same shared app.
void frame();
ghostty_app_t m_app = nullptr;
ghostty_config_t m_config = nullptr;
bool m_needsPremultiply = false;
// Live MainWindow list. Order is registration order; MainWindow
// relies on that for cascade-position fallback (see newWindow), the
// GOTO_WINDOW cycle, and the "most recent regular window" lookup
// in undoLastClose. Migrated wholesale from MainWindow::s_windows.
QList<MainWindow *> m_windows;
// The dropdown quick terminal, if any. Non-owning.
MainWindow *m_quickTerminal = nullptr;
// Process-wide 60Hz frame timer (parented to qApp). Replaces a
// per-window timer so N windows don't fire N ticks at the same
// shared ghostty_app_t.
QTimer *m_frameTimer = nullptr;
// Delayed quit-after-last-window-closed timer (parented to qApp).
// m_quitDelayMs is the configured delay in milliseconds; 0 disables.
QTimer *m_quitTimer = nullptr;
int m_quitDelayMs = 0;
// Coalesces wakeup-driven ticks: at most one tick is queued at a
// time so a busy surface can't flood the event loop.
std::atomic<bool> m_tickPending{false};
};

View File

@ -5,6 +5,7 @@
#include <QIcon>
#include <QSurfaceFormat>
#include "app/GhosttyApp.h"
#include "GlobalShortcuts.h"
#include "MainWindow.h"
#include "ghostty.h"
@ -121,9 +122,9 @@ int main(int argc, char **argv) {
QObject::connect(&globalShortcuts, &GlobalShortcuts::activated,
[](const QString &id) {
if (id == QLatin1String("toggle-quick-terminal"))
MainWindow::toggleQuickTerminal();
GhosttyApp::instance().toggleQuickTerminal();
else if (id == QLatin1String("toggle-visibility"))
MainWindow::toggleVisibility();
GhosttyApp::instance().toggleVisibility();
});
return app.exec();