From b2152919141de84a71052dd6f298d24dc1b08d63 Mon Sep 17 00:00:00 2001 From: Alaa Ali Date: Tue, 3 Mar 2026 23:00:50 +0100 Subject: [PATCH 01/18] macos: implement audio bell support with bell-audio-path Extends the macOS bell implementation to support the `audio` bell feature by playing a user-specified audio file via NSSound. Previously, macOS only supported the `system` feature (NSSound.beep()). This change adds support for: - `audio` bell feature: plays the file at `bell-audio-path` using NSSound, respecting the `bell-audio-volume` setting - Adds `cval()` to the `Path` type so it can be returned via the C API Also removes the "(GTK only)" restriction from `bell-audio-path` and `bell-audio-volume` documentation, as these options now work on macOS. Example config: bell-features = audio bell-audio-path = /System/Library/Sounds/Glass.aiff bell-audio-volume = 0.8 --- macos/Sources/App/macOS/AppDelegate.swift | 8 ++++++++ macos/Sources/Ghostty/Ghostty.Config.swift | 18 ++++++++++++++++++ src/config/Config.zig | 3 +-- src/config/path.zig | 8 ++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index bcd9a0ffa..ad290c36c 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -777,6 +777,14 @@ class AppDelegate: NSObject, NSSound.beep() } + if ghostty.config.bellFeatures.contains(.audio) { + if let path = ghostty.config.bellAudioPath, + let sound = NSSound(contentsOfFile: path, byReference: false) { + sound.volume = ghostty.config.bellAudioVolume + sound.play() + } + } + if ghostty.config.bellFeatures.contains(.attention) { // Bounce the dock icon if we're not focused. NSApp.requestUserAttention(.informationalRequest) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index a26f41038..00c1f5307 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -134,6 +134,24 @@ extension Ghostty { return .init(rawValue: v) } + var bellAudioPath: String? { + guard let config = self.config else { return nil } + var v: UnsafePointer? + let key = "bell-audio-path" + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } + guard let ptr = v else { return nil } + let path = String(cString: ptr) + return path.isEmpty ? nil : path + } + + var bellAudioVolume: Float { + guard let config = self.config else { return 0.5 } + var v: Double = 0.5 + let key = "bell-audio-volume" + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) + return Float(v) + } + var notifyOnCommandFinish: NotifyOnCommandFinish { guard let config = self.config else { return .never } var v: UnsafePointer? diff --git a/src/config/Config.zig b/src/config/Config.zig index 29a45786f..7020a2b57 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3087,7 +3087,7 @@ keybind: Keybinds = .{}, /// the path is not absolute, it is considered relative to the directory of the /// configuration file that it is referenced from, or from the current working /// directory if this is used as a CLI flag. The path may be prefixed with `~/` -/// to reference the user's home directory. (GTK only) +/// to reference the user's home directory. /// /// Available since: 1.2.0 @"bell-audio-path": ?Path = null, @@ -3095,7 +3095,6 @@ keybind: Keybinds = .{}, /// If `audio` is an enabled bell feature, this is the volume to play the audio /// file at (relative to the system volume). This is a floating point number /// ranging from 0.0 (silence) to 1.0 (as loud as possible). The default is 0.5. -/// (GTK only) /// /// Available since: 1.2.0 @"bell-audio-volume": f64 = 0.5, diff --git a/src/config/path.zig b/src/config/path.zig index ebcd084d2..7faf5f312 100644 --- a/src/config/path.zig +++ b/src/config/path.zig @@ -32,6 +32,14 @@ pub const Path = union(enum) { return std.meta.eql(self, other); } + /// Returns the path as a C-compatible null-terminated string pointer. + pub fn cval(self: Path) [*:0]const u8 { + return switch (self) { + .optional => |path| path.ptr, + .required => |path| path.ptr, + }; + } + /// Parse the input and return a Path. A leading `?` indicates that the path /// is _optional_ and an error should not be logged or displayed to the user /// if that path does not exist. Otherwise the path is required and an error From 98ad1d955cf8d66cf5548f581a6502cf10f2f852 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 3 Mar 2026 19:53:15 -0800 Subject: [PATCH 02/18] use proper type for optional path --- include/ghostty.h | 6 ++++++ macos/Sources/App/macOS/AppDelegate.swift | 4 ++-- macos/Sources/Ghostty/Ghostty.Config.swift | 9 ++++----- macos/Sources/Ghostty/Ghostty.ConfigTypes.swift | 6 ++++++ src/config/path.zig | 14 ++++++++++---- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 19b6e0fa4..19a200f10 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -463,6 +463,12 @@ typedef struct { // Config types +// config.Path +typedef struct { + const char* path; + bool optional; +} ghostty_config_path_s; + // config.Color typedef struct { uint8_t r; diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index ad290c36c..f9e2dc93f 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -778,8 +778,8 @@ class AppDelegate: NSObject, } if ghostty.config.bellFeatures.contains(.audio) { - if let path = ghostty.config.bellAudioPath, - let sound = NSSound(contentsOfFile: path, byReference: false) { + if let configPath = ghostty.config.bellAudioPath, + let sound = NSSound(contentsOfFile: configPath.path, byReference: false) { sound.volume = ghostty.config.bellAudioVolume sound.play() } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 00c1f5307..87ae0511f 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -134,14 +134,13 @@ extension Ghostty { return .init(rawValue: v) } - var bellAudioPath: String? { + var bellAudioPath: ConfigPath? { guard let config = self.config else { return nil } - var v: UnsafePointer? + var v = ghostty_config_path_s() let key = "bell-audio-path" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } - guard let ptr = v else { return nil } - let path = String(cString: ptr) - return path.isEmpty ? nil : path + let path = String(cString: v.path) + return path.isEmpty ? nil : ConfigPath(path: path, optional: v.optional) } var bellAudioVolume: Float { diff --git a/macos/Sources/Ghostty/Ghostty.ConfigTypes.swift b/macos/Sources/Ghostty/Ghostty.ConfigTypes.swift index 8c559fad2..90470f38a 100644 --- a/macos/Sources/Ghostty/Ghostty.ConfigTypes.swift +++ b/macos/Sources/Ghostty/Ghostty.ConfigTypes.swift @@ -2,6 +2,12 @@ // can get typed information without depending on all the dependencies of GhosttyKit. extension Ghostty { + /// A configuration path value that may be optional or required. + struct ConfigPath: Sendable { + let path: String + let optional: Bool + } + /// macos-icon enum MacOSIcon: String, Sendable { case official diff --git a/src/config/path.zig b/src/config/path.zig index 7faf5f312..793cf1845 100644 --- a/src/config/path.zig +++ b/src/config/path.zig @@ -32,11 +32,17 @@ pub const Path = union(enum) { return std.meta.eql(self, other); } - /// Returns the path as a C-compatible null-terminated string pointer. - pub fn cval(self: Path) [*:0]const u8 { + /// ghostty_config_path_s + pub const C = extern struct { + path: [*:0]const u8, + optional: bool, + }; + + /// Returns the path as a C-compatible struct. + pub fn cval(self: Path) C { return switch (self) { - .optional => |path| path.ptr, - .required => |path| path.ptr, + .optional => |path| .{ .path = path.ptr, .optional = true }, + .required => |path| .{ .path = path.ptr, .optional = false }, }; } From a716b9c4d421a3ab94f93fe301ddc28a5a086361 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Wed, 4 Mar 2026 11:00:03 -0500 Subject: [PATCH 03/18] macos: Ghostty.Shell.escape unit tests --- macos/Tests/Ghostty/ShellTests.swift | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/macos/Tests/Ghostty/ShellTests.swift b/macos/Tests/Ghostty/ShellTests.swift index c7b34b3d9..5990bedc7 100644 --- a/macos/Tests/Ghostty/ShellTests.swift +++ b/macos/Tests/Ghostty/ShellTests.swift @@ -2,6 +2,34 @@ import Testing @testable import Ghostty struct ShellTests { + @Test(arguments: [ + ("hello", "hello"), + ("", ""), + ("file name", "file\\ name"), + ("a\\b", "a\\\\b"), + ("(foo)", "\\(foo\\)"), + ("[bar]", "\\[bar\\]"), + ("{baz}", "\\{baz\\}"), + ("", "\\"), + ("say\"hi\"", "say\\\"hi\\\""), + ("it's", "it\\'s"), + ("`cmd`", "\\`cmd\\`"), + ("wow!", "wow\\!"), + ("#comment", "\\#comment"), + ("$HOME", "\\$HOME"), + ("a&b", "a\\&b"), + ("a;b", "a\\;b"), + ("a|b", "a\\|b"), + ("*.txt", "\\*.txt"), + ("file?.log", "file\\?.log"), + ("col1\tcol2", "col1\\\tcol2"), + ("$(echo 'hi')", "\\$\\(echo\\ \\'hi\\'\\)"), + ("/tmp/my file (1).txt", "/tmp/my\\ file\\ \\(1\\).txt"), + ]) + func escape(input: String, expected: String) { + #expect(Ghostty.Shell.escape(input) == expected) + } + @Test(arguments: [ ("", "''"), ("filename", "filename"), From 2772c90885d2ffc27fdf5e7e758c5ecca2af6f8c Mon Sep 17 00:00:00 2001 From: Baurzhan Muftakhidinov Date: Wed, 4 Mar 2026 22:02:37 +0500 Subject: [PATCH 04/18] i18n: add Kazakh translation (kk) --- CODEOWNERS | 1 + po/kk.po | 354 ++++++++++++++++++++++++++++++++++++++++ src/os/i18n_locales.zig | 1 + 3 files changed, 356 insertions(+) create mode 100644 po/kk.po diff --git a/CODEOWNERS b/CODEOWNERS index f377a73c6..1212133de 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -177,6 +177,7 @@ /po/id.po @ghostty-org/id_ID /po/it.po @ghostty-org/it_IT /po/ja.po @ghostty-org/ja_JP +/po/kk.po @ghostty-org/kk_KZ /po/ko_KR.po @ghostty-org/ko_KR /po/lt.po @ghostty-org/lt_LT /po/lv.po @ghostty-org/lv_LV diff --git a/po/kk.po b/po/kk.po new file mode 100644 index 000000000..91be4a4b3 --- /dev/null +++ b/po/kk.po @@ -0,0 +1,354 @@ +# Kazakh translation for Ghostty. +# Copyright (C) 2026 "Mitchell Hashimoto, Ghostty contributors" +# This file is distributed under the same license as the com.mitchellh.ghostty package. +# Baurzhan Muftakhidinov , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: com.mitchellh.ghostty\n" +"Report-Msgid-Bugs-To: m@mitchellh.com\n" +"POT-Creation-Date: 2026-02-17 23:16+0100\n" +"PO-Revision-Date: 2026-03-04 21:16+0500\n" +"Last-Translator: Baurzhan Muftakhidinov \n" +"Language-Team: Kazakh \n" +"Language: kk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: dist/linux/ghostty_nautilus.py:53 +msgid "Open in Ghostty" +msgstr "Ghostty ішінде ашу" + +#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197 +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:201 +msgid "Authorize Clipboard Access" +msgstr "Алмасу буферіне қол жеткізуді рұқсат ету" + +#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:17 +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:17 +msgid "Deny" +msgstr "Тыйым салу" + +#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:18 +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:18 +msgid "Allow" +msgstr "Рұқсат ету" + +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:92 +msgid "Remember choice for this split" +msgstr "Осы бөлу үшін таңдауды есте сақтау" + +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:93 +msgid "Reload configuration to show this prompt again" +msgstr "Бұл сұрауды қайта көрсету үшін конфигурацияны қайта жүктеңіз" + +#: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 +#: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +msgid "Cancel" +msgstr "Бас тарту" + +#: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 +msgid "Close" +msgstr "Жабу" + +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6 +msgid "Configuration Errors" +msgstr "Конфигурация қателері" + +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 +msgid "" +"One or more configuration errors were found. Please review the errors below, " +"and either reload your configuration or ignore these errors." +msgstr "" +"Бір немесе бірнеше конфигурация қатесі табылды. Төмендегі қателерді қарап " +"шығып, конфигурацияны қайта жүктеңіз немесе бұл қателерді елемеңіз." + +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +msgid "Ignore" +msgstr "Елемеу" + +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 +#: src/apprt/gtk/ui/1.2/surface.blp:366 src/apprt/gtk/ui/1.5/window.blp:300 +msgid "Reload Configuration" +msgstr "Конфигурацияны қайта жүктеу" + +#: src/apprt/gtk/ui/1.2/debug-warning.blp:7 +#: src/apprt/gtk/ui/1.3/debug-warning.blp:6 +msgid "⚠️ You're running a debug build of Ghostty! Performance will be degraded." +msgstr "" +"⚠️ Сіз Ghostty жөндеу құрастырылымын іске қосып тұрсыз! Өнімділік төмен болуы " +"мүмкін." + +#: src/apprt/gtk/ui/1.5/inspector-window.blp:5 +msgid "Ghostty: Terminal Inspector" +msgstr "Ghostty: Терминал инспекторы" + +#: src/apprt/gtk/ui/1.2/search-overlay.blp:29 +msgid "Find…" +msgstr "Табу…" + +#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +msgid "Previous Match" +msgstr "Алдыңғы сәйкестік" + +#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +msgid "Next Match" +msgstr "Келесі сәйкестік" + +#: src/apprt/gtk/ui/1.2/surface.blp:6 +msgid "Oh, no." +msgstr "О, жоқ." + +#: src/apprt/gtk/ui/1.2/surface.blp:7 +msgid "Unable to acquire an OpenGL context for rendering." +msgstr "Рендеринг үшін OpenGL контекстін алу мүмкін емес." + +#: src/apprt/gtk/ui/1.2/surface.blp:97 +msgid "" +"This terminal is in read-only mode. You can still view, select, and scroll " +"through the content, but no input events will be sent to the running " +"application." +msgstr "" +"Бұл терминал тек оқу режимінде. Сіз әлі де мазмұнды көре, таңдай және жылжыта " +"аласыз, бірақ іске қосылған қолданбаға ешқандай енгізу оқиғалары жіберілмейді." + +#: src/apprt/gtk/ui/1.2/surface.blp:107 +msgid "Read-only" +msgstr "Тек оқу" + +#: src/apprt/gtk/ui/1.2/surface.blp:260 src/apprt/gtk/ui/1.5/window.blp:200 +msgid "Copy" +msgstr "Көшіріп алу" + +#: src/apprt/gtk/ui/1.2/surface.blp:265 src/apprt/gtk/ui/1.5/window.blp:205 +msgid "Paste" +msgstr "Кірістіру" + +#: src/apprt/gtk/ui/1.2/surface.blp:270 +msgid "Notify on Next Command Finish" +msgstr "Келесі команда аяқталғанда хабарлау" + +#: src/apprt/gtk/ui/1.2/surface.blp:277 src/apprt/gtk/ui/1.5/window.blp:273 +msgid "Clear" +msgstr "Тазарту" + +#: src/apprt/gtk/ui/1.2/surface.blp:282 src/apprt/gtk/ui/1.5/window.blp:278 +msgid "Reset" +msgstr "Тастау" + +#: src/apprt/gtk/ui/1.2/surface.blp:289 src/apprt/gtk/ui/1.5/window.blp:242 +msgid "Split" +msgstr "Бөлу" + +#: src/apprt/gtk/ui/1.2/surface.blp:292 src/apprt/gtk/ui/1.5/window.blp:245 +msgid "Change Title…" +msgstr "Тақырыпты өзгерту…" + +#: src/apprt/gtk/ui/1.2/surface.blp:297 src/apprt/gtk/ui/1.5/window.blp:177 +#: src/apprt/gtk/ui/1.5/window.blp:250 +msgid "Split Up" +msgstr "Жоғарыға бөлу" + +#: src/apprt/gtk/ui/1.2/surface.blp:303 src/apprt/gtk/ui/1.5/window.blp:182 +#: src/apprt/gtk/ui/1.5/window.blp:255 +msgid "Split Down" +msgstr "Төменге бөлу" + +#: src/apprt/gtk/ui/1.2/surface.blp:309 src/apprt/gtk/ui/1.5/window.blp:187 +#: src/apprt/gtk/ui/1.5/window.blp:260 +msgid "Split Left" +msgstr "Сол жаққа бөлу" + +#: src/apprt/gtk/ui/1.2/surface.blp:315 src/apprt/gtk/ui/1.5/window.blp:192 +#: src/apprt/gtk/ui/1.5/window.blp:265 +msgid "Split Right" +msgstr "Оң жаққа бөлу" + +#: src/apprt/gtk/ui/1.2/surface.blp:322 +msgid "Tab" +msgstr "Бет" + +#: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 +#: src/apprt/gtk/ui/1.5/window.blp:320 +msgid "Change Tab Title…" +msgstr "Бет атауын өзгерту…" + +#: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 +#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 +msgid "New Tab" +msgstr "Жаңа бет" + +#: src/apprt/gtk/ui/1.2/surface.blp:335 src/apprt/gtk/ui/1.5/window.blp:234 +msgid "Close Tab" +msgstr "Бетті жабу" + +#: src/apprt/gtk/ui/1.2/surface.blp:342 +msgid "Window" +msgstr "Терезе" + +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:212 +msgid "New Window" +msgstr "Жаңа терезе" + +#: src/apprt/gtk/ui/1.2/surface.blp:350 src/apprt/gtk/ui/1.5/window.blp:217 +msgid "Close Window" +msgstr "Терезені жабу" + +#: src/apprt/gtk/ui/1.2/surface.blp:358 +msgid "Config" +msgstr "Конфигурация" + +#: src/apprt/gtk/ui/1.2/surface.blp:361 src/apprt/gtk/ui/1.5/window.blp:295 +msgid "Open Configuration" +msgstr "Конфигурацияны ашу" + +#: src/apprt/gtk/ui/1.5/title-dialog.blp:5 +msgid "Leave blank to restore the default title." +msgstr "Бастапқы атауды қалпына келтіру үшін бос қалдырыңыз." + +#: src/apprt/gtk/ui/1.5/title-dialog.blp:9 +msgid "OK" +msgstr "ОК" + +#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +msgid "New Split" +msgstr "Жаңа бөлу" + +#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +msgid "View Open Tabs" +msgstr "Ашық беттерді көру" + +#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +msgid "Main Menu" +msgstr "Бас мәзір" + +#: src/apprt/gtk/ui/1.5/window.blp:285 +msgid "Command Palette" +msgstr "Командалар палитрасы" + +#: src/apprt/gtk/ui/1.5/window.blp:290 +msgid "Terminal Inspector" +msgstr "Терминал инспекторы" + +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1727 +msgid "About Ghostty" +msgstr "Ghostty туралы" + +#: src/apprt/gtk/ui/1.5/window.blp:312 +msgid "Quit" +msgstr "Шығу" + +#: src/apprt/gtk/ui/1.5/command-palette.blp:17 +msgid "Execute a command…" +msgstr "Команданы орындау…" + +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 +msgid "" +"An application is attempting to write to the clipboard. The current clipboard " +"contents are shown below." +msgstr "" +"Қолданба алмасу буферіне жазуға әрекеттенуде. Ағымдағы алмасу буферінің " +"мазмұны төменде көрсетілген." + +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:202 +msgid "" +"An application is attempting to read from the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Қолданба алмасу буферінен оқуға әрекеттенуде. Ағымдағы алмасу буферінің " +"мазмұны төменде көрсетілген." + +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:205 +msgid "Warning: Potentially Unsafe Paste" +msgstr "Ескерту: Қауіпсіз емес бола алатын кірістіру" + +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:206 +msgid "" +"Pasting this text into the terminal may be dangerous as it looks like some " +"commands may be executed." +msgstr "" +"Бұл мәтінді терминалға кірістіру қауіпті болуы мүмкін, себебі кейбір " +"командалар орындалуы мүмкін сияқты." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:184 +msgid "Quit Ghostty?" +msgstr "Ghostty-ден шығу керек пе?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:185 +msgid "Close Tab?" +msgstr "Бетті жабу керек пе?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:186 +msgid "Close Window?" +msgstr "Терезені жабу керек пе?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:187 +msgid "Close Split?" +msgstr "Бөлуді жабу керек пе?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:193 +msgid "All terminal sessions will be terminated." +msgstr "Барлық терминал сессиялары тоқтатылады." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:194 +msgid "All terminal sessions in this tab will be terminated." +msgstr "Осы беттегі барлық терминал сессиялары тоқтатылады." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:195 +msgid "All terminal sessions in this window will be terminated." +msgstr "Осы терезедегі барлық терминал сессиялары тоқтатылады." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:196 +msgid "The currently running process in this split will be terminated." +msgstr "Осы бөлудегі ағымдағы орындалып жатқан процесс тоқтатылады." + +#: src/apprt/gtk/class/surface.zig:1108 +msgid "Command Finished" +msgstr "Команда аяқталды" + +#: src/apprt/gtk/class/surface.zig:1109 +msgid "Command Succeeded" +msgstr "Команда сәтті орындалды" + +#: src/apprt/gtk/class/surface.zig:1110 +msgid "Command Failed" +msgstr "Команда сәтсіз аяқталды" + +#: src/apprt/gtk/class/surface_child_exited.zig:109 +msgid "Command succeeded" +msgstr "Команда сәтті орындалды" + +#: src/apprt/gtk/class/surface_child_exited.zig:113 +msgid "Command failed" +msgstr "Команда сәтсіз аяқталды" + +#: src/apprt/gtk/class/title_dialog.zig:225 +msgid "Change Terminal Title" +msgstr "Терминал атауын өзгерту" + +#: src/apprt/gtk/class/title_dialog.zig:226 +msgid "Change Tab Title" +msgstr "Бет атауын өзгерту" + +#: src/apprt/gtk/class/window.zig:1007 +msgid "Reloaded the configuration" +msgstr "Конфигурация қайта жүктелді" + +#: src/apprt/gtk/class/window.zig:1566 +msgid "Copied to clipboard" +msgstr "Алмасу буферіне көшірілді" + +#: src/apprt/gtk/class/window.zig:1568 +msgid "Cleared clipboard" +msgstr "Алмасу буфері тазартылды" + +#: src/apprt/gtk/class/window.zig:1708 +msgid "Ghostty Developers" +msgstr "Ghostty әзірлеушілері" diff --git a/src/os/i18n_locales.zig b/src/os/i18n_locales.zig index 7a7daf998..91290dab5 100644 --- a/src/os/i18n_locales.zig +++ b/src/os/i18n_locales.zig @@ -54,4 +54,5 @@ pub const locales = [_][:0]const u8{ "hr", "lt", "lv", + "kk", }; From 9a3dbe10b05912ee30061dae6d730d8d9db0bc46 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Wed, 4 Mar 2026 12:41:55 -0500 Subject: [PATCH 05/18] zsh: fix extra newlines with leading-newline prompts In our multiline prompt logic, skip the newline immediately after the first mark to avoid introducing a double newline due to OSC 133;A's fresh-line behavior. Fixes: #11003 --- src/shell-integration/zsh/ghostty-integration | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 4d872d025..923d928a6 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -141,10 +141,16 @@ _ghostty_deferred_init() { # - False negative (with prompt_subst): PS1='$mark1' [[ $PS1 == *$mark1* ]] || PS1=${mark1}${PS1} [[ $PS1 == *$markB* ]] || PS1=${PS1}${markB} - # Handle multiline prompts by marking continuation lines as - # secondary by replacing newlines with being prefixed - # with k=s - if [[ $PS1 == *$'\n'* ]]; then + # Handle multiline prompts by marking newline-separated + # continuation lines with k=s (mark2). We skip the newline + # immediately after mark1 to avoid introducing a double + # newline due to OSC 133;A's fresh-line behavior. + if [[ $PS1 == ${mark1}$'\n'* ]]; then + builtin local rest=${PS1#${mark1}$'\n'} + if [[ $rest == *$'\n'* ]]; then + PS1=${mark1}$'\n'${rest//$'\n'/$'\n'${mark2}} + fi + elif [[ $PS1 == *$'\n'* ]]; then PS1=${PS1//$'\n'/$'\n'${mark2}} fi From 9386fa64997db6124f1cdbd4730938edb49f2a85 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Wed, 4 Mar 2026 12:48:02 -0500 Subject: [PATCH 06/18] zsh: emit missing prompt markers in line-init Emit semantic prompt markers at line-init if PS1 doesn't contain our marks. This ensures the terminal sees prompt markers even if another plugin (like zinit or oh-my-posh) regenerated PS1 after our precmd ran. We use 133;P instead of 133;A to avoid fresh-line behavior which would disrupt the display since the prompt has already been drawn. We also emit 133;B to mark the input area, which is needed for click-to-move. Fixes: #10555 --- src/shell-integration/zsh/ghostty-integration | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 4d872d025..d60fb7a20 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -239,6 +239,19 @@ _ghostty_deferred_init() { builtin print -rnu $_ghostty_fd \$'\\e[0 q'" fi + # Emit semantic prompt markers at line-init if PS1 doesn't contain our + # marks. This ensures the terminal sees prompt markers even if another + # plugin (like zinit or oh-my-posh) regenerated PS1 after our precmd ran. + # We use 133;P instead of 133;A to avoid fresh-line behavior which would + # disrupt the display since the prompt has already been drawn. We also + # emit 133;B to mark the input area, which is needed for click-to-move. + (( $+functions[_ghostty_zle_line_init] )) || _ghostty_zle_line_init() { builtin true; } + functions[_ghostty_zle_line_init]=" + if [[ \$PS1 != *$'%{\\e]133;A'* ]]; then + builtin print -nu \$_ghostty_fd '\\e]133;P;k=i\\a\\e]133;B\\a' + fi + "${functions[_ghostty_zle_line_init]} + # Add Ghostty binary to PATH if the path feature is enabled if [[ "$GHOSTTY_SHELL_FEATURES" == *"path"* ]] && [[ -n "$GHOSTTY_BIN_DIR" ]]; then if [[ ":$PATH:" != *":$GHOSTTY_BIN_DIR:"* ]]; then From 3ee8ef4f650f550698ee1e8e81e591511e195bf4 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Wed, 4 Mar 2026 12:26:13 -0600 Subject: [PATCH 07/18] macos: suppress split-focus click mouse reports Amp-Thread-ID: https://ampcode.com/threads/T-019cb9fe-b11b-753f-99e7-8ecc52b73ec4 Co-authored-by: Amp --- .../Surface View/SurfaceView_AppKit.swift | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 581691ca9..060b7990b 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -221,6 +221,10 @@ extension Ghostty { // This is set to non-null during keyDown to accumulate insertText contents private var keyTextAccumulator: [String]? + // True when we've consumed a left mouse-down only to move focus and + // should suppress the matching mouse-up from being reported. + private var suppressNextLeftMouseUp: Bool = false + // A small delay that is introduced before a title change to avoid flickers private var titleChangeTimer: Timer? @@ -644,12 +648,18 @@ extension Ghostty { let location = convert(event.locationInWindow, from: nil) guard hitTest(location) == self else { return event } - // We only want to grab focus if either our app or window was - // not focused. - guard !NSApp.isActive || !window.isKeyWindow else { return event } + // If we're already the first responder then no focus transfer is + // happening, so the click should continue as normal. + guard window.firstResponder !== self else { return event } - // If we're already focused we do nothing - guard !focused else { return event } + // If our window/app is already focused, then this click is only + // being used to transfer split focus. Consume it so it does not + // get forwarded to the terminal as a mouse click. + if NSApp.isActive && window.isKeyWindow { + window.makeFirstResponder(self) + suppressNextLeftMouseUp = true + return nil + } // Make ourselves the first responder window.makeFirstResponder(self) @@ -854,6 +864,13 @@ extension Ghostty { } override func mouseUp(with event: NSEvent) { + // If this mouse-up corresponds to a focus-only click transfer, + // suppress it so we don't emit a release without a press. + if suppressNextLeftMouseUp { + suppressNextLeftMouseUp = false + return + } + // Always reset our pressure when the mouse goes up prevPressureStage = 0 From 0fa12f89151d5332233c97308bdda8925f6627b9 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Wed, 4 Mar 2026 12:27:19 -0600 Subject: [PATCH 08/18] gtk: suppress mouse reports on focus-transfer clicks Amp-Thread-ID: https://ampcode.com/threads/T-019cb9fe-b11b-753f-99e7-8ecc52b73ec4 Co-authored-by: Amp --- src/apprt/gtk/class/surface.zig | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 2f4a13a32..b76ddba7e 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -693,6 +693,10 @@ pub const Surface = extern struct { /// Whether primary paste (middle-click paste) is enabled. gtk_enable_primary_paste: bool = true, + /// True when a left mouse down was consumed purely for a focus change, + /// and the matching left mouse release should also be suppressed. + suppress_left_mouse_release: bool = false, + /// How much pending horizontal scroll do we have? pending_horizontal_scroll: f64 = 0.0, @@ -2733,13 +2737,21 @@ pub const Surface = extern struct { // If we don't have focus, grab it. const gl_area_widget = priv.gl_area.as(gtk.Widget); - if (gl_area_widget.hasFocus() == 0) { + const had_focus = gl_area_widget.hasFocus() != 0; + if (!had_focus) { _ = gl_area_widget.grabFocus(); } // Report the event const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); + // If this click is only transitioning split focus, suppress it so + // it doesn't get forwarded to the terminal as a mouse event. + if (!had_focus and button == .left) { + priv.suppress_left_mouse_release = true; + return; + } + if (button == .middle and !priv.gtk_enable_primary_paste) { return; } @@ -2795,6 +2807,11 @@ pub const Surface = extern struct { const gtk_mods = event.getModifierState(); const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); + if (button == .left and priv.suppress_left_mouse_release) { + priv.suppress_left_mouse_release = false; + return; + } + if (button == .middle and !priv.gtk_enable_primary_paste) { return; } From d1468086efcb7ae83498c4660ecfa5c3aebd8b6a Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Wed, 4 Mar 2026 12:27:48 -0600 Subject: [PATCH 09/18] macos: defer key-window focus sync to reduce churn Amp-Thread-ID: https://ampcode.com/threads/T-019cb9fe-b11b-753f-99e7-8ecc52b73ec4 Co-authored-by: Amp --- .../Features/Terminal/BaseTerminalController.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 51d6a263d..d4b0ac080 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1239,9 +1239,11 @@ class BaseTerminalController: NSWindowController, } } - // Becoming/losing key means we have to notify our surface(s) that we have focus - // so things like cursors blink, pty events are sent, etc. - self.syncFocusToSurfaceTree() + // Becoming key can race with responder updates when activating a window. + // Sync on the next runloop so split focus has settled first. + DispatchQueue.main.async { + self.syncFocusToSurfaceTree() + } } func windowDidResignKey(_ notification: Notification) { From 6961c2265e3b760dda9146aa285f11eee1e16abe Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 17 Feb 2026 20:19:33 -0600 Subject: [PATCH 10/18] gtk: `+new-window` now respects `--working-directory` and `-e` Fixes: #8862 Fixes: #10716 This adds the machinery to pass configuration settings received over DBus down to the GObject Surface so that that configuration information can be used to override some settings from the current "live" config when creating a new window. Currently it's only possible to override `--working-directory` and `--command`. `-e` on the `ghostty +new-window` CLI works as well. Adding more overridable settings is possible, but being able to fully override any possible setting would better be served with a major revamp of how Ghostty handles configs, which I is way out of scope at the moment. --- src/Surface.zig | 35 +++++++-- src/apprt/gtk/Surface.zig | 5 ++ src/apprt/gtk/class/application.zig | 85 +++++++++++++++++---- src/apprt/gtk/class/config_overrides.zig | 94 +++++++++++++++++++++++ src/apprt/gtk/class/split_tree.zig | 5 +- src/apprt/gtk/class/surface.zig | 35 ++++++++- src/apprt/gtk/class/tab.zig | 24 ++++-- src/apprt/gtk/class/window.zig | 18 ++--- src/apprt/gtk/ipc/new_window.zig | 10 +-- src/cli/new_window.zig | 71 ++++++++++++++---- src/config.zig | 1 + src/config/Config.zig | 38 +++++++--- src/config/ConfigOverrides.zig | 95 ++++++++++++++++++++++++ src/config/c_get.zig | 20 +---- src/config/key.zig | 6 +- src/lib/main.zig | 1 + src/lib/string.zig | 15 ++++ 17 files changed, 471 insertions(+), 87 deletions(-) create mode 100644 src/apprt/gtk/class/config_overrides.zig create mode 100644 src/config/ConfigOverrides.zig create mode 100644 src/lib/string.zig diff --git a/src/Surface.zig b/src/Surface.zig index e71af3939..9f32b087f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -30,6 +30,7 @@ const font = @import("font/main.zig"); const Command = @import("Command.zig"); const terminal = @import("terminal/main.zig"); const configpkg = @import("config.zig"); +const ConfigOverrides = configpkg.ConfigOverrides; const Duration = configpkg.Config.Duration; const input = @import("input.zig"); const App = @import("App.zig"); @@ -607,10 +608,27 @@ pub fn init( }; // The command we're going to execute - const command: ?configpkg.Command = if (app.first) - config.@"initial-command" orelse config.command - else - config.command; + const command: ?configpkg.Command = command: { + if (self.getConfigOverrides()) |config_overrides| { + if (config_overrides.isSet(.command)) + break :command config_overrides.get(.command); + } + if (app.first) { + if (config.@"initial-command") |command| { + break :command command; + } + } + break :command config.command; + }; + + // The working directory to execute the command in. + const working_directory: ?[]const u8 = wd: { + if (self.getConfigOverrides()) |config_overrides| { + if (config_overrides.isSet(.@"working-directory")) + break :wd config_overrides.get(.@"working-directory"); + } + break :wd config.@"working-directory"; + }; // Start our IO implementation // This separate block ({}) is important because our errdefers must @@ -635,7 +653,7 @@ pub fn init( .shell_integration = config.@"shell-integration", .shell_integration_features = config.@"shell-integration-features", .cursor_blink = config.@"cursor-style-blink", - .working_directory = config.@"working-directory", + .working_directory = working_directory, .resources_dir = global_state.resources_dir.host(), .term = config.term, .rt_pre_exec_info = .init(config), @@ -1789,6 +1807,13 @@ pub fn updateConfig( ); } +fn getConfigOverrides(self: *Surface) ?*const ConfigOverrides { + if (@hasDecl(apprt.runtime.Surface, "getConfigOverrides")) { + return self.rt_surface.getConfigOverrides(); + } + return null; +} + const InitialSizeError = error{ ContentScaleUnavailable, AppActionFailed, diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 918e77146..f7a563d14 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2,6 +2,7 @@ const Self = @This(); const std = @import("std"); const apprt = @import("../../apprt.zig"); +const configpkg = @import("../../config.zig"); const CoreSurface = @import("../../Surface.zig"); const ApprtApp = @import("App.zig"); const Application = @import("class/application.zig").Application; @@ -101,3 +102,7 @@ pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap { pub fn redrawInspector(self: *Self) void { self.surface.redrawInspector(); } + +pub fn getConfigOverrides(self: *Self) ?*const configpkg.ConfigOverrides { + return self.gobj().getConfigOverrides(); +} diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 00560fd13..8b5fa7094 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -32,6 +32,7 @@ const ApprtApp = @import("../App.zig"); const Common = @import("../class.zig").Common; const WeakRef = @import("../weak_ref.zig").WeakRef; const Config = @import("config.zig").Config; +const ConfigOverrides = @import("config_overrides.zig").ConfigOverrides; const Surface = @import("surface.zig").Surface; const SplitTree = @import("split_tree.zig").SplitTree; const Window = @import("window.zig").Window; @@ -709,6 +710,7 @@ pub const Application = extern struct { .app => null, .surface => |v| v, }, + null, ), .open_config => return Action.openConfig(self), @@ -1669,17 +1671,29 @@ pub const Application = extern struct { ) callconv(.c) void { log.debug("received new window action", .{}); - parameter: { + const config_overrides: ?*ConfigOverrides = config_overrides: { // were we given a parameter? - const parameter = parameter_ orelse break :parameter; + const parameter = parameter_ orelse break :config_overrides null; + + const alloc = Application.default().allocator(); + const config_overrides = ConfigOverrides.new(alloc) catch |err| { + log.warn("unable to create new config overrides: {t}", .{err}); + break :config_overrides null; + }; + errdefer config_overrides.unref(); + + const co = config_overrides.get(); const as_variant_type = glib.VariantType.new("as"); defer as_variant_type.free(); // ensure that the supplied parameter is an array of strings if (glib.Variant.isOfType(parameter, as_variant_type) == 0) { - log.warn("parameter is of type {s}", .{parameter.getTypeString()}); - break :parameter; + log.warn("parameter is of type '{s}', not '{s}'", .{ + parameter.getTypeString(), + as_variant_type.peekString()[0..as_variant_type.getStringLength()], + }); + break :config_overrides null; } const s_variant_type = glib.VariantType.new("s"); @@ -1688,7 +1702,16 @@ pub const Application = extern struct { var it: glib.VariantIter = undefined; _ = it.init(parameter); - while (it.nextValue()) |value| { + var args: std.ArrayList([:0]const u8) = .empty; + defer { + for (args.items) |arg| alloc.free(arg); + args.deinit(alloc); + } + + var e_seen: bool = false; + var i: usize = 0; + + while (it.nextValue()) |value| : (i += 1) { defer value.unref(); // just to be sure @@ -1698,13 +1721,45 @@ pub const Application = extern struct { const buf = value.getString(&len); const str = buf[0..len]; - log.debug("new-window command argument: {s}", .{str}); - } - } + if (e_seen) { + const cpy = alloc.dupeZ(u8, str) catch |err| { + log.warn("unable to duplicate argument {d} {s}: {t}", .{ i, str, err }); + break :config_overrides null; + }; + errdefer alloc.free(cpy); + args.append(alloc, cpy) catch |err| { + log.warn("unable to append argument {d} {s}: {t}", .{ i, str, err }); + break :config_overrides null; + }; + continue; + } - _ = self.core().mailbox.push(.{ - .new_window = .{}, - }, .{ .forever = {} }); + if (std.mem.eql(u8, str, "-e")) { + e_seen = true; + continue; + } + + co.parseCLI(str) catch |err| { + log.warn("unable to parse argument {d} {s}: {t}", .{ i, str, err }); + continue; + }; + + log.debug("new-window argument: {d} {s}", .{ i, str }); + } + + if (args.items.len > 0) { + co.set(.command, .{ .direct = args.items }) catch |err| { + log.warn("unable to set command on config overrides: {t}", .{err}); + break :config_overrides null; + }; + } + + break :config_overrides config_overrides; + }; + + defer if (config_overrides) |v| v.unref(); + + Action.newWindow(self, null, config_overrides) catch {}; } pub fn actionOpenConfig( @@ -2151,6 +2206,7 @@ const Action = struct { pub fn newWindow( self: *Application, parent: ?*CoreSurface, + config_overrides: ?*ConfigOverrides, ) !void { // Note that we've requested a window at least once. This is used // to trigger quit on no windows. Note I'm not sure if this is REALLY @@ -2160,13 +2216,14 @@ const Action = struct { self.private().requested_window = true; const win = Window.new(self); - initAndShowWindow(self, win, parent); + initAndShowWindow(self, win, parent, config_overrides); } fn initAndShowWindow( self: *Application, win: *Window, parent: ?*CoreSurface, + config_overrides: ?*ConfigOverrides, ) void { // Setup a binding so that whenever our config changes so does the // window. There's never a time when the window config should be out @@ -2180,7 +2237,7 @@ const Action = struct { ); // Create a new tab with window context (first tab in new window) - win.newTabForWindow(parent); + win.newTabForWindow(parent, config_overrides); // Estimate the initial window size before presenting so the window // manager can position it correctly. @@ -2506,7 +2563,7 @@ const Action = struct { .@"quick-terminal" = true, }); assert(win.isQuickTerminal()); - initAndShowWindow(self, win, null); + initAndShowWindow(self, win, null, null); return true; } diff --git a/src/apprt/gtk/class/config_overrides.zig b/src/apprt/gtk/class/config_overrides.zig new file mode 100644 index 000000000..6ca6ea77e --- /dev/null +++ b/src/apprt/gtk/class/config_overrides.zig @@ -0,0 +1,94 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const gobject = @import("gobject"); + +const configpkg = @import("../../../config.zig"); +const Config = configpkg.Config; + +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_config_overrides); + +/// Wrapper for a ConfigOverrides object that keeps track of which settings have +/// been changed. +pub const ConfigOverrides = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = gobject.Object; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyConfigOverrides", + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct {}; + + const Private = struct { + config_overrides: configpkg.ConfigOverrides, + + pub var offset: c_int = 0; + }; + + pub fn new(alloc: Allocator) Allocator.Error!*ConfigOverrides { + const self = gobject.ext.newInstance(Self, .{}); + errdefer self.unref(); + + const priv: *Private = self.private(); + try priv.config_overrides.init(alloc); + + return self; + } + + pub fn get(self: *ConfigOverrides) *configpkg.ConfigOverrides { + const priv: *Private = self.private(); + return &priv.config_overrides; + } + + fn finalize(self: *ConfigOverrides) callconv(.c) void { + const priv: *Private = self.private(); + priv.config_overrides.deinit(); + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + }; +}; + +test "GhosttyConfigOverrides" { + const testing = std.testing; + const alloc = testing.allocator; + + const config_overrides: *ConfigOverrides = try .new(alloc); + defer config_overrides.unref(); + + const co = config_overrides.get(); + + try testing.expect(co.isSet(.@"font-size") == false); + try co.set(.@"font-size", 24.0); + try testing.expect(co.isSet(.@"font-size") == true); + try testing.expectApproxEqAbs(24.0, co.get(.@"font-size"), 0.01); + + try testing.expect(co.isSet(.@"working-directory") == false); + try co.parseCLI("--working-directory=/home/ghostty"); + try testing.expect(co.isSet(.@"working-directory") == true); + try testing.expectEqualStrings("/home/ghostty", co.get(.@"working-directory").?); +} diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 0ff7e6044..0d47db958 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -16,6 +16,7 @@ const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; const Surface = @import("surface.zig").Surface; const SurfaceScrolledWindow = @import("surface_scrolled_window.zig").SurfaceScrolledWindow; +const ConfigOverrides = @import("config_overrides.zig").ConfigOverrides; const log = std.log.scoped(.gtk_ghostty_split_tree); @@ -208,11 +209,12 @@ pub const SplitTree = extern struct { self: *Self, direction: Surface.Tree.Split.Direction, parent_: ?*Surface, + config_overrides: ?*ConfigOverrides, ) Allocator.Error!void { const alloc = Application.default().allocator(); // Create our new surface. - const surface: *Surface = .new(); + const surface: *Surface = .new(config_overrides); defer surface.unref(); _ = surface.refSink(); @@ -638,6 +640,7 @@ pub const SplitTree = extern struct { self.newSplit( direction, self.getActiveSurface(), + null, ) catch |err| { log.warn("new split failed error={}", .{err}); }; diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index b76ddba7e..423b5b7e8 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -10,6 +10,7 @@ const gtk = @import("gtk"); const apprt = @import("../../../apprt.zig"); const build_config = @import("../../../build_config.zig"); +const configpkg = @import("../../../config.zig"); const datastruct = @import("../../../datastruct/main.zig"); const font = @import("../../../font/main.zig"); const input = @import("../../../input.zig"); @@ -25,6 +26,7 @@ const ApprtSurface = @import("../Surface.zig"); const Common = @import("../class.zig").Common; const Application = @import("application.zig").Application; const Config = @import("config.zig").Config; +const ConfigOverrides = @import("config_overrides.zig").ConfigOverrides; const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay; const SearchOverlay = @import("search_overlay.zig").SearchOverlay; const KeyStateOverlay = @import("key_state_overlay.zig").KeyStateOverlay; @@ -89,6 +91,18 @@ pub const Surface = extern struct { ); }; + pub const @"config-overrides" = struct { + pub const name = "config-overrides"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*ConfigOverrides, + .{ + .accessor = C.privateObjFieldAccessor("config_overrides"), + }, + ); + }; + pub const @"child-exited" = struct { pub const name = "child-exited"; const impl = gobject.ext.defineProperty( @@ -551,6 +565,9 @@ pub const Surface = extern struct { /// The configuration that this surface is using. config: ?*Config = null, + /// Any configuration overrides that might apply to this surface. + config_overrides: ?*ConfigOverrides = null, + /// The default size for a window that embeds this surface. default_size: ?*Size = null, @@ -707,8 +724,10 @@ pub const Surface = extern struct { pub var offset: c_int = 0; }; - pub fn new() *Self { - return gobject.ext.newInstance(Self, .{}); + pub fn new(config_overrides: ?*ConfigOverrides) *Self { + return gobject.ext.newInstance(Self, .{ + .@"config-overrides" = config_overrides, + }); } pub fn core(self: *Self) ?*CoreSurface { @@ -1798,6 +1817,11 @@ pub const Surface = extern struct { priv.config = null; } + if (priv.config_overrides) |v| { + v.unref(); + priv.config_overrides = null; + } + if (priv.vadj_signal_group) |group| { group.setTarget(null); group.as(gobject.Object).unref(); @@ -2176,6 +2200,12 @@ pub const Surface = extern struct { self.private().search_overlay.setSearchSelected(selected); } + pub fn getConfigOverrides(self: *Self) ?*const configpkg.ConfigOverrides { + const priv: *Private = self.private(); + const config_overrides = priv.config_overrides orelse return null; + return config_overrides.get(); + } + fn propConfig( self: *Self, _: *gobject.ParamSpec, @@ -3578,6 +3608,7 @@ pub const Surface = extern struct { gobject.ext.registerProperties(class, &.{ properties.@"bell-ringing".impl, properties.config.impl, + properties.@"config-overrides".impl, properties.@"child-exited".impl, properties.@"default-size".impl, properties.@"error".impl, diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index 24caa4990..d45513e08 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -11,6 +11,7 @@ const ext = @import("../ext.zig"); const gresource = @import("../build/gresource.zig"); const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; +const ConfigOverrides = @import("config_overrides.zig").ConfigOverrides; const Application = @import("application.zig").Application; const SplitTree = @import("split_tree.zig").SplitTree; const Surface = @import("surface.zig").Surface; @@ -186,22 +187,24 @@ pub const Tab = extern struct { } } - fn init(self: *Self, _: *Class) callconv(.c) void { - gtk.Widget.initTemplate(self.as(gtk.Widget)); + pub fn new(config: ?*Config, config_overrides: ?*ConfigOverrides) *Self { + const tab = gobject.ext.newInstance(Tab, .{}); - // Init our actions - self.initActionMap(); + const priv: *Private = tab.private(); + + if (config) |c| priv.config = c.ref(); // If our configuration is null then we get the configuration // from the application. - const priv = self.private(); if (priv.config == null) { const app = Application.default(); priv.config = app.getConfig(); } + tab.as(gobject.Object).notifyByPspec(properties.config.impl.param_spec); + // Create our initial surface in the split tree. - priv.split_tree.newSplit(.right, null) catch |err| switch (err) { + priv.split_tree.newSplit(.right, null, config_overrides) catch |err| switch (err) { error.OutOfMemory => { // TODO: We should make our "no surfaces" state more aesthetically // pleasing and show something like an "Oops, something went wrong" @@ -209,6 +212,15 @@ pub const Tab = extern struct { @panic("oom"); }, }; + + return tab; + } + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Init our actions + self.initActionMap(); } fn initActionMap(self: *Self) void { diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index a79945991..a71fea111 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -21,6 +21,7 @@ const gresource = @import("../build/gresource.zig"); const winprotopkg = @import("../winproto.zig"); const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; +const ConfigOverrides = @import("config_overrides.zig").ConfigOverrides; const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; const SplitTree = @import("split_tree.zig").SplitTree; @@ -368,21 +369,20 @@ pub const Window = extern struct { /// at the position dictated by the `window-new-tab-position` config. /// The new tab will be selected. pub fn newTab(self: *Self, parent_: ?*CoreSurface) void { - _ = self.newTabPage(parent_, .tab); + _ = self.newTabPage(parent_, .tab, null); } - pub fn newTabForWindow(self: *Self, parent_: ?*CoreSurface) void { - _ = self.newTabPage(parent_, .window); + pub fn newTabForWindow(self: *Self, parent_: ?*CoreSurface, config_overrides: ?*ConfigOverrides) void { + _ = self.newTabPage(parent_, .window, config_overrides); } - fn newTabPage(self: *Self, parent_: ?*CoreSurface, context: apprt.surface.NewSurfaceContext) *adw.TabPage { - const priv = self.private(); + fn newTabPage(self: *Self, parent_: ?*CoreSurface, context: apprt.surface.NewSurfaceContext, config_overrides: ?*ConfigOverrides) *adw.TabPage { + const priv: *Private = self.private(); const tab_view = priv.tab_view; // Create our new tab object - const tab = gobject.ext.newInstance(Tab, .{ - .config = priv.config, - }); + const tab = Tab.new(priv.config, config_overrides); + if (parent_) |p| { // For a new window's first tab, inherit the parent's initial size hints. if (context == .window) { @@ -1253,7 +1253,7 @@ pub const Window = extern struct { _: *adw.TabOverview, self: *Self, ) callconv(.c) *adw.TabPage { - return self.newTabPage(if (self.getActiveSurface()) |v| v.core() else null, .tab); + return self.newTabPage(if (self.getActiveSurface()) |v| v.core() else null, .tab, null); } fn tabOverviewOpen( diff --git a/src/apprt/gtk/ipc/new_window.zig b/src/apprt/gtk/ipc/new_window.zig index 19c46e3aa..02fed3229 100644 --- a/src/apprt/gtk/ipc/new_window.zig +++ b/src/apprt/gtk/ipc/new_window.zig @@ -18,7 +18,7 @@ const DBus = @import("DBus.zig"); // `ghostty +new-window -e echo hello` would be equivalent to the following command (on a release build): // // ``` -// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window-command '[<@as ["echo" "hello"]>]' [] +// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window-command '[<@as ["-e" "echo" "hello"]>]' [] // ``` pub fn newWindow(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Action.NewWindow) (Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors)!bool { var dbus = try DBus.init( @@ -32,10 +32,10 @@ pub fn newWindow(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Ac defer dbus.deinit(alloc); if (value.arguments) |arguments| { - // If `-e` was specified on the command line, the first - // parameter is an array of strings that contain the arguments - // that came after `-e`, which will be interpreted as a command - // to run. + // If any arguments were specified on the command line, the first + // parameter is an array of strings that contain the arguments. They + // will be sent to the main Ghostty instance and interpreted as CLI + // arguments. const as_variant_type = glib.VariantType.new("as"); defer as_variant_type.free(); diff --git a/src/cli/new_window.zig b/src/cli/new_window.zig index f3f4740d1..98a93beb2 100644 --- a/src/cli/new_window.zig +++ b/src/cli/new_window.zig @@ -5,6 +5,8 @@ const Action = @import("../cli.zig").ghostty.Action; const apprt = @import("../apprt.zig"); const args = @import("args.zig"); const diagnostics = @import("diagnostics.zig"); +const lib = @import("../lib/main.zig"); +const homedir = @import("../os/homedir.zig"); pub const Options = struct { /// This is set by the CLI parser for deinit. @@ -13,28 +15,38 @@ pub const Options = struct { /// If set, open up a new window in a custom instance of Ghostty. class: ?[:0]const u8 = null, - /// If `-e` is found in the arguments, this will contain all of the - /// arguments to pass to Ghostty as the command. + /// All of the arguments after `+new-window`. They will be sent to Ghosttty + /// for processing. _arguments: ?[][:0]const u8 = null, /// Enable arg parsing diagnostics so that we don't get an error if /// there is a "normal" config setting on the cli. _diagnostics: diagnostics.DiagnosticList = .{}, - /// Manual parse hook, used to deal with `-e` - pub fn parseManuallyHook(self: *Options, alloc: Allocator, arg: []const u8, iter: anytype) Allocator.Error!bool { - // If it's not `-e` continue with the standard argument parsning. - if (!std.mem.eql(u8, arg, "-e")) return true; - + /// Manual parse hook, collect all of the arguments after `+new-window`. + pub fn parseManuallyHook(self: *Options, alloc: Allocator, arg: []const u8, iter: anytype) (error{InvalidValue} || homedir.ExpandError || std.fs.Dir.RealPathAllocError || Allocator.Error)!bool { var arguments: std.ArrayList([:0]const u8) = .empty; errdefer { for (arguments.items) |argument| alloc.free(argument); arguments.deinit(alloc); } - // Otherwise gather up the rest of the arguments to use as the command. + var e_seen: bool = std.mem.eql(u8, arg, "-e"); + // Include the argument that triggered the manual parse hook. + if (try self.checkArg(alloc, arg)) |a| try arguments.append(alloc, a); + + // Gather up the rest of the arguments to use as the command. while (iter.next()) |param| { - try arguments.append(alloc, try alloc.dupeZ(u8, param)); + if (e_seen) { + try arguments.append(alloc, try alloc.dupeZ(u8, param)); + continue; + } + if (std.mem.eql(u8, param, "-e")) { + e_seen = true; + try arguments.append(alloc, try alloc.dupeZ(u8, param)); + continue; + } + if (try self.checkArg(alloc, param)) |a| try arguments.append(alloc, a); } self._arguments = try arguments.toOwnedSlice(alloc); @@ -42,6 +54,27 @@ pub const Options = struct { return false; } + fn checkArg(self: *Options, alloc: Allocator, arg: []const u8) (error{InvalidValue} || homedir.ExpandError || std.fs.Dir.RealPathAllocError || Allocator.Error)!?[:0]const u8 { + if (lib.cutPrefix(u8, arg, "--class=")) |rest| { + self.class = try alloc.dupeZ(u8, std.mem.trim(u8, rest, &std.ascii.whitespace)); + return null; + } + + if (lib.cutPrefix(u8, arg, "--working-directory=")) |rest| { + const stripped = std.mem.trim(u8, rest, &std.ascii.whitespace); + if (std.mem.eql(u8, stripped, "home")) return error.InvalidValue; + if (std.mem.eql(u8, stripped, "inherit")) return error.InvalidValue; + const cwd: std.fs.Dir = std.fs.cwd(); + var expandhome_buf: [std.fs.max_path_bytes]u8 = undefined; + const expanded = try homedir.expandHome(stripped, &expandhome_buf); + var realpath_buf: [std.fs.max_path_bytes]u8 = undefined; + const realpath = try cwd.realpath(expanded, &realpath_buf); + return try std.fmt.allocPrintSentinel(alloc, "--working-directory={s}", .{realpath}, 0); + } + + return try alloc.dupeZ(u8, arg); + } + pub fn deinit(self: *Options) void { if (self._arena) |arena| arena.deinit(); self.* = undefined; @@ -63,11 +96,21 @@ pub const Options = struct { /// and contact a running Ghostty instance that was configured with the same /// `class` as was given on the command line. /// -/// If the `-e` flag is included on the command line, any arguments that follow -/// will be sent to the running Ghostty instance and used as the command to run -/// in the new window rather than the default. If `-e` is not specified, Ghostty -/// will use the default command (either specified with `command` in your config -/// or your default shell as configured on your system). +/// All of the arguments after the `+new-window` argument (except for the +/// `--class` flag) will be sent to the remote Ghostty instance and will be +/// parsed as command line flags. These flags will override certain settings +/// when creating the first surface in the new window. Currently, only +/// `--working-directory` and `--command` are supported. `-e` will also work +/// as an alias for `--command`, except that if `-e` is found on the command +/// line all following arguments will become part of the command and no more +/// arguments will be parsed for configuration settings. +/// +/// If `--working-directory` is found on the command line and is a relative +/// path (i.e. doesn't start with `/`) it will be resolved to an absolute path +/// relative to the current working directory that the `ghostty +new-window` +/// command is run from. The special values `home` and `inherit` that are +/// available as "normal" CLI flags or configuration entries do not work when +/// used from the `+new-window` CLI action. /// /// GTK uses an application ID to identify instances of applications. If Ghostty /// is compiled with release optimizations, the default application ID will be diff --git a/src/config.zig b/src/config.zig index 0bf61a47f..d559ab171 100644 --- a/src/config.zig +++ b/src/config.zig @@ -3,6 +3,7 @@ const builtin = @import("builtin"); const file_load = @import("config/file_load.zig"); const formatter = @import("config/formatter.zig"); pub const Config = @import("config/Config.zig"); +pub const ConfigOverrides = @import("config/ConfigOverrides.zig"); pub const conditional = @import("config/conditional.zig"); pub const io = @import("config/io.zig"); pub const string = @import("config/string.zig"); diff --git a/src/config/Config.zig b/src/config/Config.zig index 7020a2b57..ce891561c 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -29,7 +29,8 @@ const file_load = @import("file_load.zig"); const formatterpkg = @import("formatter.zig"); const themepkg = @import("theme.zig"); const url = @import("url.zig"); -const Key = @import("key.zig").Key; +pub const Key = @import("key.zig").Key; +pub const Type = @import("key.zig").Type; const MetricModifier = fontpkg.Metrics.Modifier; const help_strings = @import("help_strings"); pub const Command = @import("command.zig").Command; @@ -95,6 +96,23 @@ pub const compatibility = std.StaticStringMap( .{ "macos-dock-drop-behavior", compatMacOSDockDropBehavior }, }); +pub fn get(self: *const Config, comptime key: Key) Type(key) { + return @field(self, @tagName(key)); +} + +pub fn set(self: *Config, comptime key: Key, value: Type(key)) Allocator.Error!void { + const alloc = self.arenaAlloc(); + @field(self.*, @tagName(key)) = try cloneValue(alloc, Type(key), value); +} + +test "set/get" { + var config: Config = try .default(std.testing.allocator); + defer config.deinit(); + try std.testing.expect(config.get(.language) == null); + try config.set(.language, "en_US.UTF-8"); + try std.testing.expectEqualStrings("en_US.UTF-8", config.get(.language).?); +} + /// Set Ghostty's graphical user interface language to a language other than the /// system default language. For example: /// @@ -4757,8 +4775,8 @@ fn compatCursorInvertFgBg( // Realistically, these fields were mutually exclusive so anyone // relying on that behavior should just upgrade to the new // cursor-color/cursor-text fields. - const set = cli.args.parseBool(value_ orelse "t") catch return false; - if (set) { + const isset = cli.args.parseBool(value_ orelse "t") catch return false; + if (isset) { self.@"cursor-color" = .@"cell-foreground"; self.@"cursor-text" = .@"cell-background"; } @@ -4775,8 +4793,8 @@ fn compatSelectionInvertFgBg( _ = alloc; assert(std.mem.eql(u8, key, "selection-invert-fg-bg")); - const set = cli.args.parseBool(value_ orelse "t") catch return false; - if (set) { + const isset = cli.args.parseBool(value_ orelse "t") catch return false; + if (isset) { self.@"selection-foreground" = .@"cell-background"; self.@"selection-background" = .@"cell-foreground"; } @@ -4793,8 +4811,8 @@ fn compatBoldIsBright( _ = alloc; assert(std.mem.eql(u8, key, "bold-is-bright")); - const set = cli.args.parseBool(value_ orelse "t") catch return false; - if (set) { + const isset = cli.args.parseBool(value_ orelse "t") catch return false; + if (isset) { self.@"bold-color" = .bright; } @@ -7261,9 +7279,9 @@ pub const Keybinds = struct { defer arena.deinit(); const alloc = arena.allocator(); - var set: Keybinds = .{}; - try set.parseCLI(alloc, "shift+a=copy_to_clipboard"); - try set.parseCLI(alloc, "shift+a=csi:hello"); + var keyset: Keybinds = .{}; + try keyset.parseCLI(alloc, "shift+a=copy_to_clipboard"); + try keyset.parseCLI(alloc, "shift+a=csi:hello"); } test "formatConfig single" { diff --git a/src/config/ConfigOverrides.zig b/src/config/ConfigOverrides.zig new file mode 100644 index 000000000..325359aef --- /dev/null +++ b/src/config/ConfigOverrides.zig @@ -0,0 +1,95 @@ +//! Wrapper for a Config object that keeps track of which settings have been +//! changed. Settings will be marked as set even if they are set to whatever the +//! default value is for that setting. This allows overrides of a setting from +//! a non-default value to the default value. To remove an override it must be +//! explicitly removed from the set that keeps track of what config entries have +//! been changed. + +const ConfigOverrides = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const configpkg = @import("../config.zig"); +const args = @import("../cli/args.zig"); +const Config = configpkg.Config; +const Key = Config.Key; +const Type = Config.Type; + +const log = std.log.scoped(.config_overrides); + +/// Used to keep track of which settings have been overridden. +isset: std.EnumSet(configpkg.Config.Key), + +/// Storage for the overriding settings. +config: configpkg.Config, + +/// Create a new object that has no config settings overridden. +pub fn init(self: *ConfigOverrides, alloc: Allocator) Allocator.Error!void { + self.* = .{ + .isset = .initEmpty(), + .config = try .default(alloc), + }; +} + +/// Has a config setting been overridden? +pub fn isSet(self: *const ConfigOverrides, comptime key: Key) bool { + return self.isset.contains(key); +} + +/// Set a configuration entry and mark it as having been overridden. +pub fn set(self: *ConfigOverrides, comptime key: Key, value: Type(key)) Allocator.Error!void { + try self.config.set(key, value); + self.isset.insert(key); +} + +/// Mark a configuration entry as having not been overridden. +pub fn unset(self: *ConfigOverrides, comptime key: Key) void { + self.isset.remove(key); +} + +/// Get the value of a configuration entry. +pub fn get(self: *const ConfigOverrides, comptime key: Key) Type(key) { + return self.config.get(key); +} + +/// Parse a string that contains a CLI flag. +pub fn parseCLI(self: *ConfigOverrides, str: []const u8) !void { + const k: []const u8, const v: ?[]const u8 = kv: { + if (!std.mem.startsWith(u8, str, "--")) return; + if (std.mem.indexOfScalarPos(u8, str, 2, '=')) |pos| { + break :kv .{ + std.mem.trim(u8, str[2..pos], &std.ascii.whitespace), + std.mem.trim(u8, str[pos + 1 ..], &std.ascii.whitespace), + }; + } + break :kv .{ std.mem.trim(u8, str[2..], &std.ascii.whitespace), null }; + }; + + const key = std.meta.stringToEnum(Key, k) orelse return; + try args.parseIntoField(Config, self.config.arenaAlloc(), &self.config, k, v); + self.isset.insert(key); +} + +pub fn deinit(self: *ConfigOverrides) callconv(.c) void { + self.config.deinit(); +} + +test "ConfigOverrides" { + const testing = std.testing; + const alloc = testing.allocator; + + var config_overrides: ConfigOverrides = undefined; + try config_overrides.init(alloc); + defer config_overrides.deinit(); + + try testing.expect(config_overrides.isSet(.@"font-size") == false); + try config_overrides.set(.@"font-size", 24.0); + try testing.expect(config_overrides.isSet(.@"font-size") == true); + try testing.expectApproxEqAbs(24.0, config_overrides.get(.@"font-size"), 0.01); + + try testing.expect(config_overrides.isSet(.@"working-directory") == false); + try config_overrides.parseCLI("--working-directory=/home/ghostty"); + try testing.expect(config_overrides.isSet(.@"working-directory") == true); + try testing.expectEqualStrings("/home/ghostty", config_overrides.get(.@"working-directory").?); +} diff --git a/src/config/c_get.zig b/src/config/c_get.zig index dcfdc6716..a3a45d24c 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -4,7 +4,7 @@ const key = @import("key.zig"); const Config = @import("Config.zig"); const Color = Config.Color; const Key = key.Key; -const Value = key.Value; +const Type = key.Type; /// Get a value from the config by key into the given pointer. This is /// specifically for C-compatible APIs. If you're using Zig, just access @@ -17,7 +17,7 @@ pub fn get(config: *const Config, k: Key, ptr_raw: *anyopaque) bool { @setEvalBranchQuota(10_000); switch (k) { inline else => |tag| { - const value = fieldByKey(config, tag); + const value = config.get(tag); return getValue(ptr_raw, value); }, } @@ -102,22 +102,6 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool { return true; } -/// Get a value from the config by key. -fn fieldByKey(self: *const Config, comptime k: Key) Value(k) { - const field = comptime field: { - const fields = std.meta.fields(Config); - for (fields) |field| { - if (@field(Key, field.name) == k) { - break :field field; - } - } - - unreachable; - }; - - return @field(self, field.name); -} - test "c_get: u8" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/config/key.zig b/src/config/key.zig index 5709e2074..6c083673a 100644 --- a/src/config/key.zig +++ b/src/config/key.zig @@ -32,7 +32,7 @@ pub const Key = key: { }; /// Returns the value type for a key -pub fn Value(comptime key: Key) type { +pub fn Type(comptime key: Key) type { const field = comptime field: { @setEvalBranchQuota(100_000); @@ -52,6 +52,6 @@ pub fn Value(comptime key: Key) type { test "Value" { const testing = std.testing; - try testing.expectEqual(Config.RepeatableString, Value(.@"font-family")); - try testing.expectEqual(?bool, Value(.@"cursor-style-blink")); + try testing.expectEqual(Config.RepeatableString, Type(.@"font-family")); + try testing.expectEqual(?bool, Type(.@"cursor-style-blink")); } diff --git a/src/lib/main.zig b/src/lib/main.zig index e4a67454e..89c6f6c47 100644 --- a/src/lib/main.zig +++ b/src/lib/main.zig @@ -10,6 +10,7 @@ pub const String = types.String; pub const Struct = @import("struct.zig").Struct; pub const Target = @import("target.zig").Target; pub const TaggedUnion = unionpkg.TaggedUnion; +pub const cutPrefix = @import("string.zig").cutPrefix; test { std.testing.refAllDecls(@This()); diff --git a/src/lib/string.zig b/src/lib/string.zig new file mode 100644 index 000000000..795823c25 --- /dev/null +++ b/src/lib/string.zig @@ -0,0 +1,15 @@ +const std = @import("std"); + +// This is a copy of std.mem.cutPrefix from 0.16. Once Ghostty has been ported +// to 0.16 this can be removed. + +/// If slice starts with prefix, returns the rest of slice starting at +/// prefix.len. +pub fn cutPrefix(comptime T: type, slice: []const T, prefix: []const T) ?[]const T { + return if (std.mem.startsWith(T, slice, prefix)) slice[prefix.len..] else null; +} + +test cutPrefix { + try std.testing.expectEqualStrings("foo", cutPrefix(u8, "--example=foo", "--example=").?); + try std.testing.expectEqual(null, cutPrefix(u8, "--example=foo", "-example=")); +} From ec0f9ef4163ee8262a31c779a9062c21b7486d5c Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 19 Feb 2026 15:50:42 -0600 Subject: [PATCH 11/18] gtk: `+new-window` now respects `--title` --- src/apprt/gtk/class/application.zig | 2 +- src/apprt/gtk/class/window.zig | 115 ++++++++++++++++++++++++---- src/apprt/gtk/ui/1.5/window.blp | 2 +- 3 files changed, 102 insertions(+), 17 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 8b5fa7094..35c572338 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2215,7 +2215,7 @@ const Action = struct { // was a delay in the event loop before we created a Window. self.private().requested_window = true; - const win = Window.new(self); + const win = Window.new(self, config_overrides); initAndShowWindow(self, win, parent, config_overrides); } diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index a71fea111..d56857f28 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -80,6 +80,18 @@ pub const Window = extern struct { ); }; + pub const @"config-overrides" = struct { + pub const name = "config-overrides"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*ConfigOverrides, + .{ + .accessor = C.privateObjFieldAccessor("config_overrides"), + }, + ); + }; + pub const debug = struct { pub const name = "debug"; const impl = gobject.ext.defineProperty( @@ -232,6 +244,9 @@ pub const Window = extern struct { /// The configuration that this surface is using. config: ?*Config = null, + /// Configuration overrides. + config_overrides: ?*ConfigOverrides = null, + /// State and logic for windowing protocol for a window. winproto: winprotopkg.Window, @@ -267,10 +282,30 @@ pub const Window = extern struct { pub var offset: c_int = 0; }; - pub fn new(app: *Application) *Self { - return gobject.ext.newInstance(Self, .{ + pub fn new(app: *Application, config_overrides_: ?*ConfigOverrides) *Self { + const win = gobject.ext.newInstance(Self, .{ .application = app, }); + + const priv: *Private = win.private(); + + if (config_overrides_) |v| { + priv.config_overrides = v.ref(); + const config_overrides = v.get(); + // If the config overrides have a title set, we set that immediately + // so that any applications inspecting the window states see an + // immediate title set when the window appears, rather than waiting + // possibly a few event loop ticks for it to sync from the surface. + if (config_overrides.isSet(.title)) { + const title_ = config_overrides.get(.title); + if (title_) |title| { + win.as(gtk.Window).setTitle(title); + } + } + win.as(gobject.Object).notifyByPspec(properties.@"config-overrides".impl.param_spec); + } + + return win; } fn init(self: *Self, _: *Class) callconv(.c) void { @@ -279,10 +314,14 @@ pub const Window = extern struct { // If our configuration is null then we get the configuration // from the application. const priv = self.private(); - if (priv.config == null) { + + const config = config: { + if (priv.config) |config| break :config config.get(); const app = Application.default(); - priv.config = app.getConfig(); - } + const config = app.getConfig(); + priv.config = config; + break :config config.get(); + }; // We initialize our windowing protocol to none because we can't // actually initialize this until we get realized. @@ -306,17 +345,25 @@ pub const Window = extern struct { self.initActionMap(); // Start states based on config. - if (priv.config) |config_obj| { - const config = config_obj.get(); - if (config.maximize) self.as(gtk.Window).maximize(); - if (config.fullscreen != .false) self.as(gtk.Window).fullscreen(); + if (config.maximize) self.as(gtk.Window).maximize(); + if (config.fullscreen != .false) self.as(gtk.Window).fullscreen(); - // If we have an explicit title set, we set that immediately - // so that any applications inspecting the window states see - // an immediate title set when the window appears, rather than - // waiting possibly a few event loop ticks for it to sync from - // the surface. - if (config.title) |v| self.as(gtk.Window).setTitle(v); + // If we have an explicit title set, we set that immediately + // so that any applications inspecting the window states see + // an immediate title set when the window appears, rather than + // waiting possibly a few event loop ticks for it to sync from + // the surface. + const title_ = title: { + if (priv.config_overrides) |co| { + const config_overrides = co.get(); + if (config_overrides.isSet(.title)) { + break :title config_overrides.get(.title); + } + } + break :title config.title; + }; + if (title_) |title| { + self.as(gtk.Window).setTitle(title); } // We always sync our appearance at the end because loading our @@ -1151,6 +1198,37 @@ pub const Window = extern struct { }); } + fn closureTitle( + _: *Self, + config_: ?*Config, + config_overrides_: ?*ConfigOverrides, + title_: ?[*:0]const u8, + ) callconv(.c) ?[*:0]const u8 { + config: { + if (config_overrides_) |v| { + const config_overrides = v.get(); + if (config_overrides.isSet(.title)) { + if (config_overrides.get(.title)) |title| { + return glib.ext.dupeZ(u8, title); + } + // The `title` has explicitly been set to `null`, skip + // checking the normal config for it's title setting. + break :config; + } + } + if (config_) |v| { + const config = v.get(); + if (config.title) |title| { + return glib.ext.dupeZ(u8, title); + } + } + } + if (title_) |title| { + return glib.ext.dupeZ(u8, std.mem.span(title)); + } + return null; + } + fn closureSubtitle( _: *Self, config_: ?*Config, @@ -1179,6 +1257,11 @@ pub const Window = extern struct { priv.config = null; } + if (priv.config_overrides) |v| { + v.unref(); + priv.config_overrides = null; + } + priv.tab_bindings.setSource(null); gtk.Widget.disposeTemplate( @@ -2019,6 +2102,7 @@ pub const Window = extern struct { gobject.ext.registerProperties(class, &.{ properties.@"active-surface".impl, properties.config.impl, + properties.@"config-overrides".impl, properties.debug.impl, properties.@"headerbar-visible".impl, properties.@"quick-terminal".impl, @@ -2057,6 +2141,7 @@ pub const Window = extern struct { class.bindTemplateCallback("notify_quick_terminal", &propQuickTerminal); class.bindTemplateCallback("notify_scale_factor", &propScaleFactor); class.bindTemplateCallback("titlebar_style_is_tabs", &closureTitlebarStyleIsTab); + class.bindTemplateCallback("computed_title", &closureTitle); class.bindTemplateCallback("computed_subtitle", &closureSubtitle); // Virtual methods diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index b66a93093..514826b23 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -40,7 +40,7 @@ template $GhosttyWindow: Adw.ApplicationWindow { visible: bind template.headerbar-visible; title-widget: Adw.WindowTitle { - title: bind template.title; + title: bind $computed_title(template.config, template.config-overrides, template.title) as ; // Blueprint auto-formatter won't let me split this into multiple // lines. Let me explain myself. All parameters to a closure are used // as notifications to recompute the value of the closure. All From f2ce7c348edbd635dd74cae9b3b330825768ba76 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 19 Feb 2026 15:56:02 -0600 Subject: [PATCH 12/18] gtk: `+new-window` document `--title` --- src/cli/new_window.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cli/new_window.zig b/src/cli/new_window.zig index 98a93beb2..845a509a2 100644 --- a/src/cli/new_window.zig +++ b/src/cli/new_window.zig @@ -100,10 +100,10 @@ pub const Options = struct { /// `--class` flag) will be sent to the remote Ghostty instance and will be /// parsed as command line flags. These flags will override certain settings /// when creating the first surface in the new window. Currently, only -/// `--working-directory` and `--command` are supported. `-e` will also work -/// as an alias for `--command`, except that if `-e` is found on the command -/// line all following arguments will become part of the command and no more -/// arguments will be parsed for configuration settings. +/// `--working-directory`, `--command`, and `--title` are supported. `-e` will +/// also work as an alias for `--command`, except that if `-e` is found on the +/// command line all following arguments will become part of the command and no +/// more arguments will be parsed for configuration settings. /// /// If `--working-directory` is found on the command line and is a relative /// path (i.e. doesn't start with `/`) it will be resolved to an absolute path From 002a6cc76526240b19cee9792a79de05077bb09a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 4 Mar 2026 00:00:03 -0600 Subject: [PATCH 13/18] gtk: use simpler method for passing overrides around As discussed in Discord, this commit drops the `ConfigOverride` object in favor of a simpler method of passing the overrides around. Completely avoiding changes to the core wasn't possible but it's very minimal now. --- src/Surface.zig | 24 ++-- src/apprt/embedded.zig | 1 + src/apprt/gtk/Surface.zig | 4 - src/apprt/gtk/class/application.zig | 144 ++++++++++++++++------- src/apprt/gtk/class/config_overrides.zig | 94 --------------- src/apprt/gtk/class/split_tree.zig | 18 ++- src/apprt/gtk/class/surface.zig | 69 +++++------ src/apprt/gtk/class/tab.zig | 16 ++- src/apprt/gtk/class/window.zig | 138 +++++++++------------- src/apprt/gtk/ui/1.5/window.blp | 2 +- src/cli/new_window.zig | 58 +++++---- src/config.zig | 1 - src/config/Config.zig | 32 ++--- src/config/ConfigOverrides.zig | 95 --------------- src/config/c_get.zig | 20 +++- src/config/command.zig | 10 ++ src/config/key.zig | 6 +- 17 files changed, 300 insertions(+), 432 deletions(-) delete mode 100644 src/apprt/gtk/class/config_overrides.zig delete mode 100644 src/config/ConfigOverrides.zig diff --git a/src/Surface.zig b/src/Surface.zig index 9f32b087f..c13a29c4e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -30,7 +30,6 @@ const font = @import("font/main.zig"); const Command = @import("Command.zig"); const terminal = @import("terminal/main.zig"); const configpkg = @import("config.zig"); -const ConfigOverrides = configpkg.ConfigOverrides; const Duration = configpkg.Config.Duration; const input = @import("input.zig"); const App = @import("App.zig"); @@ -464,6 +463,12 @@ pub fn init( app: *App, rt_app: *apprt.runtime.App, rt_surface: *apprt.runtime.Surface, + overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, ) !void { // Apply our conditional state. If we fail to apply the conditional state // then we log and attempt to move forward with the old config. @@ -609,9 +614,8 @@ pub fn init( // The command we're going to execute const command: ?configpkg.Command = command: { - if (self.getConfigOverrides()) |config_overrides| { - if (config_overrides.isSet(.command)) - break :command config_overrides.get(.command); + if (overrides.command) |command| { + break :command command; } if (app.first) { if (config.@"initial-command") |command| { @@ -623,9 +627,8 @@ pub fn init( // The working directory to execute the command in. const working_directory: ?[]const u8 = wd: { - if (self.getConfigOverrides()) |config_overrides| { - if (config_overrides.isSet(.@"working-directory")) - break :wd config_overrides.get(.@"working-directory"); + if (overrides.working_directory) |working_directory| { + break :wd working_directory; } break :wd config.@"working-directory"; }; @@ -1807,13 +1810,6 @@ pub fn updateConfig( ); } -fn getConfigOverrides(self: *Surface) ?*const ConfigOverrides { - if (@hasDecl(apprt.runtime.Surface, "getConfigOverrides")) { - return self.rt_surface.getConfigOverrides(); - } - return null; -} - const InitialSizeError = error{ ContentScaleUnavailable, AppActionFailed, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 54d5472c6..810334aff 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -572,6 +572,7 @@ pub const Surface = struct { app.core_app, app, self, + .none, ); errdefer self.core_surface.deinit(); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index f7a563d14..715973671 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -102,7 +102,3 @@ pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap { pub fn redrawInspector(self: *Self) void { self.surface.redrawInspector(); } - -pub fn getConfigOverrides(self: *Self) ?*const configpkg.ConfigOverrides { - return self.gobj().getConfigOverrides(); -} diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 35c572338..55b392f76 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -22,6 +22,7 @@ const xev = @import("../../../global.zig").xev; const Binding = @import("../../../input.zig").Binding; const CoreConfig = configpkg.Config; const CoreSurface = @import("../../../Surface.zig"); +const lib = @import("../../../lib/main.zig"); const ext = @import("../ext.zig"); const key = @import("../key.zig"); @@ -32,7 +33,6 @@ const ApprtApp = @import("../App.zig"); const Common = @import("../class.zig").Common; const WeakRef = @import("../weak_ref.zig").WeakRef; const Config = @import("config.zig").Config; -const ConfigOverrides = @import("config_overrides.zig").ConfigOverrides; const Surface = @import("surface.zig").Surface; const SplitTree = @import("split_tree.zig").SplitTree; const Window = @import("window.zig").Window; @@ -710,7 +710,7 @@ pub const Application = extern struct { .app => null, .surface => |v| v, }, - null, + .none, ), .open_config => return Action.openConfig(self), @@ -1671,18 +1671,26 @@ pub const Application = extern struct { ) callconv(.c) void { log.debug("received new window action", .{}); - const config_overrides: ?*ConfigOverrides = config_overrides: { + const alloc = Application.default().allocator(); + + var working_directory: ?[:0]const u8 = null; + defer if (working_directory) |wd| alloc.free(wd); + + var title: ?[:0]const u8 = null; + defer if (title) |t| alloc.free(t); + + var command: ?configpkg.Command = null; + defer if (command) |c| c.deinit(alloc); + + var args: std.ArrayList([:0]const u8) = .empty; + defer { + for (args.items) |arg| alloc.free(arg); + args.deinit(alloc); + } + + overrides: { // were we given a parameter? - const parameter = parameter_ orelse break :config_overrides null; - - const alloc = Application.default().allocator(); - const config_overrides = ConfigOverrides.new(alloc) catch |err| { - log.warn("unable to create new config overrides: {t}", .{err}); - break :config_overrides null; - }; - errdefer config_overrides.unref(); - - const co = config_overrides.get(); + const parameter = parameter_ orelse break :overrides; const as_variant_type = glib.VariantType.new("as"); defer as_variant_type.free(); @@ -1693,7 +1701,7 @@ pub const Application = extern struct { parameter.getTypeString(), as_variant_type.peekString()[0..as_variant_type.getStringLength()], }); - break :config_overrides null; + break :overrides; } const s_variant_type = glib.VariantType.new("s"); @@ -1702,12 +1710,6 @@ pub const Application = extern struct { var it: glib.VariantIter = undefined; _ = it.init(parameter); - var args: std.ArrayList([:0]const u8) = .empty; - defer { - for (args.items) |arg| alloc.free(arg); - args.deinit(alloc); - } - var e_seen: bool = false; var i: usize = 0; @@ -1721,15 +1723,17 @@ pub const Application = extern struct { const buf = value.getString(&len); const str = buf[0..len]; + log.debug("new-window argument: {d} {s}", .{ i, str }); + if (e_seen) { const cpy = alloc.dupeZ(u8, str) catch |err| { log.warn("unable to duplicate argument {d} {s}: {t}", .{ i, str, err }); - break :config_overrides null; + break :overrides; }; errdefer alloc.free(cpy); args.append(alloc, cpy) catch |err| { log.warn("unable to append argument {d} {s}: {t}", .{ i, str, err }); - break :config_overrides null; + break :overrides; }; continue; } @@ -1739,27 +1743,52 @@ pub const Application = extern struct { continue; } - co.parseCLI(str) catch |err| { - log.warn("unable to parse argument {d} {s}: {t}", .{ i, str, err }); + if (lib.cutPrefix(u8, str, "--command=")) |v| { + if (command) |c| c.deinit(alloc); + var cmd: configpkg.Command = undefined; + cmd.parseCLI(alloc, v) catch |err| { + log.warn("unable to parse command: {t}", .{err}); + continue; + }; + command = cmd; continue; - }; - - log.debug("new-window argument: {d} {s}", .{ i, str }); + } + if (lib.cutPrefix(u8, str, "--working-directory=")) |v| { + if (working_directory) |wd| alloc.free(wd); + working_directory = alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| wd: { + log.warn("unable to duplicate working directory: {t}", .{err}); + break :wd null; + }; + continue; + } + if (lib.cutPrefix(u8, str, "--title=")) |v| { + if (title) |t| alloc.free(t); + title = alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| t: { + log.warn("unable to duplicate title: {t}", .{err}); + break :t null; + }; + continue; + } } + } - if (args.items.len > 0) { - co.set(.command, .{ .direct = args.items }) catch |err| { - log.warn("unable to set command on config overrides: {t}", .{err}); - break :config_overrides null; - }; - } + if (args.items.len > 0) direct: { + if (command) |c| c.deinit(alloc); + command = .{ + .direct = args.toOwnedSlice(alloc) catch |err| { + log.warn("unable to convert list of arguments to owned slice: {t}", .{err}); + break :direct; + }, + }; + } - break :config_overrides config_overrides; + Action.newWindow(self, null, .{ + .command = command, + .working_directory = working_directory, + .title = title, + }) catch |err| { + log.warn("unable to create new window: {t}", .{err}); }; - - defer if (config_overrides) |v| v.unref(); - - Action.newWindow(self, null, config_overrides) catch {}; } pub fn actionOpenConfig( @@ -2206,7 +2235,13 @@ const Action = struct { pub fn newWindow( self: *Application, parent: ?*CoreSurface, - config_overrides: ?*ConfigOverrides, + overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, ) !void { // Note that we've requested a window at least once. This is used // to trigger quit on no windows. Note I'm not sure if this is REALLY @@ -2215,15 +2250,32 @@ const Action = struct { // was a delay in the event loop before we created a Window. self.private().requested_window = true; - const win = Window.new(self, config_overrides); - initAndShowWindow(self, win, parent, config_overrides); + const win = Window.new(self, .{ + .title = overrides.title, + }); + initAndShowWindow( + self, + win, + parent, + .{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }, + ); } fn initAndShowWindow( self: *Application, win: *Window, parent: ?*CoreSurface, - config_overrides: ?*ConfigOverrides, + overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, ) void { // Setup a binding so that whenever our config changes so does the // window. There's never a time when the window config should be out @@ -2237,7 +2289,11 @@ const Action = struct { ); // Create a new tab with window context (first tab in new window) - win.newTabForWindow(parent, config_overrides); + win.newTabForWindow(parent, .{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }); // Estimate the initial window size before presenting so the window // manager can position it correctly. @@ -2563,7 +2619,7 @@ const Action = struct { .@"quick-terminal" = true, }); assert(win.isQuickTerminal()); - initAndShowWindow(self, win, null, null); + initAndShowWindow(self, win, null, .none); return true; } diff --git a/src/apprt/gtk/class/config_overrides.zig b/src/apprt/gtk/class/config_overrides.zig deleted file mode 100644 index 6ca6ea77e..000000000 --- a/src/apprt/gtk/class/config_overrides.zig +++ /dev/null @@ -1,94 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const gobject = @import("gobject"); - -const configpkg = @import("../../../config.zig"); -const Config = configpkg.Config; - -const Common = @import("../class.zig").Common; - -const log = std.log.scoped(.gtk_ghostty_config_overrides); - -/// Wrapper for a ConfigOverrides object that keeps track of which settings have -/// been changed. -pub const ConfigOverrides = extern struct { - const Self = @This(); - parent_instance: Parent, - pub const Parent = gobject.Object; - pub const getGObjectType = gobject.ext.defineClass(Self, .{ - .name = "GhosttyConfigOverrides", - .classInit = &Class.init, - .parent_class = &Class.parent, - .private = .{ .Type = Private, .offset = &Private.offset }, - }); - - pub const properties = struct {}; - - const Private = struct { - config_overrides: configpkg.ConfigOverrides, - - pub var offset: c_int = 0; - }; - - pub fn new(alloc: Allocator) Allocator.Error!*ConfigOverrides { - const self = gobject.ext.newInstance(Self, .{}); - errdefer self.unref(); - - const priv: *Private = self.private(); - try priv.config_overrides.init(alloc); - - return self; - } - - pub fn get(self: *ConfigOverrides) *configpkg.ConfigOverrides { - const priv: *Private = self.private(); - return &priv.config_overrides; - } - - fn finalize(self: *ConfigOverrides) callconv(.c) void { - const priv: *Private = self.private(); - priv.config_overrides.deinit(); - - gobject.Object.virtual_methods.finalize.call( - Class.parent, - self.as(Parent), - ); - } - - const C = Common(Self, Private); - pub const as = C.as; - pub const ref = C.ref; - pub const unref = C.unref; - const private = C.private; - - pub const Class = extern struct { - parent_class: Parent.Class, - var parent: *Parent.Class = undefined; - pub const Instance = Self; - - fn init(class: *Class) callconv(.c) void { - gobject.Object.virtual_methods.finalize.implement(class, &finalize); - } - }; -}; - -test "GhosttyConfigOverrides" { - const testing = std.testing; - const alloc = testing.allocator; - - const config_overrides: *ConfigOverrides = try .new(alloc); - defer config_overrides.unref(); - - const co = config_overrides.get(); - - try testing.expect(co.isSet(.@"font-size") == false); - try co.set(.@"font-size", 24.0); - try testing.expect(co.isSet(.@"font-size") == true); - try testing.expectApproxEqAbs(24.0, co.get(.@"font-size"), 0.01); - - try testing.expect(co.isSet(.@"working-directory") == false); - try co.parseCLI("--working-directory=/home/ghostty"); - try testing.expect(co.isSet(.@"working-directory") == true); - try testing.expectEqualStrings("/home/ghostty", co.get(.@"working-directory").?); -} diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 0d47db958..067546c44 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -7,6 +7,7 @@ const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); +const configpkg = @import("../../../config.zig"); const apprt = @import("../../../apprt.zig"); const ext = @import("../ext.zig"); const gresource = @import("../build/gresource.zig"); @@ -16,7 +17,6 @@ const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; const Surface = @import("surface.zig").Surface; const SurfaceScrolledWindow = @import("surface_scrolled_window.zig").SurfaceScrolledWindow; -const ConfigOverrides = @import("config_overrides.zig").ConfigOverrides; const log = std.log.scoped(.gtk_ghostty_split_tree); @@ -209,12 +209,22 @@ pub const SplitTree = extern struct { self: *Self, direction: Surface.Tree.Split.Direction, parent_: ?*Surface, - config_overrides: ?*ConfigOverrides, + overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, ) Allocator.Error!void { const alloc = Application.default().allocator(); // Create our new surface. - const surface: *Surface = .new(config_overrides); + const surface: *Surface = .new(.{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }); defer surface.unref(); _ = surface.refSink(); @@ -640,7 +650,7 @@ pub const SplitTree = extern struct { self.newSplit( direction, self.getActiveSurface(), - null, + .none, ) catch |err| { log.warn("new split failed error={}", .{err}); }; diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 423b5b7e8..64fdbe842 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -26,7 +26,6 @@ const ApprtSurface = @import("../Surface.zig"); const Common = @import("../class.zig").Common; const Application = @import("application.zig").Application; const Config = @import("config.zig").Config; -const ConfigOverrides = @import("config_overrides.zig").ConfigOverrides; const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay; const SearchOverlay = @import("search_overlay.zig").SearchOverlay; const KeyStateOverlay = @import("key_state_overlay.zig").KeyStateOverlay; @@ -91,18 +90,6 @@ pub const Surface = extern struct { ); }; - pub const @"config-overrides" = struct { - pub const name = "config-overrides"; - const impl = gobject.ext.defineProperty( - name, - Self, - ?*ConfigOverrides, - .{ - .accessor = C.privateObjFieldAccessor("config_overrides"), - }, - ); - }; - pub const @"child-exited" = struct { pub const name = "child-exited"; const impl = gobject.ext.defineProperty( @@ -565,9 +552,6 @@ pub const Surface = extern struct { /// The configuration that this surface is using. config: ?*Config = null, - /// Any configuration overrides that might apply to this surface. - config_overrides: ?*ConfigOverrides = null, - /// The default size for a window that embeds this surface. default_size: ?*Size = null, @@ -721,13 +705,33 @@ pub const Surface = extern struct { /// stops scrolling. pending_horizontal_scroll_reset: ?c_uint = null, + overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + } = .none, + pub var offset: c_int = 0; }; - pub fn new(config_overrides: ?*ConfigOverrides) *Self { - return gobject.ext.newInstance(Self, .{ - .@"config-overrides" = config_overrides, + pub fn new(overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }) *Self { + const self = gobject.ext.newInstance(Self, .{ + .@"title-override" = overrides.title, }); + const alloc = Application.default().allocator(); + const priv: *Private = self.private(); + priv.overrides = .{ + .command = if (overrides.command) |c| c.clone(alloc) catch null else null, + .working_directory = if (overrides.working_directory) |wd| alloc.dupeZ(u8, wd) catch null else null, + }; + return self; } pub fn core(self: *Self) ?*CoreSurface { @@ -1817,11 +1821,6 @@ pub const Surface = extern struct { priv.config = null; } - if (priv.config_overrides) |v| { - v.unref(); - priv.config_overrides = null; - } - if (priv.vadj_signal_group) |group| { group.setTarget(null); group.as(gobject.Object).unref(); @@ -1877,6 +1876,7 @@ pub const Surface = extern struct { } fn finalize(self: *Self) callconv(.c) void { + const alloc = Application.default().allocator(); const priv = self.private(); if (priv.core_surface) |v| { // Remove ourselves from the list of known surfaces in the app. @@ -1890,7 +1890,6 @@ pub const Surface = extern struct { // Deinit the surface v.deinit(); - const alloc = Application.default().allocator(); alloc.destroy(v); priv.core_surface = null; @@ -1923,9 +1922,16 @@ pub const Surface = extern struct { glib.free(@ptrCast(@constCast(v))); priv.title_override = null; } + if (priv.overrides.command) |c| { + c.deinit(alloc); + priv.overrides.command = null; + } + if (priv.overrides.working_directory) |wd| { + alloc.free(wd); + priv.overrides.working_directory = null; + } // Clean up key sequence and key table state - const alloc = Application.default().allocator(); for (priv.key_sequence.items) |s| alloc.free(s); priv.key_sequence.deinit(alloc); for (priv.key_tables.items) |s| alloc.free(s); @@ -2200,12 +2206,6 @@ pub const Surface = extern struct { self.private().search_overlay.setSearchSelected(selected); } - pub fn getConfigOverrides(self: *Self) ?*const configpkg.ConfigOverrides { - const priv: *Private = self.private(); - const config_overrides = priv.config_overrides orelse return null; - return config_overrides.get(); - } - fn propConfig( self: *Self, _: *gobject.ParamSpec, @@ -3387,6 +3387,10 @@ pub const Surface = extern struct { app.core(), app.rt(), &priv.rt_surface, + .{ + .command = priv.overrides.command, + .working_directory = priv.overrides.working_directory, + }, ) catch |err| { log.warn("failed to initialize surface err={}", .{err}); return error.SurfaceError; @@ -3608,7 +3612,6 @@ pub const Surface = extern struct { gobject.ext.registerProperties(class, &.{ properties.@"bell-ringing".impl, properties.config.impl, - properties.@"config-overrides".impl, properties.@"child-exited".impl, properties.@"default-size".impl, properties.@"error".impl, diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index d45513e08..0c60c8ccc 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -5,13 +5,13 @@ const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); +const configpkg = @import("../../../config.zig"); const apprt = @import("../../../apprt.zig"); const CoreSurface = @import("../../../Surface.zig"); const ext = @import("../ext.zig"); const gresource = @import("../build/gresource.zig"); const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; -const ConfigOverrides = @import("config_overrides.zig").ConfigOverrides; const Application = @import("application.zig").Application; const SplitTree = @import("split_tree.zig").SplitTree; const Surface = @import("surface.zig").Surface; @@ -187,7 +187,13 @@ pub const Tab = extern struct { } } - pub fn new(config: ?*Config, config_overrides: ?*ConfigOverrides) *Self { + pub fn new(config: ?*Config, overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }) *Self { const tab = gobject.ext.newInstance(Tab, .{}); const priv: *Private = tab.private(); @@ -204,7 +210,11 @@ pub const Tab = extern struct { tab.as(gobject.Object).notifyByPspec(properties.config.impl.param_spec); // Create our initial surface in the split tree. - priv.split_tree.newSplit(.right, null, config_overrides) catch |err| switch (err) { + priv.split_tree.newSplit(.right, null, .{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }) catch |err| switch (err) { error.OutOfMemory => { // TODO: We should make our "no surfaces" state more aesthetically // pleasing and show something like an "Oops, something went wrong" diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index d56857f28..c01cad618 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -21,7 +21,6 @@ const gresource = @import("../build/gresource.zig"); const winprotopkg = @import("../winproto.zig"); const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; -const ConfigOverrides = @import("config_overrides.zig").ConfigOverrides; const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; const SplitTree = @import("split_tree.zig").SplitTree; @@ -80,18 +79,6 @@ pub const Window = extern struct { ); }; - pub const @"config-overrides" = struct { - pub const name = "config-overrides"; - const impl = gobject.ext.defineProperty( - name, - Self, - ?*ConfigOverrides, - .{ - .accessor = C.privateObjFieldAccessor("config_overrides"), - }, - ); - }; - pub const debug = struct { pub const name = "debug"; const impl = gobject.ext.defineProperty( @@ -244,9 +231,6 @@ pub const Window = extern struct { /// The configuration that this surface is using. config: ?*Config = null, - /// Configuration overrides. - config_overrides: ?*ConfigOverrides = null, - /// State and logic for windowing protocol for a window. winproto: winprotopkg.Window, @@ -282,27 +266,24 @@ pub const Window = extern struct { pub var offset: c_int = 0; }; - pub fn new(app: *Application, config_overrides_: ?*ConfigOverrides) *Self { + pub fn new( + app: *Application, + overrides: struct { + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, + ) *Self { const win = gobject.ext.newInstance(Self, .{ .application = app, }); - const priv: *Private = win.private(); - - if (config_overrides_) |v| { - priv.config_overrides = v.ref(); - const config_overrides = v.get(); - // If the config overrides have a title set, we set that immediately + if (overrides.title) |title| { + // If the overrides have a title set, we set that immediately // so that any applications inspecting the window states see an // immediate title set when the window appears, rather than waiting // possibly a few event loop ticks for it to sync from the surface. - if (config_overrides.isSet(.title)) { - const title_ = config_overrides.get(.title); - if (title_) |title| { - win.as(gtk.Window).setTitle(title); - } - } - win.as(gobject.Object).notifyByPspec(properties.@"config-overrides".impl.param_spec); + win.as(gtk.Window).setTitle(title); } return win; @@ -353,16 +334,7 @@ pub const Window = extern struct { // an immediate title set when the window appears, rather than // waiting possibly a few event loop ticks for it to sync from // the surface. - const title_ = title: { - if (priv.config_overrides) |co| { - const config_overrides = co.get(); - if (config_overrides.isSet(.title)) { - break :title config_overrides.get(.title); - } - } - break :title config.title; - }; - if (title_) |title| { + if (config.title) |title| { self.as(gtk.Window).setTitle(title); } @@ -416,19 +388,55 @@ pub const Window = extern struct { /// at the position dictated by the `window-new-tab-position` config. /// The new tab will be selected. pub fn newTab(self: *Self, parent_: ?*CoreSurface) void { - _ = self.newTabPage(parent_, .tab, null); + _ = self.newTabPage(parent_, .tab, .none); } - pub fn newTabForWindow(self: *Self, parent_: ?*CoreSurface, config_overrides: ?*ConfigOverrides) void { - _ = self.newTabPage(parent_, .window, config_overrides); + pub fn newTabForWindow( + self: *Self, + parent_: ?*CoreSurface, + overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, + ) void { + _ = self.newTabPage( + parent_, + .window, + .{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }, + ); } - fn newTabPage(self: *Self, parent_: ?*CoreSurface, context: apprt.surface.NewSurfaceContext, config_overrides: ?*ConfigOverrides) *adw.TabPage { + fn newTabPage( + self: *Self, + parent_: ?*CoreSurface, + context: apprt.surface.NewSurfaceContext, + overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, + ) *adw.TabPage { const priv: *Private = self.private(); const tab_view = priv.tab_view; // Create our new tab object - const tab = Tab.new(priv.config, config_overrides); + const tab = Tab.new( + priv.config, + .{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }, + ); if (parent_) |p| { // For a new window's first tab, inherit the parent's initial size hints. @@ -1198,37 +1206,6 @@ pub const Window = extern struct { }); } - fn closureTitle( - _: *Self, - config_: ?*Config, - config_overrides_: ?*ConfigOverrides, - title_: ?[*:0]const u8, - ) callconv(.c) ?[*:0]const u8 { - config: { - if (config_overrides_) |v| { - const config_overrides = v.get(); - if (config_overrides.isSet(.title)) { - if (config_overrides.get(.title)) |title| { - return glib.ext.dupeZ(u8, title); - } - // The `title` has explicitly been set to `null`, skip - // checking the normal config for it's title setting. - break :config; - } - } - if (config_) |v| { - const config = v.get(); - if (config.title) |title| { - return glib.ext.dupeZ(u8, title); - } - } - } - if (title_) |title| { - return glib.ext.dupeZ(u8, std.mem.span(title)); - } - return null; - } - fn closureSubtitle( _: *Self, config_: ?*Config, @@ -1257,11 +1234,6 @@ pub const Window = extern struct { priv.config = null; } - if (priv.config_overrides) |v| { - v.unref(); - priv.config_overrides = null; - } - priv.tab_bindings.setSource(null); gtk.Widget.disposeTemplate( @@ -1336,7 +1308,7 @@ pub const Window = extern struct { _: *adw.TabOverview, self: *Self, ) callconv(.c) *adw.TabPage { - return self.newTabPage(if (self.getActiveSurface()) |v| v.core() else null, .tab, null); + return self.newTabPage(if (self.getActiveSurface()) |v| v.core() else null, .tab, .none); } fn tabOverviewOpen( @@ -2102,7 +2074,6 @@ pub const Window = extern struct { gobject.ext.registerProperties(class, &.{ properties.@"active-surface".impl, properties.config.impl, - properties.@"config-overrides".impl, properties.debug.impl, properties.@"headerbar-visible".impl, properties.@"quick-terminal".impl, @@ -2141,7 +2112,6 @@ pub const Window = extern struct { class.bindTemplateCallback("notify_quick_terminal", &propQuickTerminal); class.bindTemplateCallback("notify_scale_factor", &propScaleFactor); class.bindTemplateCallback("titlebar_style_is_tabs", &closureTitlebarStyleIsTab); - class.bindTemplateCallback("computed_title", &closureTitle); class.bindTemplateCallback("computed_subtitle", &closureSubtitle); // Virtual methods diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index 514826b23..b66a93093 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -40,7 +40,7 @@ template $GhosttyWindow: Adw.ApplicationWindow { visible: bind template.headerbar-visible; title-widget: Adw.WindowTitle { - title: bind $computed_title(template.config, template.config-overrides, template.title) as ; + title: bind template.title; // Blueprint auto-formatter won't let me split this into multiple // lines. Let me explain myself. All parameters to a closure are used // as notifications to recompute the value of the closure. All diff --git a/src/cli/new_window.zig b/src/cli/new_window.zig index 845a509a2..12acafadf 100644 --- a/src/cli/new_window.zig +++ b/src/cli/new_window.zig @@ -15,9 +15,12 @@ pub const Options = struct { /// If set, open up a new window in a custom instance of Ghostty. class: ?[:0]const u8 = null, + /// Did the user specify a `--working-directory` argument on the command line? + _working_directory_seen: bool = false, + /// All of the arguments after `+new-window`. They will be sent to Ghosttty /// for processing. - _arguments: ?[][:0]const u8 = null, + _arguments: std.ArrayList([:0]const u8) = .empty, /// Enable arg parsing diagnostics so that we don't get an error if /// there is a "normal" config setting on the cli. @@ -25,32 +28,25 @@ pub const Options = struct { /// Manual parse hook, collect all of the arguments after `+new-window`. pub fn parseManuallyHook(self: *Options, alloc: Allocator, arg: []const u8, iter: anytype) (error{InvalidValue} || homedir.ExpandError || std.fs.Dir.RealPathAllocError || Allocator.Error)!bool { - var arguments: std.ArrayList([:0]const u8) = .empty; - errdefer { - for (arguments.items) |argument| alloc.free(argument); - arguments.deinit(alloc); - } - var e_seen: bool = std.mem.eql(u8, arg, "-e"); + // Include the argument that triggered the manual parse hook. - if (try self.checkArg(alloc, arg)) |a| try arguments.append(alloc, a); + if (try self.checkArg(alloc, arg)) |a| try self._arguments.append(alloc, a); // Gather up the rest of the arguments to use as the command. while (iter.next()) |param| { if (e_seen) { - try arguments.append(alloc, try alloc.dupeZ(u8, param)); + try self._arguments.append(alloc, try alloc.dupeZ(u8, param)); continue; } if (std.mem.eql(u8, param, "-e")) { e_seen = true; - try arguments.append(alloc, try alloc.dupeZ(u8, param)); + try self._arguments.append(alloc, try alloc.dupeZ(u8, param)); continue; } - if (try self.checkArg(alloc, param)) |a| try arguments.append(alloc, a); + if (try self.checkArg(alloc, param)) |a| try self._arguments.append(alloc, a); } - self._arguments = try arguments.toOwnedSlice(alloc); - return false; } @@ -62,13 +58,14 @@ pub const Options = struct { if (lib.cutPrefix(u8, arg, "--working-directory=")) |rest| { const stripped = std.mem.trim(u8, rest, &std.ascii.whitespace); - if (std.mem.eql(u8, stripped, "home")) return error.InvalidValue; - if (std.mem.eql(u8, stripped, "inherit")) return error.InvalidValue; + if (std.mem.eql(u8, stripped, "home")) return try alloc.dupeZ(u8, arg); + if (std.mem.eql(u8, stripped, "inherit")) return try alloc.dupeZ(u8, arg); const cwd: std.fs.Dir = std.fs.cwd(); var expandhome_buf: [std.fs.max_path_bytes]u8 = undefined; const expanded = try homedir.expandHome(stripped, &expandhome_buf); var realpath_buf: [std.fs.max_path_bytes]u8 = undefined; const realpath = try cwd.realpath(expanded, &realpath_buf); + self._working_directory_seen = true; return try std.fmt.allocPrintSentinel(alloc, "--working-directory={s}", .{realpath}, 0); } @@ -108,9 +105,11 @@ pub const Options = struct { /// If `--working-directory` is found on the command line and is a relative /// path (i.e. doesn't start with `/`) it will be resolved to an absolute path /// relative to the current working directory that the `ghostty +new-window` -/// command is run from. The special values `home` and `inherit` that are -/// available as "normal" CLI flags or configuration entries do not work when -/// used from the `+new-window` CLI action. +/// command is run from. `~/` prefixes will also be expanded to the user's home +/// directory. +/// +/// If `--working-directory` is _not_ found on the command line, the working +/// directory that `ghostty +new-window` is run from will be passed to Ghostty. /// /// GTK uses an application ID to identify instances of applications. If Ghostty /// is compiled with release optimizations, the default application ID will be @@ -135,8 +134,16 @@ pub const Options = struct { /// * `--class=`: If set, open up a new window in a custom instance of /// Ghostty. The class must be a valid GTK application ID. /// +/// * `--command`: The command to be executed in the first surface of the new window. +/// +/// * `--working-directory=`: The working directory to pass to Ghostty. +/// +/// * `--title`: A title that will override the title of the first surface in +/// the new window. The title override may be edited or removed later. +/// /// * `-e`: Any arguments after this will be interpreted as a command to -/// execute inside the new window instead of the default command. +/// execute inside the first surface of the new window instead of the +/// default command. /// /// Available since: 1.2.0 pub fn run(alloc: Allocator) !u8 { @@ -186,11 +193,12 @@ fn runArgs( if (exit) return 1; } - if (opts._arguments) |arguments| { - if (arguments.len == 0) { - try stderr.print("The -e flag was specified on the command line, but no other arguments were found.\n", .{}); - return 1; - } + if (!opts._working_directory_seen) { + const alloc = opts._arena.?.allocator(); + const cwd: std.fs.Dir = std.fs.cwd(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const wd = try cwd.realpath(".", &buf); + try opts._arguments.append(alloc, try std.fmt.allocPrintSentinel(alloc, "--working-directory={s}", .{wd}, 0)); } var arena = ArenaAllocator.init(alloc_gpa); @@ -202,7 +210,7 @@ fn runArgs( if (opts.class) |class| .{ .class = class } else .detect, .new_window, .{ - .arguments = opts._arguments, + .arguments = if (opts._arguments.items.len == 0) null else opts._arguments.items, }, ) catch |err| switch (err) { error.IPCFailed => { diff --git a/src/config.zig b/src/config.zig index d559ab171..0bf61a47f 100644 --- a/src/config.zig +++ b/src/config.zig @@ -3,7 +3,6 @@ const builtin = @import("builtin"); const file_load = @import("config/file_load.zig"); const formatter = @import("config/formatter.zig"); pub const Config = @import("config/Config.zig"); -pub const ConfigOverrides = @import("config/ConfigOverrides.zig"); pub const conditional = @import("config/conditional.zig"); pub const io = @import("config/io.zig"); pub const string = @import("config/string.zig"); diff --git a/src/config/Config.zig b/src/config/Config.zig index ce891561c..ca93c85d6 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -30,7 +30,6 @@ const formatterpkg = @import("formatter.zig"); const themepkg = @import("theme.zig"); const url = @import("url.zig"); pub const Key = @import("key.zig").Key; -pub const Type = @import("key.zig").Type; const MetricModifier = fontpkg.Metrics.Modifier; const help_strings = @import("help_strings"); pub const Command = @import("command.zig").Command; @@ -96,23 +95,6 @@ pub const compatibility = std.StaticStringMap( .{ "macos-dock-drop-behavior", compatMacOSDockDropBehavior }, }); -pub fn get(self: *const Config, comptime key: Key) Type(key) { - return @field(self, @tagName(key)); -} - -pub fn set(self: *Config, comptime key: Key, value: Type(key)) Allocator.Error!void { - const alloc = self.arenaAlloc(); - @field(self.*, @tagName(key)) = try cloneValue(alloc, Type(key), value); -} - -test "set/get" { - var config: Config = try .default(std.testing.allocator); - defer config.deinit(); - try std.testing.expect(config.get(.language) == null); - try config.set(.language, "en_US.UTF-8"); - try std.testing.expectEqualStrings("en_US.UTF-8", config.get(.language).?); -} - /// Set Ghostty's graphical user interface language to a language other than the /// system default language. For example: /// @@ -4775,8 +4757,8 @@ fn compatCursorInvertFgBg( // Realistically, these fields were mutually exclusive so anyone // relying on that behavior should just upgrade to the new // cursor-color/cursor-text fields. - const isset = cli.args.parseBool(value_ orelse "t") catch return false; - if (isset) { + const set = cli.args.parseBool(value_ orelse "t") catch return false; + if (set) { self.@"cursor-color" = .@"cell-foreground"; self.@"cursor-text" = .@"cell-background"; } @@ -4793,8 +4775,8 @@ fn compatSelectionInvertFgBg( _ = alloc; assert(std.mem.eql(u8, key, "selection-invert-fg-bg")); - const isset = cli.args.parseBool(value_ orelse "t") catch return false; - if (isset) { + const set = cli.args.parseBool(value_ orelse "t") catch return false; + if (set) { self.@"selection-foreground" = .@"cell-background"; self.@"selection-background" = .@"cell-foreground"; } @@ -7279,9 +7261,9 @@ pub const Keybinds = struct { defer arena.deinit(); const alloc = arena.allocator(); - var keyset: Keybinds = .{}; - try keyset.parseCLI(alloc, "shift+a=copy_to_clipboard"); - try keyset.parseCLI(alloc, "shift+a=csi:hello"); + var set: Keybinds = .{}; + try set.parseCLI(alloc, "shift+a=copy_to_clipboard"); + try set.parseCLI(alloc, "shift+a=csi:hello"); } test "formatConfig single" { diff --git a/src/config/ConfigOverrides.zig b/src/config/ConfigOverrides.zig deleted file mode 100644 index 325359aef..000000000 --- a/src/config/ConfigOverrides.zig +++ /dev/null @@ -1,95 +0,0 @@ -//! Wrapper for a Config object that keeps track of which settings have been -//! changed. Settings will be marked as set even if they are set to whatever the -//! default value is for that setting. This allows overrides of a setting from -//! a non-default value to the default value. To remove an override it must be -//! explicitly removed from the set that keeps track of what config entries have -//! been changed. - -const ConfigOverrides = @This(); - -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const configpkg = @import("../config.zig"); -const args = @import("../cli/args.zig"); -const Config = configpkg.Config; -const Key = Config.Key; -const Type = Config.Type; - -const log = std.log.scoped(.config_overrides); - -/// Used to keep track of which settings have been overridden. -isset: std.EnumSet(configpkg.Config.Key), - -/// Storage for the overriding settings. -config: configpkg.Config, - -/// Create a new object that has no config settings overridden. -pub fn init(self: *ConfigOverrides, alloc: Allocator) Allocator.Error!void { - self.* = .{ - .isset = .initEmpty(), - .config = try .default(alloc), - }; -} - -/// Has a config setting been overridden? -pub fn isSet(self: *const ConfigOverrides, comptime key: Key) bool { - return self.isset.contains(key); -} - -/// Set a configuration entry and mark it as having been overridden. -pub fn set(self: *ConfigOverrides, comptime key: Key, value: Type(key)) Allocator.Error!void { - try self.config.set(key, value); - self.isset.insert(key); -} - -/// Mark a configuration entry as having not been overridden. -pub fn unset(self: *ConfigOverrides, comptime key: Key) void { - self.isset.remove(key); -} - -/// Get the value of a configuration entry. -pub fn get(self: *const ConfigOverrides, comptime key: Key) Type(key) { - return self.config.get(key); -} - -/// Parse a string that contains a CLI flag. -pub fn parseCLI(self: *ConfigOverrides, str: []const u8) !void { - const k: []const u8, const v: ?[]const u8 = kv: { - if (!std.mem.startsWith(u8, str, "--")) return; - if (std.mem.indexOfScalarPos(u8, str, 2, '=')) |pos| { - break :kv .{ - std.mem.trim(u8, str[2..pos], &std.ascii.whitespace), - std.mem.trim(u8, str[pos + 1 ..], &std.ascii.whitespace), - }; - } - break :kv .{ std.mem.trim(u8, str[2..], &std.ascii.whitespace), null }; - }; - - const key = std.meta.stringToEnum(Key, k) orelse return; - try args.parseIntoField(Config, self.config.arenaAlloc(), &self.config, k, v); - self.isset.insert(key); -} - -pub fn deinit(self: *ConfigOverrides) callconv(.c) void { - self.config.deinit(); -} - -test "ConfigOverrides" { - const testing = std.testing; - const alloc = testing.allocator; - - var config_overrides: ConfigOverrides = undefined; - try config_overrides.init(alloc); - defer config_overrides.deinit(); - - try testing.expect(config_overrides.isSet(.@"font-size") == false); - try config_overrides.set(.@"font-size", 24.0); - try testing.expect(config_overrides.isSet(.@"font-size") == true); - try testing.expectApproxEqAbs(24.0, config_overrides.get(.@"font-size"), 0.01); - - try testing.expect(config_overrides.isSet(.@"working-directory") == false); - try config_overrides.parseCLI("--working-directory=/home/ghostty"); - try testing.expect(config_overrides.isSet(.@"working-directory") == true); - try testing.expectEqualStrings("/home/ghostty", config_overrides.get(.@"working-directory").?); -} diff --git a/src/config/c_get.zig b/src/config/c_get.zig index a3a45d24c..dcfdc6716 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -4,7 +4,7 @@ const key = @import("key.zig"); const Config = @import("Config.zig"); const Color = Config.Color; const Key = key.Key; -const Type = key.Type; +const Value = key.Value; /// Get a value from the config by key into the given pointer. This is /// specifically for C-compatible APIs. If you're using Zig, just access @@ -17,7 +17,7 @@ pub fn get(config: *const Config, k: Key, ptr_raw: *anyopaque) bool { @setEvalBranchQuota(10_000); switch (k) { inline else => |tag| { - const value = config.get(tag); + const value = fieldByKey(config, tag); return getValue(ptr_raw, value); }, } @@ -102,6 +102,22 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool { return true; } +/// Get a value from the config by key. +fn fieldByKey(self: *const Config, comptime k: Key) Value(k) { + const field = comptime field: { + const fields = std.meta.fields(Config); + for (fields) |field| { + if (@field(Key, field.name) == k) { + break :field field; + } + } + + unreachable; + }; + + return @field(self, field.name); +} + test "c_get: u8" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/config/command.zig b/src/config/command.zig index 7e16ad5c7..7cd70acb3 100644 --- a/src/config/command.zig +++ b/src/config/command.zig @@ -165,6 +165,16 @@ pub const Command = union(enum) { }; } + pub fn deinit(self: *const Self, alloc: Allocator) void { + switch (self.*) { + .shell => |v| alloc.free(v), + .direct => |l| { + for (l) |v| alloc.free(v); + alloc.free(l); + }, + } + } + pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { switch (self) { .shell => |v| try formatter.formatEntry([]const u8, v), diff --git a/src/config/key.zig b/src/config/key.zig index 6c083673a..5709e2074 100644 --- a/src/config/key.zig +++ b/src/config/key.zig @@ -32,7 +32,7 @@ pub const Key = key: { }; /// Returns the value type for a key -pub fn Type(comptime key: Key) type { +pub fn Value(comptime key: Key) type { const field = comptime field: { @setEvalBranchQuota(100_000); @@ -52,6 +52,6 @@ pub fn Type(comptime key: Key) type { test "Value" { const testing = std.testing; - try testing.expectEqual(Config.RepeatableString, Type(.@"font-family")); - try testing.expectEqual(?bool, Type(.@"cursor-style-blink")); + try testing.expectEqual(Config.RepeatableString, Value(.@"font-family")); + try testing.expectEqual(?bool, Value(.@"cursor-style-blink")); } From e27956fdde1b3964d689f8f0c038b29f6e7d5157 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 4 Mar 2026 14:03:38 -0600 Subject: [PATCH 14/18] gtk: remove modifications to the core for overrides --- src/Surface.zig | 19 +------------------ src/apprt/embedded.zig | 1 - src/apprt/gtk/class/surface.zig | 13 ++++++++----- 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index c13a29c4e..a3691b53e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -463,12 +463,6 @@ pub fn init( app: *App, rt_app: *apprt.runtime.App, rt_surface: *apprt.runtime.Surface, - overrides: struct { - command: ?configpkg.Command = null, - working_directory: ?[:0]const u8 = null, - - pub const none: @This() = .{}; - }, ) !void { // Apply our conditional state. If we fail to apply the conditional state // then we log and attempt to move forward with the old config. @@ -614,9 +608,6 @@ pub fn init( // The command we're going to execute const command: ?configpkg.Command = command: { - if (overrides.command) |command| { - break :command command; - } if (app.first) { if (config.@"initial-command") |command| { break :command command; @@ -625,14 +616,6 @@ pub fn init( break :command config.command; }; - // The working directory to execute the command in. - const working_directory: ?[]const u8 = wd: { - if (overrides.working_directory) |working_directory| { - break :wd working_directory; - } - break :wd config.@"working-directory"; - }; - // Start our IO implementation // This separate block ({}) is important because our errdefers must // be scoped here to be valid. @@ -656,7 +639,7 @@ pub fn init( .shell_integration = config.@"shell-integration", .shell_integration_features = config.@"shell-integration-features", .cursor_blink = config.@"cursor-style-blink", - .working_directory = working_directory, + .working_directory = config.@"working-directory", .resources_dir = global_state.resources_dir.host(), .term = config.term, .rt_pre_exec_info = .init(config), diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 810334aff..54d5472c6 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -572,7 +572,6 @@ pub const Surface = struct { app.core_app, app, self, - .none, ); errdefer self.core_surface.deinit(); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 64fdbe842..8d9e1bcf0 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3343,7 +3343,7 @@ pub const Surface = extern struct { }; fn initSurface(self: *Self) InitError!void { - const priv = self.private(); + const priv: *Private = self.private(); assert(priv.core_surface == null); const gl_area = priv.gl_area; @@ -3376,6 +3376,13 @@ pub const Surface = extern struct { ); defer config.deinit(); + if (priv.overrides.command) |c| { + config.command = try c.clone(config._arena.?.allocator()); + } + if (priv.overrides.working_directory) |wd| { + config.@"working-directory" = try config._arena.?.allocator().dupeZ(u8, wd); + } + // Properties that can impact surface init if (priv.font_size_request) |size| config.@"font-size" = size.points; if (priv.pwd) |pwd| config.@"working-directory" = pwd; @@ -3387,10 +3394,6 @@ pub const Surface = extern struct { app.core(), app.rt(), &priv.rt_surface, - .{ - .command = priv.overrides.command, - .working_directory = priv.overrides.working_directory, - }, ) catch |err| { log.warn("failed to initialize surface err={}", .{err}); return error.SurfaceError; From 05807f0d72d44ba24048cce56bf716d2c629ff30 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Mar 2026 13:43:54 -0800 Subject: [PATCH 15/18] Revert "build: link to the system FontConfig by default on non-macOS systems (#11152)" This reverts commit ee4c6f88c5517d242b73427f66da4d54d41e35a8. From 57d877a0d622a01381688cbafd8d227612640fc7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Mar 2026 13:45:30 -0800 Subject: [PATCH 16/18] Revert "build: link to the system FontConfig by default on non-macOS systems" This reverts commit 89f9dd7848111b28287a70388d610d66227a53f4. --- HACKING.md | 9 --------- src/build/Config.zig | 14 +------------- src/build/SharedDeps.zig | 7 ------- 3 files changed, 1 insertion(+), 29 deletions(-) diff --git a/HACKING.md b/HACKING.md index d69bcd0f8..7ba584881 100644 --- a/HACKING.md +++ b/HACKING.md @@ -38,15 +38,6 @@ here: | `zig build dist` | Builds a source tarball | | `zig build distcheck` | Builds and validates a source tarball | -## FontConfig and GTK - -Because of the global shared state that FontConfig maintains, FontConfig must -be linked dynamically to the same system FontConfig shared library that GTK -uses. Ghostty's default has been changed to always link to the system FontConfig -library. If that is overridden (by specifying `-fno-sys=fontconfig` during the -build) Ghostty may crash when trying to locate glyphs that are not available in -the default font. - ## Extra Dependencies Building Ghostty from a Git checkout on Linux requires some additional diff --git a/src/build/Config.zig b/src/build/Config.zig index aded6007d..3a8a4e0c7 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -424,19 +424,6 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { // show up properly in `--help`. { - // These should default to `true` except on macOS because linking them - // to Ghostty statically when GTK is dynamically linked to them can - // cause crashes. - for (&[_][]const u8{ - "fontconfig", - }) |dep| { - _ = b.systemIntegrationOption( - dep, - .{ - .default = if (target.result.os.tag.isDarwin()) false else true, - }, - ); - } // These dependencies we want to default false if we're on macOS. // On macOS we don't want to use system libraries because we // generally want a fat binary. This can be overridden with the @@ -444,6 +431,7 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { for (&[_][]const u8{ "freetype", "harfbuzz", + "fontconfig", "libpng", "zlib", "oniguruma", diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 821677128..9276c9914 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -200,13 +200,6 @@ pub fn add( if (b.systemIntegrationOption("fontconfig", .{})) { step.linkSystemLibrary2("fontconfig", dynamic_link_opts); } else { - if (self.config.app_runtime == .gtk) - std.debug.print( - \\WARNING: Statically linking FontConfig when using the GTK app runtime is known - \\to cause crashes! It is HIGHLY recommended that Ghostty be dynamically linked - \\to the system FontConfig library. - \\ - , .{}); step.linkLibrary(fontconfig_dep.artifact("fontconfig")); try static_libs.append( b.allocator, From 5bc5820f3255cc8dfbf6c30e3f7edb4a947add3d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 4 Mar 2026 16:01:12 -0600 Subject: [PATCH 17/18] gtk: simplify new-window action memory management with an arena --- src/apprt/gtk/class/application.zig | 27 ++++++--------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 55b392f76..c3ff51e0f 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -1671,22 +1671,15 @@ pub const Application = extern struct { ) callconv(.c) void { log.debug("received new window action", .{}); - const alloc = Application.default().allocator(); + var arena: std.heap.ArenaAllocator = .init(Application.default().allocator()); + defer arena.deinit(); + + const alloc = arena.allocator(); var working_directory: ?[:0]const u8 = null; - defer if (working_directory) |wd| alloc.free(wd); - var title: ?[:0]const u8 = null; - defer if (title) |t| alloc.free(t); - var command: ?configpkg.Command = null; - defer if (command) |c| c.deinit(alloc); - var args: std.ArrayList([:0]const u8) = .empty; - defer { - for (args.items) |arg| alloc.free(arg); - args.deinit(alloc); - } overrides: { // were we given a parameter? @@ -1730,7 +1723,6 @@ pub const Application = extern struct { log.warn("unable to duplicate argument {d} {s}: {t}", .{ i, str, err }); break :overrides; }; - errdefer alloc.free(cpy); args.append(alloc, cpy) catch |err| { log.warn("unable to append argument {d} {s}: {t}", .{ i, str, err }); break :overrides; @@ -1744,7 +1736,6 @@ pub const Application = extern struct { } if (lib.cutPrefix(u8, str, "--command=")) |v| { - if (command) |c| c.deinit(alloc); var cmd: configpkg.Command = undefined; cmd.parseCLI(alloc, v) catch |err| { log.warn("unable to parse command: {t}", .{err}); @@ -1754,7 +1745,6 @@ pub const Application = extern struct { continue; } if (lib.cutPrefix(u8, str, "--working-directory=")) |v| { - if (working_directory) |wd| alloc.free(wd); working_directory = alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| wd: { log.warn("unable to duplicate working directory: {t}", .{err}); break :wd null; @@ -1762,7 +1752,6 @@ pub const Application = extern struct { continue; } if (lib.cutPrefix(u8, str, "--title=")) |v| { - if (title) |t| alloc.free(t); title = alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| t: { log.warn("unable to duplicate title: {t}", .{err}); break :t null; @@ -1772,13 +1761,9 @@ pub const Application = extern struct { } } - if (args.items.len > 0) direct: { - if (command) |c| c.deinit(alloc); + if (args.items.len > 0) { command = .{ - .direct = args.toOwnedSlice(alloc) catch |err| { - log.warn("unable to convert list of arguments to owned slice: {t}", .{err}); - break :direct; - }, + .direct = args.items, }; } From 58d6021ec44c2383344f2c08214ebe3dd754ea4d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Mar 2026 14:15:56 -0800 Subject: [PATCH 18/18] apprt/gtk: reduce split-tree flicker by reusing leaf widgets Fixes #8208 Split-tree updates currently clear `tree_bin` and then wait for every surface to become parentless before rebuilding. That leaves the split area blank for one or more frames, which is the visible flicker during split create/close/ resize/equalize actions. Keep the previous widget tree attached until the idle rebuild runs, then swap in the rebuilt tree in one step. During rebuild, reuse existing leaf widgets by detaching and reparenting them into the new `GtkPaned` hierarchy instead of recreating wrappers for every leaf. This removes the parent-settling rebuild path and avoids transient blank frames while preserving debounced rebuild behavior. --- src/apprt/gtk/class/split_tree.zig | 234 +++++++++++++++++------------ 1 file changed, 135 insertions(+), 99 deletions(-) diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 0ff7e6044..497188b1d 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -157,11 +157,6 @@ pub const SplitTree = extern struct { /// used to debounce updates. rebuild_source: ?c_uint = null, - /// Tracks whether we want a rebuild to happen at the next tick - /// that our surface tree has no surfaces with parents. See the - /// propTree function for a lot more details. - rebuild_pending: bool, - /// Used to store state about a pending surface close for the /// close dialog. pending_close: ?Surface.Tree.Node.Handle, @@ -408,13 +403,6 @@ pub const SplitTree = extern struct { self, .{ .detail = "focused" }, ); - _ = gobject.Object.signals.notify.connect( - surface.as(gtk.Widget), - *Self, - propSurfaceParent, - self, - .{ .detail = "parent" }, - ); } } @@ -478,20 +466,6 @@ pub const SplitTree = extern struct { return surface; } - /// Returns whether any of the surfaces in the tree have a parent. - /// This is important because we can only rebuild the widget tree - /// when every surface has no parent. - fn getTreeHasParents(self: *Self) bool { - const tree: *const Surface.Tree = self.getTree() orelse &.empty; - var it = tree.iterator(); - while (it.next()) |entry| { - const surface = entry.view; - if (surface.as(gtk.Widget).getParent() != null) return true; - } - - return false; - } - pub fn getHasSurfaces(self: *Self) bool { const tree: *const Surface.Tree = self.private().tree orelse &.empty; return !tree.isEmpty(); @@ -779,27 +753,6 @@ pub const SplitTree = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); } - fn propSurfaceParent( - _: *gtk.Widget, - _: *gobject.ParamSpec, - self: *Self, - ) callconv(.c) void { - const priv = self.private(); - - // If we're not waiting to rebuild then ignore this. - if (!priv.rebuild_pending) return; - - // If any parents still exist in our tree then don't do anything. - if (self.getTreeHasParents()) return; - - // Schedule the rebuild. Note, I tried to do this immediately (not - // on an idle tick) and it didn't work and had obvious rendering - // glitches. Something to look into in the future. - assert(priv.rebuild_source == null); - priv.rebuild_pending = false; - priv.rebuild_source = glib.idleAdd(onRebuild, self); - } - fn propTree( self: *Self, _: *gobject.ParamSpec, @@ -807,6 +760,12 @@ pub const SplitTree = extern struct { ) callconv(.c) void { const priv = self.private(); + // No matter what we notify + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec); + self.as(gobject.Object).notifyByPspec(properties.@"is-zoomed".impl.param_spec); + // If we were planning a rebuild, always remove that so we can // start from a clean slate. if (priv.rebuild_source) |v| { @@ -816,38 +775,22 @@ pub const SplitTree = extern struct { priv.rebuild_source = null; } - // We need to wait for all our previous surfaces to lose their - // parent before adding them to a new one. I'm not sure if its a GTK - // bug, but manually forcing an unparent of all prior surfaces AND - // adding them to a new parent in the same tick causes the GLArea - // to break (it seems). I didn't investigate too deeply. - // - // Note, we also can't just defer to an idle tick (via idleAdd) because - // sometimes it takes more than one tick for all our surfaces to - // lose their parent. - // - // To work around this issue, if we have any surfaces that have - // a parent, we set the build pending flag and wait for the tree - // to be fully parent-free before building. - priv.rebuild_pending = self.getTreeHasParents(); - - // Reset our prior bin. This will force all prior surfaces to - // unparent... eventually. - priv.tree_bin.setChild(null); - - // If none of the surfaces we plan on drawing require an unparent - // then we can setup our tree immediately. Otherwise, it'll happen - // via the `propSurfaceParent` callback. - if (!priv.rebuild_pending and priv.rebuild_source == null) { - priv.rebuild_source = glib.idleAdd( - onRebuild, - self, - ); + // If we transitioned to an empty tree, clear immediately instead of + // waiting for an idle callback. Delaying teardown can keep the last + // surface alive during shutdown if the main loop exits first. + if (priv.tree == null) { + priv.tree_bin.setChild(null); + return; } - // Dependent properties - self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec); - self.as(gobject.Object).notifyByPspec(properties.@"is-zoomed".impl.param_spec); + // Build on an idle callback so rapid tree changes are debounced. + // We keep the existing tree attached until the rebuild runs, + // which avoids transient empty frames. + assert(priv.rebuild_source == null); + priv.rebuild_source = glib.idleAdd( + onRebuild, + self, + ); } fn onRebuild(ud: ?*anyopaque) callconv(.c) c_int { @@ -857,22 +800,21 @@ pub const SplitTree = extern struct { const priv = self.private(); priv.rebuild_source = null; - // Prior to rebuilding the tree, our surface tree must be - // comprised of fully orphaned surfaces. - assert(!self.getTreeHasParents()); - // Rebuild our tree const tree: *const Surface.Tree = self.private().tree orelse &.empty; - if (!tree.isEmpty()) { - priv.tree_bin.setChild(self.buildTree( + if (tree.isEmpty()) { + priv.tree_bin.setChild(null); + } else { + const built = self.buildTree( tree, tree.zoomed orelse .root, - )); + ); + defer built.deinit(); + priv.tree_bin.setChild(built.widget); } - // If we have a last focused surface, we need to refocus it, because - // during the frame between setting the bin to null and rebuilding, - // GTK will reset our focus state (as it should!) + // Replacing our tree widget hierarchy can reset focus state. + // If we have a last-focused surface, restore focus to it. if (priv.last_focused.get()) |v| { defer v.unref(); v.grabFocus(); @@ -889,26 +831,120 @@ pub const SplitTree = extern struct { /// Builds the widget tree associated with a surface split tree. /// - /// The final returned widget is expected to be a floating reference, - /// ready to be attached to a parent widget. + /// Returned widgets are expected to be attached to a parent by the caller. + /// + /// If `release_ref` is true then `widget` has an extra temporary + /// reference that must be released once it is parented in the rebuilt + /// tree. + const BuildTreeResult = struct { + widget: *gtk.Widget, + release_ref: bool, + + pub fn initNew(widget: *gtk.Widget) BuildTreeResult { + return .{ .widget = widget, .release_ref = false }; + } + + pub fn initReused(widget: *gtk.Widget) BuildTreeResult { + // We add a temporary ref to the widget to ensure it doesn't + // get destroyed while we're rebuilding the tree and detaching + // it from its old parent. The caller is expected to release + // this ref once the widget is attached to its new parent. + _ = widget.as(gobject.Object).ref(); + + // Detach after we ref it so that this doesn't mark the + // widget for destruction. + detachWidget(widget); + + return .{ .widget = widget, .release_ref = true }; + } + + pub fn deinit(self: BuildTreeResult) void { + // If we have to release a ref, do it. + if (self.release_ref) self.widget.as(gobject.Object).unref(); + } + }; + fn buildTree( self: *Self, tree: *const Surface.Tree, current: Surface.Tree.Node.Handle, - ) *gtk.Widget { + ) BuildTreeResult { return switch (tree.nodes[current.idx()]) { - .leaf => |v| gobject.ext.newInstance(SurfaceScrolledWindow, .{ - .surface = v, - }).as(gtk.Widget), - .split => |s| SplitTreeSplit.new( - current, - &s, - self.buildTree(tree, s.left), - self.buildTree(tree, s.right), - ).as(gtk.Widget), + .leaf => |v| leaf: { + const window = ext.getAncestor( + SurfaceScrolledWindow, + v.as(gtk.Widget), + ) orelse { + // The surface isn't in a window already so we don't + // have to worry about reuse. + break :leaf .initNew(gobject.ext.newInstance( + SurfaceScrolledWindow, + .{ .surface = v }, + ).as(gtk.Widget)); + }; + + // Keep this widget alive while we detach it from the + // old tree and adopt it into the new one. + break :leaf .initReused(window.as(gtk.Widget)); + }, + .split => |s| split: { + const left = self.buildTree(tree, s.left); + defer left.deinit(); + const right = self.buildTree(tree, s.right); + defer right.deinit(); + + break :split .initNew(SplitTreeSplit.new( + current, + &s, + left.widget, + right.widget, + ).as(gtk.Widget)); + }, }; } + /// Detach a split widget from its current parent. + /// + /// We intentionally use parent-specific child APIs when possible + /// (`GtkPaned.setStartChild/setEndChild`, `AdwBin.setChild`) instead of + /// calling `gtk.Widget.unparent` directly. Container implementations track + /// child pointers/properties internally, and those setters are the path + /// that keeps container state and notifications in sync. + fn detachWidget(widget: *gtk.Widget) void { + const parent = widget.getParent() orelse return; + + // Surface will be in a paned when it is split. + if (gobject.ext.cast(gtk.Paned, parent)) |paned| { + if (paned.getStartChild()) |child| { + if (child == widget) { + paned.setStartChild(null); + return; + } + } + + if (paned.getEndChild()) |child| { + if (child == widget) { + paned.setEndChild(null); + return; + } + } + } + + // Surface will be in a bin when it is not split. + if (gobject.ext.cast(adw.Bin, parent)) |bin| { + if (bin.getChild()) |child| { + if (child == widget) { + bin.setChild(null); + return; + } + } + } + + // Fallback for unexpected parents where we don't have a typed + // container API available. + widget.unparent(); + } + //--------------------------------------------------------------- // Class