Merge pull request #10 from fuddlesworth/qt-modularize
qt: phase 1 — extract GhosttyApp singleton from MainWindowpull/12846/head
commit
f652ed8a23
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, ¬ifBits, "app-notifications");
|
||||
const bool notifOk = configGet(GhosttyApp::instance().config(), ¬ifBits, "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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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};
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue