From e9dc03b0b4fd5b23d0791987a517851656831ddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= Date: Thu, 26 Feb 2026 21:01:38 +0100 Subject: [PATCH 001/391] i18n: update Hungarian translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Balázs Szücs --- po/hu.po | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/po/hu.po b/po/hu.po index 6a3c61894..e7474e2c4 100644 --- a/po/hu.po +++ b/po/hu.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2026-02-17 23:16+0100\n" -"PO-Revision-Date: 2026-02-10 18:32+0200\n" +"PO-Revision-Date: 2026-02-26 21:00+0100\n" "Last-Translator: Balázs Szücs \n" "Language-Team: Hungarian \n" "Language: hu\n" @@ -19,7 +19,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "Megnyitás a Ghostty alkalmazásban" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -179,7 +179,7 @@ msgstr "Fül" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Fül címének módosítása…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -336,7 +336,7 @@ msgstr "Terminál címének módosítása" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Fül címének módosítása" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" From 96f8f0d93c19efec5cc349f71965a405f5c9c2a3 Mon Sep 17 00:00:00 2001 From: Jake Guthmiller Date: Sat, 7 Feb 2026 20:20:49 -0600 Subject: [PATCH 002/391] gtk: add setMonitor binding and kde-output-order-v1 protocol Add the missing setMonitor() function to the gtk4-layer-shell Zig bindings and provide the gdk module so it can reference gdk.Monitor. Register the kde-output-order-v1 Wayland protocol from plasma-wayland-protocols and generate its scanner binding. This protocol reports the compositor's monitor priority ordering and is needed to correctly identify the primary monitor for quick-terminal-screen support on Linux. Co-Authored-By: Claude Opus 4.6 --- pkg/gtk4-layer-shell/src/main.zig | 5 +++++ src/build/SharedDeps.zig | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/pkg/gtk4-layer-shell/src/main.zig b/pkg/gtk4-layer-shell/src/main.zig index f7848ea94..e61ce3508 100644 --- a/pkg/gtk4-layer-shell/src/main.zig +++ b/pkg/gtk4-layer-shell/src/main.zig @@ -3,6 +3,7 @@ const std = @import("std"); const c = @cImport({ @cInclude("gtk4-layer-shell.h"); }); +const gdk = @import("gdk"); const gtk = @import("gtk"); pub const ShellLayer = enum(c_uint) { @@ -61,6 +62,10 @@ pub fn setKeyboardMode(window: *gtk.Window, mode: KeyboardMode) void { c.gtk_layer_set_keyboard_mode(@ptrCast(window), @intFromEnum(mode)); } +pub fn setMonitor(window: *gtk.Window, monitor: ?*gdk.Monitor) void { + c.gtk_layer_set_monitor(@ptrCast(window), if (monitor) |m| @ptrCast(m) else null); +} + pub fn setNamespace(window: *gtk.Window, name: [:0]const u8) void { c.gtk_layer_set_namespace(@ptrCast(window), name.ptr); } diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 9276c9914..bd922c591 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -636,12 +636,16 @@ fn addGtkNg( scanner.addCustomProtocol( plasma_wayland_protocols_dep.path("src/protocols/slide.xml"), ); + scanner.addCustomProtocol( + plasma_wayland_protocols_dep.path("src/protocols/kde-output-order-v1.xml"), + ); scanner.addSystemProtocol("staging/xdg-activation/xdg-activation-v1.xml"); scanner.generate("wl_compositor", 1); scanner.generate("org_kde_kwin_blur_manager", 1); scanner.generate("org_kde_kwin_server_decoration_manager", 1); scanner.generate("org_kde_kwin_slide_manager", 1); + scanner.generate("kde_output_order_v1", 1); scanner.generate("xdg_activation_v1", 1); step.root_module.addImport("wayland", b.createModule(.{ @@ -661,6 +665,10 @@ fn addGtkNg( "gtk", gobject.module("gtk4"), ); + if (gobject_) |gobject| layer_shell_module.addImport( + "gdk", + gobject.module("gdk4"), + ); step.root_module.addImport( "gtk4-layer-shell", layer_shell_module, From 6da660a9a5eb9e57175035d23434b4c44b1b4151 Mon Sep 17 00:00:00 2001 From: Jake Guthmiller Date: Sat, 7 Feb 2026 20:22:15 -0600 Subject: [PATCH 003/391] gtk: implement quick-terminal-screen for Wayland Implement the quick-terminal-screen config option on Linux/Wayland so users can pin the quick terminal to a specific monitor instead of always following the mouse cursor. Use the kde_output_order_v1 protocol to identify the compositor's primary monitor by connector name (e.g. "DP-1"). When the protocol is unavailable, fall back to the first monitor in the GDK list. - Add resolveQuickTerminalMonitor() to map config values to a gdk.Monitor: .mouse returns null (compositor decides), .main and .macos-menu-bar match by connector name via the protocol - Call layer_shell.setMonitor() in both initQuickTerminal and syncQuickTerminal so config reloads take effect - Update enteredMonitor to size the window using the configured monitor rather than whichever monitor was entered - Update config documentation to reflect Linux support Co-Authored-By: Claude Opus 4.6 --- src/apprt/gtk/winproto/wayland.zig | 128 +++++++++++++++++++++++++++-- src/config/Config.zig | 8 +- 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index ec02fbee5..f2216bbf2 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -14,6 +14,7 @@ const input = @import("../../../input.zig"); const ApprtWindow = @import("../class/window.zig").Window; const wl = wayland.client.wl; +const kde = wayland.client.kde; const org = wayland.client.org; const xdg = wayland.client.xdg; @@ -33,6 +34,18 @@ pub const App = struct { kde_slide_manager: ?*org.KdeKwinSlideManager = null, + kde_output_order: ?*kde.OutputOrderV1 = null, + + /// Connector name of the primary output (e.g., "DP-1") as reported + /// by kde_output_order_v1. The first output in each priority list + /// is the primary. + primary_output_name: ?[63:0]u8 = null, + + /// Tracks the output order event cycle. Set to true after a `done` + /// event so the next `output` event is captured as the new primary. + /// Initialized to true so the first event after binding is captured. + output_order_done: bool = true, + default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null, xdg_activation: ?*xdg.ActivationV1 = null, @@ -83,9 +96,16 @@ pub const App = struct { registry.setListener(*Context, registryListener, context); if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; - // Do another round-trip to get the default decoration mode + // Set up listeners for protocols that send events on bind. + // All listeners must be set before the roundtrip so that + // events aren't lost. if (context.kde_decoration_manager) |deco_manager| { deco_manager.setListener(*Context, decoManagerListener, context); + } + if (context.kde_output_order) |output_order| { + output_order.setListener(*Context, outputOrderListener, context); + } + if (context.kde_decoration_manager != null or context.kde_output_order != null) { if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; } @@ -127,9 +147,55 @@ pub const App = struct { return true; } - pub fn initQuickTerminal(_: *App, apprt_window: *ApprtWindow) !void { + pub fn initQuickTerminal(self: *App, apprt_window: *ApprtWindow) !void { const window = apprt_window.as(gtk.Window); layer_shell.initForWindow(window); + + // Set target monitor based on config (null lets compositor decide) + const monitor = resolveQuickTerminalMonitor(self.context, apprt_window); + layer_shell.setMonitor(window, monitor); + } + + /// Resolve the quick-terminal-screen config to a specific monitor. + /// Returns null to let the compositor decide (used for .mouse mode). + fn resolveQuickTerminalMonitor( + context: *Context, + apprt_window: *ApprtWindow, + ) ?*gdk.Monitor { + const config = if (apprt_window.getConfig()) |v| v.get() else return null; + const display = apprt_window.as(gtk.Widget).getDisplay(); + + return switch (config.@"quick-terminal-screen") { + .mouse => null, + .main, .@"macos-menu-bar" => blk: { + const monitors = display.getMonitors(); + const primary_name: ?[]const u8 = if (context.primary_output_name) |*buf| + std.mem.sliceTo(buf, 0) + else + null; + + var fallback: ?*gdk.Monitor = null; + var i: u32 = 0; + while (monitors.getObject(i)) |item| : (i += 1) { + // getObject returns transfer-full; release immediately. + // The display keeps its own ref so the pointer stays valid. + item.unref(); + const monitor = gobject.ext.cast(gdk.Monitor, item) orelse continue; + if (fallback == null) fallback = monitor; + + if (primary_name) |name| { + const connector = std.mem.sliceTo( + monitor.getConnector() orelse continue, + 0, + ); + if (std.mem.eql(u8, connector, name)) { + break :blk monitor; + } + } + } + break :blk fallback; + }, + }; } fn getInterfaceType(comptime field: std.builtin.Type.StructField) ?type { @@ -200,10 +266,20 @@ pub const App = struct { .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; + if (@field(context, field.name)) |global| { + if (global.getId() == v.name) { + global.destroy(); + @field(context, field.name) = null; + + // Reset cached primary-output state if the protocol + // providing it disappears. + if (comptime std.mem.eql(u8, field.name, "kde_output_order")) { + context.primary_output_name = null; + context.primary_output_match_failed_logged = false; + context.output_order_done = true; + } + break :remove; + } } } }, @@ -221,6 +297,30 @@ pub const App = struct { }, } } + + fn outputOrderListener( + _: *kde.OutputOrderV1, + event: kde.OutputOrderV1.Event, + context: *Context, + ) void { + switch (event) { + .output => |v| { + if (context.output_order_done) { + context.output_order_done = false; + const name = std.mem.sliceTo(v.output_name, 0); + if (name.len <= 63) { + var buf: [63:0]u8 = @splat(0); + @memcpy(buf[0..name.len], name); + context.primary_output_name = buf; + log.debug("primary output: {s}", .{name}); + } + } + }, + .done => { + context.output_order_done = true; + }, + } + } }; /// Per-window (wl_surface) state for the Wayland protocol. @@ -417,6 +517,11 @@ pub const Window = struct { }); layer_shell.setNamespace(window, config.@"gtk-quick-terminal-namespace"); + // Re-resolve the target monitor on every sync so that config reloads + // and primary-output changes take effect without recreating the window. + const target_monitor = App.resolveQuickTerminalMonitor(self.app_context, self.apprt_window); + layer_shell.setMonitor(window, target_monitor); + layer_shell.setKeyboardMode( window, switch (config.@"quick-terminal-keyboard-interactivity") { @@ -486,8 +591,17 @@ pub const Window = struct { const window = apprt_window.as(gtk.Window); const config = if (apprt_window.getConfig()) |v| v.get() else return; + // Use the configured monitor for sizing if not in mouse mode + const size_monitor = switch (config.@"quick-terminal-screen") { + .mouse => monitor, + .main, .@"macos-menu-bar" => App.resolveQuickTerminalMonitor( + apprt_window.winproto().wayland.app_context, + apprt_window, + ) orelse monitor, + }; + var monitor_size: gdk.Rectangle = undefined; - monitor.getGeometry(&monitor_size); + size_monitor.getGeometry(&monitor_size); const dims = config.@"quick-terminal-size".calculate( config.@"quick-terminal-position", diff --git a/src/config/Config.zig b/src/config/Config.zig index bf9860c13..94d2ba8d3 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2680,7 +2680,13 @@ keybind: Keybinds = .{}, /// The default value is `main` because this is the recommended screen /// by the operating system. /// -/// Only implemented on macOS. +/// On macOS, `macos-menu-bar` uses the screen containing the menu bar. +/// On Linux/Wayland, `macos-menu-bar` is treated as equivalent to `main`. +/// +/// Note: On Linux, there is no universal concept of a "primary" monitor. +/// Ghostty uses the compositor-reported primary output when available and +/// falls back to the first monitor reported by GDK if no primary output can +/// be resolved. @"quick-terminal-screen": QuickTerminalScreen = .main, /// Duration (in seconds) of the quick terminal enter and exit animation. From 630c2dff190af14b7915f3e0d4df639e95c4f21b Mon Sep 17 00:00:00 2001 From: Jake Guthmiller Date: Sat, 7 Feb 2026 21:03:29 -0600 Subject: [PATCH 004/391] gtk: fix monitor ref ownership in Wayland quick terminal Handle g_list_model_get_object transfer-full semantics in resolveQuickTerminalMonitor by retaining exactly one monitor reference to return and unreffing the rest. Update init/sync/sizing call sites to unref the resolved monitor after setMonitor/getGeometry so monitor lifetimes are explicit and consistent. Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> --- src/apprt/gtk/winproto/wayland.zig | 64 +++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index f2216bbf2..9ce89146f 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -41,6 +41,10 @@ pub const App = struct { /// is the primary. primary_output_name: ?[63:0]u8 = null, + /// Used to avoid repeatedly logging the same primary-name mismatch + /// when we can't map the compositor connector name to a GDK monitor. + primary_output_match_failed_logged: bool = false, + /// Tracks the output order event cycle. Set to true after a `done` /// event so the next `output` event is captured as the new primary. /// Initialized to true so the first event after binding is captured. @@ -153,6 +157,7 @@ pub const App = struct { // Set target monitor based on config (null lets compositor decide) const monitor = resolveQuickTerminalMonitor(self.context, apprt_window); + defer if (monitor) |v| v.unref(); layer_shell.setMonitor(window, monitor); } @@ -174,25 +179,44 @@ pub const App = struct { else null; + // We own a strong ref for every object returned by getObject. + // Keep one ref as a fallback to return, and release all others. var fallback: ?*gdk.Monitor = null; + var matched_primary = false; var i: u32 = 0; while (monitors.getObject(i)) |item| : (i += 1) { - // getObject returns transfer-full; release immediately. - // The display keeps its own ref so the pointer stays valid. - item.unref(); - const monitor = gobject.ext.cast(gdk.Monitor, item) orelse continue; - if (fallback == null) fallback = monitor; + const monitor = gobject.ext.cast(gdk.Monitor, item) orelse { + item.unref(); + continue; + }; + const keep_as_fallback = fallback == null; + if (keep_as_fallback) fallback = monitor; if (primary_name) |name| { - const connector = std.mem.sliceTo( - monitor.getConnector() orelse continue, - 0, - ); - if (std.mem.eql(u8, connector, name)) { - break :blk monitor; + if (monitor.getConnector()) |connector_z| { + const connector = std.mem.sliceTo(connector_z, 0); + if (std.mem.eql(u8, connector, name)) { + matched_primary = true; + context.primary_output_match_failed_logged = false; + if (fallback) |v| { + if (v != monitor) v.unref(); + } + break :blk monitor; + } } } + + if (!keep_as_fallback) monitor.unref(); } + + if (primary_name != null and !matched_primary and !context.primary_output_match_failed_logged) { + context.primary_output_match_failed_logged = true; + log.debug( + "could not match primary output connector to a GDK monitor; falling back to first monitor", + .{}, + ); + } + break :blk fallback; }, }; @@ -312,7 +336,13 @@ pub const App = struct { var buf: [63:0]u8 = @splat(0); @memcpy(buf[0..name.len], name); context.primary_output_name = buf; + context.primary_output_match_failed_logged = false; log.debug("primary output: {s}", .{name}); + } else { + log.warn( + "ignoring primary output name longer than 63 bytes from kde_output_order_v1", + .{}, + ); } } }, @@ -520,6 +550,7 @@ pub const Window = struct { // Re-resolve the target monitor on every sync so that config reloads // and primary-output changes take effect without recreating the window. const target_monitor = App.resolveQuickTerminalMonitor(self.app_context, self.apprt_window); + defer if (target_monitor) |v| v.unref(); layer_shell.setMonitor(window, target_monitor); layer_shell.setKeyboardMode( @@ -591,14 +622,17 @@ pub const Window = struct { const window = apprt_window.as(gtk.Window); const config = if (apprt_window.getConfig()) |v| v.get() else return; - // Use the configured monitor for sizing if not in mouse mode - const size_monitor = switch (config.@"quick-terminal-screen") { - .mouse => monitor, + const resolved_monitor = switch (config.@"quick-terminal-screen") { + .mouse => null, .main, .@"macos-menu-bar" => App.resolveQuickTerminalMonitor( apprt_window.winproto().wayland.app_context, apprt_window, - ) orelse monitor, + ), }; + defer if (resolved_monitor) |v| v.unref(); + + // Use the configured monitor for sizing if not in mouse mode. + const size_monitor = resolved_monitor orelse monitor; var monitor_size: gdk.Rectangle = undefined; size_monitor.getGeometry(&monitor_size); From e25d8a6f2f4a1d384866ab222f920f351b8905da Mon Sep 17 00:00:00 2001 From: Jake Guthmiller Date: Fri, 13 Feb 2026 21:26:34 -0600 Subject: [PATCH 005/391] gtk: harden quick-terminal output-order state handling Install Wayland protocol listeners at bind time so late-added globals still receive events and listener setup stays tied to object lifetime. Track whether kde_output_order_v1 emitted any outputs in a cycle and clear cached primary-output state on empty or invalid updates. Also reset this cycle tracking when the protocol global is removed to avoid stale monitor selection. --- src/apprt/gtk/winproto/wayland.zig | 62 ++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 9ce89146f..52ac92831 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -50,6 +50,10 @@ pub const App = struct { /// Initialized to true so the first event after binding is captured. output_order_done: bool = true, + /// True if we've received an `output` event in the current cycle. + /// This lets us detect empty cycles and clear stale cached state. + output_order_seen_output: bool = false, + default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null, xdg_activation: ?*xdg.ActivationV1 = null, @@ -100,15 +104,9 @@ pub const App = struct { registry.setListener(*Context, registryListener, context); if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; - // Set up listeners for protocols that send events on bind. - // All listeners must be set before the roundtrip so that - // events aren't lost. - if (context.kde_decoration_manager) |deco_manager| { - deco_manager.setListener(*Context, decoManagerListener, context); - } - if (context.kde_output_order) |output_order| { - output_order.setListener(*Context, outputOrderListener, context); - } + // Do another roundtrip to process events emitted by globals we bound + // during registry discovery (e.g. default decoration mode, output + // order). Listeners are installed at bind time in registryListener. if (context.kde_decoration_manager != null or context.kde_output_order != null) { if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; } @@ -269,7 +267,15 @@ pub const App = struct { ) == .eq) { log.debug("matched {}", .{T}); - @field(context, field.name) = registry.bind( + if (@field(context, field.name) != null) { + log.warn( + "duplicate global for {s}; keeping existing binding", + .{v.interface}, + ); + break; + } + + const global = registry.bind( v.name, T, T.generated_version, @@ -280,6 +286,22 @@ pub const App = struct { ); return; }; + @field(context, field.name) = global; + + // Install listeners immediately at bind time. This + // keeps listener setup and object lifetime in one + // place and also supports globals that appear later. + if (comptime std.mem.eql(u8, field.name, "kde_decoration_manager")) { + const deco_manager: *org.KdeKwinServerDecorationManager = + @field(context, field.name).?; + deco_manager.setListener(*Context, decoManagerListener, context); + } + if (comptime std.mem.eql(u8, field.name, "kde_output_order")) { + const output_order: *kde.OutputOrderV1 = + @field(context, field.name).?; + output_order.setListener(*Context, outputOrderListener, context); + } + break; } } }, @@ -301,6 +323,7 @@ pub const App = struct { context.primary_output_name = null; context.primary_output_match_failed_logged = false; context.output_order_done = true; + context.output_order_seen_output = false; } break :remove; } @@ -331,14 +354,24 @@ pub const App = struct { .output => |v| { if (context.output_order_done) { context.output_order_done = false; + context.output_order_seen_output = true; const name = std.mem.sliceTo(v.output_name, 0); - if (name.len <= 63) { + if (name.len == 0) { + context.primary_output_name = null; + context.primary_output_match_failed_logged = false; + log.warn( + "ignoring empty primary output name from kde_output_order_v1", + .{}, + ); + } else if (name.len <= 63) { var buf: [63:0]u8 = @splat(0); @memcpy(buf[0..name.len], name); context.primary_output_name = buf; context.primary_output_match_failed_logged = false; log.debug("primary output: {s}", .{name}); } else { + context.primary_output_name = null; + context.primary_output_match_failed_logged = false; log.warn( "ignoring primary output name longer than 63 bytes from kde_output_order_v1", .{}, @@ -347,7 +380,14 @@ pub const App = struct { } }, .done => { + // An empty update means the compositor currently reports no + // outputs in priority order, so drop any stale cached primary. + if (!context.output_order_seen_output) { + context.primary_output_name = null; + context.primary_output_match_failed_logged = false; + } context.output_order_done = true; + context.output_order_seen_output = false; }, } } From 34473b069bd74a729d001e3f71df3b03e890a739 Mon Sep 17 00:00:00 2001 From: Jake Guthmiller Date: Fri, 13 Feb 2026 21:32:12 -0600 Subject: [PATCH 006/391] gtk: simplify quick-terminal monitor resolution and state management Restructure resolveQuickTerminalMonitor into a two-phase approach (match by name, then fall back to first monitor) to eliminate the interleaved fallback/match ref tracking. Remove redundant switch in enteredMonitor that duplicated the .mouse handling already in resolveQuickTerminalMonitor. Hoist the primary_output_match_failed_logged reset above the name-length branches in outputOrderListener. Co-Authored-By: Claude Opus 4.6 --- src/apprt/gtk/winproto/wayland.zig | 81 ++++++++++++------------------ 1 file changed, 31 insertions(+), 50 deletions(-) diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 52ac92831..2624cd721 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -161,6 +161,7 @@ pub const App = struct { /// Resolve the quick-terminal-screen config to a specific monitor. /// Returns null to let the compositor decide (used for .mouse mode). + /// Caller owns the returned ref and must unref it. fn resolveQuickTerminalMonitor( context: *Context, apprt_window: *ApprtWindow, @@ -172,50 +173,41 @@ pub const App = struct { .mouse => null, .main, .@"macos-menu-bar" => blk: { const monitors = display.getMonitors(); - const primary_name: ?[]const u8 = if (context.primary_output_name) |*buf| - std.mem.sliceTo(buf, 0) - else - null; - // We own a strong ref for every object returned by getObject. - // Keep one ref as a fallback to return, and release all others. - var fallback: ?*gdk.Monitor = null; - var matched_primary = false; - var i: u32 = 0; - while (monitors.getObject(i)) |item| : (i += 1) { - const monitor = gobject.ext.cast(gdk.Monitor, item) orelse { - item.unref(); - continue; - }; - const keep_as_fallback = fallback == null; - if (keep_as_fallback) fallback = monitor; - - if (primary_name) |name| { + // Try to find the monitor matching the primary output name. + if (context.primary_output_name) |*stored_name| { + const name = std.mem.sliceTo(stored_name, 0); + var i: u32 = 0; + while (monitors.getObject(i)) |item| : (i += 1) { + const monitor = gobject.ext.cast(gdk.Monitor, item) orelse { + item.unref(); + continue; + }; if (monitor.getConnector()) |connector_z| { const connector = std.mem.sliceTo(connector_z, 0); if (std.mem.eql(u8, connector, name)) { - matched_primary = true; context.primary_output_match_failed_logged = false; - if (fallback) |v| { - if (v != monitor) v.unref(); - } break :blk monitor; } } + monitor.unref(); } - if (!keep_as_fallback) monitor.unref(); + if (!context.primary_output_match_failed_logged) { + context.primary_output_match_failed_logged = true; + log.debug( + "could not match primary output connector to a GDK monitor; falling back to first monitor", + .{}, + ); + } } - if (primary_name != null and !matched_primary and !context.primary_output_match_failed_logged) { - context.primary_output_match_failed_logged = true; - log.debug( - "could not match primary output connector to a GDK monitor; falling back to first monitor", - .{}, - ); - } - - break :blk fallback; + // Fall back to the first monitor in the list. + const first = monitors.getObject(0) orelse break :blk null; + break :blk gobject.ext.cast(gdk.Monitor, first) orelse { + first.unref(); + break :blk null; + }; }, }; } @@ -355,27 +347,19 @@ pub const App = struct { if (context.output_order_done) { context.output_order_done = false; context.output_order_seen_output = true; + context.primary_output_match_failed_logged = false; const name = std.mem.sliceTo(v.output_name, 0); if (name.len == 0) { context.primary_output_name = null; - context.primary_output_match_failed_logged = false; - log.warn( - "ignoring empty primary output name from kde_output_order_v1", - .{}, - ); + log.warn("ignoring empty primary output name from kde_output_order_v1", .{}); } else if (name.len <= 63) { var buf: [63:0]u8 = @splat(0); @memcpy(buf[0..name.len], name); context.primary_output_name = buf; - context.primary_output_match_failed_logged = false; log.debug("primary output: {s}", .{name}); } else { context.primary_output_name = null; - context.primary_output_match_failed_logged = false; - log.warn( - "ignoring primary output name longer than 63 bytes from kde_output_order_v1", - .{}, - ); + log.warn("ignoring primary output name longer than 63 bytes from kde_output_order_v1", .{}); } } }, @@ -662,13 +646,10 @@ pub const Window = struct { const window = apprt_window.as(gtk.Window); const config = if (apprt_window.getConfig()) |v| v.get() else return; - const resolved_monitor = switch (config.@"quick-terminal-screen") { - .mouse => null, - .main, .@"macos-menu-bar" => App.resolveQuickTerminalMonitor( - apprt_window.winproto().wayland.app_context, - apprt_window, - ), - }; + const resolved_monitor = App.resolveQuickTerminalMonitor( + apprt_window.winproto().wayland.app_context, + apprt_window, + ); defer if (resolved_monitor) |v| v.unref(); // Use the configured monitor for sizing if not in mouse mode. From 19feaa058b333a559697fa21f7d600db0f2386fc Mon Sep 17 00:00:00 2001 From: Jake Guthmiller Date: Sun, 1 Mar 2026 15:21:03 -0600 Subject: [PATCH 007/391] gtk: improve readability of Wayland quick-terminal monitor code Flatten resolveQuickTerminalMonitor by replacing the labeled-block switch with early returns, extract max_output_name_len constant, and reduce nesting in the output-order event handler. Co-Authored-By: Claude Opus 4.6 --- src/apprt/gtk/winproto/wayland.zig | 114 +++++++++++++++-------------- 1 file changed, 59 insertions(+), 55 deletions(-) diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 2624cd721..206221e26 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -26,6 +26,8 @@ pub const App = struct { context: *Context, const Context = struct { + const max_output_name_len = 63; + kde_blur_manager: ?*org.KdeKwinBlurManager = null, // FIXME: replace with `zxdg_decoration_v1` once GTK merges @@ -39,7 +41,7 @@ pub const App = struct { /// Connector name of the primary output (e.g., "DP-1") as reported /// by kde_output_order_v1. The first output in each priority list /// is the primary. - primary_output_name: ?[63:0]u8 = null, + primary_output_name: ?[max_output_name_len:0]u8 = null, /// Used to avoid repeatedly logging the same primary-name mismatch /// when we can't map the compositor connector name to a GDK monitor. @@ -167,48 +169,48 @@ pub const App = struct { apprt_window: *ApprtWindow, ) ?*gdk.Monitor { const config = if (apprt_window.getConfig()) |v| v.get() else return null; + + switch (config.@"quick-terminal-screen") { + .mouse => return null, + .main, .@"macos-menu-bar" => {}, + } + const display = apprt_window.as(gtk.Widget).getDisplay(); + const monitors = display.getMonitors(); - return switch (config.@"quick-terminal-screen") { - .mouse => null, - .main, .@"macos-menu-bar" => blk: { - const monitors = display.getMonitors(); - - // Try to find the monitor matching the primary output name. - if (context.primary_output_name) |*stored_name| { - const name = std.mem.sliceTo(stored_name, 0); - var i: u32 = 0; - while (monitors.getObject(i)) |item| : (i += 1) { - const monitor = gobject.ext.cast(gdk.Monitor, item) orelse { - item.unref(); - continue; - }; - if (monitor.getConnector()) |connector_z| { - const connector = std.mem.sliceTo(connector_z, 0); - if (std.mem.eql(u8, connector, name)) { - context.primary_output_match_failed_logged = false; - break :blk monitor; - } - } - monitor.unref(); - } - - if (!context.primary_output_match_failed_logged) { - context.primary_output_match_failed_logged = true; - log.debug( - "could not match primary output connector to a GDK monitor; falling back to first monitor", - .{}, - ); + // Try to find the monitor matching the primary output name. + if (context.primary_output_name) |*stored_name| { + const name = std.mem.sliceTo(stored_name, 0); + var i: u32 = 0; + while (monitors.getObject(i)) |item| : (i += 1) { + const monitor = gobject.ext.cast(gdk.Monitor, item) orelse { + item.unref(); + continue; + }; + if (monitor.getConnector()) |connector_z| { + const connector = std.mem.sliceTo(connector_z, 0); + if (std.mem.eql(u8, connector, name)) { + context.primary_output_match_failed_logged = false; + return monitor; } } + monitor.unref(); + } - // Fall back to the first monitor in the list. - const first = monitors.getObject(0) orelse break :blk null; - break :blk gobject.ext.cast(gdk.Monitor, first) orelse { - first.unref(); - break :blk null; - }; - }, + if (!context.primary_output_match_failed_logged) { + context.primary_output_match_failed_logged = true; + log.debug( + "could not match primary output connector to a GDK monitor; falling back to first monitor", + .{}, + ); + } + } + + // Fall back to the first monitor in the list. + const first = monitors.getObject(0) orelse return null; + return gobject.ext.cast(gdk.Monitor, first) orelse { + first.unref(); + return null; }; } @@ -344,23 +346,25 @@ pub const App = struct { ) void { switch (event) { .output => |v| { - if (context.output_order_done) { - context.output_order_done = false; - context.output_order_seen_output = true; - context.primary_output_match_failed_logged = false; - const name = std.mem.sliceTo(v.output_name, 0); - if (name.len == 0) { - context.primary_output_name = null; - log.warn("ignoring empty primary output name from kde_output_order_v1", .{}); - } else if (name.len <= 63) { - var buf: [63:0]u8 = @splat(0); - @memcpy(buf[0..name.len], name); - context.primary_output_name = buf; - log.debug("primary output: {s}", .{name}); - } else { - context.primary_output_name = null; - log.warn("ignoring primary output name longer than 63 bytes from kde_output_order_v1", .{}); - } + // Only the first output event after a `done` is the new primary. + if (!context.output_order_done) return; + context.output_order_done = false; + context.output_order_seen_output = true; + // A new primary invalidates any cached match-failure state. + context.primary_output_match_failed_logged = false; + + const name = std.mem.sliceTo(v.output_name, 0); + if (name.len == 0) { + context.primary_output_name = null; + log.warn("ignoring empty primary output name from kde_output_order_v1", .{}); + } else if (name.len <= Context.max_output_name_len) { + var buf: [Context.max_output_name_len:0]u8 = @splat(0); + @memcpy(buf[0..name.len], name); + context.primary_output_name = buf; + log.debug("primary output: {s}", .{name}); + } else { + context.primary_output_name = null; + log.warn("ignoring primary output name longer than {} bytes from kde_output_order_v1", .{Context.max_output_name_len}); } }, .done => { From c9822535436c60587d136129a7c5beb44829d81b Mon Sep 17 00:00:00 2001 From: Jake Guthmiller Date: Sun, 1 Mar 2026 16:12:19 -0600 Subject: [PATCH 008/391] gtk: handle replacement Wayland globals before remove Track registry global names for kde decoration manager and kde_output_order bindings so we can distinguish same-global duplicates from valid replacements announced before global_remove. On global_remove, match and clear these bindings by registry global name to avoid dropping a replacement when the old global is removed. --- src/apprt/gtk/winproto/wayland.zig | 107 +++++++++++++++++++++-------- 1 file changed, 77 insertions(+), 30 deletions(-) diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 206221e26..3dcbd89da 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -33,10 +33,12 @@ pub const App = struct { // FIXME: replace with `zxdg_decoration_v1` once GTK merges // https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 kde_decoration_manager: ?*org.KdeKwinServerDecorationManager = null, + kde_decoration_manager_global_name: ?u32 = null, kde_slide_manager: ?*org.KdeKwinSlideManager = null, kde_output_order: ?*kde.OutputOrderV1 = null, + kde_output_order_global_name: ?u32 = null, /// Connector name of the primary output (e.g., "DP-1") as reported /// by kde_output_order_v1. The first output in each priority list @@ -229,6 +231,18 @@ pub const App = struct { return T; } + /// Returns the Context field that stores the registry global name for + /// protocols that support replacement, or null for simple protocols. + fn getGlobalNameField(comptime field_name: []const u8) ?[]const u8 { + if (std.mem.eql(u8, field_name, "kde_decoration_manager")) { + return "kde_decoration_manager_global_name"; + } + if (std.mem.eql(u8, field_name, "kde_output_order")) { + return "kde_output_order_global_name"; + } + return null; + } + fn registryListener( registry: *wl.Registry, event: wl.Registry.Event, @@ -253,20 +267,34 @@ pub const App = struct { inline for (ctx_fields) |field| { const T = getInterfaceType(field) orelse continue; - - if (std.mem.orderZ( - u8, - v.interface, - T.interface.name, - ) == .eq) { + if (std.mem.orderZ(u8, v.interface, T.interface.name) == .eq) { log.debug("matched {}", .{T}); - if (@field(context, field.name) != null) { - log.warn( - "duplicate global for {s}; keeping existing binding", - .{v.interface}, - ); - break; + const existing_global = @field(context, field.name); + const global_name_field = comptime getGlobalNameField(field.name); + const existing_global_name: ?u32 = if (global_name_field) |name_field| + @field(context, name_field) + else + null; + + // Already bound: skip duplicate, allow replacement for + // protocols tracked by registry global name. + if (existing_global != null) { + if (global_name_field != null) { + if (existing_global_name != null and existing_global_name.? == v.name) { + log.debug( + "duplicate global for {s} with name={}; keeping existing binding", + .{ v.interface, v.name }, + ); + break; + } + } else { + log.warn( + "duplicate global for {s}; keeping existing binding", + .{v.interface}, + ); + break; + } } const global = registry.bind( @@ -280,20 +308,28 @@ pub const App = struct { ); return; }; + + if (existing_global) |old| { + log.debug( + "replacement global for {s}; switching old_name={} to new_name={}", + .{ v.interface, existing_global_name orelse 0, v.name }, + ); + old.destroy(); + } + @field(context, field.name) = global; + if (global_name_field) |name_field| { + @field(context, name_field) = v.name; + } // Install listeners immediately at bind time. This // keeps listener setup and object lifetime in one // place and also supports globals that appear later. if (comptime std.mem.eql(u8, field.name, "kde_decoration_manager")) { - const deco_manager: *org.KdeKwinServerDecorationManager = - @field(context, field.name).?; - deco_manager.setListener(*Context, decoManagerListener, context); + global.setListener(*Context, decoManagerListener, context); } if (comptime std.mem.eql(u8, field.name, "kde_output_order")) { - const output_order: *kde.OutputOrderV1 = - @field(context, field.name).?; - output_order.setListener(*Context, outputOrderListener, context); + global.setListener(*Context, outputOrderListener, context); } break; } @@ -306,20 +342,31 @@ pub const App = struct { .global_remove => |v| remove: { inline for (ctx_fields) |field| { if (getInterfaceType(field) == null) continue; - if (@field(context, field.name)) |global| { - if (global.getId() == v.name) { - global.destroy(); - @field(context, field.name) = null; - // Reset cached primary-output state if the protocol - // providing it disappears. - if (comptime std.mem.eql(u8, field.name, "kde_output_order")) { - context.primary_output_name = null; - context.primary_output_match_failed_logged = false; - context.output_order_done = true; - context.output_order_seen_output = false; + const global_name_field = comptime getGlobalNameField(field.name); + if (global_name_field) |name_field| { + if (@field(context, name_field)) |stored_name| { + if (stored_name == v.name) { + if (@field(context, field.name)) |global| global.destroy(); + @field(context, field.name) = null; + @field(context, name_field) = null; + + if (comptime std.mem.eql(u8, field.name, "kde_output_order")) { + context.primary_output_name = null; + context.primary_output_match_failed_logged = false; + context.output_order_done = true; + context.output_order_seen_output = false; + } + break :remove; + } + } + } else { + if (@field(context, field.name)) |global| { + if (global.getId() == v.name) { + global.destroy(); + @field(context, field.name) = null; + break :remove; } - break :remove; } } } From 18fa161222916c537fb5e71e6d7bbe2479805fb1 Mon Sep 17 00:00:00 2001 From: Jake Guthmiller Date: Sun, 1 Mar 2026 17:50:57 -0600 Subject: [PATCH 009/391] gtk: simplify Wayland output-order state handling --- src/apprt/gtk/winproto/wayland.zig | 34 +++++++++++++++++------------- src/build/SharedDeps.zig | 12 ++++------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 3dcbd89da..158774149 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -54,10 +54,6 @@ pub const App = struct { /// Initialized to true so the first event after binding is captured. output_order_done: bool = true, - /// True if we've received an `output` event in the current cycle. - /// This lets us detect empty cycles and clear stale cached state. - output_order_seen_output: bool = false, - default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null, xdg_activation: ?*xdg.ActivationV1 = null, @@ -243,6 +239,13 @@ pub const App = struct { return null; } + /// Reset cached state derived from kde_output_order_v1. + fn resetOutputOrderState(context: *Context) void { + context.primary_output_name = null; + context.primary_output_match_failed_logged = false; + context.output_order_done = true; + } + fn registryListener( registry: *wl.Registry, event: wl.Registry.Event, @@ -315,6 +318,12 @@ pub const App = struct { .{ v.interface, existing_global_name orelse 0, v.name }, ); old.destroy(); + + if (comptime std.mem.eql(u8, field.name, "kde_output_order")) { + // Replacement means the previous primary may be stale + // until the new object sends a fresh cycle. + resetOutputOrderState(context); + } } @field(context, field.name) = global; @@ -352,10 +361,7 @@ pub const App = struct { @field(context, name_field) = null; if (comptime std.mem.eql(u8, field.name, "kde_output_order")) { - context.primary_output_name = null; - context.primary_output_match_failed_logged = false; - context.output_order_done = true; - context.output_order_seen_output = false; + resetOutputOrderState(context); } break :remove; } @@ -396,7 +402,6 @@ pub const App = struct { // Only the first output event after a `done` is the new primary. if (!context.output_order_done) return; context.output_order_done = false; - context.output_order_seen_output = true; // A new primary invalidates any cached match-failure state. context.primary_output_match_failed_logged = false; @@ -415,14 +420,13 @@ pub const App = struct { } }, .done => { - // An empty update means the compositor currently reports no - // outputs in priority order, so drop any stale cached primary. - if (!context.output_order_seen_output) { - context.primary_output_name = null; - context.primary_output_match_failed_logged = false; + if (context.output_order_done) { + // No output arrived since the previous done. Treat this as + // an empty update and drop any stale cached primary. + resetOutputOrderState(context); + return; } context.output_order_done = true; - context.output_order_seen_output = false; }, } } diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index bd922c591..4b1ea936d 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -661,14 +661,10 @@ fn addGtkNg( .optimize = optimize, })) |gtk4_layer_shell| { const layer_shell_module = gtk4_layer_shell.module("gtk4-layer-shell"); - if (gobject_) |gobject| layer_shell_module.addImport( - "gtk", - gobject.module("gtk4"), - ); - if (gobject_) |gobject| layer_shell_module.addImport( - "gdk", - gobject.module("gdk4"), - ); + if (gobject_) |gobject| { + layer_shell_module.addImport("gtk", gobject.module("gtk4")); + layer_shell_module.addImport("gdk", gobject.module("gdk4")); + } step.root_module.addImport( "gtk4-layer-shell", layer_shell_module, From beeb810c04d0b7c93cd74d215dd194eb03759cdb Mon Sep 17 00:00:00 2001 From: Jake Guthmiller Date: Mon, 2 Mar 2026 23:33:19 -0600 Subject: [PATCH 010/391] gtk: address PR review feedback for quick-terminal-screen --- pkg/gtk4-layer-shell/src/main.zig | 2 +- src/apprt/gtk/winproto/wayland.zig | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/gtk4-layer-shell/src/main.zig b/pkg/gtk4-layer-shell/src/main.zig index e61ce3508..a15313231 100644 --- a/pkg/gtk4-layer-shell/src/main.zig +++ b/pkg/gtk4-layer-shell/src/main.zig @@ -63,7 +63,7 @@ pub fn setKeyboardMode(window: *gtk.Window, mode: KeyboardMode) void { } pub fn setMonitor(window: *gtk.Window, monitor: ?*gdk.Monitor) void { - c.gtk_layer_set_monitor(@ptrCast(window), if (monitor) |m| @ptrCast(m) else null); + c.gtk_layer_set_monitor(@ptrCast(window), @ptrCast(monitor)); } pub fn setNamespace(window: *gtk.Window, name: [:0]const u8) void { diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 158774149..d2b0b33db 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -178,7 +178,6 @@ pub const App = struct { // Try to find the monitor matching the primary output name. if (context.primary_output_name) |*stored_name| { - const name = std.mem.sliceTo(stored_name, 0); var i: u32 = 0; while (monitors.getObject(i)) |item| : (i += 1) { const monitor = gobject.ext.cast(gdk.Monitor, item) orelse { @@ -186,8 +185,7 @@ pub const App = struct { continue; }; if (monitor.getConnector()) |connector_z| { - const connector = std.mem.sliceTo(connector_z, 0); - if (std.mem.eql(u8, connector, name)) { + if (std.mem.orderZ(u8, connector_z, stored_name) == .eq) { context.primary_output_match_failed_logged = false; return monitor; } @@ -282,6 +280,11 @@ pub const App = struct { // Already bound: skip duplicate, allow replacement for // protocols tracked by registry global name. + // Compositors may re-advertise globals at runtime + // (e.g. when a display server component restarts). + // For protocols with a stored global name we detect + // replacement (different name) vs harmless duplicate + // (same name); simple protocols just keep the first. if (existing_global != null) { if (global_name_field != null) { if (existing_global_name != null and existing_global_name.? == v.name) { From 2fe55152ca6fe74219f129ee6339b265a41d0252 Mon Sep 17 00:00:00 2001 From: Anh Thang Bui Date: Fri, 26 Sep 2025 00:12:54 +0700 Subject: [PATCH 011/391] i18n: add Vietnamese translation --- CODEOWNERS | 1 + po/vi.po | 319 ++++++++++++++++++++++++++++++++++++++++ src/os/i18n_locales.zig | 1 + 3 files changed, 321 insertions(+) create mode 100644 po/vi.po diff --git a/CODEOWNERS b/CODEOWNERS index f377a73c6..1fbcf109a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -188,6 +188,7 @@ /po/ru.po @ghostty-org/ru_RU /po/tr.po @ghostty-org/tr_TR /po/uk.po @ghostty-org/uk_UA +/po/vi.po @ghostty-org/vi_VN /po/zh_CN.po @ghostty-org/zh_CN /po/zh_TW.po @ghostty-org/zh_TW diff --git a/po/vi.po b/po/vi.po new file mode 100644 index 000000000..03b61d1e7 --- /dev/null +++ b/po/vi.po @@ -0,0 +1,319 @@ +# Vietnamese 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. +# Anh Thang , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: com.mitchellh.ghostty\n" +"Report-Msgid-Bugs-To: m@mitchellh.com\n" +"POT-Creation-Date: 2025-07-22 17:18+0000\n" +"PO-Revision-Date: 2025-09-25 23:52+0700\n" +"Last-Translator: Anh Thang \n" +"Language-Team: Vietnamese \n" +"Language: vi\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:5 +msgid "Change Terminal Title" +msgstr "Đổi Tiêu đề Terminal" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6 +msgid "Leave blank to restore the default title." +msgstr "Để trống để khôi phục tiêu đề mặc định." + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9 +#: 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 "Hủy" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:10 +msgid "OK" +msgstr "Đồng ý" + +#: 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 "Lỗi Cấu hình" + +#: 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 "" +"Đã tìm thấy một hoặc nhiều lỗi cấu hình. Vui lòng xem lại các lỗi bên dưới, " +"và chọn tải lại cấu hình hoặc bỏ qua các lỗi này." + +#: 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 "Bỏ qua" + +#: 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 "Tải lại Cấu hình" + +#: 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 "Chia Lên trên" + +#: 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 "Chia Xuống dưới" + +#: 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 "Chia Trái" + +#: 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 "Chia Phải" + +#: src/apprt/gtk/ui/1.5/command-palette.blp:16 +msgid "Execute a command…" +msgstr "Thực thi một lệnh…" + +#: 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 "Sao chép" + +#: 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.2/ccw-paste.blp:11 +msgid "Paste" +msgstr "Dán" + +#: 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 "Xóa" + +#: 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 "Thiết lập lại" + +#: 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 "Chia" + +#: 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 "Đổi Tiêu đề…" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:59 +msgid "Tab" +msgstr "Thẻ" + +#: 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:265 +msgid "New Tab" +msgstr "Thẻ Mới" + +#: 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 "Đóng Thẻ" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:73 +msgid "Window" +msgstr "Cửa sổ" + +#: 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 "Cửa sổ Mới" + +#: 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 "Đóng Cửa sổ" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:89 +msgid "Config" +msgstr "Cấu hình" + +#: 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 "Mở Cấu hình" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 +msgid "Command Palette" +msgstr "Bảng lệnh" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 +msgid "Terminal Inspector" +msgstr "Trình kiểm tra Terminal" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 +#: src/apprt/gtk/Window.zig:1038 +msgid "About Ghostty" +msgstr "Giới thiệu về Ghostty" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:112 +msgid "Quit" +msgstr "Thoát" + +#: 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 "Cho phép Truy cập Bảng tạm" + +#: 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." +msgstr "" +"Một ứng dụng đang cố gắng đọc từ bảng tạm. Nội dung bảng tạm hiện tại " +"được hiển thị bên dưới." + +#: 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 "Từ chối" + +#: 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 "Cho phép" + +#: 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 "Nhớ lựa chọn cho lần chia này" + +#: 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 "Tải lại cấu hình để hiển thị lời nhắc này lần nữa" + +#: 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 "" +"Một ứng dụng đang cố gắng ghi vào bảng tạm. Nội dung bảng tạm hiện tại " +"được hiển thị bên dưới." + +#: 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 "Cảnh báo: Dán có thể không an toàn" + +#: 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 "" +"Việc dán văn bản này vào terminal có thể nguy hiểm vì có vẻ như " +"một số lệnh có thể được thực thi." + +#: src/apprt/gtk/CloseDialog.zig:47 src/apprt/gtk/Surface.zig:2531 +msgid "Close" +msgstr "Đóng" + +#: src/apprt/gtk/CloseDialog.zig:87 +msgid "Quit Ghostty?" +msgstr "Thoát Ghostty?" + +#: src/apprt/gtk/CloseDialog.zig:88 +msgid "Close Window?" +msgstr "Đóng Cửa sổ?" + +#: src/apprt/gtk/CloseDialog.zig:89 +msgid "Close Tab?" +msgstr "Đóng Thẻ?" + +#: src/apprt/gtk/CloseDialog.zig:90 +msgid "Close Split?" +msgstr "Đóng Lần chia này?" + +#: src/apprt/gtk/CloseDialog.zig:96 +msgid "All terminal sessions will be terminated." +msgstr "Tất cả các phiên terminal sẽ bị chấm dứt." + +#: src/apprt/gtk/CloseDialog.zig:97 +msgid "All terminal sessions in this window will be terminated." +msgstr "Tất cả các phiên terminal trong cửa sổ này sẽ bị chấm dứt." + +#: src/apprt/gtk/CloseDialog.zig:98 +msgid "All terminal sessions in this tab will be terminated." +msgstr "Tất cả các phiên terminal trong tab này sẽ bị chấm dứt." + +#: src/apprt/gtk/CloseDialog.zig:99 +msgid "The currently running process in this split will be terminated." +msgstr "Tiến trình đang chạy trong lần chia này sẽ bị chấm dứt." + +#: src/apprt/gtk/Surface.zig:1266 +msgid "Copied to clipboard" +msgstr "Đã sao chép vào bảng tạm" + +#: src/apprt/gtk/Surface.zig:1268 +msgid "Cleared clipboard" +msgstr "Đã xóa bảng tạm" + +#: src/apprt/gtk/Surface.zig:2525 +msgid "Command succeeded" +msgstr "Lệnh đã thành công" + +#: src/apprt/gtk/Surface.zig:2527 +msgid "Command failed" +msgstr "Lệnh đã thất bại" + +#: src/apprt/gtk/Window.zig:216 +msgid "Main Menu" +msgstr "Menu chính" + +#: src/apprt/gtk/Window.zig:239 +msgid "View Open Tabs" +msgstr "Xem các Thẻ đang mở" + +#: src/apprt/gtk/Window.zig:266 +msgid "New Split" +msgstr "Chia Mới" + +#: src/apprt/gtk/Window.zig:329 +msgid "" +"⚠️ You're running a debug build of Ghostty! Performance will be degraded." +msgstr "" +"⚠️ Bạn đang chạy phiên bản gỡ lỗi của Ghostty! Hiệu suất sẽ bị giảm." + +#: src/apprt/gtk/Window.zig:775 +msgid "Reloaded the configuration" +msgstr "Đã tải lại cấu hình" + +#: src/apprt/gtk/Window.zig:1019 +msgid "Ghostty Developers" +msgstr "Nhà phát triển Ghostty" + +#: src/apprt/gtk/inspector.zig:144 +msgid "Ghostty: Terminal Inspector" +msgstr "Ghostty: Trình kiểm tra Terminal" diff --git a/src/os/i18n_locales.zig b/src/os/i18n_locales.zig index 7a7daf998..aa39c6593 100644 --- a/src/os/i18n_locales.zig +++ b/src/os/i18n_locales.zig @@ -54,4 +54,5 @@ pub const locales = [_][:0]const u8{ "hr", "lt", "lv", + "vi", }; From 4d30d886c636305875748b84ec06d489af669921 Mon Sep 17 00:00:00 2001 From: Anh Thang Bui Date: Wed, 4 Mar 2026 09:39:48 +0700 Subject: [PATCH 012/391] update translation --- po/vi.po | 436 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 235 insertions(+), 201 deletions(-) diff --git a/po/vi.po b/po/vi.po index 03b61d1e7..04bc5d8ed 100644 --- a/po/vi.po +++ b/po/vi.po @@ -1,14 +1,14 @@ # Vietnamese translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 "Mitchell Hashimoto, Ghostty contributors" +# Copyright (C) 2026 "Mitchell Hashimoto, Ghostty contributors" # This file is distributed under the same license as the com.mitchellh.ghostty package. -# Anh Thang , 2025. +# Anh Thang , 2026. # msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2025-07-22 17:18+0000\n" -"PO-Revision-Date: 2025-09-25 23:52+0700\n" +"POT-Creation-Date: 2026-02-17 23:16+0100\n" +"PO-Revision-Date: 2026-03-04 09:32+0700\n" "Last-Translator: Anh Thang \n" "Language-Team: Vietnamese \n" "Language: vi\n" @@ -17,303 +17,337 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:5 -msgid "Change Terminal Title" -msgstr "Đổi Tiêu đề Terminal" +#: dist/linux/ghostty_nautilus.py:53 +msgid "Open in Ghostty" +msgstr "Mở Ghostty" -#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6 -msgid "Leave blank to restore the default title." -msgstr "Để trống để khôi phục tiêu đề mặc định." +#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197 +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:201 +msgid "Authorize Clipboard Access" +msgstr "Cho phép Truy cập Bảng tạm" -#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9 -#: 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 +#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:17 +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:17 +msgid "Deny" +msgstr "Từ chối" + +#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:18 +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:18 +msgid "Allow" +msgstr "Cho phép" + +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:92 +msgid "Remember choice for this split" +msgstr "Ghi nhớ lựa chọn cho chia màn hình này" + +#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:93 +msgid "Reload configuration to show this prompt again" +msgstr "Tải lại cấu hình để hiển thị lại thông báo này" + +#: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 +#: src/apprt/gtk/ui/1.5/title-dialog.blp:8 msgid "Cancel" msgstr "Hủy" -#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:10 -msgid "OK" -msgstr "Đồng ý" +#: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8 +#: src/apprt/gtk/ui/1.2/search-overlay.blp:85 +#: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17 +msgid "Close" +msgstr "Đóng" -#: 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 "Lỗi Cấu hình" - -#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:6 #: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6 +msgid "Configuration Errors" +msgstr "Lỗi cấu hình" + +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." msgstr "" -"Đã tìm thấy một hoặc nhiều lỗi cấu hình. Vui lòng xem lại các lỗi bên dưới, " -"và chọn tải lại cấu hình hoặc bỏ qua các lỗi này." +"Phát hiện một hoặc nhiều lỗi cấu hình. Vui lòng xem xét các lỗi bên dưới, " +"sau đó tải lại cấu hình hoặc bỏ qua các lỗi này." -#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9 -#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:9 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 msgid "Ignore" msgstr "Bỏ qua" -#: 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 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 +#: src/apprt/gtk/ui/1.2/surface.blp:366 src/apprt/gtk/ui/1.5/window.blp:300 msgid "Reload Configuration" -msgstr "Tải lại Cấu hình" +msgstr "Tải lại cấu hình" -#: 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 "Chia Lên trên" +#: src/apprt/gtk/ui/1.2/debug-warning.blp:7 +#: src/apprt/gtk/ui/1.3/debug-warning.blp:6 +msgid "" +"⚠️ You're running a debug build of Ghostty! Performance will be degraded." +msgstr "" +"⚠️ Bạn đang chạy bản build thử nghiệm (debug) của Ghostty! Hiệu năng sẽ bị giảm." -#: 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 "Chia Xuống dưới" +#: src/apprt/gtk/ui/1.5/inspector-window.blp:5 +msgid "Ghostty: Terminal Inspector" +msgstr "Ghostty: Bộ kiểm tra Terminal" -#: 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 "Chia Trái" +#: src/apprt/gtk/ui/1.2/search-overlay.blp:29 +msgid "Find…" +msgstr "Tìm kiếm…" -#: 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 "Chia Phải" +#: src/apprt/gtk/ui/1.2/search-overlay.blp:64 +msgid "Previous Match" +msgstr "Kết quả trước" -#: src/apprt/gtk/ui/1.5/command-palette.blp:16 -msgid "Execute a command…" -msgstr "Thực thi một lệnh…" +#: src/apprt/gtk/ui/1.2/search-overlay.blp:74 +msgid "Next Match" +msgstr "Kết quả tiếp theo" -#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 +#: src/apprt/gtk/ui/1.2/surface.blp:6 +msgid "Oh, no." +msgstr "Ôi hỏng rồi." + +#: src/apprt/gtk/ui/1.2/surface.blp:7 +msgid "Unable to acquire an OpenGL context for rendering." +msgstr "Không thể lấy ngữ cảnh OpenGL để kết xuất đồ họa." + +#: src/apprt/gtk/ui/1.2/surface.blp:97 +msgid "" +"This terminal is in read-only mode. You can still view, select, and scroll " +"through the content, but no input events will be sent to the running " +"application." +msgstr "" +"Terminal này đang ở chế độ chỉ đọc. Bạn vẫn có thể xem, chọn và cuộn " +"nội dung, nhưng các sự kiện nhập liệu sẽ không được gửi đến ứng dụng." + +#: src/apprt/gtk/ui/1.2/surface.blp:107 +msgid "Read-only" +msgstr "Chỉ đọc" + +#: src/apprt/gtk/ui/1.2/surface.blp:260 src/apprt/gtk/ui/1.5/window.blp:200 msgid "Copy" msgstr "Sao chép" -#: 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.2/ccw-paste.blp:11 +#: src/apprt/gtk/ui/1.2/surface.blp:265 src/apprt/gtk/ui/1.5/window.blp:205 msgid "Paste" msgstr "Dán" -#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:18 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:73 +#: src/apprt/gtk/ui/1.2/surface.blp:270 +msgid "Notify on Next Command Finish" +msgstr "Thông báo khi lệnh tiếp theo kết thúc" + +#: src/apprt/gtk/ui/1.2/surface.blp:277 src/apprt/gtk/ui/1.5/window.blp:273 msgid "Clear" msgstr "Xóa" -#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:23 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:78 +#: src/apprt/gtk/ui/1.2/surface.blp:282 src/apprt/gtk/ui/1.5/window.blp:278 msgid "Reset" -msgstr "Thiết lập lại" +msgstr "Đặt lại" -#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:30 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:42 +#: src/apprt/gtk/ui/1.2/surface.blp:289 src/apprt/gtk/ui/1.5/window.blp:242 msgid "Split" -msgstr "Chia" +msgstr "Chia màn hình" -#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:33 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:45 +#: src/apprt/gtk/ui/1.2/surface.blp:292 src/apprt/gtk/ui/1.5/window.blp:245 msgid "Change Title…" -msgstr "Đổi Tiêu đề…" +msgstr "Đổi tiêu đề…" -#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:59 +#: src/apprt/gtk/ui/1.2/surface.blp:297 src/apprt/gtk/ui/1.5/window.blp:177 +#: src/apprt/gtk/ui/1.5/window.blp:250 +msgid "Split Up" +msgstr "Chia lên trên" + +#: src/apprt/gtk/ui/1.2/surface.blp:303 src/apprt/gtk/ui/1.5/window.blp:182 +#: src/apprt/gtk/ui/1.5/window.blp:255 +msgid "Split Down" +msgstr "Chia xuống dưới" + +#: src/apprt/gtk/ui/1.2/surface.blp:309 src/apprt/gtk/ui/1.5/window.blp:187 +#: src/apprt/gtk/ui/1.5/window.blp:260 +msgid "Split Left" +msgstr "Chia sang trái" + +#: src/apprt/gtk/ui/1.2/surface.blp:315 src/apprt/gtk/ui/1.5/window.blp:192 +#: src/apprt/gtk/ui/1.5/window.blp:265 +msgid "Split Right" +msgstr "Chia sang phải" + +#: src/apprt/gtk/ui/1.2/surface.blp:322 msgid "Tab" -msgstr "Thẻ" +msgstr "Tab" -#: 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:265 +#: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 +#: src/apprt/gtk/ui/1.5/window.blp:320 +msgid "Change Tab Title…" +msgstr "Đổi tiêu đề Tab…" + +#: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 +#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 msgid "New Tab" -msgstr "Thẻ Mới" +msgstr "Tab mới" -#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:67 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:35 +#: src/apprt/gtk/ui/1.2/surface.blp:335 src/apprt/gtk/ui/1.5/window.blp:234 msgid "Close Tab" -msgstr "Đóng Thẻ" +msgstr "Đóng Tab" -#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:73 +#: src/apprt/gtk/ui/1.2/surface.blp:342 msgid "Window" msgstr "Cửa sổ" -#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:76 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:18 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:212 msgid "New Window" -msgstr "Cửa sổ Mới" +msgstr "Cửa sổ mới" -#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:81 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:23 +#: src/apprt/gtk/ui/1.2/surface.blp:350 src/apprt/gtk/ui/1.5/window.blp:217 msgid "Close Window" -msgstr "Đóng Cửa sổ" +msgstr "Đóng cửa sổ" -#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:89 +#: src/apprt/gtk/ui/1.2/surface.blp:358 msgid "Config" msgstr "Cấu hình" -#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:92 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95 +#: src/apprt/gtk/ui/1.2/surface.blp:361 src/apprt/gtk/ui/1.5/window.blp:295 msgid "Open Configuration" -msgstr "Mở Cấu hình" +msgstr "Mở tệp cấu hình" -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 +#: src/apprt/gtk/ui/1.5/title-dialog.blp:5 +msgid "Leave blank to restore the default title." +msgstr "Để trống để khôi phục tiêu đề mặc định." + +#: src/apprt/gtk/ui/1.5/title-dialog.blp:9 +msgid "OK" +msgstr "Đồng ý" + +#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108 +msgid "New Split" +msgstr "Chia màn hình mới" + +#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126 +msgid "View Open Tabs" +msgstr "Xem các Tab đang mở" + +#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140 +msgid "Main Menu" +msgstr "Trình đơn chính" + +#: src/apprt/gtk/ui/1.5/window.blp:285 msgid "Command Palette" msgstr "Bảng lệnh" -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 +#: src/apprt/gtk/ui/1.5/window.blp:290 msgid "Terminal Inspector" -msgstr "Trình kiểm tra Terminal" +msgstr "Bộ kiểm tra Terminal" -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 -#: src/apprt/gtk/Window.zig:1038 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1727 msgid "About Ghostty" -msgstr "Giới thiệu về Ghostty" +msgstr "Về Ghostty" -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:112 +#: src/apprt/gtk/ui/1.5/window.blp:312 msgid "Quit" msgstr "Thoát" -#: 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 "Cho phép Truy cập Bảng tạm" +#: src/apprt/gtk/ui/1.5/command-palette.blp:17 +msgid "Execute a command…" +msgstr "Chạy một lệnh…" -#: 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." -msgstr "" -"Một ứng dụng đang cố gắng đọc từ bảng tạm. Nội dung bảng tạm hiện tại " -"được hiển thị bên dưới." - -#: 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 "Từ chối" - -#: 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 "Cho phép" - -#: 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 "Nhớ lựa chọn cho lần chia này" - -#: 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 "Tải lại cấu hình để hiển thị lời nhắc này lần nữa" - -#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 -#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7 +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198 msgid "" "An application is attempting to write to the clipboard. The current " "clipboard contents are shown below." msgstr "" -"Một ứng dụng đang cố gắng ghi vào bảng tạm. Nội dung bảng tạm hiện tại " -"được hiển thị bên dưới." +"Một ứng dụng đang cố gắng ghi vào bảng tạm. Nội dung hiện tại của " +"bảng tạm được hiển thị bên dưới." -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 src/apprt/gtk/ui/1.2/ccw-paste.blp:6 +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:202 +msgid "" +"An application is attempting to read from the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Một ứng dụng đang cố gắng đọc từ bảng tạm. Nội dung hiện tại của " +"bảng tạm được hiển thị bên dưới." + +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:205 msgid "Warning: Potentially Unsafe Paste" -msgstr "Cảnh báo: Dán có thể không an toàn" +msgstr "Cảnh báo: Thao tác Dán có thể không an toàn" -#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 src/apprt/gtk/ui/1.2/ccw-paste.blp:7 +#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:206 msgid "" "Pasting this text into the terminal may be dangerous as it looks like some " "commands may be executed." msgstr "" -"Việc dán văn bản này vào terminal có thể nguy hiểm vì có vẻ như " -"một số lệnh có thể được thực thi." +"Dán văn bản này vào terminal có thể nguy hiểm vì có vẻ như một số " +"lệnh sẽ bị thực thi." -#: src/apprt/gtk/CloseDialog.zig:47 src/apprt/gtk/Surface.zig:2531 -msgid "Close" -msgstr "Đóng" - -#: src/apprt/gtk/CloseDialog.zig:87 +#: src/apprt/gtk/class/close_confirmation_dialog.zig:184 msgid "Quit Ghostty?" msgstr "Thoát Ghostty?" -#: src/apprt/gtk/CloseDialog.zig:88 -msgid "Close Window?" -msgstr "Đóng Cửa sổ?" - -#: src/apprt/gtk/CloseDialog.zig:89 +#: src/apprt/gtk/class/close_confirmation_dialog.zig:185 msgid "Close Tab?" -msgstr "Đóng Thẻ?" +msgstr "Đóng Tab?" -#: src/apprt/gtk/CloseDialog.zig:90 +#: src/apprt/gtk/class/close_confirmation_dialog.zig:186 +msgid "Close Window?" +msgstr "Đóng cửa sổ?" + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:187 msgid "Close Split?" -msgstr "Đóng Lần chia này?" +msgstr "Đóng phần chia màn hình?" -#: src/apprt/gtk/CloseDialog.zig:96 +#: src/apprt/gtk/class/close_confirmation_dialog.zig:193 msgid "All terminal sessions will be terminated." -msgstr "Tất cả các phiên terminal sẽ bị chấm dứt." +msgstr "Tất cả các phiên làm việc terminal sẽ bị chấm dứt." -#: src/apprt/gtk/CloseDialog.zig:97 -msgid "All terminal sessions in this window will be terminated." -msgstr "Tất cả các phiên terminal trong cửa sổ này sẽ bị chấm dứt." - -#: src/apprt/gtk/CloseDialog.zig:98 +#: src/apprt/gtk/class/close_confirmation_dialog.zig:194 msgid "All terminal sessions in this tab will be terminated." -msgstr "Tất cả các phiên terminal trong tab này sẽ bị chấm dứt." +msgstr "Tất cả các phiên làm việc terminal trong tab này sẽ bị chấm dứt." -#: src/apprt/gtk/CloseDialog.zig:99 +#: src/apprt/gtk/class/close_confirmation_dialog.zig:195 +msgid "All terminal sessions in this window will be terminated." +msgstr "Tất cả các phiên làm việc terminal trong cửa sổ này sẽ bị chấm dứt." + +#: src/apprt/gtk/class/close_confirmation_dialog.zig:196 msgid "The currently running process in this split will be terminated." -msgstr "Tiến trình đang chạy trong lần chia này sẽ bị chấm dứt." +msgstr "Tiến trình đang chạy trong phần chia này sẽ bị chấm dứt." -#: src/apprt/gtk/Surface.zig:1266 -msgid "Copied to clipboard" -msgstr "Đã sao chép vào bảng tạm" +#: src/apprt/gtk/class/surface.zig:1108 +msgid "Command Finished" +msgstr "Lệnh đã kết thúc" -#: src/apprt/gtk/Surface.zig:1268 -msgid "Cleared clipboard" -msgstr "Đã xóa bảng tạm" +#: src/apprt/gtk/class/surface.zig:1109 +msgid "Command Succeeded" +msgstr "Lệnh thành công" -#: src/apprt/gtk/Surface.zig:2525 +#: src/apprt/gtk/class/surface.zig:1110 +msgid "Command Failed" +msgstr "Lệnh thất bại" + +#: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command succeeded" -msgstr "Lệnh đã thành công" +msgstr "Lệnh thành công" -#: src/apprt/gtk/Surface.zig:2527 +#: src/apprt/gtk/class/surface_child_exited.zig:113 msgid "Command failed" -msgstr "Lệnh đã thất bại" +msgstr "Lệnh thất bại" -#: src/apprt/gtk/Window.zig:216 -msgid "Main Menu" -msgstr "Menu chính" +#: src/apprt/gtk/class/title_dialog.zig:225 +msgid "Change Terminal Title" +msgstr "Đổi tiêu đề Terminal" -#: src/apprt/gtk/Window.zig:239 -msgid "View Open Tabs" -msgstr "Xem các Thẻ đang mở" +#: src/apprt/gtk/class/title_dialog.zig:226 +msgid "Change Tab Title" +msgstr "Đổi tiêu đề Tab" -#: src/apprt/gtk/Window.zig:266 -msgid "New Split" -msgstr "Chia Mới" - -#: src/apprt/gtk/Window.zig:329 -msgid "" -"⚠️ You're running a debug build of Ghostty! Performance will be degraded." -msgstr "" -"⚠️ Bạn đang chạy phiên bản gỡ lỗi của Ghostty! Hiệu suất sẽ bị giảm." - -#: src/apprt/gtk/Window.zig:775 +#: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" msgstr "Đã tải lại cấu hình" -#: src/apprt/gtk/Window.zig:1019 -msgid "Ghostty Developers" -msgstr "Nhà phát triển Ghostty" +#: src/apprt/gtk/class/window.zig:1566 +msgid "Copied to clipboard" +msgstr "Đã sao chép vào bảng tạm" -#: src/apprt/gtk/inspector.zig:144 -msgid "Ghostty: Terminal Inspector" -msgstr "Ghostty: Trình kiểm tra Terminal" +#: src/apprt/gtk/class/window.zig:1568 +msgid "Cleared clipboard" +msgstr "Đã xóa sạch bảng tạm" + +#: src/apprt/gtk/class/window.zig:1708 +msgid "Ghostty Developers" +msgstr "Các nhà phát triển Ghostty" From b823c07ae30635b4641d45db0f06f6f416756b94 Mon Sep 17 00:00:00 2001 From: Jake Guthmiller Date: Tue, 3 Mar 2026 20:56:24 -0600 Subject: [PATCH 013/391] PR feedback - simplify --- src/apprt/gtk/winproto/wayland.zig | 60 ++---------------------------- 1 file changed, 4 insertions(+), 56 deletions(-) diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index d2b0b33db..b5c9f6a52 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -45,10 +45,6 @@ pub const App = struct { /// is the primary. primary_output_name: ?[max_output_name_len:0]u8 = null, - /// Used to avoid repeatedly logging the same primary-name mismatch - /// when we can't map the compositor connector name to a GDK monitor. - primary_output_match_failed_logged: bool = false, - /// Tracks the output order event cycle. Set to true after a `done` /// event so the next `output` event is captured as the new primary. /// Initialized to true so the first event after binding is captured. @@ -186,20 +182,11 @@ pub const App = struct { }; if (monitor.getConnector()) |connector_z| { if (std.mem.orderZ(u8, connector_z, stored_name) == .eq) { - context.primary_output_match_failed_logged = false; return monitor; } } monitor.unref(); } - - if (!context.primary_output_match_failed_logged) { - context.primary_output_match_failed_logged = true; - log.debug( - "could not match primary output connector to a GDK monitor; falling back to first monitor", - .{}, - ); - } } // Fall back to the first monitor in the list. @@ -240,7 +227,6 @@ pub const App = struct { /// Reset cached state derived from kde_output_order_v1. fn resetOutputOrderState(context: *Context) void { context.primary_output_name = null; - context.primary_output_match_failed_logged = false; context.output_order_done = true; } @@ -271,38 +257,6 @@ pub const App = struct { if (std.mem.orderZ(u8, v.interface, T.interface.name) == .eq) { log.debug("matched {}", .{T}); - const existing_global = @field(context, field.name); - const global_name_field = comptime getGlobalNameField(field.name); - const existing_global_name: ?u32 = if (global_name_field) |name_field| - @field(context, name_field) - else - null; - - // Already bound: skip duplicate, allow replacement for - // protocols tracked by registry global name. - // Compositors may re-advertise globals at runtime - // (e.g. when a display server component restarts). - // For protocols with a stored global name we detect - // replacement (different name) vs harmless duplicate - // (same name); simple protocols just keep the first. - if (existing_global != null) { - if (global_name_field != null) { - if (existing_global_name != null and existing_global_name.? == v.name) { - log.debug( - "duplicate global for {s} with name={}; keeping existing binding", - .{ v.interface, v.name }, - ); - break; - } - } else { - log.warn( - "duplicate global for {s}; keeping existing binding", - .{v.interface}, - ); - break; - } - } - const global = registry.bind( v.name, T, @@ -315,22 +269,18 @@ pub const App = struct { return; }; - if (existing_global) |old| { - log.debug( - "replacement global for {s}; switching old_name={} to new_name={}", - .{ v.interface, existing_global_name orelse 0, v.name }, - ); + // Destroy old binding if this global was re-advertised. + // Bind first so a failed bind preserves the old binding. + if (@field(context, field.name)) |old| { old.destroy(); if (comptime std.mem.eql(u8, field.name, "kde_output_order")) { - // Replacement means the previous primary may be stale - // until the new object sends a fresh cycle. resetOutputOrderState(context); } } @field(context, field.name) = global; - if (global_name_field) |name_field| { + if (comptime getGlobalNameField(field.name)) |name_field| { @field(context, name_field) = v.name; } @@ -405,8 +355,6 @@ pub const App = struct { // Only the first output event after a `done` is the new primary. if (!context.output_order_done) return; context.output_order_done = false; - // A new primary invalidates any cached match-failure state. - context.primary_output_match_failed_logged = false; const name = std.mem.sliceTo(v.output_name, 0); if (name.len == 0) { From bec4c61d4dbbd1ad667e7cdcafaf15d0836c3143 Mon Sep 17 00:00:00 2001 From: Jake Guthmiller Date: Tue, 3 Mar 2026 21:28:02 -0600 Subject: [PATCH 014/391] PR feedback: heap-allocate primary_output_name --- src/apprt/gtk/winproto/wayland.zig | 32 ++++++++++++++++++------------ 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index b5c9f6a52..a4678f4e4 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -26,7 +26,7 @@ pub const App = struct { context: *Context, const Context = struct { - const max_output_name_len = 63; + alloc: Allocator, kde_blur_manager: ?*org.KdeKwinBlurManager = null, @@ -43,7 +43,7 @@ pub const App = struct { /// Connector name of the primary output (e.g., "DP-1") as reported /// by kde_output_order_v1. The first output in each priority list /// is the primary. - primary_output_name: ?[max_output_name_len:0]u8 = null, + primary_output_name: ?[:0]const u8 = null, /// Tracks the output order event cycle. Set to true after a `done` /// event so the next `output` event is captured as the new primary. @@ -91,8 +91,11 @@ pub const App = struct { // a stable pointer, but it's too scary that we'd need one in the future // and not have it and corrupt memory or something so let's just do it. const context = try alloc.create(Context); - errdefer alloc.destroy(context); - context.* = .{}; + errdefer { + if (context.primary_output_name) |name| alloc.free(name); + alloc.destroy(context); + } + context.* = .{ .alloc = alloc }; // Get our display registry so we can get all the available interfaces // and bind to what we need. @@ -114,6 +117,7 @@ pub const App = struct { } pub fn deinit(self: *App, alloc: Allocator) void { + if (self.context.primary_output_name) |name| alloc.free(name); alloc.destroy(self.context); } @@ -173,7 +177,7 @@ pub const App = struct { const monitors = display.getMonitors(); // Try to find the monitor matching the primary output name. - if (context.primary_output_name) |*stored_name| { + if (context.primary_output_name) |stored_name| { var i: u32 = 0; while (monitors.getObject(i)) |item| : (i += 1) { const monitor = gobject.ext.cast(gdk.Monitor, item) orelse { @@ -201,7 +205,7 @@ pub const App = struct { // Globals should be optional pointers const T = switch (@typeInfo(field.type)) { .optional => |o| switch (@typeInfo(o.child)) { - .pointer => |v| v.child, + .pointer => |v| if (v.size == .one) v.child else return null, else => return null, }, else => return null, @@ -226,6 +230,7 @@ pub const App = struct { /// Reset cached state derived from kde_output_order_v1. fn resetOutputOrderState(context: *Context) void { + if (context.primary_output_name) |name| context.alloc.free(name); context.primary_output_name = null; context.output_order_done = true; } @@ -357,17 +362,18 @@ pub const App = struct { context.output_order_done = false; const name = std.mem.sliceTo(v.output_name, 0); + if (context.primary_output_name) |old| context.alloc.free(old); + if (name.len == 0) { context.primary_output_name = null; log.warn("ignoring empty primary output name from kde_output_order_v1", .{}); - } else if (name.len <= Context.max_output_name_len) { - var buf: [Context.max_output_name_len:0]u8 = @splat(0); - @memcpy(buf[0..name.len], name); - context.primary_output_name = buf; - log.debug("primary output: {s}", .{name}); } else { - context.primary_output_name = null; - log.warn("ignoring primary output name longer than {} bytes from kde_output_order_v1", .{Context.max_output_name_len}); + context.primary_output_name = context.alloc.dupeZ(u8, name) catch |err| { + context.primary_output_name = null; + log.warn("failed to allocate primary output name: {}", .{err}); + return; + }; + log.debug("primary output: {s}", .{name}); } }, .done => { From e07aefa6010716ccc51c745b8e0dde9598b58812 Mon Sep 17 00:00:00 2001 From: Michielvk <16121929+Michielvk@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:22:29 +0100 Subject: [PATCH 015/391] fix: zsh shell integration when `sudo` and `ssh` aliases are defined --- src/shell-integration/zsh/ghostty-integration | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 4d872d025..b02c745f2 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -249,7 +249,7 @@ _ghostty_deferred_init() { # Sudo if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* ]] && [[ -n "$TERMINFO" ]]; then # Wrap `sudo` command to ensure Ghostty terminfo is preserved - sudo() { + function sudo() { builtin local sudo_has_sudoedit_flags="no" for arg in "$@"; do # Check if argument is '-e' or '--edit' (sudoedit flags) @@ -272,7 +272,7 @@ _ghostty_deferred_init() { # SSH Integration if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then - ssh() { + function ssh() { emulate -L zsh setopt local_options no_glob_subst From dfa968d932ecb6928ebab9a9d460ae0ac629f985 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 03:07:05 +0000 Subject: [PATCH 016/391] Update VOUCHED list (#11176) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/11175#issuecomment-4001807388) from @jcollie. Vouch: @douglas Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 54684edf9..e10175b3c 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -54,6 +54,7 @@ dervedro diaaeddin doprz douglance +douglas drepper elias8 ephemera From a5327a51f3fedea890f59ad75e7666a57bb743c4 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 04:34:36 +0000 Subject: [PATCH 017/391] Update VOUCHED list (#11179) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/11164#discussioncomment-16005149) from @mitchellh. Vouch: @Michielvk Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index e10175b3c..14e16df11 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -99,6 +99,7 @@ markdorison markhuot marrocco-simone matkotiric +michielvk miguelelgallo mihi314 mikailmm From 961bf46884dc7f75a4bfd8640bf7f57baed6b540 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 4 Mar 2026 22:35:58 -0600 Subject: [PATCH 018/391] Fix Windows test in src/Command.zig This was introduced in #10611. This doesn't fix all of the current Windows build problems, but at least fixes one that I introduced. --- src/Command.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Command.zig b/src/Command.zig index 3a40143b9..2b381912b 100644 --- a/src/Command.zig +++ b/src/Command.zig @@ -725,6 +725,11 @@ test "Command: redirect stdout to file" { .path = "C:\\Windows\\System32\\whoami.exe", .args = &.{"C:\\Windows\\System32\\whoami.exe"}, .stdout = stdout, + .os_pre_exec = null, + .rt_pre_exec = null, + .rt_post_fork = null, + .rt_pre_exec_info = undefined, + .rt_post_fork_info = undefined, } else .{ .path = "/bin/sh", .args = &.{ "/bin/sh", "-c", "echo hello" }, From 3dde6e2559e0aa67e04a6001485d87b80ed4c1dd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Mar 2026 20:37:16 -0800 Subject: [PATCH 019/391] terminal: bound link regex search work with Oniguruma retry limits Fixes #11177 Use per-search Oniguruma match params (retry_limit_in_search) in StringMap-backed link detection to avoid pathological backtracking hangs on very long lines. The units are ticks in the internal loop so its kind of opaque but this seems to still match some very long URLs. The test case in question was a 169K character line (which is now rejected). --- pkg/oniguruma/main.zig | 2 + pkg/oniguruma/match_param.zig | 23 ++++++++++ pkg/oniguruma/regex.zig | 79 ++++++++++++++++++++++++++++++----- src/terminal/StringMap.zig | 25 ++++++++++- 4 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 pkg/oniguruma/match_param.zig diff --git a/pkg/oniguruma/main.zig b/pkg/oniguruma/main.zig index a8e415cfb..2541cc358 100644 --- a/pkg/oniguruma/main.zig +++ b/pkg/oniguruma/main.zig @@ -1,4 +1,5 @@ const initpkg = @import("init.zig"); +const match_param = @import("match_param.zig"); const regex = @import("regex.zig"); const region = @import("region.zig"); const types = @import("types.zig"); @@ -10,6 +11,7 @@ pub const errors = @import("errors.zig"); pub const init = initpkg.init; pub const deinit = initpkg.deinit; pub const Encoding = types.Encoding; +pub const MatchParam = match_param.MatchParam; pub const Regex = regex.Regex; pub const Region = region.Region; pub const Syntax = types.Syntax; diff --git a/pkg/oniguruma/match_param.zig b/pkg/oniguruma/match_param.zig new file mode 100644 index 000000000..b28258ff0 --- /dev/null +++ b/pkg/oniguruma/match_param.zig @@ -0,0 +1,23 @@ +const c = @import("c.zig").c; +const errors = @import("errors.zig"); +const Error = errors.Error; + +pub const MatchParam = struct { + value: *c.OnigMatchParam, + + pub fn init() !MatchParam { + const value = c.onig_new_match_param() orelse return Error.Memory; + return .{ .value = value }; + } + + pub fn deinit(self: *MatchParam) void { + c.onig_free_match_param(self.value); + } + + pub fn setRetryLimitInSearch(self: *MatchParam, limit: usize) !void { + _ = try errors.convertError(c.onig_set_retry_limit_in_search_of_match_param( + self.value, + @intCast(limit), + )); + } +}; diff --git a/pkg/oniguruma/regex.zig b/pkg/oniguruma/regex.zig index a73c7fc10..fd920e01a 100644 --- a/pkg/oniguruma/regex.zig +++ b/pkg/oniguruma/regex.zig @@ -3,6 +3,7 @@ const c = @import("c.zig").c; const types = @import("types.zig"); const errors = @import("errors.zig"); const testEnsureInit = @import("testing.zig").ensureInit; +const MatchParam = @import("match_param.zig").MatchParam; const Region = @import("region.zig").Region; const Error = errors.Error; const ErrorInfo = errors.ErrorInfo; @@ -43,6 +44,17 @@ pub const Regex = struct { self: *Regex, str: []const u8, options: Option, + ) !Region { + return self.searchWithParam(str, options, null); + } + + /// Search an entire string for matches. This always returns a region + /// which may heap allocate (C allocator). + pub fn searchWithParam( + self: *Regex, + str: []const u8, + options: Option, + match_param: ?*MatchParam, ) !Region { var region: Region = .{}; @@ -51,7 +63,14 @@ pub const Regex = struct { // any errors to free that memory. errdefer region.deinit(); - _ = try self.searchAdvanced(str, 0, str.len, ®ion, options); + _ = try self.searchAdvancedWithParam( + str, + 0, + str.len, + ®ion, + options, + match_param, + ); return region; } @@ -64,15 +83,47 @@ pub const Regex = struct { region: *Region, options: Option, ) !usize { - const pos = try errors.convertError(c.onig_search( - self.value, - str.ptr, - str.ptr + str.len, - str.ptr + start, - str.ptr + end, - @ptrCast(region), - options.int(), - )); + return self.searchAdvancedWithParam( + str, + start, + end, + region, + options, + null, + ); + } + + /// onig_search_with_param directly + pub fn searchAdvancedWithParam( + self: *Regex, + str: []const u8, + start: usize, + end: usize, + region: *Region, + options: Option, + match_param: ?*MatchParam, + ) !usize { + const pos = try errors.convertError(if (match_param) |param| + c.onig_search_with_param( + self.value, + str.ptr, + str.ptr + str.len, + str.ptr + start, + str.ptr + end, + @ptrCast(region), + options.int(), + param.value, + ) + else + c.onig_search( + self.value, + str.ptr, + str.ptr + str.len, + str.ptr + start, + str.ptr + end, + @ptrCast(region), + options.int(), + )); return @intCast(pos); } @@ -90,4 +141,12 @@ test { try testing.expectEqual(@as(usize, 1), reg.count()); try testing.expectError(Error.Mismatch, re.search("hello", .{})); + + var match_param = try MatchParam.init(); + defer match_param.deinit(); + try match_param.setRetryLimitInSearch(1000); + + var reg_param = try re.searchWithParam("hello foo bar", .{}, &match_param); + defer reg_param.deinit(); + try testing.expectEqual(@as(usize, 1), reg_param.count()); } diff --git a/src/terminal/StringMap.zig b/src/terminal/StringMap.zig index f7d88d1c8..18dd7b19c 100644 --- a/src/terminal/StringMap.zig +++ b/src/terminal/StringMap.zig @@ -11,6 +11,12 @@ const Screen = @import("Screen.zig"); const Pin = @import("PageList.zig").Pin; const Allocator = std.mem.Allocator; +// Retry budget for StringMap regex searches. +// +// Units are Oniguruma retry steps (internal backtracking/retry counter), +// not bytes/characters/time. +const oni_search_retry_limit = 100_000; + string: [:0]const u8, map: []Pin, @@ -44,11 +50,26 @@ pub const SearchIterator = struct { pub fn next(self: *SearchIterator) !?Match { if (self.offset >= self.map.string.len) return null; - var region = self.regex.search( + // Use per-search match params so we can bound regex retry steps + // (Oniguruma's internal backtracking work counter). + var match_param = try oni.MatchParam.init(); + defer match_param.deinit(); + try match_param.setRetryLimitInSearch(oni_search_retry_limit); + + var region = self.regex.searchWithParam( self.map.string[self.offset..], .{}, + &match_param, ) catch |err| switch (err) { - error.Mismatch => { + // Retry/stack-limit errors mean we hit our work budget and + // aborted matching. + // For iterator callers this is equivalent to "no further matches". + error.Mismatch, + error.RetryLimitInMatchOver, + error.RetryLimitInSearchOver, + error.MatchStackLimitOver, + error.SubexpCallLimitInSearchOver, + => { self.offset = self.map.string.len; return null; }, From c920a88cdcc19ed42ab013c1ba2bb9ad41592ada Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 4 Mar 2026 23:30:58 -0600 Subject: [PATCH 020/391] GTK: add 'move' to the drop target actions Fixes #11175 --- src/apprt/gtk/ui/1.2/surface.blp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index d8483285f..55c54531c 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -221,7 +221,7 @@ Overlay terminal_page { DropTarget drop_target { drop => $drop(); - actions: copy; + actions: copy | move; } } From acf54a91668b524d9a5e6e800c34ce2d08fd4d48 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 5 Mar 2026 08:26:08 -0600 Subject: [PATCH 021/391] windows: use new callconv convention --- src/os/windows.zig | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/os/windows.zig b/src/os/windows.zig index 1853f4162..87d1b662e 100644 --- a/src/os/windows.zig +++ b/src/os/windows.zig @@ -53,22 +53,22 @@ pub const exp = struct { hWritePipe: *windows.HANDLE, lpPipeAttributes: ?*const windows.SECURITY_ATTRIBUTES, nSize: windows.DWORD, - ) callconv(windows.WINAPI) windows.BOOL; + ) callconv(.winapi) windows.BOOL; pub extern "kernel32" fn CreatePseudoConsole( size: windows.COORD, hInput: windows.HANDLE, hOutput: windows.HANDLE, dwFlags: windows.DWORD, phPC: *HPCON, - ) callconv(windows.WINAPI) windows.HRESULT; - pub extern "kernel32" fn ResizePseudoConsole(hPC: HPCON, size: windows.COORD) callconv(windows.WINAPI) windows.HRESULT; - pub extern "kernel32" fn ClosePseudoConsole(hPC: HPCON) callconv(windows.WINAPI) void; + ) callconv(.winapi) windows.HRESULT; + pub extern "kernel32" fn ResizePseudoConsole(hPC: HPCON, size: windows.COORD) callconv(.winapi) windows.HRESULT; + pub extern "kernel32" fn ClosePseudoConsole(hPC: HPCON) callconv(.winapi) void; pub extern "kernel32" fn InitializeProcThreadAttributeList( lpAttributeList: LPPROC_THREAD_ATTRIBUTE_LIST, dwAttributeCount: windows.DWORD, dwFlags: windows.DWORD, lpSize: *windows.SIZE_T, - ) callconv(windows.WINAPI) windows.BOOL; + ) callconv(.winapi) windows.BOOL; pub extern "kernel32" fn UpdateProcThreadAttribute( lpAttributeList: LPPROC_THREAD_ATTRIBUTE_LIST, dwFlags: windows.DWORD, @@ -77,7 +77,7 @@ pub const exp = struct { cbSize: windows.SIZE_T, lpPreviousValue: ?windows.PVOID, lpReturnSize: ?*windows.SIZE_T, - ) callconv(windows.WINAPI) windows.BOOL; + ) callconv(.winapi) windows.BOOL; pub extern "kernel32" fn PeekNamedPipe( hNamedPipe: windows.HANDLE, lpBuffer: ?windows.LPVOID, @@ -85,7 +85,7 @@ pub const exp = struct { lpBytesRead: ?*windows.DWORD, lpTotalBytesAvail: ?*windows.DWORD, lpBytesLeftThisMessage: ?*windows.DWORD, - ) callconv(windows.WINAPI) windows.BOOL; + ) callconv(.winapi) windows.BOOL; // Duplicated here because lpCommandLine is not marked optional in zig std pub extern "kernel32" fn CreateProcessW( lpApplicationName: ?windows.LPWSTR, @@ -98,7 +98,7 @@ pub const exp = struct { lpCurrentDirectory: ?windows.LPWSTR, lpStartupInfo: *windows.STARTUPINFOW, lpProcessInformation: *windows.PROCESS_INFORMATION, - ) callconv(windows.WINAPI) windows.BOOL; + ) callconv(.winapi) windows.BOOL; }; pub const PROC_THREAD_ATTRIBUTE_NUMBER = 0x0000FFFF; From f36b903479f54fc7202a95ca10509f3eac06e007 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:40:10 +0000 Subject: [PATCH 022/391] Update VOUCHED list (#11191) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/11190#issuecomment-4005537826) from @00-kat. Vouch: @AnthonyZhOon Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 14e16df11..ccc0d4f45 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -30,6 +30,7 @@ alexfeijoo44 alexjuca amadeus andrejdaskalov +anthonyzhoon atomk balazs-szucs bennettp123 From e1f4ee7fdd4d5fc4b6b86dd70986be75a0bacabd Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:53:00 +0000 Subject: [PATCH 023/391] Update VOUCHED list (#11192) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/11184#discussioncomment-16011801) from @mitchellh. Vouch: @mac0ne Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index ccc0d4f45..e246ba137 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -94,6 +94,7 @@ laxystem liby linustalacko lonsagisawa +mac0ne mahnokropotkinvich marijagjorgjieva markdorison From e8aad103263297d41335a27d9d1679a7ab47c08b Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 5 Mar 2026 09:26:52 -0600 Subject: [PATCH 024/391] windows: avoid the use of wcwidth --- src/benchmark/CodepointWidth.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/benchmark/CodepointWidth.zig b/src/benchmark/CodepointWidth.zig index effabb036..30d3f91e7 100644 --- a/src/benchmark/CodepointWidth.zig +++ b/src/benchmark/CodepointWidth.zig @@ -6,6 +6,7 @@ const CodepointWidth = @This(); const std = @import("std"); +const builtin = @import("builtin"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const Benchmark = @import("Benchmark.zig"); @@ -104,6 +105,11 @@ fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { extern "c" fn wcwidth(c: u32) c_int; fn stepWcwidth(ptr: *anyopaque) Benchmark.Error!void { + if (comptime builtin.os.tag == .windows) { + log.warn("wcwidth is not available on Windows", .{}); + return; + } + const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; From cccdb0d2ade79c0d3ef37635c5c9fe90a0ac14bf Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 5 Mar 2026 09:28:02 -0600 Subject: [PATCH 025/391] windows: add trivial implementation of expandHome --- src/os/homedir.zig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/os/homedir.zig b/src/os/homedir.zig index 0868a4fa5..14a4558cc 100644 --- a/src/os/homedir.zig +++ b/src/os/homedir.zig @@ -13,7 +13,7 @@ const Error = error{ /// is generally an expensive process so the value should be cached. pub inline fn home(buf: []u8) !?[]const u8 { return switch (builtin.os.tag) { - inline .linux, .freebsd, .macos => try homeUnix(buf), + .linux, .freebsd, .macos => try homeUnix(buf), .windows => try homeWindows(buf), // iOS doesn't have a user-writable home directory @@ -122,7 +122,13 @@ pub const ExpandError = error{ pub fn expandHome(path: []const u8, buf: []u8) ExpandError![]const u8 { return switch (builtin.os.tag) { .linux, .freebsd, .macos => try expandHomeUnix(path, buf), + + // `~/` is not an idiom generally used on Windows + .windows => return path, + + // iOS doesn't have a user-writable home directory .ios => return path, + else => @compileError("unimplemented"), }; } From d29e1cc1375e0a700df73604c528a550813c8b1a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 5 Mar 2026 09:29:04 -0600 Subject: [PATCH 026/391] windows: use explicit error sets to work around lack of file locking --- src/cli/ssh-cache/DiskCache.zig | 43 ++++++++++++++++++++++++++++----- src/cli/ssh-cache/Entry.zig | 4 ++- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index 6214d0429..6fa74b43d 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -57,6 +57,16 @@ pub fn clear(self: DiskCache) !void { pub const AddResult = enum { added, updated }; +pub const AddError = std.fs.Dir.MakeError || + std.fs.Dir.StatFileError || + std.fs.File.OpenError || + std.fs.File.ChmodError || + std.io.Reader.LimitedAllocError || + FixupPermissionsError || + ReadEntriesError || + WriteCacheFileError || + Error; + /// Add or update a hostname entry in the cache. /// Returns AddResult.added for new entries or AddResult.updated for existing ones. /// The cache file is created if it doesn't exist with secure permissions (0600). @@ -64,7 +74,7 @@ pub fn add( self: DiskCache, alloc: Allocator, hostname: []const u8, -) !AddResult { +) AddError!AddResult { if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; // Create cache directory if needed @@ -128,13 +138,19 @@ pub fn add( return result; } +pub const RemoveError = std.fs.File.OpenError || + FixupPermissionsError || + ReadEntriesError || + WriteCacheFileError || + Error; + /// Remove a hostname entry from the cache. /// No error is returned if the hostname doesn't exist or the cache file is missing. pub fn remove( self: DiskCache, alloc: Allocator, hostname: []const u8, -) !void { +) RemoveError!void { if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; // Open our file @@ -168,13 +184,17 @@ pub fn remove( try self.writeCacheFile(entries, null); } +pub const ContainsError = std.fs.File.OpenError || + ReadEntriesError || + error{HostnameIsInvalid}; + /// Check if a hostname exists in the cache. /// Returns false if the cache file doesn't exist. pub fn contains( self: DiskCache, alloc: Allocator, hostname: []const u8, -) !bool { +) ContainsError!bool { if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; // Open our file @@ -194,7 +214,9 @@ pub fn contains( return entries.contains(hostname); } -fn fixupPermissions(file: std.fs.File) (std.fs.File.StatError || std.fs.File.ChmodError)!void { +pub const FixupPermissionsError = (std.fs.File.StatError || std.fs.File.ChmodError); + +fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void { // Windows does not support chmod if (comptime builtin.os.tag == .windows) return; @@ -206,11 +228,18 @@ fn fixupPermissions(file: std.fs.File) (std.fs.File.StatError || std.fs.File.Chm } } +pub const WriteCacheFileError = std.fs.Dir.OpenError || + std.fs.AtomicFile.InitError || + std.fs.AtomicFile.FlushError || + std.fs.AtomicFile.FinishError || + Entry.FormatError || + error{InvalidCachePath}; + fn writeCacheFile( self: DiskCache, entries: std.StringHashMap(Entry), expire_days: ?u32, -) !void { +) WriteCacheFileError!void { const cache_dir = std.fs.path.dirname(self.path) orelse return error.InvalidCachePath; const cache_basename = std.fs.path.basename(self.path); @@ -270,10 +299,12 @@ pub fn deinitEntries( entries.deinit(); } +pub const ReadEntriesError = std.mem.Allocator.Error || std.io.Reader.LimitedAllocError; + fn readEntries( alloc: Allocator, file: std.fs.File, -) !std.StringHashMap(Entry) { +) ReadEntriesError!std.StringHashMap(Entry) { var reader = file.reader(&.{}); const content = try reader.interface.allocRemaining( alloc, diff --git a/src/cli/ssh-cache/Entry.zig b/src/cli/ssh-cache/Entry.zig index f3403dbd4..b586161f2 100644 --- a/src/cli/ssh-cache/Entry.zig +++ b/src/cli/ssh-cache/Entry.zig @@ -33,7 +33,9 @@ pub fn parse(line: []const u8) ?Entry { }; } -pub fn format(self: Entry, writer: *std.Io.Writer) !void { +pub const FormatError = std.Io.Writer.Error; + +pub fn format(self: Entry, writer: *std.Io.Writer) FormatError!void { try writer.print( "{s}|{d}|{s}\n", .{ self.hostname, self.timestamp, self.terminfo_version }, From b1d3e36e2ea0d428dd333019c8346b6d4bcbc762 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 5 Mar 2026 10:03:58 -0600 Subject: [PATCH 027/391] windows: add GetComputerNameA so that hostname-related functions work --- src/os/hostname.zig | 37 +++++++++++++++++++++++++++++++------ src/os/windows.zig | 5 +++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/os/hostname.zig b/src/os/hostname.zig index f728a2455..af9148fbf 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 LocalHostnameValidationError = error{ @@ -99,9 +100,21 @@ pub fn isLocal(hostname: []const u8) LocalHostnameValidationError!bool { if (std.mem.eql(u8, "localhost", hostname)) return true; // If hostname is not "localhost" it must match our hostname. - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const ourHostname = try posix.gethostname(&buf); - return std.mem.eql(u8, hostname, ourHostname); + switch (builtin.os.tag) { + .windows => { + const windows = @import("windows.zig"); + var buf: [256:0]u8 = undefined; + var nSize: windows.DWORD = buf.len; + if (windows.exp.kernel32.GetComputerNameA(&buf, &nSize) == 0) return false; + const ourHostname = buf[0..nSize]; + return std.mem.eql(u8, hostname, ourHostname); + }, + else => { + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const ourHostname = try posix.gethostname(&buf); + return std.mem.eql(u8, hostname, ourHostname); + }, + } } test "isLocal returns true when provided hostname is localhost" { @@ -109,9 +122,21 @@ test "isLocal returns true when provided hostname is localhost" { } test "isLocal returns true when hostname is local" { - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const localHostname = try posix.gethostname(&buf); - try std.testing.expect(try isLocal(localHostname)); + switch (builtin.os.tag) { + .windows => { + const windows = @import("windows.zig"); + var buf: [256:0]u8 = undefined; + var nSize: windows.DWORD = buf.len; + if (windows.exp.kernel32.GetComputerNameA(&buf, &nSize) == 0) return error.GetComputerNameFailed; + const localHostname = buf[0..nSize]; + try std.testing.expect(try isLocal(localHostname)); + }, + else => { + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const localHostname = try posix.gethostname(&buf); + try std.testing.expect(try isLocal(localHostname)); + }, + } } test "isLocal returns false when hostname is not local" { diff --git a/src/os/windows.zig b/src/os/windows.zig index 87d1b662e..e92a54537 100644 --- a/src/os/windows.zig +++ b/src/os/windows.zig @@ -99,6 +99,11 @@ pub const exp = struct { lpStartupInfo: *windows.STARTUPINFOW, lpProcessInformation: *windows.PROCESS_INFORMATION, ) callconv(.winapi) windows.BOOL; + /// https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getcomputernamea + pub extern "kernel32" fn GetComputerNameA( + lpBuffer: windows.LPSTR, + nSize: *windows.DWORD, + ) callconv(.winapi) windows.BOOL; }; pub const PROC_THREAD_ATTRIBUTE_NUMBER = 0x0000FFFF; From 96a5e71871dc583bdb4c04554a0ac6760e2db32a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 00:14:51 +0000 Subject: [PATCH 028/391] build(deps): bump docker/build-push-action from 6.19.2 to 7.0.0 Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.0.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/10e90e3645eae34f1e60eeb005ba3a3d33f178e8...d08e5c354a6adb9ed34480a06d141179aa583294) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2b59d2e97..cbd985558 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1380,7 +1380,7 @@ jobs: tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Build and push - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: dist file: dist/src/build/docker/debian/Dockerfile From 04aff46022f679cf607b6987031c4f4fe5273b86 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 5 Mar 2026 19:43:18 -0800 Subject: [PATCH 029/391] macos: add build script, update AGENTS.md, skip UI tests This is an update to address common agentic issues I run into, but the `build.nu` script may be generally helpful to people using the Nix env since `xcodebuild` is broken by default in Nix due to the compiler/linker overrides Nix shell does. --- .gitignore | 1 + .prettierignore | 3 +++ AGENTS.md | 6 +++++- macos/AGENTS.md | 6 ++++-- macos/build.nu | 32 ++++++++++++++++++++++++++++++++ src/build/GhosttyXcodebuild.zig | 2 ++ 6 files changed, 47 insertions(+), 3 deletions(-) create mode 100755 macos/build.nu diff --git a/.gitignore b/.gitignore index 40a04dbae..74f3f85eb 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ glad.zip /ghostty.qcow2 vgcore.* + diff --git a/.prettierignore b/.prettierignore index f40567bfa..2699f7e10 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,6 +11,9 @@ zig-out/ # macos is managed by XCode GUI macos/ +# Xcode asset catalogs +**/*.xcassets/ + # produced by Icon Composer on macOS images/Ghostty.icon/icon.json diff --git a/AGENTS.md b/AGENTS.md index c6bd79b0e..ff8c289c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,12 @@ A file for [guiding coding agents](https://agents.md/). ## Commands - **Build:** `zig build` + - If you're on macOS and don't need to build the macOS app, use + `-Demit-macos-app=false` to skip building the app bundle and speed up + compilation. - **Test (Zig):** `zig build test` + - Prefer to run targeted tests with `-Dtest-filter` because the full + test suite is slow to run. - **Test filter (Zig)**: `zig build test -Dtest-filter=` - **Formatting (Zig)**: `zig fmt .` - **Formatting (Swift)**: `swiftlint lint --fix` @@ -14,7 +19,6 @@ A file for [guiding coding agents](https://agents.md/). ## Directory Structure - Shared Zig core: `src/` -- C API: `include` - macOS app: `macos/` - GTK (Linux and FreeBSD) app: `src/apprt/gtk` diff --git a/macos/AGENTS.md b/macos/AGENTS.md index 50e91781d..929b37498 100644 --- a/macos/AGENTS.md +++ b/macos/AGENTS.md @@ -4,6 +4,8 @@ - If code outside of this directory is modified, use `zig build -Demit-macos-app=false` before building the macOS app to update the underlying Ghostty library. -- Use `xcodebuild` to build the macOS app, do not use `zig build` +- Use `build.nu` to build the macOS app, do not use `zig build` (except to build the underlying library as mentioned above). -- Run unit tests directly with `xcodebuild` + - Build: `build.nu [--scheme Ghostty] [--configuration Debug] [--action build]` + - Output: `build//Ghostty.app` (e.g. `build/Debug/Ghostty.app`) +- Run unit tests directly with `build.nu --action test` diff --git a/macos/build.nu b/macos/build.nu new file mode 100755 index 000000000..8c456d9b6 --- /dev/null +++ b/macos/build.nu @@ -0,0 +1,32 @@ +#!/usr/bin/env nu + +# Build the macOS Ghostty app using xcodebuild with a clean environment +# to avoid Nix shell interference (NIX_LDFLAGS, NIX_CFLAGS_COMPILE, etc.). + +def main [ + --scheme: string = "Ghostty" # Xcode scheme (Ghostty, Ghostty-iOS, DockTilePlugin) + --configuration: string = "Debug" # Build configuration (Debug, Release, ReleaseLocal) + --action: string = "build" # xcodebuild action (build, test, clean, etc.) +] { + let project = ($env.FILE_PWD | path join "Ghostty.xcodeproj") + let build_dir = ($env.FILE_PWD | path join "build") + + # Skip UI tests for CLI-based invocations because it requires + # special permissions. + let skip_testing = if $action == "test" { + [-skip-testing GhosttyUITests] + } else { + [] + } + + (^env -i + $"HOME=($env.HOME)" + "PATH=/usr/bin:/bin:/usr/sbin:/sbin" + xcodebuild + -project $project + -scheme $scheme + -configuration $configuration + $"SYMROOT=($build_dir)" + ...$skip_testing + $action) +} diff --git a/src/build/GhosttyXcodebuild.zig b/src/build/GhosttyXcodebuild.zig index 5ca4c5e9a..81af994ca 100644 --- a/src/build/GhosttyXcodebuild.zig +++ b/src/build/GhosttyXcodebuild.zig @@ -104,6 +104,8 @@ pub fn init( "test", "-scheme", "Ghostty", + "-skip-testing", + "GhosttyUITests", }); if (xc_arch) |arch| step.addArgs(&.{ "-arch", arch }); From 291fbf55cb9c6946d7c080c90d8163d0b720dfe0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 5 Mar 2026 14:41:27 -0800 Subject: [PATCH 030/391] macos: AppleScript starting --- macos/Ghostty.sdef | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 macos/Ghostty.sdef diff --git a/macos/Ghostty.sdef b/macos/Ghostty.sdef new file mode 100644 index 000000000..8a837dce8 --- /dev/null +++ b/macos/Ghostty.sdef @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + From c90a782e592aa90e3a1479b80d5b9a3acdc63dff Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 5 Mar 2026 14:55:47 -0800 Subject: [PATCH 031/391] macos: implement basic read-only applescript stuff --- macos/Ghostty-Info.plist | 4 + macos/Ghostty.sdef | 25 ++++++- macos/Ghostty.xcodeproj/project.pbxproj | 6 ++ .../AppleScript/AppDelegate+AppleScript.swift | 67 +++++++++++++++++ .../AppleScript/AppleScriptTerminal.swift | 73 +++++++++++++++++++ 5 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift create mode 100644 macos/Sources/Features/AppleScript/AppleScriptTerminal.swift diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist index 4896681b9..01ccd7b11 100644 --- a/macos/Ghostty-Info.plist +++ b/macos/Ghostty-Info.plist @@ -55,8 +55,12 @@ MDItemKeywords Terminal + NSAppleScriptEnabled + NSHighResolutionCapable + OSAScriptingDefinition + Ghostty.sdef NSServices diff --git a/macos/Ghostty.sdef b/macos/Ghostty.sdef index 8a837dce8..3182f6283 100644 --- a/macos/Ghostty.sdef +++ b/macos/Ghostty.sdef @@ -4,11 +4,15 @@ - + + + + - + + @@ -19,4 +23,21 @@ + + + + + + + + + + + diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 5a3e7a52e..867c52436 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; }; 819324582F24E78800A9ED8F /* DockTilePlugin.plugin in Copy DockTilePlugin */ = {isa = PBXBuildFile; fileRef = 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 819324642F24FF2100A9ED8F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; + 8F3A9B4C2FA6B88000A18D13 /* Ghostty.sdef in Resources */ = {isa = PBXBuildFile; fileRef = 8F3A9B4B2FA6B88000A18D13 /* Ghostty.sdef */; }; 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; A51BFC272B30F1B800E92F16 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A51BFC262B30F1B800E92F16 /* Sparkle */; }; A53D0C8E2B53B0EA00305CE6 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; @@ -74,6 +75,7 @@ 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = ""; }; 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DockTilePlugin.plugin; sourceTree = BUILT_PRODUCTS_DIR; }; + 8F3A9B4B2FA6B88000A18D13 /* Ghostty.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = Ghostty.sdef; sourceTree = ""; }; 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyDebug.entitlements; sourceTree = ""; }; A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = ""; }; @@ -134,6 +136,8 @@ "Features/App Intents/KeybindIntent.swift", "Features/App Intents/NewTerminalIntent.swift", "Features/App Intents/QuickTerminalIntent.swift", + "Features/AppleScript/AppDelegate+AppleScript.swift", + Features/AppleScript/AppleScriptTerminal.swift, Features/ClipboardConfirmation/ClipboardConfirmation.xib, Features/ClipboardConfirmation/ClipboardConfirmationController.swift, Features/ClipboardConfirmation/ClipboardConfirmationView.swift, @@ -322,6 +326,7 @@ isa = PBXGroup; children = ( A571AB1C2A206FC600248498 /* Ghostty-Info.plist */, + 8F3A9B4B2FA6B88000A18D13 /* Ghostty.sdef */, A5B30538299BEAAB0047F10C /* Assets.xcassets */, A553F4122E06EB1600257779 /* Ghostty.icon */, A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */, @@ -557,6 +562,7 @@ A553F4142E06EB1600257779 /* Ghostty.icon in Resources */, 29C15B1D2CDC3B2900520DD4 /* bat in Resources */, A586167C2B7703CC009BDB1D /* fish in Resources */, + 8F3A9B4C2FA6B88000A18D13 /* Ghostty.sdef in Resources */, 55154BE02B33911F001622DC /* ghostty in Resources */, A546F1142D7B68D7003B11A0 /* locale in Resources */, A5985CE62C33060F00C57AD3 /* man in Resources */, diff --git a/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift b/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift new file mode 100644 index 000000000..7bd0513c3 --- /dev/null +++ b/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift @@ -0,0 +1,67 @@ +import AppKit + +/// Application-level Cocoa scripting hooks for the Ghostty AppleScript dictionary. +/// +/// Cocoa scripting looks for specifically named Objective-C selectors derived +/// from the `sdef` file. This extension implements those required entry points +/// on `NSApplication`, which is the object behind the `application` class in +/// `Ghostty.sdef`. +@MainActor +extension NSApplication { + /// Backing collection for `application.terminals`. + /// + /// Required selector name: `terminals`. + @objc(terminals) + var terminals: [ScriptTerminal] { + allSurfaceViews.map(ScriptTerminal.init) + } + + /// Enables AppleScript unique-ID lookup for terminal references. + /// + /// Required selector name pattern for element `terminals`: + /// `valueInTerminalsWithUniqueID:`. + /// + /// This is what lets scripts do stable references like + /// `terminal id "..."` even as windows/tabs change. + @objc(valueInTerminalsWithUniqueID:) + func valueInTerminals(uniqueID: String) -> ScriptTerminal? { + allSurfaceViews + .first(where: { $0.id.uuidString == uniqueID }) + .map(ScriptTerminal.init) + } + + /// Handler for the `perform action` AppleScript command. + /// + /// Required selector name from the command in `sdef`: + /// `handlePerformActionScriptCommand:`. + /// + /// Cocoa scripting parses script syntax and provides: + /// - `directParameter`: the command string (`perform action "..."`). + /// - `evaluatedArguments["on"]`: the target terminal (`... on terminal ...`). + /// + /// We return a Bool to match the command's declared result type. + @objc(handlePerformActionScriptCommand:) + func handlePerformActionScriptCommand(_ command: NSScriptCommand) -> Any? { + guard let action = command.directParameter as? String else { + command.scriptErrorNumber = errAEParamMissed + command.scriptErrorString = "Missing action string." + return nil + } + + guard let terminal = command.evaluatedArguments?["on"] as? ScriptTerminal else { + command.scriptErrorNumber = errAEParamMissed + command.scriptErrorString = "Missing terminal target." + return nil + } + + return terminal.perform(action: action) + } + + /// Discovers all currently alive terminal surfaces across normal and quick + /// terminal windows. This powers both terminal enumeration and ID lookup. + private var allSurfaceViews: [Ghostty.SurfaceView] { + NSApp.windows + .compactMap { $0.windowController as? BaseTerminalController } + .flatMap { $0.surfaceTree.root?.leaves() ?? [] } + } +} diff --git a/macos/Sources/Features/AppleScript/AppleScriptTerminal.swift b/macos/Sources/Features/AppleScript/AppleScriptTerminal.swift new file mode 100644 index 000000000..3f6603d0e --- /dev/null +++ b/macos/Sources/Features/AppleScript/AppleScriptTerminal.swift @@ -0,0 +1,73 @@ +import AppKit + +/// AppleScript-facing wrapper around a live Ghostty terminal surface. +/// +/// This class is intentionally ObjC-visible because Cocoa scripting resolves +/// AppleScript objects through Objective-C runtime names/selectors, not Swift +/// protocol conformance. +/// +/// Mapping from `Ghostty.sdef`: +/// - `class terminal` -> this class (`@objc(GhosttyAppleScriptTerminal)`). +/// - `property id` -> `@objc(id)` getter below. +/// - `property title` -> `@objc(title)` getter below. +/// - `property working directory` -> `@objc(workingDirectory)` getter below. +/// +/// We keep only a weak reference to the underlying `SurfaceView` so this +/// wrapper never extends the terminal's lifetime. +@MainActor +@objc(GhosttyScriptTerminal) +final class ScriptTerminal: NSObject { + private weak var surfaceView: Ghostty.SurfaceView? + + init(surfaceView: Ghostty.SurfaceView) { + self.surfaceView = surfaceView + } + + /// Exposed as the AppleScript `id` property. + /// + /// This is a stable UUID string for the life of a surface and is also used + /// by `NSUniqueIDSpecifier` to re-identify a terminal object in scripts. + @objc(id) + var stableID: String { + surfaceView?.id.uuidString ?? "" + } + + /// Exposed as the AppleScript `title` property. + @objc(title) + var title: String { + surfaceView?.title ?? "" + } + + /// Exposed as the AppleScript `working directory` property. + /// + /// The `sdef` uses a spaced name, but Cocoa scripting maps that to the + /// camel-cased selector name `workingDirectory`. + @objc(workingDirectory) + var workingDirectory: String { + surfaceView?.pwd ?? "" + } + + /// Used by command handling (`perform action ... on `). + func perform(action: String) -> Bool { + guard let surfaceModel = surfaceView?.surfaceModel else { return false } + return surfaceModel.perform(action: action) + } + + /// Provides Cocoa scripting with a canonical "path" back to this object. + /// + /// Without an object specifier, returned terminal objects can't be reliably + /// referenced in follow-up script statements because AppleScript cannot + /// express where the object came from (`application.terminals[id]`). + override var objectSpecifier: NSScriptObjectSpecifier? { + guard let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else { + return nil + } + + return NSUniqueIDSpecifier( + containerClassDescription: appClassDescription, + containerSpecifier: nil, + key: "terminals", + uniqueID: stableID + ) + } +} From 52c0709d88c20f05edc1450d5e7105377f03206d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 5 Mar 2026 20:15:21 -0800 Subject: [PATCH 032/391] macos: add ability for agents to run debug app --- macos/AGENTS.md | 14 ++++++++++++++ macos/Ghostty.sdef | 1 + .../AppleScript/AppDelegate+AppleScript.swift | 2 +- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/macos/AGENTS.md b/macos/AGENTS.md index 929b37498..8fe34a0df 100644 --- a/macos/AGENTS.md +++ b/macos/AGENTS.md @@ -9,3 +9,17 @@ - Build: `build.nu [--scheme Ghostty] [--configuration Debug] [--action build]` - Output: `build//Ghostty.app` (e.g. `build/Debug/Ghostty.app`) - Run unit tests directly with `build.nu --action test` +## AppleScript + +- The AppleScript scripting definition is in `Ghostty.sdef`. +- Test AppleScript support: + (1) Build with `build.nu` + (2) Launch and activate the app via osascript using the absolute path + to the built app bundle: + `osascript -e 'tell application "" to activate'` + (3) Wait a few seconds for the app to fully launch and open a terminal. + (4) Run test scripts with `osascript`, always targeting the app by + its absolute path (not by name) to avoid calling the wrong + application. + (5) When done, quit via: + `osascript -e 'tell application "" to quit'` diff --git a/macos/Ghostty.sdef b/macos/Ghostty.sdef index 3182f6283..647fac3db 100644 --- a/macos/Ghostty.sdef +++ b/macos/Ghostty.sdef @@ -5,6 +5,7 @@ + diff --git a/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift b/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift index 7bd0513c3..267863712 100644 --- a/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift +++ b/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift @@ -54,7 +54,7 @@ extension NSApplication { return nil } - return terminal.perform(action: action) + return NSNumber(value: terminal.perform(action: action)) } /// Discovers all currently alive terminal surfaces across normal and quick From 40c74811f16236df9afeb522dcbd9a98ebcb4a3f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 5 Mar 2026 20:32:49 -0800 Subject: [PATCH 033/391] macos: fix perform action --- macos/Ghostty.sdef | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/macos/Ghostty.sdef b/macos/Ghostty.sdef index 647fac3db..9ed37f764 100644 --- a/macos/Ghostty.sdef +++ b/macos/Ghostty.sdef @@ -5,7 +5,9 @@ - + + + From ef669eeae7574335631d6897c07f60ce4015727c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 5 Mar 2026 20:46:52 -0800 Subject: [PATCH 034/391] macos: add AppleScript `split` command Add a new `split` command to the AppleScript scripting dictionary that splits a terminal in a given direction (right, left, down, up) and returns the newly created terminal. The command is exposed as: split terminal direction Also adds a `fourCharCode` String extension for converting four-character ASCII strings to their FourCharCode (UInt32) representation. --- macos/Ghostty.sdef | 19 +++++ macos/Ghostty.xcodeproj/project.pbxproj | 3 +- .../AppleScript/ScriptSplitCommand.swift | 74 +++++++++++++++++++ ...iptTerminal.swift => ScriptTerminal.swift} | 5 +- .../Helpers/Extensions/String+Extension.swift | 9 +++ 5 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 macos/Sources/Features/AppleScript/ScriptSplitCommand.swift rename macos/Sources/Features/AppleScript/{AppleScriptTerminal.swift => ScriptTerminal.swift} (90%) diff --git a/macos/Ghostty.sdef b/macos/Ghostty.sdef index 9ed37f764..962e1329a 100644 --- a/macos/Ghostty.sdef +++ b/macos/Ghostty.sdef @@ -8,6 +8,7 @@ + @@ -25,6 +26,24 @@ + + + + + + + + + + + + + + + + + + + + https://ghostty.org/docs/install/release-notes/1-3-0 + https://ghostty.org/docs/install/release-notes/1-0-1 diff --git a/nix/package.nix b/nix/package.nix index 1efef4164..391c9da05 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -30,7 +30,7 @@ in stdenv.mkDerivation (finalAttrs: { pname = "ghostty"; - version = "1.3.0-dev"; + version = "1.3.0"; # We limit source like this to try and reduce the amount of rebuilds as possible # thus we only provide the source that is needed for the build From f8a0a45963010e5cb3baa8069dbcc07a60c5d26d Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:20:32 +0000 Subject: [PATCH 083/391] Update VOUCHED list (#11275) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/11274#discussioncomment-16057271) from @jcollie. Vouch: @seruman Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 765c9346b..c767a0ec5 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -143,6 +143,7 @@ rmunn rockorager rpfaeffle secrus +seruman silveirapf slsrepo sunshine-syz From f8f431ba67e32b7fa0d63c54bc736d55cf27532f Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 9 Mar 2026 16:47:07 -0500 Subject: [PATCH 084/391] docs: update bell-features docs for macOS PR #11154 didn't fully update the docs regarding `bell-features=audio` on macOS. --- src/config/Config.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 591c0b049..527b0d329 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3047,7 +3047,7 @@ keybind: Keybinds = .{}, /// /// * `audio` /// -/// Play a custom sound. (GTK only) +/// Play a custom sound. (Available since 1.3.0 on macOS) /// /// * `attention` *(enabled by default)* /// @@ -3089,14 +3089,14 @@ keybind: Keybinds = .{}, /// directory if this is used as a CLI flag. The path may be prefixed with `~/` /// to reference the user's home directory. /// -/// Available since: 1.2.0 +/// Available since: 1.2.0 on GTK, 1.3.0 on macOS. @"bell-audio-path": ?Path = null, /// If `audio` is an enabled bell feature, this is the volume to play the audio /// file at (relative to the system volume). This is a floating point number /// ranging from 0.0 (silence) to 1.0 (as loud as possible). The default is 0.5. /// -/// Available since: 1.2.0 +/// Available since: 1.2.0 on GTK, 1.3.0 on macOS. @"bell-audio-volume": f64 = 0.5, /// Control the in-app notifications that Ghostty shows. From 96f9772cd838fa9d562ed369ea6fa8e657f870e3 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 9 Mar 2026 19:59:21 -0500 Subject: [PATCH 085/391] tests: disable tests that fail if you have locally installed fonts If you have "Noto Sans Tai Tham" and/or "Noto Sans Javanese" installed locally on Linux, three tests fail. This PR disables those tests until a more permanent solution can be found. --- src/font/shaper/harfbuzz.zig | 295 ++++++++++++++++++----------------- 1 file changed, 149 insertions(+), 146 deletions(-) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index b1126dd4e..10e5f99b1 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1079,66 +1079,67 @@ test "shape Devanagari string" { } test "shape Tai Tham vowels (position differs from advance)" { - // Note that while this test was necessary for CoreText, the old logic was - // working for HarfBuzz. Still we keep it to ensure it has the correct - // behavior. - const testing = std.testing; - const alloc = testing.allocator; + return error.SkipZigTest; + // // Note that while this test was necessary for CoreText, the old logic was + // // working for HarfBuzz. Still we keep it to ensure it has the correct + // // behavior. + // const testing = std.testing; + // const alloc = testing.allocator; - // We need a font that supports Tai Tham for this to work, if we can't find - // Noto Sans Tai Tham, which is a system font on macOS, we just skip the - // test. - var testdata = testShaperWithDiscoveredFont( - alloc, - "Noto Sans Tai Tham", - ) catch return error.SkipZigTest; - defer testdata.deinit(); + // // We need a font that supports Tai Tham for this to work, if we can't find + // // Noto Sans Tai Tham, which is a system font on macOS, we just skip the + // // test. + // var testdata = testShaperWithDiscoveredFont( + // alloc, + // "Noto Sans Tai Tham", + // ) catch return error.SkipZigTest; + // defer testdata.deinit(); - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; - buf_idx += try std.unicode.utf8Encode(0x1a2F, buf[buf_idx..]); // ᨯ - buf_idx += try std.unicode.utf8Encode(0x1a70, buf[buf_idx..]); // ᩰ + // var buf: [32]u8 = undefined; + // var buf_idx: usize = 0; + // buf_idx += try std.unicode.utf8Encode(0x1a2F, buf[buf_idx..]); // ᨯ + // buf_idx += try std.unicode.utf8Encode(0x1a70, buf[buf_idx..]); // ᩰ - // Make a screen with some data - var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); - defer t.deinit(alloc); + // // Make a screen with some data + // var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + // defer t.deinit(alloc); - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); + // // Enable grapheme clustering + // t.modes.set(.grapheme_cluster, true); - var s = t.vtStream(); - defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + // var s = t.vtStream(); + // defer s.deinit(); + // try s.nextSlice(buf[0..buf_idx]); - var state: terminal.RenderState = .empty; - defer state.deinit(alloc); - try state.update(alloc, &t); + // var state: terminal.RenderState = .empty; + // defer state.deinit(alloc); + // try state.update(alloc, &t); - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(.{ - .grid = testdata.grid, - .cells = state.row_data.get(0).cells.slice(), - }); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; + // // Get our run iterator + // var shaper = &testdata.shaper; + // var it = shaper.runIterator(.{ + // .grid = testdata.grid, + // .cells = state.row_data.get(0).cells.slice(), + // }); + // var count: usize = 0; + // while (try it.next(alloc)) |run| { + // count += 1; - const cells = try shaper.shape(run); - try testing.expectEqual(@as(usize, 2), cells.len); - try testing.expectEqual(@as(u16, 0), cells[0].x); - try testing.expectEqual(@as(u16, 0), cells[1].x); + // const cells = try shaper.shape(run); + // try testing.expectEqual(@as(usize, 2), cells.len); + // try testing.expectEqual(@as(u16, 0), cells[0].x); + // try testing.expectEqual(@as(u16, 0), cells[1].x); - // The first glyph renders in the next cell. We expect the x_offset - // to equal the cell width. However, with FreeType the cell_width is - // computed from ASCII glyphs, and Noto Sans Tai Tham only has the - // space character in ASCII (with a 3px advance), so the cell_width - // metric doesn't match the actual Tai Tham glyph positioning. - const expected_x_offset: i16 = if (comptime font.options.backend.hasFreetype()) 7 else @intCast(run.grid.metrics.cell_width); - try testing.expectEqual(expected_x_offset, cells[0].x_offset); - try testing.expectEqual(@as(i16, 0), cells[1].x_offset); - } - try testing.expectEqual(@as(usize, 1), count); + // // The first glyph renders in the next cell. We expect the x_offset + // // to equal the cell width. However, with FreeType the cell_width is + // // computed from ASCII glyphs, and Noto Sans Tai Tham only has the + // // space character in ASCII (with a 3px advance), so the cell_width + // // metric doesn't match the actual Tai Tham glyph positioning. + // const expected_x_offset: i16 = if (comptime font.options.backend.hasFreetype()) 7 else @intCast(run.grid.metrics.cell_width); + // try testing.expectEqual(expected_x_offset, cells[0].x_offset); + // try testing.expectEqual(@as(i16, 0), cells[1].x_offset); + // } + // try testing.expectEqual(@as(usize, 1), count); } test "shape Tibetan characters" { @@ -1195,124 +1196,126 @@ test "shape Tibetan characters" { } test "shape Tai Tham letters (run_offset.y differs from zero)" { - const testing = std.testing; - const alloc = testing.allocator; + return error.SkipZigTest; + // const testing = std.testing; + // const alloc = testing.allocator; - // We need a font that supports Tai Tham for this to work, if we can't find - // Noto Sans Tai Tham, which is a system font on macOS, we just skip the - // test. - var testdata = testShaperWithDiscoveredFont( - alloc, - "Noto Sans Tai Tham", - ) catch return error.SkipZigTest; - defer testdata.deinit(); + // // We need a font that supports Tai Tham for this to work, if we can't find + // // Noto Sans Tai Tham, which is a system font on macOS, we just skip the + // // test. + // var testdata = testShaperWithDiscoveredFont( + // alloc, + // "Noto Sans Tai Tham", + // ) catch return error.SkipZigTest; + // defer testdata.deinit(); - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; + // var buf: [32]u8 = undefined; + // var buf_idx: usize = 0; - // First grapheme cluster: - buf_idx += try std.unicode.utf8Encode(0x1a49, buf[buf_idx..]); // HA - buf_idx += try std.unicode.utf8Encode(0x1a60, buf[buf_idx..]); // SAKOT - // Second grapheme cluster, combining with the first in a ligature: - buf_idx += try std.unicode.utf8Encode(0x1a3f, buf[buf_idx..]); // YA - buf_idx += try std.unicode.utf8Encode(0x1a69, buf[buf_idx..]); // U + // // First grapheme cluster: + // buf_idx += try std.unicode.utf8Encode(0x1a49, buf[buf_idx..]); // HA + // buf_idx += try std.unicode.utf8Encode(0x1a60, buf[buf_idx..]); // SAKOT + // // Second grapheme cluster, combining with the first in a ligature: + // buf_idx += try std.unicode.utf8Encode(0x1a3f, buf[buf_idx..]); // YA + // buf_idx += try std.unicode.utf8Encode(0x1a69, buf[buf_idx..]); // U - // Make a screen with some data - var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); - defer t.deinit(alloc); + // // Make a screen with some data + // var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + // defer t.deinit(alloc); - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); + // // Enable grapheme clustering + // t.modes.set(.grapheme_cluster, true); - var s = t.vtStream(); - defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + // var s = t.vtStream(); + // defer s.deinit(); + // try s.nextSlice(buf[0..buf_idx]); - var state: terminal.RenderState = .empty; - defer state.deinit(alloc); - try state.update(alloc, &t); + // var state: terminal.RenderState = .empty; + // defer state.deinit(alloc); + // try state.update(alloc, &t); - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(.{ - .grid = testdata.grid, - .cells = state.row_data.get(0).cells.slice(), - }); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; + // // Get our run iterator + // var shaper = &testdata.shaper; + // var it = shaper.runIterator(.{ + // .grid = testdata.grid, + // .cells = state.row_data.get(0).cells.slice(), + // }); + // var count: usize = 0; + // while (try it.next(alloc)) |run| { + // count += 1; - const cells = try shaper.shape(run); - try testing.expectEqual(@as(usize, 3), cells.len); - try testing.expectEqual(@as(u16, 0), cells[0].x); - try testing.expectEqual(@as(u16, 0), cells[1].x); - try testing.expectEqual(@as(u16, 0), cells[2].x); // U from second grapheme + // const cells = try shaper.shape(run); + // try testing.expectEqual(@as(usize, 3), cells.len); + // try testing.expectEqual(@as(u16, 0), cells[0].x); + // try testing.expectEqual(@as(u16, 0), cells[1].x); + // try testing.expectEqual(@as(u16, 0), cells[2].x); // U from second grapheme - // The U glyph renders at a y below zero - try testing.expectEqual(@as(i16, -3), cells[2].y_offset); - } - try testing.expectEqual(@as(usize, 1), count); + // // The U glyph renders at a y below zero + // try testing.expectEqual(@as(i16, -3), cells[2].y_offset); + // } + // try testing.expectEqual(@as(usize, 1), count); } test "shape Javanese ligatures" { - const testing = std.testing; - const alloc = testing.allocator; + return error.SkipZigTest; + // const testing = std.testing; + // const alloc = testing.allocator; - // We need a font that supports Javanese for this to work, if we can't find - // Noto Sans Javanese Regular, which is a system font on macOS, we just - // skip the test. - var testdata = testShaperWithDiscoveredFont( - alloc, - "Noto Sans Javanese", - ) catch return error.SkipZigTest; - defer testdata.deinit(); + // // We need a font that supports Javanese for this to work, if we can't find + // // Noto Sans Javanese Regular, which is a system font on macOS, we just + // // skip the test. + // var testdata = testShaperWithDiscoveredFont( + // alloc, + // "Noto Sans Javanese", + // ) catch return error.SkipZigTest; + // defer testdata.deinit(); - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; + // var buf: [32]u8 = undefined; + // var buf_idx: usize = 0; - // First grapheme cluster: - buf_idx += try std.unicode.utf8Encode(0xa9a4, buf[buf_idx..]); // NA - buf_idx += try std.unicode.utf8Encode(0xa9c0, buf[buf_idx..]); // PANGKON - // Second grapheme cluster, combining with the first in a ligature: - buf_idx += try std.unicode.utf8Encode(0xa9b2, buf[buf_idx..]); // HA - buf_idx += try std.unicode.utf8Encode(0xa9b8, buf[buf_idx..]); // Vowel sign SUKU + // // First grapheme cluster: + // buf_idx += try std.unicode.utf8Encode(0xa9a4, buf[buf_idx..]); // NA + // buf_idx += try std.unicode.utf8Encode(0xa9c0, buf[buf_idx..]); // PANGKON + // // Second grapheme cluster, combining with the first in a ligature: + // buf_idx += try std.unicode.utf8Encode(0xa9b2, buf[buf_idx..]); // HA + // buf_idx += try std.unicode.utf8Encode(0xa9b8, buf[buf_idx..]); // Vowel sign SUKU - // Make a screen with some data - var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); - defer t.deinit(alloc); + // // Make a screen with some data + // var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + // defer t.deinit(alloc); - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); + // // Enable grapheme clustering + // t.modes.set(.grapheme_cluster, true); - var s = t.vtStream(); - defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + // var s = t.vtStream(); + // defer s.deinit(); + // try s.nextSlice(buf[0..buf_idx]); - var state: terminal.RenderState = .empty; - defer state.deinit(alloc); - try state.update(alloc, &t); + // var state: terminal.RenderState = .empty; + // defer state.deinit(alloc); + // try state.update(alloc, &t); - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(.{ - .grid = testdata.grid, - .cells = state.row_data.get(0).cells.slice(), - }); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; + // // Get our run iterator + // var shaper = &testdata.shaper; + // var it = shaper.runIterator(.{ + // .grid = testdata.grid, + // .cells = state.row_data.get(0).cells.slice(), + // }); + // var count: usize = 0; + // while (try it.next(alloc)) |run| { + // count += 1; - const cells = try shaper.shape(run); - const cell_width = run.grid.metrics.cell_width; - try testing.expectEqual(@as(usize, 3), cells.len); - try testing.expectEqual(@as(u16, 0), cells[0].x); - try testing.expectEqual(@as(u16, 0), cells[1].x); - try testing.expectEqual(@as(u16, 0), cells[2].x); + // const cells = try shaper.shape(run); + // const cell_width = run.grid.metrics.cell_width; + // try testing.expectEqual(@as(usize, 3), cells.len); + // try testing.expectEqual(@as(u16, 0), cells[0].x); + // try testing.expectEqual(@as(u16, 0), cells[1].x); + // try testing.expectEqual(@as(u16, 0), cells[2].x); - // The vowel sign SUKU renders with correct x_offset - try testing.expect(cells[2].x_offset > 3 * cell_width); - } - try testing.expectEqual(@as(usize, 1), count); + // // The vowel sign SUKU renders with correct x_offset + // try testing.expect(cells[2].x_offset > 3 * cell_width); + // } + // try testing.expectEqual(@as(usize, 1), count); } test "shape Chakma vowel sign with ligature (vowel sign renders first)" { From 327783ff6c86c5843eedaab20c7f394e4396daa4 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:58:50 +0000 Subject: [PATCH 086/391] Update VOUCHED list (#11314) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/11287#discussioncomment-16069141) from @mitchellh. Vouch: @ocean6954 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index c767a0ec5..95c44c407 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -122,6 +122,7 @@ nicosuave nmggithub noib3 nwehg +ocean6954 oshdubh pan93412 pangoraw From c83dea49fd1c4b89eee2956f8638b80122596bdc Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:09:14 +0000 Subject: [PATCH 087/391] Update VOUCHED list (#11318) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/11309#discussioncomment-16069391) from @mitchellh. Vouch: @dzhlobo Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 95c44c407..a63757b5d 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -60,6 +60,7 @@ doprz douglance douglas drepper +dzhlobo elias8 ephemera eriksremess From 6c7309196fef805e1d0fbb0ce82944aab8edda7d Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:15:21 +0000 Subject: [PATCH 088/391] Update VOUCHED list (#11321) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/11320#issuecomment-4031703556) from @mitchellh. Vouch: @chronologos Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index a63757b5d..7ba09148b 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -45,6 +45,7 @@ brentschroeter cespare charliie-dev chernetskyi +chronologos cmwetherell craziestowl curtismoncoq From cfedda1a0e9197dfa7463a3a3aeb90ad980ab86f Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:45:44 +0100 Subject: [PATCH 089/391] macOS: add regression tests for intrinsicContentSize race (#11256) Tests that validate intrinsicContentSize returns a correct value when TerminalController.windowDidLoad() reads it. Currently fail, proving the race condition where @FocusedValue hasn't propagated lastFocusedSurface before the 40ms timer fires. Co-Authored-By: Claude Opus 4.6 --- .../Terminal/IntrinsicSizeTimingTests.swift | 467 ++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 macos/Tests/Terminal/IntrinsicSizeTimingTests.swift diff --git a/macos/Tests/Terminal/IntrinsicSizeTimingTests.swift b/macos/Tests/Terminal/IntrinsicSizeTimingTests.swift new file mode 100644 index 000000000..ef7818661 --- /dev/null +++ b/macos/Tests/Terminal/IntrinsicSizeTimingTests.swift @@ -0,0 +1,467 @@ +import AppKit +import Combine +import SwiftUI +import Testing +@testable import Ghostty + +// MARK: - Test helpers + +/// Mimics TerminalView's .frame(idealWidth:idealHeight:) pattern where +/// values come from lastFocusedSurface?.value?.initialSize, which may +/// be nil before @FocusedValue propagates. +private struct OptionalIdealSizeView: View { + let idealWidth: CGFloat? + let idealHeight: CGFloat? + let titlebarStyle: String + + var body: some View { + VStack(spacing: 0) { + Color.clear + .frame(idealWidth: idealWidth, idealHeight: idealHeight) + } + // Matches TerminalView line 108: hidden style extends into titlebar + .ignoresSafeArea(.container, edges: titlebarStyle == "hidden" ? .top : []) + } +} + +private let minReasonableWidth: CGFloat = 100 +private let minReasonableHeight: CGFloat = 50 + +/// All macos-titlebar-style values that map to different window nibs. +private let allTitlebarStyles = ["native", "hidden", "transparent", "tabs"] + +/// Window style masks that roughly correspond to each titlebar style. +/// In real Ghostty these come from different nib files; in tests we +/// approximate with NSWindow style masks. +private func styleMask(for titlebarStyle: String) -> NSWindow.StyleMask { + switch titlebarStyle { + case "hidden": + return [.titled, .resizable, .fullSizeContentView] + case "transparent", "tabs": + return [.titled, .resizable, .fullSizeContentView] + default: + return [.titled, .resizable] + } +} + +// MARK: - Tests + +/// Regression tests for Issue #11256: incorrect intrinsicContentSize +/// race condition in TerminalController.windowDidLoad(). +/// +/// The contentIntrinsicSize branch of DefaultSize reads +/// intrinsicContentSize after a 40ms delay. But intrinsicContentSize +/// depends on @FocusedValue propagating lastFocusedSurface, which is +/// async and may not complete in time — producing a tiny window. +/// +/// These tests cover the matrix of: +/// - With/without window-width/window-height (initialSize set vs nil) +/// - All macos-titlebar-style values (native, hidden, transparent, tabs) +@Suite(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "Incorrect intrinsicContentSize with native titlebar")) +struct IntrinsicSizeTimingTests { + + // MARK: - Bug: nil ideal sizes → tiny window + + /// When window-width/height is set, defaultSize returns .contentIntrinsicSize. + /// Before @FocusedValue propagates, idealWidth/idealHeight are nil and + /// intrinsicContentSize returns a tiny value. + @Test(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "intrinsicContentSize too small before @FocusedValue propagates"), + arguments: allTitlebarStyles) + func intrinsicSizeTooSmallWithNilIdealSize(titlebarStyle: String) async throws { + let expectedSize = NSSize(width: 600, height: 400) + + // nil ideal sizes = @FocusedValue hasn't propagated lastFocusedSurface + let container = await TerminalViewContainer { + OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle) + } + + // TODO: Fix #11256 — set initialContentSize on the container so + // intrinsicContentSize returns the correct value immediately. + // await MainActor.run { + // container.initialContentSize = expectedSize + // } + + let window = await NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: styleMask(for: titlebarStyle), + backing: .buffered, + defer: false + ) + + await MainActor.run { + window.contentView = container + } + + let size = await container.intrinsicContentSize + + #expect( + size.width >= minReasonableWidth && size.height >= minReasonableHeight, + "[\(titlebarStyle)] intrinsicContentSize is too small: \(size). Expected at least \(minReasonableWidth)x\(minReasonableHeight)" + ) + + await MainActor.run { window.close() } + } + + /// Verifies that DefaultSize.contentIntrinsicSize.apply() produces a + /// too-small window when intrinsicContentSize is based on nil ideal sizes. + @Test(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "apply() sets wrong window size due to racy intrinsicContentSize"), + arguments: allTitlebarStyles) + func applyProducesWrongSizeWithNilIdealSize(titlebarStyle: String) async throws { + let container = await TerminalViewContainer { + OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle) + } + + // TODO: Fix #11256 — set initialContentSize on the container. + // await MainActor.run { + // container.initialContentSize = NSSize(width: 600, height: 400) + // } + + let window = await NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: styleMask(for: titlebarStyle), + backing: .buffered, + defer: false + ) + + let contentLayoutSize = await MainActor.run { + window.contentView = container + + let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize + defaultSize.apply(to: window) + + // Use contentLayoutRect — the usable area excluding titlebar + return window.contentLayoutRect.size + } + + #expect( + contentLayoutSize.width >= minReasonableWidth && contentLayoutSize.height >= minReasonableHeight, + "[\(titlebarStyle)] Window content layout size is too small after apply: \(contentLayoutSize)" + ) + + await MainActor.run { window.close() } + } + + /// Replicates the exact pattern from TerminalController.windowDidLoad(): + /// 1. Set window.contentView = container (with nil ideal sizes, simulating + /// @FocusedValue not yet propagated) + /// 2. DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(40)) + /// 3. Inside the callback: defaultSize.apply(to: window) + /// + /// This is the core race condition: 40ms is not enough for @FocusedValue + /// to propagate, so intrinsicContentSize is still tiny when apply() runs. + @Test(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "40ms async delay reads intrinsicContentSize before @FocusedValue propagates"), + arguments: allTitlebarStyles) + func asyncAfterDelayProducesWrongSizeWithNilIdealSize(titlebarStyle: String) async throws { + let container = await TerminalViewContainer { + OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle) + } + + // TODO: Fix #11256 — set initialContentSize on the container so + // intrinsicContentSize returns the correct value immediately, + // eliminating the need for the async delay. + // await MainActor.run { + // container.initialContentSize = NSSize(width: 600, height: 400) + // } + + let window = await NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: styleMask(for: titlebarStyle), + backing: .buffered, + defer: false + ) + + // Replicate TerminalController.windowDidLoad() exactly: + // 1. Set contentView + // 2. DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(40)) + // 3. apply() inside the callback + let contentLayoutSize: NSSize = await withCheckedContinuation { continuation in + DispatchQueue.main.async { + window.contentView = container + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(40)) { + let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize + defaultSize.apply(to: window) + continuation.resume(returning: window.contentLayoutRect.size) + } + } + } + + #expect( + contentLayoutSize.width >= minReasonableWidth && contentLayoutSize.height >= minReasonableHeight, + "[\(titlebarStyle)] After 40ms async delay, content layout size is too small: \(contentLayoutSize)" + ) + + await MainActor.run { window.close() } + } + + /// Verifies that applying synchronously (without the async delay) also + /// fails when ideal sizes are nil. This proves the fix must provide a + /// fallback value, not just adjust timing. + @Test(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "Synchronous apply also fails without fallback"), + arguments: allTitlebarStyles) + func synchronousApplyAlsoFailsWithNilIdealSize(titlebarStyle: String) async throws { + let container = await TerminalViewContainer { + OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle) + } + + // TODO: Fix #11256 — set initialContentSize on the container. + // await MainActor.run { + // container.initialContentSize = NSSize(width: 600, height: 400) + // } + + let window = await NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: styleMask(for: titlebarStyle), + backing: .buffered, + defer: false + ) + + let contentLayoutSize = await MainActor.run { + window.contentView = container + // Apply immediately — no async delay at all + let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize + defaultSize.apply(to: window) + return window.contentLayoutRect.size + } + + #expect( + contentLayoutSize.width >= minReasonableWidth && contentLayoutSize.height >= minReasonableHeight, + "[\(titlebarStyle)] Synchronous apply with nil ideal sizes: content layout size too small: \(contentLayoutSize)" + ) + + await MainActor.run { window.close() } + } + + // MARK: - Happy path: ideal sizes available (contentIntrinsicSize path) + + /// When @FocusedValue HAS propagated (ideal sizes are set), intrinsicContentSize + /// should be correct for every titlebar style. This is the "happy path" that + /// works today when the 40ms delay is sufficient. + @Test(arguments: allTitlebarStyles) + func intrinsicSizeCorrectWhenIdealSizesAvailable(titlebarStyle: String) async throws { + let expectedSize = NSSize(width: 600, height: 400) + + let container = await TerminalViewContainer { + OptionalIdealSizeView( + idealWidth: expectedSize.width, + idealHeight: expectedSize.height, + titlebarStyle: titlebarStyle + ) + } + + let window = await NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: styleMask(for: titlebarStyle), + backing: .buffered, + defer: false + ) + + await MainActor.run { + window.contentView = container + } + + // Wait for SwiftUI layout + try await Task.sleep(nanoseconds: 100_000_000) + + let size = await container.intrinsicContentSize + + // intrinsicContentSize should be at least the ideal size. + // With fullSizeContentView styles it may be slightly larger + // due to safe area, but should never be smaller. + #expect( + size.width >= expectedSize.width && size.height >= expectedSize.height, + "[\(titlebarStyle)] intrinsicContentSize (\(size)) should be >= expected \(expectedSize)" + ) + + await MainActor.run { window.close() } + } + + /// Verifies that apply() sets a correctly sized window when ideal sizes + /// are available, for each titlebar style. + @Test(arguments: allTitlebarStyles) + func applyProducesCorrectSizeWhenIdealSizesAvailable(titlebarStyle: String) async throws { + let expectedSize = NSSize(width: 600, height: 400) + + let container = await TerminalViewContainer { + OptionalIdealSizeView( + idealWidth: expectedSize.width, + idealHeight: expectedSize.height, + titlebarStyle: titlebarStyle + ) + } + + let window = await NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: styleMask(for: titlebarStyle), + backing: .buffered, + defer: false + ) + + await MainActor.run { + window.contentView = container + } + + // Wait for SwiftUI layout before apply + try await Task.sleep(nanoseconds: 100_000_000) + + let contentLayoutSize = await MainActor.run { + let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize + defaultSize.apply(to: window) + // contentLayoutRect gives the usable area, excluding titlebar + return window.contentLayoutRect.size + } + + // The usable content area should be at least the expected size. + #expect( + contentLayoutSize.width >= expectedSize.width && contentLayoutSize.height >= expectedSize.height, + "[\(titlebarStyle)] Content layout size (\(contentLayoutSize)) should be >= expected \(expectedSize) after apply" + ) + + await MainActor.run { window.close() } + } + + /// Same async delay pattern but with ideal sizes available (happy path). + /// This should always pass — it validates the delay works when @FocusedValue + /// has already propagated. + @Test(arguments: allTitlebarStyles) + func asyncAfterDelayProducesCorrectSizeWhenIdealSizesAvailable(titlebarStyle: String) async throws { + let expectedSize = NSSize(width: 600, height: 400) + + let container = await TerminalViewContainer { + OptionalIdealSizeView( + idealWidth: expectedSize.width, + idealHeight: expectedSize.height, + titlebarStyle: titlebarStyle + ) + } + + let window = await NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: styleMask(for: titlebarStyle), + backing: .buffered, + defer: false + ) + + // Replicate the exact TerminalController.windowDidLoad() pattern + let contentLayoutSize: NSSize = await withCheckedContinuation { continuation in + DispatchQueue.main.async { + window.contentView = container + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(40)) { + let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize + defaultSize.apply(to: window) + continuation.resume(returning: window.contentLayoutRect.size) + } + } + } + + #expect( + contentLayoutSize.width >= expectedSize.width && contentLayoutSize.height >= expectedSize.height, + "[\(titlebarStyle)] Content layout size (\(contentLayoutSize)) should be >= expected \(expectedSize) after 40ms delay" + ) + + await MainActor.run { window.close() } + } + + // MARK: - Without window-width/window-height (frame path) + + /// Without window-width/height config, defaultSize returns .frame or nil + /// (never .contentIntrinsicSize). The window uses its initial frame. + /// This should work for all titlebar styles regardless of the bug. + @Test(arguments: allTitlebarStyles) + func framePathWorksWithoutWindowSize(titlebarStyle: String) async throws { + let expectedFrame = NSRect(x: 100, y: 100, width: 800, height: 600) + + let container = await TerminalViewContainer { + Color.clear + } + + let window = await NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: styleMask(for: titlebarStyle), + backing: .buffered, + defer: false + ) + + await MainActor.run { + window.contentView = container + let defaultSize = TerminalController.DefaultSize.frame(expectedFrame) + defaultSize.apply(to: window) + } + + let frame = await MainActor.run { window.frame } + + #expect( + frame == expectedFrame, + "[\(titlebarStyle)] Window frame (\(frame)) should match expected \(expectedFrame)" + ) + + await MainActor.run { window.close() } + } + + // MARK: - isChanged + + /// Verifies isChanged correctly detects mismatch for contentIntrinsicSize + /// across titlebar styles when ideal sizes are available. + @Test(arguments: allTitlebarStyles) + func isChangedDetectsMismatch(titlebarStyle: String) async throws { + let expectedSize = NSSize(width: 600, height: 400) + + let container = await TerminalViewContainer { + OptionalIdealSizeView( + idealWidth: expectedSize.width, + idealHeight: expectedSize.height, + titlebarStyle: titlebarStyle + ) + } + + let window = await NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: styleMask(for: titlebarStyle), + backing: .buffered, + defer: false + ) + + await MainActor.run { + window.contentView = container + } + + try await Task.sleep(nanoseconds: 100_000_000) + + let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize + + let changedBefore = await MainActor.run { defaultSize.isChanged(for: window) } + #expect(changedBefore, "[\(titlebarStyle)] isChanged should return true before apply") + + await MainActor.run { defaultSize.apply(to: window) } + + let changedAfter = await MainActor.run { defaultSize.isChanged(for: window) } + #expect(!changedAfter, "[\(titlebarStyle)] isChanged should return false after apply") + + await MainActor.run { window.close() } + } + + /// Verifies isChanged for the .frame path. + @Test func isChangedForFramePath() async throws { + let expectedFrame = NSRect(x: 100, y: 100, width: 800, height: 600) + + let window = await NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled, .resizable], + backing: .buffered, + defer: false + ) + + let defaultSize = TerminalController.DefaultSize.frame(expectedFrame) + + let changedBefore = await MainActor.run { defaultSize.isChanged(for: window) } + #expect(changedBefore, "isChanged should return true before apply") + + await MainActor.run { defaultSize.apply(to: window) } + + let changedAfter = await MainActor.run { defaultSize.isChanged(for: window) } + #expect(!changedAfter, "isChanged should return false after apply") + + await MainActor.run { window.close() } + } +} From a6cd1b08af240e7be0b07163d78dac5efa6b1752 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:35:49 +0100 Subject: [PATCH 090/391] macOS: fix intrinsicContentSize race in windowDidLoad (#11256) Add initialContentSize fallback on TerminalViewContainer so intrinsicContentSize returns the correct value immediately, without waiting for @FocusedValue to propagate. This removes the need for the DispatchQueue.main.asyncAfter 40ms delay. Co-Authored-By: Claude Opus 4.6 --- .../Terminal/TerminalController.swift | 29 ++++++++-------- .../Terminal/TerminalViewContainer.swift | 20 ++++++++--- .../Terminal/IntrinsicSizeTimingTests.swift | 33 ++++++++----------- 3 files changed, 44 insertions(+), 38 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7a5bd1d4b..0c1bd38ad 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1038,27 +1038,26 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } // Initialize our content view to the SwiftUI root - window.contentView = TerminalViewContainer { + let container = TerminalViewContainer { TerminalView(ghostty: ghostty, viewModel: self, delegate: self) } + // Set the initial content size on the container so that + // intrinsicContentSize returns the correct value immediately, + // without waiting for @FocusedValue to propagate through the + // SwiftUI focus chain. + container.initialContentSize = focusedSurface?.initialSize + + window.contentView = container + // If we have a default size, we want to apply it. if let defaultSize { - switch defaultSize { - case .frame: - // Frames can be applied immediately - defaultSize.apply(to: window) + defaultSize.apply(to: window) - case .contentIntrinsicSize: - // Content intrinsic size requires a short delay so that AppKit - // can layout our SwiftUI views. - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(40)) { [weak self, weak window] in - guard let self, let window else { return } - defaultSize.apply(to: window) - if let screen = window.screen ?? NSScreen.main { - let frame = self.adjustForWindowPosition(frame: window.frame, on: screen) - window.setFrameOrigin(frame.origin) - } + if case .contentIntrinsicSize = defaultSize { + if let screen = window.screen ?? NSScreen.main { + let frame = self.adjustForWindowPosition(frame: window.frame, on: screen) + window.setFrameOrigin(frame.origin) } } } diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift index bd696a2bf..dd0190c4c 100644 --- a/macos/Sources/Features/Terminal/TerminalViewContainer.swift +++ b/macos/Sources/Features/Terminal/TerminalViewContainer.swift @@ -33,11 +33,23 @@ class TerminalViewContainer: NSView { fatalError("init(coder:) has not been implemented") } - /// To make ``TerminalController/DefaultSize/contentIntrinsicSize`` - /// work in ``TerminalController/windowDidLoad()``, - /// we override this to provide the correct size. + /// The initial content size to use as a fallback before the SwiftUI + /// view hierarchy has completed layout (i.e. before @FocusedValue + /// propagates `lastFocusedSurface`). Once the hosting view reports + /// a valid intrinsic size, this fallback is no longer used. + var initialContentSize: NSSize? + override var intrinsicContentSize: NSSize { - terminalView.intrinsicContentSize + let hostingSize = terminalView.intrinsicContentSize + // The hosting view returns a valid size once SwiftUI has laid out + // with the correct idealWidth/idealHeight. Before that (when + // @FocusedValue hasn't propagated), it returns a tiny default. + // Fall back to initialContentSize in that case. + if let initialContentSize, + hostingSize.width < initialContentSize.width || hostingSize.height < initialContentSize.height { + return initialContentSize + } + return hostingSize } private func setup() { diff --git a/macos/Tests/Terminal/IntrinsicSizeTimingTests.swift b/macos/Tests/Terminal/IntrinsicSizeTimingTests.swift index ef7818661..640f2dbdb 100644 --- a/macos/Tests/Terminal/IntrinsicSizeTimingTests.swift +++ b/macos/Tests/Terminal/IntrinsicSizeTimingTests.swift @@ -75,11 +75,11 @@ struct IntrinsicSizeTimingTests { OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle) } - // TODO: Fix #11256 — set initialContentSize on the container so - // intrinsicContentSize returns the correct value immediately. - // await MainActor.run { - // container.initialContentSize = expectedSize - // } + // Set initialContentSize so intrinsicContentSize returns the + // correct value immediately, without waiting for @FocusedValue. + await MainActor.run { + container.initialContentSize = expectedSize + } let window = await NSWindow( contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), @@ -111,10 +111,9 @@ struct IntrinsicSizeTimingTests { OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle) } - // TODO: Fix #11256 — set initialContentSize on the container. - // await MainActor.run { - // container.initialContentSize = NSSize(width: 600, height: 400) - // } + await MainActor.run { + container.initialContentSize = NSSize(width: 600, height: 400) + } let window = await NSWindow( contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), @@ -156,12 +155,9 @@ struct IntrinsicSizeTimingTests { OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle) } - // TODO: Fix #11256 — set initialContentSize on the container so - // intrinsicContentSize returns the correct value immediately, - // eliminating the need for the async delay. - // await MainActor.run { - // container.initialContentSize = NSSize(width: 600, height: 400) - // } + await MainActor.run { + container.initialContentSize = NSSize(width: 600, height: 400) + } let window = await NSWindow( contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), @@ -204,10 +200,9 @@ struct IntrinsicSizeTimingTests { OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle) } - // TODO: Fix #11256 — set initialContentSize on the container. - // await MainActor.run { - // container.initialContentSize = NSSize(width: 600, height: 400) - // } + await MainActor.run { + container.initialContentSize = NSSize(width: 600, height: 400) + } let window = await NSWindow( contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), From 1592cafa32e99119cee0b074fde3f50070ac3dac Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 10 Mar 2026 08:48:19 -0700 Subject: [PATCH 091/391] Update AGENTS.md --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ff8c289c8..794115c58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ A file for [guiding coding agents](https://agents.md/). ## Commands +- Use `nix develop -c` with all commands to ensure the Nix version is used. - **Build:** `zig build` - If you're on macOS and don't need to build the macOS app, use `-Demit-macos-app=false` to skip building the app bundle and speed up @@ -13,7 +14,7 @@ A file for [guiding coding agents](https://agents.md/). test suite is slow to run. - **Test filter (Zig)**: `zig build test -Dtest-filter=` - **Formatting (Zig)**: `zig fmt .` -- **Formatting (Swift)**: `swiftlint lint --fix` +- **Formatting (Swift)**: `swiftlint lint --strict --fix` - **Formatting (other)**: `prettier -w .` ## Directory Structure From 7629130fb4f66262684d4b75d549b522d5943f59 Mon Sep 17 00:00:00 2001 From: chronologos Date: Tue, 10 Mar 2026 06:58:44 -0700 Subject: [PATCH 092/391] macOS: restore keyboard focus after inline tab title edit After finishing an inline tab title edit (via keybind or double-click), `TabTitleEditor.finishEditing()` calls `makeFirstResponder(nil)` to clear focus from the text field, leaving the window itself as first responder. No code path restores focus to the terminal surface, so all keyboard input is lost until the user clicks into a pane. Add a `tabTitleEditorDidFinishEditing` delegate callback that fires after every edit (commit or cancel). TerminalWindow implements it by calling `makeFirstResponder(focusedSurface)` to hand focus back to the terminal. Fixes https://github.com/ghostty-org/ghostty/discussions/11315 Co-Authored-By: Claude --- .../Terminal/Window Styles/TerminalWindow.swift | 9 +++++++++ macos/Sources/Helpers/TabTitleEditor.swift | 16 ++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index dc744180d..c580b4cb8 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -835,4 +835,13 @@ extension TerminalWindow: TabTitleEditorDelegate { guard let targetController = targetWindow.windowController as? BaseTerminalController else { return } targetController.promptTabTitle() } + + func tabTitleEditor(_ editor: TabTitleEditor, didFinishEditing targetWindow: NSWindow) { + // After inline editing, the first responder is the window itself. + // Restore focus to the terminal surface so keyboard input works. + guard let controller = windowController as? BaseTerminalController, + let focusedSurface = controller.focusedSurface + else { return } + makeFirstResponder(focusedSurface) + } } diff --git a/macos/Sources/Helpers/TabTitleEditor.swift b/macos/Sources/Helpers/TabTitleEditor.swift index 0a1efae32..570be1bf4 100644 --- a/macos/Sources/Helpers/TabTitleEditor.swift +++ b/macos/Sources/Helpers/TabTitleEditor.swift @@ -26,6 +26,12 @@ protocol TabTitleEditorDelegate: AnyObject { _ editor: TabTitleEditor, performFallbackRenameFor targetWindow: NSWindow ) + + /// Called after inline editing finishes (whether committed or cancelled). + /// Use this to restore focus to the appropriate responder. + func tabTitleEditor( + _ editor: TabTitleEditor, + didFinishEditing targetWindow: NSWindow) } /// Handles inline tab title editing for native AppKit window tabs. @@ -212,8 +218,14 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { previousTabState = nil // Delegate owns title persistence semantics (including empty-title handling). - guard commit, let targetWindow else { return } - delegate?.tabTitleEditor(self, didCommitTitle: editedTitle, for: targetWindow) + guard let targetWindow else { return } + + if commit { + delegate?.tabTitleEditor(self, didCommitTitle: editedTitle, for: targetWindow) + } + + // Notify delegate that editing is done so it can restore focus. + delegate?.tabTitleEditor(self, didFinishEditing: targetWindow) } /// Chooses an editor frame that aligns with the tab title within the tab button. From de0f2ab22d941e270a4ba259ef2522f71bb84247 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:25:13 +0100 Subject: [PATCH 093/391] macos: add enum type for macos-titlebar-style --- .../Terminal/TerminalController.swift | 13 ++++---- .../Features/Terminal/TerminalView.swift | 2 +- .../Window Styles/TerminalWindow.swift | 6 ++-- .../TransparentTitlebarTerminalWindow.swift | 4 +-- macos/Sources/Ghostty/Ghostty.Config.swift | 11 +++++-- .../Terminal/IntrinsicSizeTimingTests.swift | 32 +++++++++---------- 6 files changed, 36 insertions(+), 32 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 0c1bd38ad..20b51ff36 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -19,10 +19,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } let nib = switch config.macosTitlebarStyle { - case "native": "Terminal" - case "hidden": "TerminalHiddenTitlebar" - case "transparent": "TerminalTransparentTitlebar" - case "tabs": + case .native: "Terminal" + case .hidden: "TerminalHiddenTitlebar" + case .transparent: "TerminalTransparentTitlebar" + case .tabs: #if compiler(>=6.2) if #available(macOS 26.0, *) { "TerminalTabsTitlebarTahoe" @@ -32,7 +32,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr #else "TerminalTabsTitlebarVentura" #endif - default: defaultValue } return nib @@ -1537,7 +1536,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr struct DerivedConfig { let backgroundColor: Color let macosWindowButtons: Ghostty.MacOSWindowButtons - let macosTitlebarStyle: String + let macosTitlebarStyle: Ghostty.Config.MacOSTitlebarStyle let maximize: Bool let windowPositionX: Int16? let windowPositionY: Int16? @@ -1545,7 +1544,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) self.macosWindowButtons = .visible - self.macosTitlebarStyle = "system" + self.macosTitlebarStyle = .default self.maximize = false self.windowPositionX = nil self.windowPositionY = nil diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 697009579..b6e1c637c 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -105,7 +105,7 @@ struct TerminalView: View { idealHeight: lastFocusedSurface?.value?.initialSize?.height) } // Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style - .ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : []) + .ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == .hidden ? .top : []) if let surfaceView = lastFocusedSurface?.value { TerminalCommandPaletteView( diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index c580b4cb8..60e96bb4d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -588,7 +588,7 @@ class TerminalWindow: NSWindow { let backgroundColor: NSColor let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons - let macosTitlebarStyle: String + let macosTitlebarStyle: Ghostty.Config.MacOSTitlebarStyle let windowCornerRadius: CGFloat init() { @@ -597,7 +597,7 @@ class TerminalWindow: NSWindow { self.backgroundOpacity = 1 self.macosWindowButtons = .visible self.backgroundBlur = .disabled - self.macosTitlebarStyle = "transparent" + self.macosTitlebarStyle = .default self.windowCornerRadius = 16 } @@ -613,7 +613,7 @@ class TerminalWindow: NSWindow { // Native, transparent, and hidden styles use 16pt radius // Tabs style uses 20pt radius switch config.macosTitlebarStyle { - case "tabs": + case .tabs: self.windowCornerRadius = 20 default: self.windowCornerRadius = 16 diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index a547d5286..c0e506c34 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -92,8 +92,8 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // For glass background styles, use a transparent titlebar to let the glass effect show through // Only apply this for transparent and tabs titlebar styles let isGlassStyle = derivedConfig.backgroundBlur.isGlassStyle - let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == "transparent" || - derivedConfig.macosTitlebarStyle == "tabs" + let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == .transparent || + derivedConfig.macosTitlebarStyle == .tabs titlebarView.layer?.backgroundColor = (isGlassStyle && isTransparentTitlebar) ? NSColor.clear.cgColor diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 239f458e3..4a36583d5 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -354,14 +354,14 @@ extension Ghostty { return MacOSWindowButtons(rawValue: str) ?? defaultValue } - var macosTitlebarStyle: String { - let defaultValue = "transparent" + var macosTitlebarStyle: MacOSTitlebarStyle { + let defaultValue = MacOSTitlebarStyle.transparent guard let config = self.config else { return defaultValue } var v: UnsafePointer? let key = "macos-titlebar-style" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } - return String(cString: ptr) + return MacOSTitlebarStyle(rawValue: String(cString: ptr)) ?? defaultValue } var macosTitlebarProxyIcon: MacOSTitlebarProxyIcon { @@ -906,4 +906,9 @@ extension Ghostty.Config { static let bell = NotifyOnCommandFinishAction(rawValue: 1 << 0) static let notify = NotifyOnCommandFinishAction(rawValue: 1 << 1) } + + enum MacOSTitlebarStyle: String { + static let `default` = MacOSTitlebarStyle.transparent + case native, transparent, tabs, hidden + } } diff --git a/macos/Tests/Terminal/IntrinsicSizeTimingTests.swift b/macos/Tests/Terminal/IntrinsicSizeTimingTests.swift index 640f2dbdb..7bce61bdc 100644 --- a/macos/Tests/Terminal/IntrinsicSizeTimingTests.swift +++ b/macos/Tests/Terminal/IntrinsicSizeTimingTests.swift @@ -12,7 +12,7 @@ import Testing private struct OptionalIdealSizeView: View { let idealWidth: CGFloat? let idealHeight: CGFloat? - let titlebarStyle: String + let titlebarStyle: Ghostty.Config.MacOSTitlebarStyle var body: some View { VStack(spacing: 0) { @@ -20,7 +20,7 @@ private struct OptionalIdealSizeView: View { .frame(idealWidth: idealWidth, idealHeight: idealHeight) } // Matches TerminalView line 108: hidden style extends into titlebar - .ignoresSafeArea(.container, edges: titlebarStyle == "hidden" ? .top : []) + .ignoresSafeArea(.container, edges: titlebarStyle == .hidden ? .top : []) } } @@ -28,18 +28,18 @@ private let minReasonableWidth: CGFloat = 100 private let minReasonableHeight: CGFloat = 50 /// All macos-titlebar-style values that map to different window nibs. -private let allTitlebarStyles = ["native", "hidden", "transparent", "tabs"] +private let allTitlebarStyles: [Ghostty.Config.MacOSTitlebarStyle] = [.native, .hidden, .transparent, .tabs] /// Window style masks that roughly correspond to each titlebar style. /// In real Ghostty these come from different nib files; in tests we /// approximate with NSWindow style masks. -private func styleMask(for titlebarStyle: String) -> NSWindow.StyleMask { +private func styleMask(for titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) -> NSWindow.StyleMask { switch titlebarStyle { - case "hidden": + case .hidden: return [.titled, .resizable, .fullSizeContentView] - case "transparent", "tabs": + case .transparent, .tabs: return [.titled, .resizable, .fullSizeContentView] - default: + case .native: return [.titled, .resizable] } } @@ -67,7 +67,7 @@ struct IntrinsicSizeTimingTests { /// intrinsicContentSize returns a tiny value. @Test(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "intrinsicContentSize too small before @FocusedValue propagates"), arguments: allTitlebarStyles) - func intrinsicSizeTooSmallWithNilIdealSize(titlebarStyle: String) async throws { + func intrinsicSizeTooSmallWithNilIdealSize(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { let expectedSize = NSSize(width: 600, height: 400) // nil ideal sizes = @FocusedValue hasn't propagated lastFocusedSurface @@ -106,7 +106,7 @@ struct IntrinsicSizeTimingTests { /// too-small window when intrinsicContentSize is based on nil ideal sizes. @Test(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "apply() sets wrong window size due to racy intrinsicContentSize"), arguments: allTitlebarStyles) - func applyProducesWrongSizeWithNilIdealSize(titlebarStyle: String) async throws { + func applyProducesWrongSizeWithNilIdealSize(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { let container = await TerminalViewContainer { OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle) } @@ -150,7 +150,7 @@ struct IntrinsicSizeTimingTests { /// to propagate, so intrinsicContentSize is still tiny when apply() runs. @Test(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "40ms async delay reads intrinsicContentSize before @FocusedValue propagates"), arguments: allTitlebarStyles) - func asyncAfterDelayProducesWrongSizeWithNilIdealSize(titlebarStyle: String) async throws { + func asyncAfterDelayProducesWrongSizeWithNilIdealSize(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { let container = await TerminalViewContainer { OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle) } @@ -195,7 +195,7 @@ struct IntrinsicSizeTimingTests { /// fallback value, not just adjust timing. @Test(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "Synchronous apply also fails without fallback"), arguments: allTitlebarStyles) - func synchronousApplyAlsoFailsWithNilIdealSize(titlebarStyle: String) async throws { + func synchronousApplyAlsoFailsWithNilIdealSize(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { let container = await TerminalViewContainer { OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle) } @@ -233,7 +233,7 @@ struct IntrinsicSizeTimingTests { /// should be correct for every titlebar style. This is the "happy path" that /// works today when the 40ms delay is sufficient. @Test(arguments: allTitlebarStyles) - func intrinsicSizeCorrectWhenIdealSizesAvailable(titlebarStyle: String) async throws { + func intrinsicSizeCorrectWhenIdealSizesAvailable(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { let expectedSize = NSSize(width: 600, height: 400) let container = await TerminalViewContainer { @@ -274,7 +274,7 @@ struct IntrinsicSizeTimingTests { /// Verifies that apply() sets a correctly sized window when ideal sizes /// are available, for each titlebar style. @Test(arguments: allTitlebarStyles) - func applyProducesCorrectSizeWhenIdealSizesAvailable(titlebarStyle: String) async throws { + func applyProducesCorrectSizeWhenIdealSizesAvailable(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { let expectedSize = NSSize(width: 600, height: 400) let container = await TerminalViewContainer { @@ -319,7 +319,7 @@ struct IntrinsicSizeTimingTests { /// This should always pass — it validates the delay works when @FocusedValue /// has already propagated. @Test(arguments: allTitlebarStyles) - func asyncAfterDelayProducesCorrectSizeWhenIdealSizesAvailable(titlebarStyle: String) async throws { + func asyncAfterDelayProducesCorrectSizeWhenIdealSizesAvailable(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { let expectedSize = NSSize(width: 600, height: 400) let container = await TerminalViewContainer { @@ -364,7 +364,7 @@ struct IntrinsicSizeTimingTests { /// (never .contentIntrinsicSize). The window uses its initial frame. /// This should work for all titlebar styles regardless of the bug. @Test(arguments: allTitlebarStyles) - func framePathWorksWithoutWindowSize(titlebarStyle: String) async throws { + func framePathWorksWithoutWindowSize(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { let expectedFrame = NSRect(x: 100, y: 100, width: 800, height: 600) let container = await TerminalViewContainer { @@ -399,7 +399,7 @@ struct IntrinsicSizeTimingTests { /// Verifies isChanged correctly detects mismatch for contentIntrinsicSize /// across titlebar styles when ideal sizes are available. @Test(arguments: allTitlebarStyles) - func isChangedDetectsMismatch(titlebarStyle: String) async throws { + func isChangedDetectsMismatch(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { let expectedSize = NSSize(width: 600, height: 400) let container = await TerminalViewContainer { From d9039eb85a6f12ff7de205c116d978482c80bdab Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 10 Mar 2026 09:20:22 -0700 Subject: [PATCH 094/391] config: don't double load app support path on macOS Fixes #11323 --- src/config/Config.zig | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 527b0d329..413624912 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4013,10 +4013,28 @@ pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { const app_support_path = try file_load.preferredAppSupportPath(alloc); defer alloc.free(app_support_path); const app_support_loaded: bool = loaded: { - const legacy_app_support_action = self.loadOptionalFile(alloc, legacy_app_support_path); - const app_support_action = self.loadOptionalFile(alloc, app_support_path); + const legacy_app_support_action = self.loadOptionalFile( + alloc, + legacy_app_support_path, + ); + + // The app support path and legacy may be the same, since we + // use the `preferred` call above. If its the same, avoid + // a double-load. + const app_support_action: OptionalFileAction = if (!std.mem.eql( + u8, + legacy_app_support_path, + app_support_path, + )) self.loadOptionalFile( + alloc, + app_support_path, + ) else .not_found; + if (app_support_action != .not_found and legacy_app_support_action != .not_found) { - log.warn("both config files `{s}` and `{s}` exist.", .{ legacy_app_support_path, app_support_path }); + log.warn( + "both config files `{s}` and `{s}` exist.", + .{ legacy_app_support_path, app_support_path }, + ); log.warn("loading them both in that order", .{}); break :loaded true; } From 4e24adf7177946af7f3d0e367d94fc8e2dead133 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 10 Mar 2026 09:40:20 -0700 Subject: [PATCH 095/391] ci: skip xcode tests for freetype build --- .github/workflows/test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cbd985558..e311089e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -696,9 +696,11 @@ jobs: id: deps run: nix build -L .#deps && echo "deps=$(readlink ./result)" >> $GITHUB_OUTPUT + # We run tests with an empty test filter so it runs all unit tests + # but skips Xcode tests - name: Test All run: | - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_freetype + nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_freetype -Dtest-filter="" - name: Build All run: | From 6092c299d55cd24ec72d3d5d2365279645c30ff3 Mon Sep 17 00:00:00 2001 From: Selman Kayrancioglu Date: Mon, 9 Mar 2026 01:00:59 +0300 Subject: [PATCH 096/391] macos: reset mouse state on focus loss to prevent phantom drag Fixes phantom mouse drag/selection when switching splits or apps. The suppressNextLeftMouseUp flag and core mouse click_state were not being reset on focus transitions, causing stale state that led to unexpected drag behavior. - Reset suppressNextLeftMouseUp in focusDidChange when losing focus - Defensively reset the flag when processing normal clicks - Reset core mouse.click_state and left_click_count on focus loss --- .../Surface View/SurfaceView_AppKit.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 060b7990b..a37feb9a8 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -438,6 +438,15 @@ extension Ghostty { guard let surface = self.surface else { return } guard self.focused != focused else { return } self.focused = focused + + // If we lost our focus then remove the mouse event suppression so + // our mouse release event leaving the surface can properly be + // sent to stop things like mouse selection. + if !focused { + suppressNextLeftMouseUp = false + } + + // Notify libghostty ghostty_surface_set_focus(surface, focused) // Update our secure input state if we are a password input @@ -648,9 +657,15 @@ extension Ghostty { let location = convert(event.locationInWindow, from: nil) guard hitTest(location) == self else { return event } + // We always assume that we're resetting our mouse suppression + // unless we see the specific scenario below to set it. + suppressNextLeftMouseUp = false + // If we're already the first responder then no focus transfer is // happening, so the click should continue as normal. - guard window.firstResponder !== self else { return event } + guard window.firstResponder !== self else { + return event + } // If our window/app is already focused, then this click is only // being used to transfer split focus. Consume it so it does not From aaad43c23569e75929d611a13483e96cec6b1060 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 10 Mar 2026 10:04:44 -0700 Subject: [PATCH 097/391] macos: make paste_from_clipboard performable on macos Fixes #10751 --- include/ghostty.h | 2 +- macos/Sources/Ghostty/Ghostty.App.swift | 25 +++++++++++++++---------- src/apprt/embedded.zig | 17 ++++++++++------- src/config/Config.zig | 5 +++-- 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 19a200f10..afd89542f 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -968,7 +968,7 @@ typedef struct { } ghostty_action_s; typedef void (*ghostty_runtime_wakeup_cb)(void*); -typedef void (*ghostty_runtime_read_clipboard_cb)(void*, +typedef bool (*ghostty_runtime_read_clipboard_cb)(void*, ghostty_clipboard_e, void*); typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 82b3ad35c..d57c2ea11 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -269,7 +269,9 @@ extension Ghostty { _ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer? - ) {} + ) -> Bool { + return false + } static func confirmReadClipboard( _ userdata: UnsafeMutableRawPointer?, @@ -321,20 +323,23 @@ extension Ghostty { ]) } - static func readClipboard(_ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer?) { - // If we don't even have a surface, something went terrible wrong so we have - // to leak "state". + static func readClipboard( + _ userdata: UnsafeMutableRawPointer?, + location: ghostty_clipboard_e, + state: UnsafeMutableRawPointer? + ) -> Bool { let surfaceView = self.surfaceUserdata(from: userdata) - guard let surface = surfaceView.surface else { return } + guard let surface = surfaceView.surface else { return false } // Get our pasteboard - guard let pasteboard = NSPasteboard.ghostty(location) else { - return completeClipboardRequest(surface, data: "", state: state) - } + guard let pasteboard = NSPasteboard.ghostty(location) else { return false } + + // Return false if there is no text-like clipboard content so + // performable paste bindings can pass through to the terminal. + guard let str = pasteboard.getOpinionatedStringContents() else { return false } - // Get our string - let str = pasteboard.getOpinionatedStringContents() ?? "" completeClipboardRequest(surface, data: str, state: state) + return true } static func confirmReadClipboard( diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 54d5472c6..c629be498 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -50,10 +50,11 @@ pub const App = struct { /// Callback called to handle an action. action: *const fn (*App, apprt.Target.C, apprt.Action.C) callconv(.c) bool, - /// Read the clipboard value. The return value must be preserved - /// by the host until the next call. If there is no valid clipboard - /// value then this should return null. - read_clipboard: *const fn (SurfaceUD, c_int, *apprt.ClipboardRequest) callconv(.c) void, + /// Read the clipboard value. Returns true if the clipboard request + /// was started and complete_clipboard_request may be called with the + /// given state pointer. Returns false if the clipboard request couldn't + /// be started (such as when no text is available for a paste request). + read_clipboard: *const fn (SurfaceUD, c_int, *apprt.ClipboardRequest) callconv(.c) bool, /// This may be called after a read clipboard call to request /// confirmation that the clipboard value is safe to read. The embedder @@ -672,14 +673,16 @@ pub const Surface = struct { errdefer alloc.destroy(state_ptr); state_ptr.* = state; - self.app.opts.read_clipboard( + const started = self.app.opts.read_clipboard( self.userdata, @intCast(@intFromEnum(clipboard_type)), state_ptr, ); + if (!started) { + alloc.destroy(state_ptr); + return false; + } - // Embedded apprt can't synchronously check clipboard content types, - // so we always return true to indicate the request was started. return true; } diff --git a/src/config/Config.zig b/src/config/Config.zig index 413624912..e31fbc011 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6332,10 +6332,11 @@ pub const Keybinds = struct { .{ .copy_to_clipboard = .mixed }, .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'v' }, .mods = mods }, - .{ .paste_from_clipboard = {} }, + .paste_from_clipboard, + .{ .performable = true }, ); } From f8d7876203ad65572cd085ff89afb758252217cb Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:31:32 +0000 Subject: [PATCH 098/391] Update VOUCHED list (#11329) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/11313#issuecomment-4033213188) from @mitchellh. Vouch: @VaughanAndrews Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 7ba09148b..dfaea1326 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -158,6 +158,7 @@ tristan957 tweedbeetle uhojin uzaaft +vaughanandrews vlsi yamshta zenyr From 53637ec7b2b91da8e19b79cd755874b3fc2cf0db Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 10 Mar 2026 10:38:43 -0700 Subject: [PATCH 099/391] fix jump_to_prompt forward behavior for multiline prompts Fixes #11330. When jumping forward from prompt content, skip prompt continuation rows so a multiline prompt is treated as a single prompt block. --- src/terminal/PageList.zig | 70 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index b6d53beee..6e39428db 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2682,11 +2682,22 @@ fn scrollPrompt(self: *PageList, delta: isize) void { // delta so that we don't land back on our current viewport. const start_pin = start: { const tl = self.getTopLeft(.viewport); - const adjusted: ?Pin = if (delta > 0) - tl.down(1) - else - tl.up(1); - break :start adjusted orelse return; + + // If we're moving up we can just move the viewport up because + // promptIterator handles jumpting to the start of prompts. + if (delta <= 0) break :start tl.up(1) orelse return; + + // If we're moving down and we're presently at some kind of + // prompt, we need to skip all the continuation lines because + // promptIterator can't know if we're cutoff or continuing. + var adjusted: Pin = tl.down(1) orelse return; + if (tl.rowAndCell().row.semantic_prompt != .none) skip: { + while (adjusted.rowAndCell().row.semantic_prompt == .prompt_continuation) { + adjusted = adjusted.down(1) orelse break :skip; + } + } + + break :start adjusted; }; // Go through prompts delta times @@ -6866,6 +6877,55 @@ test "Screen: jump back one prompt" { } } +test "Screen: jump forward prompt skips multiline continuation" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, null); + defer s.deinit(); + try s.growRows(7); + + // Multiline prompt on rows 1-3. + { + const p = s.pin(.{ .screen = .{ .y = 1 } }).?; + p.rowAndCell().row.semantic_prompt = .prompt; + } + { + const p = s.pin(.{ .screen = .{ .y = 2 } }).?; + p.rowAndCell().row.semantic_prompt = .prompt_continuation; + } + { + const p = s.pin(.{ .screen = .{ .y = 3 } }).?; + p.rowAndCell().row.semantic_prompt = .prompt_continuation; + } + + // Next prompt after command output. + { + const p = s.pin(.{ .screen = .{ .y = 6 } }).?; + p.rowAndCell().row.semantic_prompt = .prompt; + } + + // Starting at the first prompt line should jump to the next prompt, + // not to continuation lines. + s.scroll(.{ .row = 1 }); + s.scroll(.{ .delta_prompt = 1 }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 6, + } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); + + // Starting in the middle of continuation lines should also jump to + // the next prompt. + s.scroll(.{ .row = 2 }); + s.scroll(.{ .delta_prompt = 1 }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 6, + } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); +} + test "PageList grow fit in capacity" { const testing = std.testing; const alloc = testing.allocator; From 71f81527ad8d3393609d1e9134987653249473d4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 10 Mar 2026 11:08:46 -0700 Subject: [PATCH 100/391] macos: remove IntrinsicSizeTimingTests temporarily These were too flaky. --- .../Terminal/IntrinsicSizeTimingTests.swift | 462 ------------------ 1 file changed, 462 deletions(-) delete mode 100644 macos/Tests/Terminal/IntrinsicSizeTimingTests.swift diff --git a/macos/Tests/Terminal/IntrinsicSizeTimingTests.swift b/macos/Tests/Terminal/IntrinsicSizeTimingTests.swift deleted file mode 100644 index 7bce61bdc..000000000 --- a/macos/Tests/Terminal/IntrinsicSizeTimingTests.swift +++ /dev/null @@ -1,462 +0,0 @@ -import AppKit -import Combine -import SwiftUI -import Testing -@testable import Ghostty - -// MARK: - Test helpers - -/// Mimics TerminalView's .frame(idealWidth:idealHeight:) pattern where -/// values come from lastFocusedSurface?.value?.initialSize, which may -/// be nil before @FocusedValue propagates. -private struct OptionalIdealSizeView: View { - let idealWidth: CGFloat? - let idealHeight: CGFloat? - let titlebarStyle: Ghostty.Config.MacOSTitlebarStyle - - var body: some View { - VStack(spacing: 0) { - Color.clear - .frame(idealWidth: idealWidth, idealHeight: idealHeight) - } - // Matches TerminalView line 108: hidden style extends into titlebar - .ignoresSafeArea(.container, edges: titlebarStyle == .hidden ? .top : []) - } -} - -private let minReasonableWidth: CGFloat = 100 -private let minReasonableHeight: CGFloat = 50 - -/// All macos-titlebar-style values that map to different window nibs. -private let allTitlebarStyles: [Ghostty.Config.MacOSTitlebarStyle] = [.native, .hidden, .transparent, .tabs] - -/// Window style masks that roughly correspond to each titlebar style. -/// In real Ghostty these come from different nib files; in tests we -/// approximate with NSWindow style masks. -private func styleMask(for titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) -> NSWindow.StyleMask { - switch titlebarStyle { - case .hidden: - return [.titled, .resizable, .fullSizeContentView] - case .transparent, .tabs: - return [.titled, .resizable, .fullSizeContentView] - case .native: - return [.titled, .resizable] - } -} - -// MARK: - Tests - -/// Regression tests for Issue #11256: incorrect intrinsicContentSize -/// race condition in TerminalController.windowDidLoad(). -/// -/// The contentIntrinsicSize branch of DefaultSize reads -/// intrinsicContentSize after a 40ms delay. But intrinsicContentSize -/// depends on @FocusedValue propagating lastFocusedSurface, which is -/// async and may not complete in time — producing a tiny window. -/// -/// These tests cover the matrix of: -/// - With/without window-width/window-height (initialSize set vs nil) -/// - All macos-titlebar-style values (native, hidden, transparent, tabs) -@Suite(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "Incorrect intrinsicContentSize with native titlebar")) -struct IntrinsicSizeTimingTests { - - // MARK: - Bug: nil ideal sizes → tiny window - - /// When window-width/height is set, defaultSize returns .contentIntrinsicSize. - /// Before @FocusedValue propagates, idealWidth/idealHeight are nil and - /// intrinsicContentSize returns a tiny value. - @Test(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "intrinsicContentSize too small before @FocusedValue propagates"), - arguments: allTitlebarStyles) - func intrinsicSizeTooSmallWithNilIdealSize(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { - let expectedSize = NSSize(width: 600, height: 400) - - // nil ideal sizes = @FocusedValue hasn't propagated lastFocusedSurface - let container = await TerminalViewContainer { - OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle) - } - - // Set initialContentSize so intrinsicContentSize returns the - // correct value immediately, without waiting for @FocusedValue. - await MainActor.run { - container.initialContentSize = expectedSize - } - - let window = await NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), - styleMask: styleMask(for: titlebarStyle), - backing: .buffered, - defer: false - ) - - await MainActor.run { - window.contentView = container - } - - let size = await container.intrinsicContentSize - - #expect( - size.width >= minReasonableWidth && size.height >= minReasonableHeight, - "[\(titlebarStyle)] intrinsicContentSize is too small: \(size). Expected at least \(minReasonableWidth)x\(minReasonableHeight)" - ) - - await MainActor.run { window.close() } - } - - /// Verifies that DefaultSize.contentIntrinsicSize.apply() produces a - /// too-small window when intrinsicContentSize is based on nil ideal sizes. - @Test(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "apply() sets wrong window size due to racy intrinsicContentSize"), - arguments: allTitlebarStyles) - func applyProducesWrongSizeWithNilIdealSize(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { - let container = await TerminalViewContainer { - OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle) - } - - await MainActor.run { - container.initialContentSize = NSSize(width: 600, height: 400) - } - - let window = await NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), - styleMask: styleMask(for: titlebarStyle), - backing: .buffered, - defer: false - ) - - let contentLayoutSize = await MainActor.run { - window.contentView = container - - let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize - defaultSize.apply(to: window) - - // Use contentLayoutRect — the usable area excluding titlebar - return window.contentLayoutRect.size - } - - #expect( - contentLayoutSize.width >= minReasonableWidth && contentLayoutSize.height >= minReasonableHeight, - "[\(titlebarStyle)] Window content layout size is too small after apply: \(contentLayoutSize)" - ) - - await MainActor.run { window.close() } - } - - /// Replicates the exact pattern from TerminalController.windowDidLoad(): - /// 1. Set window.contentView = container (with nil ideal sizes, simulating - /// @FocusedValue not yet propagated) - /// 2. DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(40)) - /// 3. Inside the callback: defaultSize.apply(to: window) - /// - /// This is the core race condition: 40ms is not enough for @FocusedValue - /// to propagate, so intrinsicContentSize is still tiny when apply() runs. - @Test(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "40ms async delay reads intrinsicContentSize before @FocusedValue propagates"), - arguments: allTitlebarStyles) - func asyncAfterDelayProducesWrongSizeWithNilIdealSize(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { - let container = await TerminalViewContainer { - OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle) - } - - await MainActor.run { - container.initialContentSize = NSSize(width: 600, height: 400) - } - - let window = await NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), - styleMask: styleMask(for: titlebarStyle), - backing: .buffered, - defer: false - ) - - // Replicate TerminalController.windowDidLoad() exactly: - // 1. Set contentView - // 2. DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(40)) - // 3. apply() inside the callback - let contentLayoutSize: NSSize = await withCheckedContinuation { continuation in - DispatchQueue.main.async { - window.contentView = container - - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(40)) { - let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize - defaultSize.apply(to: window) - continuation.resume(returning: window.contentLayoutRect.size) - } - } - } - - #expect( - contentLayoutSize.width >= minReasonableWidth && contentLayoutSize.height >= minReasonableHeight, - "[\(titlebarStyle)] After 40ms async delay, content layout size is too small: \(contentLayoutSize)" - ) - - await MainActor.run { window.close() } - } - - /// Verifies that applying synchronously (without the async delay) also - /// fails when ideal sizes are nil. This proves the fix must provide a - /// fallback value, not just adjust timing. - @Test(.bug("https://github.com/ghostty-org/ghostty/issues/11256", "Synchronous apply also fails without fallback"), - arguments: allTitlebarStyles) - func synchronousApplyAlsoFailsWithNilIdealSize(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { - let container = await TerminalViewContainer { - OptionalIdealSizeView(idealWidth: nil, idealHeight: nil, titlebarStyle: titlebarStyle) - } - - await MainActor.run { - container.initialContentSize = NSSize(width: 600, height: 400) - } - - let window = await NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), - styleMask: styleMask(for: titlebarStyle), - backing: .buffered, - defer: false - ) - - let contentLayoutSize = await MainActor.run { - window.contentView = container - // Apply immediately — no async delay at all - let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize - defaultSize.apply(to: window) - return window.contentLayoutRect.size - } - - #expect( - contentLayoutSize.width >= minReasonableWidth && contentLayoutSize.height >= minReasonableHeight, - "[\(titlebarStyle)] Synchronous apply with nil ideal sizes: content layout size too small: \(contentLayoutSize)" - ) - - await MainActor.run { window.close() } - } - - // MARK: - Happy path: ideal sizes available (contentIntrinsicSize path) - - /// When @FocusedValue HAS propagated (ideal sizes are set), intrinsicContentSize - /// should be correct for every titlebar style. This is the "happy path" that - /// works today when the 40ms delay is sufficient. - @Test(arguments: allTitlebarStyles) - func intrinsicSizeCorrectWhenIdealSizesAvailable(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { - let expectedSize = NSSize(width: 600, height: 400) - - let container = await TerminalViewContainer { - OptionalIdealSizeView( - idealWidth: expectedSize.width, - idealHeight: expectedSize.height, - titlebarStyle: titlebarStyle - ) - } - - let window = await NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), - styleMask: styleMask(for: titlebarStyle), - backing: .buffered, - defer: false - ) - - await MainActor.run { - window.contentView = container - } - - // Wait for SwiftUI layout - try await Task.sleep(nanoseconds: 100_000_000) - - let size = await container.intrinsicContentSize - - // intrinsicContentSize should be at least the ideal size. - // With fullSizeContentView styles it may be slightly larger - // due to safe area, but should never be smaller. - #expect( - size.width >= expectedSize.width && size.height >= expectedSize.height, - "[\(titlebarStyle)] intrinsicContentSize (\(size)) should be >= expected \(expectedSize)" - ) - - await MainActor.run { window.close() } - } - - /// Verifies that apply() sets a correctly sized window when ideal sizes - /// are available, for each titlebar style. - @Test(arguments: allTitlebarStyles) - func applyProducesCorrectSizeWhenIdealSizesAvailable(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { - let expectedSize = NSSize(width: 600, height: 400) - - let container = await TerminalViewContainer { - OptionalIdealSizeView( - idealWidth: expectedSize.width, - idealHeight: expectedSize.height, - titlebarStyle: titlebarStyle - ) - } - - let window = await NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), - styleMask: styleMask(for: titlebarStyle), - backing: .buffered, - defer: false - ) - - await MainActor.run { - window.contentView = container - } - - // Wait for SwiftUI layout before apply - try await Task.sleep(nanoseconds: 100_000_000) - - let contentLayoutSize = await MainActor.run { - let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize - defaultSize.apply(to: window) - // contentLayoutRect gives the usable area, excluding titlebar - return window.contentLayoutRect.size - } - - // The usable content area should be at least the expected size. - #expect( - contentLayoutSize.width >= expectedSize.width && contentLayoutSize.height >= expectedSize.height, - "[\(titlebarStyle)] Content layout size (\(contentLayoutSize)) should be >= expected \(expectedSize) after apply" - ) - - await MainActor.run { window.close() } - } - - /// Same async delay pattern but with ideal sizes available (happy path). - /// This should always pass — it validates the delay works when @FocusedValue - /// has already propagated. - @Test(arguments: allTitlebarStyles) - func asyncAfterDelayProducesCorrectSizeWhenIdealSizesAvailable(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { - let expectedSize = NSSize(width: 600, height: 400) - - let container = await TerminalViewContainer { - OptionalIdealSizeView( - idealWidth: expectedSize.width, - idealHeight: expectedSize.height, - titlebarStyle: titlebarStyle - ) - } - - let window = await NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), - styleMask: styleMask(for: titlebarStyle), - backing: .buffered, - defer: false - ) - - // Replicate the exact TerminalController.windowDidLoad() pattern - let contentLayoutSize: NSSize = await withCheckedContinuation { continuation in - DispatchQueue.main.async { - window.contentView = container - - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(40)) { - let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize - defaultSize.apply(to: window) - continuation.resume(returning: window.contentLayoutRect.size) - } - } - } - - #expect( - contentLayoutSize.width >= expectedSize.width && contentLayoutSize.height >= expectedSize.height, - "[\(titlebarStyle)] Content layout size (\(contentLayoutSize)) should be >= expected \(expectedSize) after 40ms delay" - ) - - await MainActor.run { window.close() } - } - - // MARK: - Without window-width/window-height (frame path) - - /// Without window-width/height config, defaultSize returns .frame or nil - /// (never .contentIntrinsicSize). The window uses its initial frame. - /// This should work for all titlebar styles regardless of the bug. - @Test(arguments: allTitlebarStyles) - func framePathWorksWithoutWindowSize(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { - let expectedFrame = NSRect(x: 100, y: 100, width: 800, height: 600) - - let container = await TerminalViewContainer { - Color.clear - } - - let window = await NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), - styleMask: styleMask(for: titlebarStyle), - backing: .buffered, - defer: false - ) - - await MainActor.run { - window.contentView = container - let defaultSize = TerminalController.DefaultSize.frame(expectedFrame) - defaultSize.apply(to: window) - } - - let frame = await MainActor.run { window.frame } - - #expect( - frame == expectedFrame, - "[\(titlebarStyle)] Window frame (\(frame)) should match expected \(expectedFrame)" - ) - - await MainActor.run { window.close() } - } - - // MARK: - isChanged - - /// Verifies isChanged correctly detects mismatch for contentIntrinsicSize - /// across titlebar styles when ideal sizes are available. - @Test(arguments: allTitlebarStyles) - func isChangedDetectsMismatch(titlebarStyle: Ghostty.Config.MacOSTitlebarStyle) async throws { - let expectedSize = NSSize(width: 600, height: 400) - - let container = await TerminalViewContainer { - OptionalIdealSizeView( - idealWidth: expectedSize.width, - idealHeight: expectedSize.height, - titlebarStyle: titlebarStyle - ) - } - - let window = await NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), - styleMask: styleMask(for: titlebarStyle), - backing: .buffered, - defer: false - ) - - await MainActor.run { - window.contentView = container - } - - try await Task.sleep(nanoseconds: 100_000_000) - - let defaultSize = TerminalController.DefaultSize.contentIntrinsicSize - - let changedBefore = await MainActor.run { defaultSize.isChanged(for: window) } - #expect(changedBefore, "[\(titlebarStyle)] isChanged should return true before apply") - - await MainActor.run { defaultSize.apply(to: window) } - - let changedAfter = await MainActor.run { defaultSize.isChanged(for: window) } - #expect(!changedAfter, "[\(titlebarStyle)] isChanged should return false after apply") - - await MainActor.run { window.close() } - } - - /// Verifies isChanged for the .frame path. - @Test func isChangedForFramePath() async throws { - let expectedFrame = NSRect(x: 100, y: 100, width: 800, height: 600) - - let window = await NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), - styleMask: [.titled, .resizable], - backing: .buffered, - defer: false - ) - - let defaultSize = TerminalController.DefaultSize.frame(expectedFrame) - - let changedBefore = await MainActor.run { defaultSize.isChanged(for: window) } - #expect(changedBefore, "isChanged should return true before apply") - - await MainActor.run { defaultSize.apply(to: window) } - - let changedAfter = await MainActor.run { defaultSize.isChanged(for: window) } - #expect(!changedAfter, "isChanged should return false after apply") - - await MainActor.run { window.close() } - } -} From c1313294cd765e41c02e0b8e048fbad1beb5f740 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 10 Mar 2026 13:29:50 -0500 Subject: [PATCH 101/391] add comments about why tests are disabled --- src/font/shaper/harfbuzz.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 10e5f99b1..30e1d0544 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1078,6 +1078,8 @@ test "shape Devanagari string" { try testing.expect(try it.next(alloc) == null); } +// This test fails on Linux if you have the "Noto Sans Tai Tham" font installed +// locally. Disabling this test until it can be fixed. test "shape Tai Tham vowels (position differs from advance)" { return error.SkipZigTest; // // Note that while this test was necessary for CoreText, the old logic was @@ -1195,6 +1197,8 @@ test "shape Tibetan characters" { try testing.expectEqual(@as(usize, 1), count); } +// This test fails on Linux if you have the "Noto Sans Tai Tham" font installed +// locally. Disabling this test until it can be fixed. test "shape Tai Tham letters (run_offset.y differs from zero)" { return error.SkipZigTest; // const testing = std.testing; @@ -1256,6 +1260,8 @@ test "shape Tai Tham letters (run_offset.y differs from zero)" { // try testing.expectEqual(@as(usize, 1), count); } +// This test fails on Linux if you have the "Noto Sans Javanese" font installed +// locally. Disabling this test until it can be fixed. test "shape Javanese ligatures" { return error.SkipZigTest; // const testing = std.testing; From 32934445cfb60e387013f4a7c4293352ac3aae44 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:29:44 +0100 Subject: [PATCH 102/391] macos: add TemporaryConfig for AI to write test cases --- macos/Sources/Ghostty/Ghostty.Config.swift | 2 +- macos/Tests/Ghostty/ConfigTests.swift | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 macos/Tests/Ghostty/ConfigTests.swift diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 4a36583d5..668db1f5d 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -53,7 +53,7 @@ extension Ghostty { /// - Parameters: /// - path: An optional preferred config file path. Pass `nil` to load the default configuration files. /// - finalize: Whether to finalize the configuration to populate default values. - static private func loadConfig(at path: String?, finalize: Bool) -> ghostty_config_t? { + static func loadConfig(at path: String?, finalize: Bool) -> ghostty_config_t? { // Initialize the global configuration. guard let cfg = ghostty_config_new() else { logger.critical("ghostty_config_new failed") diff --git a/macos/Tests/Ghostty/ConfigTests.swift b/macos/Tests/Ghostty/ConfigTests.swift new file mode 100644 index 000000000..2df5c1b73 --- /dev/null +++ b/macos/Tests/Ghostty/ConfigTests.swift @@ -0,0 +1,20 @@ +import Testing +@testable import Ghostty + +/// Create a temporary config file and delete it when this is deallocated +class TemporaryConfig: Ghostty.Config { + let temporaryFile: URL + + init(_ configText: String, finalize: Bool = false) throws { + let temporaryFile = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("ghostty") + try configText.write(to: temporaryFile, atomically: true, encoding: .utf8) + self.temporaryFile = temporaryFile + super.init(config: Self.loadConfig(at: temporaryFile.path(), finalize: finalize)) + } + + deinit { + try? FileManager.default.removeItem(at: temporaryFile) + } +} From 90dc4315e2632faeb9771536cf526c46d33fc539 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:31:18 +0100 Subject: [PATCH 103/391] macos: add test cases for Ghostty.Config properties Test boolean, string, enum, and numeric config properties using TemporaryConfig to verify defaults and parsed values. Co-Authored-By: Claude --- macos/Tests/Ghostty/ConfigTests.swift | 226 +++++++++++++++++++++++++- 1 file changed, 225 insertions(+), 1 deletion(-) diff --git a/macos/Tests/Ghostty/ConfigTests.swift b/macos/Tests/Ghostty/ConfigTests.swift index 2df5c1b73..b9c9d6a4a 100644 --- a/macos/Tests/Ghostty/ConfigTests.swift +++ b/macos/Tests/Ghostty/ConfigTests.swift @@ -1,11 +1,225 @@ import Testing @testable import Ghostty +@testable import GhosttyKit +import SwiftUI + +@Suite +struct ConfigTests { + // MARK: - Boolean Properties + + @Test func initialWindowDefaultsToTrue() throws { + let config = try TemporaryConfig("") + #expect(config.initialWindow == true) + } + + @Test func initialWindowSetToFalse() throws { + let config = try TemporaryConfig("initial-window = false") + #expect(config.initialWindow == false) + } + + @Test func quitAfterLastWindowClosedDefaultsToFalse() throws { + let config = try TemporaryConfig("") + #expect(config.shouldQuitAfterLastWindowClosed == false) + } + + @Test func quitAfterLastWindowClosedSetToTrue() throws { + let config = try TemporaryConfig("quit-after-last-window-closed = true") + #expect(config.shouldQuitAfterLastWindowClosed == true) + } + + @Test func windowStepResizeDefaultsToFalse() throws { + let config = try TemporaryConfig("") + #expect(config.windowStepResize == false) + } + + @Test func focusFollowsMouseDefaultsToFalse() throws { + let config = try TemporaryConfig("") + #expect(config.focusFollowsMouse == false) + } + + @Test func focusFollowsMouseSetToTrue() throws { + let config = try TemporaryConfig("focus-follows-mouse = true") + #expect(config.focusFollowsMouse == true) + } + + @Test func windowDecorationsDefaultsToTrue() throws { + let config = try TemporaryConfig("") + #expect(config.windowDecorations == true) + } + + @Test func windowDecorationsNone() throws { + let config = try TemporaryConfig("window-decoration = none") + #expect(config.windowDecorations == false) + } + + @Test func macosWindowShadowDefaultsToTrue() throws { + let config = try TemporaryConfig("") + #expect(config.macosWindowShadow == true) + } + + @Test func maximizeDefaultsToFalse() throws { + let config = try TemporaryConfig("") + #expect(config.maximize == false) + } + + @Test func maximizeSetToTrue() throws { + let config = try TemporaryConfig("maximize = true") + #expect(config.maximize == true) + } + + // MARK: - String / Optional String Properties + + @Test func titleDefaultsToNil() throws { + let config = try TemporaryConfig("") + #expect(config.title == nil) + } + + @Test func titleSetToCustomValue() throws { + let config = try TemporaryConfig("title = My Terminal") + #expect(config.title == "My Terminal") + } + + @Test func windowTitleFontFamilyDefaultsToNil() throws { + let config = try TemporaryConfig("") + #expect(config.windowTitleFontFamily == nil) + } + + @Test func windowTitleFontFamilySetToValue() throws { + let config = try TemporaryConfig("window-title-font-family = Menlo") + #expect(config.windowTitleFontFamily == "Menlo") + } + + // MARK: - Enum Properties + + @Test func macosTitlebarStyleDefaultsToTransparent() throws { + let config = try TemporaryConfig("") + #expect(config.macosTitlebarStyle == .transparent) + } + + @Test(arguments: [ + ("native", Ghostty.Config.MacOSTitlebarStyle.native), + ("transparent", Ghostty.Config.MacOSTitlebarStyle.transparent), + ("tabs", Ghostty.Config.MacOSTitlebarStyle.tabs), + ("hidden", Ghostty.Config.MacOSTitlebarStyle.hidden), + ]) + func macosTitlebarStyleValues(raw: String, expected: Ghostty.Config.MacOSTitlebarStyle) throws { + let config = try TemporaryConfig("macos-titlebar-style = \(raw)") + #expect(config.macosTitlebarStyle == expected) + } + + @Test func resizeOverlayDefaultsToAfterFirst() throws { + let config = try TemporaryConfig("") + #expect(config.resizeOverlay == .after_first) + } + + @Test(arguments: [ + ("always", Ghostty.Config.ResizeOverlay.always), + ("never", Ghostty.Config.ResizeOverlay.never), + ("after-first", Ghostty.Config.ResizeOverlay.after_first), + ]) + func resizeOverlayValues(raw: String, expected: Ghostty.Config.ResizeOverlay) throws { + let config = try TemporaryConfig("resize-overlay = \(raw)") + #expect(config.resizeOverlay == expected) + } + + @Test func resizeOverlayPositionDefaultsToCenter() throws { + let config = try TemporaryConfig("") + #expect(config.resizeOverlayPosition == .center) + } + + @Test func macosIconDefaultsToOfficial() throws { + let config = try TemporaryConfig("") + #expect(config.macosIcon == .official) + } + + @Test func macosIconFrameDefaultsToAluminum() throws { + let config = try TemporaryConfig("") + #expect(config.macosIconFrame == .aluminum) + } + + @Test func macosWindowButtonsDefaultsToVisible() throws { + let config = try TemporaryConfig("") + #expect(config.macosWindowButtons == .visible) + } + + @Test func scrollbarDefaultsToSystem() throws { + let config = try TemporaryConfig("") + #expect(config.scrollbar == .system) + } + + @Test func scrollbarSetToNever() throws { + let config = try TemporaryConfig("scrollbar = never") + #expect(config.scrollbar == .never) + } + + // MARK: - Numeric Properties + + @Test func backgroundOpacityDefaultsToOne() throws { + let config = try TemporaryConfig("") + #expect(config.backgroundOpacity == 1.0) + } + + @Test func backgroundOpacitySetToCustom() throws { + let config = try TemporaryConfig("background-opacity = 0.5") + #expect(config.backgroundOpacity == 0.5) + } + + @Test func windowPositionDefaultsToNil() throws { + let config = try TemporaryConfig("") + #expect(config.windowPositionX == nil) + #expect(config.windowPositionY == nil) + } + + // MARK: - Config Loading + + @Test func loadedIsTrueForValidConfig() throws { + let config = try TemporaryConfig("") + #expect(config.loaded == true) + } + + @Test func unfinalizedConfigIsLoaded() throws { + let config = try TemporaryConfig("", finalize: false) + #expect(config.loaded == true) + } + + @Test func defaultConfigIsLoaded() throws { + let config = try TemporaryConfig("") + #expect(config.optionalAutoUpdateChannel != nil) // release or tip + let config1 = try TemporaryConfig("", finalize: false) + #expect(config1.optionalAutoUpdateChannel == nil) + } + + @Test func errorsEmptyForValidConfig() throws { + let config = try TemporaryConfig("") + #expect(config.errors.isEmpty) + } + + @Test func errorsReportedForInvalidConfig() throws { + let config = try TemporaryConfig("not-a-real-key = value") + #expect(!config.errors.isEmpty) + } + + // MARK: - Multiple Config Lines + + @Test func multipleConfigValues() throws { + let config = try TemporaryConfig(""" + initial-window = false + quit-after-last-window-closed = true + maximize = true + focus-follows-mouse = true + """) + #expect(config.initialWindow == false) + #expect(config.shouldQuitAfterLastWindowClosed == true) + #expect(config.maximize == true) + #expect(config.focusFollowsMouse == true) + } +} /// Create a temporary config file and delete it when this is deallocated class TemporaryConfig: Ghostty.Config { let temporaryFile: URL - init(_ configText: String, finalize: Bool = false) throws { + init(_ configText: String, finalize: Bool = true) throws { let temporaryFile = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) .appendingPathExtension("ghostty") @@ -14,6 +228,16 @@ class TemporaryConfig: Ghostty.Config { super.init(config: Self.loadConfig(at: temporaryFile.path(), finalize: finalize)) } + var optionalAutoUpdateChannel: Ghostty.AutoUpdateChannel? { + guard let config = self.config else { return nil } + var v: UnsafePointer? + let key = "auto-update-channel" + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } + guard let ptr = v else { return nil } + let str = String(cString: ptr) + return Ghostty.AutoUpdateChannel(rawValue: str) + } + deinit { try? FileManager.default.removeItem(at: temporaryFile) } From 04d5efc8eb7b5f660bf44c0b63b9366c881e9635 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 10 Mar 2026 14:11:35 -0700 Subject: [PATCH 104/391] config: working-directory expands ~/ prefix Fixes #11336 Introduce a proper WorkingDirectory tagged union type with home, inherit, and path variants. The field is now an optional (?WorkingDirectory) where null represents "use platform default" which is resolved during Config.finalize to .inherit (CLI) or .home (desktop launcher). --- AGENTS.md | 1 - src/Surface.zig | 2 +- src/apprt/embedded.zig | 10 +- src/apprt/gtk/class/surface.zig | 12 ++- src/apprt/surface.zig | 2 +- src/config.zig | 1 + src/config/Config.zig | 181 ++++++++++++++++++++++++++++---- 7 files changed, 182 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 794115c58..3298f2160 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,6 @@ A file for [guiding coding agents](https://agents.md/). ## Commands -- Use `nix develop -c` with all commands to ensure the Nix version is used. - **Build:** `zig build` - If you're on macOS and don't need to build the macOS app, use `-Demit-macos-app=false` to skip building the app bundle and speed up diff --git a/src/Surface.zig b/src/Surface.zig index a3691b53e..b78812ac4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -639,7 +639,7 @@ pub fn init( .shell_integration = config.@"shell-integration", .shell_integration_features = config.@"shell-integration-features", .cursor_blink = config.@"cursor-style-blink", - .working_directory = config.@"working-directory", + .working_directory = if (config.@"working-directory") |wd| wd.value() else null, .resources_dir = global_state.resources_dir.host(), .term = config.term, .rt_pre_exec_info = .init(config), diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index c629be498..0d5a4f8da 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -513,7 +513,15 @@ pub const Surface = struct { break :wd; } - config.@"working-directory" = wd; + var wd_val: configpkg.WorkingDirectory = .{ .path = wd }; + if (wd_val.finalize(config.arenaAlloc())) |_| { + config.@"working-directory" = wd_val; + } else |err| { + log.warn( + "error finalizing working directory config dir={s} err={}", + .{ wd_val.path, err }, + ); + } } } diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 8ce9ac1d1..632b0de47 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3381,12 +3381,20 @@ pub const Surface = extern struct { config.command = try c.clone(config._arena.?.allocator()); } if (priv.overrides.working_directory) |wd| { - config.@"working-directory" = try config._arena.?.allocator().dupeZ(u8, wd); + const config_alloc = config.arenaAlloc(); + var wd_val: configpkg.WorkingDirectory = .{ .path = try config_alloc.dupe(u8, wd) }; + try wd_val.finalize(config_alloc); + config.@"working-directory" = wd_val; } // Properties that can impact surface init if (priv.font_size_request) |size| config.@"font-size" = size.points; - if (priv.pwd) |pwd| config.@"working-directory" = pwd; + if (priv.pwd) |pwd| { + const config_alloc = config.arenaAlloc(); + var wd_val: configpkg.WorkingDirectory = .{ .path = try config_alloc.dupe(u8, pwd) }; + try wd_val.finalize(config_alloc); + config.@"working-directory" = wd_val; + } // Initialize the surface surface.init( diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 5c25281c8..3cb0016fa 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -188,7 +188,7 @@ pub fn newConfig( if (prev) |p| { if (shouldInheritWorkingDirectory(context, config)) { if (try p.pwd(alloc)) |pwd| { - copy.@"working-directory" = pwd; + copy.@"working-directory" = .{ .path = pwd }; } } } diff --git a/src/config.zig b/src/config.zig index 0bf61a47f..314fb49ee 100644 --- a/src/config.zig +++ b/src/config.zig @@ -44,6 +44,7 @@ pub const WindowPaddingColor = Config.WindowPaddingColor; pub const BackgroundImagePosition = Config.BackgroundImagePosition; pub const BackgroundImageFit = Config.BackgroundImageFit; pub const LinkPreviews = Config.LinkPreviews; +pub const WorkingDirectory = Config.WorkingDirectory; // Alternate APIs pub const CApi = @import("config/CApi.zig"); diff --git a/src/config/Config.zig b/src/config/Config.zig index e31fbc011..aae6c9e20 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1526,13 +1526,14 @@ class: ?[:0]const u8 = null, /// `open`, then it defaults to `home`. On Linux with GTK, if Ghostty can detect /// it was launched from a desktop launcher, then it defaults to `home`. /// -/// The value of this must be an absolute value or one of the special values -/// below: +/// The value of this must be an absolute path, a path prefixed with `~/` +/// (the tilde will be expanded to the user's home directory), or +/// one of the special values below: /// /// * `home` - The home directory of the executing user. /// /// * `inherit` - The working directory of the launching process. -@"working-directory": ?[]const u8 = null, +@"working-directory": ?WorkingDirectory = null, /// Key bindings. The format is `trigger=action`. Duplicate triggers will /// overwrite previously set values. The list of actions is available in @@ -4519,23 +4520,18 @@ pub fn finalize(self: *Config) !void { } // The default for the working directory depends on the system. - const wd = self.@"working-directory" orelse if (probable_cli) - // From the CLI, we want to inherit where we were launched from. - "inherit" + var wd: WorkingDirectory = self.@"working-directory" orelse if (probable_cli) + .inherit else - // Otherwise we typically just want the home directory because - // our pwd is probably a runtime state dir or root or something - // (launchers and desktop environments typically do this). - "home"; + .home; // If we are missing either a command or home directory, we need // to look up defaults which is kind of expensive. We only do this // on desktop. - const wd_home = std.mem.eql(u8, "home", wd); if ((comptime !builtin.target.cpu.arch.isWasm()) and (comptime !builtin.is_test)) { - if (self.command == null or wd_home) command: { + if (self.command == null or wd == .home) command: { // First look up the command using the SHELL env var if needed. // We don't do this in flatpak because SHELL in Flatpak is always // set to /bin/sh. @@ -4557,7 +4553,7 @@ pub fn finalize(self: *Config) !void { self.command = .{ .shell = copy }; // If we don't need the working directory, then we can exit now. - if (!wd_home) break :command; + if (wd != .home) break :command; } else |_| {} } @@ -4568,10 +4564,12 @@ pub fn finalize(self: *Config) !void { self.command = .{ .shell = "cmd.exe" }; } - if (wd_home) { + if (wd == .home) { var buf: [std.fs.max_path_bytes]u8 = undefined; if (try internal_os.home(&buf)) |home| { - self.@"working-directory" = try alloc.dupe(u8, home); + wd = .{ .path = try alloc.dupe(u8, home) }; + } else { + wd = .inherit; } } }, @@ -4586,10 +4584,12 @@ pub fn finalize(self: *Config) !void { } } - if (wd_home) { + if (wd == .home) { if (pw.home) |home| { log.info("default working directory src=passwd value={s}", .{home}); - self.@"working-directory" = home; + wd = .{ .path = home }; + } else { + wd = .inherit; } } @@ -4600,6 +4600,8 @@ pub fn finalize(self: *Config) !void { } } } + try wd.finalize(alloc); + self.@"working-directory" = wd; // Apprt-specific defaults switch (build_config.app_runtime) { @@ -4618,10 +4620,6 @@ pub fn finalize(self: *Config) !void { }, } - // If we have the special value "inherit" then set it to null which - // does the same. In the future we should change to a tagged union. - if (std.mem.eql(u8, wd, "inherit")) self.@"working-directory" = null; - // Default our click interval if (self.@"click-repeat-interval" == 0 and (comptime !builtin.is_test)) @@ -5245,6 +5243,127 @@ pub const LinkPreviews = enum { osc8, }; +/// See working-directory +pub const WorkingDirectory = union(enum) { + const Self = @This(); + + /// Resolve to the current user's home directory during config finalize. + home, + + /// Inherit the working directory from the launching process. + inherit, + + /// Use an explicit working directory path. This may be not be + /// expanded until finalize is called. + path: []const u8, + + pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void { + var input = input_ orelse return error.ValueRequired; + input = std.mem.trim(u8, input, &std.ascii.whitespace); + if (input.len == 0) return error.ValueRequired; + + // Match path.zig behavior for quoted values. + if (input.len >= 2 and input[0] == '"' and input[input.len - 1] == '"') { + input = input[1 .. input.len - 1]; + } + + if (std.mem.eql(u8, input, "home")) { + self.* = .home; + return; + } + + if (std.mem.eql(u8, input, "inherit")) { + self.* = .inherit; + return; + } + + self.* = .{ .path = try alloc.dupe(u8, input) }; + } + + /// Expand tilde paths in .path values. + pub fn finalize(self: *Self, alloc: Allocator) Allocator.Error!void { + const path = switch (self.*) { + .path => |path| path, + else => return, + }; + + if (!std.mem.startsWith(u8, path, "~/")) return; + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const expanded = internal_os.expandHome(path, &buf) catch |err| { + log.warn( + "error expanding home directory for working-directory path={s}: {}", + .{ path, err }, + ); + return; + }; + + if (std.mem.eql(u8, expanded, path)) return; + self.* = .{ .path = try alloc.dupe(u8, expanded) }; + } + + pub fn value(self: Self) ?[]const u8 { + return switch (self) { + .path => |path| path, + .home, .inherit => null, + }; + } + + pub fn clone(self: Self, alloc: Allocator) Allocator.Error!Self { + return switch (self) { + .path => |path| .{ .path = try alloc.dupe(u8, path) }, + else => self, + }; + } + + pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { + switch (self) { + .home, .inherit => try formatter.formatEntry([]const u8, @tagName(self)), + .path => |path| try formatter.formatEntry([]const u8, path), + } + } + + test "WorkingDirectory parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var wd: Self = .inherit; + + try wd.parseCLI(alloc, "inherit"); + try testing.expectEqual(.inherit, wd); + + try wd.parseCLI(alloc, "home"); + try testing.expectEqual(.home, wd); + + try wd.parseCLI(alloc, "~/projects/ghostty"); + try testing.expectEqualStrings("~/projects/ghostty", wd.path); + + try wd.parseCLI(alloc, "\"/tmp path\""); + try testing.expectEqualStrings("/tmp path", wd.path); + } + + test "WorkingDirectory finalize" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + { + var wd: Self = .{ .path = "~/projects/ghostty" }; + try wd.finalize(alloc); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const expected = internal_os.expandHome( + "~/projects/ghostty", + &buf, + ) catch "~/projects/ghostty"; + try testing.expectEqualStrings(expected, wd.value().?); + } + } +}; + /// Color represents a color using RGB. /// /// This is a packed struct so that the C API to read color values just @@ -10309,6 +10428,26 @@ test "clone preserves conditional set" { try testing.expect(clone1._conditional_set.contains(.theme)); } +test "working-directory expands tilde" { + const testing = std.testing; + const alloc = testing.allocator; + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--working-directory=~/projects/ghostty", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const expected = internal_os.expandHome( + "~/projects/ghostty", + &buf, + ) catch "~/projects/ghostty"; + try testing.expectEqualStrings(expected, cfg.@"working-directory".?.value().?); +} + test "changed" { const testing = std.testing; const alloc = testing.allocator; From f9862cd4e27daf72e8e983646451a0954a47258b Mon Sep 17 00:00:00 2001 From: Steve Hulet Date: Tue, 10 Mar 2026 16:14:18 -0700 Subject: [PATCH 105/391] GTK does support scrollbars --- src/config/Config.zig | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index aae6c9e20..f496a4cf2 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1398,8 +1398,6 @@ input: RepeatableReadableIO = .{}, /// * `never` - Never show a scrollbar. You can still scroll using the mouse, /// keybind actions, etc. but you will not have a visual UI widget showing /// a scrollbar. -/// -/// This only applies to macOS currently. GTK doesn't yet support scrollbars. scrollbar: Scrollbar = .system, /// Match a regular expression against the terminal text and associate clicking From 615af975f3365ea85594be7ebbc6ae90cac9558c Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:26:26 +0000 Subject: [PATCH 106/391] Update VOUCHED list (#11344) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/11343#discussioncomment-16075282) from @jcollie. Vouch: @hulet Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index dfaea1326..90058c813 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -77,6 +77,7 @@ guilhermetk hakonhagland halosatrio hqnna +hulet icodesign jacobsandlund jake-stewart From 85bec8033474438182fbb33ded8dfcdcb009ea6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 00:14:04 +0000 Subject: [PATCH 107/391] build(deps): bump cachix/install-nix-action from 31.10.0 to 31.10.1 Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.10.0 to 31.10.1. - [Release notes](https://github.com/cachix/install-nix-action/releases) - [Changelog](https://github.com/cachix/install-nix-action/blob/master/RELEASE.md) - [Commits](https://github.com/cachix/install-nix-action/compare/19effe9fe722874e6d46dd7182e4b8b7a43c4a99...1ca7d21a94afc7c957383a2d217460d980de4934) --- updated-dependencies: - dependency-name: cachix/install-nix-action dependency-version: 31.10.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 2 +- .github/workflows/release-tip.yml | 4 +- .github/workflows/test.yml | 52 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 5 files changed, 31 insertions(+), 31 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index fe3dd1336..c6535030c 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -47,7 +47,7 @@ jobs: /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index f7d4a7b6e..2a3770a13 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -89,7 +89,7 @@ jobs: /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index a2d8c1078..326b29439 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -42,7 +42,7 @@ jobs: with: path: | /nix - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -175,7 +175,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e311089e2..719b5b152 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -156,7 +156,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -199,7 +199,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -232,7 +232,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -266,7 +266,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -310,7 +310,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -387,7 +387,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -433,7 +433,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -462,7 +462,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -495,7 +495,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -541,7 +541,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -801,7 +801,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -843,7 +843,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -891,7 +891,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -926,7 +926,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -1001,7 +1001,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -1032,7 +1032,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -1074,7 +1074,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -1105,7 +1105,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -1135,7 +1135,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -1193,7 +1193,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -1221,7 +1221,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -1249,7 +1249,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -1282,7 +1282,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -1310,7 +1310,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -1347,7 +1347,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 @@ -1409,7 +1409,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + - uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 762b3d007..4c159a815 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -29,7 +29,7 @@ jobs: /zig - name: Setup Nix - uses: cachix/install-nix-action@19effe9fe722874e6d46dd7182e4b8b7a43c4a99 # v31.10.0 + uses: cachix/install-nix-action@1ca7d21a94afc7c957383a2d217460d980de4934 # v31.10.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 From 6dd5b856b05fbcb76f415ad18fbdfac600c3abde Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 10 Mar 2026 19:40:52 -0700 Subject: [PATCH 108/391] macos: disable Tahoe one-time codes This disables all the automatic one-time code inputs in Ghostty. It'd be really neat to actually dynamically change this (not sure if its possible with NSTextContext or how often thats cached) but for now we should just fully disable it. --- macos/Ghostty-Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist index 01ccd7b11..7ffe12c39 100644 --- a/macos/Ghostty-Info.plist +++ b/macos/Ghostty-Info.plist @@ -2,6 +2,8 @@ + NSAutoFillRequiresTextContentTypeForOneTimeCodeOnMac + NSDockTilePlugIn DockTilePlugin.plugin CFBundleDocumentTypes From ad6d3665c29b7e2db4da7e2a5fe67239d0f3df32 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 11 Mar 2026 02:23:12 -0500 Subject: [PATCH 109/391] gtk: fix +new-window `--working-directory` inferrence. If the CLI argument `--working-directory` is not used with `+new-window`, the current working directory that `ghostty +new-window` is run from will be appended to the list of configuration data sent to the main Ghostty process. If `-e` _was_ used on the CLI, the `--working-directory` that was appended will be interpreted as part of the command to be executed, likely causing it to fail. Instead, insert `--working-directory` at the beginning of the list of configuration that it sent to the main Ghostty process. Fixes #11356 --- src/cli/new_window.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cli/new_window.zig b/src/cli/new_window.zig index 12acafadf..a89c4ffab 100644 --- a/src/cli/new_window.zig +++ b/src/cli/new_window.zig @@ -198,7 +198,8 @@ fn runArgs( const cwd: std.fs.Dir = std.fs.cwd(); var buf: [std.fs.max_path_bytes]u8 = undefined; const wd = try cwd.realpath(".", &buf); - try opts._arguments.append(alloc, try std.fmt.allocPrintSentinel(alloc, "--working-directory={s}", .{wd}, 0)); + // This should be inserted at the beginning of the list, just in case `-e` was used. + try opts._arguments.insert(alloc, 0, try std.fmt.allocPrintSentinel(alloc, "--working-directory={s}", .{wd}, 0)); } var arena = ArenaAllocator.init(alloc_gpa); From a644fca5c5e74850312f13ed69f9677556abcd27 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:02:29 +0000 Subject: [PATCH 110/391] Update VOUCHED list (#11360) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/11358#discussioncomment-16080010) from @jcollie. Vouch: @puzza007 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 90058c813..f680a6570 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -138,6 +138,7 @@ pluiedev pouwerkerk prakhar54-byte priyans-hu +puzza007 qwerasd205 reo101 rgehan From 82a805296c3b45235571ecfa3b75821d9ca264b5 Mon Sep 17 00:00:00 2001 From: Paul Oliver Date: Wed, 11 Mar 2026 21:09:03 +1300 Subject: [PATCH 111/391] docs: fix backtick rendering in selection-word-chars default value The default value contains a literal backtick which broke inline code rendering on the website. Use double backtick delimiters to properly contain it. --- src/config/Config.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index f496a4cf2..676a9554e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -749,7 +749,7 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// The null character (U+0000) is always treated as a boundary and does not /// need to be included in this configuration. /// -/// Default: ` \t'"│`|:;,()[]{}<>$` +/// Default: `` \t'"│`|:;,()[]{}<>$ `` /// /// To add or remove specific characters, you can set this to a custom value. /// For example, to treat semicolons as part of words: From 23f3cd5f101fedcff6350648f8ba3993e6c55d90 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Wed, 11 Mar 2026 10:07:54 -0400 Subject: [PATCH 112/391] zsh: improve prompt marking with dynamic themes Replace the strip-in-preexec / re-add-in-precmd pattern for OSC 133 marks with a save/restore approach. Instead of pattern-matching marks out of PS1 (which exposes PS1 in intermediate states to other hooks), we save the original PS1/PS2 before adding marks and then restore them. This also adds dynamic theme detection: if PS1 changed between cycles (e.g., a theme rebuilt it), we skip injecting continuation marks into newlines. This prevents breaking plugins like Pure that use pattern matching to strip/rebuild the prompt. Additionally, move _ghostty_precmd to the end of precmd_functions in _ghostty_deferred_init (instead of substituting in-place) so that the first prompt is properly marked even when other hooks were appended after our auto-injection. There's one scenario that we still don't complete cover: precmd_functions+=(_test_overwrite_ps1) _test_overwrite_ps1() { PS1="test> " } ... which results in the first prompt not printing its prompt marks because _test_overwrite_ps1 becomes the last thing to run, overwriting our marks, but this will be fixed for subsequent prompts when we move our handler back to the last index. Fixes: #11282 --- src/shell-integration/zsh/ghostty-integration | 80 ++++++++++++------- 1 file changed, 52 insertions(+), 28 deletions(-) diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 7442546f8..dc9bd1605 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -131,32 +131,59 @@ _ghostty_deferred_init() { # SIGCHLD if notify is set. Themes that update prompt # asynchronously from a `zle -F` handler might still remove our # marks. Oh well. + + # Restore PS1/PS2 to their pre-mark state if nothing else has + # modified them since we last added marks. This avoids exposing + # PS1 with our marks to other hooks (which can break themes like + # Pure that use pattern matching to strip/rebuild the prompt). + # If PS1 was modified (by a theme, async update, etc.), we + # keep the modified version, prioritizing the theme's changes. + builtin local ps1_changed=0 + if [[ -n ${_ghostty_saved_ps1+x} ]]; then + if [[ $PS1 == $_ghostty_marked_ps1 ]]; then + PS1=$_ghostty_saved_ps1 + PS2=$_ghostty_saved_ps2 + elif [[ $PS1 != $_ghostty_saved_ps1 ]]; then + ps1_changed=1 + fi + fi + + # Save the clean PS1/PS2 before we add marks. + _ghostty_saved_ps1=$PS1 + _ghostty_saved_ps2=$PS2 + + # Add our marks. Since we always start from a clean PS1 + # (either restored above or freshly set by a theme), we can + # unconditionally add mark1 and markB. builtin local mark2=$'%{\e]133;A;k=s\a%}' builtin local markB=$'%{\e]133;B\a%}' - # Add marks conditionally to avoid a situation where we have - # several marks in place. These conditions can have false - # positives and false negatives though. - # - # - False positive (with prompt_percent): PS1="%(?.$mark1.)" - # - False negative (with prompt_subst): PS1='$mark1' - [[ $PS1 == *$mark1* ]] || PS1=${mark1}${PS1} - [[ $PS1 == *$markB* ]] || PS1=${PS1}${markB} + PS1=${mark1}${PS1}${markB} + # Handle multiline prompts by marking newline-separated # continuation lines with k=s (mark2). We skip the newline # immediately after mark1 to avoid introducing a double # newline due to OSC 133;A's fresh-line behavior. - if [[ $PS1 == ${mark1}$'\n'* ]]; then - builtin local rest=${PS1#${mark1}$'\n'} - if [[ $rest == *$'\n'* ]]; then - PS1=${mark1}$'\n'${rest//$'\n'/$'\n'${mark2}} + # + # We skip this when PS1 changed because injecting marks into + # newlines can break pattern matching in themes that + # strip/rebuild the prompt dynamically (e.g., Pure). + if (( ! ps1_changed )) && [[ $PS1 == *$'\n'* ]]; then + if [[ $PS1 == ${mark1}$'\n'* ]]; then + builtin local rest=${PS1#${mark1}$'\n'} + if [[ $rest == *$'\n'* ]]; then + PS1=${mark1}$'\n'${rest//$'\n'/$'\n'${mark2}} + fi + else + PS1=${PS1//$'\n'/$'\n'${mark2}} fi - elif [[ $PS1 == *$'\n'* ]]; then - PS1=${PS1//$'\n'/$'\n'${mark2}} fi # PS2 mark is needed when clearing the prompt on resize - [[ $PS2 == *$mark2* ]] || PS2=${mark2}${PS2} - [[ $PS2 == *$markB* ]] || PS2=${PS2}${markB} + PS2=${mark2}${PS2}${markB} + + # Save the marked PS1 so we can detect modifications + # by other hooks in the next cycle. + _ghostty_marked_ps1=$PS1 (( _ghostty_state = 2 )) else # If our precmd hook is not the last, we cannot rely on prompt @@ -188,17 +215,14 @@ _ghostty_deferred_init() { _ghostty_preexec() { builtin emulate -L zsh -o no_warn_create_global -o no_aliases - # This can potentially break user prompt. Oh well. The robustness of - # this code can be improved in the case prompt_subst is set because - # it'll allow us distinguish (not perfectly but close enough) between - # our own prompt, user prompt, and our own prompt with user additions on - # top. We cannot force prompt_subst on the user though, so we would - # still need this code for the no_prompt_subst case. - PS1=${PS1//$'%{\e]133;A;cl=line\a%}'} - PS1=${PS1//$'%{\e]133;A;k=s\a%}'} - PS1=${PS1//$'%{\e]133;B\a%}'} - PS2=${PS2//$'%{\e]133;A;k=s\a%}'} - PS2=${PS2//$'%{\e]133;B\a%}'} + # Restore the original PS1/PS2 if nothing else has modified them + # since our precmd added marks. This ensures other preexec hooks + # see a clean PS1 without our marks. If PS1 was modified (e.g., + # by an async theme update), we leave it alone. + if [[ -n ${_ghostty_saved_ps1+x} && $PS1 == $_ghostty_marked_ps1 ]]; then + PS1=$_ghostty_saved_ps1 + PS2=$_ghostty_saved_ps2 + fi # This will work incorrectly in the presence of a preexec hook that # prints. For example, if MichaelAquilina/zsh-you-should-use installs @@ -419,7 +443,7 @@ _ghostty_deferred_init() { builtin typeset -ag precmd_functions if (( $+functions[_ghostty_precmd] )); then - precmd_functions=(${precmd_functions:/_ghostty_deferred_init/_ghostty_precmd}) + precmd_functions=(${precmd_functions:#_ghostty_deferred_init} _ghostty_precmd) _ghostty_precmd else precmd_functions=(${precmd_functions:#_ghostty_deferred_init}) From 87e496b30ff62a08e6dbdea651d86ea18b50493a Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:17:51 +0000 Subject: [PATCH 113/391] Update VOUCHED list (#11368) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/11365#issuecomment-4039534706) from @mitchellh. Vouch: @ydah Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index f680a6570..044adc003 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -163,5 +163,6 @@ uzaaft vaughanandrews vlsi yamshta +ydah zenyr zeshi09 From c2206542d3bcb1b88eb4196620e553dad0717ca4 Mon Sep 17 00:00:00 2001 From: ydah Date: Wed, 11 Mar 2026 21:33:16 +0900 Subject: [PATCH 114/391] macos: fix tab title rename hit testing and focus handling in fullscreen mode --- .../Extensions/NSWindow+Extension.swift | 12 ++++-- macos/Sources/Helpers/TabTitleEditor.swift | 43 +++++++++++++++---- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index 3c5cbd23a..46758a42d 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -85,13 +85,17 @@ extension NSWindow { /// Returns the visual tab index and matching tab button at the given screen point. func tabButtonHit(atScreenPoint screenPoint: NSPoint) -> (index: Int, tabButton: NSView)? { - guard let tabBarView else { return nil } - let locationInWindow = convertPoint(fromScreen: screenPoint) - let locationInTabBar = tabBarView.convert(locationInWindow, from: nil) + guard let tabBarView, let tabBarWindow = tabBarView.window else { return nil } + + // In fullscreen, AppKit can host the titlebar and tab bar in a separate + // NSToolbarFullScreenWindow. Hit testing has to use that window's base + // coordinate space or content clicks can be misinterpreted as tab clicks. + let locationInTabBarWindow = tabBarWindow.convertPoint(fromScreen: screenPoint) + let locationInTabBar = tabBarView.convert(locationInTabBarWindow, from: nil) guard tabBarView.bounds.contains(locationInTabBar) else { return nil } for (index, tabButton) in tabButtonsInVisualOrder().enumerated() { - let locationInTabButton = tabButton.convert(locationInWindow, from: nil) + let locationInTabButton = tabButton.convert(locationInTabBarWindow, from: nil) if tabButton.bounds.contains(locationInTabButton) { return (index, tabButton) } diff --git a/macos/Sources/Helpers/TabTitleEditor.swift b/macos/Sources/Helpers/TabTitleEditor.swift index 570be1bf4..4be2c5306 100644 --- a/macos/Sources/Helpers/TabTitleEditor.swift +++ b/macos/Sources/Helpers/TabTitleEditor.swift @@ -40,6 +40,8 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { private weak var hostWindow: NSWindow? /// Delegate that provides and commits title data for target tab windows. private weak var delegate: TabTitleEditorDelegate? + /// Local event monitor so fullscreen titlebar-window clicks can also trigger rename. + private var eventMonitor: Any? /// Active inline editor view, if editing is in progress. private weak var inlineTitleEditor: NSTextField? @@ -52,8 +54,24 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { /// Creates a coordinator bound to a host window and rename delegate. init(hostWindow: NSWindow, delegate: TabTitleEditorDelegate) { + super.init() + self.hostWindow = hostWindow self.delegate = delegate + + // This is needed so that fullscreen clicks can register since they won't + // event on the NSWindow. We may want to tighten this up in the future by + // only doing this if we're fullscreen. + self.eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in + guard let self else { return event } + return handleMouseDown(event) ? nil : event + } + } + + deinit { + if let eventMonitor { + NSEvent.removeMonitor(eventMonitor) + } } /// Handles leftMouseDown events from the host window and begins inline edit if possible. If this @@ -64,8 +82,15 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { // If we don't have a host window to look up the click, we do nothing. guard let hostWindow else { return false } + // In native fullscreen, AppKit can route titlebar clicks through a detached + // NSToolbarFullScreenWindow. Only allow clicks from the host window or its + // fullscreen tab bar window so rename handling stays scoped to this tab strip. + let sourceWindow = event.window ?? hostWindow + guard sourceWindow === hostWindow || sourceWindow === hostWindow.tabBarView?.window + else { return false } + // Find the tab window that is being clicked. - let locationInScreen = hostWindow.convertPoint(toScreen: event.locationInWindow) + let locationInScreen = sourceWindow.convertPoint(toScreen: event.locationInWindow) guard let tabIndex = hostWindow.tabIndex(atScreenPoint: locationInScreen), let targetWindow = hostWindow.tabbedWindows?[safe: tabIndex], delegate?.tabTitleEditor(self, canRenameTabFor: targetWindow) == true @@ -171,9 +196,11 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { // Focus after insertion so AppKit has created the field editor for this text field. DispatchQueue.main.async { [weak hostWindow, weak editor] in - guard let hostWindow, let editor else { return } + guard let editor else { return } + let responderWindow = editor.window ?? hostWindow + guard let responderWindow else { return } editor.isHidden = false - hostWindow.makeFirstResponder(editor) + responderWindow.makeFirstResponder(editor) if let fieldEditor = editor.currentEditor() as? NSTextView, let editorFont = editor.font { fieldEditor.font = editorFont @@ -204,11 +231,11 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { inlineTitleTargetWindow = nil // Make sure the window grabs focus again - if let hostWindow { - if let currentEditor = editor.currentEditor(), hostWindow.firstResponder === currentEditor { - hostWindow.makeFirstResponder(nil) - } else if hostWindow.firstResponder === editor { - hostWindow.makeFirstResponder(nil) + if let responderWindow = editor.window ?? hostWindow { + if let currentEditor = editor.currentEditor(), responderWindow.firstResponder === currentEditor { + responderWindow.makeFirstResponder(nil) + } else if responderWindow.firstResponder === editor { + responderWindow.makeFirstResponder(nil) } } From 26d8bd9e71c27f1f7f31a1079bee3ca79e79b205 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Wed, 11 Mar 2026 10:41:57 -0400 Subject: [PATCH 115/391] bash: fix multiline PS1 with command substitutions Only replace the \n prompt escape when inserting secondary prompt marks, not literal newlines ($'\n'). Literal newlines may appear inside $(...) or `...` command substitutions, and inserting escape sequences there breaks the shell syntax. For example: PS1='$(if [ $? -eq 0 ]; then echo -e "P"; else echo -e "F"; fi) $ ' The literal newlines between the if/else/fi are part of the shell syntax inside the command substitution. The previous code replaced all literal newlines in PS1 with newline + OSC 133 escape sequences, which injected terminal escapes into the middle of the command substitution and caused bash to report a syntax error when evaluating it. The \n prompt escape is PS1-specific and safe to replace globally. This means prompts using literal newlines for line breaks (rather than \n) won't get per-line secondary marks, but this is the conventional form and avoids the need for complex shell parsing. Fixes: #11267 --- src/shell-integration/bash/ghostty.bash | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index a369e8f75..6e516c730 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -201,14 +201,16 @@ function __ghostty_precmd() { PS1='\[\e]133;A;redraw=last;cl=line;aid='"$BASHPID"'\a\]'$PS1'\[\e]133;B\a\]' PS2='\[\e]133;A;k=s\a\]'$PS2'\[\e]133;B\a\]' - # Bash doesn't redraw the leading lines in a multiline prompt so - # we mark the start of each line (after each newline) as a secondary - # prompt. This correctly handles multiline prompts by setting the first - # to primary and the subsequent lines to secondary. - if [[ "${PS1}" == *"\n"* || "${PS1}" == *$'\n'* ]]; then - builtin local __ghostty_mark=$'\\[\\e]133;A;k=s\\a\\]' - PS1="${PS1//$'\n'/$'\n'$__ghostty_mark}" - PS1="${PS1//\\n/\\n$__ghostty_mark}" + # Bash doesn't redraw the leading lines in a multiline prompt so we mark + # the start of each line (after each newline) as a secondary prompt. This + # correctly handles multiline prompts by setting the first to primary and + # the subsequent lines to secondary. + # + # We only replace the \n prompt escape, not literal newlines ($'\n'), + # because literal newlines may appear inside $(...) command substitutions + # where inserting escape sequences would break shell syntax. + if [[ "$PS1" == *"\n"* ]]; then + PS1="${PS1//\\n/\\n$'\\[\\e]133;A;k=s\\a\\]'}" fi # Cursor From f571c806fec71a7de5b5ca0afc35eed92fa3cf9f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Mar 2026 08:37:51 -0700 Subject: [PATCH 116/391] ci: skip vouched PRs for milestone attachment --- .github/workflows/milestone.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/milestone.yml b/.github/workflows/milestone.yml index 33a074159..05d1f83c8 100644 --- a/.github/workflows/milestone.yml +++ b/.github/workflows/milestone.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Set Milestone for PR uses: hustcer/milestone-action@ebed8d5daafd855a600d7e665c1b130f06d24130 # v3.1 - if: github.event.pull_request.merged == true + if: github.event.pull_request.merged == true && !contains(github.event.pull_request.title, 'VOUCHED') && !startsWith(github.event.pull_request.title, 'ci:') with: action: bind-pr # `bind-pr` is the default action github-token: ${{ secrets.GITHUB_TOKEN }} From 86c2a2e87faa5996ac856c65718c0765be3fa3d0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Mar 2026 09:25:08 -0700 Subject: [PATCH 117/391] input: add direct set_surface_title and set_tab_title actions Fixes #11316 This mirrors the `prompt` actions (hence why there is no window action here) and enables setting titles via keybind actions which importantly lets this work via command palettes, App Intents, AppleScript, etc. --- include/ghostty.h | 2 ++ macos/Sources/Ghostty/Ghostty.App.swift | 30 ++++++++++++++++++++++ src/Surface.zig | 20 +++++++++++++++ src/apprt/action.zig | 4 +++ src/apprt/gtk/class/application.zig | 25 ++++++++++++++++++ src/input/Binding.zig | 34 +++++++++++++++++++++++++ src/input/command.zig | 2 ++ 7 files changed, 117 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index afd89542f..40ff55c9b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -889,6 +889,7 @@ typedef enum { GHOSTTY_ACTION_RENDER_INSPECTOR, GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, + GHOSTTY_ACTION_SET_TAB_TITLE, GHOSTTY_ACTION_PROMPT_TITLE, GHOSTTY_ACTION_PWD, GHOSTTY_ACTION_MOUSE_SHAPE, @@ -937,6 +938,7 @@ typedef union { ghostty_action_inspector_e inspector; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; + ghostty_action_set_title_s set_tab_title; ghostty_action_prompt_title_e prompt_title; ghostty_action_pwd_s pwd; ghostty_action_mouse_shape_e mouse_shape; diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index d57c2ea11..a341df59a 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -539,6 +539,9 @@ extension Ghostty { case GHOSTTY_ACTION_SET_TITLE: setTitle(app, target: target, v: action.action.set_title) + case GHOSTTY_ACTION_SET_TAB_TITLE: + return setTabTitle(app, target: target, v: action.action.set_tab_title) + case GHOSTTY_ACTION_PROMPT_TITLE: return promptTitle(app, target: target, v: action.action.prompt_title) @@ -1602,6 +1605,33 @@ extension Ghostty { } } + private static func setTabTitle( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_set_title_s + ) -> Bool { + switch target.tag { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("set tab title does nothing with an app target") + return false + + case GHOSTTY_TARGET_SURFACE: + guard let title = String(cString: v.title!, encoding: .utf8) else { return false } + let titleOverride = title.isEmpty ? nil : title + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + guard let window = surfaceView.window, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.titleOverride = titleOverride + return true + + default: + assertionFailure() + return false + } + } + private static func copyTitleToClipboard( _ app: ghostty_app_t, target: ghostty_target_s) -> Bool { diff --git a/src/Surface.zig b/src/Surface.zig index b78812ac4..4d66622e3 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5482,6 +5482,26 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .tab, ), + .set_surface_title => |v| { + const title = try self.alloc.dupeZ(u8, v); + defer self.alloc.free(title); + return try self.rt_app.performAction( + .{ .surface = self }, + .set_title, + .{ .title = title }, + ); + }, + + .set_tab_title => |v| { + const title = try self.alloc.dupeZ(u8, v); + defer self.alloc.free(title); + return try self.rt_app.performAction( + .{ .surface = self }, + .set_tab_title, + .{ .title = title }, + ); + }, + .clear_screen => { // This is a duplicate of some of the logic in termio.clearScreen // but we need to do this here so we can know the answer before diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 55e80a700..f6865af83 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -201,6 +201,9 @@ pub const Action = union(Key) { /// Set the title of the target to the requested value. set_title: SetTitle, + /// Set the tab title override for the target's tab. + set_tab_title: SetTitle, + /// Set the title of the target to a prompted value. It is up to /// the apprt to prompt. The value specifies whether to prompt for the /// surface title or the tab title. @@ -375,6 +378,7 @@ pub const Action = union(Key) { render_inspector, desktop_notification, set_title, + set_tab_title, prompt_title, pwd, mouse_shape, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index c3ff51e0f..039e853aa 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -740,6 +740,7 @@ pub const Application = extern struct { .scrollbar => Action.scrollbar(target, value), .set_title => Action.setTitle(target, value), + .set_tab_title => return Action.setTabTitle(target, value), .show_child_exited => return Action.showChildExited(target, value), @@ -2545,6 +2546,30 @@ const Action = struct { } } + pub fn setTabTitle( + target: apprt.Target, + value: apprt.action.SetTitle, + ) bool { + switch (target) { + .app => { + log.warn("set_tab_title to app is unexpected", .{}); + return false; + }, + .surface => |core| { + const surface = core.rt_surface.surface; + const tab = ext.getAncestor( + Tab, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a tab, ignoring set_tab_title", .{}); + return false; + }; + tab.setTitleOverride(if (value.title.len == 0) null else value.title); + return true; + }, + } + } + pub fn showChildExited( target: apprt.Target, value: apprt.surface.Message.ChildExited, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 286c8f2ed..62a4e39ac 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -577,6 +577,16 @@ pub const Action = union(enum) { /// and persists across focus changes within the tab. prompt_tab_title, + /// Set the title for the current focused surface. + /// + /// If the title is empty, the surface title is reset to an empty title. + set_surface_title: []const u8, + + /// Set the title for the current focused tab. + /// + /// If the title is empty, the tab title override is cleared. + set_tab_title: []const u8, + /// Create a new split in the specified direction. /// /// Valid arguments: @@ -1324,6 +1334,8 @@ pub const Action = union(enum) { .set_font_size, .prompt_surface_title, .prompt_tab_title, + .set_surface_title, + .set_tab_title, .clear_screen, .select_all, .scroll_to_top, @@ -3292,6 +3304,16 @@ test "parse: action with string" { try testing.expect(binding.action == .esc); try testing.expectEqualStrings("A", binding.action.esc); } + { + const binding = try parseSingle("a=set_surface_title:surface"); + try testing.expect(binding.action == .set_surface_title); + try testing.expectEqualStrings("surface", binding.action.set_surface_title); + } + { + const binding = try parseSingle("a=set_tab_title:tab"); + try testing.expect(binding.action == .set_tab_title); + try testing.expectEqualStrings("tab", binding.action.set_tab_title); + } } test "parse: action with enum" { @@ -4557,6 +4579,18 @@ test "action: format" { try testing.expectEqualStrings("text:\\xf0\\x9f\\x91\\xbb", buf.written()); } +test "action: format set title" { + const testing = std.testing; + const alloc = testing.allocator; + + const a: Action = .{ .set_tab_title = "foo bar" }; + + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try a.format(&buf.writer); + try testing.expectEqualStrings("set_tab_title:foo bar", buf.written()); +} + test "set: appendChain with no parent returns error" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/input/command.zig b/src/input/command.zig index f50e6840b..ac048eec0 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -689,6 +689,8 @@ fn actionCommands(action: Action.Key) []const Command { .esc, .cursor_key, .set_font_size, + .set_surface_title, + .set_tab_title, .search, .scroll_to_row, .scroll_page_fractional, From a8d38fe5d807e8cf18f99dcef117355d02048d7c Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:28:08 +0000 Subject: [PATCH 118/391] Update VOUCHED list (#11374) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/11372#discussioncomment-16086042) from @mitchellh. Vouch: @faukah Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 044adc003..3f52c09a9 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -65,6 +65,7 @@ dzhlobo elias8 ephemera eriksremess +faukah filip7 flou francescarpi From 9503fa0786d3e79a5862361ae59db6d5972b4eae Mon Sep 17 00:00:00 2001 From: faukah Date: Wed, 11 Mar 2026 16:49:30 +0100 Subject: [PATCH 119/391] nix: bump zig-overlay version --- flake.lock | 32 ++++++-------------------------- flake.nix | 2 -- 2 files changed, 6 insertions(+), 28 deletions(-) diff --git a/flake.lock b/flake.lock index 6f12f66b9..b8e6d9263 100644 --- a/flake.lock +++ b/flake.lock @@ -16,24 +16,6 @@ "type": "github" } }, - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "home-manager": { "inputs": { "nixpkgs": [ @@ -70,7 +52,6 @@ "root": { "inputs": { "flake-compat": "flake-compat", - "flake-utils": "flake-utils", "home-manager": "home-manager", "nixpkgs": "nixpkgs", "zig": "zig", @@ -78,6 +59,7 @@ } }, "systems": { + "flake": false, "locked": { "lastModified": 1681028828, "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", @@ -97,19 +79,17 @@ "flake-compat": [ "flake-compat" ], - "flake-utils": [ - "flake-utils" - ], "nixpkgs": [ "nixpkgs" - ] + ], + "systems": "systems" }, "locked": { - "lastModified": 1763295135, - "narHash": "sha256-sGv/NHCmEnJivguGwB5w8LRmVqr1P72OjS+NzcJsssE=", + "lastModified": 1773145353, + "narHash": "sha256-dE8zx8WA54TRmFFQBvA48x/sXGDTP7YaDmY6nNKMAYw=", "owner": "mitchellh", "repo": "zig-overlay", - "rev": "64f8b42cfc615b2cf99144adf2b7728c7847c72a", + "rev": "8666155d83bf792956a7c40915508e6d4b2b8716", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index e063f2d70..61ca39ab1 100644 --- a/flake.nix +++ b/flake.nix @@ -10,7 +10,6 @@ # Gnome 49/Gtk 4.20. # nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"; - flake-utils.url = "github:numtide/flake-utils"; # Used for shell.nix flake-compat = { @@ -22,7 +21,6 @@ url = "github:mitchellh/zig-overlay"; inputs = { nixpkgs.follows = "nixpkgs"; - flake-utils.follows = "flake-utils"; flake-compat.follows = "flake-compat"; }; }; From 0af9938ad2f2fb84d8e00501716933029bc0ba65 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:26:26 +0100 Subject: [PATCH 120/391] macos: add UI test for window position restore across titlebar styles Tests that window position and size are correctly restored after reopen for all four macos-titlebar-style variants. Co-Authored-By: Claude Opus 4.6 --- .../GhosttyWindowPositionUITests.swift | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 macos/GhosttyUITests/GhosttyWindowPositionUITests.swift diff --git a/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift new file mode 100644 index 000000000..53a0d800a --- /dev/null +++ b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift @@ -0,0 +1,158 @@ +// +// GhosttyWindowPositionUITests.swift +// GhosttyUITests +// +// Created by Claude on 2026-03-11. +// + +import XCTest + +final class GhosttyWindowPositionUITests: GhosttyCustomConfigCase { + override static var runsForEachTargetApplicationUIConfiguration: Bool { false } + + // MARK: - Restore round-trip per titlebar style + + @MainActor func testRestoredNative() throws { try runRestoreTest(titlebarStyle: "native") } + @MainActor func testRestoredHidden() throws { try runRestoreTest(titlebarStyle: "hidden") } + @MainActor func testRestoredTransparent() throws { try runRestoreTest(titlebarStyle: "transparent") } + @MainActor func testRestoredTabs() throws { try runRestoreTest(titlebarStyle: "tabs") } + + // MARK: - Config overrides cached position/size + + @MainActor + func testConfigOverridesCachedPositionAndSize() async throws { + // Launch maximized so the cached frame is fullscreen-sized. + try updateConfig( + """ + maximize = true + title = "GhosttyWindowPositionUITests" + """ + ) + + let app = try ghosttyApplication() + app.launch() + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "Window should appear") + + let maximizedFrame = window.frame + + // Now update the config with a small explicit size and position, + // reload, and open a new window. It should respect the config, not the cache. + try updateConfig( + """ + window-position-x = 50 + window-position-y = 50 + window-width = 30 + window-height = 30 + title = "GhosttyWindowPositionUITests" + """ + ) + app.typeKey(",", modifierFlags: [.command, .shift]) + try await Task.sleep(for: .seconds(0.5)) + app.typeKey("n", modifierFlags: [.command]) + + XCTAssertEqual(app.windows.count, 2, "Should have 2 windows") + let newWindow = app.windows.element(boundBy: 0) + let newFrame = newWindow.frame + + // The new window should be smaller than the maximized one. + XCTAssertLessThan(newFrame.size.width, maximizedFrame.size.width, + "30 columns should be narrower than maximized") + XCTAssertLessThan(newFrame.size.height, maximizedFrame.size.height, + "30 rows should be shorter than maximized") + + app.terminate() + } + + // MARK: - Size-only config change preserves position + + @MainActor + func testSizeOnlyConfigPreservesPosition() async throws { + // Launch maximized so the window has a known position (top-left of visible frame). + try updateConfig( + """ + maximize = true + title = "GhosttyWindowPositionUITests" + """ + ) + + let app = try ghosttyApplication() + app.launch() + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "Window should appear") + + let initialFrame = window.frame + + // Reload with only size changed, close current window, open new one. + // Position should be restored from cache. + try updateConfig( + """ + window-width = 30 + window-height = 30 + title = "GhosttyWindowPositionUITests" + """ + ) + app.typeKey(",", modifierFlags: [.command, .shift]) + try await Task.sleep(for: .seconds(0.5)) + app.typeKey("w", modifierFlags: [.command]) + app.typeKey("n", modifierFlags: [.command]) + + let newWindow = app.windows.firstMatch + XCTAssertTrue(newWindow.waitForExistence(timeout: 5), "New window should appear") + + let newFrame = newWindow.frame + + // Position should be preserved from the cached value. + // Compare x and maxY since the window is anchored at the top-left + // but AppKit uses bottom-up coordinates (origin.y changes with height). + XCTAssertEqual(newFrame.origin.x, initialFrame.origin.x, accuracy: 2, + "x position should not change with size-only config") + XCTAssertEqual(newFrame.maxY, initialFrame.maxY, accuracy: 2, + "top edge (maxY) should not change with size-only config") + + app.terminate() + } + + // MARK: - Shared round-trip helper + + /// Opens a new window, records its frame, closes it, opens another, + /// and verifies the frame is restored consistently. + private func runRestoreTest(titlebarStyle: String) throws { + try updateConfig( + """ + macos-titlebar-style = \(titlebarStyle) + title = "GhosttyWindowPositionUITests" + """ + ) + + let app = try ghosttyApplication() + app.launch() + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "Window should appear") + + let firstFrame = window.frame + + // Close the window and open a new one — it should restore the same frame. + app.typeKey("w", modifierFlags: [.command]) + app.typeKey("n", modifierFlags: [.command]) + + let window2 = app.windows.firstMatch + XCTAssertTrue(window2.waitForExistence(timeout: 5), "New window should appear") + + let restoredFrame = window2.frame + + XCTAssertEqual(restoredFrame.origin.x, firstFrame.origin.x, accuracy: 2, + "[\(titlebarStyle)] x position should be restored") + XCTAssertEqual(restoredFrame.origin.y, firstFrame.origin.y, accuracy: 2, + "[\(titlebarStyle)] y position should be restored") + XCTAssertEqual(restoredFrame.size.width, firstFrame.size.width, accuracy: 2, + "[\(titlebarStyle)] width should be restored") + XCTAssertEqual(restoredFrame.size.height, firstFrame.size.height, accuracy: 2, + "[\(titlebarStyle)] height should be restored") + + app.terminate() + } +} From e8c82ca1af29a8e911f328abe89bcc2650ec1705 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:33:37 +0100 Subject: [PATCH 121/391] macOS: save frame only if the window is visible --- .../Features/Terminal/TerminalController.swift | 12 +++--------- macos/Sources/Helpers/LastWindowPosition.swift | 9 ++++++++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 20b51ff36..352bca475 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1171,27 +1171,21 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr self.fixTabBar() // Whenever we move save our last position for the next start. - if let window { - LastWindowPosition.shared.save(window) - } + LastWindowPosition.shared.save(window) } override func windowDidResize(_ notification: Notification) { super.windowDidResize(notification) // Whenever we resize save our last position and size for the next start. - if let window { - LastWindowPosition.shared.save(window) - } + LastWindowPosition.shared.save(window) } func windowDidBecomeMain(_ notification: Notification) { // Whenever we get focused, use that as our last window position for // restart. This differs from Terminal.app but matches iTerm2 behavior // and I think its sensible. - if let window { - LastWindowPosition.shared.save(window) - } + LastWindowPosition.shared.save(window) // Remember our last main Self.lastMain = self diff --git a/macos/Sources/Helpers/LastWindowPosition.swift b/macos/Sources/Helpers/LastWindowPosition.swift index 5a9ce1d2c..3395e07f0 100644 --- a/macos/Sources/Helpers/LastWindowPosition.swift +++ b/macos/Sources/Helpers/LastWindowPosition.swift @@ -6,10 +6,17 @@ class LastWindowPosition { private let positionKey = "NSWindowLastPosition" - func save(_ window: NSWindow) { + @discardableResult + func save(_ window: NSWindow?) -> Bool { + // We should only save the frame if the window is visible. + // This avoids overriding the previously saved one + // with the wrong one when window decorations change while creating, + // e.g. adding a toolbar affects the window's frame. + guard let window, window.isVisible else { return false } let frame = window.frame let rect = [frame.origin.x, frame.origin.y, frame.size.width, frame.size.height] UserDefaults.standard.set(rect, forKey: positionKey) + return true } func restore(_ window: NSWindow) -> Bool { From 45d360dc6879a80ca55f6f01ea36d9161732e099 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:35:23 +0100 Subject: [PATCH 122/391] macOS: set the initial window position after window is loaded --- .../Features/Terminal/TerminalController.swift | 10 ++++++++++ .../Terminal/Window Styles/TerminalWindow.swift | 11 +++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 352bca475..749e5b472 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1061,6 +1061,16 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + // Set the initial window position. This must happen after the window + // is fully set up (content view, toolbar, default size) so that + // decorations added by subclass awakeFromNib (e.g. toolbar for tabs + // style) don't change the frame after the position is restored. + if let terminalWindow = window as? TerminalWindow { + terminalWindow.setInitialWindowPosition( + x: derivedConfig.windowPositionX, + y: derivedConfig.windowPositionY, + ) + } // Store our initial frame so we can know our default later. This MUST // be after the defaultSize call above so that we don't re-apply our frame. // Note: we probably want to set this on the first frame change or something diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 60e96bb4d..b9dd3b10b 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -120,11 +120,10 @@ class TerminalWindow: NSWindow { // If window decorations are disabled, remove our title if !config.windowDecorations { styleMask.remove(.titled) } - // Set our window positioning to coordinates if config value exists, otherwise - // fallback to original centering behavior - setInitialWindowPosition( - x: config.windowPositionX, - y: config.windowPositionY) + // NOTE: setInitialWindowPosition is NOT called here because subclass + // awakeFromNib may add decorations (e.g. toolbar for tabs style) that + // change the frame. It is called from TerminalController.windowDidLoad + // after the window is fully set up. // If our traffic buttons should be hidden, then hide them if config.macosWindowButtons == .hidden { @@ -537,7 +536,7 @@ class TerminalWindow: NSWindow { terminalController?.updateColorSchemeForSurfaceTree() } - private func setInitialWindowPosition(x: Int16?, y: Int16?) { + func setInitialWindowPosition(x: Int16?, y: Int16?) { // If we don't have an X/Y then we try to use the previously saved window pos. guard let x = x, let y = y else { if !LastWindowPosition.shared.restore(self) { From 596d502a756ce6454093b5d0782bc17d700804ab Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:37:16 +0100 Subject: [PATCH 123/391] macOS: restore window frame under certain conditions --- .../Terminal/TerminalController.swift | 7 ++++++ .../Window Styles/TerminalWindow.swift | 5 +---- .../Sources/Helpers/LastWindowPosition.swift | 22 +++++++++++++++---- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 749e5b472..74b73ea00 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1071,6 +1071,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr y: derivedConfig.windowPositionY, ) } + + LastWindowPosition.shared.restore( + window, + origin: derivedConfig.windowPositionX == nil && derivedConfig.windowPositionY == nil, + size: defaultSize == nil, + ) + // Store our initial frame so we can know our default later. This MUST // be after the defaultSize call above so that we don't re-apply our frame. // Note: we probably want to set this on the first frame change or something diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index b9dd3b10b..560f45207 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -539,10 +539,7 @@ class TerminalWindow: NSWindow { func setInitialWindowPosition(x: Int16?, y: Int16?) { // If we don't have an X/Y then we try to use the previously saved window pos. guard let x = x, let y = y else { - if !LastWindowPosition.shared.restore(self) { - center() - } - + center() return } diff --git a/macos/Sources/Helpers/LastWindowPosition.swift b/macos/Sources/Helpers/LastWindowPosition.swift index 3395e07f0..933eba394 100644 --- a/macos/Sources/Helpers/LastWindowPosition.swift +++ b/macos/Sources/Helpers/LastWindowPosition.swift @@ -19,7 +19,19 @@ class LastWindowPosition { return true } - func restore(_ window: NSWindow) -> Bool { + /// Restores a previously saved window frame (or parts of it) onto the given window. + /// + /// - Parameters: + /// - window: The window whose frame should be updated. + /// - restoreOrigin: Whether to restore the saved position. Pass `false` when the + /// config specifies an explicit `window-position-x`/`window-position-y`. + /// - restoreSize: Whether to restore the saved size. Pass `false` when the config + /// specifies an explicit `window-width`/`window-height`. + /// - Returns: `true` if the frame was modified, `false` if there was nothing to restore. + @discardableResult + func restore(_ window: NSWindow, origin restoreOrigin: Bool = true, size restoreSize: Bool = true) -> Bool { + guard restoreOrigin || restoreSize else { return false } + guard let values = UserDefaults.standard.array(forKey: positionKey) as? [Double], values.count >= 2 else { return false } @@ -29,14 +41,16 @@ class LastWindowPosition { let visibleFrame = screen.visibleFrame var newFrame = window.frame - newFrame.origin = lastPosition + if restoreOrigin { + newFrame.origin = lastPosition + } - if values.count >= 4 { + if restoreSize, values.count >= 4 { newFrame.size.width = min(values[2], visibleFrame.width) newFrame.size.height = min(values[3], visibleFrame.height) } - if !visibleFrame.contains(newFrame.origin) { + if restoreOrigin, !visibleFrame.contains(newFrame.origin) { newFrame.origin.x = max(visibleFrame.minX, min(visibleFrame.maxX - newFrame.width, newFrame.origin.x)) newFrame.origin.y = max(visibleFrame.minY, min(visibleFrame.maxY - newFrame.height, newFrame.origin.y)) } From e31615d00bf3811bdba4ae697c80fcb1ede3817a Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Wed, 11 Mar 2026 12:46:14 -0400 Subject: [PATCH 124/391] bash: fix extra newlines with readline vi mode indicator Use OSC 133;P (prompt mark) instead of 133;A (fresh line + prompt mark) inside PS1 and PS2. Readline redraws the prompt on vi mode switches, Ctrl-L, and other events, and 133;A's fresh-line behavior would emit a CR+LF whenever the cursor wasn't at column 0, causing visible extra newlines. The one-time 133;A is now emitted via printf in __ghostty_precmd, which only runs once per prompt cycle via PROMPT_COMMAND. On SIGWINCH, bash redraws PS1 (firing the 133;P marks) but doesn't re-run PROMPT_COMMAND, so there's no unwanted fresh-line on resize either. The redraw=last flag persists from the initial printf. This is a little less optimal than our previous approach, in terms of number of prompt marks we emit, but it produces an overall more correct result, which is the important thing. Because readline prints its output outside the scope of PS1, those characters "inherit" the surrounded prompt scope. This is usually fine, but it can sometimes get out of sync (especially during redraws). This is inherently a limitation of the fact that it's a separate output channel, so we just have to accept that can happen. See: #11267 --- src/shell-integration/bash/ghostty.bash | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 6e516c730..48c89164b 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -195,11 +195,11 @@ function __ghostty_precmd() { _GHOSTTY_SAVE_PS1="$PS1" _GHOSTTY_SAVE_PS2="$PS2" - # Marks. We need to do fresh line (A) at the beginning of the prompt - # since if the cursor is not at the beginning of a line, the terminal - # will emit a newline. - PS1='\[\e]133;A;redraw=last;cl=line;aid='"$BASHPID"'\a\]'$PS1'\[\e]133;B\a\]' - PS2='\[\e]133;A;k=s\a\]'$PS2'\[\e]133;B\a\]' + # Use 133;P (not 133;A) inside PS1 to avoid fresh-line behavior on + # readline redraws (e.g., vi mode switches, Ctrl-L). The initial + # 133;A with fresh-line is emitted once via printf below. + PS1='\[\e]133;P;k=i\a\]'$PS1'\[\e]133;B\a\]' + PS2='\[\e]133;P;k=s\a\]'$PS2'\[\e]133;B\a\]' # Bash doesn't redraw the leading lines in a multiline prompt so we mark # the start of each line (after each newline) as a secondary prompt. This @@ -210,7 +210,7 @@ function __ghostty_precmd() { # because literal newlines may appear inside $(...) command substitutions # where inserting escape sequences would break shell syntax. if [[ "$PS1" == *"\n"* ]]; then - PS1="${PS1//\\n/\\n$'\\[\\e]133;A;k=s\\a\\]'}" + PS1="${PS1//\\n/\\n$'\\[\\e]133;P;k=s\\a\\]'}" fi # Cursor @@ -233,6 +233,9 @@ function __ghostty_precmd() { builtin printf "\e]133;D;%s;aid=%s\a" "$ret" "$BASHPID" fi + # Fresh line and start of prompt. + builtin printf "\e]133;A;redraw=last;cl=line;aid=%s\a" "$BASHPID" + # unfortunately bash provides no hooks to detect cwd changes # in particular this means cwd reporting will not happen for a # command like cd /test && cat. PS0 is evaluated before cd is run. From 12bc1e786052a31d6f50cdbb0a703b45371a182d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Mar 2026 10:02:09 -0700 Subject: [PATCH 125/391] macos: only show the grab handle in fullscreen if there are splits Fixes #11376 --- .../Surface View/SurfaceGrabHandle.swift | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index a8555e938..086511bb6 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -1,37 +1,49 @@ import SwiftUI extension Ghostty { - /// A grab handle overlay at the top of the surface for dragging the window. + /// A grab handle overlay at the top of the surface for dragging a surface. struct SurfaceGrabHandle: View { @ObservedObject var surfaceView: SurfaceView @State private var isHovering: Bool = false @State private var isDragging: Bool = false + private var handleVisible: Bool { + // Handle should always be visible in non-fullscreen + guard let window = surfaceView.window else { return true } + guard window.styleMask.contains(.fullScreen) else { return true } + + // If fullscreen, only show the handle if we have splits + guard let controller = window.windowController as? BaseTerminalController else { return false } + return controller.surfaceTree.isSplit + } + private var ellipsisVisible: Bool { surfaceView.mouseOverSurface && surfaceView.cursorVisible } var body: some View { - ZStack { - SurfaceDragSource( - surfaceView: surfaceView, - isDragging: $isDragging, - isHovering: $isHovering - ) - .frame(width: 80, height: 12) - .contentShape(Rectangle()) + if handleVisible { + ZStack { + SurfaceDragSource( + surfaceView: surfaceView, + isDragging: $isDragging, + isHovering: $isHovering + ) + .frame(width: 80, height: 12) + .contentShape(Rectangle()) - if ellipsisVisible { - Image(systemName: "ellipsis") - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(.primary.opacity(isHovering ? 0.8 : 0.3)) - .offset(y: -3) - .allowsHitTesting(false) - .transition(.opacity) + if ellipsisVisible { + Image(systemName: "ellipsis") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.primary.opacity(isHovering ? 0.8 : 0.3)) + .offset(y: -3) + .allowsHitTesting(false) + .transition(.opacity) + } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } } From fe98f3884d7dd72f0988949ab661beb018a191b4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Mar 2026 10:37:57 -0700 Subject: [PATCH 126/391] macos: only show split grab handle when the mouse is near it Fixes #11379 For this pass, I made it a very simple "within 20%" (height-wise) of the split handle. There is no horizontal component. I want to find the right balance between always visible (today mostly) to only visible on direct hover, because I think it'll be too hard to discover on that far right side. --- .../Surface View/SurfaceGrabHandle.swift | 36 +++++++++++++++++-- .../Surface View/SurfaceView_AppKit.swift | 13 +++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index 086511bb6..c5ab84124 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -3,6 +3,12 @@ import SwiftUI extension Ghostty { /// A grab handle overlay at the top of the surface for dragging a surface. struct SurfaceGrabHandle: View { + // Size of the actual drag handle; the hover reveal region is larger. + private static let handleSize = CGSize(width: 80, height: 12) + + // Reveal the handle anywhere within the top % of the pane height. + private static let hoverHeightFactor: CGFloat = 0.2 + @ObservedObject var surfaceView: SurfaceView @State private var isHovering: Bool = false @@ -19,7 +25,15 @@ extension Ghostty { } private var ellipsisVisible: Bool { - surfaceView.mouseOverSurface && surfaceView.cursorVisible + // If the cursor isn't visible, never show the handle + guard surfaceView.cursorVisible else { return false } + // If we're hovering or actively dragging, always visible + if isHovering || isDragging { return true } + + // Require our mouse location to be within the top area of the + // surface. + guard let mouseLocation = surfaceView.mouseLocationInSurface else { return false } + return Self.isInHoverRegion(mouseLocation, in: surfaceView.bounds) } var body: some View { @@ -30,7 +44,7 @@ extension Ghostty { isDragging: $isDragging, isHovering: $isHovering ) - .frame(width: 80, height: 12) + .frame(width: Self.handleSize.width, height: Self.handleSize.height) .contentShape(Rectangle()) if ellipsisVisible { @@ -45,5 +59,23 @@ extension Ghostty { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } + + /// The full-width hover band that reveals the drag handle. + private static func hoverRect(in bounds: CGRect) -> CGRect { + guard !bounds.isEmpty else { return .zero } + + let hoverHeight = min(bounds.height, max(handleSize.height, bounds.height * hoverHeightFactor)) + return CGRect( + x: bounds.minX, + y: bounds.maxY - hoverHeight, + width: bounds.width, + height: hoverHeight + ) + } + + /// Returns true when the pointer is inside the top hover band. + private static func isInHoverRegion(_ point: CGPoint, in bounds: CGRect) -> Bool { + hoverRect(in: bounds).contains(point) + } } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index a37feb9a8..338d20118 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -119,6 +119,10 @@ extension Ghostty { // Whether the mouse is currently over this surface @Published private(set) var mouseOverSurface: Bool = false + // The last known mouse location in the surface's local coordinate space, + // used by overlays such as the split drag handle reveal region. + @Published private(set) var mouseLocationInSurface: CGPoint? + // Whether the cursor is currently visible (not hidden by typing, etc.) @Published private(set) var cursorVisible: Bool = true @@ -952,13 +956,15 @@ extension Ghostty { mouseOverSurface = true super.mouseEntered(with: event) + let pos = self.convert(event.locationInWindow, from: nil) + mouseLocationInSurface = pos + guard let surfaceModel else { return } // On mouse enter we need to reset our cursor position. This is // super important because we set it to -1/-1 on mouseExit and // lots of mouse logic (i.e. whether to send mouse reports) depend // on the position being in the viewport if it is. - let pos = self.convert(event.locationInWindow, from: nil) let mouseEvent = Ghostty.Input.MousePosEvent( x: pos.x, y: frame.height - pos.y, @@ -969,6 +975,7 @@ extension Ghostty { override func mouseExited(with event: NSEvent) { mouseOverSurface = false + mouseLocationInSurface = nil guard let surfaceModel else { return } // If the mouse is being dragged then we don't have to emit @@ -988,10 +995,12 @@ extension Ghostty { } override func mouseMoved(with event: NSEvent) { + let pos = self.convert(event.locationInWindow, from: nil) + mouseLocationInSurface = pos + guard let surfaceModel else { return } // Convert window position to view position. Note (0, 0) is bottom left. - let pos = self.convert(event.locationInWindow, from: nil) let mouseEvent = Ghostty.Input.MousePosEvent( x: pos.x, y: frame.height - pos.y, From 0f745b56730ae0eff4de2e40e959d432cbdcb004 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:24:01 +0000 Subject: [PATCH 127/391] Update VOUCHED list (#11389) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/11388#discussioncomment-16087905) from @jcollie. Vouch: @wyounas Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 3f52c09a9..83de38e55 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -163,6 +163,7 @@ uhojin uzaaft vaughanandrews vlsi +wyounas yamshta ydah zenyr From 16ca9527e95ea857a5cc6a30685bfcf58705af08 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:13:04 +0000 Subject: [PATCH 128/391] build(deps): bump actions/download-artifact from 8.0.0 to 8.0.1 Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 8.0.0 to 8.0.1. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 8.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/flatpak.yml | 2 +- .github/workflows/release-tag.yml | 10 +++++----- .github/workflows/snap.yml | 2 +- .github/workflows/test.yml | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index 7bb39faf2..d64ab829a 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -30,7 +30,7 @@ jobs: runs-on: ${{ matrix.variant.runner }} steps: - name: Download Source Tarball Artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: run-id: ${{ inputs.source-run-id }} artifact-ids: ${{ inputs.source-artifact-id }} diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 2a3770a13..a742b4d4b 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -299,7 +299,7 @@ jobs: curl -sL https://sentry.io/get-cli/ | bash - name: Download macOS Artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: macos @@ -322,7 +322,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download macOS Artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: macos @@ -370,17 +370,17 @@ jobs: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} steps: - name: Download macOS Artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: macos - name: Download Sparkle Artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: sparkle - name: Download Source Tarball Artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: source-tarball diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index bdef91c30..67f291601 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -26,7 +26,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Download Source Tarball Artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: run-id: ${{ inputs.source-run-id }} artifact-ids: ${{ inputs.source-artifact-id }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 719b5b152..d0f01133b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1372,7 +1372,7 @@ jobs: uses: namespacelabs/nscloud-setup-buildx-action@f5814dcf37a16cce0624d5bec2ab879654294aa0 # v0.0.22 - name: Download Source Tarball Artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: source-tarball From 84d48d1c6a9d4fb93eccd31cf0a731adbe174d02 Mon Sep 17 00:00:00 2001 From: Michal Olechowski Date: Fri, 6 Mar 2026 21:09:04 +0100 Subject: [PATCH 129/391] config: add progress-style option Add option to disable OSC 9;4 ConEmu progress bars via config. Fixes #11241 --- macos/Sources/Ghostty/Ghostty.App.swift | 9 +++++++++ macos/Sources/Ghostty/Ghostty.Config.swift | 8 ++++++++ src/apprt/gtk/class/surface.zig | 8 ++++++++ src/config/Config.zig | 5 +++++ 4 files changed, 30 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 82b3ad35c..38cc27800 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -1934,6 +1934,15 @@ extension Ghostty { case GHOSTTY_TARGET_SURFACE: guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } + guard let config = (NSApplication.shared.delegate as? AppDelegate)?.ghostty.config else { return } + + guard config.progressStyle else { + Ghostty.logger.debug("progress_report action blocked by config") + DispatchQueue.main.async { + surfaceView.progressReport = nil + } + return + } let progressReport = Ghostty.Action.ProgressReport(c: v) DispatchQueue.main.async { diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 87ae0511f..775e4c946 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -717,6 +717,14 @@ extension Ghostty { let buffer = UnsafeBufferPointer(start: v.commands, count: v.len) return buffer.map { Ghostty.Command(cValue: $0) } } + + var progressStyle: Bool { + guard let config = self.config else { return true } + var v = true + let key = "progress-style" + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) + return v + } } } diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 8d9e1bcf0..5b4a7e183 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1012,6 +1012,14 @@ pub const Surface = extern struct { priv.progress_bar_timer = null; } + if (priv.config) |config| { + if (!config.get().@"progress-style") { + log.debug("progress_report action blocked by config", .{}); + priv.progress_bar_overlay.as(gtk.Widget).setVisible(@intFromBool(false)); + return; + } + } + const progress_bar = priv.progress_bar_overlay; switch (value.state) { // Remove the progress bar diff --git a/src/config/Config.zig b/src/config/Config.zig index ca93c85d6..26b5141f8 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3636,6 +3636,11 @@ else /// notifications using certain escape sequences such as OSC 9 or OSC 777. @"desktop-notifications": bool = true, +/// If `true` (default), applications running in the terminal can show +/// graphical progress bars using the ConEmu OSC 9;4 escape sequence. +/// If `false`, progress bar sequences are silently ignored. +@"progress-style": bool = true, + /// Modifies the color used for bold text in the terminal. /// /// This can be set to a specific color, using the same format as From 809369505534b40c83560f9cd0cbee8e7ecb7516 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Mar 2026 15:05:17 -0700 Subject: [PATCH 130/391] macos: only run key equivalents for Ghostty-owned menu items Fixes #11396 Track menu items populated from Ghostty keybind actions and only trigger those from SurfaceView performKeyEquivalent. This avoids app-default shortcuts such as Hide from pre-empting explicit keybinds. --- macos/Sources/App/macOS/AppDelegate.swift | 378 +++++++++++------- .../Surface View/SurfaceView_AppKit.swift | 3 +- 2 files changed, 236 insertions(+), 145 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index f9e2dc93f..c8538c9d5 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -154,6 +154,13 @@ class AppDelegate: NSObject, /// The custom app icon image that is currently in use. @Published private(set) var appIcon: NSImage? + /// Ghostty menu items indexed by their normalized shortcut. This avoids traversing + /// the entire menu tree on every key equivalent event. + /// + /// We store a weak reference so this cache can never be the owner of menu items. + /// If multiple items map to the same shortcut, the most recent one wins. + private var menuItemsByShortcut: [MenuShortcutKey: Weak] = [:] + override init() { #if DEBUG ghostty = Ghostty.App(configPath: ProcessInfo.processInfo.environment["GHOSTTY_CONFIG_PATH"]) @@ -516,11 +523,6 @@ class AppDelegate: NSObject, return true } - /// This is called for the dock right-click menu. - func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { - return dockMenu - } - /// Setup signal handlers private func setupSignals() { // Register a signal handler for config reloading. It appears that all @@ -549,134 +551,6 @@ class AppDelegate: NSObject, signals.append(sigusr2) } - /// Setup all the images for our menu items. - private func setupMenuImages() { - // Note: This COULD Be done all in the xib file, but I find it easier to - // modify this stuff as code. - self.menuAbout?.setImageIfDesired(systemSymbolName: "info.circle") - self.menuCheckForUpdates?.setImageIfDesired(systemSymbolName: "square.and.arrow.down") - self.menuOpenConfig?.setImageIfDesired(systemSymbolName: "gear") - self.menuReloadConfig?.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise.rotate.90") - self.menuSecureInput?.setImageIfDesired(systemSymbolName: "lock.display") - self.menuNewWindow?.setImageIfDesired(systemSymbolName: "macwindow.badge.plus") - self.menuNewTab?.setImageIfDesired(systemSymbolName: "macwindow") - self.menuSplitRight?.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled") - self.menuSplitLeft?.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled") - self.menuSplitUp?.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled") - self.menuSplitDown?.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled") - self.menuClose?.setImageIfDesired(systemSymbolName: "xmark") - self.menuPasteSelection?.setImageIfDesired(systemSymbolName: "doc.on.clipboard.fill") - self.menuIncreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.larger") - self.menuResetFontSize?.setImageIfDesired(systemSymbolName: "textformat.size") - self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller") - self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection") - self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") - self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line") - self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") - self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill") - self.menuSetAsDefaultTerminal?.setImageIfDesired(systemSymbolName: "star.fill") - self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") - self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") - self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right") - self.menuPreviousSplit?.setImageIfDesired(systemSymbolName: "chevron.backward.2") - self.menuNextSplit?.setImageIfDesired(systemSymbolName: "chevron.forward.2") - self.menuEqualizeSplits?.setImageIfDesired(systemSymbolName: "inset.filled.topleft.topright.bottomleft.bottomright.rectangle") - self.menuSelectSplitLeft?.setImageIfDesired(systemSymbolName: "arrow.left") - self.menuSelectSplitRight?.setImageIfDesired(systemSymbolName: "arrow.right") - self.menuSelectSplitAbove?.setImageIfDesired(systemSymbolName: "arrow.up") - self.menuSelectSplitBelow?.setImageIfDesired(systemSymbolName: "arrow.down") - self.menuMoveSplitDividerUp?.setImageIfDesired(systemSymbolName: "arrow.up.to.line") - self.menuMoveSplitDividerDown?.setImageIfDesired(systemSymbolName: "arrow.down.to.line") - self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line") - self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line") - self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.filled.on.square") - self.menuFindParent?.setImageIfDesired(systemSymbolName: "text.page.badge.magnifyingglass") - } - - /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. - private func syncMenuShortcuts(_ config: Ghostty.Config) { - guard ghostty.readiness == .ready else { return } - - syncMenuShortcut(config, action: "check_for_updates", menuItem: self.menuCheckForUpdates) - syncMenuShortcut(config, action: "open_config", menuItem: self.menuOpenConfig) - syncMenuShortcut(config, action: "reload_config", menuItem: self.menuReloadConfig) - syncMenuShortcut(config, action: "quit", menuItem: self.menuQuit) - - syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow) - syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab) - syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose) - syncMenuShortcut(config, action: "close_tab", menuItem: self.menuCloseTab) - syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow) - syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows) - syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight) - syncMenuShortcut(config, action: "new_split:left", menuItem: self.menuSplitLeft) - syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown) - syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp) - - syncMenuShortcut(config, action: "undo", menuItem: self.menuUndo) - syncMenuShortcut(config, action: "redo", menuItem: self.menuRedo) - syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) - syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) - syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) - syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) - syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind) - syncMenuShortcut(config, action: "search_selection", menuItem: self.menuSelectionForFind) - syncMenuShortcut(config, action: "scroll_to_selection", menuItem: self.menuScrollToSelection) - syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext) - syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious) - - syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) - syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) - syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit) - syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove) - syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow) - syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft) - syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight) - syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) - syncMenuShortcut(config, action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown) - syncMenuShortcut(config, action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight) - syncMenuShortcut(config, action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft) - syncMenuShortcut(config, action: "equalize_splits", menuItem: self.menuEqualizeSplits) - syncMenuShortcut(config, action: "reset_window_size", menuItem: self.menuReturnToDefaultSize) - - syncMenuShortcut(config, action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) - syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) - syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize) - syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle) - syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle) - syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) - syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility) - syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop) - syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector) - syncMenuShortcut(config, action: "toggle_command_palette", menuItem: self.menuCommandPalette) - - syncMenuShortcut(config, action: "toggle_secure_input", menuItem: self.menuSecureInput) - - // This menu item is NOT synced with the configuration because it disables macOS - // global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue - // to work but it won't be reflected in the menu item. - // - // syncMenuShortcut(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen) - - // Dock menu - reloadDockMenu() - } - - /// Syncs a single menu shortcut for the given action. The action string is the same - /// action string used for the Ghostty configuration. - private func syncMenuShortcut(_ config: Ghostty.Config, action: String, menuItem: NSMenuItem?) { - guard let menu = menuItem else { return } - guard let shortcut = config.keyboardShortcut(for: action) else { - // No shortcut, clear the menu item - menu.keyEquivalent = "" - menu.keyEquivalentModifierMask = [] - return - } - - menu.keyEquivalent = shortcut.key.character.description - menu.keyEquivalentModifierMask = .init(swiftUIFlags: shortcut.modifiers) - } - // MARK: Notifications and Events /// This handles events from the NSEvent.addLocalEventMonitor. We use this so we can get @@ -1038,17 +912,6 @@ class AppDelegate: NSObject, return nil } - // MARK: - Dock Menu - - private func reloadDockMenu() { - let newWindow = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "") - let newTab = NSMenuItem(title: "New Tab", action: #selector(newTab), keyEquivalent: "") - - dockMenu.removeAllItems() - dockMenu.addItem(newWindow) - dockMenu.addItem(newTab) - } - // MARK: - Global State func setSecureInput(_ mode: Ghostty.SetSecureInput) { @@ -1211,6 +1074,233 @@ class AppDelegate: NSObject, } } +// MARK: Menu + +extension AppDelegate { + /// This is called for the dock right-click menu. + func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { + return dockMenu + } + + private func reloadDockMenu() { + let newWindow = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "") + let newTab = NSMenuItem(title: "New Tab", action: #selector(newTab), keyEquivalent: "") + + dockMenu.removeAllItems() + dockMenu.addItem(newWindow) + dockMenu.addItem(newTab) + } + + /// Setup all the images for our menu items. + private func setupMenuImages() { + // Note: This COULD Be done all in the xib file, but I find it easier to + // modify this stuff as code. + self.menuAbout?.setImageIfDesired(systemSymbolName: "info.circle") + self.menuCheckForUpdates?.setImageIfDesired(systemSymbolName: "square.and.arrow.down") + self.menuOpenConfig?.setImageIfDesired(systemSymbolName: "gear") + self.menuReloadConfig?.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise.rotate.90") + self.menuSecureInput?.setImageIfDesired(systemSymbolName: "lock.display") + self.menuNewWindow?.setImageIfDesired(systemSymbolName: "macwindow.badge.plus") + self.menuNewTab?.setImageIfDesired(systemSymbolName: "macwindow") + self.menuSplitRight?.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled") + self.menuSplitLeft?.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled") + self.menuSplitUp?.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled") + self.menuSplitDown?.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled") + self.menuClose?.setImageIfDesired(systemSymbolName: "xmark") + self.menuPasteSelection?.setImageIfDesired(systemSymbolName: "doc.on.clipboard.fill") + self.menuIncreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.larger") + self.menuResetFontSize?.setImageIfDesired(systemSymbolName: "textformat.size") + self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller") + self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection") + self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") + self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line") + self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") + self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill") + self.menuSetAsDefaultTerminal?.setImageIfDesired(systemSymbolName: "star.fill") + self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") + self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") + self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right") + self.menuPreviousSplit?.setImageIfDesired(systemSymbolName: "chevron.backward.2") + self.menuNextSplit?.setImageIfDesired(systemSymbolName: "chevron.forward.2") + self.menuEqualizeSplits?.setImageIfDesired(systemSymbolName: "inset.filled.topleft.topright.bottomleft.bottomright.rectangle") + self.menuSelectSplitLeft?.setImageIfDesired(systemSymbolName: "arrow.left") + self.menuSelectSplitRight?.setImageIfDesired(systemSymbolName: "arrow.right") + self.menuSelectSplitAbove?.setImageIfDesired(systemSymbolName: "arrow.up") + self.menuSelectSplitBelow?.setImageIfDesired(systemSymbolName: "arrow.down") + self.menuMoveSplitDividerUp?.setImageIfDesired(systemSymbolName: "arrow.up.to.line") + self.menuMoveSplitDividerDown?.setImageIfDesired(systemSymbolName: "arrow.down.to.line") + self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line") + self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line") + self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.filled.on.square") + self.menuFindParent?.setImageIfDesired(systemSymbolName: "text.page.badge.magnifyingglass") + } + + /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. + private func syncMenuShortcuts(_ config: Ghostty.Config) { + guard ghostty.readiness == .ready else { return } + + // Reset our shortcut index since we're about to rebuild all menu bindings. + menuItemsByShortcut.removeAll(keepingCapacity: true) + + syncMenuShortcut(config, action: "check_for_updates", menuItem: self.menuCheckForUpdates) + syncMenuShortcut(config, action: "open_config", menuItem: self.menuOpenConfig) + syncMenuShortcut(config, action: "reload_config", menuItem: self.menuReloadConfig) + syncMenuShortcut(config, action: "quit", menuItem: self.menuQuit) + + syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow) + syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab) + syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose) + syncMenuShortcut(config, action: "close_tab", menuItem: self.menuCloseTab) + syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow) + syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows) + syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight) + syncMenuShortcut(config, action: "new_split:left", menuItem: self.menuSplitLeft) + syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown) + syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp) + + syncMenuShortcut(config, action: "undo", menuItem: self.menuUndo) + syncMenuShortcut(config, action: "redo", menuItem: self.menuRedo) + syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) + syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) + syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) + syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) + syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind) + syncMenuShortcut(config, action: "search_selection", menuItem: self.menuSelectionForFind) + syncMenuShortcut(config, action: "scroll_to_selection", menuItem: self.menuScrollToSelection) + syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext) + syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious) + + syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) + syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) + syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit) + syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove) + syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow) + syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft) + syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight) + syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) + syncMenuShortcut(config, action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown) + syncMenuShortcut(config, action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight) + syncMenuShortcut(config, action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft) + syncMenuShortcut(config, action: "equalize_splits", menuItem: self.menuEqualizeSplits) + syncMenuShortcut(config, action: "reset_window_size", menuItem: self.menuReturnToDefaultSize) + + syncMenuShortcut(config, action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) + syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) + syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize) + syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle) + syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle) + syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) + syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility) + syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop) + syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector) + syncMenuShortcut(config, action: "toggle_command_palette", menuItem: self.menuCommandPalette) + + syncMenuShortcut(config, action: "toggle_secure_input", menuItem: self.menuSecureInput) + + // This menu item is NOT synced with the configuration because it disables macOS + // global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue + // to work but it won't be reflected in the menu item. + // + // syncMenuShortcut(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen) + + // Dock menu + reloadDockMenu() + } + + /// Syncs a single menu shortcut for the given action. The action string is the same + /// action string used for the Ghostty configuration. + private func syncMenuShortcut(_ config: Ghostty.Config, action: String, menuItem: NSMenuItem?) { + guard let menu = menuItem else { return } + + guard let shortcut = config.keyboardShortcut(for: action) else { + // No shortcut, clear the menu item + menu.keyEquivalent = "" + menu.keyEquivalentModifierMask = [] + return + } + + let keyEquivalent = shortcut.key.character.description + let modifierMask = NSEvent.ModifierFlags(swiftUIFlags: shortcut.modifiers) + menu.keyEquivalent = keyEquivalent + menu.keyEquivalentModifierMask = modifierMask + + // Build a direct lookup for key-equivalent dispatch so we don't need to + // linearly walk the full menu hierarchy at event time. + guard let key = MenuShortcutKey( + keyEquivalent: keyEquivalent, + modifiers: modifierMask + ) else { + return + } + + // Later registrations intentionally override earlier ones for the same key. + menuItemsByShortcut[key] = .init(menu) + } + + /// Attempts to perform a menu key equivalent only for menu items that represent + /// Ghostty keybind actions. This is important because it lets our surface dispatch + /// bindings through the menu so they flash but also lets our surface override macOS built-ins + /// like Cmd+H. + func performGhosttyBindingMenuKeyEquivalent(with event: NSEvent) -> Bool { + // Convert this event into the same normalized lookup key we use when + // syncing menu shortcuts from configuration. + guard let key = MenuShortcutKey(event: event) else { + return false + } + + // If we don't have an entry for this key combo, no Ghostty-owned + // menu shortcut exists for this event. + guard let weakItem = menuItemsByShortcut[key] else { + return false + } + + // Weak references can be nil if a menu item was deallocated after sync. + guard let item = weakItem.value else { + menuItemsByShortcut.removeValue(forKey: key) + return false + } + + guard let parentMenu = item.menu else { + return false + } + + // Keep enablement state fresh in case menu validation hasn't run yet. + parentMenu.update() + guard item.isEnabled else { + return false + } + + let index = parentMenu.index(of: item) + guard index >= 0 else { + return false + } + + parentMenu.performActionForItem(at: index) + return true + } + + /// Hashable key for a menu shortcut match, normalized for quick lookup. + private struct MenuShortcutKey: Hashable { + private static let shortcutModifiers: NSEvent.ModifierFlags = [.shift, .control, .option, .command] + + private let keyEquivalent: String + private let modifiersRawValue: UInt + + init?(keyEquivalent: String, modifiers: NSEvent.ModifierFlags) { + let normalized = keyEquivalent.lowercased() + guard !normalized.isEmpty else { return nil } + + self.keyEquivalent = normalized + self.modifiersRawValue = modifiers.intersection(Self.shortcutModifiers).rawValue + } + + init?(event: NSEvent) { + guard let keyEquivalent = event.charactersIgnoringModifiers else { return nil } + self.init(keyEquivalent: keyEquivalent, modifiers: event.modifierFlags) + } + } +} + // MARK: Floating Windows extension AppDelegate { diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 338d20118..c4f03b117 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -1265,7 +1265,8 @@ extension Ghostty { keyTables.isEmpty, bindingFlags.isDisjoint(with: [.all, .performable]), bindingFlags.contains(.consumed) { - if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { + if let appDelegate = NSApp.delegate as? AppDelegate, + appDelegate.performGhosttyBindingMenuKeyEquivalent(with: event) { return true } } From d6dfaf28feb8e30834f18f987d1b909a3452e9fc Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:11:08 +0100 Subject: [PATCH 131/391] macOS: support injecting temporary defaults when testing --- .../GhosttyCustomConfigCase.swift | 5 +++- macos/Sources/App/macOS/AppDelegate.swift | 28 ++++++++++++------- .../Window Styles/TerminalWindow.swift | 2 +- .../Surface View/SurfaceView_AppKit.swift | 2 +- .../Extensions/NSScreen+Extension.swift | 2 +- .../Extensions/UserDefaults+Extension.swift | 15 ++++++++++ .../Sources/Helpers/LastWindowPosition.swift | 4 +-- macos/Sources/Helpers/PermissionRequest.swift | 4 +-- 8 files changed, 44 insertions(+), 18 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/UserDefaults+Extension.swift diff --git a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift index 41993247a..ca3f56677 100644 --- a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift +++ b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift @@ -27,6 +27,8 @@ class GhosttyCustomConfigCase: XCTestCase { true } + static let defaultsSuiteName: String = "GHOSTTY_UI_TESTS" + var configFile: URL? override func setUpWithError() throws { continueAfterFailure = false @@ -47,13 +49,14 @@ class GhosttyCustomConfigCase: XCTestCase { try newConfig.write(to: configFile!, atomically: true, encoding: .utf8) } - func ghosttyApplication() throws -> XCUIApplication { + func ghosttyApplication(defaultsSuite: String = GhosttyCustomConfigCase.defaultsSuiteName) throws -> XCUIApplication { let app = XCUIApplication() app.launchArguments.append(contentsOf: ["-ApplePersistenceIgnoreState", "YES"]) guard let configFile else { return app } app.launchEnvironment["GHOSTTY_CONFIG_PATH"] = configFile.path + app.launchEnvironment["GHOSTTY_USER_DEFAULTS_SUITE"] = defaultsSuite return app } } diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index c8538c9d5..b02337e4b 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -175,7 +175,15 @@ class AppDelegate: NSObject, // MARK: - NSApplicationDelegate func applicationWillFinishLaunching(_ notification: Notification) { - UserDefaults.standard.register(defaults: [ + #if DEBUG + if + let suite = UserDefaults.ghosttySuite, + let clear = ProcessInfo.processInfo.environment["GHOSTTY_CLEAR_USER_DEFAULTS"], + (clear as NSString).boolValue { + UserDefaults.ghostty.removePersistentDomain(forName: suite) + } + #endif + UserDefaults.ghostty.register(defaults: [ // Disable the automatic full screen menu item because we handle // it manually. "NSFullScreenMenuItemEverywhere": false, @@ -194,7 +202,7 @@ class AppDelegate: NSObject, func applicationDidFinishLaunching(_ notification: Notification) { // System settings overrides - UserDefaults.standard.register(defaults: [ + UserDefaults.ghostty.register(defaults: [ // Disable this so that repeated key events make it through to our terminal views. "ApplePressAndHoldEnabled": false, ]) @@ -203,7 +211,7 @@ class AppDelegate: NSObject, applicationLaunchTime = ProcessInfo.processInfo.systemUptime // Check if secure input was enabled when we last quit. - if UserDefaults.standard.bool(forKey: "SecureInput") != SecureInput.shared.enabled { + if UserDefaults.ghostty.bool(forKey: "SecureInput") != SecureInput.shared.enabled { toggleSecureInput(self) } @@ -747,10 +755,10 @@ class AppDelegate: NSObject, // configuration. This is the only way to carefully control whether macOS invokes the // state restoration system. switch config.windowSaveState { - case "never": UserDefaults.standard.setValue(false, forKey: "NSQuitAlwaysKeepsWindows") - case "always": UserDefaults.standard.setValue(true, forKey: "NSQuitAlwaysKeepsWindows") + case "never": UserDefaults.ghostty.setValue(false, forKey: "NSQuitAlwaysKeepsWindows") + case "always": UserDefaults.ghostty.setValue(true, forKey: "NSQuitAlwaysKeepsWindows") case "default": fallthrough - default: UserDefaults.standard.removeObject(forKey: "NSQuitAlwaysKeepsWindows") + default: UserDefaults.ghostty.removeObject(forKey: "NSQuitAlwaysKeepsWindows") } // Sync our auto-update settings. If SUEnableAutomaticChecks (in our Info.plist) is @@ -835,9 +843,9 @@ class AppDelegate: NSObject, private func updateAppIcon(from config: Ghostty.Config) { // Since this is called after `DockTilePlugin` has been running, // clean it up here to trigger a correct update of the current config. - UserDefaults.standard.removeObject(forKey: "CustomGhosttyIcon") + UserDefaults.ghostty.removeObject(forKey: "CustomGhosttyIcon") DispatchQueue.global().async { - UserDefaults.standard.appIcon = AppIcon(config: config) + UserDefaults.ghostty.appIcon = AppIcon(config: config) DistributedNotificationCenter.default() .postNotificationName(.ghosttyIconDidChange, object: nil, userInfo: nil, deliverImmediately: true) } @@ -927,7 +935,7 @@ class AppDelegate: NSObject, input.global.toggle() } self.menuSecureInput?.state = if input.global { .on } else { .off } - UserDefaults.standard.set(input.global, forKey: "SecureInput") + UserDefaults.ghostty.set(input.global, forKey: "SecureInput") } // MARK: - IB Actions @@ -1321,7 +1329,7 @@ extension AppDelegate { } @IBAction func useAsDefault(_ sender: NSMenuItem) { - let ud = UserDefaults.standard + let ud = UserDefaults.ghostty let key = TerminalWindow.defaultLevelKey if menuFloatOnTop?.state == .on { ud.set(NSWindow.Level.floating, forKey: key) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 560f45207..b9ca1ecc4 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -171,7 +171,7 @@ class TerminalWindow: NSWindow { tab.accessoryView = stackView // Get our saved level - level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal + level = UserDefaults.ghostty.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal } // Both of these must be true for windows without decorations to be able to diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index c4f03b117..3129acfba 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -1070,7 +1070,7 @@ extension Ghostty { // If the user has force click enabled then we do a quick look. There // is no public API for this as far as I can tell. - guard UserDefaults.standard.bool(forKey: "com.apple.trackpad.forceClick") else { return } + guard UserDefaults.ghostty.bool(forKey: "com.apple.trackpad.forceClick") else { return } quickLook(with: event) } diff --git a/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift index ca338f102..84553ed34 100644 --- a/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift @@ -18,7 +18,7 @@ extension NSScreen { // AND present on this screen. var hasDock: Bool { // If the dock autohides then we don't have a dock ever. - if let dockAutohide = UserDefaults.standard.persistentDomain(forName: "com.apple.dock")?["autohide"] as? Bool { + if let dockAutohide = UserDefaults.ghostty.persistentDomain(forName: "com.apple.dock")?["autohide"] as? Bool { if dockAutohide { return false } } diff --git a/macos/Sources/Helpers/Extensions/UserDefaults+Extension.swift b/macos/Sources/Helpers/Extensions/UserDefaults+Extension.swift new file mode 100644 index 000000000..7cd0e12ed --- /dev/null +++ b/macos/Sources/Helpers/Extensions/UserDefaults+Extension.swift @@ -0,0 +1,15 @@ +import Foundation + +extension UserDefaults { + static var ghosttySuite: String? { + #if DEBUG + ProcessInfo.processInfo.environment["GHOSTTY_USER_DEFAULTS_SUITE"] + #else + nil + #endif + } + + static var ghostty: UserDefaults { + ghosttySuite.flatMap(UserDefaults.init(suiteName:)) ?? .standard + } +} diff --git a/macos/Sources/Helpers/LastWindowPosition.swift b/macos/Sources/Helpers/LastWindowPosition.swift index 933eba394..298367c74 100644 --- a/macos/Sources/Helpers/LastWindowPosition.swift +++ b/macos/Sources/Helpers/LastWindowPosition.swift @@ -15,7 +15,7 @@ class LastWindowPosition { guard let window, window.isVisible else { return false } let frame = window.frame let rect = [frame.origin.x, frame.origin.y, frame.size.width, frame.size.height] - UserDefaults.standard.set(rect, forKey: positionKey) + UserDefaults.ghostty.set(rect, forKey: positionKey) return true } @@ -32,7 +32,7 @@ class LastWindowPosition { func restore(_ window: NSWindow, origin restoreOrigin: Bool = true, size restoreSize: Bool = true) -> Bool { guard restoreOrigin || restoreSize else { return false } - guard let values = UserDefaults.standard.array(forKey: positionKey) as? [Double], + guard let values = UserDefaults.ghostty.array(forKey: positionKey) as? [Double], values.count >= 2 else { return false } let lastPosition = CGPoint(x: values[0], y: values[1]) diff --git a/macos/Sources/Helpers/PermissionRequest.swift b/macos/Sources/Helpers/PermissionRequest.swift index 29d1ab6d3..0308a0204 100644 --- a/macos/Sources/Helpers/PermissionRequest.swift +++ b/macos/Sources/Helpers/PermissionRequest.swift @@ -126,7 +126,7 @@ class PermissionRequest { /// - Parameter key: The UserDefaults key to check /// - Returns: The cached decision, or nil if no valid cached decision exists private static func getStoredResult(for key: String) -> Bool? { - let userDefaults = UserDefaults.standard + let userDefaults = UserDefaults.ghostty guard let data = userDefaults.data(forKey: key), let storedPermission = try? NSKeyedUnarchiver.unarchivedObject( ofClass: StoredPermission.self, from: data) else { @@ -151,7 +151,7 @@ class PermissionRequest { let expiryDate = Date().addingTimeInterval(duration.timeInterval) let storedPermission = StoredPermission(result: result, expiry: expiryDate) if let data = try? NSKeyedArchiver.archivedData(withRootObject: storedPermission, requiringSecureCoding: true) { - let userDefaults = UserDefaults.standard + let userDefaults = UserDefaults.ghostty userDefaults.set(data, forKey: key) } } From c399812036a3161a7c2cf3b7dc63f4240949c607 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:17:18 +0100 Subject: [PATCH 132/391] macOS: add test case for positioning the very first window --- macos/GhosttyUITests/GhosttyWindowPositionUITests.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift index 53a0d800a..44918c8b1 100644 --- a/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift +++ b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift @@ -128,12 +128,19 @@ final class GhosttyWindowPositionUITests: GhosttyCustomConfigCase { ) let app = try ghosttyApplication() + // Suppress Restoration + app.launchArguments += ["-NSQuitAlwaysKeepsWindows", "NO"] + // Clean run + app.launchEnvironment["GHOSTTY_CLEAR_USER_DEFAULTS"] = "YES" app.launch() let window = app.windows.firstMatch XCTAssertTrue(window.waitForExistence(timeout: 5), "Window should appear") let firstFrame = window.frame + let screenFrame = NSScreen.main?.frame ?? .zero + + XCTAssertEqual(firstFrame.midX, screenFrame.midX, accuracy: 5.0, "First window should be centered horizontally") // Close the window and open a new one — it should restore the same frame. app.typeKey("w", modifierFlags: [.command]) From 4f849a15124b64dab955a489b77a80388b595523 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:43:57 +0100 Subject: [PATCH 133/391] macOS: fix window position for the very first window --- .../Terminal/TerminalController.swift | 44 ++++++++++++------- .../Window Styles/TerminalWindow.swift | 9 ++-- .../Sources/Helpers/LastWindowPosition.swift | 8 +++- 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 74b73ea00..f29e19384 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1061,22 +1061,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } - // Set the initial window position. This must happen after the window - // is fully set up (content view, toolbar, default size) so that - // decorations added by subclass awakeFromNib (e.g. toolbar for tabs - // style) don't change the frame after the position is restored. - if let terminalWindow = window as? TerminalWindow { - terminalWindow.setInitialWindowPosition( - x: derivedConfig.windowPositionX, - y: derivedConfig.windowPositionY, - ) - } - - LastWindowPosition.shared.restore( - window, - origin: derivedConfig.windowPositionX == nil && derivedConfig.windowPositionY == nil, - size: defaultSize == nil, - ) // Store our initial frame so we can know our default later. This MUST // be after the defaultSize call above so that we don't re-apply our frame. @@ -1110,6 +1094,34 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr syncAppearance(.init(config)) } + /// Setup correct window frame before showing the window + override func showWindow(_ sender: Any?) { + guard let terminalWindow = window as? TerminalWindow else { return } + + // Set the initial window position. This must happen after the window + // is fully set up (content view, toolbar, default size) so that + // decorations added by subclass awakeFromNib (e.g. toolbar for tabs + // style) don't change the frame after the position is restored. + let originChanged = terminalWindow.setInitialWindowPosition( + x: derivedConfig.windowPositionX, + y: derivedConfig.windowPositionY, + ) + let restored = LastWindowPosition.shared.restore( + terminalWindow, + origin: !originChanged, + size: defaultSize == nil, + ) + + // If nothing is changed for the frame, + // we should center the window + if !originChanged, !restored { + // This doesn't work in `windowDidLoad` somehow + terminalWindow.center() + } + + super.showWindow(sender) + } + // Shows the "+" button in the tab bar, responds to that click. override func newWindowForTab(_ sender: Any?) { // Trigger the ghostty core event logic for a new tab. diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index b9ca1ecc4..e19d6711f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -536,17 +536,15 @@ class TerminalWindow: NSWindow { terminalController?.updateColorSchemeForSurfaceTree() } - func setInitialWindowPosition(x: Int16?, y: Int16?) { + func setInitialWindowPosition(x: Int16?, y: Int16?) -> Bool { // If we don't have an X/Y then we try to use the previously saved window pos. guard let x = x, let y = y else { - center() - return + return false } // Prefer the screen our window is being placed on otherwise our primary screen. guard let screen = screen ?? NSScreen.screens.first else { - center() - return + return false } // Convert top-left coordinates to bottom-left origin using our utility extension @@ -562,6 +560,7 @@ class TerminalWindow: NSWindow { safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height) setFrameOrigin(safeOrigin) + return true } private func hideWindowButtons() { diff --git a/macos/Sources/Helpers/LastWindowPosition.swift b/macos/Sources/Helpers/LastWindowPosition.swift index 298367c74..c7989b6fa 100644 --- a/macos/Sources/Helpers/LastWindowPosition.swift +++ b/macos/Sources/Helpers/LastWindowPosition.swift @@ -50,7 +50,13 @@ class LastWindowPosition { newFrame.size.height = min(values[3], visibleFrame.height) } - if restoreOrigin, !visibleFrame.contains(newFrame.origin) { + // If the new frame is not constrained to the visible screen, + // we need to shift it a little bit before AppKit does this for us, + // so that we can save the correct size beforehand. + // This fixes restoration while running UI tests, + // where config is modified without switching apps, + // which will not trigger `windowDidBecomeMain`. + if restoreOrigin, !visibleFrame.contains(newFrame) { newFrame.origin.x = max(visibleFrame.minX, min(visibleFrame.maxX - newFrame.width, newFrame.origin.x)) newFrame.origin.y = max(visibleFrame.minY, min(visibleFrame.maxY - newFrame.height, newFrame.origin.y)) } From 08107d342a1404ea095e48b0ee7fcc5299c2024f Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:44:34 +0100 Subject: [PATCH 134/391] macOS: we don't need initialFrame anymore --- .../Features/Terminal/TerminalController.swift | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index f29e19384..5101651b4 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -56,9 +56,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] - /// This will be set to the initial frame of the window from the xib on load. - private var initialFrame: NSRect? - init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, withSurfaceTree tree: SplitTree? = nil, @@ -1061,13 +1058,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } - - // Store our initial frame so we can know our default later. This MUST - // be after the defaultSize call above so that we don't re-apply our frame. - // Note: we probably want to set this on the first frame change or something - // so it respects cascade. - initialFrame = window.frame - // In various situations, macOS automatically tabs new windows. Ghostty handles // its own tabbing so we DONT want this behavior. This detects this scenario and undoes // it. @@ -1666,9 +1656,6 @@ extension TerminalController { // Initial size as requested by the configuration (e.g. `window-width`) // takes next priority. return .contentIntrinsicSize - } else if let initialFrame { - // The initial frame we had when we started otherwise. - return .frame(initialFrame) } else { return nil } From 77c2acf843e49c9566128fd2381a667077e4f2f8 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:23:52 +0100 Subject: [PATCH 135/391] macOS: add test case for window cascading without moving the window --- .../GhosttyWindowPositionUITests.swift | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift index 44918c8b1..7204472f3 100644 --- a/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift +++ b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift @@ -10,6 +10,61 @@ import XCTest final class GhosttyWindowPositionUITests: GhosttyCustomConfigCase { override static var runsForEachTargetApplicationUIConfiguration: Bool { false } + // MARK: - Cascading + + @MainActor func testWindowCascading() async throws { + try updateConfig( + """ + title = "GhosttyWindowPositionUITests" + """ + ) + + let app = try ghosttyApplication() + // Suppress Restoration + app.launchArguments += ["-NSQuitAlwaysKeepsWindows", "NO"] + // Clean run + app.launchEnvironment["GHOSTTY_CLEAR_USER_DEFAULTS"] = "YES" + + app.launch() // window in the center + +// app.menuBarItems["Window"].firstMatch.click() +// app.menuItems["_zoomTopLeft:"].firstMatch.click() +// +// // wait for the animation to finish +// try await Task.sleep(for: .seconds(0.5)) + + let window = app.windows.firstMatch + let windowFrame = window.frame +// XCTAssertEqual(windowFrame.minX, 0, "Window should be on the left") + + app.typeKey("n", modifierFlags: [.command]) + + let window2 = app.windows.firstMatch + XCTAssertTrue(window2.waitForExistence(timeout: 5), "New window should appear") + let windowFrame2 = window2.frame + XCTAssertNotEqual(windowFrame, windowFrame2, "New window should have moved") + + XCTAssertEqual(windowFrame2.minX, windowFrame.minX + 30, accuracy: 5, "New window should be on the right") + + app.typeKey("n", modifierFlags: [.command]) + + let window3 = app.windows.firstMatch + XCTAssertTrue(window3.waitForExistence(timeout: 5), "New window should appear") + let windowFrame3 = window3.frame + XCTAssertNotEqual(windowFrame2, windowFrame3, "New window should have moved") + + XCTAssertEqual(windowFrame3.minX, windowFrame2.minX + 30, accuracy: 5, "New window should be on the right") + + app.typeKey("n", modifierFlags: [.command]) + + let window4 = app.windows.firstMatch + XCTAssertTrue(window4.waitForExistence(timeout: 5), "New window should appear") + let windowFrame4 = window4.frame + XCTAssertNotEqual(windowFrame3, windowFrame4, "New window should have moved") + + XCTAssertEqual(windowFrame4.minX, windowFrame3.minX + 30, accuracy: 5, "New window should be on the right") + } + // MARK: - Restore round-trip per titlebar style @MainActor func testRestoredNative() throws { try runRestoreTest(titlebarStyle: "native") } From ea262cdd34c36ac848ddd417cdf29a4dc93d7fb6 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:25:20 +0100 Subject: [PATCH 136/391] macOS: fix window cascading for 3rd+ window --- macos/Sources/Features/Terminal/TerminalController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 5101651b4..348bf6d22 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -256,6 +256,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // take effect. Our best theory is there is some next-event-loop-tick logic // that Cocoa is doing that we need to be after. DispatchQueue.main.async { + c.showWindow(self) + // Only cascade if we aren't fullscreen. if let window = c.window { if !window.styleMask.contains(.fullScreen) { @@ -264,8 +266,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } - c.showWindow(self) - // All new_window actions force our app to be active, so that the new // window is focused and visible. NSApp.activate(ignoringOtherApps: true) From 5e3866381b321bbc936f5de18e9f2b9622e0af4c Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:25:38 +0100 Subject: [PATCH 137/391] macOS: fix window cascading for the second window --- macos/Sources/Features/Terminal/TerminalController.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 348bf6d22..f06da571c 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -200,7 +200,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr if all.count > 1 { lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint) } else { - lastCascadePoint = window.cascadeTopLeft(from: NSPoint(x: window.frame.minX, y: window.frame.maxY)) + // We assume the window frame is already correct at this point, + // so we pass .zero to let cascade use the current frame position. + lastCascadePoint = window.cascadeTopLeft(from: .zero) } } From d6d6fe4e5800f48846815a6cb2401c495e9ca57c Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:58:37 +0100 Subject: [PATCH 138/391] macOS: update window cascading Make it smaller and add comparisons between y values --- macos/GhosttyUITests/GhosttyWindowPositionUITests.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift index 7204472f3..d326c5954 100644 --- a/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift +++ b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift @@ -15,6 +15,8 @@ final class GhosttyWindowPositionUITests: GhosttyCustomConfigCase { @MainActor func testWindowCascading() async throws { try updateConfig( """ + window-width = 30 + window-height = 10 title = "GhosttyWindowPositionUITests" """ ) @@ -46,6 +48,8 @@ final class GhosttyWindowPositionUITests: GhosttyCustomConfigCase { XCTAssertEqual(windowFrame2.minX, windowFrame.minX + 30, accuracy: 5, "New window should be on the right") + XCTAssertEqual(windowFrame2.minY, windowFrame.minY + 30, accuracy: 5, "New window should be on the bottom right") + app.typeKey("n", modifierFlags: [.command]) let window3 = app.windows.firstMatch @@ -55,6 +59,8 @@ final class GhosttyWindowPositionUITests: GhosttyCustomConfigCase { XCTAssertEqual(windowFrame3.minX, windowFrame2.minX + 30, accuracy: 5, "New window should be on the right") + XCTAssertEqual(windowFrame3.minY, windowFrame2.minY + 30, accuracy: 5, "New window should be on the bottom right") + app.typeKey("n", modifierFlags: [.command]) let window4 = app.windows.firstMatch @@ -63,6 +69,8 @@ final class GhosttyWindowPositionUITests: GhosttyCustomConfigCase { XCTAssertNotEqual(windowFrame3, windowFrame4, "New window should have moved") XCTAssertEqual(windowFrame4.minX, windowFrame3.minX + 30, accuracy: 5, "New window should be on the right") + + XCTAssertEqual(windowFrame4.minY, windowFrame3.minY + 30, accuracy: 5, "New window should be on the bottom right") } // MARK: - Restore round-trip per titlebar style From 3022aa05ea82296adb598d340735f8339f5bf753 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:54:12 +0100 Subject: [PATCH 139/391] macOS: add test cases for drag-split --- .../GhosttyWindowPositionUITests.swift | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift index d326c5954..99f7b5627 100644 --- a/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift +++ b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift @@ -73,6 +73,109 @@ final class GhosttyWindowPositionUITests: GhosttyCustomConfigCase { XCTAssertEqual(windowFrame4.minY, windowFrame3.minY + 30, accuracy: 5, "New window should be on the bottom right") } + @MainActor func testDragSplitWindowPosition() async throws { + try updateConfig( + """ + window-width = 40 + window-height = 20 + title = "GhosttyWindowPositionUITests" + macos-titlebar-style = hidden + """ + ) + + let app = try ghosttyApplication() + // Suppress Restoration + app.launchArguments += ["-NSQuitAlwaysKeepsWindows", "NO"] + // Clean run + app.launchEnvironment["GHOSTTY_CLEAR_USER_DEFAULTS"] = "YES" + + app.launch() // window in the center + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "New window should appear") + + // remove fixe size + try updateConfig( + """ + title = "GhosttyWindowPositionUITests" + macos-titlebar-style = hidden + """ + ) + app.typeKey(",", modifierFlags: [.command, .shift]) + + app.typeKey("d", modifierFlags: [.command]) + + let rightSplit = app.groups["Right pane"] + let rightFrame = rightSplit.frame + + let sourcePos = rightSplit.coordinate(withNormalizedOffset: .zero) + .withOffset(.init(dx: rightFrame.size.width / 2, dy: 3)) + + let targetPos = rightSplit.coordinate(withNormalizedOffset: .zero) + .withOffset(.init(dx: rightFrame.size.width + 100, dy: 0)) + + sourcePos.click(forDuration: 0.2, thenDragTo: targetPos) + + let window2 = app.windows.firstMatch + XCTAssertTrue(window2.waitForExistence(timeout: 5), "New window should appear") + let windowFrame2 = window2.frame + + try await Task.sleep(for: .seconds(0.5)) + + XCTAssertEqual(windowFrame2.minX, rightFrame.maxX + 100, accuracy: 5, "New window should be target position") + XCTAssertEqual(windowFrame2.minY, rightFrame.minY, accuracy: 5, "New window should be target position") + XCTAssertEqual(windowFrame2.width, rightFrame.width, accuracy: 5, "New window should use size from config") + XCTAssertEqual(windowFrame2.height, rightFrame.height, accuracy: 5, "New window should use size from config") + } + + @MainActor func testDragSplitWindowPositionWithFixedSize() async throws { + try updateConfig( + """ + window-width = 40 + window-height = 20 + title = "GhosttyWindowPositionUITests" + macos-titlebar-style = hidden + """ + ) + + let app = try ghosttyApplication() + // Suppress Restoration + app.launchArguments += ["-NSQuitAlwaysKeepsWindows", "NO"] + // Clean run + app.launchEnvironment["GHOSTTY_CLEAR_USER_DEFAULTS"] = "YES" + + app.launch() // window in the center + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "New window should appear") + let windowFrame = window.frame + + app.typeKey("d", modifierFlags: [.command]) + + let rightSplit = app.groups["Right pane"] + let rightFrame = rightSplit.frame + + let sourcePos = rightSplit.coordinate(withNormalizedOffset: .zero) + .withOffset(.init(dx: rightFrame.size.width / 2, dy: 3)) + + let targetPos = rightSplit.coordinate(withNormalizedOffset: .zero) + .withOffset(.init(dx: rightFrame.size.width + 100, dy: 0)) + + sourcePos.click(forDuration: 0.2, thenDragTo: targetPos) + + let window2 = app.windows.firstMatch + XCTAssertTrue(window2.waitForExistence(timeout: 5), "New window should appear") + let windowFrame2 = window2.frame + + try await Task.sleep(for: .seconds(0.5)) + + XCTAssertEqual(windowFrame2.minX, rightFrame.maxX + 100, accuracy: 5, "New window should be target position") + XCTAssertEqual(windowFrame2.minY, rightFrame.minY, accuracy: 5, "New window should be target position") + XCTAssertEqual(windowFrame2.width, windowFrame.width, accuracy: 5, "New window should use size from config") + // We're still using right frame, because of the debug banner + XCTAssertEqual(windowFrame2.height, rightFrame.height, accuracy: 5, "New window should use size from config") + } + // MARK: - Restore round-trip per titlebar style @MainActor func testRestoredNative() throws { try runRestoreTest(titlebarStyle: "native") } From 07bc8886822bdc19932efea54e6d01bd230078cc Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:54:41 +0100 Subject: [PATCH 140/391] macOS: fix window position when dragging split into a new window --- macos/Sources/Features/Terminal/TerminalController.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index f06da571c..7ade0e38d 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -318,8 +318,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Calculate the target frame based on the tree's view bounds let treeSize: CGSize? = tree.root?.viewBounds() - + DispatchQueue.main.async { + c.showWindow(self) if let window = c.window { // If we have a tree size, resize the window's content to match if let treeSize, treeSize.width > 0, treeSize.height > 0 { @@ -337,8 +338,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } } - - c.showWindow(self) } // Setup our undo From 5c51603b0b82a33c7461384e27ee67edbf3818fd Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:02:23 +0100 Subject: [PATCH 141/391] chore: make ci happy --- macos/GhosttyUITests/GhosttyWindowPositionUITests.swift | 2 +- macos/Sources/Features/Terminal/TerminalController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift index 99f7b5627..399c2531a 100644 --- a/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift +++ b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift @@ -94,7 +94,7 @@ final class GhosttyWindowPositionUITests: GhosttyCustomConfigCase { let window = app.windows.firstMatch XCTAssertTrue(window.waitForExistence(timeout: 5), "New window should appear") - // remove fixe size + // remove fixed size try updateConfig( """ title = "GhosttyWindowPositionUITests" diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7ade0e38d..56b0b40ad 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -318,7 +318,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Calculate the target frame based on the tree's view bounds let treeSize: CGSize? = tree.root?.viewBounds() - + DispatchQueue.main.async { c.showWindow(self) if let window = c.window { From 64331b8c35e2a39a9296594f9e6b096c54a8b49f Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Thu, 12 Mar 2026 16:27:54 -0400 Subject: [PATCH 142/391] snap: Don't leak LD_LIBRARY_PATH set by the snap launcher --- src/os/open.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/os/open.zig b/src/os/open.zig index 28d1c23ee..0cead5552 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -1,6 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; +const build_config = @import("../build_config.zig"); const apprt = @import("../apprt.zig"); const log = std.log.scoped(.@"os-open"); @@ -48,6 +49,17 @@ pub fn open( exe.stdout_behavior = .Pipe; exe.stderr_behavior = .Pipe; + // In the snap on Linux the launcher exports LD_LIBRARY_PATH pointing at + // the snap's bundled libraries. Leaking this into child process can + // can be problematic, so let's drop it from the env + var snap_env: std.process.EnvMap = if (comptime build_config.snap) blk: { + var env = try std.process.getEnvMap(alloc); + env.remove("LD_LIBRARY_PATH"); + break :blk env; + } else undefined; + defer if (comptime build_config.snap) snap_env.deinit(); + if (comptime build_config.snap) exe.env_map = &snap_env; + // Spawn the process on our same thread so we can detect failure // quickly. try exe.spawn(); From eccf960def6f15dc33abaeff6f9b7ad3894db5dd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:13:53 +0000 Subject: [PATCH 143/391] build(deps): bump dorny/paths-filter from 3.0.2 to 4.0.0 Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 3.0.2 to 4.0.0. - [Release notes](https://github.com/dorny/paths-filter/releases) - [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md) - [Commits](https://github.com/dorny/paths-filter/compare/de90cc6fb38fc0963ad72b210f1f284cd68cea36...9d7afb8d214ad99e78fbd4247752c4caed2b6e4c) --- updated-dependencies: - dependency-name: dorny/paths-filter dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d0f01133b..2762427ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + - uses: dorny/paths-filter@9d7afb8d214ad99e78fbd4247752c4caed2b6e4c # v4.0.0 id: filter_every with: token: ${{ secrets.GITHUB_TOKEN }} @@ -40,7 +40,7 @@ jobs: code: - '**' - '!.github/VOUCHED.td' - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + - uses: dorny/paths-filter@9d7afb8d214ad99e78fbd4247752c4caed2b6e4c # v4.0.0 id: filter_any with: token: ${{ secrets.GITHUB_TOKEN }} From 6f8ffecb89a4484a2fc587e0217263d28a7612e5 Mon Sep 17 00:00:00 2001 From: rhodes-b <59537185+rhodes-b@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:21:42 -0500 Subject: [PATCH 144/391] working basic search wrapping --- src/terminal/search/screen.zig | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 74828d879..c9e0ada7f 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -740,13 +740,9 @@ pub const ScreenSearch = struct { return true; }; - const next_idx = prev.idx + 1; const active_len = self.active_results.items.len; const history_len = self.history_results.items.len; - if (next_idx >= active_len + history_len) { - // No more matches. We don't wrap or reset the match currently. - return false; - } + const next_idx = if (prev.idx + 1 >= active_len + history_len) 0 else prev.idx + 1; const hl: FlattenedHighlight = if (next_idx < active_len) self.active_results.items[active_len - 1 - next_idx] else @@ -800,14 +796,10 @@ pub const ScreenSearch = struct { return true; }; - // Can't go below zero - if (prev.idx == 0) { - // No more matches. We don't wrap or reset the match currently. - return false; - } - - const next_idx = prev.idx - 1; const active_len = self.active_results.items.len; + const history_len = self.history_results.items.len; + const next_idx = if (prev.idx != 0) prev.idx - 1 else active_len - 1 + history_len; + const hl: FlattenedHighlight = if (next_idx < active_len) self.active_results.items[active_len - 1 - next_idx] else From af84fdbea8fbb1f9418f000151f99d880051a3ba Mon Sep 17 00:00:00 2001 From: rhodes-b <59537185+rhodes-b@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:39:35 -0500 Subject: [PATCH 145/391] fix tests --- src/terminal/search/screen.zig | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index c9e0ada7f..ca2a5a894 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -1075,17 +1075,17 @@ test "select next" { } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } - // Next match (no wrap) + // Next match (wrap) _ = try search.select(.next); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, - .y = 0, + .y = 2, } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, - .y = 0, + .y = 2, } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } @@ -1270,17 +1270,17 @@ test "select prev" { } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } - // Prev match (no wrap, stays at newest) + // Prev match (wrap) _ = try search.select(.prev); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, - .y = 2, + .y = 0, } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, - .y = 2, + .y = 0, } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } From 4c4e83784c5b8986d6d0a22c3f1e4fe79a4a3f03 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 08:49:12 -0700 Subject: [PATCH 146/391] macos: new tab applescript command should not activate application Related to #11457 --- macos/Sources/Features/AppleScript/ScriptTab.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/macos/Sources/Features/AppleScript/ScriptTab.swift b/macos/Sources/Features/AppleScript/ScriptTab.swift index e5715a4b0..97a5ed1e5 100644 --- a/macos/Sources/Features/AppleScript/ScriptTab.swift +++ b/macos/Sources/Features/AppleScript/ScriptTab.swift @@ -126,7 +126,6 @@ final class ScriptTab: NSObject { } tabContainerWindow.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) return nil } From 332b2aefc6e72d363aa93ab6ecfc86eeeeb5ed28 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 08:56:24 -0700 Subject: [PATCH 147/391] 1.3.1 --- build.zig.zon | 2 +- dist/linux/com.mitchellh.ghostty.metainfo.xml.in | 3 +++ nix/package.nix | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 615f13288..05547bd3b 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .ghostty, - .version = "1.3.0", + .version = "1.3.1", .paths = .{""}, .fingerprint = 0x64407a2a0b4147e5, .minimum_zig_version = "0.15.2", diff --git a/dist/linux/com.mitchellh.ghostty.metainfo.xml.in b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in index da1fa626e..4f23c35da 100644 --- a/dist/linux/com.mitchellh.ghostty.metainfo.xml.in +++ b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in @@ -52,6 +52,9 @@ + + https://ghostty.org/docs/install/release-notes/1-3-1 + https://ghostty.org/docs/install/release-notes/1-3-0 diff --git a/nix/package.nix b/nix/package.nix index 391c9da05..f0f4d4519 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -30,7 +30,7 @@ in stdenv.mkDerivation (finalAttrs: { pname = "ghostty"; - version = "1.3.0"; + version = "1.3.1"; # We limit source like this to try and reduce the amount of rebuilds as possible # thus we only provide the source that is needed for the build From 5fa1a991d0838d7bd08a1130de16b05b99efb445 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 09:22:43 -0700 Subject: [PATCH 148/391] up to 1.3.2-dev --- build.zig.zon | 2 +- nix/package.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 05547bd3b..7a669a4a1 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .ghostty, - .version = "1.3.1", + .version = "1.3.2-dev", .paths = .{""}, .fingerprint = 0x64407a2a0b4147e5, .minimum_zig_version = "0.15.2", diff --git a/nix/package.nix b/nix/package.nix index f0f4d4519..8287b0888 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -30,7 +30,7 @@ in stdenv.mkDerivation (finalAttrs: { pname = "ghostty"; - version = "1.3.1"; + version = "1.3.2-dev"; # We limit source like this to try and reduce the amount of rebuilds as possible # thus we only provide the source that is needed for the build From 2044e5030ffaadf0361a2f4fca040b77408db0b5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 13:14:03 -0700 Subject: [PATCH 149/391] terminal: make stream processing infallible The terminal.Stream next/nextSlice functions can now no longer fail. All prior failure modes were fully isolated in the handler `vt` callbacks. As such, vt callbacks are now required to not return an error and handle their own errors somehow. Allowing streams to be fallible before was an incorrect design. It caused problematic scenarios like in `nextSlice` early terminating processing due to handler errors. This should not be possible. There is no safe way to bubble up vt errors through the stream because if nextSlice is called and multiple errors are returned, we can't coalesce them. We could modify that to return a partial result but its just more work for stream that is unnecessary. The handler can do all of this. This work was discovered due to cleanups to prepare for more C APIs. Less errors make C APIs easier to implement! And, it helps clean up our Zig, too. --- example/zig-formatter/src/main.zig | 4 +- example/zig-vt-stream/src/main.zig | 16 +- src/benchmark/ScreenClone.zig | 11 +- src/benchmark/TerminalStream.zig | 11 +- src/font/shaper/coretext.zig | 94 ++-- src/font/shaper/harfbuzz.zig | 84 ++-- src/inspector/widgets/termio.zig | 4 +- src/renderer/cell.zig | 24 +- src/renderer/link.zig | 6 +- src/terminal/formatter.zig | 314 ++++++------ src/terminal/render.zig | 46 +- src/terminal/search/Thread.zig | 2 +- src/terminal/search/active.zig | 10 +- src/terminal/search/pagelist.zig | 44 +- src/terminal/search/screen.zig | 98 ++-- src/terminal/search/sliding_window.zig | 4 +- src/terminal/search/viewport.zig | 26 +- src/terminal/stream.zig | 580 ++++++++++++----------- src/terminal/stream_readonly.zig | 158 +++--- src/terminal/tmux/viewer.zig | 15 +- src/termio/Termio.zig | 6 +- src/termio/stream_handler.zig | 10 + test/fuzz-libghostty/src/fuzz_stream.zig | 6 +- 23 files changed, 791 insertions(+), 782 deletions(-) diff --git a/example/zig-formatter/src/main.zig b/example/zig-formatter/src/main.zig index ad101dbf1..df21a2046 100644 --- a/example/zig-formatter/src/main.zig +++ b/example/zig-formatter/src/main.zig @@ -23,8 +23,8 @@ pub fn main() !void { // Replace \n with \r\n for (buf[0..n]) |byte| { - if (byte == '\n') try stream.next('\r'); - try stream.next(byte); + if (byte == '\n') stream.next('\r'); + stream.next(byte); } } diff --git a/example/zig-vt-stream/src/main.zig b/example/zig-vt-stream/src/main.zig index 8fd438b70..87d8857dd 100644 --- a/example/zig-vt-stream/src/main.zig +++ b/example/zig-vt-stream/src/main.zig @@ -14,24 +14,24 @@ pub fn main() !void { defer stream.deinit(); // Basic text with newline - try stream.nextSlice("Hello, World!\r\n"); + stream.nextSlice("Hello, World!\r\n"); // ANSI color codes: ESC[1;32m = bold green, ESC[0m = reset - try stream.nextSlice("\x1b[1;32mGreen Text\x1b[0m\r\n"); + stream.nextSlice("\x1b[1;32mGreen Text\x1b[0m\r\n"); // Cursor positioning: ESC[1;1H = move to row 1, column 1 - try stream.nextSlice("\x1b[1;1HTop-left corner\r\n"); + stream.nextSlice("\x1b[1;1HTop-left corner\r\n"); // Cursor movement: ESC[5B = move down 5 lines - try stream.nextSlice("\x1b[5B"); - try stream.nextSlice("Moved down!\r\n"); + stream.nextSlice("\x1b[5B"); + stream.nextSlice("Moved down!\r\n"); // Erase line: ESC[2K = clear entire line - try stream.nextSlice("\x1b[2K"); - try stream.nextSlice("New content\r\n"); + stream.nextSlice("\x1b[2K"); + stream.nextSlice("New content\r\n"); // Multiple lines - try stream.nextSlice("Line A\r\nLine B\r\nLine C\r\n"); + stream.nextSlice("Line A\r\nLine B\r\nLine C\r\n"); // Get the final terminal state as a plain string const str = try t.plainString(alloc); diff --git a/src/benchmark/ScreenClone.zig b/src/benchmark/ScreenClone.zig index 380379bc3..108eaa0c6 100644 --- a/src/benchmark/ScreenClone.zig +++ b/src/benchmark/ScreenClone.zig @@ -94,9 +94,9 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { // Force a style on every single row, which var s = self.terminal.vtStream(); defer s.deinit(); - s.nextSlice("\x1b[48;2;20;40;60m") catch unreachable; - for (0..self.terminal.rows - 1) |_| s.nextSlice("hello\r\n") catch unreachable; - s.nextSlice("hello") catch unreachable; + s.nextSlice("\x1b[48;2;20;40;60m"); + for (0..self.terminal.rows - 1) |_| s.nextSlice("hello\r\n"); + s.nextSlice("hello"); // Setup our terminal state const data_f: std.fs.File = (options.dataFile( @@ -120,10 +120,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { return error.BenchmarkFailed; }; if (n == 0) break; // EOF reached - stream.nextSlice(buf[0..n]) catch |err| { - log.warn("error processing data file chunk err={}", .{err}); - return error.BenchmarkFailed; - }; + stream.nextSlice(buf[0..n]); } } diff --git a/src/benchmark/TerminalStream.zig b/src/benchmark/TerminalStream.zig index 7cf28217f..1cac656e2 100644 --- a/src/benchmark/TerminalStream.zig +++ b/src/benchmark/TerminalStream.zig @@ -125,10 +125,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { return error.BenchmarkFailed; }; if (n == 0) break; // EOF reached - self.stream.nextSlice(buf[0..n]) catch |err| { - log.warn("error processing data file chunk err={}", .{err}); - return error.BenchmarkFailed; - }; + self.stream.nextSlice(buf[0..n]); } } @@ -142,9 +139,11 @@ const Handler = struct { self: *Handler, comptime action: Stream.Action.Tag, value: Stream.Action.Value(action), - ) !void { + ) void { switch (action) { - .print => try self.t.print(value.cp), + .print => self.t.print(value.cp) catch |err| { + log.warn("error processing benchmark print err={}", .{err}); + }, else => {}, } } diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 5a8a6ccbf..ff7c6d9d3 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -850,7 +850,7 @@ test "run iterator" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("ABCD"); + s.nextSlice("ABCD"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -874,7 +874,7 @@ test "run iterator" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("ABCD EFG"); + s.nextSlice("ABCD EFG"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -897,7 +897,7 @@ test "run iterator" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A😃D"); + s.nextSlice("A😃D"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -922,7 +922,7 @@ test "run iterator" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(bad); + s.nextSlice(bad); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -955,8 +955,8 @@ test "run iterator: empty cells with background set" { var s = t.vtStream(); defer s.deinit(); // Set red background - try s.nextSlice("\x1b[48;2;255;0;0m"); - try s.nextSlice("A"); + s.nextSlice("\x1b[48;2;255;0;0m"); + s.nextSlice("A"); // Get our first row { @@ -1014,7 +1014,7 @@ test "shape" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1053,7 +1053,7 @@ test "shape nerd fonts" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1086,7 +1086,7 @@ test "shape inconsolata ligs" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(">="); + s.nextSlice(">="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1115,7 +1115,7 @@ test "shape inconsolata ligs" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("==="); + s.nextSlice("==="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1152,7 +1152,7 @@ test "shape monaspace ligs" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("==="); + s.nextSlice("==="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1190,7 +1190,7 @@ test "shape left-replaced lig in last run" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("!=="); + s.nextSlice("!=="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1228,7 +1228,7 @@ test "shape left-replaced lig in early run" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("!==X"); + s.nextSlice("!==X"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1263,7 +1263,7 @@ test "shape U+3C9 with JB Mono" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\u{03C9} foo"); + s.nextSlice("\u{03C9} foo"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1300,7 +1300,7 @@ test "shape emoji width" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("👍"); + s.nextSlice("👍"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1390,7 +1390,7 @@ test "shape variation selector VS15" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1429,7 +1429,7 @@ test "shape variation selector VS16" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1463,9 +1463,9 @@ test "shape with empty cells in between" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A"); - try s.nextSlice("\x1b[5C"); // 5 spaces forward - try s.nextSlice("B"); + s.nextSlice("A"); + s.nextSlice("\x1b[5C"); // 5 spaces forward + s.nextSlice("B"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1510,7 +1510,7 @@ test "shape Combining characters" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1560,7 +1560,7 @@ test "shape Devanagari string" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("अपार्टमेंट"); + s.nextSlice("अपार्टमेंट"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1619,7 +1619,7 @@ test "shape Tai Tham vowels (position differs from advance)" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1680,7 +1680,7 @@ test "shape Tai Tham letters (position.y differs from advance)" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1740,7 +1740,7 @@ test "shape Javanese ligatures" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1803,7 +1803,7 @@ test "shape Chakma vowel sign with ligature (vowel sign renders first)" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1874,7 +1874,7 @@ test "shape Bengali ligatures with out of order vowels" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1929,7 +1929,7 @@ test "shape box glyphs" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1967,7 +1967,7 @@ test "shape selection boundary" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("a1b2c3d4e5"); + s.nextSlice("a1b2c3d4e5"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -2072,7 +2072,7 @@ test "shape cursor boundary" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("a1b2c3d4e5"); + s.nextSlice("a1b2c3d4e5"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -2209,7 +2209,7 @@ test "shape cursor boundary and colored emoji" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("👍🏼"); + s.nextSlice("👍🏼"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -2306,7 +2306,7 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(">="); + s.nextSlice(">="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -2332,9 +2332,9 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(">"); - try s.nextSlice("\x1b[1m"); // Bold - try s.nextSlice("="); + s.nextSlice(">"); + s.nextSlice("\x1b[1m"); // Bold + s.nextSlice("="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -2361,11 +2361,11 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); // RGB 1, 2, 3 - try s.nextSlice("\x1b[38;2;1;2;3m"); - try s.nextSlice(">"); + s.nextSlice("\x1b[38;2;1;2;3m"); + s.nextSlice(">"); // RGB 3, 2, 1 - try s.nextSlice("\x1b[38;2;3;2;1m"); - try s.nextSlice("="); + s.nextSlice("\x1b[38;2;3;2;1m"); + s.nextSlice("="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -2392,11 +2392,11 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); // RGB 1, 2, 3 bg - try s.nextSlice("\x1b[48;2;1;2;3m"); - try s.nextSlice(">"); + s.nextSlice("\x1b[48;2;1;2;3m"); + s.nextSlice(">"); // RGB 3, 2, 1 bg - try s.nextSlice("\x1b[48;2;3;2;1m"); - try s.nextSlice("="); + s.nextSlice("\x1b[48;2;3;2;1m"); + s.nextSlice("="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -2423,9 +2423,9 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); // RGB 1, 2, 3 bg - try s.nextSlice("\x1b[48;2;1;2;3m"); - try s.nextSlice(">"); - try s.nextSlice("="); + s.nextSlice("\x1b[48;2;1;2;3m"); + s.nextSlice(">"); + s.nextSlice("="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -2468,7 +2468,7 @@ test "shape high plane sprite font codepoint" { var s = t.vtStream(); defer s.deinit(); // U+1FB70: Vertical One Eighth Block-2 - try s.nextSlice("\u{1FB70}"); + s.nextSlice("\u{1FB70}"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 30e1d0544..d17df4b1e 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -448,7 +448,7 @@ test "run iterator" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("ABCD"); + s.nextSlice("ABCD"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -472,7 +472,7 @@ test "run iterator" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("ABCD EFG"); + s.nextSlice("ABCD EFG"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -495,7 +495,7 @@ test "run iterator" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A😃D"); + s.nextSlice("A😃D"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -533,7 +533,7 @@ test "run iterator: empty cells with background set" { var s = t.vtStream(); defer s.deinit(); // Set red background and write A - try s.nextSlice("\x1b[48;2;255;0;0mA"); + s.nextSlice("\x1b[48;2;255;0;0mA"); // Get our first row { @@ -592,7 +592,7 @@ test "shape" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -626,7 +626,7 @@ test "shape inconsolata ligs" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(">="); + s.nextSlice(">="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -655,7 +655,7 @@ test "shape inconsolata ligs" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("==="); + s.nextSlice("==="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -692,7 +692,7 @@ test "shape monaspace ligs" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("==="); + s.nextSlice("==="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -732,7 +732,7 @@ test "shape arabic forced LTR" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(@embedFile("testdata/arabic.txt")); + s.nextSlice(@embedFile("testdata/arabic.txt")); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -773,7 +773,7 @@ test "shape emoji width" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("👍"); + s.nextSlice("👍"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -870,7 +870,7 @@ test "shape variation selector VS15" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -911,7 +911,7 @@ test "shape variation selector VS16" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -950,9 +950,9 @@ test "shape with empty cells in between" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A"); - try s.nextSlice("\x1b[5C"); - try s.nextSlice("B"); + s.nextSlice("A"); + s.nextSlice("\x1b[5C"); + s.nextSlice("B"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -997,7 +997,7 @@ test "shape Combining characters" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1048,7 +1048,7 @@ test "shape Devanagari string" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("अपार्टमेंट"); + s.nextSlice("अपार्टमेंट"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1111,7 +1111,7 @@ test "shape Tai Tham vowels (position differs from advance)" { // var s = t.vtStream(); // defer s.deinit(); - // try s.nextSlice(buf[0..buf_idx]); + // s.nextSlice(buf[0..buf_idx]); // var state: terminal.RenderState = .empty; // defer state.deinit(alloc); @@ -1170,7 +1170,7 @@ test "shape Tibetan characters" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1232,7 +1232,7 @@ test "shape Tai Tham letters (run_offset.y differs from zero)" { // var s = t.vtStream(); // defer s.deinit(); - // try s.nextSlice(buf[0..buf_idx]); + // s.nextSlice(buf[0..buf_idx]); // var state: terminal.RenderState = .empty; // defer state.deinit(alloc); @@ -1295,7 +1295,7 @@ test "shape Javanese ligatures" { // var s = t.vtStream(); // defer s.deinit(); - // try s.nextSlice(buf[0..buf_idx]); + // s.nextSlice(buf[0..buf_idx]); // var state: terminal.RenderState = .empty; // defer state.deinit(alloc); @@ -1358,7 +1358,7 @@ test "shape Chakma vowel sign with ligature (vowel sign renders first)" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1433,7 +1433,7 @@ test "shape Bengali ligatures with out of order vowels" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1487,7 +1487,7 @@ test "shape box glyphs" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); + s.nextSlice(buf[0..buf_idx]); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1526,7 +1526,7 @@ test "shape selection boundary" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("a1b2c3d4e5"); + s.nextSlice("a1b2c3d4e5"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1631,7 +1631,7 @@ test "shape cursor boundary" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("a1b2c3d4e5"); + s.nextSlice("a1b2c3d4e5"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1771,7 +1771,7 @@ test "shape cursor boundary and colored emoji" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("👍🏼"); + s.nextSlice("👍🏼"); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1868,7 +1868,7 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(">="); + s.nextSlice(">="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1894,9 +1894,9 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice(">"); - try s.nextSlice("\x1b[1m"); - try s.nextSlice("="); + s.nextSlice(">"); + s.nextSlice("\x1b[1m"); + s.nextSlice("="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1923,11 +1923,11 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); // RGB 1, 2, 3 - try s.nextSlice("\x1b[38;2;1;2;3m"); - try s.nextSlice(">"); + s.nextSlice("\x1b[38;2;1;2;3m"); + s.nextSlice(">"); // RGB 3, 2, 1 - try s.nextSlice("\x1b[38;2;3;2;1m"); - try s.nextSlice("="); + s.nextSlice("\x1b[38;2;3;2;1m"); + s.nextSlice("="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1954,11 +1954,11 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); // RGB 1, 2, 3 bg - try s.nextSlice("\x1b[48;2;1;2;3m"); - try s.nextSlice(">"); + s.nextSlice("\x1b[48;2;1;2;3m"); + s.nextSlice(">"); // RGB 3, 2, 1 bg - try s.nextSlice("\x1b[48;2;3;2;1m"); - try s.nextSlice("="); + s.nextSlice("\x1b[48;2;3;2;1m"); + s.nextSlice("="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -1985,9 +1985,9 @@ test "shape cell attribute change" { var s = t.vtStream(); defer s.deinit(); // RGB 1, 2, 3 bg - try s.nextSlice("\x1b[48;2;1;2;3m"); - try s.nextSlice(">"); - try s.nextSlice("="); + s.nextSlice("\x1b[48;2;1;2;3m"); + s.nextSlice(">"); + s.nextSlice("="); var state: terminal.RenderState = .empty; defer state.deinit(alloc); diff --git a/src/inspector/widgets/termio.zig b/src/inspector/widgets/termio.zig index a6c8f6081..b721d9422 100644 --- a/src/inspector/widgets/termio.zig +++ b/src/inspector/widgets/termio.zig @@ -54,7 +54,7 @@ pub const Stream = struct { .events = &self.events, }; defer self.parser_stream.handler.state = null; - try self.parser_stream.nextSlice(data); + self.parser_stream.nextSlice(data); } pub fn draw( @@ -736,7 +736,7 @@ const VTHandler = struct { self: *VTHandler, comptime action: VTHandler.Stream.Action.Tag, value: VTHandler.Stream.Action.Value(action), - ) !void { + ) void { _ = self; _ = value; } diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 5ea5b7ab0..196ebb175 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -528,7 +528,7 @@ test "Cell constraint widths" { // symbol->nothing: 2 { t.fullReset(); - try s.nextSlice(""); + s.nextSlice(""); try state.update(alloc, &t); try testing.expectEqual(2, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -540,7 +540,7 @@ test "Cell constraint widths" { // symbol->character: 1 { t.fullReset(); - try s.nextSlice("z"); + s.nextSlice("z"); try state.update(alloc, &t); try testing.expectEqual(1, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -552,7 +552,7 @@ test "Cell constraint widths" { // symbol->space: 2 { t.fullReset(); - try s.nextSlice(" z"); + s.nextSlice(" z"); try state.update(alloc, &t); try testing.expectEqual(2, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -563,7 +563,7 @@ test "Cell constraint widths" { // symbol->no-break space: 1 { t.fullReset(); - try s.nextSlice("\u{00a0}z"); + s.nextSlice("\u{00a0}z"); try state.update(alloc, &t); try testing.expectEqual(1, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -575,7 +575,7 @@ test "Cell constraint widths" { // symbol->end of row: 1 { t.fullReset(); - try s.nextSlice(" "); + s.nextSlice(" "); try state.update(alloc, &t); try testing.expectEqual(1, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -587,7 +587,7 @@ test "Cell constraint widths" { // character->symbol: 2 { t.fullReset(); - try s.nextSlice("z"); + s.nextSlice("z"); try state.update(alloc, &t); try testing.expectEqual(2, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -599,7 +599,7 @@ test "Cell constraint widths" { // symbol->symbol: 1,1 { t.fullReset(); - try s.nextSlice(""); + s.nextSlice(""); try state.update(alloc, &t); try testing.expectEqual(1, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -616,7 +616,7 @@ test "Cell constraint widths" { // symbol->space->symbol: 2,2 { t.fullReset(); - try s.nextSlice(" "); + s.nextSlice(" "); try state.update(alloc, &t); try testing.expectEqual(2, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -633,7 +633,7 @@ test "Cell constraint widths" { // symbol->powerline: 1 (dedicated test because powerline is special-cased in cellpkg) { t.fullReset(); - try s.nextSlice(""); + s.nextSlice(""); try state.update(alloc, &t); try testing.expectEqual(1, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -645,7 +645,7 @@ test "Cell constraint widths" { // powerline->symbol: 2 (dedicated test because powerline is special-cased in cellpkg) { t.fullReset(); - try s.nextSlice(""); + s.nextSlice(""); try state.update(alloc, &t); try testing.expectEqual(2, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -657,7 +657,7 @@ test "Cell constraint widths" { // powerline->nothing: 2 (dedicated test because powerline is special-cased in cellpkg) { t.fullReset(); - try s.nextSlice(""); + s.nextSlice(""); try state.update(alloc, &t); try testing.expectEqual(2, constraintWidth( state.row_data.get(0).cells.items(.raw), @@ -669,7 +669,7 @@ test "Cell constraint widths" { // powerline->space: 2 (dedicated test because powerline is special-cased in cellpkg) { t.fullReset(); - try s.nextSlice(" z"); + s.nextSlice(" z"); try state.update(alloc, &t); try testing.expectEqual(2, constraintWidth( state.row_data.get(0).cells.items(.raw), diff --git a/src/renderer/link.zig b/src/renderer/link.zig index 74df3e596..c5de61574 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -148,7 +148,7 @@ test "renderCellMap" { var s = t.vtStream(); defer s.deinit(); const str = "1ABCD2EFGH\r\n3IJKL"; - try s.nextSlice(str); + s.nextSlice(str); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -201,7 +201,7 @@ test "renderCellMap hover links" { var s = t.vtStream(); defer s.deinit(); const str = "1ABCD2EFGH\r\n3IJKL"; - try s.nextSlice(str); + s.nextSlice(str); var state: terminal.RenderState = .empty; defer state.deinit(alloc); @@ -279,7 +279,7 @@ test "renderCellMap mods no match" { var s = t.vtStream(); defer s.deinit(); const str = "1ABCD2EFGH\r\n3IJKL"; - try s.nextSlice(str); + s.nextSlice(str); var state: terminal.RenderState = .empty; defer state.deinit(alloc); diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 062e3969a..f3b503d29 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -1593,7 +1593,7 @@ test "Page plain single line" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello, world"); + s.nextSlice("hello, world"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -1640,7 +1640,7 @@ test "Page plain single line soft-wrapped unwrapped" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello!"); + s.nextSlice("hello!"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -1710,7 +1710,7 @@ test "Page plain single wide char" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("1A⚡"); + s.nextSlice("1A⚡"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -1801,7 +1801,7 @@ test "Page plain single wide char soft-wrapped unwrapped" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("1A⚡"); + s.nextSlice("1A⚡"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -1918,7 +1918,7 @@ test "Page plain multiline" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld"); + s.nextSlice("hello\r\nworld"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -1969,7 +1969,7 @@ test "Page plain multiline rectangle" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld"); + s.nextSlice("hello\r\nworld"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -2023,7 +2023,7 @@ test "Page plain multi blank lines" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\n\r\n\r\nworld"); + s.nextSlice("hello\r\n\r\n\r\nworld"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -2076,7 +2076,7 @@ test "Page plain trailing blank lines" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld\r\n\r\n"); + s.nextSlice("hello\r\nworld\r\n\r\n"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -2129,7 +2129,7 @@ test "Page plain trailing whitespace" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello \r\nworld "); + s.nextSlice("hello \r\nworld "); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -2182,7 +2182,7 @@ test "Page plain trailing whitespace no trim" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello \r\nworld "); + s.nextSlice("hello \r\nworld "); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -2238,7 +2238,7 @@ test "Page plain with prior trailing state rows" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); @@ -2284,7 +2284,7 @@ test "Page plain with prior trailing state cells no wrapped line" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); @@ -2329,7 +2329,7 @@ test "Page plain with prior trailing state cells with wrap continuation" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("world"); + s.nextSlice("world"); const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); @@ -2383,7 +2383,7 @@ test "Page plain soft-wrapped without unwrap" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world test"); + s.nextSlice("hello world test"); const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); @@ -2432,7 +2432,7 @@ test "Page plain soft-wrapped with unwrap" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world test"); + s.nextSlice("hello world test"); const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); @@ -2480,7 +2480,7 @@ test "Page plain soft-wrapped 3 lines without unwrap" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world this is a test"); + s.nextSlice("hello world this is a test"); const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); @@ -2534,7 +2534,7 @@ test "Page plain soft-wrapped 3 lines with unwrap" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world this is a test"); + s.nextSlice("hello world this is a test"); const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); @@ -2586,7 +2586,7 @@ test "Page plain start_y subset" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld\r\ntest"); + s.nextSlice("hello\r\nworld\r\ntest"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -2633,7 +2633,7 @@ test "Page plain end_y subset" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld\r\ntest"); + s.nextSlice("hello\r\nworld\r\ntest"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -2680,7 +2680,7 @@ test "Page plain start_y and end_y range" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld\r\ntest\r\nfoo"); + s.nextSlice("hello\r\nworld\r\ntest\r\nfoo"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -2728,7 +2728,7 @@ test "Page plain start_y out of bounds" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -2766,7 +2766,7 @@ test "Page plain end_y greater than rows" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -2809,7 +2809,7 @@ test "Page plain end_y less than start_y" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -2848,7 +2848,7 @@ test "Page plain start_x on first row only" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world"); + s.nextSlice("hello world"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -2890,7 +2890,7 @@ test "Page plain end_x on last row only" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("first line\r\nsecond line\r\nthird line"); + s.nextSlice("first line\r\nsecond line\r\nthird line"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -2943,7 +2943,7 @@ test "Page plain start_x and end_x multiline" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world\r\ntest case\r\nfoo bar"); + s.nextSlice("hello world\r\ntest case\r\nfoo bar"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3000,7 +3000,7 @@ test "Page plain start_x out of bounds" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3038,7 +3038,7 @@ test "Page plain end_x greater than cols" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3080,7 +3080,7 @@ test "Page plain end_x less than start_x single row" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3120,7 +3120,7 @@ test "Page plain start_y non-zero ignores trailing state" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld"); + s.nextSlice("hello\r\nworld"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3164,7 +3164,7 @@ test "Page plain start_x non-zero ignores trailing state" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world"); + s.nextSlice("hello world"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3208,7 +3208,7 @@ test "Page plain start_y and start_x zero uses trailing state" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3255,7 +3255,7 @@ test "Page plain single line with styling" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello, \x1b[1mworld\x1b[0m"); + s.nextSlice("hello, \x1b[1mworld\x1b[0m"); // Verify we have only a single page const pages = &t.screens.active.pages; @@ -3301,7 +3301,7 @@ test "Page VT single line plain text" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3340,7 +3340,7 @@ test "Page VT single line with bold" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\x1b[1mhello\x1b[0m"); + s.nextSlice("\x1b[1mhello\x1b[0m"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3386,7 +3386,7 @@ test "Page VT multiple styles" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\x1b[1mhello \x1b[3mworld\x1b[0m"); + s.nextSlice("\x1b[1mhello \x1b[3mworld\x1b[0m"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3421,7 +3421,7 @@ test "Page VT with foreground color" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\x1b[31mred\x1b[0m"); + s.nextSlice("\x1b[31mred\x1b[0m"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3467,7 +3467,7 @@ test "Page VT with background and foreground colors" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3504,7 +3504,7 @@ test "Page VT multi-line with styles" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\x1b[1mfirst\x1b[0m\r\n\x1b[3msecond\x1b[0m"); + s.nextSlice("\x1b[1mfirst\x1b[0m\r\n\x1b[3msecond\x1b[0m"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3541,7 +3541,7 @@ test "Page VT duplicate style not emitted twice" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\x1b[1mhel\x1b[1mlo\x1b[0m"); + s.nextSlice("\x1b[1mhel\x1b[1mlo\x1b[0m"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -3576,7 +3576,7 @@ test "PageList plain single line" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello, world"); + s.nextSlice("hello, world"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -3616,18 +3616,18 @@ test "PageList plain spanning two pages" { const first_page_rows = pages.pages.first.?.data.capacity.rows; // Fill the first page almost completely - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - try s.nextSlice("page one"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + s.nextSlice("page one"); // Verify we're still on one page try testing.expect(pages.pages.first == pages.pages.last); // Add one more newline to push content to a second page - try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); try testing.expect(pages.pages.first != pages.pages.last); // Write content on the second page - try s.nextSlice("page two"); + s.nextSlice("page two"); // Format the entire PageList var pin_map: std.ArrayList(Pin) = .empty; @@ -3689,8 +3689,8 @@ test "PageList soft-wrapped line spanning two pages without unwrap" { const first_page_rows = pages.pages.first.?.data.capacity.rows; // Fill the first page with soft-wrapped content - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - try s.nextSlice("hello world test"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + s.nextSlice("hello world test"); // Verify we're on two pages due to wrapping try testing.expect(pages.pages.first != pages.pages.last); @@ -3753,8 +3753,8 @@ test "PageList soft-wrapped line spanning two pages with unwrap" { const first_page_rows = pages.pages.first.?.data.capacity.rows; // Fill the first page with soft-wrapped content - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - try s.nextSlice("hello world test"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + s.nextSlice("hello world test"); // Verify we're on two pages due to wrapping try testing.expect(pages.pages.first != pages.pages.last); @@ -3814,18 +3814,18 @@ test "PageList VT spanning two pages" { const first_page_rows = pages.pages.first.?.data.capacity.rows; // Fill the first page almost completely - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - try s.nextSlice("\x1b[1mpage one"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + s.nextSlice("\x1b[1mpage one"); // Verify we're still on one page try testing.expect(pages.pages.first == pages.pages.last); // Add one more newline to push content to a second page - try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); try testing.expect(pages.pages.first != pages.pages.last); // New content is still styled - try s.nextSlice("page two"); + s.nextSlice("page two"); // Format the entire PageList with VT var pin_map: std.ArrayList(Pin) = .empty; @@ -3870,7 +3870,7 @@ test "PageList plain with x offset on single page" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world\r\ntest case\r\nfoo bar"); + s.nextSlice("hello world\r\ntest case\r\nfoo bar"); const pages = &t.screens.active.pages; const node = pages.pages.first.?; @@ -3920,17 +3920,17 @@ test "PageList plain with x offset spanning two pages" { const first_page_rows = pages.pages.first.?.data.capacity.rows; // Fill first page almost completely - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - try s.nextSlice("hello world"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + s.nextSlice("hello world"); // Verify we're still on one page try testing.expect(pages.pages.first == pages.pages.last); // Push to second page - try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); try testing.expect(pages.pages.first != pages.pages.last); - try s.nextSlice("foo bar test"); + s.nextSlice("foo bar test"); const first_node = pages.pages.first.?; const last_node = pages.pages.last.?; @@ -3986,7 +3986,7 @@ test "PageList plain with start_x only" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world"); + s.nextSlice("hello world"); const pages = &t.screens.active.pages; const node = pages.pages.first.?; @@ -4027,7 +4027,7 @@ test "PageList plain with end_x only" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world\r\ntest"); + s.nextSlice("hello world\r\ntest"); const pages = &t.screens.active.pages; const node = pages.pages.first.?; @@ -4080,11 +4080,11 @@ test "PageList plain rectangle basic" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Lorem ipsum dolor\r\n"); - try s.nextSlice("sit amet, consectetur\r\n"); - try s.nextSlice("adipiscing elit, sed do\r\n"); - try s.nextSlice("eiusmod tempor incididunt\r\n"); - try s.nextSlice("ut labore et dolore"); + s.nextSlice("Lorem ipsum dolor\r\n"); + s.nextSlice("sit amet, consectetur\r\n"); + s.nextSlice("adipiscing elit, sed do\r\n"); + s.nextSlice("eiusmod tempor incididunt\r\n"); + s.nextSlice("ut labore et dolore"); const pages = &t.screens.active.pages; @@ -4120,11 +4120,11 @@ test "PageList plain rectangle with EOL" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Lorem ipsum dolor\r\n"); - try s.nextSlice("sit amet, consectetur\r\n"); - try s.nextSlice("adipiscing elit, sed do\r\n"); - try s.nextSlice("eiusmod tempor incididunt\r\n"); - try s.nextSlice("ut labore et dolore"); + s.nextSlice("Lorem ipsum dolor\r\n"); + s.nextSlice("sit amet, consectetur\r\n"); + s.nextSlice("adipiscing elit, sed do\r\n"); + s.nextSlice("eiusmod tempor incididunt\r\n"); + s.nextSlice("ut labore et dolore"); const pages = &t.screens.active.pages; @@ -4162,14 +4162,14 @@ test "PageList plain rectangle more complex with breaks" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Lorem ipsum dolor\r\n"); - try s.nextSlice("sit amet, consectetur\r\n"); - try s.nextSlice("adipiscing elit, sed do\r\n"); - try s.nextSlice("eiusmod tempor incididunt\r\n"); - try s.nextSlice("ut labore et dolore\r\n"); - try s.nextSlice("\r\n"); - try s.nextSlice("magna aliqua. Ut enim\r\n"); - try s.nextSlice("ad minim veniam, quis"); + s.nextSlice("Lorem ipsum dolor\r\n"); + s.nextSlice("sit amet, consectetur\r\n"); + s.nextSlice("adipiscing elit, sed do\r\n"); + s.nextSlice("eiusmod tempor incididunt\r\n"); + s.nextSlice("ut labore et dolore\r\n"); + s.nextSlice("\r\n"); + s.nextSlice("magna aliqua. Ut enim\r\n"); + s.nextSlice("ad minim veniam, quis"); const pages = &t.screens.active.pages; @@ -4208,7 +4208,7 @@ test "TerminalFormatter plain no selection" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld"); + s.nextSlice("hello\r\nworld"); const formatter: TerminalFormatter = .init(&t, .plain); @@ -4233,10 +4233,10 @@ test "TerminalFormatter vt with palette" { defer s.deinit(); // Modify some palette colors using VT sequences - try s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); - try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); - try s.nextSlice("\x1b]4;255;rgb:ff/00/ff\x1b\\"); - try s.nextSlice("test"); + s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); + s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + s.nextSlice("\x1b]4;255;rgb:ff/00/ff\x1b\\"); + s.nextSlice("test"); const formatter: TerminalFormatter = .init(&t, .vt); @@ -4253,7 +4253,7 @@ test "TerminalFormatter vt with palette" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify the palettes match try testing.expectEqual(t.colors.palette.current[0], t2.colors.palette.current[0]); @@ -4277,7 +4277,7 @@ test "TerminalFormatter with selection" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("line1\r\nline2\r\nline3"); + s.nextSlice("line1\r\nline2\r\nline3"); var formatter: TerminalFormatter = .init(&t, .plain); formatter.content = .{ .selection = .init( @@ -4306,7 +4306,7 @@ test "TerminalFormatter plain with pin_map" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello, world"); + s.nextSlice("hello, world"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4343,7 +4343,7 @@ test "TerminalFormatter plain multiline with pin_map" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld"); + s.nextSlice("hello\r\nworld"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4392,8 +4392,8 @@ test "TerminalFormatter vt with palette and pin_map" { defer s.deinit(); // Modify some palette colors using VT sequences - try s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); - try s.nextSlice("test"); + s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); + s.nextSlice("test"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4428,7 +4428,7 @@ test "TerminalFormatter with selection and pin_map" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("line1\r\nline2\r\nline3"); + s.nextSlice("line1\r\nline2\r\nline3"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4472,7 +4472,7 @@ test "Screen plain single line" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello, world"); + s.nextSlice("hello, world"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4509,7 +4509,7 @@ test "Screen plain multiline" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello\r\nworld"); + s.nextSlice("hello\r\nworld"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4557,7 +4557,7 @@ test "Screen plain with selection" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("line1\r\nline2\r\nline3"); + s.nextSlice("line1\r\nline2\r\nline3"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4602,7 +4602,7 @@ test "Screen vt with cursor position" { defer s.deinit(); // Position cursor at a specific location - try s.nextSlice("hello\r\nworld"); + s.nextSlice("hello\r\nworld"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4624,7 +4624,7 @@ test "Screen vt with cursor position" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify cursor positions match try testing.expectEqual(t.screens.active.cursor.x, t2.screens.active.cursor.x); @@ -4661,7 +4661,7 @@ test "Screen vt with style" { defer s.deinit(); // Set some style attributes - try s.nextSlice("\x1b[1;31mhello"); + s.nextSlice("\x1b[1;31mhello"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4683,7 +4683,7 @@ test "Screen vt with style" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify styles match try testing.expect(t.screens.active.cursor.style.eql(t2.screens.active.cursor.style)); @@ -4713,7 +4713,7 @@ test "Screen vt with hyperlink" { defer s.deinit(); // Set a hyperlink - try s.nextSlice("\x1b]8;;http://example.com\x1b\\hello"); + s.nextSlice("\x1b]8;;http://example.com\x1b\\hello"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4735,7 +4735,7 @@ test "Screen vt with hyperlink" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify hyperlinks match const has_link1 = t.screens.active.cursor.hyperlink != null; @@ -4773,7 +4773,7 @@ test "Screen vt with protection" { defer s.deinit(); // Enable protection mode - try s.nextSlice("\x1b[1\"qhello"); + s.nextSlice("\x1b[1\"qhello"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4795,7 +4795,7 @@ test "Screen vt with protection" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify protection state matches try testing.expectEqual(t.screens.active.cursor.protected, t2.screens.active.cursor.protected); @@ -4825,7 +4825,7 @@ test "Screen vt with kitty keyboard" { defer s.deinit(); // Set kitty keyboard flags (disambiguate + report_events = 3) - try s.nextSlice("\x1b[=3;1uhello"); + s.nextSlice("\x1b[=3;1uhello"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4847,7 +4847,7 @@ test "Screen vt with kitty keyboard" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify kitty keyboard state matches const flags1 = t.screens.active.kitty_keyboard.current().int(); @@ -4879,7 +4879,7 @@ test "Screen vt with charsets" { defer s.deinit(); // Set G0 to DEC special and shift to G1 - try s.nextSlice("\x1b(0\x0ehello"); + s.nextSlice("\x1b(0\x0ehello"); var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); @@ -4901,7 +4901,7 @@ test "Screen vt with charsets" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify charset state matches try testing.expectEqual(t.screens.active.charset.gl, t2.screens.active.charset.gl); @@ -4936,7 +4936,7 @@ test "Terminal vt with scrolling region" { defer s.deinit(); // Set scrolling region: top=5, bottom=20 - try s.nextSlice("\x1b[6;21rhello"); + s.nextSlice("\x1b[6;21rhello"); var formatter: TerminalFormatter = .init(&t, .vt); formatter.extra.scrolling_region = true; @@ -4954,7 +4954,7 @@ test "Terminal vt with scrolling region" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify scrolling regions match try testing.expectEqual(t.scrolling_region.top, t2.scrolling_region.top); @@ -4980,10 +4980,10 @@ test "Terminal vt with modes" { defer s.deinit(); // Enable some modes that differ from defaults - try s.nextSlice("\x1b[?2004h"); // Bracketed paste - try s.nextSlice("\x1b[?1000h"); // Mouse event normal - try s.nextSlice("\x1b[?7l"); // Disable wraparound (default is true) - try s.nextSlice("hello"); + s.nextSlice("\x1b[?2004h"); // Bracketed paste + s.nextSlice("\x1b[?1000h"); // Mouse event normal + s.nextSlice("\x1b[?7l"); // Disable wraparound (default is true) + s.nextSlice("hello"); var formatter: TerminalFormatter = .init(&t, .vt); formatter.extra.modes = true; @@ -5001,7 +5001,7 @@ test "Terminal vt with modes" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify modes match try testing.expectEqual(t.modes.get(.bracketed_paste), t2.modes.get(.bracketed_paste)); @@ -5026,11 +5026,11 @@ test "Terminal vt with tabstops" { defer s.deinit(); // Clear all tabs and set custom tabstops - try s.nextSlice("\x1b[3g"); // Clear all tabs - try s.nextSlice("\x1b[5G\x1bH"); // Set tab at column 5 - try s.nextSlice("\x1b[15G\x1bH"); // Set tab at column 15 - try s.nextSlice("\x1b[30G\x1bH"); // Set tab at column 30 - try s.nextSlice("hello"); + s.nextSlice("\x1b[3g"); // Clear all tabs + s.nextSlice("\x1b[5G\x1bH"); // Set tab at column 5 + s.nextSlice("\x1b[15G\x1bH"); // Set tab at column 15 + s.nextSlice("\x1b[30G\x1bH"); // Set tab at column 30 + s.nextSlice("hello"); var formatter: TerminalFormatter = .init(&t, .vt); formatter.extra.tabstops = true; @@ -5048,7 +5048,7 @@ test "Terminal vt with tabstops" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify tabstops match (columns are 0-indexed in the API) try testing.expectEqual(t.tabstops.get(4), t2.tabstops.get(4)); @@ -5077,8 +5077,8 @@ test "Terminal vt with keyboard modes" { defer s.deinit(); // Set modify other keys mode 2 - try s.nextSlice("\x1b[>4;2m"); - try s.nextSlice("hello"); + s.nextSlice("\x1b[>4;2m"); + s.nextSlice("hello"); var formatter: TerminalFormatter = .init(&t, .vt); formatter.extra.keyboard = true; @@ -5096,7 +5096,7 @@ test "Terminal vt with keyboard modes" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify keyboard mode matches try testing.expectEqual(t.flags.modify_other_keys_2, t2.flags.modify_other_keys_2); @@ -5120,7 +5120,7 @@ test "Terminal vt with pwd" { defer s.deinit(); // Set pwd using OSC 7 - try s.nextSlice("\x1b]7;file://host/home/user\x1b\\hello"); + s.nextSlice("\x1b]7;file://host/home/user\x1b\\hello"); var formatter: TerminalFormatter = .init(&t, .vt); formatter.extra.pwd = true; @@ -5138,7 +5138,7 @@ test "Terminal vt with pwd" { var s2 = t2.vtStream(); defer s2.deinit(); - try s2.nextSlice(output); + s2.nextSlice(output); // Verify pwd matches try testing.expectEqualStrings(t.pwd.items, t2.pwd.items); @@ -5161,7 +5161,7 @@ test "Page html with multiple styles" { defer s.deinit(); // Set bold, then italic, then reset - try s.nextSlice("\x1b[1mbold\x1b[3mitalic\x1b[0mnormal"); + s.nextSlice("\x1b[1mbold\x1b[3mitalic\x1b[0mnormal"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5196,7 +5196,7 @@ test "Page html plain text" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello, world"); + s.nextSlice("hello, world"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5229,7 +5229,7 @@ test "Page html with colors" { defer s.deinit(); // Set red foreground, blue background - try s.nextSlice("\x1b[31;44mcolored"); + s.nextSlice("\x1b[31;44mcolored"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5263,10 +5263,10 @@ test "TerminalFormatter html with palette" { defer s.deinit(); // Modify some palette colors - try s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); - try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); - try s.nextSlice("\x1b]4;255;rgb:ff/00/ff\x1b\\"); - try s.nextSlice("test"); + s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); + s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + s.nextSlice("\x1b]4;255;rgb:ff/00/ff\x1b\\"); + s.nextSlice("test"); var formatter: TerminalFormatter = .init(&t, .{ .emit = .html }); formatter.extra.palette = true; @@ -5299,7 +5299,7 @@ test "Page html with background and foreground colors" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5334,7 +5334,7 @@ test "Page html with escaping" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("&\"'text"); + s.nextSlice("&\"'text"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5405,7 +5405,7 @@ test "Page html with unicode as numeric entities" { defer s.deinit(); // Box drawing characters that caused issue #9426 - try s.nextSlice("╰─ ❯"); + s.nextSlice("╰─ ❯"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5438,7 +5438,7 @@ test "Page html ascii characters unchanged" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world"); + s.nextSlice("hello world"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5470,7 +5470,7 @@ test "Page html mixed ascii and unicode" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("test ╰─❯ ok"); + s.nextSlice("test ╰─❯ ok"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5503,8 +5503,8 @@ test "Page VT with palette option emits RGB" { defer s.deinit(); // Set a custom palette color and use it - try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); - try s.nextSlice("\x1b[31mred"); + s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + s.nextSlice("\x1b[31mred"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5547,8 +5547,8 @@ test "Page html with palette option emits RGB" { defer s.deinit(); // Set a custom palette color and use it - try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); - try s.nextSlice("\x1b[31mred"); + s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + s.nextSlice("\x1b[31mred"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5601,7 +5601,7 @@ test "Page VT style reset properly closes styles" { defer s.deinit(); // Set bold, then reset with SGR 0 - try s.nextSlice("\x1b[1mbold\x1b[0mnormal"); + s.nextSlice("\x1b[1mbold\x1b[0mnormal"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5631,7 +5631,7 @@ test "Page codepoint_map single replacement" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world"); + s.nextSlice("hello world"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5690,7 +5690,7 @@ test "Page codepoint_map conflicting replacement prefers last" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5732,7 +5732,7 @@ test "Page codepoint_map replace with string" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello"); + s.nextSlice("hello"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5788,7 +5788,7 @@ test "Page codepoint_map range replacement" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("abcdefg"); + s.nextSlice("abcdefg"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5826,7 +5826,7 @@ test "Page codepoint_map multiple ranges" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world"); + s.nextSlice("hello world"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5870,7 +5870,7 @@ test "Page codepoint_map unicode replacement" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello ⚡ world"); + s.nextSlice("hello ⚡ world"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5935,7 +5935,7 @@ test "Page codepoint_map with styled formats" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\x1b[31mred text\x1b[0m"); + s.nextSlice("\x1b[31mred text\x1b[0m"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -5976,7 +5976,7 @@ test "Page codepoint_map empty map" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("hello world"); + s.nextSlice("hello world"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -6016,9 +6016,9 @@ test "Page VT background color on trailing blank cells" { // Simulate a TUI row: "CPU:" with text, then trailing cells with red background // to end of line (no text after the colored region). // \x1b[41m sets red background, then EL fills rest of row with that bg. - try s.nextSlice("CPU:\x1b[41m\x1b[K"); + s.nextSlice("CPU:\x1b[41m\x1b[K"); // Reset colors and move to next line with different content - try s.nextSlice("\x1b[0m\r\nline2"); + s.nextSlice("\x1b[0m\r\nline2"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -6065,7 +6065,7 @@ test "Page HTML with hyperlinks" { defer s.deinit(); // Start a hyperlink, write some text, end it - try s.nextSlice("\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\ normal"); + s.nextSlice("\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\ normal"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -6099,8 +6099,8 @@ test "Page HTML with multiple hyperlinks" { defer s.deinit(); // Two different hyperlinks - try s.nextSlice("\x1b]8;;https://first.com\x1b\\first\x1b]8;;\x1b\\ "); - try s.nextSlice("\x1b]8;;https://second.com\x1b\\second\x1b]8;;\x1b\\"); + s.nextSlice("\x1b]8;;https://first.com\x1b\\first\x1b]8;;\x1b\\ "); + s.nextSlice("\x1b]8;;https://second.com\x1b\\second\x1b]8;;\x1b\\"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -6136,7 +6136,7 @@ test "Page HTML with hyperlink escaping" { defer s.deinit(); // URL with special characters that need escaping - try s.nextSlice("\x1b]8;;https://example.com?a=1&b=2\x1b\\link\x1b]8;;\x1b\\"); + s.nextSlice("\x1b]8;;https://example.com?a=1&b=2\x1b\\link\x1b]8;;\x1b\\"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -6170,7 +6170,7 @@ test "Page HTML with styled hyperlink" { defer s.deinit(); // Bold hyperlink - try s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold link\x1b[0m\x1b]8;;\x1b\\"); + s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold link\x1b[0m\x1b]8;;\x1b\\"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -6205,7 +6205,7 @@ test "Page HTML hyperlink closes style before anchor" { defer s.deinit(); // Styled hyperlink followed by plain text - try s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold\x1b[0m plain"); + s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold\x1b[0m plain"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; @@ -6239,7 +6239,7 @@ test "Page HTML hyperlink point map maps closing to previous cell" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\ normal"); + s.nextSlice("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\ normal"); const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 2332866ac..8ce77061d 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -908,7 +908,7 @@ test "basic text" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("ABCD"); + s.nextSlice("ABCD"); var state: RenderState = .empty; defer state.deinit(alloc); @@ -944,9 +944,9 @@ test "styled text" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("\x1b[1mA"); // Bold - try s.nextSlice("\x1b[0;3mB"); // Italic - try s.nextSlice("\x1b[0;4mC"); // Underline + s.nextSlice("\x1b[1mA"); // Bold + s.nextSlice("\x1b[0;3mB"); // Italic + s.nextSlice("\x1b[0;4mC"); // Underline var state: RenderState = .empty; defer state.deinit(alloc); @@ -990,8 +990,8 @@ test "grapheme" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A"); - try s.nextSlice("👨‍"); // this has a ZWJ + s.nextSlice("A"); + s.nextSlice("👨‍"); // this has a ZWJ var state: RenderState = .empty; defer state.deinit(alloc); @@ -1037,7 +1037,7 @@ test "cursor state in viewport" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A\x1b[H"); + s.nextSlice("A\x1b[H"); var state: RenderState = .empty; defer state.deinit(alloc); @@ -1052,14 +1052,14 @@ test "cursor state in viewport" { try testing.expect(state.cursor.style.default()); // Set a style on the cursor - try s.nextSlice("\x1b[1m"); // Bold + s.nextSlice("\x1b[1m"); // Bold try state.update(alloc, &t); try testing.expect(!state.cursor.style.default()); try testing.expect(state.cursor.style.flags.bold); - try s.nextSlice("\x1b[0m"); // Reset style + s.nextSlice("\x1b[0m"); // Reset style // Move cursor to 2,1 - try s.nextSlice("\x1b[2;3H"); + s.nextSlice("\x1b[2;3H"); try state.update(alloc, &t); try testing.expectEqual(2, state.cursor.active.x); try testing.expectEqual(1, state.cursor.active.y); @@ -1079,7 +1079,7 @@ test "cursor state out of viewport" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A\r\nB\r\nC\r\nD\r\n"); + s.nextSlice("A\r\nB\r\nC\r\nD\r\n"); var state: RenderState = .empty; defer state.deinit(alloc); @@ -1139,7 +1139,7 @@ test "dirty state" { } // Write to first line - try s.nextSlice("A"); + s.nextSlice("A"); try state.update(alloc, &t); try testing.expectEqual(.partial, state.dirty); { @@ -1170,7 +1170,7 @@ test "colors" { try state.update(alloc, &t); // Change cursor color - try s.nextSlice("\x1b]12;#FF0000\x07"); + s.nextSlice("\x1b]12;#FF0000\x07"); try state.update(alloc, &t); const c = state.colors.cursor.?; @@ -1179,7 +1179,7 @@ test "colors" { try testing.expectEqual(0, c.b); // Change palette color 0 to White - try s.nextSlice("\x1b]4;0;#FFFFFF\x07"); + s.nextSlice("\x1b]4;0;#FFFFFF\x07"); try state.update(alloc, &t); const p0 = state.colors.palette[0]; try testing.expectEqual(0xFF, p0.r); @@ -1275,7 +1275,7 @@ test "linkCells" { defer state.deinit(alloc); // Create a hyperlink - try s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\"); + s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\"); try state.update(alloc, &t); // Query link at 0,0 @@ -1306,7 +1306,7 @@ test "string" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("AB"); + s.nextSlice("AB"); var state: RenderState = .empty; defer state.deinit(alloc); @@ -1345,12 +1345,12 @@ test "linkCells with scrollback spanning pages" { const first_page_cap = pages.pages.first.?.data.capacity.rows; // Fill first page - for (0..first_page_cap - 1) |_| try s.nextSlice("\r\n"); + for (0..first_page_cap - 1) |_| s.nextSlice("\r\n"); // Create second page with hyperlink - try s.nextSlice("\r\n"); - try s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\"); - for (0..(tail_rows - 1)) |_| try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); + s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\"); + for (0..(tail_rows - 1)) |_| s.nextSlice("\r\n"); var state: RenderState = .empty; defer state.deinit(alloc); @@ -1416,7 +1416,7 @@ test "dirty row resets highlights" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("ABC"); + s.nextSlice("ABC"); var state: RenderState = .empty; defer state.deinit(alloc); @@ -1451,8 +1451,8 @@ test "dirty row resets highlights" { } // Write to row 0 to make it dirty - try s.nextSlice("\x1b[H"); // Move to home - try s.nextSlice("X"); + s.nextSlice("\x1b[H"); // Move to home + s.nextSlice("X"); try state.update(alloc, &t); // Verify the highlight was reset on the dirty row diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 3f5377417..fa09af5f0 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -853,7 +853,7 @@ test { var stream = t.vtStream(); defer stream.deinit(); - try stream.nextSlice("Hello, world"); + stream.nextSlice("Hello, world"); var ud: TestUserData = .{}; defer ud.deinit(); diff --git a/src/terminal/search/active.zig b/src/terminal/search/active.zig index 236f4c7a6..692a10e12 100644 --- a/src/terminal/search/active.zig +++ b/src/terminal/search/active.zig @@ -108,7 +108,7 @@ test "simple search" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ActiveSearch = try .init(alloc, "Fizz"); defer search.deinit(); @@ -148,15 +148,15 @@ test "clear screen and search" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ActiveSearch = try .init(alloc, "Fizz"); defer search.deinit(); _ = try search.update(&t.screens.active.pages); - try s.nextSlice("\x1b[2J"); // Clear screen - try s.nextSlice("\x1b[H"); // Move cursor home - try s.nextSlice("Buzz\r\nFizz\r\nBuzz"); + s.nextSlice("\x1b[2J"); // Clear screen + s.nextSlice("\x1b[H"); // Move cursor home + s.nextSlice("Buzz\r\nFizz\r\nBuzz"); _ = try search.update(&t.screens.active.pages); { diff --git a/src/terminal/search/pagelist.zig b/src/terminal/search/pagelist.zig index 4bfd241e7..f76ad4e4b 100644 --- a/src/terminal/search/pagelist.zig +++ b/src/terminal/search/pagelist.zig @@ -141,7 +141,7 @@ test "simple search" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: PageListSearch = try .init( alloc, @@ -191,14 +191,14 @@ test "feed multiple pages with matches" { // Fill up first page const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - try s.nextSlice("Fizz"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + s.nextSlice("Fizz"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Create second page - try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); - try s.nextSlice("Buzz\r\nFizz"); + s.nextSlice("Buzz\r\nFizz"); var search: PageListSearch = try .init( alloc, @@ -235,13 +235,13 @@ test "feed multiple pages no matches" { // Fill up first page const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - try s.nextSlice("Hello"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + s.nextSlice("Hello"); // Create second page - try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); - try s.nextSlice("World"); + s.nextSlice("World"); var search: PageListSearch = try .init( alloc, @@ -275,14 +275,14 @@ test "feed iteratively through multiple matches" { const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; // Fill first page with a match at the end - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - try s.nextSlice("Page1Test"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + s.nextSlice("Page1Test"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Create second page with a match - try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); - try s.nextSlice("Page2Test"); + s.nextSlice("Page2Test"); var search: PageListSearch = try .init( alloc, @@ -316,13 +316,13 @@ test "feed with match spanning page boundary" { const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; // Fill first page ending with "Te" - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - for (0..t.screens.active.pages.cols - 2) |_| try s.nextSlice("x"); - try s.nextSlice("Te"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + for (0..t.screens.active.pages.cols - 2) |_| s.nextSlice("x"); + s.nextSlice("Te"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Second page starts with "st" - try s.nextSlice("st"); + s.nextSlice("st"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); var search: PageListSearch = try .init( @@ -370,15 +370,15 @@ test "feed with match spanning page boundary with newline" { const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; // Fill first page ending with "Te" - for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - for (0..t.screens.active.pages.cols - 2) |_| try s.nextSlice("x"); - try s.nextSlice("Te"); + for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); + for (0..t.screens.active.pages.cols - 2) |_| s.nextSlice("x"); + s.nextSlice("Te"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Second page starts with "st" - try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); - try s.nextSlice("st"); + s.nextSlice("st"); var search: PageListSearch = try .init( alloc, diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index ca2a5a894..e98ecd958 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -827,7 +827,7 @@ test "simple search" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -877,10 +877,10 @@ test "simple search with history" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\n"); - while (list.totalPages() < 3) try s.nextSlice("\r\n"); - for (0..list.rows) |_| try s.nextSlice("\r\n"); - try s.nextSlice("hello."); + s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); + s.nextSlice("hello."); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -917,7 +917,7 @@ test "reload active with history change" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\n"); + s.nextSlice("Fizz\r\n"); // Start up our search which will populate our initial active area. var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); @@ -930,9 +930,9 @@ test "reload active with history change" { } // Grow into two pages so our history pin will move. - while (list.totalPages() < 2) try s.nextSlice("\r\n"); - for (0..list.rows) |_| try s.nextSlice("\r\n"); - try s.nextSlice("2Fizz"); + while (list.totalPages() < 2) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); + s.nextSlice("2Fizz"); // Active area changed so reload try search.reloadActive(); @@ -969,7 +969,7 @@ test "reload active with history change" { // Reset the screen which will make our pin garbage. t.fullReset(); - try s.nextSlice("WeFizzing"); + s.nextSlice("WeFizzing"); try search.reloadActive(); try search.searchAll(); @@ -998,7 +998,7 @@ test "active change contents" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fuzz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fuzz\r\nBuzz\r\nFizz\r\nBang"); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -1006,8 +1006,8 @@ test "active change contents" { try testing.expectEqual(1, search.active_results.items.len); // Erase the screen, move our cursor to the top, and change contents. - try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home - try s.nextSlice("Bang\r\nFizz\r\nHello!"); + s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + s.nextSlice("Bang\r\nFizz\r\nHello!"); try search.reloadActive(); try search.searchAll(); @@ -1038,7 +1038,7 @@ test "select next" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -1097,7 +1097,7 @@ test "select in active changes contents completely" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -1118,8 +1118,8 @@ test "select in active changes contents completely" { } // Erase the screen, move our cursor to the top, and change contents. - try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home - try s.nextSlice("Fuzz\r\nFizz\r\nHello!"); + s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + s.nextSlice("Fuzz\r\nFizz\r\nHello!"); try search.reloadActive(); { @@ -1136,8 +1136,8 @@ test "select in active changes contents completely" { } // Erase the screen, redraw with same contents. - try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home - try s.nextSlice("Fuzz\r\nFizz\r\nFizz"); + s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + s.nextSlice("Fuzz\r\nFizz\r\nFizz"); try search.reloadActive(); { @@ -1167,10 +1167,10 @@ test "select into history" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\n"); - while (list.totalPages() < 3) try s.nextSlice("\r\n"); - for (0..list.rows) |_| try s.nextSlice("\r\n"); - try s.nextSlice("hello."); + s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); + s.nextSlice("hello."); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -1191,8 +1191,8 @@ test "select into history" { } // Erase the screen, redraw with same contents. - try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home - try s.nextSlice("yo yo"); + s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + s.nextSlice("yo yo"); try search.reloadActive(); { @@ -1209,7 +1209,7 @@ test "select into history" { } // Create some new history by adding more lines. - try s.nextSlice("\r\nfizz\r\nfizz\r\nfizz"); // Clear screen and move home + s.nextSlice("\r\nfizz\r\nfizz\r\nfizz"); // Clear screen and move home try search.reloadActive(); { // Our selection should not move since the history is still not @@ -1233,7 +1233,7 @@ test "select prev" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -1292,7 +1292,7 @@ test "select prev then next" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -1342,10 +1342,10 @@ test "select prev with history" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\n"); - while (list.totalPages() < 3) try s.nextSlice("\r\n"); - for (0..list.rows) |_| try s.nextSlice("\r\n"); - try s.nextSlice("Fizz."); + s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); + s.nextSlice("Fizz."); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -1399,9 +1399,9 @@ test "screen search no scrollback has no history" { // no way to test it using public APIs, but at the time of writing // this test, CSI 22 J (scroll complete) pushes into scrollback // with alt screen. - try s.nextSlice("Fizz\r\n"); - try s.nextSlice("\x1b[22J"); - try s.nextSlice("hello."); + s.nextSlice("Fizz\r\n"); + s.nextSlice("\x1b[22J"); + s.nextSlice("hello."); var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); @@ -1431,10 +1431,10 @@ test "reloadActive partial history cleanup on appendSlice error" { // Write multiple "Fizz" matches that will end up in history. // We need enough content to push "Fizz" entries into scrollback. - try s.nextSlice("Fizz\r\nFizz\r\n"); - while (list.totalPages() < 3) try s.nextSlice("\r\n"); - for (0..list.rows) |_| try s.nextSlice("\r\n"); - try s.nextSlice("Fizz."); + s.nextSlice("Fizz\r\nFizz\r\n"); + while (list.totalPages() < 3) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); + s.nextSlice("Fizz."); // Complete initial search var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); @@ -1443,9 +1443,9 @@ test "reloadActive partial history cleanup on appendSlice error" { // Now trigger reloadActive by adding more content that changes the // active/history boundary. First add more "Fizz" entries to history. - try s.nextSlice("\r\nFizz\r\nFizz\r\n"); - while (list.totalPages() < 4) try s.nextSlice("\r\n"); - for (0..list.rows) |_| try s.nextSlice("\r\n"); + s.nextSlice("\r\nFizz\r\nFizz\r\n"); + while (list.totalPages() < 4) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); // Arm the tripwire to fail at appendSlice (after the loop completes). // At this point, there are FlattenedHighlight items in the results list @@ -1478,10 +1478,10 @@ test "reloadActive partial history cleanup on loop append error" { // Write multiple "Fizz" matches that will end up in history. // We need enough content to push "Fizz" entries into scrollback. - try s.nextSlice("Fizz\r\nFizz\r\n"); - while (list.totalPages() < 3) try s.nextSlice("\r\n"); - for (0..list.rows) |_| try s.nextSlice("\r\n"); - try s.nextSlice("Fizz."); + s.nextSlice("Fizz\r\nFizz\r\n"); + while (list.totalPages() < 3) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); + s.nextSlice("Fizz."); // Complete initial search var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); @@ -1490,9 +1490,9 @@ test "reloadActive partial history cleanup on loop append error" { // Now trigger reloadActive by adding more content that changes the // active/history boundary. First add more "Fizz" entries to history. - try s.nextSlice("\r\nFizz\r\nFizz\r\n"); - while (list.totalPages() < 4) try s.nextSlice("\r\n"); - for (0..list.rows) |_| try s.nextSlice("\r\n"); + s.nextSlice("\r\nFizz\r\nFizz\r\n"); + while (list.totalPages() < 4) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); // Arm the tripwire to fail after the first loop append succeeds. // This leaves at least one FlattenedHighlight in the results list diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index c3c29e085..93a606fda 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -1583,7 +1583,7 @@ test "SlidingWindow single append soft wrapped" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A\r\nxxboo!\r\nC"); + s.nextSlice("A\r\nxxboo!\r\nC"); // We want to test single-page cases. const screen = t.screens.active; @@ -1620,7 +1620,7 @@ test "SlidingWindow single append reversed soft wrapped" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("A\r\nxxboo!\r\nC"); + s.nextSlice("A\r\nxxboo!\r\nC"); // We want to test single-page cases. const screen = t.screens.active; diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index f5e6c8601..35dd93315 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -223,7 +223,7 @@ test "simple search" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ViewportSearch = try .init(alloc, "Fizz"); defer search.deinit(); @@ -266,15 +266,15 @@ test "clear screen and search" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ViewportSearch = try .init(alloc, "Fizz"); defer search.deinit(); try testing.expect(try search.update(&t.screens.active.pages)); - try s.nextSlice("\x1b[2J"); // Clear screen - try s.nextSlice("\x1b[H"); // Move cursor home - try s.nextSlice("Buzz\r\nFizz\r\nBuzz"); + s.nextSlice("\x1b[2J"); // Clear screen + s.nextSlice("\x1b[H"); // Move cursor home + s.nextSlice("Buzz\r\nFizz\r\nBuzz"); try testing.expect(try search.update(&t.screens.active.pages)); { @@ -299,7 +299,7 @@ test "clear screen and search dirty tracking" { var s = t.vtStream(); defer s.deinit(); - try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ViewportSearch = try .init(alloc, "Fizz"); defer search.deinit(); @@ -313,9 +313,9 @@ test "clear screen and search dirty tracking" { // Should not update since nothing changed try testing.expect(!try search.update(&t.screens.active.pages)); - try s.nextSlice("\x1b[2J"); // Clear screen - try s.nextSlice("\x1b[H"); // Move cursor home - try s.nextSlice("Buzz\r\nFizz\r\nBuzz"); + s.nextSlice("\x1b[2J"); // Clear screen + s.nextSlice("\x1b[H"); // Move cursor home + s.nextSlice("Buzz\r\nFizz\r\nBuzz"); // Should still not update since active area isn't dirty try testing.expect(!try search.update(&t.screens.active.pages)); @@ -349,14 +349,14 @@ test "history search, no active area" { // Fill up first page const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; - try s.nextSlice("Fizz\r\n"); - for (1..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + s.nextSlice("Fizz\r\n"); + for (1..first_page_rows - 1) |_| s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Create second page - try s.nextSlice("\r\n"); + s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); - try s.nextSlice("Buzz\r\nFizz"); + s.nextSlice("Buzz\r\nFizz"); t.scrollViewport(.top); diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index e89c73e66..5f83128a9 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -406,7 +406,7 @@ pub const Action = union(Key) { /// Returns a type that can process a stream of tty control characters. /// This will call the `vt` function on type T with the following signature: /// -/// fn(comptime action: Action.Key, value: Action.Value(action)) !void +/// fn(comptime action: Action.Key, value: Action.Value(action)) void /// /// The handler type T can choose to react to whatever actions it cares /// about in its pursuit of implementing a terminal emulator or other @@ -468,11 +468,11 @@ pub fn Stream(comptime Handler: type) type { } /// Process a string of characters. - pub inline fn nextSlice(self: *Self, input: []const u8) !void { + pub inline fn nextSlice(self: *Self, input: []const u8) void { // Disable SIMD optimizations if build requests it or if our // manual debug mode is on. if (comptime debug or !build_options.simd) { - for (input) |c| try self.next(c); + for (input) |c| self.next(c); return; } @@ -485,13 +485,17 @@ pub fn Stream(comptime Handler: type) type { var i: usize = 0; while (true) { const len = @min(cp_buf.len, input.len - i); - try self.nextSliceCapped(input[i .. i + len], &cp_buf); + self.nextSliceCapped(input[i .. i + len], &cp_buf); i += len; if (i >= input.len) break; } } - inline fn nextSliceCapped(self: *Self, input: []const u8, cp_buf: []u32) !void { + inline fn nextSliceCapped( + self: *Self, + input: []const u8, + cp_buf: []u32, + ) void { assert(input.len <= cp_buf.len); var offset: usize = 0; @@ -500,7 +504,7 @@ pub fn Stream(comptime Handler: type) type { // a code sequence, we continue until it's not. while (self.utf8decoder.state != 0) { if (offset >= input.len) return; - try self.nextUtf8(input[offset]); + self.nextUtf8(input[offset]); offset += 1; } if (offset >= input.len) return; @@ -508,9 +512,9 @@ pub fn Stream(comptime Handler: type) type { // If we're not in the ground state then we process until // we are. This can happen if the last chunk of input put us // in the middle of a control sequence. - offset += try self.consumeUntilGround(input[offset..]); + offset += self.consumeUntilGround(input[offset..]); if (offset >= input.len) return; - offset += try self.consumeAllEscapes(input[offset..]); + offset += self.consumeAllEscapes(input[offset..]); // If we're in the ground state then we can use SIMD to process // input until we see an ESC (0x1B), since all other characters @@ -519,9 +523,9 @@ pub fn Stream(comptime Handler: type) type { const res = simd.vt.utf8DecodeUntilControlSeq(input[offset..], cp_buf); for (cp_buf[0..res.decoded]) |cp| { if (cp <= 0xF) { - try self.execute(@intCast(cp)); + self.execute(@intCast(cp)); } else { - try self.print(@intCast(cp)); + self.print(@intCast(cp)); } } // Consume the bytes we just processed. @@ -534,12 +538,12 @@ pub fn Stream(comptime Handler: type) type { // to the scalar parser. if (input[offset] != 0x1B) { const rem = input[offset..]; - for (rem) |c| try self.nextUtf8(c); + for (rem) |c| self.nextUtf8(c); return; } // Process control sequences until we run out. - offset += try self.consumeAllEscapes(input[offset..]); + offset += self.consumeAllEscapes(input[offset..]); } } @@ -548,13 +552,13 @@ pub fn Stream(comptime Handler: type) type { /// /// Expects input to start with 0x1B, use consumeUntilGround first /// if the stream may be in the middle of an escape sequence. - inline fn consumeAllEscapes(self: *Self, input: []const u8) !usize { + inline fn consumeAllEscapes(self: *Self, input: []const u8) usize { var offset: usize = 0; while (input[offset] == 0x1B) { self.parser.state = .escape; self.parser.clear(); offset += 1; - offset += try self.consumeUntilGround(input[offset..]); + offset += self.consumeUntilGround(input[offset..]); if (offset >= input.len) return input.len; } return offset; @@ -562,11 +566,11 @@ pub fn Stream(comptime Handler: type) type { /// Parses escape sequences until the parser reaches the ground state. /// Returns the number of bytes consumed from the provided input. - inline fn consumeUntilGround(self: *Self, input: []const u8) !usize { + inline fn consumeUntilGround(self: *Self, input: []const u8) usize { var offset: usize = 0; while (self.parser.state != .ground) { if (offset >= input.len) return input.len; - try self.nextNonUtf8(input[offset]); + self.nextNonUtf8(input[offset]); offset += 1; } return offset; @@ -575,27 +579,27 @@ pub fn Stream(comptime Handler: type) type { /// Like nextSlice but takes one byte and is necessarily a scalar /// operation that can't use SIMD. Prefer nextSlice if you can and /// try to get multiple bytes at once. - pub inline fn next(self: *Self, c: u8) !void { + pub inline fn next(self: *Self, c: u8) void { // The scalar path can be responsible for decoding UTF-8. if (self.parser.state == .ground) { - try self.nextUtf8(c); + self.nextUtf8(c); return; } - try self.nextNonUtf8(c); + self.nextNonUtf8(c); } /// Process the next byte and print as necessary. /// /// This assumes we're in the UTF-8 decoding state. If we may not /// be in the UTF-8 decoding state call nextSlice or next. - inline fn nextUtf8(self: *Self, c: u8) !void { + inline fn nextUtf8(self: *Self, c: u8) void { assert(self.parser.state == .ground); const res = self.utf8decoder.next(c); const consumed = res[1]; if (res[0]) |codepoint| { - try self.handleCodepoint(codepoint); + self.handleCodepoint(codepoint); } if (!consumed) { // We optimize for the scenario where the text being @@ -608,7 +612,7 @@ pub fn Stream(comptime Handler: type) type { // to not consume the byte twice in a row. assert(retry[1] == true); if (retry[0]) |codepoint| { - try self.handleCodepoint(codepoint); + self.handleCodepoint(codepoint); } } } @@ -617,7 +621,7 @@ pub fn Stream(comptime Handler: type) type { /// /// This function is abstracted this way to handle the case where /// the decoder emits a 0x1B after rejecting an ill-formed sequence. - inline fn handleCodepoint(self: *Self, c: u21) !void { + inline fn handleCodepoint(self: *Self, c: u21) void { // We need to increase the eval branch limit because a lot of // tests end up running almost completely at comptime due to // a chain of inline functions. @@ -626,7 +630,7 @@ pub fn Stream(comptime Handler: type) type { // C0 control if (c <= 0xF) { @branchHint(.unlikely); - try self.execute(@intCast(c)); + self.execute(@intCast(c)); return; } // ESC @@ -635,14 +639,14 @@ pub fn Stream(comptime Handler: type) type { self.parser.clear(); return; } - try self.print(@intCast(c)); + self.print(@intCast(c)); } /// Process the next character and call any callbacks if necessary. /// /// This assumes that we're not in the UTF-8 decoding state. If /// we may be in the UTF-8 decoding state call nextSlice or next. - fn nextNonUtf8(self: *Self, c: u8) !void { + fn nextNonUtf8(self: *Self, c: u8) void { assert(self.parser.state != .ground); // Fast path for CSI entry. @@ -659,7 +663,7 @@ pub fn Stream(comptime Handler: type) type { @branchHint(.likely); switch (c) { // A C0 escape (yes, this is valid): - 0x00...0x0F => try self.execute(c), + 0x00...0x0F => self.execute(c), // We ignore C0 escapes > 0xF since execute // doesn't have processing for them anyway: 0x10...0x17, 0x19, 0x1C...0x1F => {}, @@ -725,32 +729,32 @@ pub fn Stream(comptime Handler: type) type { } switch (action) { - .print => |p| try self.print(p), - .execute => |code| try self.execute(code), - .csi_dispatch => |csi_action| try self.csiDispatch(csi_action), - .esc_dispatch => |esc| try self.escDispatch(esc), - .osc_dispatch => |cmd| try self.oscDispatch(cmd), - .dcs_hook => |dcs| try self.handler.vt(.dcs_hook, dcs), - .dcs_put => |code| try self.handler.vt(.dcs_put, code), - .dcs_unhook => try self.handler.vt(.dcs_unhook, {}), - .apc_start => try self.handler.vt(.apc_start, {}), - .apc_put => |code| try self.handler.vt(.apc_put, code), - .apc_end => try self.handler.vt(.apc_end, {}), + .print => |p| self.print(p), + .execute => |code| self.execute(code), + .csi_dispatch => |csi_action| self.csiDispatch(csi_action), + .esc_dispatch => |esc| self.escDispatch(esc), + .osc_dispatch => |cmd| self.oscDispatch(cmd), + .dcs_hook => |dcs| self.handler.vt(.dcs_hook, dcs), + .dcs_put => |code| self.handler.vt(.dcs_put, code), + .dcs_unhook => self.handler.vt(.dcs_unhook, {}), + .apc_start => self.handler.vt(.apc_start, {}), + .apc_put => |code| self.handler.vt(.apc_put, code), + .apc_end => self.handler.vt(.apc_end, {}), } } } - pub inline fn print(self: *Self, c: u21) !void { - try self.handler.vt(.print, .{ .cp = c }); + inline fn print(self: *Self, c: u21) void { + self.handler.vt(.print, .{ .cp = c }); } - pub inline fn execute(self: *Self, c: u8) !void { + inline fn execute(self: *Self, c: u8) void { // If the character is > 0x7F, it's a C1 (8-bit) control, // which is strictly equivalent to `ESC` plus `c - 0x40`. if (c > 0x7F) { @branchHint(.unlikely); log.info("executing C1 0x{x} as ESC {c}", .{ c, c - 0x40 }); - try self.escDispatch(.{ + self.escDispatch(.{ .intermediates = &.{}, .final = c - 0x40, }); @@ -763,20 +767,20 @@ pub fn Stream(comptime Handler: type) type { // We ignore SOH/STX: https://github.com/microsoft/terminal/issues/10786 .NUL, .SOH, .STX => {}, - .ENQ => try self.handler.vt(.enquiry, {}), - .BEL => try self.handler.vt(.bell, {}), - .BS => try self.handler.vt(.backspace, {}), - .HT => try self.handler.vt(.horizontal_tab, 1), - .LF, .VT, .FF => try self.handler.vt(.linefeed, {}), - .CR => try self.handler.vt(.carriage_return, {}), - .SO => try self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G1, .locking = false }), - .SI => try self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G0, .locking = false }), + .ENQ => self.handler.vt(.enquiry, {}), + .BEL => self.handler.vt(.bell, {}), + .BS => self.handler.vt(.backspace, {}), + .HT => self.handler.vt(.horizontal_tab, 1), + .LF, .VT, .FF => self.handler.vt(.linefeed, {}), + .CR => self.handler.vt(.carriage_return, {}), + .SO => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G1, .locking = false }), + .SI => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G0, .locking = false }), else => log.warn("invalid C0 character, ignoring: 0x{x}", .{c}), } } - inline fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void { + inline fn csiDispatch(self: *Self, input: Parser.Action.CSI) void { // The branch hints here are based on real world data // which indicates that the most common CSI finals are: // @@ -806,7 +810,7 @@ pub fn Stream(comptime Handler: type) type { 'A', 'k' => { @branchHint(.likely); switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_up, .{ + 0 => self.handler.vt(.cursor_up, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -827,7 +831,7 @@ pub fn Stream(comptime Handler: type) type { // CUD - Cursor Down 'B' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_down, .{ + 0 => self.handler.vt(.cursor_down, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -849,7 +853,7 @@ pub fn Stream(comptime Handler: type) type { 'C' => { @branchHint(.likely); switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_right, .{ + 0 => self.handler.vt(.cursor_right, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -870,7 +874,7 @@ pub fn Stream(comptime Handler: type) type { // CUB - Cursor Left 'D', 'j' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_left, .{ + 0 => self.handler.vt(.cursor_left, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -891,7 +895,7 @@ pub fn Stream(comptime Handler: type) type { // CNL - Cursor Next Line 'E' => switch (input.intermediates.len) { 0 => { - try self.handler.vt(.cursor_down, .{ + self.handler.vt(.cursor_down, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -902,7 +906,7 @@ pub fn Stream(comptime Handler: type) type { }, }, }); - try self.handler.vt(.carriage_return, {}); + self.handler.vt(.carriage_return, {}); }, else => log.warn( @@ -914,7 +918,7 @@ pub fn Stream(comptime Handler: type) type { // CPL - Cursor Previous Line 'F' => switch (input.intermediates.len) { 0 => { - try self.handler.vt(.cursor_up, .{ + self.handler.vt(.cursor_up, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -925,7 +929,7 @@ pub fn Stream(comptime Handler: type) type { }, }, }); - try self.handler.vt(.carriage_return, {}); + self.handler.vt(.carriage_return, {}); }, else => log.warn( @@ -937,7 +941,7 @@ pub fn Stream(comptime Handler: type) type { // HPA - Cursor Horizontal Position Absolute // TODO: test 'G', '`' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_col, .{ + 0 => self.handler.vt(.cursor_col, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -971,7 +975,7 @@ pub fn Stream(comptime Handler: type) type { return; }, }; - try self.handler.vt(.cursor_pos, pos); + self.handler.vt(.cursor_pos, pos); }, else => log.warn( @@ -983,7 +987,7 @@ pub fn Stream(comptime Handler: type) type { // CHT - Cursor Horizontal Tabulation 'I' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.horizontal_tab, switch (input.params.len) { + 0 => self.handler.vt(.horizontal_tab, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1023,11 +1027,11 @@ pub fn Stream(comptime Handler: type) type { }; switch (mode) { - .below => try self.handler.vt(.erase_display_below, protected), - .above => try self.handler.vt(.erase_display_above, protected), - .complete => try self.handler.vt(.erase_display_complete, protected), - .scrollback => try self.handler.vt(.erase_display_scrollback, protected), - .scroll_complete => try self.handler.vt(.erase_display_scroll_complete, protected), + .below => self.handler.vt(.erase_display_below, protected), + .above => self.handler.vt(.erase_display_above, protected), + .complete => self.handler.vt(.erase_display_complete, protected), + .scrollback => self.handler.vt(.erase_display_scrollback, protected), + .scroll_complete => self.handler.vt(.erase_display_scroll_complete, protected), } }, @@ -1059,10 +1063,10 @@ pub fn Stream(comptime Handler: type) type { }; switch (mode) { - .right => try self.handler.vt(.erase_line_right, protected), - .left => try self.handler.vt(.erase_line_left, protected), - .complete => try self.handler.vt(.erase_line_complete, protected), - .right_unless_pending_wrap => try self.handler.vt(.erase_line_right_unless_pending_wrap, protected), + .right => self.handler.vt(.erase_line_right, protected), + .left => self.handler.vt(.erase_line_left, protected), + .complete => self.handler.vt(.erase_line_complete, protected), + .right_unless_pending_wrap => self.handler.vt(.erase_line_right_unless_pending_wrap, protected), _ => { @branchHint(.unlikely); log.warn("invalid erase line mode: {}", .{mode}); @@ -1073,7 +1077,7 @@ pub fn Stream(comptime Handler: type) type { // IL - Insert Lines // TODO: test 'L' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.insert_lines, switch (input.params.len) { + 0 => self.handler.vt(.insert_lines, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1091,7 +1095,7 @@ pub fn Stream(comptime Handler: type) type { // DL - Delete Lines // TODO: test 'M' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.delete_lines, switch (input.params.len) { + 0 => self.handler.vt(.delete_lines, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1108,7 +1112,7 @@ pub fn Stream(comptime Handler: type) type { // Delete Character (DCH) 'P' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.delete_chars, switch (input.params.len) { + 0 => self.handler.vt(.delete_chars, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1126,7 +1130,7 @@ pub fn Stream(comptime Handler: type) type { // Scroll Up (SD) 'S' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.scroll_up, switch (input.params.len) { + 0 => self.handler.vt(.scroll_up, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1143,7 +1147,7 @@ pub fn Stream(comptime Handler: type) type { // Scroll Down (SD) 'T' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.scroll_down, switch (input.params.len) { + 0 => self.handler.vt(.scroll_down, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1164,7 +1168,7 @@ pub fn Stream(comptime Handler: type) type { if (input.params.len == 0 or (input.params.len == 1 and input.params[0] == 0)) { - try self.handler.vt(.tab_set, {}); + self.handler.vt(.tab_set, {}); return; } @@ -1174,9 +1178,9 @@ pub fn Stream(comptime Handler: type) type { 1 => switch (input.params[0]) { 0 => unreachable, - 2 => try self.handler.vt(.tab_clear_current, {}), + 2 => self.handler.vt(.tab_clear_current, {}), - 5 => try self.handler.vt(.tab_clear_all, {}), + 5 => self.handler.vt(.tab_clear_all, {}), else => {}, }, @@ -1192,7 +1196,7 @@ pub fn Stream(comptime Handler: type) type { input.params.len == 1 and input.params[0] == 5) { - try self.handler.vt(.tab_reset, {}); + self.handler.vt(.tab_reset, {}); } else log.warn("invalid cursor tabulation control: {f}", .{input}), else => log.warn( @@ -1205,7 +1209,7 @@ pub fn Stream(comptime Handler: type) type { 'X' => { @branchHint(.likely); switch (input.intermediates.len) { - 0 => try self.handler.vt(.erase_chars, switch (input.params.len) { + 0 => self.handler.vt(.erase_chars, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1224,7 +1228,7 @@ pub fn Stream(comptime Handler: type) type { // CHT - Cursor Horizontal Tabulation Back 'Z' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.horizontal_tab_back, switch (input.params.len) { + 0 => self.handler.vt(.horizontal_tab_back, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1241,7 +1245,7 @@ pub fn Stream(comptime Handler: type) type { // HPR - Cursor Horizontal Position Relative 'a' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_col_relative, .{ + 0 => self.handler.vt(.cursor_col_relative, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -1260,7 +1264,7 @@ pub fn Stream(comptime Handler: type) type { // Repeat Previous Char (REP) 'b' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.print_repeat, switch (input.params.len) { + 0 => self.handler.vt(.print_repeat, switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -1288,7 +1292,7 @@ pub fn Stream(comptime Handler: type) type { }; if (req) |r| { - try self.handler.vt(.device_attributes, r); + self.handler.vt(.device_attributes, r); } else { log.warn("invalid device attributes command: {f}", .{input}); return; @@ -1297,7 +1301,7 @@ pub fn Stream(comptime Handler: type) type { // VPA - Cursor Vertical Position Absolute 'd' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_row, .{ + 0 => self.handler.vt(.cursor_row, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -1316,7 +1320,7 @@ pub fn Stream(comptime Handler: type) type { // VPR - Cursor Vertical Position Relative 'e' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.cursor_row_relative, .{ + 0 => self.handler.vt(.cursor_row_relative, .{ .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], @@ -1348,8 +1352,8 @@ pub fn Stream(comptime Handler: type) type { }, }; switch (mode) { - .current => try self.handler.vt(.tab_clear_current, {}), - .all => try self.handler.vt(.tab_clear_all, {}), + .current => self.handler.vt(.tab_clear_current, {}), + .all => self.handler.vt(.tab_clear_all, {}), _ => log.warn("unknown tab clear mode: {}", .{mode}), } }, @@ -1374,7 +1378,7 @@ pub fn Stream(comptime Handler: type) type { for (input.params) |mode_int| { if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { - try self.handler.vt(.set_mode, .{ .mode = mode }); + self.handler.vt(.set_mode, .{ .mode = mode }); } else { log.warn("unimplemented mode: {}", .{mode_int}); } @@ -1395,7 +1399,7 @@ pub fn Stream(comptime Handler: type) type { for (input.params) |mode_int| { if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { - try self.handler.vt(.reset_mode, .{ .mode = mode }); + self.handler.vt(.reset_mode, .{ .mode = mode }); } else { log.warn("unimplemented mode: {}", .{mode_int}); } @@ -1416,7 +1420,7 @@ pub fn Stream(comptime Handler: type) type { }; while (p.next()) |attr| { // log.info("SGR attribute: {}", .{attr}); - try self.handler.vt(.set_attribute, attr); + self.handler.vt(.set_attribute, attr); } }, @@ -1424,7 +1428,7 @@ pub fn Stream(comptime Handler: type) type { '>' => blk: { if (input.params.len == 0) { // Reset - try self.handler.vt(.modify_key_format, .legacy); + self.handler.vt(.modify_key_format, .legacy); break :blk; } @@ -1463,7 +1467,7 @@ pub fn Stream(comptime Handler: type) type { } } - try self.handler.vt(.modify_key_format, format); + self.handler.vt(.modify_key_format, format); }, else => log.warn( @@ -1510,7 +1514,7 @@ pub fn Stream(comptime Handler: type) type { return; }; - try self.handler.vt(.device_status, .{ .request = req }); + self.handler.vt(.device_status, .{ .request = req }); return; } @@ -1524,7 +1528,7 @@ pub fn Stream(comptime Handler: type) type { // control what exactly is being disabled. However, we // only support reverting back to modify other keys in // numeric except format. - try self.handler.vt(.modify_key_format, .other_keys_numeric_except); + self.handler.vt(.modify_key_format, .other_keys_numeric_except); }, else => log.warn( @@ -1566,9 +1570,9 @@ pub fn Stream(comptime Handler: type) type { const mode_raw = input.params[0]; const mode = modes.modeFromInt(mode_raw, ansi_mode); if (mode) |m| { - try self.handler.vt(.request_mode, .{ .mode = m }); + self.handler.vt(.request_mode, .{ .mode = m }); } else { - try self.handler.vt(.request_mode_unknown, .{ + self.handler.vt(.request_mode_unknown, .{ .mode = mode_raw, .ansi = ansi_mode, }); @@ -1606,7 +1610,7 @@ pub fn Stream(comptime Handler: type) type { return; }, }; - try self.handler.vt(.cursor_style, style); + self.handler.vt(.cursor_style, style); }, // DECSCA @@ -1627,14 +1631,14 @@ pub fn Stream(comptime Handler: type) type { }; switch (mode) { - .off => try self.handler.vt(.protected_mode_off, {}), - .iso => try self.handler.vt(.protected_mode_iso, {}), - .dec => try self.handler.vt(.protected_mode_dec, {}), + .off => self.handler.vt(.protected_mode_off, {}), + .iso => self.handler.vt(.protected_mode_iso, {}), + .dec => self.handler.vt(.protected_mode_dec, {}), } }, // XTVERSION - '>' => try self.handler.vt(.xtversion, {}), + '>' => self.handler.vt(.xtversion, {}), else => { log.warn( "ignoring unimplemented CSI q with intermediates: {s}", @@ -1654,9 +1658,9 @@ pub fn Stream(comptime Handler: type) type { switch (input.intermediates.len) { // DECSTBM - Set Top and Bottom Margins 0 => switch (input.params.len) { - 0 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = 0, .bottom_right = 0 }), - 1 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = input.params[0], .bottom_right = 0 }), - 2 => try self.handler.vt(.top_and_bottom_margin, .{ .top_left = input.params[0], .bottom_right = input.params[1] }), + 0 => self.handler.vt(.top_and_bottom_margin, .{ .top_left = 0, .bottom_right = 0 }), + 1 => self.handler.vt(.top_and_bottom_margin, .{ .top_left = input.params[0], .bottom_right = 0 }), + 2 => self.handler.vt(.top_and_bottom_margin, .{ .top_left = input.params[0], .bottom_right = input.params[1] }), else => { @branchHint(.unlikely); log.warn("invalid DECSTBM command: {f}", .{input}); @@ -1668,7 +1672,7 @@ pub fn Stream(comptime Handler: type) type { '?' => { for (input.params) |mode_int| { if (modes.modeFromInt(mode_int, false)) |mode| { - try self.handler.vt(.restore_mode, .{ .mode = mode }); + self.handler.vt(.restore_mode, .{ .mode = mode }); } else { log.warn( "unimplemented restore mode: {}", @@ -1698,9 +1702,9 @@ pub fn Stream(comptime Handler: type) type { // to our handler to do the proper logic. If mode 69 // is set, then we should invoke DECSLRM, otherwise // we should invoke SC. - 0 => try self.handler.vt(.left_and_right_margin_ambiguous, {}), - 1 => try self.handler.vt(.left_and_right_margin, .{ .top_left = input.params[0], .bottom_right = 0 }), - 2 => try self.handler.vt(.left_and_right_margin, .{ .top_left = input.params[0], .bottom_right = input.params[1] }), + 0 => self.handler.vt(.left_and_right_margin_ambiguous, {}), + 1 => self.handler.vt(.left_and_right_margin, .{ .top_left = input.params[0], .bottom_right = 0 }), + 2 => self.handler.vt(.left_and_right_margin, .{ .top_left = input.params[0], .bottom_right = input.params[1] }), else => log.warn("invalid DECSLRM command: {f}", .{input}), }, @@ -1708,7 +1712,7 @@ pub fn Stream(comptime Handler: type) type { '?' => { for (input.params) |mode_int| { if (modes.modeFromInt(mode_int, false)) |mode| { - try self.handler.vt(.save_mode, .{ .mode = mode }); + self.handler.vt(.save_mode, .{ .mode = mode }); } else { log.warn( "unimplemented save mode: {}", @@ -1736,7 +1740,7 @@ pub fn Stream(comptime Handler: type) type { }, }; - try self.handler.vt(.mouse_shift_capture, capture); + self.handler.vt(.mouse_shift_capture, capture); }, else => log.warn( @@ -1758,28 +1762,28 @@ pub fn Stream(comptime Handler: type) type { switch (input.params[0]) { 14 => if (input.params.len == 1) { // report the text area size in pixels - try self.handler.vt(.size_report, .csi_14_t); + self.handler.vt(.size_report, .csi_14_t); } else log.warn( "ignoring CSI 14 t with extra parameters: {f}", .{input}, ), 16 => if (input.params.len == 1) { // report cell size in pixels - try self.handler.vt(.size_report, .csi_16_t); + self.handler.vt(.size_report, .csi_16_t); } else log.warn( "ignoring CSI 16 t with extra parameters: {f}", .{input}, ), 18 => if (input.params.len == 1) { // report screen size in characters - try self.handler.vt(.size_report, .csi_18_t); + self.handler.vt(.size_report, .csi_18_t); } else log.warn( "ignoring CSI 18 t with extra parameters: {f}", .{input}, ), 21 => if (input.params.len == 1) { // report window title - try self.handler.vt(.size_report, .csi_21_t); + self.handler.vt(.size_report, .csi_21_t); } else log.warn( "ignoring CSI 21 t with extra parameters: {f}", .{input}, @@ -1796,8 +1800,8 @@ pub fn Stream(comptime Handler: type) type { else 0; switch (number) { - 22 => try self.handler.vt(.title_push, index), - 23 => try self.handler.vt(.title_pop, index), + 22 => self.handler.vt(.title_push, index), + 23 => self.handler.vt(.title_pop, index), else => @compileError("unreachable"), } } else log.warn( @@ -1821,11 +1825,11 @@ pub fn Stream(comptime Handler: type) type { }, 'u' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.restore_cursor, {}), + 0 => self.handler.vt(.restore_cursor, {}), // Kitty keyboard protocol 1 => switch (input.intermediates[0]) { - '?' => try self.handler.vt(.kitty_keyboard_query, {}), + '?' => self.handler.vt(.kitty_keyboard_query, {}), '>' => push: { const flags: u5 = if (input.params.len == 1) @@ -1836,7 +1840,7 @@ pub fn Stream(comptime Handler: type) type { else 0; - try self.handler.vt(.kitty_keyboard_push, .{ .flags = @as(kitty.KeyFlags, @bitCast(flags)) }); + self.handler.vt(.kitty_keyboard_push, .{ .flags = @as(kitty.KeyFlags, @bitCast(flags)) }); }, '<' => { @@ -1845,7 +1849,7 @@ pub fn Stream(comptime Handler: type) type { else 1; - try self.handler.vt(.kitty_keyboard_pop, number); + self.handler.vt(.kitty_keyboard_pop, number); }, '=' => set: { @@ -1874,9 +1878,9 @@ pub fn Stream(comptime Handler: type) type { const kitty_flags: streampkg.Action.KittyKeyboardFlags = .{ .flags = @as(kitty.KeyFlags, @bitCast(flags)) }; switch (action_tag) { - .kitty_keyboard_set => try self.handler.vt(.kitty_keyboard_set, kitty_flags), - .kitty_keyboard_set_or => try self.handler.vt(.kitty_keyboard_set_or, kitty_flags), - .kitty_keyboard_set_not => try self.handler.vt(.kitty_keyboard_set_not, kitty_flags), + .kitty_keyboard_set => self.handler.vt(.kitty_keyboard_set, kitty_flags), + .kitty_keyboard_set_or => self.handler.vt(.kitty_keyboard_set_or, kitty_flags), + .kitty_keyboard_set_not => self.handler.vt(.kitty_keyboard_set_not, kitty_flags), else => unreachable, } }, @@ -1895,7 +1899,7 @@ pub fn Stream(comptime Handler: type) type { // ICH - Insert Blanks '@' => switch (input.intermediates.len) { - 0 => try self.handler.vt(.insert_blanks, switch (input.params.len) { + 0 => self.handler.vt(.insert_blanks, switch (input.params.len) { 0 => 1, 1 => @max(1, input.params[0]), else => { @@ -1932,14 +1936,14 @@ pub fn Stream(comptime Handler: type) type { }, }; - try self.handler.vt(.active_status_display, display); + self.handler.vt(.active_status_display, display); }, else => log.warn("unimplemented CSI action: {f}", .{input}), } } - inline fn oscDispatch(self: *Self, cmd: osc.Command) !void { + inline fn oscDispatch(self: *Self, cmd: osc.Command) void { // The branch hints here are based on real world data // which indicates that the most common OSC commands are: // @@ -1965,7 +1969,7 @@ pub fn Stream(comptime Handler: type) type { switch (cmd) { .semantic_prompt => |sp| { @branchHint(.likely); - try self.handler.vt(.semantic_prompt, sp); + self.handler.vt(.semantic_prompt, sp); }, .change_window_title => |title| { @@ -1976,7 +1980,7 @@ pub fn Stream(comptime Handler: type) type { return; } - try self.handler.vt(.window_title, .{ .title = title }); + self.handler.vt(.window_title, .{ .title = title }); }, .change_window_icon => |icon| { @@ -1985,7 +1989,7 @@ pub fn Stream(comptime Handler: type) type { }, .clipboard_contents => |clip| { - try self.handler.vt(.clipboard_contents, .{ + self.handler.vt(.clipboard_contents, .{ .kind = clip.kind, .data = clip.data, }); @@ -1993,7 +1997,7 @@ pub fn Stream(comptime Handler: type) type { .report_pwd => |v| { @branchHint(.likely); - try self.handler.vt(.report_pwd, .{ .url = v.value }); + self.handler.vt(.report_pwd, .{ .url = v.value }); }, .mouse_shape => |v| { @@ -2003,12 +2007,12 @@ pub fn Stream(comptime Handler: type) type { return; }; - try self.handler.vt(.mouse_shape, shape); + self.handler.vt(.mouse_shape, shape); }, .color_operation => |v| { @branchHint(.likely); - try self.handler.vt(.color_operation, .{ + self.handler.vt(.color_operation, .{ .op = v.op, .requests = v.requests, .terminator = v.terminator, @@ -2016,11 +2020,11 @@ pub fn Stream(comptime Handler: type) type { }, .kitty_color_protocol => |v| { - try self.handler.vt(.kitty_color_report, v); + self.handler.vt(.kitty_color_report, v); }, .show_desktop_notification => |v| { - try self.handler.vt(.show_desktop_notification, .{ + self.handler.vt(.show_desktop_notification, .{ .title = v.title, .body = v.body, }); @@ -2028,7 +2032,7 @@ pub fn Stream(comptime Handler: type) type { .hyperlink_start => |v| { @branchHint(.likely); - try self.handler.vt(.start_hyperlink, .{ + self.handler.vt(.start_hyperlink, .{ .uri = v.uri, .id = v.id, }); @@ -2036,11 +2040,11 @@ pub fn Stream(comptime Handler: type) type { .hyperlink_end => { @branchHint(.likely); - try self.handler.vt(.end_hyperlink, {}); + self.handler.vt(.end_hyperlink, {}); }, .conemu_progress_report => |v| { - try self.handler.vt(.progress_report, v); + self.handler.vt(.progress_report, v); }, .conemu_sleep, @@ -2072,7 +2076,7 @@ pub fn Stream(comptime Handler: type) type { self: *Self, intermediates: []const u8, set: charsets.Charset, - ) !void { + ) void { if (intermediates.len != 1) { log.warn("invalid charset intermediate: {any}", .{intermediates}); return; @@ -2092,7 +2096,7 @@ pub fn Stream(comptime Handler: type) type { }, }; - try self.handler.vt(.configure_charset, .{ + self.handler.vt(.configure_charset, .{ .slot = slot, .charset = set, }); @@ -2101,7 +2105,7 @@ pub fn Stream(comptime Handler: type) type { inline fn escDispatch( self: *Self, action: Parser.Action.ESC, - ) !void { + ) void { // The branch hints here are based on real world data // which indicates that the most common ESC finals are: // @@ -2129,19 +2133,19 @@ pub fn Stream(comptime Handler: type) type { // Charsets 'B' => { @branchHint(.likely); - try self.configureCharset(action.intermediates, .ascii); + self.configureCharset(action.intermediates, .ascii); }, - 'A' => try self.configureCharset(action.intermediates, .british), + 'A' => self.configureCharset(action.intermediates, .british), '0' => { @branchHint(.likely); - try self.configureCharset(action.intermediates, .dec_special); + self.configureCharset(action.intermediates, .dec_special); }, // DECSC - Save Cursor '7' => { @branchHint(.likely); switch (action.intermediates.len) { - 0 => try self.handler.vt(.save_cursor, {}), + 0 => self.handler.vt(.save_cursor, {}), else => { @branchHint(.unlikely); log.warn("invalid command: {f}", .{action}); @@ -2155,14 +2159,14 @@ pub fn Stream(comptime Handler: type) type { switch (action.intermediates.len) { // DECRC - Restore Cursor 0 => { - try self.handler.vt(.restore_cursor, {}); + self.handler.vt(.restore_cursor, {}); break :blk {}; }, 1 => switch (action.intermediates[0]) { // DECALN - Fill Screen with E '#' => { - try self.handler.vt(.decaln, {}); + self.handler.vt(.decaln, {}); break :blk {}; }, @@ -2177,7 +2181,7 @@ pub fn Stream(comptime Handler: type) type { // IND - Index 'D' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.index, {}), + 0 => self.handler.vt(.index, {}), else => { @branchHint(.unlikely); log.warn("invalid index command: {f}", .{action}); @@ -2187,7 +2191,7 @@ pub fn Stream(comptime Handler: type) type { // NEL - Next Line 'E' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.next_line, {}), + 0 => self.handler.vt(.next_line, {}), else => { @branchHint(.unlikely); log.warn("invalid next line command: {f}", .{action}); @@ -2197,7 +2201,7 @@ pub fn Stream(comptime Handler: type) type { // HTS - Horizontal Tab Set 'H' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.tab_set, {}), + 0 => self.handler.vt(.tab_set, {}), else => { @branchHint(.unlikely); log.warn("invalid tab set command: {f}", .{action}); @@ -2209,7 +2213,7 @@ pub fn Stream(comptime Handler: type) type { 'M' => { @branchHint(.likely); switch (action.intermediates.len) { - 0 => try self.handler.vt(.reverse_index, {}), + 0 => self.handler.vt(.reverse_index, {}), else => { @branchHint(.unlikely); log.warn("invalid reverse index command: {f}", .{action}); @@ -2220,7 +2224,7 @@ pub fn Stream(comptime Handler: type) type { // SS2 - Single Shift 2 'N' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.invoke_charset, .{ + 0 => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G2, .locking = true, @@ -2234,7 +2238,7 @@ pub fn Stream(comptime Handler: type) type { // SS3 - Single Shift 3 'O' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.invoke_charset, .{ + 0 => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G3, .locking = true, @@ -2248,24 +2252,24 @@ pub fn Stream(comptime Handler: type) type { // SPA - Start of Guarded Area 'V' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.protected_mode_iso, {}), + 0 => self.handler.vt(.protected_mode_iso, {}), else => log.warn("unimplemented ESC callback: {f}", .{action}), }, // EPA - End of Guarded Area 'W' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.protected_mode_off, {}), + 0 => self.handler.vt(.protected_mode_off, {}), else => log.warn("unimplemented ESC callback: {f}", .{action}), }, // DECID 'Z' => if (action.intermediates.len == 0) { - try self.handler.vt(.device_attributes, .primary); + self.handler.vt(.device_attributes, .primary); } else log.warn("unimplemented ESC callback: {f}", .{action}), // RIS - Full Reset 'c' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.full_reset, {}), + 0 => self.handler.vt(.full_reset, {}), else => { log.warn("invalid full reset command: {f}", .{action}); return; @@ -2274,7 +2278,7 @@ pub fn Stream(comptime Handler: type) type { // LS2 - Locking Shift 2 'n' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.invoke_charset, .{ + 0 => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G2, .locking = false, @@ -2288,7 +2292,7 @@ pub fn Stream(comptime Handler: type) type { // LS3 - Locking Shift 3 'o' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.invoke_charset, .{ + 0 => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G3, .locking = false, @@ -2302,7 +2306,7 @@ pub fn Stream(comptime Handler: type) type { // LS1R - Locking Shift 1 Right '~' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.invoke_charset, .{ + 0 => self.handler.vt(.invoke_charset, .{ .bank = .GR, .charset = .G1, .locking = false, @@ -2316,7 +2320,7 @@ pub fn Stream(comptime Handler: type) type { // LS2R - Locking Shift 2 Right '}' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.invoke_charset, .{ + 0 => self.handler.vt(.invoke_charset, .{ .bank = .GR, .charset = .G2, .locking = false, @@ -2330,7 +2334,7 @@ pub fn Stream(comptime Handler: type) type { // LS3R - Locking Shift 3 Right '|' => switch (action.intermediates.len) { - 0 => try self.handler.vt(.invoke_charset, .{ + 0 => self.handler.vt(.invoke_charset, .{ .bank = .GR, .charset = .G3, .locking = false, @@ -2346,7 +2350,7 @@ pub fn Stream(comptime Handler: type) type { '=' => { @branchHint(.likely); switch (action.intermediates.len) { - 0 => try self.handler.vt(.set_mode, .{ .mode = .keypad_keys }), + 0 => self.handler.vt(.set_mode, .{ .mode = .keypad_keys }), else => log.warn("unimplemented setMode: {f}", .{action}), } }, @@ -2355,7 +2359,7 @@ pub fn Stream(comptime Handler: type) type { '>' => { @branchHint(.likely); switch (action.intermediates.len) { - 0 => try self.handler.vt(.reset_mode, .{ .mode = .keypad_keys }), + 0 => self.handler.vt(.reset_mode, .{ .mode = .keypad_keys }), else => log.warn("unimplemented setMode: {f}", .{action}), } }, @@ -2386,7 +2390,7 @@ test "stream: print" { self: *@This(), comptime action: Action.Tag, value: Action.Value(action), - ) !void { + ) void { switch (action) { .print => self.c = value.cp, else => {}, @@ -2395,7 +2399,7 @@ test "stream: print" { }; var s: Stream(H) = .init(.{}); - try s.next('x'); + s.next('x'); try testing.expectEqual(@as(u21, 'x'), s.handler.c.?); } @@ -2407,7 +2411,7 @@ test "simd: print invalid utf-8" { self: *@This(), comptime action: Action.Tag, value: Action.Value(action), - ) !void { + ) void { switch (action) { .print => self.c = value.cp, else => {}, @@ -2416,7 +2420,7 @@ test "simd: print invalid utf-8" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice(&.{0xFF}); + s.nextSlice(&.{0xFF}); try testing.expectEqual(@as(u21, 0xFFFD), s.handler.c.?); } @@ -2428,7 +2432,7 @@ test "simd: complete incomplete utf-8" { self: *@This(), comptime action: Action.Tag, value: Action.Value(action), - ) !void { + ) void { switch (action) { .print => self.c = value.cp, else => {}, @@ -2437,11 +2441,11 @@ test "simd: complete incomplete utf-8" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice(&.{0xE0}); // 3 byte + s.nextSlice(&.{0xE0}); // 3 byte try testing.expect(s.handler.c == null); - try s.nextSlice(&.{0xA0}); // still incomplete + s.nextSlice(&.{0xA0}); // still incomplete try testing.expect(s.handler.c == null); - try s.nextSlice(&.{0x80}); + s.nextSlice(&.{0x80}); try testing.expectEqual(@as(u21, 0x800), s.handler.c.?); } @@ -2453,7 +2457,7 @@ test "stream: cursor right (CUF)" { self: *@This(), comptime action: Action.Tag, value: Action.Value(action), - ) !void { + ) void { switch (action) { .cursor_right => self.amount = value.value, else => {}, @@ -2462,18 +2466,18 @@ test "stream: cursor right (CUF)" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[C"); + s.nextSlice("\x1B[C"); try testing.expectEqual(@as(u16, 1), s.handler.amount); - try s.nextSlice("\x1B[5C"); + s.nextSlice("\x1B[5C"); try testing.expectEqual(@as(u16, 5), s.handler.amount); s.handler.amount = 0; - try s.nextSlice("\x1B[5;4C"); + s.nextSlice("\x1B[5;4C"); try testing.expectEqual(@as(u16, 0), s.handler.amount); s.handler.amount = 0; - try s.nextSlice("\x1b[?3C"); + s.nextSlice("\x1b[?3C"); try testing.expectEqual(@as(u16, 0), s.handler.amount); } @@ -2485,7 +2489,7 @@ test "stream: dec set mode (SM) and reset mode (RM)" { self: *@This(), comptime action: Action.Tag, value: Action.Value(action), - ) !void { + ) void { switch (action) { .set_mode => self.mode = value.mode, .reset_mode => self.mode = @as(modes.Mode, @enumFromInt(1)), @@ -2495,14 +2499,14 @@ test "stream: dec set mode (SM) and reset mode (RM)" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[?6h"); + s.nextSlice("\x1B[?6h"); try testing.expectEqual(@as(modes.Mode, .origin), s.handler.mode); - try s.nextSlice("\x1B[?6l"); + s.nextSlice("\x1B[?6l"); try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode); s.handler.mode = @as(modes.Mode, @enumFromInt(1)); - try s.nextSlice("\x1B[6 h"); + s.nextSlice("\x1B[6 h"); try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode); } @@ -2514,7 +2518,7 @@ test "stream: ansi set mode (SM) and reset mode (RM)" { self: *@This(), comptime action: Action.Tag, value: Action.Value(action), - ) !void { + ) void { switch (action) { .set_mode => self.mode = value.mode, .reset_mode => self.mode = null, @@ -2524,14 +2528,14 @@ test "stream: ansi set mode (SM) and reset mode (RM)" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[4h"); + s.nextSlice("\x1B[4h"); try testing.expectEqual(@as(modes.Mode, .insert), s.handler.mode.?); - try s.nextSlice("\x1B[4l"); + s.nextSlice("\x1B[4l"); try testing.expect(s.handler.mode == null); s.handler.mode = null; - try s.nextSlice("\x1B[>5h"); + s.nextSlice("\x1B[>5h"); try testing.expect(s.handler.mode == null); } @@ -2548,17 +2552,17 @@ test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" { self: *@This(), comptime action: Action.Tag, value: Action.Value(action), - ) !void { + ) void { _ = self; _ = value; } }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[6h"); + s.nextSlice("\x1B[6h"); try testing.expect(s.handler.mode == null); - try s.nextSlice("\x1B[6l"); + s.nextSlice("\x1B[6l"); try testing.expect(s.handler.mode == null); } @@ -2571,7 +2575,7 @@ test "stream: restore mode" { self: *Self, comptime action: Stream(Self).Action.Tag, value: Stream(Self).Action.Value(action), - ) !void { + ) void { _ = value; switch (action) { .top_and_bottom_margin => self.called = true, @@ -2581,7 +2585,7 @@ test "stream: restore mode" { }; var s: Stream(H) = .init(.{}); - for ("\x1B[?42r") |c| try s.next(c); + for ("\x1B[?42r") |c| s.next(c); try testing.expect(!s.handler.called); } @@ -2594,7 +2598,7 @@ test "stream: pop kitty keyboard with no params defaults to 1" { self: *Self, comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .kitty_keyboard_pop => self.n = value, else => {}, @@ -2603,7 +2607,7 @@ test "stream: pop kitty keyboard with no params defaults to 1" { }; var s: Stream(H) = .init(.{}); - for ("\x1B[ self.v = .off, @@ -2629,19 +2633,19 @@ test "stream: DECSCA" { var s: Stream(H) = .init(.{}); { - for ("\x1B[\"q") |c| try s.next(c); + for ("\x1B[\"q") |c| s.next(c); try testing.expectEqual(ansi.ProtectedMode.off, s.handler.v.?); } { - for ("\x1B[0\"q") |c| try s.next(c); + for ("\x1B[0\"q") |c| s.next(c); try testing.expectEqual(ansi.ProtectedMode.off, s.handler.v.?); } { - for ("\x1B[2\"q") |c| try s.next(c); + for ("\x1B[2\"q") |c| s.next(c); try testing.expectEqual(ansi.ProtectedMode.off, s.handler.v.?); } { - for ("\x1B[1\"q") |c| try s.next(c); + for ("\x1B[1\"q") |c| s.next(c); try testing.expectEqual(ansi.ProtectedMode.dec, s.handler.v.?); } } @@ -2656,7 +2660,7 @@ test "stream: DECED, DECSED" { self: *Self, comptime action: anytype, value: anytype, - ) !void { + ) void { switch (action) { .erase_display_below => { self.mode = .below; @@ -2685,59 +2689,59 @@ test "stream: DECED, DECSED" { var s: Stream(H) = .init(.{}); { - for ("\x1B[?J") |c| try s.next(c); + for ("\x1B[?J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.below, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[?0J") |c| try s.next(c); + for ("\x1B[?0J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.below, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[?1J") |c| try s.next(c); + for ("\x1B[?1J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.above, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[?2J") |c| try s.next(c); + for ("\x1B[?2J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.complete, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[?3J") |c| try s.next(c); + for ("\x1B[?3J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[J") |c| try s.next(c); + for ("\x1B[J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.below, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { - for ("\x1B[0J") |c| try s.next(c); + for ("\x1B[0J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.below, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { - for ("\x1B[1J") |c| try s.next(c); + for ("\x1B[1J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.above, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { - for ("\x1B[2J") |c| try s.next(c); + for ("\x1B[2J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.complete, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { - for ("\x1B[3J") |c| try s.next(c); + for ("\x1B[3J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { // Invalid and ignored by the handler - for ("\x1B[>0J") |c| try s.next(c); + for ("\x1B[>0J") |c| s.next(c); try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } @@ -2753,7 +2757,7 @@ test "stream: DECEL, DECSEL" { self: *Self, comptime action: anytype, value: anytype, - ) !void { + ) void { switch (action) { .erase_line_right => { self.mode = .right; @@ -2778,49 +2782,49 @@ test "stream: DECEL, DECSEL" { var s: Stream(H) = .init(.{}); { - for ("\x1B[?K") |c| try s.next(c); + for ("\x1B[?K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.right, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[?0K") |c| try s.next(c); + for ("\x1B[?0K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.right, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[?1K") |c| try s.next(c); + for ("\x1B[?1K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.left, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[?2K") |c| try s.next(c); + for ("\x1B[?2K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?); try testing.expect(s.handler.protected.?); } { - for ("\x1B[K") |c| try s.next(c); + for ("\x1B[K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.right, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { - for ("\x1B[0K") |c| try s.next(c); + for ("\x1B[0K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.right, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { - for ("\x1B[1K") |c| try s.next(c); + for ("\x1B[1K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.left, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { - for ("\x1B[2K") |c| try s.next(c); + for ("\x1B[2K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } { // Invalid and ignored by the handler - for ("\x1B[<1K") |c| try s.next(c); + for ("\x1B[<1K") |c| s.next(c); try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } @@ -2834,7 +2838,7 @@ test "stream: DECSCUSR" { self: *@This(), comptime action: Stream(@This()).Action.Tag, value: Stream(@This()).Action.Value(action), - ) !void { + ) void { switch (action) { .cursor_style => self.style = value, else => {}, @@ -2843,14 +2847,14 @@ test "stream: DECSCUSR" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[ q"); + s.nextSlice("\x1B[ q"); try testing.expect(s.handler.style.? == .default); - try s.nextSlice("\x1B[1 q"); + s.nextSlice("\x1B[1 q"); try testing.expect(s.handler.style.? == .blinking_block); // Invalid and ignored by the handler - try s.nextSlice("\x1B[?0 q"); + s.nextSlice("\x1B[?0 q"); try testing.expect(s.handler.style.? == .blinking_block); } @@ -2862,7 +2866,7 @@ test "stream: DECSCUSR without space" { self: *@This(), comptime action: Stream(@This()).Action.Tag, value: Stream(@This()).Action.Value(action), - ) !void { + ) void { switch (action) { .cursor_style => self.style = value, else => {}, @@ -2871,10 +2875,10 @@ test "stream: DECSCUSR without space" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[q"); + s.nextSlice("\x1B[q"); try testing.expect(s.handler.style == null); - try s.nextSlice("\x1B[1q"); + s.nextSlice("\x1B[1q"); try testing.expect(s.handler.style == null); } @@ -2886,7 +2890,7 @@ test "stream: XTSHIFTESCAPE" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .mouse_shift_capture => self.escape = value, else => {}, @@ -2895,20 +2899,20 @@ test "stream: XTSHIFTESCAPE" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[>2s"); + s.nextSlice("\x1B[>2s"); try testing.expect(s.handler.escape == null); - try s.nextSlice("\x1B[>s"); + s.nextSlice("\x1B[>s"); try testing.expect(s.handler.escape.? == false); - try s.nextSlice("\x1B[>0s"); + s.nextSlice("\x1B[>0s"); try testing.expect(s.handler.escape.? == false); - try s.nextSlice("\x1B[>1s"); + s.nextSlice("\x1B[>1s"); try testing.expect(s.handler.escape.? == true); // Invalid and ignored by the handler - try s.nextSlice("\x1B[1 s"); + s.nextSlice("\x1B[1 s"); try testing.expect(s.handler.escape.? == true); } @@ -2920,7 +2924,7 @@ test "stream: change window title with invalid utf-8" { self: *@This(), comptime action: anytype, value: anytype, - ) !void { + ) void { _ = value; switch (action) { .window_title => self.seen = true, @@ -2931,13 +2935,13 @@ test "stream: change window title with invalid utf-8" { { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b]2;abc\x1b\\"); + s.nextSlice("\x1b]2;abc\x1b\\"); try testing.expect(s.handler.seen); } { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b]2;abc\xc0\x1b\\"); + s.nextSlice("\x1b]2;abc\xc0\x1b\\"); try testing.expect(!s.handler.seen); } } @@ -2951,7 +2955,7 @@ test "stream: insert characters" { self: *Self, comptime action: anytype, value: anytype, - ) !void { + ) void { _ = value; switch (action) { .insert_blanks => self.called = true, @@ -2961,11 +2965,11 @@ test "stream: insert characters" { }; var s: Stream(H) = .init(.{}); - for ("\x1B[42@") |c| try s.next(c); + for ("\x1B[42@") |c| s.next(c); try testing.expect(s.handler.called); s.handler.called = false; - for ("\x1B[?42@") |c| try s.next(c); + for ("\x1B[?42@") |c| s.next(c); try testing.expect(!s.handler.called); } @@ -2978,7 +2982,7 @@ test "stream: insert characters explicit zero clamps to 1" { self: *Self, comptime action: anytype, value: anytype, - ) !void { + ) void { switch (action) { .insert_blanks => self.value = value, else => {}, @@ -2987,7 +2991,7 @@ test "stream: insert characters explicit zero clamps to 1" { }; var s: Stream(H) = .init(.{}); - for ("\x1B[0@") |c| try s.next(c); + for ("\x1B[0@") |c| s.next(c); try testing.expectEqual(@as(usize, 1), s.handler.value.?); } @@ -3000,7 +3004,7 @@ test "stream: SCOSC" { self: *Self, comptime action: Stream(Self).Action.Tag, value: Stream(Self).Action.Value(action), - ) !void { + ) void { _ = value; switch (action) { .left_and_right_margin => @panic("bad"), @@ -3011,7 +3015,7 @@ test "stream: SCOSC" { }; var s: Stream(H) = .init(.{}); - for ("\x1B[s") |c| try s.next(c); + for ("\x1B[s") |c| s.next(c); try testing.expect(s.handler.called); } @@ -3024,7 +3028,7 @@ test "stream: SCORC" { self: *Self, comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { _ = value; switch (action) { .restore_cursor => self.called = true, @@ -3034,7 +3038,7 @@ test "stream: SCORC" { }; var s: Stream(H) = .init(.{}); - for ("\x1B[u") |c| try s.next(c); + for ("\x1B[u") |c| s.next(c); try testing.expect(s.handler.called); } @@ -3044,7 +3048,7 @@ test "stream: too many csi params" { self: *@This(), comptime action: anytype, value: anytype, - ) !void { + ) void { _ = self; _ = value; switch (action) { @@ -3055,7 +3059,7 @@ test "stream: too many csi params" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1C"); + s.nextSlice("\x1B[1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1C"); } test "stream: csi param too long" { @@ -3064,7 +3068,7 @@ test "stream: csi param too long" { self: *@This(), comptime action: anytype, value: anytype, - ) !void { + ) void { _ = self; _ = action; _ = value; @@ -3072,7 +3076,7 @@ test "stream: csi param too long" { }; var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1B[1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111C"); + s.nextSlice("\x1B[1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111C"); } test "stream: send report with CSI t" { @@ -3083,7 +3087,7 @@ test "stream: send report with CSI t" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .size_report => self.style = value, else => {}, @@ -3093,16 +3097,16 @@ test "stream: send report with CSI t" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[14t"); + s.nextSlice("\x1b[14t"); try testing.expectEqual(csi.SizeReportStyle.csi_14_t, s.handler.style); - try s.nextSlice("\x1b[16t"); + s.nextSlice("\x1b[16t"); try testing.expectEqual(csi.SizeReportStyle.csi_16_t, s.handler.style); - try s.nextSlice("\x1b[18t"); + s.nextSlice("\x1b[18t"); try testing.expectEqual(csi.SizeReportStyle.csi_18_t, s.handler.style); - try s.nextSlice("\x1b[21t"); + s.nextSlice("\x1b[21t"); try testing.expectEqual(csi.SizeReportStyle.csi_21_t, s.handler.style); } @@ -3118,7 +3122,7 @@ test "stream: invalid CSI t" { self: *@This(), comptime action: anytype, value: anytype, - ) !void { + ) void { _ = self; _ = action; _ = value; @@ -3127,7 +3131,7 @@ test "stream: invalid CSI t" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[19t"); + s.nextSlice("\x1b[19t"); try testing.expectEqual(null, s.handler.style); } @@ -3139,7 +3143,7 @@ test "stream: CSI t push title" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .title_push => self.index = value, else => {}, @@ -3149,7 +3153,7 @@ test "stream: CSI t push title" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[22;0t"); + s.nextSlice("\x1b[22;0t"); try testing.expectEqual(@as(u16, 0), s.handler.index.?); } @@ -3161,7 +3165,7 @@ test "stream: CSI t push title with explicit window" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .title_push => self.index = value, else => {}, @@ -3171,7 +3175,7 @@ test "stream: CSI t push title with explicit window" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[22;2t"); + s.nextSlice("\x1b[22;2t"); try testing.expectEqual(@as(u16, 0), s.handler.index.?); } @@ -3183,7 +3187,7 @@ test "stream: CSI t push title with explicit icon" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .title_push => self.index = value, else => {}, @@ -3193,7 +3197,7 @@ test "stream: CSI t push title with explicit icon" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[22;1t"); + s.nextSlice("\x1b[22;1t"); try testing.expectEqual(null, s.handler.index); } @@ -3205,7 +3209,7 @@ test "stream: CSI t push title with index" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .title_push => self.index = value, else => {}, @@ -3215,7 +3219,7 @@ test "stream: CSI t push title with index" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[22;0;5t"); + s.nextSlice("\x1b[22;0;5t"); try testing.expectEqual(@as(u16, 5), s.handler.index.?); } @@ -3227,7 +3231,7 @@ test "stream: CSI t pop title" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .title_pop => self.index = value, else => {}, @@ -3237,7 +3241,7 @@ test "stream: CSI t pop title" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[23;0t"); + s.nextSlice("\x1b[23;0t"); try testing.expectEqual(@as(u16, 0), s.handler.index.?); } @@ -3249,7 +3253,7 @@ test "stream: CSI t pop title with explicit window" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .title_pop => self.index = value, else => {}, @@ -3259,7 +3263,7 @@ test "stream: CSI t pop title with explicit window" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[23;2t"); + s.nextSlice("\x1b[23;2t"); try testing.expectEqual(@as(u16, 0), s.handler.index.?); } @@ -3271,7 +3275,7 @@ test "stream: CSI t pop title with explicit icon" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .title_pop => self.index = value, else => {}, @@ -3281,7 +3285,7 @@ test "stream: CSI t pop title with explicit icon" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[23;1t"); + s.nextSlice("\x1b[23;1t"); try testing.expectEqual(null, s.handler.index); } @@ -3293,7 +3297,7 @@ test "stream: CSI t pop title with index" { self: *@This(), comptime action: streampkg.Action.Tag, value: streampkg.Action.Value(action), - ) !void { + ) void { switch (action) { .title_pop => self.index = value, else => {}, @@ -3303,7 +3307,7 @@ test "stream: CSI t pop title with index" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[23;0;5t"); + s.nextSlice("\x1b[23;0;5t"); try testing.expectEqual(@as(u16, 5), s.handler.index.?); } @@ -3315,7 +3319,7 @@ test "stream CSI W clear tab stops" { self: *@This(), comptime action: anytype, value: anytype, - ) !void { + ) void { _ = value; self.action = action; } @@ -3323,10 +3327,10 @@ test "stream CSI W clear tab stops" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[2W"); + s.nextSlice("\x1b[2W"); try testing.expectEqual(Action.Key.tab_clear_current, s.handler.action.?); - try s.nextSlice("\x1b[5W"); + s.nextSlice("\x1b[5W"); try testing.expectEqual(Action.Key.tab_clear_all, s.handler.action.?); } @@ -3338,7 +3342,7 @@ test "stream CSI W tab set" { self: *@This(), comptime action: anytype, value: anytype, - ) !void { + ) void { _ = value; self.action = action; } @@ -3346,19 +3350,19 @@ test "stream CSI W tab set" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[W"); + s.nextSlice("\x1b[W"); try testing.expectEqual(Action.Key.tab_set, s.handler.action.?); s.handler.action = null; - try s.nextSlice("\x1b[0W"); + s.nextSlice("\x1b[0W"); try testing.expectEqual(Action.Key.tab_set, s.handler.action.?); s.handler.action = null; - try s.nextSlice("\x1b[>W"); + s.nextSlice("\x1b[>W"); try testing.expect(s.handler.action == null); s.handler.action = null; - try s.nextSlice("\x1b[99W"); + s.nextSlice("\x1b[99W"); try testing.expect(s.handler.action == null); } @@ -3370,7 +3374,7 @@ test "stream CSI ? W reset tab stops" { self: *@This(), comptime action: anytype, value: anytype, - ) !void { + ) void { _ = value; self.action = action; } @@ -3378,15 +3382,15 @@ test "stream CSI ? W reset tab stops" { var s: Stream(H) = .init(.{}); - try s.nextSlice("\x1b[?2W"); + s.nextSlice("\x1b[?2W"); try testing.expect(s.handler.action == null); - try s.nextSlice("\x1b[?5W"); + s.nextSlice("\x1b[?5W"); try testing.expectEqual(Action.Key.tab_reset, s.handler.action.?); // Invalid and ignored by the handler s.handler.action = null; - try s.nextSlice("\x1b[?1;2;3W"); + s.nextSlice("\x1b[?1;2;3W"); try testing.expect(s.handler.action == null); } @@ -3399,7 +3403,7 @@ test "stream: SGR with 17+ parameters for underline color" { self: *@This(), comptime action: anytype, value: anytype, - ) !void { + ) void { switch (action) { .set_attribute => { self.attrs = value; @@ -3414,7 +3418,7 @@ test "stream: SGR with 17+ parameters for underline color" { // Kakoune-style SGR with underline color as 17th parameter // This tests the fix where param 17 was being dropped - try s.nextSlice("\x1b[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136;0m"); + s.nextSlice("\x1b[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136;0m"); try testing.expect(s.handler.called); } @@ -3429,7 +3433,7 @@ test "stream: tab clear with overflowing param" { self: *@This(), comptime action: Action.Tag, value: Action.Value(action), - ) !void { + ) void { _ = value; switch (action) { .tab_clear_current, .tab_clear_all => self.called = true, @@ -3441,5 +3445,5 @@ test "stream: tab clear with overflowing param" { var s: Stream(H) = .init(.{}); // This is the exact input from the fuzz crash (minus the mode byte): // CSI with a huge numeric param that saturates to 65535, followed by 'g'. - try s.nextSlice("\x1b[388888888888888888888888888888888888g\x1b[0m"); + s.nextSlice("\x1b[388888888888888888888888888888888888g\x1b[0m"); } diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 5b97bebfa..a3e98c8e0 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -8,6 +8,8 @@ const osc_color = @import("osc/parsers/color.zig"); const kitty_color = @import("kitty/color.zig"); const Terminal = @import("Terminal.zig"); +const log = std.log.scoped(.stream_readonly); + /// This is a Stream implementation that processes actions against /// a Terminal and updates the Terminal state. It is called "readonly" because /// it only processes actions that modify terminal state, while ignoring @@ -45,6 +47,16 @@ pub const Handler = struct { self: *Handler, comptime action: Action.Tag, value: Action.Value(action), + ) void { + self.vtFallible(action, value) catch |err| { + log.warn("error handling VT action action={} err={}", .{ action, err }); + }; + } + + inline fn vtFallible( + self: *Handler, + comptime action: Action.Tag, + value: Action.Value(action), ) !void { switch (action) { .print => try self.terminal.print(value.cp), @@ -402,7 +414,7 @@ test "basic print" { var s: Stream = .initAlloc(testing.allocator, .init(&t)); defer s.deinit(); - try s.nextSlice("Hello"); + s.nextSlice("Hello"); try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); @@ -419,12 +431,12 @@ test "cursor movement" { defer s.deinit(); // Move cursor using escape sequences - try s.nextSlice("Hello\x1B[1;1H"); + s.nextSlice("Hello\x1B[1;1H"); try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); // Move to position 2,3 - try s.nextSlice("\x1B[2;3H"); + s.nextSlice("\x1B[2;3H"); try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); } @@ -437,13 +449,13 @@ test "erase operations" { defer s.deinit(); // Print some text - try s.nextSlice("Hello World"); + s.nextSlice("Hello World"); try testing.expectEqual(@as(usize, 11), t.screens.active.cursor.x); try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); // Move cursor to position 1,6 and erase from cursor to end of line - try s.nextSlice("\x1B[1;6H"); - try s.nextSlice("\x1B[K"); + s.nextSlice("\x1B[1;6H"); + s.nextSlice("\x1B[K"); const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -457,7 +469,7 @@ test "tabs" { var s: Stream = .initAlloc(testing.allocator, .init(&t)); defer s.deinit(); - try s.nextSlice("A\tB"); + s.nextSlice("A\tB"); try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.x); const str = try t.plainString(testing.allocator); @@ -474,9 +486,9 @@ test "modes" { // Test wraparound mode try testing.expect(t.modes.get(.wraparound)); - try s.nextSlice("\x1B[?7l"); // Disable wraparound + s.nextSlice("\x1B[?7l"); // Disable wraparound try testing.expect(!t.modes.get(.wraparound)); - try s.nextSlice("\x1B[?7h"); // Enable wraparound + s.nextSlice("\x1B[?7h"); // Enable wraparound try testing.expect(t.modes.get(.wraparound)); } @@ -488,7 +500,7 @@ test "scrolling regions" { defer s.deinit(); // Set scrolling region from line 5 to 20 - try s.nextSlice("\x1B[5;20r"); + s.nextSlice("\x1B[5;20r"); try testing.expectEqual(@as(usize, 4), t.scrolling_region.top); try testing.expectEqual(@as(usize, 19), t.scrolling_region.bottom); try testing.expectEqual(@as(usize, 0), t.scrolling_region.left); @@ -503,8 +515,8 @@ test "charsets" { defer s.deinit(); // Configure G0 as DEC special graphics - try s.nextSlice("\x1B(0"); - try s.nextSlice("`"); // Should print diamond character + s.nextSlice("\x1B(0"); + s.nextSlice("`"); // Should print diamond character const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -519,18 +531,18 @@ test "alt screen" { defer s.deinit(); // Write to primary screen - try s.nextSlice("Primary"); + s.nextSlice("Primary"); try testing.expectEqual(.primary, t.screens.active_key); // Switch to alt screen - try s.nextSlice("\x1B[?1049h"); + s.nextSlice("\x1B[?1049h"); try testing.expectEqual(.alternate, t.screens.active_key); // Write to alt screen - try s.nextSlice("Alt"); + s.nextSlice("Alt"); // Switch back to primary - try s.nextSlice("\x1B[?1049l"); + s.nextSlice("\x1B[?1049l"); try testing.expectEqual(.primary, t.screens.active_key); const str = try t.plainString(testing.allocator); @@ -546,20 +558,20 @@ test "cursor save and restore" { defer s.deinit(); // Move cursor to 10,15 - try s.nextSlice("\x1B[10;15H"); + s.nextSlice("\x1B[10;15H"); try testing.expectEqual(@as(usize, 14), t.screens.active.cursor.x); try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y); // Save cursor - try s.nextSlice("\x1B7"); + s.nextSlice("\x1B7"); // Move cursor elsewhere - try s.nextSlice("\x1B[1;1H"); + s.nextSlice("\x1B[1;1H"); try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); // Restore cursor - try s.nextSlice("\x1B8"); + s.nextSlice("\x1B8"); try testing.expectEqual(@as(usize, 14), t.screens.active.cursor.x); try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y); } @@ -572,7 +584,7 @@ test "attributes" { defer s.deinit(); // Set bold and write text - try s.nextSlice("\x1B[1mBold\x1B[0m"); + s.nextSlice("\x1B[1mBold\x1B[0m"); // Verify we can write attributes - just check the string was written const str = try t.plainString(testing.allocator); @@ -588,7 +600,7 @@ test "DECALN screen alignment" { defer s.deinit(); // Run DECALN - try s.nextSlice("\x1B#8"); + s.nextSlice("\x1B#8"); // Verify entire screen is filled with 'E' const str = try t.plainString(testing.allocator); @@ -608,13 +620,13 @@ test "full reset" { defer s.deinit(); // Make some changes - try s.nextSlice("Hello"); - try s.nextSlice("\x1B[10;20H"); - try s.nextSlice("\x1B[5;20r"); // Set scroll region - try s.nextSlice("\x1B[?7l"); // Disable wraparound + s.nextSlice("Hello"); + s.nextSlice("\x1B[10;20H"); + s.nextSlice("\x1B[5;20r"); // Set scroll region + s.nextSlice("\x1B[?7l"); // Disable wraparound // Full reset - try s.nextSlice("\x1Bc"); + s.nextSlice("\x1Bc"); // Verify reset state try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); @@ -632,12 +644,12 @@ test "ignores query actions" { defer s.deinit(); // These should be ignored without error - try s.nextSlice("\x1B[c"); // Device attributes - try s.nextSlice("\x1B[5n"); // Device status report - try s.nextSlice("\x1B[6n"); // Cursor position report + s.nextSlice("\x1B[c"); // Device attributes + s.nextSlice("\x1B[5n"); // Device status report + s.nextSlice("\x1B[6n"); // Cursor position report // Terminal should still be functional - try s.nextSlice("Test"); + s.nextSlice("Test"); const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); try testing.expectEqualStrings("Test", str); @@ -654,14 +666,14 @@ test "OSC 4 set and reset palette" { const default_color_0 = t.colors.palette.original[0]; // Set color 0 to red - try s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); + s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[0].r); try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[0].g); try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[0].b); try testing.expect(t.colors.palette.mask.isSet(0)); // Reset color 0 - try s.nextSlice("\x1b]104;0\x1b\\"); + s.nextSlice("\x1b]104;0\x1b\\"); try testing.expectEqual(default_color_0, t.colors.palette.current[0]); try testing.expect(!t.colors.palette.mask.isSet(0)); } @@ -674,15 +686,15 @@ test "OSC 104 reset all palette colors" { defer s.deinit(); // Set multiple colors - try s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); - try s.nextSlice("\x1b]4;1;rgb:00/ff/00\x1b\\"); - try s.nextSlice("\x1b]4;2;rgb:00/00/ff\x1b\\"); + s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); + s.nextSlice("\x1b]4;1;rgb:00/ff/00\x1b\\"); + s.nextSlice("\x1b]4;2;rgb:00/00/ff\x1b\\"); try testing.expect(t.colors.palette.mask.isSet(0)); try testing.expect(t.colors.palette.mask.isSet(1)); try testing.expect(t.colors.palette.mask.isSet(2)); // Reset all palette colors - try s.nextSlice("\x1b]104\x1b\\"); + s.nextSlice("\x1b]104\x1b\\"); try testing.expectEqual(t.colors.palette.original[0], t.colors.palette.current[0]); try testing.expectEqual(t.colors.palette.original[1], t.colors.palette.current[1]); try testing.expectEqual(t.colors.palette.original[2], t.colors.palette.current[2]); @@ -702,14 +714,14 @@ test "OSC 10 set and reset foreground color" { try testing.expect(t.colors.foreground.get() == null); // Set foreground to red - try s.nextSlice("\x1b]10;rgb:ff/00/00\x1b\\"); + s.nextSlice("\x1b]10;rgb:ff/00/00\x1b\\"); const fg = t.colors.foreground.get().?; try testing.expectEqual(@as(u8, 0xff), fg.r); try testing.expectEqual(@as(u8, 0x00), fg.g); try testing.expectEqual(@as(u8, 0x00), fg.b); // Reset foreground - try s.nextSlice("\x1b]110\x1b\\"); + s.nextSlice("\x1b]110\x1b\\"); try testing.expect(t.colors.foreground.get() == null); } @@ -721,14 +733,14 @@ test "OSC 11 set and reset background color" { defer s.deinit(); // Set background to green - try s.nextSlice("\x1b]11;rgb:00/ff/00\x1b\\"); + s.nextSlice("\x1b]11;rgb:00/ff/00\x1b\\"); const bg = t.colors.background.get().?; try testing.expectEqual(@as(u8, 0x00), bg.r); try testing.expectEqual(@as(u8, 0xff), bg.g); try testing.expectEqual(@as(u8, 0x00), bg.b); // Reset background - try s.nextSlice("\x1b]111\x1b\\"); + s.nextSlice("\x1b]111\x1b\\"); try testing.expect(t.colors.background.get() == null); } @@ -740,14 +752,14 @@ test "OSC 12 set and reset cursor color" { defer s.deinit(); // Set cursor to blue - try s.nextSlice("\x1b]12;rgb:00/00/ff\x1b\\"); + s.nextSlice("\x1b]12;rgb:00/00/ff\x1b\\"); const cursor = t.colors.cursor.get().?; try testing.expectEqual(@as(u8, 0x00), cursor.r); try testing.expectEqual(@as(u8, 0x00), cursor.g); try testing.expectEqual(@as(u8, 0xff), cursor.b); // Reset cursor - try s.nextSlice("\x1b]112\x1b\\"); + s.nextSlice("\x1b]112\x1b\\"); // After reset, cursor might be null (using default) } @@ -759,7 +771,7 @@ test "kitty color protocol set palette" { defer s.deinit(); // Set palette color 5 to magenta using kitty protocol - try s.nextSlice("\x1b]21;5=rgb:ff/00/ff\x1b\\"); + s.nextSlice("\x1b]21;5=rgb:ff/00/ff\x1b\\"); try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[5].r); try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[5].g); try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[5].b); @@ -776,10 +788,10 @@ test "kitty color protocol reset palette" { // Set and then reset palette color const original = t.colors.palette.original[7]; - try s.nextSlice("\x1b]21;7=rgb:aa/bb/cc\x1b\\"); + s.nextSlice("\x1b]21;7=rgb:aa/bb/cc\x1b\\"); try testing.expect(t.colors.palette.mask.isSet(7)); - try s.nextSlice("\x1b]21;7=\x1b\\"); + s.nextSlice("\x1b]21;7=\x1b\\"); try testing.expectEqual(original, t.colors.palette.current[7]); try testing.expect(!t.colors.palette.mask.isSet(7)); } @@ -792,7 +804,7 @@ test "kitty color protocol set foreground" { defer s.deinit(); // Set foreground using kitty protocol - try s.nextSlice("\x1b]21;foreground=rgb:12/34/56\x1b\\"); + s.nextSlice("\x1b]21;foreground=rgb:12/34/56\x1b\\"); const fg = t.colors.foreground.get().?; try testing.expectEqual(@as(u8, 0x12), fg.r); try testing.expectEqual(@as(u8, 0x34), fg.g); @@ -807,7 +819,7 @@ test "kitty color protocol set background" { defer s.deinit(); // Set background using kitty protocol - try s.nextSlice("\x1b]21;background=rgb:78/9a/bc\x1b\\"); + s.nextSlice("\x1b]21;background=rgb:78/9a/bc\x1b\\"); const bg = t.colors.background.get().?; try testing.expectEqual(@as(u8, 0x78), bg.r); try testing.expectEqual(@as(u8, 0x9a), bg.g); @@ -822,7 +834,7 @@ test "kitty color protocol set cursor" { defer s.deinit(); // Set cursor using kitty protocol - try s.nextSlice("\x1b]21;cursor=rgb:de/f0/12\x1b\\"); + s.nextSlice("\x1b]21;cursor=rgb:de/f0/12\x1b\\"); const cursor = t.colors.cursor.get().?; try testing.expectEqual(@as(u8, 0xde), cursor.r); try testing.expectEqual(@as(u8, 0xf0), cursor.g); @@ -837,10 +849,10 @@ test "kitty color protocol reset foreground" { defer s.deinit(); // Set and reset foreground - try s.nextSlice("\x1b]21;foreground=rgb:11/22/33\x1b\\"); + s.nextSlice("\x1b]21;foreground=rgb:11/22/33\x1b\\"); try testing.expect(t.colors.foreground.get() != null); - try s.nextSlice("\x1b]21;foreground=\x1b\\"); + s.nextSlice("\x1b]21;foreground=\x1b\\"); // After reset, should be unset try testing.expect(t.colors.foreground.get() == null); } @@ -856,17 +868,17 @@ test "palette dirty flag set on color change" { t.flags.dirty.palette = false; // Setting palette color should set dirty flag - try s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); + s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); try testing.expect(t.flags.dirty.palette); // Clear and test reset t.flags.dirty.palette = false; - try s.nextSlice("\x1b]104;0\x1b\\"); + s.nextSlice("\x1b]104;0\x1b\\"); try testing.expect(t.flags.dirty.palette); // Clear and test kitty protocol t.flags.dirty.palette = false; - try s.nextSlice("\x1b]21;1=rgb:00/ff/00\x1b\\"); + s.nextSlice("\x1b]21;1=rgb:00/ff/00\x1b\\"); try testing.expect(t.flags.dirty.palette); } @@ -877,8 +889,8 @@ test "semantic prompt fresh line" { var s: Stream = .initAlloc(testing.allocator, .init(&t)); defer s.deinit(); - try s.nextSlice("Hello"); - try s.nextSlice("\x1b]133;L\x07"); + s.nextSlice("Hello"); + s.nextSlice("\x1b]133;L\x07"); try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); } @@ -891,8 +903,8 @@ test "semantic prompt fresh line new prompt" { defer s.deinit(); // Write some text and then send OSC 133;A (fresh_line_new_prompt) - try s.nextSlice("Hello"); - try s.nextSlice("\x1b]133;A\x07"); + s.nextSlice("Hello"); + s.nextSlice("\x1b]133;A\x07"); // Should do a fresh line (carriage return + index) try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); @@ -902,8 +914,8 @@ test "semantic prompt fresh line new prompt" { try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); // Test with redraw option - try s.nextSlice("prompt$ "); - try s.nextSlice("\x1b]133;A;redraw=1\x07"); + s.nextSlice("prompt$ "); + s.nextSlice("\x1b]133;A;redraw=1\x07"); try testing.expect(t.flags.shell_redraws_prompt == .true); } @@ -915,12 +927,12 @@ test "semantic prompt end of input, then start output" { defer s.deinit(); // Write some text and then send OSC 133;A (fresh_line_new_prompt) - try s.nextSlice("Hello"); - try s.nextSlice("\x1b]133;A\x07"); - try s.nextSlice("prompt$ "); - try s.nextSlice("\x1b]133;B\x07"); + s.nextSlice("Hello"); + s.nextSlice("\x1b]133;A\x07"); + s.nextSlice("prompt$ "); + s.nextSlice("\x1b]133;B\x07"); try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); - try s.nextSlice("\x1b]133;C\x07"); + s.nextSlice("\x1b]133;C\x07"); try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); } @@ -932,10 +944,10 @@ test "semantic prompt prompt_start" { defer s.deinit(); // Write some text - try s.nextSlice("Hello"); + s.nextSlice("Hello"); // OSC 133;P marks the start of a prompt (without fresh line behavior) - try s.nextSlice("\x1b]133;P\x07"); + s.nextSlice("\x1b]133;P\x07"); try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); @@ -949,8 +961,8 @@ test "semantic prompt new_command" { defer s.deinit(); // Write some text - try s.nextSlice("Hello"); - try s.nextSlice("\x1b]133;N\x07"); + s.nextSlice("Hello"); + s.nextSlice("\x1b]133;N\x07"); // Should behave like fresh_line_new_prompt - cursor moves to column 0 // on next line since we had content @@ -967,7 +979,7 @@ test "semantic prompt new_command at column zero" { defer s.deinit(); // OSC 133;N when already at column 0 should stay on same line - try s.nextSlice("\x1b]133;N\x07"); + s.nextSlice("\x1b]133;N\x07"); try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); @@ -981,11 +993,11 @@ test "semantic prompt end_prompt_start_input_terminate_eol clears on linefeed" { defer s.deinit(); // Set input terminated by EOL - try s.nextSlice("\x1b]133;I\x07"); + s.nextSlice("\x1b]133;I\x07"); try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); // Linefeed should reset semantic content to output - try s.nextSlice("\n"); + s.nextSlice("\n"); try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); } @@ -1002,5 +1014,5 @@ test "stream: CSI W with intermediate but no params" { var s: Stream = .initAlloc(testing.allocator, .init(&t)); defer s.deinit(); - try s.nextSlice("\x1b[?W"); + s.nextSlice("\x1b[?W"); } diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 62a0f1d00..585c95403 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -1053,10 +1053,7 @@ pub const Viewer = struct { // correct but we'll get the active contents soon. var stream = t.vtStream(); defer stream.deinit(); - stream.nextSlice(content) catch |err| { - log.info("failed to process pane history for pane id={}: {}", .{ id, err }); - return err; - }; + stream.nextSlice(content); // Populate the active area to be empty since this is only history. // We'll fill it with blanks and move the cursor to the top-left. @@ -1097,10 +1094,7 @@ pub const Viewer = struct { var stream = t.vtStream(); defer stream.deinit(); - stream.nextSlice(content) catch |err| { - log.info("failed to process pane visible for pane id={}: {}", .{ id, err }); - return err; - }; + stream.nextSlice(content); } fn receivedOutput( @@ -1117,10 +1111,7 @@ pub const Viewer = struct { var stream = t.vtStream(); defer stream.deinit(); - stream.nextSlice(data) catch |err| { - log.info("failed to process output for pane id={}: {}", .{ id, err }); - return err; - }; + stream.nextSlice(data); } fn initLayout( diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index dcd0d8cf7..8d0f5e2c4 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -721,12 +721,10 @@ fn processOutputLocked(self: *Termio, buf: []const u8) void { log.err("error recording pty read in inspector err={}", .{err}); }; - self.terminal_stream.next(byte) catch |err| - log.err("error processing terminal data: {}", .{err}); + self.terminal_stream.next(byte); } } else { - self.terminal_stream.nextSlice(buf) catch |err| - log.err("error processing terminal data: {}", .{err}); + self.terminal_stream.nextSlice(buf); } // If our stream handling caused messages to be sent to the mailbox diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 8c1b5b8ab..fd17d299d 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -176,6 +176,16 @@ pub const StreamHandler = struct { self: *StreamHandler, comptime action: Stream.Action.Tag, value: Stream.Action.Value(action), + ) void { + self.vtFallible(action, value) catch |err| { + log.warn("error handling VT action action={} err={}", .{ action, err }); + }; + } + + inline fn vtFallible( + self: *StreamHandler, + comptime action: Stream.Action.Tag, + value: Stream.Action.Value(action), ) !void { // The branch hints here are based on real world data // which indicates that the most common actions are: diff --git a/test/fuzz-libghostty/src/fuzz_stream.zig b/test/fuzz-libghostty/src/fuzz_stream.zig index 17f63766f..ec47e90fe 100644 --- a/test/fuzz-libghostty/src/fuzz_stream.zig +++ b/test/fuzz-libghostty/src/fuzz_stream.zig @@ -43,11 +43,9 @@ pub export fn zig_fuzz_test( if (mode & 1 == 0) { // Slice path — exercises SIMD fast-path if enabled - stream.nextSlice(data) catch |err| - std.debug.panic("nextSlice: {}", .{err}); + stream.nextSlice(data); } else { // Scalar path — exercises byte-at-a-time UTF-8 decoding - for (data) |byte| _ = stream.next(byte) catch |err| - std.debug.panic("next: {}", .{err}); + for (data) |byte| stream.next(byte); } } From 302e68fd3d9891919a3b6f32f47ee7f954bef848 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 12:57:46 -0700 Subject: [PATCH 150/391] vt: expose ghostty_terminal_new/free --- include/ghostty/vt.h | 2 + include/ghostty/vt/terminal.h | 88 ++++++++++++++++++++++++++ src/lib_vt.zig | 2 + src/terminal/Terminal.zig | 2 +- src/terminal/c/main.zig | 5 ++ src/terminal/c/terminal.zig | 112 ++++++++++++++++++++++++++++++++++ 6 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 include/ghostty/vt/terminal.h create mode 100644 src/terminal/c/terminal.zig diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 4f8fef88e..e07ccefaf 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -28,6 +28,7 @@ * @section groups_sec API Reference * * The API is organized into the following groups: + * - @ref terminal "Terminal Lifecycle" - Create and destroy terminal instances * - @ref key "Key Encoding" - Encode key events into terminal sequences * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences @@ -74,6 +75,7 @@ extern "C" { #include #include +#include #include #include #include diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h new file mode 100644 index 000000000..f2e68fa96 --- /dev/null +++ b/include/ghostty/vt/terminal.h @@ -0,0 +1,88 @@ +/** + * @file terminal.h + * + * Terminal lifecycle management. + */ + +#ifndef GHOSTTY_VT_TERMINAL_H +#define GHOSTTY_VT_TERMINAL_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup terminal Terminal Lifecycle + * + * Minimal API for creating and destroying terminal instances. + * + * This currently only exposes lifecycle operations. Additional terminal + * APIs will be added over time. + * + * @{ + */ + +/** + * Opaque handle to a terminal instance. + * + * @ingroup terminal + */ +typedef struct GhosttyTerminal* GhosttyTerminal; + +/** + * Terminal initialization options. + * + * @ingroup terminal + */ +typedef struct { + /** Terminal width in cells. Must be greater than zero. */ + uint16_t cols; + + /** Terminal height in cells. Must be greater than zero. */ + uint16_t rows; + + /** Maximum number of lines to keep in scrollback history. */ + size_t max_scrollback; + + // TODO: Consider ABI compatibility implications of this struct. + // We may want to artificially pad it significantly to support + // future options. +} GhosttyTerminalOptions; + +/** + * Create a new terminal instance. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param terminal Pointer to store the created terminal handle + * @param options Terminal initialization options + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup terminal + */ +GhosttyResult ghostty_terminal_new(const GhosttyAllocator* allocator, + GhosttyTerminal* terminal, + GhosttyTerminalOptions options); + +/** + * Free a terminal instance. + * + * Releases all resources associated with the terminal. After this call, + * the terminal handle becomes invalid and must not be used. + * + * @param terminal The terminal handle to free (may be NULL) + * + * @ingroup terminal + */ +void ghostty_terminal_free(GhosttyTerminal terminal); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_TERMINAL_H */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 426660621..9af5d9d70 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -143,6 +143,8 @@ comptime { @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); + @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); + @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 706f235c7..ec08ec72c 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -271,7 +271,7 @@ pub fn vtHandler(self: *Terminal) ReadonlyHandler { } /// The general allocator we should use for this terminal. -fn gpa(self: *Terminal) Allocator { +pub fn gpa(self: *Terminal) Allocator { return self.screens.active.alloc; } diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index bc92597f5..e77769e24 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -4,6 +4,7 @@ pub const key_event = @import("key_event.zig"); pub const key_encode = @import("key_encode.zig"); pub const paste = @import("paste.zig"); pub const sgr = @import("sgr.zig"); +pub const terminal = @import("terminal.zig"); // The full C API, unexported. pub const osc_new = osc.new; @@ -52,6 +53,9 @@ pub const key_encoder_encode = key_encode.encode; pub const paste_is_safe = paste.is_safe; +pub const terminal_new = terminal.new; +pub const terminal_free = terminal.free; + test { _ = color; _ = osc; @@ -59,6 +63,7 @@ test { _ = key_encode; _ = paste; _ = sgr; + _ = terminal; // We want to make sure we run the tests for the C allocator interface. _ = @import("../../lib/allocator.zig"); diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig new file mode 100644 index 000000000..14d66ca0a --- /dev/null +++ b/src/terminal/c/terminal.zig @@ -0,0 +1,112 @@ +const std = @import("std"); +const testing = std.testing; +const lib_alloc = @import("../../lib/allocator.zig"); +const CAllocator = lib_alloc.Allocator; +const ZigTerminal = @import("../Terminal.zig"); +const size = @import("../size.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttyTerminal +pub const Terminal = ?*ZigTerminal; + +/// C: GhosttyTerminalOptions +pub const Options = extern struct { + cols: size.CellCountInt, + rows: size.CellCountInt, + max_scrollback: usize, +}; + +const NewError = error{ + InvalidValue, + OutOfMemory, +}; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Terminal, + opts: Options, +) callconv(.c) Result { + result.* = new_(alloc_, opts) catch |err| { + result.* = null; + return switch (err) { + error.InvalidValue => .invalid_value, + error.OutOfMemory => .out_of_memory, + }; + }; + + return .success; +} + +fn new_( + alloc_: ?*const CAllocator, + opts: Options, +) NewError!*ZigTerminal { + if (opts.cols == 0 or opts.rows == 0) return error.InvalidValue; + + const alloc = lib_alloc.default(alloc_); + const ptr = alloc.create(ZigTerminal) catch + return error.OutOfMemory; + errdefer alloc.destroy(ptr); + + ptr.* = try .init(alloc, .{ + .cols = opts.cols, + .rows = opts.rows, + .max_scrollback = opts.max_scrollback, + }); + + return ptr; +} + +pub fn free(terminal_: Terminal) callconv(.c) void { + const t = terminal_ orelse return; + + const alloc = t.gpa(); + t.deinit(alloc); + alloc.destroy(t); +} + +test "new/free" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + + try testing.expect(t != null); + free(t); +} + +test "new invalid value" { + var t: Terminal = null; + + try testing.expectEqual(Result.invalid_value, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 0, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + try testing.expect(t == null); + + try testing.expectEqual(Result.invalid_value, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 0, + .max_scrollback = 10_000, + }, + )); + try testing.expect(t == null); +} + +test "free null" { + free(null); +} From 18fdc15357a2f519d93987d09a2957b9369340cb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 19:35:41 -0700 Subject: [PATCH 151/391] vt: ghostty_terminal_vt_write --- include/ghostty/vt/terminal.h | 26 ++++++++++++++++++++++++++ src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 30 ++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index f2e68fa96..e59f4c4b8 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -79,6 +79,32 @@ GhosttyResult ghostty_terminal_new(const GhosttyAllocator* allocator, */ void ghostty_terminal_free(GhosttyTerminal terminal); +/** + * Write VT-encoded data to the terminal for processing. + * + * Feeds raw bytes through the terminal's VT stream parser, updating + * terminal state accordingly. Only read-only sequences are processed; + * sequences that require output (queries) are ignored. + * + * In the future, a callback-based API will be added to allow handling + * of output or side effect sequences. + * + * This never fails. Any erroneous input or errors in processing the + * input are logged internally but do not cause this function to fail + * because this input is assumed to be untrusted and from an external + * source; so the primary goal is to keep the terminal state consistent and + * not allow malformed input to corrupt or crash. + * + * @param terminal The terminal handle + * @param data Pointer to the data to write + * @param len Length of the data in bytes + * + * @ingroup terminal + */ +void ghostty_terminal_vt_write(GhosttyTerminal terminal, + const uint8_t* data, + size_t len); + /** @} */ #ifdef __cplusplus diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 9af5d9d70..516c9f882 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -145,6 +145,7 @@ comptime { @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); + @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index e77769e24..6d908ed6b 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -55,6 +55,7 @@ pub const paste_is_safe = paste.is_safe; pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; +pub const terminal_vt_write = terminal.vt_write; test { _ = color; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 14d66ca0a..4b64c7a80 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -57,6 +57,16 @@ fn new_( return ptr; } +pub fn vt_write( + terminal_: Terminal, + ptr: [*]const u8, + len: usize, +) callconv(.c) void { + const t = terminal_ orelse return; + var stream = t.vtStream(); + stream.nextSlice(ptr[0..len]); +} + pub fn free(terminal_: Terminal) callconv(.c) void { const t = terminal_ orelse return; @@ -110,3 +120,23 @@ test "new invalid value" { test "free null" { free(null); } + +test "vt_write" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + + const str = try t.?.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Hello", str); +} From 8b9afe35a706ea230473c81637c3f43f07d736b7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 19:45:22 -0700 Subject: [PATCH 152/391] vt: ghostty_terminal_scroll_viewport --- include/ghostty/vt/terminal.h | 59 ++++++++++++++++++++++++++++- src/lib_vt.zig | 1 + src/terminal/Terminal.zig | 22 ++++++++++- src/terminal/c/AGENTS.md | 10 +++++ src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 71 +++++++++++++++++++++++++++++++++++ 6 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 src/terminal/c/AGENTS.md diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index e59f4c4b8..70f98f8a5 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -53,6 +53,45 @@ typedef struct { // future options. } GhosttyTerminalOptions; +/** + * Scroll viewport behavior tag. + * + * @ingroup terminal + */ +typedef enum { + /** Scroll to the top of the scrollback. */ + GHOSTTY_SCROLL_VIEWPORT_TOP, + + /** Scroll to the bottom (active area). */ + GHOSTTY_SCROLL_VIEWPORT_BOTTOM, + + /** Scroll by a delta amount (up is negative). */ + GHOSTTY_SCROLL_VIEWPORT_DELTA, +} GhosttyTerminalScrollViewportTag; + +/** + * Scroll viewport value. + * + * @ingroup terminal + */ +typedef union { + /** Scroll delta (only used with GHOSTTY_SCROLL_VIEWPORT_DELTA). Up is negative. */ + intptr_t delta; + + /** Padding for ABI compatibility. Do not use. */ + uint64_t _padding[2]; +} GhosttyTerminalScrollViewportValue; + +/** + * Tagged union for scroll viewport behavior. + * + * @ingroup terminal + */ +typedef struct { + GhosttyTerminalScrollViewportTag tag; + GhosttyTerminalScrollViewportValue value; +} GhosttyTerminalScrollViewport; + /** * Create a new terminal instance. * @@ -102,8 +141,24 @@ void ghostty_terminal_free(GhosttyTerminal terminal); * @ingroup terminal */ void ghostty_terminal_vt_write(GhosttyTerminal terminal, - const uint8_t* data, - size_t len); + const uint8_t* data, + size_t len); + +/** + * Scroll the terminal viewport. + * + * Scrolls the terminal's viewport according to the given behavior. + * When using GHOSTTY_SCROLL_VIEWPORT_DELTA, set the delta field in + * the value union to specify the number of rows to scroll (negative + * for up, positive for down). For other behaviors, the value is ignored. + * + * @param terminal The terminal handle (may be NULL, in which case this is a no-op) + * @param behavior The scroll behavior as a tagged union + * + * @ingroup terminal + */ +void ghostty_terminal_scroll_viewport(GhosttyTerminal terminal, + GhosttyTerminalScrollViewport behavior); /** @} */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 516c9f882..a4998f1b8 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -146,6 +146,7 @@ comptime { @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); + @export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" }); // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index ec08ec72c..1ea915c67 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -5,6 +5,7 @@ const Terminal = @This(); const std = @import("std"); const build_options = @import("terminal_options"); +const lib = @import("../lib/main.zig"); const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; @@ -35,6 +36,8 @@ const Page = pagepkg.Page; const Cell = pagepkg.Cell; const Row = pagepkg.Row; +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; + const log = std.log.scoped(.terminal); /// Default tabstop interval @@ -1704,7 +1707,7 @@ pub fn scrollUp(self: *Terminal, count: usize) !void { } /// Options for scrolling the viewport of the terminal grid. -pub const ScrollViewport = union(enum) { +pub const ScrollViewport = union(Tag) { /// Scroll to the top of the scrollback top, @@ -1713,6 +1716,23 @@ pub const ScrollViewport = union(enum) { /// Scroll by some delta amount, up is negative. delta: isize, + + pub const Tag = lib.Enum(lib_target, &.{ + "top", + "bottom", + "delta", + }); + + const c_union = lib.TaggedUnion( + lib_target, + @This(), + // Padding: largest variant is isize (8 bytes on 64-bit). + // Use [2]u64 (16 bytes) for future expansion. + [2]u64, + ); + pub const C = c_union.C; + pub const CValue = c_union.CValue; + pub const cval = c_union.cval; }; /// Scroll the viewport of the terminal grid. diff --git a/src/terminal/c/AGENTS.md b/src/terminal/c/AGENTS.md new file mode 100644 index 000000000..fa922c6ba --- /dev/null +++ b/src/terminal/c/AGENTS.md @@ -0,0 +1,10 @@ +# libghostty-vt C API + +- C API must be designed with ABI compatibility in mind +- Zig tagged unions must be converted to C ABI compatible unions + via `lib.TaggedUnion`. +- Any functions must be updated all the way through from here to + `src/terminal/c/main.zig` to `src/lib_vt.zig` and the headers + in `include/ghostty/vt.h`. +- In `include/ghostty/vt.h`, always sort the header contents by: + (1) macros, (2) forward declarations, (3) types, (4) functions diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 6d908ed6b..be8da379e 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -56,6 +56,7 @@ pub const paste_is_safe = paste.is_safe; pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; pub const terminal_vt_write = terminal.vt_write; +pub const terminal_scroll_viewport = terminal.scroll_viewport; test { _ = color; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 4b64c7a80..cf491597b 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -67,6 +67,21 @@ pub fn vt_write( stream.nextSlice(ptr[0..len]); } +/// C: GhosttyTerminalScrollViewport +pub const ScrollViewport = ZigTerminal.ScrollViewport.C; + +pub fn scroll_viewport( + terminal_: Terminal, + behavior: ScrollViewport, +) callconv(.c) void { + const t = terminal_ orelse return; + t.scrollViewport(switch (behavior.tag) { + .top => .top, + .bottom => .bottom, + .delta => .{ .delta = behavior.value.delta }, + }); +} + pub fn free(terminal_: Terminal) callconv(.c) void { const t = terminal_ orelse return; @@ -121,6 +136,62 @@ test "free null" { free(null); } +test "scroll_viewport" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 5, + .rows = 2, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + const zt = t.?; + + // Write "hello" on the first line + vt_write(t, "hello", 5); + + // Push "hello" into scrollback with 3 newlines (index = ESC D) + vt_write(t, "\x1bD\x1bD\x1bD", 6); + { + // Viewport should be empty now since hello scrolled off + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Scroll to top: "hello" should be visible again + scroll_viewport(t, .{ .tag = .top, .value = undefined }); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello", str); + } + + // Scroll to bottom: viewport should be empty again + scroll_viewport(t, .{ .tag = .bottom, .value = undefined }); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Scroll up by delta to bring "hello" back into view + scroll_viewport(t, .{ .tag = .delta, .value = .{ .delta = -3 } }); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello", str); + } +} + +test "scroll_viewport null" { + scroll_viewport(null, .{ .tag = .top, .value = undefined }); +} + test "vt_write" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From fe6e7fbc6b54c835f9a5229f0b19ee9f96ec5a92 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 19:54:19 -0700 Subject: [PATCH 153/391] vt: ghostty_terminal_resize --- include/ghostty/vt/terminal.h | 18 +++++++++++++ src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 50 +++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 70f98f8a5..233389498 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -118,6 +118,24 @@ GhosttyResult ghostty_terminal_new(const GhosttyAllocator* allocator, */ void ghostty_terminal_free(GhosttyTerminal terminal); +/** + * Resize the terminal to the given dimensions. + * + * Changes the number of columns and rows in the terminal. The primary + * screen will reflow content if wraparound mode is enabled; the alternate + * screen does not reflow. If the dimensions are unchanged, this is a no-op. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param cols New width in cells (must be greater than zero) + * @param rows New height in cells (must be greater than zero) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup terminal + */ +GhosttyResult ghostty_terminal_resize(GhosttyTerminal terminal, + uint16_t cols, + uint16_t rows); + /** * Write VT-encoded data to the terminal for processing. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index a4998f1b8..a76ed8446 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -145,6 +145,7 @@ comptime { @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); + @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); @export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index be8da379e..f17b9065e 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -55,6 +55,7 @@ pub const paste_is_safe = paste.is_safe; pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; +pub const terminal_resize = terminal.resize; pub const terminal_vt_write = terminal.vt_write; pub const terminal_scroll_viewport = terminal.scroll_viewport; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index cf491597b..ef64e7c0e 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -82,6 +82,17 @@ pub fn scroll_viewport( }); } +pub fn resize( + terminal_: Terminal, + cols: size.CellCountInt, + rows: size.CellCountInt, +) callconv(.c) Result { + const t = terminal_ orelse return .invalid_value; + if (cols == 0 or rows == 0) return .invalid_value; + t.resize(t.gpa(), cols, rows) catch return .out_of_memory; + return .success; +} + pub fn free(terminal_: Terminal) callconv(.c) void { const t = terminal_ orelse return; @@ -192,6 +203,45 @@ test "scroll_viewport null" { scroll_viewport(null, .{ .tag = .top, .value = undefined }); } +test "resize" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + try testing.expectEqual(Result.success, resize(t, 40, 12)); + try testing.expectEqual(40, t.?.cols); + try testing.expectEqual(12, t.?.rows); +} + +test "resize null" { + try testing.expectEqual(Result.invalid_value, resize(null, 80, 24)); +} + +test "resize invalid value" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + try testing.expectEqual(Result.invalid_value, resize(t, 0, 24)); + try testing.expectEqual(Result.invalid_value, resize(t, 80, 0)); +} + test "vt_write" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From aa3e6e23a227cfe4ba0026d844b54e7a89ea880b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 19:55:42 -0700 Subject: [PATCH 154/391] vt: ghostty_terminal_reset --- include/ghostty/vt/terminal.h | 13 +++++++++++++ src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 30 ++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 233389498..6ecb6e62c 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -118,6 +118,19 @@ GhosttyResult ghostty_terminal_new(const GhosttyAllocator* allocator, */ void ghostty_terminal_free(GhosttyTerminal terminal); +/** + * Perform a full reset of the terminal (RIS). + * + * Resets all terminal state back to its initial configuration, including + * modes, scrollback, scrolling region, and screen contents. The terminal + * dimensions are preserved. + * + * @param terminal The terminal handle (may be NULL, in which case this is a no-op) + * + * @ingroup terminal + */ +void ghostty_terminal_reset(GhosttyTerminal terminal); + /** * Resize the terminal to the given dimensions. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index a76ed8446..1eddbd886 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -145,6 +145,7 @@ comptime { @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); + @export(&c.terminal_reset, .{ .name = "ghostty_terminal_reset" }); @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); @export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index f17b9065e..31e1b40eb 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -55,6 +55,7 @@ pub const paste_is_safe = paste.is_safe; pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; +pub const terminal_reset = terminal.reset; pub const terminal_resize = terminal.resize; pub const terminal_vt_write = terminal.vt_write; pub const terminal_scroll_viewport = terminal.scroll_viewport; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index ef64e7c0e..0af791f91 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -93,6 +93,11 @@ pub fn resize( return .success; } +pub fn reset(terminal_: Terminal) callconv(.c) void { + const t = terminal_ orelse return; + t.fullReset(); +} + pub fn free(terminal_: Terminal) callconv(.c) void { const t = terminal_ orelse return; @@ -203,6 +208,31 @@ test "scroll_viewport null" { scroll_viewport(null, .{ .tag = .top, .value = undefined }); } +test "reset" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + reset(t); + + const str = try t.?.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); +} + +test "reset null" { + reset(null); +} + test "resize" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 34acdfcc4eca388d3d4fa1a5ce03525384db8e3e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 19:59:06 -0700 Subject: [PATCH 155/391] vt: update terminal.h docs --- include/ghostty/vt.h | 2 +- include/ghostty/vt/terminal.h | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index e07ccefaf..8f7323c31 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -28,7 +28,7 @@ * @section groups_sec API Reference * * The API is organized into the following groups: - * - @ref terminal "Terminal Lifecycle" - Create and destroy terminal instances + * - @ref terminal "Terminal" - Complete terminal emulator state and rendering * - @ref key "Key Encoding" - Encode key events into terminal sequences * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 6ecb6e62c..6dc817392 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1,7 +1,7 @@ /** * @file terminal.h * - * Terminal lifecycle management. + * Complete terminal emulator state and rendering. */ #ifndef GHOSTTY_VT_TERMINAL_H @@ -16,12 +16,12 @@ extern "C" { #endif -/** @defgroup terminal Terminal Lifecycle +/** @defgroup terminal Terminal * - * Minimal API for creating and destroying terminal instances. + * Complete terminal emulator state and rendering. * - * This currently only exposes lifecycle operations. Additional terminal - * APIs will be added over time. + * A terminal instance manages the full emulator state including the screen, + * scrollback, cursor, styles, modes, and VT stream processing. * * @{ */ From 8e6bf829a746be199bd30d4670fe855035562433 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 20:06:32 -0700 Subject: [PATCH 156/391] terminal/osc: don't export context/semantic prompts to libvt yet --- src/terminal/osc/parsers/context_signal.zig | 2 ++ src/terminal/osc/parsers/semantic_prompt.zig | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/terminal/osc/parsers/context_signal.zig b/src/terminal/osc/parsers/context_signal.zig index ff82af392..c36c76f21 100644 --- a/src/terminal/osc/parsers/context_signal.zig +++ b/src/terminal/osc/parsers/context_signal.zig @@ -16,6 +16,8 @@ const max_context_id_len = 64; /// A single OSC 3008 context signal command. pub const Command = struct { + pub const C = void; + action: Action, /// The context identifier. Must be 1-64 characters in the 32..126 byte range. id: []const u8, diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig index d3a117515..c60ce4cb5 100644 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -14,6 +14,8 @@ const log = std.log.scoped(.osc_semantic_prompt); /// all except one do and the spec does also say to ignore unknown /// options. So, I think this is a fair interpretation. pub const Command = struct { + pub const C = void; + action: Action, options_unvalidated: []const u8, From 6368b00604e4543088eda552a8aa3f6776500332 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:13:09 +0000 Subject: [PATCH 157/391] Update VOUCHED list (#11488) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/11485#discussioncomment-16130186) from @mitchellh. Vouch: @jesusvazquez Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 83de38e55..e84780f4c 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -83,6 +83,7 @@ icodesign jacobsandlund jake-stewart jcollie +jesusvazquez jguthmiller jmcgover johnslavik From 1844a5f7bafbade1305e95d515eedcb010aae104 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 15:18:15 +0000 Subject: [PATCH 158/391] Update VOUCHED list (#11492) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/11491#issuecomment-4060704311) from @mitchellh. Vouch: @devsunb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index e84780f4c..767593f2c 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -55,6 +55,7 @@ damyanbogoev danulqua dariogriffo dervedro +devsunb diaaeddin dmehala doprz From b5fb7ecaaaa2d788093809614d88b6294baaf672 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 14 Mar 2026 13:48:03 -0700 Subject: [PATCH 159/391] vt: wip formatter api --- include/ghostty/vt.h | 1 + include/ghostty/vt/formatter.h | 156 +++++++++++++++ include/ghostty/vt/result.h | 2 + src/lib_vt.zig | 3 + src/terminal/c/formatter.zig | 344 +++++++++++++++++++++++++++++++++ src/terminal/c/main.zig | 6 + src/terminal/c/result.zig | 1 + src/terminal/formatter.zig | 82 ++++---- 8 files changed, 556 insertions(+), 39 deletions(-) create mode 100644 include/ghostty/vt/formatter.h create mode 100644 src/terminal/c/formatter.zig diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 8f7323c31..378c03453 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -75,6 +75,7 @@ extern "C" { #include #include +#include #include #include #include diff --git a/include/ghostty/vt/formatter.h b/include/ghostty/vt/formatter.h new file mode 100644 index 000000000..24f5212ff --- /dev/null +++ b/include/ghostty/vt/formatter.h @@ -0,0 +1,156 @@ +/** + * @file formatter.h + * + * Format terminal content as plain text, VT sequences, or HTML. + */ + +#ifndef GHOSTTY_VT_FORMATTER_H +#define GHOSTTY_VT_FORMATTER_H + +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup formatter Formatter + * + * Format terminal content as plain text, VT sequences, or HTML. + * + * A formatter captures a reference to a terminal and formatting options. + * It can be used repeatedly to produce output that reflects the current + * terminal state at the time of each format call. + * + * The terminal must outlive the formatter. + * + * @{ + */ + +/** + * Output format. + * + * @ingroup formatter + */ +typedef enum { + /** Plain text (no escape sequences). */ + GHOSTTY_FORMATTER_FORMAT_PLAIN, + + /** VT sequences preserving colors, styles, URLs, etc. */ + GHOSTTY_FORMATTER_FORMAT_VT, + + /** HTML with inline styles. */ + GHOSTTY_FORMATTER_FORMAT_HTML, +} GhosttyFormatterFormat; + +/** + * Extra terminal state to include in styled output. + * + * @ingroup formatter + */ +typedef enum { + /** Emit no extra state. */ + GHOSTTY_FORMATTER_EXTRA_NONE, + + /** Emit style-relevant state (palette, cursor style, hyperlinks). */ + GHOSTTY_FORMATTER_EXTRA_STYLES, + + /** Emit all state to reconstruct terminal as closely as possible. */ + GHOSTTY_FORMATTER_EXTRA_ALL, +} GhosttyFormatterExtra; + +/** + * Opaque handle to a formatter instance. + * + * @ingroup formatter + */ +typedef struct GhosttyFormatter* GhosttyFormatter; + +/** + * Options for creating a terminal formatter. + * + * @ingroup formatter + */ +typedef struct { + /** Output format to emit. */ + GhosttyFormatterFormat emit; + + /** Whether to unwrap soft-wrapped lines. */ + bool unwrap; + + /** Whether to trim trailing whitespace on non-blank lines. */ + bool trim; + + /** Extra terminal state to include in styled output. */ + GhosttyFormatterExtra extra; +} GhosttyFormatterTerminalOptions; + +/** + * Create a formatter for a terminal's active screen. + * + * The terminal must outlive the formatter. The formatter stores a borrowed + * reference to the terminal and reads its current state on each format call. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param formatter Pointer to store the created formatter handle + * @param terminal The terminal to format (must not be NULL) + * @param options Formatting options + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup formatter + */ +GhosttyResult ghostty_formatter_terminal_new( + const GhosttyAllocator* allocator, + GhosttyFormatter* formatter, + GhosttyTerminal terminal, + GhosttyFormatterTerminalOptions options); + +/** + * Run the formatter and produce output into the caller-provided buffer. + * + * Each call formats the current terminal state. Pass NULL for buf to + * query the required buffer size without writing any output; in that case + * out_written receives the required size and the return value is + * GHOSTTY_OUT_OF_SPACE. + * + * If the buffer is too small, returns GHOSTTY_OUT_OF_SPACE and sets + * out_written to the required size. The caller can then retry with a + * larger buffer. + * + * @param formatter The formatter handle (must not be NULL) + * @param buf Pointer to the output buffer, or NULL to query size + * @param buf_len Length of the output buffer in bytes + * @param out_written Pointer to receive the number of bytes written, + * or the required size on failure + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup formatter + */ +GhosttyResult ghostty_formatter_format(GhosttyFormatter formatter, + uint8_t* buf, + size_t buf_len, + size_t* out_written); + +/** + * Free a formatter instance. + * + * Releases all resources associated with the formatter. After this call, + * the formatter handle becomes invalid. + * + * @param formatter The formatter handle to free (may be NULL) + * + * @ingroup formatter + */ +void ghostty_formatter_free(GhosttyFormatter formatter); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_FORMATTER_H */ diff --git a/include/ghostty/vt/result.h b/include/ghostty/vt/result.h index 65938ee76..6a47d35cc 100644 --- a/include/ghostty/vt/result.h +++ b/include/ghostty/vt/result.h @@ -17,6 +17,8 @@ typedef enum { GHOSTTY_OUT_OF_MEMORY = -1, /** Operation failed due to invalid value */ GHOSTTY_INVALID_VALUE = -2, + /** Operation failed because the provided buffer was too small */ + GHOSTTY_OUT_OF_SPACE = -3, } GhosttyResult; #endif /* GHOSTTY_VT_RESULT_H */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 1eddbd886..3e1aa9d8d 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -143,6 +143,9 @@ comptime { @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); + @export(&c.formatter_terminal_new, .{ .name = "ghostty_formatter_terminal_new" }); + @export(&c.formatter_format, .{ .name = "ghostty_formatter_format" }); + @export(&c.formatter_free, .{ .name = "ghostty_formatter_free" }); @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); @export(&c.terminal_reset, .{ .name = "ghostty_terminal_reset" }); diff --git a/src/terminal/c/formatter.zig b/src/terminal/c/formatter.zig new file mode 100644 index 000000000..8b66f781a --- /dev/null +++ b/src/terminal/c/formatter.zig @@ -0,0 +1,344 @@ +const std = @import("std"); +const testing = std.testing; +const lib_alloc = @import("../../lib/allocator.zig"); +const CAllocator = lib_alloc.Allocator; +const terminal_c = @import("terminal.zig"); +const ZigTerminal = @import("../Terminal.zig"); +const formatterpkg = @import("../formatter.zig"); +const Result = @import("result.zig").Result; + +/// Wrapper around formatter that tracks the allocator for C API usage. +const FormatterWrapper = struct { + kind: Kind, + alloc: std.mem.Allocator, + + const Kind = union(enum) { + terminal: formatterpkg.TerminalFormatter, + }; +}; + +/// C: GhosttyFormatter +pub const Formatter = ?*FormatterWrapper; + +/// C: GhosttyFormatterFormat +pub const Format = formatterpkg.Format; + +/// C: GhosttyFormatterExtra +pub const Extra = enum(c_int) { + none = 0, + styles = 1, + all = 2, +}; + +/// C: GhosttyFormatterTerminalOptions +pub const TerminalOptions = extern struct { + emit: Format, + unwrap: bool, + trim: bool, + extra: Extra, +}; + +pub fn terminal_new( + alloc_: ?*const CAllocator, + result: *Formatter, + terminal_: terminal_c.Terminal, + opts: TerminalOptions, +) callconv(.c) Result { + result.* = terminal_new_( + alloc_, + terminal_, + opts, + ) catch |err| { + result.* = null; + return switch (err) { + error.InvalidValue => .invalid_value, + error.OutOfMemory => .out_of_memory, + }; + }; + + return .success; +} + +fn terminal_new_( + alloc_: ?*const CAllocator, + terminal_: terminal_c.Terminal, + opts: TerminalOptions, +) error{ + InvalidValue, + OutOfMemory, +}!*FormatterWrapper { + const t = terminal_ orelse return error.InvalidValue; + + const alloc = lib_alloc.default(alloc_); + const ptr = alloc.create(FormatterWrapper) catch + return error.OutOfMemory; + errdefer alloc.destroy(ptr); + + const extra: formatterpkg.TerminalFormatter.Extra = switch (opts.extra) { + .none => .none, + .styles => .styles, + .all => .all, + }; + + var formatter: formatterpkg.TerminalFormatter = .init(t, .{ + .emit = opts.emit, + .unwrap = opts.unwrap, + .trim = opts.trim, + }); + formatter.extra = extra; + + ptr.* = .{ + .kind = .{ .terminal = formatter }, + .alloc = alloc, + }; + + return ptr; +} + +pub fn format( + formatter_: Formatter, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(.c) Result { + const wrapper = formatter_ orelse return .invalid_value; + + var writer: std.Io.Writer = .fixed(if (out_) |out| + out[0..out_len] + else + &.{}); + + switch (wrapper.kind) { + .terminal => |*t| t.format(&writer) catch |err| switch (err) { + error.WriteFailed => { + // On write failed we always report how much + // space we actually needed. + var discarding: std.Io.Writer.Discarding = .init(&.{}); + t.format(&discarding.writer) catch unreachable; + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }, + } + + out_written.* = writer.end; + return .success; +} + +pub fn free(formatter_: Formatter) callconv(.c) void { + const wrapper = formatter_ orelse return; + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +test "terminal_new/free" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib_alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .none }, + )); + try testing.expect(f != null); + free(f); +} + +test "terminal_new invalid_value on null terminal" { + var f: Formatter = null; + try testing.expectEqual(Result.invalid_value, terminal_new( + &lib_alloc.test_allocator, + &f, + null, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .none }, + )); + try testing.expect(f == null); +} + +test "free null" { + free(null); +} + +test "format plain" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib_alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .none }, + )); + defer free(f); + + var buf: [1024]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written)); + try testing.expectEqualStrings("Hello", buf[0..written]); +} + +test "format reflects terminal changes" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib_alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .none }, + )); + defer free(f); + + var buf: [1024]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written)); + try testing.expectEqualStrings("Hello", buf[0..written]); + + // Write more data and re-format + terminal_c.vt_write(t, "\r\nWorld", 7); + + try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written)); + try testing.expectEqualStrings("Hello\nWorld", buf[0..written]); +} + +test "format null returns required size" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib_alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .none }, + )); + defer free(f); + + // Pass null buffer to query required size + var required: usize = 0; + try testing.expectEqual(Result.out_of_space, format(f, null, 0, &required)); + try testing.expect(required > 0); + + // Now allocate and format + var buf: [1024]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written)); + try testing.expectEqual(required, written); +} + +test "format buffer too small" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib_alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .none }, + )); + defer free(f); + + // Buffer too small + var buf: [2]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.out_of_space, format(f, &buf, buf.len, &written)); + // written contains the required size + try testing.expectEqual(@as(usize, 5), written); +} + +test "format null formatter" { + var written: usize = 0; + try testing.expectEqual(Result.invalid_value, format(null, null, 0, &written)); +} + +test "format vt" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Test", 4); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib_alloc.test_allocator, + &f, + t, + .{ .emit = .vt, .unwrap = false, .trim = true, .extra = .styles }, + )); + defer free(f); + + var buf: [65536]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written)); + try testing.expect(written > 0); + try testing.expect(std.mem.indexOf(u8, buf[0..written], "Test") != null); +} + +test "format html" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Html", 4); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib_alloc.test_allocator, + &f, + t, + .{ .emit = .html, .unwrap = false, .trim = true, .extra = .none }, + )); + defer free(f); + + var buf: [65536]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written)); + try testing.expect(written > 0); + try testing.expect(std.mem.indexOf(u8, buf[0..written], "Html") != null); +} diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 31e1b40eb..d2b477f95 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -1,4 +1,5 @@ pub const color = @import("color.zig"); +pub const formatter = @import("formatter.zig"); pub const osc = @import("osc.zig"); pub const key_event = @import("key_event.zig"); pub const key_encode = @import("key_encode.zig"); @@ -17,6 +18,10 @@ pub const osc_command_data = osc.commandData; pub const color_rgb_get = color.rgb_get; +pub const formatter_terminal_new = formatter.terminal_new; +pub const formatter_format = formatter.format; +pub const formatter_free = formatter.free; + pub const sgr_new = sgr.new; pub const sgr_free = sgr.free; pub const sgr_reset = sgr.reset; @@ -62,6 +67,7 @@ pub const terminal_scroll_viewport = terminal.scroll_viewport; test { _ = color; + _ = formatter; _ = osc; _ = key_event; _ = key_encode; diff --git a/src/terminal/c/result.zig b/src/terminal/c/result.zig index e9b5fc5e6..b76326e46 100644 --- a/src/terminal/c/result.zig +++ b/src/terminal/c/result.zig @@ -3,4 +3,5 @@ pub const Result = enum(c_int) { success = 0, out_of_memory = -1, invalid_value = -2, + out_of_space = -3, }; diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index f3b503d29..a68f61934 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -1,5 +1,8 @@ const std = @import("std"); +const build_options = @import("terminal_options"); const assert = @import("../quirks.zig").inlineAssert; +const lib = @import("../lib/main.zig"); +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; const Allocator = std.mem.Allocator; const color = @import("color.zig"); const size = @import("size.zig"); @@ -19,46 +22,47 @@ const Selection = @import("Selection.zig"); const Style = @import("style.zig").Style; /// Formats available. -pub const Format = enum { - /// Plain text. - plain, +pub const Format = lib.Enum(lib_target, &.{ + // Plain text. + "plain", - /// Include VT sequences to preserve colors, styles, URLs, etc. - /// This is predominantly SGR sequences but may contain others as needed. - /// - /// Note that for reference colors, like palette indices, this will - /// vary based on the formatter and you should see the docs. For example, - /// PageFormatter with VT will emit SGR sequences with palette indices, - /// not the color itself. - /// - /// For VT, newlines will be emitted as `\r\n` so that the cursor properly - /// moves back to the beginning prior emitting follow-up lines. - vt, + // Include VT sequences to preserve colors, styles, URLs, etc. + // This is predominantly SGR sequences but may contain others as needed. + // + // Note that for reference colors, like palette indices, this will + // vary based on the formatter and you should see the docs. For example, + // PageFormatter with VT will emit SGR sequences with palette indices, + // not the color itself. + // + // For VT, newlines will be emitted as `\r\n` so that the cursor properly + // moves back to the beginning prior emitting follow-up lines. + "vt", - /// HTML output. - /// - /// This will emit inline styles for as much styling as possible, - /// in the interest of simplicity and ease of editing. This isn't meant - /// to build the most beautiful or efficient HTML, but rather to be - /// stylistically correct. - /// - /// For colors, RGB values are emitted as inline CSS (#RRGGBB) while palette - /// indices use CSS variables (var(--vt-palette-N)). The palette colors are - /// emitted by TerminalFormatter.Extra.palette as a