diff --git a/CODEOWNERS b/CODEOWNERS index 1fbcf109a..07c12424f 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/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/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 bcd9a0ffa..f9e2dc93f 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 configPath = ghostty.config.bellAudioPath, + let sound = NSSound(contentsOfFile: configPath.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/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) { diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index a26f41038..87ae0511f 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -134,6 +134,23 @@ extension Ghostty { return .init(rawValue: v) } + var bellAudioPath: ConfigPath? { + guard let config = self.config else { return nil } + 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 } + let path = String(cString: v.path) + return path.isEmpty ? nil : ConfigPath(path: path, optional: v.optional) + } + + 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/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/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 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"), 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/Surface.zig b/src/Surface.zig index e71af3939..a3691b53e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -607,10 +607,14 @@ 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 (app.first) { + if (config.@"initial-command") |command| { + break :command command; + } + } + break :command config.command; + }; // Start our IO implementation // This separate block ({}) is important because our errdefers must diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 918e77146..715973671 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; diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 00560fd13..c3ff51e0f 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"); @@ -709,6 +710,7 @@ pub const Application = extern struct { .app => null, .surface => |v| v, }, + .none, ), .open_config => return Action.openConfig(self), @@ -1669,17 +1671,30 @@ pub const Application = extern struct { ) callconv(.c) void { log.debug("received new window action", .{}); - parameter: { + var arena: std.heap.ArenaAllocator = .init(Application.default().allocator()); + defer arena.deinit(); + + const alloc = arena.allocator(); + + var working_directory: ?[:0]const u8 = null; + var title: ?[:0]const u8 = null; + var command: ?configpkg.Command = null; + var args: std.ArrayList([:0]const u8) = .empty; + + overrides: { // were we given a parameter? - const parameter = parameter_ orelse break :parameter; + const parameter = parameter_ orelse break :overrides; 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 :overrides; } const s_variant_type = glib.VariantType.new("s"); @@ -1688,7 +1703,10 @@ pub const Application = extern struct { var it: glib.VariantIter = undefined; _ = it.init(parameter); - while (it.nextValue()) |value| { + var e_seen: bool = false; + var i: usize = 0; + + while (it.nextValue()) |value| : (i += 1) { defer value.unref(); // just to be sure @@ -1698,13 +1716,64 @@ pub const Application = extern struct { const buf = value.getString(&len); const str = buf[0..len]; - log.debug("new-window command argument: {s}", .{str}); + 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 :overrides; + }; + args.append(alloc, cpy) catch |err| { + log.warn("unable to append argument {d} {s}: {t}", .{ i, str, err }); + break :overrides; + }; + continue; + } + + if (std.mem.eql(u8, str, "-e")) { + e_seen = true; + continue; + } + + if (lib.cutPrefix(u8, str, "--command=")) |v| { + var cmd: configpkg.Command = undefined; + cmd.parseCLI(alloc, v) catch |err| { + log.warn("unable to parse command: {t}", .{err}); + continue; + }; + command = cmd; + continue; + } + if (lib.cutPrefix(u8, str, "--working-directory=")) |v| { + 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| { + 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; + } } } - _ = self.core().mailbox.push(.{ - .new_window = .{}, - }, .{ .forever = {} }); + if (args.items.len > 0) { + command = .{ + .direct = args.items, + }; + } + + Action.newWindow(self, null, .{ + .command = command, + .working_directory = working_directory, + .title = title, + }) catch |err| { + log.warn("unable to create new window: {t}", .{err}); + }; } pub fn actionOpenConfig( @@ -2151,6 +2220,13 @@ const Action = struct { pub fn newWindow( self: *Application, parent: ?*CoreSurface, + 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 @@ -2159,14 +2235,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); - initAndShowWindow(self, win, parent); + 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, + 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 @@ -2180,7 +2274,11 @@ const Action = struct { ); // Create a new tab with window context (first tab in new window) - win.newTabForWindow(parent); + 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. @@ -2506,7 +2604,7 @@ const Action = struct { .@"quick-terminal" = true, }); assert(win.isQuickTerminal()); - initAndShowWindow(self, win, null); + initAndShowWindow(self, win, null, .none); return true; } diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 0ff7e6044..311fbd8a6 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"); @@ -157,11 +158,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, @@ -208,11 +204,22 @@ pub const SplitTree = extern struct { self: *Self, direction: Surface.Tree.Split.Direction, parent_: ?*Surface, + 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(); + const surface: *Surface = .new(.{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }); defer surface.unref(); _ = surface.refSink(); @@ -408,13 +415,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 +478,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(); @@ -638,6 +624,7 @@ pub const SplitTree = extern struct { self.newSplit( direction, self.getActiveSurface(), + .none, ) catch |err| { log.warn("new split failed error={}", .{err}); }; @@ -779,27 +766,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 +773,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 +788,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 +813,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 +844,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 diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 2f4a13a32..8d9e1bcf0 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"); @@ -693,6 +694,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, @@ -700,11 +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() *Self { - return gobject.ext.newInstance(Self, .{}); + 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 { @@ -1849,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. @@ -1862,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; @@ -1895,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); @@ -2733,13 +2767,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 +2837,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; } @@ -3296,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; @@ -3329,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; diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index 24caa4990..0c60c8ccc 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -5,6 +5,7 @@ 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"); @@ -186,22 +187,34 @@ 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, overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, - // Init our actions - self.initActionMap(); + pub const none: @This() = .{}; + }) *Self { + const tab = gobject.ext.newInstance(Tab, .{}); + + 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, .{ + .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" @@ -209,6 +222,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..c01cad618 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -266,10 +266,27 @@ 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, + overrides: struct { + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, + ) *Self { + const win = gobject.ext.newInstance(Self, .{ .application = app, }); + + 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. + win.as(gtk.Window).setTitle(title); + } + + return win; } fn init(self: *Self, _: *Class) callconv(.c) void { @@ -278,10 +295,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. @@ -305,17 +326,16 @@ 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. + if (config.title) |title| { + self.as(gtk.Window).setTitle(title); } // We always sync our appearance at the end because loading our @@ -368,21 +388,56 @@ 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, .none); } - pub fn newTabForWindow(self: *Self, parent_: ?*CoreSurface) void { - _ = self.newTabPage(parent_, .window); + 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) *adw.TabPage { - const priv = self.private(); + 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 = gobject.ext.newInstance(Tab, .{ - .config = priv.config, - }); + 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. if (context == .window) { @@ -1253,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); + return self.newTabPage(if (self.getActiveSurface()) |v| v.core() else null, .tab, .none); } 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/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, diff --git a/src/cli/new_window.zig b/src/cli/new_window.zig index f3f4740d1..12acafadf 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,35 +15,63 @@ 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. - _arguments: ?[][: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: 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. _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 e_seen: bool = std.mem.eql(u8, arg, "-e"); - var arguments: std.ArrayList([:0]const u8) = .empty; - errdefer { - for (arguments.items) |argument| alloc.free(argument); - arguments.deinit(alloc); - } + // Include the argument that triggered the manual parse hook. + if (try self.checkArg(alloc, arg)) |a| try self._arguments.append(alloc, a); - // Otherwise gather up the rest of the arguments to use as the command. + // 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 self._arguments.append(alloc, try alloc.dupeZ(u8, param)); + continue; + } + if (std.mem.eql(u8, param, "-e")) { + e_seen = true; + try self._arguments.append(alloc, try alloc.dupeZ(u8, param)); + continue; + } + if (try self.checkArg(alloc, param)) |a| try self._arguments.append(alloc, a); } - self._arguments = try arguments.toOwnedSlice(alloc); - 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 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); + } + + return try alloc.dupeZ(u8, arg); + } + pub fn deinit(self: *Options) void { if (self._arena) |arena| arena.deinit(); self.* = undefined; @@ -63,11 +93,23 @@ 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`, `--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 +/// relative to the current working directory that the `ghostty +new-window` +/// 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 @@ -92,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 { @@ -143,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); @@ -159,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/Config.zig b/src/config/Config.zig index 29a45786f..ca93c85d6 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -29,7 +29,7 @@ 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; const MetricModifier = fontpkg.Metrics.Modifier; const help_strings = @import("help_strings"); pub const Command = @import("command.zig").Command; @@ -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, @@ -4794,8 +4793,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; } 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/path.zig b/src/config/path.zig index ebcd084d2..793cf1845 100644 --- a/src/config/path.zig +++ b/src/config/path.zig @@ -32,6 +32,20 @@ pub const Path = union(enum) { return std.meta.eql(self, other); } + /// 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 = path.ptr, .optional = true }, + .required => |path| .{ .path = path.ptr, .optional = false }, + }; + } + /// 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 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=")); +} diff --git a/src/os/i18n_locales.zig b/src/os/i18n_locales.zig index aa39c6593..f9fa3f458 100644 --- a/src/os/i18n_locales.zig +++ b/src/os/i18n_locales.zig @@ -55,4 +55,5 @@ pub const locales = [_][:0]const u8{ "lt", "lv", "vi", + "kk", }; diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 4d872d025..33a2c01d1 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 @@ -239,6 +245,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