qt: phase 2.2 — move onAction body into actions::dispatch

The 750-line switch that drove every libghostty action moves out of
MainWindow.cpp and into qt/src/actions/ActionDispatcher.cpp as
actions::dispatch — the function GhosttyApp registered as the
libghostty action_cb in phase 2.0. MainWindow::onAction is gone.

Net result: MainWindow.cpp drops from 2,729 to 1,760 lines (-969).
ActionDispatcher.cpp grows to 898 lines (the switch body + three
file-local helpers it owns: postProgress, openUrlByKind,
mouseShapeToCursor).

Three formerly-static MainWindow.cpp helpers are hoisted to Util:
  - parseDurationNs (used by initialize, refreshChrome, dispatch)
  - configValue       (used widely)
  - postNotification  (used by reloadConfigGlobal + dispatch)

The handlers reach private MainWindow internals only through the
public predicates / accessors promoted in phase 2.1, plus two
new ones added here:
  - currentSurface() — for the PROMPT_TITLE app-target promotion
    (replaces direct m_tabs->currentIndex() + private surfaceAt).
  - reloadConfigGlobal / refreshChrome / closeAllWindows are also
    promoted to public — they were already statics, just needed
    visibility for cross-file access.

defaultWindowSize() / setDefaultWindowSize() (added in 2.1) replace
the direct m_defaultWindowSize reads/writes the dispatcher previously
did via friendship.

No friend declarations introduced. The handler keeps the same
threading discipline as before (post() onto target window/surface
with QPointer-wrapped captures); behaviour is byte-equivalent.

Build verified: docker build --target qt clean, zero warnings.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
ntomsic 2026-05-23 13:22:34 -05:00
parent 615acb5403
commit 7c6fef194c
5 changed files with 1024 additions and 998 deletions

View File

@ -189,194 +189,6 @@ MainWindow::~MainWindow() {
}
}
// 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
// Each segment is added to the total. Returns the supplied default if
// parsing fails or the input is empty. libghostty exposes Duration via
// ghostty_config_get as a non-extern non-packed struct, which c_get
// silently rejects; we fall back to scanning the config file text.
static uint64_t parseDurationNs(const QString &s, uint64_t fallback) {
if (s.isEmpty()) return fallback;
// Order matters: longer units first so `ms` is matched before `s`,
// `us` before `s`, etc. Mirrors Config.zig's units array.
static constexpr struct { const char *name; uint64_t factor; } kUnits[] = {
{"ns", 1ULL},
{"us", 1000ULL},
{"µs", 1000ULL},
{"ms", 1000000ULL},
{"s", 1000000000ULL},
{"m", 60ULL * 1000000000ULL},
{"h", 3600ULL * 1000000000ULL},
{"d", 86400ULL * 1000000000ULL},
{"w", 7ULL * 86400ULL * 1000000000ULL},
{"y", 365ULL * 86400ULL * 1000000000ULL},
};
uint64_t total = 0;
int i = 0;
const int n = s.size();
bool anyMatched = false;
while (i < n) {
while (i < n && s.at(i).isSpace()) ++i;
if (i >= n) break;
// Parse an integer.
int start = i;
while (i < n && s.at(i).isDigit()) ++i;
if (i == start) return fallback; // expected a number
bool ok = false;
const uint64_t value = s.mid(start, i - start).toULongLong(&ok);
if (!ok) return fallback;
while (i < n && s.at(i).isSpace()) ++i;
// Match the longest unit prefix at i. unitLen is counted in
// QChar (UTF-16 code unit) length, NOT byte length, because `i`
// and `s.size()` are QChar-counted. `µs` is 3 UTF-8 bytes but
// 2 QChars (µ + s); using qstrlen here over-advanced past the
// input.
const QString rest = s.mid(i);
uint64_t factor = 0;
int unitLen = 0;
for (const auto &u : kUnits) {
const QString unit = QString::fromUtf8(u.name);
const int ulen = unit.size(); // QChar count, matches `i`
if (rest.startsWith(unit) && ulen > unitLen) {
factor = u.factor;
unitLen = ulen;
}
}
if (unitLen == 0) return fallback; // no unit
total += value * factor;
i += unitLen;
anyMatched = true;
}
return anyMatched ? total : fallback;
}
// Scan the primary Ghostty config file for `key = value`, returning the
// last matching value (empty if absent). For keys not cleanly exposed by
// ghostty_config_get.
static QString configValue(const QString &key) {
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 {};
const QByteArray wanted = key.toUtf8();
QString value;
while (!f.atEnd()) {
const QByteArray line = f.readLine().trimmed();
const int eq = line.indexOf('=');
if (eq < 0 || line.left(eq).trimmed() != wanted) continue;
value = QString::fromUtf8(line.mid(eq + 1).trimmed());
}
return value;
}
// Post a desktop notification via the freedesktop D-Bus service.
static void postNotification(const QString &title, const QString &body) {
QDBusMessage msg = QDBusMessage::createMethodCall(
QStringLiteral("org.freedesktop.Notifications"),
QStringLiteral("/org/freedesktop/Notifications"),
QStringLiteral("org.freedesktop.Notifications"),
QStringLiteral("Notify"));
msg.setArguments({
QStringLiteral("Ghastty"), // app_name
uint(0), // replaces_id
QStringLiteral("ghastty"), // app_icon
title, // summary
body, // body
QStringList(), // actions
QVariantMap(), // hints
-1, // expire_timeout (default)
});
QDBusConnection::sessionBus().send(msg); // fire-and-forget
}
// Drive the taskbar progress bar via the Unity LauncherEntry D-Bus API
// (honored by the KDE task manager), keyed to ghastty.desktop.
//
// Unity LauncherEntry does not have first-class ERROR / PAUSE /
// INDETERMINATE states. We approximate per progress-style:
// - REMOVE: progress-visible=false
// - SET / ERROR / PAUSE: progress-visible=true, progress=fraction;
// ERROR + PAUSE additionally flag urgent=true so the launcher
// marks attention (KDE/Plasma renders this as a bouncing icon).
// - INDETERMINATE: progress-visible=true with fraction=0 — Unity
// has no indeterminate phase, so a 0 progress is the closest
// we can do. Plasma renders this as an empty bar; better than
// dropping the state entirely.
static void postProgress(ghostty_action_progress_report_state_e state,
double fraction) {
QDBusMessage msg = QDBusMessage::createSignal(
QStringLiteral("/com/canonical/unity/launcherentry/ghastty"),
QStringLiteral("com.canonical.Unity.LauncherEntry"),
QStringLiteral("Update"));
QVariantMap props;
const bool visible = state != GHOSTTY_PROGRESS_STATE_REMOVE;
if (state == GHOSTTY_PROGRESS_STATE_INDETERMINATE) fraction = 0.0;
props[QStringLiteral("progress")] = fraction;
props[QStringLiteral("progress-visible")] = visible;
if (state == GHOSTTY_PROGRESS_STATE_ERROR ||
state == GHOSTTY_PROGRESS_STATE_PAUSE) {
props[QStringLiteral("urgent")] = true;
}
msg.setArguments(
{QStringLiteral("application://ghastty.desktop"), QVariant(props)});
QDBusConnection::sessionBus().send(msg);
}
// Open a URL through the desktop, routed by libghostty's open_url
// kind. The default `QDesktopServices::openUrl` for `text` payloads
// (e.g. the config file) lands in whatever the user has registered
// for `.txt`, which on most Linux desktops is a browser. xdg-open
// `--type=text` doesn't exist, but we can resolve the user's
// preferred text editor via `xdg-mime query default text/plain`,
// fall back to `$VISUAL` / `$EDITOR`, and finally let
// QDesktopServices try.
static void openUrlByKind(const QString &url,
ghostty_action_open_url_kind_e kind) {
if (kind != GHOSTTY_ACTION_OPEN_URL_KIND_TEXT) {
QDesktopServices::openUrl(
QUrl::fromUserInput(url, QString(), QUrl::AssumeLocalFile));
return;
}
// Try to launch a registered text/plain handler. xdg-mime returns
// a `.desktop` file id; gtk-launch (Debian) or dex (KDE) executes
// it. If that fails, fall through to the env-editor path.
const QString path =
QUrl::fromUserInput(url, QString(), QUrl::AssumeLocalFile).toLocalFile();
const QString target = path.isEmpty() ? url : path;
QProcess mime;
mime.start(QStringLiteral("xdg-mime"),
{QStringLiteral("query"), QStringLiteral("default"),
QStringLiteral("text/plain")});
mime.waitForFinished(500);
const QString desktopId =
QString::fromUtf8(mime.readAllStandardOutput()).trimmed();
if (!desktopId.isEmpty()) {
if (QProcess::startDetached(QStringLiteral("gtk-launch"),
{desktopId, target}))
return;
if (QProcess::startDetached(QStringLiteral("dex"),
{desktopId, target}))
return;
}
// $VISUAL / $EDITOR fall-back, but only if it's a GUI editor: a
// tty-only `vi` would steal the controlling terminal. We can't
// know for certain, so try a curated list (mate-, gedit, kate,
// gnome-text-editor, code) before bailing to QDesktopServices.
static const char *kGuiEditors[] = {
"gnome-text-editor", "gedit", "kate", "kwrite",
"code", "mousepad", "leafpad", nullptr};
for (const char **e = kGuiEditors; *e; ++e) {
if (QStandardPaths::findExecutable(QString::fromLatin1(*e)).isEmpty())
continue;
if (QProcess::startDetached(QString::fromLatin1(*e), {target})) return;
}
QDesktopServices::openUrl(
QUrl::fromUserInput(url, QString(), QUrl::AssumeLocalFile));
}
bool MainWindow::initialize() {
// First-call: build libghostty app + config via the singleton.
if (!GhosttyApp::instance().ensureInitialized()) return false;
@ -1155,6 +967,10 @@ int MainWindow::tabIndexForSurface(GhosttySurface *surface) const {
int MainWindow::tabCount() const { return m_tabs->count(); }
GhosttySurface *MainWindow::currentSurface() const {
return surfaceAt(m_tabs->currentIndex());
}
QList<GhosttySurface *> MainWindow::surfacesInTab(int index) const {
QWidget *page = m_tabs->widget(index);
if (!page) return {};
@ -1938,785 +1754,7 @@ void MainWindow::toggleSplitZoom(GhosttySurface *surface) {
surface->setFocus();
}
// --- libghostty runtime callbacks ------------------------------------
// 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) {
switch (s) {
case GHOSTTY_MOUSE_SHAPE_TEXT:
case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT: return Qt::IBeamCursor;
case GHOSTTY_MOUSE_SHAPE_POINTER:
case GHOSTTY_MOUSE_SHAPE_ALIAS: return Qt::PointingHandCursor;
case GHOSTTY_MOUSE_SHAPE_WAIT:
case GHOSTTY_MOUSE_SHAPE_PROGRESS: return Qt::WaitCursor;
case GHOSTTY_MOUSE_SHAPE_CROSSHAIR:
case GHOSTTY_MOUSE_SHAPE_CELL: return Qt::CrossCursor;
case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED:
case GHOSTTY_MOUSE_SHAPE_NO_DROP: return Qt::ForbiddenCursor;
case GHOSTTY_MOUSE_SHAPE_GRAB: return Qt::OpenHandCursor;
case GHOSTTY_MOUSE_SHAPE_GRABBING: return Qt::ClosedHandCursor;
case GHOSTTY_MOUSE_SHAPE_MOVE:
case GHOSTTY_MOUSE_SHAPE_ALL_SCROLL: return Qt::SizeAllCursor;
case GHOSTTY_MOUSE_SHAPE_COPY: return Qt::DragCopyCursor;
case GHOSTTY_MOUSE_SHAPE_HELP: return Qt::WhatsThisCursor;
case GHOSTTY_MOUSE_SHAPE_COL_RESIZE:
case GHOSTTY_MOUSE_SHAPE_E_RESIZE:
case GHOSTTY_MOUSE_SHAPE_W_RESIZE:
case GHOSTTY_MOUSE_SHAPE_EW_RESIZE: return Qt::SizeHorCursor;
case GHOSTTY_MOUSE_SHAPE_ROW_RESIZE:
case GHOSTTY_MOUSE_SHAPE_N_RESIZE:
case GHOSTTY_MOUSE_SHAPE_S_RESIZE:
case GHOSTTY_MOUSE_SHAPE_NS_RESIZE: return Qt::SizeVerCursor;
case GHOSTTY_MOUSE_SHAPE_NE_RESIZE:
case GHOSTTY_MOUSE_SHAPE_SW_RESIZE:
case GHOSTTY_MOUSE_SHAPE_NESW_RESIZE: return Qt::SizeBDiagCursor;
case GHOSTTY_MOUSE_SHAPE_NW_RESIZE:
case GHOSTTY_MOUSE_SHAPE_SE_RESIZE:
case GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE: return Qt::SizeFDiagCursor;
default: return Qt::ArrowCursor; // DEFAULT, CONTEXT_MENU, zoom, ...
}
}
// post() lives in Util.h now (used by both MainWindow and the
// ActionDispatcher handler files).
bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target,
ghostty_action_s action) {
// The surface this action targets, if any.
GhosttySurface *src = nullptr;
if (target.tag == GHOSTTY_TARGET_SURFACE && target.target.surface)
src = static_cast<GhosttySurface *>(
ghostty_surface_userdata(target.target.surface));
// The window the action applies to: the target surface's window, or
// (for app-level actions) any live window. Surface/window work is
// marshalled onto `win` so it is cancelled if that window goes away;
// *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()
: (live.isEmpty() ? nullptr : live.first());
QPointer<MainWindow> winp(win);
QPointer<GhosttySurface> srcp(src);
// Actions may be dispatched from non-GUI threads, so window-touching
// work is marshalled onto the GUI thread.
switch (action.tag) {
case GHOSTTY_ACTION_RENDER:
// Mark the surface dirty; the frame timer renders it. No event is
// queued here — a busy surface would otherwise flood the loop.
if (src) src->markDirty();
return true;
case GHOSTTY_ACTION_NEW_TAB: {
if (!win) return false;
// `parent` is a libghostty handle whose lifetime tracks `src`'s.
// If `src` is gone by the time the lambda runs, drop the parent
// and create an unparented tab.
post(win, [winp, srcp]() {
if (!winp) return;
winp->newTab(srcp ? srcp->surface() : nullptr);
});
return true;
}
case GHOSTTY_ACTION_NEW_WINDOW:
post(qApp, [srcp]() {
MainWindow::newWindow(srcp ? srcp->surface() : nullptr);
});
return true;
case GHOSTTY_ACTION_NEW_SPLIT: {
if (!src) return false;
const ghostty_action_split_direction_e dir = action.action.new_split;
post(win, [winp, srcp, dir]() {
if (winp && srcp) winp->splitSurface(srcp, dir);
});
return true;
}
case GHOSTTY_ACTION_CLOSE_TAB: {
if (!src) return false;
const ghostty_action_close_tab_mode_e mode = action.action.close_tab_mode;
post(win, [winp, srcp, mode]() {
if (!winp || !srcp) return;
winp->closeTabsByMode(srcp, mode);
});
return true;
}
case GHOSTTY_ACTION_SET_TITLE: {
const char *title = action.action.set_title.title;
if (!title || !src) return true;
const QString t = QString::fromUtf8(title);
post(win, [winp, srcp, t]() {
if (winp && srcp) winp->setSurfaceTitle(srcp, t);
});
return true;
}
case GHOSTTY_ACTION_SET_TAB_TITLE: {
// A manual tab-title override (an empty string clears it).
if (!src) return true;
const char *title = action.action.set_tab_title.title;
const QString t = QString::fromUtf8(title ? title : "");
post(win, [winp, srcp, t]() {
if (winp && srcp) winp->setTabTitleOverride(srcp, t);
});
return true;
}
case GHOSTTY_ACTION_PROMPT_TITLE: {
const bool tabScope =
action.action.prompt_title == GHOSTTY_PROMPT_TITLE_TAB;
// App-target: promote to the active window's current surface so a
// global keybind can rename even when no surface is the action's
// explicit target. Mirrors macOS NSApp.mainWindow promotion.
GhosttySurface *target = src;
const QList<MainWindow *> &allWindows = GhosttyApp::instance().windows();
if (!target && !allWindows.isEmpty()) {
MainWindow *active = qobject_cast<MainWindow *>(qApp->activeWindow());
if (!active) active = allWindows.first();
if (active) target = active->surfaceAt(active->m_tabs->currentIndex());
}
if (!target) return false;
QPointer<GhosttySurface> tp(target);
post(target, [tp, tabScope]() {
if (tp) tp->promptTitle(tabScope);
});
return true;
}
case GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD:
post(win, [winp, srcp]() {
if (winp) winp->copyTitleToClipboard(srcp);
});
return true;
case GHOSTTY_ACTION_RESET_WINDOW_SIZE:
post(win, [winp]() {
if (!winp) return;
winp->resize(winp->m_defaultWindowSize.isValid()
? winp->m_defaultWindowSize
: QSize(800, 600));
});
return true;
case GHOSTTY_ACTION_KEY_SEQUENCE: {
if (!src) return true;
const ghostty_action_key_sequence_s ks = action.action.key_sequence;
if (!ks.active) {
post(src, [srcp]() {
if (srcp) srcp->endKeySequence();
});
return true;
}
const QString chord = formatTrigger(ks.trigger);
post(src, [srcp, chord]() {
if (srcp) srcp->pushKeySequence(chord);
});
return true;
}
case GHOSTTY_ACTION_GOTO_TAB: {
// Performable: return false on a single tab so the chord falls
// through to the terminal. macOS does the same; GTK gates on
// tabPage count > 1.
if (!win || win->m_tabs->count() <= 1) return false;
const ghostty_action_goto_tab_e tab = action.action.goto_tab;
post(win, [winp, tab]() {
if (winp) winp->gotoTab(tab);
});
return true;
}
case GHOSTTY_ACTION_GOTO_SPLIT: {
// Performable: return false when the surface has no split sibling
// — otherwise navigation chords (e.g. ctrl+alt+arrows) eat their
// own keystrokes on an unsplit surface.
if (!src ||
!qobject_cast<QSplitter *>(src->parentWidget()))
return false;
const ghostty_action_goto_split_e dir = action.action.goto_split;
post(win, [winp, srcp, dir]() {
if (winp && srcp) winp->gotoSplit(srcp, dir);
});
return true;
}
case GHOSTTY_ACTION_RESIZE_SPLIT: {
if (!src ||
!qobject_cast<QSplitter *>(src->parentWidget()))
return false;
const ghostty_action_resize_split_s rs = action.action.resize_split;
post(win, [winp, srcp, rs]() {
if (winp && srcp) winp->resizeSplit(srcp, rs);
});
return true;
}
case GHOSTTY_ACTION_EQUALIZE_SPLITS:
if (!src ||
!qobject_cast<QSplitter *>(src->parentWidget()))
return false;
post(win, [winp, srcp]() {
if (winp && srcp) winp->equalizeSplits(srcp);
});
return true;
case GHOSTTY_ACTION_TOGGLE_FULLSCREEN:
if (!win) return false;
post(win, [winp]() {
if (!winp) return;
if (winp->isFullScreen())
winp->showNormal();
else
winp->showFullScreen();
});
return true;
case GHOSTTY_ACTION_TOGGLE_MAXIMIZE:
if (!win) return false;
post(win, [winp]() {
if (!winp) return;
if (winp->isMaximized())
winp->showNormal();
else
winp->showMaximized();
});
return true;
case GHOSTTY_ACTION_QUIT:
post(qApp, []() { MainWindow::closeAllWindows(/*thenQuit=*/true); });
return true;
case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS:
// Distinct from QUIT: close-all-windows leaves the process
// alive when quit-after-last-window-closed is false. macOS
// makes the same distinction.
post(qApp,
[]() { MainWindow::closeAllWindows(/*thenQuit=*/false); });
return true;
case GHOSTTY_ACTION_QUIT_TIMER: {
const bool start =
action.action.quit_timer == GHOSTTY_QUIT_TIMER_START;
post(qApp,
[start]() { GhosttyApp::instance().handleQuitTimer(start); });
return true;
}
case GHOSTTY_ACTION_SHOW_CHILD_EXITED: {
if (!src) return false;
const ghostty_surface_message_childexited_s ce =
action.action.child_exited;
// Suppress the banner for fast-exiting children (e.g. an
// intentional `exit 0` after a quick command). Match the macOS
// gate: only show when runtime_ms is at least the configured
// abnormal threshold (default 250ms). Banner = "the process
// died unexpectedly," not "the process exited."
uint32_t threshold = 250;
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]() {
if (srcp) srcp->showChildExited(code);
});
return true;
}
case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM:
// Performable: only meaningful inside a split tree.
if (!src ||
!qobject_cast<QSplitter *>(src->parentWidget()))
return false;
post(win, [winp, srcp]() {
if (winp && srcp) winp->toggleSplitZoom(srcp);
});
return true;
case GHOSTTY_ACTION_OPEN_CONFIG: {
// ghostty_config_open_path creates the config file if missing and
// returns its path; opening it is the apprt's job. Route through
// the text-kind opener so the user's configured editor (not a
// browser via "text/plain → .txt") gets the file.
ghostty_string_s path = ghostty_config_open_path();
if (path.ptr && path.len) {
const QString p =
QString::fromUtf8(path.ptr, static_cast<int>(path.len));
post(qApp, [p]() {
openUrlByKind(p, GHOSTTY_ACTION_OPEN_URL_KIND_TEXT);
});
}
ghostty_string_free(path);
return true;
}
case GHOSTTY_ACTION_RELOAD_CONFIG:
// Reload is app-scoped (the config is process-wide). Post to
// qApp instead of the originating window so the reload still
// happens if the window that issued the action is closed
// between the dispatch and the queued slot.
post(qApp, []() { MainWindow::reloadConfigGlobal(); });
return true;
case GHOSTTY_ACTION_CONFIG_CHANGE:
// A notification: libghostty already holds the new config (this
// often fires as the echo of our own ghostty_app_update_config).
// Re-pushing it would loop, so just refresh window chrome.
post(qApp, []() { MainWindow::refreshChrome(); });
return true;
case GHOSTTY_ACTION_INITIAL_SIZE: {
if (!win) return false;
const ghostty_action_initial_size_s sz = action.action.initial_size;
post(win, [winp, sz]() {
if (!winp) return;
// The action carries logical pixels; resize() takes the same.
// The previous code divided by devicePixelRatioF, halving the
// window on a 2x display.
const QSize logical(static_cast<int>(sz.width),
static_cast<int>(sz.height));
winp->m_defaultWindowSize = logical; // for RESET_WINDOW_SIZE
winp->resize(logical);
});
return true;
}
case GHOSTTY_ACTION_CLOSE_WINDOW:
post(win, [winp]() {
if (winp) winp->close();
});
return true;
case GHOSTTY_ACTION_RING_BELL:
post(win, [winp, srcp]() {
if (winp) winp->ringBell(srcp);
});
return true;
case GHOSTTY_ACTION_MOUSE_SHAPE: {
if (!src) return false;
const Qt::CursorShape shape =
mouseShapeToCursor(action.action.mouse_shape);
post(src, [srcp, shape]() {
if (srcp) srcp->setShape(shape);
});
return true;
}
case GHOSTTY_ACTION_MOUSE_OVER_LINK: {
if (!src) return true;
const ghostty_action_mouse_over_link_s l = action.action.mouse_over_link;
const QString url =
l.url && l.len ? QString::fromUtf8(l.url, l.len) : QString();
post(src, [srcp, url]() {
if (srcp) srcp->setLinkOverlay(url);
});
return true;
}
case GHOSTTY_ACTION_OPEN_URL: {
const ghostty_action_open_url_s u = action.action.open_url;
if (!u.url || !u.len) return true;
const QString s = QString::fromUtf8(u.url, static_cast<int>(u.len));
const ghostty_action_open_url_kind_e kind = u.kind;
post(qApp, [s, kind]() { openUrlByKind(s, kind); });
return true;
}
case GHOSTTY_ACTION_DESKTOP_NOTIFICATION: {
const ghostty_action_desktop_notification_s n =
action.action.desktop_notification;
const QString title = QString::fromUtf8(n.title ? n.title : "");
const QString body = QString::fromUtf8(n.body ? n.body : "");
// Suppress notifications from the focused surface — the user
// is already looking at it and the popup just doubles up.
// macOS does the same gate; GTK gates on surface focus too.
// App-target (no `src`) always fires.
post(qApp, [title, body, srcp]() {
if (srcp && srcp->hasFocus()) return;
postNotification(title, body);
});
return true;
}
case GHOSTTY_ACTION_COMMAND_FINISHED: {
// libghostty fires this for every command end; the apprt is
// responsible for the notify-on-command-finish gate.
if (!src) return true;
const int code = action.action.command_finished.exit_code;
const uint64_t duration = action.action.command_finished.duration;
post(src, [srcp, winp, code, duration]() {
if (!srcp || !winp) return;
// The per-command "armed via context menu" path overrides
// the never/unfocused gate (matches GTK's setup-menu).
const bool armed = srcp->consumeCommandNotify();
// notify-on-command-finish enum (string).
const QString mode = winp->configString("notify-on-command-finish");
bool fire = armed;
if (!fire) {
if (mode == QLatin1String("always")) fire = true;
else if (mode == QLatin1String("unfocused") && !srcp->hasFocus())
fire = true;
}
if (!fire) return;
// -after Duration; default 5s. Duration isn't decodable via
// ghostty_config_get (non-extern non-packed struct), so parse
// from the on-disk config.
const uint64_t afterNs = parseDurationNs(
configValue(QStringLiteral("notify-on-command-finish-after")),
5ULL * 1000 * 1000 * 1000);
if (duration < afterNs) return;
// -action: NotifyOnCommandFinishAction = packed struct
// { bell: bool = true, notify: bool = false }. Serialized
// as c_uint via c_get.zig; bit 0 = bell, bit 1 = notify.
// A zero-init reads as no-bell-no-notify, which matches the
// "configGet failed; nothing to do" semantics.
unsigned int actBits = 0;
const bool actOk =
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;
const bool actBell = (actBits & 0x1) != 0;
const bool actNotify = (actBits & 0x2) != 0;
if (actBell) winp->ringBell(srcp);
if (actNotify || armed) {
QString title;
if (code < 0) title = QStringLiteral("Command Finished");
else if (code == 0) title = QStringLiteral("Command Succeeded");
else title = QStringLiteral("Command Failed");
const QString body = code >= 0
? QStringLiteral("Exited with code %1").arg(code)
: QStringLiteral("The command completed.");
postNotification(title, body);
}
});
return true;
}
case GHOSTTY_ACTION_MOVE_TAB: {
// Surface-target only: an app-target MOVE_TAB has no meaningful
// 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;
// Performable: a single tab can't be reordered.
if (!win || win->m_tabs->count() <= 1) return false;
const int amount = static_cast<int>(action.action.move_tab.amount);
post(win, [winp, amount]() {
if (winp) winp->moveTab(amount);
});
return true;
}
case GHOSTTY_ACTION_MOUSE_VISIBILITY: {
if (!src) return false;
const bool visible =
action.action.mouse_visibility != GHOSTTY_MOUSE_HIDDEN;
post(src, [srcp, visible]() {
// setMouseVisible preserves the requested shape so toggling
// doesn't reset to ArrowCursor.
if (srcp) srcp->setMouseVisible(visible);
});
return true;
}
case GHOSTTY_ACTION_RENDERER_HEALTH: {
const bool unhealthy =
action.action.renderer_health == GHOSTTY_RENDERER_HEALTH_UNHEALTHY;
if (unhealthy)
std::fprintf(stderr, "[ghastty] renderer reported unhealthy\n");
// Surface the state in the affected pane so the user sees it
// without watching stderr. The overlay is a simple per-surface
// pill (see GhosttySurface::setRendererHealth); it clears as
// soon as libghostty reports HEALTHY again.
if (src) post(src, [srcp, unhealthy]() {
if (srcp) srcp->setRendererHealth(unhealthy);
});
return true;
}
case GHOSTTY_ACTION_SCROLLBAR: {
if (!src) return false;
const ghostty_action_scrollbar_s s = action.action.scrollbar;
post(src, [srcp, s]() {
if (srcp) srcp->updateScrollbar(s.total, s.offset, s.len);
});
return true;
}
case GHOSTTY_ACTION_PROGRESS_REPORT: {
// Honor `progress-style`: when false, OSC 9;4 progress sequences
// are silently ignored (no taskbar entry). It is a *bool* in
// Config.zig — it MUST be read with configBool. configString
// would hand ghostty_config_get a `const char**`; the 1-byte
// bool write leaves a `0x1` pointer that QString::fromUtf8 then
// dereferences and crashes on (e.g. when Claude emits progress).
if (win && !win->configBool("progress-style", true)) return true;
const ghostty_action_progress_report_s p = action.action.progress_report;
const ghostty_action_progress_report_state_e state = p.state;
const double fraction = p.progress >= 0 ? p.progress / 100.0 : 0.0;
post(qApp,
[state, fraction]() { postProgress(state, fraction); });
return true;
}
case GHOSTTY_ACTION_TOGGLE_VISIBILITY:
post(qApp, []() { GhosttyApp::instance().toggleVisibility(); });
return true;
case GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL:
post(qApp, []() { GhosttyApp::instance().toggleQuickTerminal(); });
return true;
case GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE:
post(win, [winp, srcp]() {
if (winp) winp->toggleCommandPalette(srcp);
});
return true;
case GHOSTTY_ACTION_START_SEARCH: {
if (!src) return true;
const char *needle = action.action.start_search.needle;
const QString n = QString::fromUtf8(needle ? needle : "");
post(src, [srcp, n]() {
if (srcp) srcp->openSearch(n);
});
return true;
}
case GHOSTTY_ACTION_END_SEARCH:
if (src)
post(src, [srcp]() {
if (srcp) srcp->closeSearch();
});
return true;
case GHOSTTY_ACTION_SEARCH_TOTAL: {
if (!src) return true;
const int total = static_cast<int>(action.action.search_total.total);
post(src, [srcp, total]() {
if (srcp) srcp->setSearchTotal(total);
});
return true;
}
case GHOSTTY_ACTION_SEARCH_SELECTED: {
if (!src) return true;
const int sel =
static_cast<int>(action.action.search_selected.selected);
post(src, [srcp, sel]() {
if (srcp) srcp->setSearchSelected(sel);
});
return true;
}
case GHOSTTY_ACTION_INSPECTOR: {
if (!src) return true;
const ghostty_action_inspector_e mode = action.action.inspector;
post(src, [srcp, mode]() {
if (srcp) srcp->toggleInspector(mode);
});
return true;
}
case GHOSTTY_ACTION_RENDER_INSPECTOR: {
// libghostty already has its own inspector redraw timer, but a
// wakeup here keeps it tight.
if (src)
post(src, [srcp]() {
if (srcp) srcp->refreshInspector();
});
return true;
}
case GHOSTTY_ACTION_PRESENT_TERMINAL:
if (!win) return false;
post(win, [winp, srcp]() {
if (winp) winp->presentTerminal(srcp.data());
});
return true;
case GHOSTTY_ACTION_GOTO_WINDOW: {
// Performable: return false on a single window so the chord
// falls through to the terminal.
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); });
return true;
}
case GHOSTTY_ACTION_FLOAT_WINDOW: {
if (!win) return false;
const ghostty_action_float_window_e mode = action.action.float_window;
post(win, [winp, mode]() {
if (winp) winp->setFloating(mode);
});
return true;
}
case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS:
if (!win) return false;
post(win, [winp]() {
if (winp) winp->toggleWindowDecorations();
});
return true;
case GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY:
if (!win) return false;
post(win, [winp]() {
if (winp) winp->toggleBackgroundOpacity();
});
return true;
case GHOSTTY_ACTION_SIZE_LIMIT: {
if (!win) return false;
const ghostty_action_size_limit_s sl = action.action.size_limit;
post(win, [winp, sl]() {
if (winp)
winp->setSizeLimits(sl.min_width, sl.min_height,
sl.max_width, sl.max_height);
});
return true;
}
case GHOSTTY_ACTION_CELL_SIZE: {
if (!win) return false;
const ghostty_action_cell_size_s cs = action.action.cell_size;
post(win, [winp, cs]() {
if (winp) winp->setCellSize(cs.width, cs.height);
});
return true;
}
case GHOSTTY_ACTION_KEY_TABLE: {
if (!src) return true;
// KeyTable is libghostty's bindable-mode mechanism: ACTIVATE
// pushes a named table onto the binding stack, DEACTIVATE pops
// one, DEACTIVATE_ALL clears them. Reuse the keybind chord
// overlay to surface "we're in mode X" to the user — not as
// pretty as macOS's dedicated badge but adequate.
const ghostty_action_key_table_s kt = action.action.key_table;
QString label;
if (kt.tag == GHOSTTY_KEY_TABLE_ACTIVATE && kt.value.activate.name &&
kt.value.activate.len) {
label = QString::fromUtf8(kt.value.activate.name,
static_cast<int>(kt.value.activate.len));
}
post(src, [srcp, label]() {
if (!srcp) return;
if (label.isEmpty())
srcp->endKeySequence();
else
srcp->pushKeySequence(QStringLiteral("[%1]").arg(label));
});
return true;
}
case GHOSTTY_ACTION_PWD: {
// libghostty inherits a child's pwd through the surface tree
// (ghostty_surface_inherited_config carries it across splits /
// tabs), and re-fires this action whenever the cwd changes via
// OSC 7 / shell integration. Stash it on the surface so future
// chrome (worktree-aware tab decoration, "new tab here") can
// read it without parsing /proc/<pid>/cwd. Empty pwd from
// libghostty means "unknown / cleared" — pass it through so the
// surface can drop a stale value.
if (!src) return true;
// libghostty's pwd is a sentinel-terminated Zig slice (see
// src/apprt/action.zig:Pwd) — its C ptr is always non-null;
// an "unknown / cleared" cwd is encoded as "".
const QString s = QString::fromUtf8(action.action.pwd.pwd);
post(src, [srcp, s]() {
if (srcp) srcp->setPwd(s);
});
return true;
}
case GHOSTTY_ACTION_COLOR_CHANGE: {
// OSC 4/10/11/12 colour change. libghostty already updates its
// internal palette; the next render will reflect it. Dirty the
// surface so the change is visible promptly.
if (src) src->markDirty();
// OSC 11 flips the effective background, which under
// `window-theme = ghostty` controls the chrome's light/dark
// scheme. The config-file `background` hasn't changed, so we
// can't go through refreshChrome; instead, derive the scheme
// straight from the action's RGB payload. macOS does the
// analogous thing in its color-change handler.
//
// Note: Qt's setColorScheme is a process-global style hint, so
// an OSC 11 from any window flips chrome on every window. This
// matches applyWindowConfig (also a global call) and is the
// documented Qt 6.8+ behaviour.
if (action.action.color_change.kind ==
GHOSTTY_ACTION_COLOR_KIND_BACKGROUND) {
const ghostty_action_color_change_s c = action.action.color_change;
post(qApp, [winp, c]() {
if (!winp) return;
if (winp->configString("window-theme") != QLatin1String("ghostty"))
return;
const double luma = 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;
QGuiApplication::styleHints()->setColorScheme(
luma < 128.0 ? Qt::ColorScheme::Dark : Qt::ColorScheme::Light);
});
}
return true;
}
case GHOSTTY_ACTION_READONLY:
// Read-only mode: libghostty itself drops keystrokes; we have
// no UI affordance (e.g. a padlock icon) so just acknowledge.
return true;
case GHOSTTY_ACTION_SECURE_INPUT:
// Secure-input: macOS-only enable_secure_event_input() that
// hides keystrokes from other apps. Wayland has no equivalent
// (the compositor mediates input), so this is a documented
// platform gap; acknowledge so the keybind isn't reported as
// unhandled.
return true;
case GHOSTTY_ACTION_CHECK_FOR_UPDATES:
// No in-app updater on Linux (distros / package managers handle
// updates). Acknowledge so the keybind isn't unhandled.
return true;
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
// Tab overview is GTK's adw.TabOverview — a thumbnail grid of
// tabs. Qt has no built-in equivalent and an ad-hoc Qt port
// would be a feature in its own right; acknowledge for now.
return true;
case GHOSTTY_ACTION_SHOW_GTK_INSPECTOR:
// GTK-only debug action; no analogue.
return true;
case GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD:
// libghostty defines this for iOS / on-screen-keyboard apprts.
// Linux desktops have a system-managed virtual keyboard (e.g.
// KDE's vkbd, GNOME's caribou) that the user toggles
// out-of-band; nothing for the apprt to do. Acknowledge so the
// keybind isn't reported as unhandled.
return true;
case GHOSTTY_ACTION_UNDO:
post(qApp, []() { MainWindow::undoLastClose(); });
return true;
case GHOSTTY_ACTION_REDO:
post(qApp, []() { MainWindow::redoLastClose(); });
return true;
default:
return false;
}
}
// onReadClipboard / onConfirmReadClipboard / onWriteClipboard /
// onCloseSurface all moved to GhosttyApp in phase 1.3b.
// All libghostty runtime callbacks live outside MainWindow:
// onAction → actions::dispatch in qt/src/actions/ActionDispatcher.cpp
// onWakeup, onReadClipboard, onConfirmReadClipboard, onWriteClipboard,
// onCloseSurface → GhosttyApp.

View File

@ -126,12 +126,6 @@ public:
return m_surfaces.contains(s);
}
// The libghostty action callback. Public so actions::dispatch in
// ActionDispatcher.cpp can forward to it during phase 2.0; phase
// 2.1+ progressively migrate the switch body and this method goes
// away entirely in phase 2.9.
static bool onAction(ghostty_app_t, ghostty_target_s, ghostty_action_s);
// ---- libghostty-driven mutations -------------------------------
//
// These are called from actions::dispatch (or the per-domain
@ -158,6 +152,9 @@ public:
// Tab count, used by GOTO_TAB / MOVE_TAB performable checks.
int tabCount() const;
// First surface in the currently-visible tab, or nullptr. Used by
// PROMPT_TITLE app-target promotion.
GhosttySurface *currentSurface() const;
// Default size cached on INITIAL_SIZE for RESET_WINDOW_SIZE.
QSize defaultWindowSize() const { return m_defaultWindowSize; }
void setDefaultWindowSize(QSize s) { m_defaultWindowSize = s; }
@ -168,6 +165,16 @@ public:
QString configString(const char *key) const;
bool configBool(const char *key, bool fallback) const;
// App-scoped reload entry point and chrome refresh. Both are
// called from actions::dispatch (RELOAD_CONFIG, CONFIG_CHANGE).
static void reloadConfigGlobal();
static void refreshChrome();
// Close every window, optionally quitting the process. Prompts
// once via ghostty_app_needs_confirm_quit. `thenQuit=true` is the
// QUIT action's behavior; `thenQuit=false` is CLOSE_ALL_WINDOWS.
static void closeAllWindows(bool thenQuit);
protected:
bool event(QEvent *) override;
void showEvent(QShowEvent *) override;
@ -211,15 +218,7 @@ private:
// Rebuild the config from disk and push it to libghostty.
void reloadConfig();
// 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).
static void refreshChrome();
// (reloadConfigGlobal / refreshChrome are public above)
// Apply config-driven window settings that may change on reload: the
// tab-bar visibility policy and the light/dark colour scheme.
@ -233,14 +232,6 @@ private:
// edge, per the `quick-terminal-*` config. Quick-terminal only.
void setupLayerShell();
// Close every window, optionally quitting the process. Prompts once
// via ghostty_app_needs_confirm_quit. `thenQuit=true` is the QUIT
// action's behavior (close everything and end the process);
// `thenQuit=false` is CLOSE_ALL_WINDOWS, which leaves the process
// alive when `quit-after-last-window-closed=false` is set —
// matching macOS where close-all and quit are distinct.
static void closeAllWindows(bool thenQuit);
TabWidget *m_tabs = nullptr;
QList<GhosttySurface *> m_surfaces; // every live surface in this window
bool m_firstTabPending = true; // first tab is created on show()

View File

@ -1,7 +1,14 @@
#include "Util.h"
#include <QByteArray>
#include <QChar>
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDir>
#include <QFile>
#include <QStringList>
#include <QStringLiteral>
#include <QVariantMap>
// We index libghostty's GHOSTTY_KEY_DIGIT_0..9 and GHOSTTY_KEY_A..Z
// enum ranges by arithmetic offset. If libghostty ever inserts an
@ -33,6 +40,97 @@ QString triggerKeyName(const ghostty_input_trigger_s &t) {
}
}
uint64_t parseDurationNs(const QString &s, uint64_t fallback) {
if (s.isEmpty()) return fallback;
// Order matters: longer units first so `ms` is matched before `s`,
// `us` before `s`, etc. Mirrors Config.zig's units array.
static constexpr struct { const char *name; uint64_t factor; } kUnits[] = {
{"ns", 1ULL},
{"us", 1000ULL},
{"µs", 1000ULL},
{"ms", 1000000ULL},
{"s", 1000000000ULL},
{"m", 60ULL * 1000000000ULL},
{"h", 3600ULL * 1000000000ULL},
{"d", 86400ULL * 1000000000ULL},
{"w", 7ULL * 86400ULL * 1000000000ULL},
{"y", 365ULL * 86400ULL * 1000000000ULL},
};
uint64_t total = 0;
int i = 0;
const int n = s.size();
bool anyMatched = false;
while (i < n) {
while (i < n && s.at(i).isSpace()) ++i;
if (i >= n) break;
int start = i;
while (i < n && s.at(i).isDigit()) ++i;
if (i == start) return fallback; // expected a number
bool ok = false;
const uint64_t value = s.mid(start, i - start).toULongLong(&ok);
if (!ok) return fallback;
while (i < n && s.at(i).isSpace()) ++i;
// Match the longest unit prefix at i. unitLen is counted in
// QChar (UTF-16 code unit) length, NOT byte length, because `i`
// and `s.size()` are QChar-counted. `µs` is 3 UTF-8 bytes but
// 2 QChars (µ + s); using qstrlen here over-advanced past the
// input.
const QString rest = s.mid(i);
uint64_t factor = 0;
int unitLen = 0;
for (const auto &u : kUnits) {
const QString unit = QString::fromUtf8(u.name);
const int ulen = unit.size();
if (rest.startsWith(unit) && ulen > unitLen) {
factor = u.factor;
unitLen = ulen;
}
}
if (unitLen == 0) return fallback;
total += value * factor;
i += unitLen;
anyMatched = true;
}
return anyMatched ? total : fallback;
}
QString configValue(const QString &key) {
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 {};
const QByteArray wanted = key.toUtf8();
QString value;
while (!f.atEnd()) {
const QByteArray line = f.readLine().trimmed();
const int eq = line.indexOf('=');
if (eq < 0 || line.left(eq).trimmed() != wanted) continue;
value = QString::fromUtf8(line.mid(eq + 1).trimmed());
}
return value;
}
void postNotification(const QString &title, const QString &body) {
QDBusMessage msg = QDBusMessage::createMethodCall(
QStringLiteral("org.freedesktop.Notifications"),
QStringLiteral("/org/freedesktop/Notifications"),
QStringLiteral("org.freedesktop.Notifications"),
QStringLiteral("Notify"));
msg.setArguments({
QStringLiteral("Ghastty"), // app_name
uint(0), // replaces_id
QStringLiteral("ghastty"), // app_icon
title, // summary
body, // body
QStringList(), // actions
QVariantMap(), // hints
-1, // expire_timeout (default)
});
QDBusConnection::sessionBus().send(msg); // fire-and-forget
}
QString formatTrigger(const ghostty_input_trigger_s &t) {
QString s;
if (t.mods & GHOSTTY_MODS_CTRL) s += QStringLiteral("Ctrl+");

View File

@ -36,6 +36,22 @@ inline ghostty_input_mods_e translateMods(Qt::KeyboardModifiers m) {
// (CATCH_ALL, an unmapped physical key, etc.).
QString triggerKeyName(const ghostty_input_trigger_s &t);
// Parse a libghostty duration string ("750ms", "1s500us", "2h", ...)
// into nanoseconds. Returns `fallback` if parsing fails or the input
// is empty. libghostty exposes Duration via ghostty_config_get as a
// non-extern non-packed struct, which c_get silently rejects; we
// fall back to scanning the config file text.
uint64_t parseDurationNs(const QString &s, uint64_t fallback);
// Scan the primary Ghostty config file for `key = value`, returning
// the last matching value (empty if absent). For keys not cleanly
// exposed by ghostty_config_get (Duration, paths, ...).
QString configValue(const QString &key);
// Post a desktop notification via the freedesktop D-Bus service.
// Fire-and-forget; no return code (notifications are best-effort).
void postNotification(const QString &title, const QString &body);
// Format a libghostty trigger as a human-readable chord (e.g. "Ctrl+B").
// Used for context-menu shortcut hints and the key-sequence overlay.
// Unmapped physical keys render as "•"; trigger.tag CATCH_ALL renders

View File

@ -1,15 +1,898 @@
#include "ActionDispatcher.h"
#include <cstdio>
#include <QApplication>
#include <QByteArray>
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDesktopServices>
#include <QGuiApplication>
#include <QPointer>
#include <QProcess>
#include <QSize>
#include <QSplitter>
#include <QStandardPaths>
#include <QString>
#include <QStringList>
#include <QStringLiteral>
#include <QStyleHints>
#include <QUrl>
#include <QVariant>
#include <QVariantMap>
#include <Qt>
#include "../app/GhosttyApp.h"
#include "../GhosttySurface.h"
#include "../MainWindow.h"
#include "../Util.h"
namespace actions {
bool dispatch(ghostty_app_t app,
ghostty_target_s target,
// File-local helpers used only by the dispatcher.
// Drive the taskbar progress bar via the Unity LauncherEntry D-Bus API
// (honored by the KDE task manager), keyed to ghastty.desktop.
//
// Unity LauncherEntry does not have first-class ERROR / PAUSE /
// INDETERMINATE states. We approximate per progress-style:
// - REMOVE: progress-visible=false
// - SET / ERROR / PAUSE: progress-visible=true, progress=fraction;
// ERROR + PAUSE additionally flag urgent=true so the launcher
// marks attention (KDE/Plasma renders this as a bouncing icon).
// - INDETERMINATE: progress-visible=true with fraction=0 — Unity
// has no indeterminate phase, so a 0 progress is the closest
// we can do. Plasma renders this as an empty bar; better than
// dropping the state entirely.
static void postProgress(ghostty_action_progress_report_state_e state,
double fraction) {
QDBusMessage msg = QDBusMessage::createSignal(
QStringLiteral("/com/canonical/unity/launcherentry/ghastty"),
QStringLiteral("com.canonical.Unity.LauncherEntry"),
QStringLiteral("Update"));
QVariantMap props;
const bool visible = state != GHOSTTY_PROGRESS_STATE_REMOVE;
if (state == GHOSTTY_PROGRESS_STATE_INDETERMINATE) fraction = 0.0;
props[QStringLiteral("progress")] = fraction;
props[QStringLiteral("progress-visible")] = visible;
if (state == GHOSTTY_PROGRESS_STATE_ERROR ||
state == GHOSTTY_PROGRESS_STATE_PAUSE) {
props[QStringLiteral("urgent")] = true;
}
msg.setArguments(
{QStringLiteral("application://ghastty.desktop"), QVariant(props)});
QDBusConnection::sessionBus().send(msg);
}
// Open a URL through the desktop, routed by libghostty's open_url
// kind. The default `QDesktopServices::openUrl` for `text` payloads
// (e.g. the config file) lands in whatever the user has registered
// for `.txt`, which on most Linux desktops is a browser. xdg-open
// `--type=text` doesn't exist, but we can resolve the user's
// preferred text editor via `xdg-mime query default text/plain`,
// fall back to `$VISUAL` / `$EDITOR`, and finally let
// QDesktopServices try.
static void openUrlByKind(const QString &url,
ghostty_action_open_url_kind_e kind) {
if (kind != GHOSTTY_ACTION_OPEN_URL_KIND_TEXT) {
QDesktopServices::openUrl(
QUrl::fromUserInput(url, QString(), QUrl::AssumeLocalFile));
return;
}
// Try to launch a registered text/plain handler. xdg-mime returns
// a `.desktop` file id; gtk-launch (Debian) or dex (KDE) executes
// it. If that fails, fall through to the env-editor path.
const QString path =
QUrl::fromUserInput(url, QString(), QUrl::AssumeLocalFile).toLocalFile();
const QString target = path.isEmpty() ? url : path;
QProcess mime;
mime.start(QStringLiteral("xdg-mime"),
{QStringLiteral("query"), QStringLiteral("default"),
QStringLiteral("text/plain")});
mime.waitForFinished(500);
const QString desktopId =
QString::fromUtf8(mime.readAllStandardOutput()).trimmed();
if (!desktopId.isEmpty()) {
if (QProcess::startDetached(QStringLiteral("gtk-launch"),
{desktopId, target}))
return;
if (QProcess::startDetached(QStringLiteral("dex"),
{desktopId, target}))
return;
}
// $VISUAL / $EDITOR fall-back, but only if it's a GUI editor: a
// tty-only `vi` would steal the controlling terminal. We can't
// know for certain, so try a curated list (mate-, gedit, kate,
// gnome-text-editor, code) before bailing to QDesktopServices.
static const char *kGuiEditors[] = {
"gnome-text-editor", "gedit", "kate", "kwrite",
"code", "mousepad", "leafpad", nullptr};
for (const char **e = kGuiEditors; *e; ++e) {
if (QStandardPaths::findExecutable(QString::fromLatin1(*e)).isEmpty())
continue;
if (QProcess::startDetached(QString::fromLatin1(*e), {target})) return;
}
QDesktopServices::openUrl(
QUrl::fromUserInput(url, QString(), QUrl::AssumeLocalFile));
}
// Map a libghostty mouse shape to the nearest Qt cursor.
static Qt::CursorShape mouseShapeToCursor(ghostty_action_mouse_shape_e s) {
switch (s) {
case GHOSTTY_MOUSE_SHAPE_TEXT:
case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT: return Qt::IBeamCursor;
case GHOSTTY_MOUSE_SHAPE_POINTER:
case GHOSTTY_MOUSE_SHAPE_ALIAS: return Qt::PointingHandCursor;
case GHOSTTY_MOUSE_SHAPE_WAIT:
case GHOSTTY_MOUSE_SHAPE_PROGRESS: return Qt::WaitCursor;
case GHOSTTY_MOUSE_SHAPE_CROSSHAIR:
case GHOSTTY_MOUSE_SHAPE_CELL: return Qt::CrossCursor;
case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED:
case GHOSTTY_MOUSE_SHAPE_NO_DROP: return Qt::ForbiddenCursor;
case GHOSTTY_MOUSE_SHAPE_GRAB: return Qt::OpenHandCursor;
case GHOSTTY_MOUSE_SHAPE_GRABBING: return Qt::ClosedHandCursor;
case GHOSTTY_MOUSE_SHAPE_MOVE:
case GHOSTTY_MOUSE_SHAPE_ALL_SCROLL: return Qt::SizeAllCursor;
case GHOSTTY_MOUSE_SHAPE_COPY: return Qt::DragCopyCursor;
case GHOSTTY_MOUSE_SHAPE_HELP: return Qt::WhatsThisCursor;
case GHOSTTY_MOUSE_SHAPE_COL_RESIZE:
case GHOSTTY_MOUSE_SHAPE_E_RESIZE:
case GHOSTTY_MOUSE_SHAPE_W_RESIZE:
case GHOSTTY_MOUSE_SHAPE_EW_RESIZE: return Qt::SizeHorCursor;
case GHOSTTY_MOUSE_SHAPE_ROW_RESIZE:
case GHOSTTY_MOUSE_SHAPE_N_RESIZE:
case GHOSTTY_MOUSE_SHAPE_S_RESIZE:
case GHOSTTY_MOUSE_SHAPE_NS_RESIZE: return Qt::SizeVerCursor;
case GHOSTTY_MOUSE_SHAPE_NE_RESIZE:
case GHOSTTY_MOUSE_SHAPE_SW_RESIZE:
case GHOSTTY_MOUSE_SHAPE_NESW_RESIZE: return Qt::SizeBDiagCursor;
case GHOSTTY_MOUSE_SHAPE_NW_RESIZE:
case GHOSTTY_MOUSE_SHAPE_SE_RESIZE:
case GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE: return Qt::SizeFDiagCursor;
default: return Qt::ArrowCursor; // DEFAULT, CONTEXT_MENU, zoom, ...
}
}
bool dispatch(ghostty_app_t /*app*/, ghostty_target_s target,
ghostty_action_s action) {
// Phase 2.0: forward to the legacy switch on MainWindow. Phase
// 2.1+ retire MainWindow::onAction and absorb the body here.
return MainWindow::onAction(app, target, action);
// The surface this action targets, if any.
GhosttySurface *src = nullptr;
if (target.tag == GHOSTTY_TARGET_SURFACE && target.target.surface)
src = static_cast<GhosttySurface *>(
ghostty_surface_userdata(target.target.surface));
// The window the action applies to: the target surface's window,
// or (for app-level actions) any live window. Surface/window work
// is marshalled onto `win` so it is cancelled if that window goes
// away; *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()
: (live.isEmpty() ? nullptr : live.first());
QPointer<MainWindow> winp(win);
QPointer<GhosttySurface> srcp(src);
// Actions may be dispatched from non-GUI threads, so window-touching
// work is marshalled onto the GUI thread.
switch (action.tag) {
case GHOSTTY_ACTION_RENDER:
// Mark the surface dirty; the frame timer renders it. No event
// is queued here — a busy surface would otherwise flood the loop.
if (src) src->markDirty();
return true;
case GHOSTTY_ACTION_NEW_TAB: {
if (!win) return false;
// `parent` is a libghostty handle whose lifetime tracks `src`'s.
// If `src` is gone by the time the lambda runs, drop the parent
// and create an unparented tab.
post(win, [winp, srcp]() {
if (!winp) return;
winp->newTab(srcp ? srcp->surface() : nullptr);
});
return true;
}
case GHOSTTY_ACTION_NEW_WINDOW:
post(qApp, [srcp]() {
MainWindow::newWindow(srcp ? srcp->surface() : nullptr);
});
return true;
case GHOSTTY_ACTION_NEW_SPLIT: {
if (!src) return false;
const ghostty_action_split_direction_e dir = action.action.new_split;
post(win, [winp, srcp, dir]() {
if (winp && srcp) winp->splitSurface(srcp, dir);
});
return true;
}
case GHOSTTY_ACTION_CLOSE_TAB: {
if (!src) return false;
const ghostty_action_close_tab_mode_e mode = action.action.close_tab_mode;
post(win, [winp, srcp, mode]() {
if (!winp || !srcp) return;
winp->closeTabsByMode(srcp, mode);
});
return true;
}
case GHOSTTY_ACTION_SET_TITLE: {
const char *title = action.action.set_title.title;
if (!title || !src) return true;
const QString t = QString::fromUtf8(title);
post(win, [winp, srcp, t]() {
if (winp && srcp) winp->setSurfaceTitle(srcp, t);
});
return true;
}
case GHOSTTY_ACTION_SET_TAB_TITLE: {
// A manual tab-title override (an empty string clears it).
if (!src) return true;
const char *title = action.action.set_tab_title.title;
const QString t = QString::fromUtf8(title ? title : "");
post(win, [winp, srcp, t]() {
if (winp && srcp) winp->setTabTitleOverride(srcp, t);
});
return true;
}
case GHOSTTY_ACTION_PROMPT_TITLE: {
const bool tabScope =
action.action.prompt_title == GHOSTTY_PROMPT_TITLE_TAB;
// App-target: promote to the active window's current surface so
// a global keybind can rename even when no surface is the
// action's explicit target. Mirrors macOS NSApp.mainWindow
// promotion.
GhosttySurface *t = src;
const QList<MainWindow *> &allWindows = GhosttyApp::instance().windows();
if (!t && !allWindows.isEmpty()) {
MainWindow *active = qobject_cast<MainWindow *>(qApp->activeWindow());
if (!active) active = allWindows.first();
if (active) t = active->currentSurface();
}
if (!t) return false;
QPointer<GhosttySurface> tp(t);
post(t, [tp, tabScope]() {
if (tp) tp->promptTitle(tabScope);
});
return true;
}
case GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD:
post(win, [winp, srcp]() {
if (winp) winp->copyTitleToClipboard(srcp);
});
return true;
case GHOSTTY_ACTION_RESET_WINDOW_SIZE:
post(win, [winp]() {
if (!winp) return;
const QSize def = winp->defaultWindowSize();
winp->resize(def.isValid() ? def : QSize(800, 600));
});
return true;
case GHOSTTY_ACTION_KEY_SEQUENCE: {
if (!src) return true;
const ghostty_action_key_sequence_s ks = action.action.key_sequence;
if (!ks.active) {
post(src, [srcp]() {
if (srcp) srcp->endKeySequence();
});
return true;
}
const QString chord = formatTrigger(ks.trigger);
post(src, [srcp, chord]() {
if (srcp) srcp->pushKeySequence(chord);
});
return true;
}
case GHOSTTY_ACTION_GOTO_TAB: {
// Performable: return false on a single tab so the chord falls
// through to the terminal. macOS does the same; GTK gates on
// tabPage count > 1.
if (!win || win->tabCount() <= 1) return false;
const ghostty_action_goto_tab_e tab = action.action.goto_tab;
post(win, [winp, tab]() {
if (winp) winp->gotoTab(tab);
});
return true;
}
case GHOSTTY_ACTION_GOTO_SPLIT: {
// Performable: return false when the surface has no split
// sibling — otherwise navigation chords (e.g. ctrl+alt+arrows)
// eat their own keystrokes on an unsplit surface.
if (!src ||
!qobject_cast<QSplitter *>(src->parentWidget()))
return false;
const ghostty_action_goto_split_e dir = action.action.goto_split;
post(win, [winp, srcp, dir]() {
if (winp && srcp) winp->gotoSplit(srcp, dir);
});
return true;
}
case GHOSTTY_ACTION_RESIZE_SPLIT: {
if (!src ||
!qobject_cast<QSplitter *>(src->parentWidget()))
return false;
const ghostty_action_resize_split_s rs = action.action.resize_split;
post(win, [winp, srcp, rs]() {
if (winp && srcp) winp->resizeSplit(srcp, rs);
});
return true;
}
case GHOSTTY_ACTION_EQUALIZE_SPLITS:
if (!src ||
!qobject_cast<QSplitter *>(src->parentWidget()))
return false;
post(win, [winp, srcp]() {
if (winp && srcp) winp->equalizeSplits(srcp);
});
return true;
case GHOSTTY_ACTION_TOGGLE_FULLSCREEN:
if (!win) return false;
post(win, [winp]() {
if (!winp) return;
if (winp->isFullScreen())
winp->showNormal();
else
winp->showFullScreen();
});
return true;
case GHOSTTY_ACTION_TOGGLE_MAXIMIZE:
if (!win) return false;
post(win, [winp]() {
if (!winp) return;
if (winp->isMaximized())
winp->showNormal();
else
winp->showMaximized();
});
return true;
case GHOSTTY_ACTION_QUIT:
post(qApp, []() { MainWindow::closeAllWindows(/*thenQuit=*/true); });
return true;
case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS:
// Distinct from QUIT: close-all-windows leaves the process
// alive when quit-after-last-window-closed is false. macOS
// makes the same distinction.
post(qApp,
[]() { MainWindow::closeAllWindows(/*thenQuit=*/false); });
return true;
case GHOSTTY_ACTION_QUIT_TIMER: {
const bool start =
action.action.quit_timer == GHOSTTY_QUIT_TIMER_START;
post(qApp,
[start]() { GhosttyApp::instance().handleQuitTimer(start); });
return true;
}
case GHOSTTY_ACTION_SHOW_CHILD_EXITED: {
if (!src) return false;
const ghostty_surface_message_childexited_s ce =
action.action.child_exited;
// Suppress the banner for fast-exiting children (e.g. an
// intentional `exit 0` after a quick command). Match the macOS
// gate: only show when runtime_ms is at least the configured
// abnormal threshold (default 250ms). Banner = "the process
// died unexpectedly," not "the process exited."
uint32_t threshold = 250;
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]() {
if (srcp) srcp->showChildExited(code);
});
return true;
}
case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM:
// Performable: only meaningful inside a split tree.
if (!src ||
!qobject_cast<QSplitter *>(src->parentWidget()))
return false;
post(win, [winp, srcp]() {
if (winp && srcp) winp->toggleSplitZoom(srcp);
});
return true;
case GHOSTTY_ACTION_OPEN_CONFIG: {
// ghostty_config_open_path creates the config file if missing
// and returns its path; opening it is the apprt's job. Route
// through the text-kind opener so the user's configured editor
// (not a browser via "text/plain → .txt") gets the file.
ghostty_string_s path = ghostty_config_open_path();
if (path.ptr && path.len) {
const QString p =
QString::fromUtf8(path.ptr, static_cast<int>(path.len));
post(qApp, [p]() {
openUrlByKind(p, GHOSTTY_ACTION_OPEN_URL_KIND_TEXT);
});
}
ghostty_string_free(path);
return true;
}
case GHOSTTY_ACTION_RELOAD_CONFIG:
// Reload is app-scoped (the config is process-wide). Post to
// qApp instead of the originating window so the reload still
// happens if the window that issued the action is closed
// between the dispatch and the queued slot.
post(qApp, []() { MainWindow::reloadConfigGlobal(); });
return true;
case GHOSTTY_ACTION_CONFIG_CHANGE:
// A notification: libghostty already holds the new config
// (this often fires as the echo of our own
// ghostty_app_update_config). Re-pushing it would loop, so just
// refresh window chrome.
post(qApp, []() { MainWindow::refreshChrome(); });
return true;
case GHOSTTY_ACTION_INITIAL_SIZE: {
if (!win) return false;
const ghostty_action_initial_size_s sz = action.action.initial_size;
post(win, [winp, sz]() {
if (!winp) return;
// The action carries logical pixels; resize() takes the same.
// The previous code divided by devicePixelRatioF, halving the
// window on a 2x display.
const QSize logical(static_cast<int>(sz.width),
static_cast<int>(sz.height));
winp->setDefaultWindowSize(logical); // for RESET_WINDOW_SIZE
winp->resize(logical);
});
return true;
}
case GHOSTTY_ACTION_CLOSE_WINDOW:
post(win, [winp]() {
if (winp) winp->close();
});
return true;
case GHOSTTY_ACTION_RING_BELL:
post(win, [winp, srcp]() {
if (winp) winp->ringBell(srcp);
});
return true;
case GHOSTTY_ACTION_MOUSE_SHAPE: {
if (!src) return false;
const Qt::CursorShape shape =
mouseShapeToCursor(action.action.mouse_shape);
post(src, [srcp, shape]() {
if (srcp) srcp->setShape(shape);
});
return true;
}
case GHOSTTY_ACTION_MOUSE_OVER_LINK: {
if (!src) return true;
const ghostty_action_mouse_over_link_s l = action.action.mouse_over_link;
const QString url =
l.url && l.len ? QString::fromUtf8(l.url, l.len) : QString();
post(src, [srcp, url]() {
if (srcp) srcp->setLinkOverlay(url);
});
return true;
}
case GHOSTTY_ACTION_OPEN_URL: {
const ghostty_action_open_url_s u = action.action.open_url;
if (!u.url || !u.len) return true;
const QString s = QString::fromUtf8(u.url, static_cast<int>(u.len));
const ghostty_action_open_url_kind_e kind = u.kind;
post(qApp, [s, kind]() { openUrlByKind(s, kind); });
return true;
}
case GHOSTTY_ACTION_DESKTOP_NOTIFICATION: {
const ghostty_action_desktop_notification_s n =
action.action.desktop_notification;
const QString title = QString::fromUtf8(n.title ? n.title : "");
const QString body = QString::fromUtf8(n.body ? n.body : "");
// Suppress notifications from the focused surface — the user
// is already looking at it and the popup just doubles up.
// macOS does the same gate; GTK gates on surface focus too.
// App-target (no `src`) always fires.
post(qApp, [title, body, srcp]() {
if (srcp && srcp->hasFocus()) return;
postNotification(title, body);
});
return true;
}
case GHOSTTY_ACTION_COMMAND_FINISHED: {
// libghostty fires this for every command end; the apprt is
// responsible for the notify-on-command-finish gate.
if (!src) return true;
const int code = action.action.command_finished.exit_code;
const uint64_t duration = action.action.command_finished.duration;
post(src, [srcp, winp, code, duration]() {
if (!srcp || !winp) return;
// The per-command "armed via context menu" path overrides
// the never/unfocused gate (matches GTK's setup-menu).
const bool armed = srcp->consumeCommandNotify();
// notify-on-command-finish enum (string).
const QString mode = winp->configString("notify-on-command-finish");
bool fire = armed;
if (!fire) {
if (mode == QLatin1String("always")) fire = true;
else if (mode == QLatin1String("unfocused") && !srcp->hasFocus())
fire = true;
}
if (!fire) return;
// -after Duration; default 5s. Duration isn't decodable via
// ghostty_config_get (non-extern non-packed struct), so parse
// from the on-disk config.
const uint64_t afterNs = parseDurationNs(
configValue(QStringLiteral("notify-on-command-finish-after")),
5ULL * 1000 * 1000 * 1000);
if (duration < afterNs) return;
// -action: NotifyOnCommandFinishAction = packed struct
// { bell: bool = true, notify: bool = false }. Serialized
// as c_uint via c_get.zig; bit 0 = bell, bit 1 = notify.
// A zero-init reads as no-bell-no-notify, which matches the
// "configGet failed; nothing to do" semantics.
unsigned int actBits = 0;
const bool actOk = 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;
const bool actBell = (actBits & 0x1) != 0;
const bool actNotify = (actBits & 0x2) != 0;
if (actBell) winp->ringBell(srcp);
if (actNotify || armed) {
QString title;
if (code < 0) title = QStringLiteral("Command Finished");
else if (code == 0) title = QStringLiteral("Command Succeeded");
else title = QStringLiteral("Command Failed");
const QString body = code >= 0
? QStringLiteral("Exited with code %1").arg(code)
: QStringLiteral("The command completed.");
postNotification(title, body);
}
});
return true;
}
case GHOSTTY_ACTION_MOVE_TAB: {
// Surface-target only: an app-target MOVE_TAB has no
// meaningful 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;
// Performable: a single tab can't be reordered.
if (!win || win->tabCount() <= 1) return false;
const int amount = static_cast<int>(action.action.move_tab.amount);
post(win, [winp, amount]() {
if (winp) winp->moveTab(amount);
});
return true;
}
case GHOSTTY_ACTION_MOUSE_VISIBILITY: {
if (!src) return false;
const bool visible =
action.action.mouse_visibility != GHOSTTY_MOUSE_HIDDEN;
post(src, [srcp, visible]() {
// setMouseVisible preserves the requested shape so toggling
// doesn't reset to ArrowCursor.
if (srcp) srcp->setMouseVisible(visible);
});
return true;
}
case GHOSTTY_ACTION_RENDERER_HEALTH: {
const bool unhealthy =
action.action.renderer_health == GHOSTTY_RENDERER_HEALTH_UNHEALTHY;
if (unhealthy)
std::fprintf(stderr, "[ghastty] renderer reported unhealthy\n");
// Surface the state in the affected pane so the user sees it
// without watching stderr. The overlay is a simple per-surface
// pill (see GhosttySurface::setRendererHealth); it clears as
// soon as libghostty reports HEALTHY again.
if (src) post(src, [srcp, unhealthy]() {
if (srcp) srcp->setRendererHealth(unhealthy);
});
return true;
}
case GHOSTTY_ACTION_SCROLLBAR: {
if (!src) return false;
const ghostty_action_scrollbar_s s = action.action.scrollbar;
post(src, [srcp, s]() {
if (srcp) srcp->updateScrollbar(s.total, s.offset, s.len);
});
return true;
}
case GHOSTTY_ACTION_PROGRESS_REPORT: {
// Honor `progress-style`: when false, OSC 9;4 progress
// sequences are silently ignored (no taskbar entry). It is a
// *bool* in Config.zig — it MUST be read with configBool.
// configString would hand ghostty_config_get a `const char**`;
// the 1-byte bool write leaves a `0x1` pointer that
// QString::fromUtf8 then dereferences and crashes on (e.g.
// when Claude emits progress).
if (win && !win->configBool("progress-style", true)) return true;
const ghostty_action_progress_report_s p = action.action.progress_report;
const ghostty_action_progress_report_state_e state = p.state;
const double fraction = p.progress >= 0 ? p.progress / 100.0 : 0.0;
post(qApp,
[state, fraction]() { postProgress(state, fraction); });
return true;
}
case GHOSTTY_ACTION_TOGGLE_VISIBILITY:
post(qApp, []() { GhosttyApp::instance().toggleVisibility(); });
return true;
case GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL:
post(qApp, []() { GhosttyApp::instance().toggleQuickTerminal(); });
return true;
case GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE:
post(win, [winp, srcp]() {
if (winp) winp->toggleCommandPalette(srcp);
});
return true;
case GHOSTTY_ACTION_START_SEARCH: {
if (!src) return true;
const char *needle = action.action.start_search.needle;
const QString n = QString::fromUtf8(needle ? needle : "");
post(src, [srcp, n]() {
if (srcp) srcp->openSearch(n);
});
return true;
}
case GHOSTTY_ACTION_END_SEARCH:
if (src)
post(src, [srcp]() {
if (srcp) srcp->closeSearch();
});
return true;
case GHOSTTY_ACTION_SEARCH_TOTAL: {
if (!src) return true;
const int total = static_cast<int>(action.action.search_total.total);
post(src, [srcp, total]() {
if (srcp) srcp->setSearchTotal(total);
});
return true;
}
case GHOSTTY_ACTION_SEARCH_SELECTED: {
if (!src) return true;
const int sel =
static_cast<int>(action.action.search_selected.selected);
post(src, [srcp, sel]() {
if (srcp) srcp->setSearchSelected(sel);
});
return true;
}
case GHOSTTY_ACTION_INSPECTOR: {
if (!src) return true;
const ghostty_action_inspector_e mode = action.action.inspector;
post(src, [srcp, mode]() {
if (srcp) srcp->toggleInspector(mode);
});
return true;
}
case GHOSTTY_ACTION_RENDER_INSPECTOR: {
// libghostty already has its own inspector redraw timer, but
// a wakeup here keeps it tight.
if (src)
post(src, [srcp]() {
if (srcp) srcp->refreshInspector();
});
return true;
}
case GHOSTTY_ACTION_PRESENT_TERMINAL:
if (!win) return false;
post(win, [winp, srcp]() {
if (winp) winp->presentTerminal(srcp.data());
});
return true;
case GHOSTTY_ACTION_GOTO_WINDOW: {
// Performable: return false on a single window so the chord
// falls through to the terminal.
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); });
return true;
}
case GHOSTTY_ACTION_FLOAT_WINDOW: {
if (!win) return false;
const ghostty_action_float_window_e mode = action.action.float_window;
post(win, [winp, mode]() {
if (winp) winp->setFloating(mode);
});
return true;
}
case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS:
if (!win) return false;
post(win, [winp]() {
if (winp) winp->toggleWindowDecorations();
});
return true;
case GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY:
if (!win) return false;
post(win, [winp]() {
if (winp) winp->toggleBackgroundOpacity();
});
return true;
case GHOSTTY_ACTION_SIZE_LIMIT: {
if (!win) return false;
const ghostty_action_size_limit_s sl = action.action.size_limit;
post(win, [winp, sl]() {
if (winp)
winp->setSizeLimits(sl.min_width, sl.min_height,
sl.max_width, sl.max_height);
});
return true;
}
case GHOSTTY_ACTION_CELL_SIZE: {
if (!win) return false;
const ghostty_action_cell_size_s cs = action.action.cell_size;
post(win, [winp, cs]() {
if (winp) winp->setCellSize(cs.width, cs.height);
});
return true;
}
case GHOSTTY_ACTION_KEY_TABLE: {
if (!src) return true;
// KeyTable is libghostty's bindable-mode mechanism: ACTIVATE
// pushes a named table onto the binding stack, DEACTIVATE pops
// one, DEACTIVATE_ALL clears them. Reuse the keybind chord
// overlay to surface "we're in mode X" to the user — not as
// pretty as macOS's dedicated badge but adequate.
const ghostty_action_key_table_s kt = action.action.key_table;
QString label;
if (kt.tag == GHOSTTY_KEY_TABLE_ACTIVATE && kt.value.activate.name &&
kt.value.activate.len) {
label = QString::fromUtf8(kt.value.activate.name,
static_cast<int>(kt.value.activate.len));
}
post(src, [srcp, label]() {
if (!srcp) return;
if (label.isEmpty())
srcp->endKeySequence();
else
srcp->pushKeySequence(QStringLiteral("[%1]").arg(label));
});
return true;
}
case GHOSTTY_ACTION_PWD: {
// libghostty inherits a child's pwd through the surface tree
// (ghostty_surface_inherited_config carries it across splits /
// tabs), and re-fires this action whenever the cwd changes via
// OSC 7 / shell integration. Stash it on the surface so future
// chrome (worktree-aware tab decoration, "new tab here") can
// read it without parsing /proc/<pid>/cwd. Empty pwd from
// libghostty means "unknown / cleared" — pass it through so the
// surface can drop a stale value.
if (!src) return true;
// libghostty's pwd is a sentinel-terminated Zig slice (see
// src/apprt/action.zig:Pwd) — its C ptr is always non-null;
// an "unknown / cleared" cwd is encoded as "".
const QString s = QString::fromUtf8(action.action.pwd.pwd);
post(src, [srcp, s]() {
if (srcp) srcp->setPwd(s);
});
return true;
}
case GHOSTTY_ACTION_COLOR_CHANGE: {
// OSC 4/10/11/12 colour change. libghostty already updates its
// internal palette; the next render will reflect it. Dirty the
// surface so the change is visible promptly.
if (src) src->markDirty();
// OSC 11 flips the effective background, which under
// `window-theme = ghostty` controls the chrome's light/dark
// scheme. The config-file `background` hasn't changed, so we
// can't go through refreshChrome; instead, derive the scheme
// straight from the action's RGB payload. macOS does the
// analogous thing in its color-change handler.
//
// Note: Qt's setColorScheme is a process-global style hint, so
// an OSC 11 from any window flips chrome on every window. This
// matches applyWindowConfig (also a global call) and is the
// documented Qt 6.8+ behaviour.
if (action.action.color_change.kind ==
GHOSTTY_ACTION_COLOR_KIND_BACKGROUND) {
const ghostty_action_color_change_s c = action.action.color_change;
post(qApp, [winp, c]() {
if (!winp) return;
if (winp->configString("window-theme") != QLatin1String("ghostty"))
return;
const double luma = 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;
QGuiApplication::styleHints()->setColorScheme(
luma < 128.0 ? Qt::ColorScheme::Dark : Qt::ColorScheme::Light);
});
}
return true;
}
case GHOSTTY_ACTION_READONLY:
// Read-only mode: libghostty itself drops keystrokes; we have
// no UI affordance (e.g. a padlock icon) so just acknowledge.
return true;
case GHOSTTY_ACTION_SECURE_INPUT:
// Secure-input: macOS-only enable_secure_event_input() that
// hides keystrokes from other apps. Wayland has no equivalent
// (the compositor mediates input), so this is a documented
// platform gap; acknowledge so the keybind isn't reported as
// unhandled.
return true;
case GHOSTTY_ACTION_CHECK_FOR_UPDATES:
// No in-app updater on Linux (distros / package managers
// handle updates). Acknowledge so the keybind isn't unhandled.
return true;
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
// Tab overview is GTK's adw.TabOverview — a thumbnail grid of
// tabs. Qt has no built-in equivalent and an ad-hoc Qt port
// would be a feature in its own right; acknowledge for now.
return true;
case GHOSTTY_ACTION_SHOW_GTK_INSPECTOR:
// GTK-only debug action; no analogue.
return true;
case GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD:
// libghostty defines this for iOS / on-screen-keyboard apprts.
// Linux desktops have a system-managed virtual keyboard (e.g.
// KDE's vkbd, GNOME's caribou) that the user toggles
// out-of-band; nothing for the apprt to do. Acknowledge so
// the keybind isn't reported as unhandled.
return true;
case GHOSTTY_ACTION_UNDO:
post(qApp, []() { MainWindow::undoLastClose(); });
return true;
case GHOSTTY_ACTION_REDO:
post(qApp, []() { MainWindow::redoLastClose(); });
return true;
default:
return false;
}
}
} // namespace actions