365 lines
14 KiB
C++
365 lines
14 KiB
C++
#include "GhosttyApp.h"
|
|
|
|
#include <cstdio>
|
|
|
|
#include <QApplication>
|
|
#include <QByteArray>
|
|
#include <QClipboard>
|
|
#include <QCoreApplication>
|
|
#include <QEvent>
|
|
#include <QGuiApplication>
|
|
#include <QMessageBox>
|
|
#include <QMetaObject>
|
|
#include <QPointer>
|
|
#include <QPushButton>
|
|
#include <QString>
|
|
#include <QTimer>
|
|
|
|
#include "../actions/ActionDispatcher.h"
|
|
#include "../config/Config.h"
|
|
#include "../GhosttySurface.h"
|
|
#include "../MainWindow.h"
|
|
|
|
// Process-wide libghostty state and the runtime callbacks libghostty
|
|
// hands back. Action dispatch is handled by actions::dispatch (see
|
|
// qt/src/actions/); the undo/redo stack stays on MainWindow.
|
|
|
|
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 = config::hasCustomShader();
|
|
|
|
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;
|
|
// Action dispatch lives in actions::dispatch (qt/src/actions/);
|
|
// every other runtime callback lives on GhosttyApp.
|
|
rt.wakeup_cb = GhosttyApp::onWakeup;
|
|
rt.action_cb = actions::dispatch;
|
|
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 = config::hasCustomShader();
|
|
}
|
|
|
|
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);
|
|
}
|