qt: phase 3.0 — extract config:: namespace for typed config access

Pulls the on-disk config helpers (configValue/parseDurationNs) and the
ghostty_config_get wrappers (configGet template) out of Util into a
new config:: namespace under qt/src/config/. Every accessor resolves
GhosttyApp::instance().config() at access time, so reads stay coherent
across replaceConfig swaps.

Also moves GhosttyApp's static configHasCustomShader scan into
config::hasCustomShader so the disk-fallback logic lives in one place.

MainWindow::configString / MainWindow::configBool stay as forwarders
to config::string / config::boolean for now — phase 3.1 retires them
after migrating GhosttySurface / CommandPalette / ChromeActions /
SystemActions / InspectorWindow's m_owner->configString(...) callsites.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
ntomsic 2026-05-23 14:03:56 -05:00
parent bca65fb6c4
commit 0c9f846e0e
10 changed files with 260 additions and 197 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"
@ -243,7 +244,7 @@ void GhosttySurface::layoutScrollbar() {
bool GhosttySurface::scrollbarAllowed() const {
if (!m_owner || !m_owner->config()) return true;
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 +271,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 (m_owner && 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 +318,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 +374,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 +522,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 +547,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.
@ -593,8 +584,7 @@ void GhosttySurface::paintResizeOverlay(QPainter &painter) {
// resize-overlay-position: center / {top,bottom}-{left,center,right}.
const QString pos =
m_owner ? cfgString(m_owner->config(), "resize-overlay-position")
: QString();
m_owner ? config::string("resize-overlay-position") : QString();
const qreal m = 8;
qreal x = (width() - boxW) / 2;
qreal y = (height() - boxH) / 2;

View File

@ -7,7 +7,6 @@
#include <QApplication>
#include <QAudioOutput>
#include <QByteArray>
#include <QClipboard>
#include <QCursor>
#include <QCloseEvent>
@ -17,7 +16,6 @@
#include <QDesktopServices>
#include <QEvent>
#include <QDir>
#include <QFile>
#include <QColor>
#include <QFont>
#include <QGuiApplication>
@ -55,6 +53,7 @@
#include <LayerShellQt/window.h>
#include "app/GhosttyApp.h"
#include "config/Config.h"
#include "CommandPalette.h"
#include "GhosttySurface.h"
#include "TabWidget.h"
@ -207,8 +206,8 @@ bool MainWindow::initialize() {
// 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)))
@ -268,17 +267,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 +298,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();
@ -770,9 +768,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 +783,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 +801,7 @@ void MainWindow::animateQuickTerminalIn() {
}
void MainWindow::animateQuickTerminalOut() {
const int ms = quickTerminalAnimationMs(GhosttyApp::instance().config());
const int ms = quickTerminalAnimationMs();
if (ms <= 0) {
hide();
return;
@ -885,7 +883,7 @@ void MainWindow::setupLayerShell() {
// 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) {
@ -1022,7 +1020,7 @@ void MainWindow::gotoSplit(GhosttySurface *from,
// `bool`==1) under-sized the buffer and corrupted adjacent stack;
// read into c_uint and mask the bits.
unsigned int pzBits = 0;
configGet(GhosttyApp::instance().config(), &pzBits, "split-preserve-zoom");
config::get(&pzBits, "split-preserve-zoom");
const bool preserveZoom = (pzBits & 0x1) != 0 && m_zoomed == from;
const auto centerOf = [](GhosttySurface *s) {
@ -1186,7 +1184,7 @@ void MainWindow::ringBell(GhosttySurface *surface) {
// If config-get succeeds with features=0, the user explicitly opted
// out of every bell feature and we honor that.
unsigned int features = 0;
if (!configGet(GhosttyApp::instance().config(), &features, "bell-features")) {
if (!config::get(&features, "bell-features")) {
features = BellAttention;
}
if (features & BellAttention) QApplication::alert(this);
@ -1251,12 +1249,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)))
@ -1344,8 +1342,8 @@ void MainWindow::reloadConfigGlobal() {
// forward compatibility — Qt doesn't currently post a copy
// toast, but a future one will pick up the same gate.
unsigned int notifBits = 0;
const bool notifOk = configGet(GhosttyApp::instance().config(), &notifBits, "app-notifications");
// configGet failure → defaults (both bits set) so the feature
const bool notifOk = config::get(&notifBits, "app-notifications");
// config::get failure → defaults (both bits set) so the feature
// still works as documented.
if (!notifOk) notifBits = 0x3;
const bool wantConfigReload = (notifBits & 0x2) != 0;
@ -1354,23 +1352,21 @@ void MainWindow::reloadConfigGlobal() {
QStringLiteral("Configuration reloaded."));
}
// configString / configBool are forwarders to config::string /
// config::boolean. Kept for now so external callers (GhosttySurface,
// CommandPalette) don't have to migrate their existing
// `m_owner->configString(...)` callsites in this commit; phase 3.1
// retires the forwarders.
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);
return config::string(key);
}
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;
return config::boolean(key, fallback);
}
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
@ -1636,13 +1632,13 @@ 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"));
QString path = config::diskValue("bell-audio-path");
if (path.startsWith(QLatin1String("~/")))
path = QDir::homePath() + path.mid(1);
m_bellAudioPath = 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 +1646,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 +1658,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; }")
@ -1690,7 +1686,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 +1704,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

@ -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

@ -15,6 +15,7 @@
#include <QVariantMap>
#include "../app/GhosttyApp.h"
#include "../config/Config.h"
#include "../GhosttySurface.h"
#include "../MainWindow.h"
#include "../Util.h"
@ -158,20 +159,19 @@ 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.
// "config::get 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
const bool actOk =
config::get(&actBits, "notify-on-command-finish-action");
// config::get 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;
@ -260,8 +260,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

@ -3,12 +3,9 @@
#include <cstdio>
#include <QApplication>
#include <QByteArray>
#include <QClipboard>
#include <QCoreApplication>
#include <QDir>
#include <QEvent>
#include <QFile>
#include <QGuiApplication>
#include <QMessageBox>
#include <QMetaObject>
@ -18,35 +15,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 +56,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 +90,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) {

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

@ -0,0 +1,133 @@
#include "Config.h"
#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 or the input is empty.
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;
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;
}
uint64_t durationNs(const char *key, uint64_t fallbackNs) {
return parseDurationNs(diskValue(key), fallbackNs);
}
bool hasCustomShader() {
// libghostty does not expose this through ghostty_config_get
// (`custom-shader` is a repeatable path), so scan the primary
// config file directly.
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;
}
} // namespace config

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

@ -0,0 +1,68 @@
#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. Always reads through
// the disk-fallback (configDiskValue) because libghostty's
// ghostty_config_get rejects Duration types (non-extern non-packed
// struct). 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();
// 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]) {
ghostty_config_t cfg = handle();
return cfg && ghostty_config_get(cfg, out, key, N - 1);
}
} // namespace config