Merge pull request #12 from fuddlesworth/qt-config-helper

qt: phase 3 — extract config:: namespace for typed config access
pull/12846/head
Nathan 2026-05-23 14:36:03 -05:00 committed by GitHub
commit 4102618fbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 331 additions and 269 deletions

View File

@ -106,6 +106,7 @@ add_executable(ghastty
src/actions/TabActions.cpp
src/actions/WindowActions.cpp
src/app/GhosttyApp.cpp
src/config/Config.cpp
src/CommandPalette.cpp
src/GhosttySurface.cpp
src/GlobalShortcuts.cpp

View File

@ -11,6 +11,7 @@
#include <QTimer>
#include <QVBoxLayout>
#include "config/Config.h"
#include "GhosttySurface.h"
#include "MainWindow.h"
#include "Util.h"
@ -95,12 +96,10 @@ void CommandPalette::toggleFor(GhosttySurface *surface) {
void CommandPalette::populate() {
m_model->clear();
if (!m_surface || !m_surface->owner()) return;
ghostty_config_t cfg = m_surface->owner()->config();
if (!cfg) return;
// command-palette-entry defaults to a large built-in command set.
ghostty_config_command_list_s list = {};
if (!configGet(cfg, &list, "command-palette-entry")) return;
if (!config::get(&list, "command-palette-entry")) return;
for (size_t i = 0; i < list.len; ++i) {
const ghostty_command_s &c = list.commands[i];
const QString title = QString::fromUtf8(c.title ? c.title : "");

View File

@ -1,5 +1,6 @@
#include "GhosttySurface.h"
#include "config/Config.h"
#include "InspectorWindow.h"
#include "MainWindow.h"
#include "OverlayScrollbar.h"
@ -241,9 +242,10 @@ void GhosttySurface::layoutScrollbar() {
// `scrollbar = never` in the config hides the scrollbar unconditionally;
// `system` (the default) shows it whenever there is scrollback.
bool GhosttySurface::scrollbarAllowed() const {
if (!m_owner || !m_owner->config()) return true;
// config::get is null-safe (returns false when handle() is null),
// so we only need the "could not read" → default-to-showing path.
const char *value = nullptr;
if (configGet(m_owner->config(), &value, "scrollbar") && value)
if (config::get(&value, "scrollbar") && value)
return qstrcmp(value, "never") != 0;
return true; // unknown — default to showing
}
@ -270,7 +272,7 @@ void GhosttySurface::flashScrollbar() {
if (!m_scrollbar || !scrollbarAllowed()) return;
// Handle colour: light on a dark terminal, dark on a light one.
ghostty_config_color_s bg{};
if (m_owner && configGet(m_owner->config(), &bg, "background")) {
if (config::get(&bg, "background")) {
const double luma = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b;
m_scrollbar->setHandleColor(luma < 128.0 ? QColor(235, 235, 235)
: QColor(45, 45, 45));
@ -317,13 +319,12 @@ void GhosttySurface::paintEvent(QPaintEvent *) {
// Unfocused-split dimming: a translucent fill over an inactive pane.
// Only split panes (a QSplitter parent) are dimmed, matching GTK.
if (!hasFocus() && qobject_cast<QSplitter *>(parentWidget())) {
ghostty_config_t cfg = m_owner ? m_owner->config() : nullptr;
double opacity = 0.7;
configGet(cfg, &opacity, "unfocused-split-opacity");
config::get(&opacity, "unfocused-split-opacity");
if (opacity < 1.0) {
QColor fill(0, 0, 0); // default: dim toward black
ghostty_config_color_s c{};
if (configGet(cfg, &c, "unfocused-split-fill"))
if (config::get(&c, "unfocused-split-fill"))
fill = QColor(c.r, c.g, c.b);
fill.setAlphaF(1.0 - opacity);
painter.setCompositionMode(QPainter::CompositionMode_SourceOver);
@ -374,14 +375,6 @@ static QLabel *makeOverlayLabel(QWidget *parent) {
return label;
}
// Read a string/enum config value (enums arrive as their tag name).
static QString cfgString(ghostty_config_t cfg, const char *key) {
const char *v = nullptr;
if (cfg && ghostty_config_get(cfg, &v, key, qstrlen(key)) && v)
return QString::fromUtf8(v);
return {};
}
void GhosttySurface::promptTitle(bool tabScope) {
bool ok = false;
const QString title = QInputDialog::getText(
@ -530,8 +523,7 @@ void GhosttySurface::showResizeOverlay() {
if (!m_surface || !m_owner) return;
const ghostty_surface_size_s sz = ghostty_surface_size(m_surface);
ghostty_config_t cfg = m_owner->config();
const QString mode = cfgString(cfg, "resize-overlay");
const QString mode = config::string("resize-overlay");
if (mode == QLatin1String("never")) return;
if (sz.columns != m_lastCols || sz.rows != m_lastRows) {
@ -556,7 +548,7 @@ void GhosttySurface::showResizeOverlay() {
// the hide timer fired on the next event-loop tick and the overlay
// vanished the instant it appeared.
unsigned long long durCfgMs = 0;
const bool durOk = configGet(cfg, &durCfgMs, "resize-overlay-duration");
const bool durOk = config::get(&durCfgMs, "resize-overlay-duration");
// Clamp before narrowing: a Duration's millisecond value can exceed
// INT_MAX, and a wrapped negative int would make QTimer::start()
// reject the interval, leaving the overlay stuck on screen.
@ -592,9 +584,7 @@ void GhosttySurface::paintResizeOverlay(QPainter &painter) {
const qreal boxH = ts.height() + 2 * padY;
// resize-overlay-position: center / {top,bottom}-{left,center,right}.
const QString pos =
m_owner ? cfgString(m_owner->config(), "resize-overlay-position")
: QString();
const QString pos = config::string("resize-overlay-position");
const qreal m = 8;
qreal x = (width() - boxW) / 2;
qreal y = (height() - boxH) / 2;

View File

@ -7,17 +7,12 @@
#include <QApplication>
#include <QAudioOutput>
#include <QByteArray>
#include <QClipboard>
#include <QCursor>
#include <QCloseEvent>
#include <QCoreApplication>
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDesktopServices>
#include <QEvent>
#include <QDir>
#include <QFile>
#include <QColor>
#include <QFont>
#include <QGuiApplication>
@ -55,6 +50,7 @@
#include <LayerShellQt/window.h>
#include "app/GhosttyApp.h"
#include "config/Config.h"
#include "CommandPalette.h"
#include "GhosttySurface.h"
#include "TabWidget.h"
@ -203,12 +199,12 @@ bool MainWindow::initialize() {
// 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).
const bool quitAfter = configBool("quit-after-last-window-closed", true);
const bool quitAfter = config::boolean("quit-after-last-window-closed", true);
// quit-after-last-window-closed-delay is a `?Duration` and Duration
// is neither extern nor packed, so libghostty's ghostty_config_get
// returns false for it. Read from disk and parse.
const uint64_t delayNs = parseDurationNs(
configValue(QStringLiteral("quit-after-last-window-closed-delay")), 0);
const uint64_t delayNs =
config::durationNs("quit-after-last-window-closed-delay", 0);
const uint64_t delayMs = delayNs / 1000000ULL;
const int delayMsInt = quitAfter
? static_cast<int>(std::min(delayMs, uint64_t(INT_MAX)))
@ -223,13 +219,13 @@ bool MainWindow::initialize() {
// window-decoration `none` drops the native frame; `auto`/`server`/
// `client` keep a decorated window (the compositor picks the side
// on Wayland).
if (configString("window-decoration") == QLatin1String("none"))
if (config::string("window-decoration") == QLatin1String("none"))
setWindowFlag(Qt::FramelessWindowHint, true);
// fullscreen wins over maximize; its enum is `false` when unset.
const QString fullscreen = configString("fullscreen");
const QString fullscreen = config::string("fullscreen");
if (!fullscreen.isEmpty() && fullscreen != QLatin1String("false"))
setWindowState(windowState() | Qt::WindowFullScreen);
else if (configBool("maximize", false))
else if (config::boolean("maximize", false))
setWindowState(windowState() | Qt::WindowMaximized);
}
@ -268,17 +264,16 @@ MainWindow *MainWindow::newWindow(ghostty_surface_t parent) {
w->resize(800, 600);
// Window position: window-position-x/y are optional (?i16 in
// Config.zig). configGet writes the value and returns true when the
// optional is present. Both must be set to take effect (matching
// Config.zig). config::get writes the value and returns true when
// the optional is present. Both must be set to take effect (matching
// the Config.zig doc comment). If unset, fall back to a cascade
// offset from the previous window so Cmd+N spam doesn't pile every
// 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(cfg, &posX, "window-position-x");
const bool haveY = configGet(cfg, &posY, "window-position-y");
const bool haveX = config::get(&posX, "window-position-x");
const bool haveY = config::get(&posY, "window-position-y");
if (haveX && haveY) {
w->move(posX, posY);
} else {
@ -300,7 +295,7 @@ MainWindow *MainWindow::newWindow(ghostty_surface_t parent) {
if (!s_initialWindowConsumed) {
s_initialWindowConsumed = true;
bool initialWindow = true;
configGet(cfg, &initialWindow, "initial-window");
config::get(&initialWindow, "initial-window");
wantsShow = initialWindow;
}
if (wantsShow) w->show();
@ -354,7 +349,7 @@ GhosttySurface *MainWindow::newTab(ghostty_surface_t parent) {
// window-new-tab-position: place the tab right after the current one,
// or append it at the end (the default).
int index;
if (configString("window-new-tab-position") == QLatin1String("current") &&
if (config::string("window-new-tab-position") == QLatin1String("current") &&
m_tabs->count() > 0)
index = m_tabs->insertTab(m_tabs->currentIndex() + 1, page,
QStringLiteral("Ghastty"));
@ -671,7 +666,7 @@ bool MainWindow::confirmCloseSurfaces(
// true -> prompt only when libghostty says a process is running
// always -> always prompt, even for surfaces with no live process
// (libghostty Config.zig: ConfirmCloseSurface enum.)
const QString mode = configString("confirm-close-surface");
const QString mode = config::string("confirm-close-surface");
if (mode == QLatin1String("false")) return true;
bool needsConfirm = (mode == QLatin1String("always"));
@ -770,9 +765,9 @@ MainWindow *MainWindow::makeQuickTerminal() {
}
// Read quick-terminal-animation-duration (seconds) and convert to ms.
static int quickTerminalAnimationMs(ghostty_config_t cfg) {
static int quickTerminalAnimationMs() {
double secs = 0.2; // matches Config.zig default
configGet(cfg, &secs, "quick-terminal-animation-duration");
config::get(&secs, "quick-terminal-animation-duration");
// Clamp to a sane range so a misconfigured 0 or negative value
// doesn't make the window appear/disappear instantly without an
// animation, and a very large value doesn't lock the user out.
@ -785,7 +780,7 @@ void MainWindow::animateQuickTerminalIn() {
show();
raise();
activateWindow();
const int ms = quickTerminalAnimationMs(GhosttyApp::instance().config());
const int ms = quickTerminalAnimationMs();
if (ms <= 0) {
setWindowOpacity(1.0);
return;
@ -803,7 +798,7 @@ void MainWindow::animateQuickTerminalIn() {
}
void MainWindow::animateQuickTerminalOut() {
const int ms = quickTerminalAnimationMs(GhosttyApp::instance().config());
const int ms = quickTerminalAnimationMs();
if (ms <= 0) {
hide();
return;
@ -845,7 +840,7 @@ void MainWindow::setupLayerShell() {
using LSW = LayerShellQt::Window;
ls->setLayer(LSW::LayerTop);
const QString ki = configString("quick-terminal-keyboard-interactivity");
const QString ki = config::string("quick-terminal-keyboard-interactivity");
ls->setKeyboardInteractivity(
ki == QLatin1String("exclusive") ? LSW::KeyboardInteractivityExclusive
: ki == QLatin1String("none") ? LSW::KeyboardInteractivityNone
@ -861,7 +856,7 @@ void MainWindow::setupLayerShell() {
// Pass null to fall back to the QWindow's screen (LayerShellQt's
// documented default when neither setScreen nor
// setWantsToBeOnActiveScreen is set).
const QString screenMode = configString("quick-terminal-screen");
const QString screenMode = config::string("quick-terminal-screen");
QScreen *screen = nullptr;
if (screenMode == QLatin1String("mouse")) {
screen = QGuiApplication::screenAt(QCursor::pos());
@ -872,20 +867,20 @@ void MainWindow::setupLayerShell() {
ls->setScreen(screen);
if (!screen) screen = handle->screen();
// quick-terminal-space-behavior (`remain` / `move`): macOS
// controls whether the dropdown follows the active Space or pins
// to the one it was opened on. Wayland's wlr-layer-shell has no
// equivalent — the compositor always renders the surface on the
// active workspace (KWin behaviour), which corresponds to `move`.
// Achieving `remain` would need a per-workspace pin that no
// mainstream compositor exposes; honour by no-op and document.
Q_UNUSED(configString("quick-terminal-space-behavior"));
// quick-terminal-space-behavior (`remain` / `move`) is intentionally
// not read: macOS controls whether the dropdown follows the active
// Space or pins to the one it was opened on, but Wayland's
// wlr-layer-shell has no equivalent — the compositor always renders
// the surface on the active workspace (KWin behaviour), which
// corresponds to `move`. Achieving `remain` would need a
// per-workspace pin that no mainstream compositor exposes; honour
// by no-op and document.
const QSize scr = screen ? screen->size() : QSize(1920, 1080);
// quick-terminal-size: primary is the edge-perpendicular extent.
ghostty_config_quick_terminal_size_s qsz = {};
configGet(GhosttyApp::instance().config(), &qsz, "quick-terminal-size");
config::get(&qsz, "quick-terminal-size");
const auto toPx = [](const ghostty_quick_terminal_size_s &s, int dim,
int fallback) -> int {
switch (s.tag) {
@ -898,7 +893,7 @@ void MainWindow::setupLayerShell() {
}
};
const QString pos = configString("quick-terminal-position");
const QString pos = config::string("quick-terminal-position");
LSW::Anchors anchors;
QSize size;
if (pos == QLatin1String("bottom")) {
@ -935,7 +930,7 @@ void MainWindow::changeEvent(QEvent *e) {
// an explicit toggle).
if (e->type() == QEvent::ActivationChange && m_quickTerminal &&
isVisible() && !isActiveWindow() &&
configBool("quick-terminal-autohide", true))
config::boolean("quick-terminal-autohide", true))
animateQuickTerminalOut();
QWidget::changeEvent(e);
}
@ -1015,14 +1010,10 @@ void MainWindow::gotoSplit(GhosttySurface *from,
// we'll re-zoom the destination once the focus moves. Otherwise
// the existing semantics of dropping zoom on navigation apply.
//
// libghostty serializes packed structs into a c_uint bitfield via
// c_get.zig: `ptr.* = @intCast(@as(Backing, @bitCast(value)));`.
// SplitPreserveZoom = packed struct { navigation: bool } so bit 0
// is `navigation`. Reading into a smaller C struct (sizeof
// `bool`==1) under-sized the buffer and corrupted adjacent stack;
// read into c_uint and mask the bits.
unsigned int pzBits = 0;
configGet(GhosttyApp::instance().config(), &pzBits, "split-preserve-zoom");
// is `navigation`. config::bitfield handles the c_uint sizing
// dance documented there.
const unsigned int pzBits = config::bitfield("split-preserve-zoom", 0);
const bool preserveZoom = (pzBits & 0x1) != 0 && m_zoomed == from;
const auto centerOf = [](GhosttySurface *s) {
@ -1185,10 +1176,8 @@ void MainWindow::ringBell(GhosttySurface *surface) {
// dropping the field), use BellAttention as a sane minimum fallback.
// 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(GhosttyApp::instance().config(), &features, "bell-features")) {
features = BellAttention;
}
const unsigned int features =
config::bitfield("bell-features", BellAttention);
if (features & BellAttention) QApplication::alert(this);
if (features & BellSystem) QApplication::beep();
if (features & BellAudio) playBellAudio();
@ -1251,12 +1240,12 @@ 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 (ghostty_config_t cfg = GhosttyApp::instance().config()) {
if (GhosttyApp::instance().config()) {
bool quitAfter = true;
configGet(cfg, &quitAfter, "quit-after-last-window-closed");
config::get(&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 delayNs =
config::durationNs("quit-after-last-window-closed-delay", 0);
const uint64_t delayMs = delayNs / 1000000ULL;
const int delayMsInt = quitAfter
? static_cast<int>(std::min(delayMs, uint64_t(INT_MAX)))
@ -1279,7 +1268,7 @@ void MainWindow::refreshChrome() {
// Toggling Qt::FramelessWindowHint hides+reshows the window, so
// gate on a real change.
const bool wantFrameless =
w->configString("window-decoration") == QLatin1String("none");
config::string("window-decoration") == QLatin1String("none");
const bool isFrameless =
w->windowFlags().testFlag(Qt::FramelessWindowHint);
if (wantFrameless != isFrameless) {
@ -1294,9 +1283,9 @@ void MainWindow::refreshChrome() {
// fullscreen / maximize: `fullscreen=true` wins over `maximize`.
// Setting back to a non-fullscreen window goes through showNormal
// first so the WM lets us out of fullscreen cleanly.
const QString fs = w->configString("fullscreen");
const QString fs = config::string("fullscreen");
const bool wantFullscreen = !fs.isEmpty() && fs != QLatin1String("false");
const bool wantMax = w->configBool("maximize", false);
const bool wantMax = config::boolean("maximize", false);
if (wantFullscreen) {
if (!w->isFullScreen()) w->showFullScreen();
} else if (w->isFullScreen()) {
@ -1343,34 +1332,17 @@ void MainWindow::reloadConfigGlobal() {
// bit 1 = config-reload. The clipboard-copy bit is read for
// 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(GhosttyApp::instance().config(), &notifBits, "app-notifications");
// configGet failure → defaults (both bits set) so the feature
// still works as documented.
if (!notifOk) notifBits = 0x3;
// config::bitfield failure → defaults (both bits set) so the
// feature still works as documented.
const unsigned int notifBits = config::bitfield("app-notifications", 0x3);
const bool wantConfigReload = (notifBits & 0x2) != 0;
if (wantConfigReload)
postNotification(QStringLiteral("Ghostty"),
QStringLiteral("Configuration reloaded."));
}
QString MainWindow::configString(const char *key) const {
ghostty_config_t cfg = GhosttyApp::instance().config();
const char *value = nullptr;
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 (ghostty_config_t cfg = GhosttyApp::instance().config())
ghostty_config_get(cfg, &value, key, qstrlen(key));
return value;
}
bool MainWindow::focusFollowsMouse() const {
return configBool("focus-follows-mouse", false);
return config::boolean("focus-follows-mouse", false);
}
// Bring this window forward and focus the surface inside it. Mirrors
@ -1466,7 +1438,7 @@ void MainWindow::setSizeLimits(uint32_t minW, uint32_t minH, uint32_t maxW,
// strictly a bonus.
void MainWindow::setCellSize(uint32_t w, uint32_t h) {
m_cellSize = QSize(int(w), int(h));
if (configBool("window-step-resize", false))
if (config::boolean("window-step-resize", false))
setSizeIncrement(int(w), int(h));
else
setSizeIncrement(0, 0); // back to pixel-precise
@ -1618,7 +1590,7 @@ void MainWindow::redoLastClose() {
void MainWindow::applyWindowConfig() {
// window-show-tab-bar: always shown / auto-hidden with a lone tab /
// never shown.
const QString tabBar = configString("window-show-tab-bar");
const QString tabBar = config::string("window-show-tab-bar");
if (tabBar == QLatin1String("never")) {
m_tabs->setTabBarAutoHide(false);
m_tabs->tabBar()->hide();
@ -1636,13 +1608,10 @@ void MainWindow::applyWindowConfig() {
// doesn't re-scan the on-disk config on every bell. Refreshed on
// each applyWindowConfig (i.e. at init and on reload).
{
QString path = configValue(QStringLiteral("bell-audio-path"));
if (path.startsWith(QLatin1String("~/")))
path = QDir::homePath() + path.mid(1);
m_bellAudioPath = path;
m_bellAudioPath = config::expandedPath("bell-audio-path");
bool volOk = false;
const double v =
configValue(QStringLiteral("bell-audio-volume")).toDouble(&volOk);
config::diskValue("bell-audio-volume").toDouble(&volOk);
m_bellAudioVolume = volOk ? v : 0.5;
}
@ -1650,7 +1619,7 @@ void MainWindow::applyWindowConfig() {
// title via Qt's window-title system font is harder to override
// portably; the tab bar is what users actually look at). Empty /
// unset reverts to the application font.
const QString titleFamily = configValue(QStringLiteral("window-title-font-family"));
const QString titleFamily = config::diskValue("window-title-font-family");
if (m_tabs && m_tabs->tabBar()) {
QFont tf = QApplication::font();
if (!titleFamily.isEmpty()) tf.setFamily(titleFamily);
@ -1662,7 +1631,7 @@ void MainWindow::applyWindowConfig() {
// leaves Qt's default. Applied via setStyleSheet on this window's
// QSplitter children since splitters can be added/removed at any
// time, walk them on each apply.
const QString divider = configValue(QStringLiteral("split-divider-color"));
const QString divider = config::diskValue("split-divider-color");
const QString splitterCss = divider.isEmpty()
? QString()
: QStringLiteral("QSplitter::handle { background-color: %1; }")
@ -1682,7 +1651,7 @@ void MainWindow::applyWindowConfig() {
// CMake doesn't compile against older Qt). The setColorScheme
// hint propagates to chrome (tab bar, dialogs); the terminal
// itself honours its own theme via libghostty.
const QString theme = configString("window-theme");
const QString theme = config::string("window-theme");
Qt::ColorScheme scheme = Qt::ColorScheme::Unknown;
if (theme == QLatin1String("dark")) {
scheme = Qt::ColorScheme::Dark;
@ -1690,7 +1659,7 @@ void MainWindow::applyWindowConfig() {
scheme = Qt::ColorScheme::Light;
} else if (theme == QLatin1String("ghostty")) {
ghostty_config_color_s bg{};
if (configGet(GhosttyApp::instance().config(), &bg, "background")) {
if (config::get(&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;
}
@ -1708,7 +1677,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(GhosttyApp::instance().config(), &blur, "background-blur");
config::get(&blur, "background-blur");
applyWindowBlur(this, blur > 0);
}

View File

@ -158,12 +158,6 @@ public:
QSize defaultWindowSize() const { return m_defaultWindowSize; }
void setDefaultWindowSize(QSize s) { m_defaultWindowSize = s; }
// Typed wrappers over ghostty_config_get. configString also serves
// enum keys — libghostty returns an enum as its tag name string.
// Public so handler files can read config without friending.
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();

View File

@ -1,11 +1,8 @@
#include "Util.h"
#include <QByteArray>
#include <QChar>
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDir>
#include <QFile>
#include <QStringList>
#include <QStringLiteral>
#include <QVariantMap>
@ -40,78 +37,6 @@ 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"),

View File

@ -36,18 +36,6 @@ 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);
@ -58,17 +46,6 @@ void postNotification(const QString &title, const QString &body);
// as "…".
QString formatTrigger(const ghostty_input_trigger_s &t);
// Wrapper around ghostty_config_get that infers the value's length
// from a string literal, so call sites stop repeating qstrlen().
//
// The template only binds to char-array references (string literals);
// passing a `const char*` is intentionally a compile error — runtime-
// length keys must call ghostty_config_get directly with qstrlen.
template <typename T, size_t N>
inline bool configGet(ghostty_config_t cfg, T *out, const char (&key)[N]) {
return cfg && ghostty_config_get(cfg, out, key, N - 1);
}
// Queue `f` on `target`'s thread, but only if `target` is still alive
// when the slot runs (Qt cancels queued slots whose receiver was
// deleted). Cross-captured pointers must be wrapped in QPointer

View File

@ -10,6 +10,7 @@
#include <Qt>
#include "../app/GhosttyApp.h"
#include "../config/Config.h"
#include "../GhosttySurface.h"
#include "../MainWindow.h"
#include "../Util.h"
@ -86,9 +87,13 @@ bool handleChrome(const Context &ctx, const ghostty_action_s &action) {
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"))
// No window capture: setColorScheme and config::string are
// both process-scoped, so the originating window's lifetime
// doesn't affect this slot's correctness — and gating on it
// would silently drop chrome flips for the *other* windows
// that are still alive.
post(qApp, [c]() {
if (config::string("window-theme") != QLatin1String("ghostty"))
return;
const double luma = 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;
QGuiApplication::styleHints()->setColorScheme(

View File

@ -15,6 +15,7 @@
#include <QVariantMap>
#include "../app/GhosttyApp.h"
#include "../config/Config.h"
#include "../GhosttySurface.h"
#include "../MainWindow.h"
#include "../Util.h"
@ -147,7 +148,7 @@ bool handleSystem(const Context &ctx, const ghostty_action_s &action) {
// 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");
const QString mode = config::string("notify-on-command-finish");
bool fire = armed;
if (!fire) {
if (mode == QLatin1String("always")) fire = true;
@ -158,22 +159,16 @@ bool handleSystem(const Context &ctx, const ghostty_action_s &action) {
// -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")),
const uint64_t afterNs = config::durationNs(
"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;
// { bell: bool = true, notify: bool = false }. Bit 0 = bell,
// bit 1 = notify. Fallback (config::bitfield read failure)
// is bell=true so the feature still works.
const unsigned int actBits =
config::bitfield("notify-on-command-finish-action", 0x1);
const bool actBell = (actBits & 0x1) != 0;
const bool actNotify = (actBits & 0x2) != 0;
if (actBell) winp->ringBell(srcp);
@ -193,13 +188,9 @@ bool handleSystem(const Context &ctx, const ghostty_action_s &action) {
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;
// sequences are silently ignored (no taskbar entry). The gate
// is process-wide (a config setting, not a per-window setting).
if (!config::boolean("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;
@ -260,8 +251,7 @@ bool handleSystem(const Context &ctx, const ghostty_action_s &action) {
// 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");
config::get(&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]() {

View File

@ -6,9 +6,7 @@
#include <QByteArray>
#include <QClipboard>
#include <QCoreApplication>
#include <QDir>
#include <QEvent>
#include <QFile>
#include <QGuiApplication>
#include <QMessageBox>
#include <QMetaObject>
@ -18,35 +16,13 @@
#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. 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;
}
// 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
@ -81,7 +57,7 @@ bool GhosttyApp::ensureInitialized() {
ghostty_config_load_cli_args(m_config);
ghostty_config_load_recursive_files(m_config);
ghostty_config_finalize(m_config);
m_needsPremultiply = configHasCustomShader();
m_needsPremultiply = config::hasCustomShader();
ghostty_runtime_config_s rt = {};
// No app userdata: actions are routed to a window via their target
@ -115,7 +91,7 @@ void GhosttyApp::replaceConfig(ghostty_config_t new_config) {
// 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();
m_needsPremultiply = config::hasCustomShader();
}
void GhosttyApp::registerWindow(MainWindow *w) {

146
qt/src/config/Config.cpp Normal file
View File

@ -0,0 +1,146 @@
#include "Config.h"
#include <climits>
#include <QByteArray>
#include <QChar>
#include <QDir>
#include <QFile>
#include <QStringLiteral>
#include "../app/GhosttyApp.h"
namespace config {
ghostty_config_t handle() {
return GhosttyApp::instance().config();
}
QString string(const char *key) {
ghostty_config_t cfg = handle();
const char *value = nullptr;
if (!cfg || !ghostty_config_get(cfg, &value, key, qstrlen(key)) || !value)
return {};
return QString::fromUtf8(value);
}
bool boolean(const char *key, bool fallback) {
bool value = fallback; // ghostty_config_get leaves it untouched if absent
if (ghostty_config_t cfg = handle())
ghostty_config_get(cfg, &value, key, qstrlen(key));
return value;
}
QString diskValue(const char *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);
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;
}
// 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 fallback
// when parsing fails, when the input is empty, or when the running
// total would overflow uint64 (Config.zig rejects this; we mirror).
static uint64_t parseDurationNs(const QString &s, uint64_t fallback) {
if (s.isEmpty()) return fallback;
// kUnits mirrors Config.zig's units array; the longest-prefix match
// at the matching site below makes table order semantically
// irrelevant (kept aligned with Zig for diffability).
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;
// Reject overflow on multiply or running-sum: a typo like
// `1000000000y` would otherwise wrap into a small bogus value
// that callers treat as a real (tiny) duration.
if (factor && value > ULLONG_MAX / factor) return fallback;
const uint64_t segment = value * factor;
if (segment > ULLONG_MAX - total) return fallback;
total += segment;
i += unitLen;
anyMatched = true;
}
return anyMatched ? total : fallback;
}
uint64_t durationNs(const char *key, uint64_t fallbackNs) {
return parseDurationNs(diskValue(key), fallbackNs);
}
unsigned int bitfield(const char *key, unsigned int fallbackBits) {
unsigned int bits = 0;
ghostty_config_t cfg = handle();
if (cfg && ghostty_config_get(cfg, &bits, key, qstrlen(key))) return bits;
return fallbackBits;
}
QString expandedPath(const char *key) {
QString p = diskValue(key);
if (p.startsWith(QLatin1String("~/"))) p = QDir::homePath() + p.mid(1);
return p;
}
bool hasCustomShader() {
// libghostty does not expose this through ghostty_config_get
// (`custom-shader` is a repeatable path), so scan the on-disk
// config text. diskValue does the exact-key match (so
// `custom-shader-animation = …` is not mistaken for our key) and
// last-write-wins (so `custom-shader =` clears any earlier
// assignment, matching libghostty's repeating-key semantics).
return !diskValue("custom-shader").isEmpty();
}
} // namespace config

90
qt/src/config/Config.h Normal file
View File

@ -0,0 +1,90 @@
#pragma once
#include <cstdint>
#include <QString>
#include "ghostty.h"
// Typed accessors over the live libghostty config held by
// GhosttyApp::instance().config(). Every call here resolves the
// singleton's config pointer at access time, so reads stay coherent
// after a config reload (replaceConfig swaps the pointer in place).
//
// Layout: this header is include-anywhere (depends only on QString
// and ghostty.h). The implementations live in Config.cpp; the
// templated string-literal `get()` stays inline so callers don't pay
// a function-call hop on each config read.
namespace config {
// The live ghostty_config_t. Returns nullptr before the singleton has
// finished ensureInitialized — callers that read config during early
// startup (before the first MainWindow::initialize) must check.
ghostty_config_t handle();
// Read a string-valued config key (or an enum, which libghostty
// returns as its tag-name string). Empty if absent or the call
// fails.
QString string(const char *key);
// Read a bool-valued config key. Returns `fallback` when the key is
// absent or the call fails. Note: libghostty's bool config keys are
// strict bools, NOT packed bitfields — see bitfield<>() for those.
bool boolean(const char *key, bool fallback);
// Parse a duration config key as nanoseconds via the on-disk
// fallback. Use this for `?Duration` (optional) keys: c_get.zig
// returns false for a null optional, so the disk text is the only
// way to recover the configured value. Non-optional `Duration` keys
// surface through ghostty_config_get directly (it returns the value
// in *milliseconds*, per Duration.cval()) and should use config::get
// with `unsigned long long` and a manual ms→ns multiplication, NOT
// this wrapper, to avoid a redundant disk re-scan on every read.
// Returns `fallbackNs` on parse failure or absent key.
uint64_t durationNs(const char *key, uint64_t fallbackNs);
// Scan the user's primary on-disk config file for `key = value`
// directly. Used for keys ghostty_config_get can't decode (Duration,
// repeating paths). Returns the last matching value, or empty.
QString diskValue(const char *key);
// True if the live config has any custom-shader entry. Drives
// GhosttySurface's premultiply pass — `custom-shader` is a
// repeatable path that ghostty_config_get can't expose, so we scan
// the on-disk config text directly.
bool hasCustomShader();
// Read a packed-bitfield config key. libghostty serializes packed
// structs as a c_uint via c_get.zig (`ptr.* = @intCast(@as(Backing,
// @bitCast(value)))`), so the returned bits are flag-indexed by the
// struct field order. Reading into a smaller buffer (e.g. a `bool`
// for a one-field packed struct) under-sizes the write and corrupts
// adjacent stack — always go through this. Returns `fallbackBits`
// when ghostty_config_get fails.
unsigned int bitfield(const char *key, unsigned int fallbackBits);
// Read a path-valued disk config and expand a leading `~/` to the
// user's home directory. Returns empty when the key is absent.
// Path-valued keys are read off-disk (libghostty doesn't surface
// them through ghostty_config_get) so this is just diskValue() with
// a tilde-expansion pass.
QString expandedPath(const char *key);
// Wrapper around ghostty_config_get that infers the value's length
// from a string literal so call sites stop repeating qstrlen(). The
// template only binds to char-array references (string literals);
// passing a `const char*` is intentionally a compile error —
// runtime-length keys must call ghostty_config_get directly.
//
// `out` must point to the type ghostty.h documents for the key
// (bool* for bool keys, ghostty_config_color_s* for colors, etc.).
// Returns false when the key is absent or the underlying call
// fails.
template <typename T, size_t N>
inline bool get(T *out, const char (&key)[N]) {
static_assert(N > 1, "config::get requires a non-empty key literal");
ghostty_config_t cfg = handle();
return cfg && ghostty_config_get(cfg, out, key, N - 1);
}
} // namespace config