diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index 864a97cc6..6dc23a1cd 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -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 diff --git a/qt/src/CommandPalette.cpp b/qt/src/CommandPalette.cpp index 386e24b6c..4971a8612 100644 --- a/qt/src/CommandPalette.cpp +++ b/qt/src/CommandPalette.cpp @@ -11,6 +11,7 @@ #include #include +#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 : ""); diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 77a927b6d..2d04127e6 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -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(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; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 939856e91..f1a7d30af 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -7,7 +7,6 @@ #include #include -#include #include #include #include @@ -17,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -55,6 +53,7 @@ #include #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(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(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(), ¬ifBits, "app-notifications"); - // configGet failure → defaults (both bits set) so the feature + const bool notifOk = config::get(¬ifBits, "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); } diff --git a/qt/src/Util.cpp b/qt/src/Util.cpp index 9237fc9f6..b072675b0 100644 --- a/qt/src/Util.cpp +++ b/qt/src/Util.cpp @@ -1,11 +1,8 @@ #include "Util.h" -#include #include #include #include -#include -#include #include #include #include @@ -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"), diff --git a/qt/src/Util.h b/qt/src/Util.h index 700e5952c..fe1f79159 100644 --- a/qt/src/Util.h +++ b/qt/src/Util.h @@ -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 -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 diff --git a/qt/src/actions/SystemActions.cpp b/qt/src/actions/SystemActions.cpp index 3c93db2ea..23b651975 100644 --- a/qt/src/actions/SystemActions.cpp +++ b/qt/src/actions/SystemActions.cpp @@ -15,6 +15,7 @@ #include #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(ce.exit_code); post(src, [srcp, code]() { diff --git a/qt/src/app/GhosttyApp.cpp b/qt/src/app/GhosttyApp.cpp index 1641641a0..dbdcea651 100644 --- a/qt/src/app/GhosttyApp.cpp +++ b/qt/src/app/GhosttyApp.cpp @@ -3,12 +3,9 @@ #include #include -#include #include #include -#include #include -#include #include #include #include @@ -18,35 +15,13 @@ #include #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) { diff --git a/qt/src/config/Config.cpp b/qt/src/config/Config.cpp new file mode 100644 index 000000000..0e0557dbc --- /dev/null +++ b/qt/src/config/Config.cpp @@ -0,0 +1,133 @@ +#include "Config.h" + +#include +#include +#include +#include +#include + +#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 `` 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 diff --git a/qt/src/config/Config.h b/qt/src/config/Config.h new file mode 100644 index 000000000..e1b68438d --- /dev/null +++ b/qt/src/config/Config.h @@ -0,0 +1,68 @@ +#pragma once + +#include + +#include + +#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 +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