From d6dea79bde1f2060fce56d178102db2c80773332 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Wed, 5 Feb 2025 13:58:01 +0100 Subject: [PATCH 001/110] gtk: add option to always display the tab bar Also fixes crashes in both vanilla GTK and Adwaita implementations of `closeTab`, which erroneously close windows twice when there are no more tabs left (we probably already handle it somewhere else). --- src/apprt/gtk/Window.zig | 56 ++++++++++++++++++++++------------------ src/config/Config.zig | 29 ++++++++++++++++++++- 2 files changed, 59 insertions(+), 26 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index d82087ff0..6bed56ec0 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -54,6 +54,9 @@ window: *adw.ApplicationWindow, /// The header bar for the window. headerbar: HeaderBar, +/// The tab bar for the window. +tab_bar: *adw.TabBar, + /// The tab overview for the window. This is possibly null since there is no /// taboverview without a AdwApplicationWindow (libadwaita >= 1.4.0). tab_overview: ?*adw.TabOverview, @@ -82,6 +85,7 @@ pub const DerivedConfig = struct { gtk_tabs_location: configpkg.Config.GtkTabsLocation, gtk_wide_tabs: bool, gtk_toolbar_style: configpkg.Config.GtkToolbarStyle, + window_show_tab_bar: configpkg.Config.WindowShowTabBar, quick_terminal_position: configpkg.Config.QuickTerminalPosition, quick_terminal_size: configpkg.Config.QuickTerminalSize, @@ -101,6 +105,7 @@ pub const DerivedConfig = struct { .gtk_tabs_location = config.@"gtk-tabs-location", .gtk_wide_tabs = config.@"gtk-wide-tabs", .gtk_toolbar_style = config.@"gtk-toolbar-style", + .window_show_tab_bar = config.@"window-show-tab-bar", .quick_terminal_position = config.@"quick-terminal-position", .quick_terminal_size = config.@"quick-terminal-size", @@ -135,6 +140,7 @@ pub fn init(self: *Window, app: *App) !void { .config = DerivedConfig.init(&app.config), .window = undefined, .headerbar = undefined, + .tab_bar = undefined, .tab_overview = null, .notebook = undefined, .titlebar_menu = undefined, @@ -216,8 +222,9 @@ pub fn init(self: *Window, app: *App) !void { // If we're using an AdwWindow then we can support the tab overview. if (self.tab_overview) |tab_overview| { if (!adw_version.supportsTabOverview()) unreachable; - const btn = switch (self.config.gtk_tabs_location) { - .top, .bottom => btn: { + + const btn = switch (self.config.window_show_tab_bar) { + .always, .auto => btn: { const btn = gtk.ToggleButton.new(); btn.as(gtk.Widget).setTooltipText(i18n._("View Open Tabs")); btn.as(gtk.Button).setIconName("view-grid-symbolic"); @@ -229,8 +236,7 @@ pub fn init(self: *Window, app: *App) !void { ); break :btn btn.as(gtk.Widget); }, - - .hidden => btn: { + .never => btn: { const btn = adw.TabButton.new(); btn.setView(self.notebook.tab_view); btn.as(gtk.Actionable).setActionName("overview.open"); @@ -376,21 +382,16 @@ pub fn init(self: *Window, app: *App) !void { // Our actions for the menu initActions(self); + self.tab_bar = adw.TabBar.new(); + self.tab_bar.setView(self.notebook.tab_view); + if (adw_version.supportsToolbarView()) { const toolbar_view = adw.ToolbarView.new(); toolbar_view.addTopBar(self.headerbar.asWidget()); - if (self.config.gtk_tabs_location != .hidden) { - const tab_bar = adw.TabBar.new(); - tab_bar.setView(self.notebook.tab_view); - - if (!self.config.gtk_wide_tabs) tab_bar.setExpandTabs(0); - - switch (self.config.gtk_tabs_location) { - .top => toolbar_view.addTopBar(tab_bar.as(gtk.Widget)), - .bottom => toolbar_view.addBottomBar(tab_bar.as(gtk.Widget)), - .hidden => unreachable, - } + switch (self.config.gtk_tabs_location) { + .top => toolbar_view.addTopBar(self.tab_bar.as(gtk.Widget)), + .bottom => toolbar_view.addBottomBar(self.tab_bar.as(gtk.Widget)), } toolbar_view.setContent(box.as(gtk.Widget)); @@ -405,23 +406,18 @@ pub fn init(self: *Window, app: *App) !void { // Set our application window content. self.tab_overview.?.setChild(toolbar_view.as(gtk.Widget)); self.window.setContent(self.tab_overview.?.as(gtk.Widget)); - } else tab_bar: { - if (self.config.gtk_tabs_location == .hidden) break :tab_bar; + } else { // In earlier adwaita versions, we need to add the tabbar manually since we do not use // an AdwToolbarView. - const tab_bar = adw.TabBar.new(); - tab_bar.as(gtk.Widget).addCssClass("inline"); + self.tab_bar.as(gtk.Widget).addCssClass("inline"); + switch (self.config.gtk_tabs_location) { .top => box.insertChildAfter( - tab_bar.as(gtk.Widget), + self.tab_bar.as(gtk.Widget), self.headerbar.asWidget(), ), - .bottom => box.append(tab_bar.as(gtk.Widget)), - .hidden => unreachable, + .bottom => box.append(self.tab_bar.as(gtk.Widget)), } - tab_bar.setView(self.notebook.tab_view); - - if (!self.config.gtk_wide_tabs) tab_bar.setExpandTabs(0); } // If we want the window to be maximized, we do that here. @@ -543,6 +539,16 @@ pub fn syncAppearance(self: *Window) !void { } } + self.tab_bar.setExpandTabs(@intFromBool(self.config.gtk_wide_tabs)); + self.tab_bar.setAutohide(switch (self.config.window_show_tab_bar) { + .auto, .never => @intFromBool(true), + .always => @intFromBool(false), + }); + self.tab_bar.as(gtk.Widget).setVisible(switch (self.config.window_show_tab_bar) { + .always, .auto => @intFromBool(true), + .never => @intFromBool(false), + }); + self.winproto.syncAppearance() catch |err| { log.warn("failed to sync winproto appearance error={}", .{err}); }; diff --git a/src/config/Config.zig b/src/config/Config.zig index ca330f8f6..fd6ae798e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1410,6 +1410,27 @@ keybind: Keybinds = .{}, /// * `end` - Insert the new tab at the end of the tab list. @"window-new-tab-position": WindowNewTabPosition = .current, +/// Whether to show the tab bar. +/// +/// Valid values: +/// +/// - `always` +/// +/// Always display the tab bar, even when there's only one tab. +/// +/// - `auto` *(default)* +/// +/// Automatically show and hide the tab bar. The tab bar is only +/// shown when there are two or more tabs present. +/// +/// - `never` +/// +/// Never show the tab bar. Tabs are only accessible via the tab +/// overview or by keybind actions. +/// +/// Currently only supported on Linux (GTK). +@"window-show-tab-bar": WindowShowTabBar = .auto, + /// Background color for the window titlebar. This only takes effect if /// window-theme is set to ghostty. Currently only supported in the GTK app /// runtime. @@ -5747,7 +5768,6 @@ pub const GtkSingleInstance = enum { pub const GtkTabsLocation = enum { top, bottom, - hidden, }; /// See gtk-toolbar-style @@ -5795,6 +5815,13 @@ pub const WindowNewTabPosition = enum { end, }; +/// See window-show-tab-bar +pub const WindowShowTabBar = enum { + always, + auto, + never, +}; + /// See resize-overlay pub const ResizeOverlay = enum { always, From 5f3e5afb88602c90e6ff39fd8948e84842d96af4 Mon Sep 17 00:00:00 2001 From: Alan Moyano Date: Mon, 19 May 2025 20:27:22 -0300 Subject: [PATCH 002/110] Add Argentinian Spanish translation and locale support --- po/es_AR.UTF-8.po | 285 ++++++++++++++++++++++++++++++++++++++++++++++ src/os/i18n.zig | 1 + 2 files changed, 286 insertions(+) create mode 100644 po/es_AR.UTF-8.po diff --git a/po/es_AR.UTF-8.po b/po/es_AR.UTF-8.po new file mode 100644 index 000000000..0cb99f6be --- /dev/null +++ b/po/es_AR.UTF-8.po @@ -0,0 +1,285 @@ +# Spanish translations for com.mitchellh.ghostty package. +# Copyright (C) 2025 Mitchell Hashimoto +# This file is distributed under the same license as the com.mitchellh.ghostty package. +# Alan Moyano , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: com.mitchellh.ghostty\n" +"Report-Msgid-Bugs-To: m@mitchellh.com\n" +"POT-Creation-Date: 2025-04-23 16:58+0800\n" +"PO-Revision-Date: 2025-05-19 20:17-0300\n" +"Last-Translator: Alan Moyano \n" +"Language-Team: Argentinian \n" +"Language: es_AR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:5 +msgid "Change Terminal Title" +msgstr "Cambiar el título de la terminal" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6 +msgid "Leave blank to restore the default title." +msgstr "Deja en blanco para restaurar el título predeterminado." + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/CloseDialog.zig:44 +msgid "Cancel" +msgstr "Cancelar" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:10 +msgid "OK" +msgstr Aceptar" + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:5 +msgid "Configuration Errors" +msgstr "Errores de configuración" + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:6 +msgid "" +"One or more configuration errors were found. Please review the errors below, " +"and either reload your configuration or ignore these errors." +msgstr "" +"Se encontraron uno o más errores de configuración. Por favor revisa los " +"errores a continuación, y recarga tu configuración o ignora estos errores." + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9 +msgid "Ignore" +msgstr "Ignorar" + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:100 +msgid "Reload Configuration" +msgstr "Recargar configuración" + +#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:6 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:38 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:50 +msgid "Split Up" +msgstr "Dividir hacia arriba" + +#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:11 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:43 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:55 +msgid "Split Down" +msgstr "Dividir hacia abajo" + +#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:16 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:48 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:60 +msgid "Split Left" +msgstr "Dividir hacia la izquierda" + +#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:21 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:53 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:65 +msgid "Split Right" +msgstr "Dividir hacia la derecha" + +#: src/apprt/gtk/ui/1.5/command-palette.blp:16 +msgid "Execute a command…" +msgstr "Ejecutar un comando…" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 +msgid "Copy" +msgstr "Copiar" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:11 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:11 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:11 +msgid "Paste" +msgstr "Pegar" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:18 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:73 +msgid "Clear" +msgstr "Limpiar" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:23 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:78 +msgid "Reset" +msgstr "Reiniciar" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:30 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:42 +msgid "Split" +msgstr "Dividir" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:33 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:45 +msgid "Change Title…" +msgstr "Cambiar título…" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:59 +msgid "Tab" +msgstr "Pestaña" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:62 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30 +#: src/apprt/gtk/Window.zig:255 +msgid "New Tab" +msgstr "Nueva pestaña" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:67 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:35 +msgid "Close Tab" +msgstr "Cerrar pestaña" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:73 +msgid "Window" +msgstr "Ventana" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:76 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:18 +msgid "New Window" +msgstr "Nueva ventana" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:81 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:23 +msgid "Close Window" +msgstr "Cerrar ventana" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:89 +msgid "Config" +msgstr "Configuración" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:92 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95 +msgid "Open Configuration" +msgstr "Abrir configuración" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 +msgid "Command Palette" +msgstr "Paleta de comandos" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 +msgid "Terminal Inspector" +msgstr "Inspector de la terminal" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 +#: src/apprt/gtk/Window.zig:1024 +msgid "About Ghostty" +msgstr "Acerca de Ghostty" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:112 +msgid "Quit" +msgstr "Salir" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6 +msgid "Authorize Clipboard Access" +msgstr "Autorizar acceso al portapapeles" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7 +msgid "" +"An application is attempting to read from the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Una aplicación está intentando leer desde el portapapeles. El contenido " +"actual del portapapeles se muestra a continuación." + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:10 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:10 +msgid "Deny" +msgstr "Denegar" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:11 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:11 +msgid "Allow" +msgstr "Permitir" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 +msgid "" +"An application is attempting to write to the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Una aplicación está intentando escribir en el portapapeles. El contenido " +"actual del portapapeles se muestra a continuación." + +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 +msgid "Warning: Potentially Unsafe Paste" +msgstr "Advertencia: Pegado potencialmente inseguro" + +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 +msgid "" +"Pasting this text into the terminal may be dangerous as it looks like some " +"commands may be executed." +msgstr "" +"Pegar este texto en la terminal puede ser peligroso ya que parece que " +"algunos comandos podrían ejecutarse." + +#: src/apprt/gtk/Window.zig:208 +msgid "Main Menu" +msgstr "Menú principal" + +#: src/apprt/gtk/Window.zig:229 +msgid "View Open Tabs" +msgstr "Ver pestañas abiertas" + +#: src/apprt/gtk/Window.zig:256 +msgid "New Split" +msgstr "Nueva división" + +#: src/apprt/gtk/Window.zig:319 +msgid "" +"⚠️ You're running a debug build of Ghostty! Performance will be degraded." +msgstr "" +"⚠️ Está ejecutando una versión de depuración de Ghostty. El rendimiento no " +"será óptimo." + +#: src/apprt/gtk/Window.zig:765 +msgid "Reloaded the configuration" +msgstr "Configuración recargada" + +#: src/apprt/gtk/Window.zig:1005 +msgid "Ghostty Developers" +msgstr "Desarrolladores de Ghostty" + +#: src/apprt/gtk/inspector.zig:144 +msgid "Ghostty: Terminal Inspector" +msgstr "Ghostty: Inspector de la terminal" + +#: src/apprt/gtk/CloseDialog.zig:47 +msgid "Close" +msgstr "Cerrar" + +#: src/apprt/gtk/CloseDialog.zig:87 +msgid "Quit Ghostty?" +msgstr "¿Salir de Ghostty?" + +#: src/apprt/gtk/CloseDialog.zig:88 +msgid "Close Window?" +msgstr "¿Cerrar ventana?" + +#: src/apprt/gtk/CloseDialog.zig:89 +msgid "Close Tab?" +msgstr "¿Cerrar pestaña?" + +#: src/apprt/gtk/CloseDialog.zig:90 +msgid "Close Split?" +msgstr "msgstr "¿Cerrar división?" + +#: src/apprt/gtk/CloseDialog.zig:96 +msgid "All terminal sessions will be terminated." +msgstr "Todas las sesiones de la terminal serán terminadas." + +#: src/apprt/gtk/CloseDialog.zig:97 +msgid "All terminal sessions in this window will be terminated." +msgstr "Todas las sesiones de la terminal en esta ventana serán terminadas." + +#: src/apprt/gtk/CloseDialog.zig:98 +msgid "All terminal sessions in this tab will be terminated." +msgstr "Todas las sesiones de la terminal en esta pestaña serán terminadas." + +#: src/apprt/gtk/CloseDialog.zig:99 +msgid "The currently running process in this split will be terminated." +msgstr "El proceso actualmente en ejecución en esta división será terminado." + +#: src/apprt/gtk/Surface.zig:1243 +msgid "Copied to clipboard" +msgstr "Copiado al portapapeles" diff --git a/src/os/i18n.zig b/src/os/i18n.zig index 475f5e705..fd1d44ab0 100644 --- a/src/os/i18n.zig +++ b/src/os/i18n.zig @@ -43,6 +43,7 @@ pub const locales = [_][:0]const u8{ "tr_TR.UTF-8", "id_ID.UTF-8", "es_BO.UTF-8", + "es_AR.UTF-8", "pt_BR.UTF-8", "ca_ES.UTF-8", }; From cf7e76d8f23f7e048ff55fd4db5da7a08e5176c6 Mon Sep 17 00:00:00 2001 From: Alan Moyano Date: Fri, 23 May 2025 17:25:14 -0300 Subject: [PATCH 003/110] Adding Argentinian Spanish to CODEOWNERS --- CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/CODEOWNERS b/CODEOWNERS index 3d8a4da3d..a53fb6da2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -161,6 +161,7 @@ /po/ca_ES.UTF-8.po @ghostty-org/ca_ES /po/de_DE.UTF-8.po @ghostty-org/de_DE /po/es_BO.UTF-8.po @ghostty-org/es_BO +/po/es_AR.UTF-8.po @ghostty-org/es_AR /po/fr_FR.UTF-8.po @ghostty-org/fr_FR /po/id_ID.UTF-8.po @ghostty-org/id_ID /po/ja_JP.UTF-8.po @ghostty-org/ja_JP From 0e74b8027aef60e9b8bf600ee1f88b03463661f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Mon, 7 Apr 2025 23:43:11 -0400 Subject: [PATCH 004/110] pwd: fix hostname resolution on macos When macOS's "Private WiFi address" feature is enabled it'll change the hostname to a mac address. Mac addresses look like URIs with a hostname and port component, e.g. 12:34:56:78:90:12 where `:12` looks like port 12. However, mac addresses can also contain letters a through f, so a valid mac address like ab:cd:ef:ab:cd:ef is valid, but will not be parsed as a URI, because `:ef` is not a valid port. This commit attempts to fix that by checking if the hostname is a valid mac address when `std.Uri.parse()` fails and constructing a new std.Uri struct using that information. It's not perfect, but is equally compliant with the URI spec as std.Uri currently is. --- src/termio/stream_handler.zig | 66 +++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index b3aa82d20..5327d8b36 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1041,6 +1041,13 @@ pub const StreamHandler = struct { self.terminal.markSemanticPrompt(.command); } + fn isUriPathSeparator(c: u8) bool { + return switch (c) { + '?', '#' => true, + else => false, + }; + } + pub fn reportPwd(self: *StreamHandler, url: []const u8) !void { // Special handling for the empty URL. We treat the empty URL // as resetting the pwd as if we never saw a pwd. I can't find any @@ -1069,9 +1076,62 @@ pub const StreamHandler = struct { return; } - const uri = std.Uri.parse(url) catch |e| { - log.warn("invalid url in OSC 7: {}", .{e}); - return; + const uri: std.Uri = uri: { + const uri = std.Uri.parse(url) catch |e| { + // It's possible this is a mac address on macOS where the last 2 characters in the + // address are non-digits, e.g. 'ff', and thus an invalid port. + // + // Example: file://12:34:56:78:90:12/path/to/file + + // Insufficient length to have a mac address in the hostname. + if (url.len < 24) { + log.warn("invalid url in OSC 7: {}", .{e}); + return; + } + + // The first '/' after the scheme marks the end of the hostname. If the hostname is + // not 17 characters, it's not a mac address. + if (std.mem.indexOfScalarPos(u8, url, 7, '/') != 24) { + log.warn("invalid url in OSC 7: {}", .{e}); + return; + } + + // At this point we have a potential mac address as the hostname. + const mac_address = url[7..24]; + + for (0..mac_address.len) |i| { + const c = mac_address[i]; + + if (i + 1 % 3 == 0) { + if (c != ':') { + log.warn("invalid url in OSC 7: {}", .{e}); + return; + } + } else { + if (!std.mem.containsAtLeastScalar(u8, "0123456789abcdef", 1, mac_address[i])) { + log.warn("invalid url in OSC 7: {}", .{e}); + return; + } + } + } + + // At this point we have what looks like a valid mac address. + + var uri_path_end_idx: usize = 24; + while (uri_path_end_idx < url.len and !isUriPathSeparator(url[uri_path_end_idx])) { + uri_path_end_idx += 1; + } + + // Same compliance factor as std.Uri.parse(), i.e. not at all compliant with the URI + // spec. + break :uri .{ + .scheme = "file://", + .host = .{ .percent_encoded = mac_address }, + .path = .{ .percent_encoded = url[24..uri_path_end_idx] }, + }; + }; + + break :uri uri; }; if (!std.mem.eql(u8, "file", uri.scheme) and From 19ca1bfb1ca950bd7ddcb58718903bafc7777944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Wed, 30 Apr 2025 23:54:42 -0400 Subject: [PATCH 005/110] Fix modulo operation and custom Uri struct init --- src/termio/stream_handler.zig | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 5327d8b36..6668b17cd 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1082,17 +1082,18 @@ pub const StreamHandler = struct { // address are non-digits, e.g. 'ff', and thus an invalid port. // // Example: file://12:34:56:78:90:12/path/to/file + if (e != error.InvalidPort) return; // Insufficient length to have a mac address in the hostname. if (url.len < 24) { - log.warn("invalid url in OSC 7: {}", .{e}); + log.warn("invalid MAC address in OSC 7: insufficient length", .{}); return; } // The first '/' after the scheme marks the end of the hostname. If the hostname is // not 17 characters, it's not a mac address. if (std.mem.indexOfScalarPos(u8, url, 7, '/') != 24) { - log.warn("invalid url in OSC 7: {}", .{e}); + log.warn("invalid MAC address in OSC 7: invalid scheme", .{}); return; } @@ -1102,14 +1103,14 @@ pub const StreamHandler = struct { for (0..mac_address.len) |i| { const c = mac_address[i]; - if (i + 1 % 3 == 0) { + if ((i + 1) % 3 == 0) { if (c != ':') { - log.warn("invalid url in OSC 7: {}", .{e}); + log.warn("invalid MAC address in OSC 7: missing colon", .{}); return; } } else { - if (!std.mem.containsAtLeastScalar(u8, "0123456789abcdef", 1, mac_address[i])) { - log.warn("invalid url in OSC 7: {}", .{e}); + if (!std.mem.containsAtLeastScalar(u8, "0123456789ABCDEFabcdef", 1, mac_address[i])) { + log.warn("invalid MAC address in OSC 7: invalid character '{c}' at position '{d}'", .{ mac_address[i], i }); return; } } @@ -1125,7 +1126,7 @@ pub const StreamHandler = struct { // Same compliance factor as std.Uri.parse(), i.e. not at all compliant with the URI // spec. break :uri .{ - .scheme = "file://", + .scheme = "file", .host = .{ .percent_encoded = mac_address }, .path = .{ .percent_encoded = url[24..uri_path_end_idx] }, }; From b66368b4d6e5e7eb3cc26a97574b09d6bfdbb49b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Thu, 1 May 2025 00:09:34 -0400 Subject: [PATCH 006/110] extract mac address validity check to function --- src/termio/stream_handler.zig | 57 +++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 6668b17cd..ba04ee6b1 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1048,6 +1048,29 @@ pub const StreamHandler = struct { }; } + fn isValidMacAddress(mac_address: []const u8) bool { + // A valid mac address has 6 two-character components with 5 colons, e.g. 12:34:56:ab:cd:ef. + if (mac_address.len != 17) { + return false; + } + + for (0..mac_address.len) |i| { + const c = mac_address[i]; + + if ((i + 1) % 3 == 0) { + if (c != ':') { + return false; + } + } else { + if (!std.mem.containsAtLeastScalar(u8, "0123456789ABCDEFabcdef", 1, c)) { + return false; + } + } + } + + return true; + } + pub fn reportPwd(self: *StreamHandler, url: []const u8) !void { // Special handling for the empty URL. We treat the empty URL // as resetting the pwd as if we never saw a pwd. I can't find any @@ -1084,40 +1107,22 @@ pub const StreamHandler = struct { // Example: file://12:34:56:78:90:12/path/to/file if (e != error.InvalidPort) return; - // Insufficient length to have a mac address in the hostname. - if (url.len < 24) { - log.warn("invalid MAC address in OSC 7: insufficient length", .{}); - return; - } - - // The first '/' after the scheme marks the end of the hostname. If the hostname is - // not 17 characters, it's not a mac address. + // The first '/' after the scheme marks the end of the hostname. If the first '/' + // following the end of the `file://` scheme is not at position 24 this is not a + // valid mac address. if (std.mem.indexOfScalarPos(u8, url, 7, '/') != 24) { - log.warn("invalid MAC address in OSC 7: invalid scheme", .{}); + log.warn("invalid url in OSC 7: {}", .{e}); return; } - // At this point we have a potential mac address as the hostname. + // At this point we may have a mac address as the hostname. const mac_address = url[7..24]; - for (0..mac_address.len) |i| { - const c = mac_address[i]; - - if ((i + 1) % 3 == 0) { - if (c != ':') { - log.warn("invalid MAC address in OSC 7: missing colon", .{}); - return; - } - } else { - if (!std.mem.containsAtLeastScalar(u8, "0123456789ABCDEFabcdef", 1, mac_address[i])) { - log.warn("invalid MAC address in OSC 7: invalid character '{c}' at position '{d}'", .{ mac_address[i], i }); - return; - } - } + if (!isValidMacAddress(mac_address)) { + log.warn("ivalid url in OSC 7: {}", .{e}); + return; } - // At this point we have what looks like a valid mac address. - var uri_path_end_idx: usize = 24; while (uri_path_end_idx < url.len and !isUriPathSeparator(url[uri_path_end_idx])) { uri_path_end_idx += 1; From 64bfaf23f92fe92d1799088425d3e1c587f63231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Thu, 1 May 2025 00:18:42 -0400 Subject: [PATCH 007/110] take kitty-shell-cwd scheme into account --- src/termio/stream_handler.zig | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index ba04ee6b1..7bb604936 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1107,24 +1107,34 @@ pub const StreamHandler = struct { // Example: file://12:34:56:78:90:12/path/to/file if (e != error.InvalidPort) return; + const url_without_scheme = url: { + if (std.mem.startsWith(u8, url, "file://")) break :url url[7..]; + if (std.mem.startsWith(u8, url, "kitty-shell-cwd://")) break :url url[18..]; + + log.warn("invalid url in OSC 7: invalid scheme", .{}); + return; + }; + // The first '/' after the scheme marks the end of the hostname. If the first '/' - // following the end of the `file://` scheme is not at position 24 this is not a + // following the end of the scheme is not at the right position this is not a // valid mac address. - if (std.mem.indexOfScalarPos(u8, url, 7, '/') != 24) { + if (std.mem.indexOfScalarPos(u8, url_without_scheme, 0, '/') != 17) { log.warn("invalid url in OSC 7: {}", .{e}); return; } // At this point we may have a mac address as the hostname. - const mac_address = url[7..24]; + const mac_address = url_without_scheme[0..17]; if (!isValidMacAddress(mac_address)) { log.warn("ivalid url in OSC 7: {}", .{e}); return; } - var uri_path_end_idx: usize = 24; - while (uri_path_end_idx < url.len and !isUriPathSeparator(url[uri_path_end_idx])) { + var uri_path_end_idx: usize = 17; + while (uri_path_end_idx < url_without_scheme.len and + !isUriPathSeparator(url_without_scheme[uri_path_end_idx])) + { uri_path_end_idx += 1; } @@ -1133,7 +1143,7 @@ pub const StreamHandler = struct { break :uri .{ .scheme = "file", .host = .{ .percent_encoded = mac_address }, - .path = .{ .percent_encoded = url[24..uri_path_end_idx] }, + .path = .{ .percent_encoded = url_without_scheme[17..uri_path_end_idx] }, }; }; From ffe7f0d8bfc385fe9e1afed21e3973fdabb27283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Thu, 1 May 2025 00:24:37 -0400 Subject: [PATCH 008/110] extract url parsing into its own function --- src/termio/stream_handler.zig | 98 +++++++++++++++++------------------ 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 7bb604936..d57bdb1ac 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1071,6 +1071,52 @@ pub const StreamHandler = struct { return true; } + fn parseUrl(url: []const u8) !std.Uri { + return std.Uri.parse(url) catch |e| { + // It's possible this is a mac address on macOS where the last 2 characters in the + // address are non-digits, e.g. 'ff', and thus an invalid port. + // + // Example: file://12:34:56:78:90:12/path/to/file + if (e != error.InvalidPort) return e; + + const url_without_scheme = url: { + if (std.mem.startsWith(u8, url, "file://")) break :url url[7..]; + if (std.mem.startsWith(u8, url, "kitty-shell-cwd://")) break :url url[18..]; + + return error.UnsupportedScheme; + }; + + // The first '/' after the scheme marks the end of the hostname. If the first '/' + // following the end of the scheme is not at the right position this is not a + // valid mac address. + if (std.mem.indexOfScalarPos(u8, url_without_scheme, 0, '/') != 17) { + return error.HostnameIsNotMacAddress; + } + + // At this point we may have a mac address as the hostname. + const mac_address = url_without_scheme[0..17]; + + if (!isValidMacAddress(mac_address)) { + return error.HostnameIsNotMacAddress; + } + + var uri_path_end_idx: usize = 17; + while (uri_path_end_idx < url_without_scheme.len and + !isUriPathSeparator(url_without_scheme[uri_path_end_idx])) + { + uri_path_end_idx += 1; + } + + // Same compliance factor as std.Uri.parse(), i.e. not at all compliant with the URI + // spec. + return .{ + .scheme = "file", + .host = .{ .percent_encoded = mac_address }, + .path = .{ .percent_encoded = url_without_scheme[17..uri_path_end_idx] }, + }; + }; + } + pub fn reportPwd(self: *StreamHandler, url: []const u8) !void { // Special handling for the empty URL. We treat the empty URL // as resetting the pwd as if we never saw a pwd. I can't find any @@ -1099,55 +1145,9 @@ pub const StreamHandler = struct { return; } - const uri: std.Uri = uri: { - const uri = std.Uri.parse(url) catch |e| { - // It's possible this is a mac address on macOS where the last 2 characters in the - // address are non-digits, e.g. 'ff', and thus an invalid port. - // - // Example: file://12:34:56:78:90:12/path/to/file - if (e != error.InvalidPort) return; - - const url_without_scheme = url: { - if (std.mem.startsWith(u8, url, "file://")) break :url url[7..]; - if (std.mem.startsWith(u8, url, "kitty-shell-cwd://")) break :url url[18..]; - - log.warn("invalid url in OSC 7: invalid scheme", .{}); - return; - }; - - // The first '/' after the scheme marks the end of the hostname. If the first '/' - // following the end of the scheme is not at the right position this is not a - // valid mac address. - if (std.mem.indexOfScalarPos(u8, url_without_scheme, 0, '/') != 17) { - log.warn("invalid url in OSC 7: {}", .{e}); - return; - } - - // At this point we may have a mac address as the hostname. - const mac_address = url_without_scheme[0..17]; - - if (!isValidMacAddress(mac_address)) { - log.warn("ivalid url in OSC 7: {}", .{e}); - return; - } - - var uri_path_end_idx: usize = 17; - while (uri_path_end_idx < url_without_scheme.len and - !isUriPathSeparator(url_without_scheme[uri_path_end_idx])) - { - uri_path_end_idx += 1; - } - - // Same compliance factor as std.Uri.parse(), i.e. not at all compliant with the URI - // spec. - break :uri .{ - .scheme = "file", - .host = .{ .percent_encoded = mac_address }, - .path = .{ .percent_encoded = url_without_scheme[17..uri_path_end_idx] }, - }; - }; - - break :uri uri; + const uri: std.Uri = parseUrl(url) catch |e| { + log.warn("invalid url in OSC 7: {}", .{e}); + return; }; if (!std.mem.eql(u8, "file", uri.scheme) and From e0655a7f75973dbc9013458587dac0c4e12ce66b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Thu, 8 May 2025 22:36:37 -0400 Subject: [PATCH 009/110] Move url parsing helper to os/hostname Also adds a test to verify that the function is working as intended. --- src/os/hostname.zig | 154 ++++++++++++++++++++++++++++++++++ src/termio/stream_handler.zig | 78 +---------------- 2 files changed, 155 insertions(+), 77 deletions(-) diff --git a/src/os/hostname.zig b/src/os/hostname.zig index 22f29ceff..eb6c7052c 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -6,6 +6,91 @@ pub const HostnameParsingError = error{ NoSpaceLeft, }; +fn isUriPathSeparator(c: u8) bool { + return switch (c) { + '?', '#' => true, + else => false, + }; +} + +fn isValidMacAddress(mac_address: []const u8) bool { + // A valid mac address has 6 two-character components with 5 colons, e.g. 12:34:56:ab:cd:ef. + if (mac_address.len != 17) { + return false; + } + + for (0..mac_address.len) |i| { + const c = mac_address[i]; + + if ((i + 1) % 3 == 0) { + if (c != ':') { + return false; + } + } else { + if (!std.mem.containsAtLeastScalar(u8, "0123456789ABCDEFabcdef", 1, c)) { + return false; + } + } + } + + return true; +} + +/// Parses the provided url to a `std.Uri` struct. This is very specific to getting hostname and +/// path information for Ghostty's PWD reporting functionality. Takes into account that on macOS +/// the url passed to this function might have a mac address as its hostname and parses it +/// correctly. +pub fn parseUrl(url: []const u8) !std.Uri { + return std.Uri.parse(url) catch |e| { + // It's possible this is a mac address on macOS where the last 2 characters in the + // address are non-digits, e.g. 'ff', and thus an invalid port. + // + // Example: file://12:34:56:78:90:12/path/to/file + if (e != error.InvalidPort) return e; + + const scheme, const url_without_scheme = url: { + if (std.mem.startsWith(u8, url, "file://")) break :url .{ "file", url[7..] }; + if (std.mem.startsWith(u8, url, "kitty-shell-cwd://")) break :url .{ + "kitty-shell-cwd", + url[18..], + }; + + return error.UnsupportedScheme; + }; + + // The first '/' after the scheme marks the end of the hostname. If the first '/' + // following the end of the scheme is not at the right position this is not a + // valid mac address. + if (std.mem.indexOfScalarPos(u8, url_without_scheme, 0, '/') != 17 and + url_without_scheme.len != 17) + { + return error.HostnameIsNotMacAddress; + } + + // At this point we may have a mac address as the hostname. + const mac_address = url_without_scheme[0..17]; + + if (!isValidMacAddress(mac_address)) { + return error.HostnameIsNotMacAddress; + } + + var uri_path_end_idx: usize = 17; + while (uri_path_end_idx < url_without_scheme.len and + !isUriPathSeparator(url_without_scheme[uri_path_end_idx])) + { + uri_path_end_idx += 1; + } + + // Same compliance factor as std.Uri.parse(), i.e. not at all compliant with the URI + // spec. + return .{ + .scheme = scheme, + .host = .{ .percent_encoded = mac_address }, + .path = .{ .percent_encoded = url_without_scheme[17..uri_path_end_idx] }, + }; + }; +} + /// Print the hostname from a file URI into a buffer. pub fn bufPrintHostnameFromFileUri( buf: []u8, @@ -70,6 +155,67 @@ pub fn isLocalHostname(hostname: []const u8) LocalHostnameValidationError!bool { return std.mem.eql(u8, hostname, ourHostname); } +test parseUrl { + // 1. Typical hostnames. + + var uri = try parseUrl("file://personal.computer/home/test/"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("personal.computer", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); + + uri = try parseUrl("kitty-shell-cwd://personal.computer/home/test/"); + + try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); + try std.testing.expectEqualStrings("personal.computer", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); + + // 2. Hostnames that are mac addresses. + + // Numerical mac addresses. + + uri = try parseUrl("file://12:34:56:78:90:12/home/test/"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == 12); + + uri = try parseUrl("kitty-shell-cwd://12:34:56:78:90:12/home/test/"); + + try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); + try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == 12); + + // Alphabetical mac addresses. + + uri = try parseUrl("file://ab:cd:ef:ab:cd:ef/home/test/"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); + + uri = try parseUrl("kitty-shell-cwd://ab:cd:ef:ab:cd:ef/home/test/"); + + try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); + try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); +} + +test "parseUrl succeeds even if path component is missing" { + const uri = try parseUrl("file://12:34:56:78:90:ab"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("12:34:56:78:90:ab", uri.host.?.percent_encoded); + try std.testing.expect(uri.path.isEmpty()); + try std.testing.expect(uri.port == null); +} + test "bufPrintHostnameFromFileUri succeeds with ascii hostname" { const uri = try std.Uri.parse("file://localhost/"); @@ -86,6 +232,14 @@ test "bufPrintHostnameFromFileUri succeeds with hostname as mac address" { try std.testing.expectEqualStrings("12:34:56:78:90:12", actual); } +test "bufPrintHostnameFromFileUri succeeds with hostname as mac address with the last component as ascii" { + const uri = try parseUrl("file://12:34:56:78:90:ab"); + + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const actual = try bufPrintHostnameFromFileUri(&buf, uri); + try std.testing.expectEqualStrings("12:34:56:78:90:ab", actual); +} + test "bufPrintHostnameFromFileUri succeeds with hostname as a mac address and the last section is < 10" { const uri = try std.Uri.parse("file://12:34:56:78:90:05"); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index d57bdb1ac..a238c2a59 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1041,82 +1041,6 @@ pub const StreamHandler = struct { self.terminal.markSemanticPrompt(.command); } - fn isUriPathSeparator(c: u8) bool { - return switch (c) { - '?', '#' => true, - else => false, - }; - } - - fn isValidMacAddress(mac_address: []const u8) bool { - // A valid mac address has 6 two-character components with 5 colons, e.g. 12:34:56:ab:cd:ef. - if (mac_address.len != 17) { - return false; - } - - for (0..mac_address.len) |i| { - const c = mac_address[i]; - - if ((i + 1) % 3 == 0) { - if (c != ':') { - return false; - } - } else { - if (!std.mem.containsAtLeastScalar(u8, "0123456789ABCDEFabcdef", 1, c)) { - return false; - } - } - } - - return true; - } - - fn parseUrl(url: []const u8) !std.Uri { - return std.Uri.parse(url) catch |e| { - // It's possible this is a mac address on macOS where the last 2 characters in the - // address are non-digits, e.g. 'ff', and thus an invalid port. - // - // Example: file://12:34:56:78:90:12/path/to/file - if (e != error.InvalidPort) return e; - - const url_without_scheme = url: { - if (std.mem.startsWith(u8, url, "file://")) break :url url[7..]; - if (std.mem.startsWith(u8, url, "kitty-shell-cwd://")) break :url url[18..]; - - return error.UnsupportedScheme; - }; - - // The first '/' after the scheme marks the end of the hostname. If the first '/' - // following the end of the scheme is not at the right position this is not a - // valid mac address. - if (std.mem.indexOfScalarPos(u8, url_without_scheme, 0, '/') != 17) { - return error.HostnameIsNotMacAddress; - } - - // At this point we may have a mac address as the hostname. - const mac_address = url_without_scheme[0..17]; - - if (!isValidMacAddress(mac_address)) { - return error.HostnameIsNotMacAddress; - } - - var uri_path_end_idx: usize = 17; - while (uri_path_end_idx < url_without_scheme.len and - !isUriPathSeparator(url_without_scheme[uri_path_end_idx])) - { - uri_path_end_idx += 1; - } - - // Same compliance factor as std.Uri.parse(), i.e. not at all compliant with the URI - // spec. - return .{ - .scheme = "file", - .host = .{ .percent_encoded = mac_address }, - .path = .{ .percent_encoded = url_without_scheme[17..uri_path_end_idx] }, - }; - }; - } - pub fn reportPwd(self: *StreamHandler, url: []const u8) !void { // Special handling for the empty URL. We treat the empty URL // as resetting the pwd as if we never saw a pwd. I can't find any @@ -1145,7 +1069,7 @@ pub const StreamHandler = struct { return; } - const uri: std.Uri = parseUrl(url) catch |e| { + const uri: std.Uri = internal_os.hostname.parseUrl(url) catch |e| { log.warn("invalid url in OSC 7: {}", .{e}); return; }; From 7a639a71197204254f869f50bde9cfb316acf40c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Tue, 13 May 2025 23:00:16 -0400 Subject: [PATCH 010/110] use iterator syntax in for loop --- src/os/hostname.zig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/os/hostname.zig b/src/os/hostname.zig index eb6c7052c..ef5ecbcf7 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -19,9 +19,7 @@ fn isValidMacAddress(mac_address: []const u8) bool { return false; } - for (0..mac_address.len) |i| { - const c = mac_address[i]; - + for (mac_address, 0..) |c, i| { if ((i + 1) % 3 == 0) { if (c != ':') { return false; From bb07e9c0261e229a99502871aa359e983bf81b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Tue, 13 May 2025 23:06:53 -0400 Subject: [PATCH 011/110] don't rely on hard-coded schemes --- src/os/hostname.zig | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/os/hostname.zig b/src/os/hostname.zig index ef5ecbcf7..747c145d4 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -46,15 +46,11 @@ pub fn parseUrl(url: []const u8) !std.Uri { // Example: file://12:34:56:78:90:12/path/to/file if (e != error.InvalidPort) return e; - const scheme, const url_without_scheme = url: { - if (std.mem.startsWith(u8, url, "file://")) break :url .{ "file", url[7..] }; - if (std.mem.startsWith(u8, url, "kitty-shell-cwd://")) break :url .{ - "kitty-shell-cwd", - url[18..], - }; - - return error.UnsupportedScheme; + const url_without_scheme_start = std.mem.indexOf(u8, url, "://") orelse { + return error.InvalidScheme; }; + const scheme = url[0..url_without_scheme_start]; + const url_without_scheme = url[url_without_scheme_start + 3 ..]; // The first '/' after the scheme marks the end of the hostname. If the first '/' // following the end of the scheme is not at the right position this is not a From a24d0c9faf83ebe486fc4dfd660a9ee4bc78c52c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Tue, 13 May 2025 23:15:23 -0400 Subject: [PATCH 012/110] re-order end-of-hostname validity check --- src/os/hostname.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/os/hostname.zig b/src/os/hostname.zig index 747c145d4..998b80fac 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -55,8 +55,8 @@ pub fn parseUrl(url: []const u8) !std.Uri { // The first '/' after the scheme marks the end of the hostname. If the first '/' // following the end of the scheme is not at the right position this is not a // valid mac address. - if (std.mem.indexOfScalarPos(u8, url_without_scheme, 0, '/') != 17 and - url_without_scheme.len != 17) + if (url_without_scheme.len != 17 and + std.mem.indexOfScalarPos(u8, url_without_scheme, 0, '/') != 17) { return error.HostnameIsNotMacAddress; } From dfdb588f581d1fca3230ca1d2437c5e0cee53833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Tue, 13 May 2025 23:15:53 -0400 Subject: [PATCH 013/110] add tests for hostnames without a path component --- src/os/hostname.zig | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/os/hostname.zig b/src/os/hostname.zig index 998b80fac..a04f9d4ab 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -199,6 +199,40 @@ test parseUrl { try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); try std.testing.expect(uri.port == null); + + // 3. Hostnames that are mac addresses with no path. + + // Numerical mac addresses. + + uri = try parseUrl("file://12:34:56:78:90:12"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("", uri.path.percent_encoded); + try std.testing.expect(uri.port == 12); + + uri = try parseUrl("kitty-shell-cwd://12:34:56:78:90:12"); + + try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); + try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("", uri.path.percent_encoded); + try std.testing.expect(uri.port == 12); + + // Alphabetical mac addresses. + + uri = try parseUrl("file://ab:cd:ef:ab:cd:ef"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); + + uri = try parseUrl("kitty-shell-cwd://ab:cd:ef:ab:cd:ef"); + + try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); + try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); } test "parseUrl succeeds even if path component is missing" { From 68f48b9911983cf60b258503e29900ffb928738f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Tue, 13 May 2025 23:18:35 -0400 Subject: [PATCH 014/110] name the 17 magic constant `mac_address_length` --- src/os/hostname.zig | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/os/hostname.zig b/src/os/hostname.zig index a04f9d4ab..4a2c5c841 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -55,20 +55,21 @@ pub fn parseUrl(url: []const u8) !std.Uri { // The first '/' after the scheme marks the end of the hostname. If the first '/' // following the end of the scheme is not at the right position this is not a // valid mac address. - if (url_without_scheme.len != 17 and - std.mem.indexOfScalarPos(u8, url_without_scheme, 0, '/') != 17) + const mac_address_length = 17; + if (url_without_scheme.len != mac_address_length and + std.mem.indexOfScalarPos(u8, url_without_scheme, 0, '/') != mac_address_length) { return error.HostnameIsNotMacAddress; } // At this point we may have a mac address as the hostname. - const mac_address = url_without_scheme[0..17]; + const mac_address = url_without_scheme[0..mac_address_length]; if (!isValidMacAddress(mac_address)) { return error.HostnameIsNotMacAddress; } - var uri_path_end_idx: usize = 17; + var uri_path_end_idx: usize = mac_address_length; while (uri_path_end_idx < url_without_scheme.len and !isUriPathSeparator(url_without_scheme[uri_path_end_idx])) { @@ -80,7 +81,9 @@ pub fn parseUrl(url: []const u8) !std.Uri { return .{ .scheme = scheme, .host = .{ .percent_encoded = mac_address }, - .path = .{ .percent_encoded = url_without_scheme[17..uri_path_end_idx] }, + .path = .{ + .percent_encoded = url_without_scheme[mac_address_length..uri_path_end_idx], + }, }; }; } From 7760389ab8933c7dce52c22658f988f383e00295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Tue, 13 May 2025 23:29:46 -0400 Subject: [PATCH 015/110] add comptime check for platform we only need the mac-address-as-hostname workaround on macos, so we now have a comptime check to see if we're on macos. --- src/os/hostname.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/os/hostname.zig b/src/os/hostname.zig index 4a2c5c841..fdbe822e3 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const posix = std.posix; pub const HostnameParsingError = error{ @@ -40,6 +41,10 @@ fn isValidMacAddress(mac_address: []const u8) bool { /// correctly. pub fn parseUrl(url: []const u8) !std.Uri { return std.Uri.parse(url) catch |e| { + // The mac-address-as-hostname issue is specific to macOS so we just return an error if we + // hit it on other platforms. + comptime if (builtin.os.tag != .macos) return e; + // It's possible this is a mac address on macOS where the last 2 characters in the // address are non-digits, e.g. 'ff', and thus an invalid port. // From e4a175d24a7ecb219ec20f92502bec2b86a29709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Tue, 13 May 2025 23:41:18 -0400 Subject: [PATCH 016/110] use explicit error set --- src/os/hostname.zig | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/os/hostname.zig b/src/os/hostname.zig index fdbe822e3..2cdba5763 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -7,6 +7,14 @@ pub const HostnameParsingError = error{ NoSpaceLeft, }; +pub const UrlParsingError = error{ + HostnameIsNotMacAddress, + InvalidFormat, + InvalidPort, + NoSchemeProvided, + UnexpectedCharacter, +}; + fn isUriPathSeparator(c: u8) bool { return switch (c) { '?', '#' => true, @@ -39,7 +47,7 @@ fn isValidMacAddress(mac_address: []const u8) bool { /// path information for Ghostty's PWD reporting functionality. Takes into account that on macOS /// the url passed to this function might have a mac address as its hostname and parses it /// correctly. -pub fn parseUrl(url: []const u8) !std.Uri { +pub fn parseUrl(url: []const u8) UrlParsingError!std.Uri { return std.Uri.parse(url) catch |e| { // The mac-address-as-hostname issue is specific to macOS so we just return an error if we // hit it on other platforms. @@ -52,7 +60,7 @@ pub fn parseUrl(url: []const u8) !std.Uri { if (e != error.InvalidPort) return e; const url_without_scheme_start = std.mem.indexOf(u8, url, "://") orelse { - return error.InvalidScheme; + return error.NoSchemeProvided; }; const scheme = url[0..url_without_scheme_start]; const url_without_scheme = url[url_without_scheme_start + 3 ..]; From 73e5f7e5d6d0ce3bfe27a1d791585764cb17c536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Sat, 7 Jun 2025 22:12:26 -0400 Subject: [PATCH 017/110] merge std.Uri.ParseError and os/hostname error sets --- src/os/hostname.zig | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/os/hostname.zig b/src/os/hostname.zig index 2cdba5763..05f857b82 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -7,12 +7,9 @@ pub const HostnameParsingError = error{ NoSpaceLeft, }; -pub const UrlParsingError = error{ +pub const UrlParsingError = std.Uri.ParseError || error{ HostnameIsNotMacAddress, - InvalidFormat, - InvalidPort, NoSchemeProvided, - UnexpectedCharacter, }; fn isUriPathSeparator(c: u8) bool { From 6ed94b00346a402d85ac83a9585de17ced93f351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Sat, 7 Jun 2025 22:17:01 -0400 Subject: [PATCH 018/110] move mac address length constant to file-level scope --- src/os/hostname.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/os/hostname.zig b/src/os/hostname.zig index 05f857b82..3f2c53b50 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -12,6 +12,8 @@ pub const UrlParsingError = std.Uri.ParseError || error{ NoSchemeProvided, }; +const mac_address_length = 17; + fn isUriPathSeparator(c: u8) bool { return switch (c) { '?', '#' => true, @@ -65,7 +67,6 @@ pub fn parseUrl(url: []const u8) UrlParsingError!std.Uri { // The first '/' after the scheme marks the end of the hostname. If the first '/' // following the end of the scheme is not at the right position this is not a // valid mac address. - const mac_address_length = 17; if (url_without_scheme.len != mac_address_length and std.mem.indexOfScalarPos(u8, url_without_scheme, 0, '/') != mac_address_length) { From 31e386afa6e4874be266ea083c773ffac9e6168e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Tue, 10 Jun 2025 22:03:33 -0400 Subject: [PATCH 019/110] use else if instead of else { if } --- src/os/hostname.zig | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/os/hostname.zig b/src/os/hostname.zig index 3f2c53b50..ddcdeb59e 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -32,10 +32,8 @@ fn isValidMacAddress(mac_address: []const u8) bool { if (c != ':') { return false; } - } else { - if (!std.mem.containsAtLeastScalar(u8, "0123456789ABCDEFabcdef", 1, c)) { - return false; - } + } else if (!std.mem.containsAtLeastScalar(u8, "0123456789ABCDEFabcdef", 1, c)) { + return false; } } From 8824d11e1c8d0a3cbafeb0e88dd1b6dd7fa645d0 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 24 May 2025 17:01:06 -0500 Subject: [PATCH 020/110] linux: add dbus and systemd activation services --- dist/linux/app.desktop | 5 ++++- dist/linux/dbus.service | 4 ++++ dist/linux/systemd.service | 7 +++++++ nix/package.nix | 5 +++++ src/apprt/gtk/App.zig | 23 ++++++++++++++++++++--- src/apprt/gtk/Surface.zig | 9 +++++++++ src/build/GhosttyResources.zig | 10 ++++++++++ 7 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 dist/linux/dbus.service create mode 100644 dist/linux/systemd.service diff --git a/dist/linux/app.desktop b/dist/linux/app.desktop index 6e464ea87..bb25eec65 100644 --- a/dist/linux/app.desktop +++ b/dist/linux/app.desktop @@ -1,8 +1,10 @@ [Desktop Entry] +Version=1.0 Name=Ghostty Type=Application Comment=A terminal emulator -Exec=ghostty +TryExec=ghostty +Exec=ghostty %F Icon=com.mitchellh.ghostty Categories=System;TerminalEmulator; Keywords=terminal;tty;pty; @@ -16,6 +18,7 @@ X-TerminalArgTitle=--title= X-TerminalArgAppId=--class= X-TerminalArgDir=--working-directory= X-TerminalArgHold=--wait-after-command +DBusActivatable=true [Desktop Action new-window] Name=New Window diff --git a/dist/linux/dbus.service b/dist/linux/dbus.service new file mode 100644 index 000000000..4d508d168 --- /dev/null +++ b/dist/linux/dbus.service @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=com.mitchellh.ghostty +SystemdService=com.mitchellh.ghostty.service +Exec=ghostty diff --git a/dist/linux/systemd.service b/dist/linux/systemd.service new file mode 100644 index 000000000..dcc354eff --- /dev/null +++ b/dist/linux/systemd.service @@ -0,0 +1,7 @@ +[Unit] +Description=Ghostty + +[Service] +Type=dbus +BusName=com.mitchellh.ghostty +ExecStart=ghostty diff --git a/nix/package.nix b/nix/package.nix index 08dfd710b..9b793bc4b 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -117,6 +117,11 @@ in mkdir -p "$out/nix-support" + sed -i -e "s@^Exec=.*ghostty@Exec=$out/bin/ghostty@" $out/share/applications/com.mitchellh.ghostty.desktop + sed -i -e "s@^TryExec=.*ghostty@TryExec=$out/bin/ghostty@" $out/share/applications/com.mitchellh.ghostty.desktop + sed -i -e "s@^Exec=.*ghostty@Exec=$out/bin/ghostty@" $out/share/dbus-1/services/com.mitchellh.ghostty.service + sed -i -e "s@^ExecStart=.*ghostty@ExecStart=$out/bin/ghostty@" $out/lib/systemd/user/com.mitchellh.ghostty.service + mkdir -p "$terminfo/share" mv "$terminfo_src" "$terminfo/share/terminfo" ln -sf "$terminfo/share/terminfo" "$terminfo_src" diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 099a051a4..f431c0594 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -400,11 +400,15 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // This just calls the `activate` signal but its part of the normal startup // routine so we just call it, but only if the config allows it (this allows // for launching Ghostty in the "background" without immediately opening - // a window) + // a window). An initial window will not be immediately created if we were + // launched by D-Bus activation or systemd. D-Bus activation will send it's + // own `activate` or `new-window` signal later. // // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 - if (config.@"initial-window") - gio_app.activate(); + if (config.@"initial-window") switch (config.@"launched-from".?) { + .dbus, .systemd => {}, + else => gio_app.activate(), + }; // Internally, GTK ensures that only one instance of this provider exists in the provider list // for the display. @@ -1678,6 +1682,17 @@ fn gtkActionShowGTKInspector( }; } +fn gtkActionNewWindow( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *App, +) callconv(.c) void { + log.info("received new window action", .{}); + _ = self.core_app.mailbox.push(.{ + .new_window = .{}, + }, .{ .forever = {} }); +} + /// This is called to setup the action map that this application supports. /// This should be called only once on startup. fn initActions(self: *App) void { @@ -1697,7 +1712,9 @@ fn initActions(self: *App) void { .{ "reload-config", gtkActionReloadConfig, null }, .{ "present-surface", gtkActionPresentSurface, t }, .{ "show-gtk-inspector", gtkActionShowGTKInspector, null }, + .{ "new-window", gtkActionNewWindow, null }, }; + inline for (actions) |entry| { const action = gio.SimpleAction.new(entry[0], entry[2]); defer action.unref(); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 1e5b1bfe8..6c3101c3a 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2325,6 +2325,15 @@ pub fn defaultTermioEnv(self: *Surface) !std.process.EnvMap { env.remove("GDK_DISABLE"); env.remove("GSK_RENDERER"); + // Remove some environment variables that are set when Ghostty is launched + // from a `.desktop` file, by D-Bus activation, or systemd. + env.remove("GIO_LAUNCHED_DESKTOP_FILE"); + env.remove("GIO_LAUNCHED_DESKTOP_FILE_PID"); + env.remove("DBUS_STARTER_ADDRESS"); + env.remove("DBUS_STARTER_BUS_TYPE"); + env.remove("INVOCATION_ID"); + env.remove("JOURNAL_STREAM"); + // Unset environment varies set by snaps if we're running in a snap. // This allows Ghostty to further launch additional snaps. if (env.get("SNAP")) |_| { diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 3d6b99a34..13ceeaac3 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -201,6 +201,16 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { b.path("dist/linux/app.desktop"), "share/applications/com.mitchellh.ghostty.desktop", ).step); + // DBus service for DBus activation + try steps.append(&b.addInstallFile( + b.path("dist/linux/dbus.service"), + "share/dbus-1/services/com.mitchellh.ghostty.service", + ).step); + // systemd user service + try steps.append(&b.addInstallFile( + b.path("dist/linux/systemd.service"), + "lib/systemd/user/com.mitchellh.ghostty.service", + ).step); // AppStream metainfo so that application has rich metadata within app stores try steps.append(&b.addInstallFile( From 649cca61ebb4fcfaca4fa99e988fbc2177d0a047 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 2 Jun 2025 14:37:03 -0500 Subject: [PATCH 021/110] gtk: use exhaustive switch for initial-window --- src/apprt/gtk/App.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index f431c0594..7aff9e1d2 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -406,8 +406,8 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 if (config.@"initial-window") switch (config.@"launched-from".?) { + .desktop, .cli => gio_app.activate(), .dbus, .systemd => {}, - else => gio_app.activate(), }; // Internally, GTK ensures that only one instance of this provider exists in the provider list From 57392dfcb5509f6960dc0e5055a242a87da7be34 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 2 Jun 2025 14:38:58 -0500 Subject: [PATCH 022/110] linux: use explicit launched-from config in service files --- dist/linux/app.desktop | 2 +- dist/linux/dbus.service | 2 +- dist/linux/systemd.service | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dist/linux/app.desktop b/dist/linux/app.desktop index bb25eec65..b3f2d0d66 100644 --- a/dist/linux/app.desktop +++ b/dist/linux/app.desktop @@ -4,7 +4,7 @@ Name=Ghostty Type=Application Comment=A terminal emulator TryExec=ghostty -Exec=ghostty %F +Exec=ghostty --launched-from=desktop Icon=com.mitchellh.ghostty Categories=System;TerminalEmulator; Keywords=terminal;tty;pty; diff --git a/dist/linux/dbus.service b/dist/linux/dbus.service index 4d508d168..67a80d5dd 100644 --- a/dist/linux/dbus.service +++ b/dist/linux/dbus.service @@ -1,4 +1,4 @@ [D-BUS Service] Name=com.mitchellh.ghostty SystemdService=com.mitchellh.ghostty.service -Exec=ghostty +Exec=ghostty --launched-from=dbus diff --git a/dist/linux/systemd.service b/dist/linux/systemd.service index dcc354eff..9699dccdf 100644 --- a/dist/linux/systemd.service +++ b/dist/linux/systemd.service @@ -4,4 +4,4 @@ Description=Ghostty [Service] Type=dbus BusName=com.mitchellh.ghostty -ExecStart=ghostty +ExecStart=ghostty --launched-from=systemd From e5c737a423ef373aa94762476445ac9071fad989 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 2 Jun 2025 15:24:32 -0500 Subject: [PATCH 023/110] linux: use launched-from for new window action --- dist/linux/app.desktop | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dist/linux/app.desktop b/dist/linux/app.desktop index b3f2d0d66..4475617f9 100644 --- a/dist/linux/app.desktop +++ b/dist/linux/app.desktop @@ -22,4 +22,4 @@ DBusActivatable=true [Desktop Action new-window] Name=New Window -Exec=ghostty +Exec=ghostty --launched-from=desktop From c1d04a61759d04f6adcff22bad3455d095b96c7c Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 2 Jun 2025 15:42:22 -0500 Subject: [PATCH 024/110] gtk: document effect of changing the class on launching Ghostty --- src/config/Config.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 2df66ba45..e4222583b 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -894,12 +894,17 @@ title: ?[:0]const u8 = null, /// The setting that will change the application class value. /// /// This controls the class field of the `WM_CLASS` X11 property (when running -/// under X11), and the Wayland application ID (when running under Wayland). +/// under X11), the Wayland application ID (when running under Wayland), and the +/// bus name that Ghostty uses to connect to DBus. /// /// Note that changing this value between invocations will create new, separate /// instances, of Ghostty when running with `gtk-single-instance=true`. See that /// option for more details. /// +/// Changing this value may break launching Ghostty from `.desktop` files, via +/// DBus activation, or systemd user services as the system is expecting Ghostty +/// to connect to DBus using the default `class` when it is launched. +/// /// The class name must follow the requirements defined [in the GTK /// documentation](https://docs.gtk.org/gio/type_func.Application.id_is_valid.html). /// From 2f33eee166d68f3775387df14e752244c8626bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristo=CC=81fer=20R?= Date: Sat, 14 Jun 2025 16:26:03 -0400 Subject: [PATCH 025/110] fix comptime if statement --- src/os/hostname.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/os/hostname.zig b/src/os/hostname.zig index ddcdeb59e..a75ca1cbb 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -48,7 +48,7 @@ pub fn parseUrl(url: []const u8) UrlParsingError!std.Uri { return std.Uri.parse(url) catch |e| { // The mac-address-as-hostname issue is specific to macOS so we just return an error if we // hit it on other platforms. - comptime if (builtin.os.tag != .macos) return e; + if (comptime builtin.os.tag != .macos) return e; // It's possible this is a mac address on macOS where the last 2 characters in the // address are non-digits, e.g. 'ff', and thus an invalid port. From 84b1984f08b8aa9bb5c2b0ad16c57e1ba010b5a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aindri=C3=BA=20Mac=20Giolla=20Eoin?= Date: Sun, 22 Jun 2025 19:45:02 +0100 Subject: [PATCH 026/110] Added Irish translation --- po/ga_IE.UTF-8.po | 286 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 po/ga_IE.UTF-8.po diff --git a/po/ga_IE.UTF-8.po b/po/ga_IE.UTF-8.po new file mode 100644 index 000000000..c7123194f --- /dev/null +++ b/po/ga_IE.UTF-8.po @@ -0,0 +1,286 @@ +# Irish translations for com.mitchellh.ghostty package. +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors +# This file is distributed under the same license as the com.mitchellh.ghostty package. +# Aindriú Mac Giolla Eoin , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: com.mitchellh.ghostty\n" +"Report-Msgid-Bugs-To: m@mitchellh.com\n" +"POT-Creation-Date: 2025-04-23 16:58+0800\n" +"PO-Revision-Date: 2025-06-22 19:43+0100\n" +"Last-Translator: Aindriú Mac Giolla Eoin \n" +"Language-Team: Irish \n" +"Language: ga\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n==2 ? 1 : 2;\n" +"X-Generator: Poedit 3.4.2\n" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:5 +msgid "Change Terminal Title" +msgstr "Athraigh Teideal Teirminéil" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6 +msgid "Leave blank to restore the default title." +msgstr "Fág bán chun an teideal réamhshocraithe a athbhunú." + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/CloseDialog.zig:44 +msgid "Cancel" +msgstr "Cealaigh" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:10 +msgid "OK" +msgstr "Ceart go leor" + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:5 +msgid "Configuration Errors" +msgstr "Earráidí Cumraíochta" + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:6 +msgid "" +"One or more configuration errors were found. Please review the errors below, " +"and either reload your configuration or ignore these errors." +msgstr "" +"Fuarthas earráid chumraíochta amháin nó níos mó. Athbhreithnigh na hearráidí " +"thíos, agus athlódáil do chumraíocht nó déan neamhaird de na hearráidí seo." + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9 +msgid "Ignore" +msgstr "Déan neamhaird de" + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:100 +msgid "Reload Configuration" +msgstr "Athlódáil Cumraíocht" + +#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:6 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:38 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:50 +msgid "Split Up" +msgstr "Scoilt Suas" + +#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:11 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:43 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:55 +msgid "Split Down" +msgstr "Scoilt Síos" + +#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:16 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:48 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:60 +msgid "Split Left" +msgstr "Scoilt ar Chlé" + +#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:21 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:53 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:65 +msgid "Split Right" +msgstr "Scoilt ar Dheis" + +#: src/apprt/gtk/ui/1.5/command-palette.blp:16 +msgid "Execute a command…" +msgstr "Rith ordú…" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 +msgid "Copy" +msgstr "Cóipeáil" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:11 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:11 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:11 +msgid "Paste" +msgstr "Greamaigh" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:18 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:73 +msgid "Clear" +msgstr "Glan" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:23 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:78 +msgid "Reset" +msgstr "Athshocraigh" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:30 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:42 +msgid "Split" +msgstr "Scoilt" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:33 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:45 +msgid "Change Title…" +msgstr "Athraigh Teideal…" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:59 +msgid "Tab" +msgstr "Cluaisín" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:62 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30 +#: src/apprt/gtk/Window.zig:255 +msgid "New Tab" +msgstr "Cluaisín Nua" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:67 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:35 +msgid "Close Tab" +msgstr "Dún Cluaisín" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:73 +msgid "Window" +msgstr "Fuinneog" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:76 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:18 +msgid "New Window" +msgstr "Fuinneog Nua" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:81 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:23 +msgid "Close Window" +msgstr "Dún Fuinneog" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:89 +msgid "Config" +msgstr "Cumraíocht" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:92 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95 +msgid "Open Configuration" +msgstr "Oscail Cumraíocht" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 +msgid "Command Palette" +msgstr "Pailéad Ordú" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 +msgid "Terminal Inspector" +msgstr "Cigire Teirminéil" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 +#: src/apprt/gtk/Window.zig:1024 +msgid "About Ghostty" +msgstr "Maidir le Ghostty" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:112 +msgid "Quit" +msgstr "Scoir" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6 +msgid "Authorize Clipboard Access" +msgstr "Údarú Rochtain ar an nGearrthaisce" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7 +msgid "" +"An application is attempting to read from the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Tá feidhmchlár ag iarraidh léamh ón ngearrthaisce. Taispeántar ábhar reatha " +"an ghearrthaisce thíos." + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:10 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:10 +msgid "Deny" +msgstr "Diúltaigh" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:11 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:11 +msgid "Allow" +msgstr "Ceadaigh" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 +msgid "" +"An application is attempting to write to the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Tá feidhmchlár ag iarraidh scríobh chuig an ngearrthaisce. Taispeántar ábhar " +"reatha an ghearrthaisce thíos." + +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 +msgid "Warning: Potentially Unsafe Paste" +msgstr "Rabhadh: Greamaigh a d'fhéadfadh a bheith neamhshábháilte" + +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 +msgid "" +"Pasting this text into the terminal may be dangerous as it looks like some " +"commands may be executed." +msgstr "" +"D’fhéadfadh sé a bheith contúirteach an téacs seo a ghreamú isteach sa " +"chríochfort mar is cosúil go bhféadfaí roinnt orduithe a fhorghníomhú." + +#: src/apprt/gtk/Window.zig:208 +msgid "Main Menu" +msgstr "Príomh-Roghchlár" + +#: src/apprt/gtk/Window.zig:229 +msgid "View Open Tabs" +msgstr "Féach ar na Cluaisíní Oscailte" + +#: src/apprt/gtk/Window.zig:256 +msgid "New Split" +msgstr "Scoilt Nua" + +#: src/apprt/gtk/Window.zig:319 +msgid "" +"⚠️ You're running a debug build of Ghostty! Performance will be degraded." +msgstr "" +"⚠️ Tá leagan dífhabhtaithe de Ghostty á rith agat! Laghdófar an fheidhmíocht." + +#: src/apprt/gtk/Window.zig:765 +msgid "Reloaded the configuration" +msgstr "Athluchtaigh an chumraíocht" + +#: src/apprt/gtk/Window.zig:1005 +msgid "Ghostty Developers" +msgstr "Forbróirí Ghostty" + +#: src/apprt/gtk/inspector.zig:144 +msgid "Ghostty: Terminal Inspector" +msgstr "Ghostty: Cigire Teirminéil" + +#: src/apprt/gtk/CloseDialog.zig:47 +msgid "Close" +msgstr "Dún" + +#: src/apprt/gtk/CloseDialog.zig:87 +msgid "Quit Ghostty?" +msgstr "Scoir Ghostty?" + +#: src/apprt/gtk/CloseDialog.zig:88 +msgid "Close Window?" +msgstr "Dún Fuinneog?" + +#: src/apprt/gtk/CloseDialog.zig:89 +msgid "Close Tab?" +msgstr "Dún Cluaisín?" + +#: src/apprt/gtk/CloseDialog.zig:90 +msgid "Close Split?" +msgstr "Dún an Scoilt?" + +#: src/apprt/gtk/CloseDialog.zig:96 +msgid "All terminal sessions will be terminated." +msgstr "Cuirfear deireadh le gach seisiún teirminéil." + +#: src/apprt/gtk/CloseDialog.zig:97 +msgid "All terminal sessions in this window will be terminated." +msgstr "Cuirfear deireadh le gach seisiún teirminéil san fhuinneog seo." + +#: src/apprt/gtk/CloseDialog.zig:98 +msgid "All terminal sessions in this tab will be terminated." +msgstr "Cuirfear deireadh le gach seisiún críochfoirt sa chluaisín seo." + +#: src/apprt/gtk/CloseDialog.zig:99 +msgid "The currently running process in this split will be terminated." +msgstr "" +"Cuirfear deireadh leis an bpróiseas atá ar siúl faoi láthair sa scoilt seo." + +#: src/apprt/gtk/Surface.zig:1243 +msgid "Copied to clipboard" +msgstr "Cóipeáilte chuig an ghearrthaisce" From 7ca9cd1994c4398cc84abf8b8226c2f9bcd9a41d Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 22 Jun 2025 17:05:47 -0600 Subject: [PATCH 027/110] docs: document uniforms available to custom shaders --- src/config/Config.zig | 56 +++++++++++++++++++++++++++++++++++++--- src/renderer/generic.zig | 2 +- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index cdc156032..f4f7e7964 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1967,9 +1967,59 @@ keybind: Keybinds = .{}, /// causing the window to be completely black. If this happens, you can /// unset this configuration to disable the shader. /// -/// The shader API is identical to the Shadertoy API: you specify a `mainImage` -/// function and the available uniforms match Shadertoy. The iChannel0 uniform -/// is a texture containing the rendered terminal screen. +/// Custom shader support is based on and compatible with the Shadertoy shaders. +/// Shaders should specify a `mainImage` function and the available uniforms +/// largely match Shadertoy, with some caveats and Ghostty-specific extensions. +/// +/// The uniform values available to shaders are as follows: +/// +/// * `sampler2D iChannel0` - Input texture. +/// +/// A texture containing the current terminal screen. If multiple custom +/// shaders are specified, the output of previous shaders is written to +/// this texture, to allow combining multiple effects. +/// +/// * `vec3 iResolution` - Output texture size, `[width, height, 1]` (in px). +/// +/// * `float iTime` - Time in seconds since first frame was rendered. +/// +/// * `float iTimeDelta` - Time in seconds since previous frame was rendered. +/// +/// * `float iFrameRate` - Average framerate. (NOT CURRENTLY SUPPORTED) +/// +/// * `int iFrame` - Number of frames that have been rendered so far. +/// +/// * `float iChannelTime[4]` - Current time for video or sound input. (N/A) +/// +/// * `vec3 iChannelResolution[4]` - Resolutions of the 4 input samplers. +/// +/// Currently only `iChannel0` exists, and `iChannelResolution[0]` is +/// identical to `iResolution`. +/// +/// * `vec4 iMouse` - Mouse input info. (NOT CURRENTLY SUPPORTED) +/// +/// * `vec4 iDate` - Date/time info. (NOT CURRENTLY SUPPORTED) +/// +/// * `float iSampleRate` - Sample rate for audio. (N/A) +/// +/// Ghostty-specific extensions: +/// +/// * `vec4 iCurrentCursor` - Info about the terminal cursor. +/// +/// - `iCurrentCursor.xy` is the -X, +Y corner of the current cursor. +/// - `iCurrentCursor.zw` is the width and height of the current cursor. +/// +/// * `vec4 iPreviousCursor` - Info about the previous terminal cursor. +/// +/// * `vec4 iCurrentCursorColor` - Color of the terminal cursor. +/// +/// * `vec4 iPreviousCursorColor` - Color of the previous terminal cursor. +/// +/// * `float iTimeCursorChange` - Timestamp of terminal cursor change. +/// +/// When the terminal cursor changes position or color, this is set to +/// the same time as the `iTime` uniform, allowing you to compute the +/// time since the change by subtracting this from `iTime`. /// /// If the shader fails to compile, the shader will be ignored. Any errors /// related to shader compilation will not show up as configuration errors diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index c0091cbf6..aaa9351db 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -670,7 +670,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .time_delta = 0, .frame_rate = 60, // not currently updated .frame = 0, - .channel_time = @splat(@splat(0)), + .channel_time = @splat(@splat(0)), // not currently updated .channel_resolution = @splat(@splat(0)), .mouse = @splat(0), // not currently updated .date = @splat(0), // not currently updated From 72fb87b20e50fc68ce00c60498fcdb2c6e6eabe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aindri=C3=BA=20Mac=20Giolla=20Eoin?= Date: Mon, 23 Jun 2025 09:27:22 +0100 Subject: [PATCH 028/110] Updated Irish to sentence case, updated struct, added ga_IE to supported locales --- CODEOWNERS | 1 + po/ga_IE.UTF-8.po | 46 +++++++++++++++++++++++----------------------- src/os/i18n.zig | 1 + 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 3d8a4da3d..c0d802621 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -173,6 +173,7 @@ /po/tr_TR.UTF-8.po @ghostty-org/tr_TR /po/uk_UA.UTF-8.po @ghostty-org/uk_UA /po/zh_CN.UTF-8.po @ghostty-org/zh_CN +/po/ga_IE.UTF-8.po @ghostty-org/ga_IE # Packaging - Snap /snap/ @ghostty-org/snap diff --git a/po/ga_IE.UTF-8.po b/po/ga_IE.UTF-8.po index c7123194f..1f1740d09 100644 --- a/po/ga_IE.UTF-8.po +++ b/po/ga_IE.UTF-8.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2025-04-23 16:58+0800\n" -"PO-Revision-Date: 2025-06-22 19:43+0100\n" +"PO-Revision-Date: 2025-06-23 09:00+0100\n" "Last-Translator: Aindriú Mac Giolla Eoin \n" "Language-Team: Irish \n" "Language: ga\n" @@ -20,7 +20,7 @@ msgstr "" #: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:5 msgid "Change Terminal Title" -msgstr "Athraigh Teideal Teirminéil" +msgstr "Athraigh teideal teirminéil" #: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6 msgid "Leave blank to restore the default title." @@ -37,7 +37,7 @@ msgstr "Ceart go leor" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:5 msgid "Configuration Errors" -msgstr "Earráidí Cumraíochta" +msgstr "Earráidí cumraíochta" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:6 msgid "" @@ -55,31 +55,31 @@ msgstr "Déan neamhaird de" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:100 msgid "Reload Configuration" -msgstr "Athlódáil Cumraíocht" +msgstr "Athlódáil cumraíocht" #: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:38 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:50 msgid "Split Up" -msgstr "Scoilt Suas" +msgstr "Scoilt suas" #: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:11 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:43 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:55 msgid "Split Down" -msgstr "Scoilt Síos" +msgstr "Scoilt síos" #: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:16 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:48 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:60 msgid "Split Left" -msgstr "Scoilt ar Chlé" +msgstr "Scoilt ar chlé" #: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:21 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:53 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:65 msgid "Split Right" -msgstr "Scoilt ar Dheis" +msgstr "Scoilt ar dheis" #: src/apprt/gtk/ui/1.5/command-palette.blp:16 msgid "Execute a command…" @@ -114,7 +114,7 @@ msgstr "Scoilt" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:33 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:45 msgid "Change Title…" -msgstr "Athraigh Teideal…" +msgstr "Athraigh teideal…" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:59 msgid "Tab" @@ -124,12 +124,12 @@ msgstr "Cluaisín" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30 #: src/apprt/gtk/Window.zig:255 msgid "New Tab" -msgstr "Cluaisín Nua" +msgstr "Cluaisín nua" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:67 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:35 msgid "Close Tab" -msgstr "Dún Cluaisín" +msgstr "Dún cluaisín" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:73 msgid "Window" @@ -138,12 +138,12 @@ msgstr "Fuinneog" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:76 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:18 msgid "New Window" -msgstr "Fuinneog Nua" +msgstr "Fuinneog nua" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:81 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:23 msgid "Close Window" -msgstr "Dún Fuinneog" +msgstr "Dún fuinneog" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:89 msgid "Config" @@ -152,15 +152,15 @@ msgstr "Cumraíocht" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:92 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95 msgid "Open Configuration" -msgstr "Oscail Cumraíocht" +msgstr "Oscail cumraíocht" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 msgid "Command Palette" -msgstr "Pailéad Ordú" +msgstr "Pailéad ordú" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 msgid "Terminal Inspector" -msgstr "Cigire Teirminéil" +msgstr "Cigire teirminéil" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 #: src/apprt/gtk/Window.zig:1024 @@ -174,7 +174,7 @@ msgstr "Scoir" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6 msgid "Authorize Clipboard Access" -msgstr "Údarú Rochtain ar an nGearrthaisce" +msgstr "Údarú rochtain ar an ngearrthaisce" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7 msgid "" @@ -220,11 +220,11 @@ msgstr "Príomh-Roghchlár" #: src/apprt/gtk/Window.zig:229 msgid "View Open Tabs" -msgstr "Féach ar na Cluaisíní Oscailte" +msgstr "Féach ar na cluaisíní oscailte" #: src/apprt/gtk/Window.zig:256 msgid "New Split" -msgstr "Scoilt Nua" +msgstr "Scoilt nua" #: src/apprt/gtk/Window.zig:319 msgid "" @@ -242,7 +242,7 @@ msgstr "Forbróirí Ghostty" #: src/apprt/gtk/inspector.zig:144 msgid "Ghostty: Terminal Inspector" -msgstr "Ghostty: Cigire Teirminéil" +msgstr "Ghostty: Cigire teirminéil" #: src/apprt/gtk/CloseDialog.zig:47 msgid "Close" @@ -254,15 +254,15 @@ msgstr "Scoir Ghostty?" #: src/apprt/gtk/CloseDialog.zig:88 msgid "Close Window?" -msgstr "Dún Fuinneog?" +msgstr "Dún fuinneog?" #: src/apprt/gtk/CloseDialog.zig:89 msgid "Close Tab?" -msgstr "Dún Cluaisín?" +msgstr "Dún cluaisín?" #: src/apprt/gtk/CloseDialog.zig:90 msgid "Close Split?" -msgstr "Dún an Scoilt?" +msgstr "Dún an scoilt?" #: src/apprt/gtk/CloseDialog.zig:96 msgid "All terminal sessions will be terminated." diff --git a/src/os/i18n.zig b/src/os/i18n.zig index c5fca6a78..26c032df0 100644 --- a/src/os/i18n.zig +++ b/src/os/i18n.zig @@ -45,6 +45,7 @@ pub const locales = [_][:0]const u8{ "es_BO.UTF-8", "pt_BR.UTF-8", "ca_ES.UTF-8", + "ga_IE.UTF-8", }; /// Set for faster membership lookup of locales. From 4b01cc1d8880c69cd9db06568be5c5fa55893660 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Jun 2025 12:21:30 -0600 Subject: [PATCH 029/110] renderer: unify `cell.zig` The code in metal/cell.zig and opengl/cell.zig was virtually identical aside from the types for the cell data, moved it to renderer/cell.zig --- src/renderer/Metal.zig | 1 - src/renderer/OpenGL.zig | 1 - src/renderer/cell.zig | 372 ++++++++++++++++++++++++++++++++++- src/renderer/generic.zig | 10 +- src/renderer/metal/cell.zig | 358 --------------------------------- src/renderer/opengl/cell.zig | 220 --------------------- 6 files changed, 377 insertions(+), 585 deletions(-) delete mode 100644 src/renderer/metal/cell.zig delete mode 100644 src/renderer/opengl/cell.zig diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index a001ca08d..0f8d642d0 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -29,7 +29,6 @@ pub const Buffer = bufferpkg.Buffer; pub const Texture = @import("metal/Texture.zig"); pub const shaders = @import("metal/shaders.zig"); -pub const cellpkg = @import("metal/cell.zig"); pub const imagepkg = @import("metal/image.zig"); pub const custom_shader_target: shadertoy.Target = .msl; diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index dcc295eaf..cb02a0d75 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -24,7 +24,6 @@ pub const Buffer = bufferpkg.Buffer; pub const Texture = @import("opengl/Texture.zig"); pub const shaders = @import("opengl/shaders.zig"); -pub const cellpkg = @import("opengl/cell.zig"); pub const imagepkg = @import("opengl/image.zig"); pub const custom_shader_target: shadertoy.Target = .glsl; diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index c84fbcc6f..043a25b4e 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -1,6 +1,238 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; const ziglyph = @import("ziglyph"); const font = @import("../font/main.zig"); const terminal = @import("../terminal/main.zig"); +const renderer = @import("../renderer.zig"); +const shaderpkg = renderer.Renderer.API.shaders; + +/// The possible cell content keys that exist. +pub const Key = enum { + bg, + text, + underline, + strikethrough, + overline, + + /// Returns the GPU vertex type for this key. + pub fn CellType(self: Key) type { + return switch (self) { + .bg => shaderpkg.CellBg, + + .text, + .underline, + .strikethrough, + .overline, + => shaderpkg.CellText, + }; + } +}; + +/// A pool of ArrayLists with methods for bulk operations. +fn ArrayListPool(comptime T: type) type { + return struct { + const Self = ArrayListPool(T); + const ArrayListT = std.ArrayListUnmanaged(T); + + // An array containing the lists that belong to this pool. + lists: []ArrayListT = &[_]ArrayListT{}, + + // The pool will be initialized with empty ArrayLists. + pub fn init( + alloc: Allocator, + list_count: usize, + initial_capacity: usize, + ) Allocator.Error!Self { + const self: Self = .{ + .lists = try alloc.alloc(ArrayListT, list_count), + }; + + for (self.lists) |*list| { + list.* = try .initCapacity(alloc, initial_capacity); + } + + return self; + } + + pub fn deinit(self: *Self, alloc: Allocator) void { + for (self.lists) |*list| { + list.deinit(alloc); + } + alloc.free(self.lists); + } + + /// Clear all lists in the pool. + pub fn reset(self: *Self) void { + for (self.lists) |*list| { + list.clearRetainingCapacity(); + } + } + }; +} + +/// The contents of all the cells in the terminal. +/// +/// The goal of this data structure is to allow for efficient row-wise +/// clearing of data from the GPU buffers, to allow for row-wise dirty +/// tracking to eliminate the overhead of rebuilding the GPU buffers +/// each frame. +/// +/// Must be initialized by resizing before calling any operations. +pub const Contents = struct { + size: renderer.GridSize = .{ .rows = 0, .columns = 0 }, + + /// Flat array containing cell background colors for the terminal grid. + /// + /// Indexed as `bg_cells[row * size.columns + col]`. + /// + /// Prefer accessing with `Contents.bgCell(row, col).*` instead + /// of directly indexing in order to avoid integer size bugs. + bg_cells: []shaderpkg.CellBg = undefined, + + /// The ArrayListPool which holds all of the foreground cells. When sized + /// with Contents.resize the individual ArrayLists are given enough room + /// that they can hold a single row with #cols glyphs, underlines, and + /// strikethroughs; however, appendAssumeCapacity MUST NOT be used since + /// it is possible to exceed this with combining glyphs that add a glyph + /// but take up no column since they combine with the previous one, as + /// well as with fonts that perform multi-substitutions for glyphs, which + /// can result in a similar situation where multiple glyphs reside in the + /// same column. + /// + /// Allocations should nevertheless be exceedingly rare since hitting the + /// initial capacity of a list would require a row filled with underlined + /// struck through characters, at least one of which is a multi-glyph + /// composite. + /// + /// Rows are indexed as Contents.fg_rows[y + 1], because the first list in + /// the pool is reserved for the cursor, which must be the first item in + /// the buffer. + /// + /// Must be initialized by calling resize on the Contents struct before + /// calling any operations. + fg_rows: ArrayListPool(shaderpkg.CellText) = .{}, + + pub fn deinit(self: *Contents, alloc: Allocator) void { + alloc.free(self.bg_cells); + self.fg_rows.deinit(alloc); + } + + /// Resize the cell contents for the given grid size. This will + /// always invalidate the entire cell contents. + pub fn resize( + self: *Contents, + alloc: Allocator, + size: renderer.GridSize, + ) Allocator.Error!void { + self.size = size; + + const cell_count = @as(usize, size.columns) * @as(usize, size.rows); + + const bg_cells = try alloc.alloc(shaderpkg.CellBg, cell_count); + errdefer alloc.free(bg_cells); + + @memset(bg_cells, .{ 0, 0, 0, 0 }); + + // The foreground lists can hold 3 types of items: + // - Glyphs + // - Underlines + // - Strikethroughs + // So we give them an initial capacity of size.columns * 3, which will + // avoid any further allocations in the vast majority of cases. Sadly + // we can not assume capacity though, since with combining glyphs that + // form a single grapheme, and multi-substitutions in fonts, the number + // of glyphs in a row is theoretically unlimited. + // + // We have size.rows + 1 lists because index 0 is used for a special + // list containing the cursor cell which needs to be first in the buffer. + var fg_rows = try ArrayListPool(shaderpkg.CellText).init( + alloc, + size.rows + 1, + size.columns * 3, + ); + errdefer fg_rows.deinit(alloc); + + alloc.free(self.bg_cells); + self.fg_rows.deinit(alloc); + + self.bg_cells = bg_cells; + self.fg_rows = fg_rows; + + // We don't need 3*cols worth of cells for the cursor list, so we can + // replace it with a smaller list. This is technically a tiny bit of + // extra work but resize is not a hot function so it's worth it to not + // waste the memory. + self.fg_rows.lists[0].deinit(alloc); + self.fg_rows.lists[0] = try std.ArrayListUnmanaged( + shaderpkg.CellText, + ).initCapacity(alloc, 1); + } + + /// Reset the cell contents to an empty state without resizing. + pub fn reset(self: *Contents) void { + @memset(self.bg_cells, .{ 0, 0, 0, 0 }); + self.fg_rows.reset(); + } + + /// Set the cursor value. If the value is null then the cursor is hidden. + pub fn setCursor(self: *Contents, v: ?shaderpkg.CellText) void { + self.fg_rows.lists[0].clearRetainingCapacity(); + + if (v) |cell| { + self.fg_rows.lists[0].appendAssumeCapacity(cell); + } + } + + /// Access a background cell. Prefer this function over direct indexing + /// of `bg_cells` in order to avoid integer size bugs causing overflows. + pub inline fn bgCell( + self: *Contents, + row: usize, + col: usize, + ) *shaderpkg.CellBg { + return &self.bg_cells[row * self.size.columns + col]; + } + + /// Add a cell to the appropriate list. Adding the same cell twice will + /// result in duplication in the vertex buffer. The caller should clear + /// the corresponding row with Contents.clear to remove old cells first. + pub fn add( + self: *Contents, + alloc: Allocator, + comptime key: Key, + cell: key.CellType(), + ) Allocator.Error!void { + const y = cell.grid_pos[1]; + + assert(y < self.size.rows); + + switch (key) { + .bg => comptime unreachable, + + .text, + .underline, + .strikethrough, + .overline, + // We have a special list containing the cursor cell at the start + // of our fg row pool, so we need to add 1 to the y to get the + // correct index. + => try self.fg_rows.lists[y + 1].append(alloc, cell), + } + } + + /// Clear all of the cell contents for a given row. + pub fn clear(self: *Contents, y: terminal.size.CellCountInt) void { + assert(y < self.size.rows); + + @memset(self.bg_cells[@as(usize, y) * self.size.columns ..][0..self.size.columns], .{ 0, 0, 0, 0 }); + + // We have a special list containing the cursor cell at the start + // of our fg row pool, so we need to add 1 to the y to get the + // correct index. + self.fg_rows.lists[y + 1].clearRetainingCapacity(); + } +}; /// Returns true if a codepoint for a cell is a covering character. A covering /// character is a character that covers the entire cell. This is used to @@ -38,7 +270,7 @@ pub const FgMode = enum { pub fn fgMode( presentation: font.Presentation, cell_pin: terminal.Pin, -) !FgMode { +) FgMode { return switch (presentation) { // Emoji is always full size and color. .emoji => .color, @@ -131,3 +363,141 @@ fn isPowerline(char: u21) bool { else => false, }; } + +test Contents { + const testing = std.testing; + const alloc = testing.allocator; + + const rows = 10; + const cols = 10; + + var c: Contents = .{}; + try c.resize(alloc, .{ .rows = rows, .columns = cols }); + defer c.deinit(alloc); + + // We should start off empty after resizing. + for (0..rows) |y| { + try testing.expect(c.fg_rows.lists[y + 1].items.len == 0); + for (0..cols) |x| { + try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*); + } + } + // And the cursor row should have a capacity of 1 and also be empty. + try testing.expect(c.fg_rows.lists[0].capacity == 1); + try testing.expect(c.fg_rows.lists[0].items.len == 0); + + // Add some contents. + const bg_cell: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell: shaderpkg.CellText = .{ + .mode = .fg, + .grid_pos = .{ 4, 1 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(1, 4).* = bg_cell; + try c.add(alloc, .text, fg_cell); + try testing.expectEqual(bg_cell, c.bgCell(1, 4).*); + // The fg row index is offset by 1 because of the cursor list. + try testing.expectEqual(fg_cell, c.fg_rows.lists[2].items[0]); + + // And we should be able to clear it. + c.clear(1); + for (0..rows) |y| { + try testing.expect(c.fg_rows.lists[y + 1].items.len == 0); + for (0..cols) |x| { + try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*); + } + } + + // Add a cursor. + const cursor_cell: shaderpkg.CellText = .{ + .mode = .cursor, + .grid_pos = .{ 2, 3 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.setCursor(cursor_cell); + try testing.expectEqual(cursor_cell, c.fg_rows.lists[0].items[0]); + + // And remove it. + c.setCursor(null); + try testing.expectEqual(0, c.fg_rows.lists[0].items.len); +} + +test "Contents clear retains other content" { + const testing = std.testing; + const alloc = testing.allocator; + + const rows = 10; + const cols = 10; + + var c: Contents = .{}; + try c.resize(alloc, .{ .rows = rows, .columns = cols }); + defer c.deinit(alloc); + + // Set some contents + // bg and fg cells in row 1 + const bg_cell_1: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell_1: shaderpkg.CellText = .{ + .mode = .fg, + .grid_pos = .{ 4, 1 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(1, 4).* = bg_cell_1; + try c.add(alloc, .text, fg_cell_1); + // bg and fg cells in row 2 + const bg_cell_2: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell_2: shaderpkg.CellText = .{ + .mode = .fg, + .grid_pos = .{ 4, 2 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(2, 4).* = bg_cell_2; + try c.add(alloc, .text, fg_cell_2); + + // Clear row 1, this should leave row 2 untouched + c.clear(1); + + // Row 2 should still contain its cells. + try testing.expectEqual(bg_cell_2, c.bgCell(2, 4).*); + // Fg row index is +1 because of cursor list at start + try testing.expectEqual(fg_cell_2, c.fg_rows.lists[3].items[0]); +} + +test "Contents clear last added content" { + const testing = std.testing; + const alloc = testing.allocator; + + const rows = 10; + const cols = 10; + + var c: Contents = .{}; + try c.resize(alloc, .{ .rows = rows, .columns = cols }); + defer c.deinit(alloc); + + // Set some contents + // bg and fg cells in row 1 + const bg_cell_1: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell_1: shaderpkg.CellText = .{ + .mode = .fg, + .grid_pos = .{ 4, 1 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(1, 4).* = bg_cell_1; + try c.add(alloc, .text, fg_cell_1); + // bg and fg cells in row 2 + const bg_cell_2: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell_2: shaderpkg.CellText = .{ + .mode = .fg, + .grid_pos = .{ 4, 2 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(2, 4).* = bg_cell_2; + try c.add(alloc, .text, fg_cell_2); + + // Clear row 2, this should leave row 1 untouched + c.clear(2); + + // Row 1 should still contain its cells. + try testing.expectEqual(bg_cell_1, c.bgCell(1, 4).*); + // Fg row index is +1 because of cursor list at start + try testing.expectEqual(fg_cell_1, c.fg_rows.lists[2].items[0]); +} diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index aaa9351db..ae31b2ef1 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -11,8 +11,9 @@ const renderer = @import("../renderer.zig"); const math = @import("../math.zig"); const Surface = @import("../Surface.zig"); const link = @import("link.zig"); -const fgMode = @import("cell.zig").fgMode; -const isCovering = @import("cell.zig").isCovering; +const cellpkg = @import("cell.zig"); +const fgMode = cellpkg.fgMode; +const isCovering = cellpkg.isCovering; const shadertoy = @import("shadertoy.zig"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; @@ -71,13 +72,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { return struct { const Self = @This(); + pub const API = GraphicsAPI; + const Target = GraphicsAPI.Target; const Buffer = GraphicsAPI.Buffer; const Texture = GraphicsAPI.Texture; const RenderPass = GraphicsAPI.RenderPass; const shaderpkg = GraphicsAPI.shaders; - const cellpkg = GraphicsAPI.cellpkg; const imagepkg = GraphicsAPI.imagepkg; const Image = imagepkg.Image; const ImageMap = imagepkg.ImageMap; @@ -2769,7 +2771,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { return; } - const mode: shaderpkg.CellText.Mode = switch (try fgMode( + const mode: shaderpkg.CellText.Mode = switch (fgMode( render.presentation, cell_pin, )) { diff --git a/src/renderer/metal/cell.zig b/src/renderer/metal/cell.zig deleted file mode 100644 index e1bcb7b9f..000000000 --- a/src/renderer/metal/cell.zig +++ /dev/null @@ -1,358 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; - -const renderer = @import("../../renderer.zig"); -const terminal = @import("../../terminal/main.zig"); -const mtl_shaders = @import("shaders.zig"); - -/// The possible cell content keys that exist. -pub const Key = enum { - bg, - text, - underline, - strikethrough, - overline, - - /// Returns the GPU vertex type for this key. - pub fn CellType(self: Key) type { - return switch (self) { - .bg => mtl_shaders.CellBg, - - .text, - .underline, - .strikethrough, - .overline, - => mtl_shaders.CellText, - }; - } -}; - -/// A pool of ArrayLists with methods for bulk operations. -fn ArrayListPool(comptime T: type) type { - return struct { - const Self = ArrayListPool(T); - const ArrayListT = std.ArrayListUnmanaged(T); - - // An array containing the lists that belong to this pool. - lists: []ArrayListT = &[_]ArrayListT{}, - - // The pool will be initialized with empty ArrayLists. - pub fn init(alloc: Allocator, list_count: usize, initial_capacity: usize) !Self { - const self: Self = .{ - .lists = try alloc.alloc(ArrayListT, list_count), - }; - - for (self.lists) |*list| { - list.* = try .initCapacity(alloc, initial_capacity); - } - - return self; - } - - pub fn deinit(self: *Self, alloc: Allocator) void { - for (self.lists) |*list| { - list.deinit(alloc); - } - alloc.free(self.lists); - } - - /// Clear all lists in the pool. - pub fn reset(self: *Self) void { - for (self.lists) |*list| { - list.clearRetainingCapacity(); - } - } - }; -} - -/// The contents of all the cells in the terminal. -/// -/// The goal of this data structure is to allow for efficient row-wise -/// clearing of data from the GPU buffers, to allow for row-wise dirty -/// tracking to eliminate the overhead of rebuilding the GPU buffers -/// each frame. -/// -/// Must be initialized by resizing before calling any operations. -pub const Contents = struct { - size: renderer.GridSize = .{ .rows = 0, .columns = 0 }, - - /// Flat array containing cell background colors for the terminal grid. - /// - /// Indexed as `bg_cells[row * size.columns + col]`. - /// - /// Prefer accessing with `Contents.bgCell(row, col).*` instead - /// of directly indexing in order to avoid integer size bugs. - bg_cells: []mtl_shaders.CellBg = undefined, - - /// The ArrayListPool which holds all of the foreground cells. When sized - /// with Contents.resize the individual ArrayLists are given enough room - /// that they can hold a single row with #cols glyphs, underlines, and - /// strikethroughs; however, appendAssumeCapacity MUST NOT be used since - /// it is possible to exceed this with combining glyphs that add a glyph - /// but take up no column since they combine with the previous one, as - /// well as with fonts that perform multi-substitutions for glyphs, which - /// can result in a similar situation where multiple glyphs reside in the - /// same column. - /// - /// Allocations should nevertheless be exceedingly rare since hitting the - /// initial capacity of a list would require a row filled with underlined - /// struck through characters, at least one of which is a multi-glyph - /// composite. - /// - /// Rows are indexed as Contents.fg_rows[y + 1], because the first list in - /// the pool is reserved for the cursor, which must be the first item in - /// the buffer. - /// - /// Must be initialized by calling resize on the Contents struct before - /// calling any operations. - fg_rows: ArrayListPool(mtl_shaders.CellText) = .{}, - - pub fn deinit(self: *Contents, alloc: Allocator) void { - alloc.free(self.bg_cells); - self.fg_rows.deinit(alloc); - } - - /// Resize the cell contents for the given grid size. This will - /// always invalidate the entire cell contents. - pub fn resize( - self: *Contents, - alloc: Allocator, - size: renderer.GridSize, - ) !void { - self.size = size; - - const cell_count = @as(usize, size.columns) * @as(usize, size.rows); - - const bg_cells = try alloc.alloc(mtl_shaders.CellBg, cell_count); - errdefer alloc.free(bg_cells); - - @memset(bg_cells, .{ 0, 0, 0, 0 }); - - // The foreground lists can hold 3 types of items: - // - Glyphs - // - Underlines - // - Strikethroughs - // So we give them an initial capacity of size.columns * 3, which will - // avoid any further allocations in the vast majority of cases. Sadly - // we can not assume capacity though, since with combining glyphs that - // form a single grapheme, and multi-substitutions in fonts, the number - // of glyphs in a row is theoretically unlimited. - // - // We have size.rows + 1 lists because index 0 is used for a special - // list containing the cursor cell which needs to be first in the buffer. - var fg_rows = try ArrayListPool(mtl_shaders.CellText).init(alloc, size.rows + 1, size.columns * 3); - errdefer fg_rows.deinit(alloc); - - alloc.free(self.bg_cells); - self.fg_rows.deinit(alloc); - - self.bg_cells = bg_cells; - self.fg_rows = fg_rows; - - // We don't need 3*cols worth of cells for the cursor list, so we can - // replace it with a smaller list. This is technically a tiny bit of - // extra work but resize is not a hot function so it's worth it to not - // waste the memory. - self.fg_rows.lists[0].deinit(alloc); - self.fg_rows.lists[0] = try std.ArrayListUnmanaged(mtl_shaders.CellText).initCapacity(alloc, 1); - } - - /// Reset the cell contents to an empty state without resizing. - pub fn reset(self: *Contents) void { - @memset(self.bg_cells, .{ 0, 0, 0, 0 }); - self.fg_rows.reset(); - } - - /// Set the cursor value. If the value is null then the cursor is hidden. - pub fn setCursor(self: *Contents, v: ?mtl_shaders.CellText) void { - self.fg_rows.lists[0].clearRetainingCapacity(); - - if (v) |cell| { - self.fg_rows.lists[0].appendAssumeCapacity(cell); - } - } - - /// Access a background cell. Prefer this function over direct indexing - /// of `bg_cells` in order to avoid integer size bugs causing overflows. - pub inline fn bgCell(self: *Contents, row: usize, col: usize) *mtl_shaders.CellBg { - return &self.bg_cells[row * self.size.columns + col]; - } - - /// Add a cell to the appropriate list. Adding the same cell twice will - /// result in duplication in the vertex buffer. The caller should clear - /// the corresponding row with Contents.clear to remove old cells first. - pub fn add( - self: *Contents, - alloc: Allocator, - comptime key: Key, - cell: key.CellType(), - ) !void { - const y = cell.grid_pos[1]; - - assert(y < self.size.rows); - - switch (key) { - .bg => comptime unreachable, - - .text, - .underline, - .strikethrough, - .overline, - // We have a special list containing the cursor cell at the start - // of our fg row pool, so we need to add 1 to the y to get the - // correct index. - => try self.fg_rows.lists[y + 1].append(alloc, cell), - } - } - - /// Clear all of the cell contents for a given row. - pub fn clear(self: *Contents, y: terminal.size.CellCountInt) void { - assert(y < self.size.rows); - - @memset(self.bg_cells[@as(usize, y) * self.size.columns ..][0..self.size.columns], .{ 0, 0, 0, 0 }); - - // We have a special list containing the cursor cell at the start - // of our fg row pool, so we need to add 1 to the y to get the - // correct index. - self.fg_rows.lists[y + 1].clearRetainingCapacity(); - } -}; - -test Contents { - const testing = std.testing; - const alloc = testing.allocator; - - const rows = 10; - const cols = 10; - - var c: Contents = .{}; - try c.resize(alloc, .{ .rows = rows, .columns = cols }); - defer c.deinit(alloc); - - // We should start off empty after resizing. - for (0..rows) |y| { - try testing.expect(c.fg_rows.lists[y + 1].items.len == 0); - for (0..cols) |x| { - try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*); - } - } - // And the cursor row should have a capacity of 1 and also be empty. - try testing.expect(c.fg_rows.lists[0].capacity == 1); - try testing.expect(c.fg_rows.lists[0].items.len == 0); - - // Add some contents. - const bg_cell: mtl_shaders.CellBg = .{ 0, 0, 0, 1 }; - const fg_cell: mtl_shaders.CellText = .{ - .mode = .fg, - .grid_pos = .{ 4, 1 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.bgCell(1, 4).* = bg_cell; - try c.add(alloc, .text, fg_cell); - try testing.expectEqual(bg_cell, c.bgCell(1, 4).*); - // The fg row index is offset by 1 because of the cursor list. - try testing.expectEqual(fg_cell, c.fg_rows.lists[2].items[0]); - - // And we should be able to clear it. - c.clear(1); - for (0..rows) |y| { - try testing.expect(c.fg_rows.lists[y + 1].items.len == 0); - for (0..cols) |x| { - try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*); - } - } - - // Add a cursor. - const cursor_cell: mtl_shaders.CellText = .{ - .mode = .cursor, - .grid_pos = .{ 2, 3 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.setCursor(cursor_cell); - try testing.expectEqual(cursor_cell, c.fg_rows.lists[0].items[0]); - - // And remove it. - c.setCursor(null); - try testing.expectEqual(0, c.fg_rows.lists[0].items.len); -} - -test "Contents clear retains other content" { - const testing = std.testing; - const alloc = testing.allocator; - - const rows = 10; - const cols = 10; - - var c: Contents = .{}; - try c.resize(alloc, .{ .rows = rows, .columns = cols }); - defer c.deinit(alloc); - - // Set some contents - // bg and fg cells in row 1 - const bg_cell_1: mtl_shaders.CellBg = .{ 0, 0, 0, 1 }; - const fg_cell_1: mtl_shaders.CellText = .{ - .mode = .fg, - .grid_pos = .{ 4, 1 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.bgCell(1, 4).* = bg_cell_1; - try c.add(alloc, .text, fg_cell_1); - // bg and fg cells in row 2 - const bg_cell_2: mtl_shaders.CellBg = .{ 0, 0, 0, 1 }; - const fg_cell_2: mtl_shaders.CellText = .{ - .mode = .fg, - .grid_pos = .{ 4, 2 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.bgCell(2, 4).* = bg_cell_2; - try c.add(alloc, .text, fg_cell_2); - - // Clear row 1, this should leave row 2 untouched - c.clear(1); - - // Row 2 should still contain its cells. - try testing.expectEqual(bg_cell_2, c.bgCell(2, 4).*); - // Fg row index is +1 because of cursor list at start - try testing.expectEqual(fg_cell_2, c.fg_rows.lists[3].items[0]); -} - -test "Contents clear last added content" { - const testing = std.testing; - const alloc = testing.allocator; - - const rows = 10; - const cols = 10; - - var c: Contents = .{}; - try c.resize(alloc, .{ .rows = rows, .columns = cols }); - defer c.deinit(alloc); - - // Set some contents - // bg and fg cells in row 1 - const bg_cell_1: mtl_shaders.CellBg = .{ 0, 0, 0, 1 }; - const fg_cell_1: mtl_shaders.CellText = .{ - .mode = .fg, - .grid_pos = .{ 4, 1 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.bgCell(1, 4).* = bg_cell_1; - try c.add(alloc, .text, fg_cell_1); - // bg and fg cells in row 2 - const bg_cell_2: mtl_shaders.CellBg = .{ 0, 0, 0, 1 }; - const fg_cell_2: mtl_shaders.CellText = .{ - .mode = .fg, - .grid_pos = .{ 4, 2 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.bgCell(2, 4).* = bg_cell_2; - try c.add(alloc, .text, fg_cell_2); - - // Clear row 2, this should leave row 1 untouched - c.clear(2); - - // Row 1 should still contain its cells. - try testing.expectEqual(bg_cell_1, c.bgCell(1, 4).*); - // Fg row index is +1 because of cursor list at start - try testing.expectEqual(fg_cell_1, c.fg_rows.lists[2].items[0]); -} diff --git a/src/renderer/opengl/cell.zig b/src/renderer/opengl/cell.zig deleted file mode 100644 index abdbaa0e8..000000000 --- a/src/renderer/opengl/cell.zig +++ /dev/null @@ -1,220 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; - -const renderer = @import("../../renderer.zig"); -const terminal = @import("../../terminal/main.zig"); -const shaderpkg = @import("shaders.zig"); - -/// The possible cell content keys that exist. -pub const Key = enum { - bg, - text, - underline, - strikethrough, - overline, - - /// Returns the GPU vertex type for this key. - pub fn CellType(self: Key) type { - return switch (self) { - .bg => shaderpkg.CellBg, - - .text, - .underline, - .strikethrough, - .overline, - => shaderpkg.CellText, - }; - } -}; - -/// A pool of ArrayLists with methods for bulk operations. -fn ArrayListPool(comptime T: type) type { - return struct { - const Self = ArrayListPool(T); - const ArrayListT = std.ArrayListUnmanaged(T); - - // An array containing the lists that belong to this pool. - lists: []ArrayListT = &[_]ArrayListT{}, - - // The pool will be initialized with empty ArrayLists. - pub fn init(alloc: Allocator, list_count: usize, initial_capacity: usize) !Self { - const self: Self = .{ - .lists = try alloc.alloc(ArrayListT, list_count), - }; - - for (self.lists) |*list| { - list.* = try ArrayListT.initCapacity(alloc, initial_capacity); - } - - return self; - } - - pub fn deinit(self: *Self, alloc: Allocator) void { - for (self.lists) |*list| { - list.deinit(alloc); - } - alloc.free(self.lists); - } - - /// Clear all lists in the pool. - pub fn reset(self: *Self) void { - for (self.lists) |*list| { - list.clearRetainingCapacity(); - } - } - }; -} - -/// The contents of all the cells in the terminal. -/// -/// The goal of this data structure is to allow for efficient row-wise -/// clearing of data from the GPU buffers, to allow for row-wise dirty -/// tracking to eliminate the overhead of rebuilding the GPU buffers -/// each frame. -/// -/// Must be initialized by resizing before calling any operations. -pub const Contents = struct { - size: renderer.GridSize = .{ .rows = 0, .columns = 0 }, - - /// Flat array containing cell background colors for the terminal grid. - /// - /// Indexed as `bg_cells[row * size.columns + col]`. - /// - /// Prefer accessing with `Contents.bgCell(row, col).*` instead - /// of directly indexing in order to avoid integer size bugs. - bg_cells: []shaderpkg.CellBg = undefined, - - /// The ArrayListPool which holds all of the foreground cells. When sized - /// with Contents.resize the individual ArrayLists are given enough room - /// that they can hold a single row with #cols glyphs, underlines, and - /// strikethroughs; however, appendAssumeCapacity MUST NOT be used since - /// it is possible to exceed this with combining glyphs that add a glyph - /// but take up no column since they combine with the previous one, as - /// well as with fonts that perform multi-substitutions for glyphs, which - /// can result in a similar situation where multiple glyphs reside in the - /// same column. - /// - /// Allocations should nevertheless be exceedingly rare since hitting the - /// initial capacity of a list would require a row filled with underlined - /// struck through characters, at least one of which is a multi-glyph - /// composite. - /// - /// Rows are indexed as Contents.fg_rows[y + 1], because the first list in - /// the pool is reserved for the cursor, which must be the first item in - /// the buffer. - /// - /// Must be initialized by calling resize on the Contents struct before - /// calling any operations. - fg_rows: ArrayListPool(shaderpkg.CellText) = .{}, - - pub fn deinit(self: *Contents, alloc: Allocator) void { - alloc.free(self.bg_cells); - self.fg_rows.deinit(alloc); - } - - /// Resize the cell contents for the given grid size. This will - /// always invalidate the entire cell contents. - pub fn resize( - self: *Contents, - alloc: Allocator, - size: renderer.GridSize, - ) !void { - self.size = size; - - const cell_count = @as(usize, size.columns) * @as(usize, size.rows); - - const bg_cells = try alloc.alloc(shaderpkg.CellBg, cell_count); - errdefer alloc.free(bg_cells); - - @memset(bg_cells, .{ 0, 0, 0, 0 }); - - // The foreground lists can hold 3 types of items: - // - Glyphs - // - Underlines - // - Strikethroughs - // So we give them an initial capacity of size.columns * 3, which will - // avoid any further allocations in the vast majority of cases. Sadly - // we can not assume capacity though, since with combining glyphs that - // form a single grapheme, and multi-substitutions in fonts, the number - // of glyphs in a row is theoretically unlimited. - // - // We have size.rows + 1 lists because index 0 is used for a special - // list containing the cursor cell which needs to be first in the buffer. - var fg_rows = try ArrayListPool(shaderpkg.CellText).init(alloc, size.rows + 1, size.columns * 3); - errdefer fg_rows.deinit(alloc); - - alloc.free(self.bg_cells); - self.fg_rows.deinit(alloc); - - self.bg_cells = bg_cells; - self.fg_rows = fg_rows; - - // We don't need 3*cols worth of cells for the cursor list, so we can - // replace it with a smaller list. This is technically a tiny bit of - // extra work but resize is not a hot function so it's worth it to not - // waste the memory. - self.fg_rows.lists[0].deinit(alloc); - self.fg_rows.lists[0] = try std.ArrayListUnmanaged(shaderpkg.CellText).initCapacity(alloc, 1); - } - - /// Reset the cell contents to an empty state without resizing. - pub fn reset(self: *Contents) void { - @memset(self.bg_cells, .{ 0, 0, 0, 0 }); - self.fg_rows.reset(); - } - - /// Set the cursor value. If the value is null then the cursor is hidden. - pub fn setCursor(self: *Contents, v: ?shaderpkg.CellText) void { - self.fg_rows.lists[0].clearRetainingCapacity(); - - if (v) |cell| { - self.fg_rows.lists[0].appendAssumeCapacity(cell); - } - } - - /// Access a background cell. Prefer this function over direct indexing - /// of `bg_cells` in order to avoid integer size bugs causing overflows. - pub inline fn bgCell(self: *Contents, row: usize, col: usize) *shaderpkg.CellBg { - return &self.bg_cells[row * self.size.columns + col]; - } - - /// Add a cell to the appropriate list. Adding the same cell twice will - /// result in duplication in the vertex buffer. The caller should clear - /// the corresponding row with Contents.clear to remove old cells first. - pub fn add( - self: *Contents, - alloc: Allocator, - comptime key: Key, - cell: key.CellType(), - ) !void { - const y = cell.grid_pos[1]; - - assert(y < self.size.rows); - - switch (key) { - .bg => comptime unreachable, - - .text, - .underline, - .strikethrough, - .overline, - // We have a special list containing the cursor cell at the start - // of our fg row pool, so we need to add 1 to the y to get the - // correct index. - => try self.fg_rows.lists[y + 1].append(alloc, cell), - } - } - - /// Clear all of the cell contents for a given row. - pub fn clear(self: *Contents, y: terminal.size.CellCountInt) void { - assert(y < self.size.rows); - - @memset(self.bg_cells[@as(usize, y) * self.size.columns ..][0..self.size.columns], .{ 0, 0, 0, 0 }); - - // We have a special list containing the cursor cell at the start - // of our fg row pool, so we need to add 1 to the y to get the - // correct index. - self.fg_rows.lists[y + 1].clearRetainingCapacity(); - } -}; From 7eb3e813dd1334283436623744518142915cce4a Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Jun 2025 12:24:30 -0600 Subject: [PATCH 030/110] datastruct: move ArrayListPool from renderer/cell.zig --- src/datastruct/array_list_pool.zig | 44 ++++++++++++++++++++++++++++++ src/renderer/cell.zig | 43 +---------------------------- 2 files changed, 45 insertions(+), 42 deletions(-) create mode 100644 src/datastruct/array_list_pool.zig diff --git a/src/datastruct/array_list_pool.zig b/src/datastruct/array_list_pool.zig new file mode 100644 index 000000000..72cf8d29f --- /dev/null +++ b/src/datastruct/array_list_pool.zig @@ -0,0 +1,44 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// A pool of ArrayLists with methods for bulk operations. +pub fn ArrayListPool(comptime T: type) type { + return struct { + const Self = ArrayListPool(T); + const ArrayListT = std.ArrayListUnmanaged(T); + + // An array containing the lists that belong to this pool. + lists: []ArrayListT = &[_]ArrayListT{}, + + // The pool will be initialized with empty ArrayLists. + pub fn init( + alloc: Allocator, + list_count: usize, + initial_capacity: usize, + ) Allocator.Error!Self { + const self: Self = .{ + .lists = try alloc.alloc(ArrayListT, list_count), + }; + + for (self.lists) |*list| { + list.* = try .initCapacity(alloc, initial_capacity); + } + + return self; + } + + pub fn deinit(self: *Self, alloc: Allocator) void { + for (self.lists) |*list| { + list.deinit(alloc); + } + alloc.free(self.lists); + } + + /// Clear all lists in the pool. + pub fn reset(self: *Self) void { + for (self.lists) |*list| { + list.clearRetainingCapacity(); + } + } + }; +} diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 043a25b4e..e8114aea6 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -6,6 +6,7 @@ const font = @import("../font/main.zig"); const terminal = @import("../terminal/main.zig"); const renderer = @import("../renderer.zig"); const shaderpkg = renderer.Renderer.API.shaders; +const ArrayListPool = @import("../datastruct/array_list_pool.zig").ArrayListPool; /// The possible cell content keys that exist. pub const Key = enum { @@ -29,48 +30,6 @@ pub const Key = enum { } }; -/// A pool of ArrayLists with methods for bulk operations. -fn ArrayListPool(comptime T: type) type { - return struct { - const Self = ArrayListPool(T); - const ArrayListT = std.ArrayListUnmanaged(T); - - // An array containing the lists that belong to this pool. - lists: []ArrayListT = &[_]ArrayListT{}, - - // The pool will be initialized with empty ArrayLists. - pub fn init( - alloc: Allocator, - list_count: usize, - initial_capacity: usize, - ) Allocator.Error!Self { - const self: Self = .{ - .lists = try alloc.alloc(ArrayListT, list_count), - }; - - for (self.lists) |*list| { - list.* = try .initCapacity(alloc, initial_capacity); - } - - return self; - } - - pub fn deinit(self: *Self, alloc: Allocator) void { - for (self.lists) |*list| { - list.deinit(alloc); - } - alloc.free(self.lists); - } - - /// Clear all lists in the pool. - pub fn reset(self: *Self) void { - for (self.lists) |*list| { - list.clearRetainingCapacity(); - } - } - }; -} - /// The contents of all the cells in the terminal. /// /// The goal of this data structure is to allow for efficient row-wise From df8dc33ab65731165fba91afc48367bad6c8ced0 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Jun 2025 13:12:17 -0600 Subject: [PATCH 031/110] renderer: unify `image.zig` The code in metal/image.zig and opengl/image.zig was virtually identical save for the texture options, so I've moved that to the GraphicsAPI and unified them in to renderer/image.zig --- pkg/wuffs/src/main.zig | 1 + src/renderer/Metal.zig | 40 ++- src/renderer/OpenGL.zig | 34 ++- src/renderer/generic.zig | 12 +- src/renderer/{metal => }/image.zig | 51 ++-- src/renderer/opengl/image.zig | 423 ----------------------------- 6 files changed, 96 insertions(+), 465 deletions(-) rename src/renderer/{metal => }/image.zig (90%) delete mode 100644 src/renderer/opengl/image.zig diff --git a/pkg/wuffs/src/main.zig b/pkg/wuffs/src/main.zig index f282261c2..be4eb9184 100644 --- a/pkg/wuffs/src/main.zig +++ b/pkg/wuffs/src/main.zig @@ -3,6 +3,7 @@ const std = @import("std"); pub const png = @import("png.zig"); pub const jpeg = @import("jpeg.zig"); pub const swizzle = @import("swizzle.zig"); +pub const Error = @import("error.zig").Error; pub const ImageData = struct { width: u32, diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 0f8d642d0..40ceda5a8 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -29,8 +29,6 @@ pub const Buffer = bufferpkg.Buffer; pub const Texture = @import("metal/Texture.zig"); pub const shaders = @import("metal/shaders.zig"); -pub const imagepkg = @import("metal/image.zig"); - pub const custom_shader_target: shadertoy.Target = .msl; // The fragCoord for Metal shaders is +Y = down. pub const custom_shader_y_is_down = true; @@ -304,6 +302,44 @@ pub inline fn textureOptions(self: Metal) Texture.Options { }; } +/// Pixel format for image texture options. +pub const ImageTextureFormat = enum { + /// 1 byte per pixel grayscale. + gray, + /// 4 bytes per pixel RGBA. + rgba, + /// 4 bytes per pixel BGRA. + bgra, + + fn toPixelFormat( + self: ImageTextureFormat, + srgb: bool, + ) mtl.MTLPixelFormat { + return switch (self) { + .gray => if (srgb) .r8unorm_srgb else .r8unorm, + .rgba => if (srgb) .rgba8unorm_srgb else .rgba8unorm, + .bgra => if (srgb) .bgra8unorm_srgb else .bgra8unorm, + }; + } +}; + +/// Returns the options to use when constructing textures for images. +pub inline fn imageTextureOptions( + self: Metal, + format: ImageTextureFormat, + srgb: bool, +) Texture.Options { + return .{ + .device = self.device, + .pixel_format = format.toPixelFormat(srgb), + .resource_options = .{ + // Indicate that the CPU writes to this resource but never reads it. + .cpu_cache_mode = .write_combined, + .storage_mode = self.default_storage_mode, + }, + }; +} + /// Initializes a Texture suitable for the provided font atlas. pub fn initAtlasTexture( self: *const Metal, diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index cb02a0d75..19cfd0779 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -24,8 +24,6 @@ pub const Buffer = bufferpkg.Buffer; pub const Texture = @import("opengl/Texture.zig"); pub const shaders = @import("opengl/shaders.zig"); -pub const imagepkg = @import("opengl/image.zig"); - pub const custom_shader_target: shadertoy.Target = .glsl; // The fragCoord for OpenGL shaders is +Y = up. pub const custom_shader_y_is_down = false; @@ -401,6 +399,38 @@ pub inline fn textureOptions(self: OpenGL) Texture.Options { }; } +/// Pixel format for image texture options. +pub const ImageTextureFormat = enum { + /// 1 byte per pixel grayscale. + gray, + /// 4 bytes per pixel RGBA. + rgba, + /// 4 bytes per pixel BGRA. + bgra, + + fn toPixelFormat(self: ImageTextureFormat) gl.Texture.Format { + return switch (self) { + .gray => .red, + .rgba => .rgba, + .bgra => .bgra, + }; + } +}; + +/// Returns the options to use when constructing textures for images. +pub inline fn imageTextureOptions( + self: OpenGL, + format: ImageTextureFormat, + srgb: bool, +) Texture.Options { + _ = self; + return .{ + .format = format.toPixelFormat(), + .internal_format = if (srgb) .srgba else .rgba, + .target = .Rectangle, + }; +} + /// Initializes a Texture suitable for the provided font atlas. pub fn initAtlasTexture( self: *const OpenGL, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index ae31b2ef1..8c42e68f0 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -14,6 +14,10 @@ const link = @import("link.zig"); const cellpkg = @import("cell.zig"); const fgMode = cellpkg.fgMode; const isCovering = cellpkg.isCovering; +const imagepkg = @import("image.zig"); +const Image = imagepkg.Image; +const ImageMap = imagepkg.ImageMap; +const ImagePlacementList = std.ArrayListUnmanaged(imagepkg.Placement); const shadertoy = @import("shadertoy.zig"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; @@ -78,16 +82,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const Buffer = GraphicsAPI.Buffer; const Texture = GraphicsAPI.Texture; const RenderPass = GraphicsAPI.RenderPass; + const shaderpkg = GraphicsAPI.shaders; - - const imagepkg = GraphicsAPI.imagepkg; - const Image = imagepkg.Image; - const ImageMap = imagepkg.ImageMap; - const Shaders = shaderpkg.Shaders; - const ImagePlacementList = std.ArrayListUnmanaged(imagepkg.Placement); - /// Allocator that can be used alloc: std.mem.Allocator, diff --git a/src/renderer/metal/image.zig b/src/renderer/image.zig similarity index 90% rename from src/renderer/metal/image.zig rename to src/renderer/image.zig index 1bfa3c621..277ddd8c0 100644 --- a/src/renderer/metal/image.zig +++ b/src/renderer/image.zig @@ -1,16 +1,14 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; -const objc = @import("objc"); const wuffs = @import("wuffs"); -const Metal = @import("../Metal.zig"); -const Texture = Metal.Texture; +const Renderer = @import("../renderer.zig").Renderer; +const GraphicsAPI = Renderer.API; +const Texture = GraphicsAPI.Texture; -const mtl = @import("api.zig"); - -/// Represents a single image placement on the grid. A placement is a -/// request to render an instance of an image. +/// Represents a single image placement on the grid. +/// A placement is a request to render an instance of an image. pub const Placement = struct { /// The image being rendered. This MUST be in the image map. image_id: u32, @@ -174,8 +172,8 @@ pub const Image = union(enum) { // scenarios where there is no existing texture and we can modify // the self pointer directly. const existing: Texture = switch (self.*) { - // For pending, we can free the old data and become pending - // ourselves. + // For pending, we can free the old + // data and become pending ourselves. .pending_gray => |p| { alloc.free(p.dataSlice(1)); self.* = img; @@ -214,8 +212,8 @@ pub const Image = union(enum) { break :existing r[1]; }, - // If we were already pending a replacement, then we free our - // existing pending data and use the same texture. + // If we were already pending a replacement, then we free + // our existing pending data and use the same texture. .replace_gray => |r| existing: { alloc.free(r.pending.dataSlice(1)); break :existing r.texture; @@ -236,9 +234,9 @@ pub const Image = union(enum) { break :existing r.texture; }, - // For both ready and unload_ready, we need to replace the - // texture. We can't do that here, so we just mark ourselves - // for replacement. + // For both ready and unload_ready, we need to replace + // the texture. We can't do that here, so we just mark + // ourselves for replacement. .ready, .unload_ready => |tex| tex, }; @@ -281,6 +279,8 @@ pub const Image = union(enum) { => true, .ready, + .pending_gray, + .pending_gray_alpha, .pending_rgb, .pending_rgba, => false, @@ -290,7 +290,10 @@ pub const Image = union(enum) { /// Converts the image data to a format that can be uploaded to the GPU. /// If the data is already in a format that can be uploaded, this is a /// no-op. - pub fn convert(self: *Image, alloc: Allocator) !void { + pub fn convert(self: *Image, alloc: Allocator) wuffs.Error!void { + // As things stand, we currently convert all images to RGBA before + // uploading to the GPU. This just makes things easier. In the future + // we may want to support other formats. switch (self.*) { .ready, .unload_pending, @@ -302,8 +305,6 @@ pub const Image = union(enum) { .replace_rgba, => {}, // ready - // RGB needs to be converted to RGBA because Metal textures - // don't support RGB. .pending_rgb => |*p| { const data = p.dataSlice(3); const rgba = try wuffs.swizzle.rgbToRgba(alloc, data); @@ -320,7 +321,6 @@ pub const Image = union(enum) { self.* = .{ .replace_rgba = r.* }; }, - // Gray and Gray+Alpha need to be converted to RGBA, too. .pending_gray => |*p| { const data = p.dataSlice(1); const rgba = try wuffs.swizzle.gToRgba(alloc, data); @@ -360,11 +360,8 @@ pub const Image = union(enum) { pub fn upload( self: *Image, alloc: Allocator, - metal: *const Metal, + api: *const GraphicsAPI, ) !void { - const device = metal.device; - const storage_mode = metal.default_storage_mode; - // Convert our data if we have to try self.convert(alloc); @@ -373,15 +370,7 @@ pub const Image = union(enum) { // Create our texture const texture = try Texture.init( - .{ - .device = device, - .pixel_format = .rgba8unorm_srgb, - .resource_options = .{ - // Indicate that the CPU writes to this resource but never reads it. - .cpu_cache_mode = .write_combined, - .storage_mode = storage_mode, - }, - }, + api.imageTextureOptions(.rgba, true), @intCast(p.width), @intCast(p.height), p.data[0 .. p.width * p.height * self.depth()], diff --git a/src/renderer/opengl/image.zig b/src/renderer/opengl/image.zig deleted file mode 100644 index 77779fb8a..000000000 --- a/src/renderer/opengl/image.zig +++ /dev/null @@ -1,423 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const gl = @import("opengl"); -const wuffs = @import("wuffs"); -const OpenGL = @import("../OpenGL.zig"); -const Texture = OpenGL.Texture; - -/// Represents a single image placement on the grid. A placement is a -/// request to render an instance of an image. -pub const Placement = struct { - /// The image being rendered. This MUST be in the image map. - image_id: u32, - - /// The grid x/y where this placement is located. - x: i32, - y: i32, - z: i32, - - /// The width/height of the placed image. - width: u32, - height: u32, - - /// The offset in pixels from the top left of the cell. This is - /// clamped to the size of a cell. - cell_offset_x: u32, - cell_offset_y: u32, - - /// The source rectangle of the placement. - source_x: u32, - source_y: u32, - source_width: u32, - source_height: u32, -}; - -/// The map used for storing images. -pub const ImageMap = std.AutoHashMapUnmanaged(u32, struct { - image: Image, - transmit_time: std.time.Instant, -}); - -/// The state for a single image that is to be rendered. The image can be -/// pending upload or ready to use with a texture. -pub const Image = union(enum) { - /// The image is pending upload to the GPU. The different keys are - /// different formats since some formats aren't accepted by the GPU - /// and require conversion. - /// - /// This data is owned by this union so it must be freed once the - /// image is uploaded. - pending_gray: Pending, - pending_gray_alpha: Pending, - pending_rgb: Pending, - pending_rgba: Pending, - - /// This is the same as the pending states but there is a texture - /// already allocated that we want to replace. - replace_gray: Replace, - replace_gray_alpha: Replace, - replace_rgb: Replace, - replace_rgba: Replace, - - /// The image is uploaded and ready to be used. - ready: Texture, - - /// The image is uploaded but is scheduled to be unloaded. - unload_pending: []u8, - unload_ready: Texture, - unload_replace: struct { []u8, Texture }, - - pub const Replace = struct { - texture: Texture, - pending: Pending, - }; - - /// Pending image data that needs to be uploaded to the GPU. - pub const Pending = struct { - height: u32, - width: u32, - - /// Data is always expected to be (width * height * depth). Depth - /// is based on the union key. - data: [*]u8, - - pub fn dataSlice(self: Pending, d: u32) []u8 { - return self.data[0..self.len(d)]; - } - - pub fn len(self: Pending, d: u32) u32 { - return self.width * self.height * d; - } - }; - - pub fn deinit(self: Image, alloc: Allocator) void { - switch (self) { - .pending_gray => |p| alloc.free(p.dataSlice(1)), - .pending_gray_alpha => |p| alloc.free(p.dataSlice(2)), - .pending_rgb => |p| alloc.free(p.dataSlice(3)), - .pending_rgba => |p| alloc.free(p.dataSlice(4)), - .unload_pending => |data| alloc.free(data), - - .replace_gray => |r| { - alloc.free(r.pending.dataSlice(1)); - r.texture.deinit(); - }, - - .replace_gray_alpha => |r| { - alloc.free(r.pending.dataSlice(2)); - r.texture.deinit(); - }, - - .replace_rgb => |r| { - alloc.free(r.pending.dataSlice(3)); - r.texture.deinit(); - }, - - .replace_rgba => |r| { - alloc.free(r.pending.dataSlice(4)); - r.texture.deinit(); - }, - - .unload_replace => |r| { - alloc.free(r[0]); - r[1].deinit(); - }, - - .ready, - .unload_ready, - => |tex| tex.deinit(), - } - } - - /// Mark this image for unload whatever state it is in. - pub fn markForUnload(self: *Image) void { - self.* = switch (self.*) { - .unload_pending, - .unload_replace, - .unload_ready, - => return, - - .ready => |obj| .{ .unload_ready = obj }, - .pending_gray => |p| .{ .unload_pending = p.dataSlice(1) }, - .pending_gray_alpha => |p| .{ .unload_pending = p.dataSlice(2) }, - .pending_rgb => |p| .{ .unload_pending = p.dataSlice(3) }, - .pending_rgba => |p| .{ .unload_pending = p.dataSlice(4) }, - .replace_gray => |r| .{ .unload_replace = .{ - r.pending.dataSlice(1), r.texture, - } }, - .replace_gray_alpha => |r| .{ .unload_replace = .{ - r.pending.dataSlice(2), r.texture, - } }, - .replace_rgb => |r| .{ .unload_replace = .{ - r.pending.dataSlice(3), r.texture, - } }, - .replace_rgba => |r| .{ .unload_replace = .{ - r.pending.dataSlice(4), r.texture, - } }, - }; - } - - /// Replace the currently pending image with a new one. This will - /// attempt to update the existing texture if it is already allocated. - /// If the texture is not allocated, this will act like a new upload. - /// - /// This function only marks the image for replace. The actual logic - /// to replace is done later. - pub fn markForReplace(self: *Image, alloc: Allocator, img: Image) !void { - assert(img.pending() != null); - - // Get our existing texture. This switch statement will also handle - // scenarios where there is no existing texture and we can modify - // the self pointer directly. - const existing: Texture = switch (self.*) { - // For pending, we can free the old data and become pending ourselves. - .pending_gray => |p| { - alloc.free(p.dataSlice(1)); - self.* = img; - return; - }, - - .pending_gray_alpha => |p| { - alloc.free(p.dataSlice(2)); - self.* = img; - return; - }, - - .pending_rgb => |p| { - alloc.free(p.dataSlice(3)); - self.* = img; - return; - }, - - .pending_rgba => |p| { - alloc.free(p.dataSlice(4)); - self.* = img; - return; - }, - - // If we're marked for unload but we just have pending data, - // this behaves the same as a normal "pending": free the data, - // become new pending. - .unload_pending => |data| { - alloc.free(data); - self.* = img; - return; - }, - - .unload_replace => |r| existing: { - alloc.free(r[0]); - break :existing r[1]; - }, - - // If we were already pending a replacement, then we free our - // existing pending data and use the same texture. - .replace_gray => |r| existing: { - alloc.free(r.pending.dataSlice(1)); - break :existing r.texture; - }, - - .replace_gray_alpha => |r| existing: { - alloc.free(r.pending.dataSlice(2)); - break :existing r.texture; - }, - - .replace_rgb => |r| existing: { - alloc.free(r.pending.dataSlice(3)); - break :existing r.texture; - }, - - .replace_rgba => |r| existing: { - alloc.free(r.pending.dataSlice(4)); - break :existing r.texture; - }, - - // For both ready and unload_ready, we need to replace the - // texture. We can't do that here, so we just mark ourselves - // for replacement. - .ready, .unload_ready => |tex| tex, - }; - - // We now have an existing texture, so set the proper replace key. - self.* = switch (img) { - .pending_gray => |p| .{ .replace_gray = .{ - .texture = existing, - .pending = p, - } }, - - .pending_gray_alpha => |p| .{ .replace_gray_alpha = .{ - .texture = existing, - .pending = p, - } }, - - .pending_rgb => |p| .{ .replace_rgb = .{ - .texture = existing, - .pending = p, - } }, - - .pending_rgba => |p| .{ .replace_rgba = .{ - .texture = existing, - .pending = p, - } }, - - else => unreachable, - }; - } - - /// Returns true if this image is pending upload. - pub fn isPending(self: Image) bool { - return self.pending() != null; - } - - /// Returns true if this image is pending an unload. - pub fn isUnloading(self: Image) bool { - return switch (self) { - .unload_pending, - .unload_ready, - => true, - - .ready, - .pending_gray, - .pending_gray_alpha, - .pending_rgb, - .pending_rgba, - => false, - }; - } - - /// Converts the image data to a format that can be uploaded to the GPU. - /// If the data is already in a format that can be uploaded, this is a - /// no-op. - pub fn convert(self: *Image, alloc: Allocator) !void { - switch (self.*) { - .ready, - .unload_pending, - .unload_replace, - .unload_ready, - => unreachable, // invalid - - .pending_rgba, - .replace_rgba, - => {}, // ready - - // RGB needs to be converted to RGBA because Metal textures - // don't support RGB. - .pending_rgb => |*p| { - const data = p.dataSlice(3); - const rgba = try wuffs.swizzle.rgbToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_rgb => |*r| { - const data = r.pending.dataSlice(3); - const rgba = try wuffs.swizzle.rgbToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - - // Gray and Gray+Alpha need to be converted to RGBA, too. - .pending_gray => |*p| { - const data = p.dataSlice(1); - const rgba = try wuffs.swizzle.gToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_gray => |*r| { - const data = r.pending.dataSlice(2); - const rgba = try wuffs.swizzle.gToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - - .pending_gray_alpha => |*p| { - const data = p.dataSlice(2); - const rgba = try wuffs.swizzle.gaToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_gray_alpha => |*r| { - const data = r.pending.dataSlice(2); - const rgba = try wuffs.swizzle.gaToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - } - } - - /// Upload the pending image to the GPU and change the state of this - /// image to ready. - pub fn upload( - self: *Image, - alloc: Allocator, - opengl: *const OpenGL, - ) !void { - _ = opengl; - - // Convert our data if we have to - try self.convert(alloc); - - // Get our pending info - const p = self.pending().?; - - // Get our format - const formats: struct { - internal: gl.Texture.InternalFormat, - format: gl.Texture.Format, - } = switch (self.*) { - .pending_rgb, .replace_rgb => .{ .internal = .srgb, .format = .rgb }, - .pending_rgba, .replace_rgba => .{ .internal = .srgba, .format = .rgba }, - else => unreachable, - }; - - // Create our texture - const tex = try Texture.init( - .{ - .format = formats.format, - .internal_format = formats.internal, - .target = .Rectangle, - }, - @intCast(p.width), - @intCast(p.height), - p.data[0 .. p.width * p.height * self.depth()], - ); - - // Uploaded. We can now clear our data and change our state. - self.deinit(alloc); - self.* = .{ .ready = tex }; - } - - /// Our pixel depth - fn depth(self: Image) u32 { - return switch (self) { - .pending_rgb => 3, - .pending_rgba => 4, - .replace_rgb => 3, - .replace_rgba => 4, - else => unreachable, - }; - } - - /// Returns true if this image is in a pending state and requires upload. - fn pending(self: Image) ?Pending { - return switch (self) { - .pending_rgb, - .pending_rgba, - => |p| p, - - .replace_rgb, - .replace_rgba, - => |r| r.pending, - - else => null, - }; - } -}; From 4c3ab14571dd1c777bb36f23346534352e90c81e Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Jun 2025 14:57:36 -0600 Subject: [PATCH 032/110] renderer: make shader pipeline prep code DRYer In this format it will be a lot easier to iterate on this since adding and removing pipelines only has to be done in a single place. This commit also separates out the main background color from individual cell background color drawing, because sometimes kitty images need to be between the main background and individual cell backgrounds (kitty image z-index seems to be entirely broken at the moment, I intend to fix it in future commits). --- src/build/SharedDeps.zig | 2 +- src/renderer/generic.zig | 66 +++-- src/renderer/metal/shaders.zig | 261 +++++++++--------- src/renderer/opengl/shaders.zig | 207 ++++++++------ src/renderer/shaders/glsl/bg_color.f.glsl | 13 + src/renderer/shaders/glsl/cell_bg.f.glsl | 2 +- src/renderer/shaders/glsl/cell_text.v.glsl | 6 + .../shaders/{cell.metal => shaders.metal} | 51 ++-- 8 files changed, 343 insertions(+), 265 deletions(-) create mode 100644 src/renderer/shaders/glsl/bg_color.f.glsl rename src/renderer/shaders/{cell.metal => shaders.metal} (96%) diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 5d737cb6f..acd3ed1d8 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -75,7 +75,7 @@ fn initTarget( self.metallib = .create(b, .{ .name = "Ghostty", .target = target, - .sources = &.{b.path("src/renderer/shaders/cell.metal")}, + .sources = &.{b.path("src/renderer/shaders/shaders.metal")}, }); // Change our config diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 8c42e68f0..f4b66f017 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1391,23 +1391,43 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }}); defer pass.complete(); - // bg images - try self.drawImagePlacements(&pass, self.image_placements.items[0..self.image_bg_end]); - // bg + // First we draw the background color. + // + // NOTE: We don't use the clear_color for this because that + // would require us to do color space conversion on the + // CPU-side. In the future when we have utilities for + // that we should remove this step and use clear_color. pass.step(.{ - .pipeline = self.shaders.cell_bg_pipeline, + .pipeline = self.shaders.pipelines.bg_color, .uniforms = frame.uniforms.buffer, .buffers = &.{ null, frame.cells_bg.buffer }, - .draw = .{ - .type = .triangle, - .vertex_count = 3, - }, + .draw = .{ .type = .triangle, .vertex_count = 3 }, }); - // mg images - try self.drawImagePlacements(&pass, self.image_placements.items[self.image_bg_end..self.image_text_end]); - // text + + // Then we draw any kitty images that need + // to be behind text AND cell backgrounds. + try self.drawImagePlacements( + &pass, + self.image_placements.items[0..self.image_bg_end], + ); + + // Then we draw any opaque cell backgrounds. pass.step(.{ - .pipeline = self.shaders.cell_text_pipeline, + .pipeline = self.shaders.pipelines.cell_bg, + .uniforms = frame.uniforms.buffer, + .buffers = &.{ null, frame.cells_bg.buffer }, + .draw = .{ .type = .triangle, .vertex_count = 3 }, + }); + + // Kitty images between cell backgrounds and text. + try self.drawImagePlacements( + &pass, + self.image_placements.items[self.image_bg_end..self.image_text_end], + ); + + // Text. + pass.step(.{ + .pipeline = self.shaders.pipelines.cell_text, .uniforms = frame.uniforms.buffer, .buffers = &.{ frame.cells.buffer, @@ -1423,8 +1443,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .instance_count = fg_count, }, }); - // fg images - try self.drawImagePlacements(&pass, self.image_placements.items[self.image_text_end..]); + + // Kitty images in front of text. + try self.drawImagePlacements( + &pass, + self.image_placements.items[self.image_text_end..], + ); } // If we have custom shaders, then we render them. @@ -1539,7 +1563,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { defer buf.deinit(); pass.step(.{ - .pipeline = self.shaders.image_pipeline, + .pipeline = self.shaders.pipelines.image, .buffers = &.{buf.buffer}, .textures = &.{texture}, .draw = .{ @@ -2378,8 +2402,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const bg_alpha: u8 = bg_alpha: { const default: u8 = 255; - if (self.config.background_opacity >= 1) break :bg_alpha default; - // Cells that are selected should be fully opaque. if (selected) break :bg_alpha default; @@ -2387,12 +2409,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (style.flags.inverse) break :bg_alpha default; // Cells that have an explicit bg color should be fully opaque. - if (bg_style != null) { - break :bg_alpha default; - } + if (bg_style != null) break :bg_alpha default; - // Otherwise, we use the configured background opacity. - break :bg_alpha @intFromFloat(@round(self.config.background_opacity * 255.0)); + // Otherwise, we won't draw the bg for this cell, + // we'll let the already-drawn background color + // show through. + break :bg_alpha 0; }; self.cells.bgCell(y, x).* = .{ diff --git a/src/renderer/metal/shaders.zig b/src/renderer/metal/shaders.zig index dc5d1122c..59a3a1a37 100644 --- a/src/renderer/metal/shaders.zig +++ b/src/renderer/metal/shaders.zig @@ -10,20 +10,90 @@ const Pipeline = @import("Pipeline.zig"); const log = std.log.scoped(.metal); +const pipeline_descs: []const struct { [:0]const u8, PipelineDescription } = + &.{ + .{ "bg_color", .{ + .vertex_fn = "full_screen_vertex", + .fragment_fn = "bg_color_fragment", + .blending_enabled = false, + } }, + .{ "cell_bg", .{ + .vertex_fn = "full_screen_vertex", + .fragment_fn = "cell_bg_fragment", + .blending_enabled = true, + } }, + .{ "cell_text", .{ + .vertex_attributes = CellText, + .vertex_fn = "cell_text_vertex", + .fragment_fn = "cell_text_fragment", + .step_fn = .per_instance, + .blending_enabled = true, + } }, + .{ "image", .{ + .vertex_attributes = Image, + .vertex_fn = "image_vertex", + .fragment_fn = "image_fragment", + .step_fn = .per_instance, + .blending_enabled = true, + } }, + }; + +/// All the comptime-known info about a pipeline, so that +/// we can define them ahead-of-time in an ergonomic way. +const PipelineDescription = struct { + vertex_attributes: ?type = null, + vertex_fn: []const u8, + fragment_fn: []const u8, + step_fn: mtl.MTLVertexStepFunction = .per_vertex, + blending_enabled: bool, + + fn initPipeline( + self: PipelineDescription, + device: objc.Object, + library: objc.Object, + pixel_format: mtl.MTLPixelFormat, + ) !Pipeline { + return try .init(self.vertex_attributes, .{ + .device = device, + .vertex_fn = self.vertex_fn, + .fragment_fn = self.fragment_fn, + .vertex_library = library, + .fragment_library = library, + .step_fn = self.step_fn, + .attachments = &.{.{ + .pixel_format = pixel_format, + .blending_enabled = self.blending_enabled, + }}, + }); + } +}; + +/// We create a type for the pipeline collection based on our desc array. +const PipelineCollection = t: { + var fields: [pipeline_descs.len]std.builtin.Type.StructField = undefined; + for (pipeline_descs, 0..) |pipeline, i| { + fields[i] = .{ + .name = pipeline[0], + .type = Pipeline, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(Pipeline), + }; + } + break :t @Type(.{ .@"struct" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + } }); +}; + /// This contains the state for the shaders used by the Metal renderer. pub const Shaders = struct { library: objc.Object, - /// Renders cell foreground elements (text, decorations). - cell_text_pipeline: Pipeline, - - /// The cell background shader is the shader used to render the - /// background of terminal cells. - cell_bg_pipeline: Pipeline, - - /// The image shader is the shader used to render images for things - /// like the Kitty image protocol. - image_pipeline: Pipeline, + /// Collection of available render pipelines. + pipelines: PipelineCollection, /// Custom shaders to run against the final drawable texture. This /// can be used to apply a lot of effects. Each shader is run in sequence @@ -48,14 +118,24 @@ pub const Shaders = struct { const library = try initLibrary(device); errdefer library.msgSend(void, objc.sel("release"), .{}); - const cell_text_pipeline = try initCellTextPipeline(device, library, pixel_format); - errdefer cell_text_pipeline.deinit(); + var pipelines: PipelineCollection = undefined; - const cell_bg_pipeline = try initCellBgPipeline(device, library, pixel_format); - errdefer cell_bg_pipeline.deinit(); + var initialized_pipelines: usize = 0; - const image_pipeline = try initImagePipeline(device, library, pixel_format); - errdefer image_pipeline.deinit(); + errdefer inline for (pipeline_descs, 0..) |pipeline, i| { + if (i < initialized_pipelines) { + @field(pipelines, pipeline[0]).deinit(); + } + }; + + inline for (pipeline_descs) |pipeline| { + @field(pipelines, pipeline[0]) = try pipeline[1].initPipeline( + device, + library, + pixel_format, + ); + initialized_pipelines += 1; + } const post_pipelines: []const Pipeline = initPostPipelines( alloc, @@ -77,9 +157,7 @@ pub const Shaders = struct { return .{ .library = library, - .cell_text_pipeline = cell_text_pipeline, - .cell_bg_pipeline = cell_bg_pipeline, - .image_pipeline = image_pipeline, + .pipelines = pipelines, .post_pipelines = post_pipelines, }; } @@ -89,9 +167,9 @@ pub const Shaders = struct { self.defunct = true; // Release our primary shaders - self.cell_text_pipeline.deinit(); - self.cell_bg_pipeline.deinit(); - self.image_pipeline.deinit(); + inline for (pipeline_descs) |pipeline| { + @field(self.pipelines, pipeline[0]).deinit(); + } self.library.msgSend(void, objc.sel("release"), .{}); // Release our postprocess shaders @@ -104,15 +182,7 @@ pub const Shaders = struct { } }; -/// Single parameter for the image shader. See shader for field details. -pub const Image = extern struct { - grid_pos: [2]f32, - cell_offset: [2]f32, - source_rect: [4]f32, - dest_size: [2]f32, -}; - -/// The uniforms that are passed to the terminal cell shader. +/// The uniforms that are passed to our shaders. pub const Uniforms = extern struct { // Note: all of the explicit alignments are copied from the // MSL developer reference just so that we can be sure that we got @@ -182,6 +252,42 @@ pub const Uniforms = extern struct { }; }; +/// This is a single parameter for the terminal cell shader. +pub const CellText = extern struct { + glyph_pos: [2]u32 align(8) = .{ 0, 0 }, + glyph_size: [2]u32 align(8) = .{ 0, 0 }, + bearings: [2]i16 align(4) = .{ 0, 0 }, + grid_pos: [2]u16 align(4), + color: [4]u8 align(4), + mode: Mode align(1), + constraint_width: u8 align(1) = 0, + + pub const Mode = enum(u8) { + fg = 1, + fg_constrained = 2, + fg_color = 3, + cursor = 4, + fg_powerline = 5, + }; + + test { + // Minimizing the size of this struct is important, + // so we test it in order to be aware of any changes. + try std.testing.expectEqual(32, @sizeOf(CellText)); + } +}; + +/// This is a single parameter for the cell bg shader. +pub const CellBg = [4]u8; + +/// Single parameter for the image shader. See shader for field details. +pub const Image = extern struct { + grid_pos: [2]f32, + cell_offset: [2]f32, + source_rect: [4]f32, + dest_size: [2]f32, +}; + /// Initialize the MTLLibrary. A MTLLibrary is a collection of shaders. fn initLibrary(device: objc.Object) !objc.Object { const start = try std.time.Instant.now(); @@ -294,99 +400,6 @@ fn initPostPipeline( }); } -/// This is a single parameter for the terminal cell shader. -pub const CellText = extern struct { - glyph_pos: [2]u32 align(8) = .{ 0, 0 }, - glyph_size: [2]u32 align(8) = .{ 0, 0 }, - bearings: [2]i16 align(4) = .{ 0, 0 }, - grid_pos: [2]u16 align(4), - color: [4]u8 align(4), - mode: Mode align(1), - constraint_width: u8 align(1) = 0, - - pub const Mode = enum(u8) { - fg = 1, - fg_constrained = 2, - fg_color = 3, - cursor = 4, - fg_powerline = 5, - }; - - test { - // Minimizing the size of this struct is important, - // so we test it in order to be aware of any changes. - try std.testing.expectEqual(32, @sizeOf(CellText)); - } -}; - -/// Initialize the cell render pipeline for our shader library. -fn initCellTextPipeline( - device: objc.Object, - library: objc.Object, - pixel_format: mtl.MTLPixelFormat, -) !Pipeline { - return try Pipeline.init(CellText, .{ - .device = device, - .vertex_fn = "cell_text_vertex", - .fragment_fn = "cell_text_fragment", - .vertex_library = library, - .fragment_library = library, - .step_fn = .per_instance, - .attachments = &.{ - .{ - .pixel_format = pixel_format, - .blending_enabled = true, - }, - }, - }); -} - -/// This is a single parameter for the cell bg shader. -pub const CellBg = [4]u8; - -/// Initialize the cell background render pipeline for our shader library. -fn initCellBgPipeline( - device: objc.Object, - library: objc.Object, - pixel_format: mtl.MTLPixelFormat, -) !Pipeline { - return try Pipeline.init(null, .{ - .device = device, - .vertex_fn = "cell_bg_vertex", - .fragment_fn = "cell_bg_fragment", - .vertex_library = library, - .fragment_library = library, - .attachments = &.{ - .{ - .pixel_format = pixel_format, - .blending_enabled = false, - }, - }, - }); -} - -/// Initialize the image render pipeline for our shader library. -fn initImagePipeline( - device: objc.Object, - library: objc.Object, - pixel_format: mtl.MTLPixelFormat, -) !Pipeline { - return try Pipeline.init(Image, .{ - .device = device, - .vertex_fn = "image_vertex", - .fragment_fn = "image_fragment", - .vertex_library = library, - .fragment_library = library, - .step_fn = .per_instance, - .attachments = &.{ - .{ - .pixel_format = pixel_format, - .blending_enabled = true, - }, - }, - }); -} - fn checkError(err_: ?*anyopaque) !void { const nserr = objc.Object.fromId(err_ orelse return); const str = @as( diff --git a/src/renderer/opengl/shaders.zig b/src/renderer/opengl/shaders.zig index 7e54fd37b..cc7a3ea2e 100644 --- a/src/renderer/opengl/shaders.zig +++ b/src/renderer/opengl/shaders.zig @@ -7,18 +7,77 @@ const Pipeline = @import("Pipeline.zig"); const log = std.log.scoped(.opengl); +const pipeline_descs: []const struct { [:0]const u8, PipelineDescription } = + &.{ + .{ "bg_color", .{ + .vertex_fn = loadShaderCode("../shaders/glsl/full_screen.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/bg_color.f.glsl"), + .blending_enabled = false, + } }, + .{ "cell_bg", .{ + .vertex_fn = loadShaderCode("../shaders/glsl/full_screen.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/cell_bg.f.glsl"), + .blending_enabled = true, + } }, + .{ "cell_text", .{ + .vertex_attributes = CellText, + .vertex_fn = loadShaderCode("../shaders/glsl/cell_text.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/cell_text.f.glsl"), + .step_fn = .per_instance, + .blending_enabled = true, + } }, + .{ "image", .{ + .vertex_attributes = Image, + .vertex_fn = loadShaderCode("../shaders/glsl/image.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/image.f.glsl"), + .step_fn = .per_instance, + .blending_enabled = true, + } }, + }; + +/// All the comptime-known info about a pipeline, so that +/// we can define them ahead-of-time in an ergonomic way. +const PipelineDescription = struct { + vertex_attributes: ?type = null, + vertex_fn: [:0]const u8, + fragment_fn: [:0]const u8, + step_fn: Pipeline.Options.StepFunction = .per_vertex, + blending_enabled: bool = true, + + fn initPipeline(self: PipelineDescription) !Pipeline { + return try .init(self.vertex_attributes, .{ + .vertex_fn = self.vertex_fn, + .fragment_fn = self.fragment_fn, + .step_fn = self.step_fn, + .blending_enabled = self.blending_enabled, + }); + } +}; + +/// We create a type for the pipeline collection based on our desc array. +const PipelineCollection = t: { + var fields: [pipeline_descs.len]std.builtin.Type.StructField = undefined; + for (pipeline_descs, 0..) |pipeline, i| { + fields[i] = .{ + .name = pipeline[0], + .type = Pipeline, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(Pipeline), + }; + } + break :t @Type(.{ .@"struct" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + } }); +}; + /// This contains the state for the shaders used by the Metal renderer. pub const Shaders = struct { - /// Renders cell foreground elements (text, decorations). - cell_text_pipeline: Pipeline, - - /// The cell background shader is the shader used to render the - /// background of terminal cells. - cell_bg_pipeline: Pipeline, - - /// The image shader is the shader used to render images for things - /// like the Kitty image protocol. - image_pipeline: Pipeline, + /// Collection of available render pipelines. + pipelines: PipelineCollection, /// Custom shaders to run against the final drawable texture. This /// can be used to apply a lot of effects. Each shader is run in sequence @@ -38,14 +97,20 @@ pub const Shaders = struct { alloc: Allocator, post_shaders: []const [:0]const u8, ) !Shaders { - const cell_text_pipeline = try initCellTextPipeline(); - errdefer cell_text_pipeline.deinit(); + var pipelines: PipelineCollection = undefined; - const cell_bg_pipeline = try initCellBgPipeline(); - errdefer cell_bg_pipeline.deinit(); + var initialized_pipelines: usize = 0; - const image_pipeline = try initImagePipeline(); - errdefer image_pipeline.deinit(); + errdefer inline for (pipeline_descs, 0..) |pipeline, i| { + if (i < initialized_pipelines) { + @field(pipelines, pipeline[0]).deinit(); + } + }; + + inline for (pipeline_descs) |pipeline| { + @field(pipelines, pipeline[0]) = try pipeline[1].initPipeline(); + initialized_pipelines += 1; + } const post_pipelines: []const Pipeline = initPostPipelines( alloc, @@ -63,9 +128,7 @@ pub const Shaders = struct { }; return .{ - .cell_text_pipeline = cell_text_pipeline, - .cell_bg_pipeline = cell_bg_pipeline, - .image_pipeline = image_pipeline, + .pipelines = pipelines, .post_pipelines = post_pipelines, }; } @@ -75,9 +138,9 @@ pub const Shaders = struct { self.defunct = true; // Release our primary shaders - self.cell_text_pipeline.deinit(); - self.cell_bg_pipeline.deinit(); - self.image_pipeline.deinit(); + inline for (pipeline_descs) |pipeline| { + @field(self.pipelines, pipeline[0]).deinit(); + } // Release our postprocess shaders if (self.post_pipelines.len > 0) { @@ -89,15 +152,7 @@ pub const Shaders = struct { } }; -/// Single parameter for the image shader. See shader for field details. -pub const Image = extern struct { - grid_pos: [2]f32 align(8), - cell_offset: [2]f32 align(8), - source_rect: [4]f32 align(16), - dest_size: [2]f32 align(8), -}; - -/// The uniforms that are passed to the terminal cell shader. +/// The uniforms that are passed to our shaders. pub const Uniforms = extern struct { /// The projection matrix for turning world coordinates to normalized. /// This is calculated based on the size of the screen. @@ -165,6 +220,42 @@ pub const Uniforms = extern struct { }; }; +/// This is a single parameter for the terminal cell shader. +pub const CellText = extern struct { + glyph_pos: [2]u32 align(8) = .{ 0, 0 }, + glyph_size: [2]u32 align(8) = .{ 0, 0 }, + bearings: [2]i16 align(4) = .{ 0, 0 }, + grid_pos: [2]u16 align(4), + color: [4]u8 align(4), + mode: Mode align(4), + constraint_width: u32 align(4) = 0, + + pub const Mode = enum(u32) { + fg = 1, + fg_constrained = 2, + fg_color = 3, + cursor = 4, + fg_powerline = 5, + }; + + // test { + // // Minimizing the size of this struct is important, + // // so we test it in order to be aware of any changes. + // try std.testing.expectEqual(32, @sizeOf(CellText)); + // } +}; + +/// This is a single parameter for the cell bg shader. +pub const CellBg = [4]u8; + +/// Single parameter for the image shader. See shader for field details. +pub const Image = extern struct { + grid_pos: [2]f32 align(8), + cell_offset: [2]f32 align(8), + source_rect: [4]f32 align(16), + dest_size: [2]f32 align(8), +}; + /// Initialize our custom shader pipelines. The shaders argument is a /// set of shader source code, not file paths. fn initPostPipelines( @@ -204,60 +295,6 @@ fn initPostPipeline(data: [:0]const u8) !Pipeline { }); } -/// This is a single parameter for the terminal cell shader. -pub const CellText = extern struct { - glyph_pos: [2]u32 align(8) = .{ 0, 0 }, - glyph_size: [2]u32 align(8) = .{ 0, 0 }, - bearings: [2]i16 align(4) = .{ 0, 0 }, - grid_pos: [2]u16 align(4), - color: [4]u8 align(4), - mode: Mode align(4), - constraint_width: u32 align(4) = 0, - - pub const Mode = enum(u32) { - fg = 1, - fg_constrained = 2, - fg_color = 3, - cursor = 4, - fg_powerline = 5, - }; - - // test { - // // Minimizing the size of this struct is important, - // // so we test it in order to be aware of any changes. - // try std.testing.expectEqual(32, @sizeOf(CellText)); - // } -}; - -/// Initialize the cell render pipeline. -fn initCellTextPipeline() !Pipeline { - return try Pipeline.init(CellText, .{ - .vertex_fn = loadShaderCode("../shaders/glsl/cell_text.v.glsl"), - .fragment_fn = loadShaderCode("../shaders/glsl/cell_text.f.glsl"), - .step_fn = .per_instance, - }); -} - -/// This is a single parameter for the cell bg shader. -pub const CellBg = [4]u8; - -/// Initialize the cell background render pipeline. -fn initCellBgPipeline() !Pipeline { - return try Pipeline.init(null, .{ - .vertex_fn = loadShaderCode("../shaders/glsl/full_screen.v.glsl"), - .fragment_fn = loadShaderCode("../shaders/glsl/cell_bg.f.glsl"), - }); -} - -/// Initialize the image render pipeline. -fn initImagePipeline() !Pipeline { - return try Pipeline.init(Image, .{ - .vertex_fn = loadShaderCode("../shaders/glsl/image.v.glsl"), - .fragment_fn = loadShaderCode("../shaders/glsl/image.f.glsl"), - .step_fn = .per_instance, - }); -} - /// Load shader code from the target path, processing `#include` directives. /// /// Comptime only for now, this code is really sloppy and makes a bunch of diff --git a/src/renderer/shaders/glsl/bg_color.f.glsl b/src/renderer/shaders/glsl/bg_color.f.glsl new file mode 100644 index 000000000..616c44b89 --- /dev/null +++ b/src/renderer/shaders/glsl/bg_color.f.glsl @@ -0,0 +1,13 @@ +#include "common.glsl" + +// Must declare this output for some versions of OpenGL. +layout(location = 0) out vec4 out_FragColor; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + out_FragColor = load_color( + unpack4u8(bg_color_packed_4u8), + use_linear_blending + ); +} diff --git a/src/renderer/shaders/glsl/cell_bg.f.glsl b/src/renderer/shaders/glsl/cell_bg.f.glsl index cfd598f95..7ba6caaa6 100644 --- a/src/renderer/shaders/glsl/cell_bg.f.glsl +++ b/src/renderer/shaders/glsl/cell_bg.f.glsl @@ -15,7 +15,7 @@ vec4 cell_bg() { ivec2 grid_pos = ivec2(floor((gl_FragCoord.xy - grid_padding.wx) / cell_size)); bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; - vec4 bg = load_color(unpack4u8(bg_color_packed_4u8), use_linear_blending); + vec4 bg = vec4(0.0); // Clamp x position, extends edge bg colors in to padding on sides. if (grid_pos.x < 0) { diff --git a/src/renderer/shaders/glsl/cell_text.v.glsl b/src/renderer/shaders/glsl/cell_text.v.glsl index 76ede1082..10965ddd2 100644 --- a/src/renderer/shaders/glsl/cell_text.v.glsl +++ b/src/renderer/shaders/glsl/cell_text.v.glsl @@ -139,6 +139,12 @@ void main() { unpack4u8(bg_colors[grid_pos.y * grid_size.x + grid_pos.x]), true ); + // Blend it with the global bg color + vec4 global_bg = load_color( + unpack4u8(bg_color_packed_4u8), + true + ); + out_data.bg_color += global_bg * vec4(1.0 - out_data.bg_color.a); // If we have a minimum contrast, we need to check if we need to // change the color of the text to ensure it has enough contrast diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/shaders.metal similarity index 96% rename from src/renderer/shaders/cell.metal rename to src/renderer/shaders/shaders.metal index 039c600ed..0ae7c402f 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/shaders.metal @@ -216,45 +216,34 @@ vertex FullScreenVertexOut full_screen_vertex( } //------------------------------------------------------------------- -// Cell Background Shader +// Background Color Shader //------------------------------------------------------------------- -#pragma mark - Cell BG Shader +#pragma mark - BG Color Shader -struct CellBgVertexOut { - float4 position [[position]]; - float4 bg_color; -}; - -vertex CellBgVertexOut cell_bg_vertex( - uint vid [[vertex_id]], +fragment float4 bg_color_fragment( + FullScreenVertexOut in [[stage_in]], constant Uniforms& uniforms [[buffer(1)]] ) { - CellBgVertexOut out; - - float4 position; - position.x = (vid == 2) ? 3.0 : -1.0; - position.y = (vid == 0) ? -3.0 : 1.0; - position.zw = 1.0; - out.position = position; - - // Convert the background color to Display P3 - out.bg_color = load_color( + return load_color( uniforms.bg_color, uniforms.use_display_p3, uniforms.use_linear_blending ); - - return out; } +//------------------------------------------------------------------- +// Cell Background Shader +//------------------------------------------------------------------- +#pragma mark - Cell BG Shader + fragment float4 cell_bg_fragment( - CellBgVertexOut in [[stage_in]], + FullScreenVertexOut in [[stage_in]], constant Uniforms& uniforms [[buffer(1)]], constant uchar4 *cells [[buffer(2)]] ) { int2 grid_pos = int2(floor((in.position.xy - uniforms.grid_padding.wx) / uniforms.cell_size)); - float4 bg = in.bg_color; + float4 bg = float4(0.0); // Clamp x position, extends edge bg colors in to padding on sides. if (grid_pos.x < 0) { @@ -289,17 +278,8 @@ fragment float4 cell_bg_fragment( // Load the color for the cell. uchar4 cell_color = cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x]; - // We have special case handling for when the cell color matches the bg color. - if (all(cell_color == uniforms.bg_color)) { - return bg; - } - // Convert the color and return it. // - // TODO: We may want to blend the color with the background - // color, rather than purely replacing it, this needs - // some consideration about config options though. - // // TODO: It might be a good idea to do a pass before this // to convert all of the bg colors, so we don't waste // a bunch of work converting the cell color in every @@ -462,6 +442,13 @@ vertex CellTextVertexOut cell_text_vertex( uniforms.use_display_p3, true ); + // Blend it with the global bg color + float4 global_bg = load_color( + uniforms.bg_color, + uniforms.use_display_p3, + true + ); + out.bg_color += global_bg * (1.0 - out.bg_color.a); // If we have a minimum contrast, we need to check if we need to // change the color of the text to ensure it has enough contrast From 1da40ccbacc233f5b99febdce6ed2ec647dce526 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Jun 2025 15:17:34 -0600 Subject: [PATCH 033/110] fix(renderer): kitty image z-index accounting The previous logic would consider all images fg if the only present placements were bg, or consider mg images fg if there were no fg. --- src/renderer/generic.zig | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index f4b66f017..4229b61fe 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1672,8 +1672,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }.lessThan, ); - // Find our indices. The values are sorted by z so we can find the - // first placement out of bounds to find the limits. + // Find our indices. The values are sorted by z so we can + // find the first placement out of bounds to find the limits. var bg_end: ?u32 = null; var text_end: ?u32 = null; const bg_limit = std.math.minInt(i32) / 2; @@ -1686,8 +1686,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } - self.image_bg_end = bg_end orelse 0; - self.image_text_end = text_end orelse self.image_bg_end; + // If we didn't see any images with a z > the bg limit, + // then our bg end is the end of our placement list. + self.image_bg_end = + bg_end orelse @intCast(self.image_placements.items.len); + + // Same idea for the image_text_end. + self.image_text_end = + text_end orelse @intCast(self.image_placements.items.len); } fn prepKittyVirtualPlacement( From 706a43138e65a93560819de0127367aab3d2ae16 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Jun 2025 16:30:07 -0600 Subject: [PATCH 034/110] renderer: keep post uniform buffer in frame state This avoids creating a new buffer for this every frame. --- src/renderer/generic.zig | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 4229b61fe..87666def9 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -301,7 +301,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Custom shader state, this is null if we have no custom shaders. custom_shader_state: ?CustomShaderState = null, - /// A buffer containing the uniform data. const UniformBuffer = Buffer(shaderpkg.Uniforms); const CellBgBuffer = Buffer(shaderpkg.CellBg); const CellTextBuffer = Buffer(shaderpkg.CellText); @@ -395,12 +394,20 @@ pub fn Renderer(comptime GraphicsAPI: type) type { front_texture: Texture, back_texture: Texture, + uniforms: UniformBuffer, + + const UniformBuffer = Buffer(shadertoy.Uniforms); + /// Swap the front and back textures. pub fn swap(self: *CustomShaderState) void { std.mem.swap(Texture, &self.front_texture, &self.back_texture); } pub fn init(api: GraphicsAPI) !CustomShaderState { + // Create a GPU buffer to hold our uniforms. + var uniforms = try UniformBuffer.init(api.uniformBufferOptions(), 1); + errdefer uniforms.deinit(); + // Initialize the front and back textures at 1x1 px, this // is slightly wasteful but it's only done once so whatever. const front_texture = try Texture.init( @@ -417,15 +424,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { null, ); errdefer back_texture.deinit(); + return .{ .front_texture = front_texture, .back_texture = back_texture, + .uniforms = uniforms, }; } pub fn deinit(self: *CustomShaderState) void { self.front_texture.deinit(); self.back_texture.deinit(); + self.uniforms.deinit(); } pub fn resize( @@ -1453,14 +1463,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // If we have custom shaders, then we render them. if (frame.custom_shader_state) |*state| { - // We create a buffer on the GPU for our post uniforms. - // TODO: This should be a part of the frame state tbqh. - const PostBuffer = Buffer(shadertoy.Uniforms); - const uniform_buffer = try PostBuffer.initFill( - self.api.bufferOptions(), - &.{self.custom_shader_uniforms}, - ); - defer uniform_buffer.deinit(); + // Sync our uniforms. + try state.uniforms.sync(&.{self.custom_shader_uniforms}); for (self.shaders.post_pipelines, 0..) |pipeline, i| { defer state.swap(); @@ -1476,7 +1480,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { pass.step(.{ .pipeline = pipeline, - .uniforms = uniform_buffer.buffer, + .uniforms = state.uniforms.buffer, .textures = &.{state.back_texture}, .draw = .{ .type = .triangle, From f5439c860afec37846c846a0c16e6ca4678e0bf7 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Jun 2025 17:05:15 -0600 Subject: [PATCH 035/110] renderer: extract kitty image upload in drawFrame to fn For cleanliness -- also updated some comments while I was at it. --- src/renderer/generic.zig | 68 ++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 87666def9..634f797b2 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1334,32 +1334,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } // Upload images to the GPU as necessary. - { - var image_it = self.images.iterator(); - while (image_it.next()) |kv| { - switch (kv.value_ptr.image) { - .ready => {}, - - .pending_gray, - .pending_gray_alpha, - .pending_rgb, - .pending_rgba, - .replace_gray, - .replace_gray_alpha, - .replace_rgb, - .replace_rgba, - => try kv.value_ptr.image.upload(self.alloc, &self.api), - - .unload_pending, - .unload_replace, - .unload_ready, - => { - kv.value_ptr.image.deinit(self.alloc); - self.images.removeByPtr(kv.key_ptr); - }, - } - } - } + try self.uploadKittyImages(); // Update custom shader uniforms if necessary. try self.updateCustomShaderUniforms(); @@ -1579,8 +1554,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } /// This goes through the Kitty graphic placements and accumulates the - /// placements we need to render on our viewport. It also ensures that - /// the visible images are loaded on the GPU. + /// placements we need to render on our viewport. fn prepKittyGraphics( self: *Self, t: *terminal.Terminal, @@ -1617,7 +1591,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const top_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; const bot_y = t.screen.pages.pointFromPin(.screen, bot).?.screen.y; - // Go through the placements and ensure the image is loaded on the GPU. + // Go through the placements and ensure the image is + // on the GPU or else is ready to be sent to the GPU. var it = storage.placements.iterator(); while (it.next()) |kv| { const p = kv.value_ptr; @@ -1738,7 +1713,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { unreachable; }; - // Send our image to the GPU and store the placement for rendering. + // Prepare the image for the GPU and store the placement. try self.prepKittyImage(&image); try self.image_placements.append(self.alloc, .{ .image_id = image.id, @@ -1756,6 +1731,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }); } + /// Get the viewport-relative position for this + /// placement and add it to the placements list. fn prepKittyPlacement( self: *Self, t: *terminal.Terminal, @@ -1819,6 +1796,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } + /// Prepare the provided image for upload to the GPU by copying its + /// data with our allocator and setting it to the pending state. fn prepKittyImage( self: *Self, image: *const terminal.kitty.graphics.Image, @@ -1866,6 +1845,35 @@ pub fn Renderer(comptime GraphicsAPI: type) type { gop.value_ptr.transmit_time = image.transmit_time; } + /// Upload any images to the GPU that need to be uploaded, + /// and remove any images that are no longer needed on the GPU. + fn uploadKittyImages(self: *Self) !void { + var image_it = self.images.iterator(); + while (image_it.next()) |kv| { + switch (kv.value_ptr.image) { + .ready => {}, + + .pending_gray, + .pending_gray_alpha, + .pending_rgb, + .pending_rgba, + .replace_gray, + .replace_gray_alpha, + .replace_rgb, + .replace_rgba, + => try kv.value_ptr.image.upload(self.alloc, &self.api), + + .unload_pending, + .unload_replace, + .unload_ready, + => { + kv.value_ptr.image.deinit(self.alloc); + self.images.removeByPtr(kv.key_ptr); + }, + } + } + } + /// Update the configuration. pub fn changeConfig(self: *Self, config: *DerivedConfig) !void { self.draw_mutex.lock(); From 41ae32814fd988cb729bc5c9ba83937ceb7dee5a Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Jun 2025 17:31:49 -0600 Subject: [PATCH 036/110] renderer: fix color glyph rendering under OpenGL Also changes color atlas to always use an sRGB internal format so that the texture reads automatically linearize the colors. Renames the misleading `rgba` atlas format to `bgra`, since both FreeType and CoreText are set up to draw color glyphs in bgra. --- src/font/Atlas.zig | 11 +++++++---- src/font/SharedGrid.zig | 2 +- src/font/face/freetype.zig | 2 +- src/renderer/Metal.zig | 2 +- src/renderer/OpenGL.zig | 2 +- src/renderer/generic.zig | 2 +- src/renderer/shaders/glsl/cell_text.f.glsl | 12 ++++++------ src/renderer/shaders/shaders.metal | 14 +++++++------- 8 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/font/Atlas.zig b/src/font/Atlas.zig index 327ce225f..3e3a20ad2 100644 --- a/src/font/Atlas.zig +++ b/src/font/Atlas.zig @@ -50,15 +50,18 @@ modified: std.atomic.Value(usize) = .{ .raw = 0 }, resized: std.atomic.Value(usize) = .{ .raw = 0 }, pub const Format = enum(u8) { + /// 1 byte per pixel grayscale. grayscale = 0, - rgb = 1, - rgba = 2, + /// 3 bytes per pixel BGR. + bgr = 1, + /// 4 bytes per pixel BGRA. + bgra = 2, pub fn depth(self: Format) u8 { return switch (self) { .grayscale => 1, - .rgb => 3, - .rgba => 4, + .bgr => 3, + .bgra => 4, }; } }; diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 35770f920..ad385abb5 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -79,7 +79,7 @@ pub fn init( var atlas_grayscale = try Atlas.init(alloc, 512, .grayscale); errdefer atlas_grayscale.deinit(alloc); - var atlas_color = try Atlas.init(alloc, 512, .rgba); + var atlas_color = try Atlas.init(alloc, 512, .bgra); errdefer atlas_color.deinit(alloc); var result: SharedGrid = .{ diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index bf86b88de..9a5f15200 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -391,7 +391,7 @@ pub const Face = struct { const format: ?font.Atlas.Format = switch (bitmap_ft.pixel_mode) { freetype.c.FT_PIXEL_MODE_MONO => null, freetype.c.FT_PIXEL_MODE_GRAY => .grayscale, - freetype.c.FT_PIXEL_MODE_BGRA => .rgba, + freetype.c.FT_PIXEL_MODE_BGRA => .bgra, else => { log.warn("glyph={} pixel mode={}", .{ glyph_index, bitmap_ft.pixel_mode }); @panic("unsupported pixel mode"); diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 40ceda5a8..39b6f7efc 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -347,7 +347,7 @@ pub fn initAtlasTexture( ) Texture.Error!Texture { const pixel_format: mtl.MTLPixelFormat = switch (atlas.format) { .grayscale => .r8unorm, - .rgba => .bgra8unorm, + .bgra => .bgra8unorm_srgb, else => @panic("unsupported atlas format for Metal texture"), }; diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 19cfd0779..d254934e4 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -440,7 +440,7 @@ pub fn initAtlasTexture( const format: gl.Texture.Format, const internal_format: gl.Texture.InternalFormat = switch (atlas.format) { .grayscale => .{ .red, .red }, - .rgba => .{ .rgba, .srgba }, + .bgra => .{ .bgra, .srgba }, else => @panic("unsupported atlas format for OpenGL texture"), }; diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 634f797b2..9589cb44b 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -336,7 +336,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const color = try api.initAtlasTexture(&.{ .data = undefined, .size = 1, - .format = .rgba, + .format = .bgra, }); errdefer color.deinit(); diff --git a/src/renderer/shaders/glsl/cell_text.f.glsl b/src/renderer/shaders/glsl/cell_text.f.glsl index fda552424..fda6d8134 100644 --- a/src/renderer/shaders/glsl/cell_text.f.glsl +++ b/src/renderer/shaders/glsl/cell_text.f.glsl @@ -87,19 +87,19 @@ void main() { case MODE_TEXT_COLOR: { // For now, we assume that color glyphs - // are already premultiplied sRGB colors. + // are already premultiplied linear colors. vec4 color = texture(atlas_color, in_data.tex_coord); - // If we aren't doing linear blending, we can return this right away. - if (!use_linear_blending) { + // If we are doing linear blending, we can return this right away. + if (use_linear_blending) { out_FragColor = color; return; } - // Otherwise we need to linearize the color. Since the alpha is - // premultiplied, we need to divide it out before linearizing. + // Otherwise we need to unlinearize the color. Since the alpha is + // premultiplied, we need to divide it out before unlinearizing. color.rgb /= vec3(color.a); - color = linearize(color); + color = unlinearize(color); color.rgb *= vec3(color.a); out_FragColor = color; diff --git a/src/renderer/shaders/shaders.metal b/src/renderer/shaders/shaders.metal index 0ae7c402f..19652d836 100644 --- a/src/renderer/shaders/shaders.metal +++ b/src/renderer/shaders/shaders.metal @@ -553,19 +553,19 @@ fragment float4 cell_text_fragment( } case MODE_TEXT_COLOR: { - // For now, we assume that color glyphs are - // already premultiplied Display P3 colors. + // For now, we assume that color glyphs + // are already premultiplied linear colors. float4 color = textureColor.sample(textureSampler, in.tex_coord); - // If we aren't doing linear blending, we can return this right away. - if (!uniforms.use_linear_blending) { + // If we're doing linear blending, we can return this right away. + if (uniforms.use_linear_blending) { return color; } - // Otherwise we need to linearize the color. Since the alpha is - // premultiplied, we need to divide it out before linearizing. + // Otherwise we need to unlinearize the color. Since the alpha is + // premultiplied, we need to divide it out before unlinearizing. color.rgb /= color.a; - color = linearize(color); + color = unlinearize(color); color.rgb *= color.a; return color; From c465317e4e5c5651b2dd3d061ac1eecfffb4f563 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Jun 2025 17:51:25 -0600 Subject: [PATCH 037/110] font/atlas: fix testing code that used old enum name Forgot to change these instances when I renamed rgb(a) to bgr(a), which was breaking test builds. Also went ahead and fixed some code that was assuming rgba was actually rgba order and added a note to another part. --- src/font/Atlas.zig | 33 +++++++++++++++++++++++---------- src/font/face/freetype.zig | 6 +++--- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/font/Atlas.zig b/src/font/Atlas.zig index 3e3a20ad2..969318943 100644 --- a/src/font/Atlas.zig +++ b/src/font/Atlas.zig @@ -306,7 +306,12 @@ pub fn clear(self: *Atlas) void { } /// Dump the atlas as a PPM to a writer, for debug purposes. -/// Only supports grayscale and rgb atlases. +/// Only supports grayscale and bgr atlases. +/// +/// NOTE: BGR atlases will have the red and blue channels +/// swapped because PPM expects RGB. This would be +/// easy enough to fix so next time someone needs +/// to debug a color atlas they should fix it. pub fn dump(self: Atlas, writer: anytype) !void { try writer.print( \\P{c} @@ -316,7 +321,7 @@ pub fn dump(self: Atlas, writer: anytype) !void { , .{ @as(u8, switch (self.format) { .grayscale => '5', - .rgb => '6', + .bgr => '6', else => { log.err("Unsupported format for dump: {}", .{self.format}); @panic("Cannot dump this atlas format."); @@ -421,8 +426,16 @@ pub const Wasm = struct { // We need to draw pixels so this is format dependent. const buf: []u8 = switch (self.format) { - // RGBA is the native ImageData format - .rgba => self.data, + .bgra => buf: { + // Convert from BGRA to RGBA by swapping every R and B. + var buf: []u8 = try alloc.dupe(u8, self.data); + errdefer alloc.free(buf); + var i: usize = 0; + while (i < self.data.len) : (i += 4) { + std.mem.swap(u8, &buf[i], &buf[i + 2]); + } + break :buf buf; + }, .grayscale => buf: { // Convert from A8 to RGBA so every 4th byte is set to a value. @@ -575,12 +588,12 @@ test "grow" { try testing.expectEqual(@as(u8, 4), atlas.data[atlas.size * 2 + 2]); } -test "writing RGB data" { +test "writing BGR data" { const alloc = testing.allocator; - var atlas = try init(alloc, 32, .rgb); + var atlas = try init(alloc, 32, .bgr); defer atlas.deinit(alloc); - // This is RGB so its 3 bpp + // This is BGR so its 3 bpp const reg = try atlas.reserve(alloc, 1, 2); atlas.set(reg, &[_]u8{ 1, 2, 3, @@ -597,18 +610,18 @@ test "writing RGB data" { try testing.expectEqual(@as(u8, 6), atlas.data[65 * depth + 2]); } -test "grow RGB" { +test "grow BGR" { const alloc = testing.allocator; // Atlas is 4x4 so its a 1px border meaning we only have 2x2 available - var atlas = try init(alloc, 4, .rgb); + var atlas = try init(alloc, 4, .bgr); defer atlas.deinit(alloc); // Get our 2x2, which should be ALL our usable space const reg = try atlas.reserve(alloc, 2, 2); try testing.expectError(Error.AtlasFull, atlas.reserve(alloc, 1, 1)); - // This is RGB so its 3 bpp + // This is BGR so its 3 bpp atlas.set(reg, &[_]u8{ 10, 11, 12, // (0, 0) (x, y) from top-left 13, 14, 15, // (1, 0) diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 9a5f15200..accb891a4 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -925,7 +925,7 @@ test "color emoji" { var lib = try Library.init(alloc); defer lib.deinit(); - var atlas = try font.Atlas.init(alloc, 512, .rgba); + var atlas = try font.Atlas.init(alloc, 512, .bgra); defer atlas.deinit(alloc); var ft_font = try Face.init( @@ -973,14 +973,14 @@ test "color emoji" { } } -test "mono to rgba" { +test "mono to bgra" { const alloc = testing.allocator; const testFont = font.embedded.emoji; var lib = try Library.init(alloc); defer lib.deinit(); - var atlas = try font.Atlas.init(alloc, 512, .rgba); + var atlas = try font.Atlas.init(alloc, 512, .bgra); defer atlas.deinit(alloc); var ft_font = try Face.init(lib, testFont, .{ .size = .{ .points = 12, .xdpi = 72, .ydpi = 72 } }); From 1fb5e8691ad7767eb99029c32352416e83cebe7b Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Jun 2025 20:46:16 -0600 Subject: [PATCH 038/110] naming: ArrayListPool -> ArrayListCollection Also remove unnecessary and confusing default value for the lists. --- ...ist_pool.zig => array_list_collection.zig} | 14 +++++------ src/renderer/cell.zig | 24 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) rename src/datastruct/{array_list_pool.zig => array_list_collection.zig} (69%) diff --git a/src/datastruct/array_list_pool.zig b/src/datastruct/array_list_collection.zig similarity index 69% rename from src/datastruct/array_list_pool.zig rename to src/datastruct/array_list_collection.zig index 72cf8d29f..d3fbddb13 100644 --- a/src/datastruct/array_list_pool.zig +++ b/src/datastruct/array_list_collection.zig @@ -1,16 +1,16 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -/// A pool of ArrayLists with methods for bulk operations. -pub fn ArrayListPool(comptime T: type) type { +/// A collection of ArrayLists with methods for bulk operations. +pub fn ArrayListCollection(comptime T: type) type { return struct { - const Self = ArrayListPool(T); + const Self = ArrayListCollection(T); const ArrayListT = std.ArrayListUnmanaged(T); - // An array containing the lists that belong to this pool. - lists: []ArrayListT = &[_]ArrayListT{}, + // An array containing the lists that belong to this collection. + lists: []ArrayListT, - // The pool will be initialized with empty ArrayLists. + // The collection will be initialized with empty ArrayLists. pub fn init( alloc: Allocator, list_count: usize, @@ -34,7 +34,7 @@ pub fn ArrayListPool(comptime T: type) type { alloc.free(self.lists); } - /// Clear all lists in the pool. + /// Clear all lists in the collection, retaining capacity. pub fn reset(self: *Self) void { for (self.lists) |*list| { list.clearRetainingCapacity(); diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index e8114aea6..ef7122699 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -6,7 +6,7 @@ const font = @import("../font/main.zig"); const terminal = @import("../terminal/main.zig"); const renderer = @import("../renderer.zig"); const shaderpkg = renderer.Renderer.API.shaders; -const ArrayListPool = @import("../datastruct/array_list_pool.zig").ArrayListPool; +const ArrayListCollection = @import("../datastruct/array_list_collection.zig").ArrayListCollection; /// The possible cell content keys that exist. pub const Key = enum { @@ -49,9 +49,9 @@ pub const Contents = struct { /// of directly indexing in order to avoid integer size bugs. bg_cells: []shaderpkg.CellBg = undefined, - /// The ArrayListPool which holds all of the foreground cells. When sized - /// with Contents.resize the individual ArrayLists are given enough room - /// that they can hold a single row with #cols glyphs, underlines, and + /// The ArrayListCollection which holds all of the foreground cells. When + /// sized with Contents.resize the individual ArrayLists are given enough + /// room that they can hold a single row with #cols glyphs, underlines, and /// strikethroughs; however, appendAssumeCapacity MUST NOT be used since /// it is possible to exceed this with combining glyphs that add a glyph /// but take up no column since they combine with the previous one, as @@ -65,12 +65,12 @@ pub const Contents = struct { /// composite. /// /// Rows are indexed as Contents.fg_rows[y + 1], because the first list in - /// the pool is reserved for the cursor, which must be the first item in - /// the buffer. + /// the collection is reserved for the cursor, which must be the first item + /// in the buffer. /// /// Must be initialized by calling resize on the Contents struct before /// calling any operations. - fg_rows: ArrayListPool(shaderpkg.CellText) = .{}, + fg_rows: ArrayListCollection(shaderpkg.CellText) = .{ .lists = &.{} }, pub fn deinit(self: *Contents, alloc: Allocator) void { alloc.free(self.bg_cells); @@ -105,7 +105,7 @@ pub const Contents = struct { // // We have size.rows + 1 lists because index 0 is used for a special // list containing the cursor cell which needs to be first in the buffer. - var fg_rows = try ArrayListPool(shaderpkg.CellText).init( + var fg_rows = try ArrayListCollection(shaderpkg.CellText).init( alloc, size.rows + 1, size.columns * 3, @@ -174,8 +174,8 @@ pub const Contents = struct { .strikethrough, .overline, // We have a special list containing the cursor cell at the start - // of our fg row pool, so we need to add 1 to the y to get the - // correct index. + // of our fg row collection, so we need to add 1 to the y to get + // the correct index. => try self.fg_rows.lists[y + 1].append(alloc, cell), } } @@ -187,8 +187,8 @@ pub const Contents = struct { @memset(self.bg_cells[@as(usize, y) * self.size.columns ..][0..self.size.columns], .{ 0, 0, 0, 0 }); // We have a special list containing the cursor cell at the start - // of our fg row pool, so we need to add 1 to the y to get the - // correct index. + // of our fg row collection, so we need to add 1 to the y to get + // the correct index. self.fg_rows.lists[y + 1].clearRetainingCapacity(); } }; From b8bc37fb95488197ba68e6d52b08d1bfc7178a8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aindri=C3=BA=20Mac=20Giolla=20Eoin?= Date: Tue, 24 Jun 2025 09:22:33 +0100 Subject: [PATCH 039/110] =?UTF-8?q?Changed=20Pail=C3=A9ad=20ord=C3=BA=20to?= =?UTF-8?q?=20Pail=C3=A9ad=20ordaithe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- po/ga_IE.UTF-8.po | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/po/ga_IE.UTF-8.po b/po/ga_IE.UTF-8.po index 1f1740d09..838497954 100644 --- a/po/ga_IE.UTF-8.po +++ b/po/ga_IE.UTF-8.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2025-04-23 16:58+0800\n" -"PO-Revision-Date: 2025-06-23 09:00+0100\n" +"PO-Revision-Date: 2025-06-24 09:21+0100\n" "Last-Translator: Aindriú Mac Giolla Eoin \n" "Language-Team: Irish \n" "Language: ga\n" @@ -156,7 +156,7 @@ msgstr "Oscail cumraíocht" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 msgid "Command Palette" -msgstr "Pailéad ordú" +msgstr "Pailéad ordaithe" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 msgid "Terminal Inspector" From 865ba546a99cb685a263f3801def16891f45ae95 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Jun 2025 12:29:24 -0400 Subject: [PATCH 040/110] cli: +edit-config command to open the config file in $EDITOR This adds a new CLI `ghostty +edit-config`. This will open the config file in the user's specified `$EDITOR`. If Ghostty has never been configured, this will also create the initial config file with some default templated contents (the same as that which we introduced back in Ghostty 1.0.1 or something -- not new behavior here). This is useful on its own because it will find the correct configuration path to open. If users are terminal users anyway (not a big stretch since this is a terminal app), this will allow them to easily edit config right away. This is also forward looking: I want to replace our "Open Config" action to open a Ghostty window executing this command so that users can edit their config in a terminal editor. This has been heavily requested since forever (short of a full GUI settings editor, which is not ready yet). I don't do this in this PR but plan to in a future PR. --- src/cli/action.zig | 6 ++ src/cli/edit_config.zig | 159 ++++++++++++++++++++++++++++++++++++++++ src/config/Config.zig | 124 +++++++++++++++++++++++++++---- src/os/main.zig | 1 + 4 files changed, 274 insertions(+), 16 deletions(-) create mode 100644 src/cli/edit_config.zig diff --git a/src/cli/action.zig b/src/cli/action.zig index a53e55ef8..009afb4c9 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -9,6 +9,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 edit_config = @import("edit_config.zig"); const show_config = @import("show_config.zig"); const validate_config = @import("validate_config.zig"); const crash_report = @import("crash_report.zig"); @@ -40,6 +41,9 @@ pub const Action = enum { /// List keybind actions @"list-actions", + /// Edit the config file in the configured terminal editor. + @"edit-config", + /// Dump the config to stdout @"show-config", @@ -151,6 +155,7 @@ pub const Action = enum { .@"list-themes" => try list_themes.run(alloc), .@"list-colors" => try list_colors.run(alloc), .@"list-actions" => try list_actions.run(alloc), + .@"edit-config" => try edit_config.run(alloc), .@"show-config" => try show_config.run(alloc), .@"validate-config" => try validate_config.run(alloc), .@"crash-report" => try crash_report.run(alloc), @@ -187,6 +192,7 @@ pub const Action = enum { .@"list-themes" => list_themes.Options, .@"list-colors" => list_colors.Options, .@"list-actions" => list_actions.Options, + .@"edit-config" => edit_config.Options, .@"show-config" => show_config.Options, .@"validate-config" => validate_config.Options, .@"crash-report" => crash_report.Options, diff --git a/src/cli/edit_config.zig b/src/cli/edit_config.zig new file mode 100644 index 000000000..3be88e090 --- /dev/null +++ b/src/cli/edit_config.zig @@ -0,0 +1,159 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const args = @import("args.zig"); +const Allocator = std.mem.Allocator; +const Action = @import("action.zig").Action; +const configpkg = @import("../config.zig"); +const internal_os = @import("../os/main.zig"); +const Config = configpkg.Config; + +pub const Options = struct { + pub fn deinit(self: Options) void { + _ = self; + } + + /// Enables `-h` and `--help` to work. + pub fn help(self: Options) !void { + _ = self; + return Action.help_error; + } +}; + +/// The `edit-config` command opens the Ghostty configuration file in the +/// editor specified by the `$VISUAL` or `$EDITOR` environment variables. +/// +/// IMPORTANT: This command will not reload the configuration after +/// editing. You will need to manually reload the configuration using the +/// application menu, configured keybind, or by restarting Ghostty. We +/// plan to auto-reload in the future, but Ghostty isn't capable of +/// this yet. +/// +/// The filepath opened is the default user-specific configuration +/// file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config`. +/// On macOS, this may also be located at +/// `~/Library/Application Support/com.mitchellh.ghostty/config`. +/// On macOS, whichever path exists and is non-empty will be prioritized, +/// prioritizing the Application Support directory if neither are +/// non-empty. +/// +/// This command prefers the `$VISUAL` environment variable over `$EDITOR`, +/// if both are set. If neither are set, it will print an error +/// and exit. +pub fn run(alloc: Allocator) !u8 { + // Implementation note (by @mitchellh): I do proper memory cleanup + // throughout this command, even though we plan on doing `exec`. + // I do this out of good hygiene in case we ever change this to + // not using `exec` anymore and because this command isn't performance + // critical where setting up the defer cleanup is a problem. + + const stderr = std.io.getStdErr().writer(); + + var opts: Options = .{}; + defer opts.deinit(); + + { + var iter = try args.argsIterator(alloc); + defer iter.deinit(); + try args.parse(Options, alloc, &opts, &iter); + } + + // We load the configuration once because that will write our + // default configuration files to disk. We don't use the config. + var config = try Config.load(alloc); + defer config.deinit(); + + // Find the preferred path. + const path = try Config.preferredDefaultFilePath(alloc); + defer alloc.free(path); + + // We don't currently support Windows because we use the exec syscall. + if (comptime builtin.os.tag == .windows) { + try stderr.print( + \\The `ghostty +edit-config` command is not supported on Windows. + \\Please edit the configuration file manually at the following path: + \\ + \\{s} + \\ + , + .{path}, + ); + return 1; + } + + // Get our editor + const get_env_: ?internal_os.GetEnvResult = env: { + // VISUAL vs. EDITOR: https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference + if (try internal_os.getenv(alloc, "VISUAL")) |v| { + if (v.value.len > 0) break :env v; + v.deinit(alloc); + } + + if (try internal_os.getenv(alloc, "EDITOR")) |v| { + if (v.value.len > 0) break :env v; + v.deinit(alloc); + } + + break :env null; + }; + defer if (get_env_) |v| v.deinit(alloc); + const editor: []const u8 = if (get_env_) |v| v.value else ""; + + // If we don't have `$EDITOR` set then we can't do anything + // but we can still print a helpful message. + if (editor.len == 0) { + try stderr.print( + \\The $EDITOR or $VISUAL environment variable is not set or is empty. + \\This environment variable is required to edit the Ghostty configuration + \\via this CLI command. + \\ + \\Please set the environment variable to your preferred terminal + \\text editor and try again. + \\ + \\If you prefer to edit the configuration file another way, + \\you can find the configuration file at the following path: + \\ + \\ + , + .{}, + ); + + // Output the path using the OSC8 sequence so that it is linked. + try stderr.print( + "\x1b]8;;file://{s}\x1b\\{s}\x1b]8;;\x1b\\\n", + .{ path, path }, + ); + + return 1; + } + + // We require libc because we want to use std.c.environ for envp + // and not have to build that ourselves. We can remove this + // limitation later but Ghostty already heavily requires libc + // so this is not a big deal. + comptime assert(builtin.link_libc); + + const editorZ = try alloc.dupeZ(u8, editor); + defer alloc.free(editorZ); + const pathZ = try alloc.dupeZ(u8, path); + defer alloc.free(pathZ); + const err = std.posix.execvpeZ( + editorZ, + &.{ editorZ, pathZ }, + std.c.environ, + ); + + // If we reached this point then exec failed. + try stderr.print( + \\Failed to execute the editor. Error code={}. + \\ + \\This is usually due to the executable path not existing, invalid + \\permissions, or the shell environment not being set up + \\correctly. + \\ + \\Editor: {s} + \\Path: {s} + \\ + , .{ err, editor, path }); + return 1; +} diff --git a/src/config/Config.zig b/src/config/Config.zig index 6bc3a7f23..d309ea590 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2759,24 +2759,20 @@ pub fn loadIter( /// `path` must be resolved and absolute. pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void { assert(std.fs.path.isAbsolute(path)); - - var file = try std.fs.openFileAbsolute(path, .{}); - defer file.close(); - - const stat = try file.stat(); - switch (stat.kind) { - .file => {}, - else => |kind| { - log.warn("config-file {s}: not reading because file type is {s}", .{ - path, - @tagName(kind), - }); + var file = openFile(path) catch |err| switch (err) { + error.NotAFile => { + log.warn( + "config-file {s}: not reading because it is not a file", + .{path}, + ); return; }, - } + + else => return err, + }; + defer file.close(); std.log.info("reading configuration file path={s}", .{path}); - var buf_reader = std.io.bufferedReader(file.reader()); const reader = buf_reader.reader(); const Iter = cli.args.LineIterator(@TypeOf(reader)); @@ -2831,13 +2827,13 @@ fn writeConfigTemplate(path: []const u8) !void { /// is also loaded. pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { // Load XDG first - const xdg_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" }); + const xdg_path = try defaultXdgPath(alloc); defer alloc.free(xdg_path); const xdg_action = self.loadOptionalFile(alloc, xdg_path); // On macOS load the app support directory as well if (comptime builtin.os.tag == .macos) { - const app_support_path = try internal_os.macos.appSupportDir(alloc, "config"); + const app_support_path = try defaultAppSupportPath(alloc); defer alloc.free(app_support_path); const app_support_action = self.loadOptionalFile(alloc, app_support_path); @@ -2857,6 +2853,102 @@ pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { } } +/// Default path for the XDG home configuration file. Returned value +/// must be freed by the caller. +fn defaultXdgPath(alloc: Allocator) ![]const u8 { + return try internal_os.xdg.config( + alloc, + .{ .subdir = "ghostty/config" }, + ); +} + +/// Default path for the macOS Application Support configuration file. +/// Returned value must be freed by the caller. +fn defaultAppSupportPath(alloc: Allocator) ![]const u8 { + return try internal_os.macos.appSupportDir(alloc, "config"); +} + +/// Returns the path to the preferred default configuration file. +/// This is the file where users should place their configuration. +/// +/// This doesn't create or populate the file with any default +/// contents; downstream callers must handle this. +/// +/// The returned value must be freed by the caller. +pub fn preferredDefaultFilePath(alloc: Allocator) ![]const u8 { + switch (builtin.os.tag) { + .macos => { + // macOS prefers the Application Support directory + // if it exists. + const app_support_path = try defaultAppSupportPath(alloc); + if (openFile(app_support_path)) |f| { + f.close(); + return app_support_path; + } else |_| {} + + // Try the XDG path if it exists + const xdg_path = try defaultXdgPath(alloc); + if (openFile(xdg_path)) |f| { + f.close(); + alloc.free(app_support_path); + return xdg_path; + } else |_| {} + defer alloc.free(xdg_path); + + // Neither exist, use app support + return app_support_path; + }, + + // All other platforms use XDG only + else => return try defaultXdgPath(alloc), + } +} + +const OpenFileError = error{ + FileNotFound, + FileIsEmpty, + FileOpenFailed, + NotAFile, +}; + +/// Opens the file at the given path and returns the file handle +/// if it exists and is non-empty. This also constrains the possible +/// errors to a smaller set that we can explicitly handle. +fn openFile(path: []const u8) OpenFileError!std.fs.File { + assert(std.fs.path.isAbsolute(path)); + + var file = std.fs.openFileAbsolute( + path, + .{}, + ) catch |err| switch (err) { + error.FileNotFound => return OpenFileError.FileNotFound, + else => { + log.warn("unexpected file open error path={s} err={}", .{ + path, + err, + }); + return OpenFileError.FileOpenFailed; + }, + }; + errdefer file.close(); + + const stat = file.stat() catch |err| { + log.warn("error getting file stat path={s} err={}", .{ + path, + err, + }); + return OpenFileError.FileOpenFailed; + }; + switch (stat.kind) { + .file => {}, + else => return OpenFileError.NotAFile, + } + + if (stat.size == 0) return OpenFileError.FileIsEmpty; + + return file; +} + /// Load and parse the CLI args. pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { switch (builtin.os.tag) { diff --git a/src/os/main.zig b/src/os/main.zig index 582ac75cd..96297211c 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -29,6 +29,7 @@ pub const shell = @import("shell.zig"); // Functions and types pub const CFReleaseThread = @import("cf_release_thread.zig"); pub const TempDir = @import("TempDir.zig"); +pub const GetEnvResult = env.GetEnvResult; pub const getEnvMap = env.getEnvMap; pub const appendEnv = env.appendEnv; pub const appendEnvAlways = env.appendEnvAlways; From 1688f2576ca53ca475a2d0f5295b4f896b74af8f Mon Sep 17 00:00:00 2001 From: Leorize Date: Mon, 10 Mar 2025 16:27:15 -0500 Subject: [PATCH 041/110] core, gtk: implement host resources dir for Flatpak Introduces host resources directory as a new concept: A directory containing application resources that can only be accessed from the host operating system. This is significant for sandboxed application runtimes like Flatpak where shells spawned on the host should have access to application resources to enable integrations. Alongside this, apprt is now allowed to override the resources lookup logic. --- src/Surface.zig | 2 +- src/apprt/gtk.zig | 1 + src/apprt/gtk/flatpak.zig | 29 +++++++++++++++++++++++++++ src/cli/list_themes.zig | 3 ++- src/config/theme.zig | 2 +- src/global.zig | 16 +++++++++------ src/os/main.zig | 1 + src/os/resourcesdir.zig | 42 ++++++++++++++++++++++++++++++++------- 8 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 src/apprt/gtk/flatpak.zig diff --git a/src/Surface.zig b/src/Surface.zig index a25b200f7..6005635d9 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -546,7 +546,7 @@ pub fn init( .shell_integration = config.@"shell-integration", .shell_integration_features = config.@"shell-integration-features", .working_directory = config.@"working-directory", - .resources_dir = global_state.resources_dir, + .resources_dir = global_state.resources_dir.host(), .term = config.term, // Get the cgroup if we're on linux and have the decl. I'd love diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 882448ed7..3193065c4 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -2,6 +2,7 @@ pub const App = @import("gtk/App.zig"); pub const Surface = @import("gtk/Surface.zig"); +pub const resourcesDir = @import("gtk/flatpak.zig").resourcesDir; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/apprt/gtk/flatpak.zig b/src/apprt/gtk/flatpak.zig new file mode 100644 index 000000000..dc47c671b --- /dev/null +++ b/src/apprt/gtk/flatpak.zig @@ -0,0 +1,29 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const build_config = @import("../../build_config.zig"); +const internal_os = @import("../../os/main.zig"); +const glib = @import("glib"); + +pub fn resourcesDir(alloc: Allocator) !internal_os.ResourcesDir { + if (comptime build_config.flatpak) { + // Only consult Flatpak runtime data for host case. + if (internal_os.isFlatpak()) { + var result: internal_os.ResourcesDir = .{ + .app_path = try alloc.dupe(u8, "/app/share/ghostty"), + }; + errdefer alloc.free(result.app_path.?); + + const keyfile = glib.KeyFile.new(); + defer keyfile.unref(); + + if (keyfile.loadFromFile("/.flatpak-info", .{}, null) == 0) return result; + const app_dir = std.mem.span(keyfile.getString("Instance", "app-path", null)) orelse return result; + defer glib.free(app_dir.ptr); + + result.host_path = try std.fs.path.join(alloc, &[_][]const u8{ app_dir, "share", "ghostty" }); + return result; + } + } + + return try internal_os.resourcesDir(alloc); +} diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 4bb8a74eb..e80a92286 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -115,7 +115,8 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { const stderr = std.io.getStdErr().writer(); const stdout = std.io.getStdOut().writer(); - if (global_state.resources_dir == null) + const resources_dir = global_state.resources_dir.app(); + if (resources_dir == null) try stderr.print("Could not find the Ghostty resources directory. Please ensure " ++ "that Ghostty is installed correctly.\n", .{}); diff --git a/src/config/theme.zig b/src/config/theme.zig index 21d6faf08..8fa7c93dc 100644 --- a/src/config/theme.zig +++ b/src/config/theme.zig @@ -56,7 +56,7 @@ pub const Location = enum { }, .resources => try std.fs.path.join(arena_alloc, &.{ - global_state.resources_dir orelse return null, + global_state.resources_dir.app() orelse return null, "themes", }), }; diff --git a/src/global.zig b/src/global.zig index d11dd775b..76b57898b 100644 --- a/src/global.zig +++ b/src/global.zig @@ -9,6 +9,7 @@ const harfbuzz = @import("harfbuzz"); const oni = @import("oniguruma"); const crash = @import("crash/main.zig"); const renderer = @import("renderer.zig"); +const apprt = @import("apprt.zig"); /// We export the xev backend we want to use so that the rest of /// Ghostty can import this once and have access to the proper @@ -35,7 +36,7 @@ pub const GlobalState = struct { /// The app resources directory, equivalent to zig-out/share when we build /// from source. This is null if we can't detect it. - resources_dir: ?[]const u8, + resources_dir: internal_os.ResourcesDir, /// Where logging should go pub const Logging = union(enum) { @@ -62,7 +63,7 @@ pub const GlobalState = struct { .action = null, .logging = .{ .stderr = {} }, .rlimits = .{}, - .resources_dir = null, + .resources_dir = .{}, }; errdefer self.deinit(); @@ -170,11 +171,14 @@ pub const GlobalState = struct { // Find our resources directory once for the app so every launch // hereafter can use this cached value. - self.resources_dir = try internal_os.resourcesDir(self.alloc); - errdefer if (self.resources_dir) |dir| self.alloc.free(dir); + self.resources_dir = rd: { + if (@hasDecl(apprt.runtime, "resourcesDir")) break :rd try apprt.runtime.resourcesDir(self.alloc); + break :rd try internal_os.resourcesDir(self.alloc); + }; + errdefer self.resources_dir.deinit(self.alloc); // Setup i18n - if (self.resources_dir) |v| internal_os.i18n.init(v) catch |err| { + if (self.resources_dir.app()) |v| internal_os.i18n.init(v) catch |err| { std.log.warn("failed to init i18n, translations will not be available err={}", .{err}); }; } @@ -182,7 +186,7 @@ pub const GlobalState = struct { /// Cleans up the global state. This doesn't _need_ to be called but /// doing so in dev modes will check for memory leaks. pub fn deinit(self: *GlobalState) void { - if (self.resources_dir) |dir| self.alloc.free(dir); + self.resources_dir.deinit(self.alloc); // Flush our crash logs crash.deinit(); diff --git a/src/os/main.zig b/src/os/main.zig index 96297211c..906e3d150 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -56,6 +56,7 @@ pub const open = openpkg.open; pub const OpenType = openpkg.Type; pub const pipe = pipepkg.pipe; pub const resourcesDir = resourcesdir.resourcesDir; +pub const ResourcesDir = resourcesdir.ResourcesDir; pub const ShellEscapeWriter = shell.ShellEscapeWriter; test { diff --git a/src/os/resourcesdir.zig b/src/os/resourcesdir.zig index 0ef92d3b3..d4287c1bd 100644 --- a/src/os/resourcesdir.zig +++ b/src/os/resourcesdir.zig @@ -2,13 +2,41 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; +pub const ResourcesDir = struct { + app_path: ?[]const u8 = null, + host_path: ?[]const u8 = null, + + /// Free resources held. Requires the same allocator as when resourcesDir() + /// is called. + pub fn deinit(self: *ResourcesDir, alloc: std.mem.Allocator) void { + if (self.app_path) |p| alloc.free(p); + if (self.host_path) |p| alloc.free(p); + } + + /// Get the directory to the bundled resources directory accessible + /// by the application. + pub fn app(self: *ResourcesDir) ?[]const u8 { + return self.app_path; + } + + /// Get the directory to the bundled resources directory accessible + /// by the host environment (i.e. for sandboxed applications). The + /// returned directory might not be accessible from the application + /// itself. + /// + /// In non-sandboxed environment, this should be the same as app(). + pub fn host(self: *ResourcesDir) ?[]const u8 { + return self.host_path orelse self.app_path; + } +}; + /// Gets the directory to the bundled resources directory, if it /// exists (not all platforms or packages have it). The output is /// owned by the caller. /// /// This is highly Ghostty-specific and can likely be generalized at /// some point but we can cross that bridge if we ever need to. -pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { +pub fn resourcesDir(alloc: std.mem.Allocator) !ResourcesDir { // Use the GHOSTTY_RESOURCES_DIR environment variable in release builds. // // In debug builds we try using terminfo detection first instead, since @@ -20,7 +48,7 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { // freed, do not try to use internal_os.getenv or posix getenv. if (comptime builtin.mode != .Debug) { if (std.process.getEnvVarOwned(alloc, "GHOSTTY_RESOURCES_DIR")) |dir| { - if (dir.len > 0) return dir; + if (dir.len > 0) return .{ .app_path = dir }; } else |err| switch (err) { error.EnvironmentVariableNotFound => {}, else => return err, @@ -38,7 +66,7 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { // Get the path to our running binary var exe_buf: [std.fs.max_path_bytes]u8 = undefined; - var exe: []const u8 = std.fs.selfExePath(&exe_buf) catch return null; + var exe: []const u8 = std.fs.selfExePath(&exe_buf) catch return .{}; // We have an exe path! Climb the tree looking for the terminfo // bundle as we expect it. @@ -50,7 +78,7 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { if (comptime builtin.target.os.tag.isDarwin()) { inline for (sentinels) |sentinel| { if (try maybeDir(&dir_buf, dir, "Contents/Resources", sentinel)) |v| { - return try std.fs.path.join(alloc, &.{ v, "ghostty" }); + return .{ .app_path = try std.fs.path.join(alloc, &.{ v, "ghostty" }) }; } } } @@ -65,7 +93,7 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { if (builtin.target.os.tag == .freebsd) "local/share" else "share", sentinel, )) |v| { - return try std.fs.path.join(alloc, &.{ v, "ghostty" }); + return .{ .app_path = try std.fs.path.join(alloc, &.{ v, "ghostty" }) }; } } } @@ -74,14 +102,14 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { // fallback and use the provided resources dir. if (comptime builtin.mode == .Debug) { if (std.process.getEnvVarOwned(alloc, "GHOSTTY_RESOURCES_DIR")) |dir| { - if (dir.len > 0) return dir; + if (dir.len > 0) return .{ .app_path = dir }; } else |err| switch (err) { error.EnvironmentVariableNotFound => {}, else => return err, } } - return null; + return .{}; } /// Little helper to check if the "base/sub/suffix" directory exists and From faf9d59160b55d649825f4410ddd87bcb9fa85e2 Mon Sep 17 00:00:00 2001 From: Leorize Date: Thu, 15 May 2025 13:31:43 -0500 Subject: [PATCH 042/110] core, apprt: make runtimes implement resourcesDir directly --- src/apprt/browser.zig | 2 ++ src/apprt/embedded.zig | 2 ++ src/apprt/glfw.zig | 2 ++ src/apprt/none.zig | 2 ++ src/global.zig | 5 +---- src/os/resourcesdir.zig | 5 +++-- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/apprt/browser.zig b/src/apprt/browser.zig index d60776a6a..3b1aa468f 100644 --- a/src/apprt/browser.zig +++ b/src/apprt/browser.zig @@ -1,2 +1,4 @@ +const internal_os = @import("../os/main.zig"); +pub const resourcesDir = internal_os.resourcesDir; pub const App = struct {}; pub const Window = struct {}; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 77c22c7f5..31dd2f46b 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -23,6 +23,8 @@ const Config = configpkg.Config; const log = std.log.scoped(.embedded_window); +pub const resourcesDir = internal_os.resourcesDir; + pub const App = struct { /// Because we only expect the embedding API to be used in embedded /// environments, the options are extern so that we can expose it diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 924737074..6e131435d 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -35,6 +35,8 @@ const darwin_enabled = builtin.target.os.tag.isDarwin() and const log = std.log.scoped(.glfw); +pub const resourcesDir = internal_os.resourcesDir; + pub const App = struct { app: *CoreApp, config: Config, diff --git a/src/apprt/none.zig b/src/apprt/none.zig index 76a0a8ecb..76faa88af 100644 --- a/src/apprt/none.zig +++ b/src/apprt/none.zig @@ -1,2 +1,4 @@ +const internal_os = @import("../os/main.zig"); +pub const resourcesDir = internal_os.resourcesDir; pub const App = struct {}; pub const Surface = struct {}; diff --git a/src/global.zig b/src/global.zig index 76b57898b..668d2faec 100644 --- a/src/global.zig +++ b/src/global.zig @@ -171,10 +171,7 @@ pub const GlobalState = struct { // Find our resources directory once for the app so every launch // hereafter can use this cached value. - self.resources_dir = rd: { - if (@hasDecl(apprt.runtime, "resourcesDir")) break :rd try apprt.runtime.resourcesDir(self.alloc); - break :rd try internal_os.resourcesDir(self.alloc); - }; + self.resources_dir = try apprt.runtime.resourcesDir(self.alloc); errdefer self.resources_dir.deinit(self.alloc); // Setup i18n diff --git a/src/os/resourcesdir.zig b/src/os/resourcesdir.zig index d4287c1bd..278de44fc 100644 --- a/src/os/resourcesdir.zig +++ b/src/os/resourcesdir.zig @@ -3,12 +3,13 @@ const builtin = @import("builtin"); const Allocator = std.mem.Allocator; pub const ResourcesDir = struct { + /// Avoid accessing these directly, use the app() and host() methods instead. app_path: ?[]const u8 = null, host_path: ?[]const u8 = null, /// Free resources held. Requires the same allocator as when resourcesDir() /// is called. - pub fn deinit(self: *ResourcesDir, alloc: std.mem.Allocator) void { + pub fn deinit(self: *ResourcesDir, alloc: Allocator) void { if (self.app_path) |p| alloc.free(p); if (self.host_path) |p| alloc.free(p); } @@ -36,7 +37,7 @@ pub const ResourcesDir = struct { /// /// This is highly Ghostty-specific and can likely be generalized at /// some point but we can cross that bridge if we ever need to. -pub fn resourcesDir(alloc: std.mem.Allocator) !ResourcesDir { +pub fn resourcesDir(alloc: Allocator) !ResourcesDir { // Use the GHOSTTY_RESOURCES_DIR environment variable in release builds. // // In debug builds we try using terminfo detection first instead, since From c12bccc9c57691c4634e99673ed5fff6390506c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aindri=C3=BA=20Mac=20Giolla=20Eoin?= Date: Tue, 24 Jun 2025 12:45:21 +0100 Subject: [PATCH 043/110] =?UTF-8?q?Replaced=206=20strings=20with=20t=C3=A1?= =?UTF-8?q?b=20rather=20than=20cluais=C3=ADn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- po/ga_IE.UTF-8.po | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/po/ga_IE.UTF-8.po b/po/ga_IE.UTF-8.po index 838497954..3f7d6b068 100644 --- a/po/ga_IE.UTF-8.po +++ b/po/ga_IE.UTF-8.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2025-04-23 16:58+0800\n" -"PO-Revision-Date: 2025-06-24 09:21+0100\n" +"PO-Revision-Date: 2025-06-24 12:42+0100\n" "Last-Translator: Aindriú Mac Giolla Eoin \n" "Language-Team: Irish \n" "Language: ga\n" @@ -118,18 +118,18 @@ msgstr "Athraigh teideal…" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:59 msgid "Tab" -msgstr "Cluaisín" +msgstr "Táb" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:62 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30 #: src/apprt/gtk/Window.zig:255 msgid "New Tab" -msgstr "Cluaisín nua" +msgstr "Táb nua" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:67 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:35 msgid "Close Tab" -msgstr "Dún cluaisín" +msgstr "Dún táb" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:73 msgid "Window" @@ -220,7 +220,7 @@ msgstr "Príomh-Roghchlár" #: src/apprt/gtk/Window.zig:229 msgid "View Open Tabs" -msgstr "Féach ar na cluaisíní oscailte" +msgstr "Féach ar na táib oscailte" #: src/apprt/gtk/Window.zig:256 msgid "New Split" @@ -258,7 +258,7 @@ msgstr "Dún fuinneog?" #: src/apprt/gtk/CloseDialog.zig:89 msgid "Close Tab?" -msgstr "Dún cluaisín?" +msgstr "Dún táb?" #: src/apprt/gtk/CloseDialog.zig:90 msgid "Close Split?" @@ -274,7 +274,7 @@ msgstr "Cuirfear deireadh le gach seisiún teirminéil san fhuinneog seo." #: src/apprt/gtk/CloseDialog.zig:98 msgid "All terminal sessions in this tab will be terminated." -msgstr "Cuirfear deireadh le gach seisiún críochfoirt sa chluaisín seo." +msgstr "Cuirfear deireadh le gach seisiún teirminéil sa táb seo." #: src/apprt/gtk/CloseDialog.zig:99 msgid "The currently running process in this split will be terminated." From 3d89fadc85d6160ae0cea61efbdbec749557dce3 Mon Sep 17 00:00:00 2001 From: Peter Bui Date: Mon, 23 Jun 2025 05:05:58 -0400 Subject: [PATCH 044/110] fix: always wait on open command to avoid defunct processes To avoid blocking on waiting for the child process, perform the wait in a detached thread. --- src/os/open.zig | 95 ++++++++++++++++++++++++++----------------------- 1 file changed, 51 insertions(+), 44 deletions(-) diff --git a/src/os/open.zig b/src/os/open.zig index 39f28036f..ce62a7e0b 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -2,6 +2,8 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; +const log = std.log.scoped(.@"os-open"); + /// The type of the data at the URL to open. This is used as a hint /// to potentially open the URL in a different way. pub const Type = enum { @@ -12,68 +14,73 @@ pub const Type = enum { /// 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. +/// Output on stdout is ignored. The allocator is used to buffer the +/// log output and may allocate from another thread. pub fn open( alloc: Allocator, typ: Type, url: []const u8, ) !void { - const cmd: OpenCommand = switch (builtin.os.tag) { - .linux, .freebsd => .{ .child = std.process.Child.init( + var exe: std.process.Child = switch (builtin.os.tag) { + .linux, .freebsd => .init( &.{ "xdg-open", url }, alloc, - ) }, + ), - .windows => .{ .child = std.process.Child.init( + .windows => .init( &.{ "rundll32", "url.dll,FileProtocolHandler", url }, alloc, - ) }, + ), - .macos => .{ - .child = std.process.Child.init( - switch (typ) { - .text => &.{ "open", "-t", url }, - .unknown => &.{ "open", url }, - }, - alloc, - ), - .wait = true, - }, + .macos => .init( + switch (typ) { + .text => &.{ "open", "-t", url }, + .unknown => &.{ "open", url }, + }, + alloc, + ), .ios => return error.Unimplemented, else => @compileError("unsupported OS"), }; - var exe = cmd.child; - if (cmd.wait) { - // Pipe stdout/stderr so we can collect output from the command - exe.stdout_behavior = .Pipe; - exe.stderr_behavior = .Pipe; - } + // Pipe stdout/stderr so we can collect output from the command. + // This must be set before spawning the process. + exe.stdout_behavior = .Pipe; + exe.stderr_behavior = .Pipe; + // Spawn the process on our same thread so we can detect failure + // quickly. try exe.spawn(); - if (cmd.wait) { - // 50 KiB is the default value used by std.process.Child.run - const output_max_size = 50 * 1024; - - var stdout: std.ArrayListUnmanaged(u8) = .{}; - var stderr: std.ArrayListUnmanaged(u8) = .{}; - defer { - stdout.deinit(alloc); - stderr.deinit(alloc); - } - - 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) std.log.err("open stderr={s}", .{stderr.items}); - } + // 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 }); + thread.detach(); } -const OpenCommand = struct { - child: std.process.Child, - wait: bool = false, -}; +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); + } + + // 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}); +} From 32e61d2a23ad7808fd46db56141da22fff8bbf53 Mon Sep 17 00:00:00 2001 From: Leorize Date: Mon, 23 Jun 2025 20:31:53 -0500 Subject: [PATCH 045/110] flatpak: remove references to systemd unit Flatpak currently does not export systemd user units. As such, remove references to it from D-Bus services to prevent D-Bus daemon from trying to start a non-existent service. Additionally, make sure that the D-Bus service name is correct for debug builds. --- flatpak/com.mitchellh.ghostty-debug.yml | 7 +++++++ flatpak/com.mitchellh.ghostty.yml | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/flatpak/com.mitchellh.ghostty-debug.yml b/flatpak/com.mitchellh.ghostty-debug.yml index 8a2c0056e..0ac8851e0 100644 --- a/flatpak/com.mitchellh.ghostty-debug.yml +++ b/flatpak/com.mitchellh.ghostty-debug.yml @@ -52,6 +52,13 @@ modules: --prefix /app --search-prefix /app --system $PWD/vendor/p + + # Rename to match service and drop systemd references + - sed -e 's/^Name=.*/\0-debug/' + -e '/^SystemdService=/d' + /app/share/dbus-1/services/com.mitchellh.ghostty.service + > /app/share/dbus-1/services/com.mitchellh.ghostty-debug.service + - rm /app/share/dbus-1/services/com.mitchellh.ghostty.service sources: - type: dir path: .. diff --git a/flatpak/com.mitchellh.ghostty.yml b/flatpak/com.mitchellh.ghostty.yml index 1b119c11b..9ba64dc0e 100644 --- a/flatpak/com.mitchellh.ghostty.yml +++ b/flatpak/com.mitchellh.ghostty.yml @@ -47,6 +47,10 @@ modules: --prefix /app --search-prefix /app --system $PWD/vendor/p + + # Remove references to the user service. Flatpak does not export those. + - sed -i '/^SystemdService=/d' + /app/share/dbus-1/services/com.mitchellh.ghostty.service sources: - type: dir path: .. From 93dcb1954dfc059ea50d00fee3b7333ab057732a Mon Sep 17 00:00:00 2001 From: David Rubin Date: Tue, 24 Jun 2025 15:43:48 -0700 Subject: [PATCH 046/110] faster glyph hashing There are two main improvements being made here. First, we move away from using autohash and instead use a one-shot strategy similar to the Style hashing. Since the GlyphKey includes the Metrics struct, which contains quite a few fields, autohash was performing expensive and unnecessary repeated updates. The second improvement is actually just, not hashing Metrics. By ignoring the Metrics field, we can fit the rest of the GlyphKey into a 64-bit packed struct and just return that as the hash! It ends up being unique for each GlyphKey in renderGlyph, and is nearly a zero-cost operation. This ends up boosting the performance (on my machine at least), from around 560fps to 590fps on the DOOM-fire benchmark. --- src/font/SharedGrid.zig | 35 ++++++++++++++++++++++++++++++++++- src/terminal/style.zig | 8 ++++---- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index ad385abb5..dcfa0a551 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -40,7 +40,7 @@ const log = std.log.scoped(.font_shared_grid); codepoints: std.AutoHashMapUnmanaged(CodepointKey, ?Collection.Index) = .{}, /// Cache for glyph renders into the atlas. -glyphs: std.AutoHashMapUnmanaged(GlyphKey, Render) = .{}, +glyphs: std.HashMapUnmanaged(GlyphKey, Render, GlyphKey.Context, 80) = .{}, /// The texture atlas to store renders in. The Glyph data in the glyphs /// cache is dependent on the atlas matching. @@ -307,6 +307,39 @@ const GlyphKey = struct { index: Collection.Index, glyph: u32, opts: RenderOptions, + + const Context = struct { + pub fn hash(_: Context, key: GlyphKey) u64 { + return @bitCast(Packed.from(key)); + } + + pub fn eql(_: Context, a: GlyphKey, b: GlyphKey) bool { + return Packed.from(a) == Packed.from(b); + } + }; + + const Packed = packed struct(u64) { + index: Collection.Index, + glyph: u32, + opts: packed struct(u16) { + cell_width: u2, + thicken: bool, + thicken_strength: u8, + _padding: u5 = 0, + }, + + inline fn from(key: GlyphKey) Packed { + return .{ + .index = key.index, + .glyph = key.glyph, + .opts = .{ + .cell_width = key.opts.cell_width orelse 0, + .thicken = key.opts.thicken, + .thicken_strength = key.opts.thicken_strength, + }, + }; + } + }; }; const TestMode = enum { normal }; diff --git a/src/terminal/style.zig b/src/terminal/style.zig index f35a4e1f7..865e15f64 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -8,9 +8,6 @@ const Offset = size.Offset; const OffsetBuf = size.OffsetBuf; const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet; -const XxHash3 = std.hash.XxHash3; -const autoHash = std.hash.autoHash; - /// The unique identifier for a style. This is at most the number of cells /// that can fit into a terminal page. pub const Id = size.CellCountInt; @@ -313,12 +310,15 @@ pub const Style = struct { pub fn hash(self: *const Style) u64 { const packed_style = PackedStyle.fromStyle(self.*); - return XxHash3.hash(0, std.mem.asBytes(&packed_style)); + return std.hash.XxHash3.hash(0, std.mem.asBytes(&packed_style)); } comptime { assert(@sizeOf(PackedStyle) == 16); assert(std.meta.hasUniqueRepresentation(PackedStyle)); + for (@typeInfo(PackedStyle.Data).@"union".fields) |field| { + assert(@bitSizeOf(field.type) == @bitSizeOf(PackedStyle.Data)); + } } }; From f941a2185ee796d3fe115e6fe87866d963578ad7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Jun 2025 22:06:33 -0400 Subject: [PATCH 047/110] Revert "linux: add dbus and systemd activation services (#7433)" Reverts two commits: 977cd530c7bb9551de93900170bdaec4601b1b5b 820b7e432b57cd08c49d2e76cce4cb78016f0418 These break build from source on Linux for two reasons: 1.) The systemd user service needs to be installed in the `share` prefix, not the `lib` prefix. This lets it get picked up in `~/.local` but is also correct for just standard FHS paths. 2.) The `ghostty` path in the systemd user service needs to be absolute. We should interpolate in the build install prefix to form an absolute path. --- dist/linux/app.desktop | 7 ++----- dist/linux/dbus.service | 4 ---- dist/linux/systemd.service | 7 ------- flatpak/com.mitchellh.ghostty-debug.yml | 7 ------- flatpak/com.mitchellh.ghostty.yml | 4 ---- nix/package.nix | 5 ----- src/apprt/gtk/App.zig | 23 +++-------------------- src/apprt/gtk/Surface.zig | 9 --------- src/build/GhosttyResources.zig | 10 ---------- src/config/Config.zig | 7 +------ 10 files changed, 6 insertions(+), 77 deletions(-) delete mode 100644 dist/linux/dbus.service delete mode 100644 dist/linux/systemd.service diff --git a/dist/linux/app.desktop b/dist/linux/app.desktop index 4475617f9..6e464ea87 100644 --- a/dist/linux/app.desktop +++ b/dist/linux/app.desktop @@ -1,10 +1,8 @@ [Desktop Entry] -Version=1.0 Name=Ghostty Type=Application Comment=A terminal emulator -TryExec=ghostty -Exec=ghostty --launched-from=desktop +Exec=ghostty Icon=com.mitchellh.ghostty Categories=System;TerminalEmulator; Keywords=terminal;tty;pty; @@ -18,8 +16,7 @@ X-TerminalArgTitle=--title= X-TerminalArgAppId=--class= X-TerminalArgDir=--working-directory= X-TerminalArgHold=--wait-after-command -DBusActivatable=true [Desktop Action new-window] Name=New Window -Exec=ghostty --launched-from=desktop +Exec=ghostty diff --git a/dist/linux/dbus.service b/dist/linux/dbus.service deleted file mode 100644 index 67a80d5dd..000000000 --- a/dist/linux/dbus.service +++ /dev/null @@ -1,4 +0,0 @@ -[D-BUS Service] -Name=com.mitchellh.ghostty -SystemdService=com.mitchellh.ghostty.service -Exec=ghostty --launched-from=dbus diff --git a/dist/linux/systemd.service b/dist/linux/systemd.service deleted file mode 100644 index 9699dccdf..000000000 --- a/dist/linux/systemd.service +++ /dev/null @@ -1,7 +0,0 @@ -[Unit] -Description=Ghostty - -[Service] -Type=dbus -BusName=com.mitchellh.ghostty -ExecStart=ghostty --launched-from=systemd diff --git a/flatpak/com.mitchellh.ghostty-debug.yml b/flatpak/com.mitchellh.ghostty-debug.yml index 0ac8851e0..8a2c0056e 100644 --- a/flatpak/com.mitchellh.ghostty-debug.yml +++ b/flatpak/com.mitchellh.ghostty-debug.yml @@ -52,13 +52,6 @@ modules: --prefix /app --search-prefix /app --system $PWD/vendor/p - - # Rename to match service and drop systemd references - - sed -e 's/^Name=.*/\0-debug/' - -e '/^SystemdService=/d' - /app/share/dbus-1/services/com.mitchellh.ghostty.service - > /app/share/dbus-1/services/com.mitchellh.ghostty-debug.service - - rm /app/share/dbus-1/services/com.mitchellh.ghostty.service sources: - type: dir path: .. diff --git a/flatpak/com.mitchellh.ghostty.yml b/flatpak/com.mitchellh.ghostty.yml index 9ba64dc0e..1b119c11b 100644 --- a/flatpak/com.mitchellh.ghostty.yml +++ b/flatpak/com.mitchellh.ghostty.yml @@ -47,10 +47,6 @@ modules: --prefix /app --search-prefix /app --system $PWD/vendor/p - - # Remove references to the user service. Flatpak does not export those. - - sed -i '/^SystemdService=/d' - /app/share/dbus-1/services/com.mitchellh.ghostty.service sources: - type: dir path: .. diff --git a/nix/package.nix b/nix/package.nix index 9b793bc4b..08dfd710b 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -117,11 +117,6 @@ in mkdir -p "$out/nix-support" - sed -i -e "s@^Exec=.*ghostty@Exec=$out/bin/ghostty@" $out/share/applications/com.mitchellh.ghostty.desktop - sed -i -e "s@^TryExec=.*ghostty@TryExec=$out/bin/ghostty@" $out/share/applications/com.mitchellh.ghostty.desktop - sed -i -e "s@^Exec=.*ghostty@Exec=$out/bin/ghostty@" $out/share/dbus-1/services/com.mitchellh.ghostty.service - sed -i -e "s@^ExecStart=.*ghostty@ExecStart=$out/bin/ghostty@" $out/lib/systemd/user/com.mitchellh.ghostty.service - mkdir -p "$terminfo/share" mv "$terminfo_src" "$terminfo/share/terminfo" ln -sf "$terminfo/share/terminfo" "$terminfo_src" diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 93e069376..7c9c15191 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -405,15 +405,11 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // This just calls the `activate` signal but its part of the normal startup // routine so we just call it, but only if the config allows it (this allows // for launching Ghostty in the "background" without immediately opening - // a window). An initial window will not be immediately created if we were - // launched by D-Bus activation or systemd. D-Bus activation will send it's - // own `activate` or `new-window` signal later. + // a window) // // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 - if (config.@"initial-window") switch (config.@"launched-from".?) { - .desktop, .cli => gio_app.activate(), - .dbus, .systemd => {}, - }; + if (config.@"initial-window") + gio_app.activate(); // Internally, GTK ensures that only one instance of this provider exists in the provider list // for the display. @@ -1687,17 +1683,6 @@ fn gtkActionShowGTKInspector( }; } -fn gtkActionNewWindow( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *App, -) callconv(.c) void { - log.info("received new window action", .{}); - _ = self.core_app.mailbox.push(.{ - .new_window = .{}, - }, .{ .forever = {} }); -} - /// This is called to setup the action map that this application supports. /// This should be called only once on startup. fn initActions(self: *App) void { @@ -1717,9 +1702,7 @@ fn initActions(self: *App) void { .{ "reload-config", gtkActionReloadConfig, null }, .{ "present-surface", gtkActionPresentSurface, t }, .{ "show-gtk-inspector", gtkActionShowGTKInspector, null }, - .{ "new-window", gtkActionNewWindow, null }, }; - inline for (actions) |entry| { const action = gio.SimpleAction.new(entry[0], entry[2]); defer action.unref(); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 5c886e663..cf8d651dd 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2325,15 +2325,6 @@ pub fn defaultTermioEnv(self: *Surface) !std.process.EnvMap { env.remove("GDK_DISABLE"); env.remove("GSK_RENDERER"); - // Remove some environment variables that are set when Ghostty is launched - // from a `.desktop` file, by D-Bus activation, or systemd. - env.remove("GIO_LAUNCHED_DESKTOP_FILE"); - env.remove("GIO_LAUNCHED_DESKTOP_FILE_PID"); - env.remove("DBUS_STARTER_ADDRESS"); - env.remove("DBUS_STARTER_BUS_TYPE"); - env.remove("INVOCATION_ID"); - env.remove("JOURNAL_STREAM"); - // Unset environment varies set by snaps if we're running in a snap. // This allows Ghostty to further launch additional snaps. if (env.get("SNAP")) |_| { diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 5a4455a89..640491fd6 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -228,16 +228,6 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { b.path("dist/linux/app.desktop"), "share/applications/com.mitchellh.ghostty.desktop", ).step); - // DBus service for DBus activation - try steps.append(&b.addInstallFile( - b.path("dist/linux/dbus.service"), - "share/dbus-1/services/com.mitchellh.ghostty.service", - ).step); - // systemd user service - try steps.append(&b.addInstallFile( - b.path("dist/linux/systemd.service"), - "lib/systemd/user/com.mitchellh.ghostty.service", - ).step); // AppStream metainfo so that application has rich metadata within app stores try steps.append(&b.addInstallFile( diff --git a/src/config/Config.zig b/src/config/Config.zig index 4fedce110..be719a239 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -942,17 +942,12 @@ title: ?[:0]const u8 = null, /// The setting that will change the application class value. /// /// This controls the class field of the `WM_CLASS` X11 property (when running -/// under X11), the Wayland application ID (when running under Wayland), and the -/// bus name that Ghostty uses to connect to DBus. +/// under X11), and the Wayland application ID (when running under Wayland). /// /// Note that changing this value between invocations will create new, separate /// instances, of Ghostty when running with `gtk-single-instance=true`. See that /// option for more details. /// -/// Changing this value may break launching Ghostty from `.desktop` files, via -/// DBus activation, or systemd user services as the system is expecting Ghostty -/// to connect to DBus using the default `class` when it is launched. -/// /// The class name must follow the requirements defined [in the GTK /// documentation](https://docs.gtk.org/gio/type_func.Application.id_is_valid.html). /// From 9a5aed51a3b45b3c052a849ff263ff459ff9b8f0 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Wed, 25 Jun 2025 15:13:07 +0200 Subject: [PATCH 048/110] config: make split/tab navigation keybinds performable Fixes #7680 --- src/config/Config.zig | 48 ++++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 6b1c9f3f7..55d90933d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4870,25 +4870,29 @@ pub const Keybinds = struct { .{ .key = .{ .unicode = 'w' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .close_tab = {} }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_left }, .mods = .{ .ctrl = true, .shift = true } }, .{ .previous_tab = {} }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_right }, .mods = .{ .ctrl = true, .shift = true } }, .{ .next_tab = {} }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .page_up }, .mods = .{ .ctrl = true } }, .{ .previous_tab = {} }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .page_down }, .mods = .{ .ctrl = true } }, .{ .next_tab = {} }, + .{ .performable = true }, ); try self.set.put( alloc, @@ -4900,57 +4904,67 @@ pub const Keybinds = struct { .{ .key = .{ .unicode = 'e' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_split = .down }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .bracket_left }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .previous }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .bracket_right }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .next }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .up }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_down }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .down }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_left }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .left }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_right }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .right }, + .{ .performable = true }, ); // Resizing splits - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .up, 10 } }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .down, 10 } }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .left, 10 } }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .right, 10 } }, + .{ .performable = true }, ); // Viewport scrolling @@ -5021,22 +5035,24 @@ pub const Keybinds = struct { const end: u21 = '8'; var i: u21 = start; while (i <= end) : (i += 1) { - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .unicode = i }, .mods = mods, }, .{ .goto_tab = (i - start) + 1 }, + .{ .performable = true }, ); } - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .unicode = '9' }, .mods = mods, }, .{ .last_tab = {} }, + .{ .performable = true }, ); } From 832f27596c7f4f6544d53b51608704f8c4724f89 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Wed, 25 Jun 2025 18:32:49 +0200 Subject: [PATCH 049/110] gtk(command_palette): grab focus correctly --- src/apprt/gtk/CommandPalette.zig | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/apprt/gtk/CommandPalette.zig b/src/apprt/gtk/CommandPalette.zig index a99db78d7..edbf5f841 100644 --- a/src/apprt/gtk/CommandPalette.zig +++ b/src/apprt/gtk/CommandPalette.zig @@ -94,9 +94,8 @@ pub fn deinit(self: *CommandPalette) void { pub fn toggle(self: *CommandPalette) void { self.dialog.present(self.window.window.as(gtk.Widget)); - // Focus on the search bar when opening the dialog - self.dialog.setFocus(self.search.as(gtk.Widget)); + _ = self.search.as(gtk.Widget).grabFocus(); } pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !void { From d419e5c922dd77cc10f4a79120a5e8968791e471 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Wed, 25 Jun 2025 18:37:19 +0200 Subject: [PATCH 050/110] gtk(command_palette): filter out more unimplemented actions --- src/apprt/gtk/CommandPalette.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/apprt/gtk/CommandPalette.zig b/src/apprt/gtk/CommandPalette.zig index edbf5f841..81458cdbb 100644 --- a/src/apprt/gtk/CommandPalette.zig +++ b/src/apprt/gtk/CommandPalette.zig @@ -110,6 +110,11 @@ pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !voi switch (command.action) { .close_all_windows, .toggle_secure_input, + .check_for_updates, + .redo, + .undo, + .reset_window_size, + .toggle_window_float_on_top, => continue, else => {}, From dbe6035da01383c78c6d5c1c1782c62c4d4b762d Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Wed, 25 Jun 2025 18:49:57 +0200 Subject: [PATCH 051/110] config: add `command-palette-entry` config option --- src/apprt/gtk/CommandPalette.zig | 2 +- src/config/Config.zig | 160 +++++++++++++++++++++++++++++++ src/input/command.zig | 17 +++- 3 files changed, 177 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/CommandPalette.zig b/src/apprt/gtk/CommandPalette.zig index 81458cdbb..3c8192a50 100644 --- a/src/apprt/gtk/CommandPalette.zig +++ b/src/apprt/gtk/CommandPalette.zig @@ -104,7 +104,7 @@ pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !voi _ = self.arena.reset(.retain_capacity); // TODO: Allow user-configured palette entries - for (inputpkg.command.defaults) |command| { + for (config.@"command-palette-entry".value.items) |command| { // Filter out actions that are not implemented // or don't make sense for GTK switch (command.action) { diff --git a/src/config/Config.zig b/src/config/Config.zig index 7905d00ec..eb5c18ea3 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1975,6 +1975,28 @@ keybind: Keybinds = .{}, /// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title` @"shell-integration-features": ShellIntegrationFeatures = .{}, +/// Custom entries into the command palette. +/// +/// Each entry requires the title, the corresponding action, and an optional +/// description. Each field should be prefixed with the field name, a colon +/// (`:`), and then the specified value. The syntax for actions is identical +/// to the one for keybind actions. Whitespace in between fields is ignored. +/// +/// ```ini +/// command-palette-entry = title:Reset Font Style, action:csi:0m +/// command-palette-entry = title:Crash on Main Thread,description:Causes a crash on the main (UI) thread.,action:crash:main +/// ``` +/// +/// 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 +/// setting to an empty value: +/// +/// ```ini +/// command-palette-entry = +/// ``` +@"command-palette-entry": RepeatableCommand = .{}, + /// Sets the reporting format for OSC sequences that request color information. /// Ghostty currently supports OSC 10 (foreground), OSC 11 (background), and /// OSC 4 (256 color palette) queries, and by default the reported values @@ -2784,6 +2806,9 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { // Add our default keybindings try result.keybind.init(alloc); + // Add our default command palette entries + try result.@"command-palette-entry".init(alloc); + // Add our default link for URL detection try result.link.links.append(alloc, .{ .regex = url.regex, @@ -6114,6 +6139,141 @@ pub const ShellIntegrationFeatures = packed struct { title: bool = true, }; +pub const RepeatableCommand = struct { + value: std.ArrayListUnmanaged(inputpkg.Command) = .empty, + + pub fn init(self: *RepeatableCommand, alloc: Allocator) !void { + self.value = .empty; + try self.value.appendSlice(alloc, inputpkg.command.defaults); + } + + pub fn parseCLI(self: *RepeatableCommand, alloc: Allocator, input: ?[]const u8) !void { + const input_ = input orelse { + self.value.clearRetainingCapacity(); + return; + }; + const cmd = try cli.args.parseAutoStruct(inputpkg.Command, alloc, input_); + try self.value.append(alloc, cmd); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const RepeatableCommand, alloc: Allocator) Allocator.Error!RepeatableCommand { + const value = try self.value.clone(alloc); + for (value.items) |*item| { + item.* = try item.clone(alloc); + } + + return .{ .value = value }; + } + + /// Compare if two of our value are equal. Required by Config. + pub fn equal(self: RepeatableCommand, other: RepeatableCommand) bool { + if (self.value.items.len != other.value.items.len) return false; + for (self.value.items, other.value.items) |a, b| { + if (!a.equal(b)) return false; + } + + return true; + } + + /// Used by Formatter + pub fn formatEntry(self: RepeatableCommand, formatter: anytype) !void { + if (self.value.items.len == 0) { + try formatter.formatEntry(void, {}); + return; + } + + var buf: [4096]u8 = undefined; + for (self.value.items) |item| { + const str = if (item.description.len > 0) std.fmt.bufPrint( + &buf, + "title:{s},description:{s},action:{}", + .{ item.title, item.description, item.action }, + ) else std.fmt.bufPrint( + &buf, + "title:{s},action:{}", + .{ item.title, item.action }, + ); + try formatter.formatEntry([]const u8, str catch return error.OutOfMemory); + } + } + + test "RepeatableCommand parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Foo,action:ignore"); + try list.parseCLI(alloc, "title:Bar,description:bobr,action:text:ale bydle"); + try list.parseCLI(alloc, "title:Quux,description:boo,action:increase_font_size:2.5"); + + try testing.expectEqual(@as(usize, 3), list.value.items.len); + + try testing.expectEqual(inputpkg.Binding.Action.ignore, list.value.items[0].action); + try testing.expectEqualStrings("Foo", list.value.items[0].title); + + try testing.expectEqual( + inputpkg.Binding.Action{ .text = "ale bydle" }, + list.value.items[0].action, + ); + try testing.expectEqualStrings("Bar", list.value.items[1].title); + try testing.expectEqualStrings("bobr", list.value.items[1].description); + + try testing.expectEqual( + inputpkg.Binding.Action{ .increase_font_size = 2.5 }, + list.value.items[0].action, + ); + try testing.expectEqualStrings("Quux", list.value.items[2].title); + try testing.expectEqualStrings("boo", list.value.items[2].description); + + try list.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), list.value.items.len); + } + + test "RepeatableCommand formatConfig empty" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var list: RepeatableCommand = .{}; + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = \n", buf.items); + } + + test "RepeatableCommand formatConfig single item" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Bobr, action:text:Bober"); + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = title:Bobr,action:text:Bober\n", buf.items); + } + + test "RepeatableCommand formatConfig multiple items" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Bobr, action:text:kurwa"); + try list.parseCLI(alloc, "title:Ja, description: pierdole, action:text:jakie bydle"); + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = title:bobr,action:text:kurwa\na = title:Ja,description:pierdole,action:text:jakie bydle\n", buf.items); + } +}; + /// OSC 4, 10, 11, and 12 default color reporting format. pub const OSCColorReportFormat = enum { none, diff --git a/src/input/command.zig b/src/input/command.zig index 94fbf56a5..8ae48eda1 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -18,7 +18,7 @@ const Action = @import("Binding.zig").Action; pub const Command = struct { action: Action, title: [:0]const u8, - description: [:0]const u8, + description: [:0]const u8 = "", /// ghostty_command_s pub const C = extern struct { @@ -28,6 +28,21 @@ pub const Command = struct { description: [*:0]const u8, }; + pub fn clone(self: *const Command, alloc: Allocator) Allocator.Error!Command { + return .{ + .action = try self.action.clone(alloc), + .title = try alloc.dupeZ(u8, self.title), + .description = try alloc.dupeZ(u8, self.description), + }; + } + + pub fn equal(self: Command, other: Command) bool { + if (self.action.hash() != other.action.hash()) return false; + if (!std.mem.eql(u8, self.title, other.title)) return false; + if (!std.mem.eql(u8, self.description, other.description)) return false; + return true; + } + /// Convert this command to a C struct. pub fn comptimeCval(self: Command) C { assert(@inComptime()); From ca5f301eb1be97a942a2ef2c72893984263f5654 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 22 Jun 2025 19:01:54 -0600 Subject: [PATCH 052/110] util: introduce helper for detecting file types --- src/file_type.zig | 102 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/file_type.zig diff --git a/src/file_type.zig b/src/file_type.zig new file mode 100644 index 000000000..18dd7a4a5 --- /dev/null +++ b/src/file_type.zig @@ -0,0 +1,102 @@ +const std = @import("std"); + +const type_details: []const struct { + typ: FileType, + sigs: []const []const ?u8, + exts: []const []const u8, +} = &.{ + .{ + .typ = .jpeg, + .sigs = &.{ + &.{ 0xFF, 0xD8, 0xFF, 0xDB }, + &.{ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01 }, + &.{ 0xFF, 0xD8, 0xFF, 0xEE }, + &.{ 0xFF, 0xD8, 0xFF, 0xE1, null, null, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00 }, + &.{ 0xFF, 0xD8, 0xFF, 0xE0 }, + }, + .exts = &.{ ".jpg", ".jpeg", ".jfif" }, + }, + .{ + .typ = .png, + .sigs = &.{&.{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }}, + .exts = &.{".png"}, + }, + .{ + .typ = .gif, + .sigs = &.{ + &.{ 'G', 'I', 'F', '8', '7', 'a' }, + &.{ 'G', 'I', 'F', '8', '9', 'a' }, + }, + .exts = &.{".gif"}, + }, + .{ + .typ = .bmp, + .sigs = &.{&.{ 'B', 'M' }}, + .exts = &.{".bmp"}, + }, + .{ + .typ = .qoi, + .sigs = &.{&.{ 'q', 'o', 'i', 'f' }}, + .exts = &.{".qoi"}, + }, + .{ + .typ = .webp, + .sigs = &.{ + &.{ 0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50 }, + }, + .exts = &.{".webp"}, + }, +}; + +/// This is a helper for detecting file types based on magic bytes. +/// +/// Ref: https://en.wikipedia.org/wiki/List_of_file_signatures +pub const FileType = enum { + /// JPEG image file. + jpeg, + + /// PNG image file. + png, + + /// GIF image file. + gif, + + /// BMP image file. + bmp, + + /// QOI image file. + qoi, + + /// WebP image file. + webp, + + /// Unknown file format. + unknown, + + /// Detect file type based on the magic bytes + /// at the start of the provided file contents. + pub fn detect(contents: []const u8) FileType { + inline for (type_details) |typ| { + inline for (typ.sigs) |signature| { + if (contents.len >= signature.len) { + for (contents[0..signature.len], signature) |f, sig| { + if (sig) |s| if (f != s) break; + } else { + return typ.typ; + } + } + } + } + return .unknown; + } + + /// Guess file type from its extension. + pub fn guessFromExtension(extension: []const u8) FileType { + inline for (type_details) |typ| { + inline for (typ.exts) |ext| { + if (std.ascii.eqlIgnoreCase(extension, ext)) return typ.typ; + } + } + return .unknown; + } +}; From 03bdb922929ee0d71183e0da32f57bc6191452a2 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 24 Jun 2025 15:04:22 -0600 Subject: [PATCH 053/110] renderer: clean up `image.zig`, reduce repetitive code --- pkg/wuffs/src/main.zig | 2 +- pkg/wuffs/src/swizzle.zig | 18 ++ src/renderer/generic.zig | 49 ++--- src/renderer/image.zig | 417 ++++++++++++++------------------------ 4 files changed, 191 insertions(+), 295 deletions(-) diff --git a/pkg/wuffs/src/main.zig b/pkg/wuffs/src/main.zig index be4eb9184..89f3c008c 100644 --- a/pkg/wuffs/src/main.zig +++ b/pkg/wuffs/src/main.zig @@ -8,7 +8,7 @@ pub const Error = @import("error.zig").Error; pub const ImageData = struct { width: u32, height: u32, - data: []const u8, + data: []u8, }; test { diff --git a/pkg/wuffs/src/swizzle.zig b/pkg/wuffs/src/swizzle.zig index d57da98a9..352cf2b50 100644 --- a/pkg/wuffs/src/swizzle.zig +++ b/pkg/wuffs/src/swizzle.zig @@ -33,6 +33,24 @@ pub fn rgbToRgba(alloc: Allocator, src: []const u8) Error![]u8 { ); } +pub fn bgrToRgba(alloc: Allocator, src: []const u8) Error![]u8 { + return swizzle( + alloc, + src, + c.WUFFS_BASE__PIXEL_FORMAT__BGR, + c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL, + ); +} + +pub fn bgraToRgba(alloc: Allocator, src: []const u8) Error![]u8 { + return swizzle( + alloc, + src, + c.WUFFS_BASE__PIXEL_FORMAT__BGRA_PREMUL, + c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL, + ); +} + fn swizzle( alloc: Allocator, src: []const u8, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 9589cb44b..194c7f910 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1753,9 +1753,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (img_top_y > bot_y) return; if (img_bot_y < top_y) return; - // We need to prep this image for upload if it isn't in the cache OR - // it is in the cache but the transmit time doesn't match meaning this - // image is different. + // We need to prep this image for upload if it isn't in the + // cache OR it is in the cache but the transmit time doesn't + // match meaning this image is different. try self.prepKittyImage(image); // Calculate the dimensions of our image, taking in to @@ -1819,16 +1819,17 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const pending: Image.Pending = .{ .width = image.width, .height = image.height, + .pixel_format = switch (image.format) { + .gray => .gray, + .gray_alpha => .gray_alpha, + .rgb => .rgb, + .rgba => .rgba, + .png => unreachable, // should be decoded by now + }, .data = data.ptr, }; - const new_image: Image = switch (image.format) { - .gray => .{ .pending_gray = pending }, - .gray_alpha => .{ .pending_gray_alpha = pending }, - .rgb => .{ .pending_rgb = pending }, - .rgba => .{ .pending_rgba = pending }, - .png => unreachable, // should be decoded by now - }; + const new_image: Image = .{ .pending = pending }; if (!gop.found_existing) { gop.value_ptr.* = .{ @@ -1842,6 +1843,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { ); } + try gop.value_ptr.image.prepForUpload(self.alloc); + gop.value_ptr.transmit_time = image.transmit_time; } @@ -1850,27 +1853,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { fn uploadKittyImages(self: *Self) !void { var image_it = self.images.iterator(); while (image_it.next()) |kv| { - switch (kv.value_ptr.image) { - .ready => {}, - - .pending_gray, - .pending_gray_alpha, - .pending_rgb, - .pending_rgba, - .replace_gray, - .replace_gray_alpha, - .replace_rgb, - .replace_rgba, - => try kv.value_ptr.image.upload(self.alloc, &self.api), - - .unload_pending, - .unload_replace, - .unload_ready, - => { - kv.value_ptr.image.deinit(self.alloc); - self.images.removeByPtr(kv.key_ptr); - }, + const img = &kv.value_ptr.image; + if (img.isUnloading()) { + img.deinit(self.alloc); + self.images.removeByPtr(kv.key_ptr); + return; } + if (img.isPending()) try img.upload(self.alloc, &self.api); } } diff --git a/src/renderer/image.zig b/src/renderer/image.zig index 277ddd8c0..d89c46730 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -40,34 +40,27 @@ pub const ImageMap = std.AutoHashMapUnmanaged(u32, struct { transmit_time: std.time.Instant, }); -/// The state for a single image that is to be rendered. The image can be -/// pending upload or ready to use with a texture. +/// The state for a single image that is to be rendered. pub const Image = union(enum) { - /// The image is pending upload to the GPU. The different keys are - /// different formats since some formats aren't accepted by the GPU - /// and require conversion. + /// The image data is pending upload to the GPU. /// - /// This data is owned by this union so it must be freed once the - /// image is uploaded. - pending_gray: Pending, - pending_gray_alpha: Pending, - pending_rgb: Pending, - pending_rgba: Pending, + /// This data is owned by this union so it must be freed once uploaded. + pending: Pending, - /// This is the same as the pending states but there is a texture - /// already allocated that we want to replace. - replace_gray: Replace, - replace_gray_alpha: Replace, - replace_rgb: Replace, - replace_rgba: Replace, + /// This is the same as the pending states but there is + /// a texture already allocated that we want to replace. + replace: Replace, /// The image is uploaded and ready to be used. ready: Texture, - /// The image is uploaded but is scheduled to be unloaded. - unload_pending: []u8, + /// The image isn't uploaded yet but is scheduled to be unloaded. + unload_pending: Pending, + /// The image is uploaded and is scheduled to be unloaded. unload_ready: Texture, - unload_replace: struct { []u8, Texture }, + /// The image is uploaded and scheduled to be replaced + /// with new data, but it's also scheduled to be unloaded. + unload_replace: Replace, pub const Replace = struct { texture: Texture, @@ -78,53 +71,58 @@ pub const Image = union(enum) { pub const Pending = struct { height: u32, width: u32, + pixel_format: PixelFormat, - /// Data is always expected to be (width * height * depth). Depth - /// is based on the union key. + /// Data is always expected to be (width * height * bpp). data: [*]u8, - pub fn dataSlice(self: Pending, d: u32) []u8 { - return self.data[0..self.len(d)]; + pub fn dataSlice(self: Pending) []u8 { + return self.data[0..self.len()]; } - pub fn len(self: Pending, d: u32) u32 { - return self.width * self.height * d; + pub fn len(self: Pending) usize { + return self.width * self.height * self.pixel_format.bpp(); } + + pub const PixelFormat = enum { + /// 1 byte per pixel grayscale. + gray, + /// 2 bytes per pixel grayscale + alpha. + gray_alpha, + /// 3 bytes per pixel RGB. + rgb, + /// 3 bytes per pixel BGR. + bgr, + /// 4 byte per pixel RGBA. + rgba, + /// 4 byte per pixel BGRA. + bgra, + + /// Get bytes per pixel for this format. + pub inline fn bpp(self: PixelFormat) usize { + return switch (self) { + .gray => 1, + .gray_alpha => 2, + .rgb => 3, + .bgr => 3, + .rgba => 4, + .bgra => 4, + }; + } + }; }; pub fn deinit(self: Image, alloc: Allocator) void { switch (self) { - .pending_gray => |p| alloc.free(p.dataSlice(1)), - .pending_gray_alpha => |p| alloc.free(p.dataSlice(2)), - .pending_rgb => |p| alloc.free(p.dataSlice(3)), - .pending_rgba => |p| alloc.free(p.dataSlice(4)), - .unload_pending => |data| alloc.free(data), + .pending, + .unload_pending, + => |p| alloc.free(p.dataSlice()), - .replace_gray => |r| { - alloc.free(r.pending.dataSlice(1)); + .replace, .unload_replace => |r| { + alloc.free(r.pending.dataSlice()); r.texture.deinit(); }, - .replace_gray_alpha => |r| { - alloc.free(r.pending.dataSlice(2)); - r.texture.deinit(); - }, - - .replace_rgb => |r| { - alloc.free(r.pending.dataSlice(3)); - r.texture.deinit(); - }, - - .replace_rgba => |r| { - alloc.free(r.pending.dataSlice(4)); - r.texture.deinit(); - }, - - .unload_replace => |r| { - alloc.free(r[0]); - r[1].deinit(); - }, - .ready, .unload_ready, => |t| t.deinit(), @@ -139,150 +137,55 @@ pub const Image = union(enum) { .unload_ready, => return, - .ready => |obj| .{ .unload_ready = obj }, - .pending_gray => |p| .{ .unload_pending = p.dataSlice(1) }, - .pending_gray_alpha => |p| .{ .unload_pending = p.dataSlice(2) }, - .pending_rgb => |p| .{ .unload_pending = p.dataSlice(3) }, - .pending_rgba => |p| .{ .unload_pending = p.dataSlice(4) }, - .replace_gray => |r| .{ .unload_replace = .{ - r.pending.dataSlice(1), r.texture, - } }, - .replace_gray_alpha => |r| .{ .unload_replace = .{ - r.pending.dataSlice(2), r.texture, - } }, - .replace_rgb => |r| .{ .unload_replace = .{ - r.pending.dataSlice(3), r.texture, - } }, - .replace_rgba => |r| .{ .unload_replace = .{ - r.pending.dataSlice(4), r.texture, - } }, + .ready => |t| .{ .unload_ready = t }, + .pending => |p| .{ .unload_pending = p }, + .replace => |r| .{ .unload_replace = r }, }; } - /// Replace the currently pending image with a new one. This will - /// attempt to update the existing texture if it is already allocated. - /// If the texture is not allocated, this will act like a new upload. - /// - /// This function only marks the image for replace. The actual logic - /// to replace is done later. + /// Mark the current image to be replaced with a pending one. This will + /// attempt to update the existing texture if we have one, otherwise it + /// will act like a new upload. pub fn markForReplace(self: *Image, alloc: Allocator, img: Image) !void { - assert(img.pending() != null); + assert(img.isPending()); - // Get our existing texture. This switch statement will also handle - // scenarios where there is no existing texture and we can modify - // the self pointer directly. - const existing: Texture = switch (self.*) { - // For pending, we can free the old - // data and become pending ourselves. - .pending_gray => |p| { - alloc.free(p.dataSlice(1)); - self.* = img; - return; - }, - - .pending_gray_alpha => |p| { - alloc.free(p.dataSlice(2)); - self.* = img; - return; - }, - - .pending_rgb => |p| { - alloc.free(p.dataSlice(3)); - self.* = img; - return; - }, - - .pending_rgba => |p| { - alloc.free(p.dataSlice(4)); - self.* = img; - return; - }, - - // If we're marked for unload but we just have pending data, - // this behaves the same as a normal "pending": free the data, - // become new pending. - .unload_pending => |data| { - alloc.free(data); - self.* = img; - return; - }, - - .unload_replace => |r| existing: { - alloc.free(r[0]); - break :existing r[1]; - }, - - // If we were already pending a replacement, then we free - // our existing pending data and use the same texture. - .replace_gray => |r| existing: { - alloc.free(r.pending.dataSlice(1)); - break :existing r.texture; - }, - - .replace_gray_alpha => |r| existing: { - alloc.free(r.pending.dataSlice(2)); - break :existing r.texture; - }, - - .replace_rgb => |r| existing: { - alloc.free(r.pending.dataSlice(3)); - break :existing r.texture; - }, - - .replace_rgba => |r| existing: { - alloc.free(r.pending.dataSlice(4)); - break :existing r.texture; - }, - - // For both ready and unload_ready, we need to replace - // the texture. We can't do that here, so we just mark - // ourselves for replacement. - .ready, .unload_ready => |tex| tex, - }; - - // We now have an existing texture, so set the proper replace key. - self.* = switch (img) { - .pending_gray => |p| .{ .replace_gray = .{ - .texture = existing, - .pending = p, - } }, - - .pending_gray_alpha => |p| .{ .replace_gray_alpha = .{ - .texture = existing, - .pending = p, - } }, - - .pending_rgb => |p| .{ .replace_rgb = .{ - .texture = existing, - .pending = p, - } }, - - .pending_rgba => |p| .{ .replace_rgba = .{ - .texture = existing, - .pending = p, - } }, - - else => unreachable, - }; + // If we have pending data right now, free it. + if (self.getPending()) |p| { + alloc.free(p.dataSlice()); + } + // If we have an existing texture, use it in the replace. + if (self.getTexture()) |t| { + self.* = .{ .replace = .{ + .texture = t, + .pending = img.getPending().?, + } }; + return; + } + // Otherwise we just become a pending image. + self.* = .{ .pending = img.getPending().? }; } /// Returns true if this image is pending upload. pub fn isPending(self: Image) bool { - return self.pending() != null; + return self.getPending() != null; } - /// Returns true if this image is pending an unload. + /// Returns true if this image has an associated texture. + pub fn hasTexture(self: Image) bool { + return self.getTexture() != null; + } + + /// Returns true if this image is marked for unload. pub fn isUnloading(self: Image) bool { return switch (self) { .unload_pending, + .unload_replace, .unload_ready, => true, + .pending, + .replace, .ready, - .pending_gray, - .pending_gray_alpha, - .pending_rgb, - .pending_rgba, => false, }; } @@ -291,123 +194,109 @@ pub const Image = union(enum) { /// If the data is already in a format that can be uploaded, this is a /// no-op. pub fn convert(self: *Image, alloc: Allocator) wuffs.Error!void { + const p = self.getPendingPointer().?; // As things stand, we currently convert all images to RGBA before // uploading to the GPU. This just makes things easier. In the future // we may want to support other formats. - switch (self.*) { - .ready, - .unload_pending, - .unload_replace, - .unload_ready, - => unreachable, // invalid - - .pending_rgba, - .replace_rgba, - => {}, // ready - - .pending_rgb => |*p| { - const data = p.dataSlice(3); - const rgba = try wuffs.swizzle.rgbToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_rgb => |*r| { - const data = r.pending.dataSlice(3); - const rgba = try wuffs.swizzle.rgbToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - - .pending_gray => |*p| { - const data = p.dataSlice(1); - const rgba = try wuffs.swizzle.gToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_gray => |*r| { - const data = r.pending.dataSlice(2); - const rgba = try wuffs.swizzle.gToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - - .pending_gray_alpha => |*p| { - const data = p.dataSlice(2); - const rgba = try wuffs.swizzle.gaToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_gray_alpha => |*r| { - const data = r.pending.dataSlice(2); - const rgba = try wuffs.swizzle.gaToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - } + if (p.pixel_format == .rgba) return; + // If the pending data isn't RGBA we'll need to swizzle it. + const data = p.dataSlice(); + const rgba = try switch (p.pixel_format) { + .gray => wuffs.swizzle.gToRgba(alloc, data), + .gray_alpha => wuffs.swizzle.gaToRgba(alloc, data), + .rgb => wuffs.swizzle.rgbToRgba(alloc, data), + .bgr => wuffs.swizzle.bgrToRgba(alloc, data), + .rgba => unreachable, + .bgra => wuffs.swizzle.bgraToRgba(alloc, data), + }; + alloc.free(data); + p.data = rgba.ptr; + p.pixel_format = .rgba; } - /// Upload the pending image to the GPU and change the state of this - /// image to ready. + /// Prepare the pending image data for upload to the GPU. + /// This doesn't need GPU access so is safe to call any time. + pub fn prepForUpload(self: *Image, alloc: Allocator) !void { + assert(self.isPending()); + + try self.convert(alloc); + } + + /// Upload the pending image to the GPU and + /// change the state of this image to ready. pub fn upload( self: *Image, alloc: Allocator, api: *const GraphicsAPI, ) !void { - // Convert our data if we have to - try self.convert(alloc); + assert(self.isPending()); + + try self.prepForUpload(alloc); // Get our pending info - const p = self.pending().?; + const p = self.getPending().?; // Create our texture const texture = try Texture.init( api.imageTextureOptions(.rgba, true), @intCast(p.width), @intCast(p.height), - p.data[0 .. p.width * p.height * self.depth()], + p.dataSlice(), ); // Uploaded. We can now clear our data and change our state. // - // NOTE: For "replace_*" states, this will free the old texture. - // We don't currently actually replace the existing texture in-place - // but that is an optimization we can do later. + // NOTE: For the `replace` state, this will free the old texture. + // We don't currently actually replace the existing texture + // in-place but that is an optimization we can do later. self.deinit(alloc); self.* = .{ .ready = texture }; } - /// Our pixel depth - fn depth(self: Image) u32 { + /// Returns any pending image data for this image that requires upload. + /// + /// If there is no pending data to upload, returns null. + fn getPending(self: Image) ?Pending { return switch (self) { - .pending_rgb => 3, - .pending_rgba => 4, - .replace_rgb => 3, - .replace_rgba => 4, - else => unreachable, - }; - } - - /// Returns true if this image is in a pending state and requires upload. - fn pending(self: Image) ?Pending { - return switch (self) { - .pending_rgb, - .pending_rgba, + .pending, + .unload_pending, => |p| p, - .replace_rgb, - .replace_rgba, + .replace, + .unload_replace, => |r| r.pending, else => null, }; } + + /// Returns the texture for this image. + /// + /// If there is no texture for it yet, returns null. + fn getTexture(self: Image) ?Texture { + return switch (self) { + .ready, + .unload_ready, + => |t| t, + + .replace, + .unload_replace, + => |r| r.texture, + + else => null, + }; + } + + // Same as getPending but returns a pointer instead of a copy. + fn getPendingPointer(self: *Image) ?*Pending { + return switch (self.*) { + .pending => return &self.pending, + .unload_pending => return &self.unload_pending, + + .replace => return &self.replace.pending, + .unload_replace => return &self.unload_replace.pending, + + else => null, + }; + } }; From da46a47726f39b5ca564d49ec397d76d4f1725ff Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 25 Jun 2025 09:28:51 -0600 Subject: [PATCH 054/110] renderer: add support for background images Adds support for background images via the `background-image` config. Resolves #3645, supersedes PRs #4226 and #5233. See docs of added config keys for usage details. --- src/config.zig | 3 + src/config/Config.zig | 109 +++++++++ src/renderer/Metal.zig | 1 + src/renderer/OpenGL.zig | 1 + src/renderer/generic.zig | 257 +++++++++++++++++++++- src/renderer/metal/Pipeline.zig | 5 + src/renderer/metal/shaders.zig | 42 ++++ src/renderer/opengl/Pipeline.zig | 1 + src/renderer/opengl/shaders.zig | 42 ++++ src/renderer/shaders/glsl/bg_image.f.glsl | 62 ++++++ src/renderer/shaders/glsl/bg_image.v.glsl | 145 ++++++++++++ src/renderer/shaders/glsl/common.glsl | 1 + src/renderer/shaders/shaders.metal | 212 ++++++++++++++++++ 13 files changed, 871 insertions(+), 10 deletions(-) create mode 100644 src/renderer/shaders/glsl/bg_image.f.glsl create mode 100644 src/renderer/shaders/glsl/bg_image.v.glsl diff --git a/src/config.zig b/src/config.zig index 018d0e6e8..7f390fb08 100644 --- a/src/config.zig +++ b/src/config.zig @@ -31,8 +31,11 @@ pub const RepeatableFontVariation = Config.RepeatableFontVariation; pub const RepeatableString = Config.RepeatableString; pub const RepeatableStringMap = @import("config/RepeatableStringMap.zig"); pub const RepeatablePath = Config.RepeatablePath; +pub const Path = Config.Path; pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures; pub const WindowPaddingColor = Config.WindowPaddingColor; +pub const BackgroundImagePosition = Config.BackgroundImagePosition; +pub const BackgroundImageFit = Config.BackgroundImageFit; // Alternate APIs pub const CAPI = @import("config/CAPI.zig"); diff --git a/src/config/Config.zig b/src/config/Config.zig index 7905d00ec..e1cccef1b 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -466,6 +466,84 @@ background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 }, /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, +/// Background image for the terminal. +/// +/// This should be a path to a PNG or JPEG file, +/// other image formats are not yet supported. +@"background-image": ?Path = null, + +/// Background image opacity. +/// +/// This is relative to the value of `background-opacity`. +/// +/// A value of `1.0` (the default) will result in the background image being +/// placed on top of the general background color, and then the combined result +/// will be adjusted to the opacity specified by `background-opacity`. +/// +/// A value less than `1.0` will result in the background image being mixed +/// with the general background color before the combined result is adjusted +/// to the configured `background-opacity`. +/// +/// A value greater than `1.0` will result in the background image having a +/// higher opacity than the general background color. For instance, if the +/// configured `background-opacity` is `0.5` and `background-image-opacity` +/// is set to `1.5`, then the final opacity of the background image will be +/// `0.5 * 1.5 = 0.75`. +@"background-image-opacity": f32 = 1.0, + +/// Background image position. +/// +/// Valid values are: +/// * `top-left` +/// * `top-center` +/// * `top-right` +/// * `center-left` +/// * `center` +/// * `center-right` +/// * `bottom-left` +/// * `bottom-center` +/// * `bottom-right` +/// +/// The default value is `center`. +@"background-image-position": BackgroundImagePosition = .center, + +/// Background image fit. +/// +/// Valid values are: +/// +/// * `contain` +/// +/// Preserving the aspect ratio, scale the background image to the largest +/// size that can still be contained within the terminal, so that the whole +/// image is visible. +/// +/// * `cover` +/// +/// Preserving the aspect ratio, scale the background image to the smallest +/// size that can completely cover the terminal. This may result in one or +/// more edges of the image being clipped by the edge of the terminal. +/// +/// * `stretch` +/// +/// Stretch the background image to the full size of the terminal, without +/// preserving the aspect ratio. +/// +/// * `none` +/// +/// Don't scale the background image. +/// +/// The default value is `contain`. +@"background-image-fit": BackgroundImageFit = .contain, + +/// Whether to repeat the background image or not. +/// +/// If this is set to true, the background image will be repeated if there +/// would otherwise be blank space around it because it doesn't completely +/// fill the terminal area. +/// +/// The default value is `false`. +@"background-image-repeat": bool = false, + /// The foreground and background color for selection. If this is not set, then /// the selection color is just the inverted window background and foreground /// (note: not to be confused with the cell bg/fg). @@ -3298,6 +3376,15 @@ fn expandPaths(self: *Config, base: []const u8) !void { &self._diagnostics, ); }, + ?RepeatablePath, ?Path => { + if (@field(self, field.name)) |*path| { + try path.expand( + arena_alloc, + base, + &self._diagnostics, + ); + } + }, else => {}, } } @@ -6569,6 +6656,28 @@ pub const AlphaBlending = enum { } }; +/// See background-image-position +pub const BackgroundImagePosition = enum { + @"top-left", + @"top-center", + @"top-right", + @"center-left", + @"center-center", + @"center-right", + @"bottom-left", + @"bottom-center", + @"bottom-right", + center, +}; + +/// See background-image-fit +pub const BackgroundImageFit = enum { + contain, + cover, + stretch, + none, +}; + /// See freetype-load-flag pub const FreetypeLoadFlags = packed struct { // The defaults here at the time of writing this match the defaults diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 39b6f7efc..3899bb8c5 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -282,6 +282,7 @@ pub const uniformBufferOptions = bufferOptions; pub const fgBufferOptions = bufferOptions; pub const bgBufferOptions = bufferOptions; pub const imageBufferOptions = bufferOptions; +pub const bgImageBufferOptions = bufferOptions; /// Returns the options to use when constructing textures. pub inline fn textureOptions(self: Metal) Texture.Options { diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index d254934e4..3b4ba6d80 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -388,6 +388,7 @@ pub const uniformBufferOptions = bufferOptions; pub const fgBufferOptions = bufferOptions; pub const bgBufferOptions = bufferOptions; pub const imageBufferOptions = bufferOptions; +pub const bgImageBufferOptions = bufferOptions; /// Returns the options to use when constructing textures. pub inline fn textureOptions(self: OpenGL) Texture.Options { diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 194c7f910..bf189fc4c 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -2,6 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); const glfw = @import("glfw"); const xev = @import("xev"); +const wuffs = @import("wuffs"); const apprt = @import("../apprt.zig"); const configpkg = @import("../config.zig"); const font = @import("../font/main.zig"); @@ -25,6 +26,8 @@ const ArenaAllocator = std.heap.ArenaAllocator; const Terminal = terminal.Terminal; const Health = renderer.Health; +const FileType = @import("../file_type.zig").FileType; + const macos = switch (builtin.os.tag) { .macos => @import("macos"), else => void, @@ -181,6 +184,21 @@ pub fn Renderer(comptime GraphicsAPI: type) type { image_text_end: u32 = 0, image_virtual: bool = false, + /// Background image, if we have one. + bg_image: ?imagepkg.Image = null, + /// Set whenever the background image changes, singalling + /// that the new background image needs to be uploaded to + /// the GPU. + /// + /// This is initialized as true so that we load the image + /// on renderer initialization, not just on config change. + bg_image_changed: bool = true, + /// Background image vertex buffer. + bg_image_buffer: shaderpkg.BgImage, + /// This value is used to force-update the swap chain copy + /// of the background image buffer whenever we change it. + bg_image_buffer_modified: usize = 0, + /// Graphics API state. api: GraphicsAPI, @@ -298,12 +316,21 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// See property of same name on Renderer for explanation. target_config_modified: usize = 0, + /// Buffer with the vertex data for our background image. + /// + /// TODO: Make this an optional and only create it + /// if we actually have a background image. + bg_image_buffer: BgImageBuffer, + /// See property of same name on Renderer for explanation. + bg_image_buffer_modified: usize = 0, + /// Custom shader state, this is null if we have no custom shaders. custom_shader_state: ?CustomShaderState = null, const UniformBuffer = Buffer(shaderpkg.Uniforms); const CellBgBuffer = Buffer(shaderpkg.CellBg); const CellTextBuffer = Buffer(shaderpkg.CellText); + const BgImageBuffer = Buffer(shaderpkg.BgImage); pub fn init(api: GraphicsAPI, custom_shaders: bool) !FrameState { // Uniform buffer contains exactly 1 uniform struct. The @@ -323,6 +350,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { var cells_bg = try CellBgBuffer.init(api.bgBufferOptions(), 1); errdefer cells_bg.deinit(); + // Create a GPU buffer for our background image info. + var bg_image_buffer = try BgImageBuffer.init( + api.bgImageBufferOptions(), + 1, + ); + errdefer bg_image_buffer.deinit(); + // Initialize our textures for our font atlas. // // As with the buffers above, we start these off as small @@ -355,6 +389,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .uniforms = uniforms, .cells = cells, .cells_bg = cells_bg, + .bg_image_buffer = bg_image_buffer, .grayscale = grayscale, .color = color, .target = target, @@ -368,6 +403,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.cells_bg.deinit(); self.grayscale.deinit(); self.color.deinit(); + self.bg_image_buffer.deinit(); if (self.custom_shader_state) |*state| state.deinit(); } @@ -491,6 +527,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { min_contrast: f32, padding_color: configpkg.WindowPaddingColor, custom_shaders: configpkg.RepeatablePath, + bg_image: ?configpkg.Path, + bg_image_opacity: f32, + bg_image_position: configpkg.BackgroundImagePosition, + bg_image_fit: configpkg.BackgroundImageFit, + bg_image_repeat: bool, links: link.Set, vsync: bool, colorspace: configpkg.Config.WindowColorspace, @@ -507,6 +548,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Copy our shaders const custom_shaders = try config.@"custom-shader".clone(alloc); + // Copy our background image + const bg_image = + if (config.@"background-image") |bg| + try bg.clone(alloc) + else + null; + // Copy our font features const font_features = try config.@"font-feature".clone(alloc); @@ -563,6 +611,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { null, .custom_shaders = custom_shaders, + .bg_image = bg_image, + .bg_image_opacity = config.@"background-image-opacity", + .bg_image_position = config.@"background-image-position", + .bg_image_fit = config.@"background-image-fit", + .bg_image_repeat = config.@"background-image-repeat", .links = links, .vsync = config.@"window-vsync", .colorspace = config.@"window-colorspace", @@ -657,6 +710,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .cell_size = undefined, .grid_size = undefined, .grid_padding = undefined, + .screen_size = undefined, .padding_extend = .{}, .min_contrast = options.config.min_contrast, .cursor_pos = .{ std.math.maxInt(u16), std.math.maxInt(u16) }, @@ -691,6 +745,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .previous_cursor_color = @splat(0), .cursor_change_time = 0, }, + .bg_image_buffer = undefined, // Fonts .font_grid = options.font_grid, @@ -711,6 +766,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Ensure our undefined values above are correctly initialized. result.updateFontGridUniforms(); result.updateScreenSizeUniforms(); + result.updateBgImageBuffer(); + try result.prepBackgroundImage(); return result; } @@ -739,6 +796,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } self.image_placements.deinit(self.alloc); + if (self.bg_image) |img| img.deinit(self.alloc); + self.deinitShaders(); self.api.deinit(); @@ -1336,6 +1395,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Upload images to the GPU as necessary. try self.uploadKittyImages(); + // Upload the background image to the GPU as necessary. + try self.uploadBackgroundImage(); + // Update custom shader uniforms if necessary. try self.updateCustomShaderUniforms(); @@ -1344,6 +1406,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { try frame.cells_bg.sync(self.cells.bg_cells); const fg_count = try frame.cells.syncFromArrayLists(self.cells.fg_rows.lists); + // If our background image buffer has changed, sync it. + if (frame.bg_image_buffer_modified != self.bg_image_buffer_modified) { + try frame.bg_image_buffer.sync(&.{self.bg_image_buffer}); + + frame.bg_image_buffer_modified = self.bg_image_buffer_modified; + } + // If our font atlas changed, sync the texture data texture: { const modified = self.font_grid.atlas_grayscale.modified.load(.monotonic); @@ -1376,18 +1445,33 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }}); defer pass.complete(); - // First we draw the background color. + // First we draw our background image, if we have one. + // The bg image shader also draws the main bg color. + // + // Otherwise, if we don't have a background image, we + // draw the background color by itself in its own step. // // NOTE: We don't use the clear_color for this because that // would require us to do color space conversion on the // CPU-side. In the future when we have utilities for // that we should remove this step and use clear_color. - pass.step(.{ - .pipeline = self.shaders.pipelines.bg_color, - .uniforms = frame.uniforms.buffer, - .buffers = &.{ null, frame.cells_bg.buffer }, - .draw = .{ .type = .triangle, .vertex_count = 3 }, - }); + if (self.bg_image) |img| switch (img) { + .ready => |texture| pass.step(.{ + .pipeline = self.shaders.pipelines.bg_image, + .uniforms = frame.uniforms.buffer, + .buffers = &.{frame.bg_image_buffer.buffer}, + .textures = &.{texture}, + .draw = .{ .type = .triangle, .vertex_count = 3 }, + }), + else => {}, + } else { + pass.step(.{ + .pipeline = self.shaders.pipelines.bg_color, + .uniforms = frame.uniforms.buffer, + .buffers = &.{ null, frame.cells_bg.buffer }, + .draw = .{ .type = .triangle, .vertex_count = 3 }, + }); + } // Then we draw any kitty images that need // to be behind text AND cell backgrounds. @@ -1863,6 +1947,102 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } + /// Call this any time the background image path changes. + /// + /// Caller must hold the draw mutex. + fn prepBackgroundImage(self: *Self) !void { + // Then we try to load the background image if we have a path. + if (self.config.bg_image) |p| load_background: { + const path = switch (p) { + .required, .optional => |slice| slice, + }; + + // Open the file + var file = std.fs.openFileAbsolute(path, .{}) catch |err| { + log.warn( + "error opening background image file \"{s}\": {}", + .{ path, err }, + ); + break :load_background; + }; + defer file.close(); + + // Read it + const contents = file.readToEndAlloc( + self.alloc, + std.math.maxInt(u32), // Max size of 4 GiB, for now. + ) catch |err| { + log.warn( + "error reading background image file \"{s}\": {}", + .{ path, err }, + ); + break :load_background; + }; + defer self.alloc.free(contents); + + // Figure out what type it probably is. + const file_type = switch (FileType.detect(contents)) { + .unknown => FileType.guessFromExtension( + std.fs.path.extension(path), + ), + else => |t| t, + }; + + // Decode it if we know how. + const image_data = switch (file_type) { + .png => try wuffs.png.decode(self.alloc, contents), + .jpeg => try wuffs.jpeg.decode(self.alloc, contents), + .unknown => { + log.warn( + "Cannot determine file type for background image file \"{s}\"!", + .{path}, + ); + break :load_background; + }, + else => |f| { + log.warn( + "Unsupported file type {} for background image file \"{s}\"!", + .{ f, path }, + ); + break :load_background; + }, + }; + + const image: imagepkg.Image = .{ + .pending = .{ + .width = image_data.width, + .height = image_data.height, + .pixel_format = .rgba, + .data = image_data.data.ptr, + }, + }; + + // If we have an existing background image, replace it. + // Otherwise, set this as our background image directly. + if (self.bg_image) |*img| { + try img.markForReplace(self.alloc, image); + } else { + self.bg_image = image; + } + } else { + // If we don't have a background image path, mark our + // background image for unload if we currently have one. + if (self.bg_image) |*img| img.markForUnload(); + } + } + + fn uploadBackgroundImage(self: *Self) !void { + // Make sure our bg image is uploaded if it needs to be. + if (self.bg_image) |*bg| { + if (bg.isUnloading()) { + bg.deinit(self.alloc); + self.bg_image = null; + return; + } + if (bg.isPending()) try bg.upload(self.alloc, &self.api); + } + } + /// Update the configuration. pub fn changeConfig(self: *Self, config: *DerivedConfig) !void { self.draw_mutex.lock(); @@ -1900,12 +2080,33 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null; self.cursor_invert = config.cursor_invert; + const bg_image_config_changed = + self.config.bg_image_fit != config.bg_image_fit or + self.config.bg_image_position != config.bg_image_position or + self.config.bg_image_repeat != config.bg_image_repeat or + self.config.bg_image_opacity != config.bg_image_opacity; + + const bg_image_changed = + if (self.config.bg_image) |old| + if (config.bg_image) |new| + !old.equal(new) + else + true + else + config.bg_image != null; + const old_blending = self.config.blending; const custom_shaders_changed = !self.config.custom_shaders.equal(config.custom_shaders); self.config.deinit(); self.config = config.*; + // If our background image path changed, prepare the new bg image. + if (bg_image_changed) try self.prepBackgroundImage(); + + // If our background image config changed, update the vertex buffer. + if (bg_image_config_changed) self.updateBgImageBuffer(); + // Reset our viewport to force a rebuild, in case of a font change. self.cells_viewport = null; @@ -1975,14 +2176,50 @@ pub fn Renderer(comptime GraphicsAPI: type) type { @floatFromInt(blank.bottom), @floatFromInt(blank.left), }; + self.uniforms.screen_size = .{ + @floatFromInt(self.size.screen.width), + @floatFromInt(self.size.screen.height), + }; + } + + /// Update the background image vertex buffer (CPU-side). + /// + /// This should be called if and when configs change that + /// could affect the background image. + /// + /// Caller must hold the draw mutex. + fn updateBgImageBuffer(self: *Self) void { + self.bg_image_buffer = .{ + .opacity = self.config.bg_image_opacity, + .info = .{ + .position = switch (self.config.bg_image_position) { + .@"top-left" => .tl, + .@"top-center" => .tc, + .@"top-right" => .tr, + .@"center-left" => .ml, + .@"center-center", .center => .mc, + .@"center-right" => .mr, + .@"bottom-left" => .bl, + .@"bottom-center" => .bc, + .@"bottom-right" => .br, + }, + .fit = switch (self.config.bg_image_fit) { + .contain => .contain, + .cover => .cover, + .stretch => .stretch, + .none => .none, + }, + .repeat = self.config.bg_image_repeat, + }, + }; + // Signal that the buffer was modified. + self.bg_image_buffer_modified +%= 1; } /// Update uniforms for the custom shaders, if necessary. /// /// This should be called exactly once per frame, inside `drawFrame`. - fn updateCustomShaderUniforms( - self: *Self, - ) !void { + fn updateCustomShaderUniforms(self: *Self) !void { // We only need to do this if we have custom shaders. if (!self.has_custom_shaders) return; diff --git a/src/renderer/metal/Pipeline.zig b/src/renderer/metal/Pipeline.zig index f72aeb2e1..0b8e99159 100644 --- a/src/renderer/metal/Pipeline.zig +++ b/src/renderer/metal/Pipeline.zig @@ -160,6 +160,7 @@ fn autoAttribute(T: type, attrs: objc.Object) void { const offset = @offsetOf(T, field.name); const FT = switch (@typeInfo(field.type)) { + .@"struct" => |e| e.backing_integer.?, .@"enum" => |e| e.tag_type, else => field.type, }; @@ -169,13 +170,17 @@ fn autoAttribute(T: type, attrs: objc.Object) void { [4]u8 => mtl.MTLVertexFormat.uchar4, [2]u16 => mtl.MTLVertexFormat.ushort2, [2]i16 => mtl.MTLVertexFormat.short2, + f32 => mtl.MTLVertexFormat.float, [2]f32 => mtl.MTLVertexFormat.float2, [4]f32 => mtl.MTLVertexFormat.float4, + i32 => mtl.MTLVertexFormat.int, [2]i32 => mtl.MTLVertexFormat.int2, + [4]i32 => mtl.MTLVertexFormat.int2, u32 => mtl.MTLVertexFormat.uint, [2]u32 => mtl.MTLVertexFormat.uint2, [4]u32 => mtl.MTLVertexFormat.uint4, u8 => mtl.MTLVertexFormat.uchar, + i8 => mtl.MTLVertexFormat.char, else => comptime unreachable, }; diff --git a/src/renderer/metal/shaders.zig b/src/renderer/metal/shaders.zig index 59a3a1a37..9fe0862ed 100644 --- a/src/renderer/metal/shaders.zig +++ b/src/renderer/metal/shaders.zig @@ -36,6 +36,13 @@ const pipeline_descs: []const struct { [:0]const u8, PipelineDescription } = .step_fn = .per_instance, .blending_enabled = true, } }, + .{ "bg_image", .{ + .vertex_attributes = BgImage, + .vertex_fn = "bg_image_vertex", + .fragment_fn = "bg_image_fragment", + .step_fn = .per_instance, + .blending_enabled = true, + } }, }; /// All the comptime-known info about a pipeline, so that @@ -192,6 +199,9 @@ pub const Uniforms = extern struct { /// This is calculated based on the size of the screen. projection_matrix: math.Mat align(16), + /// Size of the screen (render target) in pixels. + screen_size: [2]f32 align(8), + /// Size of a single cell in pixels, unscaled. cell_size: [2]f32 align(8), @@ -288,6 +298,38 @@ pub const Image = extern struct { dest_size: [2]f32, }; +/// Single parameter for the bg image shader. +pub const BgImage = extern struct { + opacity: f32 align(4), + info: Info align(1), + + pub const Info = packed struct(u8) { + position: Position, + fit: Fit, + repeat: bool, + _padding: u1 = 0, + + pub const Position = enum(u4) { + tl = 0, + tc = 1, + tr = 2, + ml = 3, + mc = 4, + mr = 5, + bl = 6, + bc = 7, + br = 8, + }; + + pub const Fit = enum(u2) { + contain = 0, + cover = 1, + stretch = 2, + none = 3, + }; + }; +}; + /// Initialize the MTLLibrary. A MTLLibrary is a collection of shaders. fn initLibrary(device: objc.Object) !objc.Object { const start = try std.time.Instant.now(); diff --git a/src/renderer/opengl/Pipeline.zig b/src/renderer/opengl/Pipeline.zig index 501e6124c..c3d414ff2 100644 --- a/src/renderer/opengl/Pipeline.zig +++ b/src/renderer/opengl/Pipeline.zig @@ -98,6 +98,7 @@ fn autoAttribute( const offset = @offsetOf(T, field.name); const FT = switch (@typeInfo(field.type)) { + .@"struct" => |s| s.backing_integer.?, .@"enum" => |e| e.tag_type, else => field.type, }; diff --git a/src/renderer/opengl/shaders.zig b/src/renderer/opengl/shaders.zig index cc7a3ea2e..0b67eaff0 100644 --- a/src/renderer/opengl/shaders.zig +++ b/src/renderer/opengl/shaders.zig @@ -33,6 +33,13 @@ const pipeline_descs: []const struct { [:0]const u8, PipelineDescription } = .step_fn = .per_instance, .blending_enabled = true, } }, + .{ "bg_image", .{ + .vertex_attributes = BgImage, + .vertex_fn = loadShaderCode("../shaders/glsl/bg_image.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/bg_image.f.glsl"), + .step_fn = .per_instance, + .blending_enabled = true, + } }, }; /// All the comptime-known info about a pipeline, so that @@ -158,6 +165,9 @@ pub const Uniforms = extern struct { /// This is calculated based on the size of the screen. projection_matrix: math.Mat align(16), + /// Size of the screen (render target) in pixels. + screen_size: [2]f32 align(8), + /// Size of a single cell in pixels, unscaled. cell_size: [2]f32 align(8), @@ -256,6 +266,38 @@ pub const Image = extern struct { dest_size: [2]f32 align(8), }; +/// Single parameter for the bg image shader. +pub const BgImage = extern struct { + opacity: f32 align(4), + info: Info align(1), + + pub const Info = packed struct(u8) { + position: Position, + fit: Fit, + repeat: bool, + _padding: u1 = 0, + + pub const Position = enum(u4) { + tl = 0, + tc = 1, + tr = 2, + ml = 3, + mc = 4, + mr = 5, + bl = 6, + bc = 7, + br = 8, + }; + + pub const Fit = enum(u2) { + contain = 0, + cover = 1, + stretch = 2, + none = 3, + }; + }; +}; + /// Initialize our custom shader pipelines. The shaders argument is a /// set of shader source code, not file paths. fn initPostPipelines( diff --git a/src/renderer/shaders/glsl/bg_image.f.glsl b/src/renderer/shaders/glsl/bg_image.f.glsl new file mode 100644 index 000000000..7c3e4363a --- /dev/null +++ b/src/renderer/shaders/glsl/bg_image.f.glsl @@ -0,0 +1,62 @@ +#include "common.glsl" + +// Position the FragCoord origin to the upper left +// so as to align with our texture's directionality. +layout(origin_upper_left) in vec4 gl_FragCoord; + +layout(binding = 0) uniform sampler2DRect image; + +flat in vec4 bg_color; +flat in vec2 offset; +flat in vec2 scale; +flat in float opacity; +flat in uint repeat; + +layout(location = 0) out vec4 out_FragColor; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + // Our texture coordinate is based on the screen position, offset by the + // dest rect origin, and scaled by the ratio between the dest rect size + // and the original texture size, which effectively scales the original + // size of the texture to the dest rect size. + vec2 tex_coord = (gl_FragCoord.xy - offset) * scale; + + vec2 tex_size = textureSize(image); + + // If we need to repeat the texture, wrap the coordinates. + if (repeat != 0) { + tex_coord = mod(mod(tex_coord, tex_size) + tex_size, tex_size); + } + + vec4 rgba; + // If we're out of bounds, we have no color, + // otherwise we sample the texture for it. + if (any(lessThan(tex_coord, vec2(0.0))) || + any(greaterThan(tex_coord, tex_size))) + { + rgba = vec4(0.0); + } else { + rgba = texture(image, tex_coord); + + if (!use_linear_blending) { + rgba = unlinearize(rgba); + } + + rgba.rgb *= rgba.a; + } + + // Multiply it by the configured opacity, but cap it at + // the value that will make it fully opaque relative to + // the background color alpha, so it isn't overexposed. + rgba *= min(opacity, 1.0 / bg_color.a); + + // Blend it on to a fully opaque version of the background color. + rgba += max(vec4(0.0), vec4(bg_color.rgb, 1.0) * vec4(1.0 - rgba.a)); + + // Multiply everything by the background color alpha. + rgba *= bg_color.a; + + out_FragColor = rgba; +} diff --git a/src/renderer/shaders/glsl/bg_image.v.glsl b/src/renderer/shaders/glsl/bg_image.v.glsl new file mode 100644 index 000000000..875c40518 --- /dev/null +++ b/src/renderer/shaders/glsl/bg_image.v.glsl @@ -0,0 +1,145 @@ +#include "common.glsl" + +layout(binding = 0) uniform sampler2DRect image; + +layout(location = 0) in float in_opacity; +layout(location = 1) in uint info; + +// 4 bits of info. +const uint BG_IMAGE_POSITION = 15u; +const uint BG_IMAGE_TL = 0u; +const uint BG_IMAGE_TC = 1u; +const uint BG_IMAGE_TR = 2u; +const uint BG_IMAGE_ML = 3u; +const uint BG_IMAGE_MC = 4u; +const uint BG_IMAGE_MR = 5u; +const uint BG_IMAGE_BL = 6u; +const uint BG_IMAGE_BC = 7u; +const uint BG_IMAGE_BR = 8u; + +// 2 bits of info shifted 4. +const uint BG_IMAGE_FIT = 3u << 4; +const uint BG_IMAGE_CONTAIN = 0u << 4; +const uint BG_IMAGE_COVER = 1u << 4; +const uint BG_IMAGE_STRETCH = 2u << 4; +const uint BG_IMAGE_NO_FIT = 3u << 4; + +// 1 bit of info shifted 6. +const uint BG_IMAGE_REPEAT = 1u << 6; + +flat out vec4 bg_color; +flat out vec2 offset; +flat out vec2 scale; +flat out float opacity; +// We use a uint to pass the repeat value because +// bools aren't allowed for vertex outputs in OpenGL. +flat out uint repeat; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + vec4 position; + position.x = (gl_VertexID == 2) ? 3.0 : -1.0; + position.y = (gl_VertexID == 0) ? -3.0 : 1.0; + position.z = 1.0; + position.w = 1.0; + + // Single triangle is clipped to viewport. + // + // X <- vid == 0: (-1, -3) + // |\ + // | \ + // | \ + // |###\ + // |#+# \ `+` is (0, 0). `#`s are viewport area. + // |### \ + // X------X <- vid == 2: (3, 1) + // ^ + // vid == 1: (-1, 1) + + gl_Position = position; + + opacity = in_opacity; + + repeat = info & BG_IMAGE_REPEAT; + + vec2 screen_size = screen_size; + vec2 tex_size = textureSize(image); + + vec2 dest_size = tex_size; + switch (info & BG_IMAGE_FIT) { + // For `contain` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is smaller. + case BG_IMAGE_CONTAIN: { + float scale = min(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `cover` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is larger. + case BG_IMAGE_COVER: { + float scale = max(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `stretch` we stretch the image to the size of + // the screen without worrying about aspect ratio. + case BG_IMAGE_STRETCH: { + dest_size = screen_size; + } break; + + // For `none` we just use the original texture size. + case BG_IMAGE_NO_FIT: { + dest_size = tex_size; + } break; + } + + vec2 start = vec2(0.0); + vec2 mid = (screen_size - dest_size) / vec2(2.0); + vec2 end = screen_size - dest_size; + + vec2 dest_offset = mid; + switch (info & BG_IMAGE_POSITION) { + case BG_IMAGE_TL: { + dest_offset = vec2(start.x, start.y); + } break; + case BG_IMAGE_TC: { + dest_offset = vec2(mid.x, start.y); + } break; + case BG_IMAGE_TR: { + dest_offset = vec2(end.x, start.y); + } break; + case BG_IMAGE_ML: { + dest_offset = vec2(start.x, mid.y); + } break; + case BG_IMAGE_MC: { + dest_offset = vec2(mid.x, mid.y); + } break; + case BG_IMAGE_MR: { + dest_offset = vec2(end.x, mid.y); + } break; + case BG_IMAGE_BL: { + dest_offset = vec2(start.x, end.y); + } break; + case BG_IMAGE_BC: { + dest_offset = vec2(mid.x, end.y); + } break; + case BG_IMAGE_BR: { + dest_offset = vec2(end.x, end.y); + } break; + } + + offset = dest_offset; + scale = tex_size / dest_size; + + // We load a fully opaque version of the bg color and combine it with + // the alpha separately, because we need these as separate values in + // the framgment shader. + uvec4 u_bg_color = unpack4u8(bg_color_packed_4u8); + bg_color = vec4(load_color( + uvec4(u_bg_color.rgb, 255), + use_linear_blending + ).rgb, float(u_bg_color.a) / 255.0); +} diff --git a/src/renderer/shaders/glsl/common.glsl b/src/renderer/shaders/glsl/common.glsl index 0450d0c06..a0ed9f7b4 100644 --- a/src/renderer/shaders/glsl/common.glsl +++ b/src/renderer/shaders/glsl/common.glsl @@ -13,6 +13,7 @@ //----------------------------------------------------------------------------// layout(binding = 1, std140) uniform Globals { uniform mat4 projection_matrix; + uniform vec2 screen_size; uniform vec2 cell_size; uniform uint grid_size_packed_2u16; uniform vec4 grid_padding; diff --git a/src/renderer/shaders/shaders.metal b/src/renderer/shaders/shaders.metal index 19652d836..b62e0c3cf 100644 --- a/src/renderer/shaders/shaders.metal +++ b/src/renderer/shaders/shaders.metal @@ -11,6 +11,7 @@ enum Padding : uint8_t { struct Uniforms { float4x4 projection_matrix; + float2 screen_size; float2 cell_size; ushort2 grid_size; float4 grid_padding; @@ -231,6 +232,217 @@ fragment float4 bg_color_fragment( ); } +//------------------------------------------------------------------- +// Background Image Shader +//------------------------------------------------------------------- +#pragma mark - BG Image Shader + +struct BgImageVertexIn { + float opacity [[attribute(0)]]; + uint8_t info [[attribute(1)]]; +}; + +enum BgImagePosition : uint8_t { + // 4 bits of info. + BG_IMAGE_POSITION = 15u, + + BG_IMAGE_TL = 0u, + BG_IMAGE_TC = 1u, + BG_IMAGE_TR = 2u, + BG_IMAGE_ML = 3u, + BG_IMAGE_MC = 4u, + BG_IMAGE_MR = 5u, + BG_IMAGE_BL = 6u, + BG_IMAGE_BC = 7u, + BG_IMAGE_BR = 8u, +}; + +enum BgImageFit : uint8_t { + // 2 bits of info shifted 4. + BG_IMAGE_FIT = 3u << 4, + + BG_IMAGE_CONTAIN = 0u << 4, + BG_IMAGE_COVER = 1u << 4, + BG_IMAGE_STRETCH = 2u << 4, + BG_IMAGE_NO_FIT = 3u << 4, +}; + +enum BgImageRepeat : uint8_t { + // 1 bit of info shifted 6. + BG_IMAGE_REPEAT = 1u << 6, +}; + +struct BgImageVertexOut { + float4 position [[position]]; + float4 bg_color [[flat]]; + float2 offset [[flat]]; + float2 scale [[flat]]; + float opacity [[flat]]; + bool repeat [[flat]]; +}; + +vertex BgImageVertexOut bg_image_vertex( + uint vid [[vertex_id]], + BgImageVertexIn in [[stage_in]], + texture2d image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + BgImageVertexOut out; + + float4 position; + position.x = (vid == 2) ? 3.0 : -1.0; + position.y = (vid == 0) ? -3.0 : 1.0; + position.zw = 1.0; + + // Single triangle is clipped to viewport. + // + // X <- vid == 0: (-1, -3) + // |\ + // | \ + // | \ + // |###\ + // |#+# \ `+` is (0, 0). `#`s are viewport area. + // |### \ + // X------X <- vid == 2: (3, 1) + // ^ + // vid == 1: (-1, 1) + + out.position = position; + + out.opacity = in.opacity; + + out.repeat = (in.info & BG_IMAGE_REPEAT) == BG_IMAGE_REPEAT; + + float2 screen_size = uniforms.screen_size; + float2 tex_size = float2(image.get_width(), image.get_height()); + + float2 dest_size = tex_size; + switch (in.info & BG_IMAGE_FIT) { + // For `contain` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is smaller. + case BG_IMAGE_CONTAIN: { + float scale = min(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `cover` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is larger. + case BG_IMAGE_COVER: { + float scale = max(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `stretch` we stretch the image to the size of + // the screen without worrying about aspect ratio. + case BG_IMAGE_STRETCH: { + dest_size = screen_size; + } break; + + // For `none` we just use the original texture size. + case BG_IMAGE_NO_FIT: { + dest_size = tex_size; + } break; + } + + float2 start = float2(0.0); + float2 mid = (screen_size - dest_size) / 2; + float2 end = screen_size - dest_size; + + float2 dest_offset = mid; + switch (in.info & BG_IMAGE_POSITION) { + case BG_IMAGE_TL: { + dest_offset = float2(start.x, start.y); + } break; + case BG_IMAGE_TC: { + dest_offset = float2(mid.x, start.y); + } break; + case BG_IMAGE_TR: { + dest_offset = float2(end.x, start.y); + } break; + case BG_IMAGE_ML: { + dest_offset = float2(start.x, mid.y); + } break; + case BG_IMAGE_MC: { + dest_offset = float2(mid.x, mid.y); + } break; + case BG_IMAGE_MR: { + dest_offset = float2(end.x, mid.y); + } break; + case BG_IMAGE_BL: { + dest_offset = float2(start.x, end.y); + } break; + case BG_IMAGE_BC: { + dest_offset = float2(mid.x, end.y); + } break; + case BG_IMAGE_BR: { + dest_offset = float2(end.x, end.y); + } break; + } + + out.offset = dest_offset; + out.scale = tex_size / dest_size; + + // We load a fully opaque version of the bg color and combine it with + // the alpha separately, because we need these as separate values in + // the framgment shader. + out.bg_color = float4(load_color( + uchar4(uniforms.bg_color.rgb, 255), + uniforms.use_display_p3, + uniforms.use_linear_blending + ).rgb, float(uniforms.bg_color.a) / 255.0); + + return out; +} + +fragment float4 bg_image_fragment( + BgImageVertexOut in [[stage_in]], + texture2d image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + constexpr sampler textureSampler( + coord::pixel, + address::clamp_to_zero, + filter::linear + ); + + // Our texture coordinate is based on the screen position, offset by the + // dest rect origin, and scaled by the ratio between the dest rect size + // and the original texture size, which effectively scales the original + // size of the texture to the dest rect size. + float2 tex_coord = (in.position.xy - in.offset) * in.scale; + + // If we need to repeat the texture, wrap the coordinates. + if (in.repeat) { + float2 tex_size = float2(image.get_width(), image.get_height()); + + tex_coord = fmod(fmod(tex_coord, tex_size) + tex_size, tex_size); + } + + float4 rgba = image.sample(textureSampler, tex_coord); + + if (!uniforms.use_linear_blending) { + rgba = unlinearize(rgba); + } + + // Premultiply the bg image. + rgba.rgb *= rgba.a; + + // Multiply it by the configured opacity, but cap it at + // the value that will make it fully opaque relative to + // the background color alpha, so it isn't overexposed. + rgba *= min(in.opacity, 1.0 / in.bg_color.a); + + // Blend it on to a fully opaque version of the background color. + rgba += max(float4(0.0), float4(in.bg_color.rgb, 1.0) * (1.0 - rgba.a)); + + // Multiply everything by the background color alpha. + rgba *= in.bg_color.a; + + return rgba; +} + //------------------------------------------------------------------- // Cell Background Shader //------------------------------------------------------------------- From 5cb175ff63a93c5ed1fcc80dd0976d4ee6614db0 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 25 Jun 2025 10:53:42 -0600 Subject: [PATCH 055/110] renderer/OpenGL: use compressed texture formats for images BPTC is required to be available OpenGL >= 4.2 and our minimum is 4.3 so this is safe in terms of support. I tested briefly in a VM and didn't encounter any problems so this should just be a complete win. (Note: texture data is already automatically compressed on Metal) --- pkg/opengl/Texture.zig | 6 ++++-- src/renderer/OpenGL.zig | 2 +- src/renderer/opengl/Texture.zig | 1 - 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/opengl/Texture.zig b/pkg/opengl/Texture.zig index 4a1d61433..2c8e05eff 100644 --- a/pkg/opengl/Texture.zig +++ b/pkg/opengl/Texture.zig @@ -74,6 +74,9 @@ pub const InternalFormat = enum(c_int) { srgb = c.GL_SRGB8, srgba = c.GL_SRGB8_ALPHA8, + rgba_compressed = c.GL_COMPRESSED_RGBA_BPTC_UNORM, + srgba_compressed = c.GL_COMPRESSED_SRGB_ALPHA_BPTC_UNORM, + // There are so many more that I haven't filled in. _, }; @@ -126,7 +129,6 @@ pub const Binding = struct { internal_format: InternalFormat, width: c.GLsizei, height: c.GLsizei, - border: c.GLint, format: Format, typ: DataType, data: ?*const anyopaque, @@ -137,7 +139,7 @@ pub const Binding = struct { @intFromEnum(internal_format), width, height, - border, + 0, @intFromEnum(format), @intFromEnum(typ), data, diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 3b4ba6d80..e112c0df7 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -395,7 +395,7 @@ pub inline fn textureOptions(self: OpenGL) Texture.Options { _ = self; return .{ .format = .rgba, - .internal_format = .srgba, + .internal_format = .srgba_compressed, .target = .@"2D", }; } diff --git a/src/renderer/opengl/Texture.zig b/src/renderer/opengl/Texture.zig index 07123922f..9be2b7078 100644 --- a/src/renderer/opengl/Texture.zig +++ b/src/renderer/opengl/Texture.zig @@ -57,7 +57,6 @@ pub fn init( opts.internal_format, @intCast(width), @intCast(height), - 0, opts.format, .UnsignedByte, if (data) |d| @ptrCast(d.ptr) else null, From 6c2ea8637e8201068033b84ed4b37627d01ae858 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 25 Jun 2025 16:27:12 -0400 Subject: [PATCH 056/110] config: add more docs for `background-image` --- src/config/Config.zig | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index e1cccef1b..ebc54c3b4 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -468,8 +468,17 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// Background image for the terminal. /// -/// This should be a path to a PNG or JPEG file, -/// other image formats are not yet supported. +/// This should be a path to a PNG or JPEG file, other image formats are +/// not yet supported. +/// +/// The background image is currently per-terminal, not per-window. If +/// you are a heavy split user, the background image will be repeated across +/// splits. A future improvement to Ghostty will address this. +/// +/// WARNING: Background images are currently duplicated in VRAM per-terminal. +/// For sufficiently large images, this could lead to a large increase in +/// memory usage (specifically VRAM usage). A future Ghostty improvement +/// will resolve this by sharing image textures across terminals. @"background-image": ?Path = null, /// Background image opacity. From a8091fedf324c198e31de5ef6a55393166c5f1e1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 25 Jun 2025 16:18:17 -0400 Subject: [PATCH 057/110] fix tests --- src/apprt/gtk/CommandPalette.zig | 1 - src/config/Config.zig | 29 +++++++++++++++++++---------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/apprt/gtk/CommandPalette.zig b/src/apprt/gtk/CommandPalette.zig index 3c8192a50..d05f195b3 100644 --- a/src/apprt/gtk/CommandPalette.zig +++ b/src/apprt/gtk/CommandPalette.zig @@ -103,7 +103,6 @@ pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !voi self.source.removeAll(); _ = self.arena.reset(.retain_capacity); - // TODO: Allow user-configured palette entries for (config.@"command-palette-entry".value.items) |command| { // Filter out actions that are not implemented // or don't make sense for GTK diff --git a/src/config/Config.zig b/src/config/Config.zig index eb5c18ea3..6c86de5ac 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6147,12 +6147,23 @@ pub const RepeatableCommand = struct { try self.value.appendSlice(alloc, inputpkg.command.defaults); } - pub fn parseCLI(self: *RepeatableCommand, alloc: Allocator, input: ?[]const u8) !void { - const input_ = input orelse { + pub fn parseCLI( + self: *RepeatableCommand, + alloc: Allocator, + input_: ?[]const u8, + ) !void { + // Unset or empty input clears the list + const input = input_ orelse ""; + if (input.len == 0) { self.value.clearRetainingCapacity(); return; - }; - const cmd = try cli.args.parseAutoStruct(inputpkg.Command, alloc, input_); + } + + const cmd = try cli.args.parseAutoStruct( + inputpkg.Command, + alloc, + input, + ); try self.value.append(alloc, cmd); } @@ -6214,16 +6225,14 @@ pub const RepeatableCommand = struct { try testing.expectEqual(inputpkg.Binding.Action.ignore, list.value.items[0].action); try testing.expectEqualStrings("Foo", list.value.items[0].title); - try testing.expectEqual( - inputpkg.Binding.Action{ .text = "ale bydle" }, - list.value.items[0].action, - ); + try testing.expect(list.value.items[1].action == .text); + try testing.expectEqualStrings("ale bydle", list.value.items[1].action.text); try testing.expectEqualStrings("Bar", list.value.items[1].title); try testing.expectEqualStrings("bobr", list.value.items[1].description); try testing.expectEqual( inputpkg.Binding.Action{ .increase_font_size = 2.5 }, - list.value.items[0].action, + list.value.items[2].action, ); try testing.expectEqualStrings("Quux", list.value.items[2].title); try testing.expectEqualStrings("boo", list.value.items[2].description); @@ -6270,7 +6279,7 @@ pub const RepeatableCommand = struct { try list.parseCLI(alloc, "title:Bobr, action:text:kurwa"); try list.parseCLI(alloc, "title:Ja, description: pierdole, action:text:jakie bydle"); try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); - try std.testing.expectEqualSlices(u8, "a = title:bobr,action:text:kurwa\na = title:Ja,description:pierdole,action:text:jakie bydle\n", buf.items); + try std.testing.expectEqualSlices(u8, "a = title:Bobr,action:text:kurwa\na = title:Ja,description:pierdole,action:text:jakie bydle\n", buf.items); } }; From 360124ded029c9c150a858a0808b008506230f85 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 25 Jun 2025 23:16:43 -0600 Subject: [PATCH 058/110] terminal/Screen: account for rectangle selection in clone Fixes an issue where rectangle selections would appear visually wrong if their start or end were out of the viewport area, because when cloning them the restored pins were defaulting to the start and end of the row instead of the appropriate column. --- src/terminal/Screen.zig | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 2688b03a7..deff4a394 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -402,8 +402,8 @@ pub fn clonePool( }; const start_pin = pin_remap.get(ordered.tl) orelse start: { - // No start means it is outside the cloned area. We change it - // to the top-left. + // No start means it is outside the cloned area. + // We move it back in bounds to the top row. // If we have no end pin then either // (1) our whole selection is outside the cloned area or @@ -417,14 +417,17 @@ pub fn clonePool( if (!sel.contains(self, clone_top)) break :sel null; } - break :start try pages.trackPin(.{ .node = pages.pages.first.? }); + break :start try pages.trackPin(.{ + .node = pages.pages.first.?, + .x = if (sel.rectangle) ordered.tl.x else 0, + }); }; const end_pin = pin_remap.get(ordered.br) orelse end: { - // No end means it is outside the cloned area. We change it - // to the bottom-right. + // No end means it is outside the cloned area. + // We move it back in bounds to the bottom row. break :end try pages.trackPin(pages.pin(.{ .active = .{ - .x = pages.cols - 1, + .x = if (sel.rectangle) ordered.br.x else pages.cols - 1, .y = pages.rows - 1, } }) orelse break :sel null); }; From f7ee6b3bdacc64a4973b121dc5f06fdeffb4761f Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 26 Jun 2025 11:26:03 -0600 Subject: [PATCH 059/110] terminal/Screen: clean up selection remap in clone Cleans up the logic, checks for out of bounds using rows instead of sel.contains because that excludes cases where a rectangle selection doesn't include the leftmost column. Also adds test for clipping behavior of rectangular selections. --- src/terminal/Screen.zig | 79 +++++++++++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index deff4a394..8cdaf3fa2 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -403,34 +403,46 @@ pub fn clonePool( const start_pin = pin_remap.get(ordered.tl) orelse start: { // No start means it is outside the cloned area. - // We move it back in bounds to the top row. // If we have no end pin then either // (1) our whole selection is outside the cloned area or // (2) our cloned area is within the selection if (pin_remap.get(ordered.br) == null) { - // If our tl is before the cloned area and br is after - // the cloned area then the whole screen is selected. - // This detection is somewhat more expensive so we try - // to avoid it if possible so its nested in this if. + // We check if the selection bottom right pin is above + // the cloned area or if the top left pin is below the + // cloned area, in either of these cases it means that + // the selection is fully out of bounds, so we have no + // selection in the cloned area and break out now. const clone_top = self.pages.pin(top) orelse break :sel null; - if (!sel.contains(self, clone_top)) break :sel null; + const clone_top_y = self.pages.pointFromPin( + .screen, + clone_top, + ).?.screen.y; + if (self.pages.pointFromPin( + .screen, + ordered.br.*, + ).?.screen.y < clone_top_y) break :sel null; + if (self.pages.pointFromPin( + .screen, + ordered.tl.*, + ).?.screen.y > clone_top_y) break :sel null; } + // We move the top pin back in bounds to the top row. break :start try pages.trackPin(.{ .node = pages.pages.first.?, .x = if (sel.rectangle) ordered.tl.x else 0, }); }; - const end_pin = pin_remap.get(ordered.br) orelse end: { - // No end means it is outside the cloned area. - // We move it back in bounds to the bottom row. - break :end try pages.trackPin(pages.pin(.{ .active = .{ - .x = if (sel.rectangle) ordered.br.x else pages.cols - 1, - .y = pages.rows - 1, - } }) orelse break :sel null); - }; + // If we got to this point it means that the selection is not + // fully out of bounds, so we move the bottom right pin back + // in bounds if it isn't already. + const end_pin = pin_remap.get(ordered.br) orelse try pages.trackPin(.{ + .node = pages.pages.last.?, + .x = if (sel.rectangle) ordered.br.x else pages.cols - 1, + .y = pages.pages.last.?.data.size.rows - 1, + }); break :sel .{ .bounds = .{ .tracked = .{ @@ -5290,6 +5302,45 @@ test "Screen: clone contains subset of selection" { } } +test "Screen: clone contains subset of rectangle selection" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 4, 1); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + + // Select the full screen from x=1 to x=3 + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 3 } }).?, + true, + )); + + // Clone + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 1 } }, + .{ .active = .{ .y = 2 } }, + ); + defer s2.deinit(); + + // Our selection should remain valid and be properly clipped + // preserving the columns of the start and end points of the + // selection. + { + const sel = s2.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s2.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 3, + } }, s2.pages.pointFromPin(.active, sel.end()).?); + } +} + test "Screen: clone basic" { const testing = std.testing; const alloc = testing.allocator; From 81403f59ce98009d16dc6719635b543a88cc2ad5 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 24 Jun 2025 22:56:17 -0500 Subject: [PATCH 060/110] dbus and systemd activation - take 2 This replaces #7433. The improvements are: 1) Install the systemd user service in the proper directory depending on if it's a 'user' install or a 'system' install. This is controlled either by using the `--system` build flag (as most packages will) or by the `-Dsystem-package` flag. 2) Add the absolute path to the `ghostty` binary in the application file, the DBus service, and the systemd user service. This is done so that they do not depend on `ghostty` being in the `PATH` of whatever is launching Ghostty. That `PATH` is not necessarily the same as the `PATH` in a user shell (especially for DBus activation and systemd user services). 3) Adjust the DBus bus name that is expected by the system depending on the optimization level that Ghostty is compiled with. --- PACKAGING.md | 3 +- dist/linux/app.desktop | 11 +++-- dist/linux/dbus.service | 4 ++ dist/linux/systemd.service | 7 +++ nix/package.nix | 1 + src/apprt/gtk/App.zig | 23 +++++++-- src/apprt/gtk/Surface.zig | 9 ++++ src/build/Config.zig | 9 +++- src/build/GhosttyResources.zig | 86 ++++++++++++++++++++++++++++++++-- src/config/Config.zig | 7 ++- 10 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 dist/linux/dbus.service create mode 100644 dist/linux/systemd.service diff --git a/PACKAGING.md b/PACKAGING.md index d85f55de7..e85a9e987 100644 --- a/PACKAGING.md +++ b/PACKAGING.md @@ -83,7 +83,8 @@ zig build \ --prefix /usr \ --system /tmp/offline-cache/p \ -Doptimize=ReleaseFast \ - -Dcpu=baseline + -Dcpu=baseline \ + -Dsystem-package=true ``` The build options are covered in the next section, but this will build diff --git a/dist/linux/app.desktop b/dist/linux/app.desktop index 6e464ea87..7dc0062f7 100644 --- a/dist/linux/app.desktop +++ b/dist/linux/app.desktop @@ -1,13 +1,15 @@ [Desktop Entry] -Name=Ghostty +Version=1.0 +Name=Ghostty@@NAME@@ Type=Application Comment=A terminal emulator -Exec=ghostty +TryExec=@@GHOSTTY@@ +Exec=@@GHOSTTY@@ --launched-from=desktop Icon=com.mitchellh.ghostty Categories=System;TerminalEmulator; Keywords=terminal;tty;pty; StartupNotify=true -StartupWMClass=com.mitchellh.ghostty +StartupWMClass=com.mitchellh.ghostty@@DEBUG@@ Terminal=false Actions=new-window; X-GNOME-UsesNotifications=true @@ -16,7 +18,8 @@ X-TerminalArgTitle=--title= X-TerminalArgAppId=--class= X-TerminalArgDir=--working-directory= X-TerminalArgHold=--wait-after-command +DBusActivatable=true [Desktop Action new-window] Name=New Window -Exec=ghostty +Exec=ghostty --launched-from=desktop diff --git a/dist/linux/dbus.service b/dist/linux/dbus.service new file mode 100644 index 000000000..f9c5ce7a4 --- /dev/null +++ b/dist/linux/dbus.service @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=com.mitchellh.ghostty@@DEBUG@@ +SystemdService=com.mitchellh.ghostty@@DEBUG@@.service +Exec=@@GHOSTTY@@ --launched-from=dbus diff --git a/dist/linux/systemd.service b/dist/linux/systemd.service new file mode 100644 index 000000000..2d1acdaea --- /dev/null +++ b/dist/linux/systemd.service @@ -0,0 +1,7 @@ +[Unit] +Description=Ghostty@@NAME@@ + +[Service] +Type=dbus +BusName=com.mitchellh.ghostty@@DEBUG@@ +ExecStart=@@GHOSTTY@@ --launched-from=systemd diff --git a/nix/package.nix b/nix/package.nix index 08dfd710b..f24b401cc 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -99,6 +99,7 @@ in "-Dgtk-x11=${lib.boolToString enableX11}" "-Dgtk-wayland=${lib.boolToString enableWayland}" "-Dstrip=${lib.boolToString strip}" + "-Dsystem-package=true" ]; outputs = [ diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 7c9c15191..93e069376 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -405,11 +405,15 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // This just calls the `activate` signal but its part of the normal startup // routine so we just call it, but only if the config allows it (this allows // for launching Ghostty in the "background" without immediately opening - // a window) + // a window). An initial window will not be immediately created if we were + // launched by D-Bus activation or systemd. D-Bus activation will send it's + // own `activate` or `new-window` signal later. // // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 - if (config.@"initial-window") - gio_app.activate(); + if (config.@"initial-window") switch (config.@"launched-from".?) { + .desktop, .cli => gio_app.activate(), + .dbus, .systemd => {}, + }; // Internally, GTK ensures that only one instance of this provider exists in the provider list // for the display. @@ -1683,6 +1687,17 @@ fn gtkActionShowGTKInspector( }; } +fn gtkActionNewWindow( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *App, +) callconv(.c) void { + log.info("received new window action", .{}); + _ = self.core_app.mailbox.push(.{ + .new_window = .{}, + }, .{ .forever = {} }); +} + /// This is called to setup the action map that this application supports. /// This should be called only once on startup. fn initActions(self: *App) void { @@ -1702,7 +1717,9 @@ fn initActions(self: *App) void { .{ "reload-config", gtkActionReloadConfig, null }, .{ "present-surface", gtkActionPresentSurface, t }, .{ "show-gtk-inspector", gtkActionShowGTKInspector, null }, + .{ "new-window", gtkActionNewWindow, null }, }; + inline for (actions) |entry| { const action = gio.SimpleAction.new(entry[0], entry[2]); defer action.unref(); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index cf8d651dd..5c886e663 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2325,6 +2325,15 @@ pub fn defaultTermioEnv(self: *Surface) !std.process.EnvMap { env.remove("GDK_DISABLE"); env.remove("GSK_RENDERER"); + // Remove some environment variables that are set when Ghostty is launched + // from a `.desktop` file, by D-Bus activation, or systemd. + env.remove("GIO_LAUNCHED_DESKTOP_FILE"); + env.remove("GIO_LAUNCHED_DESKTOP_FILE_PID"); + env.remove("DBUS_STARTER_ADDRESS"); + env.remove("DBUS_STARTER_BUS_TYPE"); + env.remove("INVOCATION_ID"); + env.remove("JOURNAL_STREAM"); + // Unset environment varies set by snaps if we're running in a snap. // This allows Ghostty to further launch additional snaps. if (env.get("SNAP")) |_| { diff --git a/src/build/Config.zig b/src/build/Config.zig index 8974e1f0c..ede42c738 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -45,6 +45,7 @@ version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, pie: bool = false, strip: bool = false, patch_rpath: ?[]const u8 = null, +system_package: bool = false, /// Artifacts flatpak: bool = false, @@ -87,7 +88,11 @@ pub fn init(b: *std.Build) !Config { // This is set to true when we're building a system package. For now // this is trivially detected using the "system_package_mode" bool // but we may want to make this more sophisticated in the future. - const system_package: bool = b.graph.system_package_mode; + const system_package = b.option( + bool, + "system-package", + "Controls whether we build a system package", + ) orelse b.graph.system_package_mode; // This specifies our target wasm runtime. For now only one semi-usable // one exists so this is hardcoded. @@ -261,6 +266,8 @@ pub fn init(b: *std.Build) !Config { .ReleaseFast, .ReleaseSmall => true, }; + config.system_package = system_package; + //--------------------------------------------------------------- // Artifacts to Emit diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 640491fd6..f3b169de1 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -224,10 +224,57 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { // https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html // Desktop file so that we have an icon and other metadata - try steps.append(&b.addInstallFile( - b.path("dist/linux/app.desktop"), - "share/applications/com.mitchellh.ghostty.desktop", - ).step); + try steps.append( + formatService( + b, + cfg, + b.path("dist/linux/app.desktop"), + b.fmt( + "share/applications/com.mitchellh.ghostty{s}.desktop", + .{ + switch (cfg.optimize) { + .Debug, .ReleaseSafe => "-debug", + .ReleaseFast, .ReleaseSmall => "", + }, + }, + ), + ), + ); + // Service for DBus activation. + try steps.append( + formatService( + b, + cfg, + b.path("dist/linux/dbus.service"), + b.fmt( + "share/dbus-1/services/com.mitchellh.ghostty{s}.service", + .{ + switch (cfg.optimize) { + .Debug, .ReleaseSafe => "-debug", + .ReleaseFast, .ReleaseSmall => "", + }, + }, + ), + ), + ); + // systemd user service + try steps.append( + formatService( + b, + cfg, + b.path("dist/linux/systemd.service"), + b.fmt( + "{s}/systemd/user/com.mitchellh.ghostty{s}.service", + .{ + if (cfg.system_package) "lib" else "share", + switch (cfg.optimize) { + .Debug, .ReleaseSafe => "-debug", + .ReleaseFast, .ReleaseSmall => "", + }, + }, + ), + ), + ); // AppStream metainfo so that application has rich metadata within app stores try steps.append(&b.addInstallFile( @@ -303,3 +350,34 @@ pub fn install(self: *const GhosttyResources) void { const b = self.steps[0].owner; for (self.steps) |step| b.getInstallStep().dependOn(step); } + +pub fn formatService(b: *std.Build, cfg: *const Config, src: std.Build.LazyPath, dest: []const u8) *std.Build.Step { + var cmd = b.addSystemCommand(&.{"sed"}); + cmd.setStdIn(.{ .lazy_path = src }); + const output = cmd.captureStdOut(); + + cmd.addArg(b.fmt( + "-e s!@@NAME@@!{s}!g", + .{ + switch (cfg.optimize) { + .Debug, .ReleaseSafe => " Debug", + .ReleaseFast, .ReleaseSmall => "", + }, + }, + )); + cmd.addArg(b.fmt( + "-e s!@@DEBUG@@!{s}!g", + .{ + switch (cfg.optimize) { + .Debug, .ReleaseSafe => "-debug", + .ReleaseFast, .ReleaseSmall => "", + }, + }, + )); + cmd.addArg(b.fmt( + "-e s!@@GHOSTTY@@!{s}/bin/ghostty!g", + .{b.install_prefix}, + )); + + return &b.addInstallFile(output, dest).step; +} diff --git a/src/config/Config.zig b/src/config/Config.zig index be59ae94f..64b00eb1c 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1029,12 +1029,17 @@ title: ?[:0]const u8 = null, /// The setting that will change the application class value. /// /// This controls the class field of the `WM_CLASS` X11 property (when running -/// under X11), and the Wayland application ID (when running under Wayland). +/// under X11), the Wayland application ID (when running under Wayland), and the +/// bus name that Ghostty uses to connect to DBus. /// /// Note that changing this value between invocations will create new, separate /// instances, of Ghostty when running with `gtk-single-instance=true`. See that /// option for more details. /// +/// Changing this value may break launching Ghostty from `.desktop` files, via +/// DBus activation, or systemd user services as the system is expecting Ghostty +/// to connect to DBus using the default `class` when it is launched. +/// /// The class name must follow the requirements defined [in the GTK /// documentation](https://docs.gtk.org/gio/type_func.Application.id_is_valid.html). /// From ddada2fb3fb7740a8b560cd96403cf06946c5d89 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 24 Jun 2025 23:28:04 -0500 Subject: [PATCH 061/110] flatpak: remove references to systemd unit Replaces #7676 When building as a flatpak, don't install the systemd user services since flatpaks can't use them. Remove references to the systemd service from the DBus service. Also, customize the app metadata depending on the debug mode. Co-authored-by: Leorize --- dist/linux/com.mitchellh.ghostty.metainfo.xml | 4 +-- flatpak/com.mitchellh.ghostty-debug.yml | 4 --- src/build/GhosttyResources.zig | 35 +++++++++++++------ 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/dist/linux/com.mitchellh.ghostty.metainfo.xml b/dist/linux/com.mitchellh.ghostty.metainfo.xml index 0424d3a09..46b370bb8 100644 --- a/dist/linux/com.mitchellh.ghostty.metainfo.xml +++ b/dist/linux/com.mitchellh.ghostty.metainfo.xml @@ -1,7 +1,7 @@ - com.mitchellh.ghostty - com.mitchellh.ghostty.desktop + com.mitchellh.ghostty@@DEBUG@@ + com.mitchellh.ghostty@@DEBUG@@.desktop Ghostty https://ghostty.org https://ghostty.org/docs diff --git a/flatpak/com.mitchellh.ghostty-debug.yml b/flatpak/com.mitchellh.ghostty-debug.yml index 8a2c0056e..fe4722ef5 100644 --- a/flatpak/com.mitchellh.ghostty-debug.yml +++ b/flatpak/com.mitchellh.ghostty-debug.yml @@ -6,11 +6,7 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.ziglang default-branch: tip command: ghostty -# Integrate the rename into zig build, maybe? -rename-desktop-file: com.mitchellh.ghostty.desktop -rename-appdata-file: com.mitchellh.ghostty.metainfo.xml rename-icon: com.mitchellh.ghostty -desktop-file-name-suffix: " (Debug)" finish-args: # 3D rendering - --device=dri diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index f3b169de1..f72006b4b 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -258,15 +258,34 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { ), ); // systemd user service + if (!cfg.flatpak) + try steps.append( + formatService( + b, + cfg, + b.path("dist/linux/systemd.service"), + b.fmt( + "{s}/systemd/user/com.mitchellh.ghostty{s}.service", + .{ + if (cfg.system_package) "lib" else "share", + switch (cfg.optimize) { + .Debug, .ReleaseSafe => "-debug", + .ReleaseFast, .ReleaseSmall => "", + }, + }, + ), + ), + ); + + // AppStream metainfo so that application has rich metadata within app stores try steps.append( formatService( b, cfg, - b.path("dist/linux/systemd.service"), + b.path("dist/linux/com.mitchellh.ghostty.metainfo.xml"), b.fmt( - "{s}/systemd/user/com.mitchellh.ghostty{s}.service", + "share/metainfo/com.mitchellh.ghostty{s}.metainfo.xml", .{ - if (cfg.system_package) "lib" else "share", switch (cfg.optimize) { .Debug, .ReleaseSafe => "-debug", .ReleaseFast, .ReleaseSmall => "", @@ -276,12 +295,6 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { ), ); - // AppStream metainfo so that application has rich metadata within app stores - try steps.append(&b.addInstallFile( - b.path("dist/linux/com.mitchellh.ghostty.metainfo.xml"), - "share/metainfo/com.mitchellh.ghostty.metainfo.xml", - ).step); - // Right click menu action for Plasma desktop try steps.append(&b.addInstallFile( b.path("dist/linux/ghostty_dolphin.desktop"), @@ -360,7 +373,7 @@ pub fn formatService(b: *std.Build, cfg: *const Config, src: std.Build.LazyPath, "-e s!@@NAME@@!{s}!g", .{ switch (cfg.optimize) { - .Debug, .ReleaseSafe => " Debug", + .Debug, .ReleaseSafe => " (Debug)", .ReleaseFast, .ReleaseSmall => "", }, }, @@ -378,6 +391,8 @@ pub fn formatService(b: *std.Build, cfg: *const Config, src: std.Build.LazyPath, "-e s!@@GHOSTTY@@!{s}/bin/ghostty!g", .{b.install_prefix}, )); + if (cfg.flatpak) + cmd.addArg("-e /^SystemdService=/d"); return &b.addInstallFile(output, dest).step; } From b68f9f2321048702bf11f3c7eba0fce0c4deba9c Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 24 Jun 2025 23:42:45 -0500 Subject: [PATCH 062/110] make sure that the desktop file uses the absolute path everywhere --- dist/linux/app.desktop | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dist/linux/app.desktop b/dist/linux/app.desktop index 7dc0062f7..17d7b65e3 100644 --- a/dist/linux/app.desktop +++ b/dist/linux/app.desktop @@ -22,4 +22,4 @@ DBusActivatable=true [Desktop Action new-window] Name=New Window -Exec=ghostty --launched-from=desktop +Exec=@@GHOSTTY@@ --launched-from=desktop From cf561fcc5569c6a4282c29df1de47b6f84485d89 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 25 Jun 2025 10:11:45 -0500 Subject: [PATCH 063/110] rename templated files with .in suffix --- dist/linux/{app.desktop => app.desktop.in} | 0 ...metainfo.xml => com.mitchellh.ghostty.metainfo.xml.in} | 0 dist/linux/{dbus.service => dbus.service.in} | 0 dist/linux/{systemd.service => systemd.service.in} | 0 src/build/GhosttyResources.zig | 8 ++++---- 5 files changed, 4 insertions(+), 4 deletions(-) rename dist/linux/{app.desktop => app.desktop.in} (100%) rename dist/linux/{com.mitchellh.ghostty.metainfo.xml => com.mitchellh.ghostty.metainfo.xml.in} (100%) rename dist/linux/{dbus.service => dbus.service.in} (100%) rename dist/linux/{systemd.service => systemd.service.in} (100%) diff --git a/dist/linux/app.desktop b/dist/linux/app.desktop.in similarity index 100% rename from dist/linux/app.desktop rename to dist/linux/app.desktop.in diff --git a/dist/linux/com.mitchellh.ghostty.metainfo.xml b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in similarity index 100% rename from dist/linux/com.mitchellh.ghostty.metainfo.xml rename to dist/linux/com.mitchellh.ghostty.metainfo.xml.in diff --git a/dist/linux/dbus.service b/dist/linux/dbus.service.in similarity index 100% rename from dist/linux/dbus.service rename to dist/linux/dbus.service.in diff --git a/dist/linux/systemd.service b/dist/linux/systemd.service.in similarity index 100% rename from dist/linux/systemd.service rename to dist/linux/systemd.service.in diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index f72006b4b..364818a1a 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -228,7 +228,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { formatService( b, cfg, - b.path("dist/linux/app.desktop"), + b.path("dist/linux/app.desktop.in"), b.fmt( "share/applications/com.mitchellh.ghostty{s}.desktop", .{ @@ -245,7 +245,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { formatService( b, cfg, - b.path("dist/linux/dbus.service"), + b.path("dist/linux/dbus.service.in"), b.fmt( "share/dbus-1/services/com.mitchellh.ghostty{s}.service", .{ @@ -263,7 +263,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { formatService( b, cfg, - b.path("dist/linux/systemd.service"), + b.path("dist/linux/systemd.service.in"), b.fmt( "{s}/systemd/user/com.mitchellh.ghostty{s}.service", .{ @@ -282,7 +282,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { formatService( b, cfg, - b.path("dist/linux/com.mitchellh.ghostty.metainfo.xml"), + b.path("dist/linux/com.mitchellh.ghostty.metainfo.xml.in"), b.fmt( "share/metainfo/com.mitchellh.ghostty{s}.metainfo.xml", .{ From 9c95ce28ae7f9a4026ce8d8ebe463b018c27f27a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 25 Jun 2025 10:24:47 -0500 Subject: [PATCH 064/110] drop system-package option --- PACKAGING.md | 3 +-- nix/package.nix | 1 - src/build/Config.zig | 9 +-------- src/build/GhosttyResources.zig | 2 +- 4 files changed, 3 insertions(+), 12 deletions(-) diff --git a/PACKAGING.md b/PACKAGING.md index e85a9e987..d85f55de7 100644 --- a/PACKAGING.md +++ b/PACKAGING.md @@ -83,8 +83,7 @@ zig build \ --prefix /usr \ --system /tmp/offline-cache/p \ -Doptimize=ReleaseFast \ - -Dcpu=baseline \ - -Dsystem-package=true + -Dcpu=baseline ``` The build options are covered in the next section, but this will build diff --git a/nix/package.nix b/nix/package.nix index f24b401cc..08dfd710b 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -99,7 +99,6 @@ in "-Dgtk-x11=${lib.boolToString enableX11}" "-Dgtk-wayland=${lib.boolToString enableWayland}" "-Dstrip=${lib.boolToString strip}" - "-Dsystem-package=true" ]; outputs = [ diff --git a/src/build/Config.zig b/src/build/Config.zig index ede42c738..e55da3860 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -45,7 +45,6 @@ version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, pie: bool = false, strip: bool = false, patch_rpath: ?[]const u8 = null, -system_package: bool = false, /// Artifacts flatpak: bool = false, @@ -88,11 +87,7 @@ pub fn init(b: *std.Build) !Config { // This is set to true when we're building a system package. For now // this is trivially detected using the "system_package_mode" bool // but we may want to make this more sophisticated in the future. - const system_package = b.option( - bool, - "system-package", - "Controls whether we build a system package", - ) orelse b.graph.system_package_mode; + const system_package = b.graph.system_package_mode; // This specifies our target wasm runtime. For now only one semi-usable // one exists so this is hardcoded. @@ -266,8 +261,6 @@ pub fn init(b: *std.Build) !Config { .ReleaseFast, .ReleaseSmall => true, }; - config.system_package = system_package; - //--------------------------------------------------------------- // Artifacts to Emit diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 364818a1a..f52c2e810 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -267,7 +267,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { b.fmt( "{s}/systemd/user/com.mitchellh.ghostty{s}.service", .{ - if (cfg.system_package) "lib" else "share", + if (b.graph.system_package_mode) "lib" else "share", switch (cfg.optimize) { .Debug, .ReleaseSafe => "-debug", .ReleaseFast, .ReleaseSmall => "", From eb5a488b57d4e0528d4fc3724ff1f77a6894ab61 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 25 Jun 2025 11:09:44 -0500 Subject: [PATCH 065/110] clean up duplicated code in installation of desktop services --- dist/linux/app.desktop.in | 4 +- .../com.mitchellh.ghostty.metainfo.xml.in | 4 +- dist/linux/dbus.service.in | 4 +- dist/linux/systemd.service.in | 4 +- src/build/GhosttyResources.zig | 81 ++++++++++--------- 5 files changed, 49 insertions(+), 48 deletions(-) diff --git a/dist/linux/app.desktop.in b/dist/linux/app.desktop.in index 17d7b65e3..aefb5c9cd 100644 --- a/dist/linux/app.desktop.in +++ b/dist/linux/app.desktop.in @@ -1,6 +1,6 @@ [Desktop Entry] Version=1.0 -Name=Ghostty@@NAME@@ +Name=@@NAME@@ Type=Application Comment=A terminal emulator TryExec=@@GHOSTTY@@ @@ -9,7 +9,7 @@ Icon=com.mitchellh.ghostty Categories=System;TerminalEmulator; Keywords=terminal;tty;pty; StartupNotify=true -StartupWMClass=com.mitchellh.ghostty@@DEBUG@@ +StartupWMClass=@@APPID@@ Terminal=false Actions=new-window; X-GNOME-UsesNotifications=true diff --git a/dist/linux/com.mitchellh.ghostty.metainfo.xml.in b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in index 46b370bb8..97598c215 100644 --- a/dist/linux/com.mitchellh.ghostty.metainfo.xml.in +++ b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in @@ -1,7 +1,7 @@ - com.mitchellh.ghostty@@DEBUG@@ - com.mitchellh.ghostty@@DEBUG@@.desktop + @@APPID@@ + @@APPID@@.desktop Ghostty https://ghostty.org https://ghostty.org/docs diff --git a/dist/linux/dbus.service.in b/dist/linux/dbus.service.in index f9c5ce7a4..a11ab2c13 100644 --- a/dist/linux/dbus.service.in +++ b/dist/linux/dbus.service.in @@ -1,4 +1,4 @@ [D-BUS Service] -Name=com.mitchellh.ghostty@@DEBUG@@ -SystemdService=com.mitchellh.ghostty@@DEBUG@@.service +Name=@@APPID@@ +SystemdService=@@APPID@@.service Exec=@@GHOSTTY@@ --launched-from=dbus diff --git a/dist/linux/systemd.service.in b/dist/linux/systemd.service.in index 2d1acdaea..ede2174a2 100644 --- a/dist/linux/systemd.service.in +++ b/dist/linux/systemd.service.in @@ -1,7 +1,7 @@ [Unit] -Description=Ghostty@@NAME@@ +Description=@@NAME@@ [Service] Type=dbus -BusName=com.mitchellh.ghostty@@DEBUG@@ +BusName=@@APPID@@ ExecStart=@@GHOSTTY@@ --launched-from=systemd diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index f52c2e810..a62ba0528 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -223,20 +223,31 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { if (cfg.target.result.os.tag == .linux) { // https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html + const name = b.fmt("Ghostty{s}", .{ + switch (cfg.optimize) { + .Debug, .ReleaseSafe => " (Debug)", + .ReleaseFast, .ReleaseSmall => "", + }, + }); + + const app_id = b.fmt("com.mitchellh.ghostty{s}", .{ + switch (cfg.optimize) { + .Debug, .ReleaseSafe => "-debug", + .ReleaseFast, .ReleaseSmall => "", + }, + }); + // Desktop file so that we have an icon and other metadata try steps.append( formatService( b, cfg, + name, + app_id, b.path("dist/linux/app.desktop.in"), b.fmt( - "share/applications/com.mitchellh.ghostty{s}.desktop", - .{ - switch (cfg.optimize) { - .Debug, .ReleaseSafe => "-debug", - .ReleaseFast, .ReleaseSmall => "", - }, - }, + "share/applications/{s}.desktop", + .{app_id}, ), ), ); @@ -245,15 +256,12 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { formatService( b, cfg, + name, + app_id, b.path("dist/linux/dbus.service.in"), b.fmt( - "share/dbus-1/services/com.mitchellh.ghostty{s}.service", - .{ - switch (cfg.optimize) { - .Debug, .ReleaseSafe => "-debug", - .ReleaseFast, .ReleaseSmall => "", - }, - }, + "share/dbus-1/services/{s}.service", + .{app_id}, ), ), ); @@ -263,15 +271,14 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { formatService( b, cfg, + name, + app_id, b.path("dist/linux/systemd.service.in"), b.fmt( - "{s}/systemd/user/com.mitchellh.ghostty{s}.service", + "{s}/systemd/user/{s}.service", .{ if (b.graph.system_package_mode) "lib" else "share", - switch (cfg.optimize) { - .Debug, .ReleaseSafe => "-debug", - .ReleaseFast, .ReleaseSmall => "", - }, + app_id, }, ), ), @@ -282,15 +289,12 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { formatService( b, cfg, + name, + app_id, b.path("dist/linux/com.mitchellh.ghostty.metainfo.xml.in"), b.fmt( - "share/metainfo/com.mitchellh.ghostty{s}.metainfo.xml", - .{ - switch (cfg.optimize) { - .Debug, .ReleaseSafe => "-debug", - .ReleaseFast, .ReleaseSmall => "", - }, - }, + "share/metainfo/{s}.metainfo.xml", + .{app_id}, ), ), ); @@ -364,28 +368,25 @@ pub fn install(self: *const GhosttyResources) void { for (self.steps) |step| b.getInstallStep().dependOn(step); } -pub fn formatService(b: *std.Build, cfg: *const Config, src: std.Build.LazyPath, dest: []const u8) *std.Build.Step { +pub fn formatService( + b: *std.Build, + cfg: *const Config, + name: []const u8, + app_id: []const u8, + src: std.Build.LazyPath, + dest: []const u8, +) *std.Build.Step { var cmd = b.addSystemCommand(&.{"sed"}); cmd.setStdIn(.{ .lazy_path = src }); const output = cmd.captureStdOut(); cmd.addArg(b.fmt( "-e s!@@NAME@@!{s}!g", - .{ - switch (cfg.optimize) { - .Debug, .ReleaseSafe => " (Debug)", - .ReleaseFast, .ReleaseSmall => "", - }, - }, + .{name}, )); cmd.addArg(b.fmt( - "-e s!@@DEBUG@@!{s}!g", - .{ - switch (cfg.optimize) { - .Debug, .ReleaseSafe => "-debug", - .ReleaseFast, .ReleaseSmall => "", - }, - }, + "-e s!@@APPID@@!{s}!g", + .{app_id}, )); cmd.addArg(b.fmt( "-e s!@@GHOSTTY@@!{s}/bin/ghostty!g", From fa4f4207687f56ea0651951107e29f2d69a3c2b0 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 25 Jun 2025 11:55:43 -0500 Subject: [PATCH 066/110] replace sed with a simple Zig program for templating desktop files --- src/build/GhosttyResources.zig | 36 ++++++++++++++++++---------------- src/build/desktop_template.zig | 29 +++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 17 deletions(-) create mode 100644 src/build/desktop_template.zig diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index a62ba0528..23aceda98 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -376,24 +376,26 @@ pub fn formatService( src: std.Build.LazyPath, dest: []const u8, ) *std.Build.Step { - var cmd = b.addSystemCommand(&.{"sed"}); - cmd.setStdIn(.{ .lazy_path = src }); - const output = cmd.captureStdOut(); + var cmd = b.addExecutable( + .{ + .name = "desktop-template", + .root_source_file = b.path("src/build/desktop_template.zig"), + .target = b.graph.host, + }, + ); - cmd.addArg(b.fmt( - "-e s!@@NAME@@!{s}!g", - .{name}, - )); - cmd.addArg(b.fmt( - "-e s!@@APPID@@!{s}!g", - .{app_id}, - )); - cmd.addArg(b.fmt( - "-e s!@@GHOSTTY@@!{s}/bin/ghostty!g", - .{b.install_prefix}, - )); - if (cfg.flatpak) - cmd.addArg("-e /^SystemdService=/d"); + const options = b.addOptions(); + + options.addOption([]const u8, "app_id", app_id); + options.addOption([]const u8, "name", name); + options.addOption([]const u8, "ghostty", b.fmt("{s}/bin/ghostty", .{b.install_prefix})); + options.addOption(bool, "flatpak", cfg.flatpak); + + cmd.root_module.addOptions("cfg", options); + + const run = b.addRunArtifact(cmd); + run.setStdIn(.{ .lazy_path = src }); + const output = run.captureStdOut(); return &b.addInstallFile(output, dest).step; } diff --git a/src/build/desktop_template.zig b/src/build/desktop_template.zig new file mode 100644 index 000000000..0741ff81a --- /dev/null +++ b/src/build/desktop_template.zig @@ -0,0 +1,29 @@ +const std = @import("std"); +const cfg = @import("cfg"); + +pub fn main() !void { + var debug: std.heap.DebugAllocator(.{}) = .init; + defer _ = debug.deinit(); + + const alloc = debug.allocator(); + const stdin = std.io.getStdIn(); + + const stdout = std.io.getStdOut(); + var input = stdin.reader(); + while (try input.readUntilDelimiterOrEofAlloc(alloc, '\n', 4096)) |line| { + defer alloc.free(line); + + const buf1 = try std.mem.replaceOwned(u8, alloc, line, "@@APPID@@", cfg.app_id); + defer alloc.free(buf1); + + const buf2 = try std.mem.replaceOwned(u8, alloc, buf1, "@@NAME@@", cfg.name); + defer alloc.free(buf2); + + const buf3 = try std.mem.replaceOwned(u8, alloc, buf2, "@@GHOSTTY@@", cfg.ghostty); + defer alloc.free(buf3); + + if (cfg.flatpak and std.mem.startsWith(u8, buf3, "SystemdService=")) continue; + try stdout.writeAll(buf3); + try stdout.writeAll("\n"); + } +} From 8a95212197ff0362b21027cc3f1348468322823e Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 25 Jun 2025 15:09:53 -0500 Subject: [PATCH 067/110] fix up the name in the metainfo when templating --- dist/linux/com.mitchellh.ghostty.metainfo.xml.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dist/linux/com.mitchellh.ghostty.metainfo.xml.in b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in index 97598c215..7bfa30518 100644 --- a/dist/linux/com.mitchellh.ghostty.metainfo.xml.in +++ b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in @@ -2,7 +2,7 @@ @@APPID@@ @@APPID@@.desktop - Ghostty + @@NAME@@ https://ghostty.org https://ghostty.org/docs https://github.com/ghostty-org/ghostty/discussions From 73d5eb928c80a411e5879bd609b82317258a0af4 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 25 Jun 2025 15:11:58 -0500 Subject: [PATCH 068/110] fix up formatting of desktop_template.zig --- src/build/desktop_template.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/build/desktop_template.zig b/src/build/desktop_template.zig index 0741ff81a..7b066bfcc 100644 --- a/src/build/desktop_template.zig +++ b/src/build/desktop_template.zig @@ -6,10 +6,11 @@ pub fn main() !void { defer _ = debug.deinit(); const alloc = debug.allocator(); - const stdin = std.io.getStdIn(); + const stdin = std.io.getStdIn(); const stdout = std.io.getStdOut(); var input = stdin.reader(); + while (try input.readUntilDelimiterOrEofAlloc(alloc, '\n', 4096)) |line| { defer alloc.free(line); @@ -23,6 +24,7 @@ pub fn main() !void { defer alloc.free(buf3); if (cfg.flatpak and std.mem.startsWith(u8, buf3, "SystemdService=")) continue; + try stdout.writeAll(buf3); try stdout.writeAll("\n"); } From 739b691a6d1a9ea86a021af1ce70ad5cd1e41d14 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Jun 2025 10:28:17 -0700 Subject: [PATCH 069/110] use cmake formatting to template, avoids the custom binary --- dist/linux/app.desktop.in | 10 +- .../com.mitchellh.ghostty.metainfo.xml.in | 6 +- dist/linux/dbus.service.flatpak.in | 3 + dist/linux/dbus.service.in | 6 +- dist/linux/systemd.service.in | 6 +- src/build/GhosttyResources.zig | 307 +++++++++--------- src/build/desktop_template.zig | 31 -- 7 files changed, 168 insertions(+), 201 deletions(-) create mode 100644 dist/linux/dbus.service.flatpak.in delete mode 100644 src/build/desktop_template.zig diff --git a/dist/linux/app.desktop.in b/dist/linux/app.desktop.in index aefb5c9cd..c39164158 100644 --- a/dist/linux/app.desktop.in +++ b/dist/linux/app.desktop.in @@ -1,15 +1,15 @@ [Desktop Entry] Version=1.0 -Name=@@NAME@@ +Name=@NAME@ Type=Application Comment=A terminal emulator -TryExec=@@GHOSTTY@@ -Exec=@@GHOSTTY@@ --launched-from=desktop +TryExec=@GHOSTTY@ +Exec=@GHOSTTY@ --launched-from=desktop Icon=com.mitchellh.ghostty Categories=System;TerminalEmulator; Keywords=terminal;tty;pty; StartupNotify=true -StartupWMClass=@@APPID@@ +StartupWMClass=@APPID@ Terminal=false Actions=new-window; X-GNOME-UsesNotifications=true @@ -22,4 +22,4 @@ DBusActivatable=true [Desktop Action new-window] Name=New Window -Exec=@@GHOSTTY@@ --launched-from=desktop +Exec=@GHOSTTY@ --launched-from=desktop diff --git a/dist/linux/com.mitchellh.ghostty.metainfo.xml.in b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in index 7bfa30518..00d99adb2 100644 --- a/dist/linux/com.mitchellh.ghostty.metainfo.xml.in +++ b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in @@ -1,8 +1,8 @@ - @@APPID@@ - @@APPID@@.desktop - @@NAME@@ + @APPID@id> + @APPID@desktop + @NAME@name> https://ghostty.org https://ghostty.org/docs https://github.com/ghostty-org/ghostty/discussions diff --git a/dist/linux/dbus.service.flatpak.in b/dist/linux/dbus.service.flatpak.in new file mode 100644 index 000000000..213cda78f --- /dev/null +++ b/dist/linux/dbus.service.flatpak.in @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=@APPID@ +Exec=@GHOSTTY@ --launched-from=dbus diff --git a/dist/linux/dbus.service.in b/dist/linux/dbus.service.in index a11ab2c13..85b7c425c 100644 --- a/dist/linux/dbus.service.in +++ b/dist/linux/dbus.service.in @@ -1,4 +1,4 @@ [D-BUS Service] -Name=@@APPID@@ -SystemdService=@@APPID@@.service -Exec=@@GHOSTTY@@ --launched-from=dbus +Name=@APPID@ +SystemdService=@APPID@service +Exec=@GHOSTTY@ --launched-from=dbus diff --git a/dist/linux/systemd.service.in b/dist/linux/systemd.service.in index ede2174a2..b0ef3d59a 100644 --- a/dist/linux/systemd.service.in +++ b/dist/linux/systemd.service.in @@ -1,7 +1,7 @@ [Unit] -Description=@@NAME@@ +Description=@NAME@ [Service] Type=dbus -BusName=@@APPID@@ -ExecStart=@@GHOSTTY@@ --launched-from=systemd +BusName=@APPID@ +ExecStart=@GHOSTTY@ --launched-from=systemd diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 23aceda98..6b305d0da 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -2,6 +2,7 @@ const GhosttyResources = @This(); const std = @import("std"); const builtin = @import("builtin"); +const assert = std.debug.assert; const buildpkg = @import("main.zig"); const Config = @import("Config.zig"); const config_vim = @import("../config/vim.zig"); @@ -220,182 +221,176 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { } // App (Linux) - if (cfg.target.result.os.tag == .linux) { - // https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html + if (cfg.target.result.os.tag == .linux) try addLinuxAppResources( + b, + cfg, + &steps, + ); - const name = b.fmt("Ghostty{s}", .{ - switch (cfg.optimize) { - .Debug, .ReleaseSafe => " (Debug)", - .ReleaseFast, .ReleaseSmall => "", - }, - }); + return .{ .steps = steps.items }; +} - const app_id = b.fmt("com.mitchellh.ghostty{s}", .{ - switch (cfg.optimize) { - .Debug, .ReleaseSafe => "-debug", - .ReleaseFast, .ReleaseSmall => "", - }, - }); +/// Add the resource files needed to make Ghostty a proper +/// Linux desktop application (for various desktop environments). +fn addLinuxAppResources( + b: *std.Build, + cfg: *const Config, + steps: *std.ArrayList(*std.Build.Step), +) !void { + assert(cfg.target.result.os.tag == .linux); + + // Background: + // https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html + + const name = b.fmt("Ghostty{s}", .{ + switch (cfg.optimize) { + .Debug, .ReleaseSafe => " (Debug)", + .ReleaseFast, .ReleaseSmall => "", + }, + }); + + const app_id = b.fmt("com.mitchellh.ghostty{s}", .{ + switch (cfg.optimize) { + .Debug, .ReleaseSafe => "-debug", + .ReleaseFast, .ReleaseSmall => "", + }, + }); + + const exe_abs_path = b.fmt( + "{s}/bin/ghostty", + .{b.install_prefix}, + ); + + // The templates that we will process. The templates are in + // cmake format and will be processed and saved to the + // second element of the tuple. + const Template = struct { std.Build.LazyPath, []const u8 }; + const templates: []const Template = templates: { + var ts: std.ArrayList(Template) = .init(b.allocator); // Desktop file so that we have an icon and other metadata - try steps.append( - formatService( - b, - cfg, - name, - app_id, - b.path("dist/linux/app.desktop.in"), - b.fmt( - "share/applications/{s}.desktop", - .{app_id}, - ), - ), - ); + try ts.append(.{ + b.path("dist/linux/app.desktop.in"), + b.fmt("share/applications/{s}.desktop", .{app_id}), + }); + // Service for DBus activation. - try steps.append( - formatService( - b, - cfg, - name, - app_id, + try ts.append(.{ + if (cfg.flatpak) + b.path("dist/linux/dbus.service.flatpak.in") + else b.path("dist/linux/dbus.service.in"), - b.fmt( - "share/dbus-1/services/{s}.service", - .{app_id}, - ), - ), - ); - // systemd user service - if (!cfg.flatpak) - try steps.append( - formatService( - b, - cfg, - name, + b.fmt("share/dbus-1/services/{s}.service", .{app_id}), + }); + + // systemd user service. This is kind of nasty but systemd + // looks for user services in different paths depending on + // if we are installed as a system package or not (lib vs. + // share) so we have to handle that here. We might be able + // to get away with always installing to both because it + // only ever searches in one... but I don't want to do that hack + // until we have to. + if (!cfg.flatpak) try ts.append(.{ + b.path("dist/linux/systemd.service.in"), + b.fmt( + "{s}/systemd/user/{s}.service", + .{ + if (b.graph.system_package_mode) "lib" else "share", app_id, - b.path("dist/linux/systemd.service.in"), - b.fmt( - "{s}/systemd/user/{s}.service", - .{ - if (b.graph.system_package_mode) "lib" else "share", - app_id, - }, - ), - ), - ); - - // AppStream metainfo so that application has rich metadata within app stores - try steps.append( - formatService( - b, - cfg, - name, - app_id, - b.path("dist/linux/com.mitchellh.ghostty.metainfo.xml.in"), - b.fmt( - "share/metainfo/{s}.metainfo.xml", - .{app_id}, - ), + }, ), + }); + + // AppStream metainfo so that application has rich metadata + // within app stores + try ts.append(.{ + b.path("dist/linux/com.mitchellh.ghostty.metainfo.xml.in"), + b.fmt("share/metainfo/{s}.metainfo.xml", .{app_id}), + }); + + break :templates ts.items; + }; + + // Process all our templates + for (templates) |template| { + const tpl = b.addConfigHeader(.{ + .style = .{ .cmake = template[0] }, + }, .{ + .NAME = name, + .APPID = app_id, + .GHOSTTY = exe_abs_path, + }); + + const copy = b.addInstallFile( + tpl.getOutput(), + template[1], ); - // Right click menu action for Plasma desktop - try steps.append(&b.addInstallFile( - b.path("dist/linux/ghostty_dolphin.desktop"), - "share/kio/servicemenus/com.mitchellh.ghostty.desktop", - ).step); + try steps.append(©.step); + } - // Right click menu action for Nautilus. Note that this _must_ be named - // `ghostty.py`. Using the full app id causes problems (see #5468). - try steps.append(&b.addInstallFile( - b.path("dist/linux/ghostty_nautilus.py"), - "share/nautilus-python/extensions/ghostty.py", - ).step); + // Right click menu action for Plasma desktop + try steps.append(&b.addInstallFile( + b.path("dist/linux/ghostty_dolphin.desktop"), + "share/kio/servicemenus/com.mitchellh.ghostty.desktop", + ).step); - // Various icons that our application can use, including the icon - // that will be used for the desktop. - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_16.png"), - "share/icons/hicolor/16x16/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_32.png"), - "share/icons/hicolor/32x32/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_128.png"), - "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_256.png"), - "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_512.png"), - "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png", - ).step); - // Flatpaks only support icons up to 512x512. - if (!cfg.flatpak) { - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_1024.png"), - "share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png", - ).step); - } + // Right click menu action for Nautilus. Note that this _must_ be named + // `ghostty.py`. Using the full app id causes problems (see #5468). + try steps.append(&b.addInstallFile( + b.path("dist/linux/ghostty_nautilus.py"), + "share/nautilus-python/extensions/ghostty.py", + ).step); + // Various icons that our application can use, including the icon + // that will be used for the desktop. + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_16.png"), + "share/icons/hicolor/16x16/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_32.png"), + "share/icons/hicolor/32x32/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_128.png"), + "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_256.png"), + "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_512.png"), + "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png", + ).step); + // Flatpaks only support icons up to 512x512. + if (!cfg.flatpak) { try steps.append(&b.addInstallFile( - b.path("images/icons/icon_16@2x.png"), - "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_32@2x.png"), - "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_128@2x.png"), - "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_256@2x.png"), - "share/icons/hicolor/256x256@2/apps/com.mitchellh.ghostty.png", + b.path("images/icons/icon_1024.png"), + "share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png", ).step); } - return .{ .steps = steps.items }; + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_16@2x.png"), + "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_32@2x.png"), + "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_128@2x.png"), + "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_256@2x.png"), + "share/icons/hicolor/256x256@2/apps/com.mitchellh.ghostty.png", + ).step); } pub fn install(self: *const GhosttyResources) void { const b = self.steps[0].owner; for (self.steps) |step| b.getInstallStep().dependOn(step); } - -pub fn formatService( - b: *std.Build, - cfg: *const Config, - name: []const u8, - app_id: []const u8, - src: std.Build.LazyPath, - dest: []const u8, -) *std.Build.Step { - var cmd = b.addExecutable( - .{ - .name = "desktop-template", - .root_source_file = b.path("src/build/desktop_template.zig"), - .target = b.graph.host, - }, - ); - - const options = b.addOptions(); - - options.addOption([]const u8, "app_id", app_id); - options.addOption([]const u8, "name", name); - options.addOption([]const u8, "ghostty", b.fmt("{s}/bin/ghostty", .{b.install_prefix})); - options.addOption(bool, "flatpak", cfg.flatpak); - - cmd.root_module.addOptions("cfg", options); - - const run = b.addRunArtifact(cmd); - run.setStdIn(.{ .lazy_path = src }); - const output = run.captureStdOut(); - - return &b.addInstallFile(output, dest).step; -} diff --git a/src/build/desktop_template.zig b/src/build/desktop_template.zig deleted file mode 100644 index 7b066bfcc..000000000 --- a/src/build/desktop_template.zig +++ /dev/null @@ -1,31 +0,0 @@ -const std = @import("std"); -const cfg = @import("cfg"); - -pub fn main() !void { - var debug: std.heap.DebugAllocator(.{}) = .init; - defer _ = debug.deinit(); - - const alloc = debug.allocator(); - - const stdin = std.io.getStdIn(); - const stdout = std.io.getStdOut(); - var input = stdin.reader(); - - while (try input.readUntilDelimiterOrEofAlloc(alloc, '\n', 4096)) |line| { - defer alloc.free(line); - - const buf1 = try std.mem.replaceOwned(u8, alloc, line, "@@APPID@@", cfg.app_id); - defer alloc.free(buf1); - - const buf2 = try std.mem.replaceOwned(u8, alloc, buf1, "@@NAME@@", cfg.name); - defer alloc.free(buf2); - - const buf3 = try std.mem.replaceOwned(u8, alloc, buf2, "@@GHOSTTY@@", cfg.ghostty); - defer alloc.free(buf3); - - if (cfg.flatpak and std.mem.startsWith(u8, buf3, "SystemdService=")) continue; - - try stdout.writeAll(buf3); - try stdout.writeAll("\n"); - } -} From 77654eb01cd1c0dcf07e1e427019b8633a98d188 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Jun 2025 13:07:38 -0700 Subject: [PATCH 070/110] use tail to clear the first line of the template --- src/build/GhosttyResources.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 6b305d0da..34b5e35f8 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -321,8 +321,13 @@ fn addLinuxAppResources( .GHOSTTY = exe_abs_path, }); + // Template output has a single header line we want to remove. + // We use `tail` to do it since its part of the POSIX standard. + const tail = b.addSystemCommand(&.{ "tail", "-n", "+2" }); + tail.setStdIn(.{ .lazy_path = tpl.getOutput() }); + const copy = b.addInstallFile( - tpl.getOutput(), + tail.captureStdOut(), template[1], ); From b4e81949ee9397bec34c68969e5a679eda0a7f5d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Jun 2025 13:12:03 -0700 Subject: [PATCH 071/110] wrong service name for dbus systemd service --- dist/linux/dbus.service.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dist/linux/dbus.service.in b/dist/linux/dbus.service.in index 85b7c425c..2f782a7ed 100644 --- a/dist/linux/dbus.service.in +++ b/dist/linux/dbus.service.in @@ -1,4 +1,4 @@ [D-BUS Service] Name=@APPID@ -SystemdService=@APPID@service +SystemdService=@APPID@.service Exec=@GHOSTTY@ --launched-from=dbus From d92d1cac2a29b813c67006c86aedf13ff6479b91 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Jun 2025 13:16:54 -0700 Subject: [PATCH 072/110] remove unused TODO.md --- TODO.md | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 696bed75f..000000000 --- a/TODO.md +++ /dev/null @@ -1,21 +0,0 @@ -Performance: - -- Loading fonts on startups should probably happen in multiple threads - -Correctness: - -- test wrap against wraptest: https://github.com/mattiase/wraptest - - automate this in some way -- Charsets: UTF-8 vs. ASCII mode - - we only support UTF-8 input right now - - need fallback glyphs if they're not supported - - can effect a crash using `vttest` menu `3 10` since it tries to parse - ASCII as UTF-8. - -Mac: - -- Preferences window - -Major Features: - -- Bell From 6d6dcf863a54bdbaa47c00f76b65a89670bf548a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Jun 2025 13:36:38 -0700 Subject: [PATCH 073/110] Correct AppStream metainfo XML, broken trailing tags --- dist/linux/com.mitchellh.ghostty.metainfo.xml.in | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dist/linux/com.mitchellh.ghostty.metainfo.xml.in b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in index 00d99adb2..42ccc2754 100644 --- a/dist/linux/com.mitchellh.ghostty.metainfo.xml.in +++ b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in @@ -1,8 +1,8 @@ - @APPID@id> - @APPID@desktop - @NAME@name> + @APPID@ + @APPID@.desktop + @NAME@ https://ghostty.org https://ghostty.org/docs https://github.com/ghostty-org/ghostty/discussions From 810ab6a8447f45a84155b58a00606afef5637eb3 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 26 Jun 2025 16:34:51 -0600 Subject: [PATCH 074/110] renderer/OpenGL: revert change to compressed texture format This was applied to the wrong thing by accident, making the custom shader ping-pong textures compressed, which breaks custom shaders because compressed texture formats are not color renderable. Additionally, I've not switched the compressed format to the correct texture options, because I tried that and it turns out that the default compression applied by drivers can't be trusted to be good quality and generally speaking looks terrible. In the future we can explore doing the compression ourselves CPU-side with something like b7enc_rdo. --- src/renderer/OpenGL.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index e112c0df7..3b4ba6d80 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -395,7 +395,7 @@ pub inline fn textureOptions(self: OpenGL) Texture.Options { _ = self; return .{ .format = .rgba, - .internal_format = .srgba_compressed, + .internal_format = .srgba, .target = .@"2D", }; } From d6db3013be7aff7982814c1f9ea4c17d71c71f8f Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 26 Jun 2025 16:37:06 -0600 Subject: [PATCH 075/110] renderer/OpenGL: switch image texture from Rect to 2D We were using the Rectangle target for simpler addressing, since that allows for pixel coordinates instead of normalized coordinates, but there are downsides to rectangle textures, including not supporting compressed texture formats, and we do probably want to use compressed formats in the future, so I'm making this change now. --- src/renderer/OpenGL.zig | 2 +- src/renderer/shaders/glsl/bg_image.f.glsl | 7 ++++--- src/renderer/shaders/glsl/bg_image.v.glsl | 4 ++-- src/renderer/shaders/glsl/image.f.glsl | 2 +- src/renderer/shaders/glsl/image.v.glsl | 7 ++++--- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 3b4ba6d80..cf195361e 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -428,7 +428,7 @@ pub inline fn imageTextureOptions( return .{ .format = format.toPixelFormat(), .internal_format = if (srgb) .srgba else .rgba, - .target = .Rectangle, + .target = .@"2D", }; } diff --git a/src/renderer/shaders/glsl/bg_image.f.glsl b/src/renderer/shaders/glsl/bg_image.f.glsl index 7c3e4363a..ee1195ef5 100644 --- a/src/renderer/shaders/glsl/bg_image.f.glsl +++ b/src/renderer/shaders/glsl/bg_image.f.glsl @@ -4,7 +4,7 @@ // so as to align with our texture's directionality. layout(origin_upper_left) in vec4 gl_FragCoord; -layout(binding = 0) uniform sampler2DRect image; +layout(binding = 0) uniform sampler2D image; flat in vec4 bg_color; flat in vec2 offset; @@ -23,7 +23,7 @@ void main() { // size of the texture to the dest rect size. vec2 tex_coord = (gl_FragCoord.xy - offset) * scale; - vec2 tex_size = textureSize(image); + vec2 tex_size = textureSize(image, 0); // If we need to repeat the texture, wrap the coordinates. if (repeat != 0) { @@ -38,7 +38,8 @@ void main() { { rgba = vec4(0.0); } else { - rgba = texture(image, tex_coord); + // We divide by the texture size to normalize for sampling. + rgba = texture(image, tex_coord / tex_size); if (!use_linear_blending) { rgba = unlinearize(rgba); diff --git a/src/renderer/shaders/glsl/bg_image.v.glsl b/src/renderer/shaders/glsl/bg_image.v.glsl index 875c40518..d55aa174a 100644 --- a/src/renderer/shaders/glsl/bg_image.v.glsl +++ b/src/renderer/shaders/glsl/bg_image.v.glsl @@ -1,6 +1,6 @@ #include "common.glsl" -layout(binding = 0) uniform sampler2DRect image; +layout(binding = 0) uniform sampler2D image; layout(location = 0) in float in_opacity; layout(location = 1) in uint info; @@ -64,7 +64,7 @@ void main() { repeat = info & BG_IMAGE_REPEAT; vec2 screen_size = screen_size; - vec2 tex_size = textureSize(image); + vec2 tex_size = textureSize(image, 0); vec2 dest_size = tex_size; switch (info & BG_IMAGE_FIT) { diff --git a/src/renderer/shaders/glsl/image.f.glsl b/src/renderer/shaders/glsl/image.f.glsl index cd93cf666..4f89d7a78 100644 --- a/src/renderer/shaders/glsl/image.f.glsl +++ b/src/renderer/shaders/glsl/image.f.glsl @@ -1,6 +1,6 @@ #include "common.glsl" -layout(binding = 0) uniform sampler2DRect image; +layout(binding = 0) uniform sampler2D image; in vec2 tex_coord; diff --git a/src/renderer/shaders/glsl/image.v.glsl b/src/renderer/shaders/glsl/image.v.glsl index 55b12ed68..779fae32f 100644 --- a/src/renderer/shaders/glsl/image.v.glsl +++ b/src/renderer/shaders/glsl/image.v.glsl @@ -1,6 +1,6 @@ #include "common.glsl" -layout(binding = 0) uniform sampler2DRect image; +layout(binding = 0) uniform sampler2D image; layout(location = 0) in vec2 grid_pos; layout(location = 1) in vec2 cell_offset; @@ -32,11 +32,12 @@ void main() { // The texture coordinates start at our source x/y // and add the width/height depending on the corner. - // - // We don't need to normalize because we use pixel addressing for our sampler. tex_coord = source_rect.xy; tex_coord += source_rect.zw * corner; + // Normalize the coordinates. + tex_coord /= textureSize(image, 0); + // The position of our image starts at the top-left of the grid cell and // adds the source rect width/height components. vec2 image_pos = (cell_size * grid_pos) + cell_offset; From 937b10b42238ae38e53bd7af12ce2ef680cc3db5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 27 Jun 2025 06:58:10 -0700 Subject: [PATCH 076/110] Update CODEOWNERS for localization managers --- CODEOWNERS | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index 3d8a4da3d..343e1dcc1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -81,6 +81,10 @@ # - @ghostty-org/localization/* - Anything related to localization # for a specific locale. # +# - @ghosty-org/localization/manager - Manage all localization tasks +# and tooling. They are not responsible for any specific locale but +# are responsible for the overall localization process and tooling. +# # - @ghostty-org/macos - The Ghostty macOS app and any macOS-specific # features, configurations, etc. # From c6f23bbb32cac497022f19280c929ee9b2ace3ec Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 1 Feb 2025 15:10:05 -0600 Subject: [PATCH 077/110] core: con't copy App and apprt.App Besides avoiding copying, this allows consumers to choose to allocate these structs on the stack or to allocate on the heap. It also gives the apprt.App a stable pointer sooner in the process. --- src/App.zig | 17 +++++------------ src/apprt/embedded.zig | 17 +++++++++++------ src/apprt/glfw.zig | 4 ++-- src/apprt/gtk/App.zig | 4 ++-- src/main_ghostty.zig | 8 +++++--- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/App.zig b/src/App.zig index 3bbeff2c8..fceab0275 100644 --- a/src/App.zig +++ b/src/App.zig @@ -82,28 +82,23 @@ pub const CreateError = Allocator.Error || font.SharedGridSet.InitError; /// /// After calling this function, well behaved apprts should then call /// `focusEvent` to set the initial focus state of the app. -pub fn create( +pub fn init( + self: *App, alloc: Allocator, -) CreateError!*App { - var app = try alloc.create(App); - errdefer alloc.destroy(app); - +) CreateError!void { var font_grid_set = try font.SharedGridSet.init(alloc); errdefer font_grid_set.deinit(); - app.* = .{ + self.* = .{ .alloc = alloc, .surfaces = .{}, .mailbox = .{}, .font_grid_set = font_grid_set, .config_conditional_state = .{}, }; - errdefer app.surfaces.deinit(alloc); - - return app; } -pub fn destroy(self: *App) void { +pub fn deinit(self: *App) void { // Clean up all our surfaces for (self.surfaces.items) |surface| surface.deinit(); self.surfaces.deinit(self.alloc); @@ -114,8 +109,6 @@ pub fn destroy(self: *App) void { // should gracefully close all surfaces. assert(self.font_grid_set.count() == 0); self.font_grid_set.deinit(); - - self.alloc.destroy(self); } /// Tick ticks the app loop. This will drain our mailbox and process those diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 31dd2f46b..2234fdc2d 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -117,10 +117,11 @@ pub const App = struct { config: Config, pub fn init( + self: *App, core_app: *CoreApp, config: *const Config, opts: Options, - ) !App { + ) !void { // We have to clone the config. const alloc = core_app.alloc; var config_clone = try config.clone(alloc); @@ -129,7 +130,7 @@ pub const App = struct { var keymap = try input.Keymap.init(); errdefer keymap.deinit(); - return .{ + self.* = .{ .core_app = core_app, .config = config_clone, .opts = opts, @@ -1316,13 +1317,16 @@ pub const CAPI = struct { opts: *const apprt.runtime.App.Options, config: *const Config, ) !*App { - var core_app = try CoreApp.create(global.alloc); - errdefer core_app.destroy(); + var core_app = try global.alloc.create(CoreApp); + errdefer { + core_app.deinit(); + global.alloc.destroy(core_app); + } // Create our runtime app var app = try global.alloc.create(App); errdefer global.alloc.destroy(app); - app.* = try .init(core_app, config, opts.*); + try app.init(core_app, config, opts.*); errdefer app.terminate(); return app; @@ -1345,7 +1349,8 @@ pub const CAPI = struct { const core_app = v.core_app; v.terminate(); global.alloc.destroy(v); - core_app.destroy(); + core_app.deinit(); + global.alloc.destroy(core_app); } /// Update the focused state of the app. diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 6e131435d..b82771d75 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -50,7 +50,7 @@ pub const App = struct { pub const Options = struct {}; - pub fn init(core_app: *CoreApp, _: Options) !App { + pub fn init(self: *App, core_app: *CoreApp, _: Options) !void { if (comptime builtin.target.os.tag.isDarwin()) { log.warn("WARNING WARNING WARNING: GLFW ON MAC HAS BUGS.", .{}); log.warn("You should use the AppKit-based app instead. The official download", .{}); @@ -107,7 +107,7 @@ pub const App = struct { // We want the event loop to wake up instantly so we can process our tick. glfw.postEmptyEvent(); - return .{ + self.* = .{ .app = core_app, .config = config, .darwin = darwin, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 93e069376..7786f976a 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -110,7 +110,7 @@ quit_timer: union(enum) { expired: void, } = .{ .off = {} }, -pub fn init(core_app: *CoreApp, opts: Options) !App { +pub fn init(self: *App, core_app: *CoreApp, opts: Options) !void { _ = opts; // Log our GTK version @@ -424,7 +424,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 3, ); - return .{ + self.* = .{ .core_app = core_app, .app = adw_app, .config = config, diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 985c6c9bd..1eb91b6b2 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -98,11 +98,13 @@ pub fn main() !MainReturn { } // Create our app state - var app = try App.create(alloc); - defer app.destroy(); + var app: App = undefined; + defer app.deinit(); + try app.init(alloc); // Create our runtime app - var app_runtime = try apprt.App.init(app, .{}); + var app_runtime: apprt.App = undefined; + try app_runtime.init(&app, .{}); defer app_runtime.terminate(); // Since - by definition - there are no surfaces when first started, the From 3c49d8775106b2507ad21978c678eac5a1e33669 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 5 Feb 2025 11:30:46 -0600 Subject: [PATCH 078/110] fix order of defer --- src/main_ghostty.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 1eb91b6b2..ca63c8195 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -99,8 +99,8 @@ pub fn main() !MainReturn { // Create our app state var app: App = undefined; - defer app.deinit(); try app.init(alloc); + defer app.deinit(); // Create our runtime app var app_runtime: apprt.App = undefined; From 1979fb92f42086271f5d09fc5b4f24a378a611a6 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 5 Feb 2025 15:04:28 -0600 Subject: [PATCH 079/110] embedded: fix core app init --- src/apprt/embedded.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 2234fdc2d..307efd26a 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1318,6 +1318,7 @@ pub const CAPI = struct { config: *const Config, ) !*App { var core_app = try global.alloc.create(CoreApp); + try core_app.init(global.alloc); errdefer { core_app.deinit(); global.alloc.destroy(core_app); From 83690744b2540d3bc2fc828bfefe53865d499b85 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 27 Jun 2025 09:09:54 -0700 Subject: [PATCH 080/110] reintroduce App.create --- src/App.zig | 17 +++++++++++++++++ src/apprt/embedded.zig | 11 +++-------- src/main_ghostty.zig | 5 ++--- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/App.zig b/src/App.zig index fceab0275..02089ae5b 100644 --- a/src/App.zig +++ b/src/App.zig @@ -76,6 +76,15 @@ first: bool = true, pub const CreateError = Allocator.Error || font.SharedGridSet.InitError; +/// Create a new app instance. This returns a stable pointer to the app +/// instance which is required for callbacks. +pub fn create(alloc: Allocator) CreateError!*App { + var app = try alloc.create(App); + errdefer alloc.destroy(app); + try app.init(alloc); + return app; +} + /// Initialize the main app instance. This creates the main window, sets /// up the renderer state, compiles the shaders, etc. This is the primary /// "startup" logic. @@ -111,6 +120,14 @@ pub fn deinit(self: *App) void { self.font_grid_set.deinit(); } +pub fn destroy(self: *App) void { + // Deinitialize the app + self.deinit(); + + // Free the app memory + self.alloc.destroy(self); +} + /// Tick ticks the app loop. This will drain our mailbox and process those /// events. This should be called by the application runtime on every loop /// tick. diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 307efd26a..dec1e4135 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1317,12 +1317,8 @@ pub const CAPI = struct { opts: *const apprt.runtime.App.Options, config: *const Config, ) !*App { - var core_app = try global.alloc.create(CoreApp); - try core_app.init(global.alloc); - errdefer { - core_app.deinit(); - global.alloc.destroy(core_app); - } + const core_app = try CoreApp.create(global.alloc); + errdefer core_app.destroy(); // Create our runtime app var app = try global.alloc.create(App); @@ -1350,8 +1346,7 @@ pub const CAPI = struct { const core_app = v.core_app; v.terminate(); global.alloc.destroy(v); - core_app.deinit(); - global.alloc.destroy(core_app); + core_app.destroy(); } /// Update the focused state of the app. diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index ca63c8195..b75d8397c 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -98,9 +98,8 @@ pub fn main() !MainReturn { } // Create our app state - var app: App = undefined; - try app.init(alloc); - defer app.deinit(); + const app: *App = try App.create(alloc); + defer app.destroy(); // Create our runtime app var app_runtime: apprt.App = undefined; From 4b5ccf79a502efc40c83c1f2b65819b8df41b5d7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 27 Jun 2025 09:16:00 -0700 Subject: [PATCH 081/110] fix compilation issue, tests should've caught this but GHA failed --- src/main_ghostty.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index b75d8397c..567eec5f9 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -103,7 +103,7 @@ pub fn main() !MainReturn { // Create our runtime app var app_runtime: apprt.App = undefined; - try app_runtime.init(&app, .{}); + try app_runtime.init(app, .{}); defer app_runtime.terminate(); // Since - by definition - there are no surfaces when first started, the From 52354b8becbd35f6977a65bc835a11cf8186ea3c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 27 Jun 2025 09:40:29 -0700 Subject: [PATCH 082/110] core: only update selection clipboard on left mouse release Fixes #4800, supercedes #5995 This is a rewrite of #5995 (though the solution is mostly the same since this is pretty straightforward). The main difference is the rebase on the new mouse handling we've had since, and I also continue to update the selection clipboard on non-left-mouse events. --- src/Surface.zig | 56 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 6005635d9..24b1e5be8 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3094,15 +3094,33 @@ 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 (button == .left and action == .release and self.mouse.over_link) { - const pos = try self.rt_surface.getCursorPos(); - if (self.processLinks(pos)) |processed| { - if (processed) return true; - } else |err| { - log.warn("error processing links err={}", .{err}); + if (button == .left and action == .release) { + // The selection clipboard is only updated for left-click drag when + // 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.screen.selection; + if (prev_) |prev| { + try self.setSelection(terminal.Selection.init( + prev.start(), + prev.end(), + false, + )); + } + } + + // 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) { + const pos = try self.rt_surface.getCursorPos(); + if (self.processLinks(pos)) |processed| { + if (processed) return true; + } else |err| { + log.warn("error processing links err={}", .{err}); + } } } @@ -3238,12 +3256,16 @@ pub fn mouseButtonCallback( log.err("error reading time, mouse multi-click won't work err={}", .{err}); } + // 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) { // Single click 1 => { // If we have a selection, clear it. This always happens. if (self.io.terminal.screen.selection != null) { - try self.setSelection(null); + try self.io.terminal.screen.select(null); try self.queueRender(); } }, @@ -3252,7 +3274,7 @@ pub fn mouseButtonCallback( 2 => { const sel_ = self.io.terminal.screen.selectWord(pin.*); if (sel_) |sel| { - try self.setSelection(sel); + try self.io.terminal.screen.select(sel); try self.queueRender(); } }, @@ -3264,7 +3286,7 @@ pub fn mouseButtonCallback( else self.io.terminal.screen.selectLine(.{ .pin = pin.* }); if (sel_) |sel| { - try self.setSelection(sel); + try self.io.terminal.screen.select(sel); try self.queueRender(); } }, @@ -3549,7 +3571,7 @@ pub fn mousePressureCallback( // to handle state inconsistency here. const pin = self.mouse.left_click_pin orelse break :select; const sel = self.io.terminal.screen.selectWord(pin.*) orelse break :select; - try self.setSelection(sel); + try self.io.terminal.screen.select(sel); try self.queueRender(); } } @@ -3768,13 +3790,13 @@ fn dragLeftClickDouble( // 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.setSelection(terminal.Selection.init( + try self.io.terminal.screen.select(.init( word_current.start(), word_start.end(), false, )); } else { - try self.setSelection(terminal.Selection.init( + try self.io.terminal.screen.select(.init( word_start.start(), word_current.end(), false, @@ -3806,7 +3828,7 @@ fn dragLeftClickTriple( } else { sel.endPtr().* = line.end(); } - try self.setSelection(sel); + try self.io.terminal.screen.select(sel); } fn dragLeftClickSingle( @@ -3815,7 +3837,7 @@ fn dragLeftClickSingle( drag_x: f64, ) !void { // This logic is in a separate function so that it can be unit tested. - try self.setSelection(mouseSelection( + try self.io.terminal.screen.select(mouseSelection( self.mouse.left_click_pin.?.*, drag_pin, @intFromFloat(@max(0.0, self.mouse.left_click_xpos)), From 591ef0f40f5fea31b399827bf2602ad85f44e869 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 27 Jun 2025 10:23:21 -0700 Subject: [PATCH 083/110] Move child exit handling logic to apprt thread Fixes #7500 Supersedes #7582 This commit moves the child exit handling logic from the IO thead to the apprt thread. The IO thread now only sends a `child_exited` message to the apprt thread with metadata about the exit conditions (exit code, runtime). From there, the apprt thread can handle the exit situation however is necessary. This commit doesn't change the behavior but it does fix the issue #7500. The behavior is: exit immediately, show abnormal exit message, wait for user input, etc. This also gets us closer to #7649. --- src/Surface.zig | 148 +++++++++++++++++++++++++++++++++++-- src/apprt/surface.zig | 10 ++- src/termio/Exec.zig | 163 +++-------------------------------------- src/termio/Termio.zig | 13 ---- src/termio/Thread.zig | 1 - src/termio/backend.zig | 8 +- src/termio/message.zig | 10 +-- 7 files changed, 163 insertions(+), 190 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 24b1e5be8..286d81383 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -237,6 +237,7 @@ const DerivedConfig = struct { /// For docs for these, see the associated config they are derived from. original_font_size: f32, keybind: configpkg.Keybinds, + abnormal_command_exit_runtime_ms: u32, clipboard_read: configpkg.ClipboardAccess, clipboard_write: configpkg.ClipboardAccess, clipboard_trim_trailing_spaces: bool, @@ -255,6 +256,7 @@ const DerivedConfig = struct { macos_option_as_alt: ?configpkg.OptionAsAlt, selection_clear_on_typing: bool, vt_kam_allowed: bool, + wait_after_command: bool, window_padding_top: u32, window_padding_bottom: u32, window_padding_left: u32, @@ -301,6 +303,7 @@ const DerivedConfig = struct { return .{ .original_font_size = config.@"font-size", .keybind = try config.keybind.clone(alloc), + .abnormal_command_exit_runtime_ms = config.@"abnormal-command-exit-runtime", .clipboard_read = config.@"clipboard-read", .clipboard_write = config.@"clipboard-write", .clipboard_trim_trailing_spaces = config.@"clipboard-trim-trailing-spaces", @@ -319,6 +322,7 @@ const DerivedConfig = struct { .macos_option_as_alt = config.@"macos-option-as-alt", .selection_clear_on_typing = config.@"selection-clear-on-typing", .vt_kam_allowed = config.@"vt-kam-allowed", + .wait_after_command = config.@"wait-after-command", .window_padding_top = config.@"window-padding-y".top_left, .window_padding_bottom = config.@"window-padding-y".bottom_right, .window_padding_left = config.@"window-padding-x".top_left, @@ -911,11 +915,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .close => self.close(), - // Close without confirmation. - .child_exited => { - self.child_exited = true; - self.close(); - }, + .child_exited => |v| self.childExited(v), .desktop_notification => |notification| { if (!self.config.desktop_notifications) { @@ -948,6 +948,136 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { } } +fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void { + // Mark our flag that we exited immediately + self.child_exited = true; + + // If our runtime was below some threshold then we assume that this + // was an abnormal exit and we show an error message. + if (info.runtime_ms <= self.config.abnormal_command_exit_runtime_ms) runtime: { + // On macOS, our exit code detection doesn't work, possibly + // because of our `login` wrapper. More investigation required. + if (comptime builtin.target.os.tag.isDarwin()) break :runtime; + + // If the exit code is 0 then we it was a good exit. + if (info.exit_code == 0) break :runtime; + log.warn("abnormal process exit detected, showing error message", .{}); + + // Update our terminal to note the abnormal exit. In the future we + // may want the apprt to handle this to show some native GUI element. + self.childExitedAbnormally(info) catch |err| { + log.err("error handling abnormal child exit err={}", .{err}); + return; + }; + + return; + } + + // We output a message so that the user knows whats going on and + // doesn't think their terminal just froze. We show this unconditionally + // on close even if `wait_after_command` is false and the surface closes + // immediately because if a user does an `undo` to restore a closed + // surface then they will see this message and know the process has + // completed. + terminal: { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + t.carriageReturn(); + t.linefeed() catch break :terminal; + t.printString("Process exited. Press any key to close the terminal.") catch + break :terminal; + t.modes.set(.cursor_visible, false); + } + + // Waiting after command we stop here. The terminal is updated, our + // state is updated, and now its up to the user to decide what to do. + if (self.config.wait_after_command) return; + + // If we aren't waiting after the command, then we exit immediately + // with no confirmation. + self.close(); +} + +/// Called when the child process exited abnormally. +fn childExitedAbnormally( + self: *Surface, + info: apprt.surface.Message.ChildExited, +) !void { + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Build up our command for the error message + const command = try std.mem.join(alloc, " ", switch (self.io.backend) { + .exec => |*exec| exec.subprocess.args, + }); + const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{info.runtime_ms}); + + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + + // No matter what move the cursor back to the column 0. + t.carriageReturn(); + + // Reset styles + try t.setAttribute(.{ .unset = {} }); + + // If there is data in the viewport, we want to scroll down + // a little bit and write a horizontal rule before writing + // our message. This lets the use see the error message the + // command may have output. + const viewport_str = try t.plainString(alloc); + if (viewport_str.len > 0) { + try t.linefeed(); + for (0..t.cols) |_| try t.print(0x2501); + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + } + + // Output our error message + try t.setAttribute(.{ .@"8_fg" = .bright_red }); + try t.setAttribute(.{ .bold = {} }); + try t.printString("Ghostty failed to launch the requested command:"); + try t.setAttribute(.{ .unset = {} }); + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString(command); + try t.setAttribute(.{ .unset = {} }); + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString("Runtime: "); + try t.setAttribute(.{ .@"8_fg" = .red }); + try t.printString(runtime_str); + try t.setAttribute(.{ .unset = {} }); + + // We don't print this on macOS because the exit code is always 0 + // due to the way we launch the process. + if (comptime !builtin.target.os.tag.isDarwin()) { + const exit_code_str = try std.fmt.allocPrint(alloc, "{d}", .{info.exit_code}); + t.carriageReturn(); + try t.linefeed(); + try t.printString("Exit Code: "); + try t.setAttribute(.{ .@"8_fg" = .red }); + try t.printString(exit_code_str); + try t.setAttribute(.{ .unset = {} }); + } + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString("Press any key to close the window."); + + // Hide the cursor + t.modes.set(.cursor_visible, false); +} + /// Called when the terminal detects there is a password input prompt. fn passwordInput(self: *Surface, v: bool) !void { { @@ -1953,6 +2083,14 @@ pub fn keyCallback( if (self.io.terminal.modes.get(.disable_keyboard)) return .consumed; } + // If our process is exited and we press a key then we close the + // surface. We may want to eventually move this to the apprt rather + // than in core. + if (self.child_exited and event.action == .press) { + self.close(); + return .closed; + } + // If this input event has text, then we hide the mouse if configured. // We only do this on pressed events to avoid hiding the mouse when we // change focus due to a keybinding (i.e. switching tabs). diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index dce6a3a56..fcc67134b 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -43,8 +43,9 @@ pub const Message = union(enum) { close: void, /// The child process running in the surface has exited. This may trigger - /// a surface close, it may not. - child_exited: void, + /// a surface close, it may not. Additional details about the child + /// command are given in the `ChildExited` struct. + child_exited: ChildExited, /// Show a desktop notification. desktop_notification: struct { @@ -89,6 +90,11 @@ pub const Message = union(enum) { // This enum is a placeholder for future title styles. }; + + pub const ChildExited = struct { + exit_code: u32, + runtime_ms: u64, + }; }; /// A surface mailbox. diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index aed7cefb6..b8f838cf9 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -10,6 +10,7 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const posix = std.posix; const xev = @import("../global.zig").xev; +const apprt = @import("../apprt.zig"); const build_config = @import("../build_config.zig"); const configpkg = @import("../config.zig"); const crash = @import("../crash/main.zig"); @@ -153,8 +154,6 @@ pub fn threadEnter( // Setup our threadata backend state to be our own td.backend = .{ .exec = .{ .start = process_start, - .abnormal_runtime_threshold_ms = io.config.abnormal_runtime_threshold_ms, - .wait_after_command = io.config.wait_after_command, .write_stream = stream, .process = process, .read_thread = read_thread, @@ -273,83 +272,6 @@ pub fn resize( return try self.subprocess.resize(grid_size, screen_size); } -/// Called when the child process exited abnormally but before the surface -/// is notified. -pub fn childExitedAbnormally( - self: *Exec, - gpa: Allocator, - t: *terminal.Terminal, - exit_code: u32, - runtime_ms: u64, -) !void { - var arena = ArenaAllocator.init(gpa); - defer arena.deinit(); - const alloc = arena.allocator(); - - // Build up our command for the error message - const command = try std.mem.join(alloc, " ", self.subprocess.args); - const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{runtime_ms}); - - // No matter what move the cursor back to the column 0. - t.carriageReturn(); - - // Reset styles - try t.setAttribute(.{ .unset = {} }); - - // If there is data in the viewport, we want to scroll down - // a little bit and write a horizontal rule before writing - // our message. This lets the use see the error message the - // command may have output. - const viewport_str = try t.plainString(alloc); - if (viewport_str.len > 0) { - try t.linefeed(); - for (0..t.cols) |_| try t.print(0x2501); - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - } - - // Output our error message - try t.setAttribute(.{ .@"8_fg" = .bright_red }); - try t.setAttribute(.{ .bold = {} }); - try t.printString("Ghostty failed to launch the requested command:"); - try t.setAttribute(.{ .unset = {} }); - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString(command); - try t.setAttribute(.{ .unset = {} }); - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString("Runtime: "); - try t.setAttribute(.{ .@"8_fg" = .red }); - try t.printString(runtime_str); - try t.setAttribute(.{ .unset = {} }); - - // We don't print this on macOS because the exit code is always 0 - // due to the way we launch the process. - if (comptime !builtin.target.os.tag.isDarwin()) { - const exit_code_str = try std.fmt.allocPrint(alloc, "{d}", .{exit_code}); - t.carriageReturn(); - try t.linefeed(); - try t.printString("Exit Code: "); - try t.setAttribute(.{ .@"8_fg" = .red }); - try t.printString(exit_code_str); - try t.setAttribute(.{ .unset = {} }); - } - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString("Press any key to close the window."); - - // Hide the cursor - t.modes.set(.cursor_visible, false); -} - /// This outputs an error message when exec failed and we are the /// child process. This returns so the caller should probably exit /// after calling this. @@ -386,63 +308,13 @@ fn processExitCommon(td: *termio.Termio.ThreadData, exit_code: u32) void { .{ exit_code, runtime_ms orelse 0 }, ); - // If our runtime was below some threshold then we assume that this - // was an abnormal exit and we show an error message. - if (runtime_ms) |runtime| runtime: { - // On macOS, our exit code detection doesn't work, possibly - // because of our `login` wrapper. More investigation required. - if (comptime !builtin.target.os.tag.isDarwin()) { - // If our exit code is zero, then the command was successful - // and we don't ever consider it abnormal. - if (exit_code == 0) break :runtime; - } - - // Our runtime always has to be under the threshold to be - // considered abnormal. This is because a user can always - // manually do something like `exit 1` in their shell to - // force the exit code to be non-zero. We only want to detect - // abnormal exits that happen so quickly the user can't react. - if (runtime > execdata.abnormal_runtime_threshold_ms) break :runtime; - log.warn("abnormal process exit detected, showing error message", .{}); - - // Notify our main writer thread which has access to more - // information so it can show a better error message. - td.mailbox.send(.{ - .child_exited_abnormally = .{ - .exit_code = exit_code, - .runtime_ms = runtime, - }, - }, null); - td.mailbox.notify(); - - return; - } - - // We output a message so that the user knows whats going on and - // doesn't think their terminal just froze. We show this unconditionally - // on close even if `wait_after_command` is false and the surface closes - // immediately because if a user does an `undo` to restore a closed - // surface then they will see this message and know the process has - // completed. - terminal: { - td.renderer_state.mutex.lock(); - defer td.renderer_state.mutex.unlock(); - const t = td.renderer_state.terminal; - t.carriageReturn(); - t.linefeed() catch break :terminal; - t.printString("Process exited. Press any key to close the terminal.") catch - break :terminal; - t.modes.set(.cursor_visible, false); - } - - // If we're purposely waiting then we just return since the process - // exited flag is set to true. This allows the terminal window to remain - // open. - if (execdata.wait_after_command) return; - - // Notify our surface we want to close + // We always notify the surface immediately that the child has + // exited and some metadata about the exit. _ = td.surface_mailbox.push(.{ - .child_exited = {}, + .child_exited = .{ + .exit_code = exit_code, + .runtime_ms = runtime_ms orelse 0, + }, }, .{ .forever = {} }); } @@ -563,14 +435,8 @@ pub fn queueWrite( _ = self; const exec = &td.backend.exec; - // If our process is exited then we send our surface a message - // about it but we don't queue any more writes. - if (exec.exited) { - _ = td.surface_mailbox.push(.{ - .child_exited = {}, - }, .{ .forever = {} }); - return; - } + // If our process is exited then we don't send any more writes. + if (exec.exited) return; // We go through and chunk the data if necessary to fit into // our cached buffers that we can queue to the stream. @@ -658,17 +524,6 @@ pub const ThreadData = struct { start: std.time.Instant, exited: bool = false, - /// The number of milliseconds below which we consider a process - /// exit to be abnormal. This is used to show an error message - /// when the process exits too quickly. - abnormal_runtime_threshold_ms: u32, - - /// If true, do not immediately send a child exited message to the - /// surface to close the surface when the command exits. If this is - /// false we'll show a process exited message and wait for user input - /// to close the surface. - wait_after_command: bool, - /// The data stream is the main IO for the pty. write_stream: xev.Stream, diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index c474d55bb..865a2df86 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -168,8 +168,6 @@ pub const DerivedConfig = struct { foreground: configpkg.Config.Color, background: configpkg.Config.Color, osc_color_report_format: configpkg.Config.OSCColorReportFormat, - abnormal_runtime_threshold_ms: u32, - wait_after_command: bool, enquiry_response: []const u8, pub fn init( @@ -190,8 +188,6 @@ pub const DerivedConfig = struct { .foreground = config.foreground, .background = config.background, .osc_color_report_format = config.@"osc-color-report-format", - .abnormal_runtime_threshold_ms = config.@"abnormal-command-exit-runtime", - .wait_after_command = config.@"wait-after-command", .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"), // This has to be last so that we copy AFTER the arena allocations @@ -660,15 +656,6 @@ pub fn jumpToPrompt(self: *Termio, delta: isize) !void { try self.renderer_wakeup.notify(); } -/// Called when the child process exited abnormally but before -/// the surface is notified. -pub fn childExitedAbnormally(self: *Termio, exit_code: u32, runtime_ms: u64) !void { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - const t = self.renderer_state.terminal; - try self.backend.childExitedAbnormally(self.alloc, t, exit_code, runtime_ms); -} - /// Called when focus is gained or lost (when focus events are enabled) pub fn focusGained(self: *Termio, td: *ThreadData, focused: bool) !void { self.renderer_state.mutex.lock(); diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 58a04f5a7..a701a29f8 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -311,7 +311,6 @@ fn drainMailbox( .jump_to_prompt => |v| try io.jumpToPrompt(v), .start_synchronized_output => self.startSynchronizedOutput(cb), .linefeed_mode => |v| self.flags.linefeed_mode = v, - .child_exited_abnormally => |v| try io.childExitedAbnormally(v.exit_code, v.runtime_ms), .focused => |v| try io.focusGained(data, v), .write_small => |v| try io.queueWrite( data, diff --git a/src/termio/backend.zig b/src/termio/backend.zig index 46ed3431c..280fcbde1 100644 --- a/src/termio/backend.zig +++ b/src/termio/backend.zig @@ -122,11 +122,7 @@ pub const ThreadData = union(Kind) { } pub fn changeConfig(self: *ThreadData, config: *termio.DerivedConfig) void { - switch (self.*) { - .exec => |*exec| { - exec.abnormal_runtime_threshold_ms = config.abnormal_runtime_threshold_ms; - exec.wait_after_command = config.wait_after_command; - }, - } + _ = self; + _ = config; } }; diff --git a/src/termio/message.zig b/src/termio/message.zig index 42767e109..e497a298f 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -1,6 +1,7 @@ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; +const apprt = @import("../apprt.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); @@ -58,15 +59,6 @@ pub const Message = union(enum) { /// Enable or disable linefeed mode (mode 20). linefeed_mode: bool, - /// The child exited abnormally. The termio state is marked - /// as process exited but the surface hasn't been notified to - /// close because termio can use this to update the terminal - /// with an error message. - child_exited_abnormally: struct { - exit_code: u32, - runtime_ms: u64, - }, - /// The surface gained or lost focus. focused: bool, From 138f74524e1c909620ba1c3560e032ceea79695f Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Fri, 27 Jun 2025 21:11:41 -0400 Subject: [PATCH 084/110] terminal: fix unexpected line wrapping in tests These tests write specific lines into a 10-column-wide test screen. The "prompt3$ input3\n" writes exceed that column limit, and some of their characters wrap onto the following line. These tests' current assertions aren't sensitive to that overflow, but I spotted the problem while doing some related work, and I thought it worth making these corrections to avoid any future surprises. --- src/terminal/Screen.zig | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 8cdaf3fa2..5b772ab84 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -7911,7 +7911,7 @@ test "Screen: selectOutput" { try s.testWriteString("input2\n"); // 3 try s.testWriteString("output2output2output2output2\n"); // 4, 5, 6 due to overflow try s.testWriteString("output2\n"); // 7 - try s.testWriteString("prompt3$ input3\n"); // 8 + try s.testWriteString("$ input3\n"); // 8 try s.testWriteString("output3\n"); // 9 try s.testWriteString("output3\n"); // 10 try s.testWriteString("output3"); // 11 @@ -7999,14 +7999,14 @@ test "Screen: selectOutput" { } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 9, - .y = 12, + .y = 11, } }, s.pages.pointFromPin(.active, sel.end()).?); } // input / prompt at y = 0, pt.y = 0 { s.deinit(); s = try init(alloc, 10, 5, 0); - try s.testWriteString("prompt1$ input1\n"); + try s.testWriteString("$ input1\n"); try s.testWriteString("output1\n"); try s.testWriteString("prompt2\n"); { @@ -8042,7 +8042,7 @@ test "Screen: selectPrompt basics" { try s.testWriteString("input2\n"); // 3 try s.testWriteString("output2\n"); // 4 try s.testWriteString("output2\n"); // 5 - try s.testWriteString("prompt3$ input3\n"); // 6 + try s.testWriteString("$ input3\n"); // 6 try s.testWriteString("output3\n"); // 7 try s.testWriteString("output3\n"); // 8 try s.testWriteString("output3"); // 9 @@ -8257,7 +8257,7 @@ test "Screen: promptPath" { try s.testWriteString("input2\n"); // 3 try s.testWriteString("output2\n"); // 4 try s.testWriteString("output2\n"); // 5 - try s.testWriteString("prompt3$ input3\n"); // 6 + try s.testWriteString("$ input3\n"); // 6 try s.testWriteString("output3\n"); // 7 try s.testWriteString("output3\n"); // 8 try s.testWriteString("output3"); // 9 From 22a624e56006ff33b97507656039277146b8f0e1 Mon Sep 17 00:00:00 2001 From: Islam Sharabash Date: Sat, 28 Jun 2025 09:01:41 +0200 Subject: [PATCH 085/110] Equalize splits based on children oriented in the same direction This changes equalization so it only counts children oriented in the same direction. This makes splits a bit more aesthetic, and replicates how split equalization works in neovim. --- macos/Sources/Features/Splits/SplitTree.swift | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index 1c4be7dd6..b353f6cbe 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -610,14 +610,18 @@ extension SplitTree.Node { return (self, 1) case .split(let split): - // Recursively equalize children - let (leftNode, leftWeight) = split.left.equalizeWithWeight() - let (rightNode, rightWeight) = split.right.equalizeWithWeight() - + // Calculate weights based on split direction + let leftWeight = split.left.weightForDirection(split.direction) + let rightWeight = split.right.weightForDirection(split.direction) + // Calculate new ratio based on relative weights let totalWeight = leftWeight + rightWeight let newRatio = Double(leftWeight) / Double(totalWeight) - + + // Recursively equalize children + let (leftNode, _) = split.left.equalizeWithWeight() + let (rightNode, _) = split.right.equalizeWithWeight() + // Create new split with equalized ratio let newSplit = Split( direction: split.direction, @@ -630,6 +634,23 @@ extension SplitTree.Node { } } + /// Calculate weight for equalization based on split direction. + /// Children with the same direction contribute their full weight, + /// children with different directions count as 1. + private func weightForDirection(_ direction: SplitTree.Direction) -> Int { + switch self { + case .leaf: + return 1 + case .split(let split): + if split.direction == direction { + return split.left.weightForDirection(direction) + split.right.weightForDirection(direction) + } else { + return 1 + } + } + } + + /// Calculate the bounds of all views in this subtree based on split ratios func calculateViewBounds(in bounds: CGRect) -> [(view: ViewType, bounds: CGRect)] { switch self { From 4ae75cc868e56d2c228bdd04420cfe12d42e58cb Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sat, 28 Jun 2025 00:21:38 -0700 Subject: [PATCH 086/110] Don't pass arena allocator to os.open --- src/config/edit.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config/edit.zig b/src/config/edit.zig index 871a1a755..ae4394942 100644 --- a/src/config/edit.zig +++ b/src/config/edit.zig @@ -20,10 +20,10 @@ pub fn open(alloc_gpa: Allocator) !void { // Use an arena to make memory management easier in here. var arena = ArenaAllocator.init(alloc_gpa); defer arena.deinit(); - const alloc = arena.allocator(); + const alloc_arena = arena.allocator(); // Get the path we should open - const config_path = try configPath(alloc); + const config_path = try configPath(alloc_arena); // Create config directory recursively. if (std.fs.path.dirname(config_path)) |config_dir| { @@ -41,7 +41,7 @@ pub fn open(alloc_gpa: Allocator) !void { } }; - try internal_os.open(alloc, .text, config_path); + try internal_os.open(alloc_gpa, .text, config_path); } /// Returns the config path to use for open for the current OS. From 33e07c87c90242cf529dce6975538ced58242fdc Mon Sep 17 00:00:00 2001 From: azhn Date: Mon, 14 Apr 2025 19:56:22 +1000 Subject: [PATCH 087/110] deps: Default gtk4-layer-shell system integration to true We default system-integration to true as this is a shared library that must be installed on a library path and it is recommended to use the system package. If the system does not package gtk4-layer-shell then doing `zig build -fno-sys` will now correctly build and install the shared library under a lib/ subdirectory of the output prefix. The output prefix should be a default library path (`/lib`, `/usr/lib`, or a lib64 variant) otherwise a custom library path can be configured using ldconfig (see `man ld.so 8`) --- src/build/Config.zig | 10 +++++++++- src/build/SharedDeps.zig | 9 ++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/build/Config.zig b/src/build/Config.zig index e55da3860..5f8780af9 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -361,7 +361,6 @@ pub fn init(b: *std.Build) !Config { "libpng", "zlib", "oniguruma", - "gtk4-layer-shell", }) |dep| { _ = b.systemIntegrationOption( dep, @@ -387,6 +386,15 @@ pub fn init(b: *std.Build) !Config { }) |dep| { _ = b.systemIntegrationOption(dep, .{ .default = false }); } + + // These are dynamic libraries we default to true, preferring + // to use system packages over building and installing libs + // as they require additional ldconfig of library paths or + // patching the rpath of the program to discover the dynamic library + // at runtime + for (&[_][]const u8{"gtk4-layer-shell"}) |dep| { + _ = b.systemIntegrationOption(dep, .{ .default = true }); + } } return config; diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index acd3ed1d8..ec97a9c9f 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -652,14 +652,13 @@ fn addGTK( // IMPORTANT: gtk4-layer-shell must be linked BEFORE // wayland-client, as it relies on shimming libwayland's APIs. if (b.systemIntegrationOption("gtk4-layer-shell", .{})) { - step.linkSystemLibrary2( - "gtk4-layer-shell-0", - dynamic_link_opts, - ); + step.linkSystemLibrary2("gtk4-layer-shell-0", dynamic_link_opts); } else { // gtk4-layer-shell *must* be dynamically linked, // so we don't add it as a static library - step.linkLibrary(gtk4_layer_shell.artifact("gtk4-layer-shell")); + const shared_lib = gtk4_layer_shell.artifact("gtk4-layer-shell"); + b.installArtifact(shared_lib); + step.linkLibrary(shared_lib); } } From 46b86570f21f68a32f6ae29caeb8e816ba289f67 Mon Sep 17 00:00:00 2001 From: azhn Date: Mon, 14 Apr 2025 21:20:03 +1000 Subject: [PATCH 088/110] deps: Enable building gtk4-layer-shell without system integration --- snap/snapcraft.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index d7fc63712..7c8b6dc4f 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -76,9 +76,10 @@ parts: - git - patchelf - gettext + # TODO: Remove -fno-sys=gtk4-layer-shell when we upgrade to a version that packages it Ubuntu 24.10+ override-build: | craftctl set version=$(cat VERSION) - $CRAFT_PART_SRC/../../zig/src/zig build -Dpatch-rpath=\$ORIGIN/../usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR:/snap/core24/current/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR -Doptimize=ReleaseFast -Dcpu=baseline + $CRAFT_PART_SRC/../../zig/src/zig build -Dpatch-rpath=\$ORIGIN/../usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR:/snap/core24/current/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR -Doptimize=ReleaseFast -Dcpu=baseline -fno-sys=gtk4-layer-shell cp -rp zig-out/* $CRAFT_PART_INSTALL/ # Install libgtk4-layer-shell.so mkdir -p $CRAFT_PART_INSTALL/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR From a8cad9831a09634526b612e4352153935f12b294 Mon Sep 17 00:00:00 2001 From: azhn Date: Sat, 28 Jun 2025 19:21:32 +1000 Subject: [PATCH 089/110] Remove copying libgtk4-layer-shell.so from cache since install is fixed --- snap/snapcraft.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 7c8b6dc4f..df8d6ae53 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -81,9 +81,6 @@ parts: craftctl set version=$(cat VERSION) $CRAFT_PART_SRC/../../zig/src/zig build -Dpatch-rpath=\$ORIGIN/../usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR:/snap/core24/current/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR -Doptimize=ReleaseFast -Dcpu=baseline -fno-sys=gtk4-layer-shell cp -rp zig-out/* $CRAFT_PART_INSTALL/ - # Install libgtk4-layer-shell.so - mkdir -p $CRAFT_PART_INSTALL/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR - cp .zig-cache/*/*/libgtk4-layer-shell.so $CRAFT_PART_INSTALL/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/ sed -i 's|Icon=com.mitchellh.ghostty|Icon=${SNAP}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png|g' $CRAFT_PART_INSTALL/share/applications/com.mitchellh.ghostty.desktop libs: From 5fa737834bddc3d0f37b85163a0640132813f759 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Sat, 28 Jun 2025 11:33:02 +0200 Subject: [PATCH 090/110] gtk(wayland): prevent gtk4-layer-shell crash on old versions Supersedes #7154 In gtk4-layer-shell versions < 1.0.4, the app could crash upon opening a quick terminal window on certain compositors that implement the `xdg_wm_dialog_v1` protocol. The exact reason is a bit complicated, but is nicely summarized in the upstream issue (wmww/gtk4-layer-shell#50). The circumstances that could cause this crash to occur should gradually diminish as distros update to newer gtk4-layer-shell versions, but this is known to crash on Fedora 41 and Hyprland, which could be a sizable chunk of our userbase given that this would also occur on GNOME/Mutter and KDE/KWin. The diff should be minimal enough that this can be removed or reverted once this band-aid fix is no longer necessary. --- pkg/gtk4-layer-shell/src/main.zig | 10 ++++ src/apprt/gtk/winproto/wayland.zig | 80 ++++++++++++++++++++++-------- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/pkg/gtk4-layer-shell/src/main.zig b/pkg/gtk4-layer-shell/src/main.zig index 06936bba2..f7848ea94 100644 --- a/pkg/gtk4-layer-shell/src/main.zig +++ b/pkg/gtk4-layer-shell/src/main.zig @@ -1,3 +1,5 @@ +const std = @import("std"); + const c = @cImport({ @cInclude("gtk4-layer-shell.h"); }); @@ -31,6 +33,14 @@ pub fn getProtocolVersion() c_uint { return c.gtk_layer_get_protocol_version(); } +pub fn getLibraryVersion() std.SemanticVersion { + return .{ + .major = c.gtk_layer_get_major_version(), + .minor = c.gtk_layer_get_minor_version(), + .patch = c.gtk_layer_get_micro_version(), + }; +} + pub fn initForWindow(window: *gtk.Window) void { c.gtk_layer_init_for_window(@ptrCast(window)); } diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index cbe8c01a4..ae3c871f2 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -37,6 +37,19 @@ pub const App = struct { default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null, xdg_activation: ?*xdg.ActivationV1 = null, + + /// Whether the xdg_wm_dialog_v1 protocol is present. + /// + /// If it is present, gtk4-layer-shell < 1.0.4 may crash when the user + /// creates a quick terminal, and we need to ensure this fails + /// gracefully if this situation occurs. + /// + /// FIXME: This is a temporary workaround - we should remove this when + /// all of our supported distros drop support for affected old + /// gtk4-layer-shell versions. + /// + /// See https://github.com/wmww/gtk4-layer-shell/issues/50 + xdg_wm_dialog_present: bool = false, }; pub fn init( @@ -95,11 +108,21 @@ pub const App = struct { return null; } - pub fn supportsQuickTerminal(_: App) bool { + pub fn supportsQuickTerminal(self: App) bool { if (!layer_shell.isSupported()) { log.warn("your compositor does not support the wlr-layer-shell protocol; disabling quick terminal", .{}); return false; } + + if (self.context.xdg_wm_dialog_present and layer_shell.getLibraryVersion().order(.{ + .major = 1, + .minor = 0, + .patch = 4, + }) == .lt) { + log.warn("the version of gtk4-layer-shell installed on your system is too old (must be 1.0.4 or newer); disabling quick terminal", .{}); + return false; + } + return true; } @@ -111,26 +134,38 @@ pub const App = struct { layer_shell.setNamespace(window, "ghostty-quick-terminal"); } + fn getInterfaceType(comptime field: std.builtin.Type.StructField) ?type { + // Globals should be optional pointers + const T = switch (@typeInfo(field.type)) { + .optional => |o| switch (@typeInfo(o.child)) { + .pointer => |v| v.child, + else => return null, + }, + else => return null, + }; + + // Only process Wayland interfaces + if (!@hasDecl(T, "interface")) return null; + return T; + } + fn registryListener( registry: *wl.Registry, event: wl.Registry.Event, context: *Context, ) void { - inline for (@typeInfo(Context).@"struct".fields) |field| { - // Globals should be optional pointers - const T = switch (@typeInfo(field.type)) { - .optional => |o| switch (@typeInfo(o.child)) { - .pointer => |v| v.child, - else => continue, - }, - else => continue, - }; + const ctx_fields = @typeInfo(Context).@"struct".fields; - // Only process Wayland interfaces - if (!@hasDecl(T, "interface")) continue; + switch (event) { + .global => |v| global: { + // We don't actually do anything with this other than checking + // for its existence, so we process this separately. + if (std.mem.orderZ(u8, v.interface, "xdg_wm_dialog_v1") == .eq) + context.xdg_wm_dialog_present = true; + + inline for (ctx_fields) |field| { + const T = getInterfaceType(field) orelse continue; - switch (event) { - .global => |v| global: { if (std.mem.orderZ( u8, v.interface, @@ -148,19 +183,22 @@ pub const App = struct { ); return; }; - }, + } + }, - // This should be a rare occurrence, but in case a global - // is suddenly no longer available, we destroy and unset it - // as the protocol mandates. - .global_remove => |v| remove: { + // This should be a rare occurrence, but in case a global + // is suddenly no longer available, we destroy and unset it + // as the protocol mandates. + .global_remove => |v| remove: { + inline for (ctx_fields) |field| { + if (getInterfaceType(field) == null) continue; const global = @field(context, field.name) orelse break :remove; if (global.getId() == v.name) { global.destroy(); @field(context, field.name) = null; } - }, - } + } + }, } } From ce015899f36374ddb67c3b6ced631168c762bfaf Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Mon, 17 Mar 2025 14:22:47 +0100 Subject: [PATCH 091/110] gtk: add "remember choice" toggle for clipboard confirmation dialog --- src/apprt/gtk/ClipboardConfirmationWindow.zig | 36 +++++- src/apprt/gtk/adw_version.zig | 4 + src/apprt/gtk/style.css | 6 +- src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp | 104 ++++++++++-------- src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp | 94 +++++++++------- 5 files changed, 153 insertions(+), 91 deletions(-) diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index fab1aa893..bf1549021 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -17,7 +17,7 @@ const adw_version = @import("adw_version.zig"); const log = std.log.scoped(.gtk); -const DialogType = if (adw_version.atLeast(1, 5, 0)) adw.AlertDialog else adw.MessageDialog; +const DialogType = if (adw_version.supportsDialogs()) adw.AlertDialog else adw.MessageDialog; app: *App, dialog: *DialogType, @@ -28,6 +28,7 @@ text_view: *gtk.TextView, text_view_scroll: *gtk.ScrolledWindow, reveal_button: *gtk.Button, hide_button: *gtk.Button, +remember_choice: if (adw_version.supportsSwitchRow()) ?*adw.SwitchRow else ?*anyopaque, pub fn create( app: *App, @@ -89,6 +90,10 @@ fn init( const reveal_button = builder.getObject(gtk.Button, "reveal_button").?; const hide_button = builder.getObject(gtk.Button, "hide_button").?; const text_view_scroll = builder.getObject(gtk.ScrolledWindow, "text_view_scroll").?; + const remember_choice = if (adw_version.supportsSwitchRow()) + builder.getObject(adw.SwitchRow, "remember_choice") + else + null; const copy = try app.core_app.alloc.dupeZ(u8, data); errdefer app.core_app.alloc.free(copy); @@ -102,6 +107,7 @@ fn init( .text_view_scroll = text_view_scroll, .reveal_button = reveal_button, .hide_button = hide_button, + .remember_choice = remember_choice, }; const buffer = gtk.TextBuffer.new(null); @@ -152,8 +158,10 @@ fn init( } } -fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) callconv(.c) void { - if (std.mem.orderZ(u8, response, "ok") == .eq) { +fn handleResponse(self: *ClipboardConfirmation, response: [*:0]const u8) void { + const is_ok = std.mem.orderZ(u8, response, "ok") == .eq; + + if (is_ok) { self.core_surface.completeClipboardRequest( self.pending_req, self.data, @@ -162,8 +170,30 @@ fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) log.err("Failed to requeue clipboard request: {}", .{err}); }; } + + if (self.remember_choice) |remember| remember: { + if (!adw_version.supportsSwitchRow()) break :remember; + if (remember.getActive() == 0) break :remember; + + switch (self.pending_req) { + .osc_52_read => self.core_surface.config.clipboard_read = if (is_ok) .allow else .deny, + .osc_52_write => self.core_surface.config.clipboard_write = if (is_ok) .allow else .deny, + .paste => {}, + } + } + self.destroy(); } +fn gtkChoose(dialog_: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.C) void { + const dialog = gobject.ext.cast(DialogType, dialog_.?).?; + const self: *ClipboardConfirmation = @ptrCast(@alignCast(ud.?)); + const response = dialog.chooseFinish(result); + self.handleResponse(response); +} + +fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) callconv(.C) void { + self.handleResponse(response); +} fn gtkRevealButtonClicked(_: *gtk.Button, self: *ClipboardConfirmation) callconv(.c) void { self.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(true)); diff --git a/src/apprt/gtk/adw_version.zig b/src/apprt/gtk/adw_version.zig index ff7439a21..7ce88f585 100644 --- a/src/apprt/gtk/adw_version.zig +++ b/src/apprt/gtk/adw_version.zig @@ -109,6 +109,10 @@ pub inline fn supportsTabOverview() bool { return atLeast(1, 4, 0); } +pub inline fn supportsSwitchRow() bool { + return atLeast(1, 4, 0); +} + pub inline fn supportsToolbarView() bool { return atLeast(1, 4, 0); } diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css index 7c4b53d03..2051ab1e3 100644 --- a/src/apprt/gtk/style.css +++ b/src/apprt/gtk/style.css @@ -64,14 +64,18 @@ window.ssd.no-border-radius { padding: 0; } +.clipboard-overlay { + border-radius: 10px; +} + .clipboard-content-view { filter: blur(0px); transition: filter 0.3s ease; + border-radius: 10px; } .clipboard-content-view.blurred { filter: blur(5px); - transition: filter 0.3s ease; } .command-palette-search { diff --git a/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp b/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp index 640556535..ad0b5c01f 100644 --- a/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp +++ b/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp @@ -14,58 +14,72 @@ Adw.AlertDialog clipboard_confirmation_window { default-response: "cancel"; close-response: "cancel"; - extra-child: Overlay { + extra-child: ListBox { + selection-mode: none; + styles [ - "osd", + "boxed-list-separate", ] - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 250; - - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; - - styles [ - "clipboard-content-view", - ] - } - } - - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - + Overlay { styles [ - "opaque", + "osd", + "clipboard-overlay", ] - Image { - icon-name: "view-conceal-symbolic"; + ScrolledWindow text_view_scroll { + width-request: 500; + height-request: 200; + + TextView text_view { + cursor-visible: false; + editable: false; + monospace: true; + top-margin: 8; + left-margin: 8; + bottom-margin: 8; + right-margin: 8; + + styles [ + "clipboard-content-view", + ] + } } + + [overlay] + Button reveal_button { + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + Image { + icon-name: "view-reveal-symbolic"; + } + } + + [overlay] + Button hide_button { + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + styles [ + "opaque", + ] + + Image { + icon-name: "view-conceal-symbolic"; + } + } + } + + Adw.SwitchRow remember_choice { + title: _("Remember choice for this split"); + subtitle: _("Reload configuration to show this prompt again"); } }; } diff --git a/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp b/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp index 2e28359ff..b71131940 100644 --- a/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp +++ b/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp @@ -14,58 +14,68 @@ Adw.AlertDialog clipboard_confirmation_window { default-response: "cancel"; close-response: "cancel"; - extra-child: Overlay { + extra-child: ListBox { + selection-mode: none; + styles [ - "osd", + "boxed-list-separate", ] - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 250; + Overlay { + styles [ + "osd", + "clipboard-overlay", + ] - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; + ScrolledWindow text_view_scroll { + width-request: 500; + height-request: 200; + + TextView text_view { + cursor-visible: false; + editable: false; + monospace: true; + top-margin: 8; + left-margin: 8; + bottom-margin: 8; + right-margin: 8; + + styles [ + "clipboard-content-view", + ] + } + } + + [overlay] + Button reveal_button { + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + Image { + icon-name: "view-reveal-symbolic"; + } + } + + [overlay] + Button hide_button { + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; styles [ - "clipboard-content-view", + "opaque", ] } } - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - styles [ - "opaque", - ] - - Image { - icon-name: "view-conceal-symbolic"; - } + Adw.SwitchRow remember_choice { + title: _("Remember choice for this split"); + subtitle: _("Reload configuration to show this prompt again"); } }; } From fbe94156f9de5b766872ff6f7750bfba9796163e Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Sat, 28 Jun 2025 17:01:49 +0200 Subject: [PATCH 092/110] translations: update --- po/com.mitchellh.ghostty.pot | 53 ++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/po/com.mitchellh.ghostty.pot b/po/com.mitchellh.ghostty.pot index da0efbbee..7691f91b5 100644 --- a/po/com.mitchellh.ghostty.pot +++ b/po/com.mitchellh.ghostty.pot @@ -1,5 +1,5 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR Mitchell Hashimoto, Ghostty contributors +# Copyright (C) YEAR "Mitchell Hashimoto, Ghostty contributors" # This file is distributed under the same license as the com.mitchellh.ghostty package. # FIRST AUTHOR , YEAR. # @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2025-04-23 16:58+0800\n" +"POT-Creation-Date: 2025-06-28 17:01+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -26,7 +26,8 @@ msgid "Leave blank to restore the default title." msgstr "" #: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9 -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/CloseDialog.zig:44 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/ui/1.2/ccw-paste.blp:10 +#: src/apprt/gtk/CloseDialog.zig:44 msgid "Cancel" msgstr "" @@ -35,22 +36,26 @@ msgid "OK" msgstr "" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:5 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:5 msgid "Configuration Errors" msgstr "" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:6 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." msgstr "" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:9 msgid "Ignore" msgstr "" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:10 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:100 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 msgid "Reload Configuration" msgstr "" @@ -89,7 +94,7 @@ msgstr "" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:11 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:11 -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:11 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:11 src/apprt/gtk/ui/1.2/ccw-paste.blp:11 msgid "Paste" msgstr "" @@ -119,7 +124,7 @@ msgstr "" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:62 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30 -#: src/apprt/gtk/Window.zig:255 +#: src/apprt/gtk/Window.zig:263 msgid "New Tab" msgstr "" @@ -160,7 +165,7 @@ msgid "Terminal Inspector" msgstr "" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 -#: src/apprt/gtk/Window.zig:1024 +#: src/apprt/gtk/Window.zig:1036 msgid "About Ghostty" msgstr "" @@ -170,10 +175,13 @@ msgstr "" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:6 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:6 msgid "Authorize Clipboard Access" msgstr "" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:7 msgid "" "An application is attempting to read from the clipboard. The current " "clipboard contents are shown below." @@ -181,52 +189,67 @@ msgstr "" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:10 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:10 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:10 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:10 msgid "Deny" msgstr "" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:11 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:11 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:11 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:11 msgid "Allow" msgstr "" +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:81 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:77 +msgid "Remember choice for this split" +msgstr "" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:82 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:78 +msgid "Reload configuration to show this prompt again" +msgstr "" + #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7 msgid "" "An application is attempting to write to the clipboard. The current " "clipboard contents are shown below." msgstr "" -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 src/apprt/gtk/ui/1.2/ccw-paste.blp:6 msgid "Warning: Potentially Unsafe Paste" msgstr "" -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 src/apprt/gtk/ui/1.2/ccw-paste.blp:7 msgid "" "Pasting this text into the terminal may be dangerous as it looks like some " "commands may be executed." msgstr "" -#: src/apprt/gtk/Window.zig:208 +#: src/apprt/gtk/Window.zig:216 msgid "Main Menu" msgstr "" -#: src/apprt/gtk/Window.zig:229 +#: src/apprt/gtk/Window.zig:238 msgid "View Open Tabs" msgstr "" -#: src/apprt/gtk/Window.zig:256 +#: src/apprt/gtk/Window.zig:264 msgid "New Split" msgstr "" -#: src/apprt/gtk/Window.zig:319 +#: src/apprt/gtk/Window.zig:327 msgid "" "⚠️ You're running a debug build of Ghostty! Performance will be degraded." msgstr "" -#: src/apprt/gtk/Window.zig:765 +#: src/apprt/gtk/Window.zig:773 msgid "Reloaded the configuration" msgstr "" -#: src/apprt/gtk/Window.zig:1005 +#: src/apprt/gtk/Window.zig:1017 msgid "Ghostty Developers" msgstr "" @@ -270,6 +293,6 @@ msgstr "" msgid "The currently running process in this split will be terminated." msgstr "" -#: src/apprt/gtk/Surface.zig:1243 +#: src/apprt/gtk/Surface.zig:1257 msgid "Copied to clipboard" msgstr "" From 0973abf9f9ebb93fc30496525219187dea1ec23a Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Sat, 28 Jun 2025 17:04:21 +0200 Subject: [PATCH 093/110] translations(zh_CN): add lines from #6783 --- po/zh_CN.UTF-8.po | 51 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/po/zh_CN.UTF-8.po b/po/zh_CN.UTF-8.po index 77be8a351..17a6dc921 100644 --- a/po/zh_CN.UTF-8.po +++ b/po/zh_CN.UTF-8.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2025-04-23 16:58+0800\n" +"POT-Creation-Date: 2025-06-28 17:01+0200\n" "PO-Revision-Date: 2025-02-27 09:16+0100\n" "Last-Translator: Leah \n" "Language-Team: Chinese (simplified) \n" @@ -26,7 +26,8 @@ msgid "Leave blank to restore the default title." msgstr "留空以重置至默认标题。" #: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9 -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/CloseDialog.zig:44 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/ui/1.2/ccw-paste.blp:10 +#: src/apprt/gtk/CloseDialog.zig:44 msgid "Cancel" msgstr "取消" @@ -35,10 +36,12 @@ msgid "OK" msgstr "确认" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:5 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:5 msgid "Configuration Errors" msgstr "配置错误" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:6 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." @@ -46,12 +49,14 @@ msgstr "" "加载配置时发现了以下错误。请仔细阅读错误信息,并选择忽略或重新加载配置文件。" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:9 msgid "Ignore" msgstr "忽略" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:10 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:100 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 msgid "Reload Configuration" msgstr "重新加载配置" @@ -90,7 +95,7 @@ msgstr "复制" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:11 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:11 -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:11 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:11 src/apprt/gtk/ui/1.2/ccw-paste.blp:11 msgid "Paste" msgstr "粘贴" @@ -120,7 +125,7 @@ msgstr "标签页" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:62 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30 -#: src/apprt/gtk/Window.zig:255 +#: src/apprt/gtk/Window.zig:263 msgid "New Tab" msgstr "新建标签页" @@ -161,7 +166,7 @@ msgid "Terminal Inspector" msgstr "终端调试器" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 -#: src/apprt/gtk/Window.zig:1024 +#: src/apprt/gtk/Window.zig:1036 msgid "About Ghostty" msgstr "关于 Ghostty" @@ -171,10 +176,13 @@ msgstr "退出" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:6 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:6 msgid "Authorize Clipboard Access" msgstr "剪贴板访问授权" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:7 msgid "" "An application is attempting to read from the clipboard. The current " "clipboard contents are shown below." @@ -182,52 +190,67 @@ msgstr "一个应用正在试图从剪贴板读取内容。剪贴板目前的内 #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:10 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:10 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:10 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:10 msgid "Deny" msgstr "拒绝" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:11 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:11 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:11 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:11 msgid "Allow" msgstr "允许" +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:81 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:77 +msgid "Remember choice for this split" +msgstr "为本分屏记住当前选择" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:82 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:78 +msgid "Reload configuration to show this prompt again" +msgstr "本提示将在重载配置后再次出现" + #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7 msgid "" "An application is attempting to write to the clipboard. The current " "clipboard contents are shown below." msgstr "一个应用正在试图向剪贴板写入内容。剪贴板目前的内容如下:" -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 src/apprt/gtk/ui/1.2/ccw-paste.blp:6 msgid "Warning: Potentially Unsafe Paste" msgstr "警告:粘贴内容可能不安全" -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 src/apprt/gtk/ui/1.2/ccw-paste.blp:7 msgid "" "Pasting this text into the terminal may be dangerous as it looks like some " "commands may be executed." msgstr "将以下内容粘贴至终端内将可能执行有害命令。" -#: src/apprt/gtk/Window.zig:208 +#: src/apprt/gtk/Window.zig:216 msgid "Main Menu" msgstr "主菜单" -#: src/apprt/gtk/Window.zig:229 +#: src/apprt/gtk/Window.zig:238 msgid "View Open Tabs" msgstr "浏览标签页" -#: src/apprt/gtk/Window.zig:256 +#: src/apprt/gtk/Window.zig:264 msgid "New Split" msgstr "新建分屏" -#: src/apprt/gtk/Window.zig:319 +#: src/apprt/gtk/Window.zig:327 msgid "" "⚠️ You're running a debug build of Ghostty! Performance will be degraded." msgstr "⚠️ Ghostty 正在以调试模式运行!性能将大打折扣。" -#: src/apprt/gtk/Window.zig:765 +#: src/apprt/gtk/Window.zig:773 msgid "Reloaded the configuration" msgstr "已重新加载配置" -#: src/apprt/gtk/Window.zig:1005 +#: src/apprt/gtk/Window.zig:1017 msgid "Ghostty Developers" msgstr "Ghostty 开发团队" @@ -271,6 +294,6 @@ msgstr "标签页内所有运行中的进程将被终止。" msgid "The currently running process in this split will be terminated." msgstr "分屏内正在运行中的进程将被终止。" -#: src/apprt/gtk/Surface.zig:1243 +#: src/apprt/gtk/Surface.zig:1257 msgid "Copied to clipboard" msgstr "已复制至剪贴板" From 4fac5f3749e68752323759d08d14b5762623e89c Mon Sep 17 00:00:00 2001 From: -k Date: Sat, 28 Jun 2025 13:08:56 -0400 Subject: [PATCH 094/110] fix: enable `boo` on FreeBSD --- src/cli/boo.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/boo.zig b/src/cli/boo.zig index 7ecbf79fb..47c8ab741 100644 --- a/src/cli/boo.zig +++ b/src/cli/boo.zig @@ -176,7 +176,7 @@ const Boo = struct { pub fn run(gpa: Allocator) !u8 { // Disable on non-desktop systems. switch (builtin.os.tag) { - .windows, .macos, .linux => {}, + .windows, .macos, .linux, .freebsd => {}, else => return 1, } From 84432a7beb01ffdc87812a3f3ddde2dc74475010 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Jun 2025 13:06:36 -0700 Subject: [PATCH 095/110] config: more general purpose backwards compatibility handlers Fixes #7706 We previously had a very specific backwards compatibility handler for handling renamed fields. We always knew that wouldn't scale but I wanted to wait for a real case. Well, #7706 is a real case, so here we are. This commit makes our backwards compatibility handler more general purpose, and makes a special-case handler for renamed fields built on top of this same general purpose system. The new system lets us do a lot more with regards to backwards compatibility. To start, this addresses #7706 by allowing us to handle a removed single enum value of a still-existing field. --- src/cli.zig | 2 + src/cli/args.zig | 190 ++++++++++++++++++++++++++++++------------ src/config/Config.zig | 37 +++++++- 3 files changed, 174 insertions(+), 55 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index 4336501a8..151e6e648 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -2,6 +2,8 @@ const diags = @import("cli/diagnostics.zig"); pub const args = @import("cli/args.zig"); pub const Action = @import("cli/action.zig").Action; +pub const CompatibilityHandler = args.CompatibilityHandler; +pub const compatibilityRenamed = args.compatibilityRenamed; pub const DiagnosticList = diags.DiagnosticList; pub const Diagnostic = diags.Diagnostic; pub const Location = diags.Location; diff --git a/src/cli/args.zig b/src/cli/args.zig index 3c34e17fe..65e72636e 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -40,11 +40,14 @@ pub const Error = error{ /// "DiagnosticList" and any diagnostic messages will be added to that list. /// When diagnostics are present, only allocation errors will be returned. /// -/// If the destination type has a decl "renamed", it must be of type -/// std.StaticStringMap([]const u8) and contains a mapping from the old -/// field name to the new field name. This is used to allow renaming fields -/// while still supporting the old name. If a renamed field is set, parsing -/// will automatically set the new field name. +/// If the destination type has a decl "compatibility", it must be of type +/// std.StaticStringMap(CompatibilityHandler(T)), and it will be used to +/// handle backwards compatibility for fields with the given name. The +/// field name doesn't need to exist (so you can setup compatibility for +/// removed fields). The value is a function that will be called when +/// all other parsing fails for that field. If a field changes such that +/// the old values would NOT error, then the caller should handle that +/// downstream after parsing is done, not through this method. /// /// Note: If the arena is already non-null, then it will be used. In this /// case, in the case of an error some memory might be leaked into the arena. @@ -57,24 +60,6 @@ pub fn parse( const info = @typeInfo(T); assert(info == .@"struct"); - comptime { - // Verify all renamed fields are valid (source does not exist, - // destination does exist). - if (@hasDecl(T, "renamed")) { - for (T.renamed.keys(), T.renamed.values()) |key, value| { - if (@hasField(T, key)) { - @compileLog(key); - @compileError("renamed field source exists"); - } - - if (!@hasField(T, value)) { - @compileLog(value); - @compileError("renamed field destination does not exist"); - } - } - } - } - // Make an arena for all our allocations if we support it. Otherwise, // use an allocator that always fails. If the arena is already set on // the config, then we reuse that. See memory note in parse docs. @@ -148,6 +133,16 @@ pub fn parse( }; parseIntoField(T, arena_alloc, dst, key, value) catch |err| { + // If we get an error parsing a field, then we try to fall + // back to compatibility handlers if able. + if (@hasDecl(T, "compatibility")) { + // If we have a compatibility handler for this key, then + // we call it and see if it handles the error. + if (T.compatibility.get(key)) |handler| { + if (handler(dst, arena_alloc, key, value)) return; + } + } + if (comptime !canTrackDiags(T)) return err; // The error set is dependent on comptime T, so we always add @@ -177,6 +172,58 @@ pub fn parse( } } +/// The function type for a compatibility handler. The compatibility +/// handler is documented in the `parse` function documentation. +/// +/// The function type should return bool if the compatibility was +/// handled, and false otherwise. If false is returned then the +/// naturally occurring error will continue to be processed as if +/// this compatibility handler was not present. +/// +/// Compatibility handlers aren't allowed to return errors because +/// they're generally only called in error cases, so we already have +/// an error message to show users. If there is an error in handling +/// the compatibility, then the handler should return false. +pub fn CompatibilityHandler(comptime T: type) type { + return *const fn ( + dst: *T, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, + ) bool; +} + +/// Convenience function to create a compatibility handler that +/// renames a field from `from` to `to`. +pub fn compatibilityRenamed( + comptime T: type, + comptime to: []const u8, +) CompatibilityHandler(T) { + comptime assert(@hasField(T, to)); + + return (struct { + fn compat( + dst: *T, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, + ) bool { + _ = key; + + parseIntoField(T, alloc, dst, to, value) catch |err| { + log.warn("error parsing renamed field {s}: {}", .{ + to, + err, + }); + + return false; + }; + + return true; + } + }).compat; +} + fn formatValueRequired( comptime T: type, arena_alloc: std.mem.Allocator, @@ -401,16 +448,6 @@ pub fn parseIntoField( } } - // Unknown field, is the field renamed? - if (@hasDecl(T, "renamed")) { - for (T.renamed.keys(), T.renamed.values()) |old, new| { - if (mem.eql(u8, old, key)) { - try parseIntoField(T, alloc, dst, new, value); - return; - } - } - } - return error.InvalidField; } @@ -752,6 +789,75 @@ test "parse: diagnostic location" { } } +test "parse: compatibility handler" { + const testing = std.testing; + + var data: struct { + a: bool = false, + _arena: ?ArenaAllocator = null, + + pub const compatibility: std.StaticStringMap( + CompatibilityHandler(@This()), + ) = .initComptime(&.{ + .{ "a", compat }, + }); + + fn compat( + self: *@This(), + alloc: Allocator, + key: []const u8, + value: ?[]const u8, + ) bool { + _ = alloc; + if (std.mem.eql(u8, key, "a")) { + if (value) |v| { + if (mem.eql(u8, v, "yuh")) { + self.a = true; + return true; + } + } + } + + return false; + } + } = .{}; + defer if (data._arena) |arena| arena.deinit(); + + var iter = try std.process.ArgIteratorGeneral(.{}).init( + testing.allocator, + "--a=yuh", + ); + defer iter.deinit(); + try parse(@TypeOf(data), testing.allocator, &data, &iter); + try testing.expect(data._arena != null); + try testing.expect(data.a); +} + +test "parse: compatibility renamed" { + const testing = std.testing; + + var data: struct { + a: bool = false, + _arena: ?ArenaAllocator = null, + + pub const compatibility: std.StaticStringMap( + CompatibilityHandler(@This()), + ) = .initComptime(&.{ + .{ "old", compatibilityRenamed(@This(), "a") }, + }); + } = .{}; + defer if (data._arena) |arena| arena.deinit(); + + var iter = try std.process.ArgIteratorGeneral(.{}).init( + testing.allocator, + "--old=true", + ); + defer iter.deinit(); + try parse(@TypeOf(data), testing.allocator, &data, &iter); + try testing.expect(data._arena != null); + try testing.expect(data.a); +} + test "parseIntoField: ignore underscore-prefixed fields" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); @@ -1176,24 +1282,6 @@ test "parseIntoField: tagged union missing tag" { ); } -test "parseIntoField: renamed field" { - const testing = std.testing; - var arena = ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var data: struct { - a: []const u8, - - const renamed = std.StaticStringMap([]const u8).initComptime(&.{ - .{ "old", "a" }, - }); - } = undefined; - - try parseIntoField(@TypeOf(data), alloc, &data, "old", "42"); - try testing.expectEqualStrings("42", data.a); -} - /// An iterator that considers its location to be CLI args. It /// iterates through an underlying iterator and increments a counter /// to track the current CLI arg index. diff --git a/src/config/Config.zig b/src/config/Config.zig index 44089fb57..3cb61fc76 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -46,14 +46,22 @@ const c = @cImport({ @cInclude("unistd.h"); }); -/// Renamed fields, used by cli.parse -pub const renamed = std.StaticStringMap([]const u8).initComptime(&.{ +pub const compatibility = std.StaticStringMap( + cli.CompatibilityHandler(Config), +).initComptime(&.{ // Ghostty 1.1 introduced background-blur support for Linux which // doesn't support a specific radius value. The renaming is to let // one field be used for both platforms (macOS retained the ability // to set a radius). - .{ "background-blur-radius", "background-blur" }, - .{ "adw-toolbar-style", "gtk-toolbar-style" }, + .{ "background-blur-radius", cli.compatibilityRenamed(Config, "background-blur") }, + + // Ghostty 1.2 renamed all our adw options to gtk because we now have + // a hard dependency on libadwaita. + .{ "adw-toolbar-style", cli.compatibilityRenamed(Config, "gtk-toolbar-style") }, + + // Ghostty 1.2 removed the `hidden` value from `gtk-tabs-location` and + // moved it to `window-show-tab-bar`. + .{ "gtk-tabs-location", compatGtkTabsLocation }, }); /// The font families to use. @@ -3792,6 +3800,27 @@ pub fn parseManuallyHook( return true; } +/// parseFieldManuallyFallback is a fallback called only when +/// parsing the field directly failed. It can be used to implement +/// backward compatibility. Since this is only called when parsing +/// fails, it doesn't impact happy-path performance. +fn compatGtkTabsLocation( + self: *Config, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, +) bool { + _ = alloc; + assert(std.mem.eql(u8, key, "gtk-tabs-location")); + + if (std.mem.eql(u8, value orelse "", "hidden")) { + self.@"window-show-tab-bar" = .never; + return true; + } + + return false; +} + /// Create a shallow copy of this config. This will share all the memory /// allocated with the previous config but will have a new arena for /// any changes or new allocations. The config should have `deinit` From 2f978fbdcf583c5f82081eed374fe723de1d10b4 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 29 Jun 2025 00:15:18 +0000 Subject: [PATCH 096/110] 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 e85958aaf..43986637f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -103,8 +103,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e436898274ecb89c055da476a8188aa4f79ffb17.tar.gz", - .hash = "N-V-__8AAHncWgThrlpsuJJ2BIINQ6L7SO6SUOT1pEL8UQaX", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz", + .hash = "N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 4217c17aa..d9f43a766 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AAHncWgThrlpsuJJ2BIINQ6L7SO6SUOT1pEL8UQaX": { + "N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e436898274ecb89c055da476a8188aa4f79ffb17.tar.gz", - "hash": "sha256-C93MSyNgyB+uhvzMQETDXr7839hFyX7NfTMp4HUKs3U=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz", + "hash": "sha256-g9o2CIc/TfWYoUS/l/HP5KZECD7qNsdQUlFruaKkVz4=" }, "N-V-__8AAJrvXQCqAT8Mg9o_tk6m0yf5Fz-gCNEOKLyTSerD": { "name": "libpng", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 46345871b..26209e778 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -170,11 +170,11 @@ in }; } { - name = "N-V-__8AAHncWgThrlpsuJJ2BIINQ6L7SO6SUOT1pEL8UQaX"; + name = "N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e436898274ecb89c055da476a8188aa4f79ffb17.tar.gz"; - hash = "sha256-C93MSyNgyB+uhvzMQETDXr7839hFyX7NfTMp4HUKs3U="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz"; + hash = "sha256-g9o2CIc/TfWYoUS/l/HP5KZECD7qNsdQUlFruaKkVz4="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index b7cb2772f..553b0fb06 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -27,7 +27,7 @@ https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5. https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/glfw/glfw/archive/e7ea71be039836da3a98cea55ae5569cb5eb885c.tar.gz https://github.com/jcollie/ghostty-gobject/releases/download/0.14.0-2025-03-18-21-1/ghostty-gobject-0.14.0-2025-03-18-21-1.tar.zst -https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e436898274ecb89c055da476a8188aa4f79ffb17.tar.gz +https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz https://github.com/mitchellh/zig-objc/archive/3ab0d37c7d6b933d6ded1b3a35b6b60f05590a98.tar.gz https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 32bd8bd54..4990f794a 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e436898274ecb89c055da476a8188aa4f79ffb17.tar.gz", - "dest": "vendor/p/N-V-__8AAHncWgThrlpsuJJ2BIINQ6L7SO6SUOT1pEL8UQaX", - "sha256": "0bddcc4b2360c81fae86fccc4044c35ebefcdfd845c97ecd7d3329e0750ab375" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz", + "dest": "vendor/p/N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS", + "sha256": "83da3608873f4df598a144bf97f1cfe4a644083eea36c75052516bb9a2a4573e" }, { "type": "archive", From 5ab7ceb589a9d763cf2e8c20702b259556afbfaf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Jun 2025 19:24:03 -0700 Subject: [PATCH 097/110] config: fix regression where we halted parsing on deprecated field Fix regression from d44a6cde2c7ed59f0f28fad16e6b760d7529ebee where we halted parsing on deprecated fields, which was not the intended behavior. This commit fixes that and adds a test to verify it. --- src/cli/args.zig | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/cli/args.zig b/src/cli/args.zig index 65e72636e..1af74df69 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -132,14 +132,20 @@ pub fn parse( break :value null; }; - parseIntoField(T, arena_alloc, dst, key, value) catch |err| { + parseIntoField(T, arena_alloc, dst, key, value) catch |err| err: { // If we get an error parsing a field, then we try to fall // back to compatibility handlers if able. if (@hasDecl(T, "compatibility")) { // If we have a compatibility handler for this key, then // we call it and see if it handles the error. if (T.compatibility.get(key)) |handler| { - if (handler(dst, arena_alloc, key, value)) return; + if (handler(dst, arena_alloc, key, value)) { + log.info( + "compatibility handler for {s} handled error, you may be using a deprecated field: {}", + .{ key, err }, + ); + break :err; + } } } @@ -838,6 +844,7 @@ test "parse: compatibility renamed" { var data: struct { a: bool = false, + b: bool = false, _arena: ?ArenaAllocator = null, pub const compatibility: std.StaticStringMap( @@ -850,12 +857,13 @@ test "parse: compatibility renamed" { var iter = try std.process.ArgIteratorGeneral(.{}).init( testing.allocator, - "--old=true", + "--old=true --b=true", ); defer iter.deinit(); try parse(@TypeOf(data), testing.allocator, &data, &iter); try testing.expect(data._arena != null); try testing.expect(data.a); + try testing.expect(data.b); } test "parseIntoField: ignore underscore-prefixed fields" { From ef06e3d02c278aa42712d371e8172482bf3487b6 Mon Sep 17 00:00:00 2001 From: Troels Thomsen Date: Sun, 29 Jun 2025 09:15:40 +0200 Subject: [PATCH 098/110] Introduce action for copying into clipboard --- src/Surface.zig | 5 +++++ src/config/Config.zig | 6 ++++++ src/input/Binding.zig | 5 +++++ src/input/command.zig | 10 ++++++++++ 4 files changed, 26 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 286d81383..5acec8c00 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4845,6 +4845,11 @@ fn writeScreenFile( const path = try tmp_dir.dir.realpath(filename, &path_buf); switch (write_action) { + .copy => { + const pathZ = try self.alloc.dupeZ(u8, path); + defer self.alloc.free(pathZ); + try self.rt_surface.setClipboardString(pathZ, .standard, false); + }, .open => try internal_os.open(self.alloc, .text, path), .paste => self.io.queueMessage(try termio.Message.writeReq( self.alloc, diff --git a/src/config/Config.zig b/src/config/Config.zig index 3cb61fc76..14ab5219d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -5032,6 +5032,12 @@ pub const Keybinds = struct { .{ .reset_font_size = {} }, ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'j' }, .mods = .{ .shift = true, .ctrl = true, .super = true } }, + .{ .write_screen_file = .copy }, + ); + try self.set.put( alloc, .{ .key = .{ .unicode = 'j' }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index cccf12ac4..7cdb8047c 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -379,6 +379,10 @@ pub const Action = union(enum) { /// /// Valid actions are: /// + /// - `copy` + /// + /// Copy the file path into the clipboard. + /// /// - `paste` /// /// Paste the file path into the terminal. @@ -813,6 +817,7 @@ pub const Action = union(enum) { }; pub const WriteScreenAction = enum { + copy, paste, open, }; diff --git a/src/input/command.zig b/src/input/command.zig index 8ae48eda1..693d5c8d4 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -204,6 +204,11 @@ fn actionCommands(action: Action.Key) []const Command { }}, .write_screen_file => comptime &.{ + .{ + .action = .{ .write_screen_file = .copy }, + .title = "Copy Screen to Temporary File and Copy Path", + .description = "Copy the screen contents to a temporary file and copy the path to the clipboard.", + }, .{ .action = .{ .write_screen_file = .paste }, .title = "Copy Screen to Temporary File and Paste Path", @@ -217,6 +222,11 @@ fn actionCommands(action: Action.Key) []const Command { }, .write_selection_file => comptime &.{ + .{ + .action = .{ .write_selection_file = .copy }, + .title = "Copy Selection to Temporary File and Copy Path", + .description = "Copy the selection contents to a temporary file and copy the path to the clipboard.", + }, .{ .action = .{ .write_selection_file = .paste }, .title = "Copy Selection to Temporary File and Paste Path", From 7f0778bcf28e3ba773a4f2a8204be8fe9d0cda78 Mon Sep 17 00:00:00 2001 From: James Holderness Date: Sun, 29 Jun 2025 15:32:17 +0100 Subject: [PATCH 099/110] termio: indicate support for OSC 52 in primary DA report This is an extension agreed upon by modern terminals to indicate that they support copying to the clipboard with XTerm's OSC 52 sequence. It is only reported when writing to the clipboard is actually allowed. --- src/termio/Termio.zig | 3 +++ src/termio/stream_handler.zig | 12 +++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 865a2df86..8aaa87011 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -168,6 +168,7 @@ pub const DerivedConfig = struct { foreground: configpkg.Config.Color, background: configpkg.Config.Color, osc_color_report_format: configpkg.Config.OSCColorReportFormat, + clipboard_write: configpkg.ClipboardAccess, enquiry_response: []const u8, pub fn init( @@ -188,6 +189,7 @@ pub const DerivedConfig = struct { .foreground = config.foreground, .background = config.background, .osc_color_report_format = config.@"osc-color-report-format", + .clipboard_write = config.@"clipboard-write", .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"), // This has to be last so that we copy AFTER the arena allocations @@ -278,6 +280,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .size = &self.size, .terminal = &self.terminal, .osc_color_report_format = opts.config.osc_color_report_format, + .clipboard_write = opts.config.clipboard_write, .enquiry_response = opts.config.enquiry_response, .default_foreground_color = opts.config.foreground.toTerminalRGB(), .default_background_color = opts.config.background.toTerminalRGB(), diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 90add84ae..1b4fdd3aa 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -74,6 +74,9 @@ pub const StreamHandler = struct { /// The color reporting format for OSC requests. osc_color_report_format: configpkg.Config.OSCColorReportFormat, + /// The clipboard write access configuration. + clipboard_write: configpkg.ClipboardAccess, + //--------------------------------------------------------------- // Internal state @@ -112,6 +115,7 @@ pub const StreamHandler = struct { /// Change the configuration for this handler. pub fn changeConfig(self: *StreamHandler, config: *termio.DerivedConfig) void { self.osc_color_report_format = config.osc_color_report_format; + self.clipboard_write = config.clipboard_write; self.enquiry_response = config.enquiry_response; self.default_foreground_color = config.foreground.toTerminalRGB(); self.default_background_color = config.background.toTerminalRGB(); @@ -723,7 +727,13 @@ pub const StreamHandler = struct { // a 420 because we don't support DCS sequences. switch (req) { .primary => self.messageWriter(.{ - .write_stable = "\x1B[?62;22c", + // 62 = Level 2 conformance + // 22 = Color text + // 52 = Clipboard access + .write_stable = if (self.clipboard_write != .deny) + "\x1B[?62;22;52c" + else + "\x1B[?62;22c", }), .secondary => self.messageWriter(.{ From 14ba7effcd6065ba38abb6140127804da1b14538 Mon Sep 17 00:00:00 2001 From: Alan Moyano Date: Tue, 20 May 2025 10:45:44 -0300 Subject: [PATCH 100/110] Fixing issues and making the translation more similar to the es_BO version --- po/es_AR.UTF-8.po | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/po/es_AR.UTF-8.po b/po/es_AR.UTF-8.po index 0cb99f6be..10aa7bbfe 100644 --- a/po/es_AR.UTF-8.po +++ b/po/es_AR.UTF-8.po @@ -23,7 +23,7 @@ msgstr "Cambiar el título de la terminal" #: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6 msgid "Leave blank to restore the default title." -msgstr "Deja en blanco para restaurar el título predeterminado." +msgstr "Dejar en blanco para restaurar el título predeterminado." #: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9 #: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/CloseDialog.zig:44 @@ -32,7 +32,7 @@ msgstr "Cancelar" #: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:10 msgid "OK" -msgstr Aceptar" +msgstr "Aceptar" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:5 msgid "Configuration Errors" @@ -60,25 +60,25 @@ msgstr "Recargar configuración" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:38 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:50 msgid "Split Up" -msgstr "Dividir hacia arriba" +msgstr "Dividir arriba" #: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:11 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:43 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:55 msgid "Split Down" -msgstr "Dividir hacia abajo" +msgstr "Dividir abajo" #: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:16 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:48 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:60 msgid "Split Left" -msgstr "Dividir hacia la izquierda" +msgstr "Dividir a la izquierda" #: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:21 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:53 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:65 msgid "Split Right" -msgstr "Dividir hacia la derecha" +msgstr "Dividir a la derecha" #: src/apprt/gtk/ui/1.5/command-palette.blp:16 msgid "Execute a command…" @@ -262,19 +262,19 @@ msgstr "¿Cerrar pestaña?" #: src/apprt/gtk/CloseDialog.zig:90 msgid "Close Split?" -msgstr "msgstr "¿Cerrar división?" +msgstr "¿Cerrar división?" #: src/apprt/gtk/CloseDialog.zig:96 msgid "All terminal sessions will be terminated." -msgstr "Todas las sesiones de la terminal serán terminadas." +msgstr "Todas las sesiones de terminal serán terminadas." #: src/apprt/gtk/CloseDialog.zig:97 msgid "All terminal sessions in this window will be terminated." -msgstr "Todas las sesiones de la terminal en esta ventana serán terminadas." +msgstr "Todas las sesiones de terminal en esta ventana serán terminadas." #: src/apprt/gtk/CloseDialog.zig:98 msgid "All terminal sessions in this tab will be terminated." -msgstr "Todas las sesiones de la terminal en esta pestaña serán terminadas." +msgstr "Todas las sesiones de terminal en esta pestaña serán terminadas." #: src/apprt/gtk/CloseDialog.zig:99 msgid "The currently running process in this split will be terminated." From ff599b5cf7f399d0211bfaa8a6dcafa3588a6b42 Mon Sep 17 00:00:00 2001 From: Alan Moyano Date: Tue, 20 May 2025 10:58:11 -0300 Subject: [PATCH 101/110] Improving Argentinian voseo --- po/es_AR.UTF-8.po | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/po/es_AR.UTF-8.po b/po/es_AR.UTF-8.po index 10aa7bbfe..b05f2c678 100644 --- a/po/es_AR.UTF-8.po +++ b/po/es_AR.UTF-8.po @@ -43,8 +43,8 @@ msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." msgstr "" -"Se encontraron uno o más errores de configuración. Por favor revisa los " -"errores a continuación, y recarga tu configuración o ignora estos errores." +"Se encontraron uno o más errores de configuración. Por favor revisá los " +"errores a continuación, y recargá tu configuración o ignorá estos errores." #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9 msgid "Ignore" @@ -229,7 +229,7 @@ msgstr "Nueva división" msgid "" "⚠️ You're running a debug build of Ghostty! Performance will be degraded." msgstr "" -"⚠️ Está ejecutando una versión de depuración de Ghostty. El rendimiento no " +"⚠️ Estás ejecutando una versión de depuración de Ghostty. El rendimiento no " "será óptimo." #: src/apprt/gtk/Window.zig:765 From 046f21f2dc79820f682de00d96278f150d9698df Mon Sep 17 00:00:00 2001 From: Alan Moyano Date: Sat, 28 Jun 2025 16:14:36 +0000 Subject: [PATCH 102/110] Adding email address --- po/es_AR.UTF-8.po | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/po/es_AR.UTF-8.po b/po/es_AR.UTF-8.po index b05f2c678..3cd0625c8 100644 --- a/po/es_AR.UTF-8.po +++ b/po/es_AR.UTF-8.po @@ -1,7 +1,7 @@ # Spanish translations for com.mitchellh.ghostty package. # Copyright (C) 2025 Mitchell Hashimoto # This file is distributed under the same license as the com.mitchellh.ghostty package. -# Alan Moyano , 2025. +# Alan Moyano , 2025. # msgid "" msgstr "" @@ -9,7 +9,7 @@ msgstr "" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2025-04-23 16:58+0800\n" "PO-Revision-Date: 2025-05-19 20:17-0300\n" -"Last-Translator: Alan Moyano \n" +"Last-Translator: Alan Moyano \n" "Language-Team: Argentinian \n" "Language: es_AR\n" "MIME-Version: 1.0\n" From 5da461dc35a74079b76c2374338bc7b1e089c366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aindri=C3=BA=20Mac=20Giolla=20Eoin?= Date: Sun, 29 Jun 2025 21:20:20 +0100 Subject: [PATCH 103/110] Corrected 2 strings for better readability and consistency --- po/ga_IE.UTF-8.po | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/po/ga_IE.UTF-8.po b/po/ga_IE.UTF-8.po index 3f7d6b068..686d22d76 100644 --- a/po/ga_IE.UTF-8.po +++ b/po/ga_IE.UTF-8.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2025-04-23 16:58+0800\n" -"PO-Revision-Date: 2025-06-24 12:42+0100\n" +"PO-Revision-Date: 2025-06-29 21:15+0100\n" "Last-Translator: Aindriú Mac Giolla Eoin \n" "Language-Team: Irish \n" "Language: ga\n" @@ -16,7 +16,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=n==1 ? 0 : n==2 ? 1 : 2;\n" -"X-Generator: Poedit 3.4.2\n" +"X-Generator: Poedit 3.4.4\n" #: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:5 msgid "Change Terminal Title" @@ -212,7 +212,7 @@ msgid "" "commands may be executed." msgstr "" "D’fhéadfadh sé a bheith contúirteach an téacs seo a ghreamú isteach sa " -"chríochfort mar is cosúil go bhféadfaí roinnt orduithe a fhorghníomhú." +"teirminéal, toisc go d'fhéadfadh roinnt orduithe a fhorghníomhú." #: src/apprt/gtk/Window.zig:208 msgid "Main Menu" @@ -234,7 +234,7 @@ msgstr "" #: src/apprt/gtk/Window.zig:765 msgid "Reloaded the configuration" -msgstr "Athluchtaigh an chumraíocht" +msgstr "Tá an chumraíocht athlódáilte" #: src/apprt/gtk/Window.zig:1005 msgid "Ghostty Developers" From d0e12cc0821f188820675548d04c1fec1b52a93c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 29 Jun 2025 15:06:18 -0700 Subject: [PATCH 104/110] update libxev to workaround the io_uring regression in Linux 6.15.4 Fixes #7724 Background at the end of the commit message. The fix in libxev is described in the PR and commit we pin to here, but basically we swap read for poll for eventfd/timerfd. From Jens Axboe on X: > This will fix it: https://pastebin.com/n7JSZWpW which makes me suspicious > that it's an S_IFREG check somewhere else, as anon inodes are now listed as > regular files. Has potentially pretty broad implications... > I think I can already answer why that breaks things - io_uring checks if > this is a regular file, and if it is, it doesn't do short reads. Short > reads on regular files (or a bdev) will cause application issues, as > basically nobody expects them. > Now we have what acts like a char dev, but where io_uring will retry IO > because the application asked for more data than what was delivered. This > will cause the weird slowdowns as data isn't delivered as soon as it's > available. --- 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 43986637f..51e2e4538 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -8,8 +8,8 @@ .libxev = .{ // mitchellh/libxev - .url = "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz", - .hash = "libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3", + .url = "https://github.com/mitchellh/libxev/archive/75a10d0fb374e8eb84948dcfc68d865e755e59c2.tar.gz", + .hash = "libxev-0.0.0-86vtcyMBEwA-UpcjfOICyI2GYG8o6MiRbinS1_8g1_rw", .lazy = true, }, .vaxis = .{ diff --git a/build.zig.zon.json b/build.zig.zon.json index d9f43a766..1d95ed93a 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -64,10 +64,10 @@ "url": "https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz", "hash": "sha256-/syVtGzwXo4/yKQUdQ4LparQDYnp/fF16U/wQcrxoDo=" }, - "libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3": { + "libxev-0.0.0-86vtcyMBEwA-UpcjfOICyI2GYG8o6MiRbinS1_8g1_rw": { "name": "libxev", - "url": "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz", - "hash": "sha256-VwFByDoptqiN5UkolFQ7TbRhwMERReD9Er2pjxTCYIU=" + "url": "https://github.com/mitchellh/libxev/archive/75a10d0fb374e8eb84948dcfc68d865e755e59c2.tar.gz", + "hash": "sha256-/CSKSuZZfn0aIQlVZ0O8ch5O4gCajYBTTuoetRdo0n4=" }, "N-V-__8AAG3RoQEyRC2Vw7Qoro5SYBf62IHn3HjqtNVY6aWK": { "name": "libxml2", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 26209e778..fffc639b4 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -186,11 +186,11 @@ in }; } { - name = "libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3"; + name = "libxev-0.0.0-86vtcyMBEwA-UpcjfOICyI2GYG8o6MiRbinS1_8g1_rw"; path = fetchZigArtifact { name = "libxev"; - url = "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz"; - hash = "sha256-VwFByDoptqiN5UkolFQ7TbRhwMERReD9Er2pjxTCYIU="; + url = "https://github.com/mitchellh/libxev/archive/75a10d0fb374e8eb84948dcfc68d865e755e59c2.tar.gz"; + hash = "sha256-/CSKSuZZfn0aIQlVZ0O8ch5O4gCajYBTTuoetRdo0n4="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 553b0fb06..d032711e5 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -28,7 +28,7 @@ https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90 https://github.com/glfw/glfw/archive/e7ea71be039836da3a98cea55ae5569cb5eb885c.tar.gz https://github.com/jcollie/ghostty-gobject/releases/download/0.14.0-2025-03-18-21-1/ghostty-gobject-0.14.0-2025-03-18-21-1.tar.zst https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz -https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz +https://github.com/mitchellh/libxev/archive/75a10d0fb374e8eb84948dcfc68d865e755e59c2.tar.gz https://github.com/mitchellh/zig-objc/archive/3ab0d37c7d6b933d6ded1b3a35b6b60f05590a98.tar.gz https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz https://github.com/vancluever/z2d/archive/1bf4bc81819385f4b24596445c9a7cf3b3592b08.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 4990f794a..81024bb26 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -79,9 +79,9 @@ }, { "type": "archive", - "url": "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz", - "dest": "vendor/p/libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3", - "sha256": "570141c83a29b6a88de5492894543b4db461c0c11145e0fd12bda98f14c26085" + "url": "https://github.com/mitchellh/libxev/archive/75a10d0fb374e8eb84948dcfc68d865e755e59c2.tar.gz", + "dest": "vendor/p/libxev-0.0.0-86vtcyMBEwA-UpcjfOICyI2GYG8o6MiRbinS1_8g1_rw", + "sha256": "fc248a4ae6597e7d1a2109556743bc721e4ee2009a8d80534eea1eb51768d27e" }, { "type": "archive", From a82223259acb4213af721844c8d996860906646c Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 29 Jun 2025 18:54:40 -0400 Subject: [PATCH 105/110] terminal: introduce testWriteSemanticString This test-only function wraps testWriteString with semantic prompt marking. This replaces the manual, row-based semantic_prompt field manipulation we were doing in all of our prompt-related test setups. This function's heuristics are a little complex because it wraps testWriteString as a "black box"; we don't benefit from that function's own line-based logic to know which rows need to be updated with the semantic prompt flag. We need to infer them externally instead. I considered adding an options argument to testWriteString that would allow passing e.g. a semantic_prompt prompt. Given that it's called from 200+ places, that would involve a lot of unrelated changes, but it remains an "option" (ha!) if there's value there for other cases. I also have plans that move us from row-based to cell-based semantic tracking, where the current semantic type is tracked by the cursor. In that implementation, testWriteString can update the written cells directly, and testWriteSemanticString just helps manage the cursor's state. Introducing testWriteSemanticString here and now therefore helps bridge us to that world while maintaining test consistency. --- src/terminal/Screen.zig | 309 +++++++++++++--------------------------- 1 file changed, 96 insertions(+), 213 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 5b772ab84..079df37db 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -3068,6 +3068,29 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { } } +/// Write text that's marked as a semantic prompt. +fn testWriteSemanticString(self: *Screen, text: []const u8, semantic_prompt: Row.SemanticPrompt) !void { + // Determine the first row using the cursor position. If we know that our + // first write is going to start on the next line because of a pending + // wrap, we'll proactively start there. + const start_y = if (self.cursor.pending_wrap) self.cursor.y + 1 else self.cursor.y; + + try self.testWriteString(text); + + // Determine the last row that we actually wrote by inspecting the cursor's + // position. If we're in the first column, we haven't actually written any + // characters to it, so we end at the preceding row instead. + const end_y = if (self.cursor.x > 0) self.cursor.y else self.cursor.y - 1; + + // Mark the full range of written rows with our semantic prompt. + var y = start_y; + while (y <= end_y) { + const pin = self.pages.pin(.{ .active = .{ .y = y } }).?; + pin.rowAndCell().row.semantic_prompt = semantic_prompt; + y += 1; + } +} + test "Screen read and write" { const testing = std.testing; const alloc = testing.allocator; @@ -3686,16 +3709,11 @@ test "Screen: clearPrompt" { var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); // Set one of the rows to be a prompt - { - s.cursorAbsolute(0, 1); - s.cursor.page_row.semantic_prompt = .prompt; - s.cursorAbsolute(0, 2); - s.cursor.page_row.semantic_prompt = .input; - } + try s.testWriteSemanticString("1ABCD\n", .unknown); + try s.testWriteSemanticString("2EFGH\n", .prompt); + try s.testWriteSemanticString("3IJKL", .input); s.clearPrompt(); @@ -3712,18 +3730,12 @@ test "Screen: clearPrompt continuation" { var s = try init(alloc, 5, 4, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL\n4MNOP"; - try s.testWriteString(str); // Set one of the rows to be a prompt followed by a continuation row - { - s.cursorAbsolute(0, 1); - s.cursor.page_row.semantic_prompt = .prompt; - s.cursorAbsolute(0, 2); - s.cursor.page_row.semantic_prompt = .prompt_continuation; - s.cursorAbsolute(0, 3); - s.cursor.page_row.semantic_prompt = .input; - } + try s.testWriteSemanticString("1ABCD\n", .unknown); + try s.testWriteSemanticString("2EFGH\n", .prompt); + try s.testWriteSemanticString("3IJKL\n", .prompt_continuation); + try s.testWriteSemanticString("4MNOP", .input); s.clearPrompt(); @@ -3734,22 +3746,17 @@ test "Screen: clearPrompt continuation" { } } -test "Screen: clearPrompt consecutive prompts" { +test "Screen: clearPrompt consecutive inputs" { const testing = std.testing; const alloc = testing.allocator; var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - // Set both rows to be prompts - { - s.cursorAbsolute(0, 1); - s.cursor.page_row.semantic_prompt = .input; - s.cursorAbsolute(0, 2); - s.cursor.page_row.semantic_prompt = .input; - } + // Set both rows to be inputs + try s.testWriteSemanticString("1ABCD\n", .unknown); + try s.testWriteSemanticString("2EFGH\n", .input); + try s.testWriteSemanticString("3IJKL", .input); s.clearPrompt(); @@ -6057,26 +6064,24 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); // Set one of the rows to be a prompt - { - s.cursorAbsolute(0, 1); - s.cursor.page_row.semantic_prompt = .prompt; - } + try s.testWriteSemanticString("1ABCD\n", .unknown); + try s.testWriteSemanticString("2EFGH\n", .prompt); + try s.testWriteSemanticString("3IJKL", .unknown); try s.resize(10, 3); + const expected = "1ABCD\n2EFGH\n3IJKL"; { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + try testing.expectEqualStrings(expected, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + try testing.expectEqualStrings(expected, contents); } // Our one row should still be a semantic prompt, the others should not. @@ -7507,7 +7512,9 @@ test "Screen: selectLine semantic prompt boundary" { var s = try init(alloc, 5, 10, 0); defer s.deinit(); - try s.testWriteString("ABCDE\nA > "); + try s.testWriteSemanticString("ABCDE\n", .unknown); + try s.testWriteSemanticString("A ", .prompt); + try s.testWriteSemanticString("> ", .unknown); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); @@ -7515,12 +7522,6 @@ test "Screen: selectLine semantic prompt boundary" { try testing.expectEqualStrings("ABCDE\nA \n> ", contents); } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - // Selecting output stops at the prompt even if soft-wrapped { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ @@ -7905,55 +7906,23 @@ test "Screen: selectOutput" { // zig fmt: off { // line number: - try s.testWriteString("output1\n"); // 0 - try s.testWriteString("output1\n"); // 1 - try s.testWriteString("prompt2\n"); // 2 - try s.testWriteString("input2\n"); // 3 - try s.testWriteString("output2output2output2output2\n"); // 4, 5, 6 due to overflow - try s.testWriteString("output2\n"); // 7 - try s.testWriteString("$ input3\n"); // 8 - try s.testWriteString("output3\n"); // 9 - try s.testWriteString("output3\n"); // 10 - try s.testWriteString("output3"); // 11 + try s.testWriteSemanticString("output1\n", .command); // 0 + try s.testWriteSemanticString("output1\n", .command); // 1 + try s.testWriteSemanticString("prompt2\n", .prompt); // 2 + try s.testWriteSemanticString("input2\n", .input); // 3 + try s.testWriteSemanticString( // + "output2output2output2output2\n", // 4, 5, 6 due to overflow + .command, // + ); // + try s.testWriteSemanticString("output2\n", .command); // 7 + try s.testWriteSemanticString("$ ", .prompt); // 8 prompt + try s.testWriteSemanticString("input3\n", .input); // 8 input + try s.testWriteSemanticString("output3\n", .command); // 9 + try s.testWriteSemanticString("output3\n", .command); // 10 + try s.testWriteSemanticString("output3", .command); // 11 } // zig fmt: on - { - const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 5 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 8 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 9 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - // No start marker, should select from the beginning { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ @@ -8006,19 +7975,10 @@ test "Screen: selectOutput" { { s.deinit(); s = try init(alloc, 10, 5, 0); - try s.testWriteString("$ input1\n"); - try s.testWriteString("output1\n"); - try s.testWriteString("prompt2\n"); - { - const pin = s.pages.pin(.{ .screen = .{ .y = 0 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } + try s.testWriteSemanticString("$ ", .prompt); + try s.testWriteSemanticString("input1\n", .input); + try s.testWriteSemanticString("output1\n", .command); + try s.testWriteSemanticString("prompt2\n", .prompt); try testing.expect(s.selectOutput(s.pages.pin(.{ .active = .{ .x = 2, .y = 0, @@ -8035,46 +7995,21 @@ test "Screen: selectPrompt basics" { // zig fmt: off { - // line number: - try s.testWriteString("output1\n"); // 0 - try s.testWriteString("output1\n"); // 1 - try s.testWriteString("prompt2\n"); // 2 - try s.testWriteString("input2\n"); // 3 - try s.testWriteString("output2\n"); // 4 - try s.testWriteString("output2\n"); // 5 - try s.testWriteString("$ input3\n"); // 6 - try s.testWriteString("output3\n"); // 7 - try s.testWriteString("output3\n"); // 8 - try s.testWriteString("output3"); // 9 + // line number: + try s.testWriteSemanticString("output1\n", .command); // 0 + try s.testWriteSemanticString("output1\n", .command); // 1 + try s.testWriteSemanticString("prompt2\n", .prompt); // 2 + try s.testWriteSemanticString("input2\n", .input); // 3 + try s.testWriteSemanticString("output2\n", .command); // 4 + try s.testWriteSemanticString("output2\n", .command); // 5 + try s.testWriteSemanticString("$ ", .prompt); // 6 prompt + try s.testWriteSemanticString("input3\n", .input); // 6 input + try s.testWriteSemanticString("output3\n", .command); // 7 + try s.testWriteSemanticString("output3\n", .command); // 8 + try s.testWriteSemanticString("output3", .command); // 9 } // zig fmt: on - { - const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - // Not at a prompt { const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ @@ -8135,30 +8070,14 @@ test "Screen: selectPrompt prompt at start" { // zig fmt: off { - // line number: - try s.testWriteString("prompt1\n"); // 0 - try s.testWriteString("input1\n"); // 1 - try s.testWriteString("output2\n"); // 2 - try s.testWriteString("output2\n"); // 3 + // line number: + try s.testWriteSemanticString("prompt1\n", .prompt); // 0 + try s.testWriteSemanticString("input1\n", .input); // 1 + try s.testWriteSemanticString("output2\n", .command); // 2 + try s.testWriteSemanticString("output2\n", .command); // 3 } // zig fmt: on - { - const pin = s.pages.pin(.{ .screen = .{ .y = 0 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - // Not at a prompt { const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ @@ -8195,25 +8114,14 @@ test "Screen: selectPrompt prompt at end" { // zig fmt: off { - // line number: - try s.testWriteString("output2\n"); // 0 - try s.testWriteString("output2\n"); // 1 - try s.testWriteString("prompt1\n"); // 2 - try s.testWriteString("input1\n"); // 3 + // line number: + try s.testWriteSemanticString("output2\n", .command); // 0 + try s.testWriteSemanticString("output2\n", .command); // 1 + try s.testWriteSemanticString("prompt1\n", .prompt); // 2 + try s.testWriteSemanticString("input1\n", .input); // 3 } // zig fmt: on - { - const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - // Not at a prompt { const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ @@ -8250,46 +8158,21 @@ test "Screen: promptPath" { // zig fmt: off { - // line number: - try s.testWriteString("output1\n"); // 0 - try s.testWriteString("output1\n"); // 1 - try s.testWriteString("prompt2\n"); // 2 - try s.testWriteString("input2\n"); // 3 - try s.testWriteString("output2\n"); // 4 - try s.testWriteString("output2\n"); // 5 - try s.testWriteString("$ input3\n"); // 6 - try s.testWriteString("output3\n"); // 7 - try s.testWriteString("output3\n"); // 8 - try s.testWriteString("output3"); // 9 + // line number: + try s.testWriteSemanticString("output1\n", .command); // 0 + try s.testWriteSemanticString("output1\n", .command); // 1 + try s.testWriteSemanticString("prompt2\n", .prompt); // 2 + try s.testWriteSemanticString("input2\n", .input); // 3 + try s.testWriteSemanticString("output2\n", .command); // 4 + try s.testWriteSemanticString("output2\n", .command); // 5 + try s.testWriteSemanticString("$ ", .prompt); // 6 prompt + try s.testWriteSemanticString("input3\n", .input); // 6 input + try s.testWriteSemanticString("output3\n", .command); // 7 + try s.testWriteSemanticString("output3\n", .command); // 8 + try s.testWriteSemanticString("output3", .command); // 9 } // zig fmt: on - { - const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - // From is not in the prompt { const path = s.promptPath( From beb961fb809ce0bb7dbb8c4ae243b19502742ed0 Mon Sep 17 00:00:00 2001 From: Daniel Patterson Date: Sun, 26 Jan 2025 00:30:18 +0000 Subject: [PATCH 106/110] Introduce `font-shaping-break` config option --- src/config.zig | 1 + src/config/Config.zig | 31 +++++ src/font/shaper/coretext.zig | 231 ++++++++++++++++++++++++++------- src/font/shaper/harfbuzz.zig | 226 +++++++++++++++++++++++++------- src/font/shaper/noop.zig | 3 + src/font/shaper/run.zig | 61 +++++---- src/font/shaper/web_canvas.zig | 3 + 7 files changed, 436 insertions(+), 120 deletions(-) diff --git a/src/config.zig b/src/config.zig index 7f390fb08..ac38eb89c 100644 --- a/src/config.zig +++ b/src/config.zig @@ -20,6 +20,7 @@ pub const ConfirmCloseSurface = Config.ConfirmCloseSurface; pub const CopyOnSelect = Config.CopyOnSelect; pub const CustomShaderAnimation = Config.CustomShaderAnimation; pub const FontSyntheticStyle = Config.FontSyntheticStyle; +pub const FontShapingBreak = Config.FontShapingBreak; pub const FontStyle = Config.FontStyle; pub const FreetypeLoadFlags = Config.FreetypeLoadFlags; pub const Keybinds = Config.Keybinds; diff --git a/src/config/Config.zig b/src/config/Config.zig index 14ab5219d..ef8f48ee9 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -270,6 +270,32 @@ pub const compatibility = std.StaticStringMap( /// This is currently only supported on macOS. @"font-thicken-strength": u8 = 255, +/// Locations to break font shaping into multiple runs. +/// +/// A "run" is a contiguous segment of text that is shaped together. "Shaping" +/// is the process of converting text (codepoints) into glyphs (renderable +/// characters). This is how ligatures are formed, among other things. +/// For example, if a coding font turns "!=" into a single glyph, then it +/// must see "!" and "=" next to each other in a single run. When a run +/// is broken, the text is shaped separately. To continue our example, if +/// "!" is at the end of one run and "=" is at the start of the next run, +/// then the ligature will not be formed. +/// +/// Ghostty breaks runs at certain points to improve readability or usability. +/// For example, Ghostty by default will break runs under the cursor so that +/// text editing can see the individual characters rather than a ligature. +/// This configuration lets you configure this behavior. +/// +/// Combine values with a comma to set multiple options. Prefix an +/// option with "no-" to disable it. Enabling and disabling options +/// can be done at the same time. +/// +/// Available options: +/// +/// * `cursor` - Break runs under the cursor. +/// +@"font-shaping-break": FontShapingBreak = .{}, + /// What color space to use when performing alpha blending. /// /// This affects the appearance of text and of any images with transparency. @@ -6214,6 +6240,11 @@ pub const FontSyntheticStyle = packed struct { @"bold-italic": bool = true, }; +/// See "font-shaping-break" for documentation +pub const FontShapingBreak = packed struct { + cursor: bool = true, +}; + /// See "link" for documentation. pub const RepeatableLink = struct { const Self = @This(); diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 8e2c45c69..654af02d9 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -7,6 +7,7 @@ const trace = @import("tracy").trace; const font = @import("../main.zig"); const os = @import("../../os/main.zig"); const terminal = @import("../../terminal/main.zig"); +const config = @import("../../config.zig"); const Feature = font.shape.Feature; const FeatureList = font.shape.FeatureList; const default_features = font.shape.default_features; @@ -293,6 +294,7 @@ pub const Shaper = struct { row: terminal.Pin, selection: ?terminal.Selection, cursor_x: ?usize, + break_config: config.FontShapingBreak, ) font.shape.RunIterator { return .{ .hooks = .{ .shaper = self }, @@ -301,6 +303,7 @@ pub const Shaper = struct { .row = row, .selection = selection, .cursor_x = cursor_x, + .break_config = break_config, }; } @@ -600,6 +603,7 @@ test "run iterator" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -619,6 +623,7 @@ test "run iterator" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -639,6 +644,7 @@ test "run iterator" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -660,6 +666,7 @@ test "run iterator" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -707,6 +714,7 @@ test "run iterator: empty cells with background set" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); { const run = (try it.next(alloc)).?; @@ -743,6 +751,7 @@ test "shape" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -778,6 +787,7 @@ test "shape nerd fonts" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -806,6 +816,7 @@ test "shape inconsolata ligs" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -831,6 +842,7 @@ test "shape inconsolata ligs" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -864,6 +876,7 @@ test "shape monaspace ligs" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -898,6 +911,7 @@ test "shape left-replaced lig in last run" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -932,6 +946,7 @@ test "shape left-replaced lig in early run" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); const run = (try it.next(alloc)).?; @@ -963,6 +978,7 @@ test "shape U+3C9 with JB Mono" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var run_count: usize = 0; @@ -996,6 +1012,7 @@ test "shape emoji width" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1046,6 +1063,7 @@ test "shape emoji width long" { screen.pages.pin(.{ .screen = .{ .y = 1 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1082,6 +1100,7 @@ test "shape variation selector VS15" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1117,6 +1136,7 @@ test "shape variation selector VS16" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1149,6 +1169,7 @@ test "shape with empty cells in between" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1187,6 +1208,7 @@ test "shape Chinese characters" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1227,6 +1249,7 @@ test "shape box glyphs" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1267,6 +1290,7 @@ test "shape selection boundary" { false, ), null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1290,6 +1314,7 @@ test "shape selection boundary" { false, ), null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1313,6 +1338,7 @@ test "shape selection boundary" { false, ), null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1336,6 +1362,7 @@ test "shape selection boundary" { false, ), null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1359,6 +1386,7 @@ test "shape selection boundary" { false, ), null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1391,6 +1419,7 @@ test "shape cursor boundary" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1400,61 +1429,126 @@ test "shape cursor boundary" { try testing.expectEqual(@as(usize, 1), count); } - // Cursor at index 0 is two runs { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 0, - ); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); + // Cursor at index 0 is two runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 0, + .{ .cursor = true }, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + // And without cursor splitting remains one + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 0, + .{ .cursor = false }, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); } - try testing.expectEqual(@as(usize, 2), count); } - // Cursor at index 1 is three runs { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 1, - ); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); + // Cursor at index 1 is three runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 1, + .{ .cursor = true }, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 3), count); + } + // And without cursor splitting remains one + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 1, + .{ .cursor = false }, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); } - try testing.expectEqual(@as(usize, 3), count); } - - // Cursor at last col is two runs { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 9, - ); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); + // Cursor at last col is two runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 9, + .{ .cursor = true }, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + // And without cursor splitting remains one + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 9, + .{ .cursor = false }, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); } - try testing.expectEqual(@as(usize, 2), count); } } @@ -1480,6 +1574,7 @@ test "shape cursor boundary and colored emoji" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1499,6 +1594,25 @@ test "shape cursor boundary and colored emoji" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, 0, + .{}, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 0, + .{ .cursor = false }, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1516,6 +1630,25 @@ test "shape cursor boundary and colored emoji" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, 1, + .{}, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 1, + .{ .cursor = false }, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1546,6 +1679,7 @@ test "shape cell attribute change" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1570,6 +1704,7 @@ test "shape cell attribute change" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1595,6 +1730,7 @@ test "shape cell attribute change" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1620,6 +1756,7 @@ test "shape cell attribute change" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1644,6 +1781,7 @@ test "shape cell attribute change" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1684,6 +1822,7 @@ test "shape high plane sprite font codepoint" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); // We should get one run const run = (try it.next(alloc)).?; diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 361cbbe93..56654e88f 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator; const harfbuzz = @import("harfbuzz"); const font = @import("../main.zig"); const terminal = @import("../../terminal/main.zig"); +const config = @import("../../config.zig"); const Feature = font.shape.Feature; const FeatureList = font.shape.FeatureList; const default_features = font.shape.default_features; @@ -94,6 +95,7 @@ pub const Shaper = struct { row: terminal.Pin, selection: ?terminal.Selection, cursor_x: ?usize, + break_config: config.FontShapingBreak, ) font.shape.RunIterator { return .{ .hooks = .{ .shaper = self }, @@ -102,6 +104,7 @@ pub const Shaper = struct { .row = row, .selection = selection, .cursor_x = cursor_x, + .break_config = break_config, }; } @@ -231,6 +234,7 @@ test "run iterator" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -250,6 +254,7 @@ test "run iterator" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; @@ -270,6 +275,7 @@ test "run iterator" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |_| { @@ -322,6 +328,7 @@ test "run iterator: empty cells with background set" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); { const run = (try it.next(alloc)).?; @@ -359,6 +366,7 @@ test "shape" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -388,6 +396,7 @@ test "shape inconsolata ligs" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -413,6 +422,7 @@ test "shape inconsolata ligs" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -446,6 +456,7 @@ test "shape monaspace ligs" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -482,6 +493,7 @@ test "shape arabic forced LTR" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -519,6 +531,7 @@ test "shape emoji width" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -571,6 +584,7 @@ test "shape emoji width long" { screen.pages.pin(.{ .screen = .{ .y = 1 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -609,6 +623,7 @@ test "shape variation selector VS15" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -646,6 +661,7 @@ test "shape variation selector VS16" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -680,6 +696,7 @@ test "shape with empty cells in between" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -718,6 +735,7 @@ test "shape Chinese characters" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -758,6 +776,7 @@ test "shape box glyphs" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -799,6 +818,7 @@ test "shape selection boundary" { false, ), null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -822,6 +842,7 @@ test "shape selection boundary" { false, ), null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -845,6 +866,7 @@ test "shape selection boundary" { false, ), null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -868,6 +890,7 @@ test "shape selection boundary" { false, ), null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -891,6 +914,7 @@ test "shape selection boundary" { false, ), null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -923,6 +947,7 @@ test "shape cursor boundary" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -932,61 +957,126 @@ test "shape cursor boundary" { try testing.expectEqual(@as(usize, 1), count); } - // Cursor at index 0 is two runs { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 0, - ); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); + // Cursor at index 0 is two runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 0, + .{ .cursor = true }, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + // And without cursor splitting remains one + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 0, + .{ .cursor = false }, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); } - try testing.expectEqual(@as(usize, 2), count); } - // Cursor at index 1 is three runs { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 1, - ); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); + // Cursor at index 1 is three runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 1, + .{ .cursor = true }, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 3), count); + } + // And without cursor splitting remains one + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 1, + .{ .cursor = false }, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); } - try testing.expectEqual(@as(usize, 3), count); } - - // Cursor at last col is two runs { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 9, - ); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); + // Cursor at last col is two runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 9, + .{ .cursor = true }, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + // And without cursor splitting remains one + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 9, + .{ .cursor = false }, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); } - try testing.expectEqual(@as(usize, 2), count); } } @@ -1012,6 +1102,7 @@ test "shape cursor boundary and colored emoji" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1031,6 +1122,25 @@ test "shape cursor boundary and colored emoji" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, 0, + .{ .cursor = true }, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 0, + .{ .cursor = false }, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1048,6 +1158,25 @@ test "shape cursor boundary and colored emoji" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, 1, + .{ .cursor = true }, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 1, + .{ .cursor = false }, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1078,6 +1207,7 @@ test "shape cell attribute change" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1102,6 +1232,7 @@ test "shape cell attribute change" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1127,6 +1258,7 @@ test "shape cell attribute change" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1152,6 +1284,7 @@ test "shape cell attribute change" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { @@ -1176,6 +1309,7 @@ test "shape cell attribute change" { screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, + .{}, ); var count: usize = 0; while (try it.next(alloc)) |run| { diff --git a/src/font/shaper/noop.zig b/src/font/shaper/noop.zig index f8988f4ee..1041954e6 100644 --- a/src/font/shaper/noop.zig +++ b/src/font/shaper/noop.zig @@ -3,6 +3,7 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const trace = @import("tracy").trace; const font = @import("../main.zig"); +const config = @import("../../config.zig"); const Face = font.Face; const Collection = font.Collection; const DeferredFace = font.DeferredFace; @@ -75,6 +76,7 @@ pub const Shaper = struct { row: terminal.Pin, selection: ?terminal.Selection, cursor_x: ?usize, + break_config: config.FontShapingBreak, ) font.shape.RunIterator { return .{ .hooks = .{ .shaper = self }, @@ -83,6 +85,7 @@ pub const Shaper = struct { .row = row, .selection = selection, .cursor_x = cursor_x, + .break_config = break_config, }; } diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index 18ddd4b56..a6ff79e39 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -6,6 +6,8 @@ const shape = @import("../shape.zig"); const terminal = @import("../../terminal/main.zig"); const autoHash = std.hash.autoHash; const Hasher = std.hash.Wyhash; +const configpkg = @import("../../config.zig"); +const Config = configpkg.Config; /// A single text run. A text run is only valid for one Shaper instance and /// until the next run is created. A text run never goes across multiple @@ -40,6 +42,7 @@ pub const RunIterator = struct { row: terminal.Pin, selection: ?terminal.Selection = null, cursor_x: ?usize = null, + break_config: configpkg.FontShapingBreak, i: usize = 0, pub fn next(self: *RunIterator, alloc: Allocator) !?TextRun { @@ -175,36 +178,38 @@ pub const RunIterator = struct { break :emoji null; }; - // If our cursor is on this line then we break the run around the - // cursor. This means that any row with a cursor has at least - // three breaks: before, exactly the cursor, and after. - // - // We do not break a cell that is exactly the grapheme. If there - // are cells following that contain joiners, we allow those to - // break. This creates an effect where hovering over an emoji - // such as a skin-tone emoji is fine, but hovering over the - // joiners will show the joiners allowing you to modify the - // emoji. - if (!cell.hasGrapheme()) { - if (self.cursor_x) |cursor_x| { - // Exactly: self.i is the cursor and we iterated once. This - // means that we started exactly at the cursor and did at - // exactly one iteration. Why exactly one? Because we may - // start at our cursor but do many if our cursor is exactly - // on an emoji. - if (self.i == cursor_x and j == self.i + 1) break; + if (self.break_config.cursor) { + // If our cursor is on this line then we break the run around the + // cursor. This means that any row with a cursor has at least + // three breaks: before, exactly the cursor, and after. + // + // We do not break a cell that is exactly the grapheme. If there + // are cells following that contain joiners, we allow those to + // break. This creates an effect where hovering over an emoji + // such as a skin-tone emoji is fine, but hovering over the + // joiners will show the joiners allowing you to modify the + // emoji. + if (!cell.hasGrapheme()) { + if (self.cursor_x) |cursor_x| { + // Exactly: self.i is the cursor and we iterated once. This + // means that we started exactly at the cursor and did at + // exactly one iteration. Why exactly one? Because we may + // start at our cursor but do many if our cursor is exactly + // on an emoji. + if (self.i == cursor_x and j == self.i + 1) break; - // Before: up to and not including the cursor. This means - // that we started before the cursor (self.i < cursor_x) - // and j is now at the cursor meaning we haven't yet processed - // the cursor. - if (self.i < cursor_x and j == cursor_x) { - assert(j > 0); - break; + // Before: up to and not including the cursor. This means + // that we started before the cursor (self.i < cursor_x) + // and j is now at the cursor meaning we haven't yet processed + // the cursor. + if (self.i < cursor_x and j == cursor_x) { + assert(j > 0); + break; + } + + // After: after the cursor. We don't need to do anything + // special, we just let the run complete. } - - // After: after the cursor. We don't need to do anything - // special, we just let the run complete. } } diff --git a/src/font/shaper/web_canvas.zig b/src/font/shaper/web_canvas.zig index f38ab885a..95e220b84 100644 --- a/src/font/shaper/web_canvas.zig +++ b/src/font/shaper/web_canvas.zig @@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator; const ziglyph = @import("ziglyph"); const font = @import("../main.zig"); const terminal = @import("../../terminal/main.zig"); +const config = @import("../../config.zig"); const log = std.log.scoped(.font_shaper); @@ -65,6 +66,7 @@ pub const Shaper = struct { row: terminal.Screen.Row, selection: ?terminal.Selection, cursor_x: ?usize, + break_config: config.FontShapingBreak, ) font.shape.RunIterator { return .{ .hooks = .{ .shaper = self }, @@ -72,6 +74,7 @@ pub const Shaper = struct { .row = row, .selection = selection, .cursor_x = cursor_x, + .break_config = break_config, }; } From 73ff4b8f7472a3cccefbd900f24f3f0c604ad044 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Jun 2025 08:22:49 -0700 Subject: [PATCH 107/110] move runIterator options to dedicated struct --- src/font/shape.zig | 35 ++ src/font/shaper/coretext.zig | 583 +++++++++++++-------------------- src/font/shaper/harfbuzz.zig | 518 ++++++++++++----------------- src/font/shaper/noop.zig | 15 +- src/font/shaper/run.zig | 101 +++--- src/font/shaper/web_canvas.zig | 13 +- src/renderer/generic.zig | 18 +- 7 files changed, 525 insertions(+), 758 deletions(-) diff --git a/src/font/shape.zig b/src/font/shape.zig index cc67fc7a0..5e1e30eec 100644 --- a/src/font/shape.zig +++ b/src/font/shape.zig @@ -2,6 +2,9 @@ const builtin = @import("builtin"); const options = @import("main.zig").options; const run = @import("shaper/run.zig"); const feature = @import("shaper/feature.zig"); +const configpkg = @import("../config.zig"); +const terminal = @import("../terminal/main.zig"); +const SharedGrid = @import("main.zig").SharedGrid; pub const noop = @import("shaper/noop.zig"); pub const harfbuzz = @import("shaper/harfbuzz.zig"); pub const coretext = @import("shaper/coretext.zig"); @@ -61,6 +64,38 @@ pub const Options = struct { features: []const []const u8 = &.{}, }; +/// Options for runIterator. +pub const RunOptions = struct { + /// The font state for the terminal screen. This is mutable because + /// cached values may be updated during shaping. + grid: *SharedGrid, + + /// The terminal screen to shape. + screen: *const terminal.Screen, + + /// The row within the screen to shape. This row must exist within + /// screen; it is not validated. + row: terminal.Pin, + + /// The selection boundaries. This is used to break shaping on + /// selection boundaries. This can be disabled by setting this to + /// null. + selection: ?terminal.Selection = null, + + /// The cursor position within this row. This is used to break shaping + /// on cursor boundaries. This can be disabled by setting this to + /// null. + cursor_x: ?usize = null, + + /// Apply the font break configuration to the run. + pub fn applyBreakConfig( + self: *RunOptions, + config: configpkg.FontShapingBreak, + ) void { + if (!config.cursor) self.cursor_x = null; + } +}; + test { _ = Cache; _ = Shaper; diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 654af02d9..1fd9719bb 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -289,21 +289,11 @@ pub const Shaper = struct { pub fn runIterator( self: *Shaper, - grid: *SharedGrid, - screen: *const terminal.Screen, - row: terminal.Pin, - selection: ?terminal.Selection, - cursor_x: ?usize, - break_config: config.FontShapingBreak, + opts: font.shape.RunOptions, ) font.shape.RunIterator { return .{ .hooks = .{ .shaper = self }, - .grid = grid, - .screen = screen, - .row = row, - .selection = selection, - .cursor_x = cursor_x, - .break_config = break_config, + .opts = opts, }; } @@ -597,14 +587,11 @@ test "run iterator" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; try testing.expectEqual(@as(usize, 1), count); @@ -617,14 +604,11 @@ test "run iterator" { try screen.testWriteString("ABCD EFG"); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; try testing.expectEqual(@as(usize, 1), count); @@ -638,14 +622,11 @@ test "run iterator" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; try testing.expectEqual(@as(usize, 3), count); @@ -660,14 +641,11 @@ test "run iterator" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; try testing.expectEqual(@as(usize, 2), count); @@ -708,14 +686,11 @@ test "run iterator: empty cells with background set" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); { const run = (try it.next(alloc)).?; const cells = try shaper.shape(run); @@ -745,14 +720,11 @@ test "shape" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -781,14 +753,11 @@ test "shape nerd fonts" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -810,14 +779,11 @@ test "shape inconsolata ligs" { try screen.testWriteString(">="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -836,14 +802,11 @@ test "shape inconsolata ligs" { try screen.testWriteString("==="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -870,14 +833,11 @@ test "shape monaspace ligs" { try screen.testWriteString("==="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -905,14 +865,11 @@ test "shape left-replaced lig in last run" { try screen.testWriteString("!=="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -940,14 +897,11 @@ test "shape left-replaced lig in early run" { try screen.testWriteString("!==X"); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); const run = (try it.next(alloc)).?; @@ -972,14 +926,11 @@ test "shape U+3C9 with JB Mono" { try screen.testWriteString("\u{03C9} foo"); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var run_count: usize = 0; var cell_count: usize = 0; @@ -1006,14 +957,11 @@ test "shape emoji width" { try screen.testWriteString("👍"); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1057,14 +1005,11 @@ test "shape emoji width long" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 1 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 1 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1094,14 +1039,11 @@ test "shape variation selector VS15" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1130,14 +1072,11 @@ test "shape variation selector VS16" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1163,14 +1102,11 @@ test "shape with empty cells in between" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1202,14 +1138,11 @@ test "shape Chinese characters" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1243,14 +1176,11 @@ test "shape box glyphs" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1280,18 +1210,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, false, ), - null, - .{}, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1304,18 +1232,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, false, ), - null, - .{}, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1328,18 +1254,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, false, ), - null, - .{}, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1352,18 +1276,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, false, ), - null, - .{}, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1376,18 +1298,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, false, ), - null, - .{}, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1413,14 +1333,11 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1434,14 +1351,12 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 0, - .{ .cursor = true }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 0, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1453,14 +1368,11 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 0, - .{ .cursor = false }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1475,14 +1387,12 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 1, - .{ .cursor = true }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 1, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1494,14 +1404,11 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 1, - .{ .cursor = false }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1515,14 +1422,12 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 9, - .{ .cursor = true }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 9, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1534,14 +1439,11 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 9, - .{ .cursor = false }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1568,14 +1470,11 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1588,14 +1487,12 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 0, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 0, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1606,14 +1503,11 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 0, - .{ .cursor = false }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1624,14 +1518,12 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 1, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 1, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1642,14 +1534,11 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 1, - .{ .cursor = false }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1673,14 +1562,11 @@ test "shape cell attribute change" { try screen.testWriteString(">="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1698,14 +1584,11 @@ test "shape cell attribute change" { try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1724,14 +1607,11 @@ test "shape cell attribute change" { try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1750,14 +1630,11 @@ test "shape cell attribute change" { try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1775,14 +1652,11 @@ test "shape cell attribute change" { try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1816,14 +1690,11 @@ test "shape high plane sprite font codepoint" { try screen.testWriteString("\u{1FB70}"); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); // We should get one run const run = (try it.next(alloc)).?; // The run state should have the UTF-16 encoding of the character. diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 56654e88f..4209f795c 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -90,21 +90,11 @@ pub const Shaper = struct { /// and assume the y value matches. pub fn runIterator( self: *Shaper, - grid: *SharedGrid, - screen: *const terminal.Screen, - row: terminal.Pin, - selection: ?terminal.Selection, - cursor_x: ?usize, - break_config: config.FontShapingBreak, + opts: font.shape.RunOptions, ) font.shape.RunIterator { return .{ .hooks = .{ .shaper = self }, - .grid = grid, - .screen = screen, - .row = row, - .selection = selection, - .cursor_x = cursor_x, - .break_config = break_config, + .opts = opts, }; } @@ -228,14 +218,11 @@ test "run iterator" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; try testing.expectEqual(@as(usize, 1), count); @@ -248,14 +235,11 @@ test "run iterator" { try screen.testWriteString("ABCD EFG"); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; try testing.expectEqual(@as(usize, 1), count); @@ -269,14 +253,11 @@ test "run iterator" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |_| { count += 1; @@ -322,14 +303,11 @@ test "run iterator: empty cells with background set" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); { const run = (try it.next(alloc)).?; try testing.expectEqual(@as(u32, 3), shaper.hb_buf.getLength()); @@ -360,14 +338,11 @@ test "shape" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -390,14 +365,11 @@ test "shape inconsolata ligs" { try screen.testWriteString(">="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -416,14 +388,11 @@ test "shape inconsolata ligs" { try screen.testWriteString("==="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -450,14 +419,11 @@ test "shape monaspace ligs" { try screen.testWriteString("==="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -487,14 +453,11 @@ test "shape arabic forced LTR" { try screen.testWriteString(@embedFile("testdata/arabic.txt")); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -525,14 +488,11 @@ test "shape emoji width" { try screen.testWriteString("👍"); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -578,14 +538,11 @@ test "shape emoji width long" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 1 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 1 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -617,14 +574,11 @@ test "shape variation selector VS15" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -655,14 +609,11 @@ test "shape variation selector VS16" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -690,14 +641,11 @@ test "shape with empty cells in between" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -729,14 +677,11 @@ test "shape Chinese characters" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -770,14 +715,11 @@ test "shape box glyphs" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -808,18 +750,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, false, ), - null, - .{}, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -832,18 +772,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, false, ), - null, - .{}, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -856,18 +794,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, false, ), - null, - .{}, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -880,18 +816,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, false, ), - null, - .{}, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -904,18 +838,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, false, ), - null, - .{}, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -941,14 +873,11 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -962,14 +891,12 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 0, - .{ .cursor = true }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 0, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -981,14 +908,11 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 0, - .{ .cursor = false }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1003,14 +927,12 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 1, - .{ .cursor = true }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 1, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1022,14 +944,11 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 1, - .{ .cursor = false }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1043,14 +962,12 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 9, - .{ .cursor = true }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 9, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1062,14 +979,11 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 9, - .{ .cursor = false }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1096,14 +1010,11 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1116,14 +1027,12 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 0, - .{ .cursor = true }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 0, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1134,14 +1043,11 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 0, - .{ .cursor = false }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1152,14 +1058,12 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 1, - .{ .cursor = true }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 1, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1170,14 +1074,11 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 1, - .{ .cursor = false }, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1201,14 +1102,11 @@ test "shape cell attribute change" { try screen.testWriteString(">="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1226,14 +1124,11 @@ test "shape cell attribute change" { try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1252,14 +1147,11 @@ test "shape cell attribute change" { try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1278,14 +1170,11 @@ test "shape cell attribute change" { try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1303,14 +1192,11 @@ test "shape cell attribute change" { try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - .{}, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; diff --git a/src/font/shaper/noop.zig b/src/font/shaper/noop.zig index 1041954e6..8723071d7 100644 --- a/src/font/shaper/noop.zig +++ b/src/font/shaper/noop.zig @@ -3,7 +3,6 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const trace = @import("tracy").trace; const font = @import("../main.zig"); -const config = @import("../../config.zig"); const Face = font.Face; const Collection = font.Collection; const DeferredFace = font.DeferredFace; @@ -71,21 +70,11 @@ pub const Shaper = struct { pub fn runIterator( self: *Shaper, - grid: *SharedGrid, - screen: *const terminal.Screen, - row: terminal.Pin, - selection: ?terminal.Selection, - cursor_x: ?usize, - break_config: config.FontShapingBreak, + opts: font.shape.RunOptions, ) font.shape.RunIterator { return .{ .hooks = .{ .shaper = self }, - .grid = grid, - .screen = screen, - .row = row, - .selection = selection, - .cursor_x = cursor_x, - .break_config = break_config, + .opts = opts, }; } diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index a6ff79e39..92e629e19 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -6,8 +6,6 @@ const shape = @import("../shape.zig"); const terminal = @import("../../terminal/main.zig"); const autoHash = std.hash.autoHash; const Hasher = std.hash.Wyhash; -const configpkg = @import("../../config.zig"); -const Config = configpkg.Config; /// A single text run. A text run is only valid for one Shaper instance and /// until the next run is created. A text run never goes across multiple @@ -37,16 +35,11 @@ pub const TextRun = struct { /// RunIterator is an iterator that yields text runs. pub const RunIterator = struct { hooks: font.Shaper.RunIteratorHook, - grid: *font.SharedGrid, - screen: *const terminal.Screen, - row: terminal.Pin, - selection: ?terminal.Selection = null, - cursor_x: ?usize = null, - break_config: configpkg.FontShapingBreak, + opts: shape.RunOptions, i: usize = 0, pub fn next(self: *RunIterator, alloc: Allocator) !?TextRun { - const cells = self.row.cells(.all); + const cells = self.opts.row.cells(.all); // Trim the right side of a row that might be empty const max: usize = max: { @@ -61,7 +54,7 @@ pub const RunIterator = struct { // Invisible cells don't have any glyphs rendered, // so we explicitly skip them in the shaping process. while (self.i < max and - self.row.style(&cells[self.i]).flags.invisible) + self.opts.row.style(&cells[self.i]).flags.invisible) { self.i += 1; } @@ -79,7 +72,7 @@ pub const RunIterator = struct { var hasher = Hasher.init(0); // Let's get our style that we'll expect for the run. - const style = self.row.style(&cells[self.i]); + const style = self.opts.row.style(&cells[self.i]); // Go through cell by cell and accumulate while we build our run. var j: usize = self.i; @@ -89,9 +82,9 @@ pub const RunIterator = struct { // If we have a selection and we're at a boundary point, then // we break the run here. - if (self.selection) |unordered_sel| { + if (self.opts.selection) |unordered_sel| { if (j > self.i) { - const sel = unordered_sel.ordered(self.screen, .forward); + const sel = unordered_sel.ordered(self.opts.screen, .forward); const start_x = sel.start().x; const end_x = sel.end().x; @@ -145,7 +138,7 @@ pub const RunIterator = struct { // The style is different. We allow differing background // styles but any other change results in a new run. const c1 = comparableStyle(style); - const c2 = comparableStyle(self.row.style(&cells[j])); + const c2 = comparableStyle(self.opts.row.style(&cells[j])); if (!c1.eql(c2)) break; } @@ -165,7 +158,7 @@ pub const RunIterator = struct { const presentation: ?font.Presentation = if (cell.hasGrapheme()) p: { // We only check the FIRST codepoint because I believe the // presentation format must be directly adjacent to the codepoint. - const cps = self.row.grapheme(cell) orelse break :p null; + const cps = self.opts.row.grapheme(cell) orelse break :p null; assert(cps.len > 0); if (cps[0] == 0xFE0E) break :p .text; if (cps[0] == 0xFE0F) break :p .emoji; @@ -178,38 +171,36 @@ pub const RunIterator = struct { break :emoji null; }; - if (self.break_config.cursor) { - // If our cursor is on this line then we break the run around the - // cursor. This means that any row with a cursor has at least - // three breaks: before, exactly the cursor, and after. - // - // We do not break a cell that is exactly the grapheme. If there - // are cells following that contain joiners, we allow those to - // break. This creates an effect where hovering over an emoji - // such as a skin-tone emoji is fine, but hovering over the - // joiners will show the joiners allowing you to modify the - // emoji. - if (!cell.hasGrapheme()) { - if (self.cursor_x) |cursor_x| { - // Exactly: self.i is the cursor and we iterated once. This - // means that we started exactly at the cursor and did at - // exactly one iteration. Why exactly one? Because we may - // start at our cursor but do many if our cursor is exactly - // on an emoji. - if (self.i == cursor_x and j == self.i + 1) break; + // If our cursor is on this line then we break the run around the + // cursor. This means that any row with a cursor has at least + // three breaks: before, exactly the cursor, and after. + // + // We do not break a cell that is exactly the grapheme. If there + // are cells following that contain joiners, we allow those to + // break. This creates an effect where hovering over an emoji + // such as a skin-tone emoji is fine, but hovering over the + // joiners will show the joiners allowing you to modify the + // emoji. + if (!cell.hasGrapheme()) { + if (self.opts.cursor_x) |cursor_x| { + // Exactly: self.i is the cursor and we iterated once. This + // means that we started exactly at the cursor and did at + // exactly one iteration. Why exactly one? Because we may + // start at our cursor but do many if our cursor is exactly + // on an emoji. + if (self.i == cursor_x and j == self.i + 1) break; - // Before: up to and not including the cursor. This means - // that we started before the cursor (self.i < cursor_x) - // and j is now at the cursor meaning we haven't yet processed - // the cursor. - if (self.i < cursor_x and j == cursor_x) { - assert(j > 0); - break; - } - - // After: after the cursor. We don't need to do anything - // special, we just let the run complete. + // Before: up to and not including the cursor. This means + // that we started before the cursor (self.i < cursor_x) + // and j is now at the cursor meaning we haven't yet processed + // the cursor. + if (self.i < cursor_x and j == cursor_x) { + assert(j > 0); + break; } + + // After: after the cursor. We don't need to do anything + // special, we just let the run complete. } } @@ -232,7 +223,7 @@ pub const RunIterator = struct { // Otherwise we need a fallback character. Prefer the // official replacement character. - if (try self.grid.getIndex( + if (try self.opts.grid.getIndex( alloc, 0xFFFD, // replacement char font_style, @@ -240,7 +231,7 @@ pub const RunIterator = struct { )) |idx| break :font_info .{ .idx = idx, .fallback = 0xFFFD }; // Fallback to space - if (try self.grid.getIndex( + if (try self.opts.grid.getIndex( alloc, ' ', font_style, @@ -278,7 +269,7 @@ pub const RunIterator = struct { @intCast(cluster), ); if (cell.hasGrapheme()) { - const cps = self.row.grapheme(cell).?; + const cps = self.opts.row.grapheme(cell).?; for (cps) |cp| { // Do not send presentation modifiers if (cp == 0xFE0E or cp == 0xFE0F) continue; @@ -303,7 +294,7 @@ pub const RunIterator = struct { .hash = hasher.final(), .offset = @intCast(self.i), .cells = @intCast(j - self.i), - .grid = self.grid, + .grid = self.opts.grid, .font_index = current_font, }; } @@ -331,7 +322,7 @@ pub const RunIterator = struct { cell.codepoint() == 0 or cell.codepoint() == terminal.kitty.graphics.unicode.placeholder) { - return try self.grid.getIndex( + return try self.opts.grid.getIndex( alloc, ' ', style, @@ -341,7 +332,7 @@ pub const RunIterator = struct { // Get the font index for the primary codepoint. const primary_cp: u32 = cell.codepoint(); - const primary = try self.grid.getIndex( + const primary = try self.opts.grid.getIndex( alloc, primary_cp, style, @@ -354,7 +345,7 @@ pub const RunIterator = struct { // If this is a grapheme, we need to find a font that supports // all of the codepoints in the grapheme. - const cps = self.row.grapheme(cell) orelse return primary; + const cps = self.opts.row.grapheme(cell) orelse return primary; var candidates = try std.ArrayList(font.Collection.Index).initCapacity(alloc, cps.len + 1); defer candidates.deinit(); candidates.appendAssumeCapacity(primary); @@ -370,7 +361,7 @@ pub const RunIterator = struct { // to support the base presentation, since it is common for emoji // fonts to support the base emoji with emoji presentation but not // certain ZWJ-combined characters like the male and female signs. - const idx = try self.grid.getIndex( + const idx = try self.opts.grid.getIndex( alloc, cp, style, @@ -381,11 +372,11 @@ pub const RunIterator = struct { // We need to find a candidate that has ALL of our codepoints for (candidates.items) |idx| { - if (!self.grid.hasCodepoint(idx, primary_cp, presentation)) continue; + if (!self.opts.grid.hasCodepoint(idx, primary_cp, presentation)) continue; for (cps) |cp| { // Ignore Emoji ZWJs if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue; - if (!self.grid.hasCodepoint(idx, cp, null)) break; + if (!self.opts.grid.hasCodepoint(idx, cp, null)) break; } else { // If the while completed, then we have a candidate that // supports all of our codepoints. diff --git a/src/font/shaper/web_canvas.zig b/src/font/shaper/web_canvas.zig index 95e220b84..4ed4b7db6 100644 --- a/src/font/shaper/web_canvas.zig +++ b/src/font/shaper/web_canvas.zig @@ -4,7 +4,6 @@ const Allocator = std.mem.Allocator; const ziglyph = @import("ziglyph"); const font = @import("../main.zig"); const terminal = @import("../../terminal/main.zig"); -const config = @import("../../config.zig"); const log = std.log.scoped(.font_shaper); @@ -62,19 +61,11 @@ pub const Shaper = struct { /// for a Shaper struct since they share state. pub fn runIterator( self: *Shaper, - group: *font.GroupCache, - row: terminal.Screen.Row, - selection: ?terminal.Selection, - cursor_x: ?usize, - break_config: config.FontShapingBreak, + opts: font.shape.RunOptions, ) font.shape.RunIterator { return .{ .hooks = .{ .shaper = self }, - .group = group, - .row = row, - .selection = selection, - .cursor_x = cursor_x, - .break_config = break_config, + .opts = opts, }; } diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index bf189fc4c..fba577231 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -513,6 +513,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { font_thicken_strength: u8, font_features: std.ArrayListUnmanaged([:0]const u8), font_styles: font.CodepointResolver.StyleStatus, + font_shaping_break: configpkg.FontShapingBreak, cursor_color: ?terminal.color.RGB, cursor_invert: bool, cursor_opacity: f64, @@ -578,6 +579,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .font_thicken_strength = config.@"font-thicken-strength", .font_features = font_features.list, .font_styles = font_styles, + .font_shaping_break = config.@"font-shaping-break", .cursor_color = if (!cursor_invert and config.@"cursor-color" != null) config.@"cursor-color".?.toTerminalRGB() @@ -2467,13 +2469,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } // Iterator of runs for shaping. - var run_iter = self.font_shaper.runIterator( - self.font_grid, - screen, - row, - row_selection, - if (shape_cursor) screen.cursor.x else null, - ); + var run_iter_opts: font.shape.RunOptions = .{ + .grid = self.font_grid, + .screen = screen, + .row = row, + .selection = row_selection, + .cursor_x = if (shape_cursor) screen.cursor.x else null, + }; + run_iter_opts.applyBreakConfig(self.config.font_shaping_break); + var run_iter = self.font_shaper.runIterator(run_iter_opts); var shaper_run: ?font.shape.TextRun = try run_iter.next(self.alloc); var shaper_cells: ?[]const font.shape.Cell = null; var shaper_cells_i: usize = 0; From c00b8740aa53ca1dfbd2bff42d92d740a1449bb3 Mon Sep 17 00:00:00 2001 From: moni-dz Date: Thu, 2 Jan 2025 13:12:11 +0800 Subject: [PATCH 108/110] termio: add selection scrolling callback --- src/termio/Thread.zig | 66 ++++++++++++++++++++++++++++++++++++++++++ src/termio/message.zig | 3 ++ 2 files changed, 69 insertions(+) diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index a701a29f8..e10387ef9 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -37,6 +37,9 @@ const Coalesce = struct { /// if the running program hasn't already. const sync_reset_ms = 1000; +/// The number of milliseconds between each movement during selection scrolling. +const selection_scroll_ms = 15; + /// Allocator used for some state alloc: std.mem.Allocator, @@ -53,6 +56,11 @@ wakeup_c: xev.Completion = .{}, stop: xev.Async, stop_c: xev.Completion = .{}, +/// This is used for timer-based selection scrolling. +scroll: xev.Timer, +scroll_c: xev.Completion = .{}, +scroll_active: bool = false, + /// This is used to coalesce resize events. coalesce: xev.Timer, coalesce_c: xev.Completion = .{}, @@ -92,6 +100,10 @@ pub fn init( var stop_h = try xev.Async.init(); errdefer stop_h.deinit(); + // This timer is used for selection scrolling. + var scroll_h = try xev.Timer.init(); + errdefer scroll_h.deinit(); + // This timer is used to coalesce resize events. var coalesce_h = try xev.Timer.init(); errdefer coalesce_h.deinit(); @@ -104,6 +116,7 @@ pub fn init( .alloc = alloc, .loop = loop, .stop = stop_h, + .scroll = scroll_h, .coalesce = coalesce_h, .sync_reset = sync_reset_h, }; @@ -112,6 +125,7 @@ pub fn init( /// Clean up the thread. This is only safe to call once the thread /// completes executing; the caller must join prior to this. pub fn deinit(self: *Thread) void { + self.scroll.deinit(); self.coalesce.deinit(); self.sync_reset.deinit(); self.stop.deinit(); @@ -308,6 +322,13 @@ fn drainMailbox( .size_report => |v| try io.sizeReport(data, v), .clear_screen => |v| try io.clearScreen(data, v.history), .scroll_viewport => |v| try io.scrollViewport(v), + .selection_scroll => |v| { + if (v) { + self.startScrollTimer(cb); + } else { + self.stopScrollTimer(); + } + }, .jump_to_prompt => |v| try io.jumpToPrompt(v), .start_synchronized_output => self.startSynchronizedOutput(cb), .linefeed_mode => |v| self.flags.linefeed_mode = v, @@ -446,3 +467,48 @@ fn stopCallback( cb_.?.self.loop.stop(); return .disarm; } + +fn startScrollTimer(self: *Thread, cb: *CallbackData) void { + self.scroll_active = true; + + // Start the timer which loops + self.scroll.run(&self.loop, &self.scroll_c, selection_scroll_ms, CallbackData, cb, selectionScrollCallback); +} + +fn stopScrollTimer(self: *Thread) void { + // This will stop the scrolling on the next iteration. + self.scroll_active = false; +} + +fn selectionScrollCallback( + cb_: ?*CallbackData, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Timer.RunError!void, +) xev.CallbackAction { + _ = r catch |err| switch (err) { + error.Canceled => {}, + else => { + log.warn("error during selection scroll callback err={}", .{err}); + return .disarm; + }, + }; + + const cb = cb_ orelse return .disarm; + const surface = cb.io.surface_mailbox.surface; + const pos = try surface.rt_surface.getCursorPos(); + const delta: isize = if (pos.y < 0) -1 else 1; + + try cb.io.terminal.scrollViewport(.{ .delta = delta }); + + // Notify the renderer that it should repaint immediately after scrolling + cb.io.renderer_wakeup.notify() catch {}; + + const self = cb.self; + + if (self.scroll_active) { + self.scroll.run(&self.loop, &self.scroll_c, selection_scroll_ms, CallbackData, cb, selectionScrollCallback); + } + + return .disarm; +} diff --git a/src/termio/message.zig b/src/termio/message.zig index e497a298f..8057d226a 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -48,6 +48,9 @@ pub const Message = union(enum) { /// Scroll the viewport scroll_viewport: terminal.Terminal.ScrollViewport, + /// Selection scrolling + selection_scroll: bool, + /// Jump forward/backward n prompts. jump_to_prompt: isize, From f73c90bf5d146862f08e6ca1870a570d9c053019 Mon Sep 17 00:00:00 2001 From: moni-dz Date: Thu, 2 Jan 2025 13:12:11 +0800 Subject: [PATCH 109/110] surface: add timer-based scrolling during selection --- src/Surface.zig | 53 ++++++++++++++++++++++++++++++++++----- src/apprt/surface.zig | 3 +++ src/terminal/Terminal.zig | 3 +++ src/termio/Thread.zig | 8 ++---- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 5acec8c00..754807238 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -138,6 +138,9 @@ child_exited: bool = false, /// to let us know. focused: bool = true, +/// Used to determine whether to continuously scroll. +selection_scroll_active: bool = false, + /// The effect of an input event. This can be used by callers to take /// the appropriate action after an input event. For example, key /// input can be forwarded to the OS for further processing if it @@ -945,6 +948,34 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { log.warn("apprt failed to ring bell={}", .{err}); }; }, + + .selection_scroll => |active| { + self.selection_scroll_active = active; + + if (self.selection_scroll_active) { + const pos = try self.rt_surface.getCursorPos(); + const pos_vp = self.posToViewport(pos.x, pos.y); + const screen = &self.renderer_state.terminal.screen; + const delta: isize = if (pos.y < 0) -1 else 1; + + try self.io.terminal.scrollViewport(.{ .delta = delta }); + + // Always the case, but doesn't hurt to check + if (self.mouse.left_click_count == 1) { + const pin = screen.pages.pin(.{ + .viewport = .{ + .x = pos_vp.x, + .y = pos_vp.y, + }, + }) orelse { + if (comptime std.debug.runtime_safety) unreachable; + return; + }; + + try self.dragLeftClickSingle(pin, pos.x); + } + } + }, } } @@ -3260,6 +3291,12 @@ pub fn mouseButtonCallback( log.warn("error processing links err={}", .{err}); } } + + // Stop selection scrolling when releasing the left mouse button + // but only when selection scrolling is active. + if (self.selection_scroll_active) { + self.io.queueMessage(.{ .selection_scroll = false }, .unlocked); + } } // Report mouse events if enabled @@ -3767,6 +3804,12 @@ pub fn cursorPosCallback( self.renderer_state.terminal.screen.dirty.hyperlink_hover = true; } + // Stop selection scrolling when inside the viewport within a 1px buffer + // for fullscreen windows, but only when selection scrolling is active. + if (pos.x >= 1 and pos.y >= 1 and self.selection_scroll_active) { + self.io.queueMessage(.{ .selection_scroll = false }, .unlocked); + } + // Always show the mouse again if it is hidden if (self.mouse.hidden) self.showMouse(); @@ -3868,13 +3911,11 @@ pub fn cursorPosCallback( // 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 (pos.y <= 1 or pos.y > max_y - 1) { - const delta: isize = if (pos.y < 0) -1 else 1; - try self.io.terminal.scrollViewport(.{ .delta = delta }); - // TODO: We want a timer or something to repeat while we're still - // at this cursor position. Right now, the user has to jiggle their - // mouse in order to scroll. + // Only send a message when outside the viewport and + // selection scrolling is not currently active. + if ((pos.y <= 1 or pos.y > max_y - 1) and !self.selection_scroll_active) { + self.io.queueMessage(.{ .selection_scroll = true }, .locked); } // Convert to points diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index fcc67134b..f20e70393 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -79,6 +79,9 @@ pub const Message = union(enum) { color: terminal.color.RGB, }, + // Tell the surface to perform selection scrolling. + selection_scroll: bool, + /// The terminal has reported a change in the working directory. pwd_change: WriteReq, diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index be7a58f9b..dd7207f6d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -124,6 +124,9 @@ flags: packed struct { /// to true based on termios state. password_input: bool = false, + /// True if the terminal should perform selection scrolling. + selection_scroll: bool = false, + /// Dirty flags for the renderer. dirty: Dirty = .{}, } = .{}, diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index e10387ef9..0cc6e1758 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -495,17 +495,13 @@ fn selectionScrollCallback( }; const cb = cb_ orelse return .disarm; - const surface = cb.io.surface_mailbox.surface; - const pos = try surface.rt_surface.getCursorPos(); - const delta: isize = if (pos.y < 0) -1 else 1; + const self = cb.self; - try cb.io.terminal.scrollViewport(.{ .delta = delta }); + _ = cb.io.surface_mailbox.push(.{ .selection_scroll = self.scroll_active }, .{ .instant = {} }); // Notify the renderer that it should repaint immediately after scrolling cb.io.renderer_wakeup.notify() catch {}; - const self = cb.self; - if (self.scroll_active) { self.scroll.run(&self.loop, &self.scroll_c, selection_scroll_ms, CallbackData, cb, selectionScrollCallback); } From 81cef6e63b996869d0e4696598562ea0b4f882a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Jun 2025 09:18:27 -0700 Subject: [PATCH 110/110] various cleanups around scroll timers --- src/Surface.zig | 107 +++++++++++++++++++++++++---------------- src/apprt/surface.zig | 8 ++- src/termio/Thread.zig | 29 ++++++++--- src/termio/message.zig | 5 +- 4 files changed, 97 insertions(+), 52 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 754807238..390adf91b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -949,36 +949,50 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { }; }, - .selection_scroll => |active| { + .selection_scroll_tick => |active| { self.selection_scroll_active = active; - - if (self.selection_scroll_active) { - const pos = try self.rt_surface.getCursorPos(); - const pos_vp = self.posToViewport(pos.x, pos.y); - const screen = &self.renderer_state.terminal.screen; - const delta: isize = if (pos.y < 0) -1 else 1; - - try self.io.terminal.scrollViewport(.{ .delta = delta }); - - // Always the case, but doesn't hurt to check - if (self.mouse.left_click_count == 1) { - const pin = screen.pages.pin(.{ - .viewport = .{ - .x = pos_vp.x, - .y = pos_vp.y, - }, - }) orelse { - if (comptime std.debug.runtime_safety) unreachable; - return; - }; - - try self.dragLeftClickSingle(pin, pos.x); - } - } + try self.selectionScrollTick(); }, } } +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.left_click_count == 0) return; + + 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(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + + // Scroll the viewport as required + try t.scrollViewport(.{ .delta = delta }); + + // Next, trigger our drag behavior + const pin = t.screen.pages.pin(.{ + .viewport = .{ + .x = pos_vp.x, + .y = pos_vp.y, + }, + }) orelse { + if (comptime std.debug.runtime_safety) unreachable; + return; + }; + try self.dragLeftClickSingle(pin, pos.x); + + // We modified our viewport and selection so we need to queue + // a render. + try self.queueRender(); +} + fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void { // Mark our flag that we exited immediately self.child_exited = true; @@ -3264,6 +3278,15 @@ pub fn mouseButtonCallback( } if (button == .left and action == .release) { + // Stop selection scrolling when releasing the left mouse button + // but only when selection scrolling is active. + if (self.selection_scroll_active) { + self.io.queueMessage( + .{ .selection_scroll = false }, + .unlocked, + ); + } + // The selection clipboard is only updated for left-click drag when // the left button is released. This is to avoid the clipboard // being updated on every mouse move which would be noisy. @@ -3291,12 +3314,6 @@ pub fn mouseButtonCallback( log.warn("error processing links err={}", .{err}); } } - - // Stop selection scrolling when releasing the left mouse button - // but only when selection scrolling is active. - if (self.selection_scroll_active) { - self.io.queueMessage(.{ .selection_scroll = false }, .unlocked); - } } // Report mouse events if enabled @@ -3804,12 +3821,6 @@ pub fn cursorPosCallback( self.renderer_state.terminal.screen.dirty.hyperlink_hover = true; } - // Stop selection scrolling when inside the viewport within a 1px buffer - // for fullscreen windows, but only when selection scrolling is active. - if (pos.x >= 1 and pos.y >= 1 and self.selection_scroll_active) { - self.io.queueMessage(.{ .selection_scroll = false }, .unlocked); - } - // Always show the mouse again if it is hidden if (self.mouse.hidden) self.showMouse(); @@ -3829,6 +3840,15 @@ 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.x >= 1 and pos.y >= 1 and self.selection_scroll_active) { + self.io.queueMessage( + .{ .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. @@ -3912,10 +3932,15 @@ pub fn cursorPosCallback( //log.warn("CURSOR POS: {} {}", .{ pos, self.size.screen }); const max_y: f32 = @floatFromInt(self.size.screen.height); - // Only send a message when outside the viewport and - // selection scrolling is not currently active. - if ((pos.y <= 1 or pos.y > max_y - 1) and !self.selection_scroll_active) { - self.io.queueMessage(.{ .selection_scroll = true }, .locked); + // 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.io.queueMessage( + .{ .selection_scroll = true }, + .locked, + ); } // Convert to points diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index f20e70393..9254b2fd5 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -79,8 +79,12 @@ pub const Message = union(enum) { color: terminal.color.RGB, }, - // Tell the surface to perform selection scrolling. - selection_scroll: bool, + /// Notifies the surface that a tick of the timer that is timing + /// out selection scrolling has occurred. "selection scrolling" + /// is when the user has clicked and dragged the mouse outside + /// the viewport of the terminal and the terminal is scrolling + /// the viewport to follow the mouse cursor. + selection_scroll_tick: bool, /// The terminal has reported a change in the working directory. pwd_change: WriteReq, diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 0cc6e1758..7773ea7cd 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -472,7 +472,14 @@ fn startScrollTimer(self: *Thread, cb: *CallbackData) void { self.scroll_active = true; // Start the timer which loops - self.scroll.run(&self.loop, &self.scroll_c, selection_scroll_ms, CallbackData, cb, selectionScrollCallback); + self.scroll.run( + &self.loop, + &self.scroll_c, + selection_scroll_ms, + CallbackData, + cb, + selectionScrollCallback, + ); } fn stopScrollTimer(self: *Thread) void { @@ -497,14 +504,20 @@ fn selectionScrollCallback( const cb = cb_ orelse return .disarm; const self = cb.self; - _ = cb.io.surface_mailbox.push(.{ .selection_scroll = self.scroll_active }, .{ .instant = {} }); + // Send the tick to the main surface + _ = cb.io.surface_mailbox.push( + .{ .selection_scroll_tick = self.scroll_active }, + .{ .instant = {} }, + ); - // Notify the renderer that it should repaint immediately after scrolling - cb.io.renderer_wakeup.notify() catch {}; - - if (self.scroll_active) { - self.scroll.run(&self.loop, &self.scroll_c, selection_scroll_ms, CallbackData, cb, selectionScrollCallback); - } + if (self.scroll_active) self.scroll.run( + &self.loop, + &self.scroll_c, + selection_scroll_ms, + CallbackData, + cb, + selectionScrollCallback, + ); return .disarm; } diff --git a/src/termio/message.zig b/src/termio/message.zig index 8057d226a..ee6dbcc0f 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -48,7 +48,10 @@ pub const Message = union(enum) { /// Scroll the viewport scroll_viewport: terminal.Terminal.ScrollViewport, - /// Selection scrolling + /// Selection scrolling. If this is set to true then the termio + /// thread starts a timer that will trigger a `selection_scroll_tick` + /// message back to the surface. This ping/pong is because the + /// surface thread doesn't have access to an event loop from libghostty. selection_scroll: bool, /// Jump forward/backward n prompts.