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..2e7bb1213 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" @@ -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(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; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 939856e91..3b41230bd 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -7,17 +7,12 @@ #include #include -#include #include #include #include #include -#include -#include #include #include -#include -#include #include #include #include @@ -55,6 +50,7 @@ #include #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(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(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(), ¬ifBits, "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); } diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index 0d9dbfb33..370e251e2 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -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(); 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/ChromeActions.cpp b/qt/src/actions/ChromeActions.cpp index c3b0cca04..0c92b745a 100644 --- a/qt/src/actions/ChromeActions.cpp +++ b/qt/src/actions/ChromeActions.cpp @@ -10,6 +10,7 @@ #include #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( diff --git a/qt/src/actions/SystemActions.cpp b/qt/src/actions/SystemActions.cpp index 3c93db2ea..0ca3963cf 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" @@ -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(ce.exit_code); post(src, [srcp, code]() { diff --git a/qt/src/app/GhosttyApp.cpp b/qt/src/app/GhosttyApp.cpp index 1641641a0..deb4c6d7a 100644 --- a/qt/src/app/GhosttyApp.cpp +++ b/qt/src/app/GhosttyApp.cpp @@ -6,9 +6,7 @@ #include #include #include -#include #include -#include #include #include #include @@ -18,35 +16,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 +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) { diff --git a/qt/src/config/Config.cpp b/qt/src/config/Config.cpp new file mode 100644 index 000000000..4bbccdba6 --- /dev/null +++ b/qt/src/config/Config.cpp @@ -0,0 +1,146 @@ +#include "Config.h" + +#include + +#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, 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 diff --git a/qt/src/config/Config.h b/qt/src/config/Config.h new file mode 100644 index 000000000..7dcc8971e --- /dev/null +++ b/qt/src/config/Config.h @@ -0,0 +1,90 @@ +#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 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 +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