From 3c2c596dc77679385e18039052e2b7556049e07f Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 28 Mar 2026 17:32:58 -0400 Subject: [PATCH 001/137] fix global keybinds from not repsonding anymore --- .../Features/Global Keybinds/GlobalEventTap.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift index 9d4023c2e..d308e59ea 100644 --- a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift +++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift @@ -16,7 +16,7 @@ class GlobalEventTap { // The event tap used for global event listening. This is non-nil if it is // created. - private var eventTap: CFMachPort? + fileprivate var eventTap: CFMachPort? // This is the timer used to retry enabling the global event tap if we // don't have permissions. @@ -125,6 +125,17 @@ private func cgEventFlagsChangedHandler( ) -> Unmanaged? { let result = Unmanaged.passUnretained(cgEvent) + // macOS disables the event tap if the callback is too slow or for other + // internal reasons. When that happens it sends this event type. We need + // to re-enable the tap or it stays dead forever. + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + GlobalEventTap.logger.warning("global event tap was disabled by the system, re-enabling") + if let machPort = GlobalEventTap.shared.eventTap { + CGEvent.tapEnable(tap: machPort, enable: true) + } + return result + } + // We only care about keydown events guard type == .keyDown else { return result } From e5c31e8b379f6f850caadc2f11c74ea9e6644f34 Mon Sep 17 00:00:00 2001 From: davidsanchez222 Date: Sat, 28 Feb 2026 15:50:52 -0500 Subject: [PATCH 002/137] macos: opacity-toggle setting persists between tabs in a window and to a newly created window --- .../Terminal/BaseTerminalController.swift | 3 +- .../Terminal/TerminalController.swift | 38 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 5d9d5d527..eca789d47 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -766,7 +766,8 @@ class BaseTerminalController: NSWindowController, ghostty, tree: newTree, position: notification.userInfo?[Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey] as? NSPoint, - confirmUndo: false) + confirmUndo: false, + inheritBackgroundOpacityFrom: self as? TerminalController) } // MARK: Local Events diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 56b0b40ad..066e9430c 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -230,6 +230,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Get our parent. Our parent is the one explicitly given to us, // otherwise the focused terminal, otherwise an arbitrary one. let parent: NSWindow? = explicitParent ?? preferredParent?.window + if let parentController = parent?.windowController as? TerminalController { + c.isBackgroundOpaque = parentController.isBackgroundOpaque + } if let parent, parent.styleMask.contains(.fullScreen) { // If our previous window was fullscreen then we want our new window to @@ -313,8 +316,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr tree: SplitTree, position: NSPoint? = nil, confirmUndo: Bool = true, + inheritBackgroundOpacityFrom parentController: TerminalController? = nil ) -> TerminalController { let c = TerminalController.init(ghostty, withSurfaceTree: tree) + if let parentController { + c.isBackgroundOpaque = parentController.isBackgroundOpaque + } // Calculate the target frame based on the tree's view bounds let treeSize: CGSize? = tree.root?.viewBounds() @@ -359,7 +366,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr withTarget: ghostty, expiresAfter: target.undoExpiration ) { ghostty in - _ = TerminalController.newWindow(ghostty, tree: tree) + _ = TerminalController.newWindow( + ghostty, + tree: tree, + inheritBackgroundOpacityFrom: parentController + ) } } } @@ -394,6 +405,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Create a new window and add it to the parent let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig) + controller.isBackgroundOpaque = parentController.isBackgroundOpaque guard let window = controller.window else { return controller } // If the parent is miniaturized, then macOS exhibits really strange behaviors @@ -491,6 +503,30 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return controller } + override func toggleBackgroundOpacity() { + // Do nothing if config is already fully opaque + guard ghostty.config.backgroundOpacity < 1 else { return } + + // Do nothing if in fullscreen (transparency doesn't apply in fullscreen) + guard let window, !window.styleMask.contains(.fullScreen) else { return } + + let newValue = !isBackgroundOpaque + let controllers: [TerminalController] + + if let tabGroup = window.tabGroup { + controllers = tabGroup.windows.compactMap { + $0.windowController as? TerminalController + } + } else { + controllers = [self] + } + + for controller in controllers { + controller.isBackgroundOpaque = newValue + controller.syncAppearance() + } + } + // MARK: - Methods @objc private func ghosttyConfigDidChange(_ notification: Notification) { From 0e49204b95fca41b1342ad56c9a0092f0872d737 Mon Sep 17 00:00:00 2001 From: davidsanchez222 Date: Sun, 5 Apr 2026 12:35:29 -0400 Subject: [PATCH 003/137] refactor(macos): centralize background opacity toggling across controllers --- .../Terminal/BaseTerminalController.swift | 14 +++++--- .../Terminal/TerminalController.swift | 32 +++---------------- 2 files changed, 13 insertions(+), 33 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index eca789d47..2c03f87cb 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -767,7 +767,7 @@ class BaseTerminalController: NSWindowController, tree: newTree, position: notification.userInfo?[Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey] as? NSPoint, confirmUndo: false, - inheritBackgroundOpacityFrom: self as? TerminalController) + inheritBackgroundOpacity: isBackgroundOpaque) } // MARK: Local Events @@ -991,11 +991,15 @@ class BaseTerminalController: NSWindowController, // Do nothing if in fullscreen (transparency doesn't apply in fullscreen) guard let window, !window.styleMask.contains(.fullScreen) else { return } - // Toggle between transparent and opaque - isBackgroundOpaque.toggle() + let newValue = !isBackgroundOpaque + let controllers = NSApplication.shared.windows.compactMap { + $0.windowController as? BaseTerminalController + } - // Update our appearance - syncAppearance() + for controller in controllers { + controller.isBackgroundOpaque = newValue + controller.syncAppearance() + } } /// Override this to resync any appearance related properties. This will be called automatically diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 066e9430c..7502da8d3 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -316,11 +316,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr tree: SplitTree, position: NSPoint? = nil, confirmUndo: Bool = true, - inheritBackgroundOpacityFrom parentController: TerminalController? = nil + inheritBackgroundOpacity: Bool? = nil ) -> TerminalController { let c = TerminalController.init(ghostty, withSurfaceTree: tree) - if let parentController { - c.isBackgroundOpaque = parentController.isBackgroundOpaque + if let inheritBackgroundOpacity { + c.isBackgroundOpaque = inheritBackgroundOpacity } // Calculate the target frame based on the tree's view bounds @@ -369,7 +369,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr _ = TerminalController.newWindow( ghostty, tree: tree, - inheritBackgroundOpacityFrom: parentController + inheritBackgroundOpacity: inheritBackgroundOpacity ) } } @@ -503,30 +503,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return controller } - override func toggleBackgroundOpacity() { - // Do nothing if config is already fully opaque - guard ghostty.config.backgroundOpacity < 1 else { return } - - // Do nothing if in fullscreen (transparency doesn't apply in fullscreen) - guard let window, !window.styleMask.contains(.fullScreen) else { return } - - let newValue = !isBackgroundOpaque - let controllers: [TerminalController] - - if let tabGroup = window.tabGroup { - controllers = tabGroup.windows.compactMap { - $0.windowController as? TerminalController - } - } else { - controllers = [self] - } - - for controller in controllers { - controller.isBackgroundOpaque = newValue - controller.syncAppearance() - } - } - // MARK: - Methods @objc private func ghosttyConfigDidChange(_ notification: Notification) { From 41878d6f7977d4758ea15b6599ee7d025be40ecc Mon Sep 17 00:00:00 2001 From: Aaron Ang Date: Wed, 15 Apr 2026 22:42:51 -0400 Subject: [PATCH 004/137] snap: export TERMINFO_DIRS so child shells find xterm-ghostty Without this, shells spawned by ghostty cannot find the xterm-ghostty terminfo entry because ncurses only searches standard system paths. The snap's terminfo lives inside the snap sandbox and is inaccessible unless TERMINFO_DIRS is set explicitly. --- snap/local/launcher | 1 + 1 file changed, 1 insertion(+) diff --git a/snap/local/launcher b/snap/local/launcher index 306ee4d8c..6aaccfeaa 100755 --- a/snap/local/launcher +++ b/snap/local/launcher @@ -50,6 +50,7 @@ export XLOCALEDIR="${SNAP}/usr/share/X11/locale" export GTK_PATH="$SNAP/usr/lib/$ARCH/gtk-4.0" export GIO_MODULE_DIR="$SNAP/usr/lib/$ARCH/gio/modules" unset GIO_EXTRA_MODULES +export TERMINFO_DIRS="${SNAP}/share/terminfo${TERMINFO_DIRS:+:$TERMINFO_DIRS}" # Gdk-pixbuf loaders mkdir -p "$SNAP_USER_COMMON/.cache" From cbd43fd4834a7969480866c037f8650ca0912a25 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 1 May 2026 13:34:23 +0200 Subject: [PATCH 005/137] feature: add basque translation --- CODEOWNERS | 1 + po/eu.po | 353 ++++++++++++++++++++++++++++++++++++++++ src/os/i18n_locales.zig | 1 + 3 files changed, 355 insertions(+) create mode 100644 po/eu.po diff --git a/CODEOWNERS b/CODEOWNERS index 68eb86013..ed1217313 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -172,6 +172,7 @@ /po/es_AR.po @ghostty-org/es_AR /po/es_BO.po @ghostty-org/es_BO /po/es_ES.po @ghostty-org/es_ES +/po/eu.po @ghostty-org/eu_ES /po/fr.po @ghostty-org/fr_FR /po/ga.po @ghostty-org/ga_IE /po/he.po @ghostty-org/he_IL diff --git a/po/eu.po b/po/eu.po new file mode 100644 index 000000000..ca17e4965 --- /dev/null +++ b/po/eu.po @@ -0,0 +1,353 @@ +# Language X translations for com.mitchellh.ghostty package. +# Copyright (C) 2026 "Mitchell Hashimoto, Ghostty contributors" +# This file is distributed under the same license as the com.mitchellh.ghostty package. +# Mikel Larreategi , 2026. +# +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-05-01 12:56+0200\n" +"Last-Translator: Mikel Larreategi \n" +"Language-Team: Language eu\n" +"Language: eu\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: dist/linux/ghostty_nautilus.py:53 +msgid "Open in Ghostty" +msgstr "Ireki Ghostty-n" + +#: 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 "Baimendu arbelera sartzea" + +#: 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 "Ukatu" + +#: 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 "Baimendu" + +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:92 +msgid "Remember choice for this split" +msgstr "Gogoratu zatiketa-aukera hau" + +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:93 +msgid "Reload configuration to show this prompt again" +msgstr "Birkargatu konfigurazioa eta erakutsi hau berriz" + +#: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 +#: src/apprt/gtk/ui/1.5/title-dialog.blp:8 +msgid "Cancel" +msgstr "Utzi" + +#: 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 "Itxi" + +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6 +msgid "Configuration Errors" +msgstr "Konfigurazio erroreak" + +#: 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 "" +"Konfigurazio erroreren bat aurkitu da. Begiratu erroreak azpian, eta " +"birkargatu konfigurazioa edo baztertu erroreak." + +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +msgid "Ignore" +msgstr "Baztertu" + +#: 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 "Birkargatu konfigurazioa" + +#: 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 "" +"⚠️ Garapeneko Ghostty bertsio bat erabiltzen ari zara! Errendimendua ez da " +"ona izango" + +#: src/apprt/gtk/ui/1.5/inspector-window.blp:5 +msgid "Ghostty: Terminal Inspector" +msgstr "Ghostty: terminal ikuskatzailea" + +#: src/apprt/gtk/ui/1.2/search-overlay.blp:29 +msgid "Find…" +msgstr "Bilatu…" + +#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +msgid "Previous Match" +msgstr "Aurreko bilaketa" + +#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +msgid "Next Match" +msgstr "Hurrengo bilaketa" + +#: src/apprt/gtk/ui/1.2/surface.blp:6 +msgid "Oh, no." +msgstr "Oh, ez!" + +#: src/apprt/gtk/ui/1.2/surface.blp:7 +msgid "Unable to acquire an OpenGL context for rendering." +msgstr "Ezin izan da OpenGL kontestua erabili." + +#: 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 "" +"Terminal hau irakurtzeko moduan dago. Ikusi, aukeratu eta kontestuan zehar " +"gora eta behera ibili zaitezke, baina ez da sarrera ekintzarik bidaliko." + +#: src/apprt/gtk/ui/1.2/surface.blp:107 +msgid "Read-only" +msgstr "Irakurtzeko-bakarrik" + +#: src/apprt/gtk/ui/1.2/surface.blp:260 src/apprt/gtk/ui/1.5/window.blp:200 +msgid "Copy" +msgstr "Kopiatu" + +#: src/apprt/gtk/ui/1.2/surface.blp:265 src/apprt/gtk/ui/1.5/window.blp:205 +msgid "Paste" +msgstr "Itsatsi" + +#: src/apprt/gtk/ui/1.2/surface.blp:270 +msgid "Notify on Next Command Finish" +msgstr "Jakinarazi hurrengo komandoa amiatzean" + +#: src/apprt/gtk/ui/1.2/surface.blp:277 src/apprt/gtk/ui/1.5/window.blp:273 +msgid "Clear" +msgstr "Garbitu" + +#: src/apprt/gtk/ui/1.2/surface.blp:282 src/apprt/gtk/ui/1.5/window.blp:278 +msgid "Reset" +msgstr "Berrezarri" + +#: src/apprt/gtk/ui/1.2/surface.blp:289 src/apprt/gtk/ui/1.5/window.blp:242 +msgid "Split" +msgstr "Zatitu" + +#: src/apprt/gtk/ui/1.2/surface.blp:292 src/apprt/gtk/ui/1.5/window.blp:245 +msgid "Change Title…" +msgstr "Aldatu izenburua…" + +#: 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 "Zatitu gorantz" + +#: 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 "Zatitu beherantz" + +#: 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 "Zatitu ezkerrera" + +#: 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 "Zatitu eskumara" + +#: src/apprt/gtk/ui/1.2/surface.blp:322 +msgid "Tab" +msgstr "Fitxa" + +#: 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 "Aldatu fitxaren izenburua…" + +#: 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 "Fitxa berria" + +#: src/apprt/gtk/ui/1.2/surface.blp:335 src/apprt/gtk/ui/1.5/window.blp:234 +msgid "Close Tab" +msgstr "Itxi fitxa" + +#: src/apprt/gtk/ui/1.2/surface.blp:342 +msgid "Window" +msgstr "Leihoa" + +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:212 +msgid "New Window" +msgstr "Leiho berria" + +#: src/apprt/gtk/ui/1.2/surface.blp:350 src/apprt/gtk/ui/1.5/window.blp:217 +msgid "Close Window" +msgstr "Itxi leihoa" + +#: src/apprt/gtk/ui/1.2/surface.blp:358 +msgid "Config" +msgstr "Konfigurazioa" + +#: src/apprt/gtk/ui/1.2/surface.blp:361 src/apprt/gtk/ui/1.5/window.blp:295 +msgid "Open Configuration" +msgstr "Ireki konfigurazioa" + +#: src/apprt/gtk/ui/1.5/title-dialog.blp:5 +msgid "Leave blank to restore the default title." +msgstr "Utzi hutsik defektuzko izenburua berrezartzeko" + +#: src/apprt/gtk/ui/1.5/title-dialog.blp:9 +msgid "OK" +msgstr "OK" + +#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +msgid "New Split" +msgstr "Zatitu" + +#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +msgid "View Open Tabs" +msgstr "Ikusi irekitako fitxak" + +#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +msgid "Main Menu" +msgstr "Menu nagusia" + +#: src/apprt/gtk/ui/1.5/window.blp:285 +msgid "Command Palette" +msgstr "Komando paleta" + +#: src/apprt/gtk/ui/1.5/window.blp:290 +msgid "Terminal Inspector" +msgstr "Terminal ikuskatzailea" + +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1727 +msgid "About Ghostty" +msgstr "Ghosttyri buruz" + +#: src/apprt/gtk/ui/1.5/window.blp:312 +msgid "Quit" +msgstr "Irten" + +#: src/apprt/gtk/ui/1.5/command-palette.blp:17 +msgid "Execute a command…" +msgstr "Exekutatu komando bat…" + +#: 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 "" +"Aplikazio bat arbelean idatzi nahian dabil. Hau da arbelaren gaur egungo " +"edukia." + +#: 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 "" +"Aplikazio bat arbeletik irakurri nahian dabil. Hau da arbelaren gaur egungo " +"edukia." + +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:205 +msgid "Warning: Potentially Unsafe Paste" +msgstr "Oharra: hau itsastea ez da segurua" + +#: 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 "" +"Testu hau terminalean itsastea arriskutsua izan daiteke komandoren batzuk " +"exekutatzea ekar dezakeelako." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:184 +msgid "Quit Ghostty?" +msgstr "Ghostty itxi?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:185 +msgid "Close Tab?" +msgstr "Fitxa itxi?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:186 +msgid "Close Window?" +msgstr "Leihoa itxi?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:187 +msgid "Close Split?" +msgstr "Zatikatzea itxi?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:193 +msgid "All terminal sessions will be terminated." +msgstr "Terminal saio guztiak itxi egingo dira." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:194 +msgid "All terminal sessions in this tab will be terminated." +msgstr "Fitxa honetako terminal saioa itxi egingo da." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:195 +msgid "All terminal sessions in this window will be terminated." +msgstr "Leiho honetako terminal saio guztiak itxi egingo dira." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:196 +msgid "The currently running process in this split will be terminated." +msgstr "Zatikatze honetan martxan dagoen prozesua itxi egingo da." + +#: src/apprt/gtk/class/surface.zig:1108 +msgid "Command Finished" +msgstr "Komandoa amaitu da" + +#: src/apprt/gtk/class/surface.zig:1109 +msgid "Command Succeeded" +msgstr "Komandoa ondo amaitu da" + +#: src/apprt/gtk/class/surface.zig:1110 +msgid "Command Failed" +msgstr "Komandoak huts egin du" + +#: src/apprt/gtk/class/surface_child_exited.zig:109 +msgid "Command succeeded" +msgstr "Komandoa ondo amaitu da" + +#: src/apprt/gtk/class/surface_child_exited.zig:113 +msgid "Command failed" +msgstr "Komandoak huts egin du" + +#: src/apprt/gtk/class/title_dialog.zig:225 +msgid "Change Terminal Title" +msgstr "Aldatu terminalaren izenburua" + +#: src/apprt/gtk/class/title_dialog.zig:226 +msgid "Change Tab Title" +msgstr "Aldatu fitxaren izenburua" + +#: src/apprt/gtk/class/window.zig:1007 +msgid "Reloaded the configuration" +msgstr "Konfigurazioa berriz kargatu da" + +#: src/apprt/gtk/class/window.zig:1566 +msgid "Copied to clipboard" +msgstr "Arbelera kopiatu da" + +#: src/apprt/gtk/class/window.zig:1568 +msgid "Cleared clipboard" +msgstr "Arbela garbitu da" + +#: src/apprt/gtk/class/window.zig:1708 +msgid "Ghostty Developers" +msgstr "Ghostty garatzaileak" diff --git a/src/os/i18n_locales.zig b/src/os/i18n_locales.zig index 455569524..5c1ee063f 100644 --- a/src/os/i18n_locales.zig +++ b/src/os/i18n_locales.zig @@ -58,4 +58,5 @@ pub const locales = [_][:0]const u8{ "vi", "kk", "be", + "eu", }; From afb8fc7eb3042196b7e1d0fb5ec0eb0601efaf85 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 1 May 2026 13:51:01 +0200 Subject: [PATCH 006/137] Update po/eu.po Co-authored-by: kat <65649991+00-kat@users.noreply.github.com> --- po/eu.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/po/eu.po b/po/eu.po index ca17e4965..e4dc5dc77 100644 --- a/po/eu.po +++ b/po/eu.po @@ -1,4 +1,4 @@ -# Language X translations for com.mitchellh.ghostty package. +# Basque translations for com.mitchellh.ghostty package. # Copyright (C) 2026 "Mitchell Hashimoto, Ghostty contributors" # This file is distributed under the same license as the com.mitchellh.ghostty package. # Mikel Larreategi , 2026. From 484d6ec66b0c27808341c05eb71416a39517ad03 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 4 May 2026 19:59:09 -0400 Subject: [PATCH 007/137] cli: add an ssh-wrapping +ssh action Add a drop-in `ssh` wrapper that sets up the remote environment for Ghostty. Anything not consumed as one of our own flags is forwarded to the real, wrapped `ssh` binary. It can be used directly (`ghostty +ssh user@host`), aliased (`alias ssh='ghostty +ssh --'`), or invoked through Ghostty's shell integration. Before exec'ing ssh, `+ssh`: - Forwards Ghostty environment to the remote (`--forward-env`): sets TERM=xterm-256color and requests SendEnv forwarding of COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION. - Installs Ghostty's terminfo on the remote (`--terminfo`), informed by our existing `ssh-cache` system and using our internal xterm-ghostty terminfo representation. A third flag, `--cache`, controls cache use; `--cache=false` bypasses both read and write, which is useful for scripting and for debugging install failures without polluting the cache. For shell integration, this replaces the per-shell logic (which made up roughly a third of our shell integration scripts) with a simple wrapper function that translates GHOSTTY_SHELL_FEATURES into a `ghostty +ssh` command line. This commit only migrates the bash integration; the other shells will follow separately. --- src/cli/ghostty.zig | 6 + src/cli/ssh.zig | 635 ++++++++++++++++++++++++ src/shell-integration/bash/ghostty.bash | 71 +-- 3 files changed, 649 insertions(+), 63 deletions(-) create mode 100644 src/cli/ssh.zig diff --git a/src/cli/ghostty.zig b/src/cli/ghostty.zig index 3acb90043..d86394daa 100644 --- a/src/cli/ghostty.zig +++ b/src/cli/ghostty.zig @@ -11,6 +11,7 @@ const list_keybinds = @import("list_keybinds.zig"); const list_themes = @import("list_themes.zig"); const list_colors = @import("list_colors.zig"); const list_actions = @import("list_actions.zig"); +const ssh = @import("ssh.zig"); const ssh_cache = @import("ssh_cache.zig"); const edit_config = @import("edit_config.zig"); const show_config = @import("show_config.zig"); @@ -46,6 +47,9 @@ pub const Action = enum { /// List keybind actions @"list-actions", + /// Wrap `ssh` to configure Ghostty terminal integration on remote hosts + ssh, + /// Manage SSH terminfo cache for automatic remote host setup @"ssh-cache", @@ -144,6 +148,7 @@ pub const Action = enum { .@"list-colors" => try list_colors.run(alloc), .@"list-actions" => try list_actions.run(alloc), .@"ssh-cache" => try ssh_cache.run(alloc), + .ssh => try ssh.run(alloc), .@"edit-config" => try edit_config.run(alloc), .@"show-config" => try show_config.run(alloc), .@"explain-config" => try explain_config.run(alloc), @@ -184,6 +189,7 @@ pub const Action = enum { .@"list-colors" => list_colors.Options, .@"list-actions" => list_actions.Options, .@"ssh-cache" => ssh_cache.Options, + .ssh => ssh.Options, .@"edit-config" => edit_config.Options, .@"show-config" => show_config.Options, .@"explain-config" => explain_config.Options, diff --git a/src/cli/ssh.zig b/src/cli/ssh.zig new file mode 100644 index 000000000..7f808a6cd --- /dev/null +++ b/src/cli/ssh.zig @@ -0,0 +1,635 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const cli_args = @import("args.zig"); +const diagnostics = @import("diagnostics.zig"); +const Action = @import("ghostty.zig").Action; +const DiskCache = @import("ssh_cache.zig").DiskCache; +const internal_os = @import("../os/main.zig"); +const ghostty_terminfo = @import("../terminfo/main.zig").ghostty; + +const log = std.log.scoped(.ssh); + +const usage = + \\Usage: ghostty +ssh [flags] [--] + \\ + \\Flags: + \\ --forward-env[=bool] Enable TERM / SendEnv forwarding. Default: true. + \\ --terminfo[=bool] Install Ghostty terminfo on first connect. Default: true. + \\ --cache[=bool] Use the terminfo install cache. Default: true. + \\ --ssh= Path to the ssh binary. Default: first `ssh` on PATH. + \\ --verbose Print +ssh status lines to stderr. + \\ --help Show full help. + \\ + \\ssh flags and the destination go after +ssh's own flags (or after `--`). + \\ +; + +pub const Options = struct { + /// Set by the CLI parser for deinit. + _arena: ?ArenaAllocator = null, + + /// Maps to the `ssh-env` shell integration feature. + @"forward-env": bool = true, + + /// Maps to the `ssh-terminfo` shell integration feature. + terminfo: bool = true, + + /// When false, both cache read and write are bypassed. + cache: bool = true, + + /// The wrapped `ssh` binary. + /// `/`-containing values are treated as paths; otherwise resolved via PATH. + ssh: []const u8 = "ssh", + + /// When true, print verbose output to stderr. + verbose: bool = false, + + /// Arguments passed through to `ssh` verbatim. Populated by + /// `parseManuallyHook` when we reach the first non-flag argument (or + /// an explicit `--`). + _ssh_args: std.ArrayList([]const u8) = .empty, + + /// Enables arg parsing diagnostics so unknown flags become + /// diagnostics rather than fatal errors. + _diagnostics: diagnostics.DiagnosticList = .{}, + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } + + /// Enables `-h` and `--help` to work. + pub fn help(_: Options) !void { + return Action.help_error; + } + + /// Manual parse hook. For each argument: + /// - If it's a literal `--`, consume everything after it as ssh + /// args and stop parsing. + /// - If it doesn't start with `--`, this is the start of the ssh + /// argv. Consume this arg and everything after as ssh args and + /// stop parsing. + /// - Otherwise (a `--foo` arg), return true so the generic parser + /// handles it as one of our own flags. + pub fn parseManuallyHook( + self: *Options, + alloc: Allocator, + arg: []const u8, + iter: anytype, + ) Allocator.Error!bool { + if (std.mem.eql(u8, arg, "--")) { + while (iter.next()) |rest| { + try self._ssh_args.append(alloc, try alloc.dupe(u8, rest)); + } + return false; + } + + if (!std.mem.startsWith(u8, arg, "--")) { + try self._ssh_args.append(alloc, try alloc.dupe(u8, arg)); + while (iter.next()) |rest| { + try self._ssh_args.append(alloc, try alloc.dupe(u8, rest)); + } + return false; + } + + return true; + } +}; + +/// Wrap `ssh` to automatically configure Ghostty terminal integration on +/// remote hosts. +/// +/// Any arguments that aren't recognized as `+ssh` flags are passed to +/// the real `ssh` binary unchanged. You can use `--` as an explicit +/// disambiguator if needed, though it's almost never required: `ssh` +/// has no long flags, and `+ssh` defines no short flags, so there's +/// nothing to collide. +/// +/// This is typically called via Ghostty's shell integration. When +/// `shell-integration-features` includes `ssh-env` or `ssh-terminfo`, +/// each shell defines an `ssh` function that runs: +/// +/// ghostty +ssh -- "$@" +/// +/// You can also run `ghostty +ssh` directly, or alias it yourself (e.g. +/// `alias ssh='ghostty +ssh --'`) if you prefer not to use the shell +/// integration. +/// +/// `+ssh` performs up to two pieces of setup before launching `ssh`: +/// +/// 1. **Environment forwarding** (`--forward-env`). Sets `TERM` to +/// `xterm-256color` and requests `SendEnv` forwarding of +/// `COLORTERM`, `TERM_PROGRAM`, and `TERM_PROGRAM_VERSION` so the +/// remote shell can still detect that it's running inside Ghostty. +/// The remote `sshd_config` must list these in `AcceptEnv` for +/// forwarding to succeed. +/// +/// 2. **Terminfo install** (`--terminfo`). On the first connection to a +/// given destination, installs Ghostty's terminfo entry on the remote +/// host using `infocmp -x xterm-ghostty | ssh tic -x -` over a +/// shared `ControlMaster` connection. Successful installs are cached +/// (see `ghostty +ssh-cache`) so subsequent connections skip this +/// step. When terminfo is successfully installed or already cached, +/// `TERM` is set to `xterm-ghostty` instead of `xterm-256color`. +/// +/// If `--terminfo` install fails (e.g. `tic` not available on the +/// remote, filesystem permissions), a warning is logged and the +/// connection continues with `TERM=xterm-256color`. +/// +/// Flags: +/// +/// * `--forward-env=`: Enable `TERM` / `SendEnv` environment +/// forwarding. Default: `true`. +/// +/// * `--terminfo=`: Enable automatic terminfo install on first +/// connection. Default: `true`. +/// +/// * `--cache=`: Use the terminfo install cache. Default: `true`. +/// When `false`, both the cache read (skip-if-installed) and the +/// cache write (record-on-success) are bypassed, and every +/// connection performs the install. To one-shot reinstall a single +/// host while keeping the cache in use, prefer `ghostty +ssh-cache +/// --remove=` followed by a normal connection. +/// +/// * `--ssh=`: Path to the `ssh` binary to execute. Default: the +/// first `ssh` found on `PATH`. +/// +/// * `--verbose`: Print +ssh status lines to stderr, and surface +/// remote stderr during the terminfo install. +/// +/// Examples: +/// +/// # Basic invocation using defaults: +/// ghostty +ssh user@example.com +/// +/// # Forward Ghostty env vars but skip the terminfo install: +/// ghostty +ssh --terminfo=false user@example.com +/// +/// # `ssh` flags (short-form `-p`, etc.) pass through unchanged: +/// ghostty +ssh -p 2222 -i ~/.ssh/id_ed25519 user@example.com +/// +/// # Use `--` explicitly if your ssh args might collide with our flags: +/// ghostty +ssh -- --some-rare-ssh-arg user@example.com +/// +/// Pass `--verbose` to see what `+ssh` is doing. For cache inspection +/// and management, see `ghostty +ssh-cache`. +/// +/// Available since: 1.4.0 +pub fn run(alloc_gpa: Allocator) !u8 { + var opts: Options = .{}; + defer opts.deinit(); + + { + var iter = try cli_args.argsIterator(alloc_gpa); + defer iter.deinit(); + try cli_args.parse(Options, alloc_gpa, &opts, &iter); + } + + var stderr_buffer: [1024]u8 = undefined; + var stderr_file: std.fs.File = .stderr(); + var stderr_writer = stderr_file.writer(&stderr_buffer); + const stderr = &stderr_writer.interface; + + // Any diagnostic from the arg parser is an unknown flag or bad + // value. Reject loudly — silently forwarding `--typo` to ssh would + // produce confusing downstream errors. + if (!opts._diagnostics.empty()) { + for (opts._diagnostics.items()) |diag| { + if (diag.key.len > 0) { + stderr.print( + "Error: unknown flag `--{s}`.\n", + .{diag.key}, + ) catch {}; + } else { + stderr.print("Error: {s}\n", .{diag.message}) catch {}; + } + } + stderr.print("\n{s}", .{usage}) catch {}; + stderr.flush() catch {}; + return 2; + } + + const result = runInner(alloc_gpa, &opts, stderr); + + stderr.flush() catch {}; + return result; +} + +fn runInner( + gpa: Allocator, + opts: *const Options, + stderr: *std.Io.Writer, +) !u8 { + var arena = ArenaAllocator.init(gpa); + defer arena.deinit(); + const alloc = arena.allocator(); + + if (opts._ssh_args.items.len == 0) { + try stderr.print("Error: no ssh arguments provided.\n\n{s}", .{usage}); + return 2; + } + + const session: struct { + term: []const u8, + to_cache: ?struct { cache: DiskCache, dest: []const u8 } = null, + } = session: { + if (!opts.terminfo) break :session .{ .term = "xterm-256color" }; + + const dest = resolveDestination(alloc, opts.ssh, opts._ssh_args.items) orelse { + warnPrint(stderr, "could not resolve ssh destination; skipping terminfo install", .{}); + break :session .{ .term = "xterm-256color" }; + }; + + const cache: ?DiskCache = if (opts.cache) cache: { + const path = DiskCache.defaultPath(alloc, "ghostty") catch |err| { + warnPrint(stderr, "ghostty terminfo cache unavailable: {}", .{err}); + break :session .{ .term = "xterm-256color" }; + }; + break :cache .{ .path = path }; + } else null; + + if (cache) |c| { + if (c.contains(alloc, dest) catch false) { + verbosePrint(opts, stderr, "dest: {s} (cached, skipping install)", .{dest}); + break :session .{ .term = "xterm-ghostty" }; + } else { + verbosePrint(opts, stderr, "dest: {s} (not cached, will install)", .{dest}); + } + } else { + verbosePrint(opts, stderr, "dest: {s} (cache disabled, will install)", .{dest}); + } + + stderr.print("Setting up xterm-ghostty terminfo on {s}...\n", .{dest}) catch {}; + stderr.flush() catch {}; + + installRemoteTerminfo(alloc, opts, stderr) catch |err| { + warnPrint(stderr, "failed to install terminfo: {}", .{err}); + break :session .{ .term = "xterm-256color" }; + }; + break :session .{ + .term = "xterm-ghostty", + .to_cache = if (cache) |c| .{ .cache = c, .dest = dest } else null, + }; + }; + + // Build the full argv: [ssh, ...our opts, ...user args] + const env_opts: []const []const u8 = if (opts.@"forward-env") env_opts: { + const set_term = try std.fmt.allocPrint( + alloc, + "SetEnv=TERM={s}", + .{session.term}, + ); + break :env_opts &.{ + "-o", set_term, + "-o", "SendEnv=COLORTERM", + "-o", "SendEnv=TERM_PROGRAM", + "-o", "SendEnv=TERM_PROGRAM_VERSION", + }; + } else &.{}; + const argv = try std.mem.concat(alloc, []const u8, &.{ + &.{opts.ssh}, + env_opts, + opts._ssh_args.items, + }); + verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }}); + + const exit_code = childExec(alloc, argv) catch |err| { + try stderr.print("Error: failed to run {s}: {}\n", .{ argv[0], err }); + return 1; + }; + verbosePrint(opts, stderr, "exit: {d}", .{exit_code}); + + // Attempt to cache (if needed) on a successful ssh execution. + if (exit_code == 0) if (session.to_cache) |entry| { + if (entry.cache.add(alloc, entry.dest)) |_| { + verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest}); + } else |err| { + log.debug("cache add failed for '{s}': {}", .{ entry.dest, err }); + } + }; + + return exit_code; +} + +/// Log to `.ssh` and, if `--verbose`, also print to stderr. +fn verbosePrint( + opts: *const Options, + stderr: *std.Io.Writer, + comptime fmt: []const u8, + args: anytype, +) void { + log.debug(fmt, args); + if (!opts.verbose) return; + stderr.print("+ssh: " ++ fmt ++ "\n", args) catch return; + stderr.flush() catch return; +} + +/// Log a warning and also print a `Warning: ` line to stderr. +fn warnPrint( + stderr: *std.Io.Writer, + comptime fmt: []const u8, + args: anytype, +) void { + log.warn(fmt, args); + stderr.print("Warning: " ++ fmt ++ "\n", args) catch return; + stderr.flush() catch return; +} + +/// Space-joined items, formattable as `{f}`. +const Joined = struct { + items: []const []const u8, + + pub fn format(self: Joined, writer: *std.Io.Writer) !void { + for (self.items, 0..) |a, i| { + if (i > 0) try writer.writeByte(' '); + try writer.writeAll(a); + } + } + + test { + const testing = std.testing; + var buf: [128]u8 = undefined; + { + var w: std.Io.Writer = .fixed(&buf); + try w.print("{f}", .{Joined{ .items = &.{} }}); + try testing.expectEqualStrings("", buf[0..w.end]); + } + { + var w: std.Io.Writer = .fixed(&buf); + try w.print("{f}", .{Joined{ .items = &.{"only"} }}); + try testing.expectEqualStrings("only", buf[0..w.end]); + } + { + var w: std.Io.Writer = .fixed(&buf); + try w.print("{f}", .{Joined{ .items = &.{ "a", "b", "c" } }}); + try testing.expectEqualStrings("a b c", buf[0..w.end]); + } + } +}; + +fn checkExit(term: std.process.Child.Term, label: []const u8) error{ChildFailed}!void { + switch (term) { + .Exited => |rc| if (rc != 0) { + log.warn("{s} exited with non-zero status: {d}", .{ label, rc }); + return error.ChildFailed; + }, + else => { + log.warn("{s} terminated abnormally: {}", .{ label, term }); + return error.ChildFailed; + }, + } +} + +/// Run `ssh -G ` and parse the output for `user` and `hostname`. +/// Returns the resolved `user@hostname`, or null if the destination +/// could not be resolved. +fn resolveDestination( + alloc: Allocator, + ssh: []const u8, + args: []const []const u8, +) ?[]const u8 { + const argv = std.mem.concat(alloc, []const u8, &.{ + &.{ ssh, "-G" }, + args, + }) catch return null; + const result = std.process.Child.run(.{ + .allocator = alloc, + .argv = argv, + }) catch |err| { + log.warn("ssh -G spawn failed: {}", .{err}); + return null; + }; + checkExit(result.term, "ssh -G") catch return null; + return parseDestination(alloc, result.stdout); +} + +/// Parse `ssh -G` output for `user` and `hostname` and return the +/// formatted `user@hostname`. Returns null if either key is missing +/// or formatting fails. +fn parseDestination(alloc: Allocator, stdout: []const u8) ?[]const u8 { + var user: []const u8 = ""; + var host: []const u8 = ""; + var it = std.mem.tokenizeScalar(u8, stdout, '\n'); + while (it.next()) |line| { + const space = std.mem.indexOfScalar(u8, line, ' ') orelse continue; + const key = line[0..space]; + const value = line[space + 1 ..]; + if (std.mem.eql(u8, key, "user")) { + user = value; + } else if (std.mem.eql(u8, key, "hostname")) { + host = value; + } + if (user.len > 0 and host.len > 0) break; + } + + if (user.len == 0) { + log.warn("ssh -G output missing user", .{}); + return null; + } + if (host.len == 0) { + log.warn("ssh -G output missing hostname", .{}); + return null; + } + + return std.fmt.allocPrint(alloc, "{s}@{s}", .{ user, host }) catch null; +} + +/// Install Ghostty's terminfo on the remote host over a short-lived SSH +/// ControlMaster connection. The master tears down with the client +/// (`ControlPersist=no`) so no socket lingers. +fn installRemoteTerminfo( + alloc: Allocator, + opts: *const Options, + stderr: *std.Io.Writer, +) !void { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try ghostty_terminfo.encode(&buf.writer); + const terminfo = buf.written(); + + // ControlPath is in TMPDIR with a short, random basename. ssh uses + // ControlPath as the bind address for a Unix domain socket; macOS + // limits sockaddr_un.sun_path to ~104 bytes, so keeping the path + // short leaves margin. + const control_path = try internal_os.randomTmpPath(alloc, "ghostty-ssh-"); + const control_path_opt = try std.fmt.allocPrint( + alloc, + "ControlPath={s}", + .{control_path}, + ); + + // Under --verbose, let remote stderr through (the `tic` step is + // the most common failure source) and inherit ssh's stderr so it + // reaches the user's terminal. Other steps stay quiet either way. + const remote_script = if (opts.verbose) + \\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + \\command -v tic >/dev/null 2>&1 || exit 1 + \\mkdir -p ~/.terminfo 2>/dev/null && tic -x - && exit 0 + \\exit 1 + else + \\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + \\command -v tic >/dev/null 2>&1 || exit 1 + \\mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + \\exit 1 + ; + + // Set up an SSH ControlMaster scoped to this single install: + // - ControlMaster=yes makes our client also act as the master, + // so `infocmp | ssh tic` runs over a single connection. + // - ControlPersist=no tears the master down when our client + // exits; no socket lingers on the remote side. + const argv = try std.mem.concat(alloc, []const u8, &.{ + &.{opts.ssh}, + &.{ + "-o", "ControlMaster=yes", + "-o", "ControlPersist=no", + "-o", control_path_opt, + }, + opts._ssh_args.items, + &.{remote_script}, + }); + verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }}); + + var child: std.process.Child = .init(argv, alloc); + child.stdin_behavior = .Pipe; + child.stdout_behavior = .Ignore; + child.stderr_behavior = if (opts.verbose) .Inherit else .Ignore; + + child.spawn() catch |err| { + log.warn("terminfo install spawn failed: {}", .{err}); + return error.InstallFailed; + }; + + if (child.stdin) |stdin| { + stdin.writeAll(terminfo) catch {}; + stdin.close(); + child.stdin = null; + } + + const term = child.wait() catch |err| { + log.warn("terminfo install wait failed: {}", .{err}); + return error.InstallFailed; + }; + checkExit(term, "terminfo install") catch return error.InstallFailed; +} + +/// Returns `128 + signum` for signal-killed children, matching shell convention. +fn childExec(alloc: Allocator, argv: []const []const u8) !u8 { + var child: std.process.Child = .init(argv, alloc); + child.stdin_behavior = .Inherit; + child.stdout_behavior = .Inherit; + child.stderr_behavior = .Inherit; + + try child.spawn(); + const term = try child.wait(); + return switch (term) { + .Exited => |rc| rc, + .Signal => |sig| @as(u8, 128) + @as(u8, @intCast(@min(sig, 127))), + .Stopped, .Unknown => 1, + }; +} + +fn parseTestArgs(alloc: Allocator, opts: *Options, line: []const u8) !void { + var iter = try std.process.ArgIteratorGeneral(.{}).init(alloc, line); + defer iter.deinit(); + try cli_args.parse(Options, alloc, opts, &iter); +} + +test "parseManuallyHook: bare destination starts ssh args" { + const testing = std.testing; + var opts: Options = .{}; + defer opts.deinit(); + try parseTestArgs(testing.allocator, &opts, "--terminfo=false user@example.com"); + try testing.expectEqual(false, opts.terminfo); + try testing.expectEqual(true, opts.@"forward-env"); + try testing.expectEqual(@as(usize, 1), opts._ssh_args.items.len); + try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[0]); +} + +test "parseManuallyHook: short ssh flags pass through verbatim" { + const testing = std.testing; + var opts: Options = .{}; + defer opts.deinit(); + try parseTestArgs(testing.allocator, &opts, "-p 2222 user@example.com"); + try testing.expectEqual(@as(usize, 3), opts._ssh_args.items.len); + try testing.expectEqualStrings("-p", opts._ssh_args.items[0]); + try testing.expectEqualStrings("2222", opts._ssh_args.items[1]); + try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[2]); +} + +test "parseManuallyHook: explicit -- separator" { + const testing = std.testing; + var opts: Options = .{}; + defer opts.deinit(); + try parseTestArgs( + testing.allocator, + &opts, + "--verbose -- --some-rare-ssh-arg user@example.com", + ); + try testing.expectEqual(true, opts.verbose); + try testing.expectEqual(@as(usize, 2), opts._ssh_args.items.len); + try testing.expectEqualStrings("--some-rare-ssh-arg", opts._ssh_args.items[0]); + try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[1]); +} + +test "parseDestination: typical ssh -G output" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const stdout = + \\user alice + \\hostname example.com + \\port 22 + \\identityfile ~/.ssh/id_ed25519 + \\ + ; + const result = parseDestination(arena.allocator(), stdout); + try testing.expectEqualStrings("alice@example.com", result.?); +} + +test "parseDestination: hostname before user" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const stdout = + \\hostname example.com + \\port 22 + \\user alice + \\ + ; + const result = parseDestination(arena.allocator(), stdout); + try testing.expectEqualStrings("alice@example.com", result.?); +} + +test "parseDestination: missing hostname returns null" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const stdout = "user alice\nport 22\n"; + try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout)); +} + +test "parseDestination: missing user returns null" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const stdout = "hostname example.com\nport 22\n"; + try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout)); +} + +test "parseDestination: empty input returns null" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), "")); +} + +test "parseDestination: IPv6 hostname" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const stdout = "user alice\nhostname ::1\n"; + const result = parseDestination(arena.allocator(), stdout); + try testing.expectEqualStrings("alice@::1", result.?); +} diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 7eaf1397b..729951ab9 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -115,71 +115,16 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then fi # SSH Integration +# +# Wrap `ssh` with `ghostty +ssh` and translate the shell-integration +# feature flags into command options. if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then function ssh() { - builtin local ssh_term ssh_opts - ssh_term="xterm-256color" - ssh_opts=() - - # Configure environment variables for remote session - if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then - ssh_opts+=(-o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION") - fi - - # Install terminfo on remote host if needed - if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then - builtin local ssh_user ssh_hostname - - while IFS=' ' read -r ssh_key ssh_value; do - case "$ssh_key" in - user) ssh_user="$ssh_value" ;; - hostname) ssh_hostname="$ssh_value" ;; - esac - [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break - done < <(builtin command ssh -G "$@" 2>/dev/null) - - if [[ -n "$ssh_hostname" ]]; then - builtin local ssh_target="${ssh_user}@${ssh_hostname}" - - # Check if terminfo is already cached - if "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then - ssh_term="xterm-ghostty" - elif builtin command -v infocmp >/dev/null 2>&1; then - builtin local ssh_terminfo ssh_cpath_dir ssh_cpath - - ssh_terminfo=$(infocmp -0 -x xterm-ghostty 2>/dev/null) - - if [[ -n "$ssh_terminfo" ]]; then - builtin echo "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2 - - ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" - ssh_cpath="$ssh_cpath_dir/socket" - - if builtin echo "$ssh_terminfo" | builtin command ssh -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' 2>/dev/null; then - ssh_term="xterm-ghostty" - ssh_opts+=(-o "ControlPath=$ssh_cpath") - - # Cache successful installation - "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true - else - builtin echo "Warning: Failed to install terminfo." >&2 - fi - else - builtin echo "Warning: Could not generate terminfo data." >&2 - fi - else - builtin echo "Warning: ghostty command not available for cache management." >&2 - fi - fi - fi - - # Execute SSH with TERM environment variable - TERM="$ssh_term" COLORTERM=truecolor builtin command ssh "${ssh_opts[@]}" "$@" + builtin local -a flags + flags=() + [[ "$GHOSTTY_SHELL_FEATURES" != *ssh-env* ]] && flags+=(--forward-env=false) + [[ "$GHOSTTY_SHELL_FEATURES" != *ssh-terminfo* ]] && flags+=(--terminfo=false) + "$GHOSTTY_BIN_DIR/ghostty" +ssh "${flags[@]}" -- "$@" } fi From 2d112059a7e87104a483866ff472638e3ebd81b1 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 5 May 2026 08:09:58 -0400 Subject: [PATCH 008/137] zsh: replace ssh wrapper with ghostty +ssh Replace the inline ssh integration with a thin wrapper that translates GHOSTTY_SHELL_FEATURES into a `ghostty +ssh` command line. The shell wrapper no longer carries terminfo install, ControlMaster wiring, or cache bookkeeping; it just maps the feature flags to flags on `+ssh` and forwards everything else. --- src/shell-integration/zsh/ghostty-integration | 73 ++----------------- 1 file changed, 7 insertions(+), 66 deletions(-) diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 76c5ce246..a96ff4bb0 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -311,74 +311,15 @@ _ghostty_deferred_init() { fi # SSH Integration + # + # Wrap `ssh` with `ghostty +ssh` and translate the shell-integration + # feature flags into command options. if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then function ssh() { - emulate -L zsh - setopt local_options no_glob_subst - - local ssh_term ssh_opts - ssh_term="xterm-256color" - ssh_opts=() - - # Configure environment variables for remote session - if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then - ssh_opts+=(-o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION") - fi - - # Install terminfo on remote host if needed - if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then - local ssh_user ssh_hostname - - while IFS=' ' read -r ssh_key ssh_value; do - case "$ssh_key" in - user) ssh_user="$ssh_value" ;; - hostname) ssh_hostname="$ssh_value" ;; - esac - [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break - done < <(command ssh -G "$@" 2>/dev/null) - - if [[ -n "$ssh_hostname" ]]; then - local ssh_target="${ssh_user}@${ssh_hostname}" - - # Check if terminfo is already cached - if "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then - ssh_term="xterm-ghostty" - elif (( $+commands[infocmp] )); then - local ssh_terminfo ssh_cpath_dir ssh_cpath - - ssh_terminfo=$(infocmp -0 -x xterm-ghostty 2>/dev/null) - - if [[ -n "$ssh_terminfo" ]]; then - print "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2 - - ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" - ssh_cpath="$ssh_cpath_dir/socket" - - if builtin print -r "$ssh_terminfo" | command ssh "${ssh_opts[@]}" -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' 2>/dev/null; then - ssh_term="xterm-ghostty" - ssh_opts+=(-o "ControlPath=$ssh_cpath") - - # Cache successful installation - "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true - else - print "Warning: Failed to install terminfo." >&2 - fi - else - print "Warning: Could not generate terminfo data." >&2 - fi - else - print "Warning: ghostty command not available for cache management." >&2 - fi - fi - fi - - # Execute SSH with TERM environment variable - TERM="$ssh_term" COLORTERM=truecolor command ssh "${ssh_opts[@]}" "$@" + local flags=() + [[ "$GHOSTTY_SHELL_FEATURES" != *ssh-env* ]] && flags+=(--forward-env=false) + [[ "$GHOSTTY_SHELL_FEATURES" != *ssh-terminfo* ]] && flags+=(--terminfo=false) + "$GHOSTTY_BIN_DIR/ghostty" +ssh $flags -- "$@" } fi From 283dca130e02945aed1d3f2eae43676d37782717 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 5 May 2026 08:10:30 -0400 Subject: [PATCH 009/137] fish: replace ssh wrapper with ghostty +ssh Replace the inline ssh integration with a thin wrapper that translates GHOSTTY_SHELL_FEATURES into a `ghostty +ssh` command line. --- .../ghostty-shell-integration.fish | 81 ++----------------- 1 file changed, 7 insertions(+), 74 deletions(-) diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish index f8bfe0910..e0360b5ac 100644 --- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -120,84 +120,17 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end # SSH Integration + # + # Wrap `ssh` with `ghostty +ssh` and translate the shell-integration + # feature flags into command options. set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES") if contains ssh-env $features; or contains ssh-terminfo $features function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration" set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES") - set -l ssh_term xterm-256color - set -l ssh_opts - - # Configure environment variables for remote session - if contains ssh-env $features - set -a ssh_opts -o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION" - end - - # Install terminfo on remote host if needed - if contains ssh-terminfo $features - set -l ssh_user - set -l ssh_hostname - - for line in (command ssh -G $argv 2>/dev/null) - set -l parts (string split ' ' -- $line) - if test (count $parts) -ge 2 - switch $parts[1] - case user - set ssh_user $parts[2] - case hostname - set ssh_hostname $parts[2] - end - if test -n "$ssh_user"; and test -n "$ssh_hostname" - break - end - end - end - - if test -n "$ssh_hostname" - set -l ssh_target "$ssh_user@$ssh_hostname" - - # Check if terminfo is already cached - if test -x "$GHOSTTY_BIN_DIR/ghostty"; and "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1 - set ssh_term xterm-ghostty - else if command -q infocmp - set -l ssh_terminfo - set -l ssh_cpath_dir - set -l ssh_cpath - - set ssh_terminfo "$(infocmp -0 -x xterm-ghostty 2>/dev/null)" - - if test -n "$ssh_terminfo" - echo "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2 - - set ssh_cpath_dir (mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null; or echo "/tmp/ghostty-ssh-$ssh_user."(random)) - set ssh_cpath "$ssh_cpath_dir/socket" - - if echo "$ssh_terminfo" | command ssh $ssh_opts -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s $argv ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' 2>/dev/null - set ssh_term xterm-ghostty - set -a ssh_opts -o "ControlPath=$ssh_cpath" - - # Cache successful installation - if test -x "$GHOSTTY_BIN_DIR/ghostty" - "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1; or true - end - else - echo "Warning: Failed to install terminfo." >&2 - end - else - echo "Warning: Could not generate terminfo data." >&2 - end - else - echo "Warning: ghostty command not available for cache management." >&2 - end - end - end - - # Execute SSH with TERM environment variable - TERM="$ssh_term" COLORTERM=truecolor command ssh $ssh_opts $argv + set -l flags + contains ssh-env $features; or set -a flags --forward-env=false + contains ssh-terminfo $features; or set -a flags --terminfo=false + "$GHOSTTY_BIN_DIR/ghostty" +ssh $flags -- $argv end end From e5378107eb8ffff72ba98eab45092b569f56bcf0 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 5 May 2026 08:11:22 -0400 Subject: [PATCH 010/137] elvish: replace ssh wrapper with ghostty +ssh Replace the inline ssh integration with a thin wrapper that translates GHOSTTY_SHELL_FEATURES into a `ghostty +ssh` command line. --- .../elvish/lib/ghostty-integration.elv | 82 +++---------------- 1 file changed, 11 insertions(+), 71 deletions(-) diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 31ebf0941..fc334c378 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -76,80 +76,20 @@ (external sudo) $@args } + # SSH Integration + # + # Wrap `ssh` with `ghostty +ssh` and translate the shell-integration + # feature flags into command options. fn ssh-integration {|@args| - var ssh-term = "xterm-256color" - var ssh-opts = [] - - # Configure environment variables for remote session - if (has-value $features ssh-env) { - set ssh-opts = (conj $ssh-opts ^ - -o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION") + var ghostty = $E:GHOSTTY_BIN_DIR/"ghostty" + var flags = [] + if (not (has-value $features ssh-env)) { + set flags = (conj $flags --forward-env=false) } - - if (has-value $features ssh-terminfo) { - var ssh-user = "" - var ssh-hostname = "" - - # Parse ssh config - for line [((external ssh) -G $@args)] { - var parts = [(str:fields $line)] - if (> (count $parts) 1) { - var ssh-key = $parts[0] - var ssh-value = $parts[1] - if (eq $ssh-key user) { - set ssh-user = $ssh-value - } elif (eq $ssh-key hostname) { - set ssh-hostname = $ssh-value - } - if (and (not-eq $ssh-user "") (not-eq $ssh-hostname "")) { - break - } - } - } - - if (not-eq $ssh-hostname "") { - var ghostty = $E:GHOSTTY_BIN_DIR/"ghostty" - var ssh-target = $ssh-user"@"$ssh-hostname - - # Check if terminfo is already cached - if (bool ?($ghostty +ssh-cache --host=$ssh-target)) { - set ssh-term = "xterm-ghostty" - } elif (has-external infocmp) { - var ssh-terminfo = ((external infocmp) -0 -x xterm-ghostty 2>/dev/null | slurp) - - if (not-eq $ssh-terminfo "") { - echo "Setting up xterm-ghostty terminfo on "$ssh-hostname"..." >&2 - - use os - var ssh-cpath-dir = (os:temp-dir "ghostty-ssh-"$ssh-user".*") - var ssh-cpath = $ssh-cpath-dir"/socket" - - if (bool ?(echo $ssh-terminfo | (external ssh) $@ssh-opts -o ControlMaster=yes -o ControlPath=$ssh-cpath -o ControlPersist=60s $@args ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - ' 2>/dev/null)) { - set ssh-term = "xterm-ghostty" - set ssh-opts = (conj $ssh-opts -o ControlPath=$ssh-cpath) - - # Cache successful installation - $ghostty +ssh-cache --add=$ssh-target >/dev/null - } else { - echo "Warning: Failed to install terminfo." >&2 - } - } else { - echo "Warning: Could not generate terminfo data." >&2 - } - } else { - echo "Warning: ghostty command not available for cache management." >&2 - } - } - } - - with [E:TERM = $ssh-term E:COLORTERM = truecolor] { - (external ssh) $@ssh-opts $@args + if (not (has-value $features ssh-terminfo)) { + set flags = (conj $flags --terminfo=false) } + $ghostty +ssh $@flags -- $@args } defer { From ac103b8f75f5206af3cc98deecc2b7d0f9705683 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 5 May 2026 08:12:07 -0400 Subject: [PATCH 011/137] nushell: replace ssh wrapper with ghostty +ssh Replace the inline ssh integration with a thin wrapper that translates GHOSTTY_SHELL_FEATURES into a `ghostty +ssh` command line. When no ssh-* feature is enabled, the wrapper falls through to the real `ssh` binary unchanged so nushell users without ssh integration get plain ssh behavior. --- .../nushell/vendor/autoload/ghostty.nu | 80 +++---------------- 1 file changed, 12 insertions(+), 68 deletions(-) diff --git a/src/shell-integration/nushell/vendor/autoload/ghostty.nu b/src/shell-integration/nushell/vendor/autoload/ghostty.nu index 17970f513..b2731f8a8 100644 --- a/src/shell-integration/nushell/vendor/autoload/ghostty.nu +++ b/src/shell-integration/nushell/vendor/autoload/ghostty.nu @@ -4,79 +4,23 @@ export module ghostty { $feature in ($env.GHOSTTY_SHELL_FEATURES | default "" | split row ',') } - # Wrap `ssh` with Ghostty TERMINFO support + # Wrap `ssh` with `ghostty +ssh` and translate the shell-integration + # feature flags into command options. export def --wrapped ssh [...args] { - mut ssh_env = {} - mut ssh_opts = [] - - # `ssh-env`: use xterm-256color and propagate COLORTERM/TERM_PROGRAM vars - if (has_feature "ssh-env") { - $ssh_env.TERM = "xterm-256color" - $ssh_env.COLORTERM = "truecolor" - $ssh_opts = [ - "-o" "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION" - ] + if not ((has_feature "ssh-env") or (has_feature "ssh-terminfo")) { + ^ssh ...$args + return } - # `ssh-terminfo`: auto-install xterm-ghostty terminfo on remote hosts - if (has_feature "ssh-terminfo") { - let ghostty = ($env.GHOSTTY_BIN_DIR? | default "") | path join "ghostty" - - let ssh_cfg = ^ssh -G ...$args - | lines - | parse "{key} {value}" - | where key in ["user" "hostname"] - | select key value - | transpose -rd - | default {user: $env.USER hostname: "localhost"} - let ssh_id = $"($ssh_cfg.user)@($ssh_cfg.hostname)" - - if (^$ghostty "+ssh-cache" $"--host=($ssh_id)" | complete | $in.exit_code == 0) { - $ssh_env.TERM = "xterm-ghostty" - } else { - $ssh_env.TERM = "xterm-256color" - - let terminfo = try { - ^infocmp -0 -x xterm-ghostty - } catch { - print -e "infocmp failed, using xterm-256color" - } - - if ($terminfo | is-not-empty) { - print $"Setting up xterm-ghostty terminfo on ($ssh_cfg.hostname)..." - - let ctrl_path = ( - mktemp -td $"ghostty-ssh-($ssh_cfg.user).XXXXXX" - | path join "socket" - ) - - let remote_args = $ssh_opts ++ [ - "-o" "ControlMaster=yes" - "-o" $"ControlPath=($ctrl_path)" - "-o" "ControlPersist=60s" - ] ++ $args - - $terminfo | ^ssh ...$remote_args ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1' - | complete - | if $in.exit_code == 0 { - ^$ghostty "+ssh-cache" $"--add=($ssh_id)" e>| print -e - $ssh_env.TERM = "xterm-ghostty" - $ssh_opts = ($ssh_opts ++ ["-o" $"ControlPath=($ctrl_path)"]) - } else { - print -e "terminfo install failed, using xterm-256color" - } - } - } + let ghostty = ($env.GHOSTTY_BIN_DIR? | default "") | path join "ghostty" + mut flags = [] + if not (has_feature "ssh-env") { + $flags = ($flags ++ ["--forward-env=false"]) } - - let ssh_args = $ssh_opts ++ $args - with-env $ssh_env { - ^ssh ...$ssh_args + if not (has_feature "ssh-terminfo") { + $flags = ($flags ++ ["--terminfo=false"]) } + ^$ghostty "+ssh" ...$flags "--" ...$args } # Wrap `sudo` to preserve Ghostty's TERMINFO environment variable From 57a9adce7164b7491bf333be2ee7b336c2b8f045 Mon Sep 17 00:00:00 2001 From: Daniel Kinzler Date: Tue, 5 May 2026 18:00:04 +0200 Subject: [PATCH 012/137] fix datastruct/SplitTree not calculating the correct new split ratio when resizing a split --- src/datastruct/split_tree.zig | 180 +++++++++++++++++++++++++++++++--- 1 file changed, 165 insertions(+), 15 deletions(-) diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index c7da8ad9b..1dc924ca7 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -860,25 +860,26 @@ pub fn SplitTree(comptime V: type) type { var sp = try result.spatial(gpa); defer sp.deinit(gpa); - // Get the ratio of the split relative to the full grid. - const full_ratio = full_ratio: { - // Our scale is the amount we need to multiply our individual - // ratio by to get the full ratio. Its actually a ratio on its - // own but I'm trying to avoid that word: its the ratio of - // our spatial width/height to the total. - const scale = switch (layout) { - .horizontal => sp.slots[parent_handle.idx()].width / sp.slots[0].width, - .vertical => sp.slots[parent_handle.idx()].height / sp.slots[0].height, - }; - - const current = result.nodes[parent_handle.idx()].split.ratio; - break :full_ratio current * scale; + // Our scale is the amount we need to divide our ratio delta by to + // get a delta relative to the split, not the entire grid. + // Its actually a ratio on its own but I'm trying to avoid that word: + // its the ratio of our spatial width/height to the total. + const scale = switch (layout) { + .horizontal => sp.slots[parent_handle.idx()].width / sp.slots[0].width, + .vertical => sp.slots[parent_handle.idx()].height / sp.slots[0].height, }; - // Set the final new ratio, clamping it to [0, 1] + // If the split has spatial width/height 0, resizing by a percentage + // of the total grid size doesn't make sense. + if (scale == 0) return result; + + // Adjust the old split ratio by the scaled ratio delta. + const new_ratio = result.nodes[parent_handle.idx()].split.ratio + (ratio / scale); + + // Set the new ratio, clamping it to [0, 1] result.resizeInPlace( parent_handle, - @min(@max(full_ratio + ratio, 0), 1), + @min(@max(new_ratio, 0), 1), ); return result; } @@ -2172,6 +2173,155 @@ test "SplitTree: resize" { } } +test "SplitTree: resize nested split" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + var v3: TestTree.View = .{ .label = "C" }; + var t3: TestTree = try .init(alloc, &v3); + defer t3.deinit(); + + // A | B vertical + var splitAB = try t1.split( + alloc, + .root, // at root + .down, // split down + 0.5, + &t2, // insert t2 + ); + defer splitAB.deinit(); + + var splitBC = try splitAB.split( + alloc, + at: { + var it = splitAB.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound; + }, + .down, // split down + 0.5, + &t3, // insert t3 + ); + defer splitBC.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(splitBC, .formatDiagram)}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| | + \\| | + \\| A | + \\| | + \\+---+ + \\+---+ + \\| B | + \\+---+ + \\+---+ + \\| C | + \\+---+ + \\ + ); + } + + // Resize + { + var resized = try splitBC.resize( + alloc, + at: { + var it = splitBC.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound; + }, + .vertical, // resize down + 0.125, + ); + defer resized.deinit(); + const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(resized, .formatDiagram)}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| | + \\| | + \\| | + \\| | + \\| | + \\| A | + \\| | + \\| | + \\| | + \\| | + \\+---+ + \\+---+ + \\| | + \\| | + \\| | + \\| B | + \\| | + \\| | + \\| | + \\+---+ + \\+---+ + \\| C | + \\+---+ + \\ + ); + } + + // Resize the other direction (negative ratio) + { + var resized = try splitBC.resize( + alloc, + at: { + var it = splitBC.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound; + }, + .vertical, // resize up + -0.0833, + ); + defer resized.deinit(); + const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(resized, .formatDiagram)}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| | + \\| | + \\| | + \\| A | + \\| | + \\| | + \\| | + \\+---+ + \\+---+ + \\| B | + \\+---+ + \\+---+ + \\| | + \\| | + \\| C | + \\| | + \\+---+ + \\ + ); + } +} + test "SplitTree: clone empty tree" { const testing = std.testing; const alloc = testing.allocator; From 3f11e695d0fd8f3cabc2c46c7a091c0509884b41 Mon Sep 17 00:00:00 2001 From: Riccardo Mazzarini Date: Wed, 6 May 2026 09:58:35 +0200 Subject: [PATCH 013/137] fix: apply variation selectors to preceding codepoint This fixes a bug where the variation selectors (VS15 & VS16) were checked against the first codepoint in a cell instead of the previous codepoint in the cell's grapheme cluster, causing them to be dropped if the first codepoint was not a valid base. --- src/terminal/Terminal.zig | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 6740a93e6..d41e47501 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -377,20 +377,20 @@ pub fn print(self: *Terminal, c: u21) !void { // necessarily a grapheme break. if (prev.cell.codepoint() == 0) break :grapheme; + var previous_codepoint: u21 = prev.cell.content.codepoint; const grapheme_break = brk: { var state: uucode.grapheme.BreakState = .default; - var cp1: u21 = prev.cell.content.codepoint; if (prev.cell.hasGrapheme()) { const cps = self.screens.active.cursor.page_pin.node.data.lookupGrapheme(prev.cell).?; for (cps) |cp2| { - // log.debug("cp1={x} cp2={x}", .{ cp1, cp2 }); - assert(!unicode.graphemeBreak(cp1, cp2, &state)); - cp1 = cp2; + // log.debug("cp1={x} cp2={x}", .{ previous_codepoint, cp2 }); + assert(!unicode.graphemeBreak(previous_codepoint, cp2, &state)); + previous_codepoint = cp2; } } - // log.debug("cp1={x} cp2={x} end", .{ cp1, c }); - break :brk unicode.graphemeBreak(cp1, c, &state); + // log.debug("cp1={x} cp2={x} end", .{ previous_codepoint, c }); + break :brk unicode.graphemeBreak(previous_codepoint, c, &state); }; // If we can NOT break, this means that "c" is part of a grapheme @@ -402,7 +402,7 @@ pub fn print(self: *Terminal, c: u21) !void { // the cell width accordingly. VS16 makes the character wide and // VS15 makes it narrow. if (c == 0xFE0F or c == 0xFE0E) { - const prev_props = unicode.table.get(prev.cell.content.codepoint); + const prev_props = unicode.table.get(previous_codepoint); // Check if it is a valid variation sequence in // emoji-variation-sequences.txt, and if not, ignore the char. if (!prev_props.emoji_vs_base) return; @@ -3318,7 +3318,7 @@ test "Terminal: zero-width character at start" { try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } -// https://github.com/ghostty-org/ghostty/issues/12581 +// https://github.com/ghostty-org/ghostty/pull/12581 test "Terminal: zero-width character attaches to pending wrap cell" { var t = try init(testing.allocator, .{ .cols = 2, .rows = 2 }); defer t.deinit(testing.allocator); @@ -3741,6 +3741,27 @@ test "Terminal: invalid VS16 doesn't mark dirty" { try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } +// https://github.com/ghostty-org/ghostty/pull/12596 +test "Terminal: variation selectors apply to preceding codepoint" { + var t = try init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // Pirate flag: black flag + ZWJ + skull and crossbones + VS16. + try t.print(0x1F3F4); + try t.print(0x200D); + try t.print(0x2620); + try t.print(0xFE0F); + + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F3F4), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x200D, 0x2620, 0xFE0F }, list_cell.node.data.lookupGrapheme(cell).?); +} + test "Terminal: print multicodepoint grapheme, mode 2027" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); From c44afa62506dd94b9dcc680da412ddb650931b3b Mon Sep 17 00:00:00 2001 From: Riccardo Mazzarini Date: Wed, 6 May 2026 14:52:36 +0200 Subject: [PATCH 014/137] fix: preserve active cursor position during reflow This PR fixes an issue where reflowing could leave the active cursor attached to a clipped trailing blank cell instead of following the current write position. --- src/terminal/PageList.zig | 41 +++++++++++++++++++++++++++++++-------- src/terminal/Screen.zig | 41 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 17eea73d0..89fdaec1f 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -935,6 +935,10 @@ pub const Resize = struct { pub const Cursor = struct { x: size.CellCountInt, y: size.CellCountInt, + + /// When set, this pin preserves right-side blank cells up to the cursor + /// during reflow. + pin: ?*Pin = null, }; }; @@ -1013,10 +1017,6 @@ fn resizeCols( ) Allocator.Error!void { assert(cols != self.cols); - // Update our cols. We have to do this early because grow() that we - // may call below relies on this to calculate the proper page size. - self.cols = cols; - // If we have a cursor position (x,y), then we try under any col resizing // to keep the same number remaining active rows beneath it. This is a // very special case if you can imagine clearing the screen (i.e. @@ -1025,10 +1025,11 @@ fn resizeCols( // pull down scrollback. const preserved_cursor: ?struct { tracked_pin: *Pin, + untrack: bool, remaining_rows: usize, wrapped_rows: usize, } = if (cursor) |c| cursor: { - const p = self.pin(.{ .active = .{ + const p = if (c.pin) |cursor_pin| cursor_pin.* else self.pin(.{ .active = .{ .x = c.x, .y = c.y, } }) orelse break :cursor null; @@ -1051,12 +1052,21 @@ fn resizeCols( }; break :cursor .{ - .tracked_pin = try self.trackPin(p), + .tracked_pin = c.pin orelse try self.trackPin(p), + .untrack = c.pin == null, .remaining_rows = self.rows - c.y - 1, .wrapped_rows = wrapped, }; } else null; - defer if (preserved_cursor) |c| self.untrackPin(c.tracked_pin); + defer if (preserved_cursor) |c| { + if (c.untrack) self.untrackPin(c.tracked_pin); + }; + + // Update our cols. We have to do this early because grow() that we + // may call below relies on this to calculate the proper page size, but + // after preserved_cursor so that the cursor pin can resolve coordinates in + // the old active coordinate space. + self.cols = cols; // Create the first node that contains our reflow. const first_rewritten_node = node: { @@ -1110,7 +1120,11 @@ fn resizeCols( { var reflow_cursor: ReflowCursor = .init(first_rewritten_node); while (it.next()) |row| { - try reflow_cursor.reflowRow(self, row); + try reflow_cursor.reflowRow( + self, + row, + if (preserved_cursor) |c| c.tracked_pin else null, + ); // Once we're done reflowing a page, destroy it immediately. // This frees memory and makes it more likely in memory @@ -1226,6 +1240,7 @@ const ReflowCursor = struct { self: *ReflowCursor, list: *PageList, row: Pin, + cursor_pin: ?*Pin, ) Allocator.Error!void { const src_page: *Page = &row.node.data; const src_row = row.rowAndCell().row; @@ -1253,6 +1268,8 @@ const ReflowCursor = struct { if (&p.node.data != src_page or p.y != src_y) continue; + if (cursor_pin != null and p == cursor_pin.?) continue; + // If this pin is in the blanks on the right and past the end // of the dst col width then we move it to the end of the dst // col width instead. @@ -1268,6 +1285,14 @@ const ReflowCursor = struct { } } + // If the cursor is after blanks on the right, those cells are still + // before the next write and must reflow with it. + if (cursor_pin) |p| { + if (&p.node.data == src_page and p.y == src_y) { + cols_len = @max(cols_len, p.x + 1); + } + } + // Defer processing of blank rows so that blank rows // at the end of the page list are never written. if (cols_len == 0) { diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 39fdd6109..ac53a2d72 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1766,7 +1766,11 @@ pub inline fn resize( .rows = opts.rows, .cols = opts.cols, .reflow = opts.reflow, - .cursor = .{ .x = self.cursor.x, .y = self.cursor.y }, + .cursor = .{ + .x = self.cursor.x, + .y = self.cursor.y, + .pin = self.cursor.page_pin, + }, }); // If we have no scrollback and we shrunk our rows, we must explicitly @@ -7277,6 +7281,41 @@ test "Screen: resize less cols to eliminate wide char with row space" { } } +test "Screen: resize less cols reflows cursor after wrapped text" { + const testing = std.testing; + const alloc = testing.allocator; + var s = try Screen.init(alloc, .{ .cols = 50, .rows = 7, .max_scrollback = 0 }); + defer s.deinit(); + + for (0..30) |_| try s.testWriteString("a"); + + try testing.expectEqual(@as(usize, 0), s.cursor.y); + try testing.expectEqual(@as(usize, 30), s.cursor.x); + + try s.resize(.{ .cols = 25, .rows = 7 }); + + try testing.expectEqual(@as(usize, 1), s.cursor.y); + try testing.expectEqual(@as(usize, 5), s.cursor.x); +} + +test "Screen: resize less cols reflows cursor after empty cells" { + const testing = std.testing; + const alloc = testing.allocator; + var s = try Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + defer s.deinit(); + + try s.testWriteString("abc"); + s.cursorRight(6); + + try testing.expectEqual(@as(usize, 0), s.cursor.y); + try testing.expectEqual(@as(usize, 9), s.cursor.x); + + try s.resize(.{ .cols = 5, .rows = 3 }); + + try testing.expectEqual(@as(usize, 1), s.cursor.y); + try testing.expectEqual(@as(usize, 4), s.cursor.x); +} + test "Screen: resize more cols with wide spacer head" { const testing = std.testing; const alloc = testing.allocator; From 0deaac08ed1a95330346afabbad03da701708331 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 23:25:11 +0000 Subject: [PATCH 015/137] Update VOUCHED list (#12606) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/12604#issuecomment-4392933026) from @jcollie. Vouch: @mohshami Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 852e1f56a..8a943096a 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -175,6 +175,7 @@ misairuzame mischief mitchellh miupa +mohshami molechowski moonmao42 -morgengeluk Appears to be using AI inappropriately even after it was requested they abide by the AI policy (there is clear evidence of the person-in-the-loop not attempting to clean up AI generated text, and their AI disclosure itself reads like AI-generated text and shows no signs of remorse or intent to improve). From 063ac3ecc5adae6360ae2044dc54e7a68c64f3a1 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 08:56:04 +0000 Subject: [PATCH 016/137] Update VOUCHED list (#12613) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/12612#issuecomment-4395645191) from @trag1c. Vouch: @raphamorim Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 8a943096a..a45196d72 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -211,6 +211,7 @@ prakhar54-byte priyans-hu puzza007 qwerasd205 +raphamorim reo101 rgehan rhodes-b From ec145bca9f1b24df3c6c8321892e60fb68f05c3d Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 8 May 2026 19:18:09 +0200 Subject: [PATCH 017/137] Fix translation errors in eu --- po/eu.po | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/po/eu.po b/po/eu.po index e4dc5dc77..5c7c23872 100644 --- a/po/eu.po +++ b/po/eu.po @@ -132,7 +132,7 @@ msgstr "Itsatsi" #: src/apprt/gtk/ui/1.2/surface.blp:270 msgid "Notify on Next Command Finish" -msgstr "Jakinarazi hurrengo komandoa amiatzean" +msgstr "Jakinarazi hurrengo komandoa amaitzean" #: src/apprt/gtk/ui/1.2/surface.blp:277 src/apprt/gtk/ui/1.5/window.blp:273 msgid "Clear" @@ -253,7 +253,7 @@ msgid "" "An application is attempting to write to the clipboard. The current " "clipboard contents are shown below." msgstr "" -"Aplikazio bat arbelean idatzi nahian dabil. Hau da arbelaren gaur egungo " +"Aplikazio bat arbelean idatzi nahian dabil. Hau da arbelaren uneko " "edukia." #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:202 From 607152ec6d2487ab10fb1cf5de6e0519baf8a21b Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 7 May 2026 11:14:55 +0200 Subject: [PATCH 018/137] macOS: normalize working directory paths with FilePath This fixes for nuShell when opening Ghostty via Finder service and Shortcuts, also makes path parsing more robust in AppleScript. --- .../Ghostty/Surface View/SurfaceView.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index d9fe83cd6..a17d8d704 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -1,6 +1,7 @@ import SwiftUI import UserNotifications import GhosttyKit +import System extension Ghostty { /// Render a terminal for the active app in the environment. @@ -611,8 +612,20 @@ extension Ghostty { /// Explicit font size to use in points var fontSize: Float32? + private var normalizedWorkingDirectory: String? /// Explicit working directory to set - var workingDirectory: String? + var workingDirectory: String? { + get { normalizedWorkingDirectory } + set { + guard let newValue else { + normalizedWorkingDirectory = nil + return + } + // We use FilePath to normalize separators by removing redundant intermediary separators + // and stripping any trailing separators. + normalizedWorkingDirectory = FilePath(newValue).string + } + } /// Explicit command to set var command: String? From 2b480457316b3b21739d3ed26b301e456f5856c7 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sat, 9 May 2026 14:37:43 -0400 Subject: [PATCH 019/137] macos: simplify workingDirectory setter This is a minor improvement to the computed property's `set` logic: we can just use `.map {}` to unify the two optional paths. --- .../Ghostty/Surface View/SurfaceView.swift | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index a17d8d704..4b90a3016 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -612,20 +612,13 @@ extension Ghostty { /// Explicit font size to use in points var fontSize: Float32? - private var normalizedWorkingDirectory: String? - /// Explicit working directory to set + /// Explicit working directory. This is normalized on assignment to + /// remove any redundant and trailing path separators. var workingDirectory: String? { get { normalizedWorkingDirectory } - set { - guard let newValue else { - normalizedWorkingDirectory = nil - return - } - // We use FilePath to normalize separators by removing redundant intermediary separators - // and stripping any trailing separators. - normalizedWorkingDirectory = FilePath(newValue).string - } + set { normalizedWorkingDirectory = newValue.map { FilePath($0).string } } } + private var normalizedWorkingDirectory: String? /// Explicit command to set var command: String? From ce6a00bfbfc7f0fed25e9385ac9100f7b4b0e098 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 14:02:12 +0000 Subject: [PATCH 020/137] Update VOUCHED list (#12647) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/12644#issuecomment-4415471290) from @jcollie. Denounce: @f1813483-netizen Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index a45196d72..acc95bb0d 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -93,6 +93,7 @@ enzowilliam ephemera eriksremess erral +-f1813483-netizen faukah filip7 flou From b3c1f754adf228631b7665751b322aa5652b6296 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 00:28:49 +0000 Subject: [PATCH 021/137] build(deps): bump cachix/cachix-action Bumps [cachix/cachix-action](https://github.com/cachix/cachix-action) from 1eb2ef646ac0255473d23a5907ad7b04ce94065c to 5f2d7c5294214f71b873db4b969586b980625e71. - [Release notes](https://github.com/cachix/cachix-action/releases) - [Changelog](https://github.com/cachix/cachix-action/blob/master/RELEASE.md) - [Commits](https://github.com/cachix/cachix-action/compare/1eb2ef646ac0255473d23a5907ad7b04ce94065c...5f2d7c5294214f71b873db4b969586b980625e71) --- updated-dependencies: - dependency-name: cachix/cachix-action dependency-version: 5f2d7c5294214f71b873db4b969586b980625e71 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 4 +- .github/workflows/release-tip.yml | 14 ++--- .github/workflows/test.yml | 76 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 5 files changed, 49 insertions(+), 49 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 350318a9b..4d5ecba4c 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -50,7 +50,7 @@ jobs: uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 04ee3dabc..eec812488 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -93,7 +93,7 @@ jobs: with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -147,7 +147,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index e6ee7f44f..37443cb0b 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -45,7 +45,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -178,7 +178,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -233,7 +233,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -312,7 +312,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -404,7 +404,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -660,7 +660,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -857,7 +857,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5ba099c8d..85e5e3792 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -169,7 +169,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -227,7 +227,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -263,7 +263,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -329,7 +329,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -369,7 +369,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -407,7 +407,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -524,7 +524,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -558,7 +558,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -603,7 +603,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -639,7 +639,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -679,7 +679,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -756,7 +756,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -785,7 +785,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -818,7 +818,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -900,7 +900,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -940,7 +940,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1008,7 +1008,7 @@ jobs: # - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 # with: # nix_path: nixpkgs=channel:nixos-unstable - # - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + # - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 # with: # name: ghostty # authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1071,7 +1071,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1131,7 +1131,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1185,7 +1185,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1222,7 +1222,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1257,7 +1257,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1305,7 +1305,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1340,7 +1340,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1371,7 +1371,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1430,7 +1430,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1461,7 +1461,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1503,7 +1503,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1534,7 +1534,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1564,7 +1564,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1593,7 +1593,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1622,7 +1622,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1650,7 +1650,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1678,7 +1678,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1711,7 +1711,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1739,7 +1739,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1776,7 +1776,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1838,7 +1838,7 @@ jobs: - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 64c21efc0..916575a82 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -32,7 +32,7 @@ jobs: uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17 + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" From b771a3d4f1a2087ec5938e4a653c6926775caf5b Mon Sep 17 00:00:00 2001 From: Riccardo Mazzarini Date: Mon, 11 May 2026 08:45:41 +0200 Subject: [PATCH 022/137] libghostty-vt: preserve shell prompts on resize by default This PR makes libghostty-vt preserve shell prompts across resize unless the shell explicitly opts into prompt clearing/redraw with `redraw=1`. --- src/terminal/c/terminal.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index d3293f3bc..773a40b21 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -259,6 +259,11 @@ fn new_( }); errdefer t.deinit(alloc); + // libghostty-vt embedders don't necessarily install Ghostty's shell + // integration, so don't assume OSC 133 prompts can be redrawn on resize. + // Shells can still opt in with OSC 133;A;redraw=1. + t.flags.shell_redraws_prompt = .false; + // Setup our stream with trampolines always installed so that // setting C callbacks at any time takes effect immediately. var handler: Stream.Handler = t.vtHandler(); From 4c6859447cd794a96b79ae549d1e8e48a5c9874e Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 08:01:15 +0000 Subject: [PATCH 023/137] Update VOUCHED list (#12654) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12650#discussioncomment-16876487) from @pluiedev. Vouch: @athaapa Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index acc95bb0d..5c21fbca4 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -43,6 +43,7 @@ andrejdaskalov anhthang anmitalidev anthonyzhoon +athaapa atomk balazs-szucs barutsrb From 611525ac3f6cc3e2c63988ad8111d7ace093950e Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 08:07:26 +0000 Subject: [PATCH 024/137] Update VOUCHED list (#12655) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12618#discussioncomment-16876561) from @pluiedev. Vouch: @thirstycrow Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 5c21fbca4..74c8ce05b 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -234,6 +234,7 @@ sunshine-syz tbrundige tdgroot tdslot +thirstycrow thoutbeckers ticclick tnagatomi From 64131dcd413ef27147378e2139efe1d2d81e092a Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 08:07:48 +0000 Subject: [PATCH 025/137] Update VOUCHED list (#12656) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12616#discussioncomment-16876564) from @pluiedev. Vouch: @00JCIV00 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 74c8ce05b..1baf5664c 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -20,6 +20,7 @@ # "!denounce" or "!denounce [username]" on a discussion. 00-kat 007hacky007 +00jciv00 04cb 0xdvc -4rh1t3ct0r7 From 81af65766f319a954a26196b4812d159496d4f00 Mon Sep 17 00:00:00 2001 From: Hua Jiang Date: Tue, 12 May 2026 09:03:14 +0800 Subject: [PATCH 026/137] feat: add +toggle-quick-terminal IPC command Expose toggle-quick-terminal as a proper IPC action so it can be triggered via 'ghostty +toggle-quick-terminal' from the command line, instead of calling the raw D-Bus org.gtk.Actions.Activate interface. This follows the same pattern as the existing +new-window IPC command: - Add toggle_quick_terminal to apprt.ipc.Action enum (Zig + C ABI) - Create apprt/gtk/ipc/toggle_quick_terminal.zig (GTK D-Bus handler) - Route .toggle_quick_terminal in apprt/gtk/App.zig performIpc - Register toggle-quick-terminal GAction in application.zig - Add +toggle-quick-terminal CLI handler in cli/ - Register in cli/ghostty.zig Action enum, runMain, and options - Add stub in apprt/embedded.zig - Update include/ghostty.h C header enum Usage: ghostty +toggle-quick-terminal Closes: #12618 --- include/ghostty.h | 1 + src/apprt/embedded.zig | 1 + src/apprt/gtk/App.zig | 2 + src/apprt/gtk/class/application.zig | 12 ++++ src/apprt/gtk/ipc/toggle_quick_terminal.zig | 24 ++++++++ src/apprt/ipc.zig | 4 ++ src/cli/ghostty.zig | 6 ++ src/cli/toggle_quick_terminal.zig | 62 +++++++++++++++++++++ 8 files changed, 112 insertions(+) create mode 100644 src/apprt/gtk/ipc/toggle_quick_terminal.zig create mode 100644 src/cli/toggle_quick_terminal.zig diff --git a/include/ghostty.h b/include/ghostty.h index b099741fc..fbfe3ee2c 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -1054,6 +1054,7 @@ typedef union { // apprt.ipc.Action.Key typedef enum { GHOSTTY_IPC_ACTION_NEW_WINDOW, + GHOSTTY_IPC_ACTION_TOGGLE_QUICK_TERMINAL, } ghostty_ipc_action_tag_e; //------------------------------------------------------------------- diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 730913eba..7310159cc 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -336,6 +336,7 @@ pub const App = struct { ) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool { switch (action) { .new_window => return false, + .toggle_quick_terminal => return false, } } }; diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 39c13c19d..8a7b3a8e5 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -13,6 +13,7 @@ const CoreApp = @import("../../App.zig"); const Application = @import("class/application.zig").Application; const Surface = @import("Surface.zig"); const ipcNewWindow = @import("ipc/new_window.zig").newWindow; +const ipcToggleQuickTerminal = @import("ipc/toggle_quick_terminal.zig").toggleQuickTerminal; const log = std.log.scoped(.gtk); @@ -84,6 +85,7 @@ pub fn performIpc( ) !bool { switch (action) { .new_window => return try ipcNewWindow(alloc, target, value), + .toggle_quick_terminal => return try ipcToggleQuickTerminal(alloc, target), } } diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 873674cec..107510b43 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -1419,6 +1419,7 @@ pub const Application = extern struct { .init("present-surface", actionPresentSurface, t_variant_type), .init("quit", actionQuit, null), .init("reload-config", actionReloadConfig, null), + .init("toggle-quick-terminal", actionToggleQuickTerminal, null), }; ext.actions.add(Self, self, &actions); @@ -1669,6 +1670,17 @@ pub const Application = extern struct { }; } + fn actionToggleQuickTerminal( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + priv.core_app.performAction(self.rt(), .toggle_quick_terminal) catch |err| { + log.warn("error toggling quick terminal err={}", .{err}); + }; + } + fn actionQuit( _: *gio.SimpleAction, _: ?*glib.Variant, diff --git a/src/apprt/gtk/ipc/toggle_quick_terminal.zig b/src/apprt/gtk/ipc/toggle_quick_terminal.zig new file mode 100644 index 000000000..702ad3df3 --- /dev/null +++ b/src/apprt/gtk/ipc/toggle_quick_terminal.zig @@ -0,0 +1,24 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const apprt = @import("../../../apprt.zig"); +const DBus = @import("DBus.zig"); + +/// Use a D-Bus method call to toggle the quick terminal on GTK. +/// +/// `ghostty +toggle-quick-terminal` is equivalent to the following command +/// (on a release build): +/// +/// ```sh +/// gdbus call --session \ +/// --dest com.mitchellh.ghostty \ +/// --object-path /com/mitchellh/ghostty \ +/// --method org.gtk.Actions.Activate \ +/// toggle-quick-terminal [] [] +/// ``` +pub fn toggleQuickTerminal(alloc: Allocator, target: apprt.ipc.Target) (Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors)!bool { + var dbus = try DBus.init(alloc, target, "toggle-quick-terminal"); + defer dbus.deinit(alloc); + try dbus.send(); + return true; +} diff --git a/src/apprt/ipc.zig b/src/apprt/ipc.zig index b37647e02..dda794ae1 100644 --- a/src/apprt/ipc.zig +++ b/src/apprt/ipc.zig @@ -73,6 +73,9 @@ pub const Action = union(enum) { /// The arguments to pass to Ghostty as the command. new_window: NewWindow, + /// Toggle the quick terminal. + toggle_quick_terminal: void, + pub const NewWindow = struct { /// A list of command arguments to launch in the new window. If this is /// `null` the command configured in the config or the user's default @@ -113,6 +116,7 @@ pub const Action = union(enum) { /// Sync with: ghostty_ipc_action_tag_e pub const Key = enum(c_int) { new_window, + toggle_quick_terminal, test "ghostty.h Action.Key" { try lib.checkGhosttyHEnum(Key, "GHOSTTY_IPC_ACTION_"); diff --git a/src/cli/ghostty.zig b/src/cli/ghostty.zig index 3acb90043..e44bdf9ed 100644 --- a/src/cli/ghostty.zig +++ b/src/cli/ghostty.zig @@ -20,6 +20,7 @@ const crash_report = @import("crash_report.zig"); const show_face = @import("show_face.zig"); const boo = @import("boo.zig"); const new_window = @import("new_window.zig"); +const toggle_quick_terminal = @import("toggle_quick_terminal.zig"); /// Special commands that can be invoked via CLI flags. These are all /// invoked by using `+` as a CLI flag. The only exception is @@ -73,6 +74,9 @@ pub const Action = enum { // Use IPC to tell the running Ghostty to open a new window. @"new-window", + // Use IPC to tell the running Ghostty to toggle the quick terminal. + @"toggle-quick-terminal", + pub fn detectSpecialCase(arg: []const u8) ?SpecialCase(Action) { // If we see a "-e" and we haven't seen a command yet, then // we are done looking for commands. This special case enables @@ -152,6 +156,7 @@ pub const Action = enum { .@"show-face" => try show_face.run(alloc), .boo => try boo.run(alloc), .@"new-window" => try new_window.run(alloc), + .@"toggle-quick-terminal" => try toggle_quick_terminal.run(alloc), }; } @@ -192,6 +197,7 @@ pub const Action = enum { .@"show-face" => show_face.Options, .boo => boo.Options, .@"new-window" => new_window.Options, + .@"toggle-quick-terminal" => toggle_quick_terminal.Options, }; } } diff --git a/src/cli/toggle_quick_terminal.zig b/src/cli/toggle_quick_terminal.zig new file mode 100644 index 000000000..16d2a1473 --- /dev/null +++ b/src/cli/toggle_quick_terminal.zig @@ -0,0 +1,62 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Action = @import("../cli.zig").ghostty.Action; +const apprt = @import("../apprt.zig"); + +pub const Options = struct { + /// If set, connect to a custom instance of Ghostty. + class: ?[:0]const u8 = null, + + pub fn deinit(self: *Options) void { + self.* = undefined; + } + + /// Enables "-h" and "--help" to work. + pub fn help(self: Options) !void { + _ = self; + return Action.help_error; + } +}; + +/// The `+toggle-quick-terminal` command will use native platform IPC to toggle +/// the quick terminal in a running instance of Ghostty. +/// +/// If the `--class` flag is not set, the command will try and connect to the +/// default running Ghostty instance. Otherwise it will contact a Ghostty +/// instance configured with the given `class`. +/// +/// On GTK, D-Bus activation must be properly configured. Ghostty does not need +/// to be running, as D-Bus will handle launching a new instance if it is not +/// already running. +/// +/// Only supported on GTK. +/// +/// Flags: +/// +/// * `--class=`: If set, connect to a custom instance of Ghostty. +/// The class must be a valid GTK application ID. +/// +/// Available since: 1.3.0 +pub fn run(alloc: Allocator) !u8 { + var buf: [256]u8 = undefined; + var stderr_writer = std.fs.File.stderr().writer(&buf); + const stderr = &stderr_writer.interface; + + if (apprt.App.performIpc( + alloc, + .detect, + .toggle_quick_terminal, + {}, + ) catch |err| switch (err) { + error.IPCFailed => { + return 1; + }, + else => { + try stderr.print("Sending the IPC failed: {}\n", .{err}); + return 1; + }, + }) return 0; + + try stderr.print("+toggle-quick-terminal is not supported on this platform.\n", .{}); + return 1; +} From 366c34831a39651b196f216f1e9fd651568b60b8 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:53:19 +0200 Subject: [PATCH 027/137] macOS: fix first responder after dragging a non-focused surface This fixes a bug: after dragging a non-focused surface from window A to window B **quickly without making B the key window**, the focused surface in window A is not receiving `keyDown` events. --- .../Features/Terminal/BaseTerminalController.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index f5b500b70..9454b1f86 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -458,8 +458,12 @@ class BaseTerminalController: NSWindowController, replaceSurfaceTree( surfaceTree.removing(node), - moveFocusTo: nextFocus, - moveFocusFrom: focusedSurface, + // When a non-focused surface is removed and this window stays as the key window, + // we should refocus the `focusedSurface` to make sure the window's firstResponder remains as it is. + // + // This is a weird workaround, since `resignFirstResponder` wasn't called on `focusedSurface` after drag, + // but the first responder became the window itself. + moveFocusTo: nextFocus ?? focusedSurface, undoAction: "Close Terminal" ) } From 2c6dd5940688643604f82cd50fd426a463e78d56 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:13:40 +0200 Subject: [PATCH 028/137] macOS: fix render_thread "stuck" after dragging surface to another tab within the same window The reason the thread is stuck is because the surface's occlusion state is set to invisible after target tab's activate while dragging, since the dragged surface is still in previous tree before dropping, and after dropping the occlusion state of this surface is not updated to visible, which causing the surface is accepting input but not rendering. --- .../Features/Terminal/BaseTerminalController.swift | 8 +++++++- .../Sources/Ghostty/Surface View/SurfaceView_AppKit.swift | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 9454b1f86..273d9733f 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -292,6 +292,7 @@ class BaseTerminalController: NSWindowController, if to.isEmpty { focusedSurface = nil } + syncSurfaceTreeOcclusionState() } /// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about @@ -1256,10 +1257,15 @@ class BaseTerminalController: NSWindowController, } func windowDidChangeOcclusionState(_ notification: Notification) { + syncSurfaceTreeOcclusionState() + } + + private func syncSurfaceTreeOcclusionState() { let visible = self.window?.occlusionState.contains(.visible) ?? false for view in surfaceTree { - if let surface = view.surface { + if let surface = view.surface, view.isWindowVisible != visible { ghostty_surface_set_occlusion(surface, visible) + view.isWindowVisible = visible } } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index b1920f170..887482b30 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -89,6 +89,12 @@ extension Ghostty { // Whether the cursor is currently visible (not hidden by typing, etc.) @Published private(set) var cursorVisible: Bool = true + /// Whether the belonging window is visible + /// + /// We track this to restore surface occlusion state + /// after this surface is dragged to another window + var isWindowVisible = false + /// The configuration derived from the Ghostty config so we don't need to rely on references. @Published private(set) var derivedConfig: DerivedConfig From 0226bcf034a0ba040ae4178336144f9df80e4c6e Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 6 May 2026 19:14:42 +0200 Subject: [PATCH 029/137] macOS: update window appearance for About and ConfigurationErrors --- macos/Sources/Features/About/AboutController.swift | 1 + .../Features/Settings/ConfigurationErrorsController.swift | 1 + macos/Sources/Features/Settings/ConfigurationErrorsView.swift | 3 +++ 3 files changed, 5 insertions(+) diff --git a/macos/Sources/Features/About/AboutController.swift b/macos/Sources/Features/About/AboutController.swift index 6f4cccf6d..ace92052c 100644 --- a/macos/Sources/Features/About/AboutController.swift +++ b/macos/Sources/Features/About/AboutController.swift @@ -13,6 +13,7 @@ class AboutController: NSWindowController, NSWindowDelegate { window.center() window.isMovableByWindowBackground = true window.contentView = NSHostingView(rootView: AboutView().environmentObject(viewModel)) + window.titlebarAppearsTransparent = true } // MARK: - Functions diff --git a/macos/Sources/Features/Settings/ConfigurationErrorsController.swift b/macos/Sources/Features/Settings/ConfigurationErrorsController.swift index 06fcebda3..9956f7873 100644 --- a/macos/Sources/Features/Settings/ConfigurationErrorsController.swift +++ b/macos/Sources/Features/Settings/ConfigurationErrorsController.swift @@ -29,5 +29,6 @@ class ConfigurationErrorsController: NSWindowController, NSWindowDelegate, Confi window.center() window.level = .popUpMenu window.contentView = NSHostingView(rootView: ConfigurationErrorsView(model: self)) + window.titlebarAppearsTransparent = true } } diff --git a/macos/Sources/Features/Settings/ConfigurationErrorsView.swift b/macos/Sources/Features/Settings/ConfigurationErrorsView.swift index 3ed84e3f0..d81552efc 100644 --- a/macos/Sources/Features/Settings/ConfigurationErrorsView.swift +++ b/macos/Sources/Features/Settings/ConfigurationErrorsView.swift @@ -46,8 +46,11 @@ struct ConfigurationErrorsView: View { HStack { Spacer() Button("Ignore") { model.errors = [] } + .keyboardShortcut(.cancelAction) Button("Reload Configuration") { reloadConfig() } + .keyboardShortcut(.defaultAction) } + .controlSize(.large) .padding([.bottom, .trailing]) } .frame(minWidth: 480, maxWidth: 960, minHeight: 270) From e59e27f8bd7610f82ca66c3f0971e6e88713e06c Mon Sep 17 00:00:00 2001 From: Daniel Kinzler Date: Tue, 12 May 2026 14:13:10 +0200 Subject: [PATCH 030/137] Fix nested splits disappearing and focus being lost. The cause of these bugs is that GTK can initially allocate a split/surface a width/height of 0 which causes it to get unmapped and lose focus. Additionally the split ratio is only set once but not accurately for tiny splits, which can keep a surface invisible even when the split gets resized later. To fix these problems the split ratio is always checked and possibly corrected when a split gets resized. Changes in a split ratio caused by the user dragging the divider are detected separately using an event controller. If a surface loses focus we restore it once the surface becomes mapped again. --- src/apprt/gtk/class/split_tree.zig | 272 +++++++++++++++------- src/apprt/gtk/class/surface.zig | 53 ++++- src/apprt/gtk/ui/1.2/surface.blp | 2 + src/apprt/gtk/ui/1.5/split-tree-split.blp | 7 +- 4 files changed, 251 insertions(+), 83 deletions(-) diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 311fbd8a6..33b1530fa 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -158,6 +158,12 @@ pub const SplitTree = extern struct { /// used to debounce updates. rebuild_source: ?c_uint = null, + // The source that we use to restore focus. With enough splits, some + // surfaces are initially allocated a width/height of 0 which causes + // them to get unmapped and lose focus. We can reliably restore focus + // to the last focused surface only once it is mapped again. + restore_focus_source: ?c_uint = null, + /// Used to store state about a pending surface close for the /// close dialog. pending_close: ?Surface.Tree.Node.Handle, @@ -415,6 +421,13 @@ pub const SplitTree = extern struct { self, .{ .detail = "focused" }, ); + _ = gobject.Object.signals.notify.connect( + surface, + *Self, + propSurfaceMapped, + self, + .{ .detail = "mapped" }, + ); } } @@ -571,6 +584,12 @@ pub const SplitTree = extern struct { } priv.rebuild_source = null; } + if (priv.restore_focus_source) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove restore_focus source", .{}); + } + priv.restore_focus_source = null; + } gtk.Widget.disposeTemplate( self.as(gtk.Widget), @@ -766,6 +785,23 @@ pub const SplitTree = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); } + fn propSurfaceMapped( + surface: *Surface, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + if (!surface.getMapped()) return; + + // We could add the idle callback only if this is actually the last focused + // surface. But we can avoid that check because usually all the surfaces get + // mapped at once, so the idle callback will run only once anyway. + const priv = self.private(); + if (priv.restore_focus_source == null) priv.restore_focus_source = glib.idleAdd( + onRestoreFocus, + self, + ); + } + fn propTree( self: *Self, _: *gobject.ParamSpec, @@ -779,14 +815,20 @@ pub const SplitTree = extern struct { 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 we were planning a rebuild or focus restore, always remove + // that so we can start from a clean slate. if (priv.rebuild_source) |v| { if (glib.Source.remove(v) == 0) { log.warn("unable to remove rebuild source", .{}); } priv.rebuild_source = null; } + if (priv.restore_focus_source) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove restore_focus source", .{}); + } + priv.restore_focus_source = null; + } // If we transitioned to an empty tree, clear immediately instead of // waiting for an idle callback. Delaying teardown can keep the last @@ -842,6 +884,26 @@ pub const SplitTree = extern struct { return 0; } + fn onRestoreFocus(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + + // Always mark our source as null since we're done. + const priv = self.private(); + priv.restore_focus_source = null; + + // If we have a last-focused surface and it is mapped, restore focus to it. + // Depending on the available size, the surface might already have focus + // because it never got unmapped. In that case grabbing focus will have no + // effect. + if (priv.last_focused.get()) |v| { + defer v.unref(); + if (v.getMapped()) { + v.grabFocus(); + } + } + return 0; + } + /// Builds the widget tree associated with a surface split tree. /// /// Returned widgets are expected to be attached to a parent by the caller. @@ -905,7 +967,6 @@ pub const SplitTree = extern struct { defer left.deinit(); const right = self.buildTree(tree, s.right); defer right.deinit(); - break :split .initNew(SplitTreeSplit.new( current, &s, @@ -1041,8 +1102,11 @@ const SplitTreeSplit = extern struct { /// Assumed to be correct. handle: Surface.Tree.Node.Handle, - /// Source to handle repositioning the split when properties change. - idle: ?c_uint = null, + /// Source to handle repositioning the split when its size changes. + idle_max_pos: ?c_uint = null, + /// Source to write back the updated split ratio to the split tree + /// when the user manually drags the divider. + idle_drag: ?c_uint = null, // Template bindings paned: *gtk.Paned, @@ -1083,21 +1147,13 @@ const SplitTreeSplit = extern struct { gtk.Widget.initTemplate(self.as(gtk.Widget)); } - fn refresh(self: *Self) void { - const priv = self.private(); - if (priv.idle == null) priv.idle = glib.idleAdd( - onIdle, - self, - ); - } - - fn onIdle(ud: ?*anyopaque) callconv(.c) c_int { + fn onIdleMaxPos(ud: ?*anyopaque) callconv(.c) c_int { const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); const priv = self.private(); const paned = priv.paned; - // Our idle source is always over - priv.idle = null; + // Our idle source is always over. + priv.idle_max_pos = null; // Get our split. This is the most dangerous part of this entire // widget. We assume that this widget is always a child of a @@ -1110,42 +1166,10 @@ const SplitTreeSplit = extern struct { const tree = split_tree.getTree() orelse return 0; const split: *const Surface.Tree.Split = &tree.nodes[priv.handle.idx()].split; - // Current, min, and max positions as pixels. - const pos = paned.getPosition(); - const min = min: { - var val = gobject.ext.Value.new(c_int); - defer val.unset(); - gobject.Object.getProperty( - paned.as(gobject.Object), - "min-position", - &val, - ); - break :min gobject.ext.Value.get(&val, c_int); + const pos, const max = positions: { + const p = self.getPanedPositions(); + break :positions .{ p.pos, p.max }; }; - const max = max: { - var val = gobject.ext.Value.new(c_int); - defer val.unset(); - gobject.Object.getProperty( - paned.as(gobject.Object), - "max-position", - &val, - ); - break :max gobject.ext.Value.get(&val, c_int); - }; - const pos_set: bool = max: { - var val = gobject.ext.Value.new(c_int); - defer val.unset(); - gobject.Object.getProperty( - paned.as(gobject.Object), - "position-set", - &val, - ); - break :max gobject.ext.Value.get(&val, c_int) != 0; - }; - - // We don't actually use min, but we don't expect this to ever - // be non-zero, so let's add an assert to ensure that. - assert(min == 0); // If our max is zero then we can't do any math. I don't know // if this is possible but I suspect it can be if you make a nested @@ -1172,51 +1196,134 @@ const SplitTreeSplit = extern struct { return 0; } - // If we're out of bounds, then we need to either set the position - // to what we expect OR update our expected ratio. + // Note that if max is small, it might not be possible to accurately + // set the desired ratio. E.g. with max=2 you can only set ratios + // of 0, 0.5 and 1. + const desired_pos: c_int = desired_pos: { + const max_f64: f64 = @floatFromInt(max); + break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); + }; + paned.setPosition(desired_pos); + return 0; + } - // If we've never set the position, then we set it to the desired. - if (!pos_set) { - const desired_pos: c_int = desired_pos: { - const max_f64: f64 = @floatFromInt(max); - break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); - }; - paned.setPosition(desired_pos); + fn onIdleDrag(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const priv = self.private(); + + // Our idle source is always over. + priv.idle_drag = null; + + const split_tree = ext.getAncestor( + SplitTree, + self.as(gtk.Widget), + ) orelse return 0; + const tree = split_tree.getTree() orelse return 0; + const split: *const Surface.Tree.Split = &tree.nodes[priv.handle.idx()].split; + + const pos, const max = positions: { + const p = self.getPanedPositions(); + break :positions .{ p.pos, p.max }; + }; + + if (max == 0) return 0; + + // Determine our current ratio. + const current_ratio: f64 = ratio: { + const pos_f64: f64 = @floatFromInt(pos); + const max_f64: f64 = @floatFromInt(max); + break :ratio pos_f64 / max_f64; + }; + const old_ratio: f64 = @floatCast(split.ratio); + + // If our ratio is close enough to the old ratio, then + // we ignore the update. + if (std.math.approxEqAbs( + f64, + current_ratio, + old_ratio, + 0.001, + )) { return 0; } - // If we've set the position, then this is a manual human update - // and we need to write our update back to the tree. + // Write our update back to the tree. tree.resizeInPlace(priv.handle, @floatCast(current_ratio)); return 0; } + const PanedPositions = struct { + pos: c_int, + min: c_int, + max: c_int, + }; + + // Returns the current, min, and max positions of the gtk.Paned + // this widget wraps. + fn getPanedPositions(self: *Self) PanedPositions { + const priv = self.private(); + const paned = priv.paned; + + const pos = paned.getPosition(); + const min = min: { + var val = gobject.ext.Value.new(c_int); + defer val.unset(); + gobject.Object.getProperty( + paned.as(gobject.Object), + "min-position", + &val, + ); + break :min gobject.ext.Value.get(&val, c_int); + }; + const max = max: { + var val = gobject.ext.Value.new(c_int); + defer val.unset(); + gobject.Object.getProperty( + paned.as(gobject.Object), + "max-position", + &val, + ); + break :max gobject.ext.Value.get(&val, c_int); + }; + + // We don't actually use min, but we don't expect this to ever + // be non-zero, so let's add an assert to ensure that. + assert(min == 0); + + return .{ + .pos = pos, + .min = min, + .max = max, + }; + } + //--------------------------------------------------------------- // Signal handlers - fn propPosition( - _: *gtk.Paned, - _: *gobject.ParamSpec, - self: *Self, - ) callconv(.c) void { - self.refresh(); - } - fn propMaxPosition( _: *gtk.Paned, _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { - self.refresh(); + const priv = self.private(); + if (priv.idle_max_pos == null) priv.idle_max_pos = glib.idleAdd( + onIdleMaxPos, + self, + ); } - fn propMinPosition( - _: *gtk.Paned, - _: *gobject.ParamSpec, + fn onDragEnd( + _: *gtk.GestureDrag, + _: f64, + _: f64, self: *Self, ) callconv(.c) void { - self.refresh(); + const priv = self.private(); + if (priv.idle_drag == null) priv.idle_drag = glib.idleAdd( + onIdleDrag, + self, + ); } //--------------------------------------------------------------- @@ -1224,11 +1331,17 @@ const SplitTreeSplit = extern struct { fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); - if (priv.idle) |v| { + if (priv.idle_max_pos) |v| { if (glib.Source.remove(v) == 0) { - log.warn("unable to remove idle source", .{}); + log.warn("unable to remove idle_max_pos source", .{}); } - priv.idle = null; + priv.idle_max_pos = null; + } + if (priv.idle_drag) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove idle_drag source", .{}); + } + priv.idle_drag = null; } gtk.Widget.disposeTemplate( @@ -1275,8 +1388,7 @@ const SplitTreeSplit = extern struct { // Template Callbacks class.bindTemplateCallback("notify_max_position", &propMaxPosition); - class.bindTemplateCallback("notify_min_position", &propMinPosition); - class.bindTemplateCallback("notify_position", &propPosition); + class.bindTemplateCallback("on_drag_end", &onDragEnd); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 179c779d7..2e05d7b12 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -169,6 +169,24 @@ pub const Surface = extern struct { ); }; + pub const mapped = struct { + pub const name = "mapped"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "mapped", + ), + }, + ); + }; + pub const @"min-size" = struct { pub const name = "min-size"; const impl = gobject.ext.defineProperty( @@ -592,11 +610,15 @@ pub const Surface = extern struct { /// focus events. focused: bool = true, + /// Whether the GLArea widget is mapped. Some operations like grabbing + /// focus only work if a widget is mapped. + mapped: bool = false, + /// Whether this surface is "zoomed" or not. A zoomed surface /// shows up taking the full bounds of a split view. zoom: bool = false, - /// The GLAarea that renders the actual surface. This is a binding + /// The GLArea that renders the actual surface. This is a binding /// to the template so it doesn't have to be unrefed manually. gl_area: *gtk.GLArea, @@ -1768,6 +1790,7 @@ pub const Surface = extern struct { priv.mouse_shape = .text; priv.mouse_hidden = false; priv.focused = true; + priv.mapped = false; priv.size = .{ .width = 0, .height = 0 }; priv.vadj_signal_group = null; @@ -2019,6 +2042,11 @@ pub const Surface = extern struct { return self.private().focused; } + /// Returns true if the GLArea of this surface is mapped. + pub fn getMapped(self: *Self) bool { + return self.private().mapped; + } + /// Change the configuration for this surface. pub fn setConfig(self: *Self, config: *Config) void { const priv = self.private(); @@ -3250,6 +3278,26 @@ pub const Surface = extern struct { priv.im_context.as(gtk.IMContext).setClientWidget(null); } + fn glareaMap( + _: *gtk.GLArea, + self: *Self, + ) callconv(.c) void { + self.updateMapped(true); + } + + fn glareaUnmap( + _: *gtk.GLArea, + self: *Self, + ) callconv(.c) void { + self.updateMapped(false); + } + + fn updateMapped(self: *Self, mapped: bool) void { + const priv = self.private(); + priv.mapped = mapped; + self.as(gobject.Object).notifyByPspec(properties.mapped.impl.param_spec); + } + fn glareaRender( _: *gtk.GLArea, _: *gdk.GLContext, @@ -3560,6 +3608,8 @@ pub const Surface = extern struct { class.bindTemplateCallback("drop", &dtDrop); class.bindTemplateCallback("gl_realize", &glareaRealize); class.bindTemplateCallback("gl_unrealize", &glareaUnrealize); + class.bindTemplateCallback("gl_map", &glareaMap); + class.bindTemplateCallback("gl_unmap", &glareaUnmap); class.bindTemplateCallback("gl_render", &glareaRender); class.bindTemplateCallback("gl_resize", &glareaResize); class.bindTemplateCallback("im_preedit_start", &imPreeditStart); @@ -3592,6 +3642,7 @@ pub const Surface = extern struct { properties.@"error".impl, properties.@"font-size-request".impl, properties.focused.impl, + properties.mapped.impl, properties.@"key-sequence".impl, properties.@"key-table".impl, properties.@"min-size".impl, diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 794ea1801..2f01c48ce 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -23,6 +23,8 @@ Overlay terminal_page { GLArea gl_area { realize => $gl_realize(); unrealize => $gl_unrealize(); + map => $gl_map(); + unmap => $gl_unmap(); render => $gl_render(); resize => $gl_resize(); hexpand: true; diff --git a/src/apprt/gtk/ui/1.5/split-tree-split.blp b/src/apprt/gtk/ui/1.5/split-tree-split.blp index 182919f4e..82dd79e69 100644 --- a/src/apprt/gtk/ui/1.5/split-tree-split.blp +++ b/src/apprt/gtk/ui/1.5/split-tree-split.blp @@ -13,8 +13,11 @@ template $GhosttySplitTreeSplit: Adw.Bin { Adw.Bin { Paned paned { notify::max-position => $notify_max_position(); - notify::min-position => $notify_min_position(); - notify::position => $notify_position(); + + GestureDrag { + drag-end => $on_drag_end(); + } } + } } From ee316e43c140568729487a95fc7dfd7ee87a4176 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 01:38:02 +0000 Subject: [PATCH 031/137] build(deps): bump actions/create-github-app-token from 3.1.1 to 3.2.0 Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 3.1.1 to 3.2.0. - [Release notes](https://github.com/actions/create-github-app-token/releases) - [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/create-github-app-token/compare/1b10c78c7865c340bc4f6099eb2f838309f1e8c3...bcd2ba49218906704ab6c1aa796996da409d3eb1) --- updated-dependencies: - dependency-name: actions/create-github-app-token dependency-version: 3.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/vouch-check-issue.yml | 2 +- .github/workflows/vouch-check-pr.yml | 2 +- .github/workflows/vouch-manage-by-discussion.yml | 2 +- .github/workflows/vouch-manage-by-issue.yml | 2 +- .github/workflows/vouch-sync-codeowners.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/vouch-check-issue.yml b/.github/workflows/vouch-check-issue.yml index d01f24853..181f19329 100644 --- a/.github/workflows/vouch-check-issue.yml +++ b/.github/workflows/vouch-check-issue.yml @@ -8,7 +8,7 @@ jobs: check: runs-on: namespace-profile-ghostty-xsm steps: - - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 id: app-token with: app-id: ${{ secrets.VOUCH_APP_ID }} diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml index cbba619ce..4cc7906a1 100644 --- a/.github/workflows/vouch-check-pr.yml +++ b/.github/workflows/vouch-check-pr.yml @@ -8,7 +8,7 @@ jobs: check: runs-on: namespace-profile-ghostty-xsm steps: - - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 id: app-token with: app-id: ${{ secrets.VOUCH_APP_ID }} diff --git a/.github/workflows/vouch-manage-by-discussion.yml b/.github/workflows/vouch-manage-by-discussion.yml index 0c8a4eab8..172c7cde7 100644 --- a/.github/workflows/vouch-manage-by-discussion.yml +++ b/.github/workflows/vouch-manage-by-discussion.yml @@ -12,7 +12,7 @@ jobs: manage: runs-on: namespace-profile-ghostty-xsm steps: - - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 id: app-token with: app-id: ${{ secrets.VOUCH_APP_ID }} diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml index d07e247a2..e78de9266 100644 --- a/.github/workflows/vouch-manage-by-issue.yml +++ b/.github/workflows/vouch-manage-by-issue.yml @@ -12,7 +12,7 @@ jobs: manage: runs-on: namespace-profile-ghostty-xsm steps: - - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 id: app-token with: app-id: ${{ secrets.VOUCH_APP_ID }} diff --git a/.github/workflows/vouch-sync-codeowners.yml b/.github/workflows/vouch-sync-codeowners.yml index 7db9dcefb..d9b7ce74b 100644 --- a/.github/workflows/vouch-sync-codeowners.yml +++ b/.github/workflows/vouch-sync-codeowners.yml @@ -13,7 +13,7 @@ jobs: sync: runs-on: namespace-profile-ghostty-xsm steps: - - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 id: app-token with: app-id: ${{ secrets.VOUCH_APP_ID }} From b23d567cd89874ffe218036536a2aec52851f34f Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 20:43:34 +0000 Subject: [PATCH 032/137] Update VOUCHED list (#12675) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/12674#issuecomment-4445057781) from @trag1c. Vouch: @B1NAR10 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 1baf5664c..f0249b16a 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -46,6 +46,7 @@ anmitalidev anthonyzhoon athaapa atomk +b1nar10 balazs-szucs barutsrb bch From 54a38e8134b8418d1d8d5293c2881b48a7274689 Mon Sep 17 00:00:00 2001 From: Daniel Kinzler Date: Thu, 14 May 2026 15:45:29 +0200 Subject: [PATCH 033/137] Distinguish resize and manual update using a combination of max-position and position properties. Listening to drag events directly did not work that well. --- src/apprt/gtk/class/split_tree.zig | 218 +++++++++------------- src/apprt/gtk/ui/1.5/split-tree-split.blp | 6 +- 2 files changed, 90 insertions(+), 134 deletions(-) diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 33b1530fa..43383720d 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -1102,11 +1102,14 @@ const SplitTreeSplit = extern struct { /// Assumed to be correct. handle: Surface.Tree.Node.Handle, - /// Source to handle repositioning the split when its size changes. - idle_max_pos: ?c_uint = null, - /// Source to write back the updated split ratio to the split tree - /// when the user manually drags the divider. - idle_drag: ?c_uint = null, + /// Source to handle repositioning the split when properties change. + idle: ?c_uint = null, + + /// Whether the max-position/position property of the gtk.Paned widget + /// changed. We use these to distinguish between a resize and the user + /// manually moving the split divider. See the "on-idle" function. + max_changed: bool = false, + pos_changed: bool = false, // Template bindings paned: *gtk.Paned, @@ -1147,13 +1150,37 @@ const SplitTreeSplit = extern struct { gtk.Widget.initTemplate(self.as(gtk.Widget)); } - fn onIdleMaxPos(ud: ?*anyopaque) callconv(.c) c_int { + // We need to keep the split ratios from the tree datastructure and + // widget tree in sync. Using the max-position and position properties + // of the gtk.Paned widget, we can distinguish a resize from a manual + // update (e.g. the user dragging the divider).If max-position changes, + // we always have a widget resize. Usually position will change as well + // but it might not if the size change is small enough. If only position + // changes, we have a manual human update. + // + // This is a hack, it relies on the timing of property notifcations. + // From looking at the GTK source code, it should not be possible that we + // erroneously interpret a position change from a resize as a manual update. + // When a gtk.Paned is resized, internally the gtk_paned_calc_position function + // will change both max-position and position and synchronously call our + // propMaxPosition and propPosition functions. I.e. when the widget is resized, + // it should not be possible for onIdle to run before we have been notified of + // both property changes. + fn onIdle(ud: ?*anyopaque) callconv(.c) c_int { const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); const priv = self.private(); const paned = priv.paned; - // Our idle source is always over. - priv.idle_max_pos = null; + // Clear source and fields at the end. Otherwise if setPosition is called + // below, propPosition is triggered and would add another idle callback + // before this one is finished. + defer priv.idle = null; + defer priv.max_changed = false; + defer priv.pos_changed = false; + + if (!priv.max_changed and !priv.pos_changed) { + return 0; + } // Get our split. This is the most dangerous part of this entire // widget. We assume that this widget is always a child of a @@ -1166,105 +1193,7 @@ const SplitTreeSplit = extern struct { const tree = split_tree.getTree() orelse return 0; const split: *const Surface.Tree.Split = &tree.nodes[priv.handle.idx()].split; - const pos, const max = positions: { - const p = self.getPanedPositions(); - break :positions .{ p.pos, p.max }; - }; - - // If our max is zero then we can't do any math. I don't know - // if this is possible but I suspect it can be if you make a nested - // split completely minimized. - if (max == 0) return 0; - - // Determine our current ratio. - const current_ratio: f64 = ratio: { - const pos_f64: f64 = @floatFromInt(pos); - const max_f64: f64 = @floatFromInt(max); - break :ratio pos_f64 / max_f64; - }; - const desired_ratio: f64 = @floatCast(split.ratio); - - // If our ratio is close enough to our desired ratio, then - // we ignore the update. This is to avoid constant split updates - // for lossy floating point math. - if (std.math.approxEqAbs( - f64, - current_ratio, - desired_ratio, - 0.001, - )) { - return 0; - } - - // Note that if max is small, it might not be possible to accurately - // set the desired ratio. E.g. with max=2 you can only set ratios - // of 0, 0.5 and 1. - const desired_pos: c_int = desired_pos: { - const max_f64: f64 = @floatFromInt(max); - break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); - }; - paned.setPosition(desired_pos); - return 0; - } - - fn onIdleDrag(ud: ?*anyopaque) callconv(.c) c_int { - const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); - const priv = self.private(); - - // Our idle source is always over. - priv.idle_drag = null; - - const split_tree = ext.getAncestor( - SplitTree, - self.as(gtk.Widget), - ) orelse return 0; - const tree = split_tree.getTree() orelse return 0; - const split: *const Surface.Tree.Split = &tree.nodes[priv.handle.idx()].split; - - const pos, const max = positions: { - const p = self.getPanedPositions(); - break :positions .{ p.pos, p.max }; - }; - - if (max == 0) return 0; - - // Determine our current ratio. - const current_ratio: f64 = ratio: { - const pos_f64: f64 = @floatFromInt(pos); - const max_f64: f64 = @floatFromInt(max); - break :ratio pos_f64 / max_f64; - }; - const old_ratio: f64 = @floatCast(split.ratio); - - // If our ratio is close enough to the old ratio, then - // we ignore the update. - if (std.math.approxEqAbs( - f64, - current_ratio, - old_ratio, - 0.001, - )) { - return 0; - } - - // Write our update back to the tree. - tree.resizeInPlace(priv.handle, @floatCast(current_ratio)); - - return 0; - } - - const PanedPositions = struct { - pos: c_int, - min: c_int, - max: c_int, - }; - - // Returns the current, min, and max positions of the gtk.Paned - // this widget wraps. - fn getPanedPositions(self: *Self) PanedPositions { - const priv = self.private(); - const paned = priv.paned; - + // Current, min, and max positions as pixels. const pos = paned.getPosition(); const min = min: { var val = gobject.ext.Value.new(c_int); @@ -1291,11 +1220,47 @@ const SplitTreeSplit = extern struct { // be non-zero, so let's add an assert to ensure that. assert(min == 0); - return .{ - .pos = pos, - .min = min, - .max = max, + // If our max is zero then we can't do any math. I don't know + // if this is possible but I suspect it can be if you make a nested + // split completely minimized. + if (max == 0) return 0; + + // Determine our current ratio. + const current_ratio: f64 = ratio: { + const pos_f64: f64 = @floatFromInt(pos); + const max_f64: f64 = @floatFromInt(max); + break :ratio pos_f64 / max_f64; }; + const desired_ratio: f64 = @floatCast(split.ratio); + + // If our ratio is close enough to our desired ratio, then + // we ignore the update. This is to avoid constant split updates + // for lossy floating point math. + if (std.math.approxEqAbs( + f64, + current_ratio, + desired_ratio, + 0.001, + )) { + return 0; + } + + if (priv.max_changed) { + // Widget got resized, update position to match desired ratio. + // Note that if max-position is small, it might not be possible + // to accurately set the desired ratio. E.g. with max-position=2 + // you can only have ratios 0, 0.5 and 1. + const desired_pos: c_int = desired_pos: { + const max_f64: f64 = @floatFromInt(max); + break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); + }; + paned.setPosition(desired_pos); + } else { + // If only position changed, this is a manual human update and + // we need to write our update back to the tree. + tree.resizeInPlace(priv.handle, @floatCast(current_ratio)); + } + return 0; } //--------------------------------------------------------------- @@ -1307,21 +1272,22 @@ const SplitTreeSplit = extern struct { self: *Self, ) callconv(.c) void { const priv = self.private(); - if (priv.idle_max_pos == null) priv.idle_max_pos = glib.idleAdd( - onIdleMaxPos, + priv.max_changed = true; + if (priv.idle == null) priv.idle = glib.idleAdd( + onIdle, self, ); } - fn onDragEnd( - _: *gtk.GestureDrag, - _: f64, - _: f64, + fn propPosition( + _: *gtk.Paned, + _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { const priv = self.private(); - if (priv.idle_drag == null) priv.idle_drag = glib.idleAdd( - onIdleDrag, + priv.pos_changed = true; + if (priv.idle == null) priv.idle = glib.idleAdd( + onIdle, self, ); } @@ -1331,17 +1297,11 @@ const SplitTreeSplit = extern struct { fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); - if (priv.idle_max_pos) |v| { + if (priv.idle) |v| { if (glib.Source.remove(v) == 0) { - log.warn("unable to remove idle_max_pos source", .{}); + log.warn("unable to remove idle source", .{}); } - priv.idle_max_pos = null; - } - if (priv.idle_drag) |v| { - if (glib.Source.remove(v) == 0) { - log.warn("unable to remove idle_drag source", .{}); - } - priv.idle_drag = null; + priv.idle = null; } gtk.Widget.disposeTemplate( @@ -1388,7 +1348,7 @@ const SplitTreeSplit = extern struct { // Template Callbacks class.bindTemplateCallback("notify_max_position", &propMaxPosition); - class.bindTemplateCallback("on_drag_end", &onDragEnd); + class.bindTemplateCallback("notify_position", &propPosition); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk/ui/1.5/split-tree-split.blp b/src/apprt/gtk/ui/1.5/split-tree-split.blp index 82dd79e69..521665d18 100644 --- a/src/apprt/gtk/ui/1.5/split-tree-split.blp +++ b/src/apprt/gtk/ui/1.5/split-tree-split.blp @@ -13,11 +13,7 @@ template $GhosttySplitTreeSplit: Adw.Bin { Adw.Bin { Paned paned { notify::max-position => $notify_max_position(); - - GestureDrag { - drag-end => $on_drag_end(); - } + notify::position => $notify_position(); } - } } From 47382f8dcbf9fe1dc448f0dfcbc6b4230d17cb06 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 16:15:46 +0000 Subject: [PATCH 034/137] Update VOUCHED list (#12680) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/12678#issuecomment-4452472142) from @trag1c. Denounce: @zaviro Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index f0249b16a..e923deeba 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -255,6 +255,7 @@ wyounas yabbal yamshta ydah +-zaviro zenyr zeshi09 zubb From 13ca032b1de461146f8e9c416901d2414df19189 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 14 May 2026 19:43:08 +0200 Subject: [PATCH 035/137] config: clear `command-palette-entry` like `keybind` After #1368, `command-palette-entry=` will no longer clear the entries like the documentation says. Since i couldn't find an existing issue or discussion about this, I assume no one is actually using it. So I put 1.4.0 here, lemme know if you want to change it to 1.3.2. > I basically copied the `keybind` parsing code and doc. --- src/config/Config.zig | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 5b1d73deb..9e6e5629c 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2880,9 +2880,16 @@ keybind: Keybinds = .{}, /// command-palette-entry = title:"Ghostty",description:"Add a little Ghostty to your terminal.",action:"text:\xf0\x9f\x91\xbb" /// ``` /// +/// There are some additional special values that can be specified for +/// command-palette-entry: +/// +/// * `command-palette-entry=clear` will clear all command entries. Warning: this +/// removes ALL entries up to this point, including the default +/// entries. Available since: 1.4.0 +/// /// By default, the command palette is preloaded with most actions that might /// be useful in an interactive setting yet do not have easily accessible or -/// memorizable shortcuts. The default entries can be cleared by setting this +/// memorizable shortcuts. The default entries can be restored by setting this /// setting to an empty value: /// /// ```ini @@ -8736,6 +8743,13 @@ pub const RepeatableCommand = struct { // Unset or empty input clears the list const input = input_ orelse ""; if (input.len == 0) { + log.info("config has 'command-palette-entry =', using default entries", .{}); + try self.init(alloc); + return; + } + + if (std.mem.eql(u8, input, "clear")) { + log.info("config has 'command-palette-entry = clear', all command entries cleared", .{}); self.value.clearRetainingCapacity(); self.value_c.clearRetainingCapacity(); return; @@ -8847,8 +8861,11 @@ pub const RepeatableCommand = struct { try testing.expectEqualStrings("Baz", list.value.items[3].title); try testing.expectEqualStrings("Raspberry Pie", list.value.items[3].description); - try list.parseCLI(alloc, ""); + try list.parseCLI(alloc, "clear"); try testing.expectEqual(@as(usize, 0), list.value.items.len); + + try list.parseCLI(alloc, ""); + try testing.expectEqual(inputpkg.command.defaults.len, list.value.items.len); } test "RepeatableCommand formatConfig empty" { @@ -8963,7 +8980,7 @@ pub const RepeatableCommand = struct { try list.parseCLI(alloc, "title:Foo,action:ignore"); try testing.expectEqual(@as(usize, 1), list.cval().len); - try list.parseCLI(alloc, ""); + try list.parseCLI(alloc, "clear"); try testing.expectEqual(@as(usize, 0), list.cval().len); } }; From e9213bb1e7d1eaf5bce486d6b03bc102d6dee507 Mon Sep 17 00:00:00 2001 From: Chris Marchesi Date: Thu, 14 May 2026 20:27:04 -0700 Subject: [PATCH 036/137] Delete test_align Checked in to make sure that this wasn't added intentionally :slightly_smiling_face: Looks like it snuck in in #11868. --- test_align | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100755 test_align diff --git a/test_align b/test_align deleted file mode 100755 index e69de29bb..000000000 From 84ad649128be60fb7e449d03a8d1369fed51a84b Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 03:28:40 +0000 Subject: [PATCH 037/137] Update VOUCHED list (#12689) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/12688#issuecomment-4456633108) from @rhodes-b. Vouch: @vancluever Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index e923deeba..e9d45b7c6 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -247,6 +247,7 @@ tweedbeetle uhojin unphased uzaaft +vancluever vaughanandrews viruslobster vlsi From 93d1142ada9336c3e33e23bd6343aa1366265bbd Mon Sep 17 00:00:00 2001 From: Daniel Kinzler Date: Fri, 15 May 2026 17:20:57 +0200 Subject: [PATCH 038/137] small formatting changes --- src/apprt/gtk/class/split_tree.zig | 44 ++++++++++++++++-------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 43383720d..56796975d 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -158,10 +158,11 @@ pub const SplitTree = extern struct { /// used to debounce updates. rebuild_source: ?c_uint = null, - // The source that we use to restore focus. With enough splits, some - // surfaces are initially allocated a width/height of 0 which causes - // them to get unmapped and lose focus. We can reliably restore focus - // to the last focused surface only once it is mapped again. + /// The source that we use to restore focus. With enough nested + /// splits, some surfaces might initially be allocated a width or + /// height of 0 which causes them to get unmapped and lose focus. + /// We can reliably restore focus to the last focused surface only + /// once it is mapped again. restore_focus_source: ?c_uint = null, /// Used to store state about a pending surface close for the @@ -792,9 +793,10 @@ pub const SplitTree = extern struct { ) callconv(.c) void { if (!surface.getMapped()) return; - // We could add the idle callback only if this is actually the last focused - // surface. But we can avoid that check because usually all the surfaces get - // mapped at once, so the idle callback will run only once anyway. + // We could add the idle callback only if this is actually the last + // focused surface. But we can avoid that check because usually all + // the surfaces get mapped at once, so the idle callback will run + // only once anyway. const priv = self.private(); if (priv.restore_focus_source == null) priv.restore_focus_source = glib.idleAdd( onRestoreFocus, @@ -891,10 +893,10 @@ pub const SplitTree = extern struct { const priv = self.private(); priv.restore_focus_source = null; - // If we have a last-focused surface and it is mapped, restore focus to it. - // Depending on the available size, the surface might already have focus - // because it never got unmapped. In that case grabbing focus will have no - // effect. + // If we have a last-focused surface and it is mapped, restore focus + // to it. Depending on the available size, the surface might already + // have focus because it never got unmapped. In that case grabbing + // focus will have no effect. if (priv.last_focused.get()) |v| { defer v.unref(); if (v.getMapped()) { @@ -1159,21 +1161,21 @@ const SplitTreeSplit = extern struct { // changes, we have a manual human update. // // This is a hack, it relies on the timing of property notifcations. - // From looking at the GTK source code, it should not be possible that we - // erroneously interpret a position change from a resize as a manual update. - // When a gtk.Paned is resized, internally the gtk_paned_calc_position function - // will change both max-position and position and synchronously call our - // propMaxPosition and propPosition functions. I.e. when the widget is resized, - // it should not be possible for onIdle to run before we have been notified of - // both property changes. + // From looking at the GTK source code, it should not be possible that + // we interpret a position change from a resize as a manual update. + // When a gtk.Paned is resized, internally the gtk_paned_calc_position + // function will change both max-position and position and synchronously + // call our propMaxPosition and propPosition functions. I.e. when the + // widget is resized, it should not be possible for onIdle to run before + // we have been notified of both property changes. fn onIdle(ud: ?*anyopaque) callconv(.c) c_int { const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); const priv = self.private(); const paned = priv.paned; - // Clear source and fields at the end. Otherwise if setPosition is called - // below, propPosition is triggered and would add another idle callback - // before this one is finished. + // Clear source and fields at the end. Otherwise if setPosition is + // called below, propPosition is triggered and would add another + // idle callback before this one is finished. defer priv.idle = null; defer priv.max_changed = false; defer priv.pos_changed = false; From 9f72eb9d7ca05fb1e7dfd1f9eb0395ed77205d13 Mon Sep 17 00:00:00 2001 From: Daniel Kinzler Date: Fri, 15 May 2026 17:52:48 +0200 Subject: [PATCH 039/137] added back accidentally deleted empty line --- src/apprt/gtk/class/split_tree.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 56796975d..5aef53d2e 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -969,6 +969,7 @@ pub const SplitTree = extern struct { defer left.deinit(); const right = self.buildTree(tree, s.right); defer right.deinit(); + break :split .initNew(SplitTreeSplit.new( current, &s, From 0a3598d7a1e794214b2887f3f2acf79f67222fac Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 12:53:21 +0000 Subject: [PATCH 040/137] Update VOUCHED list (#12705) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12625#discussioncomment-16940011) from @bo2themax. Vouch: @backnotprop Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index e9d45b7c6..719798850 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -47,6 +47,7 @@ anthonyzhoon athaapa atomk b1nar10 +backnotprop balazs-szucs barutsrb bch From 42ed74bf8cda529553a655439788e6c36d2a8549 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 12:56:56 +0000 Subject: [PATCH 041/137] Update VOUCHED list (#12706) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12686#discussioncomment-16940039) from @bo2themax. Vouch: @nolinmcfarland Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 719798850..3a284316c 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -195,6 +195,7 @@ nicholas-ochoa nicosuave nmggithub noib3 +nolinmcfarland nouritsu nwehg ocean6954 From cf24a4856b24f7b381c13f1491421e84b3bf802a Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 12:57:27 +0000 Subject: [PATCH 042/137] Update VOUCHED list (#12707) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12625#discussioncomment-16940042) from @bo2themax. Unvouch: @backnotprop Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 3a284316c..04fb89ecd 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -47,7 +47,6 @@ anthonyzhoon athaapa atomk b1nar10 -backnotprop balazs-szucs barutsrb bch From 59eece9a8edebf29f59d7d1b627cbc97c1363924 Mon Sep 17 00:00:00 2001 From: Nolin McFarland Date: Sat, 16 May 2026 19:59:20 -0400 Subject: [PATCH 043/137] feat: use find pasteboard to store search needle --- .../Ghostty/Surface View/OSSurfaceView.swift | 32 +++++++++++++++++-- .../Ghostty/Surface View/SurfaceView.swift | 12 +++++++ macos/Sources/Helpers/CrossKit.swift | 2 ++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift index d07a2e0c8..3af562744 100644 --- a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift @@ -115,13 +115,39 @@ extension Ghostty { // MARK: Search State extension Ghostty.OSSurfaceView { - class SearchState: ObservableObject { + @MainActor class SearchState: ObservableObject { + /// The pasteboard used to persist the search needle. + /// + /// The `.find` pasteboard lets us sync our needle across the system and other find bars. + private let pasteboard: OSPasteboard + @Published var needle: String = "" @Published var selected: UInt? @Published var total: UInt? - init(from startSearch: Ghostty.Action.StartSearch) { - self.needle = startSearch.needle ?? "" + init( + from startSearch: Ghostty.Action.StartSearch, + pasteboard: OSPasteboard = OSPasteboard(name: .find) + ) { + self.pasteboard = pasteboard + if let needle = startSearch.needle, !needle.isEmpty { + self.needle = needle + writePasteboardNeedle() + } else { + readPasteboardNeedle() + } + } + + func readPasteboardNeedle() { + let pasteboardNeedle = pasteboard.string(forType: .string) + if let pasteboardNeedle, pasteboardNeedle != needle { + needle = pasteboardNeedle + } + } + + func writePasteboardNeedle() { + pasteboard.clearContents() + pasteboard.setString(needle, forType: .string) } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 4b90a3016..52b39b074 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -401,6 +401,18 @@ extension Ghostty { .padding(.trailing, 8) } } + .onChange(of: searchState.needle) { _ in + searchState.writePasteboardNeedle() + } + .onReceive( + NotificationCenter.default.publisher( + for: OSApplication.didBecomeActiveNotification + ) + ) { _ in + // When the app becomes active, we want to check for external changes + // to our synced needle. + searchState.readPasteboardNeedle() + } #if canImport(AppKit) .onExitCommand { if searchState.needle.isEmpty { diff --git a/macos/Sources/Helpers/CrossKit.swift b/macos/Sources/Helpers/CrossKit.swift index 690e811bb..c7b782072 100644 --- a/macos/Sources/Helpers/CrossKit.swift +++ b/macos/Sources/Helpers/CrossKit.swift @@ -11,6 +11,7 @@ typealias OSView = NSView typealias OSColor = NSColor typealias OSSize = NSSize typealias OSPasteboard = NSPasteboard +typealias OSApplication = NSApplication protocol OSViewRepresentable: NSViewRepresentable where NSViewType == OSViewType { associatedtype OSViewType: NSView @@ -36,6 +37,7 @@ typealias OSView = UIView typealias OSColor = UIColor typealias OSSize = CGSize typealias OSPasteboard = UIPasteboard +typealias OSApplication = UIApplication protocol OSViewRepresentable: UIViewRepresentable { associatedtype OSViewType: UIView From 8fa42c6ec0b2be97148ec7e90c00e3cc58d5c589 Mon Sep 17 00:00:00 2001 From: Nolin McFarland Date: Sat, 16 May 2026 20:05:11 -0400 Subject: [PATCH 044/137] feat: add search state unit tests --- .../SurfaceView+SearchStateTests.swift | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift diff --git a/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift b/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift new file mode 100644 index 000000000..7b3c1942e --- /dev/null +++ b/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift @@ -0,0 +1,87 @@ +import AppKit +import GhosttyKit +import Testing +@testable import Ghostty + +@MainActor struct SurfaceView_SearchStateTests { + typealias SearchState = Ghostty.OSSurfaceView.SearchState + typealias StartSearch = Ghostty.Action.StartSearch + + /// A unique pasteboard for each test case prevents flakiness. + let pasteboard = OSPasteboard( + name: OSPasteboard.Name(rawValue: UUID().uuidString) + ) + + init() { + pasteboard.setString("pb", forType: .string) + } + + @Test func init_withNilNeedle_readsPasteboardNeedle() { + let sut = SearchState( + from: StartSearch(c: .init(needle: nil)), + pasteboard: pasteboard + ) + #expect(sut.needle == "pb") + } + + @Test func init_withEmptyNeedle_readsPasteboardNeedle() { + "".withCString { needle in + let sut = SearchState( + from: StartSearch(c: .init(needle: needle)), + pasteboard: pasteboard + ) + #expect(sut.needle == "pb") + } + } + + @Test func init_withNeedle_setsNeedle() { + "start".withCString { needle in + let sut = SearchState( + from: StartSearch(c: .init(needle: needle)), + pasteboard: pasteboard + ) + #expect(sut.needle == "start") + } + } + + @Test func init_withNeedle_writesPasteboard() { + "start".withCString { needle in + _ = SearchState( + from: StartSearch(c: .init(needle: needle)), + pasteboard: pasteboard + ) + #expect(pasteboard.string(forType: .string) == "start") + } + } + + @Test func writePasteboardNeedle_writesPasteboard() { + let sut = SearchState( + from: StartSearch(c: .init(needle: nil)), + pasteboard: pasteboard + ) + sut.needle = "sut" + sut.writePasteboardNeedle() + #expect(pasteboard.string(forType: .string) == "sut") + } + + @Test func readPasteboardNeedle_whenPasteboardNeedleIsNil() { + let sut = SearchState( + from: StartSearch(c: .init(needle: nil)), + pasteboard: pasteboard + ) + pasteboard.clearContents() + sut.needle = "sut" + sut.readPasteboardNeedle() + #expect(sut.needle == "sut") + } + + @Test func readPasteboardNeedle_whenPasteboardNeedleIsValid() { + let sut = SearchState( + from: StartSearch(c: .init(needle: nil)), + pasteboard: pasteboard + ) + sut.needle = "sut" + sut.readPasteboardNeedle() + #expect(sut.needle == "pb") + } +} From aed646139d2a8571c4d4048158e6a6f0c8b353e0 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 17 May 2026 00:33:21 +0000 Subject: [PATCH 045/137] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 814145c30..413c30a2f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .android_ndk = .{ .path = "./pkg/android-ndk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260427-153600-5e4d1de.tgz", - .hash = "N-V-__8AAG6jAwDWij8XfaQ0fy-HAQqvl1b6kZb4GfbHjbkZ", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz", + .hash = "N-V-__8AAPy1AwDnEoq1ww42uq58nusIeQgR16W4-5SQZFIM", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 2d51774bc..ed745b364 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz", "hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=" }, - "N-V-__8AAG6jAwDWij8XfaQ0fy-HAQqvl1b6kZb4GfbHjbkZ": { + "N-V-__8AAPy1AwDnEoq1ww42uq58nusIeQgR16W4-5SQZFIM": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260427-153600-5e4d1de.tgz", - "hash": "sha256-3iY7YiCQrhLGcH1nVNozirX1DW9/WyRNaJCElJzcKwU=" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz", + "hash": "sha256-R2NJUKxz2LHRiCBi/MAnN3XzMyY4VWlbX0uWCbWefjQ=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 0dc3cb1b3..1ca2810b1 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -193,11 +193,11 @@ in }; } { - name = "N-V-__8AAG6jAwDWij8XfaQ0fy-HAQqvl1b6kZb4GfbHjbkZ"; + name = "N-V-__8AAPy1AwDnEoq1ww42uq58nusIeQgR16W4-5SQZFIM"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/ghostty-themes-release-20260427-153600-5e4d1de.tgz"; - hash = "sha256-3iY7YiCQrhLGcH1nVNozirX1DW9/WyRNaJCElJzcKwU="; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz"; + hash = "sha256-R2NJUKxz2LHRiCBi/MAnN3XzMyY4VWlbX0uWCbWefjQ="; unpack = false; }; } diff --git a/build.zig.zon.txt b/build.zig.zon.txt index c8a68d086..108afb31b 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,7 +6,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918 https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz -https://deps.files.ghostty.org/ghostty-themes-release-20260427-153600-5e4d1de.tgz +https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 2e72109b8..1d6c5acb8 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260427-153600-5e4d1de.tgz", - "dest": "vendor/p/N-V-__8AAG6jAwDWij8XfaQ0fy-HAQqvl1b6kZb4GfbHjbkZ", - "sha256": "de263b622090ae12c6707d6754da338ab5f50d6f7f5b244d689084949cdc2b05" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz", + "dest": "vendor/p/N-V-__8AAPy1AwDnEoq1ww42uq58nusIeQgR16W4-5SQZFIM", + "sha256": "47634950ac73d8b1d1882062fcc0273775f333263855695b5f4b9609b59e7e34" }, { "type": "archive", From 69cab3d8085d0731574f4498864ced901e55c8b0 Mon Sep 17 00:00:00 2001 From: Nolin McFarland Date: Sun, 17 May 2026 11:26:32 -0400 Subject: [PATCH 046/137] feat: select needle when reading from pasteboard --- .../Ghostty/Surface View/OSSurfaceView.swift | 5 ++ .../Ghostty/Surface View/SurfaceView.swift | 6 ++- macos/Sources/Helpers/Backport.swift | 46 +++++++++++++++++++ .../SurfaceView+SearchStateTests.swift | 12 +++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift index 3af562744..d9c01b089 100644 --- a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift @@ -1,5 +1,6 @@ import Foundation import GhosttyKit +import SwiftUI extension Ghostty { class OSSurfaceView: OSView, ObservableObject { @@ -125,6 +126,9 @@ extension Ghostty.OSSurfaceView { @Published var selected: UInt? @Published var total: UInt? + /// The range of the needle's text selection in the find bar. + @Published var needleSelection: Range? + init( from startSearch: Ghostty.Action.StartSearch, pasteboard: OSPasteboard = OSPasteboard(name: .find) @@ -142,6 +146,7 @@ extension Ghostty.OSSurfaceView { let pasteboardNeedle = pasteboard.string(forType: .string) if let pasteboardNeedle, pasteboardNeedle != needle { needle = pasteboardNeedle + needleSelection = needle.startIndex..? + + init( + _ titleKey: LocalizedStringKey, + text: Binding, + selection: Binding?> + ) { + self.titleKey = titleKey + self._text = text + self._textSelection = selection + } + + var body: some View { + if #available(macOS 15, *) { + TextField( + titleKey, + text: _text, + selection: Binding( + get: { + if let textSelection { + TextSelection(range: textSelection) + } else { + nil + } + }, + set: { selection in + if let selection, + case .selection(let range) = selection.indices { + self.textSelection = range + } else { + self.textSelection = nil + } + } + ) + ) + } else { + TextField(titleKey, text: _text) + } + } +} diff --git a/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift b/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift index 7b3c1942e..00a37f67f 100644 --- a/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift +++ b/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift @@ -84,4 +84,16 @@ import Testing sut.readPasteboardNeedle() #expect(sut.needle == "pb") } + + @Test func readPasteboardNeedle_setsNeedleSelectionRange() { + let sut = SearchState( + from: StartSearch(c: .init(needle: nil)), + pasteboard: pasteboard + ) + sut.needle = "sut" + sut.readPasteboardNeedle() + + let expected = "pb".startIndex..<"pb".endIndex + #expect(sut.needleSelection == expected) + } } From ed521606122cde73f0072abc6a7caa5da7dc4829 Mon Sep 17 00:00:00 2001 From: Nolin McFarland Date: Sun, 17 May 2026 12:33:56 -0400 Subject: [PATCH 047/137] feat: support BackportSelectionTextField on iOS 18 --- macos/Sources/Helpers/Backport.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index 37c2c8089..168612221 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -132,8 +132,8 @@ enum BackportNSGlassStyle { #endif } -/// Backported `TextField` that supports text selection on macOS 15 and up. The `selection` has no -/// effect on versions below macOS 15. +/// Backported `TextField` that supports text selection on macOS 15/iOS 18 and up. The `selection` +/// has no effect on versions below macOS 15/iOS 18. struct BackportSelectionTextField: View { private let titleKey: LocalizedStringKey @Binding private var text: String @@ -150,7 +150,7 @@ struct BackportSelectionTextField: View { } var body: some View { - if #available(macOS 15, *) { + if #available(iOS 18.0, macOS 15, *) { TextField( titleKey, text: _text, From 22b9df25e65a37292e2cf91750947d73fc669b64 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 17 May 2026 14:42:27 -0500 Subject: [PATCH 048/137] Fix "Available since" Co-authored-by: Leah Amelia Chen --- src/cli/toggle_quick_terminal.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/toggle_quick_terminal.zig b/src/cli/toggle_quick_terminal.zig index 16d2a1473..233700f77 100644 --- a/src/cli/toggle_quick_terminal.zig +++ b/src/cli/toggle_quick_terminal.zig @@ -36,7 +36,7 @@ pub const Options = struct { /// * `--class=`: If set, connect to a custom instance of Ghostty. /// The class must be a valid GTK application ID. /// -/// Available since: 1.3.0 +/// Available since: 1.4.0 pub fn run(alloc: Allocator) !u8 { var buf: [256]u8 = undefined; var stderr_writer = std.fs.File.stderr().writer(&buf); From 7c2b29a9f3047b2f73d632546c51cfbe52fd6a7b Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Mon, 18 May 2026 13:44:34 +0800 Subject: [PATCH 049/137] build(highway): require `apple_sdk` for darwin builds --- pkg/highway/build.zig | 7 +++++++ pkg/highway/build.zig.zon | 1 + 2 files changed, 8 insertions(+) diff --git a/pkg/highway/build.zig b/pkg/highway/build.zig index 0ac776123..a02447285 100644 --- a/pkg/highway/build.zig +++ b/pkg/highway/build.zig @@ -37,6 +37,13 @@ pub fn build(b: *std.Build) !void { try android_ndk.addPaths(b, lib); } + // Mainly for iOS simulators, but we add for all Darwin target for + // consistency. + if (target.result.os.tag.isDarwin()) { + const apple_sdk = @import("apple_sdk"); + try apple_sdk.addPaths(b, lib); + } + var flags: std.ArrayList([]const u8) = .empty; defer flags.deinit(b.allocator); try flags.appendSlice(b.allocator, &.{ diff --git a/pkg/highway/build.zig.zon b/pkg/highway/build.zig.zon index 96b2768ae..e68636309 100644 --- a/pkg/highway/build.zig.zon +++ b/pkg/highway/build.zig.zon @@ -12,5 +12,6 @@ }, .android_ndk = .{ .path = "../android-ndk" }, + .apple_sdk = .{ .path = "../apple-sdk" }, }, } From bf716a0c3940ef9a77a4a5d38581e83c0ad44411 Mon Sep 17 00:00:00 2001 From: Nolin McFarland Date: Mon, 18 May 2026 10:12:26 -0400 Subject: [PATCH 050/137] feat: add extension to normalize OSPasteboard string interface --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../Ghostty/Surface View/OSSurfaceView.swift | 7 ++--- .../Extensions/OSPasteboard+Extension.swift | 28 +++++++++++++++++++ .../SurfaceView+SearchStateTests.swift | 4 +-- 4 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/OSPasteboard+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index fb24d0813..6d883ded8 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -109,6 +109,7 @@ Helpers/CrossKit.swift, "Helpers/Extensions/NSImage+Extension.swift", "Helpers/Extensions/OSColor+Extension.swift", + "Helpers/Extensions/OSPasteboard+Extension.swift", ); target = 8193244C2F24E6C000A9ED8F /* DockTilePlugin */; }; diff --git a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift index d9c01b089..2bf9e4cf3 100644 --- a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift @@ -131,7 +131,7 @@ extension Ghostty.OSSurfaceView { init( from startSearch: Ghostty.Action.StartSearch, - pasteboard: OSPasteboard = OSPasteboard(name: .find) + pasteboard: OSPasteboard = OSPasteboard.find ) { self.pasteboard = pasteboard if let needle = startSearch.needle, !needle.isEmpty { @@ -143,7 +143,7 @@ extension Ghostty.OSSurfaceView { } func readPasteboardNeedle() { - let pasteboardNeedle = pasteboard.string(forType: .string) + let pasteboardNeedle = pasteboard.string if let pasteboardNeedle, pasteboardNeedle != needle { needle = pasteboardNeedle needleSelection = needle.startIndex.. Date: Mon, 18 May 2026 19:17:07 +0000 Subject: [PATCH 051/137] Update VOUCHED list (#12733) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12732#discussioncomment-16966426) from @jcollie. Vouch: @rewdy Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 04fb89ecd..f1741289b 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -218,6 +218,7 @@ puzza007 qwerasd205 raphamorim reo101 +rewdy rgehan rhodes-b rightaditya From fdf84ef7ce611b4374d5cef3e213f155fae26914 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Mon, 18 May 2026 20:40:57 +0200 Subject: [PATCH 052/137] macOS: check the resource the URL refers to. Fixes #12727. [`NSURL.hasDirectoryPath` doesn't do this](https://developer.apple.com/documentation/foundation/nsurl/hasdirectorypath). We don't need to check this in NewTerminalIntent since AppIntent already appends `/` to the directory. --- macos/Sources/Features/Services/ServiceProvider.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift index 9bf46fcf9..2ec4e473c 100644 --- a/macos/Sources/Features/Services/ServiceProvider.swift +++ b/macos/Sources/Features/Services/ServiceProvider.swift @@ -42,7 +42,13 @@ class ServiceProvider: NSObject { // to their directories because that's the only thing we can open. let directoryURLs = Set( pathURLs.map { url -> URL in - url.hasDirectoryPath ? url : url.deletingLastPathComponent() + /// We check file system resources here because + /// NSURL doesn't append `/` when reading string contents from pasteboard + /// ``` + /// NSURL(pasteboardPropertyList: "/System/Library".propertyList(), ofType: .fileURL)?.hasDirectoryPath + /// ``` + let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory ?? url.hasDirectoryPath + return isDirectory ? url : url.deletingLastPathComponent() } ) From 3ac75627910232ac761e8e932a2850123243d5a3 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Mon, 18 May 2026 20:42:05 +0200 Subject: [PATCH 053/137] macOS: set error when there is no directory to open with --- macos/Sources/Features/Services/ServiceProvider.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift index 2ec4e473c..fd3e8e9cc 100644 --- a/macos/Sources/Features/Services/ServiceProvider.swift +++ b/macos/Sources/Features/Services/ServiceProvider.swift @@ -52,6 +52,11 @@ class ServiceProvider: NSObject { } ) + guard !directoryURLs.isEmpty else { + error.pointee = Self.errorNoString + return + } + for url in directoryURLs { var config = Ghostty.SurfaceConfiguration() config.workingDirectory = url.path(percentEncoded: false) From 7f5c233492c40e26d6e58e25b4ad07617e728ea4 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Tue, 19 May 2026 19:56:09 +0200 Subject: [PATCH 054/137] macOS: add `windowCanBeClosedWithoutConfirmation` without any side effects --- .../Terminal/BaseTerminalController.swift | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index cc631eb72..f2b2d0e44 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -343,12 +343,12 @@ class BaseTerminalController: NSWindowController, let alertWindow = alert.window self.alert = nil if response == .alertFirstButtonReturn { - // This is important so that we avoid losing focus when Stage - // Manager is used (#8336) + // This is important so that we avoid losing focus when Stage + // Manager is used (#8336) alertWindow.orderOut(nil) completion() - } } + } // Store our alert so we only ever show one. self.alert = alert @@ -1183,10 +1183,8 @@ class BaseTerminalController: NSWindowController, // MARK: NSWindowDelegate - // This is called when performClose is called on a window (NOT when close() - // is called directly). performClose is called primarily when UI elements such - // as the "red X" are pressed. - func windowShouldClose(_ sender: NSWindow) -> Bool { + /// Check whether window should be closed without showing an alert + func windowCanBeClosedWithoutConfirmation() -> Bool { // We must have a window. Is it even possible not to? guard let window = self.window else { return true } @@ -1199,12 +1197,22 @@ class BaseTerminalController: NSWindowController, // If our surfaces don't require confirmation, close. if !surfaceTree.contains(where: { $0.needsConfirmQuit }) { return true } + return false + } + + // This is called when performClose is called on a window (NOT when close() + // is called directly). performClose is called primarily when UI elements such + // as the "red X" are pressed. + func windowShouldClose(_ sender: NSWindow) -> Bool { + guard !windowCanBeClosedWithoutConfirmation() else { + return true + } // We require confirmation, so show an alert as long as we aren't already. confirmClose( messageText: "Close Terminal?", informativeText: "The terminal still has a running process. If you close the terminal the process will be killed." - ) { - window.close() + ) { [weak self] in + self?.window?.close() } return false From 8f9b86afa8d48afe94e2b895c8442f670c4a0b9b Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Tue, 19 May 2026 19:58:01 +0200 Subject: [PATCH 055/137] macOS: add confirmCloseAsync to return the actual response --- .../Terminal/BaseTerminalController.swift | 48 ++++++++++++------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index f2b2d0e44..6903c8439 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -316,19 +316,18 @@ class BaseTerminalController: NSWindowController, savedFrame = .init(window: window.frame, screen: screen.visibleFrame) } - func confirmClose( + func confirmCloseAsync( messageText: String, informativeText: String, - completion: @escaping () -> Void - ) { + confirmButtonTitle: String = "Close", + ) async -> NSApplication.ModalResponse? { // If we already have an alert, we need to wait for that one. - guard alert == nil else { return } + guard alert == nil else { return nil } // If there is no window to attach the modal then we assume success // since we'll never be able to show the modal. guard let window else { - completion() - return + return .OK } // If we need confirmation by any, show one confirmation for all windows @@ -336,22 +335,35 @@ class BaseTerminalController: NSWindowController, let alert = NSAlert() alert.messageText = messageText alert.informativeText = informativeText - alert.addButton(withTitle: "Close") + alert.addButton(withTitle: confirmButtonTitle) alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning - alert.beginSheetModal(for: window) { response in - let alertWindow = alert.window - self.alert = nil - if response == .alertFirstButtonReturn { - // This is important so that we avoid losing focus when Stage - // Manager is used (#8336) - alertWindow.orderOut(nil) - completion() - } - } - // Store our alert so we only ever show one. self.alert = alert + defer { + // This is important so that we avoid losing focus when Stage + // Manager is used (#8336) + alert.window.orderOut(nil) + self.alert = nil + } + return await alert.beginSheetModal(for: window) + } + + func confirmClose( + messageText: String, + informativeText: String, + confirmButtonTitle: String = "Close", + completion: @escaping () -> Void + ) { + Task { + guard let response = await confirmCloseAsync(messageText: messageText, informativeText: informativeText, confirmButtonTitle: confirmButtonTitle) else { + completion() + return + } + if [.alertFirstButtonReturn, .OK].contains(response) { + completion() + } + } } /// Prompt the user to change the tab/window title. From 00a989774e23d4c6035f5565a167be63e22c788d Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Tue, 19 May 2026 20:01:32 +0200 Subject: [PATCH 056/137] macOS: add review windows when quitting Inspired by Terminal.app --- macos/Sources/App/macOS/AppDelegate.swift | 88 +++++++++++++++++++---- 1 file changed, 74 insertions(+), 14 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 67ec9ac4a..a971df9ba 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -404,20 +404,7 @@ class AppDelegate: NSObject, // If our app says we don't need to confirm, we can exit now. if !ghostty.needsConfirmQuit { return .terminateNow } - // We have some visible window. Show an app-wide modal to confirm quitting. - let alert = NSAlert() - alert.messageText = "Quit Ghostty?" - alert.informativeText = "All terminal sessions will be terminated." - alert.addButton(withTitle: "Close Ghostty") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - switch alert.runModal() { - case .alertFirstButtonReturn: - return .terminateNow - - default: - return .terminateCancel - } + return terminate() } func applicationWillTerminate(_ notification: Notification) { @@ -1305,6 +1292,79 @@ extension AppDelegate: NSMenuItemValidation { } } +// MARK: - Termination Flow + +extension AppDelegate { + func terminate() -> NSApplication.TerminateReply { + let controllersNeedConfirmation = NSApplication.shared.windows + .compactMap { $0.windowController as? BaseTerminalController } + .filter { !$0.windowCanBeClosedWithoutConfirmation() } + + guard !controllersNeedConfirmation.isEmpty else { + return .terminateNow + } + + if controllersNeedConfirmation.count == 1 { + Task { + let response = await controllersNeedConfirmation[0].confirmCloseAsync( + messageText: "Quit Ghostty?", + informativeText: "The terminal still has a running process. If you quit, the process will be killed.", + confirmButtonTitle: "Terminate", + ) + + if [.OK, .alertFirstButtonReturn].contains(response) { + await NSApp.reply(toApplicationShouldTerminate: true) + } else { + await NSApp.reply(toApplicationShouldTerminate: false) + } + } + + return .terminateLater + } else { + let alert = NSAlert() + alert.messageText = "You have \(controllersNeedConfirmation.count) windows with running processes. Do you want to review these windows before quitting?" + alert.informativeText = "If you don't review your windows, any running processes will be terminated" + alert.addButton(withTitle: "Review Windows...") + alert.addButton(withTitle: "Terminate Processes") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + + switch alert.runModal() { + case .alertFirstButtonReturn: + reviewWindows(controllersNeedConfirmation) + return .terminateLater + case .alertSecondButtonReturn: + return .terminateNow + default: + return .terminateCancel + } + } + } + + private func reviewWindows(_ controllers: [BaseTerminalController]) { + Task { + for controller in controllers { + let response = await controller.confirmCloseAsync( + messageText: "Quit Ghostty?", + informativeText: "The terminal still has a running process. If you quit, the process will be killed.", + confirmButtonTitle: "Terminate", + ) + + if [.OK, .alertFirstButtonReturn].contains(response) { + // Close this window and until next review is cancelled + await controller.window?.close() + continue + } else { + await NSApp.reply(toApplicationShouldTerminate: false) + // Cancel the review + return + } + } + await NSApp.reply(toApplicationShouldTerminate: true) + } + } +} + /// Represents the state of the quick terminal controller. private enum QuickTerminalState { /// Controller has not been initialized and has no pending restoration state. From 9bcb30aa119b20833d74d4e32104dafe20bd8203 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 20:36:54 +0000 Subject: [PATCH 057/137] Update VOUCHED list (#12744) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12743#discussioncomment-16981434) from @bo2themax. Vouch: @b0uks Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index f1741289b..c575e334b 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -46,6 +46,7 @@ anmitalidev anthonyzhoon athaapa atomk +b0uks b1nar10 balazs-szucs barutsrb From 19e20f7664dc7a755d2d7a16ab545b2503f26caf Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 03:31:15 +0000 Subject: [PATCH 058/137] Update VOUCHED list (#12746) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12745#discussioncomment-16984203) from @jcollie. Vouch: @mjbommar Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index c575e334b..f25e4763d 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -180,6 +180,7 @@ misairuzame mischief mitchellh miupa +mjbommar mohshami molechowski moonmao42 From 4f94afdb4b25b52583eef6c03217b8bb561c9ad6 Mon Sep 17 00:00:00 2001 From: trag1c Date: Wed, 20 May 2026 18:13:49 +0200 Subject: [PATCH 059/137] vouch: enable auto-locking closed issues --- .github/workflows/vouch-check-issue.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/vouch-check-issue.yml b/.github/workflows/vouch-check-issue.yml index 181f19329..c91de8aeb 100644 --- a/.github/workflows/vouch-check-issue.yml +++ b/.github/workflows/vouch-check-issue.yml @@ -18,10 +18,11 @@ jobs: with: sparse-checkout: .github/issue-unvouched-message - - uses: mitchellh/vouch/action/check-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + - uses: mitchellh/vouch/action/check-issue@52aec3d64655edf2fdb58f298e02da754a056daf # unreleased main with: issue-number: ${{ github.event.issue.number }} auto-close: true + auto-lock: true template-file: .github/issue-unvouched-message env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} From 88d30bb30a02459a97e49fbb0817748422d6f65a Mon Sep 17 00:00:00 2001 From: Mike Bommarito Date: Wed, 20 May 2026 22:50:03 -0400 Subject: [PATCH 060/137] gtk: wire occlusionCallback to GLArea map/unmap Calls core_surface.occlusionCallback(visible) from the existing glareaMap/glareaUnmap handlers (added in #12698) so the renderer thread learns when a surface is off-screen. Co-Authored-By: Claude Opus 4.7 --- src/apprt/gtk/class/surface.zig | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 2e05d7b12..3c9293a82 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3283,6 +3283,7 @@ pub const Surface = extern struct { self: *Self, ) callconv(.c) void { self.updateMapped(true); + self.updateOcclusion(true); } fn glareaUnmap( @@ -3290,6 +3291,7 @@ pub const Surface = extern struct { self: *Self, ) callconv(.c) void { self.updateMapped(false); + self.updateOcclusion(false); } fn updateMapped(self: *Self, mapped: bool) void { @@ -3298,6 +3300,13 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.mapped.impl.param_spec); } + fn updateOcclusion(self: *Self, visible: bool) void { + const surface = self.core() orelse return; + surface.occlusionCallback(visible) catch |err| { + log.warn("error in occlusion callback err={}", .{err}); + }; + } + fn glareaRender( _: *gtk.GLArea, _: *gdk.GLContext, From 14d9e600acf274f9b04da0dd2695d092aa93c3b6 Mon Sep 17 00:00:00 2001 From: Mike Bommarito Date: Wed, 20 May 2026 22:50:03 -0400 Subject: [PATCH 061/137] renderer: skip updateFrame when surface is not visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderCallback early-returns while !flags.visible to avoid the cell rebuild for hidden surfaces (tab switch, minimize, etc.). The .visible → true mailbox handler now runs updateFrame before drawFrame so the first frame after re-show isn't stale. Co-Authored-By: Claude Opus 4.7 --- src/renderer/Thread.zig | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 508721379..488642199 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -360,10 +360,16 @@ fn drainMailbox(self: *Thread) !void { // Visibility affects our QoS class self.setQosClass(); - // If we became visible then we immediately trigger a draw. - // We don't need to update frame data because that should - // still be happening. - if (v) self.drawFrame(false); + // If we became visible then we immediately rebuild cells + // (renderCallback skips updateFrame while invisible) and draw. + if (v) { + self.renderer.updateFrame( + self.state, + self.flags.cursor_blink_visible, + ) catch |err| + log.warn("error rendering on visibility regain err={}", .{err}); + self.drawFrame(false); + } // Notify the renderer so it can update any state. self.renderer.setVisible(v); @@ -606,6 +612,10 @@ fn renderCallback( return .disarm; }; + // If we're not visible there's no point spending CPU rebuilding cells — + // we'll catch up when the .visible mailbox message flips us back on. + if (!t.flags.visible) return .disarm; + // Update our frame data t.renderer.updateFrame( t.state, From cb79efa779ce05535797ff191bd0b7ce06b2ea77 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 00:23:23 +0000 Subject: [PATCH 062/137] build(deps): bump docker/build-push-action from 7.1.0 to 7.2.0 Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 7.1.0 to 7.2.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/bcafcacb16a39f128d818304e6c9c0c18556b85f...f9f3042f7e2789586610d6e8b85c8f03e5195baf) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: 7.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 85e5e3792..421abfce4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1808,7 +1808,7 @@ jobs: tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Build and push - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: dist file: dist/src/build/docker/debian/Dockerfile From 1b3c5b57ff50c4241c612337bc202c12823cc5d8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 07:25:16 +0000 Subject: [PATCH 063/137] Update VOUCHED list https://github.com/ghostty-org/ghostty/discussions/12775#discussioncomment-DC_kwDOHFhdAs4BA9x5 --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index f25e4763d..77e569f4f 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -176,6 +176,7 @@ michielvk miguelelgallo mihi314 mikailmm +minorcell misairuzame mischief mitchellh From a968e120dd084bd886239d1cac938f0177f019d9 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 07:58:07 +0000 Subject: [PATCH 064/137] Update VOUCHED list (#12780) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12775#discussioncomment-17030265) from @bo2themax. Vouch: @minorcell Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index f25e4763d..77e569f4f 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -176,6 +176,7 @@ michielvk miguelelgallo mihi314 mikailmm +minorcell misairuzame mischief mitchellh From 7a346dd8d40e21b14c96114c45f75eb0d347c236 Mon Sep 17 00:00:00 2001 From: minorcell Date: Sat, 23 May 2026 00:08:34 +0800 Subject: [PATCH 065/137] macOS: fix search bar Enter key blocking IME composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use onSubmit for the plain Enter → next-match behavior, which respects IME composition state. Keep onKeyPress only for Shift+Enter (previous match), returning .ignored for plain Enter so the IME can process it. --- macos/Sources/Ghostty/Surface View/SurfaceView.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 825927323..67862391c 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -417,6 +417,10 @@ extension Ghostty { // to our synced needle. searchState.readPasteboardNeedle() } + .onSubmit { + _ = surfaceView.navigateSearchToNext() + } + } #if canImport(AppKit) .onExitCommand { if searchState.needle.isEmpty { @@ -429,10 +433,9 @@ extension Ghostty { .backport.onKeyPress(.return) { modifiers in if modifiers.contains(.shift) { _ = surfaceView.navigateSearchToPrevious() - } else { - _ = surfaceView.navigateSearchToNext() + return .handled } - return .handled + return .ignored } Button(action: { From da541bea6333458c5f4a987e734269e019a2103d Mon Sep 17 00:00:00 2001 From: minorcell Date: Sat, 23 May 2026 16:33:33 +0800 Subject: [PATCH 066/137] fix stray brace from conflict resolution --- macos/Sources/Ghostty/Surface View/SurfaceView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 67862391c..f6b30a7ad 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -420,7 +420,6 @@ extension Ghostty { .onSubmit { _ = surfaceView.navigateSearchToNext() } - } #if canImport(AppKit) .onExitCommand { if searchState.needle.isEmpty { From 2355550a9410f0f10bc1e88e677a9f9ed091bb71 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 13:56:12 -0700 Subject: [PATCH 067/137] libghostty: add tracked grid ref API Add a C API for tracked pins, known as a tracked grid ref in C. The new API can create tracked refs from terminal points, snapshot them back to regular grid refs for cell access, convert them to coordinates, move them to a new point, report when their semantic location was lost, and free the tracked pin bookkeeping. This is backed by PageList tracked pins and exposed through the libghostty-vt export layer and headers. --- example/c-vt-grid-ref-tracked/README.md | 19 ++ example/c-vt-grid-ref-tracked/build.zig | 42 +++++ example/c-vt-grid-ref-tracked/build.zig.zon | 24 +++ example/c-vt-grid-ref-tracked/src/main.c | 94 ++++++++++ include/ghostty/vt.h | 7 + include/ghostty/vt/grid_ref.h | 80 +++++++-- include/ghostty/vt/grid_ref_tracked.h | 134 ++++++++++++++ include/ghostty/vt/terminal.h | 31 ++++ include/ghostty/vt/types.h | 10 ++ src/lib_vt.zig | 6 + src/terminal/ScreenSet.zig | 43 +++++ src/terminal/c/grid_ref_tracked.zig | 187 ++++++++++++++++++++ src/terminal/c/main.zig | 8 + src/terminal/c/terminal.zig | 39 +++- src/terminal/point.zig | 10 ++ 15 files changed, 715 insertions(+), 19 deletions(-) create mode 100644 example/c-vt-grid-ref-tracked/README.md create mode 100644 example/c-vt-grid-ref-tracked/build.zig create mode 100644 example/c-vt-grid-ref-tracked/build.zig.zon create mode 100644 example/c-vt-grid-ref-tracked/src/main.c create mode 100644 include/ghostty/vt/grid_ref_tracked.h create mode 100644 src/terminal/c/grid_ref_tracked.zig diff --git a/example/c-vt-grid-ref-tracked/README.md b/example/c-vt-grid-ref-tracked/README.md new file mode 100644 index 000000000..e2e9ac980 --- /dev/null +++ b/example/c-vt-grid-ref-tracked/README.md @@ -0,0 +1,19 @@ +# Example: `ghostty-vt` Tracked Grid References + +This contains a simple example of how to use the `ghostty-vt` terminal and +tracked grid reference APIs to keep a long-lived reference to a cell as the +terminal scrolls, detect when that reference loses its meaningful location, +and move the same tracked handle to a new point. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-grid-ref-tracked/build.zig b/example/c-vt-grid-ref-tracked/build.zig new file mode 100644 index 000000000..ec3df2da7 --- /dev/null +++ b/example/c-vt-grid-ref-tracked/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_grid_ref_tracked", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-grid-ref-tracked/build.zig.zon b/example/c-vt-grid-ref-tracked/build.zig.zon new file mode 100644 index 000000000..ecb6f110e --- /dev/null +++ b/example/c-vt-grid-ref-tracked/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_grid_ref_tracked, + .version = "0.0.0", + .fingerprint = 0x64bd14b59e76c294, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-grid-ref-tracked/src/main.c b/example/c-vt-grid-ref-tracked/src/main.c new file mode 100644 index 000000000..a914a9727 --- /dev/null +++ b/example/c-vt-grid-ref-tracked/src/main.c @@ -0,0 +1,94 @@ +#include +#include +#include +#include +#include + +//! [grid-ref-tracked] +static uint32_t codepoint_at_tracked_ref(GhosttyTrackedGridRef tracked) { + GhosttyGridRef snapshot = GHOSTTY_INIT_SIZED(GhosttyGridRef); + GhosttyResult result = ghostty_tracked_grid_ref_snapshot(tracked, &snapshot); + assert(result == GHOSTTY_SUCCESS); + + GhosttyCell cell; + result = ghostty_grid_ref_cell(&snapshot, &cell); + assert(result == GHOSTTY_SUCCESS); + + bool has_text = false; + ghostty_cell_get(cell, GHOSTTY_CELL_DATA_HAS_TEXT, &has_text); + assert(has_text); + + uint32_t codepoint = 0; + ghostty_cell_get(cell, GHOSTTY_CELL_DATA_CODEPOINT, &codepoint); + return codepoint; +} + +int main() { + GhosttyTerminal terminal; + GhosttyTerminalOptions opts = { + .cols = 8, + .rows = 3, + .max_scrollback = 100, + }; + GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + const char *text = "alpha\r\n" + "bravo\r\n" + "charlie"; + ghostty_terminal_vt_write( + terminal, (const uint8_t *)text, strlen(text)); + + GhosttyTrackedGridRef tracked = NULL; + GhosttyPoint alpha = { + .tag = GHOSTTY_POINT_TAG_ACTIVE, + .value = { .coordinate = { .x = 0, .y = 0 } }, + }; + result = ghostty_terminal_grid_ref_track(terminal, alpha, &tracked); + assert(result == GHOSTTY_SUCCESS); + + // Writing another line scrolls the original "alpha" row into scrollback. + // The tracked ref still follows the same cell. + const char *more = "\r\ndelta"; + ghostty_terminal_vt_write( + terminal, (const uint8_t *)more, strlen(more)); + + assert(ghostty_tracked_grid_ref_has_value(tracked)); + printf("tracked codepoint after scroll: %c\n", + (char)codepoint_at_tracked_ref(tracked)); + + GhosttyPointCoordinate screen = {0}; + result = ghostty_tracked_grid_ref_point( + tracked, GHOSTTY_POINT_TAG_SCREEN, &screen); + assert(result == GHOSTTY_SUCCESS); + printf("tracked screen point: %u,%u\n", screen.x, screen.y); + + // Resetting the terminal discards the old grid contents. The tracked + // handle remains valid, but no longer has a meaningful location. + ghostty_terminal_reset(terminal); + assert(!ghostty_tracked_grid_ref_has_value(tracked)); + + GhosttyGridRef discarded = GHOSTTY_INIT_SIZED(GhosttyGridRef); + result = ghostty_tracked_grid_ref_snapshot(tracked, &discarded); + assert(result == GHOSTTY_NO_VALUE); + + // The same handle can be moved to a new point after it loses its value. + const char *replacement = "echo"; + ghostty_terminal_vt_write( + terminal, (const uint8_t *)replacement, strlen(replacement)); + + GhosttyPoint echo = { + .tag = GHOSTTY_POINT_TAG_ACTIVE, + .value = { .coordinate = { .x = 0, .y = 0 } }, + }; + result = ghostty_tracked_grid_ref_set(tracked, terminal, echo); + assert(result == GHOSTTY_SUCCESS); + assert(ghostty_tracked_grid_ref_has_value(tracked)); + printf("tracked codepoint after reset/set: %c\n", + (char)codepoint_at_tracked_ref(tracked)); + + ghostty_tracked_grid_ref_free(tracked); + ghostty_terminal_free(terminal); + return 0; +} +//! [grid-ref-tracked] diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 649ab1d4d..75bbb3b5b 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -54,6 +54,7 @@ * - @ref c-vt-sgr/src/main.c - SGR parser example * - @ref c-vt-formatter/src/main.c - Terminal formatter example * - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs + * - @ref c-vt-grid-ref-tracked/src/main.c - Tracked grid ref example * */ @@ -98,6 +99,11 @@ * grid refs to inspect cell codepoints, row wrap state, and cell styles. */ +/** @example c-vt-grid-ref-tracked/src/main.c + * This example demonstrates how to track a grid ref as the terminal scrolls, + * detect when it loses its value, and move it to a new point. + */ + /** @example c-vt-kitty-graphics/src/main.c * This example demonstrates how to use the system interface to install a * PNG decoder callback and send a Kitty Graphics Protocol image. @@ -120,6 +126,7 @@ extern "C" { #include #include #include +#include #include #include #include diff --git a/include/ghostty/vt/grid_ref.h b/include/ghostty/vt/grid_ref.h index 1f9f52b9b..ca857a499 100644 --- a/include/ghostty/vt/grid_ref.h +++ b/include/ghostty/vt/grid_ref.h @@ -20,24 +20,78 @@ extern "C" { /** @defgroup grid_ref Grid Reference * - * A grid reference is a resolved reference to a specific cell position in the - * terminal's internal page structure. Obtain a grid reference from - * ghostty_terminal_grid_ref(), then extract the cell or row via - * ghostty_grid_ref_cell() and ghostty_grid_ref_row(). + * A grid reference is a reference to a specific cell position in the + * terminal. Obtain a grid reference from `ghostty_terminal_grid_ref` + * for untracked or `ghostty_terminal_grid_ref_track` for tracked. Untracked + * vs tracked is explained next. * - * A grid reference is only valid until the next update to the terminal - * instance. There is no guarantee that a grid reference will remain - * valid after ANY operation, even if a seemingly unrelated part of - * the grid is changed, so any information related to the grid reference - * should be read and cached immediately after obtaining the grid reference. + * Important: The grid reference APIs are not meant to be used as the core of a render + * loop. They are not built to sustain the framerates needed for rendering large + * screens. Use the render state API for that. * - * This API is not meant to be used as the core of render loop. It isn't - * built to sustain the framerates needed for rendering large screens. - * Use the render state API for that. + * ## Untracked vs Tracked References + * + * ### Untracked Reference * - * ## Example + * An untracked grid reference is a value type that snapshots a specific + * cell. It is only valid until the next update to the terminal instance. + * There is no guarantee that it will remain valid after any operation, + * even if a seemingly unrelated part of the grid is changed. These are meant + * to be read and have their values cached immediately after obtaining it. + * + * An untracked grid reference has a performance cost in its initial lookup, + * but doesn't affect the ongoing performance of the terminal in any way, + * since it is a one-time snapshot. + * + * ### Tracked Reference + * + * A tracked grid reference follows its cell across normal screen operations. + * For example scrolling, scrollback pruning, resize/reflow, and other + * terminal mutations update the tracked reference automatically. + * + * A tracked reference can still lose its original semantic location. This can + * happen when the underlying grid is reset, pruned, or otherwise discarded in a + * way that cannot be mapped to a meaningful new cell. In that state, + * ghostty_tracked_grid_ref_has_value() returns false and + * ghostty_tracked_grid_ref_snapshot() / ghostty_tracked_grid_ref_point() return + * GHOSTTY_NO_VALUE. The handle remains valid, and callers may move it to a new + * point with ghostty_tracked_grid_ref_set(). + * + * To read cell data from a tracked reference, first snapshot it with + * ghostty_tracked_grid_ref_snapshot(). The returned `GhosttyGridRef` is again + * an untracked reference and follows the same short lifetime rules as any other + * untracked grid reference. + * + * A tracked reference belongs to the terminal screen/page-list that was active + * when it was created or last set. Converting it to a point uses that owning + * screen/page-list, even if the terminal has since switched between primary and + * alternate screens. Calling ghostty_tracked_grid_ref_set() resolves the new + * point against the terminal's currently active screen/page-list and may move + * the tracked reference between screens. + * + * Tracked references are owned by the caller and must be freed with + * ghostty_tracked_grid_ref_free() before the terminal that created them is + * freed. + * + * Each tracked reference adds bookkeeping to terminal mutations. Use them + * sparingly for long-lived anchors such as selections, search state, marks, + * or application-side bookmarks. + * + * ## Lifetime + * + * An untracked reference is a snapshot. It doesn't need to be freed. + * The safety of accessing the value is documented explicitly above: it + * is only safe to access any data until the next terminal mutating + * operation (including free). + * + * A tracked reference is allocated and must be freed when it is no + * longer needed. All tracked references must be freed prior to the + * terminal being freed. + * + * ## Examples * * @snippet c-vt-grid-traverse/src/main.c grid-ref-traverse + * @snippet c-vt-grid-ref-tracked/src/main.c grid-ref-tracked * * @{ */ diff --git a/include/ghostty/vt/grid_ref_tracked.h b/include/ghostty/vt/grid_ref_tracked.h new file mode 100644 index 000000000..f80d7fbad --- /dev/null +++ b/include/ghostty/vt/grid_ref_tracked.h @@ -0,0 +1,134 @@ +/** + * @file grid_ref_tracked.h + * + * Tracked terminal grid references. + */ + +#ifndef GHOSTTY_VT_GRID_REF_TRACKED_H +#define GHOSTTY_VT_GRID_REF_TRACKED_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Tracked grid references are owned grid references that move with the + * terminal. See @ref grid_ref for the full overview of tracked and untracked + * grid reference behavior. + * + * @ingroup grid_ref + */ + +/** + * Free a tracked grid reference. + * + * Passing NULL is allowed and has no effect. The reference must be freed before + * the terminal that created it is freed. + * + * @param ref Tracked grid reference to free. + * + * @ingroup grid_ref + */ +GHOSTTY_API void ghostty_tracked_grid_ref_free(GhosttyTrackedGridRef ref); + +/** + * Return whether a tracked grid reference currently has a meaningful value. + * + * @param ref Tracked grid reference. + * @return true if the reference currently has a meaningful value. + * + * @ingroup grid_ref + */ +GHOSTTY_API bool ghostty_tracked_grid_ref_has_value( + GhosttyTrackedGridRef ref); + +/** + * Convert a tracked grid reference to a point in the requested coordinate + * space. + * + * This is the tracked equivalent of ghostty_terminal_point_from_grid_ref(). + * Unlike snapshotting, this does not expose an intermediate untracked + * GhosttyGridRef. + * + * A tracked reference is resolved against the terminal screen/page-list that + * currently owns the reference. If the terminal has switched between primary + * and alternate screens since the reference was created or last set, this may + * be different from the terminal's currently active screen. + * + * If the tracked reference no longer has a meaningful value, this returns + * GHOSTTY_NO_VALUE. GHOSTTY_NO_VALUE is also returned when the reference cannot + * be represented in the requested coordinate space. + * + * @param ref Tracked grid reference. + * @param tag Coordinate space to convert into. + * @param[out] out_point On success, receives the coordinate. May be NULL. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref is invalid, + * or GHOSTTY_NO_VALUE if there is no representable value. + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_point( + GhosttyTrackedGridRef ref, + GhosttyPointTag tag, + GhosttyPointCoordinate *out_point); + +/** + * Move an existing tracked grid reference to a new terminal point. + * + * On success, the tracked reference begins tracking the new point and any prior + * "no value" state is cleared. On GHOSTTY_OUT_OF_MEMORY, the original tracked + * reference is left unchanged. + * + * The terminal must be the same terminal that created the tracked reference. + * The point is resolved against the terminal screen/page-list that is active at + * the time this function is called. If the terminal has switched between + * primary and alternate screens, this may move the tracked reference from one + * screen/page-list to the other. + * + * @param ref Tracked grid reference. + * @param terminal Terminal instance that owns the reference. + * @param point New point to track. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref, terminal, + * or point is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation fails. + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_set( + GhosttyTrackedGridRef ref, + GhosttyTerminal terminal, + GhosttyPoint point); + +/** + * Snapshot a tracked grid reference into a regular GhosttyGridRef. + * + * The returned GhosttyGridRef is an untracked snapshot and has the same + * lifetime rules as ghostty_terminal_grid_ref(): it is only valid until the + * next terminal update. Snapshot immediately before calling + * ghostty_grid_ref_cell(), ghostty_grid_ref_row(), + * ghostty_grid_ref_graphemes(), ghostty_grid_ref_hyperlink_uri(), or + * ghostty_grid_ref_style(). + * + * If the tracked reference no longer has a meaningful value, this returns + * GHOSTTY_NO_VALUE. + * + * @param ref Tracked grid reference. + * @param[out] out_ref On success, receives an untracked snapshot. May be NULL. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref is invalid, + * or GHOSTTY_NO_VALUE if the tracked location was discarded. + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_snapshot( + GhosttyTrackedGridRef ref, + GhosttyGridRef *out_ref); + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_GRID_REF_TRACKED_H */ diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 0e6d048e1..1751aa126 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1120,6 +1120,37 @@ GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref(GhosttyTerminal terminal, GhosttyPoint point, GhosttyGridRef *out_ref); +/** + * Create an owned tracked grid reference for a terminal point. + * + * This is the tracked variant of ghostty_terminal_grid_ref(). The returned + * handle follows the referenced cell as the terminal's page list is modified: + * scrolling, pruning, resize/reflow, and other page-list operations update the + * tracked reference automatically. + * + * The reference is attached to the terminal screen/page-list that is active at + * creation time. + * + * If the point is outside the requested coordinate space, this returns + * GHOSTTY_INVALID_VALUE and writes NULL to out_ref. + * + * The returned handle must be freed with ghostty_tracked_grid_ref_free() before + * the terminal is freed. + * + * @param terminal Terminal instance. + * @param point Point to track. + * @param[out] out_ref On success, receives the tracked reference handle. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if terminal, + * point, or out_ref is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation + * fails. + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref_track( + GhosttyTerminal terminal, + GhosttyPoint point, + GhosttyTrackedGridRef *out_ref); + /** * Convert a grid reference back to a point in the given coordinate system. * diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index e0be0b77d..e8e976207 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -94,6 +94,16 @@ typedef enum GHOSTTY_ENUM_TYPED { */ typedef struct GhosttyTerminalImpl* GhosttyTerminal; +/** + * Opaque handle to a tracked grid reference. + * + * A tracked grid reference is owned by the caller and must be freed with + * ghostty_tracked_grid_ref_free() before the terminal that created it is freed. + * + * @ingroup grid_ref + */ +typedef struct GhosttyTrackedGridRefImpl* GhosttyTrackedGridRef; + /** * Opaque handle to a Kitty graphics image storage. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index ae0c87b1e..12aa66bfe 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -240,6 +240,7 @@ comptime { @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); + @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); @export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" }); @export(&c.kitty_graphics_image, .{ .name = "ghostty_kitty_graphics_image" }); @@ -262,6 +263,11 @@ comptime { @export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" }); @export(&c.grid_ref_hyperlink_uri, .{ .name = "ghostty_grid_ref_hyperlink_uri" }); @export(&c.grid_ref_style, .{ .name = "ghostty_grid_ref_style" }); + @export(&c.tracked_grid_ref_free, .{ .name = "ghostty_tracked_grid_ref_free" }); + @export(&c.tracked_grid_ref_has_value, .{ .name = "ghostty_tracked_grid_ref_has_value" }); + @export(&c.tracked_grid_ref_point, .{ .name = "ghostty_tracked_grid_ref_point" }); + @export(&c.tracked_grid_ref_set, .{ .name = "ghostty_tracked_grid_ref_set" }); + @export(&c.tracked_grid_ref_snapshot, .{ .name = "ghostty_tracked_grid_ref_snapshot" }); @export(&c.build_info, .{ .name = "ghostty_build_info" }); @export(&c.type_json, .{ .name = "ghostty_type_json" }); @export(&c.alloc_alloc, .{ .name = "ghostty_alloc" }); diff --git a/src/terminal/ScreenSet.zig b/src/terminal/ScreenSet.zig index d0856da51..4d3689e3b 100644 --- a/src/terminal/ScreenSet.zig +++ b/src/terminal/ScreenSet.zig @@ -30,6 +30,11 @@ active: *Screen, /// All screens that are initialized. all: std.EnumMap(Key, *Screen), +/// Monotonic generation counter for each screen key. This changes whenever a +/// screen is removed so external handles can distinguish a newly initialized +/// screen from stale references into destroyed screen storage. +generations: std.EnumMap(Key, usize), + pub fn init( alloc: Allocator, opts: Screen.Options, @@ -42,6 +47,7 @@ pub fn init( .active_key = .primary, .active = screen, .all = .init(.{ .primary = screen }), + .generations = .initFull(0), }; } @@ -59,6 +65,11 @@ pub fn get(self: *const ScreenSet, key: Key) ?*Screen { return self.all.get(key); } +/// Get the current generation for the given screen key. +pub fn generation(self: *const ScreenSet, key: Key) usize { + return self.generations.get(key).?; +} + /// Get the screen for the given key, initializing it if necessary. pub fn getInit( self: *ScreenSet, @@ -82,6 +93,7 @@ pub fn remove( ) void { assert(key != .primary); if (self.all.fetchRemove(key)) |screen| { + self.generations.put(key, self.generation(key) +% 1); screen.deinit(); alloc.destroy(screen); } @@ -99,9 +111,40 @@ test ScreenSet { var set: ScreenSet = try .init(alloc, .default); defer set.deinit(alloc); try testing.expectEqual(.primary, set.active_key); + try testing.expectEqual(@as(usize, 0), set.generation(.primary)); + try testing.expectEqual(@as(usize, 0), set.generation(.alternate)); // Initialize a secondary screen _ = try set.getInit(alloc, .alternate, .default); + try testing.expectEqual(@as(usize, 0), set.generation(.alternate)); + set.switchTo(.alternate); try testing.expectEqual(.alternate, set.active_key); } + +test "ScreenSet generations" { + const alloc = testing.allocator; + var set: ScreenSet = try .init(alloc, .default); + defer set.deinit(alloc); + + try testing.expectEqual(@as(usize, 0), set.generation(.primary)); + try testing.expectEqual(@as(usize, 0), set.generation(.alternate)); + + // A no-op removal doesn't change the generation. + set.remove(alloc, .alternate); + try testing.expectEqual(@as(usize, 0), set.generation(.alternate)); + + // Initializing a screen doesn't change the generation. + _ = try set.getInit(alloc, .alternate, .default); + try testing.expectEqual(@as(usize, 0), set.generation(.alternate)); + + const alternate_generation = set.generation(.alternate); + set.remove(alloc, .alternate); + try testing.expectEqual(alternate_generation +% 1, set.generation(.alternate)); + + // Reinitializing keeps the generation from the last removal, so stale + // handles can distinguish the new screen from the destroyed screen. + _ = try set.getInit(alloc, .alternate, .default); + try testing.expectEqual(alternate_generation +% 1, set.generation(.alternate)); + try testing.expectEqual(@as(usize, 0), set.generation(.primary)); +} diff --git a/src/terminal/c/grid_ref_tracked.zig b/src/terminal/c/grid_ref_tracked.zig new file mode 100644 index 000000000..3233bb054 --- /dev/null +++ b/src/terminal/c/grid_ref_tracked.zig @@ -0,0 +1,187 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const PageList = @import("../PageList.zig"); +const point = @import("../point.zig"); +const grid_ref_c = @import("grid_ref.zig"); +const terminal_c = @import("terminal.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttyTrackedGridRef +/// +/// An owned tracked reference to a position in the terminal grid. The +/// underlying PageList pin is automatically updated as the PageList changes. +pub const CTrackedGridRef = ?*TrackedGridRef; + +pub const TrackedGridRef = struct { + alloc: std.mem.Allocator, + terminal: terminal_c.Terminal, + screen_key: terminal_c.TerminalScreen, + screen_generation: usize, + pin: *PageList.Pin, + + /// Return the PageList that owns this tracked ref's pin, or null if the + /// owning screen has been removed/reinitialized since the ref was created. + fn pageList(ref: *const TrackedGridRef) ?*PageList { + const wrapper = ref.terminal orelse return null; + const t = wrapper.terminal; + if (t.screens.generation(ref.screen_key) != ref.screen_generation) return null; + const screen = t.screens.get(ref.screen_key) orelse return null; + return &screen.pages; + } +}; + +pub fn tracked_grid_ref_free(ref_: CTrackedGridRef) callconv(lib.calling_conv) void { + const ref = ref_ orelse return; + if (ref.pageList()) |list| list.untrackPin(ref.pin); + ref.alloc.destroy(ref); +} + +pub fn tracked_grid_ref_has_value(ref_: CTrackedGridRef) callconv(lib.calling_conv) bool { + const ref = ref_ orelse return false; + _ = ref.pageList() orelse return false; + return !ref.pin.garbage; +} + +pub fn tracked_grid_ref_snapshot( + ref_: CTrackedGridRef, + out_ref: ?*grid_ref_c.CGridRef, +) callconv(lib.calling_conv) Result { + const ref = ref_ orelse return .invalid_value; + _ = ref.pageList() orelse return .no_value; + if (ref.pin.garbage) return .no_value; + if (out_ref) |out| out.* = grid_ref_c.CGridRef.fromPin(ref.pin.*); + return .success; +} + +pub fn tracked_grid_ref_point( + ref_: CTrackedGridRef, + tag: point.Tag, + out: ?*point.Coordinate, +) callconv(lib.calling_conv) Result { + const ref = ref_ orelse return .invalid_value; + const list = ref.pageList() orelse return .no_value; + if (ref.pin.garbage) return .no_value; + const pt = list.pointFromPin(tag, ref.pin.*) orelse return .no_value; + if (out) |o| o.* = pt.coord(); + return .success; +} + +pub fn tracked_grid_ref_set( + ref_: CTrackedGridRef, + terminal_: terminal_c.Terminal, + pt: point.Point.C, +) callconv(lib.calling_conv) Result { + const ref = ref_ orelse return .invalid_value; + const wrapper = terminal_ orelse return .invalid_value; + if (ref.terminal != terminal_) return .invalid_value; + + const t = wrapper.terminal; + const list = &t.screens.active.pages; + const p = list.pin(point.Point.fromC(pt)) orelse return .invalid_value; + const tracked_pin = list.trackPin(p) catch return .out_of_memory; + + if (ref.pageList()) |old_list| old_list.untrackPin(ref.pin); + ref.screen_key = t.screens.active_key; + ref.screen_generation = t.screens.generation(ref.screen_key); + ref.pin = tracked_pin; + return .success; +} + +test "tracked_grid_ref snapshots after terminal scroll" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "A", 1); + + var ref: CTrackedGridRef = null; + try testing.expectEqual(Result.success, terminal_c.grid_ref_track( + terminal, + point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }), + &ref, + )); + defer tracked_grid_ref_free(ref); + + terminal_c.vt_write(terminal, "\r\nB\r\nC", 6); + try testing.expect(tracked_grid_ref_has_value(ref)); + + var snapshot: grid_ref_c.CGridRef = undefined; + try testing.expectEqual(Result.success, tracked_grid_ref_snapshot(ref, &snapshot)); + + var buf: [1]u32 = undefined; + var len: usize = undefined; + try testing.expectEqual(Result.success, grid_ref_c.grid_ref_graphemes(&snapshot, &buf, buf.len, &len)); + try testing.expectEqual(@as(usize, 1), len); + try testing.expectEqual(@as(u32, 'A'), buf[0]); +} + +test "tracked_grid_ref reports no value after reset" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "A", 1); + + var ref: CTrackedGridRef = null; + try testing.expectEqual(Result.success, terminal_c.grid_ref_track( + terminal, + point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }), + &ref, + )); + defer tracked_grid_ref_free(ref); + + terminal_c.reset(terminal); + try testing.expect(!tracked_grid_ref_has_value(ref)); + + var snapshot: grid_ref_c.CGridRef = undefined; + try testing.expectEqual(Result.no_value, tracked_grid_ref_snapshot(ref, &snapshot)); +} + +test "tracked_grid_ref reports no value after alternate screen reset" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "\x1b[?1049hA", 9); + + var ref: CTrackedGridRef = null; + try testing.expectEqual(Result.success, terminal_c.grid_ref_track( + terminal, + point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }), + &ref, + )); + defer tracked_grid_ref_free(ref); + + terminal_c.vt_write(terminal, "\x1bc", 2); + try testing.expect(!tracked_grid_ref_has_value(ref)); + + var snapshot: grid_ref_c.CGridRef = undefined; + try testing.expectEqual(Result.no_value, tracked_grid_ref_snapshot(ref, &snapshot)); + + var coord: point.Coordinate = undefined; + try testing.expectEqual(Result.no_value, tracked_grid_ref_point(ref, .active, &coord)); + + terminal_c.vt_write(terminal, "\x1b[?1049h", 8); + try testing.expect(!tracked_grid_ref_has_value(ref)); + + try testing.expectEqual(Result.success, tracked_grid_ref_set( + ref, + terminal, + point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }), + )); + try testing.expect(tracked_grid_ref_has_value(ref)); + try testing.expectEqual(Result.success, tracked_grid_ref_snapshot(ref, &snapshot)); +} diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 126b66401..ab6ab719b 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -8,6 +8,7 @@ pub const color = @import("color.zig"); pub const focus = @import("focus.zig"); pub const formatter = @import("formatter.zig"); pub const grid_ref = @import("grid_ref.zig"); +pub const grid_ref_tracked = @import("grid_ref_tracked.zig"); pub const kitty_graphics = @import("kitty_graphics.zig"); pub const kitty_graphics_get = kitty_graphics.get; pub const kitty_graphics_image = kitty_graphics.image_get_handle; @@ -170,6 +171,7 @@ pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; pub const terminal_get_multi = terminal.get_multi; pub const terminal_grid_ref = terminal.grid_ref; +pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; pub const type_json = types.get_json; @@ -179,6 +181,11 @@ pub const grid_ref_row = grid_ref.grid_ref_row; pub const grid_ref_graphemes = grid_ref.grid_ref_graphemes; pub const grid_ref_hyperlink_uri = grid_ref.grid_ref_hyperlink_uri; pub const grid_ref_style = grid_ref.grid_ref_style; +pub const tracked_grid_ref_free = grid_ref_tracked.tracked_grid_ref_free; +pub const tracked_grid_ref_has_value = grid_ref_tracked.tracked_grid_ref_has_value; +pub const tracked_grid_ref_point = grid_ref_tracked.tracked_grid_ref_point; +pub const tracked_grid_ref_set = grid_ref_tracked.tracked_grid_ref_set; +pub const tracked_grid_ref_snapshot = grid_ref_tracked.tracked_grid_ref_snapshot; test { _ = allocator; @@ -186,6 +193,7 @@ test { _ = cell; _ = color; _ = grid_ref; + _ = grid_ref_tracked; _ = kitty_graphics; _ = row; _ = focus; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index d3293f3bc..662a2ec03 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -19,6 +19,7 @@ const size_report = @import("../size_report.zig"); const cell_c = @import("cell.zig"); const row_c = @import("row.zig"); const grid_ref_c = @import("grid_ref.zig"); +const grid_ref_tracked_c = @import("grid_ref_tracked.zig"); const style_c = @import("style.zig"); const color = @import("../color.zig"); const Result = @import("result.zig").Result; @@ -723,18 +724,44 @@ pub fn grid_ref( out_ref: ?*grid_ref_c.CGridRef, ) callconv(lib.calling_conv) Result { const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; - const zig_pt: point.Point = switch (pt.tag) { - .active => .{ .active = pt.value.active }, - .viewport => .{ .viewport = pt.value.viewport }, - .screen => .{ .screen = pt.value.screen }, - .history => .{ .history = pt.value.history }, - }; + const zig_pt: point.Point = .fromC(pt); const p = t.screens.active.pages.pin(zig_pt) orelse return .invalid_value; if (out_ref) |out| out.* = grid_ref_c.CGridRef.fromPin(p); return .success; } +pub fn grid_ref_track( + terminal_: Terminal, + pt: point.Point.C, + out_ref: ?*grid_ref_tracked_c.CTrackedGridRef, +) callconv(lib.calling_conv) Result { + const wrapper = terminal_ orelse return .invalid_value; + const out = out_ref orelse return .invalid_value; + out.* = null; + + const t: *ZigTerminal = wrapper.terminal; + const list = &t.screens.active.pages; + const p = list.pin(.fromC(pt)) orelse return .invalid_value; + const tracked_pin = list.trackPin(p) catch return .out_of_memory; + + const alloc = t.gpa(); + const ref = alloc.create(grid_ref_tracked_c.TrackedGridRef) catch { + list.untrackPin(tracked_pin); + return .out_of_memory; + }; + ref.* = .{ + .alloc = alloc, + .terminal = wrapper, + .screen_key = t.screens.active_key, + .screen_generation = t.screens.generation(t.screens.active_key), + .pin = tracked_pin, + }; + + out.* = ref; + return .success; +} + pub fn point_from_grid_ref( terminal_: Terminal, ref: *const grid_ref_c.CGridRef, diff --git a/src/terminal/point.zig b/src/terminal/point.zig index 4297bf5b5..f6bf25c39 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -76,6 +76,16 @@ pub const Point = union(Tag) { pub const C = c_union.C; pub const CValue = c_union.CValue; pub const cval = c_union.cval; + + /// Convert a C ABI point into the native Zig tagged union. + pub fn fromC(pt: C) Point { + return switch (pt.tag) { + .active => .{ .active = pt.value.active }, + .viewport => .{ .viewport = pt.value.viewport }, + .screen => .{ .screen = pt.value.screen }, + .history => .{ .history = pt.value.history }, + }; + } }; pub const Coordinate = extern struct { From 60f767dd84cac94d5a6f4e827847361c42d1c078 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 14:42:33 -0700 Subject: [PATCH 068/137] core: guard surface left-click pins with screen generations Left-click mouse state stored a tracked pin with only the screen key that owned it. If the alternate screen was removed and later recreated, the key could match again even though the stored pin belonged to destroyed PageList storage. Store the screen generation alongside the left-click pin and resolve the pin through helpers that require both the key and generation to match. This keeps selection scrolling, link hover checks, pressure selection, and drag selection from dereferencing stale tracked pins after screen teardown. --- src/Surface.zig | 56 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 5d16f3326..525e73a9e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -228,6 +228,7 @@ const Mouse = struct { /// coordinates so that scrolling preserves the location. left_click_pin: ?*terminal.Pin = null, left_click_screen: terminal.ScreenSet.Key = .primary, + left_click_screen_generation: usize = 0, /// The starting xpos/ypos of the left click. Note that if scrolling occurs, /// these will point to different "cells", but the xpos/ypos will stay @@ -261,6 +262,22 @@ const Mouse = struct { /// The last x/y in the cursor position for links. We use this to /// only process link hover events when the mouse actually moves cells. link_point: ?terminal.point.Coordinate = null, + + /// Return the PageList that owns the left-click pin, or null if the screen + /// has been removed/reinitialized since the pin was tracked. + fn leftClickPageList(self: *const Mouse, screens: *const terminal.ScreenSet) ?*terminal.PageList { + if (screens.generation(self.left_click_screen) != self.left_click_screen_generation) return null; + const screen = screens.get(self.left_click_screen) orelse return null; + return &screen.pages; + } + + /// Return the left-click pin only if it still belongs to the active screen. + fn activeLeftClickPin(self: *const Mouse, screens: *const terminal.ScreenSet) ?*terminal.Pin { + const pin = self.left_click_pin orelse return null; + if (self.left_click_screen != screens.active_key) return null; + _ = self.leftClickPageList(screens) orelse return null; + return pin; + } }; /// Keyboard state for the surface. @@ -1192,9 +1209,9 @@ fn selectionScrollTick(self: *Surface) !void { defer self.renderer_state.mutex.unlock(); const t: *terminal.Terminal = self.renderer_state.terminal; - // If our screen changed while this is happening, we stop our - // selection scroll. - if (self.mouse.left_click_screen != t.screens.active_key) { + // If our left-click pin no longer belongs to the active screen, we stop + // our selection scroll. + if (self.mouse.activeLeftClickPin(&t.screens) == null) { self.queueIo( .{ .selection_scroll = false }, .locked, @@ -1592,7 +1609,7 @@ fn mouseRefreshLinks( // mouse actions. const left_idx = @intFromEnum(input.MouseButton.left); if (self.mouse.click_state[left_idx] == .press) click: { - const pin = self.mouse.left_click_pin orelse break :click; + const pin = self.mouse.activeLeftClickPin(&self.io.terminal.screens) orelse break :click; const click_pt = self.io.terminal.screens.active.pages.pointFromPin( .viewport, pin.*, @@ -3927,15 +3944,14 @@ pub fn mouseButtonCallback( } if (self.mouse.left_click_pin) |prev| { - if (t.screens.get(self.mouse.left_click_screen)) |pin_screen| { - pin_screen.pages.untrackPin(prev); - } + if (self.mouse.leftClickPageList(&t.screens)) |pages| pages.untrackPin(prev); self.mouse.left_click_pin = null; } // Store it self.mouse.left_click_pin = pin; self.mouse.left_click_screen = t.screens.active_key; + self.mouse.left_click_screen_generation = t.screens.generation(t.screens.active_key); self.mouse.left_click_xpos = pos.x; self.mouse.left_click_ypos = pos.y; @@ -4466,7 +4482,7 @@ pub fn mousePressureCallback( // This should always be set in this state but we don't want // to handle state inconsistency here. - const pin = self.mouse.left_click_pin orelse break :select; + const pin = self.mouse.activeLeftClickPin(&self.io.terminal.screens) orelse break :select; const sel = self.io.terminal.screens.active.selectWord( pin.*, self.config.selection_word_chars, @@ -4631,11 +4647,12 @@ pub fn cursorPosCallback( // count because we don't want to handle selection. if (self.mouse.left_click_count == 0) break :select; - // If our terminal screen changed then we don't process this. We don't - // invalidate our pin or mouse state because if the screen switches - // back then we can continue our selection. + // If our left-click pin no longer belongs to the active screen then we + // don't process this. We don't invalidate our pin or mouse state + // because if the same screen switches back then we can continue our + // selection. const t: *terminal.Terminal = self.renderer_state.terminal; - if (self.mouse.left_click_screen != t.screens.active_key) break :select; + if (self.mouse.activeLeftClickPin(&t.screens) == null) break :select; // All roads lead to requiring a re-render at this point. try self.queueRender(); @@ -4690,7 +4707,7 @@ fn dragLeftClickDouble( drag_pin: terminal.Pin, ) !void { const screen: *terminal.Screen = self.io.terminal.screens.active; - const click_pin = self.mouse.left_click_pin.?.*; + const click_pin = (self.mouse.activeLeftClickPin(&self.io.terminal.screens) orelse return).*; // Get the word closest to our starting click. const word_start = screen.selectWordBetween( @@ -4735,7 +4752,11 @@ fn dragLeftClickTriple( drag_pin: terminal.Pin, ) !void { const screen: *terminal.Screen = self.io.terminal.screens.active; - const click_pin = self.mouse.left_click_pin.?.*; + const click_pin: terminal.Pin = pin: { + const set: *terminal.ScreenSet = &self.io.terminal.screens; + const tracked = self.mouse.activeLeftClickPin(set) orelse return; + break :pin tracked.*; + }; // Get the line selection under our current drag point. If there isn't a // line, do nothing. @@ -4762,8 +4783,13 @@ fn dragLeftClickSingle( drag_x: f64, ) !void { // This logic is in a separate function so that it can be unit tested. + const click_pin: terminal.Pin = pin: { + const set: *terminal.ScreenSet = &self.io.terminal.screens; + const tracked = self.mouse.activeLeftClickPin(set) orelse return; + break :pin tracked.*; + }; try self.io.terminal.screens.active.select(mouseSelection( - self.mouse.left_click_pin.?.*, + click_pin, drag_pin, @intFromFloat(@max(0.0, self.mouse.left_click_xpos)), @intFromFloat(@max(0.0, drag_x)), From ae03d3cae4d4af244a43d91a0d8040739899e4a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 14:54:34 -0700 Subject: [PATCH 069/137] libghostty: expose get/set active selection state Add terminal set/get support for the active screen selection through the existing option and data APIs. Setting a selection copies the C snapshot into terminal-owned tracked state, while passing NULL clears the current selection. Getting the selection now returns an untracked GhosttySelection snapshot or GHOSTTY_NO_VALUE when there is no selection. The C header documents the different lifetimes for set and get so embedders know when input and returned grid references remain valid. --- include/ghostty/vt/terminal.h | 33 ++++++++++++++++++ src/terminal/c/selection.zig | 8 +++++ src/terminal/c/terminal.zig | 64 +++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 1751aa126..756698449 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -19,6 +19,7 @@ #include #include #include +#include #include #ifdef __cplusplus @@ -592,6 +593,21 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: size_t* */ GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES_KITTY = 20, + + /** + * Set the active screen selection. + * + * The value must point to a GhosttySelection whose grid references are + * valid for this terminal's active screen at the time of the call. The + * terminal copies the selection immediately and converts it to + * terminal-owned tracked state, so the GhosttySelection struct and its + * untracked grid references do not need to outlive this call. + * + * Passing NULL clears the active screen selection. + * + * Input type: GhosttySelection* + */ + GHOSTTY_TERMINAL_OPT_SELECTION = 21, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; @@ -868,6 +884,23 @@ typedef enum GHOSTTY_ENUM_TYPED { * Output type: GhosttyKittyGraphics * */ GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS = 30, + + /** + * The active screen's current selection. + * + * On success, writes an untracked snapshot of the terminal-owned selection + * to the caller-provided GhosttySelection. The GhosttySelection struct is + * caller-owned and may be kept, but the grid references inside it are + * untracked borrowed references into the active screen. They are only valid + * until the next mutating terminal call, such as ghostty_terminal_set(), + * ghostty_terminal_vt_write(), ghostty_terminal_resize(), or + * ghostty_terminal_reset(). + * + * Returns GHOSTTY_NO_VALUE when there is no active selection. + * + * Output type: GhosttySelection * + */ + GHOSTTY_TERMINAL_DATA_SELECTION = 31, GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalData; diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index 74e96598f..c4c6284e6 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -13,4 +13,12 @@ pub const CSelection = extern struct { const end_pin = self.end.toPin() orelse return null; return Selection.init(start_pin, end_pin, self.rectangle); } + + pub fn fromZig(sel: Selection) CSelection { + return .{ + .start = .fromPin(sel.start()), + .end = .fromPin(sel.end()), + .rectangle = sel.rectangle, + }; + } }; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 662a2ec03..2e88ea524 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -20,6 +20,7 @@ const cell_c = @import("cell.zig"); const row_c = @import("row.zig"); const grid_ref_c = @import("grid_ref.zig"); const grid_ref_tracked_c = @import("grid_ref_tracked.zig"); +const selection_c = @import("selection.zig"); const style_c = @import("style.zig"); const color = @import("../color.zig"); const Result = @import("result.zig").Result; @@ -314,6 +315,7 @@ pub const Option = enum(c_int) { kitty_image_medium_shared_mem = 18, apc_max_bytes = 19, apc_max_bytes_kitty = 20, + selection = 21, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -336,6 +338,7 @@ pub const Option = enum(c_int) { .kitty_image_medium_shared_mem, => ?*const bool, .apc_max_bytes, .apc_max_bytes_kitty => ?*const usize, + .selection => ?*const selection_c.CSelection, }; } }; @@ -443,6 +446,14 @@ fn setTyped( wrapper.stream.handler.apc_handler.max_bytes.remove(.kitty); } }, + .selection => { + if (value) |ptr| { + const sel = ptr.toZig() orelse return .invalid_value; + wrapper.terminal.screens.active.select(sel) catch return .out_of_memory; + } else { + wrapper.terminal.screens.active.clearSelection(); + } + }, } return .success; } @@ -576,6 +587,7 @@ pub const TerminalData = enum(c_int) { kitty_image_medium_temp_file = 28, kitty_image_medium_shared_mem = 29, kitty_graphics = 30, + selection = 31, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: TerminalData) type { @@ -604,6 +616,7 @@ pub const TerminalData = enum(c_int) { .kitty_image_medium_shared_mem, => bool, .kitty_graphics => KittyGraphics, + .selection => selection_c.CSelection, }; } }; @@ -713,6 +726,9 @@ fn getTyped( if (comptime !build_options.kitty_graphics) return .no_value; out.* = &t.screens.active.kitty_images; }, + .selection => out.* = selection_c.CSelection.fromZig( + t.screens.active.selection orelse return .no_value, + ), } return .success; @@ -1325,6 +1341,54 @@ test "get invalid" { try testing.expectEqual(Result.invalid_value, get(t, .invalid, null)); } +test "set and get selection" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + + var start_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 0, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 4, .y = 0 } }, + }, &end_ref)); + + var out: selection_c.CSelection = undefined; + try testing.expectEqual(Result.no_value, get(t, .selection, @ptrCast(&out))); + + const sel: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + .rectangle = true, + }; + try testing.expectEqual(Result.success, set(t, .selection, @ptrCast(&sel))); + try testing.expect(t.?.terminal.screens.active.selection.?.tracked()); + + try testing.expectEqual(Result.success, get(t, .selection, @ptrCast(&out))); + try testing.expect(out.start.toPin().?.eql(start_ref.toPin().?)); + try testing.expect(out.end.toPin().?.eql(end_ref.toPin().?)); + try testing.expect(out.rectangle); + + try testing.expectEqual(Result.success, set(t, .selection, null)); + try testing.expect(t.?.terminal.screens.active.selection == null); + try testing.expectEqual(Result.no_value, get(t, .selection, @ptrCast(&out))); +} + test "grid_ref" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 24048ffd471c5e88006bafc6ab3e5eb3c710a15d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 14:58:56 -0700 Subject: [PATCH 070/137] libghostty: expose row-local render selections Render state already tracks the selected cell range for each viewport row, but C renderers could only get the full terminal selection. That required consumers to map global selection pins back into row-local spans themselves. Add row selection data to the render-state row API. Querying the new row data returns GHOSTTY_NO_VALUE for unselected rows and writes the inclusive start and end columns for selected rows. The render example now demonstrates setting a selection and reading the row-local range while iterating rows. --- example/c-vt-render/README.md | 4 +- example/c-vt-render/src/main.c | 38 +++++++++++++++++ include/ghostty/vt/render.h | 26 ++++++++++++ src/terminal/c/render.zig | 75 ++++++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 2 deletions(-) diff --git a/example/c-vt-render/README.md b/example/c-vt-render/README.md index 3725ed46f..b56cd8384 100644 --- a/example/c-vt-render/README.md +++ b/example/c-vt-render/README.md @@ -2,8 +2,8 @@ This contains an example of how to use the `ghostty-vt` render-state API to create a render state, update it from terminal content, iterate rows -and cells, read styles and colors, inspect cursor state, and manage dirty -tracking. +and cells, read styles and colors, inspect cursor and row-local selection +state, and manage dirty tracking. This uses a `build.zig` and `Zig` to build the C program so that we can reuse a lot of our build logic and depend directly on our source diff --git a/example/c-vt-render/src/main.c b/example/c-vt-render/src/main.c index 0714d4160..feb3628d4 100644 --- a/example/c-vt-render/src/main.c +++ b/example/c-vt-render/src/main.c @@ -46,6 +46,32 @@ int main(void) { ghostty_terminal_vt_write( terminal, (const uint8_t*)content, strlen(content)); + // Select "underlined" on the second row. Render state exposes this + // later as a row-local selected cell range. + GhosttyGridRef selection_start = GHOSTTY_INIT_SIZED(GhosttyGridRef); + GhosttyPoint selection_start_pt = { + .tag = GHOSTTY_POINT_TAG_ACTIVE, + .value = { .coordinate = { .x = 0, .y = 1 } }, + }; + result = ghostty_terminal_grid_ref( + terminal, selection_start_pt, &selection_start); + assert(result == GHOSTTY_SUCCESS); + + GhosttyGridRef selection_end = GHOSTTY_INIT_SIZED(GhosttyGridRef); + GhosttyPoint selection_end_pt = { + .tag = GHOSTTY_POINT_TAG_ACTIVE, + .value = { .coordinate = { .x = 9, .y = 1 } }, + }; + result = ghostty_terminal_grid_ref(terminal, selection_end_pt, &selection_end); + assert(result == GHOSTTY_SUCCESS); + + GhosttySelection selection = GHOSTTY_INIT_SIZED(GhosttySelection); + selection.start = selection_start; + selection.end = selection_end; + result = ghostty_terminal_set( + terminal, GHOSTTY_TERMINAL_OPT_SELECTION, &selection); + assert(result == GHOSTTY_SUCCESS); + result = ghostty_render_state_update(render_state, terminal); assert(result == GHOSTTY_SUCCESS); //! [render-state-update] @@ -154,6 +180,18 @@ int main(void) { printf("Row %2d [%s]: ", row_index, row_dirty ? "dirty" : "clean"); + // Query the row-local selection range. Rows without a selection return + // GHOSTTY_NO_VALUE; selected rows return inclusive start/end columns. + GhosttyRenderStateRowSelection row_selection = + GHOSTTY_INIT_SIZED(GhosttyRenderStateRowSelection); + result = ghostty_render_state_row_get( + row_iter, GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION, &row_selection); + assert(result == GHOSTTY_SUCCESS || result == GHOSTTY_NO_VALUE); + if (result == GHOSTTY_SUCCESS) { + printf("selection=%u..%u ", + row_selection.start_x, row_selection.end_x); + } + // Get cells for this row (reuses the same cells handle). result = ghostty_render_state_row_get( row_iter, GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, &cells); diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index d1a3687d9..f1f201c44 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -221,6 +221,9 @@ typedef enum GHOSTTY_ENUM_TYPED { * valid as long as the underlying render state is not updated. * It is unsafe to use cell data after updating the render state. */ GHOSTTY_RENDER_STATE_ROW_DATA_CELLS = 3, + + /** Row-local selected cell range (GhosttyRenderStateRowSelection). */ + GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION = 4, GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateRowData; @@ -235,6 +238,29 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_RENDER_STATE_ROW_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateRowOption; +/** + * Row-local selection range. + * + * This struct uses the sized-struct ABI pattern. Initialize with + * GHOSTTY_INIT_SIZED(GhosttyRenderStateRowSelection) before querying + * GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION. + * + * Querying GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION returns GHOSTTY_NO_VALUE + * if the current row does not intersect the current selection. + * + * @ingroup render + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateRowSelection). */ + size_t size; + + /** Start column of the row-local selection range, inclusive. */ + uint16_t start_x; + + /** End column of the row-local selection range, inclusive. */ + uint16_t end_x; +} GhosttyRenderStateRowSelection; + /** * Render-state color information. * diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index af82ddfa1..f8b48353f 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -31,6 +31,7 @@ const RowIteratorWrapper = struct { /// These are the raw pointers into the render state data. raws: []const page.Row, cells: []const std.MultiArrayList(renderpkg.RenderState.Cell), + selection: []const ?[2]size.CellCountInt, dirty: []bool, /// The color palette from the render state, needed to resolve @@ -61,6 +62,13 @@ pub const RowCells = ?*RowCellsWrapper; /// C: GhosttyRenderStateDirty pub const Dirty = renderpkg.RenderState.Dirty; +/// C: GhosttyRenderStateRowSelection +pub const RowSelection = extern struct { + size: usize = @sizeOf(RowSelection), + start_x: u16 = 0, + end_x: u16 = 0, +}; + /// C: GhosttyRenderStateCursorVisualStyle pub const CursorVisualStyle = enum(c_int) { bar = 0, @@ -241,6 +249,7 @@ fn getTyped( .y = null, .raws = row_data.items(.raw), .cells = row_data.items(.cells), + .selection = row_data.items(.selection), .dirty = row_data.items(.dirty), .palette = &state.state.colors.palette, }; @@ -381,6 +390,7 @@ pub fn row_iterator_new( .y = undefined, .raws = undefined, .cells = undefined, + .selection = undefined, .dirty = undefined, .palette = undefined, }; @@ -564,6 +574,7 @@ pub const RowData = enum(c_int) { dirty = 1, raw = 2, cells = 3, + selection = 4, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: RowData) type { @@ -572,6 +583,7 @@ pub const RowData = enum(c_int) { .dirty => bool, .raw => row.CRow, .cells => RowCells, + .selection => RowSelection, }; } }; @@ -654,6 +666,14 @@ fn rowGetTyped( .palette = it.palette, }; }, + .selection => { + const out_size = out.size; + if (out_size < @sizeOf(RowSelection)) return .invalid_value; + + const sel = it.selection[y] orelse return .no_value; + out.start_x = sel[0]; + out.end_x = sel[1]; + }, } return .success; @@ -845,6 +865,7 @@ test "render: row iterator new/free" { try testing.expectEqual(@as(?size.CellCountInt, null), iterator_ptr.y); try testing.expectEqual(row_data.items(.raw).len, iterator_ptr.raws.len); try testing.expectEqual(row_data.items(.cells).len, iterator_ptr.cells.len); + try testing.expectEqual(row_data.items(.selection).len, iterator_ptr.selection.len); try testing.expectEqual(row_data.items(.dirty).len, iterator_ptr.dirty.len); } @@ -1026,6 +1047,60 @@ test "render: row get/set dirty" { try testing.expect(!dirty); } +test "render: row get selection" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 10, + .rows = 3, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + const t = terminal.?.terminal; + const screen = t.screens.active; + try screen.select(.init( + screen.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?, + screen.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, + false, + )); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + + var sel: RowSelection = .{}; + try testing.expect(row_iterator_next(it)); + try testing.expectEqual(Result.no_value, row_get(it, .selection, @ptrCast(&sel))); + + try testing.expect(row_iterator_next(it)); + sel = .{}; + try testing.expectEqual(Result.success, row_get(it, .selection, @ptrCast(&sel))); + try testing.expectEqual(@as(u16, 2), sel.start_x); + try testing.expectEqual(@as(u16, 4), sel.end_x); + + try testing.expect(row_iterator_next(it)); + sel = .{}; + try testing.expectEqual(Result.no_value, row_get(it, .selection, @ptrCast(&sel))); +} + test "render: row iterator next" { var terminal: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( From 545a5aef663ef551bd8b2b2b794f47d9a74d7586 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 15:04:51 -0700 Subject: [PATCH 071/137] libghostty: document selection snapshot lifetime Clarify that GhosttySelection is a snapshot type whose endpoints are untracked GhosttyGridRef values. The previous documentation described the range shape but did not repeat the grid reference lifetime caveat, which made it easy to keep selections across terminal mutations incorrectly. --- include/ghostty/vt/selection.h | 45 +++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 9f878fadc..3ba2f00db 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -17,14 +17,34 @@ extern "C" { /** @defgroup selection Selection * - * A selection range defined by two grid references that identifies a - * contiguous or rectangular region of terminal content. + * A snapshot selection range defined by two grid references that identifies + * a contiguous or rectangular region of terminal content. + * + * The start and end values are GhosttyGridRef values. They are therefore + * untracked grid references and inherit the same lifetime rules: they are + * only safe to use until the next mutating operation on the terminal that + * produced them, including freeing the terminal. To keep a selection valid + * across terminal mutations, callers must maintain tracked grid references + * for the endpoints and reconstruct a GhosttySelection from fresh snapshots + * when needed. * * @{ */ /** - * A selection range defined by two grid references. + * A snapshot selection range defined by two grid references. + * + * Both endpoints are inclusive. The endpoints preserve selection direction + * and may be reversed; callers must not assume that start is the top-left + * endpoint or that end is the bottom-right endpoint. + * + * When rectangle is false, the endpoints describe a linear selection. When + * rectangle is true, the same endpoints are interpreted as opposite corners + * of a rectangular/block selection. + * + * The start and end values are untracked GhosttyGridRef snapshots and are + * only valid until the next mutating operation on the terminal that produced + * them unless the selection is reconstructed from tracked references. * * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. * @@ -34,13 +54,26 @@ typedef struct { /** Size of this struct in bytes. Must be set to sizeof(GhosttySelection). */ size_t size; - /** Start of the selection range (inclusive). */ + /** + * Start of the selection range (inclusive). + * + * This may be after end in terminal order. It is an untracked + * GhosttyGridRef snapshot and follows untracked grid-ref lifetime rules. + */ GhosttyGridRef start; - /** End of the selection range (inclusive). */ + /** + * End of the selection range (inclusive). + * + * This may be before start in terminal order. It is an untracked + * GhosttyGridRef snapshot and follows untracked grid-ref lifetime rules. + */ GhosttyGridRef end; - /** Whether the selection is rectangular (block) rather than linear. */ + /** + * Whether the endpoints are interpreted as a rectangular/block selection + * rather than a linear selection. + */ bool rectangle; } GhosttySelection; From 15d89636814ac0d8d0beb783da0dee6ba63f8f7c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 15:08:38 -0700 Subject: [PATCH 072/137] libghostty: add selection adjustment api --- include/ghostty/vt/selection.h | 55 +++++++++++++++++++++++++++ include/ghostty/vt/terminal.h | 29 ++++++++++++++ src/lib_vt.zig | 1 + src/terminal/Selection.zig | 25 ++++++------ src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 69 ++++++++++++++++++++++++++++++++++ 6 files changed, 168 insertions(+), 12 deletions(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 3ba2f00db..cc20a0691 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -77,6 +77,61 @@ typedef struct { bool rectangle; } GhosttySelection; +/** + * Operation used to adjust a selection endpoint. + * + * Adjustment mutates the selection's logical end endpoint, not whichever + * endpoint is visually bottom/right. This preserves keyboard and drag + * behavior for both forward and reversed selections. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Move left to the previous non-empty cell, wrapping upward. */ + GHOSTTY_SELECTION_ADJUST_LEFT = 0, + + /** Move right to the next non-empty cell, wrapping downward. */ + GHOSTTY_SELECTION_ADJUST_RIGHT = 1, + + /** + * Move up one row at the current column, or to the beginning of the + * line if already at the top. + */ + GHOSTTY_SELECTION_ADJUST_UP = 2, + + /** + * Move down to the next non-blank row at the current column, or to the + * end of the line if none exists. + */ + GHOSTTY_SELECTION_ADJUST_DOWN = 3, + + /** Move to the top-left cell of the screen. */ + GHOSTTY_SELECTION_ADJUST_HOME = 4, + + /** Move to the right edge of the last non-blank row on the screen. */ + GHOSTTY_SELECTION_ADJUST_END = 5, + + /** + * Move up by one terminal page height, or to home if that would move + * past the top. + */ + GHOSTTY_SELECTION_ADJUST_PAGE_UP = 6, + + /** + * Move down by one terminal page height, or to end if that would move + * past the bottom. + */ + GHOSTTY_SELECTION_ADJUST_PAGE_DOWN = 7, + + /** Move to the left edge of the current line. */ + GHOSTTY_SELECTION_ADJUST_BEGINNING_OF_LINE = 8, + + /** Move to the right edge of the current line. */ + GHOSTTY_SELECTION_ADJUST_END_OF_LINE = 9, + + GHOSTTY_SELECTION_ADJUST_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionAdjust; + /** @} */ #ifdef __cplusplus diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 756698449..55886e395 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1123,6 +1123,35 @@ GHOSTTY_API GhosttyResult ghostty_terminal_get_multi(GhosttyTerminal terminal, void** values, size_t* out_written); +/** + * Adjust a selection snapshot using terminal selection semantics. + * + * This mutates the caller-provided GhosttySelection in place. The logical end + * endpoint is always moved, regardless of whether the selection is forward or + * reversed visually. The input selection remains a snapshot: after adjustment, + * call ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_SELECTION to install it + * as the terminal-owned selection if desired. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to adjust in place + * @param adjustment The adjustment operation to apply + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, or adjustment are invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_adjust( + GhosttyTerminal terminal, + GhosttySelection* selection, + GhosttySelectionAdjust adjustment); + /** * Resolve a point in the terminal grid to a grid reference. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 12aa66bfe..6276f707c 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -239,6 +239,7 @@ comptime { @export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" }); @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" }); + @export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 8cb52816c..4d7d4a2fa 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -4,6 +4,7 @@ const Selection = @This(); const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; +const lib = @import("lib.zig"); const page = @import("page.zig"); const point = @import("point.zig"); const PageList = @import("PageList.zig"); @@ -389,18 +390,18 @@ pub fn containedRowCached( } /// Possible adjustments to the selection. -pub const Adjustment = enum { - left, - right, - up, - down, - home, - end, - page_up, - page_down, - beginning_of_line, - end_of_line, -}; +pub const Adjustment = lib.Enum(lib.target, &.{ + "left", + "right", + "up", + "down", + "home", + "end", + "page_up", + "page_down", + "beginning_of_line", + "end_of_line", +}); /// Adjust the selection by some given adjustment. An adjustment allows /// a selection to be expanded slightly left, right, up, down, etc. diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index ab6ab719b..35fb8b197 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -170,6 +170,7 @@ pub const terminal_mode_get = terminal.mode_get; pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; pub const terminal_get_multi = terminal.get_multi; +pub const terminal_selection_adjust = terminal.selection_adjust; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 2e88ea524..fef6fbad9 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -7,6 +7,7 @@ const ZigTerminal = @import("../Terminal.zig"); const Stream = @import("../stream_terminal.zig").Stream; const ScreenSet = @import("../ScreenSet.zig"); const PageList = @import("../PageList.zig"); +const Selection = @import("../Selection.zig"); const apc = @import("../apc.zig"); const kitty = @import("../kitty/key.zig"); const kitty_gfx_c = @import("kitty_graphics.zig"); @@ -664,6 +665,26 @@ pub fn get_multi( return .success; } +pub fn selection_adjust( + terminal_: Terminal, + selection: ?*selection_c.CSelection, + adjustment: Selection.Adjustment, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Selection.Adjustment, @intFromEnum(adjustment)) catch { + log.warn("terminal_selection_adjust invalid adjustment value={d}", .{@intFromEnum(adjustment)}); + return .invalid_value; + }; + } + + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const sel_ptr = selection orelse return .invalid_value; + var sel = sel_ptr.toZig() orelse return .invalid_value; + sel.adjust(t.screens.active, adjustment); + sel_ptr.* = .fromZig(sel); + return .success; +} + fn getTyped( terminal_: Terminal, comptime data: TerminalData, @@ -1389,6 +1410,54 @@ test "set and get selection" { try testing.expectEqual(Result.no_value, get(t, .selection, @ptrCast(&out))); } +test "selection_adjust mutates snapshot end" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + + var start_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 0, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &end_ref)); + + var sel: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + }; + try testing.expectEqual(Result.success, selection_adjust(t, &sel, .right)); + try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x); + try testing.expectEqual(@as(u16, 2), sel.end.toPin().?.x); + + try testing.expectEqual(Result.success, selection_adjust(t, &sel, .left)); + try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x); + try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x); + + sel = .{ + .start = end_ref, + .end = start_ref, + }; + try testing.expectEqual(Result.success, selection_adjust(t, &sel, .right)); + try testing.expectEqual(@as(u16, 1), sel.start.toPin().?.x); + try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x); +} + test "grid_ref" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 4a77e8196720088cbdce701c88412d3ba16089b5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 15:15:03 -0700 Subject: [PATCH 073/137] libghostty: add selection ordering APIs Expose selection endpoint ordering through the libghostty-vt C API so embedders can safely normalize selections whose start and end refs may be reversed. The new APIs report the current order and return a fresh untracked selection with forward or reverse bounds. Selection.Order now uses lib.Enum, matching the existing adjustment enum pattern and keeping the C ABI enum generated from the same Zig source of truth. The new functions are wired through the C API re-export and lib-vt export paths, with coverage for mirrored rectangular selection ordering. --- include/ghostty/vt/selection.h | 25 +++++++ include/ghostty/vt/terminal.h | 55 ++++++++++++++++ src/lib_vt.zig | 2 + src/terminal/Selection.zig | 7 +- src/terminal/c/main.zig | 2 + src/terminal/c/terminal.zig | 115 +++++++++++++++++++++++++++++++++ 6 files changed, 205 insertions(+), 1 deletion(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index cc20a0691..de00899aa 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -77,6 +77,31 @@ typedef struct { bool rectangle; } GhosttySelection; +/** + * Ordering of a selection's endpoints in terminal coordinates. + * + * Mirrored orders are only produced by rectangular selections whose start + * and end endpoints are on opposite diagonal corners that are not simple + * top-left-to-bottom-right or bottom-right-to-top-left orderings. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Start is before end in top-left to bottom-right order. */ + GHOSTTY_SELECTION_ORDER_FORWARD = 0, + + /** End is before start in top-left to bottom-right order. */ + GHOSTTY_SELECTION_ORDER_REVERSE = 1, + + /** Rectangular selection from top-right to bottom-left. */ + GHOSTTY_SELECTION_ORDER_MIRRORED_FORWARD = 2, + + /** Rectangular selection from bottom-left to top-right. */ + GHOSTTY_SELECTION_ORDER_MIRRORED_REVERSE = 3, + + GHOSTTY_SELECTION_ORDER_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionOrder; + /** * Operation used to adjust a selection endpoint. * diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 55886e395..f9c951b47 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1152,6 +1152,61 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_adjust( GhosttySelection* selection, GhosttySelectionAdjust adjustment); +/** + * Get the current endpoint ordering of a selection snapshot. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to inspect + * @param[out] out_order On success, receives the selection order + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, or output pointer are invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_order( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttySelectionOrder* out_order); + +/** + * Return a selection snapshot with endpoints ordered as requested. + * + * Use GHOSTTY_SELECTION_ORDER_FORWARD to get top-left to bottom-right bounds, + * and GHOSTTY_SELECTION_ORDER_REVERSE to get bottom-right to top-left bounds. + * Mirrored desired orders are accepted but normalized the same as forward. + * The output selection is a fresh untracked snapshot and is not installed as + * the terminal's current selection. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to order + * @param desired Desired endpoint order + * @param[out] out_selection On success, receives the ordered selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, desired order, or output pointer + * are invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_ordered( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttySelectionOrder desired, + GhosttySelection* out_selection); + /** * Resolve a point in the terminal grid to a grid reference. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 6276f707c..1feb51932 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -240,6 +240,8 @@ comptime { @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" }); @export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" }); + @export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" }); + @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 4d7d4a2fa..5258210cf 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -196,7 +196,12 @@ pub fn bottomRight(self: Selection, s: *const Screen) Pin { /// operations only flip the x or y axis, not both. Depending on the y axis /// direction, this is either mirrored_forward or mirrored_reverse. /// -pub const Order = enum { forward, reverse, mirrored_forward, mirrored_reverse }; +pub const Order = lib.Enum(lib.target, &.{ + "forward", + "reverse", + "mirrored_forward", + "mirrored_reverse", +}); pub fn order(self: Selection, s: *const Screen) Order { const start_pt = s.pages.pointFromPin(.screen, self.start()).?.screen; diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 35fb8b197..e495cda1a 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -171,6 +171,8 @@ pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; pub const terminal_get_multi = terminal.get_multi; pub const terminal_selection_adjust = terminal.selection_adjust; +pub const terminal_selection_order = terminal.selection_order; +pub const terminal_selection_ordered = terminal.selection_ordered; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index fef6fbad9..98208c102 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -685,6 +685,50 @@ pub fn selection_adjust( return .success; } +pub fn selection_order( + terminal_: Terminal, + selection: ?*const selection_c.CSelection, + out_order: ?*Selection.Order, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const sel = (selection orelse return .invalid_value).toZig() orelse + return .invalid_value; + const out = out_order orelse return .invalid_value; + if (!selectionValid(t, sel)) return .invalid_value; + + out.* = sel.order(t.screens.active); + return .success; +} + +pub fn selection_ordered( + terminal_: Terminal, + selection: ?*const selection_c.CSelection, + desired: Selection.Order, + out_selection: ?*selection_c.CSelection, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Selection.Order, @intFromEnum(desired)) catch { + log.warn("terminal_selection_ordered invalid desired value={d}", .{@intFromEnum(desired)}); + return .invalid_value; + }; + } + + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const sel = (selection orelse return .invalid_value).toZig() orelse + return .invalid_value; + const out = out_selection orelse return .invalid_value; + if (!selectionValid(t, sel)) return .invalid_value; + + out.* = .fromZig(sel.ordered(t.screens.active, desired)); + return .success; +} + +fn selectionValid(t: *ZigTerminal, sel: Selection) bool { + const screen = t.screens.active; + return screen.pages.pointFromPin(.screen, sel.start()) != null and + screen.pages.pointFromPin(.screen, sel.end()) != null; +} + fn getTyped( terminal_: Terminal, comptime data: TerminalData, @@ -1458,6 +1502,77 @@ test "selection_adjust mutates snapshot end" { try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x); } +test "selection_order and selection_ordered" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + vt_write(t, "Hello\r\nWorld", 12); + + var start_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 3, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 1 } }, + }, &end_ref)); + + const sel: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + .rectangle = true, + }; + + var order: Selection.Order = undefined; + try testing.expectEqual(Result.success, selection_order(t, &sel, &order)); + try testing.expectEqual(Selection.Order.mirrored_forward, order); + + var out: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, selection_ordered(t, &sel, .forward, &out)); + try testing.expectEqual(@as(u16, 1), out.start.toPin().?.x); + try testing.expectEqual(@as(u16, 0), out.start.toPin().?.y); + try testing.expectEqual(@as(u16, 3), out.end.toPin().?.x); + try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y); + try testing.expect(out.rectangle); + + try testing.expectEqual(Result.success, selection_ordered(t, &sel, .reverse, &out)); + try testing.expectEqual(@as(u16, 3), out.start.toPin().?.x); + try testing.expectEqual(@as(u16, 1), out.start.toPin().?.y); + try testing.expectEqual(@as(u16, 1), out.end.toPin().?.x); + try testing.expectEqual(@as(u16, 0), out.end.toPin().?.y); + try testing.expect(out.rectangle); +} + +test "selection_order invalid values" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var order: Selection.Order = undefined; + try testing.expectEqual(Result.invalid_value, selection_order(null, null, &order)); + try testing.expectEqual(Result.invalid_value, selection_order(t, null, &order)); +} + test "grid_ref" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 671c12fad9f85c8b384773c3ba936b07b4af45bf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 15:17:18 -0700 Subject: [PATCH 074/137] libghostty: add selection contains API Expose a C API for checking whether a GhosttyPoint is inside a GhosttySelection. The new terminal helper validates the selection snapshot against the active screen, resolves the point to a grid pin, and delegates to the internal Selection.contains implementation so C consumers get the same linear and rectangular selection semantics as Ghostty. Wire the symbol through the C API exports and public headers, and add a focused test covering linear containment and rectangular selection behavior. --- include/ghostty/vt.h | 1 + include/ghostty/vt/terminal.h | 28 +++++++++++++ src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 76 +++++++++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+) diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 75bbb3b5b..7a6a9758a 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -136,6 +136,7 @@ extern "C" { #include #include #include +#include #include #include #include diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index f9c951b47..b525af54e 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1207,6 +1207,34 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_ordered( GhosttySelectionOrder desired, GhosttySelection* out_selection); +/** + * Test whether a terminal point is inside a selection snapshot. + * + * This uses the same selection semantics as the terminal, including + * rectangular/block selections and linear selections spanning multiple rows. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to inspect + * @param point Point to test for containment + * @param[out] out_contains On success, receives whether point is inside selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, point, or output pointer are invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_contains( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttyPoint point, + bool* out_contains); + /** * Resolve a point in the terminal grid to a grid reference. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 1feb51932..6e3ff18cd 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -242,6 +242,7 @@ comptime { @export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" }); @export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" }); @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); + @export(&c.terminal_selection_contains, .{ .name = "ghostty_terminal_selection_contains" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index e495cda1a..0b75fe81b 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -173,6 +173,7 @@ pub const terminal_get_multi = terminal.get_multi; pub const terminal_selection_adjust = terminal.selection_adjust; pub const terminal_selection_order = terminal.selection_order; pub const terminal_selection_ordered = terminal.selection_ordered; +pub const terminal_selection_contains = terminal.selection_contains; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 98208c102..6dc8f9e90 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -723,6 +723,24 @@ pub fn selection_ordered( return .success; } +pub fn selection_contains( + terminal_: Terminal, + selection: ?*const selection_c.CSelection, + pt: point.Point.C, + out_contains: ?*bool, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const sel = (selection orelse return .invalid_value).toZig() orelse + return .invalid_value; + const out = out_contains orelse return .invalid_value; + if (!selectionValid(t, sel)) return .invalid_value; + + const screen = t.screens.active; + const pin = screen.pages.pin(.fromC(pt)) orelse return .invalid_value; + out.* = sel.contains(screen, pin); + return .success; +} + fn selectionValid(t: *ZigTerminal, sel: Selection) bool { const screen = t.screens.active; return screen.pages.pointFromPin(.screen, sel.start()) != null and @@ -1555,6 +1573,64 @@ test "selection_order and selection_ordered" { try testing.expect(out.rectangle); } +test "selection_contains" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + vt_write(t, "Hello\r\nWorld", 12); + + var start_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 3, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 1 } }, + }, &end_ref)); + + const linear: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + }; + + var contains: bool = undefined; + try testing.expectEqual(Result.success, selection_contains(t, &linear, .{ + .tag = .active, + .value = .{ .active = .{ .x = 4, .y = 0 } }, + }, &contains)); + try testing.expect(contains); + + try testing.expectEqual(Result.success, selection_contains(t, &linear, .{ + .tag = .active, + .value = .{ .active = .{ .x = 2, .y = 0 } }, + }, &contains)); + try testing.expect(!contains); + + const rectangle: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + .rectangle = true, + }; + + try testing.expectEqual(Result.success, selection_contains(t, &rectangle, .{ + .tag = .active, + .value = .{ .active = .{ .x = 2, .y = 0 } }, + }, &contains)); + try testing.expect(contains); +} + test "selection_order invalid values" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 2512fad9408bf0fee76dda2eae7a401e28d6b18f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 15:20:12 -0700 Subject: [PATCH 075/137] libghostty: move selection functions to selection doxygen group --- include/ghostty/vt/selection.h | 113 +++++++++++++++++++++++++++++++ include/ghostty/vt/terminal.h | 112 ------------------------------- src/terminal/c/main.zig | 8 +-- src/terminal/c/selection.zig | 93 ++++++++++++++++++++++++++ src/terminal/c/terminal.zig | 117 ++++++--------------------------- 5 files changed, 229 insertions(+), 214 deletions(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index de00899aa..e397e9a5d 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -10,6 +10,7 @@ #include #include #include +#include #ifdef __cplusplus extern "C" { @@ -157,6 +158,118 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_SELECTION_ADJUST_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionAdjust; +/** + * Adjust a selection snapshot using terminal selection semantics. + * + * This mutates the caller-provided GhosttySelection in place. The logical end + * endpoint is always moved, regardless of whether the selection is forward or + * reversed visually. The input selection remains a snapshot: after adjustment, + * call ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_SELECTION to install it + * as the terminal-owned selection if desired. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to adjust in place + * @param adjustment The adjustment operation to apply + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, or adjustment are invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_adjust( + GhosttyTerminal terminal, + GhosttySelection* selection, + GhosttySelectionAdjust adjustment); + +/** + * Get the current endpoint ordering of a selection snapshot. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to inspect + * @param[out] out_order On success, receives the selection order + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, or output pointer are invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_order( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttySelectionOrder* out_order); + +/** + * Return a selection snapshot with endpoints ordered as requested. + * + * Use GHOSTTY_SELECTION_ORDER_FORWARD to get top-left to bottom-right bounds, + * and GHOSTTY_SELECTION_ORDER_REVERSE to get bottom-right to top-left bounds. + * Mirrored desired orders are accepted but normalized the same as forward. + * The output selection is a fresh untracked snapshot and is not installed as + * the terminal's current selection. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to order + * @param desired Desired endpoint order + * @param[out] out_selection On success, receives the ordered selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, desired order, or output pointer + * are invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_ordered( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttySelectionOrder desired, + GhosttySelection* out_selection); + +/** + * Test whether a terminal point is inside a selection snapshot. + * + * This uses the same selection semantics as the terminal, including + * rectangular/block selections and linear selections spanning multiple rows. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to inspect + * @param point Point to test for containment + * @param[out] out_contains On success, receives whether point is inside selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, point, or output pointer are invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_contains( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttyPoint point, + bool* out_contains); + /** @} */ #ifdef __cplusplus diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index b525af54e..756698449 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1123,118 +1123,6 @@ GHOSTTY_API GhosttyResult ghostty_terminal_get_multi(GhosttyTerminal terminal, void** values, size_t* out_written); -/** - * Adjust a selection snapshot using terminal selection semantics. - * - * This mutates the caller-provided GhosttySelection in place. The logical end - * endpoint is always moved, regardless of whether the selection is forward or - * reversed visually. The input selection remains a snapshot: after adjustment, - * call ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_SELECTION to install it - * as the terminal-owned selection if desired. - * - * The selection's start and end grid refs must both be valid untracked - * snapshots for the given terminal's currently active screen. In practice, - * they must come from that terminal and screen, and no mutating terminal call - * may have occurred since the refs were produced or reconstructed from - * tracked refs. Passing refs from another terminal, another screen, or stale - * refs violates this precondition. - * - * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) - * @param selection Selection snapshot to adjust in place - * @param adjustment The adjustment operation to apply - * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selection, selection references, or adjustment are invalid - * - * @ingroup terminal - */ -GHOSTTY_API GhosttyResult ghostty_terminal_selection_adjust( - GhosttyTerminal terminal, - GhosttySelection* selection, - GhosttySelectionAdjust adjustment); - -/** - * Get the current endpoint ordering of a selection snapshot. - * - * The selection's start and end grid refs must both be valid untracked - * snapshots for the given terminal's currently active screen. In practice, - * they must come from that terminal and screen, and no mutating terminal call - * may have occurred since the refs were produced or reconstructed from - * tracked refs. Passing refs from another terminal, another screen, or stale - * refs violates this precondition. - * - * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) - * @param selection Selection snapshot to inspect - * @param[out] out_order On success, receives the selection order - * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selection, selection references, or output pointer are invalid - * - * @ingroup terminal - */ -GHOSTTY_API GhosttyResult ghostty_terminal_selection_order( - GhosttyTerminal terminal, - const GhosttySelection* selection, - GhosttySelectionOrder* out_order); - -/** - * Return a selection snapshot with endpoints ordered as requested. - * - * Use GHOSTTY_SELECTION_ORDER_FORWARD to get top-left to bottom-right bounds, - * and GHOSTTY_SELECTION_ORDER_REVERSE to get bottom-right to top-left bounds. - * Mirrored desired orders are accepted but normalized the same as forward. - * The output selection is a fresh untracked snapshot and is not installed as - * the terminal's current selection. - * - * The selection's start and end grid refs must both be valid untracked - * snapshots for the given terminal's currently active screen. In practice, - * they must come from that terminal and screen, and no mutating terminal call - * may have occurred since the refs were produced or reconstructed from - * tracked refs. Passing refs from another terminal, another screen, or stale - * refs violates this precondition. - * - * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) - * @param selection Selection snapshot to order - * @param desired Desired endpoint order - * @param[out] out_selection On success, receives the ordered selection - * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selection, selection references, desired order, or output pointer - * are invalid - * - * @ingroup terminal - */ -GHOSTTY_API GhosttyResult ghostty_terminal_selection_ordered( - GhosttyTerminal terminal, - const GhosttySelection* selection, - GhosttySelectionOrder desired, - GhosttySelection* out_selection); - -/** - * Test whether a terminal point is inside a selection snapshot. - * - * This uses the same selection semantics as the terminal, including - * rectangular/block selections and linear selections spanning multiple rows. - * - * The selection's start and end grid refs must both be valid untracked - * snapshots for the given terminal's currently active screen. In practice, - * they must come from that terminal and screen, and no mutating terminal call - * may have occurred since the refs were produced or reconstructed from - * tracked refs. Passing refs from another terminal, another screen, or stale - * refs violates this precondition. - * - * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) - * @param selection Selection snapshot to inspect - * @param point Point to test for containment - * @param[out] out_contains On success, receives whether point is inside selection - * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selection, selection references, point, or output pointer are invalid - * - * @ingroup terminal - */ -GHOSTTY_API GhosttyResult ghostty_terminal_selection_contains( - GhosttyTerminal terminal, - const GhosttySelection* selection, - GhosttyPoint point, - bool* out_contains); - /** * Resolve a point in the terminal grid to a grid reference. * diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 0b75fe81b..fd009fce4 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -170,10 +170,10 @@ pub const terminal_mode_get = terminal.mode_get; pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; pub const terminal_get_multi = terminal.get_multi; -pub const terminal_selection_adjust = terminal.selection_adjust; -pub const terminal_selection_order = terminal.selection_order; -pub const terminal_selection_ordered = terminal.selection_ordered; -pub const terminal_selection_contains = terminal.selection_contains; +pub const terminal_selection_adjust = selection.adjust; +pub const terminal_selection_order = selection.order; +pub const terminal_selection_ordered = selection.ordered; +pub const terminal_selection_contains = selection.contains; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index c4c6284e6..3d2581310 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -1,5 +1,16 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); const grid_ref = @import("grid_ref.zig"); +const point = @import("../point.zig"); const Selection = @import("../Selection.zig"); +const Result = @import("result.zig").Result; +const terminal_c = @import("terminal.zig"); + +const log = std.log.scoped(.selection_c); + +pub const Adjustment = Selection.Adjustment; +pub const Order = Selection.Order; /// C: GhosttySelection pub const CSelection = extern struct { @@ -22,3 +33,85 @@ pub const CSelection = extern struct { }; } }; + +pub fn adjust( + terminal: terminal_c.Terminal, + selection: ?*CSelection, + adjustment: Selection.Adjustment, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Selection.Adjustment, @intFromEnum(adjustment)) catch { + log.warn("terminal_selection_adjust invalid adjustment value={d}", .{@intFromEnum(adjustment)}); + return .invalid_value; + }; + } + + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const sel_ptr = selection orelse return .invalid_value; + var sel = sel_ptr.toZig() orelse return .invalid_value; + sel.adjust(t.screens.active, adjustment); + sel_ptr.* = .fromZig(sel); + return .success; +} + +pub fn order( + terminal: terminal_c.Terminal, + selection: ?*const CSelection, + out_order: ?*Selection.Order, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const sel = (selection orelse return .invalid_value).toZig() orelse + return .invalid_value; + const out = out_order orelse return .invalid_value; + if (!valid(t, sel)) return .invalid_value; + + out.* = sel.order(t.screens.active); + return .success; +} + +pub fn ordered( + terminal: terminal_c.Terminal, + selection: ?*const CSelection, + desired: Selection.Order, + out_selection: ?*CSelection, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Selection.Order, @intFromEnum(desired)) catch { + log.warn("terminal_selection_ordered invalid desired value={d}", .{@intFromEnum(desired)}); + return .invalid_value; + }; + } + + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const sel = (selection orelse return .invalid_value).toZig() orelse + return .invalid_value; + const out = out_selection orelse return .invalid_value; + if (!valid(t, sel)) return .invalid_value; + + out.* = .fromZig(sel.ordered(t.screens.active, desired)); + return .success; +} + +pub fn contains( + terminal: terminal_c.Terminal, + selection: ?*const CSelection, + pt: point.Point.C, + out_contains: ?*bool, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const sel = (selection orelse return .invalid_value).toZig() orelse + return .invalid_value; + const out = out_contains orelse return .invalid_value; + if (!valid(t, sel)) return .invalid_value; + + const screen = t.screens.active; + const pin = screen.pages.pin(.fromC(pt)) orelse return .invalid_value; + out.* = sel.contains(screen, pin); + return .success; +} + +fn valid(t: *terminal_c.ZigTerminal, sel: Selection) bool { + const screen = t.screens.active; + return screen.pages.pointFromPin(.screen, sel.start()) != null and + screen.pages.pointFromPin(.screen, sel.end()) != null; +} diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 6dc8f9e90..6a47bdf22 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -3,11 +3,10 @@ const testing = std.testing; const build_options = @import("terminal_options"); const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; -const ZigTerminal = @import("../Terminal.zig"); +pub const ZigTerminal = @import("../Terminal.zig"); const Stream = @import("../stream_terminal.zig").Stream; const ScreenSet = @import("../ScreenSet.zig"); const PageList = @import("../PageList.zig"); -const Selection = @import("../Selection.zig"); const apc = @import("../apc.zig"); const kitty = @import("../kitty/key.zig"); const kitty_gfx_c = @import("kitty_graphics.zig"); @@ -211,6 +210,10 @@ const Effects = struct { /// C: GhosttyTerminal pub const Terminal = ?*TerminalWrapper; +pub fn zigTerminal(terminal_: Terminal) ?*ZigTerminal { + return (terminal_ orelse return null).terminal; +} + /// C: GhosttyTerminalOptions pub const Options = extern struct { cols: size.CellCountInt, @@ -665,88 +668,6 @@ pub fn get_multi( return .success; } -pub fn selection_adjust( - terminal_: Terminal, - selection: ?*selection_c.CSelection, - adjustment: Selection.Adjustment, -) callconv(lib.calling_conv) Result { - if (comptime std.debug.runtime_safety) { - _ = std.meta.intToEnum(Selection.Adjustment, @intFromEnum(adjustment)) catch { - log.warn("terminal_selection_adjust invalid adjustment value={d}", .{@intFromEnum(adjustment)}); - return .invalid_value; - }; - } - - const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; - const sel_ptr = selection orelse return .invalid_value; - var sel = sel_ptr.toZig() orelse return .invalid_value; - sel.adjust(t.screens.active, adjustment); - sel_ptr.* = .fromZig(sel); - return .success; -} - -pub fn selection_order( - terminal_: Terminal, - selection: ?*const selection_c.CSelection, - out_order: ?*Selection.Order, -) callconv(lib.calling_conv) Result { - const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; - const sel = (selection orelse return .invalid_value).toZig() orelse - return .invalid_value; - const out = out_order orelse return .invalid_value; - if (!selectionValid(t, sel)) return .invalid_value; - - out.* = sel.order(t.screens.active); - return .success; -} - -pub fn selection_ordered( - terminal_: Terminal, - selection: ?*const selection_c.CSelection, - desired: Selection.Order, - out_selection: ?*selection_c.CSelection, -) callconv(lib.calling_conv) Result { - if (comptime std.debug.runtime_safety) { - _ = std.meta.intToEnum(Selection.Order, @intFromEnum(desired)) catch { - log.warn("terminal_selection_ordered invalid desired value={d}", .{@intFromEnum(desired)}); - return .invalid_value; - }; - } - - const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; - const sel = (selection orelse return .invalid_value).toZig() orelse - return .invalid_value; - const out = out_selection orelse return .invalid_value; - if (!selectionValid(t, sel)) return .invalid_value; - - out.* = .fromZig(sel.ordered(t.screens.active, desired)); - return .success; -} - -pub fn selection_contains( - terminal_: Terminal, - selection: ?*const selection_c.CSelection, - pt: point.Point.C, - out_contains: ?*bool, -) callconv(lib.calling_conv) Result { - const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; - const sel = (selection orelse return .invalid_value).toZig() orelse - return .invalid_value; - const out = out_contains orelse return .invalid_value; - if (!selectionValid(t, sel)) return .invalid_value; - - const screen = t.screens.active; - const pin = screen.pages.pin(.fromC(pt)) orelse return .invalid_value; - out.* = sel.contains(screen, pin); - return .success; -} - -fn selectionValid(t: *ZigTerminal, sel: Selection) bool { - const screen = t.screens.active; - return screen.pages.pointFromPin(.screen, sel.start()) != null and - screen.pages.pointFromPin(.screen, sel.end()) != null; -} - fn getTyped( terminal_: Terminal, comptime data: TerminalData, @@ -1503,11 +1424,11 @@ test "selection_adjust mutates snapshot end" { .start = start_ref, .end = end_ref, }; - try testing.expectEqual(Result.success, selection_adjust(t, &sel, .right)); + try testing.expectEqual(Result.success, selection_c.adjust(t, &sel, .right)); try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x); try testing.expectEqual(@as(u16, 2), sel.end.toPin().?.x); - try testing.expectEqual(Result.success, selection_adjust(t, &sel, .left)); + try testing.expectEqual(Result.success, selection_c.adjust(t, &sel, .left)); try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x); try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x); @@ -1515,7 +1436,7 @@ test "selection_adjust mutates snapshot end" { .start = end_ref, .end = start_ref, }; - try testing.expectEqual(Result.success, selection_adjust(t, &sel, .right)); + try testing.expectEqual(Result.success, selection_c.adjust(t, &sel, .right)); try testing.expectEqual(@as(u16, 1), sel.start.toPin().?.x); try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x); } @@ -1553,19 +1474,19 @@ test "selection_order and selection_ordered" { .rectangle = true, }; - var order: Selection.Order = undefined; - try testing.expectEqual(Result.success, selection_order(t, &sel, &order)); - try testing.expectEqual(Selection.Order.mirrored_forward, order); + var order: selection_c.Order = undefined; + try testing.expectEqual(Result.success, selection_c.order(t, &sel, &order)); + try testing.expectEqual(selection_c.Order.mirrored_forward, order); var out: selection_c.CSelection = undefined; - try testing.expectEqual(Result.success, selection_ordered(t, &sel, .forward, &out)); + try testing.expectEqual(Result.success, selection_c.ordered(t, &sel, .forward, &out)); try testing.expectEqual(@as(u16, 1), out.start.toPin().?.x); try testing.expectEqual(@as(u16, 0), out.start.toPin().?.y); try testing.expectEqual(@as(u16, 3), out.end.toPin().?.x); try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y); try testing.expect(out.rectangle); - try testing.expectEqual(Result.success, selection_ordered(t, &sel, .reverse, &out)); + try testing.expectEqual(Result.success, selection_c.ordered(t, &sel, .reverse, &out)); try testing.expectEqual(@as(u16, 3), out.start.toPin().?.x); try testing.expectEqual(@as(u16, 1), out.start.toPin().?.y); try testing.expectEqual(@as(u16, 1), out.end.toPin().?.x); @@ -1606,13 +1527,13 @@ test "selection_contains" { }; var contains: bool = undefined; - try testing.expectEqual(Result.success, selection_contains(t, &linear, .{ + try testing.expectEqual(Result.success, selection_c.contains(t, &linear, .{ .tag = .active, .value = .{ .active = .{ .x = 4, .y = 0 } }, }, &contains)); try testing.expect(contains); - try testing.expectEqual(Result.success, selection_contains(t, &linear, .{ + try testing.expectEqual(Result.success, selection_c.contains(t, &linear, .{ .tag = .active, .value = .{ .active = .{ .x = 2, .y = 0 } }, }, &contains)); @@ -1624,7 +1545,7 @@ test "selection_contains" { .rectangle = true, }; - try testing.expectEqual(Result.success, selection_contains(t, &rectangle, .{ + try testing.expectEqual(Result.success, selection_c.contains(t, &rectangle, .{ .tag = .active, .value = .{ .active = .{ .x = 2, .y = 0 } }, }, &contains)); @@ -1644,9 +1565,9 @@ test "selection_order invalid values" { )); defer free(t); - var order: Selection.Order = undefined; - try testing.expectEqual(Result.invalid_value, selection_order(null, null, &order)); - try testing.expectEqual(Result.invalid_value, selection_order(t, null, &order)); + var order: selection_c.Order = undefined; + try testing.expectEqual(Result.invalid_value, selection_c.order(null, null, &order)); + try testing.expectEqual(Result.invalid_value, selection_c.order(t, null, &order)); } test "grid_ref" { From ae839393d9b0a6d6776e816e8a9193c3d6875850 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 15:21:44 -0700 Subject: [PATCH 076/137] libghostty: add Selection equal and validate --- include/ghostty/vt/selection.h | 47 ++++++++++++++++++ src/lib_vt.zig | 2 + src/terminal/c/main.zig | 2 + src/terminal/c/selection.zig | 30 +++++++++++ src/terminal/c/terminal.zig | 91 ++++++++++++++++++++++++++++++++++ 5 files changed, 172 insertions(+) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index e397e9a5d..d050f74ab 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -270,6 +270,53 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_contains( GhosttyPoint point, bool* out_contains); +/** + * Test whether two selection snapshots are equal. + * + * Equality uses the terminal's internal selection semantics: both endpoint + * pins must match and both selections must have the same rectangular/block + * state. This avoids requiring callers to compare raw GhosttyGridRef internals. + * + * Both selections' start and end grid refs must be valid untracked snapshots + * for the given terminal's currently active screen. In practice, they must + * come from that terminal and screen, and no mutating terminal call may have + * occurred since the refs were produced or reconstructed from tracked refs. + * Passing refs from another terminal, another screen, or stale refs returns + * GHOSTTY_INVALID_VALUE. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param a First selection snapshot to compare + * @param b Second selection snapshot to compare + * @param[out] out_equal On success, receives whether the selections are equal + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selections, selection references, or output pointer are invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_equal( + GhosttyTerminal terminal, + const GhosttySelection* a, + const GhosttySelection* b, + bool* out_equal); + +/** + * Validate that a selection snapshot is representable for a terminal. + * + * A valid selection has both endpoint grid refs resolved in the terminal's + * currently active screen/page list. Malformed refs, stale refs, refs from + * another terminal, or refs from an inactive screen return GHOSTTY_INVALID_VALUE. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to validate + * @return GHOSTTY_SUCCESS if the selection is valid for the terminal's active + * screen/page list, otherwise GHOSTTY_INVALID_VALUE + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_validate( + GhosttyTerminal terminal, + const GhosttySelection* selection); + /** @} */ #ifdef __cplusplus diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 6e3ff18cd..68aa4db63 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -243,6 +243,8 @@ comptime { @export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" }); @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); @export(&c.terminal_selection_contains, .{ .name = "ghostty_terminal_selection_contains" }); + @export(&c.terminal_selection_equal, .{ .name = "ghostty_terminal_selection_equal" }); + @export(&c.terminal_selection_validate, .{ .name = "ghostty_terminal_selection_validate" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index fd009fce4..86898e7b9 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -174,6 +174,8 @@ pub const terminal_selection_adjust = selection.adjust; pub const terminal_selection_order = selection.order; pub const terminal_selection_ordered = selection.ordered; pub const terminal_selection_contains = selection.contains; +pub const terminal_selection_equal = selection.equal; +pub const terminal_selection_validate = selection.validate; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index 3d2581310..a5767609f 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -110,6 +110,36 @@ pub fn contains( return .success; } +pub fn equal( + terminal: terminal_c.Terminal, + a: ?*const CSelection, + b: ?*const CSelection, + out_equal: ?*bool, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const sel_a = (a orelse return .invalid_value).toZig() orelse + return .invalid_value; + const sel_b = (b orelse return .invalid_value).toZig() orelse + return .invalid_value; + const out = out_equal orelse return .invalid_value; + if (!valid(t, sel_a) or !valid(t, sel_b)) return .invalid_value; + + out.* = sel_a.eql(sel_b); + return .success; +} + +pub fn validate( + terminal: terminal_c.Terminal, + selection: ?*const CSelection, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const sel = (selection orelse return .invalid_value).toZig() orelse + return .invalid_value; + if (!valid(t, sel)) return .invalid_value; + + return .success; +} + fn valid(t: *terminal_c.ZigTerminal, sel: Selection) bool { const screen = t.screens.active; return screen.pages.pointFromPin(.screen, sel.start()) != null and diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 6a47bdf22..3114f92ad 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -1552,6 +1552,97 @@ test "selection_contains" { try testing.expect(contains); } +test "selection_equal and selection_validate" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var other_t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &other_t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(other_t); + + vt_write(t, "Hello", 5); + vt_write(other_t, "Hello", 5); + + var start_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 0, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &end_ref)); + + var other_end_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 2, .y = 0 } }, + }, &other_end_ref)); + + var cross_terminal_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(other_t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &cross_terminal_ref)); + + const sel: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + }; + const equal_sel: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + }; + const different_endpoint: selection_c.CSelection = .{ + .start = start_ref, + .end = other_end_ref, + }; + const different_rectangle: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + .rectangle = true, + }; + const cross_terminal: selection_c.CSelection = .{ + .start = start_ref, + .end = cross_terminal_ref, + }; + + try testing.expectEqual(Result.success, selection_c.validate(t, &sel)); + try testing.expectEqual(Result.invalid_value, selection_c.validate(t, &cross_terminal)); + + var equal: bool = undefined; + try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &equal_sel, &equal)); + try testing.expect(equal); + + try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &different_endpoint, &equal)); + try testing.expect(!equal); + + try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &different_rectangle, &equal)); + try testing.expect(!equal); + + try testing.expectEqual(Result.invalid_value, selection_c.equal(t, &sel, &cross_terminal, &equal)); + try testing.expectEqual(Result.invalid_value, selection_c.equal(t, &sel, &equal_sel, null)); +} + test "selection_order invalid values" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 7b49d1f12928908de022c00ef5dbc099a66517fb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 15:26:24 -0700 Subject: [PATCH 077/137] terminal: PageList.reset needs to reset page serial mins --- src/terminal/PageList.zig | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 89fdaec1f..8e5cd1934 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -658,6 +658,11 @@ pub fn deinit(self: *PageList) void { pub fn reset(self: *PageList) void { defer self.assertIntegrity(); + // Invalidate all external page refs to the previous list. The reset below + // rebuilds the page list from the pools, so old untracked refs must be + // rejected before any validation attempts to inspect their node pointers. + self.page_serial_min = self.page_serial; + // We need enough pages/nodes to keep our active area. This should // never fail since we by definition have allocated a page already // that fits our size but I'm not confident to make that assertion. @@ -13543,6 +13548,30 @@ test "PageList reset" { }, s.getTopLeft(.active)); } +test "PageList reset invalidates stale untracked refs even if node memory is reused" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const old_serial = s.pages.first.?.serial; + try testing.expect(old_serial >= s.page_serial_min); + try testing.expect(old_serial < s.page_serial); + + s.reset(); + + // The important safety property is that stale serials are rejected before + // the node pointer is inspected. Reset rebuilds the page list from the + // pools, so old untracked refs may contain node pointers that are no + // longer safe to dereference. + try testing.expect(old_serial < s.page_serial_min); + + const new_serial = s.pages.first.?.serial; + try testing.expect(new_serial >= s.page_serial_min); + try testing.expect(new_serial < s.page_serial); +} + test "PageList reset across two pages" { const testing = std.testing; const alloc = testing.allocator; From 847b8afc872110f6cfd0c0f4690133904a06da16 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 18:31:07 -0700 Subject: [PATCH 078/137] libghostty: remove selection validation, way too expensive --- include/ghostty/vt/selection.h | 38 +++++++++++----------------------- src/lib_vt.zig | 1 - src/terminal/c/main.zig | 1 - src/terminal/c/selection.zig | 24 +-------------------- src/terminal/c/terminal.zig | 8 +++---- 5 files changed, 16 insertions(+), 56 deletions(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index d050f74ab..f71235a5a 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -178,7 +178,8 @@ typedef enum GHOSTTY_ENUM_TYPED { * @param selection Selection snapshot to adjust in place * @param adjustment The adjustment operation to apply * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selection, selection references, or adjustment are invalid + * selection, or adjustment are invalid. Selection reference validity + * is a precondition and is not checked. * * @ingroup selection */ @@ -201,7 +202,8 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_adjust( * @param selection Selection snapshot to inspect * @param[out] out_order On success, receives the selection order * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selection, selection references, or output pointer are invalid + * selection, or output pointer are invalid. Selection reference + * validity is a precondition and is not checked. * * @ingroup selection */ @@ -231,8 +233,8 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_order( * @param desired Desired endpoint order * @param[out] out_selection On success, receives the ordered selection * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selection, selection references, desired order, or output pointer - * are invalid + * selection, desired order, or output pointer are invalid. Selection + * reference validity is a precondition and is not checked. * * @ingroup selection */ @@ -260,7 +262,8 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_ordered( * @param point Point to test for containment * @param[out] out_contains On success, receives whether point is inside selection * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selection, selection references, point, or output pointer are invalid + * selection, point, or output pointer are invalid. Selection reference + * validity is a precondition and is not checked. * * @ingroup selection */ @@ -281,15 +284,16 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_contains( * for the given terminal's currently active screen. In practice, they must * come from that terminal and screen, and no mutating terminal call may have * occurred since the refs were produced or reconstructed from tracked refs. - * Passing refs from another terminal, another screen, or stale refs returns - * GHOSTTY_INVALID_VALUE. + * Passing refs from another terminal, another screen, or stale refs violates + * this precondition. * * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) * @param a First selection snapshot to compare * @param b Second selection snapshot to compare * @param[out] out_equal On success, receives whether the selections are equal * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, - * selections, selection references, or output pointer are invalid + * selections, or output pointer are invalid. Selection reference + * validity is a precondition and is not checked. * * @ingroup selection */ @@ -299,24 +303,6 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_equal( const GhosttySelection* b, bool* out_equal); -/** - * Validate that a selection snapshot is representable for a terminal. - * - * A valid selection has both endpoint grid refs resolved in the terminal's - * currently active screen/page list. Malformed refs, stale refs, refs from - * another terminal, or refs from an inactive screen return GHOSTTY_INVALID_VALUE. - * - * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) - * @param selection Selection snapshot to validate - * @return GHOSTTY_SUCCESS if the selection is valid for the terminal's active - * screen/page list, otherwise GHOSTTY_INVALID_VALUE - * - * @ingroup selection - */ -GHOSTTY_API GhosttyResult ghostty_terminal_selection_validate( - GhosttyTerminal terminal, - const GhosttySelection* selection); - /** @} */ #ifdef __cplusplus diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 68aa4db63..cf3c2f820 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -244,7 +244,6 @@ comptime { @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); @export(&c.terminal_selection_contains, .{ .name = "ghostty_terminal_selection_contains" }); @export(&c.terminal_selection_equal, .{ .name = "ghostty_terminal_selection_equal" }); - @export(&c.terminal_selection_validate, .{ .name = "ghostty_terminal_selection_validate" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 86898e7b9..19294d4a0 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -175,7 +175,6 @@ pub const terminal_selection_order = selection.order; pub const terminal_selection_ordered = selection.ordered; pub const terminal_selection_contains = selection.contains; pub const terminal_selection_equal = selection.equal; -pub const terminal_selection_validate = selection.validate; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index a5767609f..5734e9fb0 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -63,7 +63,6 @@ pub fn order( const sel = (selection orelse return .invalid_value).toZig() orelse return .invalid_value; const out = out_order orelse return .invalid_value; - if (!valid(t, sel)) return .invalid_value; out.* = sel.order(t.screens.active); return .success; @@ -86,7 +85,6 @@ pub fn ordered( const sel = (selection orelse return .invalid_value).toZig() orelse return .invalid_value; const out = out_selection orelse return .invalid_value; - if (!valid(t, sel)) return .invalid_value; out.* = .fromZig(sel.ordered(t.screens.active, desired)); return .success; @@ -102,7 +100,6 @@ pub fn contains( const sel = (selection orelse return .invalid_value).toZig() orelse return .invalid_value; const out = out_contains orelse return .invalid_value; - if (!valid(t, sel)) return .invalid_value; const screen = t.screens.active; const pin = screen.pages.pin(.fromC(pt)) orelse return .invalid_value; @@ -116,32 +113,13 @@ pub fn equal( b: ?*const CSelection, out_equal: ?*bool, ) callconv(lib.calling_conv) Result { - const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + _ = terminal_c.zigTerminal(terminal) orelse return .invalid_value; const sel_a = (a orelse return .invalid_value).toZig() orelse return .invalid_value; const sel_b = (b orelse return .invalid_value).toZig() orelse return .invalid_value; const out = out_equal orelse return .invalid_value; - if (!valid(t, sel_a) or !valid(t, sel_b)) return .invalid_value; out.* = sel_a.eql(sel_b); return .success; } - -pub fn validate( - terminal: terminal_c.Terminal, - selection: ?*const CSelection, -) callconv(lib.calling_conv) Result { - const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; - const sel = (selection orelse return .invalid_value).toZig() orelse - return .invalid_value; - if (!valid(t, sel)) return .invalid_value; - - return .success; -} - -fn valid(t: *terminal_c.ZigTerminal, sel: Selection) bool { - const screen = t.screens.active; - return screen.pages.pointFromPin(.screen, sel.start()) != null and - screen.pages.pointFromPin(.screen, sel.end()) != null; -} diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 3114f92ad..939b7a673 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -1552,7 +1552,7 @@ test "selection_contains" { try testing.expect(contains); } -test "selection_equal and selection_validate" { +test "selection_equal" { var t: Terminal = null; try testing.expectEqual(Result.success, new( &lib.alloc.test_allocator, @@ -1626,9 +1626,6 @@ test "selection_equal and selection_validate" { .end = cross_terminal_ref, }; - try testing.expectEqual(Result.success, selection_c.validate(t, &sel)); - try testing.expectEqual(Result.invalid_value, selection_c.validate(t, &cross_terminal)); - var equal: bool = undefined; try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &equal_sel, &equal)); try testing.expect(equal); @@ -1639,7 +1636,8 @@ test "selection_equal and selection_validate" { try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &different_rectangle, &equal)); try testing.expect(!equal); - try testing.expectEqual(Result.invalid_value, selection_c.equal(t, &sel, &cross_terminal, &equal)); + try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &cross_terminal, &equal)); + try testing.expect(!equal); try testing.expectEqual(Result.invalid_value, selection_c.equal(t, &sel, &equal_sel, null)); } From cc48312c08a0f0e08f77a8df74c15e9e367ce70e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 May 2026 12:53:41 -0700 Subject: [PATCH 079/137] libghostty: selection word/line/output/all helpers --- include/ghostty/vt/selection.h | 132 ++++++++++++++++++++++++++ src/config/Config.zig | 27 +----- src/lib_vt.zig | 4 + src/terminal/Screen.zig | 23 ++--- src/terminal/c/main.zig | 4 + src/terminal/c/selection.zig | 125 ++++++++++++++++++++++++ src/terminal/c/terminal.zig | 66 +++++++++++++ src/terminal/c/types.zig | 53 ++++++----- src/terminal/selection_codepoints.zig | 31 ++++++ 9 files changed, 405 insertions(+), 60 deletions(-) create mode 100644 src/terminal/selection_codepoints.zig diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index f71235a5a..89a722673 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -78,6 +79,57 @@ typedef struct { bool rectangle; } GhosttySelection; +/** + * Options for deriving a word selection from a terminal grid reference. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * If boundary_codepoints is NULL and boundary_codepoints_len is 0, Ghostty's + * default word-boundary codepoints are used. If boundary_codepoints_len is + * non-zero, boundary_codepoints must not be NULL. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectWordOptions). */ + size_t size; + + /** Grid reference under which to derive the word selection. */ + GhosttyGridRef ref; + + /** Optional word-boundary codepoints as uint32_t scalar values. */ + const uint32_t* boundary_codepoints; + + /** Number of entries in boundary_codepoints. */ + size_t boundary_codepoints_len; +} GhosttyTerminalSelectWordOptions; + +/** + * Options for deriving a line selection from a terminal grid reference. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * If whitespace is NULL and whitespace_len is 0, Ghostty's default line-trim + * whitespace codepoints are used. If whitespace_len is non-zero, whitespace + * must not be NULL. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectLineOptions). */ + size_t size; + + /** Grid reference under which to derive the line selection. */ + GhosttyGridRef ref; + + /** Optional codepoints to trim from the start and end of the line. */ + const uint32_t* whitespace; + + /** Number of entries in whitespace. */ + size_t whitespace_len; + + /** Whether semantic prompt state changes should bound the line selection. */ + bool semantic_prompt_boundary; +} GhosttyTerminalSelectLineOptions; + /** * Ordering of a selection's endpoints in terminal coordinates. * @@ -158,6 +210,86 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_SELECTION_ADJUST_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionAdjust; +/** + * Derive a word selection snapshot from a terminal grid reference. + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param options Word-selection options + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref has + * no selectable word content, or GHOSTTY_INVALID_VALUE if the + * terminal, options, ref, codepoint pointer, or output pointer are + * invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_word( + GhosttyTerminal terminal, + const GhosttyTerminalSelectWordOptions* options, + GhosttySelection* out_selection); + +/** + * Derive a line selection snapshot from a terminal grid reference. + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param options Line-selection options + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref has + * no selectable line content, or GHOSTTY_INVALID_VALUE if the + * terminal, options, ref, codepoint pointer, or output pointer are + * invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_line( + GhosttyTerminal terminal, + const GhosttyTerminalSelectLineOptions* options, + GhosttySelection* out_selection); + +/** + * Derive a selection snapshot covering all selectable terminal content. + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no + * selectable content, or GHOSTTY_INVALID_VALUE if the terminal or + * output pointer is invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_all( + GhosttyTerminal terminal, + GhosttySelection* out_selection); + +/** + * Derive a command-output selection snapshot from a terminal grid reference. + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param ref Grid reference within command output to select + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref is + * not selectable command output, or GHOSTTY_INVALID_VALUE if the + * terminal, ref, or output pointer is invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_output( + GhosttyTerminal terminal, + GhosttyGridRef ref, + GhosttySelection* out_selection); + /** * Adjust a selection snapshot using terminal selection semantics. * diff --git a/src/config/Config.zig b/src/config/Config.zig index 9e6e5629c..380155127 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -49,6 +49,7 @@ const string = @import("string.zig"); const terminal = struct { const CursorStyle = @import("../terminal/cursor.zig").Style; const color = @import("../terminal/color.zig"); + const selection_codepoints = @import("../terminal/selection_codepoints.zig"); const style = @import("../terminal/style.zig"); const x11_color = @import("../terminal/x11_color.zig"); }; @@ -6149,32 +6150,8 @@ pub const RepeatableString = struct { pub const SelectionWordChars = struct { const Self = @This(); - /// Default boundary characters: ` \t'"│`|:;,()[]{}<>$` - const default_codepoints = [_]u21{ - 0, // null - ' ', // space - '\t', // tab - '\'', // single quote - '"', // double quote - '│', // U+2502 box drawing - '`', // backtick - '|', // pipe - ':', // colon - ';', // semicolon - ',', // comma - '(', // left paren - ')', // right paren - '[', // left bracket - ']', // right bracket - '{', // left brace - '}', // right brace - '<', // less than - '>', // greater than - '$', // dollar - }; - /// The parsed codepoints. Always includes null (U+0000) at index 0. - codepoints: []const u21 = &default_codepoints, + codepoints: []const u21 = &terminal.selection_codepoints.default_word_boundaries, pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { const value = input orelse return error.ValueRequired; diff --git a/src/lib_vt.zig b/src/lib_vt.zig index cf3c2f820..543c5b447 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -239,6 +239,10 @@ comptime { @export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" }); @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" }); + @export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" }); + @export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" }); + @export(&c.terminal_select_all, .{ .name = "ghostty_terminal_select_all" }); + @export(&c.terminal_select_output, .{ .name = "ghostty_terminal_select_output" }); @export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" }); @export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" }); @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index ac53a2d72..becda78b7 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -13,6 +13,7 @@ const tripwire = @import("../tripwire.zig"); const unicode = @import("../unicode/main.zig"); const Selection = @import("Selection.zig"); const PageList = @import("PageList.zig"); +const selection_codepoints = @import("selection_codepoints.zig"); const StringMap = @import("StringMap.zig"); const ScreenFormatter = @import("formatter.zig").ScreenFormatter; const osc = @import("osc.zig"); @@ -2516,7 +2517,7 @@ pub const SelectLine = struct { /// These are the codepoints to consider whitespace to trim /// from the ends of the selection. - whitespace: ?[]const u21 = &.{ 0, ' ', '\t' }, + whitespace: ?[]const u21 = &selection_codepoints.default_line_whitespace, /// If true, line selection will consider semantic prompt /// state changing a boundary. State changing is ANY state @@ -2652,10 +2653,10 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { if (!cell.hasText()) continue; // Non-empty means we found it. - const this_whitespace = std.mem.indexOfAny( + const this_whitespace = std.mem.indexOfScalar( u21, whitespace, - &[_]u21{cell.content.codepoint}, + cell.content.codepoint, ) != null; if (this_whitespace) continue; @@ -2674,10 +2675,10 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { if (!cell.hasText()) continue; // Non-empty means we found it. - const this_whitespace = std.mem.indexOfAny( + const this_whitespace = std.mem.indexOfScalar( u21, whitespace, - &[_]u21{cell.content.codepoint}, + cell.content.codepoint, ) != null; if (this_whitespace) continue; @@ -2798,10 +2799,10 @@ pub fn selectWord( if (!start_cell.hasText()) return null; // Determine if we are a boundary or not to determine what our boundary is. - const expect_boundary = std.mem.indexOfAny( + const expect_boundary = std.mem.indexOfScalar( u21, boundary_codepoints, - &[_]u21{start_cell.content.codepoint}, + start_cell.content.codepoint, ) != null; // Go forwards to find our end boundary @@ -2816,10 +2817,10 @@ pub fn selectWord( if (!cell.hasText()) break :end prev; // If we do not match our expected set, we hit a boundary - const this_boundary = std.mem.indexOfAny( + const this_boundary = std.mem.indexOfScalar( u21, boundary_codepoints, - &[_]u21{cell.content.codepoint}, + cell.content.codepoint, ) != null; if (this_boundary != expect_boundary) break :end prev; @@ -2853,10 +2854,10 @@ pub fn selectWord( if (!cell.hasText()) break :start prev; // If we do not match our expected set, we hit a boundary - const this_boundary = std.mem.indexOfAny( + const this_boundary = std.mem.indexOfScalar( u21, boundary_codepoints, - &[_]u21{cell.content.codepoint}, + cell.content.codepoint, ) != null; if (this_boundary != expect_boundary) break :start prev; diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 19294d4a0..1cd7e0231 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -170,6 +170,10 @@ pub const terminal_mode_get = terminal.mode_get; pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; pub const terminal_get_multi = terminal.get_multi; +pub const terminal_select_word = selection.word; +pub const terminal_select_line = selection.line; +pub const terminal_select_all = selection.all; +pub const terminal_select_output = selection.output; pub const terminal_selection_adjust = selection.adjust; pub const terminal_selection_order = selection.order; pub const terminal_selection_ordered = selection.ordered; diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index 5734e9fb0..ea1eea473 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -3,6 +3,7 @@ const testing = std.testing; const lib = @import("../lib.zig"); const grid_ref = @import("grid_ref.zig"); const point = @import("../point.zig"); +const selection_codepoints = @import("../selection_codepoints.zig"); const Selection = @import("../Selection.zig"); const Result = @import("result.zig").Result; const terminal_c = @import("terminal.zig"); @@ -34,6 +35,130 @@ pub const CSelection = extern struct { } }; +/// C: GhosttyTerminalSelectWordOptions +pub const SelectWordOptions = extern struct { + size: usize = @sizeOf(SelectWordOptions), + ref: grid_ref.CGridRef, + boundary_codepoints: ?[*]const u32 = null, + boundary_codepoints_len: usize = 0, +}; + +/// C: GhosttyTerminalSelectLineOptions +pub const SelectLineOptions = extern struct { + size: usize = @sizeOf(SelectLineOptions), + ref: grid_ref.CGridRef, + whitespace: ?[*]const u32 = null, + whitespace_len: usize = 0, + semantic_prompt_boundary: bool = false, +}; + +pub fn word( + terminal: terminal_c.Terminal, + options: ?*const SelectWordOptions, + out_selection: ?*CSelection, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const opts = options orelse return .invalid_value; + if (opts.size < @sizeOf(SelectWordOptions)) return .invalid_value; + const out = out_selection orelse return .invalid_value; + + const boundary_codepoints = codepointSlice( + opts.boundary_codepoints, + opts.boundary_codepoints_len, + ) catch return .invalid_value; + + const screen = t.screens.active; + const pin = opts.ref.toPin() orelse return .invalid_value; + out.* = .fromZig(screen.selectWord( + pin, + boundary_codepoints orelse &selection_codepoints.default_word_boundaries, + ) orelse + return .no_value); + return .success; +} + +pub fn line( + terminal: terminal_c.Terminal, + options: ?*const SelectLineOptions, + out_selection: ?*CSelection, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const opts = options orelse return .invalid_value; + if (opts.size < @sizeOf(SelectLineOptions)) return .invalid_value; + const out = out_selection orelse return .invalid_value; + + const whitespace = codepointSlice( + opts.whitespace, + opts.whitespace_len, + ) catch return .invalid_value; + + const screen = t.screens.active; + const pin = opts.ref.toPin() orelse return .invalid_value; + out.* = .fromZig(screen.selectLine(.{ + .pin = pin, + .whitespace = whitespace orelse &selection_codepoints.default_line_whitespace, + .semantic_prompt_boundary = opts.semantic_prompt_boundary, + }) orelse return .no_value); + return .success; +} + +pub fn all( + terminal: terminal_c.Terminal, + out_selection: ?*CSelection, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const out = out_selection orelse return .invalid_value; + + out.* = .fromZig(t.screens.active.selectAll() orelse return .no_value); + return .success; +} + +pub fn output( + terminal: terminal_c.Terminal, + ref: grid_ref.CGridRef, + out_selection: ?*CSelection, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const out = out_selection orelse return .invalid_value; + + const screen = t.screens.active; + const pin = ref.toPin() orelse return .invalid_value; + out.* = .fromZig(screen.selectOutput(pin) orelse return .no_value); + return .success; +} + +/// Return the borrowed C array of `uint32_t` codepoints as a `[]const u21`. +/// +/// `NULL + len 0` returns null, which callers treat as “use the API default +/// set.” A non-null pointer with `len 0` returns an empty slice, meaning “use an +/// explicitly empty set.” A non-zero length requires a non-null pointer. +/// +/// This is intentionally zero-copy. In the C ABI, codepoints are `uint32_t`, +/// but selection internals use Zig's `u21` to represent valid Unicode scalar +/// values. Zig currently stores `u21` in the same size and alignment as `u32`, +/// so we assert that layout relationship and reinterpret the borrowed slice. +/// If Zig ever changes that representation, these comptime assertions fail +/// loudly rather than silently making this cast wrong. +fn codepointSlice( + ptr: ?[*]const u32, + len: usize, +) error{InvalidValue}!?[]const u21 { + comptime { + std.debug.assert(@sizeOf(u21) == @sizeOf(u32)); + std.debug.assert(@alignOf(u21) == @alignOf(u32)); + } + + if (len == 0) { + const p = ptr orelse return null; + _ = p; + return &.{}; + } + + const p = ptr orelse return error.InvalidValue; + const cps: [*]const u21 = @ptrCast(p); + return cps[0..len]; +} + pub fn adjust( terminal: terminal_c.Terminal, selection: ?*CSelection, diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 939b7a673..6c7ce3ce5 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -1393,6 +1393,72 @@ test "set and get selection" { try testing.expectEqual(Result.no_value, get(t, .selection, @ptrCast(&out))); } +test "selection derivation helpers" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + vt_write(t, " Hello \r\nWorld", 16); + + var out: selection_c.CSelection = undefined; + + var word_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 3, .y = 0 } }, + }, &word_ref)); + + var empty_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 20, .y = 0 } }, + }, &empty_ref)); + + var line_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 0, .y = 0 } }, + }, &line_ref)); + + var word_opts: selection_c.SelectWordOptions = .{ + .ref = word_ref, + }; + try testing.expectEqual(Result.success, selection_c.word(t, &word_opts, &out)); + try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x); + try testing.expectEqual(@as(u16, 6), out.end.toPin().?.x); + + word_opts.ref = empty_ref; + try testing.expectEqual(Result.no_value, selection_c.word(t, &word_opts, &out)); + + var line_opts: selection_c.SelectLineOptions = .{ + .ref = line_ref, + }; + try testing.expectEqual(Result.success, selection_c.line(t, &line_opts, &out)); + try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x); + try testing.expectEqual(@as(u16, 6), out.end.toPin().?.x); + + try testing.expectEqual(Result.success, selection_c.all(t, &out)); + try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x); + try testing.expectEqual(@as(u16, 0), out.start.toPin().?.y); + try testing.expectEqual(@as(u16, 4), out.end.toPin().?.x); + try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y); + + try testing.expectEqual(Result.no_value, selection_c.output(t, line_ref, &out)); + + line_opts.size = @sizeOf(usize) - 1; + try testing.expectEqual(Result.invalid_value, selection_c.line(t, &line_opts, &out)); + try testing.expectEqual(Result.invalid_value, selection_c.word(t, null, &out)); + try testing.expectEqual(Result.invalid_value, selection_c.word(t, &word_opts, null)); +} + test "selection_adjust mutates snapshot end" { var t: Terminal = null; try testing.expectEqual(Result.success, new( diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 500809d9c..8a6a7f927 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -20,30 +20,35 @@ const mouse_encode = @import("mouse_encode.zig"); const grid_ref = @import("grid_ref.zig"); /// All C API structs and their Ghostty C names. -pub const structs: std.StaticStringMap(StructInfo) = .initComptime(.{ - .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) }, - .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) }, - .{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) }, - .{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) }, - .{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) }, - .{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) }, - .{ "GhosttySelection", StructInfo.init(selection.CSelection) }, - .{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) }, - .{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) }, - .{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) }, - .{ "GhosttyMouseEncoderSize", StructInfo.init(mouse_encode.Size) }, - .{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) }, - .{ "GhosttyPoint", StructInfo.init(point.Point.C) }, - .{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) }, - .{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) }, - .{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) }, - .{ "GhosttyString", StructInfo.init(lib.String) }, - .{ "GhosttyStyle", StructInfo.init(style_c.Style) }, - .{ "GhosttyStyleColor", StructInfo.init(style_c.Color) }, - .{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) }, - .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) }, - .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) }, -}); +pub const structs: std.StaticStringMap(StructInfo) = structs: { + @setEvalBranchQuota(10_000); + break :structs .initComptime(.{ + .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) }, + .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) }, + .{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) }, + .{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) }, + .{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) }, + .{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) }, + .{ "GhosttySelection", StructInfo.init(selection.CSelection) }, + .{ "GhosttyTerminalSelectWordOptions", StructInfo.init(selection.SelectWordOptions) }, + .{ "GhosttyTerminalSelectLineOptions", StructInfo.init(selection.SelectLineOptions) }, + .{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) }, + .{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) }, + .{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) }, + .{ "GhosttyMouseEncoderSize", StructInfo.init(mouse_encode.Size) }, + .{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) }, + .{ "GhosttyPoint", StructInfo.init(point.Point.C) }, + .{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) }, + .{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) }, + .{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) }, + .{ "GhosttyString", StructInfo.init(lib.String) }, + .{ "GhosttyStyle", StructInfo.init(style_c.Style) }, + .{ "GhosttyStyleColor", StructInfo.init(style_c.Color) }, + .{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) }, + .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) }, + .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) }, + }); +}; /// The comptime-generated JSON string of all structs. pub const json: [:0]const u8 = json: { diff --git a/src/terminal/selection_codepoints.zig b/src/terminal/selection_codepoints.zig new file mode 100644 index 000000000..4c3184030 --- /dev/null +++ b/src/terminal/selection_codepoints.zig @@ -0,0 +1,31 @@ +// This file contains various default word boundaries used for +// selection logic. We put it in a separate file so that different +// subsystems can import it without introducing a number of +// dependencies. + +/// Default boundary characters for word selection: ` \t'"│`|:;,()[]{}<>$` +pub const default_word_boundaries = [_]u21{ + 0, // null + ' ', // space + '\t', // tab + '\'', // single quote + '"', // double quote + '│', // U+2502 box drawing + '`', // backtick + '|', // pipe + ':', // colon + ';', // semicolon + ',', // comma + '(', // left paren + ')', // right paren + '[', // left bracket + ']', // right bracket + '{', // left brace + '}', // right brace + '<', // less than + '>', // greater than + '$', // dollar +}; + +/// Default whitespace characters trimmed from line selections. +pub const default_line_whitespace = [_]u21{ 0, ' ', '\t' }; From e8f53539120e73e06c5bd82ab39dbf1499bf9311 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 May 2026 13:47:32 -0700 Subject: [PATCH 080/137] example/c-vt-selection --- example/c-vt-selection/README.md | 18 +++++ example/c-vt-selection/build.zig | 42 +++++++++++ example/c-vt-selection/build.zig.zon | 24 +++++++ example/c-vt-selection/src/main.c | 102 +++++++++++++++++++++++++++ include/ghostty/vt/selection.h | 4 ++ 5 files changed, 190 insertions(+) create mode 100644 example/c-vt-selection/README.md create mode 100644 example/c-vt-selection/build.zig create mode 100644 example/c-vt-selection/build.zig.zon create mode 100644 example/c-vt-selection/src/main.c diff --git a/example/c-vt-selection/README.md b/example/c-vt-selection/README.md new file mode 100644 index 000000000..c88f7a11d --- /dev/null +++ b/example/c-vt-selection/README.md @@ -0,0 +1,18 @@ +# Example: `ghostty-vt` Selection + +This contains a simple example of how to use the `ghostty-vt` terminal, +grid reference, selection, and formatter APIs to derive selections such as a +word, semantic command line, command output, and all visible content. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-selection/build.zig b/example/c-vt-selection/build.zig new file mode 100644 index 000000000..49f7c8cb3 --- /dev/null +++ b/example/c-vt-selection/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_selection", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-selection/build.zig.zon b/example/c-vt-selection/build.zig.zon new file mode 100644 index 000000000..d09800a51 --- /dev/null +++ b/example/c-vt-selection/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_selection, + .version = "0.0.0", + .fingerprint = 0xb2c2f1a828086fef, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-selection/src/main.c b/example/c-vt-selection/src/main.c new file mode 100644 index 000000000..ea638bbe8 --- /dev/null +++ b/example/c-vt-selection/src/main.c @@ -0,0 +1,102 @@ +#include +#include +#include +#include + +//! [selection-main] +static void vt_write(GhosttyTerminal terminal, const char *s) { + ghostty_terminal_vt_write(terminal, (const uint8_t *)s, strlen(s)); +} + +static GhosttyGridRef ref_at(GhosttyTerminal terminal, uint16_t x, uint16_t y) { + GhosttyGridRef ref = GHOSTTY_INIT_SIZED(GhosttyGridRef); + GhosttyPoint point = { + .tag = GHOSTTY_POINT_TAG_ACTIVE, + .value = { .coordinate = { .x = x, .y = y } }, + }; + + GhosttyResult result = ghostty_terminal_grid_ref(terminal, point, &ref); + assert(result == GHOSTTY_SUCCESS); + return ref; +} + +static void print_selection( + GhosttyTerminal terminal, + const char *label, + const GhosttySelection *selection) { + GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions); + opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN; + opts.trim = true; + opts.selection = selection; + + GhosttyFormatter formatter; + GhosttyResult result = ghostty_formatter_terminal_new( + NULL, &formatter, terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + uint8_t *buf = NULL; + size_t len = 0; + result = ghostty_formatter_format_alloc(formatter, NULL, &buf, &len); + assert(result == GHOSTTY_SUCCESS); + + printf("%s: ", label); + fwrite(buf, 1, len, stdout); + printf("\n"); + + ghostty_free(NULL, buf, len); + ghostty_formatter_free(formatter); +} + +int main() { + GhosttyTerminal terminal; + GhosttyTerminalOptions opts = { + .cols = 80, + .rows = 8, + .max_scrollback = 0, + }; + GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + // A realistic shell transcript with OSC 133 semantic prompt markers. + // Ghostty uses these markers to distinguish prompt/input from command + // output for semantic line and output selections. + vt_write(terminal, + "\033]133;A\007$ " // Prompt starts: "$ " + "\033]133;B\007git status" // Input starts: "git status" + "\033]133;C\007\r\n" // Output starts after Enter + "On branch main\r\n" + "nothing to commit, working tree clean"); + + GhosttySelection selection = GHOSTTY_INIT_SIZED(GhosttySelection); + + // Double-click style word selection under the cursor. + GhosttyTerminalSelectWordOptions word = GHOSTTY_INIT_SIZED(GhosttyTerminalSelectWordOptions); + word.ref = ref_at(terminal, 6, 0); // the "status" in "git status" + result = ghostty_terminal_select_word(terminal, &word, &selection); + assert(result == GHOSTTY_SUCCESS); + print_selection(terminal, "word", &selection); + + // Triple-click style line selection. With semantic prompt boundaries enabled, + // this selects only the input area rather than the leading "$ " prompt. + GhosttyTerminalSelectLineOptions line = GHOSTTY_INIT_SIZED(GhosttyTerminalSelectLineOptions); + line.ref = ref_at(terminal, 2, 0); // the "git status" input area + line.semantic_prompt_boundary = true; + result = ghostty_terminal_select_line(terminal, &line, &selection); + assert(result == GHOSTTY_SUCCESS); + print_selection(terminal, "line", &selection); + + // Select exactly the command output for the command under the cursor. + result = ghostty_terminal_select_output( + terminal, ref_at(terminal, 0, 1), &selection); + assert(result == GHOSTTY_SUCCESS); + print_selection(terminal, "output", &selection); + + // Select all visible content. + result = ghostty_terminal_select_all(terminal, &selection); + assert(result == GHOSTTY_SUCCESS); + print_selection(terminal, "all", &selection); + + ghostty_terminal_free(terminal); + return 0; +} +//! [selection-main] diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 89a722673..654396aef 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -30,6 +30,10 @@ extern "C" { * for the endpoints and reconstruct a GhosttySelection from fresh snapshots * when needed. * + * ## Examples + * + * @snippet c-vt-selection/src/main.c selection-main + * * @{ */ From eb777b8036d8c457ee181eab136858d1ca86aa88 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 May 2026 13:51:43 -0700 Subject: [PATCH 081/137] libghostty: selectWordBetween in C --- example/c-vt-selection/src/main.c | 34 +++++++++++++++++ include/ghostty/vt/selection.h | 61 +++++++++++++++++++++++++++++++ src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/selection.zig | 36 ++++++++++++++++++ src/terminal/c/terminal.zig | 24 ++++++++++++ src/terminal/c/types.zig | 1 + 7 files changed, 158 insertions(+) diff --git a/example/c-vt-selection/src/main.c b/example/c-vt-selection/src/main.c index ea638bbe8..83384ec15 100644 --- a/example/c-vt-selection/src/main.c +++ b/example/c-vt-selection/src/main.c @@ -76,6 +76,40 @@ int main() { assert(result == GHOSTTY_SUCCESS); print_selection(terminal, "word", &selection); + //! [selection-word-between] + // Double-click-and-drag style selection. Suppose the user double-clicks + // "git" and drags to "status". The pointer may pass over whitespace, so + // select the nearest word between the original click and current drag point + // in both directions, then combine the outer word bounds. + GhosttyGridRef click_ref = ref_at(terminal, 2, 0); // the "git" in "git status" + GhosttyGridRef drag_ref = ref_at(terminal, 6, 0); // the "status" in "git status" + + GhosttyTerminalSelectWordBetweenOptions start_word_opts = + GHOSTTY_INIT_SIZED(GhosttyTerminalSelectWordBetweenOptions); + start_word_opts.start = click_ref; + start_word_opts.end = drag_ref; + + GhosttySelection start_word = GHOSTTY_INIT_SIZED(GhosttySelection); + result = ghostty_terminal_select_word_between( + terminal, &start_word_opts, &start_word); + assert(result == GHOSTTY_SUCCESS); + + GhosttyTerminalSelectWordBetweenOptions end_word_opts = + GHOSTTY_INIT_SIZED(GhosttyTerminalSelectWordBetweenOptions); + end_word_opts.start = drag_ref; + end_word_opts.end = click_ref; + + GhosttySelection end_word = GHOSTTY_INIT_SIZED(GhosttySelection); + result = ghostty_terminal_select_word_between( + terminal, &end_word_opts, &end_word); + assert(result == GHOSTTY_SUCCESS); + + GhosttySelection drag_selection = GHOSTTY_INIT_SIZED(GhosttySelection); + drag_selection.start = start_word.start; + drag_selection.end = end_word.end; + print_selection(terminal, "double-click drag", &drag_selection); + //! [selection-word-between] + // Triple-click style line selection. With semantic prompt boundaries enabled, // this selects only the input area rather than the leading "$ " prompt. GhosttyTerminalSelectLineOptions line = GHOSTTY_INIT_SIZED(GhosttyTerminalSelectLineOptions); diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 654396aef..52f1e09c2 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -107,6 +107,33 @@ typedef struct { size_t boundary_codepoints_len; } GhosttyTerminalSelectWordOptions; +/** + * Options for deriving the nearest word selection between two grid references. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * If boundary_codepoints is NULL and boundary_codepoints_len is 0, Ghostty's + * default word-boundary codepoints are used. If boundary_codepoints_len is + * non-zero, boundary_codepoints must not be NULL. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectWordBetweenOptions). */ + size_t size; + + /** Starting grid reference for the inclusive search range. */ + GhosttyGridRef start; + + /** Ending grid reference for the inclusive search range. */ + GhosttyGridRef end; + + /** Optional word-boundary codepoints as uint32_t scalar values. */ + const uint32_t* boundary_codepoints; + + /** Number of entries in boundary_codepoints. */ + size_t boundary_codepoints_len; +} GhosttyTerminalSelectWordBetweenOptions; + /** * Options for deriving a line selection from a terminal grid reference. * @@ -235,6 +262,40 @@ GHOSTTY_API GhosttyResult ghostty_terminal_select_word( const GhosttyTerminalSelectWordOptions* options, GhosttySelection* out_selection); +/** + * Derive the nearest word selection snapshot between two terminal grid refs. + * + * Starting at options->start, this searches toward options->end (inclusive) + * and returns the first selectable word found using Ghostty's word-selection + * rules. + * + * This is useful for implementing double-click-and-drag selection in a UI. If + * a user double-clicks one word and drags across spaces or punctuation toward + * another word, selecting only the word directly under the current pointer can + * flicker or collapse when the pointer is between words. Instead, ask for the + * nearest word between the original click and the drag point, ask again in the + * reverse direction, and combine the two word bounds into the drag selection. + * + * @snippet c-vt-selection/src/main.c selection-word-between + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param options Word-between-selection options + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no + * selectable word content between the valid refs, or + * GHOSTTY_INVALID_VALUE if the terminal, options, refs, codepoint + * pointer, or output pointer are invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_word_between( + GhosttyTerminal terminal, + const GhosttyTerminalSelectWordBetweenOptions* options, + GhosttySelection* out_selection); + /** * Derive a line selection snapshot from a terminal grid reference. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 543c5b447..291bf37f6 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -240,6 +240,7 @@ comptime { @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" }); @export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" }); + @export(&c.terminal_select_word_between, .{ .name = "ghostty_terminal_select_word_between" }); @export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" }); @export(&c.terminal_select_all, .{ .name = "ghostty_terminal_select_all" }); @export(&c.terminal_select_output, .{ .name = "ghostty_terminal_select_output" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 1cd7e0231..3e776a0e4 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -171,6 +171,7 @@ pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; pub const terminal_get_multi = terminal.get_multi; pub const terminal_select_word = selection.word; +pub const terminal_select_word_between = selection.word_between; pub const terminal_select_line = selection.line; pub const terminal_select_all = selection.all; pub const terminal_select_output = selection.output; diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index ea1eea473..6bd8a9bb3 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -43,6 +43,15 @@ pub const SelectWordOptions = extern struct { boundary_codepoints_len: usize = 0, }; +/// C: GhosttyTerminalSelectWordBetweenOptions +pub const SelectWordBetweenOptions = extern struct { + size: usize = @sizeOf(SelectWordBetweenOptions), + start: grid_ref.CGridRef, + end: grid_ref.CGridRef, + boundary_codepoints: ?[*]const u32 = null, + boundary_codepoints_len: usize = 0, +}; + /// C: GhosttyTerminalSelectLineOptions pub const SelectLineOptions = extern struct { size: usize = @sizeOf(SelectLineOptions), @@ -77,6 +86,33 @@ pub fn word( return .success; } +pub fn word_between( + terminal: terminal_c.Terminal, + options: ?*const SelectWordBetweenOptions, + out_selection: ?*CSelection, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const opts = options orelse return .invalid_value; + if (opts.size < @sizeOf(SelectWordBetweenOptions)) return .invalid_value; + const out = out_selection orelse return .invalid_value; + + const boundary_codepoints = codepointSlice( + opts.boundary_codepoints, + opts.boundary_codepoints_len, + ) catch return .invalid_value; + + const screen = t.screens.active; + const start = opts.start.toPin() orelse return .invalid_value; + const end = opts.end.toPin() orelse return .invalid_value; + out.* = .fromZig(screen.selectWordBetween( + start, + end, + boundary_codepoints orelse &selection_codepoints.default_word_boundaries, + ) orelse + return .no_value); + return .success; +} + pub fn line( terminal: terminal_c.Terminal, options: ?*const SelectLineOptions, diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 6c7ce3ce5..c3336129b 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -1438,6 +1438,28 @@ test "selection derivation helpers" { word_opts.ref = empty_ref; try testing.expectEqual(Result.no_value, selection_c.word(t, &word_opts, &out)); + var between_start_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 20, .y = 1 } }, + }, &between_start_ref)); + + var between_end_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 0, .y = 1 } }, + }, &between_end_ref)); + + var word_between_opts: selection_c.SelectWordBetweenOptions = .{ + .start = between_start_ref, + .end = between_end_ref, + }; + try testing.expectEqual(Result.success, selection_c.word_between(t, &word_between_opts, &out)); + try testing.expectEqual(@as(u16, 0), out.start.toPin().?.x); + try testing.expectEqual(@as(u16, 1), out.start.toPin().?.y); + try testing.expectEqual(@as(u16, 4), out.end.toPin().?.x); + try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y); + var line_opts: selection_c.SelectLineOptions = .{ .ref = line_ref, }; @@ -1457,6 +1479,8 @@ test "selection derivation helpers" { try testing.expectEqual(Result.invalid_value, selection_c.line(t, &line_opts, &out)); try testing.expectEqual(Result.invalid_value, selection_c.word(t, null, &out)); try testing.expectEqual(Result.invalid_value, selection_c.word(t, &word_opts, null)); + try testing.expectEqual(Result.invalid_value, selection_c.word_between(t, null, &out)); + try testing.expectEqual(Result.invalid_value, selection_c.word_between(t, &word_between_opts, null)); } test "selection_adjust mutates snapshot end" { diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 8a6a7f927..d9ece57ee 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -31,6 +31,7 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: { .{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) }, .{ "GhosttySelection", StructInfo.init(selection.CSelection) }, .{ "GhosttyTerminalSelectWordOptions", StructInfo.init(selection.SelectWordOptions) }, + .{ "GhosttyTerminalSelectWordBetweenOptions", StructInfo.init(selection.SelectWordBetweenOptions) }, .{ "GhosttyTerminalSelectLineOptions", StructInfo.init(selection.SelectLineOptions) }, .{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) }, .{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) }, From 2ce5db29ca162033e3cc570533184e13f3c01b53 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 May 2026 13:56:54 -0700 Subject: [PATCH 082/137] libghostty: selection formatting --- include/ghostty/vt/formatter.h | 17 --- include/ghostty/vt/selection.h | 110 +++++++++++++++ include/ghostty/vt/types.h | 17 +++ src/lib_vt.zig | 2 + src/terminal/c/main.zig | 2 + src/terminal/c/selection.zig | 245 +++++++++++++++++++++++++++++++++ 6 files changed, 376 insertions(+), 17 deletions(-) diff --git a/include/ghostty/vt/formatter.h b/include/ghostty/vt/formatter.h index 358e95f66..5cdcd11a3 100644 --- a/include/ghostty/vt/formatter.h +++ b/include/ghostty/vt/formatter.h @@ -32,23 +32,6 @@ extern "C" { * @{ */ -/** - * Output format. - * - * @ingroup formatter - */ -typedef enum GHOSTTY_ENUM_TYPED { - /** Plain text (no escape sequences). */ - GHOSTTY_FORMATTER_FORMAT_PLAIN, - - /** VT sequences preserving colors, styles, URLs, etc. */ - GHOSTTY_FORMATTER_FORMAT_VT, - - /** HTML with inline styles. */ - GHOSTTY_FORMATTER_FORMAT_HTML, - GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, -} GhosttyFormatterFormat; - /** * Extra screen state to include in styled output. * diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 52f1e09c2..142877a97 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -10,8 +10,10 @@ #include #include #include +#include #include #include +#include #ifdef __cplusplus extern "C" { @@ -161,6 +163,46 @@ typedef struct { bool semantic_prompt_boundary; } GhosttyTerminalSelectLineOptions; +/** + * Options for one-shot formatting of a terminal selection. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * + * If selection is NULL, the terminal's current active selection is used. + * If selection is non-NULL, that caller-provided snapshot selection is used. + * + * The selection is formatted from the terminal's active screen using the same + * formatting semantics as GhosttyFormatter. For copy/clipboard behavior + * matching Ghostty's Screen.selectionString(), use plain output with unwrap + * and trim both set to true. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectionFormatOptions). */ + size_t size; + + /** Output format to emit. */ + GhosttyFormatterFormat emit; + + /** Whether to unwrap soft-wrapped lines. */ + bool unwrap; + + /** Whether to trim trailing whitespace on non-blank lines. */ + bool trim; + + /** + * Optional selection to format. + * + * If NULL, the terminal's current active selection is used. If the terminal + * has no active selection, formatting returns GHOSTTY_NO_VALUE. + * + * If non-NULL, the pointed-to selection must be a valid snapshot selection + * for this terminal and must obey GhosttySelection lifetime rules. + */ + const GhosttySelection *selection; +} GhosttyTerminalSelectionFormatOptions; + /** * Ordering of a selection's endpoints in terminal coordinates. * @@ -355,6 +397,74 @@ GHOSTTY_API GhosttyResult ghostty_terminal_select_output( GhosttyGridRef ref, GhosttySelection* out_selection); +/** + * Format a terminal selection into a caller-provided buffer. + * + * This is a one-shot convenience API for formatting either the terminal's + * active selection or a caller-provided GhosttySelection without explicitly + * creating a GhosttyFormatter. + * + * Pass NULL for buf to query the required output size. In that case, + * out_written receives the required size and the function returns + * GHOSTTY_OUT_OF_SPACE. + * + * If buf is too small, the function returns GHOSTTY_OUT_OF_SPACE and writes + * the required size to out_written. The caller can then retry with a larger + * buffer. + * + * If options.selection is NULL and the terminal has no active selection, the + * function returns GHOSTTY_NO_VALUE. + * + * @param terminal The terminal to read from (must not be NULL) + * @param options Selection formatting options + * @param buf Output buffer, or NULL to query required size + * @param buf_len Length of buf in bytes + * @param out_written Number of bytes written, or required size on + * GHOSTTY_OUT_OF_SPACE (must not be NULL) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_format_buf( + GhosttyTerminal terminal, + GhosttyTerminalSelectionFormatOptions options, + uint8_t* buf, + size_t buf_len, + size_t* out_written); + +/** + * Format a terminal selection into an allocated buffer. + * + * This is a one-shot convenience API for formatting either the terminal's + * active selection or a caller-provided GhosttySelection without explicitly + * creating a GhosttyFormatter. + * + * The returned buffer is allocated using allocator, or the default allocator + * if NULL is passed. The caller owns the returned buffer and must free it with + * ghostty_free(), passing the same allocator and returned length. + * + * The returned bytes are not NUL-terminated. This supports plain text, VT, and + * HTML uniformly as byte output. + * + * If options.selection is NULL and the terminal has no active selection, the + * function returns GHOSTTY_NO_VALUE and leaves out_ptr as NULL and out_len as 0. + * + * @param terminal The terminal to read from (must not be NULL) + * @param allocator Allocator used for the returned buffer, or NULL for the default allocator + * @param options Selection formatting options + * @param out_ptr Receives the allocated output buffer (must not be NULL) + * @param out_len Receives the output length in bytes (must not be NULL) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_format_alloc( + GhosttyTerminal terminal, + const GhosttyAllocator* allocator, + GhosttyTerminalSelectionFormatOptions options, + uint8_t** out_ptr, + size_t* out_len); + /** * Adjust a selection snapshot using terminal selection semantics. * diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index e8e976207..0e35124c6 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -194,6 +194,23 @@ typedef struct GhosttyOscCommandImpl* GhosttyOscCommand; /* ---- Common value types ---- */ +/** + * Terminal content output format. + * + * @ingroup formatter + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Plain text (no escape sequences). */ + GHOSTTY_FORMATTER_FORMAT_PLAIN, + + /** VT sequences preserving colors, styles, URLs, etc. */ + GHOSTTY_FORMATTER_FORMAT_VT, + + /** HTML with inline styles. */ + GHOSTTY_FORMATTER_FORMAT_HTML, + GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyFormatterFormat; + /** * A borrowed byte string (pointer + length). * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 291bf37f6..71b709135 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -209,6 +209,8 @@ comptime { @export(&c.formatter_format_buf, .{ .name = "ghostty_formatter_format_buf" }); @export(&c.formatter_format_alloc, .{ .name = "ghostty_formatter_format_alloc" }); @export(&c.formatter_free, .{ .name = "ghostty_formatter_free" }); + @export(&c.terminal_selection_format_buf, .{ .name = "ghostty_terminal_selection_format_buf" }); + @export(&c.terminal_selection_format_alloc, .{ .name = "ghostty_terminal_selection_format_alloc" }); @export(&c.render_state_new, .{ .name = "ghostty_render_state_new" }); @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); @export(&c.render_state_get, .{ .name = "ghostty_render_state_get" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 3e776a0e4..1d78f06bb 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -175,6 +175,8 @@ pub const terminal_select_word_between = selection.word_between; pub const terminal_select_line = selection.line; pub const terminal_select_all = selection.all; pub const terminal_select_output = selection.output; +pub const terminal_selection_format_buf = selection.format_buf; +pub const terminal_selection_format_alloc = selection.format_alloc; pub const terminal_selection_adjust = selection.adjust; pub const terminal_selection_order = selection.order; pub const terminal_selection_ordered = selection.ordered; diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index 6bd8a9bb3..cb574ecc2 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -1,6 +1,8 @@ const std = @import("std"); const testing = std.testing; const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const formatterpkg = @import("../formatter.zig"); const grid_ref = @import("grid_ref.zig"); const point = @import("../point.zig"); const selection_codepoints = @import("../selection_codepoints.zig"); @@ -12,6 +14,7 @@ const log = std.log.scoped(.selection_c); pub const Adjustment = Selection.Adjustment; pub const Order = Selection.Order; +pub const Format = formatterpkg.Format; /// C: GhosttySelection pub const CSelection = extern struct { @@ -61,6 +64,15 @@ pub const SelectLineOptions = extern struct { semantic_prompt_boundary: bool = false, }; +/// C: GhosttyTerminalSelectionFormatOptions +pub const FormatOptions = extern struct { + size: usize = @sizeOf(FormatOptions), + emit: Format, + unwrap: bool, + trim: bool, + selection: ?*const CSelection = null, +}; + pub fn word( terminal: terminal_c.Terminal, options: ?*const SelectWordOptions, @@ -163,6 +175,101 @@ pub fn output( return .success; } +pub fn format_buf( + terminal: terminal_c.Terminal, + opts: FormatOptions, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + + if (out_ == null) { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + formatSelection(t, opts, &discarding.writer) catch |err| return switch (err) { + error.InvalidValue => .invalid_value, + error.NoValue => .no_value, + error.WriteFailed => unreachable, + }; + out_written.* = @intCast(discarding.count); + return .out_of_space; + } + + var writer: std.Io.Writer = .fixed(out_.?[0..out_len]); + formatSelection(t, opts, &writer) catch |err| switch (err) { + error.InvalidValue => return .invalid_value, + error.NoValue => return .no_value, + error.WriteFailed => { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + formatSelection(t, opts, &discarding.writer) catch unreachable; + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }; + + out_written.* = writer.end; + return .success; +} + +pub fn format_alloc( + terminal: terminal_c.Terminal, + alloc_: ?*const CAllocator, + opts: FormatOptions, + out_ptr: *?[*]u8, + out_len: *usize, +) callconv(lib.calling_conv) Result { + out_ptr.* = null; + out_len.* = 0; + + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const alloc = lib.alloc.default(alloc_); + + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + + formatSelection(t, opts, &aw.writer) catch |err| return switch (err) { + error.InvalidValue => .invalid_value, + error.NoValue => .no_value, + error.WriteFailed => .out_of_memory, + }; + + const buf = aw.toOwnedSlice() catch return .out_of_memory; + out_ptr.* = buf.ptr; + out_len.* = buf.len; + return .success; +} + +fn formatSelection( + t: *terminal_c.ZigTerminal, + opts: FormatOptions, + writer: *std.Io.Writer, +) error{ InvalidValue, NoValue, WriteFailed }!void { + var formatter = selectionFormatter(t, opts) catch |err| return err; + try formatter.format(writer); +} + +fn selectionFormatter( + t: *terminal_c.ZigTerminal, + opts: FormatOptions, +) error{ InvalidValue, NoValue }!formatterpkg.TerminalFormatter { + if (opts.size < @sizeOf(FormatOptions)) return error.InvalidValue; + _ = std.meta.intToEnum(Format, @intFromEnum(opts.emit)) catch + return error.InvalidValue; + + const sel = if (opts.selection) |sel| + sel.toZig() orelse return error.InvalidValue + else + t.screens.active.selection orelse return error.NoValue; + + var formatter: formatterpkg.TerminalFormatter = .init(t, .{ + .emit = opts.emit, + .unwrap = opts.unwrap, + .trim = opts.trim, + }); + formatter.content = .{ .selection = sel }; + return formatter; +} + /// Return the borrowed C array of `uint32_t` codepoints as a `[]const u21`. /// /// `NULL + len 0` returns null, which callers treat as “use the API default @@ -284,3 +391,141 @@ pub fn equal( out.* = sel_a.eql(sel_b); return .success; } + +test "selection_format_alloc uses active selection" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello World", 11); + + var start_ref: grid_ref.CGridRef = .{}; + try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 6, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref.CGridRef = .{}; + try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 10, .y = 0 } }, + }, &end_ref)); + + const sel: CSelection = .{ + .start = start_ref, + .end = end_ref, + }; + try testing.expectEqual(Result.success, terminal_c.set(t, .selection, @ptrCast(&sel))); + + const opts: FormatOptions = .{ + .emit = .plain, + .unwrap = true, + .trim = true, + }; + + var required: usize = 0; + try testing.expectEqual(Result.out_of_space, format_buf( + t, + opts, + null, + 0, + &required, + )); + try testing.expectEqual(@as(usize, 5), required); + + var out_ptr: ?[*]u8 = null; + var out_len: usize = 0; + try testing.expectEqual(Result.success, format_alloc( + t, + &lib.alloc.test_allocator, + opts, + &out_ptr, + &out_len, + )); + const ptr = out_ptr orelse return error.TestExpectedEqual; + defer lib.alloc.default(&lib.alloc.test_allocator).free(ptr[0..out_len]); + + try testing.expectEqualStrings("World", ptr[0..out_len]); +} + +test "selection_format_buf uses provided selection" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello World", 11); + + var start_ref: grid_ref.CGridRef = .{}; + try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 0, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref.CGridRef = .{}; + try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 4, .y = 0 } }, + }, &end_ref)); + + const sel: CSelection = .{ + .start = start_ref, + .end = end_ref, + }; + const opts: FormatOptions = .{ + .emit = .plain, + .unwrap = true, + .trim = true, + .selection = &sel, + }; + + var small: [2]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.out_of_space, format_buf( + t, + opts, + &small, + small.len, + &written, + )); + try testing.expectEqual(@as(usize, 5), written); + + var buf: [32]u8 = undefined; + try testing.expectEqual(Result.success, format_buf( + t, + opts, + &buf, + buf.len, + &written, + )); + try testing.expectEqualStrings("Hello", buf[0..written]); +} + +test "selection_format_alloc returns no_value without active selection" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + var out_ptr: ?[*]u8 = @ptrFromInt(1); + var out_len: usize = 123; + try testing.expectEqual(Result.no_value, format_alloc( + t, + &lib.alloc.test_allocator, + .{ .emit = .plain, .unwrap = true, .trim = true }, + &out_ptr, + &out_len, + )); + try testing.expect(out_ptr == null); + try testing.expectEqual(@as(usize, 0), out_len); +} From 03df613e392b764d828941cb4f5864c5a75edb83 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 May 2026 14:09:28 -0700 Subject: [PATCH 083/137] libghostty: detach tracked grid refs on free Tracked grid references previously held a raw terminal wrapper pointer and were required to be freed before the terminal. If callers kept one past terminal destruction, later tracked-ref calls could dereference freed terminal or page-list memory before detecting that the reference was no longer meaningful. Track live C tracked-grid-ref handles from the terminal wrapper and detach them before tearing down terminal storage. Detached refs now report no value through the tracked-ref APIs and can still be freed by the caller. Update the C API docs to describe this lifetime behavior and add a regression test for using a tracked ref after terminal free. This introduces some overhead but tracked pins shouldn't be numerous and this dramatically improves safety. --- include/ghostty/vt/grid_ref.h | 9 ++++--- include/ghostty/vt/grid_ref_tracked.h | 13 ++++++--- include/ghostty/vt/terminal.h | 5 ++-- include/ghostty/vt/types.h | 4 ++- src/terminal/c/grid_ref_tracked.zig | 38 +++++++++++++++++++++++++++ src/terminal/c/terminal.zig | 19 ++++++++++++-- 6 files changed, 75 insertions(+), 13 deletions(-) diff --git a/include/ghostty/vt/grid_ref.h b/include/ghostty/vt/grid_ref.h index ca857a499..c43791dc2 100644 --- a/include/ghostty/vt/grid_ref.h +++ b/include/ghostty/vt/grid_ref.h @@ -70,8 +70,9 @@ extern "C" { * the tracked reference between screens. * * Tracked references are owned by the caller and must be freed with - * ghostty_tracked_grid_ref_free() before the terminal that created them is - * freed. + * ghostty_tracked_grid_ref_free(). If the terminal that created a tracked + * reference is freed first, the handle remains valid only for tracked-grid-ref + * APIs: it reports no value and can still be freed. * * Each tracked reference adds bookkeeping to terminal mutations. Use them * sparingly for long-lived anchors such as selections, search state, marks, @@ -85,8 +86,8 @@ extern "C" { * operation (including free). * * A tracked reference is allocated and must be freed when it is no - * longer needed. All tracked references must be freed prior to the - * terminal being freed. + * longer needed. A tracked reference may outlive the terminal that created it; + * after terminal free, it reports no value and can still be freed. * * ## Examples * diff --git a/include/ghostty/vt/grid_ref_tracked.h b/include/ghostty/vt/grid_ref_tracked.h index f80d7fbad..b56aefacd 100644 --- a/include/ghostty/vt/grid_ref_tracked.h +++ b/include/ghostty/vt/grid_ref_tracked.h @@ -27,8 +27,8 @@ extern "C" { /** * Free a tracked grid reference. * - * Passing NULL is allowed and has no effect. The reference must be freed before - * the terminal that created it is freed. + * Passing NULL is allowed and has no effect. A tracked reference may be freed + * after the terminal that created it is freed. * * @param ref Tracked grid reference to free. * @@ -39,6 +39,9 @@ GHOSTTY_API void ghostty_tracked_grid_ref_free(GhosttyTrackedGridRef ref); /** * Return whether a tracked grid reference currently has a meaningful value. * + * If the terminal that created the tracked reference has been freed, this + * returns false. + * * @param ref Tracked grid reference. * @return true if the reference currently has a meaningful value. * @@ -62,7 +65,8 @@ GHOSTTY_API bool ghostty_tracked_grid_ref_has_value( * * If the tracked reference no longer has a meaningful value, this returns * GHOSTTY_NO_VALUE. GHOSTTY_NO_VALUE is also returned when the reference cannot - * be represented in the requested coordinate space. + * be represented in the requested coordinate space, including after the + * terminal that created the tracked reference has been freed. * * @param ref Tracked grid reference. * @param tag Coordinate space to convert into. @@ -114,7 +118,8 @@ GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_set( * ghostty_grid_ref_style(). * * If the tracked reference no longer has a meaningful value, this returns - * GHOSTTY_NO_VALUE. + * GHOSTTY_NO_VALUE. This includes references whose owning terminal has been + * freed. * * @param ref Tracked grid reference. * @param[out] out_ref On success, receives an untracked snapshot. May be NULL. diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 1751aa126..0acd921b7 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1134,8 +1134,9 @@ GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref(GhosttyTerminal terminal, * If the point is outside the requested coordinate space, this returns * GHOSTTY_INVALID_VALUE and writes NULL to out_ref. * - * The returned handle must be freed with ghostty_tracked_grid_ref_free() before - * the terminal is freed. + * The returned handle must be freed with ghostty_tracked_grid_ref_free(). If + * the terminal is freed first, the handle remains valid only for + * tracked-grid-ref APIs: it reports no value and can still be freed. * * @param terminal Terminal instance. * @param point Point to track. diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index e8e976207..502b01679 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -98,7 +98,9 @@ typedef struct GhosttyTerminalImpl* GhosttyTerminal; * Opaque handle to a tracked grid reference. * * A tracked grid reference is owned by the caller and must be freed with - * ghostty_tracked_grid_ref_free() before the terminal that created it is freed. + * ghostty_tracked_grid_ref_free(). If the terminal that created it is freed + * first, the handle remains valid only for tracked-grid-ref APIs: it reports no + * value and can still be freed. * * @ingroup grid_ref */ diff --git a/src/terminal/c/grid_ref_tracked.zig b/src/terminal/c/grid_ref_tracked.zig index 3233bb054..60091715f 100644 --- a/src/terminal/c/grid_ref_tracked.zig +++ b/src/terminal/c/grid_ref_tracked.zig @@ -33,6 +33,9 @@ pub const TrackedGridRef = struct { pub fn tracked_grid_ref_free(ref_: CTrackedGridRef) callconv(lib.calling_conv) void { const ref = ref_ orelse return; + if (ref.terminal) |wrapper| { + _ = wrapper.tracked_grid_refs.swapRemove(ref); + } if (ref.pageList()) |list| list.untrackPin(ref.pin); ref.alloc.destroy(ref); } @@ -185,3 +188,38 @@ test "tracked_grid_ref reports no value after alternate screen reset" { try testing.expect(tracked_grid_ref_has_value(ref)); try testing.expectEqual(Result.success, tracked_grid_ref_snapshot(ref, &snapshot)); } + +test "tracked_grid_ref reports no value after terminal free" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + + terminal_c.vt_write(terminal, "A", 1); + + var ref: CTrackedGridRef = null; + try testing.expectEqual(Result.success, terminal_c.grid_ref_track( + terminal, + point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }), + &ref, + )); + + terminal_c.free(terminal); + try testing.expect(!tracked_grid_ref_has_value(ref)); + + var snapshot: grid_ref_c.CGridRef = undefined; + try testing.expectEqual(Result.no_value, tracked_grid_ref_snapshot(ref, &snapshot)); + + var coord: point.Coordinate = undefined; + try testing.expectEqual(Result.no_value, tracked_grid_ref_point(ref, .active, &coord)); + + try testing.expectEqual(Result.invalid_value, tracked_grid_ref_set( + ref, + terminal, + point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }), + )); + + tracked_grid_ref_free(ref); +} diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 662a2ec03..a38afd485 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -35,6 +35,7 @@ const TerminalWrapper = struct { terminal: *ZigTerminal, stream: Stream, effects: Effects = .{}, + tracked_grid_refs: std.AutoArrayHashMapUnmanaged(*grid_ref_tracked_c.TrackedGridRef, void) = .{}, }; /// C callback state for terminal effects. Trampolines are always @@ -758,6 +759,18 @@ pub fn grid_ref_track( .pin = tracked_pin, }; + // Store the tracked ref in the terminal so that when we free + // the terminal the tracked ref can be detached safely. + wrapper.tracked_grid_refs.putNoClobber( + alloc, + ref, + {}, + ) catch { + list.untrackPin(tracked_pin); + alloc.destroy(ref); + return .out_of_memory; + }; + out.* = ref; return .success; } @@ -779,9 +792,11 @@ pub fn point_from_grid_ref( pub fn free(terminal_: Terminal) callconv(lib.calling_conv) void { const wrapper = terminal_ orelse return; const t = wrapper.terminal; - - wrapper.stream.deinit(); const alloc = t.gpa(); + + for (wrapper.tracked_grid_refs.keys()) |ref| ref.terminal = null; + wrapper.tracked_grid_refs.deinit(alloc); + wrapper.stream.deinit(); t.deinit(alloc); alloc.destroy(t); alloc.destroy(wrapper); From bb375a2f7565b9d8c8b60c6cbc0bd4e6c4532023 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 24 May 2026 20:35:39 -0500 Subject: [PATCH 084/137] deal with large outputs from xdg-open/rundll32/open Depending on your system config, `xdg-open` may stay open for extended periods, and potentially log more than the 50kb of output that we were previously able to deal with. This changes `open()` so that output on `stdout` is just directly ignored. Any output from `stderr` is immedialy logged rather than collected for later logging. Note that this will generally occur if your system is not configured with the DBus portals that `xdg-open` uses to open URLs rather than launching programs like your web browser directly. This could be seen as user misconfiguration but we should deal with it robustly anyway. --- src/os/open.zig | 68 ++++++++++++++++++++++++------------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/src/os/open.zig b/src/os/open.zig index 0cead5552..86c3d76fd 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -9,13 +9,11 @@ const log = std.log.scoped(.@"os-open"); /// Open a URL in the default handling application. /// /// Any output on stderr is logged as a warning in the application logs. -/// Output on stdout is ignored. The allocator is used to buffer the -/// log output and may allocate from another thread. +/// Output on stdout is ignored. /// -/// This function is purposely simple for the sake of providing -/// some portable way to open URLs. If you are implementing an -/// apprt for Ghostty, you should consider doing something special-cased -/// for your platform. +/// This function is purposely simple for the sake of providing some portable +/// way to open URLs. If you are implementing an apprt for Ghostty, you should +/// consider doing something special-cased for your platform. pub fn open( alloc: Allocator, kind: apprt.action.OpenUrl.Kind, @@ -44,14 +42,16 @@ pub fn open( else => @compileError("unsupported OS"), }; - // Pipe stdout/stderr so we can collect output from the command. - // This must be set before spawning the process. - exe.stdout_behavior = .Pipe; + // Ignore anything from stdout. This must be set before spawning the + // process. + exe.stdout_behavior = .Ignore; + // Pipe stderr so we can log the stderr from the command. This must be set + // before spawning the process. exe.stderr_behavior = .Pipe; // In the snap on Linux the launcher exports LD_LIBRARY_PATH pointing at - // the snap's bundled libraries. Leaking this into child process can - // can be problematic, so let's drop it from the env + // the snap's bundled libraries. Leaking this into child process can can be + // problematic, so let's drop it from the env var snap_env: std.process.EnvMap = if (comptime build_config.snap) blk: { var env = try std.process.getEnvMap(alloc); env.remove("LD_LIBRARY_PATH"); @@ -64,34 +64,34 @@ pub fn open( // quickly. try exe.spawn(); - // Create a thread that handles collecting output and reaping - // the process. This is done in a separate thread because SOME - // open implementations block and some do not. It's easier to just - // spawn a thread to handle this so that we never block. - const thread = try std.Thread.spawn(.{}, openThread, .{ alloc, exe }); + // Create a thread that handles collecting output and reaping the process. + // This is done in a separate thread because SOME open implementations block + // and some do not. It's easier to just spawn a thread to handle this so + // that we never block. + const thread = try std.Thread.spawn(.{}, openThread, .{exe}); thread.detach(); } -fn openThread(alloc: Allocator, exe_: std.process.Child) !void { - // 50 KiB is the default value used by std.process.Child.run and should - // be enough to get the output we care about. - const output_max_size = 50 * 1024; - - var stdout: std.ArrayListUnmanaged(u8) = .{}; - var stderr: std.ArrayListUnmanaged(u8) = .{}; - defer { - stdout.deinit(alloc); - stderr.deinit(alloc); - } - +fn openThread(exe_: std.process.Child) void { // Copy the exe so it is non-const. This is necessary because wait() // requires a mutable reference and we can't have one as a thread // param. var exe = exe_; - try exe.collectOutput(alloc, &stdout, &stderr, output_max_size); - _ = try exe.wait(); - - // If we have any stderr output we log it. This makes it easier for - // users to debug why some open commands may not work as expected. - if (stderr.items.len > 0) log.warn("wait stderr={s}", .{stderr.items}); + if (exe.stderr) |stderr| { + var buffer: [256]u8 = undefined; + var stream = stderr.readerStreaming(&buffer); + const reader = &stream.interface; + while (true) { + const line = reader.takeDelimiterExclusive('\n') catch |outer| switch (outer) { + error.EndOfStream => break, + error.ReadFailed => break, + error.StreamTooLong => reader.take(buffer.len) catch |inner| switch (inner) { + error.ReadFailed => break, + error.EndOfStream => break, + }, + }; + log.warn("open stderr={s}", .{line}); + } + } + _ = exe.wait() catch {}; } From edf2da015705db22fffdcab62a0871c898fa064b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 May 2026 20:22:30 -0700 Subject: [PATCH 085/137] libghostty: expose per-cell selection state Render-state rows already expose their selected range, but cell-oriented C API consumers had to fetch that row range separately and duplicate the containment check while rendering. Add a SELECTED row-cells data kind that carries the row selection into the row-cells wrapper and returns whether the current cell column is in that inclusive range. The field remains separate from cell colors and style so selection stays an explicit render overlay policy. For performance reasons, the span-based row getter is recommended still but this is a convenient thing to do for cell-oriented folks. --- include/ghostty/vt/render.h | 10 ++++ src/terminal/c/render.zig | 92 +++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index f1f201c44..dff101dcc 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -597,6 +597,16 @@ typedef enum GHOSTTY_ENUM_TYPED { * color, in which case the caller should use whatever default foreground * color it wants (e.g. the terminal foreground). */ GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR = 6, + + /** Whether the cell is contained within the current selection (bool). + * This returns true when the cell's column is within the current row's + * row-local selection range, and false otherwise. Rendering policy for + * selected cells (colors, inversion, etc.) is left to the caller. + * + * Renderers that can draw cells in spans may be more efficient querying + * GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION once per row and applying that + * range directly, avoiding one C API call per cell for selection state. */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_SELECTED = 7, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateRowCellsData; diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index f8b48353f..a33307151 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -45,6 +45,7 @@ const RowCellsWrapper = struct { raws: []const page.Cell, graphemes: []const []const u21, styles: []const Style, + selection: ?[2]size.CellCountInt, /// The color palette, needed to resolve palette-indexed background colors. palette: *const colorpkg.Palette, @@ -427,6 +428,7 @@ pub fn row_cells_new( .raws = undefined, .graphemes = undefined, .styles = undefined, + .selection = undefined, .palette = undefined, }; result.* = ptr; @@ -463,6 +465,7 @@ pub const RowCellsData = enum(c_int) { graphemes_buf = 4, bg_color = 5, fg_color = 6, + selected = 7, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: RowCellsData) type { @@ -473,6 +476,7 @@ pub const RowCellsData = enum(c_int) { .graphemes_len => u32, .graphemes_buf => u32, .bg_color, .fg_color => colorpkg.RGB.C, + .selected => bool, }; } }; @@ -563,6 +567,10 @@ fn rowCellsGetTyped( const fg = s.fg(.{ .default = .{}, .palette = cells.palette }); out.* = fg.cval(); }, + .selected => out.* = if (cells.selection) |sel| + x >= sel[0] and x <= sel[1] + else + false, } return .success; @@ -663,6 +671,7 @@ fn rowGetTyped( .raws = cell_data.items(.raw), .graphemes = cell_data.items(.grapheme), .styles = cell_data.items(.style), + .selection = it.selection[y], .palette = it.palette, }; }, @@ -1101,6 +1110,89 @@ test "render: row get selection" { try testing.expectEqual(Result.no_value, row_get(it, .selection, @ptrCast(&sel))); } +test "render: row cells get selected" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 10, + .rows = 3, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + const t = terminal.?.terminal; + const screen = t.screens.active; + try screen.select(.init( + screen.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?, + screen.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, + false, + )); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + + try testing.expect(row_iterator_next(it)); + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + + var selected: bool = true; + try testing.expectEqual(Result.success, row_cells_select(cells, 0)); + try testing.expectEqual(Result.success, row_cells_get(cells, .selected, @ptrCast(&selected))); + try testing.expect(!selected); + + try testing.expect(row_iterator_next(it)); + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + + try testing.expectEqual(Result.success, row_cells_select(cells, 1)); + try testing.expectEqual(Result.success, row_cells_get(cells, .selected, @ptrCast(&selected))); + try testing.expect(!selected); + + try testing.expectEqual(Result.success, row_cells_select(cells, 2)); + try testing.expectEqual(Result.success, row_cells_get(cells, .selected, @ptrCast(&selected))); + try testing.expect(selected); + + try testing.expectEqual(Result.success, row_cells_select(cells, 4)); + try testing.expectEqual(Result.success, row_cells_get(cells, .selected, @ptrCast(&selected))); + try testing.expect(selected); + + try testing.expectEqual(Result.success, row_cells_select(cells, 5)); + try testing.expectEqual(Result.success, row_cells_get(cells, .selected, @ptrCast(&selected))); + try testing.expect(!selected); + + try testing.expectEqual(Result.success, row_cells_select(cells, 3)); + selected = false; + var written: usize = 0; + const keys = [_]RowCellsData{.selected}; + var values = [_]?*anyopaque{@ptrCast(&selected)}; + try testing.expectEqual(Result.success, row_cells_get_multi(cells, keys.len, &keys, &values, &written)); + try testing.expectEqual(keys.len, written); + try testing.expect(selected); +} + test "render: row iterator next" { var terminal: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( From ae52f97dcac558735cfa916ea3965f247e5c6e9e Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 13:31:49 +0000 Subject: [PATCH 086/137] Update VOUCHED list (#12809) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/12807#issuecomment-4534655288) from @pluiedev. Denounce: @eric-assetpass Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 77e569f4f..af393cba9 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -95,6 +95,7 @@ elias8 -enkr1 enzowilliam ephemera +-eric-assetpass Try talking, not botting eriksremess erral -f1813483-netizen From a5550a2dcb8c81d0e6a53391e68d7887c0078147 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 25 May 2026 11:29:00 -0400 Subject: [PATCH 087/137] cli: fix readEntries leak and double-free readEntries had two memory bugs on the allocation failure path, both only reachable under OOM: - The map itself was never freed if we ran into an allocation failure - The unconditional `errdefer`s for the dupe'd hostname and terminfo values could double-free if there was a later allocation failure. This change restructures this function so that these values are dupe'd up-front, and then their ownership is tracked using optionals that can be null'ed out once their ownership is transferred into the map. Both of these cases are now covered by unit tests. --- src/cli/ssh-cache/DiskCache.zig | 117 ++++++++++++++++++++++++++++---- 1 file changed, 105 insertions(+), 12 deletions(-) diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index 6fa74b43d..d9232bea8 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -313,26 +313,35 @@ fn readEntries( defer alloc.free(content); var entries = std.StringHashMap(Entry).init(alloc); + errdefer deinitEntries(alloc, &entries); + var lines = std.mem.tokenizeScalar(u8, content, '\n'); while (lines.next()) |line| { const trimmed = std.mem.trim(u8, line, " \t\r"); const entry = Entry.parse(trimmed) orelse continue; - // Always allocate hostname first to avoid key pointer confusion - const hostname = try alloc.dupe(u8, entry.hostname); - errdefer alloc.free(hostname); + // Dupe both strings up front, before inserting, so the map never + // holds a half-built entry (a borrowed key or a freed/undefined + // value) for `deinitEntries` to walk if an allocation fails. + var hostname: ?[]u8 = try alloc.dupe(u8, entry.hostname); + errdefer if (hostname) |h| alloc.free(h); + var terminfo: ?[]u8 = try alloc.dupe(u8, entry.terminfo_version); + errdefer if (terminfo) |t| alloc.free(t); - const gop = try entries.getOrPut(hostname); + const gop = try entries.getOrPut(hostname.?); if (!gop.found_existing) { - const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); + // New entry: transfer both copies to the map. gop.value_ptr.* = .{ - .hostname = hostname, + .hostname = hostname.?, .timestamp = entry.timestamp, - .terminfo_version = terminfo_copy, + .terminfo_version = terminfo.?, }; + hostname = null; + terminfo = null; } else { - // Don't need the copy since entry already exists - alloc.free(hostname); + // Duplicate key: the map keeps its existing key, so free ours. + alloc.free(hostname.?); + hostname = null; // Handle duplicate entries - keep newer timestamp if (entry.timestamp > gop.value_ptr.timestamp) { @@ -340,13 +349,15 @@ fn readEntries( if (!std.mem.eql( u8, gop.value_ptr.terminfo_version, - entry.terminfo_version, + terminfo.?, )) { alloc.free(gop.value_ptr.terminfo_version); - const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); - gop.value_ptr.terminfo_version = terminfo_copy; + gop.value_ptr.terminfo_version = terminfo.?; + terminfo = null; } } + if (terminfo) |t| alloc.free(t); + terminfo = null; } } @@ -507,6 +518,88 @@ test "disk cache cleans up temp files" { try testing.expectEqual(1, count); } +test "disk cache reads duplicate keys" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + // Exercise readEntries' found_existing branch: replace the existing + // key with the updated entry and ensure (via testing.allocator) that + // we don't double-free or leak. + { + var file = try tmp.dir.createFile("cache", .{}); + defer file.close(); + var buf: [256]u8 = undefined; + var file_writer = file.writer(&buf); + try file_writer.interface.writeAll( + "example.com|100|xterm-ghostty\nexample.com|200|xterm-newer\n", + ); + try file_writer.interface.flush(); + } + const path = try tmp.dir.realpathAlloc(alloc, "cache"); + defer alloc.free(path); + + const cache: DiskCache = .{ .path = path }; + var entries = try cache.list(alloc); + defer deinitEntries(alloc, &entries); + + try testing.expectEqual(@as(u32, 1), entries.count()); + const entry = entries.get("example.com").?; + try testing.expectEqual(@as(i64, 200), entry.timestamp); + try testing.expectEqualStrings("xterm-newer", entry.terminfo_version); +} + +test "disk cache reads survive allocation failure" { + const testing = std.testing; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + // Exercise a populated cache containing a duplicate key to ensure + // that we hit all of the possible allocation behaviors below. + { + var file = try tmp.dir.createFile("cache", .{}); + defer file.close(); + var buf: [256]u8 = undefined; + var file_writer = file.writer(&buf); + try file_writer.interface.writeAll( + "a.com|100|xterm-ghostty\n" ++ + "b.com|100|xterm-ghostty\n" ++ + "c.com|100|xterm-ghostty\n" ++ + "a.com|200|xterm-newer\n", + ); + try file_writer.interface.flush(); + } + const path = try tmp.dir.realpathAlloc(testing.allocator, "cache"); + defer testing.allocator.free(path); + + const cache: DiskCache = .{ .path = path }; + + // Fail the Nth allocation for every N until the read completes. The + // FailingAllocator is backed by testing.allocator so we also ensure + // that we don't double-free or leak; this can only completely succeed + // or fail with OutOfMemory. + var fail_index: usize = 0; + while (true) : (fail_index += 1) { + var failing = std.testing.FailingAllocator.init( + testing.allocator, + .{ .fail_index = fail_index }, + ); + const alloc = failing.allocator(); + + if (cache.list(alloc)) |entries_const| { + var entries = entries_const; + deinitEntries(alloc, &entries); + // Reached a run with no induced failure: every path covered. + if (!failing.has_induced_failure) break; + } else |err| { + try testing.expectEqual(error.OutOfMemory, err); + } + } +} + test isValidHost { const testing = std.testing; From 16d7c8f2b42890c09590e8c9a00ec34504d449b5 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 25 May 2026 11:56:37 -0400 Subject: [PATCH 088/137] elvish: remove community maintenance note The Elvish integration is currently actively maintained by the Ghostty maintainers. Contributions are of course still welcome. --- src/shell-integration/README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 590632da7..07b016e6d 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -61,12 +61,6 @@ if (eq $E:TERM "xterm-ghostty") { } ``` -The [Elvish](https://elv.sh) shell integration is supported by -the community and is not officially supported by Ghostty. We distribute -it for ease of access and use but do not provide support for it. -If you experience issues with the Elvish shell integration, I welcome -any contributions to fix them. Thank you! - ### Fish For [Fish](https://fishshell.com/), Ghostty prepends to the From 2d0fb81751def478e2f8a5f7e2ee91fa9cbf9bff Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 17:54:35 +0000 Subject: [PATCH 089/137] Update VOUCHED list (#12813) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12793#discussioncomment-17052752) from @bo2themax. Vouch: @LePips Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index af393cba9..302e9809e 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -157,6 +157,7 @@ kristofersoler kylesower laxystem lebdron +lepips liby linustalacko lonsagisawa From ac69942cdcc2d7c26bc5fe7ed1d5d505c3461e0c Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 25 May 2026 14:53:24 -0400 Subject: [PATCH 090/137] cli: rework +ssh-cache internals and user interface This change primarily focused on a revised +ssh-cache user interface, but it also reworks a bunch of the internals. The primary CLI improvement is support for positional arguments and a consistent list output format that includes both the ISO-formatted timestamp and relative age. ghostty +ssh-cache # List all cached destinations ghostty +ssh-cache user@example.com # Show that destination ghostty +ssh-cache example.com # Show all users on that host ghostty +ssh-cache --add=user@example.com # Manually add a destination ghostty +ssh-cache --remove=user@example.com # Remove a destination ghostty +ssh-cache --prune=30d # Remove entries older than 30 days ghostty +ssh-cache --clear # Clear entire cache Notable, we now support a --prune operation that replaces the previous --expire-days flag that was never actually hooked up to anything (!!). --prune also supports a wider range of Duration-based values. We're also much more consistent with error codes: 0=success, 1=failure, 2=usage. While working on those changes, I also reworked the cache internals, particularly the code around timestamp handling and errors. For example, I dropped the explicit error sets because they were growing unwieldy, and in practice we only matched on a subset of those errors. Lastly, overall test coverage should be much improved, especially around the time- and allocation-related operations. --- src/cli/ssh-cache/DiskCache.zig | 295 +++++++++++++-------- src/cli/ssh-cache/Entry.zig | 55 ---- src/cli/ssh.zig | 2 +- src/cli/ssh_cache.zig | 456 +++++++++++++++++++++++++------- 4 files changed, 544 insertions(+), 264 deletions(-) diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index d9232bea8..bb11e74fb 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -17,14 +17,6 @@ const MAX_CACHE_SIZE = 512 * 1024; /// Path to a file where the cache is stored. path: []const u8, -pub const DefaultPathError = Allocator.Error || error{ - /// The general error that is returned for any filesystem error - /// that may have resulted in the XDG lookup failing. - XdgLookupFailed, -}; - -pub const Error = error{ CacheIsLocked, HostnameIsInvalid }; - /// Returns the default path for the cache for a given program. /// /// On all platforms, this is `${XDG_STATE_HOME}/ghostty/ssh_cache`. @@ -33,7 +25,7 @@ pub const Error = error{ CacheIsLocked, HostnameIsInvalid }; pub fn defaultPath( alloc: Allocator, program: []const u8, -) DefaultPathError![]const u8 { +) ![]const u8 { const state_dir: []const u8 = xdg.state( alloc, .{ .subdir = program }, @@ -55,27 +47,15 @@ pub fn clear(self: DiskCache) !void { }; } -pub const AddResult = enum { added, updated }; - -pub const AddError = std.fs.Dir.MakeError || - std.fs.Dir.StatFileError || - std.fs.File.OpenError || - std.fs.File.ChmodError || - std.io.Reader.LimitedAllocError || - FixupPermissionsError || - ReadEntriesError || - WriteCacheFileError || - Error; - -/// Add or update a hostname entry in the cache. -/// Returns AddResult.added for new entries or AddResult.updated for existing ones. +/// Add or update an entry in the cache, recording `timestamp` (Unix seconds). /// The cache file is created if it doesn't exist with secure permissions (0600). pub fn add( self: DiskCache, alloc: Allocator, - hostname: []const u8, -) AddError!AddResult { - if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + key: []const u8, + timestamp: i64, +) !void { + if (!isValidCacheKey(key)) return error.InvalidCacheKey; // Create cache directory if needed if (std.fs.path.dirname(self.path)) |dir| { @@ -107,58 +87,49 @@ pub fn add( // Lock // Causes a compile failure in the Zig std library on Windows, see: // https://github.com/ziglang/zig/issues/18430 - if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked; + if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked; defer if (comptime builtin.os.tag != .windows) file.unlock(); var entries = try readEntries(alloc, file); defer deinitEntries(alloc, &entries); - // Add or update entry - const gop = try entries.getOrPut(hostname); - const result: AddResult = if (!gop.found_existing) add: { - const hostname_copy = try alloc.dupe(u8, hostname); - errdefer alloc.free(hostname_copy); + // Update the timestamp of an existing entry, or insert a new one. For a + // new entry, dupe both strings up front so a failed allocation never + // leaves a half-built slot (borrowed key, undefined value) for the + // `deinitEntries` defer to walk. + if (entries.getPtr(key)) |existing| { + existing.timestamp = timestamp; + } else { + const key_copy = try alloc.dupe(u8, key); + errdefer alloc.free(key_copy); const terminfo_copy = try alloc.dupe(u8, "xterm-ghostty"); errdefer alloc.free(terminfo_copy); - gop.key_ptr.* = hostname_copy; - gop.value_ptr.* = .{ - .hostname = gop.key_ptr.*, - .timestamp = std.time.timestamp(), + try entries.put(key_copy, .{ + .hostname = key_copy, + .timestamp = timestamp, .terminfo_version = terminfo_copy, - }; - break :add .added; - } else update: { - // Update timestamp for existing entry - gop.value_ptr.timestamp = std.time.timestamp(); - break :update .updated; - }; + }); + } - try self.writeCacheFile(entries, null); - return result; + try self.writeCacheFile(entries); } -pub const RemoveError = std.fs.File.OpenError || - FixupPermissionsError || - ReadEntriesError || - WriteCacheFileError || - Error; - -/// Remove a hostname entry from the cache. -/// No error is returned if the hostname doesn't exist or the cache file is missing. +/// Remove an entry from the cache. Returns true if an entry was removed, +/// false if the key wasn't present (or the cache file is missing). pub fn remove( self: DiskCache, alloc: Allocator, - hostname: []const u8, -) RemoveError!void { - if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + key: []const u8, +) !bool { + if (!isValidCacheKey(key)) return error.InvalidCacheKey; // Open our file const file = std.fs.openFileAbsolute( self.path, .{ .mode = .read_write }, ) catch |err| switch (err) { - error.FileNotFound => return, + error.FileNotFound => return false, else => return err, }; defer file.close(); @@ -167,7 +138,7 @@ pub fn remove( // Lock // Causes a compile failure in the Zig std library on Windows, see: // https://github.com/ziglang/zig/issues/18430 - if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked; + if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked; defer if (comptime builtin.os.tag != .windows) file.unlock(); // Read existing entries @@ -175,27 +146,73 @@ pub fn remove( defer deinitEntries(alloc, &entries); // Remove the entry if it exists and ensure we free the memory - if (entries.fetchRemove(hostname)) |kv| { + const removed = if (entries.fetchRemove(key)) |kv| removed: { + assert(kv.key.ptr == kv.value.hostname.ptr); + alloc.free(kv.value.hostname); + alloc.free(kv.value.terminfo_version); + break :removed true; + } else false; + + try self.writeCacheFile(entries); + return removed; +} + +/// Remove all entries older than `max_age_s` seconds and return how many +/// were pruned. Returns zero (and nothing written) if the cache file is +/// missing. +pub fn prune( + self: DiskCache, + alloc: Allocator, + max_age_s: u64, +) !usize { + const file = std.fs.openFileAbsolute( + self.path, + .{ .mode = .read_write }, + ) catch |err| switch (err) { + error.FileNotFound => return 0, + else => return err, + }; + defer file.close(); + try fixupPermissions(file); + + // Lock + // Causes a compile failure in the Zig std library on Windows, see: + // https://github.com/ziglang/zig/issues/18430 + if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked; + defer if (comptime builtin.os.tag != .windows) file.unlock(); + + // Read existing entries + var entries = try readEntries(alloc, file); + defer deinitEntries(alloc, &entries); + + // Drop expired entries from the map, then persist what remains. + const now = std.time.timestamp(); + var expired: std.ArrayList([]const u8) = .empty; + defer expired.deinit(alloc); + var iter = entries.iterator(); + while (iter.next()) |kv| { + const age_s = now -| kv.value_ptr.timestamp; + if (age_s > max_age_s) try expired.append(alloc, kv.key_ptr.*); + } + for (expired.items) |key| { + const kv = entries.fetchRemove(key).?; assert(kv.key.ptr == kv.value.hostname.ptr); alloc.free(kv.value.hostname); alloc.free(kv.value.terminfo_version); } - try self.writeCacheFile(entries, null); + try self.writeCacheFile(entries); + return expired.items.len; } -pub const ContainsError = std.fs.File.OpenError || - ReadEntriesError || - error{HostnameIsInvalid}; - -/// Check if a hostname exists in the cache. +/// Check if a key exists in the cache. /// Returns false if the cache file doesn't exist. pub fn contains( self: DiskCache, alloc: Allocator, - hostname: []const u8, -) ContainsError!bool { - if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + key: []const u8, +) !bool { + if (!isValidCacheKey(key)) return error.InvalidCacheKey; // Open our file const file = std.fs.openFileAbsolute( @@ -211,12 +228,10 @@ pub fn contains( var entries = try readEntries(alloc, file); defer deinitEntries(alloc, &entries); - return entries.contains(hostname); + return entries.contains(key); } -pub const FixupPermissionsError = (std.fs.File.StatError || std.fs.File.ChmodError); - -fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void { +fn fixupPermissions(file: std.fs.File) !void { // Windows does not support chmod if (comptime builtin.os.tag == .windows) return; @@ -228,18 +243,10 @@ fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void { } } -pub const WriteCacheFileError = std.fs.Dir.OpenError || - std.fs.AtomicFile.InitError || - std.fs.AtomicFile.FlushError || - std.fs.AtomicFile.FinishError || - Entry.FormatError || - error{InvalidCachePath}; - fn writeCacheFile( self: DiskCache, entries: std.StringHashMap(Entry), - expire_days: ?u32, -) WriteCacheFileError!void { +) !void { const cache_dir = std.fs.path.dirname(self.path) orelse return error.InvalidCachePath; const cache_basename = std.fs.path.basename(self.path); @@ -255,8 +262,6 @@ fn writeCacheFile( var iter = entries.iterator(); while (iter.next()) |kv| { - // Only write non-expired entries - if (kv.value_ptr.isExpired(expire_days)) continue; try kv.value_ptr.format(&atomic_file.file_writer.interface); } @@ -299,12 +304,10 @@ pub fn deinitEntries( entries.deinit(); } -pub const ReadEntriesError = std.mem.Allocator.Error || std.io.Reader.LimitedAllocError; - fn readEntries( alloc: Allocator, file: std.fs.File, -) ReadEntriesError!std.StringHashMap(Entry) { +) !std.StringHashMap(Entry) { var reader = file.reader(&.{}); const content = try reader.interface.allocRemaining( alloc, @@ -365,7 +368,7 @@ fn readEntries( } // Supports both standalone hostnames and user@hostname format -fn isValidCacheKey(key: []const u8) bool { +pub fn isValidCacheKey(key: []const u8) bool { if (key.len == 0) return false; // Check for user@hostname format @@ -463,33 +466,23 @@ test "disk cache operations" { const path = try tmp.dir.realpathAlloc(alloc, "cache"); defer alloc.free(path); - // Setup our cache + // Setup our cache. Adding the same key twice exercises both the new + // and existing-entry paths. const cache: DiskCache = .{ .path = path }; - try testing.expectEqual( - AddResult.added, - try cache.add(alloc, "example.com"), - ); - try testing.expectEqual( - AddResult.updated, - try cache.add(alloc, "example.com"), - ); - try testing.expect( - try cache.contains(alloc, "example.com"), - ); + try cache.add(alloc, "example.com", std.time.timestamp()); + try cache.add(alloc, "example.com", std.time.timestamp()); + try testing.expect(try cache.contains(alloc, "example.com")); // List var entries = try cache.list(alloc); deinitEntries(alloc, &entries); - // Remove - try cache.remove(alloc, "example.com"); - try testing.expect( - !(try cache.contains(alloc, "example.com")), - ); - try testing.expectEqual( - AddResult.added, - try cache.add(alloc, "example.com"), - ); + // Remove reports that it removed the entry, and a second remove of the + // same key reports nothing to remove. + try testing.expect(try cache.remove(alloc, "example.com")); + try testing.expect(!try cache.remove(alloc, "example.com")); + try testing.expect(!(try cache.contains(alloc, "example.com"))); + try cache.add(alloc, "example.com", std.time.timestamp()); } test "disk cache cleans up temp files" { @@ -505,8 +498,8 @@ test "disk cache cleans up temp files" { defer alloc.free(cache_path); const cache: DiskCache = .{ .path = cache_path }; - try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.com")); - try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.org")); + try cache.add(alloc, "example.com", std.time.timestamp()); + try cache.add(alloc, "example.org", std.time.timestamp()); // Verify only the cache file exists and no temp files left behind var count: usize = 0; @@ -518,6 +511,55 @@ test "disk cache cleans up temp files" { try testing.expectEqual(1, count); } +test "disk cache prune" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + defer alloc.free(tmp_path); + const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" }); + defer alloc.free(cache_path); + + const cache: DiskCache = .{ .path = cache_path }; + + // Back-date one entry an hour old and one 100 days old. + const day = std.time.s_per_day; + const hour = std.time.s_per_hour; + const now = std.time.timestamp(); + try cache.add(alloc, "recent.com", now - hour); + try cache.add(alloc, "old.com", now - 100 * day); + + // Prune entries older than 90 days: only old.com goes. + try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 90 * day)); + try testing.expect(try cache.contains(alloc, "recent.com")); + try testing.expect(!try cache.contains(alloc, "old.com")); + + // Pruning again removes nothing. + try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 90 * day)); + + // Sub-day granularity: a 30-minute max age prunes the hour-old entry. + try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 30 * std.time.s_per_min)); + try testing.expect(!try cache.contains(alloc, "recent.com")); +} + +test "disk cache prune missing file" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + defer alloc.free(tmp_path); + const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" }); + defer alloc.free(cache_path); + + const cache: DiskCache = .{ .path = cache_path }; + try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 30)); +} + test "disk cache reads duplicate keys" { const testing = std.testing; const alloc = testing.allocator; @@ -600,6 +642,39 @@ test "disk cache reads survive allocation failure" { } } +test "disk cache add survives allocation failure" { + const testing = std.testing; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const tmp_path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(tmp_path); + const path = try std.fs.path.join(testing.allocator, &.{ tmp_path, "cache" }); + defer testing.allocator.free(path); + + const cache: DiskCache = .{ .path = path }; + + // Fail the Nth allocation for every N until add completes. A failed add + // must not leak or leave a half-built map entry. The FailingAllocator + // is backed by testing.allocator to catch either. Each iteration starts + // from a clean cache file. + var fail_index: usize = 0; + while (true) : (fail_index += 1) { + std.fs.cwd().deleteFile(path) catch {}; + var failing = std.testing.FailingAllocator.init( + testing.allocator, + .{ .fail_index = fail_index }, + ); + const alloc = failing.allocator(); + + if (cache.add(alloc, "user@example.com", 100)) |_| { + if (!failing.has_induced_failure) break; + } else |err| { + try testing.expectEqual(error.OutOfMemory, err); + } + } +} + test isValidHost { const testing = std.testing; diff --git a/src/cli/ssh-cache/Entry.zig b/src/cli/ssh-cache/Entry.zig index b586161f2..158694f9a 100644 --- a/src/cli/ssh-cache/Entry.zig +++ b/src/cli/ssh-cache/Entry.zig @@ -42,61 +42,6 @@ pub fn format(self: Entry, writer: *std.Io.Writer) FormatError!void { ); } -pub fn isExpired(self: Entry, expire_days_: ?u32) bool { - const expire_days = expire_days_ orelse return false; - const now = std.time.timestamp(); - const age_days = @divTrunc(now -| self.timestamp, std.time.s_per_day); - return age_days > expire_days; -} - -test "cache entry expiration" { - const testing = std.testing; - const now = std.time.timestamp(); - - const fresh_entry: Entry = .{ - .hostname = "test.com", - .timestamp = now - std.time.s_per_day, // 1 day old - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(!fresh_entry.isExpired(90)); - - const old_entry: Entry = .{ - .hostname = "old.com", - .timestamp = now - (std.time.s_per_day * 100), // 100 days old - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(old_entry.isExpired(90)); - - // Test never-expire case - try testing.expect(!old_entry.isExpired(null)); -} - -test "cache entry expiration exact boundary" { - const testing = std.testing; - const now = std.time.timestamp(); - - // Exactly at expiration boundary - const boundary_entry: Entry = .{ - .hostname = "example.com", - .timestamp = now - (std.time.s_per_day * 30), - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(!boundary_entry.isExpired(30)); - try testing.expect(boundary_entry.isExpired(29)); -} - -test "cache entry expiration large timestamp" { - const testing = std.testing; - const now = std.time.timestamp(); - - const boundary_entry: Entry = .{ - .hostname = "example.com", - .timestamp = now + (std.time.s_per_day * 30), - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(!boundary_entry.isExpired(30)); -} - test "cache entry parsing valid formats" { const testing = std.testing; diff --git a/src/cli/ssh.zig b/src/cli/ssh.zig index 7f808a6cd..76bfb10ee 100644 --- a/src/cli/ssh.zig +++ b/src/cli/ssh.zig @@ -302,7 +302,7 @@ fn runInner( // Attempt to cache (if needed) on a successful ssh execution. if (exit_code == 0) if (session.to_cache) |entry| { - if (entry.cache.add(alloc, entry.dest)) |_| { + if (entry.cache.add(alloc, entry.dest, std.time.timestamp())) |_| { verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest}); } else |err| { log.debug("cache add failed for '{s}': {}", .{ entry.dest, err }); diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index d3ee658af..83031e8e7 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -3,6 +3,7 @@ const fs = std.fs; const Allocator = std.mem.Allocator; const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; +const Duration = @import("../config.zig").Config.Duration; pub const Entry = @import("ssh-cache/Entry.zig"); pub const DiskCache = @import("ssh-cache/DiskCache.zig"); @@ -10,8 +11,7 @@ pub const Options = struct { clear: bool = false, add: ?[]const u8 = null, remove: ?[]const u8 = null, - host: ?[]const u8 = null, - @"expire-days": ?u32 = null, + prune: ?Duration = null, pub fn deinit(self: *Options) void { _ = self; @@ -25,27 +25,36 @@ pub const Options = struct { /// Manage the SSH terminfo cache for automatic remote host setup. /// -/// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`, -/// Ghostty automatically installs its terminfo on remote hosts. This command -/// manages the cache of successful installations to avoid redundant uploads. +/// The `+ssh` action installs Ghostty's terminfo on remote hosts and records +/// each success in this cache so it doesn't re-upload on later connections. +/// (`+ssh` runs automatically from the shell integration when +/// `shell-integration-features` includes `ssh-terminfo`.) This command +/// inspects and maintains that cache. /// -/// The cache stores hostnames (or user@hostname combinations) along with timestamps. -/// Entries older than the expiration period are automatically removed during cache -/// operations. By default, entries never expire. +/// The cache stores destinations (a hostname or user@hostname) along with +/// timestamps. /// -/// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified. -/// If multiple are specified, one of the actions will be executed but -/// it isn't guaranteed which one. This is entirely unsafe so you should split -/// multiple actions into separate commands. +/// A positional destination queries the cache: `user@hostname` shows that +/// exact entry, while a bare `hostname` shows every cached entry for that +/// host regardless of user. With no destination and no action, the entire +/// cache is listed. A query that matches nothing exits 1. +/// +/// At most one action (`--clear`, `--add`, `--remove`, or `--prune`) may be +/// specified, and not together with a positional destination; combining them +/// is an error. +/// +/// `--prune` takes a duration with unit suffixes (`s`, `m`, `h`, `d`, `w`, +/// `y`) and removes every entry older than it, e.g. `--prune=30d`, +/// `--prune=6h`, `--prune=1y`. /// /// Examples: -/// ghostty +ssh-cache # List all cached hosts -/// ghostty +ssh-cache --host=example.com # Check if host is cached -/// ghostty +ssh-cache --add=example.com # Manually add host to cache -/// ghostty +ssh-cache --add=user@example.com # Add user@host combination -/// ghostty +ssh-cache --remove=example.com # Remove host from cache -/// ghostty +ssh-cache --clear # Clear entire cache -/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period +/// ghostty +ssh-cache # List all cached destinations +/// ghostty +ssh-cache user@example.com # Show that destination +/// ghostty +ssh-cache example.com # Show all users on that host +/// ghostty +ssh-cache --add=user@example.com # Manually add a destination +/// ghostty +ssh-cache --remove=user@example.com # Remove a destination +/// ghostty +ssh-cache --prune=30d # Remove entries older than 30 days +/// ghostty +ssh-cache --clear # Clear entire cache pub fn run(alloc_gpa: Allocator) !u8 { var arena = std.heap.ArenaAllocator.init(alloc_gpa); defer arena.deinit(); @@ -54,12 +63,6 @@ pub fn run(alloc_gpa: Allocator) !u8 { var opts: Options = .{}; defer opts.deinit(); - { - var iter = try args.argsIterator(alloc_gpa); - defer iter.deinit(); - try args.parse(Options, alloc_gpa, &opts, &iter); - } - var stdout_buffer: [1024]u8 = undefined; var stdout_file: std.fs.File = .stdout(); var stdout_writer = stdout_file.writer(&stdout_buffer); @@ -70,7 +73,66 @@ pub fn run(alloc_gpa: Allocator) !u8 { var stderr_writer = stderr_file.writer(&stderr_buffer); const stderr = &stderr_writer.interface; - const result = runInner(alloc, opts, stdout, stderr); + // The cache is queried by a positional destination (`user@host` or a + // bare `host`). `args.parse` rejects non-`--` tokens, so we lift the + // positional out here and parse only the remaining flags. `--host=X` + // is accepted as a deprecated spelling of the positional (it was the + // original shipped flag name). + var query: ?[]const u8 = null; + var flags: std.ArrayList([]const u8) = .empty; + { + var iter = try args.argsIterator(alloc_gpa); + defer iter.deinit(); + while (iter.next()) |arg| { + const is_host_flag = std.mem.startsWith(u8, arg, "--host="); + if (is_host_flag) { + try stderr.print( + "Warning: --host is deprecated; pass the destination " ++ + "directly, e.g. `ghostty +ssh-cache {s}`.\n", + .{arg["--host=".len..]}, + ); + } + const dest: ?[]const u8 = if (is_host_flag) + arg["--host=".len..] + else if (!std.mem.startsWith(u8, arg, "-")) + arg + else + null; + + if (dest) |d| { + if (query != null) { + try stderr.print( + "Error: only one destination may be specified.\n", + .{}, + ); + stderr.flush() catch {}; + return 2; + } + query = try alloc.dupe(u8, d); + } else { + try flags.append(alloc, try alloc.dupe(u8, arg)); + } + } + } + + { + var iter = args.sliceIterator(flags.items); + args.parse(Options, alloc_gpa, &opts, &iter) catch |err| switch (err) { + error.InvalidField => { + try stderr.print("Error: unknown flag.\n", .{}); + stderr.flush() catch {}; + return 2; + }, + error.InvalidValue, error.ValueRequired => { + try stderr.print("Error: invalid flag value.\n", .{}); + stderr.flush() catch {}; + return 2; + }, + else => return err, + }; + } + + const result = runInner(alloc, opts, query, stdout, stderr); // Flushing *shouldn't* fail but... stdout.flush() catch {}; @@ -81,103 +143,126 @@ pub fn run(alloc_gpa: Allocator) !u8 { pub fn runInner( alloc: Allocator, opts: Options, + query: ?[]const u8, stdout: *std.Io.Writer, stderr: *std.Io.Writer, ) !u8 { + // At most one action may be specified, and a query (positional + // destination) is itself an action. + const action_count = + @as(usize, @intFromBool(opts.clear)) + + @intFromBool(opts.add != null) + + @intFromBool(opts.remove != null) + + @intFromBool(opts.prune != null) + + @intFromBool(query != null); + if (action_count > 1) { + try stderr.print( + "Error: only one of a destination, --clear, --add, --remove, " ++ + "or --prune may be specified.\n", + .{}, + ); + return 2; + } + // Setup our disk cache to the standard location const cache_path = try DiskCache.defaultPath(alloc, "ghostty"); const cache: DiskCache = .{ .path = cache_path }; if (opts.clear) { try cache.clear(); - try stdout.print("Cache cleared.\n", .{}); return 0; } - if (opts.add) |host| { - const result = cache.add(alloc, host) catch |err| switch (err) { - DiskCache.Error.HostnameIsInvalid => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - DiskCache.Error.CacheIsLocked => { - try stderr.print("Error: Cache is busy, try again\n", .{}); - return 1; + if (opts.add) |dest| { + cache.add(alloc, dest, std.time.timestamp()) catch |err| switch (err) { + error.InvalidCacheKey => { + try stderr.print( + "Error: Invalid destination '{s}' (expected hostname or user@hostname)\n", + .{dest}, + ); + return 2; }, else => { try stderr.print( "Error: Unable to add '{s}' to cache. Error: {}\n", - .{ host, err }, + .{ dest, err }, ); return 1; }, }; - - switch (result) { - .added => try stdout.print("Added '{s}' to cache.\n", .{host}), - .updated => try stdout.print("Updated '{s}' cache entry.\n", .{host}), - } return 0; } - if (opts.remove) |host| { - cache.remove(alloc, host) catch |err| switch (err) { - DiskCache.Error.HostnameIsInvalid => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - DiskCache.Error.CacheIsLocked => { - try stderr.print("Error: Cache is busy, try again\n", .{}); - return 1; + if (opts.remove) |dest| { + const removed = cache.remove(alloc, dest) catch |err| switch (err) { + error.InvalidCacheKey => { + try stderr.print( + "Error: Invalid destination '{s}' (expected hostname or user@hostname)\n", + .{dest}, + ); + return 2; }, else => { try stderr.print( "Error: Unable to remove '{s}' from cache. Error: {}\n", - .{ host, err }, + .{ dest, err }, ); return 1; }, }; - try stdout.print("Removed '{s}' from cache.\n", .{host}); + // Silence on success; a no-op removal is an error (exit 1). + if (!removed) { + try stderr.print("Error: '{s}' is not in the cache.\n", .{dest}); + return 1; + } return 0; } - if (opts.host) |host| { - const cached = cache.contains(alloc, host) catch |err| switch (err) { - error.HostnameIsInvalid => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - else => { - try stderr.print( - "Error: Unable to check host '{s}' in cache. Error: {}\n", - .{ host, err }, - ); - return 1; - }, - }; - - if (cached) { - try stdout.print( - "'{s}' has Ghostty terminfo installed.\n", - .{host}, + if (opts.prune) |max_age| { + const max_age_s = max_age.duration / std.time.ns_per_s; + if (max_age_s == 0) { + try stderr.print( + "Error: --prune requires a duration of at least one second.\n", + .{}, ); - return 0; - } else { - try stdout.print( - "'{s}' does not have Ghostty terminfo installed.\n", - .{host}, - ); - return 1; + return 2; } + const pruned = cache.prune(alloc, max_age_s) catch |err| { + try stderr.print("Error: Unable to prune cache. Error: {}\n", .{err}); + return 1; + }; + try stdout.print("Pruned cache entries: {d}\n", .{pruned}); + return 0; } - // Default action: list all hosts var entries = try cache.list(alloc); defer DiskCache.deinitEntries(alloc, &entries); + + // A positional query filters the listing: an exact `user@host` match, + // or every entry on a bare `host`. + if (query) |q| { + if (!DiskCache.isValidCacheKey(q)) { + try stderr.print( + "Error: Invalid destination '{s}' (expected hostname or user@hostname)\n", + .{q}, + ); + return 2; + } + + var matches: std.StringHashMap(Entry) = .init(alloc); + defer matches.deinit(); + var iter = entries.iterator(); + while (iter.next()) |kv| { + const key = kv.key_ptr.*; + if (matchesQuery(key, q)) try matches.put(key, kv.value_ptr.*); + } + + if (matches.count() == 0) return 1; + try listEntries(alloc, &matches, stdout); + return 0; + } + + // List all destinations by default. try listEntries(alloc, &entries, stdout); return 0; } @@ -187,10 +272,7 @@ fn listEntries( entries: *const std.StringHashMap(Entry), writer: *std.Io.Writer, ) !void { - if (entries.count() == 0) { - try writer.print("No hosts in cache.\n", .{}); - return; - } + if (entries.count() == 0) return; // Sort entries by hostname for consistent output var items: std.ArrayList(Entry) = .empty; @@ -207,22 +289,200 @@ fn listEntries( } }.lessThan); - try writer.print("Cached hosts ({d}):\n", .{items.items.len}); - const now = std.time.timestamp(); - + // Align the timestamp column by padding destinations to the widest. + var widest: usize = 0; for (items.items) |entry| { - const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day); - if (age_days == 0) { - try writer.print(" {s} (today)\n", .{entry.hostname}); - } else if (age_days == 1) { - try writer.print(" {s} (yesterday)\n", .{entry.hostname}); - } else { - try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days }); - } + widest = @max(widest, entry.hostname.len); } + + const now = std.time.timestamp(); + for (items.items) |entry| { + try writer.print("{s}", .{entry.hostname}); + try writer.splatByteAll(' ', widest - entry.hostname.len + 2); + + var iso_buf: [20]u8 = undefined; + var age_buf: [32]u8 = undefined; + try writer.print("{s} ({s})\n", .{ + formatTimestamp(&iso_buf, entry.timestamp), + relativeAge(&age_buf, now, entry.timestamp), + }); + } +} + +/// Whether a cache `key` matches a positional `query`. A `user@host` query +/// (containing `@`) matches one exact key; a bare `host` query matches every +/// key on that host regardless of user, comparing against the key's host +/// component (everything after its first `@`, or the whole key if userless). +fn matchesQuery(key: []const u8, query: []const u8) bool { + if (std.mem.indexOfScalar(u8, query, '@') != null) { + return std.mem.eql(u8, key, query); + } + + const at = std.mem.indexOfScalar(u8, key, '@'); + const host = if (at) |i| key[i + 1 ..] else key; + return std.mem.eql(u8, host, query); +} + +test matchesQuery { + const testing = std.testing; + + // Exact user@host: only the identical key. + try testing.expect(matchesQuery("user@example.com", "user@example.com")); + try testing.expect(!matchesQuery("root@example.com", "user@example.com")); + try testing.expect(!matchesQuery("example.com", "user@example.com")); + + // Bare host: every key on that host, plus a keyless entry for it. + try testing.expect(matchesQuery("user@example.com", "example.com")); + try testing.expect(matchesQuery("root@example.com", "example.com")); + try testing.expect(matchesQuery("example.com", "example.com")); + try testing.expect(!matchesQuery("user@other.com", "example.com")); +} + +/// Format a Unix timestamp as an ISO-8601 UTC string +/// (`YYYY-MM-DDTHH:MM:SSZ`) into `buf`, which must be at least 20 bytes. +/// Out-of-range input is clamped so this can't crash on a garbage cache line. +fn formatTimestamp(buf: []u8, timestamp: i64) []const u8 { + // Clamp to [epoch, last second of 9999-12-31Z]: `std.time.epoch` + // accumulates the year in a `u16` (panics beyond that), and the buffer + // only fits a 4-digit year. + const secs: u64 = @intCast(std.math.clamp(timestamp, 0, 253402300799)); + + const epoch = std.time.epoch; + const epoch_secs: epoch.EpochSeconds = .{ .secs = secs }; + const day = epoch_secs.getEpochDay(); + const year_day = day.calculateYearDay(); + const month_day = year_day.calculateMonthDay(); + const ds = epoch_secs.getDaySeconds(); + return std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{ + year_day.year, + month_day.month.numeric(), + month_day.day_index + 1, + ds.getHoursIntoDay(), + ds.getMinutesIntoHour(), + ds.getSecondsIntoMinute(), + }) catch unreachable; +} + +test formatTimestamp { + const testing = std.testing; + var buf: [20]u8 = undefined; + + try testing.expectEqualStrings( + "2026-05-05T22:49:33Z", + formatTimestamp(&buf, 1778021373), + ); + + // Epoch. + try testing.expectEqualStrings( + "1970-01-01T00:00:00Z", + formatTimestamp(&buf, 0), + ); + + // Out-of-range inputs clamp instead of overflowing the [20]u8 / + // panicking inside std: negatives floor at the epoch, huge values cap + // at the last second of year 9999. + try testing.expectEqualStrings( + "1970-01-01T00:00:00Z", + formatTimestamp(&buf, -5), + ); + try testing.expectEqualStrings( + "9999-12-31T23:59:59Z", + formatTimestamp(&buf, std.math.maxInt(i64)), + ); +} + +/// Format the age of `timestamp` (relative to `now`, both Unix seconds) +/// as a coarse relative time into `buf`, e.g. "2w ago". Uses `Duration`'s +/// unit vocabulary but keeps only the single largest unit for scannability. +/// A non-positive age (timestamp at or after `now`) is "now". +fn relativeAge(buf: []u8, now: i64, timestamp: i64) []const u8 { + // Saturating so a garbage timestamp can't overflow; clamp at 0 so a + // future timestamp becomes a zero age rather than going negative. + const age: u64 = @intCast(@max(0, now -| timestamp)); + if (age == 0) return "now"; + + // Round down to the largest unit that fits, so Duration.format emits + // only that unit (e.g. 19d -> 2w, 90m -> 1h). + const units = [_]u64{ + 365 * std.time.s_per_day, // y + std.time.s_per_week, // w + std.time.s_per_day, // d + std.time.s_per_hour, // h + std.time.s_per_min, // m + 1, // s + }; + const unit = for (units) |u| { + if (age >= u) break u; + } else 1; + + // Cap the age so `age * ns_per_s` can't overflow u64 (a garbage, e.g. + // hugely negative, timestamp otherwise yields an age near i64-max). + const max_age = std.math.maxInt(u64) / std.time.ns_per_s; + const rounded = @min(age, max_age) / unit * unit; + const d: Duration = .{ .duration = rounded * std.time.ns_per_s }; + return std.fmt.bufPrint(buf, "{f} ago", .{d}) catch unreachable; +} + +test relativeAge { + const testing = std.testing; + var buf: [32]u8 = undefined; + const now: i64 = 2_000_000_000; // fixed reference + const min = std.time.s_per_min; + const hour = std.time.s_per_hour; + const day = std.time.s_per_day; + + // Out-of-range timestamps don't crash: a huge future one saturates to + // a non-positive age ("now"); a negative one is a large but real age. + try testing.expectEqualStrings("now", relativeAge(&buf, now, std.math.maxInt(i64))); + try testing.expectEqualStrings("63y ago", relativeAge(&buf, now, -100)); + + // A huge age (garbage timestamp) saturates the ns conversion instead of + // overflowing; it must not crash and must fit the buffer. + try testing.expect(std.mem.endsWith(u8, relativeAge(&buf, std.math.maxInt(i64), 0), " ago")); + + // Future timestamp (clock skew) and same-instant read "now". + try testing.expectEqualStrings("now", relativeAge(&buf, now, now + 100)); + try testing.expectEqualStrings("now", relativeAge(&buf, now, now)); + + // Only the single largest unit is kept (smaller units rounded away). + try testing.expectEqualStrings("30s ago", relativeAge(&buf, now, now - 30)); + try testing.expectEqualStrings("1m ago", relativeAge(&buf, now, now - min)); + try testing.expectEqualStrings("1m ago", relativeAge(&buf, now, now - 90)); // 90s -> 1m + try testing.expectEqualStrings("1h ago", relativeAge(&buf, now, now - hour)); + try testing.expectEqualStrings("1h ago", relativeAge(&buf, now, now - (hour + 30 * min))); // 1h30m -> 1h + try testing.expectEqualStrings("1d ago", relativeAge(&buf, now, now - day)); + try testing.expectEqualStrings("2w ago", relativeAge(&buf, now, now - 19 * day)); // 19d -> 2w } test { _ = DiskCache; _ = Entry; } + +test "runInner rejects multiple actions" { + const testing = std.testing; + const alloc = testing.allocator; + + var stdout: std.Io.Writer.Allocating = .init(alloc); + defer stdout.deinit(); + var stderr: std.Io.Writer.Allocating = .init(alloc); + defer stderr.deinit(); + + // The check runs before any cache access, so it never touches disk. + const code = try runInner(alloc, .{ + .add = "example.com", + .remove = "other.com", + }, null, &stdout.writer, &stderr.writer); + + try testing.expectEqual(@as(u8, 2), code); + try testing.expectEqualStrings("", stdout.written()); + try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null); + + // A positional query is itself an action: query + a flag conflicts. + stderr.clearRetainingCapacity(); + const code2 = try runInner(alloc, .{ + .clear = true, + }, "example.com", &stdout.writer, &stderr.writer); + try testing.expectEqual(@as(u8, 2), code2); + try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null); +} From a746d0f7281954eb251915f4cd9fcea4924ad999 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 20:38:13 +0000 Subject: [PATCH 091/137] Update VOUCHED list (#12816) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/12815#issuecomment-4537093020) from @jcollie. Vouch: @nikicat Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 302e9809e..6fd37e40d 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -197,6 +197,7 @@ neo773 neurosnap nicholas-ochoa nicosuave +nikicat nmggithub noib3 nolinmcfarland From 0b6d91e531a14c70f25bb1c8cd4a376a89bad7a4 Mon Sep 17 00:00:00 2001 From: Nikolay Bryskin Date: Mon, 25 May 2026 21:58:09 +0300 Subject: [PATCH 092/137] apprt/gtk: reuse one audio-bell MediaFile per surface to fix thread leak Each audio bell called gtk.MediaFile.newForFilename, which spins up a full GStreamer pipeline. The GTK4 GStreamer backend's GL sink starts gstglcontext/gldisplay-event threads that are never joined on teardown, so allocating a MediaFile per ring leaked a pipeline and ~4 threads on every bell. A long-running instance accumulated 705 threads over ~4h of normal use. Cache one MediaFile per surface (priv.bell_media), rebuilt only when bell-audio-path changes and unref'd on dispose. Each bell now replays the same pipeline via seek(0)+play() instead of creating a new one. The notify::ended -> unref handler is removed: it was what discarded (and leaked) a pipeline per ring. seek(0) is required so an ended stream plays again (#8957). Verified on a real instance: GStreamer's global element counter reached only oggdemux4 over an hour of use (one pipeline per bell-ringing surface, reused) and thread count stayed flat, versus per-bell growth before. Co-Authored-By: Claude Opus 4.7 --- src/apprt/gtk/class/surface.zig | 22 ++++++++++- src/apprt/gtk/media.zig | 67 +++++++++++++++++++++++---------- 2 files changed, 67 insertions(+), 22 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 3c9293a82..629866dc2 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -674,6 +674,12 @@ pub const Surface = extern struct { // false by a parent widget. bell_ringing: bool = false, + // The audio bell's MediaFile, reused across bells so we don't leak a + // GStreamer pipeline (and its GL threads) on every ring. Built lazily + // on the first audio bell and rebuilt when `bell-audio-path` changes; + // unref'd on dispose. See ringBell and media.zig. + bell_media: ?*gtk.MediaFile = null, + /// True if this surface is in an error state. This is currently /// a simple boolean with no additional information on WHAT the /// error state is, because we don't yet need it or use it. For now, @@ -1854,6 +1860,11 @@ pub const Surface = extern struct { priv.config = null; } + if (priv.bell_media) |v| { + v.unref(); + priv.bell_media = null; + } + if (priv.vadj_signal_group) |group| { group.setTarget(null); group.as(gobject.Object).unref(); @@ -2486,8 +2497,15 @@ pub const Surface = extern struct { 1.0, ); - const media_file = media.fromFilename(path) orelse break :audio; - media.playMediaFile(media_file, volume, required); + // Reuse one MediaFile per surface (rebuilt only when the path + // changes) so each bell replays the same pipeline instead of + // leaking a fresh one. Assign unconditionally: bellMediaFile frees + // any stale MediaFile and returns the current slot value (possibly + // null if the path is now inaccessible), so priv.bell_media never + // dangles. + priv.bell_media = media.bellMediaFile(priv.bell_media, path, required); + const media_file = priv.bell_media orelse break :audio; + media.playBell(media_file, volume); } } diff --git a/src/apprt/gtk/media.zig b/src/apprt/gtk/media.zig index 1015c933f..62bf0db8f 100644 --- a/src/apprt/gtk/media.zig +++ b/src/apprt/gtk/media.zig @@ -44,9 +44,38 @@ pub fn fromResource(path: [:0]const u8) ?*gtk.MediaFile { return gtk.MediaFile.newForResource(path); } -pub fn playMediaFile(media_file: *gtk.MediaFile, volume: f64, required: bool) void { - // If the audio file is marked as required, we'll emit an error if - // there was a problem playing it. Otherwise there will be silence. +/// Get-or-create a reusable bell MediaFile targeting `path`. +/// +/// `current` is the surface's currently-cached MediaFile (or null). If it +/// already targets `path` it is returned unchanged; otherwise it is unref'd and +/// a fresh MediaFile is built for `path`. Returns null (after freeing `current`) +/// if `path` is inaccessible, leaving the caller's slot empty. +/// +/// Reusing one MediaFile per surface is what prevents the GStreamer pipeline +/// leak: `gtk.MediaFile.newForFilename` spins up a full pipeline (and, via the +/// GTK4 GStreamer backend's GL sink, gstglcontext/gldisplay-event threads) that +/// is never torn down on the happy path, so allocating one per bell leaked a +/// pipeline + its threads on every ring. See the caller in surface.zig. +pub fn bellMediaFile( + current: ?*gtk.MediaFile, + path: [:0]const u8, + required: bool, +) ?*gtk.MediaFile { + if (current) |media_file| { + if (isForPath(media_file, path)) return media_file; + media_file.unref(); + } + + const media_file = fromFilename(path) orelse return null; + + // If the audio file is marked as required, we'll emit an error if there + // was a problem playing it. Otherwise there will be silence. We connect + // this once, here, because the MediaFile is reused across bells. + // + // NOTE: we intentionally do NOT connect notify::ended to unref. The + // MediaFile is owned by the surface and replayed via `seek(0)` for every + // bell; unref'ing on `ended` is precisely what previously discarded (and + // leaked) a pipeline per ring. if (required) { _ = gobject.Object.signals.notify.connect( media_file, @@ -57,21 +86,27 @@ pub fn playMediaFile(media_file: *gtk.MediaFile, volume: f64, required: bool) vo ); } - // Watch for the "ended" signal so that we can clean up after - // ourselves. - _ = gobject.Object.signals.notify.connect( - media_file, - ?*anyopaque, - mediaFileEnded, - null, - .{ .detail = "ended" }, - ); + return media_file; +} +/// (Re)play `media_file` at `volume`. `seek(0)` rewinds first so that a +/// previously-ended stream plays again; without it playback only ever happens +/// once (see #8957). Safe on a freshly-created stream as well. +pub fn playBell(media_file: *gtk.MediaFile, volume: f64) void { const media_stream = media_file.as(gtk.MediaStream); media_stream.setVolume(volume); + media_stream.seek(0); media_stream.play(); } +/// Whether `media_file` was created for `path`. +fn isForPath(media_file: *gtk.MediaFile, path: [:0]const u8) bool { + const file = media_file.getFile() orelse return false; + const cur = file.getPath() orelse return false; + defer glib.free(cur); + return std.mem.eql(u8, std.mem.span(cur), path); +} + fn mediaFileError( media_file: *gtk.MediaFile, _: *gobject.ParamSpec, @@ -92,11 +127,3 @@ fn mediaFileError( err.f_message orelse "", }); } - -fn mediaFileEnded( - media_file: *gtk.MediaFile, - _: *gobject.ParamSpec, - _: ?*anyopaque, -) callconv(.c) void { - media_file.unref(); -} From 0708f932a51d7e4a0d1022a01f73d0267d42660d Mon Sep 17 00:00:00 2001 From: Nikolay Bryskin Date: Mon, 25 May 2026 23:10:34 +0300 Subject: [PATCH 093/137] apprt/gtk: add regression test for audio-bell MediaFile reuse Guards the contract that prevents the bell thread leak: bellMediaFile must return the same cached MediaFile for an unchanged path and only rebuild when the path changes. A revert to per-bell allocation (the leak) would fail this. Runs in the existing test-gtk CI job; needs no display or playback since the path bookkeeping is all that's asserted. Co-Authored-By: Claude Opus 4.7 --- src/apprt/gtk/media.zig | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/apprt/gtk/media.zig b/src/apprt/gtk/media.zig index 62bf0db8f..7883bf372 100644 --- a/src/apprt/gtk/media.zig +++ b/src/apprt/gtk/media.zig @@ -127,3 +127,31 @@ fn mediaFileError( err.f_message orelse "", }); } + +test "bellMediaFile reuses one MediaFile per path" { + // Regression guard for the audio-bell thread leak: each bell must replay a + // single cached MediaFile, not allocate a fresh GStreamer pipeline (which + // leaked gstglcontext/gldisplay-event threads) per ring. We assert the + // reuse contract of bellMediaFile directly; this needs no display and no + // playback (MediaFile is lazy), only that the path comparison drives reuse. + const testing = std.testing; + + // The files need not exist: MediaFile only records the path until played. + const path_a: [:0]const u8 = "/tmp/ghostty-bell-test-a.oga"; + const path_b: [:0]const u8 = "/tmp/ghostty-bell-test-b.oga"; + + var current = bellMediaFile(null, path_a, false) orelse return error.SkipZigTest; + const first = current; + try testing.expect(isForPath(current, path_a)); + + // Same path => identical object (the leak regression is rebuilding here). + current = bellMediaFile(current, path_a, false).?; + try testing.expectEqual(first, current); + + // Changed path => rebuilt object targeting the new path (old one freed). + current = bellMediaFile(current, path_b, false) orelse return error.SkipZigTest; + try testing.expect(isForPath(current, path_b)); + try testing.expect(!isForPath(current, path_a)); + + current.unref(); +} From 9910a1a4753c9e135cd1add4119624f86d8167aa Mon Sep 17 00:00:00 2001 From: Nikolay Bryskin Date: Mon, 25 May 2026 23:21:27 +0300 Subject: [PATCH 094/137] test: add audio-bell thread-leak NixOS check (GNOME/Wayland) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a bell-leak-check-gnome NixOS test (nix/tests.nix) that launches Ghostty under GNOME on Wayland, rings 100 bells in the window, and fails if the GUI process thread count grows per-bell — the end-to-end signature of the GStreamer pipeline leak fixed in this branch. Verified locally: growth of ~1 thread over 100 bells, vs ~+400 pre-fix. Replaces the earlier Xvfb shell script + workflow job: per review, X11 support in GNOME is going away, and this belongs as a Nix check alongside the other *-gnome tests rather than a standalone script. The VM has no GPU, so it renders via llvmpipe; the test gives the guest enough cores/RAM for software GL and tolerates the +new-window D-Bus activation exceeding its client-side timeout (the window still comes up) by waiting for the window rather than hard-failing on the call. Co-Authored-By: Claude Opus 4.7 --- nix/tests.nix | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/nix/tests.nix b/nix/tests.nix index 28fefbf25..49627478f 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -281,4 +281,113 @@ in { server.wait_for_file("${user.home}/.terminfo/x/xterm-ghostty", timeout=30) ''; }; + + # Regression test for the GTK audio-bell GStreamer thread leak. Each audio + # bell used to allocate a fresh gtk.MediaFile (and thus a GStreamer pipeline + # whose GL sink spawns gstglcontext/gldisplay-event threads that are never + # joined), leaking ~4 threads per ring; the fix reuses one MediaFile per + # surface. This rings many bells and asserts the GUI process thread count + # stays bounded. Runs under GNOME on Wayland so it exercises the real path. + bell-leak-check-gnome = mkTestGnome { + name = "bell-leak-check-gnome"; + settings = { + # The VM has no GPU, so GNOME and Ghostty render via llvmpipe. Give the + # guest enough cores/RAM that software GL can bring up Ghostty's window + # before the +new-window D-Bus activation times out, and force clean + # software GL so mesa doesn't stall probing for absent hardware. + virtualisation.cores = 4; + virtualisation.memorySize = 4096; + environment.sessionVariables = { + LIBGL_ALWAYS_SOFTWARE = "1"; + GALLIUM_DRIVER = "llvmpipe"; + }; + + home-manager.users.ghostty = { + xdg.configFile = { + "ghostty/config".text = '' + bell-features = audio + bell-audio-path = ${pkgs.sound-theme-freedesktop}/share/sounds/freedesktop/stereo/bell.oga + bell-audio-volume = 0 + ''; + }; + }; + }; + testScript = {nodes, ...}: let + user = nodes.machine.users.users.ghostty; + bus_path = "/run/user/${toString user.uid}/bus"; + bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}"; + gdbus = "${bus} gdbus"; + ghostty = "${bus} ghostty"; + su = command: "su - ${user.name} -c '${command}'"; + gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval"; + wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class"; + + # Emits N BELs >100ms apart (which clears the bell rate-limit), then holds + # so the window (and its audio pipeline) stays alive while we sample. Run + # by typing its path into the open window; written as a script to avoid + # shell-escaping the BEL byte through the test driver. + ringBells = pkgs.writeShellScript "ring-bells" '' + for _ in $(seq 100); do printf '\a'; sleep 0.12; done + sleep 60 + ''; + in '' + # Thread count of the ghostty GUI process: the ghostty process with the + # most threads. The CLI also spawns 1-thread launcher/helper stubs (and + # this very command matches the pgrep), but those are filtered by the max. + def ghostty_threads(): + out = machine.succeed( + "max=0; " + "for p in $(pgrep -f ghostty); do " + " n=$(ls /proc/$p/task 2>/dev/null | wc -l); " + " [ \"$n\" -gt \"$max\" ] && max=$n; " + "done; " + "echo $max" + ).strip() + return int(out) + + def window_open(): + status, _ = machine.execute("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'") + return status == 0 + + with subtest("boot and open a keep-alive ghostty window"): + start_all() + machine.wait_for_x() + machine.wait_for_file("${bus_path}") + machine.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}") + + # Under software GL the +new-window D-Bus activation can exceed its + # client-side timeout even though the window still comes up, so we + # tolerate a failed call and (re)nudge until the window appears. + for _ in range(6): + machine.execute("${su "${ghostty} +new-window"}") + if window_open(): + break + machine.sleep(5) + assert window_open(), "ghostty window never appeared" + machine.sleep(2) + + with subtest("ring 100 bells and assert the thread count stays bounded"): + baseline = ghostty_threads() + + # Ring the bells by running the script inside the focused window (type + # its path + Enter). A separate `ghostty -e` process can't open the + # display from the bare su environment, so we drive the open window. + machine.send_chars("${ringBells}\n") + + # 100 bells * 0.12s + settle, within the script's trailing hold so the + # window (and its audio pipeline) is still alive when we sample. + machine.sleep(22) + final = ghostty_threads() + + growth = final - baseline + print(f"bell-leak: baseline={baseline} final={final} growth={growth}") + + # Pre-fix grows ~4 threads/bell (~+400 over 100 bells); the fix adds + # only one pipeline's worth of threads. 40 sits well clear of both. + assert growth <= 40, ( + f"thread count grew by {growth} over 100 bells " + f"(baseline={baseline}, final={final}): audio-bell pipeline leak regressed" + ) + ''; + }; } From d86ff37a58f77d87f1774a433bdcfabf9f99e246 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 25 May 2026 14:07:41 -0700 Subject: [PATCH 095/137] terminal: SelectionGesture, but only with mouse press --- src/terminal/SelectionGesture.zig | 363 ++++++++++++++++++++++++++++++ src/terminal/main.zig | 1 + 2 files changed, 364 insertions(+) create mode 100644 src/terminal/SelectionGesture.zig diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig new file mode 100644 index 000000000..a85e22a2a --- /dev/null +++ b/src/terminal/SelectionGesture.zig @@ -0,0 +1,363 @@ +/// SelectionGesture manages gesture-based selection logic (mouse press, drag, +/// etc.). Callers setup initial state, make calls for various external +/// events, and react to the requested effects. +const SelectionGesture = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; +const Allocator = std.mem.Allocator; +const PageList = @import("PageList.zig"); +const Pin = PageList.Pin; +const ScreenSet = @import("ScreenSet.zig"); +const Terminal = @import("Terminal.zig"); + +/// The tracked pin of the initial left click along with the screen +/// that the pin is part of. +left_click_pin: ?*Pin, +left_click_screen: ScreenSet.Key, +left_click_screen_generation: usize, + +/// The count of clicks to count double and triple clicks and so on. +/// The left click time was the last time the left click was done, if the +/// caller could provide one. If this is null then we only support single clicks. +left_click_count: u3, +left_click_time: ?std.time.Instant, + +/// The starting xpos/ypos of the left click. Note that if scrolling occurs, +/// these will point to different cells, but the xpos/ypos will stay +/// stable during scrolling relative to the surface. +left_click_xpos: f64, +left_click_ypos: f64, + +pub const init: SelectionGesture = .{ + .left_click_pin = null, + .left_click_count = 0, + .left_click_time = null, + .left_click_screen = .primary, + .left_click_screen_generation = 0, + .left_click_xpos = 0, + .left_click_ypos = 0, +}; + +pub fn deinit(self: *SelectionGesture, t: *Terminal) void { + // Grab our pagelist that is associated with the pin. If it doesn't + // exist anymore then our tracked pin is already free. + const pin = self.left_click_pin orelse return; + if (t.screens.generation(self.left_click_screen) != self.left_click_screen_generation) return; + const screen = t.screens.get(self.left_click_screen) orelse return; + screen.pages.untrackPin(pin); +} + +/// Reset any active gesture state and untrack the tracked click pin. +pub fn reset(self: *SelectionGesture, t: *Terminal) void { + self.left_click_count = 0; + self.left_click_time = null; + self.untrackPin(t); +} + +pub const Press = struct { + /// The time when the press event occurred. Use a monotonic timer. + /// This can be null if you're on a system that doesn't support + /// time for some reason. In that case, we only support single clicks. + time: ?std.time.Instant, + + /// The cell where the click was. + pin: Pin, + + /// The x/y value of the click relative to the surface with (0,0) being + /// top-left. This is used for distance detection for multi-clicks so + /// double/triple clicks too far away from each other will reset the click + /// count as well more accurate drag behaviors. + xpos: f64, + ypos: f64, + + /// Maximum distance a click can be from the original click to register + /// as a repeat. If uncertain, set this to cell width. + max_distance: f64, + + /// The maximum interval in nanoseconds that a press is considered + /// a repeat e.g. to record double/triple clicks. + repeat_interval: u64, +}; + +/// Record a press event. +pub fn press( + self: *SelectionGesture, + t: *Terminal, + p: Press, +) Allocator.Error!void { + if (self.left_click_count > 0) { + if (self.pressRepeat(t, p)) { + // Successful repeat, return. + return; + } else |err| switch (err) { + error.PressRequiresReset => {}, + } + } + + // Initial click or the repeat failed for some reason such as + // the subsequent click being too far away. + try self.pressInitial(t, p); +} + +fn pressInitial( + self: *SelectionGesture, + t: *Terminal, + p: Press, +) Allocator.Error!void { + // Setup our pin first, reusing our existing pin if we can. + if (self.left_click_pin) |pin| { + if (comptime std.debug.runtime_safety) { + assert(self.left_click_screen == t.screens.active_key); + assert(self.left_click_screen_generation == t.screens.generation(t.screens.active_key)); + } + pin.* = p.pin; + } else { + const screens: *const ScreenSet = &t.screens; + self.left_click_pin = try screens.active.pages.trackPin(p.pin); + errdefer comptime unreachable; + self.left_click_screen = screens.active_key; + self.left_click_screen_generation = screens.generation(screens.active_key); + } + errdefer comptime unreachable; + self.left_click_count = 1; + self.left_click_xpos = p.xpos; + self.left_click_ypos = p.ypos; + self.left_click_time = p.time; +} + +fn pressRepeat( + self: *SelectionGesture, + t: *Terminal, + p: Press, +) error{PressRequiresReset}!void { + errdefer { + self.left_click_count = 0; + self.untrackPin(t); + } + + // If too much time has passed then we always reset. + const time = p.time orelse return error.PressRequiresReset; + { + const prev_time = self.left_click_time orelse return error.PressRequiresReset; + const since = time.since(prev_time); + if (since > p.repeat_interval) return error.PressRequiresReset; + } + + // If the click is too far away from the initial click we can't continue. + const distance = @sqrt( + std.math.pow(f64, p.xpos - self.left_click_xpos, 2) + + std.math.pow(f64, p.ypos - self.left_click_ypos, 2), + ); + if (distance > p.max_distance) return error.PressRequiresReset; + + // If our prior click was on another screen then free and reset. "Another screen" + // doesn't just mean alt vs primary, it could mean an alt screen that was + // recycled since we free tracked pins on recycle. + const screens: *const ScreenSet = &t.screens; + if (self.left_click_screen != screens.active_key or + screens.generation(self.left_click_screen) != + self.left_click_screen_generation) + { + // The error return will trigger the top-level errdefer which + // will reset our pin. + return error.PressRequiresReset; + } + + self.left_click_time = time; + self.left_click_count = @min( + self.left_click_count + 1, + 3, // We only support triple clicks max + ); +} + +fn untrackPin(self: *SelectionGesture, t: *Terminal) void { + // Can't untrack unless we have a pin. + const pin = self.left_click_pin orelse return; + self.left_click_pin = null; + + // If the generation changed our pin is already invalid. + const screens: *const ScreenSet = &t.screens; + if (screens.generation(self.left_click_screen) != self.left_click_screen_generation) return; + + // If we can't get a screen then its already freed. + const screen = screens.get(self.left_click_screen) orelse return; + screen.pages.untrackPin(pin); +} + +fn testPress(t: *Terminal, x: u16, y: u32, time: ?std.time.Instant) Press { + return .{ + .time = time, + .pin = t.screens.active.pages.pin(.{ .active = .{ + .x = x, + .y = y, + } }).?, + .xpos = @floatFromInt(x), + .ypos = @floatFromInt(y), + .max_distance = 1, + .repeat_interval = std.math.maxInt(u64), + }; +} + +test "SelectionGesture press records initial click" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + try gesture.press(&t, testPress(&t, 1, 2, time)); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(time, gesture.left_click_time.?); + try testing.expectEqual(@as(f64, 1), gesture.left_click_xpos); + try testing.expectEqual(@as(f64, 2), gesture.left_click_ypos); +} + +test "SelectionGesture repeat increments click count" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + try gesture.press(&t, testPress(&t, 1, 1, time)); + try gesture.press(&t, testPress(&t, 1, 1, time)); + + try testing.expectEqual(@as(u3, 2), gesture.left_click_count); +} + +test "SelectionGesture repeat clamps at triple click" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + for (0..4) |_| try gesture.press(&t, testPress(&t, 1, 1, time)); + + try testing.expectEqual(@as(u3, 3), gesture.left_click_count); +} + +test "SelectionGesture null initial time stays single click" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + try gesture.press(&t, testPress(&t, 1, 1, null)); + try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expect(gesture.left_click_time != null); +} + +test "SelectionGesture null repeat time stays single click" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + try gesture.press(&t, testPress(&t, 1, 1, null)); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(@as(?std.time.Instant, null), gesture.left_click_time); +} + +test "SelectionGesture distant press resets click count" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + try gesture.press(&t, testPress(&t, 1, 1, time)); + try gesture.press(&t, testPress(&t, 4, 1, time)); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(@as(f64, 4), gesture.left_click_xpos); +} + +test "SelectionGesture expired repeat resets click count" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var event = testPress(&t, 1, 1, try std.time.Instant.now()); + event.repeat_interval = 0; + try gesture.press(&t, event); + + std.Thread.sleep(std.time.ns_per_ms); + event.time = try std.time.Instant.now(); + try gesture.press(&t, event); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); +} + +test "SelectionGesture screen switch resets click count" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + const primary_tracked = t.screens.active.pages.countTrackedPins(); + try gesture.press(&t, testPress(&t, 1, 1, time)); + + _ = try t.screens.getInit(testing.allocator, .alternate, .{ + .cols = t.cols, + .rows = t.rows, + }); + t.screens.switchTo(.alternate); + try gesture.press(&t, testPress(&t, 1, 1, time)); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(.alternate, gesture.left_click_screen); + try testing.expectEqual(primary_tracked, t.screens.get(.primary).?.pages.countTrackedPins()); +} + +test "SelectionGesture removed screen resets without untracking stale pin" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + _ = try t.screens.getInit(testing.allocator, .alternate, .{ + .cols = t.cols, + .rows = t.rows, + }); + t.screens.switchTo(.alternate); + try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + + t.screens.switchTo(.primary); + t.screens.remove(testing.allocator, .alternate); + try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(.primary, gesture.left_click_screen); +} + +test "SelectionGesture deinit untracks pin" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + const tracked = t.screens.active.pages.countTrackedPins(); + try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); + + gesture.deinit(&t); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 87a9aded9..53491a009 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -49,6 +49,7 @@ pub const Screen = @import("Screen.zig"); pub const ScreenSet = @import("ScreenSet.zig"); pub const Scrollbar = PageList.Scrollbar; pub const Selection = @import("Selection.zig"); +pub const SelectionGesture = @import("SelectionGesture.zig"); pub const SizeReportStyle = csi.SizeReportStyle; pub const StringMap = @import("StringMap.zig"); pub const Style = style.Style; From 14df684a70fed6085ec70b711f045f0261dbe4c2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 25 May 2026 15:45:20 -0700 Subject: [PATCH 096/137] core: adapt Surface to use SelectionGesture with press only --- src/Surface.zig | 119 +++++++++--------------------- src/inspector/widgets/surface.zig | 6 +- 2 files changed, 37 insertions(+), 88 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 525e73a9e..2ba4354b5 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -224,23 +224,8 @@ const Mouse = struct { /// pressed or release. mods: input.Mods = .{}, - /// The point at which the left mouse click happened. This is in screen - /// coordinates so that scrolling preserves the location. - left_click_pin: ?*terminal.Pin = null, - left_click_screen: terminal.ScreenSet.Key = .primary, - left_click_screen_generation: usize = 0, - - /// The starting xpos/ypos of the left click. Note that if scrolling occurs, - /// these will point to different "cells", but the xpos/ypos will stay - /// stable during scrolling relative to the surface. - left_click_xpos: f64 = 0, - left_click_ypos: f64 = 0, - - /// The count of clicks to count double and triple clicks and so on. - /// The left click time was the last time the left click was done. This - /// is always set on the first left click. - left_click_count: u8 = 0, - left_click_time: std.time.Instant = undefined, + /// Gesture state for text selection. + selection_gesture: terminal.SelectionGesture = .init, /// The last x/y sent for mouse reports. event_point: ?terminal.point.Coordinate = null, @@ -263,19 +248,13 @@ const Mouse = struct { /// only process link hover events when the mouse actually moves cells. link_point: ?terminal.point.Coordinate = null, - /// Return the PageList that owns the left-click pin, or null if the screen - /// has been removed/reinitialized since the pin was tracked. - fn leftClickPageList(self: *const Mouse, screens: *const terminal.ScreenSet) ?*terminal.PageList { - if (screens.generation(self.left_click_screen) != self.left_click_screen_generation) return null; - const screen = screens.get(self.left_click_screen) orelse return null; - return &screen.pages; - } - /// Return the left-click pin only if it still belongs to the active screen. fn activeLeftClickPin(self: *const Mouse, screens: *const terminal.ScreenSet) ?*terminal.Pin { - const pin = self.left_click_pin orelse return null; - if (self.left_click_screen != screens.active_key) return null; - _ = self.leftClickPageList(screens) orelse return null; + const gesture = &self.selection_gesture; + const pin = gesture.left_click_pin orelse return null; + if (gesture.left_click_screen != screens.active_key) return null; + if (screens.generation(gesture.left_click_screen) != gesture.left_click_screen_generation) return null; + _ = screens.get(gesture.left_click_screen) orelse return null; return pin; } }; @@ -839,6 +818,7 @@ pub fn deinit(self: *Surface) void { self.renderer_thread.deinit(); self.renderer.deinit(); self.io_thread.deinit(); + self.mouse.selection_gesture.deinit(&self.io.terminal); self.io.deinit(); if (self.inspector) |v| { @@ -1198,7 +1178,7 @@ fn selectionScrollTick(self: *Surface) !void { // If we don't have a left mouse button down then we // don't do anything. - if (self.mouse.left_click_count == 0) return; + if (self.mouse.selection_gesture.left_click_count == 0) return; const pos = try self.rt_surface.getCursorPos(); const pos_vp = self.posToViewport(pos.x, pos.y); @@ -3781,7 +3761,7 @@ pub fn mouseButtonCallback( // We could do all the conditionals in one but I find it more // readable as a human to break this one up. if (mods.shift and - self.mouse.left_click_count > 0 and + self.mouse.selection_gesture.left_click_count > 0 and !shift_capture) extend_selection: { // We split this conditional out on its own because this is the @@ -3792,7 +3772,9 @@ pub fn mouseButtonCallback( // If we are within the interval that the click would register // an increment then we do not extend the selection. if (std.time.Instant.now()) |now| { - const since = now.since(self.mouse.left_click_time); + const click_time = self.mouse.selection_gesture.left_click_time orelse + break :extend_selection; + const since = now.since(click_time); if (since <= self.config.mouse_interval) { // Click interval very short, we may be increasing // click counts so we don't extend the selection. @@ -3880,7 +3862,7 @@ pub fn mouseButtonCallback( // We also set the left click count to 0 so that if mouse reporting // is disabled in the middle of press (before release) we don't // suddenly start selecting text. - self.mouse.left_click_count = 0; + self.mouse.selection_gesture.reset(self.renderer_state.terminal); const pos = try self.rt_surface.getCursorPos(); @@ -3927,60 +3909,27 @@ pub fn mouseButtonCallback( break :click; }; - break :pin try screen.pages.trackPin(pin); + break :pin pin; }; - errdefer screen.pages.untrackPin(pin); - // If we move our cursor too much between clicks then we reset - // the multi-click state. - if (self.mouse.left_click_count > 0) { - const max_distance: f64 = @floatFromInt(self.size.cell.width); - const distance = @sqrt( - std.math.pow(f64, pos.x - self.mouse.left_click_xpos, 2) + - std.math.pow(f64, pos.y - self.mouse.left_click_ypos, 2), - ); - - if (distance > max_distance) self.mouse.left_click_count = 0; - } - - if (self.mouse.left_click_pin) |prev| { - if (self.mouse.leftClickPageList(&t.screens)) |pages| pages.untrackPin(prev); - self.mouse.left_click_pin = null; - } - - // Store it - self.mouse.left_click_pin = pin; - self.mouse.left_click_screen = t.screens.active_key; - self.mouse.left_click_screen_generation = t.screens.generation(t.screens.active_key); - self.mouse.left_click_xpos = pos.x; - self.mouse.left_click_ypos = pos.y; - - // Setup our click counter and timer - if (std.time.Instant.now()) |now| { - // If we have mouse clicks, then we check if the time elapsed - // is less than and our interval and if so, increase the count. - if (self.mouse.left_click_count > 0) { - const since = now.since(self.mouse.left_click_time); - if (since > self.config.mouse_interval) { - self.mouse.left_click_count = 0; - } - } - - self.mouse.left_click_time = now; - self.mouse.left_click_count += 1; - - // We only support up to triple-clicks. - if (self.mouse.left_click_count > 3) self.mouse.left_click_count = 1; - } else |err| { - self.mouse.left_click_count = 1; + const time = std.time.Instant.now() catch |err| time: { log.err("error reading time, mouse multi-click won't work err={}", .{err}); - } + break :time null; + }; + try self.mouse.selection_gesture.press(t, .{ + .time = time, + .pin = pin, + .xpos = pos.x, + .ypos = pos.y, + .max_distance = @floatFromInt(self.size.cell.width), + .repeat_interval = self.config.mouse_interval, + }); // In all cases below, we set the selection directly rather than use // `setSelection` because we want to avoid copying the selection // to the selection clipboard. For left mouse clicks we only set // the clipboard on release. - switch (self.mouse.left_click_count) { + switch (self.mouse.selection_gesture.left_click_count) { // Single click 1 => { // If we have a selection, clear it. This always happens. @@ -3996,7 +3945,7 @@ pub fn mouseButtonCallback( const sel_ = sel: { // Try link detection without requiring modifier keys if (self.linkAtPin( - pin.*, + pin, null, )) |result_| { if (result_) |result| { @@ -4006,7 +3955,7 @@ pub fn mouseButtonCallback( // Ignore any errors, likely regex errors. } - break :sel self.io.terminal.screens.active.selectWord(pin.*, self.config.selection_word_chars); + break :sel self.io.terminal.screens.active.selectWord(pin, self.config.selection_word_chars); }; if (sel_) |sel| { try self.io.terminal.screens.active.select(sel); @@ -4017,9 +3966,9 @@ pub fn mouseButtonCallback( // Triple click, select the line under our mouse 3 => { const sel_ = if (mods.ctrlOrSuper()) - self.io.terminal.screens.active.selectOutput(pin.*) + self.io.terminal.screens.active.selectOutput(pin) else - self.io.terminal.screens.active.selectLine(.{ .pin = pin.* }); + self.io.terminal.screens.active.selectLine(.{ .pin = pin }); if (sel_) |sel| { try self.io.terminal.screens.active.select(sel); try self.queueRender(); @@ -4645,7 +4594,7 @@ pub fn cursorPosCallback( // In this scenario, we mark the click state because we need that to // properly make some mouse reports, but we don't keep track of the // count because we don't want to handle selection. - if (self.mouse.left_click_count == 0) break :select; + if (self.mouse.selection_gesture.left_click_count == 0) break :select; // If our left-click pin no longer belongs to the active screen then we // don't process this. We don't invalidate our pin or mouse state @@ -4689,7 +4638,7 @@ pub fn cursorPosCallback( }; // Handle dragging depending on click count - switch (self.mouse.left_click_count) { + switch (self.mouse.selection_gesture.left_click_count) { 1 => try self.dragLeftClickSingle(pin, pos.x), 2 => try self.dragLeftClickDouble(pin), 3 => try self.dragLeftClickTriple(pin), @@ -4791,7 +4740,7 @@ fn dragLeftClickSingle( try self.io.terminal.screens.active.select(mouseSelection( click_pin, drag_pin, - @intFromFloat(@max(0.0, self.mouse.left_click_xpos)), + @intFromFloat(@max(0.0, self.mouse.selection_gesture.left_click_xpos)), @intFromFloat(@max(0.0, drag_x)), self.mouse.mods, self.size, diff --git a/src/inspector/widgets/surface.zig b/src/inspector/widgets/surface.zig index d73e784ce..c2dd6ab1d 100644 --- a/src/inspector/widgets/surface.zig +++ b/src/inspector/widgets/surface.zig @@ -462,7 +462,7 @@ fn mouseTable( { const left_click_point: terminal.point.Coordinate = pt: { - const p = surface_mouse.left_click_pin orelse break :pt .{}; + const p = surface_mouse.selection_gesture.left_click_pin orelse break :pt .{}; const pt = t.screens.active.pages.pointFromPin( .active, p.*, @@ -495,8 +495,8 @@ fn mouseTable( _ = cimgui.c.ImGui_TableSetColumnIndex(1); cimgui.c.ImGui_Text( "(%dpx, %dpx)", - @as(u32, @intFromFloat(surface_mouse.left_click_xpos)), - @as(u32, @intFromFloat(surface_mouse.left_click_ypos)), + @as(u32, @intFromFloat(surface_mouse.selection_gesture.left_click_xpos)), + @as(u32, @intFromFloat(surface_mouse.selection_gesture.left_click_ypos)), ); } } From 37997f8dbe1221f19343e6b9fa907ce2b944f1a2 Mon Sep 17 00:00:00 2001 From: Daniel Kinzler Date: Tue, 26 May 2026 18:26:17 +0200 Subject: [PATCH 097/137] Use a timeout callback to wait for changes in window active state to settle. Depending on the backend a window might temporarily become inactive. Fixes an issue where quick-terminal would disappear when opening the surface context menu. --- src/apprt/gtk/class/window.zig | 62 +++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 0c8dfaa7c..7294f2aa3 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -220,6 +220,9 @@ pub const Window = extern struct { /// behaves slightly differently under certain scenarios. quick_terminal: bool = false, + /// Timeout source to react to this window becoming (in)active. + timeout: ?c_uint = null, + /// The window decoration override. If this is not set then we'll /// inherit whatever the config has. This allows overriding the /// config on a per-window basis. @@ -855,6 +858,35 @@ pub const Window = extern struct { } } + fn onTimeout(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const priv = self.private(); + priv.timeout = null; + + // Hide quick-terminal if set to autohide + if (self.isQuickTerminal()) { + if (self.getConfig()) |cfg| { + if (cfg.get().@"quick-terminal-autohide" and + self.as(gtk.Window).isActive() == 0 and + self.as(gtk.Widget).isVisible() == 1) + { + self.toggleVisibility(); + } + } + } + + // Don't change urgency if we're not the active window. + if (self.as(gtk.Window).isActive() == 0) return 0; + + self.winproto().setUrgent(false) catch |err| { + log.warn( + "winproto failed to reset urgency={}", + .{err}, + ); + }; + return 0; + } + //--------------------------------------------------------------- // Properties @@ -1076,27 +1108,17 @@ pub const Window = extern struct { _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { - // Hide quick-terminal if set to autohide - if (self.isQuickTerminal()) { - if (self.getConfig()) |cfg| { - if (cfg.get().@"quick-terminal-autohide" and - self.as(gtk.Window).isActive() == 0 and - self.as(gtk.Widget).isVisible() == 1) - { - self.toggleVisibility(); - } - } - } + const priv = self.private(); - // Don't change urgency if we're not the active window. - if (self.as(gtk.Window).isActive() == 0) return; - - self.winproto().setUrgent(false) catch |err| { - log.warn( - "winproto failed to reset urgency={}", - .{err}, - ); - }; + // Use a timeout callback to wait for focus state to settle, + // because depending on the windowing backend the window might + // become inactive and immediately active again. This happens + // e.g. on Wayland when opening a context menu. + if (priv.timeout == null) priv.timeout = glib.timeoutAdd( + 100, + onTimeout, + self, + ); } fn propGdkSurfaceDims( From 57d202066d138e9078f89c9b27302a5aee6b9422 Mon Sep 17 00:00:00 2001 From: Adam Bouker Date: Mon, 27 Apr 2026 11:35:04 -0500 Subject: [PATCH 098/137] macOS: clear stale OSC 11 background cache on config change SurfaceView caches the background color set by OSC 11 in backgroundColor. TerminalWindow.preferredBackgroundColor consults that cache before falling back to derivedConfig.backgroundColor, so once OSC 11 has fired the cached value masks any later config change. After a light/dark theme auto-switch this leaves the window chrome on the previous theme's color until the application next emits OSC 11. In ghosttyConfigDidChange, after updating derivedConfig, drop the cache when it no longer matches the new config-derived background. A subsequent ghosttyColorDidChange repopulates it as before, so within-config OSC 11 behavior is unchanged. --- .../Ghostty/Surface View/SurfaceView_AppKit.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 887482b30..c0013ec78 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -725,7 +725,19 @@ extension Ghostty { // Update our derived config DispatchQueue.main.async { [weak self] in - self?.derivedConfig = DerivedConfig(config) + guard let self else { return } + self.derivedConfig = DerivedConfig(config) + + // If the cached OSC 11 background color disagrees with the new + // config-derived background, drop it so window chrome follows + // the new config (e.g., on light/dark theme auto-switch). The + // cached value is restored next time the terminal emits a + // color_change. + if let cached = self.backgroundColor, + cached != self.derivedConfig.backgroundColor + { + self.backgroundColor = nil + } } } From 33f1558801d5282e9b2fb7b35194fed69d98f167 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 26 May 2026 17:00:45 -0700 Subject: [PATCH 099/137] core: mouse left release renderer lock made more coarse This will make our selection gesture extraction a bit easier. --- src/Surface.zig | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 2ba4354b5..40d85bda9 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3796,12 +3796,15 @@ pub fn mouseButtonCallback( } if (button == .left and action == .release) { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + // Stop selection scrolling when releasing the left mouse button // but only when selection scrolling is active. if (self.selection_scroll_active) { self.queueIo( .{ .selection_scroll = false }, - .unlocked, + .locked, ); } @@ -3809,8 +3812,6 @@ pub fn mouseButtonCallback( // the left button is released. This is to avoid the clipboard // being updated on every mouse move which would be noisy. if (self.config.copy_on_select != .false) { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); const prev_ = self.io.terminal.screens.active.selection; if (prev_) |prev| { try self.setSelection(terminal.Selection.init( @@ -3825,9 +3826,9 @@ pub fn mouseButtonCallback( // reporting or any other mouse handling because a successfully // clicked link will swallow the event. if (self.mouse.over_link) { + // We are holding the renderer lock, but this should just be + // a cached value. const pos = try self.rt_surface.getCursorPos(); - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); if (self.processLinks(pos)) |processed| { if (processed) return true; } else |err| { @@ -4105,9 +4106,8 @@ pub fn mouseButtonCallback( return false; } +/// Requires the renderer state mutex is held. fn maybePromptClick(self: *Surface) !bool { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); const t: *terminal.Terminal = self.renderer_state.terminal; const screen: *terminal.Screen = t.screens.active; From c00cdd886b933cd7db175ddcf031c2e703ea1409 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 26 May 2026 20:34:44 -0700 Subject: [PATCH 100/137] SelectionGesture: drag events --- src/Surface.zig | 683 +++------------------------- src/terminal/SelectionGesture.zig | 711 ++++++++++++++++++++++++++++++ 2 files changed, 764 insertions(+), 630 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 40d85bda9..248cccea1 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -250,12 +250,7 @@ const Mouse = struct { /// Return the left-click pin only if it still belongs to the active screen. fn activeLeftClickPin(self: *const Mouse, screens: *const terminal.ScreenSet) ?*terminal.Pin { - const gesture = &self.selection_gesture; - const pin = gesture.left_click_pin orelse return null; - if (gesture.left_click_screen != screens.active_key) return null; - if (screens.generation(gesture.left_click_screen) != gesture.left_click_screen_generation) return null; - _ = screens.get(gesture.left_click_screen) orelse return null; - return pin; + return self.selection_gesture.validatedLeftClickPin(screens); } }; @@ -1180,9 +1175,14 @@ fn selectionScrollTick(self: *Surface) !void { // don't do anything. if (self.mouse.selection_gesture.left_click_count == 0) return; + const delta: isize = switch (self.mouse.selection_gesture.left_drag_autoscroll) { + .none => return, + .up => -1, + .down => 1, + }; + const pos = try self.rt_surface.getCursorPos(); const pos_vp = self.posToViewport(pos.x, pos.y); - const delta: isize = if (pos.y < 0) -1 else 1; // We need our locked state for the remainder self.renderer_state.mutex.lock(); @@ -1212,7 +1212,22 @@ fn selectionScrollTick(self: *Surface) !void { if (comptime std.debug.runtime_safety) unreachable; return; }; - try self.dragLeftClickSingle(pin, pos.x); + if (self.mouse.selection_gesture.drag(t, .{ + .pin = pin, + .xpos = pos.x, + .ypos = pos.y, + .rectangle = SurfaceMouse.isRectangleSelectState(self.mouse.mods), + .geometry = .{ + .columns = @intCast(self.size.grid().columns), + .cell_width = self.size.cell.width, + .padding_left = self.size.padding.left, + .screen_height = self.size.screen.height, + }, + })) |sel| { + try self.io.terminal.screens.active.select(sel); + } else { + try self.io.terminal.screens.active.select(null); + } // We modified our viewport and selection so we need to queue // a render. @@ -3807,6 +3822,7 @@ pub fn mouseButtonCallback( .locked, ); } + self.mouse.selection_gesture.left_drag_autoscroll = .none; // The selection clipboard is only updated for left-click drag when // the left button is released. This is to avoid the clipboard @@ -4515,15 +4531,6 @@ pub fn cursorPosCallback( self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - // Stop selection scrolling when inside the viewport within a 1px buffer - // for fullscreen windows, but only when selection scrolling is active. - if (pos.y >= 1 and self.selection_scroll_active) { - self.queueIo( - .{ .selection_scroll = false }, - .locked, - ); - } - // Update our mouse state. We set this to null initially because we only // want to set it when we're not selecting or doing any other mouse // event. @@ -4606,25 +4613,6 @@ pub fn cursorPosCallback( // All roads lead to requiring a re-render at this point. try self.queueRender(); - // If our y is negative, we're above the window. In this case, we scroll - // up. The amount we scroll up is dependent on how negative we are. - // We allow for a 1 pixel buffer at the top and bottom to detect - // scroll even in full screen windows. - // Note: one day, we can change this from distance to time based if we want. - //log.warn("CURSOR POS: {} {}", .{ pos, self.size.screen }); - const max_y: f32 = @floatFromInt(self.size.screen.height); - - // If the mouse is outside the viewport and we have the left - // mouse button pressed then we need to start the scroll timer. - if ((pos.y <= 1 or pos.y > max_y - 1) and - !self.selection_scroll_active) - { - self.queueIo( - .{ .selection_scroll = true }, - .locked, - ); - } - // Convert to points const screen: *terminal.Screen = t.screens.active; const pin = screen.pages.pin(.{ @@ -4637,9 +4625,37 @@ pub fn cursorPosCallback( return; }; + const drag_selection = self.mouse.selection_gesture.drag(t, .{ + .pin = pin, + .xpos = pos.x, + .ypos = pos.y, + .rectangle = SurfaceMouse.isRectangleSelectState(self.mouse.mods), + .geometry = .{ + .columns = @intCast(self.size.grid().columns), + .cell_width = self.size.cell.width, + .padding_left = self.size.padding.left, + .screen_height = self.size.screen.height, + }, + }); + + switch (self.mouse.selection_gesture.left_drag_autoscroll) { + .none => if (self.selection_scroll_active) { + self.queueIo( + .{ .selection_scroll = false }, + .locked, + ); + }, + .up, .down => if (!self.selection_scroll_active) { + self.queueIo( + .{ .selection_scroll = true }, + .locked, + ); + }, + } + // Handle dragging depending on click count switch (self.mouse.selection_gesture.left_click_count) { - 1 => try self.dragLeftClickSingle(pin, pos.x), + 1 => try self.io.terminal.screens.active.select(drag_selection), 2 => try self.dragLeftClickDouble(pin), 3 => try self.dragLeftClickTriple(pin), 0 => unreachable, // handled above @@ -4726,172 +4742,6 @@ fn dragLeftClickTriple( try self.io.terminal.screens.active.select(sel); } -fn dragLeftClickSingle( - self: *Surface, - drag_pin: terminal.Pin, - drag_x: f64, -) !void { - // This logic is in a separate function so that it can be unit tested. - const click_pin: terminal.Pin = pin: { - const set: *terminal.ScreenSet = &self.io.terminal.screens; - const tracked = self.mouse.activeLeftClickPin(set) orelse return; - break :pin tracked.*; - }; - try self.io.terminal.screens.active.select(mouseSelection( - click_pin, - drag_pin, - @intFromFloat(@max(0.0, self.mouse.selection_gesture.left_click_xpos)), - @intFromFloat(@max(0.0, drag_x)), - self.mouse.mods, - self.size, - )); -} - -/// Calculates the appropriate selection given pins and pixel x positions for -/// the click point and the drag point, as well as mouse mods and screen size. -fn mouseSelection( - click_pin: terminal.Pin, - drag_pin: terminal.Pin, - click_x: u32, - drag_x: u32, - mods: input.Mods, - size: rendererpkg.Size, -) ?terminal.Selection { - // Explanation: - // - // # Normal selections - // - // ## Left-to-right selections - // - The clicked cell is included if it was clicked to the left of its - // threshold point and the drag location is right of the threshold point. - // - The cell under the cursor (the "drag cell") is included if the drag - // location is right of its threshold point. - // - // ## Right-to-left selections - // - The clicked cell is included if it was clicked to the right of its - // threshold point and the drag location is left of the threshold point. - // - The cell under the cursor (the "drag cell") is included if the drag - // location is left of its threshold point. - // - // # Rectangular selections - // - // Rectangular selections are handled similarly, except that - // entire columns are considered rather than individual cells. - - // We only include cells in the selection if the threshold point lies - // between the start and end points of the selection. A threshold of - // 60% of the cell width was chosen empirically because it felt good. - const threshold_point: u32 = @intFromFloat(@round( - @as(f64, @floatFromInt(size.cell.width)) * 0.6, - )); - - // We use this to clamp the pixel positions below. - const max_x = size.grid().columns * size.cell.width - 1; - - // We need to know how far across in the cell the drag pos is, so - // we subtract the padding and then take it modulo the cell width. - const drag_x_frac = @min(max_x, drag_x -| size.padding.left) % size.cell.width; - - // We figure out the fractional part of the click x position similarly. - const click_x_frac = @min(max_x, click_x -| size.padding.left) % size.cell.width; - - // Whether or not this is a rectangular selection. - const rectangle_selection = SurfaceMouse.isRectangleSelectState(mods); - - // Whether the click pin and drag pin are equal. - const same_pin = drag_pin.eql(click_pin); - - // Whether or not the end point of our selection is before the start point. - const end_before_start = ebs: { - if (same_pin) { - break :ebs drag_x_frac < click_x_frac; - } - - // Special handling for rectangular selections, we only use x position. - if (rectangle_selection) { - break :ebs switch (std.math.order(drag_pin.x, click_pin.x)) { - .eq => drag_x_frac < click_x_frac, - .lt => true, - .gt => false, - }; - } - - break :ebs drag_pin.before(click_pin); - }; - - // Whether or not the click pin cell - // should be included in the selection. - const include_click_cell = if (end_before_start) - click_x_frac >= threshold_point - else - click_x_frac < threshold_point; - - // Whether or not the drag pin cell - // should be included in the selection. - const include_drag_cell = if (end_before_start) - drag_x_frac < threshold_point - else - drag_x_frac >= threshold_point; - - // If the click cell should be included in the selection then it's the - // start, otherwise we get the previous or next cell to it depending on - // the type and direction of the selection. - const start_pin = - if (include_click_cell) - click_pin - else if (end_before_start) - if (rectangle_selection) - click_pin.leftClamp(1) - else - click_pin.leftWrap(1) orelse click_pin - else if (rectangle_selection) - click_pin.rightClamp(1) - else - click_pin.rightWrap(1) orelse click_pin; - - // Likewise for the end pin with the drag cell. - const end_pin = - if (include_drag_cell) - drag_pin - else if (end_before_start) - if (rectangle_selection) - drag_pin.rightClamp(1) - else - drag_pin.rightWrap(1) orelse drag_pin - else if (rectangle_selection) - drag_pin.leftClamp(1) - else - drag_pin.leftWrap(1) orelse drag_pin; - - // If the click cell is the same as the drag cell and the click cell - // shouldn't be included, or if the cells are adjacent such that the - // start or end pin becomes the other cell, and that cell should not - // be included, then we have no selection, so we set it to null. - // - // If in rectangular selection mode, we compare columns as well. - // - // TODO(qwerasd): this can/should probably be refactored, it's a bit - // repetitive and does excess work in rectangle mode. - if ((!include_click_cell and same_pin) or - (!include_click_cell and rectangle_selection and click_pin.x == drag_pin.x) or - (!include_click_cell and end_pin.eql(click_pin)) or - (!include_click_cell and rectangle_selection and end_pin.x == click_pin.x) or - (!include_drag_cell and start_pin.eql(drag_pin)) or - (!include_drag_cell and rectangle_selection and start_pin.x == drag_pin.x)) - { - return null; - } - - // TODO: Clamp selection to the screen area, don't - // let it extend past the last written row. - - return .init( - start_pin, - end_pin, - rectangle_selection, - ); -} - /// Call to notify Ghostty that the color scheme for the terminal has /// changed. pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) !void { @@ -6220,436 +6070,9 @@ fn presentSurface(self: *Surface) !void { ); } -/// Utility function for the unit tests for mouse selection logic. -/// -/// Tests a click and drag on a 10x5 cell grid, x positions are given in -/// fractional cells, e.g. 3.1 would be 10% through the cell at x = 3. -/// -/// NOTE: The size tested with has 10px wide cells, meaning only one digit -/// after the decimal place has any meaning, e.g. 3.14 is equal to 3.1. -/// -/// The provided start_x/y and end_x/y are the expected start and end points -/// of the resulting selection. -fn testMouseSelection( - click_x: f64, - click_y: u32, - drag_x: f64, - drag_y: u32, - start_x: terminal.size.CellCountInt, - start_y: u32, - end_x: terminal.size.CellCountInt, - end_y: u32, - rect: bool, -) !void { - assert(builtin.is_test); - - // Our screen size is 10x5 cells that are - // 10x20 px, with 5px padding on all sides. - const size: rendererpkg.Size = .{ - .cell = .{ .width = 10, .height = 20 }, - .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, - .screen = .{ .width = 110, .height = 110 }, - }; - var screen = try terminal.Screen.init(std.testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); - defer screen.deinit(); - - // We hold both ctrl and alt for rectangular - // select so that this test is platform agnostic. - const mods: input.Mods = .{ - .ctrl = rect, - .alt = rect, - }; - - try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods)); - - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, - }) orelse unreachable; - - const cell_width_f64: f64 = @floatFromInt(size.cell.width); - const click_x_pos: u32 = - @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + - size.padding.left; - const drag_x_pos: u32 = - @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + - size.padding.left; - - const start_pin = screen.pages.pin(.{ - .viewport = .{ .x = start_x, .y = start_y }, - }) orelse unreachable; - const end_pin = screen.pages.pin(.{ - .viewport = .{ .x = end_x, .y = end_y }, - }) orelse unreachable; - - try std.testing.expectEqualDeep(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = rect, - }, mouseSelection( - click_pin, - drag_pin, - click_x_pos, - drag_x_pos, - mods, - size, - )); -} - -/// Like `testMouseSelection` but checks that the resulting selection is null. -/// -/// See `testMouseSelection` for more details. -fn testMouseSelectionIsNull( - click_x: f64, - click_y: u32, - drag_x: f64, - drag_y: u32, - rect: bool, -) !void { - assert(builtin.is_test); - - // Our screen size is 10x5 cells that are - // 10x20 px, with 5px padding on all sides. - const size: rendererpkg.Size = .{ - .cell = .{ .width = 10, .height = 20 }, - .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, - .screen = .{ .width = 110, .height = 110 }, - }; - var screen = try terminal.Screen.init(std.testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); - defer screen.deinit(); - - // We hold both ctrl and alt for rectangular - // select so that this test is platform agnostic. - const mods: input.Mods = .{ - .ctrl = rect, - .alt = rect, - }; - - try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods)); - - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, - }) orelse unreachable; - - const cell_width_f64: f64 = @floatFromInt(size.cell.width); - const click_x_pos: u32 = - @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + - size.padding.left; - const drag_x_pos: u32 = - @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + - size.padding.left; - - try std.testing.expectEqual( - null, - mouseSelection( - click_pin, - drag_pin, - click_x_pos, - drag_x_pos, - mods, - size, - ), - ); -} - /// Get information about the process(es) running within the surface. Returns /// `null` if there was an error getting the information or the information is /// not available on a particular platform. pub fn getProcessInfo(self: *Surface, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { return self.io.getProcessInfo(info); } - -test "Surface: selection logic" { - // We disable format to make these easier to - // read by pairing sets of coordinates per line. - // zig fmt: off - - // -- LTR - // single cell selection - try testMouseSelection( - 3.0, 3, // click - 3.9, 3, // drag - 3, 3, // expected start - 3, 3, // expected end - false, // regular selection - ); - // including click and drag pin cells - try testMouseSelection( - 3.0, 3, // click - 5.9, 3, // drag - 3, 3, // expected start - 5, 3, // expected end - false, // regular selection - ); - // including click pin cell but not drag pin cell - try testMouseSelection( - 3.0, 3, // click - 5.0, 3, // drag - 3, 3, // expected start - 4, 3, // expected end - false, // regular selection - ); - // including drag pin cell but not click pin cell - try testMouseSelection( - 3.9, 3, // click - 5.9, 3, // drag - 4, 3, // expected start - 5, 3, // expected end - false, // regular selection - ); - // including neither click nor drag pin cells - try testMouseSelection( - 3.9, 3, // click - 5.0, 3, // drag - 4, 3, // expected start - 4, 3, // expected end - false, // regular selection - ); - // empty selection (single cell on only left half) - try testMouseSelectionIsNull( - 3.0, 3, // click - 3.1, 3, // drag - false, // regular selection - ); - // empty selection (single cell on only right half) - try testMouseSelectionIsNull( - 3.8, 3, // click - 3.9, 3, // drag - false, // regular selection - ); - // empty selection (between two cells, not crossing threshold) - try testMouseSelectionIsNull( - 3.9, 3, // click - 4.0, 3, // drag - false, // regular selection - ); - - // -- RTL - // single cell selection - try testMouseSelection( - 3.9, 3, // click - 3.0, 3, // drag - 3, 3, // expected start - 3, 3, // expected end - false, // regular selection - ); - // including click and drag pin cells - try testMouseSelection( - 5.9, 3, // click - 3.0, 3, // drag - 5, 3, // expected start - 3, 3, // expected end - false, // regular selection - ); - // including click pin cell but not drag pin cell - try testMouseSelection( - 5.9, 3, // click - 3.9, 3, // drag - 5, 3, // expected start - 4, 3, // expected end - false, // regular selection - ); - // including drag pin cell but not click pin cell - try testMouseSelection( - 5.0, 3, // click - 3.0, 3, // drag - 4, 3, // expected start - 3, 3, // expected end - false, // regular selection - ); - // including neither click nor drag pin cells - try testMouseSelection( - 5.0, 3, // click - 3.9, 3, // drag - 4, 3, // expected start - 4, 3, // expected end - false, // regular selection - ); - // empty selection (single cell on only left half) - try testMouseSelectionIsNull( - 3.1, 3, // click - 3.0, 3, // drag - false, // regular selection - ); - // empty selection (single cell on only right half) - try testMouseSelectionIsNull( - 3.9, 3, // click - 3.8, 3, // drag - false, // regular selection - ); - // empty selection (between two cells, not crossing threshold) - try testMouseSelectionIsNull( - 4.0, 3, // click - 3.9, 3, // drag - false, // regular selection - ); - - // -- Wrapping - // LTR, wrap excluded cells - try testMouseSelection( - 9.9, 2, // click - 0.0, 4, // drag - 0, 3, // expected start - 9, 3, // expected end - false, // regular selection - ); - // RTL, wrap excluded cells - try testMouseSelection( - 0.0, 4, // click - 9.9, 2, // drag - 9, 3, // expected start - 0, 3, // expected end - false, // regular selection - ); -} - -test "Surface: rectangle selection logic" { - // We disable format to make these easier to - // read by pairing sets of coordinates per line. - // zig fmt: off - - // -- LTR - // single column selection - try testMouseSelection( - 3.0, 2, // click - 3.9, 4, // drag - 3, 2, // expected start - 3, 4, // expected end - true, //rectangle selection - ); - // including click and drag pin columns - try testMouseSelection( - 3.0, 2, // click - 5.9, 4, // drag - 3, 2, // expected start - 5, 4, // expected end - true, //rectangle selection - ); - // including click pin column but not drag pin column - try testMouseSelection( - 3.0, 2, // click - 5.0, 4, // drag - 3, 2, // expected start - 4, 4, // expected end - true, //rectangle selection - ); - // including drag pin column but not click pin column - try testMouseSelection( - 3.9, 2, // click - 5.9, 4, // drag - 4, 2, // expected start - 5, 4, // expected end - true, //rectangle selection - ); - // including neither click nor drag pin columns - try testMouseSelection( - 3.9, 2, // click - 5.0, 4, // drag - 4, 2, // expected start - 4, 4, // expected end - true, //rectangle selection - ); - // empty selection (single column on only left half) - try testMouseSelectionIsNull( - 3.0, 2, // click - 3.1, 4, // drag - true, //rectangle selection - ); - // empty selection (single column on only right half) - try testMouseSelectionIsNull( - 3.8, 2, // click - 3.9, 4, // drag - true, //rectangle selection - ); - // empty selection (between two columns, not crossing threshold) - try testMouseSelectionIsNull( - 3.9, 2, // click - 4.0, 4, // drag - true, //rectangle selection - ); - - // -- RTL - // single column selection - try testMouseSelection( - 3.9, 2, // click - 3.0, 4, // drag - 3, 2, // expected start - 3, 4, // expected end - true, //rectangle selection - ); - // including click and drag pin columns - try testMouseSelection( - 5.9, 2, // click - 3.0, 4, // drag - 5, 2, // expected start - 3, 4, // expected end - true, //rectangle selection - ); - // including click pin column but not drag pin column - try testMouseSelection( - 5.9, 2, // click - 3.9, 4, // drag - 5, 2, // expected start - 4, 4, // expected end - true, //rectangle selection - ); - // including drag pin column but not click pin column - try testMouseSelection( - 5.0, 2, // click - 3.0, 4, // drag - 4, 2, // expected start - 3, 4, // expected end - true, //rectangle selection - ); - // including neither click nor drag pin columns - try testMouseSelection( - 5.0, 2, // click - 3.9, 4, // drag - 4, 2, // expected start - 4, 4, // expected end - true, //rectangle selection - ); - // empty selection (single column on only left half) - try testMouseSelectionIsNull( - 3.1, 2, // click - 3.0, 4, // drag - true, //rectangle selection - ); - // empty selection (single column on only right half) - try testMouseSelectionIsNull( - 3.9, 2, // click - 3.8, 4, // drag - true, //rectangle selection - ); - // empty selection (between two columns, not crossing threshold) - try testMouseSelectionIsNull( - 4.0, 2, // click - 3.9, 4, // drag - true, //rectangle selection - ); - - // -- Wrapping - // LTR, do not wrap - try testMouseSelection( - 9.9, 2, // click - 0.0, 4, // drag - 9, 2, // expected start - 0, 4, // expected end - true, //rectangle selection - ); - // RTL, do not wrap - try testMouseSelection( - 0.0, 4, // click - 9.9, 2, // drag - 0, 4, // expected start - 9, 2, // expected end - true, //rectangle selection - ); -} diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index a85e22a2a..73904844c 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -9,7 +9,9 @@ const testing = std.testing; const Allocator = std.mem.Allocator; const PageList = @import("PageList.zig"); const Pin = PageList.Pin; +const Screen = @import("Screen.zig"); const ScreenSet = @import("ScreenSet.zig"); +const Selection = @import("Selection.zig"); const Terminal = @import("Terminal.zig"); /// The tracked pin of the initial left click along with the screen @@ -30,6 +32,27 @@ left_click_time: ?std.time.Instant, left_click_xpos: f64, left_click_ypos: f64, +/// The current autoscroll state for the active left-click drag gesture. +left_drag_autoscroll: Autoscroll, + +/// The direction that selection dragging should autoscroll the viewport. +/// This is derived from the most recent drag position relative to the +/// surface bounds and reset whenever there is no active drag gesture. +/// +/// When autoscroll is non-none, the caller should setup a timer +/// to periodically scroll the screen the desired direction a certain +/// amount. The timer and amount is up to the caller but reasonable +/// defaults are approximately one row every 15 milliseconds. +/// +/// This is used to implement selection above/below the viewport that +/// wants to drag the viewport. +pub const Autoscroll = enum { none, up, down }; + +/// Distance from the top or bottom surface edge, in pixels, where dragging +/// should request autoscroll. This preserves the historical 1px buffer used +/// so fullscreen-edge drags can still trigger autoscroll. +const autoscroll_buffer: f64 = 1; + pub const init: SelectionGesture = .{ .left_click_pin = null, .left_click_count = 0, @@ -38,6 +61,7 @@ pub const init: SelectionGesture = .{ .left_click_screen_generation = 0, .left_click_xpos = 0, .left_click_ypos = 0, + .left_drag_autoscroll = .none, }; pub fn deinit(self: *SelectionGesture, t: *Terminal) void { @@ -53,9 +77,24 @@ pub fn deinit(self: *SelectionGesture, t: *Terminal) void { pub fn reset(self: *SelectionGesture, t: *Terminal) void { self.left_click_count = 0; self.left_click_time = null; + self.left_drag_autoscroll = .none; self.untrackPin(t); } +/// Return the tracked left-click pin only if it still belongs to the active +/// screen instance. This validates both the screen key and generation so a pin +/// from a removed, recycled, or inactive screen is never exposed to callers. +pub fn validatedLeftClickPin( + self: *const SelectionGesture, + screens: *const ScreenSet, +) ?*Pin { + const pin = self.left_click_pin orelse return null; + if (self.left_click_screen != screens.active_key) return null; + if (screens.generation(self.left_click_screen) != self.left_click_screen_generation) return null; + _ = screens.get(self.left_click_screen) orelse return null; + return pin; +} + pub const Press = struct { /// The time when the press event occurred. Use a monotonic timer. /// This can be null if you're on a system that doesn't support @@ -101,6 +140,78 @@ pub fn press( try self.pressInitial(t, p); } +pub const Drag = struct { + /// The cell where the current drag position is. This is used + /// synchronously to calculate the selection and is not tracked. + pin: Pin, + + /// The x/y value of the drag relative to the surface with (0,0) being + /// top-left. + xpos: f64, + ypos: f64, + + /// True if the current drag should produce a rectangular selection. + rectangle: bool, + + /// Geometry required for selection threshold and autoscroll calculations. + geometry: Geometry, + + /// Display geometry needed to translate surface-relative pointer positions + /// into selection behavior. + pub const Geometry = struct { + /// The number of columns in the rendered terminal grid. + columns: u32, + + /// The width of one terminal cell in surface pixels. + cell_width: u32, + + /// The left padding before the terminal grid begins, in surface pixels. + padding_left: u32, + + /// The height of the rendered terminal surface in surface pixels. + screen_height: u32, + }; +}; + +/// Record a drag event and return the current untracked drag selection. +pub fn drag( + self: *SelectionGesture, + t: *Terminal, + d: Drag, +) ?Selection { + // If we aren't currently clicked then we don't do any dragging + // behavior. + if (self.left_click_count == 0) { + assert(self.left_drag_autoscroll == .none); + return null; + } + + // Get our click pin. We get a validated pin because if our + // screen changed out from under us then we aren't actually + // clicking anymore. + const click_pin = self.validatedLeftClickPin(&t.screens) orelse + return null; + + // Determine if we should autoscroll. If our drag position is above + // the top, we go up. If its below the bottom we go down. Easy. + const max_y: f64 = @floatFromInt(d.geometry.screen_height); + self.left_drag_autoscroll = if (d.ypos <= autoscroll_buffer) + .up + else if (d.ypos > max_y - autoscroll_buffer) + .down + else + .none; + + return dragSelection( + click_pin.*, + d.pin, + @intFromFloat(@max(0, self.left_click_xpos)), + @intFromFloat(@max(0, d.xpos)), + d.rectangle, + d.geometry, + ); +} + fn pressInitial( self: *SelectionGesture, t: *Terminal, @@ -125,6 +236,7 @@ fn pressInitial( self.left_click_xpos = p.xpos; self.left_click_ypos = p.ypos; self.left_click_time = p.time; + self.left_drag_autoscroll = .none; } fn pressRepeat( @@ -166,12 +278,155 @@ fn pressRepeat( } self.left_click_time = time; + self.left_drag_autoscroll = .none; self.left_click_count = @min( self.left_click_count + 1, 3, // We only support triple clicks max ); } +/// Calculates the appropriate selection given pins and pixel x positions for +/// the click point and the drag point, as well as selection mode and geometry. +fn dragSelection( + click_pin: Pin, + drag_pin: Pin, + click_x: u32, + drag_x: u32, + rectangle_selection: bool, + geometry: Drag.Geometry, +) ?Selection { + // Explanation: + // + // # Normal selections + // + // ## Left-to-right selections + // - The clicked cell is included if it was clicked to the left of its + // threshold point and the drag location is right of the threshold point. + // - The cell under the cursor (the "drag cell") is included if the drag + // location is right of its threshold point. + // + // ## Right-to-left selections + // - The clicked cell is included if it was clicked to the right of its + // threshold point and the drag location is left of the threshold point. + // - The cell under the cursor (the "drag cell") is included if the drag + // location is left of its threshold point. + // + // # Rectangular selections + // + // Rectangular selections are handled similarly, except that + // entire columns are considered rather than individual cells. + + // We only include cells in the selection if the threshold point lies + // between the start and end points of the selection. A threshold of + // 60% of the cell width was chosen empirically because it felt good. + const threshold_point: u32 = @intFromFloat(@round( + @as(f64, @floatFromInt(geometry.cell_width)) * 0.6, + )); + + // We use this to clamp the pixel positions below. + const max_x = geometry.columns * geometry.cell_width - 1; + + // We need to know how far across in the cell the drag pos is, so + // we subtract the padding and then take it modulo the cell width. + const drag_x_frac = @min(max_x, drag_x -| geometry.padding_left) % geometry.cell_width; + + // We figure out the fractional part of the click x position similarly. + const click_x_frac = @min(max_x, click_x -| geometry.padding_left) % geometry.cell_width; + + // Whether the click pin and drag pin are equal. + const same_pin = drag_pin.eql(click_pin); + + // Whether or not the end point of our selection is before the start point. + const end_before_start = ebs: { + if (same_pin) { + break :ebs drag_x_frac < click_x_frac; + } + + // Special handling for rectangular selections, we only use x position. + if (rectangle_selection) { + break :ebs switch (std.math.order(drag_pin.x, click_pin.x)) { + .eq => drag_x_frac < click_x_frac, + .lt => true, + .gt => false, + }; + } + + break :ebs drag_pin.before(click_pin); + }; + + // Whether or not the click pin cell + // should be included in the selection. + const include_click_cell = if (end_before_start) + click_x_frac >= threshold_point + else + click_x_frac < threshold_point; + + // Whether or not the drag pin cell + // should be included in the selection. + const include_drag_cell = if (end_before_start) + drag_x_frac < threshold_point + else + drag_x_frac >= threshold_point; + + // If the click cell should be included in the selection then it's the + // start, otherwise we get the previous or next cell to it depending on + // the type and direction of the selection. + const start_pin = + if (include_click_cell) + click_pin + else if (end_before_start) + if (rectangle_selection) + click_pin.leftClamp(1) + else + click_pin.leftWrap(1) orelse click_pin + else if (rectangle_selection) + click_pin.rightClamp(1) + else + click_pin.rightWrap(1) orelse click_pin; + + // Likewise for the end pin with the drag cell. + const end_pin = + if (include_drag_cell) + drag_pin + else if (end_before_start) + if (rectangle_selection) + drag_pin.rightClamp(1) + else + drag_pin.rightWrap(1) orelse drag_pin + else if (rectangle_selection) + drag_pin.leftClamp(1) + else + drag_pin.leftWrap(1) orelse drag_pin; + + // If the click cell is the same as the drag cell and the click cell + // shouldn't be included, or if the cells are adjacent such that the + // start or end pin becomes the other cell, and that cell should not + // be included, then we have no selection, so we set it to null. + // + // If in rectangular selection mode, we compare columns as well. + // + // TODO(qwerasd): this can/should probably be refactored, it's a bit + // repetitive and does excess work in rectangle mode. + if ((!include_click_cell and same_pin) or + (!include_click_cell and rectangle_selection and click_pin.x == drag_pin.x) or + (!include_click_cell and end_pin.eql(click_pin)) or + (!include_click_cell and rectangle_selection and end_pin.x == click_pin.x) or + (!include_drag_cell and start_pin.eql(drag_pin)) or + (!include_drag_cell and rectangle_selection and start_pin.x == drag_pin.x)) + { + return null; + } + + // TODO: Clamp selection to the screen area, don't + // let it extend past the last written row. + + return .init( + start_pin, + end_pin, + rectangle_selection, + ); +} + fn untrackPin(self: *SelectionGesture, t: *Terminal) void { // Can't untrack unless we have a pin. const pin = self.left_click_pin orelse return; @@ -200,6 +455,435 @@ fn testPress(t: *Terminal, x: u16, y: u32, time: ?std.time.Instant) Press { }; } +fn testDrag(t: *Terminal, x: u16, y: u32, xpos: f64, ypos: f64) Drag { + return .{ + .pin = t.screens.active.pages.pin(.{ .active = .{ + .x = x, + .y = y, + } }).?, + .xpos = xpos, + .ypos = ypos, + .rectangle = false, + .geometry = .{ + .columns = 5, + .cell_width = 10, + .padding_left = 0, + .screen_height = 100, + }, + }; +} + +/// Utility function for the unit tests for drag selection logic. +/// +/// Tests a click and drag on a 10x5 cell grid, x positions are given in +/// fractional cells, e.g. 3.1 would be 10% through the cell at x = 3. +/// +/// NOTE: The geometry tested with has 10px wide cells, meaning only one digit +/// after the decimal place has any meaning, e.g. 3.14 is equal to 3.1. +/// +/// The provided start_x/y and end_x/y are the expected start and end points +/// of the resulting selection. +fn testDragSelection( + click_x: f64, + click_y: u32, + drag_x: f64, + drag_y: u32, + start_x: u16, + start_y: u32, + end_x: u16, + end_y: u32, + rect: bool, +) !void { + assert(@import("builtin").is_test); + + // Our screen size is 10x5 cells that are + // 10x20 px, with 5px padding on all sides. + const geometry: Drag.Geometry = .{ + .columns = 10, + .cell_width = 10, + .padding_left = 5, + .screen_height = 110, + }; + var screen = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer screen.deinit(); + + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, + }) orelse unreachable; + + const cell_width_f64: f64 = @floatFromInt(geometry.cell_width); + const click_x_pos: u32 = + @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + + geometry.padding_left; + const drag_x_pos: u32 = + @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + + geometry.padding_left; + + const start_pin = screen.pages.pin(.{ + .viewport = .{ .x = start_x, .y = start_y }, + }) orelse unreachable; + const end_pin = screen.pages.pin(.{ + .viewport = .{ .x = end_x, .y = end_y }, + }) orelse unreachable; + + try testing.expectEqualDeep(Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = rect, + }, dragSelection( + click_pin, + drag_pin, + click_x_pos, + drag_x_pos, + rect, + geometry, + )); +} + +/// Like `testDragSelection` but checks that the resulting selection is null. +/// +/// See `testDragSelection` for more details. +fn testDragSelectionIsNull( + click_x: f64, + click_y: u32, + drag_x: f64, + drag_y: u32, + rect: bool, +) !void { + assert(@import("builtin").is_test); + + // Our screen size is 10x5 cells that are + // 10x20 px, with 5px padding on all sides. + const geometry: Drag.Geometry = .{ + .columns = 10, + .cell_width = 10, + .padding_left = 5, + .screen_height = 110, + }; + var screen = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer screen.deinit(); + + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, + }) orelse unreachable; + + const cell_width_f64: f64 = @floatFromInt(geometry.cell_width); + const click_x_pos: u32 = + @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + + geometry.padding_left; + const drag_x_pos: u32 = + @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + + geometry.padding_left; + + try testing.expectEqual( + null, + dragSelection( + click_pin, + drag_pin, + click_x_pos, + drag_x_pos, + rect, + geometry, + ), + ); +} + +test "SelectionGesture drag selection logic" { + // We disable format to make these easier to + // read by pairing sets of coordinates per line. + // zig fmt: off + + // -- LTR + // single cell selection + try testDragSelection( + 3.0, 3, // click + 3.9, 3, // drag + 3, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click and drag pin cells + try testDragSelection( + 3.0, 3, // click + 5.9, 3, // drag + 3, 3, // expected start + 5, 3, // expected end + false, // regular selection + ); + // including click pin cell but not drag pin cell + try testDragSelection( + 3.0, 3, // click + 5.0, 3, // drag + 3, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // including drag pin cell but not click pin cell + try testDragSelection( + 3.9, 3, // click + 5.9, 3, // drag + 4, 3, // expected start + 5, 3, // expected end + false, // regular selection + ); + // including neither click nor drag pin cells + try testDragSelection( + 3.9, 3, // click + 5.0, 3, // drag + 4, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // empty selection (single cell on only left half) + try testDragSelectionIsNull( + 3.0, 3, // click + 3.1, 3, // drag + false, // regular selection + ); + // empty selection (single cell on only right half) + try testDragSelectionIsNull( + 3.8, 3, // click + 3.9, 3, // drag + false, // regular selection + ); + // empty selection (between two cells, not crossing threshold) + try testDragSelectionIsNull( + 3.9, 3, // click + 4.0, 3, // drag + false, // regular selection + ); + + // -- RTL + // single cell selection + try testDragSelection( + 3.9, 3, // click + 3.0, 3, // drag + 3, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click and drag pin cells + try testDragSelection( + 5.9, 3, // click + 3.0, 3, // drag + 5, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click pin cell but not drag pin cell + try testDragSelection( + 5.9, 3, // click + 3.9, 3, // drag + 5, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // including drag pin cell but not click pin cell + try testDragSelection( + 5.0, 3, // click + 3.0, 3, // drag + 4, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including neither click nor drag pin cells + try testDragSelection( + 5.0, 3, // click + 3.9, 3, // drag + 4, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // empty selection (single cell on only left half) + try testDragSelectionIsNull( + 3.1, 3, // click + 3.0, 3, // drag + false, // regular selection + ); + // empty selection (single cell on only right half) + try testDragSelectionIsNull( + 3.9, 3, // click + 3.8, 3, // drag + false, // regular selection + ); + // empty selection (between two cells, not crossing threshold) + try testDragSelectionIsNull( + 4.0, 3, // click + 3.9, 3, // drag + false, // regular selection + ); + + // -- Wrapping + // LTR, wrap excluded cells + try testDragSelection( + 9.9, 2, // click + 0.0, 4, // drag + 0, 3, // expected start + 9, 3, // expected end + false, // regular selection + ); + // RTL, wrap excluded cells + try testDragSelection( + 0.0, 4, // click + 9.9, 2, // drag + 9, 3, // expected start + 0, 3, // expected end + false, // regular selection + ); +} + +test "SelectionGesture rectangle drag selection logic" { + // We disable format to make these easier to + // read by pairing sets of coordinates per line. + // zig fmt: off + + // -- LTR + // single column selection + try testDragSelection( + 3.0, 2, // click + 3.9, 4, // drag + 3, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click and drag pin columns + try testDragSelection( + 3.0, 2, // click + 5.9, 4, // drag + 3, 2, // expected start + 5, 4, // expected end + true, //rectangle selection + ); + // including click pin column but not drag pin column + try testDragSelection( + 3.0, 2, // click + 5.0, 4, // drag + 3, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // including drag pin column but not click pin column + try testDragSelection( + 3.9, 2, // click + 5.9, 4, // drag + 4, 2, // expected start + 5, 4, // expected end + true, //rectangle selection + ); + // including neither click nor drag pin columns + try testDragSelection( + 3.9, 2, // click + 5.0, 4, // drag + 4, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // empty selection (single column on only left half) + try testDragSelectionIsNull( + 3.0, 2, // click + 3.1, 4, // drag + true, //rectangle selection + ); + // empty selection (single column on only right half) + try testDragSelectionIsNull( + 3.8, 2, // click + 3.9, 4, // drag + true, //rectangle selection + ); + // empty selection (between two columns, not crossing threshold) + try testDragSelectionIsNull( + 3.9, 2, // click + 4.0, 4, // drag + true, //rectangle selection + ); + + // -- RTL + // single column selection + try testDragSelection( + 3.9, 2, // click + 3.0, 4, // drag + 3, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click and drag pin columns + try testDragSelection( + 5.9, 2, // click + 3.0, 4, // drag + 5, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click pin column but not drag pin column + try testDragSelection( + 5.9, 2, // click + 3.9, 4, // drag + 5, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // including drag pin column but not click pin column + try testDragSelection( + 5.0, 2, // click + 3.0, 4, // drag + 4, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including neither click nor drag pin columns + try testDragSelection( + 5.0, 2, // click + 3.9, 4, // drag + 4, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // empty selection (single column on only left half) + try testDragSelectionIsNull( + 3.1, 2, // click + 3.0, 4, // drag + true, //rectangle selection + ); + // empty selection (single column on only right half) + try testDragSelectionIsNull( + 3.9, 2, // click + 3.8, 4, // drag + true, //rectangle selection + ); + // empty selection (between two columns, not crossing threshold) + try testDragSelectionIsNull( + 4.0, 2, // click + 3.9, 4, // drag + true, //rectangle selection + ); + + // -- Wrapping + // LTR, do not wrap + try testDragSelection( + 9.9, 2, // click + 0.0, 4, // drag + 9, 2, // expected start + 0, 4, // expected end + true, //rectangle selection + ); + // RTL, do not wrap + try testDragSelection( + 0.0, 4, // click + 9.9, 2, // drag + 0, 4, // expected start + 9, 2, // expected end + true, //rectangle selection + ); +} + test "SelectionGesture press records initial click" { var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); defer t.deinit(testing.allocator); @@ -216,6 +900,33 @@ test "SelectionGesture press records initial click" { try testing.expectEqual(@as(f64, 2), gesture.left_click_ypos); } +test "SelectionGesture drag returns selection and records autoscroll" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + try gesture.press(&t, press_event); + + const sel = gesture.drag(&t, testDrag(&t, 3, 1, 39, 50)).?; + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + + try testing.expectEqualDeep(Selection.init( + t.screens.active.pages.pin(.{ .active = .{ .x = 1, .y = 1 } }).?, + t.screens.active.pages.pin(.{ .active = .{ .x = 3, .y = 1 } }).?, + false, + ), sel); + + _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + + _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 100)); + try testing.expectEqual(.down, gesture.left_drag_autoscroll); +} + test "SelectionGesture repeat increments click count" { var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); defer t.deinit(testing.allocator); From 229f4c1f4fdd2a24ff1e2634d6450de158fd987c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 26 May 2026 20:58:14 -0700 Subject: [PATCH 101/137] terminal: SelectionGesture handles word/line drag --- src/Surface.zig | 92 +--------- src/terminal/SelectionGesture.zig | 282 ++++++++++++++++++++++++++++-- 2 files changed, 278 insertions(+), 96 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 248cccea1..f9945efb8 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1217,6 +1217,7 @@ fn selectionScrollTick(self: *Surface) !void { .xpos = pos.x, .ypos = pos.y, .rectangle = SurfaceMouse.isRectangleSelectState(self.mouse.mods), + .word_boundary_codepoints = self.config.selection_word_chars, .geometry = .{ .columns = @intCast(self.size.grid().columns), .cell_width = self.size.cell.width, @@ -4625,11 +4626,13 @@ pub fn cursorPosCallback( return; }; + // Perform our drag behavior in our gesture handler. const drag_selection = self.mouse.selection_gesture.drag(t, .{ .pin = pin, .xpos = pos.x, .ypos = pos.y, .rectangle = SurfaceMouse.isRectangleSelectState(self.mouse.mods), + .word_boundary_codepoints = self.config.selection_word_chars, .geometry = .{ .columns = @intCast(self.size.grid().columns), .cell_width = self.size.cell.width, @@ -4638,6 +4641,7 @@ pub fn cursorPosCallback( }, }); + // Update our autoscroll timer based on the gesture state switch (self.mouse.selection_gesture.left_drag_autoscroll) { .none => if (self.selection_scroll_active) { self.queueIo( @@ -4653,95 +4657,11 @@ pub fn cursorPosCallback( }, } - // Handle dragging depending on click count - switch (self.mouse.selection_gesture.left_click_count) { - 1 => try self.io.terminal.screens.active.select(drag_selection), - 2 => try self.dragLeftClickDouble(pin), - 3 => try self.dragLeftClickTriple(pin), - 0 => unreachable, // handled above - else => unreachable, - } - - return; + // Update our selection based on the gesture state + try self.io.terminal.screens.active.select(drag_selection); } } -/// Double-click dragging moves the selection one "word" at a time. -fn dragLeftClickDouble( - self: *Surface, - drag_pin: terminal.Pin, -) !void { - const screen: *terminal.Screen = self.io.terminal.screens.active; - const click_pin = (self.mouse.activeLeftClickPin(&self.io.terminal.screens) orelse return).*; - - // Get the word closest to our starting click. - const word_start = screen.selectWordBetween( - click_pin, - drag_pin, - self.config.selection_word_chars, - ) orelse { - try self.setSelection(null); - return; - }; - - // Get the word closest to our current point. - const word_current = screen.selectWordBetween( - drag_pin, - click_pin, - self.config.selection_word_chars, - ) orelse { - try self.setSelection(null); - return; - }; - - // If our current mouse position is before the starting position, - // then the selection start is the word nearest our current position. - if (drag_pin.before(click_pin)) { - try self.io.terminal.screens.active.select(.init( - word_current.start(), - word_start.end(), - false, - )); - } else { - try self.io.terminal.screens.active.select(.init( - word_start.start(), - word_current.end(), - false, - )); - } -} - -/// Triple-click dragging moves the selection one "line" at a time. -fn dragLeftClickTriple( - self: *Surface, - drag_pin: terminal.Pin, -) !void { - const screen: *terminal.Screen = self.io.terminal.screens.active; - const click_pin: terminal.Pin = pin: { - const set: *terminal.ScreenSet = &self.io.terminal.screens; - const tracked = self.mouse.activeLeftClickPin(set) orelse return; - break :pin tracked.*; - }; - - // Get the line selection under our current drag point. If there isn't a - // line, do nothing. - const line = screen.selectLine(.{ .pin = drag_pin }) orelse return; - - // Get the selection under our click point. We first try to trim - // whitespace if we've selected a word. But if no word exists then - // we select the blank line. - const sel_ = screen.selectLine(.{ .pin = click_pin }) orelse - screen.selectLine(.{ .pin = click_pin, .whitespace = null }); - - var sel = sel_ orelse return; - if (drag_pin.before(click_pin)) { - sel.startPtr().* = line.start(); - } else { - sel.endPtr().* = line.end(); - } - try self.io.terminal.screens.active.select(sel); -} - /// Call to notify Ghostty that the color scheme for the terminal has /// changed. pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) !void { diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index 73904844c..6adad5b4a 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -153,6 +153,9 @@ pub const Drag = struct { /// True if the current drag should produce a rectangular selection. rectangle: bool, + /// The codepoints that delimit words for double-click drag selection. + word_boundary_codepoints: []const u21, + /// Geometry required for selection threshold and autoscroll calculations. geometry: Geometry, @@ -189,8 +192,7 @@ pub fn drag( // Get our click pin. We get a validated pin because if our // screen changed out from under us then we aren't actually // clicking anymore. - const click_pin = self.validatedLeftClickPin(&t.screens) orelse - return null; + const click_pin = self.validatedLeftClickPin(&t.screens) orelse return null; // Determine if we should autoscroll. If our drag position is above // the top, we go up. If its below the bottom we go down. Easy. @@ -202,14 +204,32 @@ pub fn drag( else .none; - return dragSelection( - click_pin.*, - d.pin, - @intFromFloat(@max(0, self.left_click_xpos)), - @intFromFloat(@max(0, d.xpos)), - d.rectangle, - d.geometry, - ); + return switch (self.left_click_count) { + 0 => unreachable, // handled above + + 1 => dragSelection( + click_pin.*, + d.pin, + @intFromFloat(@max(0, self.left_click_xpos)), + @intFromFloat(@max(0, d.xpos)), + d.rectangle, + d.geometry, + ), + + 2 => dragSelectionWord( + t.screens.active, + click_pin.*, + d.pin, + d.word_boundary_codepoints, + ), + + 3 => dragSelectionLine( + t.screens.active, + click_pin.*, + d.pin, + ), + else => unreachable, + }; } fn pressInitial( @@ -427,6 +447,68 @@ fn dragSelection( ); } +/// Calculates the appropriate word-wise selection for a double-click drag. +fn dragSelectionWord( + screen: *Screen, + click_pin: Pin, + drag_pin: Pin, + boundary_codepoints: []const u21, +) ?Selection { + // Get the word closest to our starting click. + const word_start = screen.selectWordBetween( + click_pin, + drag_pin, + boundary_codepoints, + ) orelse return null; + + // Get the word closest to our current point. + const word_current = screen.selectWordBetween( + drag_pin, + click_pin, + boundary_codepoints, + ) orelse return null; + + // If our current mouse position is before the starting position, + // then the selection start is the word nearest our current position. + return if (drag_pin.before(click_pin)) + .init( + word_current.start(), + word_start.end(), + false, + ) + else + .init( + word_start.start(), + word_current.end(), + false, + ); +} + +/// Calculates the appropriate line-wise selection for a triple-click drag. +fn dragSelectionLine( + screen: *Screen, + click_pin: Pin, + drag_pin: Pin, +) ?Selection { + // Get the line selection under our current drag point. If there isn't a + // line, do nothing. + const line = screen.selectLine(.{ .pin = drag_pin }) orelse return null; + + // Get the selection under our click point. We first try to trim + // whitespace if we've selected a word. But if no word exists then + // we select the blank line. + const sel_ = screen.selectLine(.{ .pin = click_pin }) orelse + screen.selectLine(.{ .pin = click_pin, .whitespace = null }); + + var sel = sel_ orelse return null; + if (drag_pin.before(click_pin)) { + sel.startPtr().* = line.start(); + } else { + sel.endPtr().* = line.end(); + } + return sel; +} + fn untrackPin(self: *SelectionGesture, t: *Terminal) void { // Can't untrack unless we have a pin. const pin = self.left_click_pin orelse return; @@ -464,6 +546,7 @@ fn testDrag(t: *Terminal, x: u16, y: u32, xpos: f64, ypos: f64) Drag { .xpos = xpos, .ypos = ypos, .rectangle = false, + .word_boundary_codepoints = &.{}, .geometry = .{ .columns = 5, .cell_width = 10, @@ -473,6 +556,13 @@ fn testDrag(t: *Terminal, x: u16, y: u32, xpos: f64, ypos: f64) Drag { }; } +fn testPin(t: *Terminal, x: u16, y: u32) Pin { + return t.screens.active.pages.pin(.{ .active = .{ + .x = x, + .y = y, + } }).?; +} + /// Utility function for the unit tests for drag selection logic. /// /// Tests a click and drag on a 10x5 cell grid, x positions are given in @@ -927,6 +1017,178 @@ test "SelectionGesture drag returns selection and records autoscroll" { try testing.expectEqual(.down, gesture.left_drag_autoscroll); } +test "SelectionGesture drag without press returns null" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + try testing.expectEqual(null, gesture.drag(&t, testDrag(&t, 1, 1, 10, 50))); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); +} + +test "SelectionGesture drag autoscroll edge boundaries" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + try gesture.press(&t, press_event); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1.1)); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 99)); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 99.1)); + try testing.expectEqual(.down, gesture.left_drag_autoscroll); +} + +test "SelectionGesture drag with invalidated click returns null" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + try gesture.press(&t, press_event); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + + _ = try t.screens.getInit(testing.allocator, .alternate, .{ + .cols = t.cols, + .rows = t.rows, + }); + t.screens.switchTo(.alternate); + + try testing.expectEqual(null, gesture.drag(&t, testDrag(&t, 2, 1, 20, 50))); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); +} + +test "SelectionGesture double-click drag selects by word" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta gamma"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + try gesture.press(&t, testPress(&t, 1, 0, time)); + try gesture.press(&t, testPress(&t, 1, 0, time)); + + var drag_event = testDrag(&t, 7, 0, 70, 50); + drag_event.word_boundary_codepoints = &.{ ' ' }; + const sel = gesture.drag(&t, drag_event).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 0), + false, + ), sel); +} + +test "SelectionGesture double-click drag selects by word backwards" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta gamma"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + try gesture.press(&t, testPress(&t, 7, 0, time)); + try gesture.press(&t, testPress(&t, 7, 0, time)); + + var drag_event = testDrag(&t, 1, 0, 10, 50); + drag_event.word_boundary_codepoints = &.{ ' ' }; + const sel = gesture.drag(&t, drag_event).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 0), + false, + ), sel); +} + +test "SelectionGesture double-click drag on empty cell selects nearest word" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + try gesture.press(&t, testPress(&t, 1, 0, time)); + try gesture.press(&t, testPress(&t, 1, 0, time)); + + var drag_event = testDrag(&t, 15, 0, 150, 50); + drag_event.word_boundary_codepoints = &.{ ' ' }; + const sel = gesture.drag(&t, drag_event).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 0), + false, + ), sel); +} + +test "SelectionGesture triple-click drag selects by line" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta\none two\nthree four"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + try gesture.press(&t, testPress(&t, 1, 0, time)); + try gesture.press(&t, testPress(&t, 1, 0, time)); + try gesture.press(&t, testPress(&t, 1, 0, time)); + + const sel = gesture.drag(&t, testDrag(&t, 2, 2, 20, 50)).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 2), + false, + ), sel); +} + +test "SelectionGesture triple-click drag selects by line backwards" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta\none two\nthree four"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + try gesture.press(&t, testPress(&t, 2, 2, time)); + try gesture.press(&t, testPress(&t, 2, 2, time)); + try gesture.press(&t, testPress(&t, 2, 2, time)); + + const sel = gesture.drag(&t, testDrag(&t, 1, 0, 10, 50)).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 2), + false, + ), sel); +} + test "SelectionGesture repeat increments click count" { var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); defer t.deinit(testing.allocator); From 141c7d44d2d10621d6b8f014c6a1ec3e416f14ea Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 26 May 2026 21:13:46 -0700 Subject: [PATCH 102/137] SelectionGesture: release event --- src/Surface.zig | 40 ++++++++++++++++---- src/terminal/SelectionGesture.zig | 62 +++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index f9945efb8..8abbcecea 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3815,6 +3815,30 @@ pub fn mouseButtonCallback( self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); + // The selection gesture tracks whether a press became a drag by + // comparing the release cell to the original press cell. Resolve the + // release position and pin before notifying the gesture so later + // release handling can query that state. + const release_pos: ?apprt.CursorPos = self.rt_surface.getCursorPos() catch |err| pos: { + log.warn("error reading cursor position for mouse release err={}", .{err}); + break :pos null; + }; + + // If we can't map the release position to a cell, pass null so the + // gesture can conservatively treat the release as having moved away + // from the pressed cell. + const release_pin: ?terminal.Pin = if (release_pos) |pos| pin: { + const release_vp = self.posToViewport(pos.x, pos.y); + break :pin self.io.terminal.screens.active.pages.pin(.{ .viewport = .{ + .x = release_vp.x, + .y = release_vp.y, + } }); + } else null; + self.mouse.selection_gesture.release( + self.renderer_state.terminal, + .{ .pin = release_pin }, + ); + // Stop selection scrolling when releasing the left mouse button // but only when selection scrolling is active. if (self.selection_scroll_active) { @@ -3823,7 +3847,6 @@ pub fn mouseButtonCallback( .locked, ); } - self.mouse.selection_gesture.left_drag_autoscroll = .none; // The selection clipboard is only updated for left-click drag when // the left button is released. This is to avoid the clipboard @@ -3842,10 +3865,10 @@ pub fn mouseButtonCallback( // Handle link clicking. We want to do this before we do mouse // reporting or any other mouse handling because a successfully // clicked link will swallow the event. - if (self.mouse.over_link) { + if (self.mouse.over_link and !self.mouse.selection_gesture.left_click_dragged) { // We are holding the renderer lock, but this should just be // a cached value. - const pos = try self.rt_surface.getCursorPos(); + const pos = release_pos orelse try self.rt_surface.getCursorPos(); if (self.processLinks(pos)) |processed| { if (processed) return true; } else |err| { @@ -4139,11 +4162,12 @@ fn maybePromptClick(self: *Surface) !bool { // prompt clicks because we can't move if we're not in a prompt! if (!t.cursorIsAtPrompt()) return false; - // If we have a selection currently, then releasing the mouse - // completes the selection and we don't do prompt moving. I don't - // love this logic, I think it should be generalized to "if the - // mouse release was on a different cell than the mouse press" but - // our mouse state at the time of writing this doesn't support that. + // If the left click moved away from its pressed cell then releasing the + // mouse completes the drag gesture and we don't do prompt moving. + if (self.mouse.selection_gesture.left_click_dragged) return false; + + // If we have a selection currently, then releasing the mouse completes + // the selection and we don't do prompt moving. if (screen.selection != null) return false; // Get the pin for our mouse click. diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index 6adad5b4a..4e7db013b 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -32,6 +32,12 @@ left_click_time: ?std.time.Instant, left_click_xpos: f64, left_click_ypos: f64, +/// True once the active left-click gesture has moved away from the initially +/// pressed cell. This is reset on every press that starts or continues a +/// multi-click sequence, and is left available for callers to inspect while +/// handling the corresponding release. +left_click_dragged: bool, + /// The current autoscroll state for the active left-click drag gesture. left_drag_autoscroll: Autoscroll, @@ -61,6 +67,7 @@ pub const init: SelectionGesture = .{ .left_click_screen_generation = 0, .left_click_xpos = 0, .left_click_ypos = 0, + .left_click_dragged = false, .left_drag_autoscroll = .none, }; @@ -77,6 +84,7 @@ pub fn deinit(self: *SelectionGesture, t: *Terminal) void { pub fn reset(self: *SelectionGesture, t: *Terminal) void { self.left_click_count = 0; self.left_click_time = null; + self.left_click_dragged = false; self.left_drag_autoscroll = .none; self.untrackPin(t); } @@ -193,6 +201,7 @@ pub fn drag( // screen changed out from under us then we aren't actually // clicking anymore. const click_pin = self.validatedLeftClickPin(&t.screens) orelse return null; + if (!d.pin.eql(click_pin.*)) self.left_click_dragged = true; // Determine if we should autoscroll. If our drag position is above // the top, we go up. If its below the bottom we go down. Easy. @@ -232,6 +241,34 @@ pub fn drag( }; } +pub const Release = struct { + /// The cell where the release occurred, if the release position mapped to + /// a valid cell. This is used synchronously to update gesture state and is + /// not tracked. + pin: ?Pin, +}; + +/// Record a release event for the active left-click gesture. +pub fn release( + self: *SelectionGesture, + t: *Terminal, + r: Release, +) void { + if (self.left_click_count == 0) { + assert(self.left_drag_autoscroll == .none); + return; + } + + if (r.pin) |release_pin| { + if (self.validatedLeftClickPin(&t.screens)) |click_pin| { + if (!release_pin.eql(click_pin.*)) self.left_click_dragged = true; + } + } else { + self.left_click_dragged = true; + } + self.left_drag_autoscroll = .none; +} + fn pressInitial( self: *SelectionGesture, t: *Terminal, @@ -256,6 +293,7 @@ fn pressInitial( self.left_click_xpos = p.xpos; self.left_click_ypos = p.ypos; self.left_click_time = p.time; + self.left_click_dragged = false; self.left_drag_autoscroll = .none; } @@ -298,6 +336,7 @@ fn pressRepeat( } self.left_click_time = time; + self.left_click_dragged = false; self.left_drag_autoscroll = .none; self.left_click_count = @min( self.left_click_count + 1, @@ -988,6 +1027,7 @@ test "SelectionGesture press records initial click" { try testing.expectEqual(time, gesture.left_click_time.?); try testing.expectEqual(@as(f64, 1), gesture.left_click_xpos); try testing.expectEqual(@as(f64, 2), gesture.left_click_ypos); + try testing.expectEqual(false, gesture.left_click_dragged); } test "SelectionGesture drag returns selection and records autoscroll" { @@ -1003,6 +1043,7 @@ test "SelectionGesture drag returns selection and records autoscroll" { const sel = gesture.drag(&t, testDrag(&t, 3, 1, 39, 50)).?; try testing.expectEqual(.none, gesture.left_drag_autoscroll); + try testing.expectEqual(true, gesture.left_click_dragged); try testing.expectEqualDeep(Selection.init( t.screens.active.pages.pin(.{ .active = .{ .x = 1, .y = 1 } }).?, @@ -1017,6 +1058,27 @@ test "SelectionGesture drag returns selection and records autoscroll" { try testing.expectEqual(.down, gesture.left_drag_autoscroll); } +test "SelectionGesture release clears autoscroll and records drag" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + try testing.expectEqual(false, gesture.left_click_dragged); + + _ = gesture.drag(&t, testDrag(&t, 1, 1, 10, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + try testing.expectEqual(false, gesture.left_click_dragged); + + gesture.release(&t, .{ + .pin = testPin(&t, 2, 1), + }); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + try testing.expectEqual(true, gesture.left_click_dragged); +} + test "SelectionGesture drag without press returns null" { var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); defer t.deinit(testing.allocator); From df98b6d9833dbc80babee58b8a02d102d14bfd83 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 26 May 2026 21:21:08 -0700 Subject: [PATCH 103/137] terminal: SelectionGesture autoscrollTick --- src/Surface.zig | 52 +++++++++---------- src/terminal/SelectionGesture.zig | 85 +++++++++++++++++++++++++++++-- 2 files changed, 106 insertions(+), 31 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 8abbcecea..fded5b137 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1171,15 +1171,15 @@ fn selectionScrollTick(self: *Surface) !void { // If we're no longer active then we don't do anything. if (!self.selection_scroll_active) return; - // If we don't have a left mouse button down then we - // don't do anything. - if (self.mouse.selection_gesture.left_click_count == 0) return; - - const delta: isize = switch (self.mouse.selection_gesture.left_drag_autoscroll) { - .none => return, - .up => -1, - .down => 1, - }; + // If our gesture doesn't want autoscrolling then disable it. + const was_autoscrolling = self.mouse.selection_gesture.left_drag_autoscroll != .none; + if (!was_autoscrolling) { + self.queueIo( + .{ .selection_scroll = false }, + .unlocked, + ); + return; + } const pos = try self.rt_surface.getCursorPos(); const pos_vp = self.posToViewport(pos.x, pos.y); @@ -1189,20 +1189,6 @@ fn selectionScrollTick(self: *Surface) !void { defer self.renderer_state.mutex.unlock(); const t: *terminal.Terminal = self.renderer_state.terminal; - // If our left-click pin no longer belongs to the active screen, we stop - // our selection scroll. - if (self.mouse.activeLeftClickPin(&t.screens) == null) { - self.queueIo( - .{ .selection_scroll = false }, - .locked, - ); - return; - } - - // Scroll the viewport as required - t.scrollViewport(.{ .delta = delta }); - - // Next, trigger our drag behavior const pin = t.screens.active.pages.pin(.{ .viewport = .{ .x = pos_vp.x, @@ -1212,7 +1198,8 @@ fn selectionScrollTick(self: *Surface) !void { if (comptime std.debug.runtime_safety) unreachable; return; }; - if (self.mouse.selection_gesture.drag(t, .{ + + const selection = self.mouse.selection_gesture.autoscrollTick(t, .{ .pin = pin, .xpos = pos.x, .ypos = pos.y, @@ -1224,14 +1211,23 @@ fn selectionScrollTick(self: *Surface) !void { .padding_left = self.size.padding.left, .screen_height = self.size.screen.height, }, - })) |sel| { - try self.io.terminal.screens.active.select(sel); - } else { - try self.io.terminal.screens.active.select(null); + }); + + // If we're no longer autoscrolling for whatever reason, disable it. + if (self.mouse.selection_gesture.left_drag_autoscroll == .none) { + self.queueIo( + .{ .selection_scroll = false }, + .locked, + ); } + // If our left click was invalidated, ignore the result. This isn't + // strictly necessary but its a nice to have. + if (self.mouse.selection_gesture.left_click_count == 0) return; + // We modified our viewport and selection so we need to queue // a render. + try self.io.terminal.screens.active.select(selection); try self.queueRender(); } diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index 4e7db013b..28663ac08 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -46,9 +46,8 @@ left_drag_autoscroll: Autoscroll, /// surface bounds and reset whenever there is no active drag gesture. /// /// When autoscroll is non-none, the caller should setup a timer -/// to periodically scroll the screen the desired direction a certain -/// amount. The timer and amount is up to the caller but reasonable -/// defaults are approximately one row every 15 milliseconds. +/// to periodically call autoscrollTick. The timer interval is up to the +/// caller but reasonable defaults are approximately every 15 milliseconds. /// /// This is used to implement selection above/below the viewport that /// wants to drag the viewport. @@ -241,6 +240,37 @@ pub fn drag( }; } +/// Record a selection autoscroll tick for the active left-click drag gesture. +/// This scrolls the viewport in the active autoscroll direction and then +/// continues the drag at the provided position. +pub fn autoscrollTick( + self: *SelectionGesture, + t: *Terminal, + d: Drag, +) ?Selection { + if (self.left_click_count == 0) { + assert(self.left_drag_autoscroll == .none); + return null; + } + + const delta: isize = switch (self.left_drag_autoscroll) { + .none => return null, + .up => -1, + .down => 1, + }; + + // If our click pin no longer belongs to the active screen, the gesture is + // no longer valid. Stop it so callers can stop their autoscroll timer + // without clearing the current selection as if this were a real drag. + _ = self.validatedLeftClickPin(&t.screens) orelse { + self.reset(t); + return null; + }; + + t.scrollViewport(.{ .delta = delta }); + return self.drag(t, d); +} + pub const Release = struct { /// The cell where the release occurred, if the release position mapped to /// a valid cell. This is used synchronously to update gesture state and is @@ -1114,6 +1144,55 @@ test "SelectionGesture drag autoscroll edge boundaries" { try testing.expectEqual(.down, gesture.left_drag_autoscroll); } +test "SelectionGesture autoscroll tick scrolls and continues drag" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + try gesture.press(&t, press_event); + + _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 100)); + try testing.expectEqual(.down, gesture.left_drag_autoscroll); + + const sel = gesture.autoscrollTick(&t, testDrag(&t, 3, 2, 39, 100)).?; + try testing.expectEqual(.down, gesture.left_drag_autoscroll); + try testing.expectEqual(true, gesture.left_click_dragged); + try testing.expectEqualDeep(Selection.init( + testPin(&t, 1, 1), + testPin(&t, 3, 2), + false, + ), sel); +} + +test "SelectionGesture autoscroll tick stops with invalidated click" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + try gesture.press(&t, press_event); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + + _ = try t.screens.getInit(testing.allocator, .alternate, .{ + .cols = t.cols, + .rows = t.rows, + }); + t.screens.switchTo(.alternate); + + try testing.expectEqual(null, gesture.autoscrollTick(&t, testDrag(&t, 2, 1, 20, 1))); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + try testing.expectEqual(@as(u3, 0), gesture.left_click_count); +} + test "SelectionGesture drag with invalidated click returns null" { var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); defer t.deinit(testing.allocator); From f5f9d32d0a42b55bb80599a000e63c33a25d549e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 26 May 2026 21:33:10 -0700 Subject: [PATCH 104/137] terminal: SelectionGesture deep press --- src/Surface.zig | 31 ++++++++----- src/terminal/SelectionGesture.zig | 76 +++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index fded5b137..f2c98ec0d 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4456,9 +4456,11 @@ pub fn mousePressureCallback( // Update our pressure stage. self.mouse.pressure_stage = stage; - // If our left mouse button is pressed and we're entering a deep - // click then we want to start a selection. We treat this as a - // word selection since that is typical macOS behavior. + // A deep press is pressure-sensitive pointer input, such as macOS force + // click / deep click on a trackpad, that occurs while the left mouse + // button is already down. Treat it as the platform text-selection + // affordance: select the pressed word, then consume the active gesture so + // further cursor motion doesn't drag the selection. const left_idx = @intFromEnum(input.MouseButton.left); if (self.mouse.click_state[left_idx] == .press and stage == .deep) @@ -4466,14 +4468,21 @@ pub fn mousePressureCallback( self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - // This should always be set in this state but we don't want - // to handle state inconsistency here. - const pin = self.mouse.activeLeftClickPin(&self.io.terminal.screens) orelse break :select; - const sel = self.io.terminal.screens.active.selectWord( - pin.*, - self.config.selection_word_chars, - ) orelse break :select; - try self.io.terminal.screens.active.select(sel); + const sel = self.mouse.selection_gesture.deepPress( + self.renderer_state.terminal, + .{ .word_boundary_codepoints = self.config.selection_word_chars }, + ); + + // Deep press consumes the active drag gesture, so stop any pending + // selection autoscroll timer that may have been started by the drag. + if (self.selection_scroll_active) { + self.queueIo( + .{ .selection_scroll = false }, + .locked, + ); + } + + try self.io.terminal.screens.active.select(sel orelse break :select); try self.queueRender(); } } diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index 28663ac08..70ccaa149 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -243,6 +243,10 @@ pub fn drag( /// Record a selection autoscroll tick for the active left-click drag gesture. /// This scrolls the viewport in the active autoscroll direction and then /// continues the drag at the provided position. +/// +/// This always scrolls the viewport by exactly one row in the current +/// autoscroll direction. If you want to scroll by more, increase your +/// tick rate. pub fn autoscrollTick( self: *SelectionGesture, t: *Terminal, @@ -271,6 +275,46 @@ pub fn autoscrollTick( return self.drag(t, d); } +/// A pressure-based activation during an existing left-click gesture. +/// +/// This is the terminal gesture model for platform features such as macOS +/// force click / deep click on pressure-sensitive trackpads. It is not a +/// distinct mouse button and it is not part of the normal single/double/triple +/// click count sequence; it can only occur after a left press is already +/// active. +pub const DeepPress = struct { + /// The codepoints that delimit words for the word selection produced by + /// the deep press. + word_boundary_codepoints: []const u21, +}; + +/// Record a deep press event for the active left-click gesture. +/// +/// A deep press is a force/pressure activation while the primary pointer is +/// already down. Ghostty treats it like the platform text-selection affordance: +/// select the word under the original press, then consume the gesture so +/// further cursor movement while the button remains pressed does not drag or +/// autoscroll the selection. +pub fn deepPress( + self: *SelectionGesture, + t: *Terminal, + p: DeepPress, +) ?Selection { + const click_pin = self.validatedLeftClickPin(&t.screens) orelse return null; + const sel = t.screens.active.selectWord( + click_pin.*, + p.word_boundary_codepoints, + ); + + self.left_click_count = 0; + self.left_click_time = null; + self.left_click_dragged = true; + self.left_drag_autoscroll = .none; + self.untrackPin(t); + + return sel; +} + pub const Release = struct { /// The cell where the release occurred, if the release position mapped to /// a valid cell. This is used synchronously to update gesture state and is @@ -1193,6 +1237,38 @@ test "SelectionGesture autoscroll tick stops with invalidated click" { try testing.expectEqual(@as(u3, 0), gesture.left_click_count); } +test "SelectionGesture deep press selects word and consumes drag" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + try gesture.press(&t, testPress(&t, 1, 0, try std.time.Instant.now())); + _ = gesture.drag(&t, testDrag(&t, 1, 0, 10, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + + const sel = gesture.deepPress(&t, .{ + .word_boundary_codepoints = &.{ ' ' }, + }).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 4, 0), + false, + ), sel); + try testing.expectEqual(@as(u3, 0), gesture.left_click_count); + try testing.expectEqual(@as(?std.time.Instant, null), gesture.left_click_time); + try testing.expectEqual(true, gesture.left_click_dragged); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + try testing.expect(gesture.left_click_pin == null); + + try testing.expectEqual(null, gesture.drag(&t, testDrag(&t, 7, 0, 70, 50))); + gesture.release(&t, .{ .pin = testPin(&t, 7, 0) }); + try testing.expectEqual(true, gesture.left_click_dragged); +} + test "SelectionGesture drag with invalidated click returns null" { var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); defer t.deinit(testing.allocator); From 5368adcd29754939e6c283198ef6b1c122293815 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Wed, 27 May 2026 19:43:41 +0800 Subject: [PATCH 105/137] macos: avoid duplicate appearance sync on tab focus Close #12825 Skip the initial emissions from the focused surface appearance publishers after a tab focus change. The focused surface is already synced immediately, so the initial Combine values only repeat the same titlebar and background updates. Subsequent derived config and OSC background changes still resync the window appearance. --- macos/Sources/Features/Terminal/TerminalController.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index bcccef8f9..ee6312ab9 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1410,9 +1410,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // We also want to get notified of certain changes to update our appearance. focusedSurface.$derivedConfig + .dropFirst() .sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) } .store(in: &surfaceAppearanceCancellables) focusedSurface.$backgroundColor + .dropFirst() .sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) } .store(in: &surfaceAppearanceCancellables) } From ce4128afc494d192edcc0f3a28e2d63fbb059eda Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 12:33:56 +0000 Subject: [PATCH 106/137] Update VOUCHED list (#12829) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12827#discussioncomment-17075382) from @trag1c. Denounce: @Cznorth Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 6fd37e40d..cee5494f1 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -69,6 +69,7 @@ cmwetherell crayxt craziestowl curtismoncoq +-cznorth Automated advertising + likely AI communication d-dudas -daedaevibin daiimus From 9b00bb436a99ece1b78bea90b403f38636cbff15 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 06:20:18 -0700 Subject: [PATCH 107/137] terminal: better SelectionGesture docs --- src/terminal/SelectionGesture.zig | 150 ++++++++++++++++++++++++++++-- 1 file changed, 143 insertions(+), 7 deletions(-) diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index 70ccaa149..ba221c38c 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -1,6 +1,65 @@ -/// SelectionGesture manages gesture-based selection logic (mouse press, drag, -/// etc.). Callers setup initial state, make calls for various external -/// events, and react to the requested effects. +/// SelectionGesture manages gesture-based terminal text selection for one +/// pointer stream: press, drag, release, autoscroll, and pressure/deep-press +/// selection. +/// +/// This type owns only the state required to interpret a gesture. It does not +/// modify the terminal selection directly, except for scrolling the viewport +/// during `autoscrollTick`. The caller feeds platform events into this type and +/// applies the returned `Selection` to the active screen when appropriate. +/// +/// A typical single-click drag flow looks like this: +/// +/// ```zig +/// try gesture.press(terminal, .{ ... }); +/// if (gesture.drag(terminal, .{ ... })) |selection| { +/// try terminal.screens.active.select(selection); +/// } +/// gesture.release(terminal, .{ ... }); +/// ``` +/// +/// Double- and triple-click gestures use the same event flow. Repeated presses +/// inside `Press.repeat_interval` and within `Press.max_distance` increment the +/// internal click count up to three. A drag after a double-click expands by word; +/// a drag after a triple-click expands by line. A new press that is too late, +/// too far away, or on another active screen starts a new single-click gesture. +/// +/// # Resetting and lifetime +/// +/// `release` ends the active drag/autoscroll phase but intentionally preserves +/// enough state for a subsequent press to become a double- or triple-click. +/// Call `reset` when the gesture is cancelled rather than released normally, or +/// when another subsystem takes ownership of pointer input. Examples include +/// enabling mouse reporting for an application, losing pointer/button state, +/// destroying the surface, switching to a mode that must not continue text +/// selection, or otherwise abandoning the current click sequence. Call `deinit` +/// once before discarding the gesture object so any tracked click pin is +/// released. +/// +/// # Terminal and screen changes +/// +/// The initial press pin is tracked in the active screen's `PageList`, so normal +/// terminal output and viewport scrolling can move rows without making the +/// gesture immediately stale. Selection results are computed against the current +/// terminal contents at the time of each call. For example, a double-click drag +/// selects word boundaries from the screen as it exists during `drag`, not from a +/// snapshot captured at `press`. +/// +/// The tracked pin is tied to both a `ScreenSet.Key` and that screen's +/// generation. If the active screen changes, or a screen is removed/recycled, +/// `validatedLeftClickPin` returns null and drag-style operations stop producing +/// selections. `autoscrollTick` treats this as cancellation and calls `reset` so +/// callers can stop their timers. This avoids exposing pins from inactive or +/// freed screens, but it does not make a historical snapshot of terminal data. +/// +/// # Concurrency +/// +/// SelectionGesture is not concurrency safe. It has mutable gesture state and +/// mutates/tracks pins inside the terminal page list without taking locks. The +/// caller must serialize all calls that touch the same gesture and terminal, +/// typically by holding the same terminal/renderer mutex used for other screen +/// mutations. Do not call `press`, `drag`, `release`, `reset`, `deinit`, or +/// `autoscrollTick` concurrently with each other or with unrelated terminal +/// mutations unless the caller provides that synchronization. const SelectionGesture = @This(); const std = @import("std"); @@ -80,6 +139,17 @@ pub fn deinit(self: *SelectionGesture, t: *Terminal) void { } /// Reset any active gesture state and untrack the tracked click pin. +/// +/// Use this for cancellation/abandonment, not for the ordinary left-button +/// release path. `release` deliberately keeps the last press time/count so a +/// following press can become a double- or triple-click; `reset` clears that +/// sequence and makes the next press a fresh single click. +/// +/// Examples of reset-worthy events are: mouse reporting taking over, pointer +/// capture being lost, a surface/window being torn down, or another interaction +/// mode deciding that text selection must stop immediately. If the active screen +/// was already removed or recycled, this safely drops the stale reference without +/// trying to untrack a pin from the wrong screen generation. pub fn reset(self: *SelectionGesture, t: *Terminal) void { self.left_click_count = 0; self.left_click_time = null; @@ -88,9 +158,16 @@ pub fn reset(self: *SelectionGesture, t: *Terminal) void { self.untrackPin(t); } -/// Return the tracked left-click pin only if it still belongs to the active -/// screen instance. This validates both the screen key and generation so a pin -/// from a removed, recycled, or inactive screen is never exposed to callers. +/// Return the tracked left-click pin only if it still belongs to the current +/// active screen instance. +/// +/// This validates both the screen key and generation so a pin from a removed, +/// recycled, or inactive screen is never exposed to callers. A null result means +/// callers should treat the in-progress gesture as temporarily or permanently +/// unable to produce a selection. For a normal drag this usually means "do +/// nothing for this event"; for autoscroll it is treated as cancellation because +/// a timer should not continue firing for a gesture that no longer has a valid +/// anchor. pub fn validatedLeftClickPin( self: *const SelectionGesture, screens: *const ScreenSet, @@ -109,6 +186,10 @@ pub const Press = struct { time: ?std.time.Instant, /// The cell where the click was. + /// + /// `press` stores a tracked copy of this pin. The caller does not need to + /// keep `p.pin` alive after the call returns, but the pin must belong to the + /// terminal's active screen when passed in. pin: Pin, /// The x/y value of the click relative to the surface with (0,0) being @@ -128,6 +209,20 @@ pub const Press = struct { }; /// Record a press event. +/// +/// If this press continues the existing click sequence, the click count is +/// incremented up to three and the original anchor pin is kept. Otherwise, the +/// previous gesture state is cleared and this press becomes the new anchor. +/// +/// Examples: +/// +/// * first press: `left_click_count == 1`, later drags select by cell; +/// * second nearby press within the repeat interval: `left_click_count == 2`, +/// later drags select by word; +/// * third nearby press within the repeat interval: `left_click_count == 3`, +/// later drags select by line; +/// * press after the interval, too far away, or after a screen generation +/// change: starts over at `left_click_count == 1`. pub fn press( self: *SelectionGesture, t: *Terminal, @@ -184,6 +279,23 @@ pub const Drag = struct { }; /// Record a drag event and return the current untracked drag selection. +/// +/// The returned selection is untracked and represents the best selection for the +/// terminal contents at the time of this call. The caller is responsible for +/// applying it to the screen, usually with `Screen.select`, and for arranging any +/// copy-on-select behavior. A null result means either there is no active +/// selection gesture, the original press is no longer valid for the active +/// screen, or the drag has not crossed the threshold required to select a cell. +/// +/// This method also updates `left_click_dragged` and `left_drag_autoscroll`. +/// If `left_drag_autoscroll` becomes `.up` or `.down`, the caller should start or +/// keep a timer that calls `autoscrollTick` while the button remains pressed. If +/// it becomes `.none`, the caller should stop that timer. +/// +/// Normal terminal output and viewport movement between drag events are allowed: +/// the tracked press pin follows the page list, and the drag pin is used only +/// synchronously. Content-sensitive selections such as word and line selection +/// are recalculated from the current active screen every time. pub fn drag( self: *SelectionGesture, t: *Terminal, @@ -241,12 +353,20 @@ pub fn drag( } /// Record a selection autoscroll tick for the active left-click drag gesture. +/// /// This scrolls the viewport in the active autoscroll direction and then -/// continues the drag at the provided position. +/// continues the drag at the provided position. The caller should pass the same +/// sort of `Drag` payload it would pass to `drag`, usually using the current +/// pointer position at the time the timer fires. /// /// This always scrolls the viewport by exactly one row in the current /// autoscroll direction. If you want to scroll by more, increase your /// tick rate. +/// +/// If the original press pin no longer belongs to the active screen, this calls +/// `reset` and returns null. That is a signal for the caller to stop its +/// autoscroll timer and leave any existing terminal selection alone unless some +/// other event says otherwise. pub fn autoscrollTick( self: *SelectionGesture, t: *Terminal, @@ -295,6 +415,11 @@ pub const DeepPress = struct { /// select the word under the original press, then consume the gesture so /// further cursor movement while the button remains pressed does not drag or /// autoscroll the selection. +/// +/// After a successful deep press, the click sequence is cleared and the tracked +/// pin is untracked. The returned selection should be applied by the caller. A +/// null result means there was no valid active left-click anchor, commonly +/// because the screen changed or the gesture had already been cancelled. pub fn deepPress( self: *SelectionGesture, t: *Terminal, @@ -323,6 +448,17 @@ pub const Release = struct { }; /// Record a release event for the active left-click gesture. +/// +/// This stops autoscroll and updates `left_click_dragged`, but it does not clear +/// the click count or time. Keeping that state is what lets the next nearby press +/// become a double- or triple-click. Call `reset` instead if the release should +/// cancel the click sequence entirely. +/// +/// Pass the release pin when the pointer position maps to a valid terminal cell. +/// If it does not, pass null; the gesture then conservatively records that the +/// pointer moved away from the original pressed cell. This is useful for callers +/// that use `left_click_dragged` after release to decide whether a click should +/// activate links or other hit targets. pub fn release( self: *SelectionGesture, t: *Terminal, From 82a73f2bf185ee2836378e3aeea8fc528e757921 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 06:23:30 -0700 Subject: [PATCH 108/137] terminal: SelectionGesture press returns standard behaviors --- src/Surface.zig | 74 +++++++-------- src/terminal/SelectionGesture.zig | 150 ++++++++++++++++++++---------- 2 files changed, 135 insertions(+), 89 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index f2c98ec0d..7a9b11667 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3953,68 +3953,60 @@ pub fn mouseButtonCallback( log.err("error reading time, mouse multi-click won't work err={}", .{err}); break :time null; }; - try self.mouse.selection_gesture.press(t, .{ + var press_selection = try self.mouse.selection_gesture.press(t, .{ .time = time, .pin = pin, .xpos = pos.x, .ypos = pos.y, .max_distance = @floatFromInt(self.size.cell.width), .repeat_interval = self.config.mouse_interval, + .word_boundary_codepoints = self.config.selection_word_chars, }); - // In all cases below, we set the selection directly rather than use - // `setSelection` because we want to avoid copying the selection - // to the selection clipboard. For left mouse clicks we only set - // the clipboard on release. + // The gesture owns the standard single/double/triple-click selection + // behavior. Surface keeps terminal-surface-specific overrides here. switch (self.mouse.selection_gesture.left_click_count) { - // Single click - 1 => { - // If we have a selection, clear it. This always happens. - if (self.io.terminal.screens.active.selection != null) { - try self.io.terminal.screens.active.select(null); - try self.queueRender(); - } - }, + 1 => {}, - // Double click, select the word under our mouse. - // First try to detect if we're clicking on a URL to select the entire URL. + // Double click on a URL selects the entire URL instead of the + // standard word selection returned by the gesture. 2 => { - const sel_ = sel: { - // Try link detection without requiring modifier keys - if (self.linkAtPin( - pin, - null, - )) |result_| { - if (result_) |result| { - break :sel result.selection; - } - } else |_| { - // Ignore any errors, likely regex errors. + // Try link detection without requiring modifier keys. + if (self.linkAtPin( + pin, + null, + )) |result_| { + if (result_) |result| { + press_selection = result.selection; } - - break :sel self.io.terminal.screens.active.selectWord(pin, self.config.selection_word_chars); - }; - if (sel_) |sel| { - try self.io.terminal.screens.active.select(sel); - try self.queueRender(); + } else |_| { + // Ignore any errors, likely regex errors. } }, - // Triple click, select the line under our mouse + // Cmd/Ctrl triple-click selects semantic command output instead of + // the standard line selection returned by the gesture. 3 => { - const sel_ = if (mods.ctrlOrSuper()) - self.io.terminal.screens.active.selectOutput(pin) - else - self.io.terminal.screens.active.selectLine(.{ .pin = pin }); - if (sel_) |sel| { - try self.io.terminal.screens.active.select(sel); - try self.queueRender(); - } + if (mods.ctrlOrSuper()) press_selection = + self.io.terminal.screens.active.selectOutput(pin); }, // We should be bounded by 1 to 3 else => unreachable, } + + // We set the selection directly rather than use `setSelection` because + // we want to avoid copying the selection to the selection clipboard. + // For left mouse clicks we only set the clipboard on release. + if (press_selection) |selection| { + try self.io.terminal.screens.active.select(selection); + try self.queueRender(); + } else if (self.mouse.selection_gesture.left_click_count == 1 and + self.io.terminal.screens.active.selection != null) + { + try self.io.terminal.screens.active.select(null); + try self.queueRender(); + } } // Middle-click paste source follows copy-on-select: when copy-on-select diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index ba221c38c..161a18438 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -10,7 +10,8 @@ /// A typical single-click drag flow looks like this: /// /// ```zig -/// try gesture.press(terminal, .{ ... }); +/// const selection = try gesture.press(terminal, .{ ... }); +/// try terminal.screens.active.select(selection); /// if (gesture.drag(terminal, .{ ... })) |selection| { /// try terminal.screens.active.select(selection); /// } @@ -19,9 +20,12 @@ /// /// Double- and triple-click gestures use the same event flow. Repeated presses /// inside `Press.repeat_interval` and within `Press.max_distance` increment the -/// internal click count up to three. A drag after a double-click expands by word; -/// a drag after a triple-click expands by line. A new press that is too late, -/// too far away, or on another active screen starts a new single-click gesture. +/// internal click count up to three. A single press returns null to clear any +/// existing selection, a double-click returns a word selection, and a +/// triple-click returns a line selection. A drag after a double-click expands by +/// word; a drag after a triple-click expands by line. A new press that is too +/// late, too far away, or on another active screen starts a new single-click +/// gesture. /// /// # Resetting and lifetime /// @@ -206,32 +210,39 @@ pub const Press = struct { /// The maximum interval in nanoseconds that a press is considered /// a repeat e.g. to record double/triple clicks. repeat_interval: u64, + + /// The codepoints that delimit words for double-click selection. + word_boundary_codepoints: []const u21, }; -/// Record a press event. +/// Record a press event and return the standard selection for this click. /// /// If this press continues the existing click sequence, the click count is /// incremented up to three and the original anchor pin is kept. Otherwise, the /// previous gesture state is cleared and this press becomes the new anchor. +/// The returned selection is untracked and represents the standard terminal +/// click behavior for the resulting click count. The caller is responsible for +/// applying it to the screen, usually with `Screen.select`, and for arranging +/// any copy-on-select behavior. /// /// Examples: /// -/// * first press: `left_click_count == 1`, later drags select by cell; +/// * first press: `left_click_count == 1`, returns null to clear selection; /// * second nearby press within the repeat interval: `left_click_count == 2`, -/// later drags select by word; +/// returns a word selection and later drags select by word; /// * third nearby press within the repeat interval: `left_click_count == 3`, -/// later drags select by line; +/// returns a line selection and later drags select by line; /// * press after the interval, too far away, or after a screen generation -/// change: starts over at `left_click_count == 1`. +/// change: starts over at `left_click_count == 1` and returns null. pub fn press( self: *SelectionGesture, t: *Terminal, p: Press, -) Allocator.Error!void { +) Allocator.Error!?Selection { if (self.left_click_count > 0) { if (self.pressRepeat(t, p)) { - // Successful repeat, return. - return; + // Successful repeat. + return self.pressSelection(t.screens.active, p); } else |err| switch (err) { error.PressRequiresReset => {}, } @@ -240,6 +251,7 @@ pub fn press( // Initial click or the repeat failed for some reason such as // the subsequent click being too far away. try self.pressInitial(t, p); + return self.pressSelection(t.screens.active, p); } pub const Drag = struct { @@ -554,6 +566,20 @@ fn pressRepeat( ); } +fn pressSelection( + self: *const SelectionGesture, + screen: *Screen, + p: Press, +) ?Selection { + return switch (self.left_click_count) { + 0 => unreachable, + 1 => null, + 2 => screen.selectWord(p.pin, p.word_boundary_codepoints), + 3 => screen.selectLine(.{ .pin = p.pin }), + else => unreachable, + }; +} + /// Calculates the appropriate selection given pins and pixel x positions for /// the click point and the drag point, as well as selection mode and geometry. fn dragSelection( @@ -783,6 +809,7 @@ fn testPress(t: *Terminal, x: u16, y: u32, time: ?std.time.Instant) Press { .ypos = @floatFromInt(y), .max_distance = 1, .repeat_interval = std.math.maxInt(u64), + .word_boundary_codepoints = &.{}, }; } @@ -1231,7 +1258,7 @@ test "SelectionGesture press records initial click" { defer gesture.deinit(&t); const time = try std.time.Instant.now(); - try gesture.press(&t, testPress(&t, 1, 2, time)); + _ = try gesture.press(&t, testPress(&t, 1, 2, time)); try testing.expectEqual(@as(u3, 1), gesture.left_click_count); try testing.expectEqual(time, gesture.left_click_time.?); @@ -1240,6 +1267,33 @@ test "SelectionGesture press records initial click" { try testing.expectEqual(false, gesture.left_click_dragged); } +test "SelectionGesture press returns standard click selections" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta\none two"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + var event = testPress(&t, 1, 0, time); + event.word_boundary_codepoints = &.{ ' ' }; + + try testing.expectEqual(null, try gesture.press(&t, event)); + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 4, 0), + false, + ), (try gesture.press(&t, event)).?); + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 0), + false, + ), (try gesture.press(&t, event)).?); +} + test "SelectionGesture drag returns selection and records autoscroll" { var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); defer t.deinit(testing.allocator); @@ -1249,7 +1303,7 @@ test "SelectionGesture drag returns selection and records autoscroll" { var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); press_event.xpos = 10; - try gesture.press(&t, press_event); + _ = try gesture.press(&t, press_event); const sel = gesture.drag(&t, testDrag(&t, 3, 1, 39, 50)).?; try testing.expectEqual(.none, gesture.left_drag_autoscroll); @@ -1275,7 +1329,7 @@ test "SelectionGesture release clears autoscroll and records drag" { var gesture: SelectionGesture = .init; defer gesture.deinit(&t); - try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); try testing.expectEqual(false, gesture.left_click_dragged); _ = gesture.drag(&t, testDrag(&t, 1, 1, 10, 1)); @@ -1309,7 +1363,7 @@ test "SelectionGesture drag autoscroll edge boundaries" { var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); press_event.xpos = 10; - try gesture.press(&t, press_event); + _ = try gesture.press(&t, press_event); _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1)); try testing.expectEqual(.up, gesture.left_drag_autoscroll); @@ -1333,7 +1387,7 @@ test "SelectionGesture autoscroll tick scrolls and continues drag" { var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); press_event.xpos = 10; - try gesture.press(&t, press_event); + _ = try gesture.press(&t, press_event); _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 100)); try testing.expectEqual(.down, gesture.left_drag_autoscroll); @@ -1357,7 +1411,7 @@ test "SelectionGesture autoscroll tick stops with invalidated click" { var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); press_event.xpos = 10; - try gesture.press(&t, press_event); + _ = try gesture.press(&t, press_event); _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1)); try testing.expectEqual(.up, gesture.left_drag_autoscroll); @@ -1381,7 +1435,7 @@ test "SelectionGesture deep press selects word and consumes drag" { var gesture: SelectionGesture = .init; defer gesture.deinit(&t); - try gesture.press(&t, testPress(&t, 1, 0, try std.time.Instant.now())); + _ = try gesture.press(&t, testPress(&t, 1, 0, try std.time.Instant.now())); _ = gesture.drag(&t, testDrag(&t, 1, 0, 10, 1)); try testing.expectEqual(.up, gesture.left_drag_autoscroll); @@ -1414,7 +1468,7 @@ test "SelectionGesture drag with invalidated click returns null" { var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); press_event.xpos = 10; - try gesture.press(&t, press_event); + _ = try gesture.press(&t, press_event); _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1)); try testing.expectEqual(.up, gesture.left_drag_autoscroll); @@ -1438,8 +1492,8 @@ test "SelectionGesture double-click drag selects by word" { defer gesture.deinit(&t); const time = try std.time.Instant.now(); - try gesture.press(&t, testPress(&t, 1, 0, time)); - try gesture.press(&t, testPress(&t, 1, 0, time)); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); var drag_event = testDrag(&t, 7, 0, 70, 50); drag_event.word_boundary_codepoints = &.{ ' ' }; @@ -1461,8 +1515,8 @@ test "SelectionGesture double-click drag selects by word backwards" { defer gesture.deinit(&t); const time = try std.time.Instant.now(); - try gesture.press(&t, testPress(&t, 7, 0, time)); - try gesture.press(&t, testPress(&t, 7, 0, time)); + _ = try gesture.press(&t, testPress(&t, 7, 0, time)); + _ = try gesture.press(&t, testPress(&t, 7, 0, time)); var drag_event = testDrag(&t, 1, 0, 10, 50); drag_event.word_boundary_codepoints = &.{ ' ' }; @@ -1484,8 +1538,8 @@ test "SelectionGesture double-click drag on empty cell selects nearest word" { defer gesture.deinit(&t); const time = try std.time.Instant.now(); - try gesture.press(&t, testPress(&t, 1, 0, time)); - try gesture.press(&t, testPress(&t, 1, 0, time)); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); var drag_event = testDrag(&t, 15, 0, 150, 50); drag_event.word_boundary_codepoints = &.{ ' ' }; @@ -1507,9 +1561,9 @@ test "SelectionGesture triple-click drag selects by line" { defer gesture.deinit(&t); const time = try std.time.Instant.now(); - try gesture.press(&t, testPress(&t, 1, 0, time)); - try gesture.press(&t, testPress(&t, 1, 0, time)); - try gesture.press(&t, testPress(&t, 1, 0, time)); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); const sel = gesture.drag(&t, testDrag(&t, 2, 2, 20, 50)).?; @@ -1529,9 +1583,9 @@ test "SelectionGesture triple-click drag selects by line backwards" { defer gesture.deinit(&t); const time = try std.time.Instant.now(); - try gesture.press(&t, testPress(&t, 2, 2, time)); - try gesture.press(&t, testPress(&t, 2, 2, time)); - try gesture.press(&t, testPress(&t, 2, 2, time)); + _ = try gesture.press(&t, testPress(&t, 2, 2, time)); + _ = try gesture.press(&t, testPress(&t, 2, 2, time)); + _ = try gesture.press(&t, testPress(&t, 2, 2, time)); const sel = gesture.drag(&t, testDrag(&t, 1, 0, 10, 50)).?; @@ -1550,8 +1604,8 @@ test "SelectionGesture repeat increments click count" { defer gesture.deinit(&t); const time = try std.time.Instant.now(); - try gesture.press(&t, testPress(&t, 1, 1, time)); - try gesture.press(&t, testPress(&t, 1, 1, time)); + _ = try gesture.press(&t, testPress(&t, 1, 1, time)); + _ = try gesture.press(&t, testPress(&t, 1, 1, time)); try testing.expectEqual(@as(u3, 2), gesture.left_click_count); } @@ -1564,7 +1618,7 @@ test "SelectionGesture repeat clamps at triple click" { defer gesture.deinit(&t); const time = try std.time.Instant.now(); - for (0..4) |_| try gesture.press(&t, testPress(&t, 1, 1, time)); + for (0..4) |_| _ = try gesture.press(&t, testPress(&t, 1, 1, time)); try testing.expectEqual(@as(u3, 3), gesture.left_click_count); } @@ -1576,8 +1630,8 @@ test "SelectionGesture null initial time stays single click" { var gesture: SelectionGesture = .init; defer gesture.deinit(&t); - try gesture.press(&t, testPress(&t, 1, 1, null)); - try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + _ = try gesture.press(&t, testPress(&t, 1, 1, null)); + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); try testing.expectEqual(@as(u3, 1), gesture.left_click_count); try testing.expect(gesture.left_click_time != null); @@ -1590,8 +1644,8 @@ test "SelectionGesture null repeat time stays single click" { var gesture: SelectionGesture = .init; defer gesture.deinit(&t); - try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); - try gesture.press(&t, testPress(&t, 1, 1, null)); + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + _ = try gesture.press(&t, testPress(&t, 1, 1, null)); try testing.expectEqual(@as(u3, 1), gesture.left_click_count); try testing.expectEqual(@as(?std.time.Instant, null), gesture.left_click_time); @@ -1605,8 +1659,8 @@ test "SelectionGesture distant press resets click count" { defer gesture.deinit(&t); const time = try std.time.Instant.now(); - try gesture.press(&t, testPress(&t, 1, 1, time)); - try gesture.press(&t, testPress(&t, 4, 1, time)); + _ = try gesture.press(&t, testPress(&t, 1, 1, time)); + _ = try gesture.press(&t, testPress(&t, 4, 1, time)); try testing.expectEqual(@as(u3, 1), gesture.left_click_count); try testing.expectEqual(@as(f64, 4), gesture.left_click_xpos); @@ -1621,11 +1675,11 @@ test "SelectionGesture expired repeat resets click count" { var event = testPress(&t, 1, 1, try std.time.Instant.now()); event.repeat_interval = 0; - try gesture.press(&t, event); + _ = try gesture.press(&t, event); std.Thread.sleep(std.time.ns_per_ms); event.time = try std.time.Instant.now(); - try gesture.press(&t, event); + _ = try gesture.press(&t, event); try testing.expectEqual(@as(u3, 1), gesture.left_click_count); } @@ -1639,14 +1693,14 @@ test "SelectionGesture screen switch resets click count" { const time = try std.time.Instant.now(); const primary_tracked = t.screens.active.pages.countTrackedPins(); - try gesture.press(&t, testPress(&t, 1, 1, time)); + _ = try gesture.press(&t, testPress(&t, 1, 1, time)); _ = try t.screens.getInit(testing.allocator, .alternate, .{ .cols = t.cols, .rows = t.rows, }); t.screens.switchTo(.alternate); - try gesture.press(&t, testPress(&t, 1, 1, time)); + _ = try gesture.press(&t, testPress(&t, 1, 1, time)); try testing.expectEqual(@as(u3, 1), gesture.left_click_count); try testing.expectEqual(.alternate, gesture.left_click_screen); @@ -1665,11 +1719,11 @@ test "SelectionGesture removed screen resets without untracking stale pin" { .rows = t.rows, }); t.screens.switchTo(.alternate); - try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); t.screens.switchTo(.primary); t.screens.remove(testing.allocator, .alternate); - try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); try testing.expectEqual(@as(u3, 1), gesture.left_click_count); try testing.expectEqual(.primary, gesture.left_click_screen); @@ -1681,7 +1735,7 @@ test "SelectionGesture deinit untracks pin" { var gesture: SelectionGesture = .init; const tracked = t.screens.active.pages.countTrackedPins(); - try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); gesture.deinit(&t); From 7d4d1e5819b635004b17e28bc302a036e4461c04 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 06:29:43 -0700 Subject: [PATCH 109/137] terminal: add configurable behaviors based on click count --- src/Surface.zig | 12 +-- src/terminal/SelectionGesture.zig | 168 ++++++++++++++++++++++++++---- 2 files changed, 152 insertions(+), 28 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 7a9b11667..581a510a9 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3961,6 +3961,11 @@ pub fn mouseButtonCallback( .max_distance = @floatFromInt(self.size.cell.width), .repeat_interval = self.config.mouse_interval, .word_boundary_codepoints = self.config.selection_word_chars, + .behaviors = &.{ + .cell, + .word, + if (mods.ctrlOrSuper()) .output else .line, + }, }); // The gesture owns the standard single/double/triple-click selection @@ -3984,12 +3989,7 @@ pub fn mouseButtonCallback( } }, - // Cmd/Ctrl triple-click selects semantic command output instead of - // the standard line selection returned by the gesture. - 3 => { - if (mods.ctrlOrSuper()) press_selection = - self.io.terminal.screens.active.selectOutput(pin); - }, + 3 => {}, // We should be bounded by 1 to 3 else => unreachable, diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index 161a18438..cd56d514e 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -20,12 +20,12 @@ /// /// Double- and triple-click gestures use the same event flow. Repeated presses /// inside `Press.repeat_interval` and within `Press.max_distance` increment the -/// internal click count up to three. A single press returns null to clear any -/// existing selection, a double-click returns a word selection, and a -/// triple-click returns a line selection. A drag after a double-click expands by -/// word; a drag after a triple-click expands by line. A new press that is too -/// late, too far away, or on another active screen starts a new single-click -/// gesture. +/// internal click count up to three. `Press.behaviors` maps single-, double-, +/// and triple-clicks to behavior. By default, a single press returns null to +/// clear any existing selection, a double-click returns a word selection, and a +/// triple-click returns a line selection. Drags use the behavior selected by the +/// corresponding press. A new press that is too late, too far away, or on +/// another active screen starts a new single-click gesture. /// /// # Resetting and lifetime /// @@ -89,6 +89,9 @@ left_click_screen_generation: usize, left_click_count: u3, left_click_time: ?std.time.Instant, +/// The selection behavior chosen for the active left-click gesture. +left_click_behavior: Behavior, + /// The starting xpos/ypos of the left click. Note that if scrolling occurs, /// these will point to different cells, but the xpos/ypos will stay /// stable during scrolling relative to the surface. @@ -116,6 +119,28 @@ left_drag_autoscroll: Autoscroll, /// wants to drag the viewport. pub const Autoscroll = enum { none, up, down }; +/// The selection behavior for a click and subsequent drag. +pub const Behavior = enum { + /// Cell-granular drag selection. Press returns null to clear selection. + cell, + + /// Word selection on press and word-granular drag selection. + word, + + /// Line selection on press and line-granular drag selection. + line, + + /// Semantic command output selection on press and drag. + output, +}; + +/// Standard terminal selection behavior for single-, double-, and triple-clicks. +/// +/// A single click uses cell behavior, which returns null on press so callers can +/// clear any existing selection and then drag by cell. A double-click selects and +/// drags by word. A triple-click selects and drags by line. +pub const default_behaviors: [3]Behavior = .{ .cell, .word, .line }; + /// Distance from the top or bottom surface edge, in pixels, where dragging /// should request autoscroll. This preserves the historical 1px buffer used /// so fullscreen-edge drags can still trigger autoscroll. @@ -125,6 +150,7 @@ pub const init: SelectionGesture = .{ .left_click_pin = null, .left_click_count = 0, .left_click_time = null, + .left_click_behavior = .cell, .left_click_screen = .primary, .left_click_screen_generation = 0, .left_click_xpos = 0, @@ -157,6 +183,7 @@ pub fn deinit(self: *SelectionGesture, t: *Terminal) void { pub fn reset(self: *SelectionGesture, t: *Terminal) void { self.left_click_count = 0; self.left_click_time = null; + self.left_click_behavior = .cell; self.left_click_dragged = false; self.left_drag_autoscroll = .none; self.untrackPin(t); @@ -213,6 +240,9 @@ pub const Press = struct { /// The codepoints that delimit words for double-click selection. word_boundary_codepoints: []const u21, + + /// Selection behaviors for single-, double-, and triple-clicks. + behaviors: *const [3]Behavior = &default_behaviors, }; /// Record a press event and return the standard selection for this click. @@ -227,11 +257,11 @@ pub const Press = struct { /// /// Examples: /// -/// * first press: `left_click_count == 1`, returns null to clear selection; +/// * first press: `left_click_count == 1`, defaults to cell behavior; /// * second nearby press within the repeat interval: `left_click_count == 2`, -/// returns a word selection and later drags select by word; +/// defaults to word behavior; /// * third nearby press within the repeat interval: `left_click_count == 3`, -/// returns a line selection and later drags select by line; +/// defaults to line behavior; /// * press after the interval, too far away, or after a screen generation /// change: starts over at `left_click_count == 1` and returns null. pub fn press( @@ -336,10 +366,8 @@ pub fn drag( else .none; - return switch (self.left_click_count) { - 0 => unreachable, // handled above - - 1 => dragSelection( + return switch (self.left_click_behavior) { + .cell => dragSelection( click_pin.*, d.pin, @intFromFloat(@max(0, self.left_click_xpos)), @@ -348,19 +376,24 @@ pub fn drag( d.geometry, ), - 2 => dragSelectionWord( + .word => dragSelectionWord( t.screens.active, click_pin.*, d.pin, d.word_boundary_codepoints, ), - 3 => dragSelectionLine( + .line => dragSelectionLine( + t.screens.active, + click_pin.*, + d.pin, + ), + + .output => dragSelectionOutput( t.screens.active, click_pin.*, d.pin, ), - else => unreachable, }; } @@ -445,6 +478,7 @@ pub fn deepPress( self.left_click_count = 0; self.left_click_time = null; + self.left_click_behavior = .cell; self.left_click_dragged = true; self.left_drag_autoscroll = .none; self.untrackPin(t); @@ -512,6 +546,7 @@ fn pressInitial( } errdefer comptime unreachable; self.left_click_count = 1; + self.left_click_behavior = p.behaviors[0]; self.left_click_xpos = p.xpos; self.left_click_ypos = p.ypos; self.left_click_time = p.time; @@ -526,6 +561,7 @@ fn pressRepeat( ) error{PressRequiresReset}!void { errdefer { self.left_click_count = 0; + self.left_click_behavior = .cell; self.untrackPin(t); } @@ -564,6 +600,7 @@ fn pressRepeat( self.left_click_count + 1, 3, // We only support triple clicks max ); + self.left_click_behavior = p.behaviors[self.left_click_count - 1]; } fn pressSelection( @@ -571,12 +608,11 @@ fn pressSelection( screen: *Screen, p: Press, ) ?Selection { - return switch (self.left_click_count) { - 0 => unreachable, - 1 => null, - 2 => screen.selectWord(p.pin, p.word_boundary_codepoints), - 3 => screen.selectLine(.{ .pin = p.pin }), - else => unreachable, + return switch (self.left_click_behavior) { + .cell => null, + .word => screen.selectWord(p.pin, p.word_boundary_codepoints), + .line => screen.selectLine(.{ .pin = p.pin }), + .output => screen.selectOutput(p.pin), }; } @@ -784,6 +820,26 @@ fn dragSelectionLine( return sel; } +/// Calculates the appropriate semantic-output-wise selection for an output +/// drag. This expands from the output block under the click point to the output +/// block under the current drag point. If the drag point is not output, keep the +/// original output selection. +fn dragSelectionOutput( + screen: *Screen, + click_pin: Pin, + drag_pin: Pin, +) ?Selection { + var sel = screen.selectOutput(click_pin) orelse return null; + const current = screen.selectOutput(drag_pin) orelse return sel; + + if (drag_pin.before(click_pin)) { + sel.startPtr().* = current.start(); + } else { + sel.endPtr().* = current.end(); + } + return sel; +} + fn untrackPin(self: *SelectionGesture, t: *Terminal) void { // Can't untrack unless we have a pin. const pin = self.left_click_pin orelse return; @@ -1294,6 +1350,74 @@ test "SelectionGesture press returns standard click selections" { ), (try gesture.press(&t, event)).?); } +test "SelectionGesture press behaviors choose press and drag behavior" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta\none two\nthree four"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + var event = testPress(&t, 1, 0, time); + event.behaviors = &.{ .cell, .line, .word }; + event.word_boundary_codepoints = &.{ ' ' }; + + _ = try gesture.press(&t, event); + try testing.expectEqual(.cell, gesture.left_click_behavior); + + const double_click = (try gesture.press(&t, event)).?; + try testing.expectEqual(.line, gesture.left_click_behavior); + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 0), + false, + ), double_click); + + const line_drag = gesture.drag(&t, testDrag(&t, 2, 2, 20, 50)).?; + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 2), + false, + ), line_drag); +} + +test "SelectionGesture output behavior selects and drags semantic output" { + var t = try Terminal.init(testing.allocator, .{ .cols = 10, .rows = 6 }); + defer t.deinit(testing.allocator); + + const screen = t.screens.active; + screen.cursorSetSemanticContent(.output); + try screen.testWriteString("out1\n"); + screen.cursorSetSemanticContent(.{ .prompt = .initial }); + try screen.testWriteString("$ "); + screen.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try screen.testWriteString("cmd\n"); + screen.cursorSetSemanticContent(.output); + try screen.testWriteString("out2"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var event = testPress(&t, 1, 0, try std.time.Instant.now()); + event.behaviors = &.{ .output, .word, .line }; + + const press_selection = (try gesture.press(&t, event)).?; + try testing.expectEqual(.output, gesture.left_click_behavior); + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 3, 0), + false, + ), press_selection); + + const output_drag = gesture.drag(&t, testDrag(&t, 1, 2, 10, 50)).?; + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 3, 2), + false, + ), output_drag); +} + test "SelectionGesture drag returns selection and records autoscroll" { var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); defer t.deinit(testing.allocator); From 68959c5c6388fad9f7c169ad8229a6bfd17d8ff0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 06:55:53 -0700 Subject: [PATCH 110/137] terminal: fix selection gesture edge cases Selection gestures now treat releases with invalidated anchors as dragged, so a press that crosses screen boundaries cannot also activate links or prompt clicks on release. Cell drags that create a same-cell selection also mark the gesture as dragged, which keeps click-only actions from firing after a threshold-crossing drag. Autoscroll now resolves the drag pin after moving the viewport instead of reusing the pin from before the scroll. This keeps the selection aligned with the row currently under the pointer. The inspector also validates the tracked click pin before displaying it so stale pins from inactive screens are ignored. --- src/Surface.zig | 12 +-- src/inspector/widgets/surface.zig | 3 +- src/terminal/SelectionGesture.zig | 152 ++++++++++++++++++++++++++++-- 3 files changed, 147 insertions(+), 20 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 581a510a9..410f717b0 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1189,18 +1189,8 @@ fn selectionScrollTick(self: *Surface) !void { defer self.renderer_state.mutex.unlock(); const t: *terminal.Terminal = self.renderer_state.terminal; - const pin = t.screens.active.pages.pin(.{ - .viewport = .{ - .x = pos_vp.x, - .y = pos_vp.y, - }, - }) orelse { - if (comptime std.debug.runtime_safety) unreachable; - return; - }; - const selection = self.mouse.selection_gesture.autoscrollTick(t, .{ - .pin = pin, + .viewport = pos_vp, .xpos = pos.x, .ypos = pos.y, .rectangle = SurfaceMouse.isRectangleSelectState(self.mouse.mods), diff --git a/src/inspector/widgets/surface.zig b/src/inspector/widgets/surface.zig index c2dd6ab1d..a630bad87 100644 --- a/src/inspector/widgets/surface.zig +++ b/src/inspector/widgets/surface.zig @@ -462,7 +462,8 @@ fn mouseTable( { const left_click_point: terminal.point.Coordinate = pt: { - const p = surface_mouse.selection_gesture.left_click_pin orelse break :pt .{}; + const p = surface_mouse.selection_gesture.validatedLeftClickPin(&t.screens) orelse + break :pt .{}; const pt = t.screens.active.pages.pointFromPin( .active, p.*, diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index cd56d514e..4b1edac88 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -76,6 +76,7 @@ const Screen = @import("Screen.zig"); const ScreenSet = @import("ScreenSet.zig"); const Selection = @import("Selection.zig"); const Terminal = @import("Terminal.zig"); +const point = @import("point.zig"); /// The tracked pin of the initial left click along with the screen /// that the pin is part of. @@ -366,7 +367,7 @@ pub fn drag( else .none; - return switch (self.left_click_behavior) { + const selection = switch (self.left_click_behavior) { .cell => dragSelection( click_pin.*, d.pin, @@ -395,14 +396,44 @@ pub fn drag( d.pin, ), }; + + // Same-cell cell selections can still become real selections when the drag + // crosses the within-cell threshold. Treat those as drags so callers don't + // also process click-only actions such as opening links. + if (self.left_click_behavior == .cell and selection != null) { + self.left_click_dragged = true; + } + + return selection; } +pub const AutoscrollTick = struct { + /// The viewport cell where the current drag position is. This is resolved + /// after the viewport is scrolled so the selection tracks the newly visible + /// row under the pointer. + viewport: point.Coordinate, + + /// The x/y value of the drag relative to the surface with (0,0) being + /// top-left. + xpos: f64, + ypos: f64, + + /// True if the current drag should produce a rectangular selection. + rectangle: bool, + + /// The codepoints that delimit words for double-click drag selection. + word_boundary_codepoints: []const u21, + + /// Geometry required for selection threshold and autoscroll calculations. + geometry: Drag.Geometry, +}; + /// Record a selection autoscroll tick for the active left-click drag gesture. /// /// This scrolls the viewport in the active autoscroll direction and then -/// continues the drag at the provided position. The caller should pass the same -/// sort of `Drag` payload it would pass to `drag`, usually using the current -/// pointer position at the time the timer fires. +/// continues the drag at the provided viewport position. The viewport position +/// is resolved to a pin after scrolling so the drag applies to the row now under +/// the pointer. /// /// This always scrolls the viewport by exactly one row in the current /// autoscroll direction. If you want to scroll by more, increase your @@ -415,7 +446,7 @@ pub fn drag( pub fn autoscrollTick( self: *SelectionGesture, t: *Terminal, - d: Drag, + tick: AutoscrollTick, ) ?Selection { if (self.left_click_count == 0) { assert(self.left_drag_autoscroll == .none); @@ -437,7 +468,16 @@ pub fn autoscrollTick( }; t.scrollViewport(.{ .delta = delta }); - return self.drag(t, d); + + const pin = t.screens.active.pages.pin(.{ .viewport = tick.viewport }) orelse return null; + return self.drag(t, .{ + .pin = pin, + .xpos = tick.xpos, + .ypos = tick.ypos, + .rectangle = tick.rectangle, + .word_boundary_codepoints = tick.word_boundary_codepoints, + .geometry = tick.geometry, + }); } /// A pressure-based activation during an existing left-click gesture. @@ -518,6 +558,11 @@ pub fn release( if (r.pin) |release_pin| { if (self.validatedLeftClickPin(&t.screens)) |click_pin| { if (!release_pin.eql(click_pin.*)) self.left_click_dragged = true; + } else { + // If the original anchor is no longer valid, conservatively treat + // this as a drag/cancelled click so callers don't perform click-only + // actions on a different or recycled screen. + self.left_click_dragged = true; } } else { self.left_click_dragged = true; @@ -888,6 +933,26 @@ fn testDrag(t: *Terminal, x: u16, y: u32, xpos: f64, ypos: f64) Drag { }; } +fn testAutoscrollTick( + viewport: point.Coordinate, + xpos: f64, + ypos: f64, +) AutoscrollTick { + return .{ + .viewport = viewport, + .xpos = xpos, + .ypos = ypos, + .rectangle = false, + .word_boundary_codepoints = &.{}, + .geometry = .{ + .columns = 5, + .cell_width = 10, + .padding_left = 0, + .screen_height = 100, + }, + }; +} + fn testPin(t: *Terminal, x: u16, y: u32) Pin { return t.screens.active.pages.pin(.{ .active = .{ .x = x, @@ -1467,6 +1532,48 @@ test "SelectionGesture release clears autoscroll and records drag" { try testing.expectEqual(true, gesture.left_click_dragged); } +test "SelectionGesture release with invalidated click records drag" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + try testing.expectEqual(false, gesture.left_click_dragged); + + _ = try t.screens.getInit(testing.allocator, .alternate, .{ + .cols = t.cols, + .rows = t.rows, + }); + t.screens.switchTo(.alternate); + + gesture.release(&t, .{ .pin = testPin(&t, 1, 1) }); + try testing.expectEqual(true, gesture.left_click_dragged); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); +} + +test "SelectionGesture same-cell threshold selection records drag" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + try testing.expectEqual(false, gesture.left_click_dragged); + + const sel = gesture.drag(&t, testDrag(&t, 1, 1, 19, 50)).?; + try testing.expectEqual(true, gesture.left_click_dragged); + try testing.expectEqualDeep(Selection.init( + testPin(&t, 1, 1), + testPin(&t, 1, 1), + false, + ), sel); +} + test "SelectionGesture drag without press returns null" { var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); defer t.deinit(testing.allocator); @@ -1516,7 +1623,7 @@ test "SelectionGesture autoscroll tick scrolls and continues drag" { _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 100)); try testing.expectEqual(.down, gesture.left_drag_autoscroll); - const sel = gesture.autoscrollTick(&t, testDrag(&t, 3, 2, 39, 100)).?; + const sel = gesture.autoscrollTick(&t, testAutoscrollTick(.{ .x = 3, .y = 2 }, 39, 100)).?; try testing.expectEqual(.down, gesture.left_drag_autoscroll); try testing.expectEqual(true, gesture.left_click_dragged); try testing.expectEqualDeep(Selection.init( @@ -1526,6 +1633,35 @@ test "SelectionGesture autoscroll tick scrolls and continues drag" { ), sel); } +test "SelectionGesture autoscroll tick resolves drag pin after scrolling" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 3, .max_scrollback = 10 }); + defer t.deinit(testing.allocator); + try t.printString("1111\n2222\n3333\n4444\n5555"); + t.scrollViewport(.{ .delta = -2 }); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + + _ = gesture.drag(&t, testDrag(&t, 3, 2, 39, 100)); + try testing.expectEqual(.down, gesture.left_drag_autoscroll); + + const viewport: point.Coordinate = .{ .x = 3, .y = 2 }; + const pre_scroll_pin = t.screens.active.pages.pin(.{ .viewport = viewport }).?; + const sel = gesture.autoscrollTick(&t, testAutoscrollTick(viewport, 39, 100)).?; + const post_scroll_pin = t.screens.active.pages.pin(.{ .viewport = viewport }).?; + + try testing.expect(!pre_scroll_pin.eql(post_scroll_pin)); + try testing.expectEqualDeep(Selection.init( + testPin(&t, 1, 1), + post_scroll_pin, + false, + ), sel); +} + test "SelectionGesture autoscroll tick stops with invalidated click" { var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); defer t.deinit(testing.allocator); @@ -1546,7 +1682,7 @@ test "SelectionGesture autoscroll tick stops with invalidated click" { }); t.screens.switchTo(.alternate); - try testing.expectEqual(null, gesture.autoscrollTick(&t, testDrag(&t, 2, 1, 20, 1))); + try testing.expectEqual(null, gesture.autoscrollTick(&t, testAutoscrollTick(.{ .x = 2, .y = 1 }, 20, 1))); try testing.expectEqual(.none, gesture.left_drag_autoscroll); try testing.expectEqual(@as(u3, 0), gesture.left_click_count); } From 2f61ba036ed4d0013f34414728938bf7825219c8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 08:00:37 -0700 Subject: [PATCH 111/137] libghostty: starting the SelectionGesture API, just init/get --- include/ghostty/vt/selection.h | 194 +++++++++++++++++++ src/lib_vt.zig | 5 + src/terminal/SelectionGesture.zig | 27 +-- src/terminal/c/main.zig | 7 + src/terminal/c/selection_gesture.zig | 272 +++++++++++++++++++++++++++ 5 files changed, 494 insertions(+), 11 deletions(-) create mode 100644 src/terminal/c/selection_gesture.zig diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 142877a97..dcdf9108c 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -39,6 +39,17 @@ extern "C" { * @{ */ +/** + * Opaque handle to state for interpreting terminal selection gestures. + * + * The gesture owns only the state required to interpret pointer events. Calls + * that use a gesture are not concurrency-safe and must be serialized with + * terminal mutations. + * + * @ingroup selection + */ +typedef struct GhosttySelectionGestureImpl* GhosttySelectionGesture; + /** * A snapshot selection range defined by two grid references. * @@ -283,6 +294,189 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_SELECTION_ADJUST_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionAdjust; +/** + * Selection behavior chosen for a gesture's click sequence. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Cell-granular drag selection. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_CELL = 0, + + /** Word selection on press and word-granular drag selection. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_WORD = 1, + + /** Line selection on press and line-granular drag selection. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_LINE = 2, + + /** Semantic command output selection on press and drag. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_OUTPUT = 3, + + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureBehavior; + +/** + * Current autoscroll direction for an active selection drag gesture. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** No selection autoscroll is requested. */ + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_NONE = 0, + + /** Selection dragging should autoscroll the viewport upward. */ + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_UP = 1, + + /** Selection dragging should autoscroll the viewport downward. */ + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_DOWN = 2, + + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureAutoscroll; + +/** + * Data fields readable from a selection gesture with + * ghostty_selection_gesture_get(). + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Current click count: uint8_t*. 0 means inactive. */ + GHOSTTY_SELECTION_GESTURE_DATA_CLICK_COUNT = 0, + + /** Whether the current/last left-click gesture has dragged: bool*. */ + GHOSTTY_SELECTION_GESTURE_DATA_DRAGGED = 1, + + /** Current autoscroll request: GhosttySelectionGestureAutoscroll*. */ + GHOSTTY_SELECTION_GESTURE_DATA_AUTOSCROLL = 2, + + /** Current gesture behavior: GhosttySelectionGestureBehavior*. */ + GHOSTTY_SELECTION_GESTURE_DATA_BEHAVIOR = 3, + + /** + * Current left-click anchor: GhosttyGridRef*. + * + * Returns GHOSTTY_NO_VALUE if there is no valid active anchor. On success, + * writes an untracked GhosttyGridRef snapshot with normal GhosttyGridRef + * lifetime rules. + */ + GHOSTTY_SELECTION_GESTURE_DATA_ANCHOR = 4, + + GHOSTTY_SELECTION_GESTURE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureData; + +/** + * Create a selection gesture object. + * + * The gesture stores mutable state for terminal text selection gestures. The + * gesture is not bound to a terminal at creation time; terminal-dependent APIs + * take the terminal explicitly. + * + * @param allocator Allocator, or NULL for the default allocator + * @param out_gesture Receives the created gesture handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if out_gesture is + * NULL, or GHOSTTY_OUT_OF_MEMORY if allocation fails + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_new( + const GhosttyAllocator* allocator, + GhosttySelectionGesture* out_gesture); + +/** + * Free a selection gesture object. + * + * This releases any tracked terminal references owned by the gesture using the + * provided terminal, then frees the gesture object. Passing NULL for gesture is + * allowed and is a no-op. + * + * If the terminal is still alive, pass the terminal most recently used with the + * gesture so any tracked terminal references can be released correctly. If the + * terminal has already been freed, pass NULL for terminal; the terminal's page + * storage has already released the underlying tracked references, so the + * gesture wrapper can be safely discarded without touching the stale terminal + * state. + * + * @param gesture Selection gesture handle to free + * @param terminal Terminal used to release tracked gesture state, or NULL if + * the terminal has already been freed + * + * @ingroup selection + */ +GHOSTTY_API void ghostty_selection_gesture_free( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal); + +/** + * Reset any active selection gesture state. + * + * This cancels the active click sequence and releases any tracked terminal + * references owned by the gesture without freeing the gesture object. + * Passing NULL is allowed and is a no-op. + * + * @param gesture Selection gesture handle to reset + * @param terminal Terminal used to release tracked gesture state + * + * @ingroup selection + */ +GHOSTTY_API void ghostty_selection_gesture_reset( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal); + +/** + * Read data from a selection gesture. + * + * The type of value depends on data and is documented by + * GhosttySelectionGestureData. For GHOSTTY_SELECTION_GESTURE_DATA_ANCHOR, + * the returned GhosttyGridRef is an untracked snapshot with normal grid-ref + * lifetime rules. + * + * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal Terminal used to validate terminal-backed gesture state + * @param data Data field to read + * @param value Output pointer whose type depends on data + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the requested data + * has no value, or GHOSTTY_INVALID_VALUE if gesture, terminal, data, or + * value is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_get( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal, + GhosttySelectionGestureData data, + void* value); + +/** + * Read multiple data fields from a selection gesture in a single call. + * + * This is an optimization over calling ghostty_selection_gesture_get() multiple + * times. Each entry in values must point to storage of the type documented by + * the corresponding GhosttySelectionGestureData key. + * + * If any individual read fails, the function returns that error and writes the + * index of the failing key to out_written when out_written is non-NULL. On + * success, out_written receives count when non-NULL. + * + * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal Terminal used to validate terminal-backed gesture state + * @param count Number of data fields to read + * @param keys Data fields to read (must not be NULL) + * @param values Output pointers corresponding to keys (must not be NULL) + * @param out_written Optional number of fields read, or failing index on error + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if a requested data + * field has no value, or GHOSTTY_INVALID_VALUE if gesture, terminal, + * keys, values, or a value pointer is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_get_multi( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal, + size_t count, + const GhosttySelectionGestureData* keys, + void** values, + size_t* out_written); + /** * Derive a word selection snapshot from a terminal grid reference. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 71b709135..1806d74bc 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -251,6 +251,11 @@ comptime { @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); @export(&c.terminal_selection_contains, .{ .name = "ghostty_terminal_selection_contains" }); @export(&c.terminal_selection_equal, .{ .name = "ghostty_terminal_selection_equal" }); + @export(&c.selection_gesture_new, .{ .name = "ghostty_selection_gesture_new" }); + @export(&c.selection_gesture_free, .{ .name = "ghostty_selection_gesture_free" }); + @export(&c.selection_gesture_reset, .{ .name = "ghostty_selection_gesture_reset" }); + @export(&c.selection_gesture_get, .{ .name = "ghostty_selection_gesture_get" }); + @export(&c.selection_gesture_get_multi, .{ .name = "ghostty_selection_gesture_get_multi" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index 4b1edac88..22ba468b9 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -70,6 +70,7 @@ const std = @import("std"); const assert = std.debug.assert; const testing = std.testing; const Allocator = std.mem.Allocator; +const lib = @import("lib.zig"); const PageList = @import("PageList.zig"); const Pin = PageList.Pin; const Screen = @import("Screen.zig"); @@ -118,22 +119,26 @@ left_drag_autoscroll: Autoscroll, /// /// This is used to implement selection above/below the viewport that /// wants to drag the viewport. -pub const Autoscroll = enum { none, up, down }; +pub const Autoscroll = lib.Enum(lib.target, &.{ + "none", + "up", + "down", +}); /// The selection behavior for a click and subsequent drag. -pub const Behavior = enum { - /// Cell-granular drag selection. Press returns null to clear selection. - cell, +pub const Behavior = lib.Enum(lib.target, &.{ + // Cell-granular drag selection. Press returns null to clear selection. + "cell", - /// Word selection on press and word-granular drag selection. - word, + // Word selection on press and word-granular drag selection. + "word", - /// Line selection on press and line-granular drag selection. - line, + // Line selection on press and line-granular drag selection. + "line", - /// Semantic command output selection on press and drag. - output, -}; + // Semantic command output selection on press and drag. + "output", +}); /// Standard terminal selection behavior for single-, double-, and triple-clicks. /// diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 1d78f06bb..3b853b467 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -31,6 +31,7 @@ pub const modes = @import("modes.zig"); pub const osc = @import("osc.zig"); pub const render = @import("render.zig"); pub const selection = @import("selection.zig"); +pub const selection_gesture = @import("selection_gesture.zig"); pub const key_event = @import("key_event.zig"); pub const key_encode = @import("key_encode.zig"); pub const mouse_event = @import("mouse_event.zig"); @@ -182,6 +183,11 @@ pub const terminal_selection_order = selection.order; pub const terminal_selection_ordered = selection.ordered; pub const terminal_selection_contains = selection.contains; pub const terminal_selection_equal = selection.equal; +pub const selection_gesture_new = selection_gesture.new; +pub const selection_gesture_free = selection_gesture.free; +pub const selection_gesture_reset = selection_gesture.reset; +pub const selection_gesture_get = selection_gesture.get; +pub const selection_gesture_get_multi = selection_gesture.get_multi; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; @@ -214,6 +220,7 @@ test { _ = osc; _ = render; _ = selection; + _ = selection_gesture; _ = key_event; _ = key_encode; _ = mouse_event; diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig new file mode 100644 index 000000000..70c3d76b5 --- /dev/null +++ b/src/terminal/c/selection_gesture.zig @@ -0,0 +1,272 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const SelectionGesture = @import("../SelectionGesture.zig"); +const grid_ref = @import("grid_ref.zig"); +const terminal_c = @import("terminal.zig"); +const Result = @import("result.zig").Result; + +const log = std.log.scoped(.selection_gesture_c); + +/// C: GhosttySelectionGesture +pub const Gesture = ?*GestureWrapper; + +const GestureWrapper = struct { + alloc: std.mem.Allocator, + gesture: SelectionGesture = .init, +}; + +/// C: GhosttySelectionGestureBehavior +pub const Behavior = SelectionGesture.Behavior; + +/// C: GhosttySelectionGestureAutoscroll +pub const Autoscroll = SelectionGesture.Autoscroll; + +/// C: GhosttySelectionGestureData +pub const Data = enum(c_int) { + click_count = 0, + dragged = 1, + autoscroll = 2, + behavior = 3, + anchor = 4, + + pub fn OutType(comptime self: Data) type { + return switch (self) { + .click_count => u8, + .dragged => bool, + .autoscroll => Autoscroll, + .behavior => Behavior, + .anchor => grid_ref.CGridRef, + }; + } +}; + +pub fn new( + alloc_: ?*const CAllocator, + out_gesture: ?*Gesture, +) callconv(lib.calling_conv) Result { + const out = out_gesture orelse return .invalid_value; + + const alloc = lib.alloc.default(alloc_); + const gesture = alloc.create(GestureWrapper) catch { + out.* = null; + return .out_of_memory; + }; + gesture.* = .{ + .alloc = alloc, + }; + out.* = gesture; + return .success; +} + +pub fn free( + gesture_: Gesture, + terminal: terminal_c.Terminal, +) callconv(lib.calling_conv) void { + const wrapper = gesture_ orelse return; + if (terminal_c.zigTerminal(terminal)) |t| { + wrapper.gesture.deinit(t); + } + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +pub fn reset( + gesture_: Gesture, + terminal: terminal_c.Terminal, +) callconv(lib.calling_conv) void { + const wrapper = gesture_ orelse return; + const t = terminal_c.zigTerminal(terminal) orelse return; + wrapper.gesture.reset(t); +} + +pub fn get( + gesture_: Gesture, + terminal: terminal_c.Terminal, + data: Data, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Data, @intFromEnum(data)) catch { + log.warn("selection_gesture_get invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + const out_ptr = out orelse return .invalid_value; + return switch (data) { + inline else => |comptime_data| getTyped( + gesture_, + terminal, + comptime_data, + @ptrCast(@alignCast(out_ptr)), + ), + }; +} + +pub fn get_multi( + gesture_: Gesture, + terminal: terminal_c.Terminal, + count: usize, + keys: ?[*]const Data, + values: ?[*]?*anyopaque, + out_written: ?*usize, +) callconv(lib.calling_conv) Result { + const k = keys orelse return .invalid_value; + const v = values orelse return .invalid_value; + + for (0..count) |i| { + const result = get(gesture_, terminal, k[i], v[i]); + if (result != .success) { + if (out_written) |w| w.* = i; + return result; + } + } + if (out_written) |w| w.* = count; + return .success; +} + +fn getTyped( + gesture_: Gesture, + terminal: terminal_c.Terminal, + comptime data: Data, + out: *data.OutType(), +) Result { + const wrapper = gesture_ orelse return .invalid_value; + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + + switch (data) { + .click_count => out.* = wrapper.gesture.left_click_count, + .dragged => out.* = wrapper.gesture.left_click_dragged, + .autoscroll => out.* = wrapper.gesture.left_drag_autoscroll, + .behavior => out.* = wrapper.gesture.left_click_behavior, + .anchor => { + const pin = wrapper.gesture.validatedLeftClickPin(&t.screens) orelse + return .no_value; + out.* = .fromPin(pin.*); + }, + } + + return .success; +} + +test "selection gesture lifecycle and get" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var click_count: u8 = 255; + try testing.expectEqual(Result.success, get(gesture, terminal, .click_count, &click_count)); + try testing.expectEqual(@as(u8, 0), click_count); + + var dragged = true; + try testing.expectEqual(Result.success, get(gesture, terminal, .dragged, &dragged)); + try testing.expect(!dragged); + + var autoscroll: Autoscroll = .up; + try testing.expectEqual(Result.success, get(gesture, terminal, .autoscroll, &autoscroll)); + try testing.expectEqual(Autoscroll.none, autoscroll); + + var behavior: Behavior = .word; + try testing.expectEqual(Result.success, get(gesture, terminal, .behavior, &behavior)); + try testing.expectEqual(Behavior.cell, behavior); + + var anchor: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.no_value, get(gesture, terminal, .anchor, &anchor)); +} + +test "selection gesture get_multi" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + const keys = [_]Data{ .click_count, .dragged, .autoscroll, .behavior }; + var click_count: u8 = 255; + var dragged = true; + var autoscroll: Autoscroll = .up; + var behavior: Behavior = .word; + var values = [_]?*anyopaque{ + &click_count, + &dragged, + &autoscroll, + &behavior, + }; + var written: usize = 0; + + try testing.expectEqual(Result.success, get_multi( + gesture, + terminal, + keys.len, + &keys, + &values, + &written, + )); + try testing.expectEqual(keys.len, written); + try testing.expectEqual(@as(u8, 0), click_count); + try testing.expect(!dragged); + try testing.expectEqual(Autoscroll.none, autoscroll); + try testing.expectEqual(Behavior.cell, behavior); +} + +test "selection gesture get_multi returns first failing index" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + const keys = [_]Data{ .click_count, .anchor, .dragged }; + var click_count: u8 = 255; + var anchor: grid_ref.CGridRef = undefined; + var dragged = true; + var values = [_]?*anyopaque{ &click_count, &anchor, &dragged }; + var written: usize = 0; + + try testing.expectEqual(Result.no_value, get_multi( + gesture, + terminal, + keys.len, + &keys, + &values, + &written, + )); + try testing.expectEqual(@as(usize, 1), written); + try testing.expectEqual(@as(u8, 0), click_count); + try testing.expect(dragged); +} + +test "selection gesture free null" { + free(null, null); +} From bbfa984aec99c8d3e2e7dde1a10c7520f4f873cb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 09:10:30 -0700 Subject: [PATCH 112/137] libghostty: GhosttySelectionGestureEvent --- include/ghostty/vt/selection.h | 136 ++++++++++++ include/ghostty/vt/types.h | 32 +++ src/lib_vt.zig | 3 + src/terminal/c/main.zig | 3 + src/terminal/c/selection_gesture.zig | 312 +++++++++++++++++++++++++++ src/terminal/c/types.zig | 21 ++ 6 files changed, 507 insertions(+) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index dcdf9108c..053c3bc44 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -50,6 +50,16 @@ extern "C" { */ typedef struct GhosttySelectionGestureImpl* GhosttySelectionGesture; +/** + * Opaque handle to reusable input data for selection gesture operations. + * + * Event options are set with ghostty_selection_gesture_event_set(). Individual + * gesture operations document which options are required or optional. + * + * @ingroup selection + */ +typedef struct GhosttySelectionGestureEventImpl* GhosttySelectionGestureEvent; + /** * A snapshot selection range defined by two grid references. * @@ -315,6 +325,22 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_SELECTION_GESTURE_BEHAVIOR_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionGestureBehavior; +/** + * Selection behaviors for single-, double-, and triple-click gestures. + * + * @ingroup selection + */ +typedef struct { + /** Behavior for single-click selection gestures. */ + GhosttySelectionGestureBehavior single_click; + + /** Behavior for double-click selection gestures. */ + GhosttySelectionGestureBehavior double_click; + + /** Behavior for triple-click selection gestures. */ + GhosttySelectionGestureBehavior triple_click; +} GhosttySelectionGestureBehaviors; + /** * Current autoscroll direction for an active selection drag gesture. * @@ -364,6 +390,116 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_SELECTION_GESTURE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionGestureData; +/** + * Selection gesture event type. + * + * The event type is fixed when the event is created. Each event type documents + * which options are valid and which options are required by gesture operations. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Press event for ghostty_selection_gesture_press(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS = 0, + + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureEventType; + +/** + * Options stored on a reusable selection gesture event. + * + * Passing NULL as the value to ghostty_selection_gesture_event_set() clears the + * corresponding option. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Grid reference under the pointer: GhosttyGridRef*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF = 0, + + /** Surface-space pointer position: GhosttySurfacePosition*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION = 1, + + /** Maximum repeat-click distance in pixels: double*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REPEAT_DISTANCE = 2, + + /** + * Optional monotonic event time in nanoseconds: uint64_t*. + * + * If unset, press treats the event as untimed and only single-click behavior + * is available. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_TIME_NS = 3, + + /** Maximum interval between repeat clicks in nanoseconds: uint64_t*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REPEAT_INTERVAL_NS = 4, + + /** + * Word-boundary codepoints: GhosttyCodepoints*. + * + * The codepoints are copied into event-owned storage when set. If unset, + * operations that need word boundaries use Ghostty's defaults. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS = 5, + + /** + * Selection behavior table: GhosttySelectionGestureBehaviors*. + * + * If unset, press uses the default behavior table: cell, word, line. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_BEHAVIORS = 6, + + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureEventOption; + +/** + * Create a reusable selection gesture event object. + * + * @param allocator Allocator, or NULL for the default allocator + * @param out_event Receives the created event handle + * @param type Event type. This is fixed for the lifetime of the event. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if out_event is + * NULL or type is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation fails + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_new( + const GhosttyAllocator* allocator, + GhosttySelectionGestureEvent* out_event, + GhosttySelectionGestureEventType type); + +/** + * Free a selection gesture event object. + * + * Passing NULL is allowed and is a no-op. + * + * @param event Selection gesture event handle to free + * + * @ingroup selection + */ +GHOSTTY_API void ghostty_selection_gesture_event_free( + GhosttySelectionGestureEvent event); + +/** + * Set or clear an option on a selection gesture event. + * + * The value type depends on option and is documented by + * GhosttySelectionGestureEventOption. Passing NULL for value clears the option. + * + * @param event Selection gesture event handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param option Event option to set or clear + * @param value Pointer to the input value for option, or NULL to clear + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if copying + * event-owned data fails, or GHOSTTY_INVALID_VALUE if event, option, or + * value is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_set( + GhosttySelectionGestureEvent event, + GhosttySelectionGestureEventOption option, + const void* value); + /** * Create a selection gesture object. * diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index f3874153f..1bec223d6 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -227,6 +227,38 @@ typedef struct { size_t len; } GhosttyString; +/** + * A surface-space position in pixels. + * + * This is not a terminal grid coordinate. It represents an x/y position in the + * rendered surface coordinate space, with (0, 0) at the top-left of the + * surface. + */ +typedef struct { + /** X position in surface pixels. */ + double x; + + /** Y position in surface pixels. */ + double y; +} GhosttySurfacePosition; + +/** + * A borrowed list of Unicode scalar values. + * + * Values are encoded as uint32_t scalar values. The memory is not owned by this + * struct. The pointer is only valid for the lifetime documented by the API that + * consumes or produces it. + * + * APIs may document special handling for NULL + len 0, such as “use defaults”. + */ +typedef struct { + /** Pointer to Unicode scalar values. */ + const uint32_t* ptr; + + /** Number of entries in ptr. */ + size_t len; +} GhosttyCodepoints; + /** * Initialize a sized struct to zero and set its size field. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 1806d74bc..a7139476f 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -256,6 +256,9 @@ comptime { @export(&c.selection_gesture_reset, .{ .name = "ghostty_selection_gesture_reset" }); @export(&c.selection_gesture_get, .{ .name = "ghostty_selection_gesture_get" }); @export(&c.selection_gesture_get_multi, .{ .name = "ghostty_selection_gesture_get_multi" }); + @export(&c.selection_gesture_event_new, .{ .name = "ghostty_selection_gesture_event_new" }); + @export(&c.selection_gesture_event_free, .{ .name = "ghostty_selection_gesture_event_free" }); + @export(&c.selection_gesture_event_set, .{ .name = "ghostty_selection_gesture_event_set" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 3b853b467..959edaabe 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -188,6 +188,9 @@ pub const selection_gesture_free = selection_gesture.free; pub const selection_gesture_reset = selection_gesture.reset; pub const selection_gesture_get = selection_gesture.get; pub const selection_gesture_get_multi = selection_gesture.get_multi; +pub const selection_gesture_event_new = selection_gesture.event_new; +pub const selection_gesture_event_free = selection_gesture.event_free; +pub const selection_gesture_event_set = selection_gesture.event_set; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig index 70c3d76b5..8087a88a9 100644 --- a/src/terminal/c/selection_gesture.zig +++ b/src/terminal/c/selection_gesture.zig @@ -1,10 +1,13 @@ const std = @import("std"); const testing = std.testing; +const builtin = @import("builtin"); const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; const SelectionGesture = @import("../SelectionGesture.zig"); +const selection_codepoints = @import("../selection_codepoints.zig"); const grid_ref = @import("grid_ref.zig"); const terminal_c = @import("terminal.zig"); +const types = @import("types.zig"); const Result = @import("result.zig").Result; const log = std.log.scoped(.selection_gesture_c); @@ -12,17 +15,71 @@ const log = std.log.scoped(.selection_gesture_c); /// C: GhosttySelectionGesture pub const Gesture = ?*GestureWrapper; +/// C: GhosttySelectionGestureEvent +pub const Event = ?*EventWrapper; + const GestureWrapper = struct { alloc: std.mem.Allocator, gesture: SelectionGesture = .init, }; +const EventWrapper = struct { + alloc: std.mem.Allocator, + event: union(EventType) { + press: SelectionGesture.Press, + }, + + // Backing storage for Press.word_boundary_codepoints. The C API receives + // codepoints as borrowed uint32_t values, but SelectionGesture.Press stores + // a []const u21 slice. We copy/convert into event-owned storage so the real + // Press payload can safely point at it until the event is changed or freed. + word_boundary_codepoints: ?[]u21 = null, + + // Backing storage for Press.behaviors. The C API sets behaviors as a value + // struct, but SelectionGesture.Press stores a pointer to a [3]Behavior. + // Keep the array on the event wrapper so the Press payload can point at a + // stable location for the lifetime of the event. + behaviors: [3]Behavior = SelectionGesture.default_behaviors, + + fn init(self: *EventWrapper, event_type: EventType) void { + self.event = switch (event_type) { + .press => .{ .press = self.defaultPress() }, + }; + } + + fn defaultPress(self: *EventWrapper) SelectionGesture.Press { + return .{ + .time = null, + .pin = undefined, + .xpos = 0, + .ypos = 0, + .max_distance = 0, + .repeat_interval = 0, + .word_boundary_codepoints = &selection_codepoints.default_word_boundaries, + .behaviors = &self.behaviors, + }; + } + + fn deinit(self: *EventWrapper) void { + if (self.word_boundary_codepoints) |cps| { + if (cps.len > 0) self.alloc.free(cps); + } + } +}; + /// C: GhosttySelectionGestureBehavior pub const Behavior = SelectionGesture.Behavior; /// C: GhosttySelectionGestureAutoscroll pub const Autoscroll = SelectionGesture.Autoscroll; +/// C: GhosttySelectionGestureBehaviors +pub const Behaviors = extern struct { + single_click: Behavior, + double_click: Behavior, + triple_click: Behavior, +}; + /// C: GhosttySelectionGestureData pub const Data = enum(c_int) { click_count = 0, @@ -42,6 +99,34 @@ pub const Data = enum(c_int) { } }; +/// C: GhosttySelectionGestureEventType +pub const EventType = enum(c_int) { + press = 0, +}; + +/// C: GhosttySelectionGestureEventOption +pub const EventOption = enum(c_int) { + ref = 0, + position = 1, + repeat_distance = 2, + time_ns = 3, + repeat_interval_ns = 4, + word_boundary_codepoints = 5, + behaviors = 6, + + pub fn Type(comptime self: EventOption) type { + return switch (self) { + .ref => grid_ref.CGridRef, + .position => types.SurfacePosition, + .repeat_distance => f64, + .time_ns => u64, + .repeat_interval_ns => u64, + .word_boundary_codepoints => types.Codepoints, + .behaviors => Behaviors, + }; + } +}; + pub fn new( alloc_: ?*const CAllocator, out_gesture: ?*Gesture, @@ -60,6 +145,29 @@ pub fn new( return .success; } +pub fn event_new( + alloc_: ?*const CAllocator, + out_event: ?*Event, + event_type: EventType, +) callconv(lib.calling_conv) Result { + const out = out_event orelse return .invalid_value; + _ = std.meta.intToEnum(EventType, @intFromEnum(event_type)) catch + return .invalid_value; + + const alloc = lib.alloc.default(alloc_); + const event = alloc.create(EventWrapper) catch { + out.* = null; + return .out_of_memory; + }; + event.* = .{ + .alloc = alloc, + .event = undefined, + }; + event.init(event_type); + out.* = event; + return .success; +} + pub fn free( gesture_: Gesture, terminal: terminal_c.Terminal, @@ -72,6 +180,13 @@ pub fn free( alloc.destroy(wrapper); } +pub fn event_free(event_: Event) callconv(lib.calling_conv) void { + const event = event_ orelse return; + event.deinit(); + const alloc = event.alloc; + alloc.destroy(event); +} + pub fn reset( gesture_: Gesture, terminal: terminal_c.Terminal, @@ -81,6 +196,27 @@ pub fn reset( wrapper.gesture.reset(t); } +pub fn event_set( + event_: Event, + option: EventOption, + value: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(EventOption, @intFromEnum(option)) catch { + log.warn("selection_gesture_event_set invalid option value={d}", .{@intFromEnum(option)}); + return .invalid_value; + }; + } + + return switch (option) { + inline else => |comptime_option| eventSetTyped( + event_, + comptime_option, + if (value) |ptr| @ptrCast(@alignCast(ptr)) else null, + ), + }; +} + pub fn get( gesture_: Gesture, terminal: terminal_c.Terminal, @@ -151,6 +287,102 @@ fn getTyped( return .success; } +fn eventSetTyped( + event_: Event, + comptime option: EventOption, + value: ?*const option.Type(), +) Result { + const event = event_ orelse return .invalid_value; + return switch (event.event) { + .press => |*press| pressSetTyped(event, press, option, value), + }; +} + +fn pressSetTyped( + event: *EventWrapper, + press: *SelectionGesture.Press, + comptime option: EventOption, + value: ?*const option.Type(), +) Result { + const v = value orelse { + switch (option) { + .ref => {}, + .position => { + press.xpos = 0; + press.ypos = 0; + }, + .repeat_distance => press.max_distance = 0, + .time_ns => press.time = null, + .repeat_interval_ns => press.repeat_interval = 0, + .word_boundary_codepoints => clearPressCodepoints(event, press), + .behaviors => { + event.behaviors = SelectionGesture.default_behaviors; + press.behaviors = &event.behaviors; + }, + } + return .success; + }; + + switch (option) { + .ref => press.pin = v.toPin() orelse return .invalid_value, + .position => { + press.xpos = v.x; + press.ypos = v.y; + }, + .repeat_distance => press.max_distance = v.*, + .time_ns => press.time = instantFromNs(v.*), + .repeat_interval_ns => press.repeat_interval = v.*, + .word_boundary_codepoints => { + if (v.len > 0 and v.ptr == null) return .invalid_value; + clearPressCodepoints(event, press); + const ptr = v.ptr orelse { + event.word_boundary_codepoints = &.{}; + press.word_boundary_codepoints = event.word_boundary_codepoints.?; + return .success; + }; + const copy = event.alloc.alloc(u21, v.len) catch return .out_of_memory; + errdefer event.alloc.free(copy); + for (copy, ptr[0..v.len]) |*dst, cp| { + dst.* = std.math.cast(u21, cp) orelse return .invalid_value; + } + event.word_boundary_codepoints = copy; + press.word_boundary_codepoints = copy; + }, + .behaviors => { + if (!validBehavior(v.single_click) or + !validBehavior(v.double_click) or + !validBehavior(v.triple_click)) return .invalid_value; + event.behaviors = .{ v.single_click, v.double_click, v.triple_click }; + press.behaviors = &event.behaviors; + }, + } + + return .success; +} + +fn clearPressCodepoints(event: *EventWrapper, press: *SelectionGesture.Press) void { + if (event.word_boundary_codepoints) |cps| { + if (cps.len > 0) event.alloc.free(cps); + } + event.word_boundary_codepoints = null; + press.word_boundary_codepoints = &selection_codepoints.default_word_boundaries; +} + +fn instantFromNs(ns: u64) std.time.Instant { + return switch (builtin.os.tag) { + .windows, .uefi, .wasi => .{ .timestamp = ns }, + else => .{ .timestamp = .{ + .sec = @intCast(ns / std.time.ns_per_s), + .nsec = @intCast(ns % std.time.ns_per_s), + } }, + }; +} + +fn validBehavior(behavior: Behavior) bool { + _ = std.meta.intToEnum(Behavior, @intFromEnum(behavior)) catch return false; + return true; +} + test "selection gesture lifecycle and get" { var terminal: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( @@ -267,6 +499,86 @@ test "selection gesture get_multi returns first failing index" { try testing.expect(dragged); } +test "selection gesture event set clear and free" { + var event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &event, + .press, + )); + defer event_free(event); + + const in_pos: types.SurfacePosition = .{ .x = 12.5, .y = -3.25 }; + try testing.expectEqual(Result.success, event_set(event, .position, &in_pos)); + try testing.expectEqual(@as(f64, 12.5), event.?.event.press.xpos); + try testing.expectEqual(@as(f64, -3.25), event.?.event.press.ypos); + + try testing.expectEqual(Result.success, event_set(event, .position, null)); + try testing.expectEqual(@as(f64, 0), event.?.event.press.xpos); + try testing.expectEqual(@as(f64, 0), event.?.event.press.ypos); + + const repeat_distance: f64 = 4.0; + try testing.expectEqual(Result.success, event_set(event, .repeat_distance, &repeat_distance)); + try testing.expectEqual(repeat_distance, event.?.event.press.max_distance); +} + +test "selection gesture event copies clears and frees codepoints" { + var event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &event, + .press, + )); + defer event_free(event); + + var values = [_]u32{ ' ', '\t' }; + const in: types.Codepoints = .{ .ptr = &values, .len = values.len }; + try testing.expectEqual(Result.success, event_set(event, .word_boundary_codepoints, &in)); + + values[0] = 'x'; + + try testing.expectEqual(@as(usize, 2), event.?.event.press.word_boundary_codepoints.len); + try testing.expectEqual(@as(u21, ' '), event.?.event.press.word_boundary_codepoints[0]); + try testing.expectEqual(@as(u21, '\t'), event.?.event.press.word_boundary_codepoints[1]); + + const invalid: types.Codepoints = .{ .ptr = null, .len = 1 }; + try testing.expectEqual(Result.invalid_value, event_set(event, .word_boundary_codepoints, &invalid)); + + try testing.expectEqual(Result.success, event_set(event, .word_boundary_codepoints, null)); + try testing.expectEqual( + selection_codepoints.default_word_boundaries.len, + event.?.event.press.word_boundary_codepoints.len, + ); + + const empty: types.Codepoints = .{ .ptr = null, .len = 0 }; + try testing.expectEqual(Result.success, event_set(event, .word_boundary_codepoints, &empty)); + try testing.expectEqual(@as(usize, 0), event.?.event.press.word_boundary_codepoints.len); +} + +test "selection gesture event behaviors" { + var event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &event, + .press, + )); + defer event_free(event); + + const in: Behaviors = .{ + .single_click = .cell, + .double_click = .word, + .triple_click = .line, + }; + try testing.expectEqual(Result.success, event_set(event, .behaviors, &in)); + try testing.expectEqual(Behavior.cell, event.?.event.press.behaviors[0]); + try testing.expectEqual(Behavior.word, event.?.event.press.behaviors[1]); + try testing.expectEqual(Behavior.line, event.?.event.press.behaviors[2]); +} + test "selection gesture free null" { free(null, null); } + +test "selection gesture event free null" { + event_free(null); +} diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index d9ece57ee..9a4ef3d56 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -14,15 +14,29 @@ const size_report = @import("size_report.zig"); const terminal = @import("terminal.zig"); const formatter = @import("formatter.zig"); const selection = @import("selection.zig"); +const selection_gesture = @import("selection_gesture.zig"); const render = @import("render.zig"); const style_c = @import("style.zig"); const mouse_encode = @import("mouse_encode.zig"); const grid_ref = @import("grid_ref.zig"); +/// C: GhosttySurfacePosition +pub const SurfacePosition = extern struct { + x: f64, + y: f64, +}; + +/// C: GhosttyCodepoints +pub const Codepoints = extern struct { + ptr: ?[*]const u32 = null, + len: usize = 0, +}; + /// All C API structs and their Ghostty C names. pub const structs: std.StaticStringMap(StructInfo) = structs: { @setEvalBranchQuota(10_000); break :structs .initComptime(.{ + .{ "GhosttyCodepoints", StructInfo.init(Codepoints) }, .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) }, .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) }, .{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) }, @@ -41,8 +55,10 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: { .{ "GhosttyPoint", StructInfo.init(point.Point.C) }, .{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) }, .{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) }, + .{ "GhosttySelectionGestureBehaviors", StructInfo.init(selection_gesture.Behaviors) }, .{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) }, .{ "GhosttyString", StructInfo.init(lib.String) }, + .{ "GhosttySurfacePosition", StructInfo.init(SurfacePosition) }, .{ "GhosttyStyle", StructInfo.init(style_c.Style) }, .{ "GhosttyStyleColor", StructInfo.init(style_c.Color) }, .{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) }, @@ -150,6 +166,11 @@ fn jsonWriteAll(writer: *std.Io.Writer) std.Io.Writer.Error!void { fn typeName(comptime T: type) []const u8 { return switch (@typeInfo(T)) { .bool => "bool", + .float => |info| switch (info.bits) { + 32 => "f32", + 64 => "f64", + else => @compileError("unsupported float size"), + }, .int => |info| switch (info.signedness) { .signed => switch (info.bits) { 8 => "i8", From 5ac8e6569a8d1d73f1bff9b4fc82ea703b9ca97e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 10:44:40 -0700 Subject: [PATCH 113/137] libghostty: add ghostty_selection_gesture_event --- include/ghostty/vt/selection.h | 29 ++++++ src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/selection_gesture.zig | 145 ++++++++++++++++++++++++++- 4 files changed, 174 insertions(+), 2 deletions(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 053c3bc44..0270a3038 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -500,6 +500,35 @@ GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_set( GhosttySelectionGestureEventOption option, const void* value); +/** + * Apply a selection gesture event and return the resulting selection snapshot. + * + * This dispatches to the gesture operation matching the event's fixed type. + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS, the event must have + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF set before calling this function. + * All other press options use their initialized defaults when unset or cleared. + * + * The returned selection is not installed as the terminal's current selection. + * It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal Terminal used to interpret and update gesture state + * @param event Selection gesture event handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_selection On success, receives the resulting selection. May + * be NULL to apply the event and discard the selection result. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the event does not + * currently produce a selection, GHOSTTY_OUT_OF_MEMORY if tracking + * gesture state fails, or GHOSTTY_INVALID_VALUE if gesture, terminal, + * event, or required event data is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_event( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal, + GhosttySelectionGestureEvent event, + GhosttySelection* out_selection); + /** * Create a selection gesture object. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index a7139476f..6d4406e88 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -254,6 +254,7 @@ comptime { @export(&c.selection_gesture_new, .{ .name = "ghostty_selection_gesture_new" }); @export(&c.selection_gesture_free, .{ .name = "ghostty_selection_gesture_free" }); @export(&c.selection_gesture_reset, .{ .name = "ghostty_selection_gesture_reset" }); + @export(&c.selection_gesture_event, .{ .name = "ghostty_selection_gesture_event" }); @export(&c.selection_gesture_get, .{ .name = "ghostty_selection_gesture_get" }); @export(&c.selection_gesture_get_multi, .{ .name = "ghostty_selection_gesture_get_multi" }); @export(&c.selection_gesture_event_new, .{ .name = "ghostty_selection_gesture_event_new" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 959edaabe..648bdbe51 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -186,6 +186,7 @@ pub const terminal_selection_equal = selection.equal; pub const selection_gesture_new = selection_gesture.new; pub const selection_gesture_free = selection_gesture.free; pub const selection_gesture_reset = selection_gesture.reset; +pub const selection_gesture_event = selection_gesture.handle_event; pub const selection_gesture_get = selection_gesture.get; pub const selection_gesture_get_multi = selection_gesture.get_multi; pub const selection_gesture_event_new = selection_gesture.event_new; diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig index 8087a88a9..df823e62e 100644 --- a/src/terminal/c/selection_gesture.zig +++ b/src/terminal/c/selection_gesture.zig @@ -6,6 +6,7 @@ const CAllocator = lib.alloc.Allocator; const SelectionGesture = @import("../SelectionGesture.zig"); const selection_codepoints = @import("../selection_codepoints.zig"); const grid_ref = @import("grid_ref.zig"); +const selection_c = @import("selection.zig"); const terminal_c = @import("terminal.zig"); const types = @import("types.zig"); const Result = @import("result.zig").Result; @@ -29,6 +30,12 @@ const EventWrapper = struct { press: SelectionGesture.Press, }, + // Press.pin has no safe sentinel value: PageList.Pin contains a non-null + // node pointer and is undefined until the C caller provides a GhosttyGridRef. + // Track that separately so event execution can reject a press whose required + // ref option was never set, or was later cleared. + press_pin_set: bool = false, + // Backing storage for Press.word_boundary_codepoints. The C API receives // codepoints as borrowed uint32_t values, but SelectionGesture.Press stores // a []const u21 slice. We copy/convert into event-owned storage so the real @@ -196,6 +203,28 @@ pub fn reset( wrapper.gesture.reset(t); } +pub fn handle_event( + gesture_: Gesture, + terminal: terminal_c.Terminal, + event_: Event, + out_selection: ?*selection_c.CSelection, +) callconv(lib.calling_conv) Result { + const wrapper = gesture_ orelse return .invalid_value; + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const event_wrapper = event_ orelse return .invalid_value; + + return switch (event_wrapper.event) { + .press => |press| { + if (!event_wrapper.press_pin_set) return .invalid_value; + const sel = wrapper.gesture.press(t, press) catch return .out_of_memory; + if (out_selection) |out| { + out.* = selection_c.CSelection.fromZig(sel orelse return .no_value); + } else if (sel == null) return .no_value; + return .success; + }, + }; +} + pub fn event_set( event_: Event, option: EventOption, @@ -306,7 +335,7 @@ fn pressSetTyped( ) Result { const v = value orelse { switch (option) { - .ref => {}, + .ref => event.press_pin_set = false, .position => { press.xpos = 0; press.ypos = 0; @@ -324,7 +353,10 @@ fn pressSetTyped( }; switch (option) { - .ref => press.pin = v.toPin() orelse return .invalid_value, + .ref => { + press.pin = v.toPin() orelse return .invalid_value; + event.press_pin_set = true; + }, .position => { press.xpos = v.x; press.ypos = v.y; @@ -575,6 +607,115 @@ test "selection gesture event behaviors" { try testing.expectEqual(Behavior.line, event.?.event.press.behaviors[2]); } +test "selection gesture event applies press" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &press_event, + .press, + )); + defer event_free(press_event); + + terminal_c.vt_write(terminal, "abc", 3); + + var ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &ref)); + try testing.expectEqual(Result.success, event_set(press_event, .ref, &ref)); + const behaviors: Behaviors = .{ + .single_click = .word, + .double_click = .word, + .triple_click = .line, + }; + try testing.expectEqual(Result.success, event_set(press_event, .behaviors, &behaviors)); + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, handle_event(gesture, terminal, press_event, &sel)); + try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x); + try testing.expectEqual(@as(u16, 2), sel.end.toPin().?.x); + + try testing.expectEqual(Result.success, handle_event(gesture, terminal, press_event, null)); +} + +test "selection gesture event press requires ref" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &press_event, + .press, + )); + defer event_free(press_event); + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.invalid_value, handle_event(gesture, terminal, press_event, &sel)); +} + +test "selection gesture event null output still reports no selection" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &press_event, + .press, + )); + defer event_free(press_event); + + var ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &ref)); + try testing.expectEqual(Result.success, event_set(press_event, .ref, &ref)); + + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null)); +} + test "selection gesture free null" { free(null, null); } From 3fd2c66a048ad12901ea30ef30da1a4dfc7395a4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 10:48:30 -0700 Subject: [PATCH 114/137] libghostty: selection gesture release event --- include/ghostty/vt/selection.h | 16 +++- src/terminal/c/selection_gesture.zig | 137 +++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 0270a3038..e0476f7db 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -402,6 +402,9 @@ typedef enum GHOSTTY_ENUM_TYPED { /** Press event for ghostty_selection_gesture_press(). */ GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS = 0, + /** Release event for ghostty_selection_gesture_release(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE = 1, + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionGestureEventType; @@ -414,7 +417,12 @@ typedef enum GHOSTTY_ENUM_TYPED { * @ingroup selection */ typedef enum GHOSTTY_ENUM_TYPED { - /** Grid reference under the pointer: GhosttyGridRef*. */ + /** + * Grid reference under the pointer: GhosttyGridRef*. + * + * Required for PRESS events. Optional for RELEASE events; when unset or + * cleared, release records that the pointer did not map to a valid cell. + */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF = 0, /** Surface-space pointer position: GhosttySurfacePosition*. */ @@ -508,6 +516,12 @@ GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_set( * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF set before calling this function. * All other press options use their initialized defaults when unset or cleared. * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE, only + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF is valid. It is optional; if unset or + * cleared, release records that the pointer did not map to a valid cell. Release + * events update gesture state but do not produce a selection, so this function + * returns GHOSTTY_NO_VALUE after applying them. + * * The returned selection is not installed as the terminal's current selection. * It is a snapshot with the same lifetime rules as GhosttySelection. * diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig index df823e62e..39ad8b489 100644 --- a/src/terminal/c/selection_gesture.zig +++ b/src/terminal/c/selection_gesture.zig @@ -28,6 +28,7 @@ const EventWrapper = struct { alloc: std.mem.Allocator, event: union(EventType) { press: SelectionGesture.Press, + release: SelectionGesture.Release, }, // Press.pin has no safe sentinel value: PageList.Pin contains a non-null @@ -51,6 +52,7 @@ const EventWrapper = struct { fn init(self: *EventWrapper, event_type: EventType) void { self.event = switch (event_type) { .press => .{ .press = self.defaultPress() }, + .release => .{ .release = self.defaultRelease() }, }; } @@ -67,6 +69,11 @@ const EventWrapper = struct { }; } + fn defaultRelease(self: *EventWrapper) SelectionGesture.Release { + _ = self; + return .{ .pin = null }; + } + fn deinit(self: *EventWrapper) void { if (self.word_boundary_codepoints) |cps| { if (cps.len > 0) self.alloc.free(cps); @@ -109,6 +116,7 @@ pub const Data = enum(c_int) { /// C: GhosttySelectionGestureEventType pub const EventType = enum(c_int) { press = 0, + release = 1, }; /// C: GhosttySelectionGestureEventOption @@ -222,6 +230,10 @@ pub fn handle_event( } else if (sel == null) return .no_value; return .success; }, + .release => |release| { + wrapper.gesture.release(t, release); + return .no_value; + }, }; } @@ -324,6 +336,7 @@ fn eventSetTyped( const event = event_ orelse return .invalid_value; return switch (event.event) { .press => |*press| pressSetTyped(event, press, option, value), + .release => |*release| releaseSetTyped(release, option, value), }; } @@ -392,6 +405,32 @@ fn pressSetTyped( return .success; } +fn releaseSetTyped( + release: *SelectionGesture.Release, + comptime option: EventOption, + value: ?*const option.Type(), +) Result { + switch (option) { + .ref => { + const v = value orelse { + release.pin = null; + return .success; + }; + release.pin = v.toPin() orelse return .invalid_value; + }, + + .position, + .repeat_distance, + .time_ns, + .repeat_interval_ns, + .word_boundary_codepoints, + .behaviors, + => return .invalid_value, + } + + return .success; +} + fn clearPressCodepoints(event: *EventWrapper, press: *SelectionGesture.Press) void { if (event.word_boundary_codepoints) |cps| { if (cps.len > 0) event.alloc.free(cps); @@ -716,6 +755,104 @@ test "selection gesture event null output still reports no selection" { try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null)); } +test "selection gesture event applies release" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &press_event, + .press, + )); + defer event_free(press_event); + + var release_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &release_event, + .release, + )); + defer event_free(release_event); + + var ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &ref)); + try testing.expectEqual(Result.success, event_set(press_event, .ref, &ref)); + try testing.expectEqual(Result.success, event_set(release_event, .ref, &ref)); + + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null)); + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, release_event, null)); + + var dragged = true; + try testing.expectEqual(Result.success, get(gesture, terminal, .dragged, &dragged)); + try testing.expect(!dragged); + + const pos: types.SurfacePosition = .{ .x = 0, .y = 0 }; + try testing.expectEqual(Result.invalid_value, event_set(release_event, .position, &pos)); +} + +test "selection gesture release without ref marks dragged" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &press_event, + .press, + )); + defer event_free(press_event); + + var release_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &release_event, + .release, + )); + defer event_free(release_event); + + var ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &ref)); + try testing.expectEqual(Result.success, event_set(press_event, .ref, &ref)); + + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null)); + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, release_event, null)); + + var dragged = false; + try testing.expectEqual(Result.success, get(gesture, terminal, .dragged, &dragged)); + try testing.expect(dragged); +} + test "selection gesture free null" { free(null, null); } From 90fd1ec2e78d1c0b3f640c9eb1e73a6e7dd7232b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 10:51:12 -0700 Subject: [PATCH 115/137] libghostty: selection gesture drag events --- include/ghostty/vt/selection.h | 43 +++- src/terminal/c/selection_gesture.zig | 300 +++++++++++++++++++++++++-- src/terminal/c/types.zig | 1 + 3 files changed, 317 insertions(+), 27 deletions(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index e0476f7db..1cb774071 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -341,6 +341,25 @@ typedef struct { GhosttySelectionGestureBehavior triple_click; } GhosttySelectionGestureBehaviors; +/** + * Display geometry used to interpret selection gesture drag events. + * + * @ingroup selection + */ +typedef struct { + /** Number of columns in the rendered terminal grid. Must be non-zero. */ + uint32_t columns; + + /** Width of one terminal cell in surface pixels. Must be non-zero. */ + uint32_t cell_width; + + /** Left padding before the terminal grid begins in surface pixels. */ + uint32_t padding_left; + + /** Height of the rendered terminal surface in surface pixels. Must be non-zero. */ + uint32_t screen_height; +} GhosttySelectionGestureGeometry; + /** * Current autoscroll direction for an active selection drag gesture. * @@ -405,6 +424,9 @@ typedef enum GHOSTTY_ENUM_TYPED { /** Release event for ghostty_selection_gesture_release(). */ GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE = 1, + /** Drag event for ghostty_selection_gesture_drag(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG = 2, + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionGestureEventType; @@ -420,12 +442,12 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Grid reference under the pointer: GhosttyGridRef*. * - * Required for PRESS events. Optional for RELEASE events; when unset or - * cleared, release records that the pointer did not map to a valid cell. + * Required for PRESS and DRAG events. Optional for RELEASE events; when unset + * or cleared, release records that the pointer did not map to a valid cell. */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF = 0, - /** Surface-space pointer position: GhosttySurfacePosition*. */ + /** Surface-space pointer position: GhosttySurfacePosition*. Valid for PRESS and DRAG. */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION = 1, /** Maximum repeat-click distance in pixels: double*. */ @@ -446,7 +468,8 @@ typedef enum GHOSTTY_ENUM_TYPED { * Word-boundary codepoints: GhosttyCodepoints*. * * The codepoints are copied into event-owned storage when set. If unset, - * operations that need word boundaries use Ghostty's defaults. + * operations that need word boundaries use Ghostty's defaults. Valid for + * PRESS and DRAG. */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS = 5, @@ -457,6 +480,12 @@ typedef enum GHOSTTY_ENUM_TYPED { */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_BEHAVIORS = 6, + /** Whether a drag event should produce a rectangular selection: bool*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_RECTANGLE = 7, + + /** Drag display geometry: GhosttySelectionGestureGeometry*. Required for DRAG. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY = 8, + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionGestureEventOption; @@ -522,6 +551,12 @@ GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_set( * events update gesture state but do not produce a selection, so this function * returns GHOSTTY_NO_VALUE after applying them. * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG, + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF and + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY are required. Position, + * rectangle, and word-boundary codepoints are optional and use initialized + * defaults when unset or cleared. + * * The returned selection is not installed as the terminal's current selection. * It is a snapshot with the same lifetime rules as GhosttySelection. * diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig index 39ad8b489..580fbe723 100644 --- a/src/terminal/c/selection_gesture.zig +++ b/src/terminal/c/selection_gesture.zig @@ -29,6 +29,7 @@ const EventWrapper = struct { event: union(EventType) { press: SelectionGesture.Press, release: SelectionGesture.Release, + drag: SelectionGesture.Drag, }, // Press.pin has no safe sentinel value: PageList.Pin contains a non-null @@ -37,10 +38,18 @@ const EventWrapper = struct { // ref option was never set, or was later cleared. press_pin_set: bool = false, - // Backing storage for Press.word_boundary_codepoints. The C API receives - // codepoints as borrowed uint32_t values, but SelectionGesture.Press stores - // a []const u21 slice. We copy/convert into event-owned storage so the real - // Press payload can safely point at it until the event is changed or freed. + // Drag.pin and Drag.geometry are required by SelectionGesture.drag but have + // no meaningful zero/sentinel value. Track whether the C caller set them so + // dispatch can reject incomplete drag events instead of using placeholder + // data. + drag_pin_set: bool = false, + drag_geometry_set: bool = false, + + // Backing storage for Press/Drag.word_boundary_codepoints. The C API + // receives codepoints as borrowed uint32_t values, but SelectionGesture + // stores a []const u21 slice. We copy/convert into event-owned storage so + // the real payload can safely point at it until the event is changed or + // freed. word_boundary_codepoints: ?[]u21 = null, // Backing storage for Press.behaviors. The C API sets behaviors as a value @@ -53,6 +62,7 @@ const EventWrapper = struct { self.event = switch (event_type) { .press => .{ .press = self.defaultPress() }, .release => .{ .release = self.defaultRelease() }, + .drag => .{ .drag = self.defaultDrag() }, }; } @@ -74,6 +84,18 @@ const EventWrapper = struct { return .{ .pin = null }; } + fn defaultDrag(self: *EventWrapper) SelectionGesture.Drag { + _ = self; + return .{ + .pin = undefined, + .xpos = 0, + .ypos = 0, + .rectangle = false, + .word_boundary_codepoints = &selection_codepoints.default_word_boundaries, + .geometry = undefined, + }; + } + fn deinit(self: *EventWrapper) void { if (self.word_boundary_codepoints) |cps| { if (cps.len > 0) self.alloc.free(cps); @@ -117,6 +139,7 @@ pub const Data = enum(c_int) { pub const EventType = enum(c_int) { press = 0, release = 1, + drag = 2, }; /// C: GhosttySelectionGestureEventOption @@ -128,6 +151,8 @@ pub const EventOption = enum(c_int) { repeat_interval_ns = 4, word_boundary_codepoints = 5, behaviors = 6, + rectangle = 7, + geometry = 8, pub fn Type(comptime self: EventOption) type { return switch (self) { @@ -138,6 +163,28 @@ pub const EventOption = enum(c_int) { .repeat_interval_ns => u64, .word_boundary_codepoints => types.Codepoints, .behaviors => Behaviors, + .rectangle => bool, + .geometry => Geometry, + }; + } +}; + +/// C: GhosttySelectionGestureGeometry +pub const Geometry = extern struct { + columns: u32, + cell_width: u32, + padding_left: u32, + screen_height: u32, + + fn toZig(self: Geometry) ?SelectionGesture.Drag.Geometry { + if (self.columns == 0) return null; + if (self.cell_width == 0) return null; + if (self.screen_height == 0) return null; + return .{ + .columns = self.columns, + .cell_width = self.cell_width, + .padding_left = self.padding_left, + .screen_height = self.screen_height, }; } }; @@ -234,6 +281,15 @@ pub fn handle_event( wrapper.gesture.release(t, release); return .no_value; }, + .drag => |drag| { + if (!event_wrapper.drag_pin_set) return .invalid_value; + if (!event_wrapper.drag_geometry_set) return .invalid_value; + const sel = wrapper.gesture.drag(t, drag); + if (out_selection) |out| { + out.* = selection_c.CSelection.fromZig(sel orelse return .no_value); + } else if (sel == null) return .no_value; + return .success; + }, }; } @@ -337,6 +393,7 @@ fn eventSetTyped( return switch (event.event) { .press => |*press| pressSetTyped(event, press, option, value), .release => |*release| releaseSetTyped(release, option, value), + .drag => |*drag| dragSetTyped(event, drag, option, value), }; } @@ -356,11 +413,17 @@ fn pressSetTyped( .repeat_distance => press.max_distance = 0, .time_ns => press.time = null, .repeat_interval_ns => press.repeat_interval = 0, - .word_boundary_codepoints => clearPressCodepoints(event, press), + .word_boundary_codepoints => clearWordBoundaryCodepoints( + event, + &press.word_boundary_codepoints, + ), .behaviors => { event.behaviors = SelectionGesture.default_behaviors; press.behaviors = &event.behaviors; }, + .rectangle, + .geometry, + => return .invalid_value, } return .success; }; @@ -377,22 +440,11 @@ fn pressSetTyped( .repeat_distance => press.max_distance = v.*, .time_ns => press.time = instantFromNs(v.*), .repeat_interval_ns => press.repeat_interval = v.*, - .word_boundary_codepoints => { - if (v.len > 0 and v.ptr == null) return .invalid_value; - clearPressCodepoints(event, press); - const ptr = v.ptr orelse { - event.word_boundary_codepoints = &.{}; - press.word_boundary_codepoints = event.word_boundary_codepoints.?; - return .success; - }; - const copy = event.alloc.alloc(u21, v.len) catch return .out_of_memory; - errdefer event.alloc.free(copy); - for (copy, ptr[0..v.len]) |*dst, cp| { - dst.* = std.math.cast(u21, cp) orelse return .invalid_value; - } - event.word_boundary_codepoints = copy; - press.word_boundary_codepoints = copy; - }, + .word_boundary_codepoints => return trySetWordBoundaryCodepoints( + event, + &press.word_boundary_codepoints, + v, + ), .behaviors => { if (!validBehavior(v.single_click) or !validBehavior(v.double_click) or @@ -400,6 +452,9 @@ fn pressSetTyped( event.behaviors = .{ v.single_click, v.double_click, v.triple_click }; press.behaviors = &event.behaviors; }, + .rectangle, + .geometry, + => return .invalid_value, } return .success; @@ -425,18 +480,101 @@ fn releaseSetTyped( .repeat_interval_ns, .word_boundary_codepoints, .behaviors, + .rectangle, + .geometry, => return .invalid_value, } return .success; } -fn clearPressCodepoints(event: *EventWrapper, press: *SelectionGesture.Press) void { +fn dragSetTyped( + event: *EventWrapper, + drag: *SelectionGesture.Drag, + comptime option: EventOption, + value: ?*const option.Type(), +) Result { + const v = value orelse { + switch (option) { + .ref => event.drag_pin_set = false, + .position => { + drag.xpos = 0; + drag.ypos = 0; + }, + .word_boundary_codepoints => clearWordBoundaryCodepoints( + event, + &drag.word_boundary_codepoints, + ), + .rectangle => drag.rectangle = false, + .geometry => event.drag_geometry_set = false, + + .repeat_distance, + .time_ns, + .repeat_interval_ns, + .behaviors, + => return .invalid_value, + } + return .success; + }; + + switch (option) { + .ref => { + drag.pin = v.toPin() orelse return .invalid_value; + event.drag_pin_set = true; + }, + .position => { + drag.xpos = v.x; + drag.ypos = v.y; + }, + .word_boundary_codepoints => return trySetWordBoundaryCodepoints( + event, + &drag.word_boundary_codepoints, + v, + ), + .rectangle => drag.rectangle = v.*, + .geometry => { + drag.geometry = v.toZig() orelse return .invalid_value; + event.drag_geometry_set = true; + }, + + .repeat_distance, + .time_ns, + .repeat_interval_ns, + .behaviors, + => return .invalid_value, + } + + return .success; +} + +fn trySetWordBoundaryCodepoints( + event: *EventWrapper, + target: *[]const u21, + value: *const types.Codepoints, +) Result { + if (value.len > 0 and value.ptr == null) return .invalid_value; + clearWordBoundaryCodepoints(event, target); + const ptr = value.ptr orelse { + event.word_boundary_codepoints = &.{}; + target.* = event.word_boundary_codepoints.?; + return .success; + }; + const copy = event.alloc.alloc(u21, value.len) catch return .out_of_memory; + errdefer event.alloc.free(copy); + for (copy, ptr[0..value.len]) |*dst, cp| { + dst.* = std.math.cast(u21, cp) orelse return .invalid_value; + } + event.word_boundary_codepoints = copy; + target.* = copy; + return .success; +} + +fn clearWordBoundaryCodepoints(event: *EventWrapper, target: *[]const u21) void { if (event.word_boundary_codepoints) |cps| { if (cps.len > 0) event.alloc.free(cps); } event.word_boundary_codepoints = null; - press.word_boundary_codepoints = &selection_codepoints.default_word_boundaries; + target.* = &selection_codepoints.default_word_boundaries; } fn instantFromNs(ns: u64) std.time.Instant { @@ -853,6 +991,122 @@ test "selection gesture release without ref marks dragged" { try testing.expect(dragged); } +test "selection gesture event applies drag" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "abcde", 5); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &press_event, + .press, + )); + defer event_free(press_event); + + var drag_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &drag_event, + .drag, + )); + defer event_free(drag_event); + + var press_ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &press_ref)); + try testing.expectEqual(Result.success, event_set(press_event, .ref, &press_ref)); + + const press_pos: types.SurfacePosition = .{ .x = 10, .y = 10 }; + try testing.expectEqual(Result.success, event_set(press_event, .position, &press_pos)); + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null)); + + var drag_ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 3, .y = 0 } }, + }, &drag_ref)); + try testing.expectEqual(Result.success, event_set(drag_event, .ref, &drag_ref)); + + const drag_pos: types.SurfacePosition = .{ .x = 36, .y = 10 }; + try testing.expectEqual(Result.success, event_set(drag_event, .position, &drag_pos)); + const geometry: Geometry = .{ + .columns = 5, + .cell_width = 10, + .padding_left = 0, + .screen_height = 20, + }; + try testing.expectEqual(Result.success, event_set(drag_event, .geometry, &geometry)); + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, handle_event(gesture, terminal, drag_event, &sel)); + try testing.expectEqual(@as(u16, 1), sel.start.toPin().?.x); + try testing.expectEqual(@as(u16, 3), sel.end.toPin().?.x); + + var dragged = false; + try testing.expectEqual(Result.success, get(gesture, terminal, .dragged, &dragged)); + try testing.expect(dragged); +} + +test "selection gesture drag requires ref and geometry" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var drag_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &drag_event, + .drag, + )); + defer event_free(drag_event); + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.invalid_value, handle_event(gesture, terminal, drag_event, &sel)); + + var ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &ref)); + try testing.expectEqual(Result.success, event_set(drag_event, .ref, &ref)); + try testing.expectEqual(Result.invalid_value, handle_event(gesture, terminal, drag_event, &sel)); + + const invalid_geometry: Geometry = .{ + .columns = 5, + .cell_width = 0, + .padding_left = 0, + .screen_height = 20, + }; + try testing.expectEqual(Result.invalid_value, event_set(drag_event, .geometry, &invalid_geometry)); +} + test "selection gesture free null" { free(null, null); } diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 9a4ef3d56..a44dd1ff5 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -56,6 +56,7 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: { .{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) }, .{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) }, .{ "GhosttySelectionGestureBehaviors", StructInfo.init(selection_gesture.Behaviors) }, + .{ "GhosttySelectionGestureGeometry", StructInfo.init(selection_gesture.Geometry) }, .{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) }, .{ "GhosttyString", StructInfo.init(lib.String) }, .{ "GhosttySurfacePosition", StructInfo.init(SurfacePosition) }, From 603684ba11092b9430c336b3378ba22ef9615cc0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 10:54:54 -0700 Subject: [PATCH 116/137] libghostty: selection gesture autotick --- include/ghostty/vt/selection.h | 27 +++- src/terminal/c/selection_gesture.zig | 233 ++++++++++++++++++++++++++- 2 files changed, 250 insertions(+), 10 deletions(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 1cb774071..bcba934b2 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -427,6 +427,9 @@ typedef enum GHOSTTY_ENUM_TYPED { /** Drag event for ghostty_selection_gesture_drag(). */ GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG = 2, + /** Autoscroll tick event for ghostty_selection_gesture_autoscroll_tick(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_AUTOSCROLL_TICK = 3, + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionGestureEventType; @@ -447,7 +450,11 @@ typedef enum GHOSTTY_ENUM_TYPED { */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF = 0, - /** Surface-space pointer position: GhosttySurfacePosition*. Valid for PRESS and DRAG. */ + /** + * Surface-space pointer position: GhosttySurfacePosition*. + * + * Valid for PRESS, DRAG, and AUTOSCROLL_TICK. + */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION = 1, /** Maximum repeat-click distance in pixels: double*. */ @@ -468,8 +475,9 @@ typedef enum GHOSTTY_ENUM_TYPED { * Word-boundary codepoints: GhosttyCodepoints*. * * The codepoints are copied into event-owned storage when set. If unset, - * operations that need word boundaries use Ghostty's defaults. Valid for - * PRESS and DRAG. + * operations that need word boundaries use Ghostty's defaults. + * + * Valid for PRESS, DRAG, and AUTOSCROLL_TICK. */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS = 5, @@ -480,12 +488,15 @@ typedef enum GHOSTTY_ENUM_TYPED { */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_BEHAVIORS = 6, - /** Whether a drag event should produce a rectangular selection: bool*. */ + /** Whether a drag or autoscroll tick should produce a rectangular selection: bool*. */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_RECTANGLE = 7, - /** Drag display geometry: GhosttySelectionGestureGeometry*. Required for DRAG. */ + /** Drag display geometry: GhosttySelectionGestureGeometry*. Required for DRAG and AUTOSCROLL_TICK. */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY = 8, + /** Viewport coordinate for an autoscroll tick: GhosttyPointCoordinate*. Required for AUTOSCROLL_TICK. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_VIEWPORT = 9, + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionGestureEventOption; @@ -557,6 +568,12 @@ GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_set( * rectangle, and word-boundary codepoints are optional and use initialized * defaults when unset or cleared. * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_AUTOSCROLL_TICK, + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_VIEWPORT and + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY are required. Position, + * rectangle, and word-boundary codepoints are optional and use initialized + * defaults when unset or cleared. + * * The returned selection is not installed as the terminal's current selection. * It is a snapshot with the same lifetime rules as GhosttySelection. * diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig index 580fbe723..5fd16f33f 100644 --- a/src/terminal/c/selection_gesture.zig +++ b/src/terminal/c/selection_gesture.zig @@ -6,6 +6,7 @@ const CAllocator = lib.alloc.Allocator; const SelectionGesture = @import("../SelectionGesture.zig"); const selection_codepoints = @import("../selection_codepoints.zig"); const grid_ref = @import("grid_ref.zig"); +const point = @import("../point.zig"); const selection_c = @import("selection.zig"); const terminal_c = @import("terminal.zig"); const types = @import("types.zig"); @@ -30,6 +31,7 @@ const EventWrapper = struct { press: SelectionGesture.Press, release: SelectionGesture.Release, drag: SelectionGesture.Drag, + autoscroll_tick: SelectionGesture.AutoscrollTick, }, // Press.pin has no safe sentinel value: PageList.Pin contains a non-null @@ -45,11 +47,18 @@ const EventWrapper = struct { drag_pin_set: bool = false, drag_geometry_set: bool = false, - // Backing storage for Press/Drag.word_boundary_codepoints. The C API - // receives codepoints as borrowed uint32_t values, but SelectionGesture - // stores a []const u21 slice. We copy/convert into event-owned storage so - // the real payload can safely point at it until the event is changed or - // freed. + // AutoscrollTick.viewport and AutoscrollTick.geometry are required by + // SelectionGesture.autoscrollTick but have no meaningful zero/sentinel + // value. Track whether the C caller set them so dispatch can reject + // incomplete tick events instead of using placeholder data. + autoscroll_tick_viewport_set: bool = false, + autoscroll_tick_geometry_set: bool = false, + + // Backing storage for Press/Drag/AutoscrollTick.word_boundary_codepoints. + // The C API receives codepoints as borrowed uint32_t values, but + // SelectionGesture stores a []const u21 slice. We copy/convert into + // event-owned storage so the real payload can safely point at it until the + // event is changed or freed. word_boundary_codepoints: ?[]u21 = null, // Backing storage for Press.behaviors. The C API sets behaviors as a value @@ -63,6 +72,7 @@ const EventWrapper = struct { .press => .{ .press = self.defaultPress() }, .release => .{ .release = self.defaultRelease() }, .drag => .{ .drag = self.defaultDrag() }, + .autoscroll_tick => .{ .autoscroll_tick = self.defaultAutoscrollTick() }, }; } @@ -96,6 +106,18 @@ const EventWrapper = struct { }; } + fn defaultAutoscrollTick(self: *EventWrapper) SelectionGesture.AutoscrollTick { + _ = self; + return .{ + .viewport = undefined, + .xpos = 0, + .ypos = 0, + .rectangle = false, + .word_boundary_codepoints = &selection_codepoints.default_word_boundaries, + .geometry = undefined, + }; + } + fn deinit(self: *EventWrapper) void { if (self.word_boundary_codepoints) |cps| { if (cps.len > 0) self.alloc.free(cps); @@ -140,6 +162,7 @@ pub const EventType = enum(c_int) { press = 0, release = 1, drag = 2, + autoscroll_tick = 3, }; /// C: GhosttySelectionGestureEventOption @@ -153,6 +176,7 @@ pub const EventOption = enum(c_int) { behaviors = 6, rectangle = 7, geometry = 8, + viewport = 9, pub fn Type(comptime self: EventOption) type { return switch (self) { @@ -165,6 +189,7 @@ pub const EventOption = enum(c_int) { .behaviors => Behaviors, .rectangle => bool, .geometry => Geometry, + .viewport => point.Coordinate, }; } }; @@ -290,6 +315,15 @@ pub fn handle_event( } else if (sel == null) return .no_value; return .success; }, + .autoscroll_tick => |tick| { + if (!event_wrapper.autoscroll_tick_viewport_set) return .invalid_value; + if (!event_wrapper.autoscroll_tick_geometry_set) return .invalid_value; + const sel = wrapper.gesture.autoscrollTick(t, tick); + if (out_selection) |out| { + out.* = selection_c.CSelection.fromZig(sel orelse return .no_value); + } else if (sel == null) return .no_value; + return .success; + }, }; } @@ -394,6 +428,7 @@ fn eventSetTyped( .press => |*press| pressSetTyped(event, press, option, value), .release => |*release| releaseSetTyped(release, option, value), .drag => |*drag| dragSetTyped(event, drag, option, value), + .autoscroll_tick => |*tick| autoscrollTickSetTyped(event, tick, option, value), }; } @@ -423,6 +458,7 @@ fn pressSetTyped( }, .rectangle, .geometry, + .viewport, => return .invalid_value, } return .success; @@ -454,6 +490,7 @@ fn pressSetTyped( }, .rectangle, .geometry, + .viewport, => return .invalid_value, } @@ -482,6 +519,7 @@ fn releaseSetTyped( .behaviors, .rectangle, .geometry, + .viewport, => return .invalid_value, } @@ -507,6 +545,7 @@ fn dragSetTyped( ), .rectangle => drag.rectangle = false, .geometry => event.drag_geometry_set = false, + .viewport => return .invalid_value, .repeat_distance, .time_ns, @@ -536,6 +575,7 @@ fn dragSetTyped( drag.geometry = v.toZig() orelse return .invalid_value; event.drag_geometry_set = true; }, + .viewport => return .invalid_value, .repeat_distance, .time_ns, @@ -547,6 +587,67 @@ fn dragSetTyped( return .success; } +fn autoscrollTickSetTyped( + event: *EventWrapper, + tick: *SelectionGesture.AutoscrollTick, + comptime option: EventOption, + value: ?*const option.Type(), +) Result { + const v = value orelse { + switch (option) { + .viewport => event.autoscroll_tick_viewport_set = false, + .position => { + tick.xpos = 0; + tick.ypos = 0; + }, + .word_boundary_codepoints => clearWordBoundaryCodepoints( + event, + &tick.word_boundary_codepoints, + ), + .rectangle => tick.rectangle = false, + .geometry => event.autoscroll_tick_geometry_set = false, + + .ref, + .repeat_distance, + .time_ns, + .repeat_interval_ns, + .behaviors, + => return .invalid_value, + } + return .success; + }; + + switch (option) { + .viewport => { + tick.viewport = v.*; + event.autoscroll_tick_viewport_set = true; + }, + .position => { + tick.xpos = v.x; + tick.ypos = v.y; + }, + .word_boundary_codepoints => return trySetWordBoundaryCodepoints( + event, + &tick.word_boundary_codepoints, + v, + ), + .rectangle => tick.rectangle = v.*, + .geometry => { + tick.geometry = v.toZig() orelse return .invalid_value; + event.autoscroll_tick_geometry_set = true; + }, + + .ref, + .repeat_distance, + .time_ns, + .repeat_interval_ns, + .behaviors, + => return .invalid_value, + } + + return .success; +} + fn trySetWordBoundaryCodepoints( event: *EventWrapper, target: *[]const u21, @@ -1107,6 +1208,128 @@ test "selection gesture drag requires ref and geometry" { try testing.expectEqual(Result.invalid_value, event_set(drag_event, .geometry, &invalid_geometry)); } +test "selection gesture event applies autoscroll tick" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "abcde\r\nfghij", 12); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &press_event, + .press, + )); + defer event_free(press_event); + + var drag_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &drag_event, + .drag, + )); + defer event_free(drag_event); + + var tick_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &tick_event, + .autoscroll_tick, + )); + defer event_free(tick_event); + + const geometry: Geometry = .{ + .columns = 5, + .cell_width = 10, + .padding_left = 0, + .screen_height = 20, + }; + + var press_ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &press_ref)); + try testing.expectEqual(Result.success, event_set(press_event, .ref, &press_ref)); + const press_pos: types.SurfacePosition = .{ .x = 10, .y = 10 }; + try testing.expectEqual(Result.success, event_set(press_event, .position, &press_pos)); + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null)); + + var drag_ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 3, .y = 1 } }, + }, &drag_ref)); + try testing.expectEqual(Result.success, event_set(drag_event, .ref, &drag_ref)); + const drag_pos: types.SurfacePosition = .{ .x = 36, .y = 20 }; + try testing.expectEqual(Result.success, event_set(drag_event, .position, &drag_pos)); + try testing.expectEqual(Result.success, event_set(drag_event, .geometry, &geometry)); + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, handle_event(gesture, terminal, drag_event, &sel)); + + var autoscroll: Autoscroll = .none; + try testing.expectEqual(Result.success, get(gesture, terminal, .autoscroll, &autoscroll)); + try testing.expectEqual(Autoscroll.down, autoscroll); + + const viewport: point.Coordinate = .{ .x = 3, .y = 1 }; + try testing.expectEqual(Result.success, event_set(tick_event, .viewport, &viewport)); + try testing.expectEqual(Result.success, event_set(tick_event, .position, &drag_pos)); + try testing.expectEqual(Result.success, event_set(tick_event, .geometry, &geometry)); + + try testing.expectEqual(Result.success, handle_event(gesture, terminal, tick_event, &sel)); +} + +test "selection gesture autoscroll tick requires viewport and geometry" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var tick_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &tick_event, + .autoscroll_tick, + )); + defer event_free(tick_event); + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.invalid_value, handle_event(gesture, terminal, tick_event, &sel)); + + const viewport: point.Coordinate = .{ .x = 1, .y = 0 }; + try testing.expectEqual(Result.success, event_set(tick_event, .viewport, &viewport)); + try testing.expectEqual(Result.invalid_value, handle_event(gesture, terminal, tick_event, &sel)); + + var ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 0 } }, + }, &ref)); + try testing.expectEqual(Result.invalid_value, event_set(tick_event, .ref, &ref)); +} + test "selection gesture free null" { free(null, null); } From f0fcb104069647051b2e612c23c90e2414a0db58 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 10:56:55 -0700 Subject: [PATCH 117/137] libghostty: selection gesture deep press --- include/ghostty/vt/selection.h | 17 ++- src/terminal/c/selection_gesture.zig | 150 +++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 5 deletions(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index bcba934b2..d42fa3c0e 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -418,18 +418,21 @@ typedef enum GHOSTTY_ENUM_TYPED { * @ingroup selection */ typedef enum GHOSTTY_ENUM_TYPED { - /** Press event for ghostty_selection_gesture_press(). */ + /** Press event for ghostty_selection_gesture_event(). */ GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS = 0, - /** Release event for ghostty_selection_gesture_release(). */ + /** Release event for ghostty_selection_gesture_event(). */ GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE = 1, - /** Drag event for ghostty_selection_gesture_drag(). */ + /** Drag event for ghostty_selection_gesture_event(). */ GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG = 2, - /** Autoscroll tick event for ghostty_selection_gesture_autoscroll_tick(). */ + /** Autoscroll tick event for ghostty_selection_gesture_event(). */ GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_AUTOSCROLL_TICK = 3, + /** Deep press event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS = 4, + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionGestureEventType; @@ -477,7 +480,7 @@ typedef enum GHOSTTY_ENUM_TYPED { * The codepoints are copied into event-owned storage when set. If unset, * operations that need word boundaries use Ghostty's defaults. * - * Valid for PRESS, DRAG, and AUTOSCROLL_TICK. + * Valid for PRESS, DRAG, AUTOSCROLL_TICK, and DEEP_PRESS. */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS = 5, @@ -574,6 +577,10 @@ GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_set( * rectangle, and word-boundary codepoints are optional and use initialized * defaults when unset or cleared. * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS, only + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS is valid. It is + * optional and uses initialized defaults when unset or cleared. + * * The returned selection is not installed as the terminal's current selection. * It is a snapshot with the same lifetime rules as GhosttySelection. * diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig index 5fd16f33f..981bb0022 100644 --- a/src/terminal/c/selection_gesture.zig +++ b/src/terminal/c/selection_gesture.zig @@ -32,6 +32,7 @@ const EventWrapper = struct { release: SelectionGesture.Release, drag: SelectionGesture.Drag, autoscroll_tick: SelectionGesture.AutoscrollTick, + deep_press: SelectionGesture.DeepPress, }, // Press.pin has no safe sentinel value: PageList.Pin contains a non-null @@ -73,6 +74,7 @@ const EventWrapper = struct { .release => .{ .release = self.defaultRelease() }, .drag => .{ .drag = self.defaultDrag() }, .autoscroll_tick => .{ .autoscroll_tick = self.defaultAutoscrollTick() }, + .deep_press => .{ .deep_press = self.defaultDeepPress() }, }; } @@ -118,6 +120,13 @@ const EventWrapper = struct { }; } + fn defaultDeepPress(self: *EventWrapper) SelectionGesture.DeepPress { + _ = self; + return .{ + .word_boundary_codepoints = &selection_codepoints.default_word_boundaries, + }; + } + fn deinit(self: *EventWrapper) void { if (self.word_boundary_codepoints) |cps| { if (cps.len > 0) self.alloc.free(cps); @@ -163,6 +172,7 @@ pub const EventType = enum(c_int) { release = 1, drag = 2, autoscroll_tick = 3, + deep_press = 4, }; /// C: GhosttySelectionGestureEventOption @@ -324,6 +334,13 @@ pub fn handle_event( } else if (sel == null) return .no_value; return .success; }, + .deep_press => |deep_press| { + const sel = wrapper.gesture.deepPress(t, deep_press); + if (out_selection) |out| { + out.* = selection_c.CSelection.fromZig(sel orelse return .no_value); + } else if (sel == null) return .no_value; + return .success; + }, }; } @@ -429,6 +446,7 @@ fn eventSetTyped( .release => |*release| releaseSetTyped(release, option, value), .drag => |*drag| dragSetTyped(event, drag, option, value), .autoscroll_tick => |*tick| autoscrollTickSetTyped(event, tick, option, value), + .deep_press => |*deep_press| deepPressSetTyped(event, deep_press, option, value), }; } @@ -648,6 +666,55 @@ fn autoscrollTickSetTyped( return .success; } +fn deepPressSetTyped( + event: *EventWrapper, + deep_press: *SelectionGesture.DeepPress, + comptime option: EventOption, + value: ?*const option.Type(), +) Result { + const v = value orelse { + switch (option) { + .word_boundary_codepoints => clearWordBoundaryCodepoints( + event, + &deep_press.word_boundary_codepoints, + ), + + .ref, + .position, + .repeat_distance, + .time_ns, + .repeat_interval_ns, + .behaviors, + .rectangle, + .geometry, + .viewport, + => return .invalid_value, + } + return .success; + }; + + switch (option) { + .word_boundary_codepoints => return trySetWordBoundaryCodepoints( + event, + &deep_press.word_boundary_codepoints, + v, + ), + + .ref, + .position, + .repeat_distance, + .time_ns, + .repeat_interval_ns, + .behaviors, + .rectangle, + .geometry, + .viewport, + => return .invalid_value, + } + + return .success; +} + fn trySetWordBoundaryCodepoints( event: *EventWrapper, target: *[]const u21, @@ -1330,6 +1397,89 @@ test "selection gesture autoscroll tick requires viewport and geometry" { try testing.expectEqual(Result.invalid_value, event_set(tick_event, .ref, &ref)); } +test "selection gesture event applies deep press" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "abcde", 5); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &press_event, + .press, + )); + defer event_free(press_event); + + var deep_press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &deep_press_event, + .deep_press, + )); + defer event_free(deep_press_event); + + var ref: grid_ref.CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{ + .tag = .active, + .value = .{ .active = .{ .x = 2, .y = 0 } }, + }, &ref)); + try testing.expectEqual(Result.success, event_set(press_event, .ref, &ref)); + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null)); + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, handle_event(gesture, terminal, deep_press_event, &sel)); + try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x); + try testing.expectEqual(@as(u16, 4), sel.end.toPin().?.x); + + var dragged = false; + try testing.expectEqual(Result.success, get(gesture, terminal, .dragged, &dragged)); + try testing.expect(dragged); + + const pos: types.SurfacePosition = .{ .x = 0, .y = 0 }; + try testing.expectEqual(Result.invalid_value, event_set(deep_press_event, .position, &pos)); +} + +test "selection gesture deep press without active anchor returns no value" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var gesture: Gesture = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &gesture, + )); + defer free(gesture, terminal); + + var deep_press_event: Event = null; + try testing.expectEqual(Result.success, event_new( + &lib.alloc.test_allocator, + &deep_press_event, + .deep_press, + )); + defer event_free(deep_press_event); + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, deep_press_event, &sel)); +} + test "selection gesture free null" { free(null, null); } From 3e0477a14a1a6a0a8f4a5256b95528aa8145351a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 11:00:51 -0700 Subject: [PATCH 118/137] example/c-vt-selection-gesture --- example/c-vt-selection-gesture/README.md | 18 +++ example/c-vt-selection-gesture/build.zig | 42 +++++ example/c-vt-selection-gesture/build.zig.zon | 24 +++ example/c-vt-selection-gesture/src/main.c | 162 +++++++++++++++++++ include/ghostty/vt.h | 5 + include/ghostty/vt/selection.h | 10 ++ 6 files changed, 261 insertions(+) create mode 100644 example/c-vt-selection-gesture/README.md create mode 100644 example/c-vt-selection-gesture/build.zig create mode 100644 example/c-vt-selection-gesture/build.zig.zon create mode 100644 example/c-vt-selection-gesture/src/main.c diff --git a/example/c-vt-selection-gesture/README.md b/example/c-vt-selection-gesture/README.md new file mode 100644 index 000000000..a64df0e53 --- /dev/null +++ b/example/c-vt-selection-gesture/README.md @@ -0,0 +1,18 @@ +# Example: `ghostty-vt` Selection Gestures + +This contains a simple example of how to use the `ghostty-vt` selection +gesture API from C. It creates synthetic press, drag, release, and deep-press +events and formats the resulting selection snapshots. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-selection-gesture/build.zig b/example/c-vt-selection-gesture/build.zig new file mode 100644 index 000000000..05f8d1bbc --- /dev/null +++ b/example/c-vt-selection-gesture/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_selection_gesture", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-selection-gesture/build.zig.zon b/example/c-vt-selection-gesture/build.zig.zon new file mode 100644 index 000000000..08db85223 --- /dev/null +++ b/example/c-vt-selection-gesture/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_selection_gesture, + .version = "0.0.0", + .fingerprint = 0x5a4e72d27b582404, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-selection-gesture/src/main.c b/example/c-vt-selection-gesture/src/main.c new file mode 100644 index 000000000..050e9a3b1 --- /dev/null +++ b/example/c-vt-selection-gesture/src/main.c @@ -0,0 +1,162 @@ +#include +#include +#include +#include +#include + +//! [selection-gesture-main] +static void vt_write(GhosttyTerminal terminal, const char *s) { + ghostty_terminal_vt_write(terminal, (const uint8_t *)s, strlen(s)); +} + +static GhosttyGridRef ref_at(GhosttyTerminal terminal, uint16_t x, uint16_t y) { + GhosttyGridRef ref = GHOSTTY_INIT_SIZED(GhosttyGridRef); + GhosttyPoint point = { + .tag = GHOSTTY_POINT_TAG_ACTIVE, + .value = { .coordinate = { .x = x, .y = y } }, + }; + + GhosttyResult result = ghostty_terminal_grid_ref(terminal, point, &ref); + assert(result == GHOSTTY_SUCCESS); + return ref; +} + +static void print_selection( + GhosttyTerminal terminal, + const char *label, + const GhosttySelection *selection) { + GhosttyTerminalSelectionFormatOptions opts = + GHOSTTY_INIT_SIZED(GhosttyTerminalSelectionFormatOptions); + opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN; + opts.trim = true; + opts.selection = selection; + + uint8_t *buf = NULL; + size_t len = 0; + GhosttyResult result = ghostty_terminal_selection_format_alloc( + terminal, NULL, opts, &buf, &len); + assert(result == GHOSTTY_SUCCESS); + + printf("%s: ", label); + fwrite(buf, 1, len, stdout); + printf("\n"); + + ghostty_free(NULL, buf, len); +} + +static GhosttySelectionGestureEvent new_event( + GhosttySelectionGestureEventType type) { + GhosttySelectionGestureEvent event = NULL; + GhosttyResult result = ghostty_selection_gesture_event_new(NULL, &event, type); + assert(result == GHOSTTY_SUCCESS); + return event; +} + +int main() { + GhosttyTerminal terminal; + GhosttyTerminalOptions opts = { + .cols = 20, + .rows = 4, + .max_scrollback = 100, + }; + GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + vt_write(terminal, "hello world\r\nsecond line"); + + GhosttySelectionGesture gesture = NULL; + result = ghostty_selection_gesture_new(NULL, &gesture); + assert(result == GHOSTTY_SUCCESS); + + GhosttySelectionGestureEvent press = + new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS); + GhosttySelectionGestureEvent drag = + new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG); + GhosttySelectionGestureEvent release = + new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE); + GhosttySelectionGestureEvent deep_press = + new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS); + + GhosttySelectionGestureGeometry geometry = { + .columns = 20, + .cell_width = 10, + .padding_left = 0, + .screen_height = 40, + }; + + // Press in the first cell. A normal single press records the click anchor but + // doesn't produce a selection yet, so we discard the optional output. + GhosttyGridRef press_ref = ref_at(terminal, 0, 0); + result = ghostty_selection_gesture_event_set( + press, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &press_ref); + assert(result == GHOSTTY_SUCCESS); + + GhosttySurfacePosition press_pos = { .x = 2, .y = 8 }; + result = ghostty_selection_gesture_event_set( + press, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION, &press_pos); + assert(result == GHOSTTY_SUCCESS); + + result = ghostty_selection_gesture_event( + gesture, terminal, press, NULL); + assert(result == GHOSTTY_NO_VALUE); + + // Drag across "hello". The drag event returns a selection snapshot that the + // embedder can apply to its UI, copy, or format immediately. + GhosttyGridRef drag_ref = ref_at(terminal, 4, 0); + result = ghostty_selection_gesture_event_set( + drag, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &drag_ref); + assert(result == GHOSTTY_SUCCESS); + + GhosttySurfacePosition drag_pos = { .x = 46, .y = 8 }; + result = ghostty_selection_gesture_event_set( + drag, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION, &drag_pos); + assert(result == GHOSTTY_SUCCESS); + + result = ghostty_selection_gesture_event_set( + drag, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY, &geometry); + assert(result == GHOSTTY_SUCCESS); + + GhosttySelection selection = GHOSTTY_INIT_SIZED(GhosttySelection); + result = ghostty_selection_gesture_event( + gesture, terminal, drag, &selection); + assert(result == GHOSTTY_SUCCESS); + print_selection(terminal, "drag", &selection); + + // Release updates gesture state but never produces a selection. + result = ghostty_selection_gesture_event_set( + release, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &drag_ref); + assert(result == GHOSTTY_SUCCESS); + result = ghostty_selection_gesture_event( + gesture, terminal, release, NULL); + assert(result == GHOSTTY_NO_VALUE); + + bool dragged = false; + result = ghostty_selection_gesture_get( + gesture, terminal, GHOSTTY_SELECTION_GESTURE_DATA_DRAGGED, &dragged); + assert(result == GHOSTTY_SUCCESS); + printf("dragged: %s\n", dragged ? "true" : "false"); + + // Deep press uses the active click anchor to select the surrounding word. + ghostty_selection_gesture_reset(gesture, terminal); + GhosttyGridRef world_ref = ref_at(terminal, 6, 0); + result = ghostty_selection_gesture_event_set( + press, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &world_ref); + assert(result == GHOSTTY_SUCCESS); + result = ghostty_selection_gesture_event( + gesture, terminal, press, NULL); + assert(result == GHOSTTY_NO_VALUE); + + result = ghostty_selection_gesture_event( + gesture, terminal, deep_press, &selection); + assert(result == GHOSTTY_SUCCESS); + print_selection(terminal, "deep press", &selection); + + ghostty_selection_gesture_event_free(deep_press); + ghostty_selection_gesture_event_free(release); + ghostty_selection_gesture_event_free(drag); + ghostty_selection_gesture_event_free(press); + ghostty_selection_gesture_free(gesture, terminal); + ghostty_terminal_free(terminal); + return 0; +} +//! [selection-gesture-main] diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 7a6a9758a..94a850334 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -104,6 +104,11 @@ * detect when it loses its value, and move it to a new point. */ +/** @example c-vt-selection-gesture/src/main.c + * This example demonstrates how to use synthetic selection gesture events to + * derive drag and deep-press selection snapshots. + */ + /** @example c-vt-kitty-graphics/src/main.c * This example demonstrates how to use the system interface to install a * PNG decoder callback and send a Kitty Graphics Protocol image. diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index d42fa3c0e..3b926aab6 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -32,9 +32,19 @@ extern "C" { * for the endpoints and reconstruct a GhosttySelection from fresh snapshots * when needed. * + * Selection gestures provide a reusable state machine for turning UI pointer + * interactions into selection snapshots. A caller creates one + * GhosttySelectionGesture per active gesture stream, reuses typed + * GhosttySelectionGestureEvent objects for synthetic press, drag, release, + * autoscroll tick, and deep-press events, and applies each event with + * ghostty_selection_gesture_event(). The returned GhosttySelection is a + * snapshot; the embedder decides whether to render it, format/copy it, or + * install it as the terminal's active selection. + * * ## Examples * * @snippet c-vt-selection/src/main.c selection-main + * @snippet c-vt-selection-gesture/src/main.c selection-gesture-main * * @{ */ From 4e2d7c314b2e3645924c50eba309a431d28c4bb2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 11:05:20 -0700 Subject: [PATCH 119/137] libghostty: optimize bits for selection gesture validation fields --- src/terminal/c/selection_gesture.zig | 61 ++++++++++++---------------- 1 file changed, 27 insertions(+), 34 deletions(-) diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig index 981bb0022..3562447d9 100644 --- a/src/terminal/c/selection_gesture.zig +++ b/src/terminal/c/selection_gesture.zig @@ -35,25 +35,18 @@ const EventWrapper = struct { deep_press: SelectionGesture.DeepPress, }, - // Press.pin has no safe sentinel value: PageList.Pin contains a non-null - // node pointer and is undefined until the C caller provides a GhosttyGridRef. - // Track that separately so event execution can reject a press whose required - // ref option was never set, or was later cleared. - press_pin_set: bool = false, - - // Drag.pin and Drag.geometry are required by SelectionGesture.drag but have - // no meaningful zero/sentinel value. Track whether the C caller set them so - // dispatch can reject incomplete drag events instead of using placeholder - // data. - drag_pin_set: bool = false, - drag_geometry_set: bool = false, - - // AutoscrollTick.viewport and AutoscrollTick.geometry are required by - // SelectionGesture.autoscrollTick but have no meaningful zero/sentinel - // value. Track whether the C caller set them so dispatch can reject - // incomplete tick events instead of using placeholder data. - autoscroll_tick_viewport_set: bool = false, - autoscroll_tick_geometry_set: bool = false, + // Validation sidecar for required event fields that don't have safe + // sentinels in the real SelectionGesture payloads. For example, PageList.Pin + // contains a non-null node pointer and Geometry has no meaningful zero + // value. Keep these as one-bit flags so dispatch can reject incomplete C + // events instead of using undefined placeholder data. + event_validation: packed struct { + press_pin_set: bool = false, + drag_pin_set: bool = false, + drag_geometry_set: bool = false, + autoscroll_tick_viewport_set: bool = false, + autoscroll_tick_geometry_set: bool = false, + } = .{}, // Backing storage for Press/Drag/AutoscrollTick.word_boundary_codepoints. // The C API receives codepoints as borrowed uint32_t values, but @@ -305,7 +298,7 @@ pub fn handle_event( return switch (event_wrapper.event) { .press => |press| { - if (!event_wrapper.press_pin_set) return .invalid_value; + if (!event_wrapper.event_validation.press_pin_set) return .invalid_value; const sel = wrapper.gesture.press(t, press) catch return .out_of_memory; if (out_selection) |out| { out.* = selection_c.CSelection.fromZig(sel orelse return .no_value); @@ -317,8 +310,8 @@ pub fn handle_event( return .no_value; }, .drag => |drag| { - if (!event_wrapper.drag_pin_set) return .invalid_value; - if (!event_wrapper.drag_geometry_set) return .invalid_value; + if (!event_wrapper.event_validation.drag_pin_set) return .invalid_value; + if (!event_wrapper.event_validation.drag_geometry_set) return .invalid_value; const sel = wrapper.gesture.drag(t, drag); if (out_selection) |out| { out.* = selection_c.CSelection.fromZig(sel orelse return .no_value); @@ -326,8 +319,8 @@ pub fn handle_event( return .success; }, .autoscroll_tick => |tick| { - if (!event_wrapper.autoscroll_tick_viewport_set) return .invalid_value; - if (!event_wrapper.autoscroll_tick_geometry_set) return .invalid_value; + if (!event_wrapper.event_validation.autoscroll_tick_viewport_set) return .invalid_value; + if (!event_wrapper.event_validation.autoscroll_tick_geometry_set) return .invalid_value; const sel = wrapper.gesture.autoscrollTick(t, tick); if (out_selection) |out| { out.* = selection_c.CSelection.fromZig(sel orelse return .no_value); @@ -458,7 +451,7 @@ fn pressSetTyped( ) Result { const v = value orelse { switch (option) { - .ref => event.press_pin_set = false, + .ref => event.event_validation.press_pin_set = false, .position => { press.xpos = 0; press.ypos = 0; @@ -485,7 +478,7 @@ fn pressSetTyped( switch (option) { .ref => { press.pin = v.toPin() orelse return .invalid_value; - event.press_pin_set = true; + event.event_validation.press_pin_set = true; }, .position => { press.xpos = v.x; @@ -552,7 +545,7 @@ fn dragSetTyped( ) Result { const v = value orelse { switch (option) { - .ref => event.drag_pin_set = false, + .ref => event.event_validation.drag_pin_set = false, .position => { drag.xpos = 0; drag.ypos = 0; @@ -562,7 +555,7 @@ fn dragSetTyped( &drag.word_boundary_codepoints, ), .rectangle => drag.rectangle = false, - .geometry => event.drag_geometry_set = false, + .geometry => event.event_validation.drag_geometry_set = false, .viewport => return .invalid_value, .repeat_distance, @@ -577,7 +570,7 @@ fn dragSetTyped( switch (option) { .ref => { drag.pin = v.toPin() orelse return .invalid_value; - event.drag_pin_set = true; + event.event_validation.drag_pin_set = true; }, .position => { drag.xpos = v.x; @@ -591,7 +584,7 @@ fn dragSetTyped( .rectangle => drag.rectangle = v.*, .geometry => { drag.geometry = v.toZig() orelse return .invalid_value; - event.drag_geometry_set = true; + event.event_validation.drag_geometry_set = true; }, .viewport => return .invalid_value, @@ -613,7 +606,7 @@ fn autoscrollTickSetTyped( ) Result { const v = value orelse { switch (option) { - .viewport => event.autoscroll_tick_viewport_set = false, + .viewport => event.event_validation.autoscroll_tick_viewport_set = false, .position => { tick.xpos = 0; tick.ypos = 0; @@ -623,7 +616,7 @@ fn autoscrollTickSetTyped( &tick.word_boundary_codepoints, ), .rectangle => tick.rectangle = false, - .geometry => event.autoscroll_tick_geometry_set = false, + .geometry => event.event_validation.autoscroll_tick_geometry_set = false, .ref, .repeat_distance, @@ -638,7 +631,7 @@ fn autoscrollTickSetTyped( switch (option) { .viewport => { tick.viewport = v.*; - event.autoscroll_tick_viewport_set = true; + event.event_validation.autoscroll_tick_viewport_set = true; }, .position => { tick.xpos = v.x; @@ -652,7 +645,7 @@ fn autoscrollTickSetTyped( .rectangle => tick.rectangle = v.*, .geometry => { tick.geometry = v.toZig() orelse return .invalid_value; - event.autoscroll_tick_geometry_set = true; + event.event_validation.autoscroll_tick_geometry_set = true; }, .ref, From f730ee0557917258024e18a45489918de2ce9fa7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 15:23:48 -0700 Subject: [PATCH 120/137] libghostty: expose viewport active state Expose whether the terminal viewport is currently pinned to the active area through the libghostty-vt terminal data API. Previously embedders could only infer this from scrollbar geometry, which was indirect and could require the more expensive scrollbar calculation. The new GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE value returns the exact PageList viewport state as a bool. The scroll viewport test now verifies the value while moving between the active area and scrollback. --- include/ghostty/vt/terminal.h | 10 ++++++++++ src/terminal/c/terminal.zig | 14 +++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 19b27b7a6..ddfcb9c0d 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -901,6 +901,16 @@ typedef enum GHOSTTY_ENUM_TYPED { * Output type: GhosttySelection * */ GHOSTTY_TERMINAL_DATA_SELECTION = 31, + + /** + * Whether the viewport is currently pinned to the active area. + * + * This is true when the viewport is following the active terminal area, + * and false when the user has scrolled into history. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE = 32, GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalData; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 8deb4c95c..302fb77a6 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -593,13 +593,14 @@ pub const TerminalData = enum(c_int) { kitty_image_medium_shared_mem = 29, kitty_graphics = 30, selection = 31, + viewport_active = 32, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: TerminalData) type { return switch (self) { .invalid => void, .cols, .rows, .cursor_x, .cursor_y => size.CellCountInt, - .cursor_pending_wrap, .cursor_visible, .mouse_tracking => bool, + .cursor_pending_wrap, .cursor_visible, .mouse_tracking, .viewport_active => bool, .active_screen => TerminalScreen, .kitty_keyboard_flags => u8, .scrollbar => TerminalScrollbar, @@ -734,6 +735,7 @@ fn getTyped( .selection => out.* = selection_c.CSelection.fromZig( t.screens.active.selection orelse return .no_value, ), + .viewport_active => out.* = t.screens.active.pages.viewport == .active, } return .success; @@ -883,6 +885,10 @@ test "scroll_viewport" { const zt = t.?.terminal; + var viewport_active: bool = false; + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(viewport_active); + // Write "hello" on the first line vt_write(t, "hello", 5); @@ -897,6 +903,8 @@ test "scroll_viewport" { // Scroll to top: "hello" should be visible again scroll_viewport(t, .{ .tag = .top, .value = undefined }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(!viewport_active); { const str = try zt.plainString(testing.allocator); defer testing.allocator.free(str); @@ -905,6 +913,8 @@ test "scroll_viewport" { // Scroll to bottom: viewport should be empty again scroll_viewport(t, .{ .tag = .bottom, .value = undefined }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(viewport_active); { const str = try zt.plainString(testing.allocator); defer testing.allocator.free(str); @@ -913,6 +923,8 @@ test "scroll_viewport" { // Scroll up by delta to bring "hello" back into view scroll_viewport(t, .{ .tag = .delta, .value = .{ .delta = -3 } }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(!viewport_active); { const str = try zt.plainString(testing.allocator); defer testing.allocator.free(str); From 8beea5f92dcfb229b9434eed6ea5548e32ed5df8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 21:09:00 -0700 Subject: [PATCH 121/137] libghostty: expose row cell styling bit Add a render row-cells data key for querying whether the current cell has explicit styling. This lets consumers avoid fetching a raw cell or full style snapshot when all they need is the cell's HasStyling bit. The new key is appended to the existing enum for ABI safety and is served by the existing row-cells getter path. Existing data keys and function exports are unchanged. --- include/ghostty/vt/render.h | 7 ++++ src/terminal/c/render.zig | 65 ++++++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index dff101dcc..acf44076a 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -607,6 +607,13 @@ typedef enum GHOSTTY_ENUM_TYPED { * GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION once per row and applying that * range directly, avoiding one C API call per cell for selection state. */ GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_SELECTED = 7, + + /** Whether the cell has any explicit styling (bool). + * This is equivalent to querying the raw cell's + * GHOSTTY_CELL_DATA_HAS_STYLING value, but avoids materializing the raw + * GhosttyCell for renderers that only need to know whether fetching the + * full style is necessary. */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_HAS_STYLING = 8, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateRowCellsData; diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index a33307151..bf74bd545 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -466,6 +466,7 @@ pub const RowCellsData = enum(c_int) { bg_color = 5, fg_color = 6, selected = 7, + has_styling = 8, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: RowCellsData) type { @@ -476,7 +477,7 @@ pub const RowCellsData = enum(c_int) { .graphemes_len => u32, .graphemes_buf => u32, .bg_color, .fg_color => colorpkg.RGB.C, - .selected => bool, + .selected, .has_styling => bool, }; } }; @@ -571,6 +572,7 @@ fn rowCellsGetTyped( x >= sel[0] and x <= sel[1] else false, + .has_styling => out.* = cell.hasStyling(), } return .success; @@ -1193,6 +1195,67 @@ test "render: row cells get selected" { try testing.expect(selected); } +test "render: row cells get has_styling" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 10, + .rows = 3, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + const input = "A\x1b[31mB"; + terminal_c.vt_write(terminal, input, input.len); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + + var has_styling = true; + try testing.expectEqual(Result.success, row_cells_select(cells, 0)); + try testing.expectEqual(Result.success, row_cells_get(cells, .has_styling, @ptrCast(&has_styling))); + try testing.expect(!has_styling); + + try testing.expectEqual(Result.success, row_cells_select(cells, 1)); + try testing.expectEqual(Result.success, row_cells_get(cells, .has_styling, @ptrCast(&has_styling))); + try testing.expect(has_styling); + + has_styling = false; + var written: usize = 0; + const keys = [_]RowCellsData{.has_styling}; + var values = [_]?*anyopaque{@ptrCast(&has_styling)}; + try testing.expectEqual(Result.success, row_cells_get_multi(cells, keys.len, &keys, &values, &written)); + try testing.expectEqual(keys.len, written); + try testing.expect(has_styling); +} + test "render: row iterator next" { var terminal: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( From 1753d57bfdf0ac694ac624e7d63ec9fecd220bc6 Mon Sep 17 00:00:00 2001 From: Daniel Kinzler Date: Thu, 28 May 2026 15:08:12 +0200 Subject: [PATCH 122/137] remove timeout source when window is disposed --- src/apprt/gtk/class/window.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 7294f2aa3..eede346f3 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -1237,6 +1237,13 @@ pub const Window = extern struct { fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); + if (priv.timeout) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove timeout source", .{}); + } + priv.timeout = null; + } + priv.command_palette.set(null); if (priv.config) |v| { From 3cf01e84453c73e196cd3900d1b30757f4358e84 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 28 May 2026 09:29:57 -0700 Subject: [PATCH 123/137] libghostty: add utf-8 grapheme cell getter to C API Add a render-state row-cells getter that encodes the current cell's full grapheme cluster directly as UTF-8 into a caller-provided GhosttyBuffer. The getter writes the base codepoint first, followed by any extra grapheme codepoints, and follows the existing buffer-writer convention where len is bytes written on success or required capacity on GHOSTTY_OUT_OF_SPACE. Previously C consumers could query grapheme codepoints, but bindings that needed UTF-8 text had to reconstruct and encode the cluster themselves. That duplicated terminal internals in downstream bindings and made users pay for awkward cross-language struct handling. By owning the UTF-8/grapheme behavior in libghostty, bindings can use one stable C API and optionally wrap it with small binding-local helpers. --- include/ghostty/vt/render.h | 13 +++++ include/ghostty/vt/types.h | 17 +++++++ src/lib/main.zig | 1 + src/lib/types.zig | 6 +++ src/terminal/c/render.zig | 99 +++++++++++++++++++++++++++++++++++++ src/terminal/c/types.zig | 1 + src/terminal/lib.zig | 1 + 7 files changed, 138 insertions(+) diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index acf44076a..c5b1d0d4f 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -614,6 +614,19 @@ typedef enum GHOSTTY_ENUM_TYPED { * GhosttyCell for renderers that only need to know whether fetching the * full style is necessary. */ GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_HAS_STYLING = 8, + + /** + * Encode the current cell's full grapheme cluster as UTF-8 into a + * caller-provided buffer (GhosttyBuffer). + * + * The base codepoint is encoded first, followed by any extra grapheme + * codepoints. Returns GHOSTTY_SUCCESS with len=0 when the cell has no text. + * + * If ptr is NULL or cap is too small for a non-empty cell, returns + * GHOSTTY_OUT_OF_SPACE without writing any bytes and sets len to the required + * buffer size in bytes. + */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8 = 9, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateRowCellsData; diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index 1bec223d6..214d28229 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -227,6 +227,23 @@ typedef struct { size_t len; } GhosttyString; +/** + * A caller-provided byte buffer. + * + * APIs that write to this type use `len` for the number of bytes written on + * GHOSTTY_SUCCESS and the required byte capacity on GHOSTTY_OUT_OF_SPACE. + */ +typedef struct { + /** Destination buffer for bytes. May be NULL when cap is 0 to query required size. */ + uint8_t* ptr; + + /** Capacity of ptr in bytes. */ + size_t cap; + + /** Bytes written on success, or required byte capacity on GHOSTTY_OUT_OF_SPACE. */ + size_t len; +} GhosttyBuffer; + /** * A surface-space position in pixels. * diff --git a/src/lib/main.zig b/src/lib/main.zig index 05ebe9bd7..b22b3d987 100644 --- a/src/lib/main.zig +++ b/src/lib/main.zig @@ -5,6 +5,7 @@ const types = @import("types.zig"); const unionpkg = @import("union.zig"); pub const allocator = @import("allocator.zig"); +pub const Buffer = types.Buffer; pub const Enum = enumpkg.Enum; pub const checkGhosttyHEnum = enumpkg.checkGhosttyHEnum; pub const String = types.String; diff --git a/src/lib/types.zig b/src/lib/types.zig index 758540d12..655fdc220 100644 --- a/src/lib/types.zig +++ b/src/lib/types.zig @@ -11,3 +11,9 @@ pub const String = extern struct { }; } }; + +pub const Buffer = extern struct { + ptr: ?[*]u8 = null, + cap: usize = 0, + len: usize = 0, +}; diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index bf74bd545..0f3b2e781 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -467,6 +467,7 @@ pub const RowCellsData = enum(c_int) { fg_color = 6, selected = 7, has_styling = 8, + graphemes_utf8 = 9, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: RowCellsData) type { @@ -478,6 +479,7 @@ pub const RowCellsData = enum(c_int) { .graphemes_buf => u32, .bg_color, .fg_color => colorpkg.RGB.C, .selected, .has_styling => bool, + .graphemes_utf8 => lib.Buffer, }; } }; @@ -493,6 +495,7 @@ pub fn row_cells_get( return .invalid_value; }; } + if (out == null) return .invalid_value; return switch (data) { .invalid => .invalid_value, @@ -573,11 +576,44 @@ fn rowCellsGetTyped( else false, .has_styling => out.* = cell.hasStyling(), + .graphemes_utf8 => return rowCellsGetGraphemesUtf8(cell, if (cell.hasGrapheme()) cells.graphemes[x] else &.{}, out), } return .success; } +fn rowCellsGetGraphemesUtf8( + cell: page.Cell, + extra: []const u21, + out: *lib.Buffer, +) Result { + out.len = 0; + + if (!cell.hasText()) return .success; + + var needed = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch + return .invalid_value; + for (extra) |cp| { + needed += std.unicode.utf8CodepointSequenceLength(cp) catch + return .invalid_value; + } + out.len = needed; + + if (out.ptr == null or out.cap < needed) return .out_of_space; + + const buf = out.ptr.?[0..out.cap]; + var i: usize = 0; + i += std.unicode.utf8Encode(cell.codepoint(), buf[i..]) catch + return .invalid_value; + for (extra) |cp| { + i += std.unicode.utf8Encode(cp, buf[i..]) catch + return .invalid_value; + } + + out.len = i; + return .success; +} + /// C: GhosttyRenderStateRowData pub const RowData = enum(c_int) { invalid = 0, @@ -1256,6 +1292,69 @@ test "render: row cells get has_styling" { try testing.expect(has_styling); } +test "render: row cells get graphemes utf8" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 10, .rows = 3, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + const input = "e\u{301}"; + terminal_c.vt_write(terminal, input, input.len); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + + try testing.expectEqual(Result.success, row_cells_select(cells, 0)); + + var text: lib.Buffer = .{}; + try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); + try testing.expectEqual(@as(usize, input.len), text.len); + + var small = [_]u8{ 'x', 'x' }; + text = .{ .ptr = &small, .cap = small.len }; + try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); + try testing.expectEqual(@as(usize, input.len), text.len); + try testing.expectEqualSlices(u8, &.{ 'x', 'x' }, &small); + + var buf: [8]u8 = undefined; + text = .{ .ptr = &buf, .cap = buf.len }; + try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); + try testing.expectEqual(input.len, text.len); + try testing.expectEqualStrings(input, buf[0..text.len]); + + try testing.expectEqual(Result.success, row_cells_select(cells, 1)); + text = .{ .ptr = &buf, .cap = buf.len }; + try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); + try testing.expectEqual(@as(usize, 0), text.len); +} + test "render: row iterator next" { var terminal: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index a44dd1ff5..de043a038 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -36,6 +36,7 @@ pub const Codepoints = extern struct { pub const structs: std.StaticStringMap(StructInfo) = structs: { @setEvalBranchQuota(10_000); break :structs .initComptime(.{ + .{ "GhosttyBuffer", StructInfo.init(lib.Buffer) }, .{ "GhosttyCodepoints", StructInfo.init(Codepoints) }, .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) }, .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) }, diff --git a/src/terminal/lib.zig b/src/terminal/lib.zig index 3cd657b4e..921d831b7 100644 --- a/src/terminal/lib.zig +++ b/src/terminal/lib.zig @@ -14,6 +14,7 @@ pub const calling_conv: std.builtin.CallingConvention = .c; /// Forwarded decls from lib that are used. pub const alloc = lib.allocator; +pub const Buffer = lib.Buffer; pub const Enum = lib.Enum; pub const TaggedUnion = lib.TaggedUnion; pub const Struct = lib.Struct; From 519a612bebf25887973bab4ae22bba85f48a5e6b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 28 May 2026 13:00:14 -0700 Subject: [PATCH 124/137] libghostty: fix wasm build for selection gesture --- src/terminal/SelectionGesture.zig | 22 +++++++++++++++++++--- src/terminal/c/selection_gesture.zig | 8 +++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index 22ba468b9..f0e11f63e 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -67,6 +67,7 @@ const SelectionGesture = @This(); const std = @import("std"); +const builtin = @import("builtin"); const assert = std.debug.assert; const testing = std.testing; const Allocator = std.mem.Allocator; @@ -79,6 +80,16 @@ const Selection = @import("Selection.zig"); const Terminal = @import("Terminal.zig"); const point = @import("point.zig"); +const freestanding_wasm = builtin.target.cpu.arch == .wasm32 and + builtin.target.os.tag == .freestanding; + +/// Monotonic timestamp type for click-repeat detection. +/// +/// Freestanding wasm cannot reference std.time.Instant because Zig's stdlib +/// Instant type depends on POSIX timespec for that target, so represent the C +/// API nanosecond timestamp directly as a u64 there. +pub const Time = if (freestanding_wasm) u64 else std.time.Instant; + /// The tracked pin of the initial left click along with the screen /// that the pin is part of. left_click_pin: ?*Pin, @@ -89,7 +100,7 @@ left_click_screen_generation: usize, /// The left click time was the last time the left click was done, if the /// caller could provide one. If this is null then we only support single clicks. left_click_count: u3, -left_click_time: ?std.time.Instant, +left_click_time: ?Time, /// The selection behavior chosen for the active left-click gesture. left_click_behavior: Behavior, @@ -220,7 +231,7 @@ pub const Press = struct { /// The time when the press event occurred. Use a monotonic timer. /// This can be null if you're on a system that doesn't support /// time for some reason. In that case, we only support single clicks. - time: ?std.time.Instant, + time: ?Time, /// The cell where the click was. /// @@ -619,7 +630,7 @@ fn pressRepeat( const time = p.time orelse return error.PressRequiresReset; { const prev_time = self.left_click_time orelse return error.PressRequiresReset; - const since = time.since(prev_time); + const since = timeSince(time, prev_time); if (since > p.repeat_interval) return error.PressRequiresReset; } @@ -653,6 +664,11 @@ fn pressRepeat( self.left_click_behavior = p.behaviors[self.left_click_count - 1]; } +fn timeSince(time: Time, prev_time: Time) u64 { + if (comptime freestanding_wasm) return time -| prev_time; + return time.since(prev_time); +} + fn pressSelection( self: *const SelectionGesture, screen: *Screen, diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig index 3562447d9..cc56da3c0 100644 --- a/src/terminal/c/selection_gesture.zig +++ b/src/terminal/c/selection_gesture.zig @@ -738,7 +738,13 @@ fn clearWordBoundaryCodepoints(event: *EventWrapper, target: *[]const u21) void target.* = &selection_codepoints.default_word_boundaries; } -fn instantFromNs(ns: u64) std.time.Instant { +fn instantFromNs(ns: u64) SelectionGesture.Time { + if (comptime builtin.target.cpu.arch == .wasm32 and + builtin.target.os.tag == .freestanding) + { + return ns; + } + return switch (builtin.os.tag) { .windows, .uefi, .wasi => .{ .timestamp = ns }, else => .{ .timestamp = .{ From ff963f3119bffbd9366a5b7d98fbcaba06fc9f05 Mon Sep 17 00:00:00 2001 From: Daniel Kinzler Date: Fri, 29 May 2026 17:40:25 +0200 Subject: [PATCH 125/137] Renamed timeout source and callback function. Added comment explaining timeout delay. --- src/apprt/gtk/class/window.zig | 44 ++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index eede346f3..2a1a5435a 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -221,7 +221,7 @@ pub const Window = extern struct { quick_terminal: bool = false, /// Timeout source to react to this window becoming (in)active. - timeout: ?c_uint = null, + handle_active_state_source: ?c_uint = null, /// The window decoration override. If this is not set then we'll /// inherit whatever the config has. This allows overriding the @@ -858,10 +858,13 @@ pub const Window = extern struct { } } - fn onTimeout(ud: ?*anyopaque) callconv(.c) c_int { + /// Callback to handle this window becoming active or inactive. + /// Triggered by propIsActive with a timeout to debounce temporary + /// changes in active state. + fn handleActiveState(ud: ?*anyopaque) callconv(.c) c_int { const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); const priv = self.private(); - priv.timeout = null; + priv.handle_active_state_source = null; // Hide quick-terminal if set to autohide if (self.isQuickTerminal()) { @@ -1113,12 +1116,29 @@ pub const Window = extern struct { // Use a timeout callback to wait for focus state to settle, // because depending on the windowing backend the window might // become inactive and immediately active again. This happens - // e.g. on Wayland when opening a context menu. - if (priv.timeout == null) priv.timeout = glib.timeoutAdd( - 100, - onTimeout, - self, - ); + // e.g. on Wayland when opening a context menu or a submenu + // inside a context menu. + if (priv.handle_active_state_source == null) { + priv.handle_active_state_source = glib.timeoutAddFull( + // Use priority of an idle callback instead of the higher + // default timeout priority. This allows us to use a shorter + // timeout duration. + glib.PRIORITY_DEFAULT_IDLE, + // 50ms was chosen to be conservative. From testing we know + // that, depending on the backend and system performance, a + // shorter timeout or just an idle callback can be enough for + // the focus to settle. On the other hand a delay of e.g. 10ms + // does not work reliably on some slow systems. The downside + // of a high value is that some operations in handleActiveState, + // e.g. hiding the quick-terminal, will be visibly delayed. + // However, 50ms should barely be noticeable. We can change + // this in the future if necessary. + 50, + handleActiveState, + self, + null, + ); + } } fn propGdkSurfaceDims( @@ -1237,11 +1257,11 @@ pub const Window = extern struct { fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); - if (priv.timeout) |v| { + if (priv.handle_active_state_source) |v| { if (glib.Source.remove(v) == 0) { - log.warn("unable to remove timeout source", .{}); + log.warn("unable to remove handle active state source", .{}); } - priv.timeout = null; + priv.handle_active_state_source = null; } priv.command_palette.set(null); From c09ade225acb0abfea2f845197b227086a76922f Mon Sep 17 00:00:00 2001 From: Uzair Aftab Date: Fri, 29 May 2026 21:11:09 +0200 Subject: [PATCH 126/137] agents: symlink CLAUDE.md to AGENTS.md --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 024880b9ca40ecf8a399deff14e422dd32746f68 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 31 May 2026 00:38:07 +0000 Subject: [PATCH 127/137] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 413c30a2f..58ea0a4f8 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .android_ndk = .{ .path = "./pkg/android-ndk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz", - .hash = "N-V-__8AAPy1AwDnEoq1ww42uq58nusIeQgR16W4-5SQZFIM", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260525-155808-7335c0a.tgz", + .hash = "N-V-__8AAGi9AwC7QV7hLqjN6iBkXA2y5dxw285nkSLlVB7I", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index ed745b364..f00670883 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz", "hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=" }, - "N-V-__8AAPy1AwDnEoq1ww42uq58nusIeQgR16W4-5SQZFIM": { + "N-V-__8AAGi9AwC7QV7hLqjN6iBkXA2y5dxw285nkSLlVB7I": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz", - "hash": "sha256-R2NJUKxz2LHRiCBi/MAnN3XzMyY4VWlbX0uWCbWefjQ=" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260525-155808-7335c0a.tgz", + "hash": "sha256-HGujRdQdWtVIf3GwCgQgjV9lbwWxJSIDJOWq3gOX3kU=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 1ca2810b1..2db95ea1d 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -193,11 +193,11 @@ in }; } { - name = "N-V-__8AAPy1AwDnEoq1ww42uq58nusIeQgR16W4-5SQZFIM"; + name = "N-V-__8AAGi9AwC7QV7hLqjN6iBkXA2y5dxw285nkSLlVB7I"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz"; - hash = "sha256-R2NJUKxz2LHRiCBi/MAnN3XzMyY4VWlbX0uWCbWefjQ="; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20260525-155808-7335c0a.tgz"; + hash = "sha256-HGujRdQdWtVIf3GwCgQgjV9lbwWxJSIDJOWq3gOX3kU="; unpack = false; }; } diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 108afb31b..037347e9f 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,7 +6,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918 https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz -https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz +https://deps.files.ghostty.org/ghostty-themes-release-20260525-155808-7335c0a.tgz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 1d6c5acb8..4a6a14734 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz", - "dest": "vendor/p/N-V-__8AAPy1AwDnEoq1ww42uq58nusIeQgR16W4-5SQZFIM", - "sha256": "47634950ac73d8b1d1882062fcc0273775f333263855695b5f4b9609b59e7e34" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260525-155808-7335c0a.tgz", + "dest": "vendor/p/N-V-__8AAGi9AwC7QV7hLqjN6iBkXA2y5dxw285nkSLlVB7I", + "sha256": "1c6ba345d41d5ad5487f71b00a04208d5f656f05b125220324e5aade0397de45" }, { "type": "archive", From 33adb58bee9eeca906e29ee957140275d4903257 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 31 May 2026 09:54:41 -0400 Subject: [PATCH 128/137] macos: remove unneeded initializers These will be automatically synthesized (they only do memberwise initialization) and do not need to be manually defined. --- .../QuickTerminalRestorableState.swift | 10 ---------- .../TerminalRestorableState+InteralState.swift | 14 -------------- 2 files changed, 24 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalRestorableState.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalRestorableState.swift index 17e9d2a27..f32da0758 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalRestorableState.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalRestorableState.swift @@ -44,16 +44,6 @@ extension QuickTerminalRestorableState { let focusedSurface: String? let surfaceTree: SplitTree let screenStateEntries: QuickTerminalScreenStateCache.Entries - - init( - focusedSurface: String?, - surfaceTree: SplitTree, - screenStateEntries: QuickTerminalScreenStateCache.Entries, - ) { - self.focusedSurface = focusedSurface - self.surfaceTree = surfaceTree - self.screenStateEntries = screenStateEntries - } } } diff --git a/macos/Sources/Features/Terminal/TerminalRestorableState+InteralState.swift b/macos/Sources/Features/Terminal/TerminalRestorableState+InteralState.swift index a9114693a..ac89b5f7c 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorableState+InteralState.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorableState+InteralState.swift @@ -15,20 +15,6 @@ extension TerminalRestorableState { let effectiveFullscreenMode: FullscreenMode? let tabColor: TerminalTabColor? let titleOverride: String? - - init( - focusedSurface: String?, - surfaceTree: SplitTree, - effectiveFullscreenMode: FullscreenMode?, - tabColor: TerminalTabColor?, - titleOverride: String?, - ) { - self.focusedSurface = focusedSurface - self.surfaceTree = surfaceTree - self.effectiveFullscreenMode = effectiveFullscreenMode - self.tabColor = tabColor - self.titleOverride = titleOverride - } } } From e32d7abe6eeae9c3aa557fef2bcfe97a212688c5 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 31 May 2026 10:00:24 -0400 Subject: [PATCH 129/137] macos: fix swiftlint opening_brace issue --- macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index c0013ec78..f631c2c05 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -734,8 +734,7 @@ extension Ghostty { // cached value is restored next time the terminal emits a // color_change. if let cached = self.backgroundColor, - cached != self.derivedConfig.backgroundColor - { + cached != self.derivedConfig.backgroundColor { self.backgroundColor = nil } } From eb5c1c7220121c8616160e10cb1aa664166a06f3 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.7" Date: Sat, 30 May 2026 17:29:41 +0200 Subject: [PATCH 130/137] fix(macos): mark Swift os.Logger interpolations as public --- macos/Sources/App/macOS/AppDelegate.swift | 4 +-- .../Global Keybinds/GlobalEventTap.swift | 2 +- .../Features/Secure Input/SecureInput.swift | 8 +++--- .../Terminal/BaseTerminalController.swift | 10 +++---- .../Terminal/TerminalRestorable.swift | 2 +- macos/Sources/Ghostty/Ghostty.App.swift | 22 ++++++++-------- macos/Sources/Ghostty/Ghostty.Config.swift | 4 +-- .../Ghostty/Surface View/OSSurfaceView.swift | 4 +-- .../Surface View/SurfaceView_AppKit.swift | 26 +++++++++---------- .../Extensions/NSWindow+Extension.swift | 2 +- 10 files changed, 42 insertions(+), 42 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index a971df9ba..9700a31ae 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -607,7 +607,7 @@ class AppDelegate: NSObject, // Build our event input and call ghostty if ghostty_app_key(ghostty, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) { // The key was used so we want to stop it from going to our Mac app - Ghostty.logger.debug("local key event handled event=\(event)") + Ghostty.logger.debug("local key event handled event=\(event, privacy: .public)") return nil } @@ -662,7 +662,7 @@ class AppDelegate: NSObject, private func requestBadgeAuthorizationAndSet(_ center: UNUserNotificationCenter) { center.requestAuthorization(options: [.badge]) { granted, error in if let error = error { - Self.logger.warning("Error requesting badge authorization: \(error)") + Self.logger.warning("Error requesting badge authorization: \(error, privacy: .public)") return } diff --git a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift index d308e59ea..9bb63776a 100644 --- a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift +++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift @@ -154,7 +154,7 @@ private func cgEventFlagsChangedHandler( // Build our event input and call ghostty let key_ev = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) if ghostty_app_key(ghostty, key_ev) { - GlobalEventTap.logger.info("global key event handled event=\(event)") + GlobalEventTap.logger.info("global key event handled event=\(event, privacy: .public)") return nil } diff --git a/macos/Sources/Features/Secure Input/SecureInput.swift b/macos/Sources/Features/Secure Input/SecureInput.swift index 261a38e5c..1fcaf66be 100644 --- a/macos/Sources/Features/Secure Input/SecureInput.swift +++ b/macos/Sources/Features/Secure Input/SecureInput.swift @@ -97,11 +97,11 @@ class SecureInput: ObservableObject { } if err == noErr { enabled = desired - Self.logger.debug("secure input state=\(self.enabled)") + Self.logger.debug("secure input state=\(self.enabled, privacy: .public)") return } - Self.logger.warning("secure input apply failed err=\(err)") + Self.logger.warning("secure input apply failed err=\(err, privacy: .public)") } // MARK: Notifications @@ -117,7 +117,7 @@ class SecureInput: ObservableObject { return } - Self.logger.warning("secure input apply failed err=\(err)") + Self.logger.warning("secure input apply failed err=\(err, privacy: .public)") } @objc private func onDidResignActive(notification: NSNotification) { @@ -130,6 +130,6 @@ class SecureInput: ObservableObject { return } - Self.logger.warning("secure input apply failed err=\(err)") + Self.logger.warning("secure input apply failed err=\(err, privacy: .public)") } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 9de22b3d9..269fbca49 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -256,7 +256,7 @@ class BaseTerminalController: NSWindowController, // If splitting fails for any reason (it should not), then we just log // and return. The new view we created will be deinitialized and its // no big deal. - Ghostty.logger.warning("failed to insert split: \(error)") + Ghostty.logger.warning("failed to insert split: \(error, privacy: .public)") return nil } @@ -730,7 +730,7 @@ class BaseTerminalController: NSWindowController, do { surfaceTree = try surfaceTree.resizing(node: targetNode, by: amount, in: spatialDirection, with: bounds) } catch { - Ghostty.logger.warning("failed to resize split: \(error)") + Ghostty.logger.warning("failed to resize split: \(error, privacy: .public)") } } @@ -904,7 +904,7 @@ class BaseTerminalController: NSWindowController, do { surfaceTree = try surfaceTree.replacing(node: node, with: resizedNode) } catch { - Ghostty.logger.warning("failed to replace node during split resize: \(error)") + Ghostty.logger.warning("failed to replace node during split resize: \(error, privacy: .public)") } } @@ -929,7 +929,7 @@ class BaseTerminalController: NSWindowController, do { newTree = try treeWithoutSource.inserting(view: source, at: destination, direction: direction) } catch { - Ghostty.logger.warning("failed to insert surface during drop: \(error)") + Ghostty.logger.warning("failed to insert surface during drop: \(error, privacy: .public)") return } @@ -966,7 +966,7 @@ class BaseTerminalController: NSWindowController, do { newTree = try surfaceTree.inserting(view: source, at: destination, direction: direction) } catch { - Ghostty.logger.warning("failed to insert surface during cross-window drop: \(error)") + Ghostty.logger.warning("failed to insert surface during cross-window drop: \(error, privacy: .public)") return } diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index f3a166420..c93ea75b6 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -52,7 +52,7 @@ extension TerminalRestorable { coder.encode(Self.version, forKey: Self.versionKey) coder.encode(CodableBridge(self), forKey: Self.selfKey) - AppDelegate.logger.debug("saved terminal state: \(debugDescription)") + AppDelegate.logger.debug("saved terminal state: \(debugDescription, privacy: .public)") } } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 018122760..ce35fa42d 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -190,14 +190,14 @@ extension Ghostty { func newTab(surface: ghostty_surface_t) { let action = "new_tab" if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { - logger.warning("action failed action=\(action)") + logger.warning("action failed action=\(action, privacy: .public)") } } func newWindow(surface: ghostty_surface_t) { let action = "new_window" if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { - logger.warning("action failed action=\(action)") + logger.warning("action failed action=\(action, privacy: .public)") } } @@ -220,14 +220,14 @@ extension Ghostty { func splitToggleZoom(surface: ghostty_surface_t) { let action = "toggle_split_zoom" if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { - logger.warning("action failed action=\(action)") + logger.warning("action failed action=\(action, privacy: .public)") } } func toggleFullscreen(surface: ghostty_surface_t) { let action = "toggle_fullscreen" if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { - logger.warning("action failed action=\(action)") + logger.warning("action failed action=\(action, privacy: .public)") } } @@ -248,21 +248,21 @@ extension Ghostty { action = "reset_font_size" } if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { - logger.warning("action failed action=\(action)") + logger.warning("action failed action=\(action, privacy: .public)") } } func toggleTerminalInspector(surface: ghostty_surface_t) { let action = "inspector:toggle" if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { - logger.warning("action failed action=\(action)") + logger.warning("action failed action=\(action, privacy: .public)") } } func resetTerminal(surface: ghostty_surface_t) { let action = "reset" if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { - logger.warning("action failed action=\(action)") + logger.warning("action failed action=\(action, privacy: .public)") } } @@ -485,7 +485,7 @@ extension Ghostty { break default: - Ghostty.logger.warning("unknown action target=\(target.tag.rawValue)") + Ghostty.logger.warning("unknown action target=\(target.tag.rawValue, privacy: .public)") return false } @@ -672,7 +672,7 @@ extension Ghostty { case GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD: return copyTitleToClipboard(app, target: target) default: - Ghostty.logger.warning("unknown action action=\(action.tag.rawValue)") + Ghostty.logger.warning("unknown action action=\(action.tag.rawValue, privacy: .public)") return false } @@ -979,7 +979,7 @@ extension Ghostty { guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } guard let mode = FullscreenMode.from(ghostty: raw) else { - Ghostty.logger.warning("unknown fullscreen mode raw=\(raw.rawValue)") + Ghostty.logger.warning("unknown fullscreen mode raw=\(raw.rawValue, privacy: .public)") return } NotificationCenter.default.post( @@ -1399,7 +1399,7 @@ extension Ghostty { let center = UNUserNotificationCenter.current() center.requestAuthorization(options: [.alert, .sound]) { _, error in if let error = error { - Ghostty.logger.error("Error while requesting notification authorization: \(error)") + Ghostty.logger.error("Error while requesting notification authorization: \(error, privacy: .public)") } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 9b094f21c..d7134029d 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -95,13 +95,13 @@ extension Ghostty { // pop-up window too. let diagsCount = ghostty_config_diagnostics_count(cfg) if diagsCount > 0 { - logger.warning("config error: \(diagsCount) configuration errors on reload") + logger.warning("config error: \(diagsCount, privacy: .public) configuration errors on reload") var diags: [String] = [] for i in 0.. Date: Sun, 31 May 2026 18:23:29 +0200 Subject: [PATCH 131/137] config: fix missing space in docs fixes #12873 comment/docs only change: switched space and tab in default value of `selection-word-chars` so there is no space at the value boundary needed because markdown trims spaces at the beginning & end of a code snippet --- src/config/Config.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 380155127..66b8c6057 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -752,12 +752,12 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// The null character (U+0000) is always treated as a boundary and does not /// need to be included in this configuration. /// -/// Default: `` \t'"│`|:;,()[]{}<>$ `` +/// Default: ``\t '"│`|:;,()[]{}<>$`` /// /// To add or remove specific characters, you can set this to a custom value. /// For example, to treat semicolons as part of words: /// -/// selection-word-chars = " \t'\"│`|:,()[]{}<>$" +/// selection-word-chars = "\t '\"│`|:,()[]{}<>$" /// /// Available since: 1.3.0 @"selection-word-chars": SelectionWordChars = .{}, From c4c9e945aefbd0afbfc21a05f76d06e36e10a625 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 16:39:28 +0000 Subject: [PATCH 132/137] Update VOUCHED list (#12880) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/12879#issuecomment-4587359428) from @00-kat. Vouch: @masterflitzer Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index cee5494f1..08f2e7977 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -172,6 +172,7 @@ markdorison markhuot marler8997 marrocco-simone +masterflitzer matkotiric mattn micaeljarniac From 0f7cd84b880b203c98683e520e84b9db0c5938d8 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 08:07:04 +0000 Subject: [PATCH 133/137] Update VOUCHED list (#12889) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12840#discussioncomment-17132417) from @bo2themax. Vouch: @52dyd Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 08f2e7977..1749611a0 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -24,6 +24,7 @@ 04cb 0xdvc -4rh1t3ct0r7 +52dyd aalhendi aaron-ang abdurrahmanski From d3775d1ed0a2e41ee8f2ecdb325f6c016b2b3e93 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 19 Apr 2026 14:34:01 -0700 Subject: [PATCH 134/137] terminal: glyph protocol parser and response encoder This adds the core parse/encode for the still in-development and experimental terminal glyph protocol: https://github.com/raphamorim/rio/pull/1542 Up to version 1.9. The only cross-cutting change necessary was changing the APC identification logic which previously only looked at a single byte to support multi-byte identifiers since the glyph protocol uses `25a1`. --- src/terminal/apc.zig | 161 +++++- src/terminal/apc/glyph.zig | 158 ++++++ src/terminal/apc/glyph/request.zig | 726 ++++++++++++++++++++++++++++ src/terminal/apc/glyph/response.zig | 334 +++++++++++++ src/terminal/stream_terminal.zig | 2 + src/termio/stream_handler.zig | 2 + 6 files changed, 1366 insertions(+), 17 deletions(-) create mode 100644 src/terminal/apc/glyph.zig create mode 100644 src/terminal/apc/glyph/request.zig create mode 100644 src/terminal/apc/glyph/response.zig diff --git a/src/terminal/apc.zig b/src/terminal/apc.zig index 7e6f08a7a..4ae9ead51 100644 --- a/src/terminal/apc.zig +++ b/src/terminal/apc.zig @@ -2,6 +2,7 @@ const std = @import("std"); const build_options = @import("terminal_options"); const Allocator = std.mem.Allocator; +const glyph = @import("apc/glyph.zig"); const kitty_gfx = @import("kitty/graphics.zig"); const log = std.log.scoped(.terminal_apc); @@ -18,6 +19,7 @@ pub const Handler = struct { /// use `.initFull`. max_bytes: std.EnumMap(Protocol, usize) = .initFullWith(.{ .kitty = Protocol.defaultMaxBytes(.kitty), + .glyph = Protocol.defaultMaxBytes(.glyph), }), pub fn deinit(self: *Handler) void { @@ -26,7 +28,7 @@ pub const Handler = struct { pub fn start(self: *Handler) void { self.state.deinit(); - self.state = .{ .identify = {} }; + self.state = .{ .identify = .{} }; } pub fn feed(self: *Handler, alloc: Allocator, byte: u8) void { @@ -38,21 +40,45 @@ pub const Handler = struct { .ignore => return, // We identify the APC command by the first byte. - .identify => { - switch (byte) { - // Kitty graphics protocol - 'G' => self.state = if (comptime build_options.kitty_graphics) - .{ .kitty = .init( + .identify => |*id| id: { + // Kitty graphics is detected immediately on the `G` byte, + // since commands begin immediately after with no termination + // character after the 'G'. + if (comptime build_options.kitty_graphics) { + if (id.len == 0 and byte == 'G') { + self.state = .{ .kitty = .init( alloc, self.max_bytes.get(.kitty) orelse Protocol.defaultMaxBytes(.kitty), - ) } - else - .ignore, - - // Unknown - else => self.state = .ignore, + ) }; + break :id; + } } + + // If we hit `;` then identify... + if (byte == ';') { + const str = id.buf[0..id.len]; + if (std.mem.eql(u8, str, "25a1")) { + self.state = .{ .glyph = .init( + alloc, + self.max_bytes.get(.glyph) orelse + Protocol.defaultMaxBytes(.glyph), + ) }; + } else { + self.state = .ignore; + } + + break :id; + } + + // If we're out of space to buffer then we're done. + if (id.len >= id.buf.len) { + self.state = .ignore; + break :id; + } + + id.buf[id.len] = byte; + id.len += 1; }, .kitty => |*p| if (comptime build_options.kitty_graphics) { @@ -62,6 +88,12 @@ pub const Handler = struct { self.state = .ignore; }; } else unreachable, + + .glyph => |*p| p.feed(byte) catch |err| { + log.warn("glyph protocol error: {}", .{err}); + p.deinit(); + self.state = .ignore; + }, } } @@ -86,21 +118,40 @@ pub const Handler = struct { break :kitty .{ .kitty = command }; }, + + .glyph => |*p| glyph_cmd: { + const command = p.complete(p.alloc) catch |err| { + log.warn("glyph protocol error: {}", .{err}); + break :glyph_cmd null; + }; + + break :glyph_cmd .{ .glyph = command }; + }, }; } }; pub const State = union(enum) { /// We're not in the middle of an APC command yet. - inactive: void, + inactive, /// We got an unrecognized APC sequence or the APC sequence we /// recognized became invalid. We're just dropping bytes. - ignore: void, + ignore, - /// We're waiting to identify the APC sequence. This is done by - /// inspecting the first byte of the sequence. - identify: void, + /// We're waiting to identify the APC sequence. The way this is done + /// is pretty fluid depending on supported APC protocols, but for now + /// our rule is: + /// + /// * 'G' - immediate transition to Kitty graphics protocol + /// * Buffer up to `;` and the bytes before dictate the protocol. + /// If we overflow then we're immediately invalid because we don't + /// support anything longer than this. + /// + identify: struct { + len: u3 = 0, + buf: [4]u8 = undefined, + }, /// Kitty graphics protocol kitty: if (build_options.kitty_graphics) @@ -108,9 +159,13 @@ pub const State = union(enum) { else void, + /// Glyph protocol + glyph: glyph.CommandParser, + pub fn deinit(self: *State) void { switch (self.*) { .inactive, .ignore, .identify => {}, + .glyph => |*v| v.deinit(), .kitty => |*v| if (comptime build_options.kitty_graphics) v.deinit() else @@ -122,6 +177,7 @@ pub const State = union(enum) { /// Possible APC command types. pub const Protocol = enum { kitty, + glyph, /// Returns the default maximum bytes for the given protocol. pub fn defaultMaxBytes(self: Protocol) usize { @@ -129,6 +185,10 @@ pub const Protocol = enum { // Kitty graphics payloads can be very large (e.g. full images // encoded as base64), so the default is set to 65 MiB. .kitty => 65 * 1024 * 1024, + // Glyph protocol messages carry single glyf outlines which + // are small, but base64 encoding inflates them. 1 MiB is + // generous for any single simple-glyph record. + .glyph => 1 * 1024 * 1024, }; } }; @@ -140,12 +200,16 @@ pub const Command = union(Protocol) { else void, + glyph: glyph.Request, + pub fn deinit(self: *Command, alloc: Allocator) void { switch (self.*) { .kitty => |*v| if (comptime build_options.kitty_graphics) v.deinit(alloc) else unreachable, + + .glyph => |*v| v.deinit(alloc), } } }; @@ -246,3 +310,66 @@ test "valid Kitty command" { defer cmd.deinit(alloc); try testing.expect(cmd == .kitty); } + +test "identify with unrecognized command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("abcd;payload") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} + +test "identify buffer overflow" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("abcde;payload") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} + +test "identify with no input" { + const testing = std.testing; + + var h: Handler = .{}; + h.start(); + try testing.expect(h.end() == null); +} + +test "identify with unknown partial input" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("25a") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} + +test "garbage glyph command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("25a1;X") |c| h.feed(alloc, c); + + try testing.expect(h.end() == null); +} + +test "valid glyph command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("25a1;q;cp=E0A0") |c| h.feed(alloc, c); + + var cmd = h.end().?; + defer cmd.deinit(alloc); + try testing.expect(cmd == .glyph); + try testing.expect(cmd.glyph == .query); +} diff --git a/src/terminal/apc/glyph.zig b/src/terminal/apc/glyph.zig new file mode 100644 index 000000000..67eb5163f --- /dev/null +++ b/src/terminal/apc/glyph.zig @@ -0,0 +1,158 @@ +//! # Glyph Protocol +//! +//! The Glyph Protocol lets applications register custom glyphs with the +//! terminal at runtime and query whether a given codepoint is already +//! covered by a system font or a prior registration. It eliminates the +//! requirement for users to install patched fonts (e.g. Nerd Fonts) in +//! order to render icons in TUIs. +//! +//! This file documents the current wire protocol surface parsed and formatted +//! by the glyph APC modules. +//! +//! ## Transport +//! +//! Messages use APC (Application Program Command) framing. +//! Terminals that do not implement the protocol can safely ignore APC +//! sequences. Every message is prefixed with the identifier `25a1` +//! (U+25A1 WHITE SQUARE — the canonical tofu symbol). +//! +//! ## Framing +//! +//! ``` +//! ESC _ 25a1 ; [ ; key=value ]* [ ; ] ESC \ +//! ``` +//! +//! Four verbs are defined: +//! +//! - `s` — support query +//! - `q` — codepoint query +//! - `r` — register a glyph +//! - `c` — clear registrations +//! +//! ## Support (`s`) +//! +//! Detects whether the terminal implements Glyph Protocol and which +//! payload formats it supports. +//! +//! Request: `ESC _ 25a1 ; s ESC \` +//! Response: `ESC _ 25a1 ; s ; fmt= ESC \` +//! +//! `fmt` is a comma-separated list of supported payload format names: +//! - `glyf` — TrueType simple glyphs (required in v1) +//! - `colrv0` — COLR v0 layered flat-colour glyphs +//! - `colrv1` — COLR v1 paint-graph glyphs +//! +//! Order is not significant. An empty `fmt=` means the terminal recognizes +//! Glyph Protocol but currently advertises no payload formats. Clients must +//! ignore unknown format names. +//! +//! Any reply confirms support; no reply within a timeout means the +//! terminal does not implement the protocol. +//! +//! ## Query (`q`) +//! +//! Asks whether a codepoint is renderable and by whom. +//! +//! Request: `ESC _ 25a1 ; q ; cp= ESC \` +//! Response: `ESC _ 25a1 ; q ; cp= ; status= ESC \` +//! +//! `status` is a comma-separated list of coverage names: +//! - empty — nothing renders this codepoint (tofu) +//! - `system` — a system font covers it +//! - `glossary` — a session registration covers it +//! - `system,glossary` — both; the registration shadows the system font +//! +//! Non-PUA codepoints can only report empty or `system`. Clients must ignore +//! unknown coverage names. +//! +//! ## Register (`r`) +//! +//! Registers a glyph outline at a Private Use Area codepoint. +//! +//! Request: +//! `ESC _ 25a1 ; r ; cp= [; fmt=glyf] [; reply=<0|1|2>] +//! [; upm=] [; aw=] [; lh=] [; width=<1|2>] +//! [; size=] +//! [; align=,] +//! [; pad=,,,] ; ESC \` +//! +//! Response: +//! `ESC _ 25a1 ; r ; cp= ; status=0 ESC \` +//! On error: `status= ; reason=` +//! +//! Parameters: +//! - `cp` — target codepoint (hex). Must be in a PUA range: +//! U+E000–U+F8FF, U+F0000–U+FFFFD, or U+100000–U+10FFFD. +//! Non-PUA values are rejected with `reason=out_of_namespace`. +//! - `fmt` — payload format. Default `glyf`; `colrv0` and `colrv1` +//! are optional and advertised via the `s` reply. +//! - `reply` — response verbosity: +//! `1` (default) = success + failure replies +//! `2` = failure replies only (silent success) +//! `0` = no replies (fire-and-forget) +//! - `upm` — units-per-em for the coordinate space. Default 1000. +//! - `aw` — authored advance width in upm units. Default `upm`. +//! - `lh` — authored line height in upm units. Default `upm`. +//! - `width` — Unicode/wcwidth cell width. Must be `1` or `2`; default `1`. +//! This is authoritative for cursor advance, wrapping, and +//! selection geometry. +//! - `size` — scale policy. Default `height`. +//! - `align` — horizontal and vertical placement within the render span. +//! Default `center,center`. +//! - `pad` — fractional insets from the render span edges. Default +//! `0,0,0,0`; degenerate padding is treated as no padding. +//! - payload — base64-encoded payload for the selected `fmt`. +//! +//! The `glyf` subset accepted: +//! - Simple glyphs only (no composites). +//! - Standard flag encoding (on-curve, off-curve, x/y-short, repeat). +//! - No hinting instructions. +//! - Coordinates are in the `upm` space, Y-up, with `y=0` at the baseline; +//! the terminal scales and positions at render time using `aw`, `lh`, +//! `width`, `size`, `align`, and `pad`. +//! +//! `colrv0` and `colrv1` wrap OpenType `COLR`/`CPAL` data together with the +//! simple-glyph outlines they reference. `colrv0` uses layered flat colours; +//! `colrv1` uses the OpenType paint graph and may omit `CPAL` if it does not +//! reference palette indices. +//! +//! A second `r` on the same `cp` overwrites the previous registration. +//! `glyf` outlines render in the current foreground colour. +//! +//! ## Clear (`c`) +//! +//! Removes registrations. +//! +//! Single slot: `ESC _ 25a1 ; c ; cp= ESC \` +//! All slots: `ESC _ 25a1 ; c ESC \` +//! +//! The terminal acks with `status=0` even if the slot was already empty. +//! Clear replies do not echo `cp`. `cp` must be in a PUA range; non-PUA values return +//! `reason=out_of_namespace`. +//! +//! ## Glossary Capacity +//! +//! Each session holds at most 1024 registrations keyed by codepoint. +//! Registrations live for the session duration. A 1025th registration +//! evicts the oldest entry (FIFO). Sessions are isolated: two tabs may +//! independently register the same codepoint. +//! +//! ## Security: PUA-Only Restriction +//! +//! Registration is restricted to the three Unicode Private Use Areas to +//! prevent glyph-spoofing attacks. PUA codepoints never appear in normal +//! text (filenames, URLs, commands), so a registered glyph cannot alter +//! how real text is perceived. The cell buffer always stores the original +//! codepoint — copy/paste, search, and hyperlink detection return the +//! codepoint the application emitted, never the rendered glyph. +//! +//! Reference: + +const std = @import("std"); + +pub const request = @import("glyph/request.zig"); +pub const response = @import("glyph/response.zig"); + +pub const CommandParser = request.CommandParser; +pub const Request = request.Request; +pub const Response = response.Response; diff --git a/src/terminal/apc/glyph/request.zig b/src/terminal/apc/glyph/request.zig new file mode 100644 index 000000000..4c50525fc --- /dev/null +++ b/src/terminal/apc/glyph/request.zig @@ -0,0 +1,726 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; + +/// Stateful parser for a single glyph APC payload after the `25a1;` prefix. +pub const CommandParser = struct { + alloc: Allocator, + data: std.ArrayList(u8) = .empty, + + /// Maximum bytes the data payload can buffer. This is to prevent + /// malicious input from causing us to allocate too much memory. + max_bytes: usize, + + pub const Error = Allocator.Error || error{InvalidFormat}; + + /// Create a glyph APC parser that buffers the raw command bytes. + pub fn init(alloc: Allocator, max_bytes: usize) CommandParser { + return .{ .alloc = alloc, .max_bytes = max_bytes }; + } + + /// Release any buffered command bytes owned by the parser. + pub fn deinit(self: *CommandParser) void { + self.data.deinit(self.alloc); + } + + /// Append one more byte of APC payload to the buffered command. + pub fn feed(self: *CommandParser, byte: u8) Allocator.Error!void { + if (self.data.items.len >= self.max_bytes) return error.OutOfMemory; + try self.data.append(self.alloc, byte); + } + + /// Finish parsing and return an owned request that can outlive the parser. + pub fn complete(self: *CommandParser, alloc: Allocator) Error!Request { + // Normalize bare single-byte verbs like `s` into `s;` so the parsed + // command always has the standard `verb;...` layout. + if (self.data.items.len == 1) try self.data.append(self.alloc, ';'); + + const raw = try self.data.toOwnedSlice(alloc); + + // Ownership of the buffered bytes has moved to `raw`, so clear the + // array list before we build the final command value. + self.data = .empty; + errdefer alloc.free(raw); + return try Request.parse(alloc, raw); + } +}; + +/// Parsed glyph APC request with the verb classified eagerly. +pub const Request = union(enum) { + /// Support query (bare `s` verb, no options). + support, + + /// Codepoint coverage query. + query: Query, + + /// Glyph registration request. + register: Register, + + /// Registration clear request. + clear: Clear, + + /// Query verb payload with lazily-decoded options. + pub const Query = struct { + raw: []const u8, + + /// Initialize a query command from owned raw command bytes. + pub fn init(raw: []const u8) Query { + return .{ .raw = raw }; + } + + /// Options recognized for the glyph query request. + pub const Option = enum { + /// Target Unicode codepoint encoded in hexadecimal. + cp, + + /// Return the decoded Zig type for a query option. + pub fn Type(comptime self: Option) type { + return switch (self) { + .cp => u21, + }; + } + + /// Return the wire-format option key for this query option. + fn key(comptime self: Option) []const u8 { + return @tagName(self); + } + + /// Read and decode a query option from the raw option string. + pub fn read(comptime self: Option, raw: []const u8) ?self.Type() { + const value = optionValue(raw, self.key()) orelse return null; + return switch (self) { + .cp => std.fmt.parseInt(u21, value, 16) catch null, + }; + } + }; + + /// Lazily decode a query option on demand. + pub fn get(self: Query, comptime option: Option) ?option.Type() { + return option.read(self.rawOptions()); + } + + /// Return the raw option portion of a valid query command. + fn rawOptions(self: Query) []const u8 { + assert(self.raw.len >= 2); + assert(self.raw[0] == 'q'); + assert(self.raw[1] == ';'); + return self.raw[2..]; + } + }; + + /// Register verb payload with lazily-decoded options and optional base64 data. + pub const Register = struct { + raw: []const u8, + payload_idx: usize, + + /// Initialize a register command from owned raw command bytes. + pub fn init(raw: []const u8) Register { + assert(raw.len >= 2); + assert(raw[0] == 'r'); + assert(raw[1] == ';'); + const payload_idx = std.mem.lastIndexOfScalar(u8, raw, ';').?; + assert(payload_idx > 1); + + return .{ + .raw = raw, + .payload_idx = payload_idx, + }; + } + + /// Options recognized for the glyph register verb. + pub const Option = enum { + /// Target Unicode codepoint encoded in hexadecimal. + cp, + + /// Glyph payload format. + fmt, + + /// Requested reply verbosity for registration. + reply, + + /// Units-per-em for the glyph coordinate system. + upm, + + /// Authored advance width in units-per-em units. + aw, + + /// Authored line height in units-per-em units. + lh, + + /// Unicode cell width for terminal layout. + width, + + /// Glyph scale policy. + size, + + /// Glyph placement within the render span. + @"align", + + /// Fractional insets from the render span edges. + pad, + + /// Return the decoded Zig type for a register option. + pub fn Type(comptime self: Option) type { + return switch (self) { + .cp => u21, + .fmt => Format, + .reply => Reply, + .upm => u32, + .aw => u32, + .lh => u32, + .width => Width, + .size => Size, + .@"align" => Align, + .pad => Pad, + }; + } + + /// Return the protocol default value for this option, if any. + pub fn default(comptime self: Option) ?self.Type() { + return switch (self) { + .cp => null, + .fmt => .glyf, + .reply => .all, + .upm => 1000, + .aw => null, + .lh => null, + .width => .narrow, + .size => .height, + .@"align" => .{}, + .pad => .{}, + }; + } + + /// Return the wire-format option key for this register option. + fn key(comptime self: Option) []const u8 { + return @tagName(self); + } + + /// Read and decode a register option from the raw option string. + pub fn read(comptime self: Option, raw: []const u8) ?self.Type() { + const value = optionValue(raw, self.key()) orelse return null; + return switch (self) { + .cp => std.fmt.parseInt(u21, value, 16) catch null, + .fmt => Format.init(value), + .reply => Reply.init(value) orelse .all, + .upm => std.fmt.parseInt(u32, value, 10) catch null, + .aw => std.fmt.parseInt(u32, value, 10) catch null, + .lh => std.fmt.parseInt(u32, value, 10) catch null, + .width => Width.init(value), + .size => Size.init(value), + .@"align" => Align.init(value), + .pad => Pad.init(value), + }; + } + }; + + /// Lazily decode a register option on demand, applying protocol + /// defaults when the option is omitted. + pub fn get(self: Register, comptime option: Option) ?option.Type() { + const raw = self.rawOptions(); + if (optionValue(raw, option.key()) == null) { + return switch (option) { + .aw, .lh => self.get(.upm), + else => option.default(), + }; + } + return option.read(raw); + } + + /// Return the base64 payload carried by a register request. + /// + /// If no payload is present, this returns an empty slice. The returned + /// bytes may still be invalid base64; this function only exposes the raw + /// payload segment and does not validate or decode it. + pub fn payload(self: Register) []const u8 { + assert(self.raw.len >= 2); + assert(self.raw[0] == 'r'); + assert(self.raw[1] == ';'); + return if (self.payload_idx == self.raw.len) + "" + else + self.raw[self.payload_idx + 1 ..]; + } + + /// Return the raw option portion of a valid register command. + fn rawOptions(self: Register) []const u8 { + assert(self.raw.len >= 2); + assert(self.raw[0] == 'r'); + assert(self.raw[1] == ';'); + assert(self.payload_idx >= 2); + assert(self.payload_idx <= self.raw.len); + return self.raw[2..self.payload_idx]; + } + }; + + /// Clear verb payload with lazily-decoded options. + pub const Clear = struct { + raw: []const u8, + + /// Initialize a clear command from owned raw command bytes. + pub fn init(raw: []const u8) Clear { + return .{ .raw = raw }; + } + + /// Options recognized for the glyph clear request. + pub const Option = enum { + /// Target Unicode codepoint encoded in hexadecimal. + cp, + + /// Return the decoded Zig type for a clear option. + pub fn Type(comptime self: Option) type { + return switch (self) { + .cp => u21, + }; + } + + /// Return the wire-format option key for this clear option. + fn key(comptime self: Option) []const u8 { + return @tagName(self); + } + + /// Read and decode a clear option from the raw option string. + pub fn read(comptime self: Option, raw: []const u8) ?self.Type() { + const value = optionValue(raw, self.key()) orelse return null; + return switch (self) { + .cp => std.fmt.parseInt(u21, value, 16) catch null, + }; + } + }; + + /// Lazily decode a clear option on demand. + pub fn get(self: Clear, comptime option: Option) ?option.Type() { + return option.read(self.rawOptions()); + } + + /// Return the raw option portion of a valid clear command. + fn rawOptions(self: Clear) []const u8 { + assert(self.raw.len >= 2); + assert(self.raw[0] == 'c'); + assert(self.raw[1] == ';'); + return self.raw[2..]; + } + }; + + /// Parse an owned glyph APC payload into its eagerly-classified request + /// form. + /// + /// The raw format here is strict on its requirements to avoid + /// edge cases: it must contain the request AND the request must + /// end in a semicolon (even if there are no options). The spec itself + /// does not require this but we artificially insert it in our parser + /// to simplify parsing later. + pub fn parse(alloc: Allocator, raw: []const u8) error{InvalidFormat}!Request { + if (raw.len < 2) return error.InvalidFormat; + if (raw[1] != ';') return error.InvalidFormat; + + return switch (raw[0]) { + 's' => { + alloc.free(raw); + return .support; + }, + 'q' => .{ .query = .init(raw) }, + 'r' => .{ .register = .init(raw) }, + 'c' => .{ .clear = .init(raw) }, + else => error.InvalidFormat, + }; + } + + /// Free the raw bytes retained by any request variant. + pub fn deinit(self: *Request, alloc: Allocator) void { + switch (self.*) { + .support => {}, + inline else => |*cmd| if (cmd.raw.len > 0) alloc.free(cmd.raw), + } + } +}; + +/// Glyph payload formats named by the protocol. +pub const Format = enum { + /// TrueType simple glyph outline data. + glyf, + + /// OpenType COLR version 0 layered color glyph data. + colrv0, + + /// OpenType COLR version 1 paint graph glyph data. + colrv1, + + /// Parse a glyph payload format name. + pub fn init(value: []const u8) ?Format { + return std.meta.stringToEnum(Format, value); + } +}; + +/// Register command reply verbosity. +pub const Reply = enum(u2) { + /// Suppress both success and failure replies. + none = 0, + + /// Emit replies for both success and failure cases. + all = 1, + + /// Emit replies only for failure cases. + failures = 2, + + /// Parse the register command reply mode from its single-digit encoding. + pub fn init(value: []const u8) ?Reply { + if (value.len != 1) return null; + return switch (value[0]) { + '0' => .none, + '1' => .all, + '2' => .failures, + else => null, + }; + } +}; + +/// Register command width override for terminal layout. +pub const Width = enum(u2) { + /// One terminal cell. + narrow = 1, + + /// Two terminal cells. + wide = 2, + + /// Parse the register command width from its single-digit encoding. + pub fn init(value: []const u8) ?Width { + if (value.len != 1) return null; + return switch (value[0]) { + '1' => .narrow, + '2' => .wide, + else => null, + }; + } +}; + +/// Register command glyph scale policy. +pub const Size = enum { + height, + advance, + contain, + cover, + stretch, + + /// Parse a glyph scale policy name. + pub fn init(value: []const u8) ?Size { + return std.meta.stringToEnum(Size, value); + } +}; + +/// Register command glyph placement within the render span. +pub const Align = struct { + horizontal: Horizontal = .center, + vertical: Vertical = .center, + + pub const Horizontal = enum { + start, + center, + end, + + fn init(value: []const u8) ?Horizontal { + return std.meta.stringToEnum(Horizontal, value); + } + }; + + pub const Vertical = enum { + start, + center, + end, + baseline, + + fn init(value: []const u8) ?Vertical { + return std.meta.stringToEnum(Vertical, value); + } + }; + + /// Parse an align value in `,` form. + pub fn init(value: []const u8) ?Align { + var it = std.mem.splitScalar(u8, value, ','); + const horizontal = Horizontal.init(it.next() orelse return null) orelse return null; + const vertical = Vertical.init(it.next() orelse return null) orelse return null; + if (it.next() != null) return null; + + return .{ + .horizontal = horizontal, + .vertical = vertical, + }; + } +}; + +/// Register command fractional insets from the render span edges. +pub const Pad = struct { + top: f64 = 0, + right: f64 = 0, + bottom: f64 = 0, + left: f64 = 0, + + /// Parse a pad value in `,,,` form. + pub fn init(value: []const u8) ?Pad { + var it = std.mem.splitScalar(u8, value, ','); + const top = parseFraction(it.next() orelse return null) orelse return null; + const right = parseFraction(it.next() orelse return null) orelse return null; + const bottom = parseFraction(it.next() orelse return null) orelse return null; + const left = parseFraction(it.next() orelse return null) orelse return null; + if (it.next() != null) return null; + + // Glyph Protocol §8.5.2: "If `l + r ≥ 1` or `t + b ≥ 1` + // the terminal MUST treat the request as if `pad=0,0,0,0`." + if (left + right >= 1 or top + bottom >= 1) return .{}; + + return .{ + .top = top, + .right = right, + .bottom = bottom, + .left = left, + }; + } + + /// Parse one pad component from the spec's `0.0`–`1.0` fractional range. + /// Top/bottom fractions are relative to cell height; left/right fractions + /// are relative to render span width. + fn parseFraction(value: []const u8) ?f64 { + const result = std.fmt.parseFloat(f64, value) catch return null; + if (!(result >= 0 and result <= 1)) return null; + return result; + } +}; + +/// Find the last occurrence of `key=value` for a lazily-parsed option list. +fn optionValue(raw: []const u8, comptime key: []const u8) ?[]const u8 { + var remaining = raw; + var result: ?[]const u8 = null; + while (remaining.len > 0) { + // Options are semicolon-delimited, so each loop peels off one segment + // and checks whether it matches the requested key. + const len = std.mem.indexOfScalar(u8, remaining, ';') orelse remaining.len; + const full = remaining[0..len]; + + if (std.mem.indexOfScalar(u8, full, '=')) |eql_idx| { + if (std.mem.eql(u8, full[0..eql_idx], key)) { + result = full[eql_idx + 1 ..]; + } + } + + if (len == remaining.len) break; + remaining = remaining[len + 1 ..]; + } + + return result; +} + +fn testParse(alloc: Allocator, data: []const u8) CommandParser.Error!Request { + var parser = CommandParser.init(alloc, 1024 * 1024); + defer parser.deinit(); + for (data) |byte| try parser.feed(byte); + return try parser.complete(alloc); +} + +test "support command" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "s"); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .support); +} + +test "query command" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "q;cp=E0A0"); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .query); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.query.get(.cp).?); +} + +test "register command with payload" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;fmt=glyf;upm=1000;reply=2;QQ==", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?); + try testing.expectEqual(Format.glyf, cmd.register.get(.fmt).?); + try testing.expectEqual(@as(u32, 1000), cmd.register.get(.upm).?); + try testing.expectEqual(Reply.failures, cmd.register.get(.reply).?); + try testing.expectEqualStrings("QQ==", cmd.register.payload()); +} + +test "register command with sizing and placement options" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;upm=2048;aw=1024;lh=1536;width=2;size=contain;align=end,baseline;pad=0.1,0.2,0.3,0.4;QQ==", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u32, 2048), cmd.register.get(.upm).?); + try testing.expectEqual(@as(u32, 1024), cmd.register.get(.aw).?); + try testing.expectEqual(@as(u32, 1536), cmd.register.get(.lh).?); + try testing.expectEqual(Width.wide, cmd.register.get(.width).?); + try testing.expectEqual(Size.contain, cmd.register.get(.size).?); + try testing.expectEqual(Align{ + .horizontal = .end, + .vertical = .baseline, + }, cmd.register.get(.@"align").?); + try testing.expectEqual(Pad{ + .top = 0.1, + .right = 0.2, + .bottom = 0.3, + .left = 0.4, + }, cmd.register.get(.pad).?); + try testing.expectEqualStrings("QQ==", cmd.register.payload()); +} + +test "register option defaults" { + const testing = std.testing; + const Option = Request.Register.Option; + + try testing.expect(Option.cp.default() == null); + try testing.expectEqual(Format.glyf, Option.fmt.default().?); + try testing.expectEqual(@as(u32, 1000), Option.upm.default().?); + try testing.expect(Option.aw.default() == null); + try testing.expect(Option.lh.default() == null); + try testing.expectEqual(Width.narrow, Option.width.default().?); + try testing.expectEqual(Size.height, Option.size.default().?); + try testing.expectEqual(Align{}, Option.@"align".default().?); + try testing.expectEqual(Pad{}, Option.pad.default().?); + try testing.expectEqual(Reply.all, Option.reply.default().?); +} + +test "register command defaults" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;QQ=="); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?); + try testing.expectEqual(Format.glyf, cmd.register.get(.fmt).?); + try testing.expectEqual(@as(u32, 1000), cmd.register.get(.upm).?); + try testing.expectEqual(@as(u32, 1000), cmd.register.get(.aw).?); + try testing.expectEqual(@as(u32, 1000), cmd.register.get(.lh).?); + try testing.expectEqual(Width.narrow, cmd.register.get(.width).?); + try testing.expectEqual(Size.height, cmd.register.get(.size).?); + try testing.expectEqual(Align{}, cmd.register.get(.@"align").?); + try testing.expectEqual(Pad{}, cmd.register.get(.pad).?); + try testing.expectEqual(Reply.all, cmd.register.get(.reply).?); +} + +test "register command aw and lh default to upm" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;upm=2048;QQ=="); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u32, 2048), cmd.register.get(.aw).?); + try testing.expectEqual(@as(u32, 2048), cmd.register.get(.lh).?); +} + +test "register command invalid sizing and placement options" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;width=3;size=invalid;align=center,middle;pad=0,1.2,0,0;QQ==", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expect(cmd.register.get(.width) == null); + try testing.expect(cmd.register.get(.size) == null); + try testing.expect(cmd.register.get(.@"align") == null); + try testing.expect(cmd.register.get(.pad) == null); +} + +test "register command degenerate padding defaults to no padding" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;pad=0.4,0.2,0.6,0.1;QQ==", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(Pad{}, cmd.register.get(.pad).?); +} + +test "register command invalid reply falls back to reply=1" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;reply=9;QQ=="); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(Reply.all, cmd.register.get(.reply).?); +} + +test "register command duplicate options use the last value" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;reply=1;reply=2;QQ=="); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(Reply.failures, cmd.register.get(.reply).?); +} + +test "register command with invalid payload" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;fmt=glyf;%%%not-base64%%%", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?); + try testing.expectEqual(Format.glyf, cmd.register.get(.fmt).?); + try testing.expectEqualStrings("%%%not-base64%%%", cmd.register.payload()); +} + +test "register response without payload" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=E0A0;status=4;reason=out_of_namespace", + ); + defer cmd.deinit(testing.allocator); + + // Register parsing is request-only, so the final segment is always treated + // as payload rather than as a response field. + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?); + try testing.expectEqualStrings("reason=out_of_namespace", cmd.register.payload()); +} + +test "clear command" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "c;cp=e0a0"); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .clear); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.clear.get(.cp).?); +} + +test "invalid command" { + const testing = std.testing; + + try testing.expectError( + error.InvalidFormat, + testParse(testing.allocator, "x"), + ); +} diff --git a/src/terminal/apc/glyph/response.zig b/src/terminal/apc/glyph/response.zig new file mode 100644 index 000000000..4ed52b0b2 --- /dev/null +++ b/src/terminal/apc/glyph/response.zig @@ -0,0 +1,334 @@ +const std = @import("std"); + +/// Query response coverage state for a codepoint. +pub const Coverage = packed struct(u2) { + /// A system font covers the codepoint. + system: bool = false, + + /// A session glyph registration covers the codepoint. + glossary: bool = false, + + /// No system font or registered glyph covers the codepoint. + pub const free: Coverage = .{}; + + /// Parse the query response coverage list from its comma-separated form. + /// Unknown coverage names are ignored for forward compatibility. + pub fn init(value: []const u8) ?Coverage { + var result: Coverage = .free; + var it = std.mem.splitScalar(u8, value, ','); + while (it.next()) |name| { + if (std.mem.eql(u8, name, "system")) { + result.system = true; + } else if (std.mem.eql(u8, name, "glossary")) { + result.glossary = true; + } + } + + return result; + } +}; + +/// Response to a glyph APC request, formatted for the wire protocol. +pub const Response = union(enum) { + /// Support query response listing supported payload formats. + support: Support, + + /// Codepoint coverage query response. + query: Query, + + /// Glyph registration response (success or error). + register: Register, + + /// Registration clear response. + clear: Clear, + + /// Support query response fields. + pub const Support = struct { + /// Supported payload formats. + fmt: Formats, + + pub const Formats = packed struct(u8) { + /// TrueType simple glyph outlines (required in v1). + glyf: bool = false, + + /// COLR v0 layered flat-colour glyphs. + colrv0: bool = false, + + /// COLR v1 paint-graph glyphs. + colrv1: bool = false, + + _padding: u5 = 0, + }; + }; + + /// Codepoint query response fields. + pub const Query = struct { + /// The queried codepoint. + cp: u21, + + /// Coverage status for the codepoint. + status: Coverage, + }; + + /// Register response fields. + pub const Register = struct { + /// The target codepoint of the registration. + cp: u21, + + /// Result status of the registration encoded as a decimal u8. + status: Status = .ok, + + /// Optional symbolic error reason. + reason: ?Reason = null, + + /// Register error reason codes defined by Glyph Protocol §6.2. + pub const Reason = union(enum) { + /// `cp` is not in any PUA range. + out_of_namespace, + + /// Payload contains composite glyphs. + composite_unsupported, + + /// Payload contains hinting instructions. + hinting_unsupported, + + /// Payload failed to parse as the declared `fmt`. + malformed_payload, + + /// Payload exceeds 64 KiB after base64 decoding. + payload_too_large, + + /// A reason code not known by this version of Ghostty. + other: []const u8, + + /// Return the wire-format reason name. + pub fn name(self: Reason) []const u8 { + return switch (self) { + .out_of_namespace => "out_of_namespace", + .composite_unsupported => "composite_unsupported", + .hinting_unsupported => "hinting_unsupported", + .malformed_payload => "malformed_payload", + .payload_too_large => "payload_too_large", + .other => |value| value, + }; + } + }; + }; + + /// Clear response fields. + pub const Clear = struct { + /// Result status of the clear operation encoded as a decimal u8. + status: Status = .ok, + + /// Optional symbolic error reason. + reason: ?[]const u8 = null, + }; + + /// Status code for register and clear responses. + pub const Status = enum(u8) { + /// The operation completed successfully. + ok = 0, + + /// A generic or unspecified error occurred. + err = 1, + + _, + }; + + /// Write the response in the glyph APC wire format to `writer`. + /// + /// The framing is: `ESC _ 25a1 ; ; * ESC \` + pub fn formatWire( + self: Response, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try writer.writeAll("\x1b_25a1;"); + switch (self) { + .support => |r| { + // From the spec: + // Order is not significant; clients MUST treat the value as a + // set. An empty fmt= value means the terminal recognises + // Glyph Protocol but currently advertises no payload formats + // — every r will be rejected. Clients MUST ignore names they + // do not recognise rather than failing the reply, so future + // format names are forward- compatible. + try writer.writeAll("s;fmt="); + var first = true; + if (r.fmt.glyf) { + first = false; + try writer.writeAll("glyf"); + } + if (r.fmt.colrv0) { + if (!first) try writer.writeByte(','); + first = false; + try writer.writeAll("colrv0"); + } + if (r.fmt.colrv1) { + if (!first) try writer.writeByte(','); + first = false; + try writer.writeAll("colrv1"); + } + }, + .query => |r| { + // status is a comma-separated list of coverage names — the + // set of sources that can render cp in this session. Order is + // not significant; clients MUST treat the value as a set. + try writer.print("q;cp={x};status=", .{r.cp}); + var first = true; + if (r.status.system) { + first = false; + try writer.writeAll("system"); + } + if (r.status.glossary) { + if (!first) try writer.writeByte(','); + try writer.writeAll("glossary"); + } + }, + .register => |r| { + try writer.print("r;cp={x};status={d}", .{ r.cp, @intFromEnum(r.status) }); + if (r.reason) |reason| { + try writer.writeAll(";reason="); + try writer.writeAll(reason.name()); + } + }, + .clear => |r| { + try writer.print("c;status={d}", .{@intFromEnum(r.status)}); + if (r.reason) |reason| { + try writer.writeAll(";reason="); + try writer.writeAll(reason); + } + }, + } + try writer.writeAll("\x1b\\"); + } +}; + +test "support formats default to no advertised formats" { + const testing = std.testing; + const Formats = Response.Support.Formats; + + try testing.expectEqual(@as(u8, 0), @as(u8, @bitCast(Formats{}))); +} + +test "response support formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .support = .{ .fmt = .{ .glyf = true, .colrv0 = true } } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;s;fmt=glyf,colrv0\x1b\\", writer.buffered()); +} + +test "response support formatWire with no formats" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .support = .{ .fmt = .{} } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;s;fmt=\x1b\\", writer.buffered()); +} + +test "response query formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .query = .{ .cp = 0xE0A0, .status = .{ .system = true, .glossary = true } } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;q;cp=e0a0;status=system,glossary\x1b\\", writer.buffered()); +} + +test "response query formatWire with no coverage" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .query = .{ .cp = 0xE0A0, .status = .{} } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;q;cp=e0a0;status=\x1b\\", writer.buffered()); +} + +test "coverage parses comma-separated names" { + const testing = std.testing; + + try testing.expectEqual(Coverage{}, Coverage.init("").?); + try testing.expectEqual(Coverage{ .system = true }, Coverage.init("system").?); + try testing.expectEqual(Coverage{ .glossary = true }, Coverage.init("glossary").?); + try testing.expectEqual(Coverage{ .system = true, .glossary = true }, Coverage.init("system,glossary").?); + try testing.expectEqual(Coverage{ .system = true, .glossary = true }, Coverage.init("glossary,system").?); + try testing.expectEqual(Coverage{ .system = true }, Coverage.init("system,future").?); + try testing.expectEqual(Coverage{}, Coverage.init("future").?); +} + +test "response register success formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .register = .{ .cp = 0xE0A0 } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;r;cp=e0a0;status=0\x1b\\", writer.buffered()); +} + +test "response register error formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .register = .{ .cp = 0xE0A0, .status = .err, .reason = .out_of_namespace } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;r;cp=e0a0;status=1;reason=out_of_namespace\x1b\\", writer.buffered()); +} + +test "response register arbitrary status formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .register = .{ .cp = 0xE0A0, .status = @enumFromInt(37), .reason = .payload_too_large } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;r;cp=e0a0;status=37;reason=payload_too_large\x1b\\", writer.buffered()); +} + +test "register reason names" { + const testing = std.testing; + const Reason = Response.Register.Reason; + + try testing.expectEqualStrings("out_of_namespace", Reason.out_of_namespace.name()); + try testing.expectEqualStrings("composite_unsupported", Reason.composite_unsupported.name()); + try testing.expectEqualStrings("hinting_unsupported", Reason.hinting_unsupported.name()); + try testing.expectEqualStrings("malformed_payload", Reason.malformed_payload.name()); + try testing.expectEqualStrings("payload_too_large", Reason.payload_too_large.name()); + try testing.expectEqualStrings("future_reason", (Reason{ .other = "future_reason" }).name()); +} + +test "response clear formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .clear = .{} }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;c;status=0\x1b\\", writer.buffered()); +} + +test "response clear error formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .clear = .{ .status = .err, .reason = "out_of_namespace" } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;c;status=1;reason=out_of_namespace\x1b\\", writer.buffered()); +} diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index f68f088bf..51ef63422 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -679,6 +679,8 @@ pub const Handler = struct { if (final.len > 3) self.writePty(final[0 .. final.len - 1 :0]); } }, + + .glyph => {}, } } }; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index fb3a6b3ff..cb6305546 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -559,6 +559,8 @@ pub const StreamHandler = struct { } } }, + + .glyph => {}, } } From ab82b8ab720ce46183a58a55554e4a4a7423e3f5 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 1 Jun 2026 19:58:20 -0400 Subject: [PATCH 135/137] core: fix use-after-free in Surface.setSelection setSelection captured the previous selection, then called Screen.select (which deinits the previous selection's tracked pins), then compared the new selection against the now-freed previous pin via `sel.eql(prev)`. That read freed pin memory (use-after-free). The comparison was a copy-on-select optimization ("only re-copy if the selection changed"). Remove it rather than repair it because: - It never fired correctly. It compared against freed memory, so the shipped behavior was already "always copy". - It can't be repaired by copying `prev`'s pin before Screen.select. That fixes the use-after-free but not the logic: the call sites (e.g. mouse drag release) pass a selection equal to the one already set, so a working `eql` skip would suppress the very copy those sites exist to perform. A correct optimization would have to compare against the last-copied selection (before the mouse event mutated the live one), which would require extra state. - It isn't worth tracking that additional state. The copy runs once per selection gesture (mouse up, double-click), which isn't in a hot path, so skipping a redundant re-copy only saves a single clipboard write. Removing the skip eliminates the use-after-free and keeps the behavior consistent with what we've already been doing. --- src/Surface.zig | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 410f717b0..99c740c89 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2330,18 +2330,14 @@ fn copySelectionToClipboards( /// /// This must be called with the renderer mutex held. fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { - const prev_ = self.io.terminal.screens.active.selection; try self.io.terminal.screens.active.select(sel_); // If copy on select is false then exit early. if (self.config.copy_on_select == .false) return; // Set our selection clipboard. If the selection is cleared we do not - // clear the clipboard. If the selection is set, we only set the clipboard - // again if it changed, since setting the clipboard can be an expensive - // operation. + // clear the clipboard. const sel = sel_ orelse return; - if (prev_) |prev| if (sel.eql(prev)) return; switch (self.config.copy_on_select) { .false => unreachable, // handled above with an early exit From 76b9bdb1999398fa1b64d000f9a77088af232b62 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 1 Jun 2026 19:58:25 -0400 Subject: [PATCH 136/137] terminal: test Screen.select frees existing pins --- src/terminal/Screen.zig | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index becda78b7..8ee700252 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -7652,6 +7652,32 @@ test "Screen: select untracked" { try testing.expectEqual(tracked, s.pages.countTrackedPins()); } +test "Screen: select replaces existing pins" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("ABC DEF\n 123\n456"); + + const tracked = s.pages.countTrackedPins(); + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, + false, + )); + try testing.expectEqual(tracked + 2, s.pages.countTrackedPins()); + + // Replacing the selection must untrack the prior selection's pins + // rather than leak them. + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?, + false, + )); + try testing.expectEqual(tracked + 2, s.pages.countTrackedPins()); +} + test "Screen: selectAll" { const testing = std.testing; const alloc = testing.allocator; From 629838b9bd050b4708bdd162c115b58874646b34 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 04:48:36 +0000 Subject: [PATCH 137/137] Update VOUCHED list (#12906) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/12905#discussioncomment-17160340) from @jcollie. Vouch: @c0x0o Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 1749611a0..bd5f8e874 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -62,6 +62,7 @@ bleikurr bo2themax brentschroeter brianc442 +c0x0o cespare charliie-dev chernetskyi