From d7087627d728dda8a3baa45ad104977533b0d0d9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 30 Nov 2025 07:15:23 -0800 Subject: [PATCH 01/49] terminal: renderstate needs to reset highlights on dirty This fixes memory corruption where future matches on a fully dirty row would write highlights out of bounds. It was easy to reproduce in debug by searching for `$` in `ghostty +boo` --- src/terminal/render.zig | 61 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 83b4a7145..b6430ea34 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -385,6 +385,7 @@ pub const RenderState = struct { const row_rows = row_data.items(.raw); const row_cells = row_data.items(.cells); const row_sels = row_data.items(.selection); + const row_highlights = row_data.items(.highlights); const row_dirties = row_data.items(.dirty); // Track the last page that we know was dirty. This lets us @@ -468,6 +469,7 @@ pub const RenderState = struct { _ = arena.reset(.retain_capacity); row_cells[y].clearRetainingCapacity(); row_sels[y] = null; + row_highlights[y] = .empty; } row_dirties[y] = true; @@ -1314,3 +1316,62 @@ test "string" { const expected = "AB\x00\x00\x00\n\x00\x00\x00\x00\x00\n"; try testing.expectEqualStrings(expected, result); } + +test "dirty row resets highlights" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("ABC"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Reset dirty state + state.dirty = .false; + { + const row_data = state.row_data.slice(); + const dirty = row_data.items(.dirty); + @memset(dirty, false); + } + + // Manually add a highlight to row 0 + { + const row_data = state.row_data.slice(); + const row_arenas = row_data.items(.arena); + const row_highlights = row_data.items(.highlights); + var arena = row_arenas[0].promote(alloc); + defer row_arenas[0] = arena.state; + try row_highlights[0].append(arena.allocator(), .{ + .tag = 1, + .range = .{ 0, 2 }, + }); + } + + // Verify we have a highlight + { + const row_data = state.row_data.slice(); + const row_highlights = row_data.items(.highlights); + try testing.expectEqual(1, row_highlights[0].items.len); + } + + // Write to row 0 to make it dirty + try s.nextSlice("\x1b[H"); // Move to home + try s.nextSlice("X"); + try state.update(alloc, &t); + + // Verify the highlight was reset on the dirty row + { + const row_data = state.row_data.slice(); + const row_highlights = row_data.items(.highlights); + try testing.expectEqual(0, row_highlights[0].items.len); + } +} From a58e33c06bfdefec663a879b5f056f11a3d41a24 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Sun, 30 Nov 2025 08:25:04 -0600 Subject: [PATCH 02/49] PageList: preserve size.cols in adjustCapacity after column shrink When columns shrink during resize-without-reflow, page.size.cols is updated but page.capacity.cols retains the old larger value. When adjustCapacity later runs (e.g., to expand style/grapheme storage), it was creating a new page using page.capacity which has the stale column count, causing size.cols to revert to the old value. This caused a crash in render.zig where an assertion checks that page.size.cols matches PageList.cols. Fix by explicitly copying page.size.cols to the new page after creation, matching how size.rows is already handled. Amp-Thread-ID: https://ampcode.com/threads/T-976bc49a-7bfd-40bd-bbbb-38f66fc925ff Co-authored-by: Amp --- src/terminal/PageList.zig | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index e7cb56da7..29f414e03 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2608,7 +2608,9 @@ pub fn adjustCapacity( errdefer self.destroyNode(new_node); const new_page: *Page = &new_node.data; assert(new_page.capacity.rows >= page.capacity.rows); + assert(new_page.capacity.cols >= page.capacity.cols); new_page.size.rows = page.size.rows; + new_page.size.cols = page.size.cols; try new_page.cloneFrom(page, 0, page.size.rows); // Fix up all our tracked pins to point to the new page. @@ -6257,6 +6259,39 @@ test "PageList adjustCapacity to increase hyperlinks" { } } +test "PageList adjustCapacity after col shrink" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 2, 0); + defer s.deinit(); + + // Shrink columns - this updates size.cols but not capacity.cols + try s.resize(.{ .cols = 5, .reflow = false }); + try testing.expectEqual(5, s.cols); + + { + const page = &s.pages.first.?.data; + // capacity.cols is still 10, but size.cols should be 5 + try testing.expectEqual(5, page.size.cols); + try testing.expect(page.capacity.cols >= 10); + } + + // Now adjust capacity (e.g., to increase styles) + // This should preserve the current size.cols, not revert to capacity.cols + _ = try s.adjustCapacity( + s.pages.first.?, + .{ .styles = std_capacity.styles * 2 }, + ); + + { + const page = &s.pages.first.?.data; + // After adjustCapacity, size.cols should still be 5, not 10 + try testing.expectEqual(5, page.size.cols); + try testing.expectEqual(5, s.cols); + } +} + test "PageList pageIterator single page" { const testing = std.testing; const alloc = testing.allocator; From 832883b600dcbd9435a48174e638a19bdb205626 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 14:45:20 -0800 Subject: [PATCH 03/49] apprt/gtk: move surface event controllers, block events from revealers --- src/apprt/gtk/ui/1.2/surface.blp | 61 +++++++++++++++++--------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 0596bf15d..8ff4a2e78 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -41,6 +41,34 @@ Overlay terminal_page { halign: start; has-arrow: false; } + + EventControllerFocus { + enter => $focus_enter(); + leave => $focus_leave(); + } + + EventControllerKey { + key-pressed => $key_pressed(); + key-released => $key_released(); + } + + EventControllerScroll { + scroll => $scroll(); + scroll-begin => $scroll_begin(); + scroll-end => $scroll_end(); + flags: both_axes; + } + + EventControllerMotion { + motion => $mouse_motion(); + leave => $mouse_leave(); + } + + GestureClick { + pressed => $mouse_down(); + released => $mouse_up(); + button: 0; + } }; [overlay] @@ -64,6 +92,10 @@ Overlay terminal_page { reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as ; transition-type: crossfade; transition-duration: 500; + // Revealers take up the full size, we need this to not capture events. + can-focus: false; + can-target: false; + focusable: false; Box bell_overlay { styles [ @@ -129,35 +161,6 @@ Overlay terminal_page { } } - // Event controllers for interactivity - EventControllerFocus { - enter => $focus_enter(); - leave => $focus_leave(); - } - - EventControllerKey { - key-pressed => $key_pressed(); - key-released => $key_released(); - } - - EventControllerMotion { - motion => $mouse_motion(); - leave => $mouse_leave(); - } - - EventControllerScroll { - scroll => $scroll(); - scroll-begin => $scroll_begin(); - scroll-end => $scroll_end(); - flags: both_axes; - } - - GestureClick { - pressed => $mouse_down(); - released => $mouse_up(); - button: 0; - } - DropTarget drop_target { drop => $drop(); actions: copy; From 548d1f0300ca65b0f99c22d385b26cc6d667485a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 14:11:42 -0800 Subject: [PATCH 04/49] apprt/gtk: search overlay UI --- src/apprt/gtk/build/gresource.zig | 1 + src/apprt/gtk/class/search_overlay.zig | 141 ++++++++++++++++++++++++ src/apprt/gtk/class/surface.zig | 2 + src/apprt/gtk/css/style.css | 12 ++ src/apprt/gtk/ui/1.2/search-overlay.blp | 72 ++++++++++++ src/apprt/gtk/ui/1.2/surface.blp | 12 ++ 6 files changed, 240 insertions(+) create mode 100644 src/apprt/gtk/class/search_overlay.zig create mode 100644 src/apprt/gtk/ui/1.2/search-overlay.blp diff --git a/src/apprt/gtk/build/gresource.zig b/src/apprt/gtk/build/gresource.zig index cc701d7c2..c77579aab 100644 --- a/src/apprt/gtk/build/gresource.zig +++ b/src/apprt/gtk/build/gresource.zig @@ -43,6 +43,7 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 5, .name = "inspector-widget" }, .{ .major = 1, .minor = 5, .name = "inspector-window" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" }, + .{ .major = 1, .minor = 2, .name = "search-overlay" }, .{ .major = 1, .minor = 5, .name = "split-tree" }, .{ .major = 1, .minor = 5, .name = "split-tree-split" }, .{ .major = 1, .minor = 2, .name = "surface" }, diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig new file mode 100644 index 000000000..1e49750fa --- /dev/null +++ b/src/apprt/gtk/class/search_overlay.zig @@ -0,0 +1,141 @@ +const std = @import("std"); +const adw = @import("adw"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_search_overlay); + +/// The overlay that shows the current size while a surface is resizing. +/// This can be used generically to show pretty much anything with a +/// disappearing overlay, but we have no other use at this point so it +/// is named specifically for what it does. +/// +/// General usage: +/// +/// 1. Add it to an overlay +/// 2. Set the label with `setLabel` +/// 3. Schedule to show it with `schedule` +/// +/// Set any properties to change the behavior. +pub const SearchOverlay = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySearchOverlay", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const duration = struct { + pub const name = "duration"; + const impl = gobject.ext.defineProperty( + name, + Self, + c_uint, + .{ + .default = 750, + .minimum = 250, + .maximum = std.math.maxInt(c_uint), + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "duration", + ), + }, + ); + }; + }; + + const Private = struct { + /// The time that the overlay appears. + duration: c_uint, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + const priv = self.private(); + _ = priv; + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + _ = priv; + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + _ = priv; + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 2, + .name = "search-overlay", + }), + ); + + // Bindings + // class.bindTemplateChildPrivate("label", .{}); + + // Properties + // gobject.ext.registerProperties(class, &.{ + // properties.duration.impl, + // properties.label.impl, + // properties.@"first-delay".impl, + // properties.@"overlay-halign".impl, + // properties.@"overlay-valign".impl, + // }); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + }; +}; diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 9ba7ce0ab..587392464 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -25,6 +25,7 @@ const Common = @import("../class.zig").Common; const Application = @import("application.zig").Application; const Config = @import("config.zig").Config; const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay; +const SearchOverlay = @import("search_overlay.zig").SearchOverlay; const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited; const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog; const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog; @@ -3184,6 +3185,7 @@ pub const Surface = extern struct { fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(ResizeOverlay); + gobject.ext.ensureType(SearchOverlay); gobject.ext.ensureType(ChildExited); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), diff --git a/src/apprt/gtk/css/style.css b/src/apprt/gtk/css/style.css index 5620c9ca4..938d23ad8 100644 --- a/src/apprt/gtk/css/style.css +++ b/src/apprt/gtk/css/style.css @@ -34,6 +34,18 @@ label.url-overlay.right { border-radius: 6px 0px 0px 0px; } +/* + * GhosttySurface search overlay + */ +.search-overlay { + padding: 6px 8px; + margin: 8px; + border-radius: 8px; + outline-style: solid; + outline-color: #555555; + outline-width: 1px; +} + /* * GhosttySurface resize overlay */ diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp new file mode 100644 index 000000000..030780260 --- /dev/null +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -0,0 +1,72 @@ +using Gtk 4.0; +using Gdk 4.0; +using Adw 1; + +template $GhosttySearchOverlay: Adw.Bin { + halign: end; + valign: start; + + Adw.Bin { + Box container { + styles [ + "background", + "search-overlay", + ] + + orientation: horizontal; + spacing: 6; + + SearchEntry search_entry { + placeholder-text: _("Find…"); + width-chars: 20; + hexpand: true; + } + + Label match_label { + styles [ + "dim-label", + ] + + label: "0/0"; + width-chars: 6; + xalign: 1.0; + } + + Box button_box { + orientation: horizontal; + spacing: 1; + + styles [ + "linked", + ] + + Button prev_button { + icon-name: "go-up-symbolic"; + tooltip-text: _("Previous Match"); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + + Button next_button { + icon-name: "go-down-symbolic"; + tooltip-text: _("Next Match"); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + } + + Button close_button { + icon-name: "window-close-symbolic"; + tooltip-text: _("Close"); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + } + } +} diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 8ff4a2e78..3b382259d 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -147,12 +147,24 @@ Overlay terminal_page { label: bind template.mouse-hover-url; } + [overlay] + $GhosttySearchOverlay search_overlay { + halign: end; + valign: start; + } + [overlay] // Apply unfocused-split-fill and unfocused-split-opacity to current surface // this is only applied when a tab has more than one surface Revealer { reveal-child: bind $should_unfocused_split_be_shown(template.focused, template.is-split) as ; transition-duration: 0; + // This is all necessary so that the Revealer itself doesn't override + // any input events from the other overlays. Namely, if you don't have + // these then the search overlay won't get mouse events. + can-focus: false; + can-target: false; + focusable: false; DrawingArea { styles [ From 027e5d631afce2b8ea2d5eb991b6539dd45e0334 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 14:59:50 -0800 Subject: [PATCH 05/49] config: default search keybindings for Linux --- src/config/Config.zig | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index bac7d3443..82e81a01f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6098,6 +6098,20 @@ pub const Keybinds = struct { .{ .jump_to_prompt = 1 }, ); + // Search + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .ctrl = true, .shift = true } }, + .start_search, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .escape } }, + .end_search, + .{ .performable = true }, + ); + // Inspector, matching Chromium try self.set.put( alloc, From 778b49c9a164df4e6118c857fdcfbea8360aff0c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 14:53:30 -0800 Subject: [PATCH 06/49] apprt/gtk: hook up start_search/end_search to set active state --- src/apprt/gtk/class/application.zig | 19 +++++++++-- src/apprt/gtk/class/search_overlay.zig | 42 +++++++++++-------------- src/apprt/gtk/class/surface.zig | 18 +++++++++++ src/apprt/gtk/ui/1.2/search-overlay.blp | 1 + 4 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index cc070240c..0efa7a3e0 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -727,6 +727,9 @@ pub const Application = extern struct { .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), .command_finished => return Action.commandFinished(target, value), + .start_search => Action.startSearch(target), + .end_search => Action.endSearch(target), + // Unimplemented .secure_input, .close_all_windows, @@ -741,8 +744,6 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, - .start_search, - .end_search, .search_total, .search_selected, => { @@ -2341,6 +2342,20 @@ const Action = struct { } } + pub fn startSearch(target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchActive(true), + } + } + + pub fn endSearch(target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchActive(false), + } + } + pub fn setTitle( target: apprt.Target, value: apprt.action.SetTitle, diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 1e49750fa..67c6ba38c 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -34,39 +34,39 @@ pub const SearchOverlay = extern struct { }); pub const properties = struct { - pub const duration = struct { - pub const name = "duration"; + pub const active = struct { + pub const name = "active"; const impl = gobject.ext.defineProperty( name, Self, - c_uint, + bool, .{ - .default = 750, - .minimum = 250, - .maximum = std.math.maxInt(c_uint), - .accessor = gobject.ext.privateFieldAccessor( - Self, - Private, - &Private.offset, - "duration", - ), + .default = false, + .accessor = C.privateShallowFieldAccessor("active"), }, ); }; }; const Private = struct { - /// The time that the overlay appears. - duration: c_uint, + /// The search entry widget. + search_entry: *gtk.SearchEntry, + + /// True when a search is active, meaning we should show the overlay. + active: bool = false, pub var offset: c_int = 0; }; fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + /// Grab focus on the search entry and select all text. + pub fn grabFocus(self: *Self) void { const priv = self.private(); - _ = priv; + _ = priv.search_entry.as(gtk.Widget).grabFocus(); + priv.search_entry.as(gtk.Editable).selectRegion(0, -1); } //--------------------------------------------------------------- @@ -119,16 +119,12 @@ pub const SearchOverlay = extern struct { ); // Bindings - // class.bindTemplateChildPrivate("label", .{}); + class.bindTemplateChildPrivate("search_entry", .{}); // Properties - // gobject.ext.registerProperties(class, &.{ - // properties.duration.impl, - // properties.label.impl, - // properties.@"first-delay".impl, - // properties.@"overlay-halign".impl, - // properties.@"overlay-valign".impl, - // }); + gobject.ext.registerProperties(class, &.{ + properties.active.impl, + }); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 587392464..a91ae9d45 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -550,6 +550,9 @@ pub const Surface = extern struct { /// The resize overlay resize_overlay: *ResizeOverlay, + /// The search overlay + search_overlay: *SearchOverlay, + /// The apprt Surface. rt_surface: ApprtSurface = undefined, @@ -1952,6 +1955,20 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"error".impl.param_spec); } + pub fn setSearchActive(self: *Self, active: bool) void { + const priv = self.private(); + var value = gobject.ext.Value.newFrom(active); + defer value.unset(); + gobject.Object.setProperty( + priv.search_overlay.as(gobject.Object), + SearchOverlay.properties.active.name, + &value, + ); + if (active) { + priv.search_overlay.grabFocus(); + } + } + fn propConfig( self: *Self, _: *gobject.ParamSpec, @@ -3205,6 +3222,7 @@ pub const Surface = extern struct { class.bindTemplateChildPrivate("error_page", .{}); class.bindTemplateChildPrivate("progress_bar_overlay", .{}); class.bindTemplateChildPrivate("resize_overlay", .{}); + class.bindTemplateChildPrivate("search_overlay", .{}); class.bindTemplateChildPrivate("terminal_page", .{}); class.bindTemplateChildPrivate("drop_target", .{}); class.bindTemplateChildPrivate("im_context", .{}); diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 030780260..79e3ef58f 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -3,6 +3,7 @@ using Gdk 4.0; using Adw 1; template $GhosttySearchOverlay: Adw.Bin { + visible: bind template.active; halign: end; valign: start; From 0d32e7d814264c8f84c397235fae206774eeac90 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 15:10:38 -0800 Subject: [PATCH 07/49] apprt/gtk: escape to stop search and hide overlay --- src/apprt/gtk/class/search_overlay.zig | 28 +++++++++++++++++++++++++ src/apprt/gtk/class/surface.zig | 9 ++++++++ src/apprt/gtk/ui/1.2/search-overlay.blp | 1 + src/apprt/gtk/ui/1.2/surface.blp | 1 + 4 files changed, 39 insertions(+) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 67c6ba38c..75aedc154 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -48,6 +48,20 @@ pub const SearchOverlay = extern struct { }; }; + pub const signals = struct { + /// Emitted when the search is stopped (e.g., Escape pressed). + pub const @"stop-search" = struct { + pub const name = "stop-search"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + }; + const Private = struct { /// The search entry widget. search_entry: *gtk.SearchEntry, @@ -69,6 +83,13 @@ pub const SearchOverlay = extern struct { priv.search_entry.as(gtk.Editable).selectRegion(0, -1); } + //--------------------------------------------------------------- + // Template callbacks + + fn stopSearch(_: *gtk.SearchEntry, self: *Self) callconv(.c) void { + signals.@"stop-search".impl.emit(self, null, .{}, null); + } + //--------------------------------------------------------------- // Virtual methods @@ -121,11 +142,17 @@ pub const SearchOverlay = extern struct { // Bindings class.bindTemplateChildPrivate("search_entry", .{}); + // Template Callbacks + class.bindTemplateCallback("stop_search", &stopSearch); + // Properties gobject.ext.registerProperties(class, &.{ properties.active.impl, }); + // Signals + signals.@"stop-search".impl.register(.{}); + // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); gobject.Object.virtual_methods.finalize.implement(class, &finalize); @@ -133,5 +160,6 @@ pub const SearchOverlay = extern struct { pub const as = C.Class.as; pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; }; }; diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index a91ae9d45..405beea3e 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3188,6 +3188,14 @@ pub const Surface = extern struct { self.setTitleOverride(if (title.len == 0) null else title); } + fn searchStop(_: *SearchOverlay, self: *Self) callconv(.c) void { + // Note: at the time of writing this, this behavior doesn't match + // macOS. But I think it makes more sense on Linux/GTK to do this. + // We may follow suit on macOS in the future. + self.setSearchActive(false); + _ = self.private().gl_area.as(gtk.Widget).grabFocus(); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -3260,6 +3268,7 @@ pub const Surface = extern struct { class.bindTemplateCallback("notify_vadjustment", &propVAdjustment); class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown); class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown); + class.bindTemplateCallback("search_stop", &searchStop); // Properties gobject.ext.registerProperties(class, &.{ diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 79e3ef58f..b9d282df5 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -21,6 +21,7 @@ template $GhosttySearchOverlay: Adw.Bin { placeholder-text: _("Find…"); width-chars: 20; hexpand: true; + stop-search => $stop_search(); } Label match_label { diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 3b382259d..9803b47e0 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -151,6 +151,7 @@ Overlay terminal_page { $GhosttySearchOverlay search_overlay { halign: end; valign: start; + stop-search => $search_stop(); } [overlay] From fc9b578ef42aae4c55f76aefe003dd76835e4516 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 15:16:29 -0800 Subject: [PATCH 08/49] apprt/gtk: hook up search-changed to start a search --- src/apprt/gtk/class/search_overlay.zig | 19 +++++++++++++++++++ src/apprt/gtk/class/surface.zig | 8 ++++++++ src/apprt/gtk/ui/1.2/search-overlay.blp | 1 + src/apprt/gtk/ui/1.2/surface.blp | 1 + 4 files changed, 29 insertions(+) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 75aedc154..5cc64be62 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -60,6 +60,18 @@ pub const SearchOverlay = extern struct { void, ); }; + + /// Emitted when the search text changes (debounced). + pub const @"search-changed" = struct { + pub const name = "search-changed"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{?[*:0]const u8}, + void, + ); + }; }; const Private = struct { @@ -90,6 +102,11 @@ pub const SearchOverlay = extern struct { signals.@"stop-search".impl.emit(self, null, .{}, null); } + fn searchChanged(entry: *gtk.SearchEntry, self: *Self) callconv(.c) void { + const text = entry.as(gtk.Editable).getText(); + signals.@"search-changed".impl.emit(self, null, .{text}, null); + } + //--------------------------------------------------------------- // Virtual methods @@ -144,6 +161,7 @@ pub const SearchOverlay = extern struct { // Template Callbacks class.bindTemplateCallback("stop_search", &stopSearch); + class.bindTemplateCallback("search_changed", &searchChanged); // Properties gobject.ext.registerProperties(class, &.{ @@ -152,6 +170,7 @@ pub const SearchOverlay = extern struct { // Signals signals.@"stop-search".impl.register(.{}); + signals.@"search-changed".impl.register(.{}); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 405beea3e..66663dc53 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3196,6 +3196,13 @@ pub const Surface = extern struct { _ = self.private().gl_area.as(gtk.Widget).grabFocus(); } + fn searchChanged(_: *SearchOverlay, needle: ?[*:0]const u8, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.{ .search = std.mem.sliceTo(needle orelse "", 0) }) catch |err| { + log.warn("unable to perform search action err={}", .{err}); + }; + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -3269,6 +3276,7 @@ pub const Surface = extern struct { class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown); class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown); class.bindTemplateCallback("search_stop", &searchStop); + class.bindTemplateCallback("search_changed", &searchChanged); // Properties gobject.ext.registerProperties(class, &.{ diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index b9d282df5..18d7f4e5c 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -22,6 +22,7 @@ template $GhosttySearchOverlay: Adw.Bin { width-chars: 20; hexpand: true; stop-search => $stop_search(); + search-changed => $search_changed(); } Label match_label { diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 9803b47e0..7f1c1b01f 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -152,6 +152,7 @@ Overlay terminal_page { halign: end; valign: start; stop-search => $search_stop(); + search-changed => $search_changed(); } [overlay] From 0ea85fc483a4fe780877ec5e3e774a8edd037466 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 15:22:29 -0800 Subject: [PATCH 09/49] apprt/gtk: hook up search_total/search_selected apprt actions --- src/apprt/gtk/class/application.zig | 18 ++++++- src/apprt/gtk/class/search_overlay.zig | 64 +++++++++++++++++++++++++ src/apprt/gtk/class/surface.zig | 8 ++++ src/apprt/gtk/ui/1.2/search-overlay.blp | 4 +- 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 0efa7a3e0..69576bf00 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -729,6 +729,8 @@ pub const Application = extern struct { .start_search => Action.startSearch(target), .end_search => Action.endSearch(target), + .search_total => Action.searchTotal(target, value), + .search_selected => Action.searchSelected(target, value), // Unimplemented .secure_input, @@ -744,8 +746,6 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, - .search_total, - .search_selected, => { log.warn("unimplemented action={}", .{action}); return false; @@ -2356,6 +2356,20 @@ const Action = struct { } } + pub fn searchTotal(target: apprt.Target, value: apprt.action.SearchTotal) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchTotal(value.total), + } + } + + pub fn searchSelected(target: apprt.Target, value: apprt.action.SearchSelected) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchSelected(value.selected), + } + } + pub fn setTitle( target: apprt.Target, value: apprt.action.SetTitle, diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 5cc64be62..eee7b7bc1 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -46,6 +46,36 @@ pub const SearchOverlay = extern struct { }, ); }; + + pub const @"search-total" = struct { + pub const name = "search-total"; + const impl = gobject.ext.defineProperty( + name, + Self, + i64, + .{ + .default = -1, + .minimum = -1, + .maximum = std.math.maxInt(i64), + .accessor = C.privateShallowFieldAccessor("search_total"), + }, + ); + }; + + pub const @"search-selected" = struct { + pub const name = "search-selected"; + const impl = gobject.ext.defineProperty( + name, + Self, + i64, + .{ + .default = -1, + .minimum = -1, + .maximum = std.math.maxInt(i64), + .accessor = C.privateShallowFieldAccessor("search_selected"), + }, + ); + }; }; pub const signals = struct { @@ -81,6 +111,12 @@ pub const SearchOverlay = extern struct { /// True when a search is active, meaning we should show the overlay. active: bool = false, + /// Total number of search matches (-1 means unknown/none). + search_total: i64 = -1, + + /// Currently selected match index (-1 means none selected). + search_selected: i64 = -1, + pub var offset: c_int = 0; }; @@ -95,6 +131,31 @@ pub const SearchOverlay = extern struct { priv.search_entry.as(gtk.Editable).selectRegion(0, -1); } + /// Set the total number of search matches. + pub fn setSearchTotal(self: *Self, total: ?usize) void { + const value: i64 = if (total) |t| @intCast(t) else -1; + var gvalue = gobject.ext.Value.newFrom(value); + defer gvalue.unset(); + self.as(gobject.Object).setProperty(properties.@"search-total".name, &gvalue); + } + + /// Set the currently selected match index. + pub fn setSearchSelected(self: *Self, selected: ?usize) void { + const value: i64 = if (selected) |s| @intCast(s) else -1; + var gvalue = gobject.ext.Value.newFrom(value); + defer gvalue.unset(); + self.as(gobject.Object).setProperty(properties.@"search-selected".name, &gvalue); + } + + fn closureMatchLabel(_: *Self, selected: i64, total: i64) callconv(.c) ?[*:0]const u8 { + var buf: [32]u8 = undefined; + const label = std.fmt.bufPrintZ(&buf, "{}/{}", .{ + if (selected >= 0) selected else 0, + if (total >= 0) total else 0, + }) catch return null; + return glib.ext.dupeZ(u8, label); + } + //--------------------------------------------------------------- // Template callbacks @@ -162,10 +223,13 @@ pub const SearchOverlay = extern struct { // Template Callbacks class.bindTemplateCallback("stop_search", &stopSearch); class.bindTemplateCallback("search_changed", &searchChanged); + class.bindTemplateCallback("match_label_closure", &closureMatchLabel); // Properties gobject.ext.registerProperties(class, &.{ properties.active.impl, + properties.@"search-total".impl, + properties.@"search-selected".impl, }); // Signals diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 66663dc53..5951b49f6 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1969,6 +1969,14 @@ pub const Surface = extern struct { } } + pub fn setSearchTotal(self: *Self, total: ?usize) void { + self.private().search_overlay.setSearchTotal(total); + } + + pub fn setSearchSelected(self: *Self, selected: ?usize) void { + self.private().search_overlay.setSearchSelected(selected); + } + fn propConfig( self: *Self, _: *gobject.ParamSpec, diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 18d7f4e5c..43ede3178 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -25,12 +25,12 @@ template $GhosttySearchOverlay: Adw.Bin { search-changed => $search_changed(); } - Label match_label { + Label { styles [ "dim-label", ] - label: "0/0"; + label: bind $match_label_closure(template.search-selected, template.search-total) as ; width-chars: 6; xalign: 1.0; } From 76496d40fdcc0c6aaadf98e3153effdd10dc2cdf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 15:27:52 -0800 Subject: [PATCH 10/49] apprt/gtk: hook up next/prev match --- src/apprt/gtk/class/search_overlay.zig | 46 +++++++++++++++++++++++++ src/apprt/gtk/class/surface.zig | 16 +++++++++ src/apprt/gtk/ui/1.2/search-overlay.blp | 4 +++ src/apprt/gtk/ui/1.2/surface.blp | 2 ++ 4 files changed, 68 insertions(+) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index eee7b7bc1..75a28de80 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -102,6 +102,30 @@ pub const SearchOverlay = extern struct { void, ); }; + + /// Emitted when navigating to the next match. + pub const @"next-match" = struct { + pub const name = "next-match"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted when navigating to the previous match. + pub const @"previous-match" = struct { + pub const name = "previous-match"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; }; const Private = struct { @@ -168,6 +192,22 @@ pub const SearchOverlay = extern struct { signals.@"search-changed".impl.emit(self, null, .{text}, null); } + fn nextMatch(_: *gtk.Button, self: *Self) callconv(.c) void { + signals.@"next-match".impl.emit(self, null, .{}, null); + } + + fn previousMatch(_: *gtk.Button, self: *Self) callconv(.c) void { + signals.@"previous-match".impl.emit(self, null, .{}, null); + } + + fn nextMatchEntry(_: *gtk.SearchEntry, self: *Self) callconv(.c) void { + signals.@"next-match".impl.emit(self, null, .{}, null); + } + + fn previousMatchEntry(_: *gtk.SearchEntry, self: *Self) callconv(.c) void { + signals.@"previous-match".impl.emit(self, null, .{}, null); + } + //--------------------------------------------------------------- // Virtual methods @@ -224,6 +264,10 @@ pub const SearchOverlay = extern struct { class.bindTemplateCallback("stop_search", &stopSearch); class.bindTemplateCallback("search_changed", &searchChanged); class.bindTemplateCallback("match_label_closure", &closureMatchLabel); + class.bindTemplateCallback("next_match", &nextMatch); + class.bindTemplateCallback("previous_match", &previousMatch); + class.bindTemplateCallback("next_match_entry", &nextMatchEntry); + class.bindTemplateCallback("previous_match_entry", &previousMatchEntry); // Properties gobject.ext.registerProperties(class, &.{ @@ -235,6 +279,8 @@ pub const SearchOverlay = extern struct { // Signals signals.@"stop-search".impl.register(.{}); signals.@"search-changed".impl.register(.{}); + signals.@"next-match".impl.register(.{}); + signals.@"previous-match".impl.register(.{}); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 5951b49f6..9a77c4c53 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3211,6 +3211,20 @@ pub const Surface = extern struct { }; } + fn searchNextMatch(_: *SearchOverlay, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.{ .navigate_search = .next }) catch |err| { + log.warn("unable to perform navigate_search action err={}", .{err}); + }; + } + + fn searchPreviousMatch(_: *SearchOverlay, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.{ .navigate_search = .previous }) catch |err| { + log.warn("unable to perform navigate_search action err={}", .{err}); + }; + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -3285,6 +3299,8 @@ pub const Surface = extern struct { class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown); class.bindTemplateCallback("search_stop", &searchStop); class.bindTemplateCallback("search_changed", &searchChanged); + class.bindTemplateCallback("search_next_match", &searchNextMatch); + class.bindTemplateCallback("search_previous_match", &searchPreviousMatch); // Properties gobject.ext.registerProperties(class, &.{ diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 43ede3178..62401959e 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -23,6 +23,8 @@ template $GhosttySearchOverlay: Adw.Bin { hexpand: true; stop-search => $stop_search(); search-changed => $search_changed(); + next-match => $next_match_entry(); + previous-match => $previous_match_entry(); } Label { @@ -46,6 +48,7 @@ template $GhosttySearchOverlay: Adw.Bin { Button prev_button { icon-name: "go-up-symbolic"; tooltip-text: _("Previous Match"); + clicked => $next_match(); cursor: Gdk.Cursor { name: "pointer"; @@ -55,6 +58,7 @@ template $GhosttySearchOverlay: Adw.Bin { Button next_button { icon-name: "go-down-symbolic"; tooltip-text: _("Next Match"); + clicked => $previous_match(); cursor: Gdk.Cursor { name: "pointer"; diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 7f1c1b01f..0abc6c356 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -153,6 +153,8 @@ Overlay terminal_page { valign: start; stop-search => $search_stop(); search-changed => $search_changed(); + next-match => $search_next_match(); + previous-match => $search_previous_match(); } [overlay] From eebce6a78cc7d5a0073239bcc31eaadd35a16830 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 15:31:28 -0800 Subject: [PATCH 11/49] apprt/gtk: hook up close search button --- src/apprt/gtk/class/search_overlay.zig | 5 +++++ src/apprt/gtk/class/surface.zig | 8 ++++---- src/apprt/gtk/ui/1.2/search-overlay.blp | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 75a28de80..46c489f75 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -187,6 +187,10 @@ pub const SearchOverlay = extern struct { signals.@"stop-search".impl.emit(self, null, .{}, null); } + fn stopSearchButton(_: *gtk.Button, self: *Self) callconv(.c) void { + signals.@"stop-search".impl.emit(self, null, .{}, null); + } + fn searchChanged(entry: *gtk.SearchEntry, self: *Self) callconv(.c) void { const text = entry.as(gtk.Editable).getText(); signals.@"search-changed".impl.emit(self, null, .{text}, null); @@ -262,6 +266,7 @@ pub const SearchOverlay = extern struct { // Template Callbacks class.bindTemplateCallback("stop_search", &stopSearch); + class.bindTemplateCallback("stop_search_button", &stopSearchButton); class.bindTemplateCallback("search_changed", &searchChanged); class.bindTemplateCallback("match_label_closure", &closureMatchLabel); class.bindTemplateCallback("next_match", &nextMatch); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 9a77c4c53..2af53e1ef 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3197,10 +3197,10 @@ pub const Surface = extern struct { } fn searchStop(_: *SearchOverlay, self: *Self) callconv(.c) void { - // Note: at the time of writing this, this behavior doesn't match - // macOS. But I think it makes more sense on Linux/GTK to do this. - // We may follow suit on macOS in the future. - self.setSearchActive(false); + const surface = self.core() orelse return; + _ = surface.performBindingAction(.end_search) catch |err| { + log.warn("unable to perform end_search action err={}", .{err}); + }; _ = self.private().gl_area.as(gtk.Widget).grabFocus(); } diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 62401959e..0d2dd659b 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -69,6 +69,7 @@ template $GhosttySearchOverlay: Adw.Bin { Button close_button { icon-name: "window-close-symbolic"; tooltip-text: _("Close"); + clicked => $stop_search_button(); cursor: Gdk.Cursor { name: "pointer"; From 56a76cc1746933cdefb2a7d36958c9ecb2a406d2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 15:33:05 -0800 Subject: [PATCH 12/49] apprt/gtk: fix selected search label off by one --- src/apprt/gtk/class/search_overlay.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 46c489f75..396946062 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -172,9 +172,10 @@ pub const SearchOverlay = extern struct { } fn closureMatchLabel(_: *Self, selected: i64, total: i64) callconv(.c) ?[*:0]const u8 { + if (total <= 0) return glib.ext.dupeZ(u8, "0/0"); var buf: [32]u8 = undefined; const label = std.fmt.bufPrintZ(&buf, "{}/{}", .{ - if (selected >= 0) selected else 0, + if (selected >= 0) selected + 1 else 0, if (total >= 0) total else 0, }) catch return null; return glib.ext.dupeZ(u8, label); From 72b3c14833d11053681605948c35eae5a8f744ad Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 20:16:48 -0800 Subject: [PATCH 13/49] clean up some stuff --- src/apprt/gtk/class/search_overlay.zig | 30 ++++++++----------------- src/apprt/gtk/ui/1.2/search-overlay.blp | 6 ++--- src/apprt/gtk/ui/1.2/surface.blp | 2 -- 3 files changed, 12 insertions(+), 26 deletions(-) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 396946062..e469e1903 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -184,32 +184,23 @@ pub const SearchOverlay = extern struct { //--------------------------------------------------------------- // Template callbacks - fn stopSearch(_: *gtk.SearchEntry, self: *Self) callconv(.c) void { - signals.@"stop-search".impl.emit(self, null, .{}, null); - } - - fn stopSearchButton(_: *gtk.Button, self: *Self) callconv(.c) void { - signals.@"stop-search".impl.emit(self, null, .{}, null); - } - fn searchChanged(entry: *gtk.SearchEntry, self: *Self) callconv(.c) void { const text = entry.as(gtk.Editable).getText(); signals.@"search-changed".impl.emit(self, null, .{text}, null); } - fn nextMatch(_: *gtk.Button, self: *Self) callconv(.c) void { + // NOTE: The callbacks below use anyopaque for the first parameter + // because they're shared with multiple widgets in the template. + + fn stopSearch(_: *anyopaque, self: *Self) callconv(.c) void { + signals.@"stop-search".impl.emit(self, null, .{}, null); + } + + fn nextMatch(_: *anyopaque, self: *Self) callconv(.c) void { signals.@"next-match".impl.emit(self, null, .{}, null); } - fn previousMatch(_: *gtk.Button, self: *Self) callconv(.c) void { - signals.@"previous-match".impl.emit(self, null, .{}, null); - } - - fn nextMatchEntry(_: *gtk.SearchEntry, self: *Self) callconv(.c) void { - signals.@"next-match".impl.emit(self, null, .{}, null); - } - - fn previousMatchEntry(_: *gtk.SearchEntry, self: *Self) callconv(.c) void { + fn previousMatch(_: *anyopaque, self: *Self) callconv(.c) void { signals.@"previous-match".impl.emit(self, null, .{}, null); } @@ -267,13 +258,10 @@ pub const SearchOverlay = extern struct { // Template Callbacks class.bindTemplateCallback("stop_search", &stopSearch); - class.bindTemplateCallback("stop_search_button", &stopSearchButton); class.bindTemplateCallback("search_changed", &searchChanged); class.bindTemplateCallback("match_label_closure", &closureMatchLabel); class.bindTemplateCallback("next_match", &nextMatch); class.bindTemplateCallback("previous_match", &previousMatch); - class.bindTemplateCallback("next_match_entry", &nextMatchEntry); - class.bindTemplateCallback("previous_match_entry", &previousMatchEntry); // Properties gobject.ext.registerProperties(class, &.{ diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 0d2dd659b..7ca5fded7 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -23,8 +23,8 @@ template $GhosttySearchOverlay: Adw.Bin { hexpand: true; stop-search => $stop_search(); search-changed => $search_changed(); - next-match => $next_match_entry(); - previous-match => $previous_match_entry(); + next-match => $next_match(); + previous-match => $previous_match(); } Label { @@ -69,7 +69,7 @@ template $GhosttySearchOverlay: Adw.Bin { Button close_button { icon-name: "window-close-symbolic"; tooltip-text: _("Close"); - clicked => $stop_search_button(); + clicked => $stop_search(); cursor: Gdk.Cursor { name: "pointer"; diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 0abc6c356..4ebfeabfb 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -149,8 +149,6 @@ Overlay terminal_page { [overlay] $GhosttySearchOverlay search_overlay { - halign: end; - valign: start; stop-search => $search_stop(); search-changed => $search_changed(); next-match => $search_next_match(); From f7a6822e30818af306c165dcee5b13233f33db65 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 20:20:38 -0800 Subject: [PATCH 14/49] apprt/gtk: enter/shift+enter for traversing search results --- src/apprt/gtk/class/search_overlay.zig | 22 ++++++++++++++++++++++ src/apprt/gtk/ui/1.2/search-overlay.blp | 6 ++++++ 2 files changed, 28 insertions(+) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index e469e1903..192ec7ab4 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -2,6 +2,7 @@ const std = @import("std"); const adw = @import("adw"); const glib = @import("glib"); const gobject = @import("gobject"); +const gdk = @import("gdk"); const gtk = @import("gtk"); const gresource = @import("../build/gresource.zig"); @@ -204,6 +205,26 @@ pub const SearchOverlay = extern struct { signals.@"previous-match".impl.emit(self, null, .{}, null); } + fn searchEntryKeyPressed( + _: *gtk.EventControllerKey, + keyval: c_uint, + _: c_uint, + gtk_mods: gdk.ModifierType, + self: *Self, + ) callconv(.c) c_int { + if (keyval == gdk.KEY_Return or keyval == gdk.KEY_KP_Enter) { + if (gtk_mods.shift_mask) { + signals.@"previous-match".impl.emit(self, null, .{}, null); + } else { + signals.@"next-match".impl.emit(self, null, .{}, null); + } + + return 1; + } + + return 0; + } + //--------------------------------------------------------------- // Virtual methods @@ -262,6 +283,7 @@ pub const SearchOverlay = extern struct { class.bindTemplateCallback("match_label_closure", &closureMatchLabel); class.bindTemplateCallback("next_match", &nextMatch); class.bindTemplateCallback("previous_match", &previousMatch); + class.bindTemplateCallback("search_entry_key_pressed", &searchEntryKeyPressed); // Properties gobject.ext.registerProperties(class, &.{ diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 7ca5fded7..5a011c0c9 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -25,6 +25,12 @@ template $GhosttySearchOverlay: Adw.Bin { search-changed => $search_changed(); next-match => $next_match(); previous-match => $previous_match(); + + EventControllerKey { + // We need this so we capture before the SearchEntry. + propagation-phase: capture; + key-pressed => $search_entry_key_pressed(); + } } Label { From e18a7d95014c3b762bd2fd750c794c54bfc47244 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 20:33:46 -0800 Subject: [PATCH 15/49] apprt/gtk: drag --- src/apprt/gtk/class/search_overlay.zig | 92 +++++++++++++++++++++++-- src/apprt/gtk/ui/1.2/search-overlay.blp | 12 +++- 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 192ec7ab4..17ff5861e 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -77,6 +77,32 @@ pub const SearchOverlay = extern struct { }, ); }; + + pub const @"halign-target" = struct { + pub const name = "halign-target"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.Align, + .{ + .default = .end, + .accessor = C.privateShallowFieldAccessor("halign_target"), + }, + ); + }; + + pub const @"valign-target" = struct { + pub const name = "valign-target"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.Align, + .{ + .default = .start, + .accessor = C.privateShallowFieldAccessor("valign_target"), + }, + ); + }; }; pub const signals = struct { @@ -142,6 +168,12 @@ pub const SearchOverlay = extern struct { /// Currently selected match index (-1 means none selected). search_selected: i64 = -1, + /// Target horizontal alignment for the overlay. + halign_target: gtk.Align = .end, + + /// Target vertical alignment for the overlay. + valign_target: gtk.Align = .start, + pub var offset: c_int = 0; }; @@ -158,18 +190,20 @@ pub const SearchOverlay = extern struct { /// Set the total number of search matches. pub fn setSearchTotal(self: *Self, total: ?usize) void { + const priv = self.private(); const value: i64 = if (total) |t| @intCast(t) else -1; - var gvalue = gobject.ext.Value.newFrom(value); - defer gvalue.unset(); - self.as(gobject.Object).setProperty(properties.@"search-total".name, &gvalue); + if (priv.search_total == value) return; + priv.search_total = value; + self.as(gobject.Object).notifyByPspec(properties.@"search-total".impl.param_spec); } /// Set the currently selected match index. pub fn setSearchSelected(self: *Self, selected: ?usize) void { + const priv = self.private(); const value: i64 = if (selected) |s| @intCast(s) else -1; - var gvalue = gobject.ext.Value.newFrom(value); - defer gvalue.unset(); - self.as(gobject.Object).setProperty(properties.@"search-selected".name, &gvalue); + if (priv.search_selected == value) return; + priv.search_selected = value; + self.as(gobject.Object).notifyByPspec(properties.@"search-selected".impl.param_spec); } fn closureMatchLabel(_: *Self, selected: i64, total: i64) callconv(.c) ?[*:0]const u8 { @@ -225,6 +259,49 @@ pub const SearchOverlay = extern struct { return 0; } + fn onDragEnd( + _: *gtk.GestureDrag, + offset_x: f64, + offset_y: f64, + self: *Self, + ) callconv(.c) void { + // On drag end, we want to move our halign/valign if we crossed + // the midpoint on either axis. This lets the search overlay be + // moved to different corners of the parent container. + + const priv = self.private(); + const widget = self.as(gtk.Widget); + const parent = widget.getParent() orelse return; + + const parent_width: f64 = @floatFromInt(parent.getAllocatedWidth()); + const parent_height: f64 = @floatFromInt(parent.getAllocatedHeight()); + const self_width: f64 = @floatFromInt(widget.getAllocatedWidth()); + const self_height: f64 = @floatFromInt(widget.getAllocatedHeight()); + + const self_x: f64 = if (priv.halign_target == .start) 0 else parent_width - self_width; + const self_y: f64 = if (priv.valign_target == .start) 0 else parent_height - self_height; + + const new_x = self_x + offset_x + (self_width / 2); + const new_y = self_y + offset_y + (self_height / 2); + + const new_halign: gtk.Align = if (new_x > parent_width / 2) .end else .start; + const new_valign: gtk.Align = if (new_y > parent_height / 2) .end else .start; + + var changed = false; + if (new_halign != priv.halign_target) { + priv.halign_target = new_halign; + self.as(gobject.Object).notifyByPspec(properties.@"halign-target".impl.param_spec); + changed = true; + } + if (new_valign != priv.valign_target) { + priv.valign_target = new_valign; + self.as(gobject.Object).notifyByPspec(properties.@"valign-target".impl.param_spec); + changed = true; + } + + if (changed) self.as(gtk.Widget).queueResize(); + } + //--------------------------------------------------------------- // Virtual methods @@ -284,12 +361,15 @@ pub const SearchOverlay = extern struct { class.bindTemplateCallback("next_match", &nextMatch); class.bindTemplateCallback("previous_match", &previousMatch); class.bindTemplateCallback("search_entry_key_pressed", &searchEntryKeyPressed); + class.bindTemplateCallback("on_drag_end", &onDragEnd); // Properties gobject.ext.registerProperties(class, &.{ properties.active.impl, properties.@"search-total".impl, properties.@"search-selected".impl, + properties.@"halign-target".impl, + properties.@"valign-target".impl, }); // Signals diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index 5a011c0c9..dfb2d9475 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -4,8 +4,16 @@ using Adw 1; template $GhosttySearchOverlay: Adw.Bin { visible: bind template.active; - halign: end; - valign: start; + halign-target: end; + valign-target: start; + halign: bind template.halign-target; + valign: bind template.valign-target; + + GestureDrag { + button: 1; + propagation-phase: capture; + drag-end => $on_drag_end(); + } Adw.Bin { Box container { From b8393fd4aa9be2af866fb72003a9afba46451573 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 30 Nov 2025 07:04:08 -0800 Subject: [PATCH 16/49] apprt/gtk: comments --- src/apprt/gtk/class/search_overlay.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 17ff5861e..f1e56ed37 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -185,6 +185,9 @@ pub const SearchOverlay = extern struct { pub fn grabFocus(self: *Self) void { const priv = self.private(); _ = priv.search_entry.as(gtk.Widget).grabFocus(); + + // Select all text in the search entry field. -1 is distance from + // the end, causing the entire text to be selected. priv.search_entry.as(gtk.Editable).selectRegion(0, -1); } From c67bcf969cc8bd584cb7dc8bb23c6decaebe9ad3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 30 Nov 2025 07:06:44 -0800 Subject: [PATCH 17/49] apprt/gtk: switch to has-x and optional internals for search counts --- src/apprt/gtk/class/search_overlay.zig | 120 +++++++++++++++++++----- src/apprt/gtk/ui/1.2/search-overlay.blp | 2 +- 2 files changed, 97 insertions(+), 25 deletions(-) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index f1e56ed37..2595cefa2 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -53,12 +53,33 @@ pub const SearchOverlay = extern struct { const impl = gobject.ext.defineProperty( name, Self, - i64, + u64, .{ - .default = -1, - .minimum = -1, - .maximum = std.math.maxInt(i64), - .accessor = C.privateShallowFieldAccessor("search_total"), + .default = 0, + .minimum = 0, + .maximum = std.math.maxInt(u64), + .accessor = gobject.ext.typedAccessor( + Self, + u64, + .{ .getter = getSearchTotal }, + ), + }, + ); + }; + + pub const @"has-search-total" = struct { + pub const name = "has-search-total"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ .getter = getHasSearchTotal }, + ), }, ); }; @@ -68,12 +89,33 @@ pub const SearchOverlay = extern struct { const impl = gobject.ext.defineProperty( name, Self, - i64, + u64, .{ - .default = -1, - .minimum = -1, - .maximum = std.math.maxInt(i64), - .accessor = C.privateShallowFieldAccessor("search_selected"), + .default = 0, + .minimum = 0, + .maximum = std.math.maxInt(u64), + .accessor = gobject.ext.typedAccessor( + Self, + u64, + .{ .getter = getSearchSelected }, + ), + }, + ); + }; + + pub const @"has-search-selected" = struct { + pub const name = "has-search-selected"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ .getter = getHasSearchSelected }, + ), }, ); }; @@ -162,11 +204,11 @@ pub const SearchOverlay = extern struct { /// True when a search is active, meaning we should show the overlay. active: bool = false, - /// Total number of search matches (-1 means unknown/none). - search_total: i64 = -1, + /// Total number of search matches (null means unknown/none). + search_total: ?usize = null, - /// Currently selected match index (-1 means none selected). - search_selected: i64 = -1, + /// Currently selected match index (null means none selected). + search_selected: ?usize = null, /// Target horizontal alignment for the overlay. halign_target: gtk.Align = .end, @@ -194,27 +236,55 @@ pub const SearchOverlay = extern struct { /// Set the total number of search matches. pub fn setSearchTotal(self: *Self, total: ?usize) void { const priv = self.private(); - const value: i64 = if (total) |t| @intCast(t) else -1; - if (priv.search_total == value) return; - priv.search_total = value; + const had_total = priv.search_total != null; + if (priv.search_total == total) return; + priv.search_total = total; self.as(gobject.Object).notifyByPspec(properties.@"search-total".impl.param_spec); + if (had_total != (total != null)) { + self.as(gobject.Object).notifyByPspec(properties.@"has-search-total".impl.param_spec); + } } /// Set the currently selected match index. pub fn setSearchSelected(self: *Self, selected: ?usize) void { const priv = self.private(); - const value: i64 = if (selected) |s| @intCast(s) else -1; - if (priv.search_selected == value) return; - priv.search_selected = value; + const had_selected = priv.search_selected != null; + if (priv.search_selected == selected) return; + priv.search_selected = selected; self.as(gobject.Object).notifyByPspec(properties.@"search-selected".impl.param_spec); + if (had_selected != (selected != null)) { + self.as(gobject.Object).notifyByPspec(properties.@"has-search-selected".impl.param_spec); + } } - fn closureMatchLabel(_: *Self, selected: i64, total: i64) callconv(.c) ?[*:0]const u8 { - if (total <= 0) return glib.ext.dupeZ(u8, "0/0"); + fn getSearchTotal(self: *Self) u64 { + return self.private().search_total orelse 0; + } + + fn getHasSearchTotal(self: *Self) bool { + return self.private().search_total != null; + } + + fn getSearchSelected(self: *Self) u64 { + return self.private().search_selected orelse 0; + } + + fn getHasSearchSelected(self: *Self) bool { + return self.private().search_selected != null; + } + + fn closureMatchLabel( + _: *Self, + has_selected: bool, + selected: u64, + has_total: bool, + total: u64, + ) callconv(.c) ?[*:0]const u8 { + if (!has_total or total == 0) return glib.ext.dupeZ(u8, "0/0"); var buf: [32]u8 = undefined; const label = std.fmt.bufPrintZ(&buf, "{}/{}", .{ - if (selected >= 0) selected + 1 else 0, - if (total >= 0) total else 0, + if (has_selected) selected + 1 else 0, + total, }) catch return null; return glib.ext.dupeZ(u8, label); } @@ -370,7 +440,9 @@ pub const SearchOverlay = extern struct { gobject.ext.registerProperties(class, &.{ properties.active.impl, properties.@"search-total".impl, + properties.@"has-search-total".impl, properties.@"search-selected".impl, + properties.@"has-search-selected".impl, properties.@"halign-target".impl, properties.@"valign-target".impl, }); diff --git a/src/apprt/gtk/ui/1.2/search-overlay.blp b/src/apprt/gtk/ui/1.2/search-overlay.blp index dfb2d9475..6523d4149 100644 --- a/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -46,7 +46,7 @@ template $GhosttySearchOverlay: Adw.Bin { "dim-label", ] - label: bind $match_label_closure(template.search-selected, template.search-total) as ; + label: bind $match_label_closure(template.has-search-selected, template.search-selected, template.has-search-total, template.search-total) as ; width-chars: 6; xalign: 1.0; } From 7be28e72159c1e1be1703dc33b61a38693ee6cc7 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 30 Nov 2025 17:53:21 +0100 Subject: [PATCH 18/49] core: encode mouse buttons 8 & 9 (back/forward) --- src/Surface.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 591ee7220..40929e168 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3464,6 +3464,8 @@ fn mouseReport( .five => 65, .six => 66, .seven => 67, + .eight => 128, + .nine => 129, else => return, // unsupported }; } From 7820608b04a6d3b83b4ef428b196842210b3d4c0 Mon Sep 17 00:00:00 2001 From: rhodes-b <59537185+rhodes-b@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:32:13 -0600 Subject: [PATCH 19/49] if search has text already update the search state with matches --- src/apprt/gtk/class/search_overlay.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 2595cefa2..ffa9174b2 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -231,6 +231,10 @@ pub const SearchOverlay = extern struct { // Select all text in the search entry field. -1 is distance from // the end, causing the entire text to be selected. priv.search_entry.as(gtk.Editable).selectRegion(0, -1); + + // update search state with the active text + const text = priv.search_entry.as(gtk.Editable).getText(); + signals.@"search-changed".impl.emit(self, null, .{text}, null); } /// Set the total number of search matches. From 3ab49fdb5fb681295670cf06a410f076794b6947 Mon Sep 17 00:00:00 2001 From: rhodes-b <59537185+rhodes-b@users.noreply.github.com> Date: Sun, 30 Nov 2025 21:06:25 -0600 Subject: [PATCH 20/49] only notify search change when widget was inactive --- src/apprt/gtk/class/search_overlay.zig | 11 +++++++---- src/apprt/gtk/class/surface.zig | 11 +++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index ffa9174b2..b193d9511 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -223,6 +223,13 @@ pub const SearchOverlay = extern struct { gtk.Widget.initTemplate(self.as(gtk.Widget)); } + /// Update search contents when widget is activated + pub fn updateSearch(self: *Self) void { + const priv = self.private(); + const text = priv.search_entry.as(gtk.Editable).getText(); + signals.@"search-changed".impl.emit(self, null, .{text}, null); + } + /// Grab focus on the search entry and select all text. pub fn grabFocus(self: *Self) void { const priv = self.private(); @@ -231,10 +238,6 @@ pub const SearchOverlay = extern struct { // Select all text in the search entry field. -1 is distance from // the end, causing the entire text to be selected. priv.search_entry.as(gtk.Editable).selectRegion(0, -1); - - // update search state with the active text - const text = priv.search_entry.as(gtk.Editable).getText(); - signals.@"search-changed".impl.emit(self, null, .{text}, null); } /// Set the total number of search matches. diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 2af53e1ef..49a6fbf42 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1964,6 +1964,17 @@ pub const Surface = extern struct { SearchOverlay.properties.active.name, &value, ); + + var is_active = gobject.ext.Value.newFrom(false); + defer is_active.unset(); + gobject.Object.getProperty( + priv.search_overlay.as(gobject.Object), + SearchOverlay.properties.active.name, + &is_active + ); + if (active and !is_active) { + priv.search_overlay.updateSearch(); + } if (active) { priv.search_overlay.grabFocus(); } From 27c82f739e9ae22a93e9cef0bb1912dd976dd0d0 Mon Sep 17 00:00:00 2001 From: rhodes-b <59537185+rhodes-b@users.noreply.github.com> Date: Sun, 30 Nov 2025 21:22:07 -0600 Subject: [PATCH 21/49] only update search when going from inactive to active --- src/apprt/gtk/class/search_overlay.zig | 30 +++++++++++++++++++------- src/apprt/gtk/class/surface.zig | 10 --------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index b193d9511..4936cd967 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -43,7 +43,14 @@ pub const SearchOverlay = extern struct { bool, .{ .default = false, - .accessor = C.privateShallowFieldAccessor("active"), + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getSearchActive, + .setter = setSearchActive, + }, + ), }, ); }; @@ -223,13 +230,6 @@ pub const SearchOverlay = extern struct { gtk.Widget.initTemplate(self.as(gtk.Widget)); } - /// Update search contents when widget is activated - pub fn updateSearch(self: *Self) void { - const priv = self.private(); - const text = priv.search_entry.as(gtk.Editable).getText(); - signals.@"search-changed".impl.emit(self, null, .{text}, null); - } - /// Grab focus on the search entry and select all text. pub fn grabFocus(self: *Self) void { const priv = self.private(); @@ -240,6 +240,16 @@ pub const SearchOverlay = extern struct { priv.search_entry.as(gtk.Editable).selectRegion(0, -1); } + // Set active status, and update search on activation + fn setSearchActive(self: *Self, active: bool) void { + const priv = self.private(); + if (!priv.active and active) { + const text = priv.search_entry.as(gtk.Editable).getText(); + signals.@"search-changed".impl.emit(self, null, .{text}, null); + } + priv.active = active; + } + /// Set the total number of search matches. pub fn setSearchTotal(self: *Self, total: ?usize) void { const priv = self.private(); @@ -264,6 +274,10 @@ pub const SearchOverlay = extern struct { } } + fn getSearchActive(self: *Self) bool { + return self.private().active; + } + fn getSearchTotal(self: *Self) u64 { return self.private().search_total orelse 0; } diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 49a6fbf42..a4d2d6696 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1965,16 +1965,6 @@ pub const Surface = extern struct { &value, ); - var is_active = gobject.ext.Value.newFrom(false); - defer is_active.unset(); - gobject.Object.getProperty( - priv.search_overlay.as(gobject.Object), - SearchOverlay.properties.active.name, - &is_active - ); - if (active and !is_active) { - priv.search_overlay.updateSearch(); - } if (active) { priv.search_overlay.grabFocus(); } From b776b3df6115a04faaecad05729c1456b38534cd Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 1 Dec 2025 10:19:00 -0500 Subject: [PATCH 22/49] zsh: improve minimum version check - Handle autoload failures - Prefer ">&2" to "/dev/stderr" for portability - Quote commands for consistency and to avoid alias conflicts --- src/shell-integration/zsh/.zshenv | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/shell-integration/zsh/.zshenv b/src/shell-integration/zsh/.zshenv index 3332b1c1f..4201b295c 100644 --- a/src/shell-integration/zsh/.zshenv +++ b/src/shell-integration/zsh/.zshenv @@ -43,9 +43,8 @@ fi [[ ! -r "$_ghostty_file" ]] || 'builtin' 'source' '--' "$_ghostty_file" } always { if [[ -o 'interactive' ]]; then - 'builtin' 'autoload' '--' 'is-at-least' - 'is-at-least' "5.1" || { - builtin echo "ZSH ${ZSH_VERSION} is too old for ghostty shell integration" > /dev/stderr + 'builtin' 'autoload' '--' 'is-at-least' 2>/dev/null && 'is-at-least' "5.1" || { + 'builtin' 'echo' "zsh ${ZSH_VERSION} is too old for ghostty shell integration" >&2 'builtin' 'unset' '_ghostty_file' return } From da014d98cd58f8bec540a6dc7aae24081532a3e3 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 1 Dec 2025 19:07:50 -0500 Subject: [PATCH 23/49] zsh: improve ZDOTDIR documentation The main thing to emphasize is that end users should never source .zshenv directly; it's only meant to be used as part of our shell injection environment. At the moment, there's no way to guard against accidentally use, but we can consider making e.g. GHOSTTY_SHELL_FEATURES always defined in this environment to that it can be used to differentiate the cases. In practice, it's unlikely that people actually source this .zshenv script directly, so hopefully this additional documentation clarifies things well enough. --- src/shell-integration/README.md | 2 +- src/shell-integration/zsh/.zshenv | 7 ++++++- src/termio/shell_integration.zig | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 1fd11091d..2357a64f6 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -83,7 +83,7 @@ from the `zsh` directory. The existing `ZDOTDIR` is retained so that after loading the Ghostty shell integration the normal Zsh loading sequence occurs. -```bash +```zsh if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then source "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration fi diff --git a/src/shell-integration/zsh/.zshenv b/src/shell-integration/zsh/.zshenv index 4201b295c..437e7f5c4 100644 --- a/src/shell-integration/zsh/.zshenv +++ b/src/shell-integration/zsh/.zshenv @@ -15,11 +15,16 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +# This script is sourced automatically by zsh when ZDOTDIR is set to this +# directory. It therefore assumes it's running within our shell integration +# environment and should not be sourced manually (unlike ghostty-integration). +# # This file can get sourced with aliases enabled. To avoid alias expansion # we quote everything that can be quoted. Some aliases will still break us # though. -# Restore the original ZDOTDIR value. +# Restore the original ZDOTDIR value if GHOSTTY_ZSH_ZDOTDIR is set. +# Otherwise, unset the ZDOTDIR that was set during shell injection. if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then 'builtin' 'export' ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR" 'builtin' 'unset' 'GHOSTTY_ZSH_ZDOTDIR' diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 8b2648dbd..c2a637b80 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -659,12 +659,12 @@ fn setupZsh( resource_dir: []const u8, env: *EnvMap, ) !void { - // Preserve the old zdotdir value so we can recover it. + // Preserve an existing ZDOTDIR value. We're about to overwrite it. if (env.get("ZDOTDIR")) |old| { try env.put("GHOSTTY_ZSH_ZDOTDIR", old); } - // Set our new ZDOTDIR + // Set our new ZDOTDIR to point to our shell resource directory. var path_buf: [std.fs.max_path_bytes]u8 = undefined; const integ_dir = try std.fmt.bufPrint( &path_buf, From 7fe3f5cd3f39f0566ff5ff9babca35e3b91a6675 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Mon, 1 Dec 2025 18:23:37 -0600 Subject: [PATCH 24/49] build: fix path access to work with relative build roots Replace std.fs.accessAbsolute(b.pathFromRoot(...)) with b.build_root.handle.access(...) since pathFromRoot can return relative paths, but accessAbsolute asserts the path is absolute. --- src/build/GhosttyDist.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/build/GhosttyDist.zig b/src/build/GhosttyDist.zig index 092322689..600aa4883 100644 --- a/src/build/GhosttyDist.zig +++ b/src/build/GhosttyDist.zig @@ -170,11 +170,11 @@ pub const Resource = struct { /// Returns true if the dist path exists at build time. pub fn exists(self: *const Resource, b: *std.Build) bool { - if (std.fs.accessAbsolute(b.pathFromRoot(self.dist), .{})) { + if (b.build_root.handle.access(self.dist, .{})) { // If we have a ".git" directory then we're a git checkout // and we never want to use the dist path. This shouldn't happen // so show a warning to the user. - if (std.fs.accessAbsolute(b.pathFromRoot(".git"), .{})) { + if (b.build_root.handle.access(".git", .{})) { std.log.warn( "dist resource '{s}' should not be in a git checkout", .{self.dist}, From 6babcc97f59b1e02d07379228b578bbdb76ef0fb Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 1 Dec 2025 20:34:55 -0500 Subject: [PATCH 25/49] zsh: move version check to ghostty-integration The ghostty-integration script can be manually sourced, and it uses the Zsh 5.1+ features, so that's a better place to guard against older Zsh versions. This also keeps the .zshenv script focused on just bootstrapping our automatic shell integration. I also changed the version check to a slightly more idiomatic pattern. --- src/shell-integration/README.md | 2 ++ src/shell-integration/zsh/.zshenv | 5 ----- src/shell-integration/zsh/ghostty-integration | 9 +++++++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 1fd11091d..2ac388644 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -88,3 +88,5 @@ if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then source "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration fi ``` + +Shell integration requires Zsh 5.1+. diff --git a/src/shell-integration/zsh/.zshenv b/src/shell-integration/zsh/.zshenv index 4201b295c..4ed96cd79 100644 --- a/src/shell-integration/zsh/.zshenv +++ b/src/shell-integration/zsh/.zshenv @@ -43,11 +43,6 @@ fi [[ ! -r "$_ghostty_file" ]] || 'builtin' 'source' '--' "$_ghostty_file" } always { if [[ -o 'interactive' ]]; then - 'builtin' 'autoload' '--' 'is-at-least' 2>/dev/null && 'is-at-least' "5.1" || { - 'builtin' 'echo' "zsh ${ZSH_VERSION} is too old for ghostty shell integration" >&2 - 'builtin' 'unset' '_ghostty_file' - return - } # ${(%):-%x} is the path to the current file. # On top of it we add :A:h to get the directory. 'builtin' 'typeset' _ghostty_file="${${(%):-%x}:A:h}"/ghostty-integration diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 27ef39bbc..7ff43efd9 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -1,3 +1,5 @@ +# vim:ft=zsh +# # Based on (started as) a copy of Kitty's zsh integration. Kitty is # distributed under GPLv3, so this file is also distributed under GPLv3. # The license header is reproduced below: @@ -41,6 +43,13 @@ _entrypoint() { [[ -o interactive ]] || builtin return 0 # non-interactive shell (( ! $+_ghostty_state )) || builtin return 0 # already initialized + # We require zsh 5.1+ (released Sept 2015) for features like functions_source, + # introspection arrays, and array pattern substitution. + if ! { builtin autoload -- is-at-least 2>/dev/null && is-at-least 5.1; }; then + builtin echo "Zsh ${ZSH_VERSION} is too old for ghostty shell integration (5.1+ required)" >&2 + builtin return 1 + fi + # 0: no OSC 133 [AC] marks have been written yet. # 1: the last written OSC 133 C has not been closed with D yet. # 2: none of the above. From 56d4e6d955906255b727593d106099eb210941fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 03:08:36 +0000 Subject: [PATCH 26/49] build(deps): bump softprops/action-gh-release from 2.4.2 to 2.5.0 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.4.2 to 2.5.0. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/5be0e66d93ac7ed76da52eca8bb058f665c3a5fe...a06a81a03ee405af7f2048a818ed3f03bbf83c7b) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 2.5.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/release-tip.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index a8a7f641f..f88df3440 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -186,7 +186,7 @@ jobs: nix develop -c minisign -S -m ghostty-source.tar.gz -s minisign.key < minisign.password - name: Update Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -356,7 +356,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -583,7 +583,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -767,7 +767,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true From d926bd5376c582e83874be374630f1d19f53785f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 00:07:23 +0000 Subject: [PATCH 27/49] build(deps): bump actions/checkout from 6.0.0 to 6.0.1 Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.0 to 6.0.1. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/1af3b93b6815bc44a9784bd300feb67ff0d1eeb3...8e8c483db84b4bee98b60c0593521ed34d9990e8) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.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 | 8 +-- .github/workflows/release-tip.yml | 18 +++---- .github/workflows/test.yml | 62 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 5 files changed, 46 insertions(+), 46 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index d8b9d2c18..825cf52f5 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -34,7 +34,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 50892a151..82970a065 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -56,7 +56,7 @@ jobs: fi - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -80,7 +80,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -132,7 +132,7 @@ jobs: GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: DeterminateSystems/nix-installer-action@main with: @@ -306,7 +306,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Download macOS Artifacts uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index f88df3440..df73198d1 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -29,7 +29,7 @@ jobs: commit: ${{ steps.extract_build_info.outputs.commit }} commit_long: ${{ steps.extract_build_info.outputs.commit_long }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -66,7 +66,7 @@ jobs: needs: [setup, build-macos] if: needs.setup.outputs.should_skip != 'true' steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Tip Tag run: | git config user.name "github-actions[bot]" @@ -81,7 +81,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install sentry-cli run: | @@ -104,7 +104,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install sentry-cli run: | @@ -127,7 +127,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install sentry-cli run: | @@ -159,7 +159,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -217,7 +217,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -451,7 +451,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 @@ -635,7 +635,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Important so that build number generation works fetch-depth: 0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9b6acd385..916745f58 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,7 +69,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -112,7 +112,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -145,7 +145,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -179,7 +179,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -222,7 +222,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -258,7 +258,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -287,7 +287,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -320,7 +320,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -366,7 +366,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -404,7 +404,7 @@ jobs: needs: [build-dist, build-snap] steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Trigger Snap workflow run: | @@ -421,7 +421,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -464,7 +464,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -509,7 +509,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # This could be from a script if we wanted to but inlining here for now # in one place. @@ -580,7 +580,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Get required Zig version id: zig @@ -627,7 +627,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -675,7 +675,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -710,7 +710,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -737,7 +737,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -774,7 +774,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -804,7 +804,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -832,7 +832,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -859,7 +859,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -886,7 +886,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -913,7 +913,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -940,7 +940,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -974,7 +974,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -1001,7 +1001,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -1035,7 +1035,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -1104,7 +1104,7 @@ jobs: runs-on: ${{ matrix.variant.runner }} needs: test steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 with: bundle: com.mitchellh.ghostty @@ -1123,7 +1123,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -1162,7 +1162,7 @@ jobs: # timeout-minutes: 10 # steps: # - name: Checkout Ghostty - # uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + # uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # # - name: Start SSH # run: | diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index b641c0bc9..b9ff89c35 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -17,7 +17,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 From 5bc78d59fb27ddab4c39ab8617ac8120d4289c5f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 1 Dec 2025 11:19:26 -0800 Subject: [PATCH 28/49] terminal/tmux: add more control mode parsing keys --- src/terminal/dcs.zig | 22 ++---- src/terminal/tmux.zig | 172 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 176 insertions(+), 18 deletions(-) diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 52f696131..447905d24 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -26,7 +26,7 @@ pub const Handler = struct { assert(self.state == .inactive); // Initialize our state to ignore in case of error - self.state = .{ .ignore = {} }; + self.state = .ignore; // Try to parse the hook. const hk_ = self.tryHook(alloc, dcs) catch |err| { @@ -70,7 +70,7 @@ pub const Handler = struct { ), }, }, - .command = .{ .tmux = .{ .enter = {} } }, + .command = .{ .tmux = .enter }, }; }, @@ -116,7 +116,7 @@ pub const Handler = struct { // On error we just discard our state and ignore the rest log.info("error putting byte into DCS handler err={}", .{err}); self.discard(); - self.state = .{ .ignore = {} }; + self.state = .ignore; return null; }; } @@ -158,7 +158,7 @@ pub const Handler = struct { // Note: we do NOT call deinit here on purpose because some commands // transfer memory ownership. If state needs cleanup, the switch // prong below should handle it. - defer self.state = .{ .inactive = {} }; + defer self.state = .inactive; return switch (self.state) { .inactive, @@ -167,7 +167,7 @@ pub const Handler = struct { .tmux => if (comptime build_options.tmux_control_mode) tmux: { self.state.deinit(); - break :tmux .{ .tmux = .{ .exit = {} } }; + break :tmux .{ .tmux = .exit }; } else unreachable, .xtgettcap => |*list| xtgettcap: { @@ -200,7 +200,7 @@ pub const Handler = struct { fn discard(self: *Handler) void { self.state.deinit(); - self.state = .{ .inactive = {} }; + self.state = .inactive; } }; @@ -255,21 +255,15 @@ pub const Command = union(enum) { decstbm, decslrm, }; - - /// Tmux control mode - pub const Tmux = union(enum) { - enter: void, - exit: void, - }; }; const State = union(enum) { /// We're not in a DCS state at the moment. - inactive: void, + inactive, /// We're hooked, but its an unknown DCS command or one that went /// invalid due to some bad input, so we're ignoring the rest. - ignore: void, + ignore, /// XTGETTCAP xtgettcap: std.Io.Writer.Allocating, diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 56d4c5fe2..f1eb178fe 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -271,6 +271,90 @@ pub const Client = struct { // Important: do not clear buffer here since name points to it self.state = .idle; return .{ .window_renamed = .{ .id = id, .name = name } }; + } else if (std.mem.eql(u8, cmd, "%window-pane-changed")) cmd: { + var re = try oni.Regex.init( + "^%window-pane-changed @([0-9]+) %([0-9]+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const window_id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const pane_id = std.fmt.parseInt( + usize, + line[@intCast(starts[2])..@intCast(ends[2])], + 10, + ) catch unreachable; + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .window_pane_changed = .{ .window_id = window_id, .pane_id = pane_id } }; + } else if (std.mem.eql(u8, cmd, "%client-detached")) cmd: { + var re = try oni.Regex.init( + "^%client-detached (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const client = line[@intCast(starts[1])..@intCast(ends[1])]; + + // Important: do not clear buffer here since client points to it + self.state = .idle; + return .{ .client_detached = .{ .client = client } }; + } else if (std.mem.eql(u8, cmd, "%client-session-changed")) cmd: { + var re = try oni.Regex.init( + "^%client-session-changed (.+) \\$([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const client = line[@intCast(starts[1])..@intCast(ends[1])]; + const session_id = std.fmt.parseInt( + usize, + line[@intCast(starts[2])..@intCast(ends[2])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[3])..@intCast(ends[3])]; + + // Important: do not clear buffer here since client/name point to it + self.state = .idle; + return .{ .client_session_changed = .{ .client = client, .session_id = session_id, .name = name } }; } else { // Unknown notification, log it and return to idle state. log.warn("unknown tmux control mode notification={s}", .{cmd}); @@ -291,34 +375,75 @@ pub const Client = struct { }; /// Possible notification types from tmux control mode. These are documented -/// in tmux(1). +/// in tmux(1). A lot of the simple documentation was copied from that man +/// page here. pub const Notification = union(enum) { - enter: void, - exit: void, + /// Entering tmux control mode. This isn't an actual event sent by + /// tmux but is one sent by us to indicate that we have detected that + /// tmux control mode is starting. + enter, + /// Exit. + /// + /// NOTE: The tmux protocol contains a "reason" string (human friendly) + /// associated with this. We currently drop it because we don't need it + /// but this may be something we want to add later. If we do add it, + /// we have to consider buffer limits and how we handle those (dropping + /// vs truncating, etc.). + exit, + + /// Dispatched at the end of a begin/end block with the raw data. + /// The control mode parser can't parse the data because it is unaware + /// of the command that was sent to trigger this output. block_end: []const u8, block_err: []const u8, + /// Raw output from a pane. output: struct { pane_id: usize, data: []const u8, // unescaped }, + /// The client is now attached to the session with ID session-id, which is + /// named name. session_changed: struct { id: usize, name: []const u8, }, - sessions_changed: void, + /// A session was created or destroyed. + sessions_changed, + /// The window with ID window-id was linked to the current session. window_add: struct { id: usize, }, + /// The window with ID window-id was renamed to name. window_renamed: struct { id: usize, name: []const u8, }, + + /// The active pane in the window with ID window-id changed to the pane + /// with ID pane-id. + window_pane_changed: struct { + window_id: usize, + pane_id: usize, + }, + + /// The client has detached. + client_detached: struct { + client: []const u8, + }, + + /// The client is now attached to the session with ID session-id, which is + /// named name. + client_session_changed: struct { + client: []const u8, + session_id: usize, + name: []const u8, + }, }; test "tmux begin/end empty" { @@ -433,3 +558,42 @@ test "tmux window-renamed" { try testing.expectEqual(42, n.window_renamed.id); try testing.expectEqualStrings("bar", n.window_renamed.name); } + +test "tmux window-pane-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Client = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-pane-changed @42 %2") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_pane_changed); + try testing.expectEqual(42, n.window_pane_changed.window_id); + try testing.expectEqual(2, n.window_pane_changed.pane_id); +} + +test "tmux client-detached" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Client = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%client-detached /dev/pts/1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .client_detached); + try testing.expectEqualStrings("/dev/pts/1", n.client_detached.client); +} + +test "tmux client-session-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Client = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%client-session-changed /dev/pts/1 $2 mysession") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .client_session_changed); + try testing.expectEqualStrings("/dev/pts/1", n.client_session_changed.client); + try testing.expectEqual(2, n.client_session_changed.session_id); + try testing.expectEqualStrings("mysession", n.client_session_changed.name); +} From 6e016ea81e5fee07cce9a329581c7d47e6fd2aa1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 1 Dec 2025 11:38:15 -0800 Subject: [PATCH 29/49] terminal: move tmux into folder --- src/terminal/dcs.zig | 4 +- src/terminal/tmux.zig | 602 +--------------------------------- src/terminal/tmux/control.zig | 602 ++++++++++++++++++++++++++++++++++ 3 files changed, 610 insertions(+), 598 deletions(-) create mode 100644 src/terminal/tmux/control.zig diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 447905d24..425325d4a 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -213,7 +213,7 @@ pub const Command = union(enum) { /// Tmux control mode tmux: if (build_options.tmux_control_mode) - terminal.tmux.Notification + terminal.tmux.ControlNotification else void, @@ -276,7 +276,7 @@ const State = union(enum) { /// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode tmux: if (build_options.tmux_control_mode) - terminal.tmux.Client + terminal.tmux.ControlParser else void, diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index f1eb178fe..a6538ea50 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -1,599 +1,9 @@ -//! This file contains the implementation for tmux control mode. See -//! tmux(1) for more information on control mode. Some basics are documented -//! here but this is not meant to be a comprehensive source of protocol -//! documentation. +//! Types and functions related to tmux protocols. -const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; -const oni = @import("oniguruma"); +const control = @import("tmux/control.zig"); +pub const ControlParser = control.Parser; +pub const ControlNotification = control.Notification; -const log = std.log.scoped(.terminal_tmux); - -/// A tmux control mode client. It is expected that the caller establishes -/// the connection in some way (i.e. detects the opening DCS sequence). This -/// just works on a byte stream. -pub const Client = struct { - /// Current state of the client. - state: State = .idle, - - /// The buffer used to store in-progress notifications, output, etc. - buffer: std.Io.Writer.Allocating, - - /// The maximum size in bytes of the buffer. This is used to limit - /// memory usage. If the buffer exceeds this size, the client will - /// enter a broken state (the control mode session will be forcibly - /// exited and future data dropped). - max_bytes: usize = 1024 * 1024, - - const State = enum { - /// Outside of any active notifications. This should drop any output - /// unless it is '%' on the first byte of a line. The buffer will be - /// cleared when it sees '%', this is so that the previous notification - /// data is valid until we receive/process new data. - idle, - - /// We experienced unexpected input and are in a broken state - /// so we cannot continue processing. When this state is set, - /// the buffer has been deinited and must not be accessed. - broken, - - /// Inside an active notification (started with '%'). - notification, - - /// Inside a begin/end block. - block, - }; - - pub fn deinit(self: *Client) void { - // If we're in a broken state, we already deinited - // the buffer, so we don't need to do anything. - if (self.state == .broken) return; - - self.buffer.deinit(); - } - - // Handle a byte of input. - pub fn put(self: *Client, byte: u8) !?Notification { - // If we're in a broken state, just do nothing. - // - // We have to do this check here before we check the buffer, because if - // we're in a broken state then we'd have already deinited the buffer. - if (self.state == .broken) return null; - - if (self.buffer.written().len >= self.max_bytes) { - self.broken(); - return error.OutOfMemory; - } - - switch (self.state) { - // Drop because we're in a broken state. - .broken => return null, - - // Waiting for a notification so if the byte is not '%' then - // we're in a broken state. Control mode output should always - // be wrapped in '%begin/%end' orelse we expect a notification. - // Return an exit notification. - .idle => if (byte != '%') { - self.broken(); - return .{ .exit = {} }; - } else { - self.buffer.clearRetainingCapacity(); - self.state = .notification; - }, - - // If we're in a notification and its not a newline then - // we accumulate. If it is a newline then we have a - // complete notification we need to parse. - .notification => if (byte == '\n') { - // We have a complete notification, parse it. - return try self.parseNotification(); - }, - - // If we're in a block then we accumulate until we see a newline - // and then we check to see if that line ended the block. - .block => if (byte == '\n') { - const written = self.buffer.written(); - const idx = if (std.mem.lastIndexOfScalar( - u8, - written, - '\n', - )) |v| v + 1 else 0; - const line = written[idx..]; - - if (std.mem.startsWith(u8, line, "%end") or - std.mem.startsWith(u8, line, "%error")) - { - const err = std.mem.startsWith(u8, line, "%error"); - const output = std.mem.trimRight(u8, written[0..idx], "\r\n"); - - // If it is an error then log it. - if (err) log.warn("tmux control mode error={s}", .{output}); - - // Important: do not clear buffer since the notification - // contains it. - self.state = .idle; - return if (err) .{ .block_err = output } else .{ .block_end = output }; - } - - // Didn't end the block, continue accumulating. - }, - } - - try self.buffer.writer.writeByte(byte); - - return null; - } - - fn parseNotification(self: *Client) !?Notification { - assert(self.state == .notification); - - const line = line: { - var line = self.buffer.written(); - if (line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; - break :line line; - }; - const cmd = cmd: { - const idx = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; - break :cmd line[0..idx]; - }; - - // The notification MUST exist because we guard entering the notification - // state on seeing at least a '%'. - if (std.mem.eql(u8, cmd, "%begin")) { - // We don't use the rest of the tokens for now because tmux - // claims to guarantee that begin/end are always in order and - // never intermixed. In the future, we should probably validate - // this. - // TODO(tmuxcc): do this before merge? - - // Move to block state because we expect a corresponding end/error - // and want to accumulate the data. - self.state = .block; - self.buffer.clearRetainingCapacity(); - return null; - } else if (std.mem.eql(u8, cmd, "%output")) cmd: { - var re = try oni.Regex.init( - "^%output %([0-9]+) (.+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - const data = line[@intCast(starts[2])..@intCast(ends[2])]; - - // Important: do not clear buffer here since name points to it - self.state = .idle; - return .{ .output = .{ .pane_id = id, .data = data } }; - } else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: { - var re = try oni.Regex.init( - "^%session-changed \\$([0-9]+) (.+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - const name = line[@intCast(starts[2])..@intCast(ends[2])]; - - // Important: do not clear buffer here since name points to it - self.state = .idle; - return .{ .session_changed = .{ .id = id, .name = name } }; - } else if (std.mem.eql(u8, cmd, "%sessions-changed")) cmd: { - if (!std.mem.eql(u8, line, "%sessions-changed")) { - log.warn("failed to match notification cmd={s} line=\"{s}\"", .{ cmd, line }); - break :cmd; - } - - self.buffer.clearRetainingCapacity(); - self.state = .idle; - return .{ .sessions_changed = {} }; - } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: { - var re = try oni.Regex.init( - "^%window-add @([0-9]+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - - self.buffer.clearRetainingCapacity(); - self.state = .idle; - return .{ .window_add = .{ .id = id } }; - } else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: { - var re = try oni.Regex.init( - "^%window-renamed @([0-9]+) (.+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - const name = line[@intCast(starts[2])..@intCast(ends[2])]; - - // Important: do not clear buffer here since name points to it - self.state = .idle; - return .{ .window_renamed = .{ .id = id, .name = name } }; - } else if (std.mem.eql(u8, cmd, "%window-pane-changed")) cmd: { - var re = try oni.Regex.init( - "^%window-pane-changed @([0-9]+) %([0-9]+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const window_id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - const pane_id = std.fmt.parseInt( - usize, - line[@intCast(starts[2])..@intCast(ends[2])], - 10, - ) catch unreachable; - - self.buffer.clearRetainingCapacity(); - self.state = .idle; - return .{ .window_pane_changed = .{ .window_id = window_id, .pane_id = pane_id } }; - } else if (std.mem.eql(u8, cmd, "%client-detached")) cmd: { - var re = try oni.Regex.init( - "^%client-detached (.+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const client = line[@intCast(starts[1])..@intCast(ends[1])]; - - // Important: do not clear buffer here since client points to it - self.state = .idle; - return .{ .client_detached = .{ .client = client } }; - } else if (std.mem.eql(u8, cmd, "%client-session-changed")) cmd: { - var re = try oni.Regex.init( - "^%client-session-changed (.+) \\$([0-9]+) (.+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const client = line[@intCast(starts[1])..@intCast(ends[1])]; - const session_id = std.fmt.parseInt( - usize, - line[@intCast(starts[2])..@intCast(ends[2])], - 10, - ) catch unreachable; - const name = line[@intCast(starts[3])..@intCast(ends[3])]; - - // Important: do not clear buffer here since client/name point to it - self.state = .idle; - return .{ .client_session_changed = .{ .client = client, .session_id = session_id, .name = name } }; - } else { - // Unknown notification, log it and return to idle state. - log.warn("unknown tmux control mode notification={s}", .{cmd}); - } - - // Unknown command. Clear the buffer and return to idle state. - self.buffer.clearRetainingCapacity(); - self.state = .idle; - - return null; - } - - // Mark the tmux state as broken. - fn broken(self: *Client) void { - self.state = .broken; - self.buffer.deinit(); - } -}; - -/// Possible notification types from tmux control mode. These are documented -/// in tmux(1). A lot of the simple documentation was copied from that man -/// page here. -pub const Notification = union(enum) { - /// Entering tmux control mode. This isn't an actual event sent by - /// tmux but is one sent by us to indicate that we have detected that - /// tmux control mode is starting. - enter, - - /// Exit. - /// - /// NOTE: The tmux protocol contains a "reason" string (human friendly) - /// associated with this. We currently drop it because we don't need it - /// but this may be something we want to add later. If we do add it, - /// we have to consider buffer limits and how we handle those (dropping - /// vs truncating, etc.). - exit, - - /// Dispatched at the end of a begin/end block with the raw data. - /// The control mode parser can't parse the data because it is unaware - /// of the command that was sent to trigger this output. - block_end: []const u8, - block_err: []const u8, - - /// Raw output from a pane. - output: struct { - pane_id: usize, - data: []const u8, // unescaped - }, - - /// The client is now attached to the session with ID session-id, which is - /// named name. - session_changed: struct { - id: usize, - name: []const u8, - }, - - /// A session was created or destroyed. - sessions_changed, - - /// The window with ID window-id was linked to the current session. - window_add: struct { - id: usize, - }, - - /// The window with ID window-id was renamed to name. - window_renamed: struct { - id: usize, - name: []const u8, - }, - - /// The active pane in the window with ID window-id changed to the pane - /// with ID pane-id. - window_pane_changed: struct { - window_id: usize, - pane_id: usize, - }, - - /// The client has detached. - client_detached: struct { - client: []const u8, - }, - - /// The client is now attached to the session with ID session-id, which is - /// named name. - client_session_changed: struct { - client: []const u8, - session_id: usize, - name: []const u8, - }, -}; - -test "tmux begin/end empty" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); - for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .block_end); - try testing.expectEqualStrings("", n.block_end); -} - -test "tmux begin/error empty" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); - for ("%error 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .block_err); - try testing.expectEqualStrings("", n.block_err); -} - -test "tmux begin/end data" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); - for ("hello\nworld\n") |byte| try testing.expect(try c.put(byte) == null); - for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .block_end); - try testing.expectEqualStrings("hello\nworld", n.block_end); -} - -test "tmux output" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%output %42 foo bar baz") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .output); - try testing.expectEqual(42, n.output.pane_id); - try testing.expectEqualStrings("foo bar baz", n.output.data); -} - -test "tmux session-changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%session-changed $42 foo") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .session_changed); - try testing.expectEqual(42, n.session_changed.id); - try testing.expectEqualStrings("foo", n.session_changed.name); -} - -test "tmux sessions-changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%sessions-changed") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .sessions_changed); -} - -test "tmux sessions-changed carriage return" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%sessions-changed\r") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .sessions_changed); -} - -test "tmux window-add" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%window-add @14") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .window_add); - try testing.expectEqual(14, n.window_add.id); -} - -test "tmux window-renamed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%window-renamed @42 bar") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .window_renamed); - try testing.expectEqual(42, n.window_renamed.id); - try testing.expectEqualStrings("bar", n.window_renamed.name); -} - -test "tmux window-pane-changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%window-pane-changed @42 %2") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .window_pane_changed); - try testing.expectEqual(42, n.window_pane_changed.window_id); - try testing.expectEqual(2, n.window_pane_changed.pane_id); -} - -test "tmux client-detached" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%client-detached /dev/pts/1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .client_detached); - try testing.expectEqualStrings("/dev/pts/1", n.client_detached.client); -} - -test "tmux client-session-changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%client-session-changed /dev/pts/1 $2 mysession") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .client_session_changed); - try testing.expectEqualStrings("/dev/pts/1", n.client_session_changed.client); - try testing.expectEqual(2, n.client_session_changed.session_id); - try testing.expectEqualStrings("mysession", n.client_session_changed.name); +test { + @import("std").testing.refAllDecls(@This()); } diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig new file mode 100644 index 000000000..8304b2f1f --- /dev/null +++ b/src/terminal/tmux/control.zig @@ -0,0 +1,602 @@ +//! This file contains the implementation for tmux control mode. See +//! tmux(1) for more information on control mode. Some basics are documented +//! here but this is not meant to be a comprehensive source of protocol +//! documentation. + +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; +const oni = @import("oniguruma"); + +const log = std.log.scoped(.terminal_tmux); + +/// A tmux control mode parser. This takes in output from tmux control +/// mode and parses it into a structured notifications. +/// +/// It is up to the caller to establish the connection to the tmux +/// control mode session in some way (e.g. via exec, a network socket, +/// whatever). This is fully agnostic to how the data is received and sent. +pub const Parser = struct { + /// Current state of the client. + state: State = .idle, + + /// The buffer used to store in-progress notifications, output, etc. + buffer: std.Io.Writer.Allocating, + + /// The maximum size in bytes of the buffer. This is used to limit + /// memory usage. If the buffer exceeds this size, the client will + /// enter a broken state (the control mode session will be forcibly + /// exited and future data dropped). + max_bytes: usize = 1024 * 1024, + + const State = enum { + /// Outside of any active notifications. This should drop any output + /// unless it is '%' on the first byte of a line. The buffer will be + /// cleared when it sees '%', this is so that the previous notification + /// data is valid until we receive/process new data. + idle, + + /// We experienced unexpected input and are in a broken state + /// so we cannot continue processing. When this state is set, + /// the buffer has been deinited and must not be accessed. + broken, + + /// Inside an active notification (started with '%'). + notification, + + /// Inside a begin/end block. + block, + }; + + pub fn deinit(self: *Parser) void { + // If we're in a broken state, we already deinited + // the buffer, so we don't need to do anything. + if (self.state == .broken) return; + + self.buffer.deinit(); + } + + // Handle a byte of input. + pub fn put(self: *Parser, byte: u8) !?Notification { + // If we're in a broken state, just do nothing. + // + // We have to do this check here before we check the buffer, because if + // we're in a broken state then we'd have already deinited the buffer. + if (self.state == .broken) return null; + + if (self.buffer.written().len >= self.max_bytes) { + self.broken(); + return error.OutOfMemory; + } + + switch (self.state) { + // Drop because we're in a broken state. + .broken => return null, + + // Waiting for a notification so if the byte is not '%' then + // we're in a broken state. Control mode output should always + // be wrapped in '%begin/%end' orelse we expect a notification. + // Return an exit notification. + .idle => if (byte != '%') { + self.broken(); + return .{ .exit = {} }; + } else { + self.buffer.clearRetainingCapacity(); + self.state = .notification; + }, + + // If we're in a notification and its not a newline then + // we accumulate. If it is a newline then we have a + // complete notification we need to parse. + .notification => if (byte == '\n') { + // We have a complete notification, parse it. + return try self.parseNotification(); + }, + + // If we're in a block then we accumulate until we see a newline + // and then we check to see if that line ended the block. + .block => if (byte == '\n') { + const written = self.buffer.written(); + const idx = if (std.mem.lastIndexOfScalar( + u8, + written, + '\n', + )) |v| v + 1 else 0; + const line = written[idx..]; + + if (std.mem.startsWith(u8, line, "%end") or + std.mem.startsWith(u8, line, "%error")) + { + const err = std.mem.startsWith(u8, line, "%error"); + const output = std.mem.trimRight(u8, written[0..idx], "\r\n"); + + // If it is an error then log it. + if (err) log.warn("tmux control mode error={s}", .{output}); + + // Important: do not clear buffer since the notification + // contains it. + self.state = .idle; + return if (err) .{ .block_err = output } else .{ .block_end = output }; + } + + // Didn't end the block, continue accumulating. + }, + } + + try self.buffer.writer.writeByte(byte); + + return null; + } + + fn parseNotification(self: *Parser) !?Notification { + assert(self.state == .notification); + + const line = line: { + var line = self.buffer.written(); + if (line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; + break :line line; + }; + const cmd = cmd: { + const idx = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; + break :cmd line[0..idx]; + }; + + // The notification MUST exist because we guard entering the notification + // state on seeing at least a '%'. + if (std.mem.eql(u8, cmd, "%begin")) { + // We don't use the rest of the tokens for now because tmux + // claims to guarantee that begin/end are always in order and + // never intermixed. In the future, we should probably validate + // this. + // TODO(tmuxcc): do this before merge? + + // Move to block state because we expect a corresponding end/error + // and want to accumulate the data. + self.state = .block; + self.buffer.clearRetainingCapacity(); + return null; + } else if (std.mem.eql(u8, cmd, "%output")) cmd: { + var re = try oni.Regex.init( + "^%output %([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const data = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .output = .{ .pane_id = id, .data = data } }; + } else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: { + var re = try oni.Regex.init( + "^%session-changed \\$([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .session_changed = .{ .id = id, .name = name } }; + } else if (std.mem.eql(u8, cmd, "%sessions-changed")) cmd: { + if (!std.mem.eql(u8, line, "%sessions-changed")) { + log.warn("failed to match notification cmd={s} line=\"{s}\"", .{ cmd, line }); + break :cmd; + } + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .sessions_changed = {} }; + } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: { + var re = try oni.Regex.init( + "^%window-add @([0-9]+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .window_add = .{ .id = id } }; + } else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: { + var re = try oni.Regex.init( + "^%window-renamed @([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .window_renamed = .{ .id = id, .name = name } }; + } else if (std.mem.eql(u8, cmd, "%window-pane-changed")) cmd: { + var re = try oni.Regex.init( + "^%window-pane-changed @([0-9]+) %([0-9]+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const window_id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const pane_id = std.fmt.parseInt( + usize, + line[@intCast(starts[2])..@intCast(ends[2])], + 10, + ) catch unreachable; + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .window_pane_changed = .{ .window_id = window_id, .pane_id = pane_id } }; + } else if (std.mem.eql(u8, cmd, "%client-detached")) cmd: { + var re = try oni.Regex.init( + "^%client-detached (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const client = line[@intCast(starts[1])..@intCast(ends[1])]; + + // Important: do not clear buffer here since client points to it + self.state = .idle; + return .{ .client_detached = .{ .client = client } }; + } else if (std.mem.eql(u8, cmd, "%client-session-changed")) cmd: { + var re = try oni.Regex.init( + "^%client-session-changed (.+) \\$([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const client = line[@intCast(starts[1])..@intCast(ends[1])]; + const session_id = std.fmt.parseInt( + usize, + line[@intCast(starts[2])..@intCast(ends[2])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[3])..@intCast(ends[3])]; + + // Important: do not clear buffer here since client/name point to it + self.state = .idle; + return .{ .client_session_changed = .{ .client = client, .session_id = session_id, .name = name } }; + } else { + // Unknown notification, log it and return to idle state. + log.warn("unknown tmux control mode notification={s}", .{cmd}); + } + + // Unknown command. Clear the buffer and return to idle state. + self.buffer.clearRetainingCapacity(); + self.state = .idle; + + return null; + } + + // Mark the tmux state as broken. + fn broken(self: *Parser) void { + self.state = .broken; + self.buffer.deinit(); + } +}; + +/// Possible notification types from tmux control mode. These are documented +/// in tmux(1). A lot of the simple documentation was copied from that man +/// page here. +pub const Notification = union(enum) { + /// Entering tmux control mode. This isn't an actual event sent by + /// tmux but is one sent by us to indicate that we have detected that + /// tmux control mode is starting. + enter, + + /// Exit. + /// + /// NOTE: The tmux protocol contains a "reason" string (human friendly) + /// associated with this. We currently drop it because we don't need it + /// but this may be something we want to add later. If we do add it, + /// we have to consider buffer limits and how we handle those (dropping + /// vs truncating, etc.). + exit, + + /// Dispatched at the end of a begin/end block with the raw data. + /// The control mode parser can't parse the data because it is unaware + /// of the command that was sent to trigger this output. + block_end: []const u8, + block_err: []const u8, + + /// Raw output from a pane. + output: struct { + pane_id: usize, + data: []const u8, // unescaped + }, + + /// The client is now attached to the session with ID session-id, which is + /// named name. + session_changed: struct { + id: usize, + name: []const u8, + }, + + /// A session was created or destroyed. + sessions_changed, + + /// The window with ID window-id was linked to the current session. + window_add: struct { + id: usize, + }, + + /// The window with ID window-id was renamed to name. + window_renamed: struct { + id: usize, + name: []const u8, + }, + + /// The active pane in the window with ID window-id changed to the pane + /// with ID pane-id. + window_pane_changed: struct { + window_id: usize, + pane_id: usize, + }, + + /// The client has detached. + client_detached: struct { + client: []const u8, + }, + + /// The client is now attached to the session with ID session-id, which is + /// named name. + client_session_changed: struct { + client: []const u8, + session_id: usize, + name: []const u8, + }, +}; + +test "tmux begin/end empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_end); + try testing.expectEqualStrings("", n.block_end); +} + +test "tmux begin/error empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%error 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_err); + try testing.expectEqualStrings("", n.block_err); +} + +test "tmux begin/end data" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("hello\nworld\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_end); + try testing.expectEqualStrings("hello\nworld", n.block_end); +} + +test "tmux output" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%output %42 foo bar baz") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .output); + try testing.expectEqual(42, n.output.pane_id); + try testing.expectEqualStrings("foo bar baz", n.output.data); +} + +test "tmux session-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%session-changed $42 foo") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .session_changed); + try testing.expectEqual(42, n.session_changed.id); + try testing.expectEqualStrings("foo", n.session_changed.name); +} + +test "tmux sessions-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%sessions-changed") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .sessions_changed); +} + +test "tmux sessions-changed carriage return" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%sessions-changed\r") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .sessions_changed); +} + +test "tmux window-add" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-add @14") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_add); + try testing.expectEqual(14, n.window_add.id); +} + +test "tmux window-renamed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-renamed @42 bar") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_renamed); + try testing.expectEqual(42, n.window_renamed.id); + try testing.expectEqualStrings("bar", n.window_renamed.name); +} + +test "tmux window-pane-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-pane-changed @42 %2") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_pane_changed); + try testing.expectEqual(42, n.window_pane_changed.window_id); + try testing.expectEqual(2, n.window_pane_changed.pane_id); +} + +test "tmux client-detached" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%client-detached /dev/pts/1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .client_detached); + try testing.expectEqualStrings("/dev/pts/1", n.client_detached.client); +} + +test "tmux client-session-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%client-session-changed /dev/pts/1 $2 mysession") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .client_session_changed); + try testing.expectEqualStrings("/dev/pts/1", n.client_session_changed.client); + try testing.expectEqual(2, n.client_session_changed.session_id); + try testing.expectEqualStrings("mysession", n.client_session_changed.name); +} From 7a9dc77a94d05610ec576add0a6096985f83a17b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 1 Dec 2025 12:51:09 -0800 Subject: [PATCH 30/49] terminal/tmux: clean up error handling, explicit error sets --- src/terminal/tmux/control.zig | 74 ++++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig index 8304b2f1f..b41671aab 100644 --- a/src/terminal/tmux/control.zig +++ b/src/terminal/tmux/control.zig @@ -4,6 +4,7 @@ //! documentation. const std = @import("std"); +const Allocator = std.mem.Allocator; const assert = @import("../../quirks.zig").inlineAssert; const oni = @import("oniguruma"); @@ -56,7 +57,11 @@ pub const Parser = struct { } // Handle a byte of input. - pub fn put(self: *Parser, byte: u8) !?Notification { + // + // If we reach our byte limit this will return OutOfMemory. It only + // does this on the first time we exceed the limit; subsequent calls + // will return null as we drop all input in a broken state. + pub fn put(self: *Parser, byte: u8) Allocator.Error!?Notification { // If we're in a broken state, just do nothing. // // We have to do this check here before we check the buffer, because if @@ -89,7 +94,15 @@ pub const Parser = struct { // complete notification we need to parse. .notification => if (byte == '\n') { // We have a complete notification, parse it. - return try self.parseNotification(); + return self.parseNotification() catch { + // If parsing failed, then we do not mark the state + // as broken because we may be able to continue parsing + // other types of notifications. + // + // In the future we may want to emit a notification + // here about unknown or unsupported notifications. + return null; + }; }, // If we're in a block then we accumulate until we see a newline @@ -122,12 +135,16 @@ pub const Parser = struct { }, } - try self.buffer.writer.writeByte(byte); + self.buffer.writer.writeByte(byte) catch |err| switch (err) { + error.WriteFailed => return error.OutOfMemory, + }; return null; } - fn parseNotification(self: *Parser) !?Notification { + const ParseError = error{RegexError}; + + fn parseNotification(self: *Parser) ParseError!?Notification { assert(self.state == .notification); const line = line: { @@ -155,13 +172,16 @@ pub const Parser = struct { self.buffer.clearRetainingCapacity(); return null; } else if (std.mem.eql(u8, cmd, "%output")) cmd: { - var re = try oni.Regex.init( + var re = oni.Regex.init( "^%output %([0-9]+) (.+)$", .{ .capture_group = true }, oni.Encoding.utf8, oni.Syntax.default, null, - ); + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; defer re.deinit(); var region = re.search(line, .{}) catch |err| { @@ -183,13 +203,16 @@ pub const Parser = struct { self.state = .idle; return .{ .output = .{ .pane_id = id, .data = data } }; } else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: { - var re = try oni.Regex.init( + var re = oni.Regex.init( "^%session-changed \\$([0-9]+) (.+)$", .{ .capture_group = true }, oni.Encoding.utf8, oni.Syntax.default, null, - ); + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; defer re.deinit(); var region = re.search(line, .{}) catch |err| { @@ -220,13 +243,16 @@ pub const Parser = struct { self.state = .idle; return .{ .sessions_changed = {} }; } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: { - var re = try oni.Regex.init( + var re = oni.Regex.init( "^%window-add @([0-9]+)$", .{ .capture_group = true }, oni.Encoding.utf8, oni.Syntax.default, null, - ); + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; defer re.deinit(); var region = re.search(line, .{}) catch |err| { @@ -247,13 +273,16 @@ pub const Parser = struct { self.state = .idle; return .{ .window_add = .{ .id = id } }; } else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: { - var re = try oni.Regex.init( + var re = oni.Regex.init( "^%window-renamed @([0-9]+) (.+)$", .{ .capture_group = true }, oni.Encoding.utf8, oni.Syntax.default, null, - ); + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; defer re.deinit(); var region = re.search(line, .{}) catch |err| { @@ -275,13 +304,16 @@ pub const Parser = struct { self.state = .idle; return .{ .window_renamed = .{ .id = id, .name = name } }; } else if (std.mem.eql(u8, cmd, "%window-pane-changed")) cmd: { - var re = try oni.Regex.init( + var re = oni.Regex.init( "^%window-pane-changed @([0-9]+) %([0-9]+)$", .{ .capture_group = true }, oni.Encoding.utf8, oni.Syntax.default, null, - ); + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; defer re.deinit(); var region = re.search(line, .{}) catch |err| { @@ -307,13 +339,16 @@ pub const Parser = struct { self.state = .idle; return .{ .window_pane_changed = .{ .window_id = window_id, .pane_id = pane_id } }; } else if (std.mem.eql(u8, cmd, "%client-detached")) cmd: { - var re = try oni.Regex.init( + var re = oni.Regex.init( "^%client-detached (.+)$", .{ .capture_group = true }, oni.Encoding.utf8, oni.Syntax.default, null, - ); + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; defer re.deinit(); var region = re.search(line, .{}) catch |err| { @@ -330,13 +365,16 @@ pub const Parser = struct { self.state = .idle; return .{ .client_detached = .{ .client = client } }; } else if (std.mem.eql(u8, cmd, "%client-session-changed")) cmd: { - var re = try oni.Regex.init( + var re = oni.Regex.init( "^%client-session-changed (.+) \\$([0-9]+) (.+)$", .{ .capture_group = true }, oni.Encoding.utf8, oni.Syntax.default, null, - ); + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; defer re.deinit(); var region = re.search(line, .{}) catch |err| { From dfa22379b23ad612d34853aceacc6a7353ec31ca Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 2 Dec 2025 08:46:33 -0800 Subject: [PATCH 31/49] terminal/tmux: layout string parser --- src/terminal/tmux.zig | 2 + src/terminal/tmux/layout.zig | 459 +++++++++++++++++++++++++++++++++++ 2 files changed, 461 insertions(+) create mode 100644 src/terminal/tmux/layout.zig diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index a6538ea50..0e8c41262 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -1,8 +1,10 @@ //! Types and functions related to tmux protocols. const control = @import("tmux/control.zig"); +const layout = @import("tmux/layout.zig"); pub const ControlParser = control.Parser; pub const ControlNotification = control.Notification; +pub const Layout = layout.Layout; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/terminal/tmux/layout.zig b/src/terminal/tmux/layout.zig new file mode 100644 index 000000000..595738251 --- /dev/null +++ b/src/terminal/tmux/layout.zig @@ -0,0 +1,459 @@ +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +/// A tmux layout. +/// +/// This is a tree structure so by definition it pretty much needs to be +/// allocated. We leave allocation up to the user of this struct, but +/// a general recommendation is to use an arena allocator for simplicity +/// in freeing the entire layout at once. +pub const Layout = struct { + /// Width, height of the node + width: usize, + height: usize, + + /// X and Y offset from the top-left corner of the window. + x: usize, + y: usize, + + /// The content of this node, either a pane (leaf) or more nodes + /// (split) horizontally or vertically. + content: Content, + + pub const Content = union(enum) { + pane: usize, + horizontal: []const Layout, + vertical: []const Layout, + }; + + pub const ParseError = Allocator.Error || error{SyntaxError}; + + /// Parse a layout string into a Layout structure. The given allocator + /// will be used for all allocations within the layout. Note that + /// individual nodes can't be freed so this allocator must be some + /// kind of arena allocator. + /// + /// The layout string must be fully provided as a single string. + /// Layouts are generally small so this should not be a problem. + /// + /// Tmux layout strings have the following format: + /// + /// - WxH,X,Y,ID Leaf pane: width×height, x-offset, y-offset, pane ID + /// - WxH,X,Y{...} Horizontal split (left-right), children comma-separated + /// - WxH,X,Y[...] Vertical split (top-bottom), children comma-separated + pub fn parse(alloc: Allocator, str: []const u8) ParseError!Layout { + var offset: usize = 0; + const root = try parseNext( + alloc, + str, + &offset, + ); + if (offset != str.len) return error.SyntaxError; + return root; + } + + fn parseNext( + alloc: Allocator, + str: []const u8, + offset: *usize, + ) ParseError!Layout { + // Find the first `x` to grab the width. + const width: usize = if (std.mem.indexOfScalar( + u8, + str[offset.*..], + 'x', + )) |idx| width: { + defer offset.* += idx + 1; // Consume `x` + break :width std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + } else return error.SyntaxError; + + // Find the height, up to a comma. + const height: usize = if (std.mem.indexOfScalar( + u8, + str[offset.*..], + ',', + )) |idx| height: { + defer offset.* += idx + 1; // Consume `,` + break :height std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + } else return error.SyntaxError; + + // Find X + const x: usize = if (std.mem.indexOfScalar( + u8, + str[offset.*..], + ',', + )) |idx| x: { + defer offset.* += idx + 1; // Consume `,` + break :x std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + } else return error.SyntaxError; + + // Find Y, which can end in any of `,{,[` + const y: usize = if (std.mem.indexOfAny( + u8, + str[offset.*..], + ",{[", + )) |idx| y: { + defer offset.* += idx; // Don't consume the delimiter! + break :y std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + } else return error.SyntaxError; + + // Determine our child node. + const content: Layout.Content = switch (str[offset.*]) { + ',' => content: { + // Consume the delimiter + offset.* += 1; + + // Leaf pane. Read up to `,}]` because we may be in + // a set of nodes. If none exist, end of string is fine. + const idx = std.mem.indexOfAny( + u8, + str[offset.*..], + ",}]", + ) orelse str.len - offset.*; + + defer offset.* += idx; // Consume the pane ID, not the delimiter + const pane_id = std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + + break :content .{ .pane = pane_id }; + }, + + '{', '[' => |opening| content: { + var nodes: std.ArrayList(Layout) = .empty; + defer nodes.deinit(alloc); + + // Move beyond our opening + offset.* += 1; + + while (true) { + try nodes.append(alloc, try parseNext( + alloc, + str, + offset, + )); + + // We should not reach the end of string here because + // we expect a closing bracket. + if (offset.* >= str.len) return error.SyntaxError; + + // If it is a comma, we expect another node. + if (str[offset.*] == ',') { + offset.* += 1; // Consume + continue; + } + + // We expect a closing bracket now. + switch (opening) { + '{' => if (str[offset.*] != '}') return error.SyntaxError, + '[' => if (str[offset.*] != ']') return error.SyntaxError, + else => return error.SyntaxError, + } + + // Successfully parsed all children. + offset.* += 1; // Consume closing bracket + break :content switch (opening) { + '{' => .{ .horizontal = try nodes.toOwnedSlice(alloc) }, + '[' => .{ .vertical = try nodes.toOwnedSlice(alloc) }, + else => unreachable, + }; + } + }, + + // indexOfAny above guarantees we have only the above + else => unreachable, + }; + + return .{ + .width = width, + .height = height, + .x = x, + .y = y, + .content = content, + }; + } +}; + +test "simple single pane" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0,42"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + try testing.expectEqual(0, layout.x); + try testing.expectEqual(0, layout.y); + try testing.expectEqual(42, layout.content.pane); +} + +test "single pane with offset" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "40x12,10,5,7"); + try testing.expectEqual(40, layout.width); + try testing.expectEqual(12, layout.height); + try testing.expectEqual(10, layout.x); + try testing.expectEqual(5, layout.y); + try testing.expectEqual(7, layout.content.pane); +} + +test "single pane large values" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "1920x1080,100,200,999"); + try testing.expectEqual(1920, layout.width); + try testing.expectEqual(1080, layout.height); + try testing.expectEqual(100, layout.x); + try testing.expectEqual(200, layout.y); + try testing.expectEqual(999, layout.content.pane); +} + +test "horizontal split two panes" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0,1,40x24,40,0,2}"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + try testing.expectEqual(0, layout.x); + try testing.expectEqual(0, layout.y); + + const children = layout.content.horizontal; + try testing.expectEqual(2, children.len); + + try testing.expectEqual(40, children[0].width); + try testing.expectEqual(24, children[0].height); + try testing.expectEqual(0, children[0].x); + try testing.expectEqual(0, children[0].y); + try testing.expectEqual(1, children[0].content.pane); + + try testing.expectEqual(40, children[1].width); + try testing.expectEqual(24, children[1].height); + try testing.expectEqual(40, children[1].x); + try testing.expectEqual(0, children[1].y); + try testing.expectEqual(2, children[1].content.pane); +} + +test "vertical split two panes" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0[80x12,0,0,1,80x12,0,12,2]"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + try testing.expectEqual(0, layout.x); + try testing.expectEqual(0, layout.y); + + const children = layout.content.vertical; + try testing.expectEqual(2, children.len); + + try testing.expectEqual(80, children[0].width); + try testing.expectEqual(12, children[0].height); + try testing.expectEqual(0, children[0].x); + try testing.expectEqual(0, children[0].y); + try testing.expectEqual(1, children[0].content.pane); + + try testing.expectEqual(80, children[1].width); + try testing.expectEqual(12, children[1].height); + try testing.expectEqual(0, children[1].x); + try testing.expectEqual(12, children[1].y); + try testing.expectEqual(2, children[1].content.pane); +} + +test "horizontal split three panes" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "120x24,0,0{40x24,0,0,1,40x24,40,0,2,40x24,80,0,3}"); + try testing.expectEqual(120, layout.width); + try testing.expectEqual(24, layout.height); + + const children = layout.content.horizontal; + try testing.expectEqual(3, children.len); + try testing.expectEqual(1, children[0].content.pane); + try testing.expectEqual(2, children[1].content.pane); + try testing.expectEqual(3, children[2].content.pane); +} + +test "nested horizontal in vertical" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + // Vertical split with top pane and bottom horizontal split + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0[80x12,0,0,1,80x12,0,12{40x12,0,12,2,40x12,40,12,3}]"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + + const vert_children = layout.content.vertical; + try testing.expectEqual(2, vert_children.len); + + // First child is a simple pane + try testing.expectEqual(1, vert_children[0].content.pane); + + // Second child is a horizontal split + const horiz_children = vert_children[1].content.horizontal; + try testing.expectEqual(2, horiz_children.len); + try testing.expectEqual(2, horiz_children[0].content.pane); + try testing.expectEqual(3, horiz_children[1].content.pane); +} + +test "nested vertical in horizontal" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + // Horizontal split with left pane and right vertical split + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0,1,40x24,40,0[40x12,40,0,2,40x12,40,12,3]}"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + + const horiz_children = layout.content.horizontal; + try testing.expectEqual(2, horiz_children.len); + + // First child is a simple pane + try testing.expectEqual(1, horiz_children[0].content.pane); + + // Second child is a vertical split + const vert_children = horiz_children[1].content.vertical; + try testing.expectEqual(2, vert_children.len); + try testing.expectEqual(2, vert_children[0].content.pane); + try testing.expectEqual(3, vert_children[1].content.pane); +} + +test "deeply nested layout" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + // Three levels deep + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0[40x12,0,0,1,40x12,0,12,2],40x24,40,0,3}"); + + const horiz = layout.content.horizontal; + try testing.expectEqual(2, horiz.len); + + const vert = horiz[0].content.vertical; + try testing.expectEqual(2, vert.len); + try testing.expectEqual(1, vert[0].content.pane); + try testing.expectEqual(2, vert[1].content.pane); + + try testing.expectEqual(3, horiz[1].content.pane); +} + +test "syntax error empty string" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "")); +} + +test "syntax error missing width" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "x24,0,0,1")); +} + +test "syntax error missing height" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x,0,0,1")); +} + +test "syntax error missing x" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,,0,1")); +} + +test "syntax error missing y" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,,1")); +} + +test "syntax error missing pane id" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,")); +} + +test "syntax error non-numeric width" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "abcx24,0,0,1")); +} + +test "syntax error non-numeric pane id" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,abc")); +} + +test "syntax error unclosed horizontal bracket" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0{40x24,0,0,1")); +} + +test "syntax error unclosed vertical bracket" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0[40x24,0,0,1")); +} + +test "syntax error mismatched brackets" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0{40x24,0,0,1]")); + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0[40x24,0,0,1}")); +} + +test "syntax error trailing data" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,1extra")); +} + +test "syntax error no x separator" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "8024,0,0,1")); +} + +test "syntax error no content delimiter" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0")); +} From 92ea8d0eb521032376cad618620ae634cffc6963 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 2 Dec 2025 08:57:41 -0800 Subject: [PATCH 32/49] terminal/tmux: layout checksums --- src/terminal/tmux/layout.zig | 179 +++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/src/terminal/tmux/layout.zig b/src/terminal/tmux/layout.zig index 595738251..df1a53917 100644 --- a/src/terminal/tmux/layout.zig +++ b/src/terminal/tmux/layout.zig @@ -30,6 +30,36 @@ pub const Layout = struct { pub const ParseError = Allocator.Error || error{SyntaxError}; + /// Parse a layout string that includes a 4-character checksum prefix. + /// + /// The expected format is: `XXXX,layout_string` where XXXX is the + /// 4-character hexadecimal checksum and the layout string follows + /// after the comma. For example: `f8f9,80x24,0,0{40x24,0,0,1,40x24,40,0,2}`. + /// + /// Returns `ChecksumMismatch` if the checksum doesn't match the layout. + /// Returns `SyntaxError` if the format is invalid. + pub fn parseWithChecksum( + alloc: Allocator, + str: []const u8, + ) (ParseError || error{ChecksumMismatch})!Layout { + // If the string is less than 5 characters, it can't possibly + // be correct. 4-char checksum + comma. In practice it should + // be even longer, but that'll fail parse later. + if (str.len < 5) return error.SyntaxError; + if (str[4] != ',') return error.SyntaxError; + + // The layout string should start with a 4-character checksum. + const checksum: Checksum = .calculate(str[5..]); + if (!std.mem.startsWith( + u8, + str, + &checksum.asString(), + )) return error.ChecksumMismatch; + + // Checksum matches, parse the rest. + return try parse(alloc, str[5..]); + } + /// Parse a layout string into a Layout structure. The given allocator /// will be used for all allocations within the layout. Note that /// individual nodes can't be freed so this allocator must be some @@ -194,6 +224,38 @@ pub const Layout = struct { } }; +pub const Checksum = enum(u16) { + _, + + /// Calculate the checksum of a tmux layout string. + /// The algorithm rotates the checksum right by 1 bit (with wraparound) + /// and adds the ASCII value of each character. + pub fn calculate(str: []const u8) Checksum { + var result: u16 = 0; + for (str) |c| { + // Rotate right by 1: (result >> 1) + ((result & 1) << 15) + result = (result >> 1) | ((result & 1) << 15); + result +%= c; + } + + return @enumFromInt(result); + } + + /// Convert the checksum to a 4-character hexadecimal string. This + /// is always zero-padded to match the tmux implementation + /// (in layout-custom.c). + pub fn asString(self: Checksum) [4]u8 { + const value = @intFromEnum(self); + const charset = "0123456789abcdef"; + return .{ + charset[(value >> 12) & 0xf], + charset[(value >> 8) & 0xf], + charset[(value >> 4) & 0xf], + charset[value & 0xf], + }; + } +}; + test "simple single pane" { var arena: ArenaAllocator = .init(testing.allocator); defer arena.deinit(); @@ -457,3 +519,120 @@ test "syntax error no content delimiter" { try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0")); } + +// parseWithChecksum tests + +test "parseWithChecksum valid" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parseWithChecksum(arena.allocator(), "f8f9,80x24,0,0{40x24,0,0,1,40x24,40,0,2}"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); +} + +test "parseWithChecksum mismatch" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.ChecksumMismatch, Layout.parseWithChecksum(arena.allocator(), "0000,80x24,0,0{40x24,0,0,1,40x24,40,0,2}")); +} + +test "parseWithChecksum too short" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "bb62")); + try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "")); +} + +test "parseWithChecksum missing comma" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "bb62x159x48,0,0")); +} + +// Checksum tests + +test "checksum empty string" { + const checksum = Checksum.calculate(""); + try testing.expectEqual(@as(u16, 0), @intFromEnum(checksum)); + try testing.expectEqualStrings("0000", &checksum.asString()); +} + +test "checksum single character" { + // 'A' = 65, first iteration: csum = 0 >> 1 | 0 = 0, then 0 + 65 = 65 + const checksum = Checksum.calculate("A"); + try testing.expectEqual(@as(u16, 65), @intFromEnum(checksum)); + try testing.expectEqualStrings("0041", &checksum.asString()); +} + +test "checksum two characters" { + // 'A' (65): csum = 0, rotate = 0, add 65 => 65 + // 'B' (66): csum = 65, rotate => (65 >> 1) | ((65 & 1) << 15) = 32 | 32768 = 32800 + // add 66 => 32800 + 66 = 32866 + const checksum = Checksum.calculate("AB"); + try testing.expectEqual(@as(u16, 32866), @intFromEnum(checksum)); + try testing.expectEqualStrings("8062", &checksum.asString()); +} + +test "checksum simple layout" { + const checksum = Checksum.calculate("80x24,0,0,42"); + try testing.expectEqualStrings("d962", &checksum.asString()); +} + +test "checksum horizontal split layout" { + const checksum = Checksum.calculate("80x24,0,0{40x24,0,0,1,40x24,40,0,2}"); + try testing.expectEqualStrings("f8f9", &checksum.asString()); +} + +test "checksum asString zero padding" { + // Value 0x000f should produce "000f" + const checksum: Checksum = @enumFromInt(0x000f); + try testing.expectEqualStrings("000f", &checksum.asString()); +} + +test "checksum asString all digits" { + // Value 0x1234 should produce "1234" + const checksum: Checksum = @enumFromInt(0x1234); + try testing.expectEqualStrings("1234", &checksum.asString()); +} + +test "checksum asString with letters" { + // Value 0xabcd should produce "abcd" + const checksum: Checksum = @enumFromInt(0xabcd); + try testing.expectEqualStrings("abcd", &checksum.asString()); +} + +test "checksum asString max value" { + // Value 0xffff should produce "ffff" + const checksum: Checksum = @enumFromInt(0xffff); + try testing.expectEqualStrings("ffff", &checksum.asString()); +} + +test "checksum wraparound" { + const checksum = Checksum.calculate("\xff\xff\xff\xff\xff\xff\xff\xff"); + try testing.expectEqualStrings("03fc", &checksum.asString()); +} + +test "checksum deterministic" { + // Same input should always produce same output + const str = "159x48,0,0{79x48,0,0,79x48,80,0}"; + const checksum1 = Checksum.calculate(str); + const checksum2 = Checksum.calculate(str); + try testing.expectEqual(checksum1, checksum2); +} + +test "checksum different inputs different outputs" { + const checksum1 = Checksum.calculate("80x24,0,0,1"); + const checksum2 = Checksum.calculate("80x24,0,0,2"); + try testing.expect(@intFromEnum(checksum1) != @intFromEnum(checksum2)); +} + +test "checksum known tmux layout bb62" { + // From tmux documentation: "bb62,159x48,0,0{79x48,0,0,79x48,80,0}" + // The checksum "bb62" corresponds to the layout "159x48,0,0{79x48,0,0,79x48,80,0}" + const checksum = Checksum.calculate("159x48,0,0{79x48,0,0,79x48,80,0}"); + try testing.expectEqualStrings("bb62", &checksum.asString()); +} From b95965cb5ae83db763a93b8852a4905d6a154619 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 2 Dec 2025 09:27:09 -0800 Subject: [PATCH 33/49] terminal/tmux: add layout-change to control mode parsing --- src/terminal/tmux/control.zig | 61 +++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig index b41671aab..3624173dd 100644 --- a/src/terminal/tmux/control.zig +++ b/src/terminal/tmux/control.zig @@ -242,6 +242,44 @@ pub const Parser = struct { self.buffer.clearRetainingCapacity(); self.state = .idle; return .{ .sessions_changed = {} }; + } else if (std.mem.eql(u8, cmd, "%layout-change")) cmd: { + var re = oni.Regex.init( + "^%layout-change @([0-9]+) (.+) (.+) (.*)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const layout = line[@intCast(starts[2])..@intCast(ends[2])]; + const visible_layout = line[@intCast(starts[3])..@intCast(ends[3])]; + const raw_flags = line[@intCast(starts[4])..@intCast(ends[4])]; + + // Important: do not clear buffer here since layout strings point to it + self.state = .idle; + return .{ .layout_change = .{ + .window_id = id, + .layout = layout, + .visible_layout = visible_layout, + .raw_flags = raw_flags, + } }; } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: { var re = oni.Regex.init( "^%window-add @([0-9]+)$", @@ -455,6 +493,14 @@ pub const Notification = union(enum) { /// A session was created or destroyed. sessions_changed, + /// The layout of the window with ID window-id changed. + layout_change: struct { + window_id: usize, + layout: []const u8, + visible_layout: []const u8, + raw_flags: []const u8, + }, + /// The window with ID window-id was linked to the current session. window_add: struct { id: usize, @@ -575,6 +621,21 @@ test "tmux sessions-changed carriage return" { try testing.expect(n == .sessions_changed); } +test "tmux layout-change" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%layout-change @2 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} *-") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .layout_change); + try testing.expectEqual(2, n.layout_change.window_id); + try testing.expectEqualStrings("1234x791,0,0{617x791,0,0,0,617x791,618,0,1}", n.layout_change.layout); + try testing.expectEqualStrings("1234x791,0,0{617x791,0,0,0,617x791,618,0,1}", n.layout_change.visible_layout); + try testing.expectEqualStrings("*-", n.layout_change.raw_flags); +} + test "tmux window-add" { const testing = std.testing; const alloc = testing.allocator; From 6b21b9147c09028ebb58a5a7f09b44aacb49d1ba Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Dec 2025 19:57:21 -0800 Subject: [PATCH 34/49] terminal/tmux: add output format parsing (minimal) --- src/terminal/tmux.zig | 1 + src/terminal/tmux/output.zig | 205 +++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 src/terminal/tmux/output.zig diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 0e8c41262..82ef5036b 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -2,6 +2,7 @@ const control = @import("tmux/control.zig"); const layout = @import("tmux/layout.zig"); +pub const output = @import("tmux/output.zig"); pub const ControlParser = control.Parser; pub const ControlNotification = control.Notification; pub const Layout = layout.Layout; diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig new file mode 100644 index 000000000..dcfa89ac3 --- /dev/null +++ b/src/terminal/tmux/output.zig @@ -0,0 +1,205 @@ +const std = @import("std"); +const testing = std.testing; + +pub const ParseError = error{ + MissingEntry, + ExtraEntry, + FormatError, +}; + +/// Parse the output from a command with the given format struct +/// (returned usually by FormatStruct). The format struct is expected +/// to be in the order of the variables used in the format string and +/// the variables are expected to be plain variables (no conditionals, +/// extra formatting, etc.). Each variable is expected to be separated +/// by a single `delimiter` character. +pub fn parseFormatStruct( + comptime T: type, + str: []const u8, + delimiter: u8, +) ParseError!T { + // Parse all our fields + const fields = @typeInfo(T).@"struct".fields; + var it = std.mem.splitScalar(u8, str, delimiter); + var result: T = undefined; + inline for (fields) |field| { + const part = it.next() orelse return error.MissingEntry; + @field(result, field.name) = Variable.parse( + @field(Variable, field.name), + part, + ) catch return error.FormatError; + } + + // We should have consumed all parts now. + if (it.next() != null) return error.ExtraEntry; + + return result; +} + +/// Returns a struct type that contains fields for each of the given +/// format variables. This can be used with `parseFormatStruct` to +/// parse an output string into a format struct. +pub fn FormatStruct(comptime vars: []const Variable) type { + var fields: [vars.len]std.builtin.Type.StructField = undefined; + for (vars, &fields) |variable, *field| { + field.* = .{ + .name = @tagName(variable), + .type = variable.Type(), + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(variable.Type()), + }; + } + + return @Type(.{ .@"struct" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + } }); +} + +/// Possible variables in a tmux format string that we support. +/// +/// Tmux supports a large number of variables, but we only implement +/// a subset of them here that are relevant to the use case of implementing +/// control mode for terminal emulators. +pub const Variable = enum { + session_id, + window_id, + window_width, + window_height, + window_layout, + + /// Parse the given string value into the appropriate resulting + /// type for this variable. + pub fn parse(comptime self: Variable, value: []const u8) !Type(self) { + return switch (self) { + .session_id => if (value.len >= 2 and value[0] == '$') + try std.fmt.parseInt(usize, value[1..], 10) + else + return error.FormatError, + .window_id => if (value.len >= 2 and value[0] == '@') + try std.fmt.parseInt(usize, value[1..], 10) + else + return error.FormatError, + .window_width => try std.fmt.parseInt(usize, value, 10), + .window_height => try std.fmt.parseInt(usize, value, 10), + .window_layout => value, + }; + } + + /// The type of the parsed value for this variable type. + pub fn Type(comptime self: Variable) type { + return switch (self) { + .session_id => usize, + .window_id => usize, + .window_width => usize, + .window_height => usize, + .window_layout => []const u8, + }; + } +}; + +test "parse session id" { + try testing.expectEqual(42, try Variable.parse(.session_id, "$42")); + try testing.expectEqual(0, try Variable.parse(.session_id, "$0")); + try testing.expectError(error.FormatError, Variable.parse(.session_id, "0")); + try testing.expectError(error.FormatError, Variable.parse(.session_id, "@0")); + try testing.expectError(error.FormatError, Variable.parse(.session_id, "$")); + try testing.expectError(error.FormatError, Variable.parse(.session_id, "")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.session_id, "$abc")); +} + +test "parse window id" { + try testing.expectEqual(42, try Variable.parse(.window_id, "@42")); + try testing.expectEqual(0, try Variable.parse(.window_id, "@0")); + try testing.expectEqual(12345, try Variable.parse(.window_id, "@12345")); + try testing.expectError(error.FormatError, Variable.parse(.window_id, "0")); + try testing.expectError(error.FormatError, Variable.parse(.window_id, "$0")); + try testing.expectError(error.FormatError, Variable.parse(.window_id, "@")); + try testing.expectError(error.FormatError, Variable.parse(.window_id, "")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_id, "@abc")); +} + +test "parse window width" { + try testing.expectEqual(80, try Variable.parse(.window_width, "80")); + try testing.expectEqual(0, try Variable.parse(.window_width, "0")); + try testing.expectEqual(12345, try Variable.parse(.window_width, "12345")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_width, "abc")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_width, "80px")); + try testing.expectError(error.Overflow, Variable.parse(.window_width, "-1")); +} + +test "parse window height" { + try testing.expectEqual(24, try Variable.parse(.window_height, "24")); + try testing.expectEqual(0, try Variable.parse(.window_height, "0")); + try testing.expectEqual(12345, try Variable.parse(.window_height, "12345")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_height, "abc")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_height, "24px")); + try testing.expectError(error.Overflow, Variable.parse(.window_height, "-1")); +} + +test "parse window layout" { + try testing.expectEqualStrings("abc123", try Variable.parse(.window_layout, "abc123")); + try testing.expectEqualStrings("", try Variable.parse(.window_layout, "")); + try testing.expectEqualStrings("a]b,c{d}e(f)", try Variable.parse(.window_layout, "a]b,c{d}e(f)")); +} + +test "parseFormatStruct single field" { + const T = FormatStruct(&.{.session_id}); + const result = try parseFormatStruct(T, "$42", ' '); + try testing.expectEqual(42, result.session_id); +} + +test "parseFormatStruct multiple fields" { + const T = FormatStruct(&.{ .session_id, .window_id, .window_width, .window_height }); + const result = try parseFormatStruct(T, "$1 @2 80 24", ' '); + try testing.expectEqual(1, result.session_id); + try testing.expectEqual(2, result.window_id); + try testing.expectEqual(80, result.window_width); + try testing.expectEqual(24, result.window_height); +} + +test "parseFormatStruct with string field" { + const T = FormatStruct(&.{ .window_id, .window_layout }); + const result = try parseFormatStruct(T, "@5,abc123", ','); + try testing.expectEqual(5, result.window_id); + try testing.expectEqualStrings("abc123", result.window_layout); +} + +test "parseFormatStruct different delimiter" { + const T = FormatStruct(&.{ .window_width, .window_height }); + const result = try parseFormatStruct(T, "120\t40", '\t'); + try testing.expectEqual(120, result.window_width); + try testing.expectEqual(40, result.window_height); +} + +test "parseFormatStruct missing entry" { + const T = FormatStruct(&.{ .session_id, .window_id }); + try testing.expectError(error.MissingEntry, parseFormatStruct(T, "$1", ' ')); +} + +test "parseFormatStruct extra entry" { + const T = FormatStruct(&.{.session_id}); + try testing.expectError(error.ExtraEntry, parseFormatStruct(T, "$1 @2", ' ')); +} + +test "parseFormatStruct format error" { + const T = FormatStruct(&.{.session_id}); + try testing.expectError(error.FormatError, parseFormatStruct(T, "42", ' ')); + try testing.expectError(error.FormatError, parseFormatStruct(T, "@42", ' ')); + try testing.expectError(error.FormatError, parseFormatStruct(T, "$abc", ' ')); +} + +test "parseFormatStruct empty string" { + const T = FormatStruct(&.{.session_id}); + try testing.expectError(error.FormatError, parseFormatStruct(T, "", ' ')); +} + +test "parseFormatStruct with empty layout field" { + const T = FormatStruct(&.{ .session_id, .window_layout }); + const result = try parseFormatStruct(T, "$1,", ','); + try testing.expectEqual(1, result.session_id); + try testing.expectEqualStrings("", result.window_layout); +} From 0a03434656d064163428cbb180bf4a5880e70064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Bulteel?= Date: Sun, 2 Nov 2025 14:09:54 +0100 Subject: [PATCH 35/49] gtk: fix xkb mapping not working on linux Signed-off-by: Cedric BULTEEL --- src/apprt/gtk/class/surface.zig | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index fcbfbe6ab..09d82fe5c 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1137,13 +1137,14 @@ pub const Surface = extern struct { if (entry.native == keycode) break :w3c entry.key; } else .unidentified; - // If the key should be remappable, then consult the pre-remapped - // XKB keyval/keysym to get the (possibly) remapped key. + // Consult the pre-remapped XKB keyval/keysym to get the (possibly) + // remapped key. If the W3C key or the remapped key + // is eligible for remapping, we use it. // // See the docs for `shouldBeRemappable` for why we even have to // do this in the first place. - if (w3c_key.shouldBeRemappable()) { - if (gtk_key.keyFromKeyval(keyval)) |remapped| + if (gtk_key.keyFromKeyval(keyval)) |remapped| { + if (w3c_key.shouldBeRemappable() or remapped.shouldBeRemappable()) break :keycode remapped; } From 68426dc21a93ed97b52511c3bdf69c5fec1fd6ae Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 4 Dec 2025 12:30:51 -0600 Subject: [PATCH 36/49] core: rate limit BEL character processing If the BEL character is received too frequently, the GUI thread can be starved and Ghostty will lock up and eventually crash. This PR limits BEL handling to 1 per 100ms. Fixes #9800. --- src/Surface.zig | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index 40929e168..18eac39ca 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -155,6 +155,9 @@ command_timer: ?std.time.Instant = null, /// Search state search: ?Search = null, +/// Used to rate limit BEL handling. +last_bell_time: ?std.time.Instant = null, + /// The effect of an input event. This can be used by callers to take /// the appropriate action after an input event. For example, key /// input can be forwarded to the OS for further processing if it @@ -1026,7 +1029,12 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .password_input => |v| try self.passwordInput(v), - .ring_bell => { + .ring_bell => bell: { + const now = std.time.Instant.now() catch unreachable; + if (self.last_bell_time) |last| { + if (now.since(last) < 100 * std.time.ns_per_ms) break :bell; + } + self.last_bell_time = now; _ = self.rt_app.performAction( .{ .surface = self }, .ring_bell, From 6b2097e8720a3bd8b5ca104bb70a10ed24178522 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Dec 2025 19:44:39 -0800 Subject: [PATCH 37/49] core: hold lock during keyCallback when mouseRefreshLinks is called From #9812 I'm not sure if this is the root cause of the crash in #9812 but the LLM-discovered issue that we are not holding a lock here appears to be a real issue. I manually traced the code paths and thought about this and looked where we call `mouseRefreshLinks` in other places and this appears to be a real bug. --- src/Surface.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 18eac39ca..1926c4394 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2592,6 +2592,8 @@ pub fn keyCallback( { // Refresh our link state const pos = self.rt_surface.getCursorPos() catch break :mouse_mods; + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); self.mouseRefreshLinks( pos, self.posToViewport(pos.x, pos.y), From f98b12579e7b02108bb03fc9d75d8fecef82ca7e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Dec 2025 08:30:33 -0800 Subject: [PATCH 38/49] core: selection and copy bindings need to hold the big lock This was found by LLM hunting! We were not holding the lock properly during these operations. There aren't any known cases where we can directly attribute these races to issues but we did find at least one consistent crash for a user when `linkAtPos` wasn't properly locked (in another PR). This continues those fixes. --- src/Surface.zig | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 1926c4394..653178bdc 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5032,8 +5032,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .copy_to_clipboard => |format| { - // We can read from the renderer state without holding - // the lock because only we will write to this field. + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + if (self.io.terminal.screens.active.selection) |sel| { try self.copySelectionToClipboards( sel, @@ -5061,8 +5062,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .copy_url_to_clipboard => { // If the mouse isn't over a link, nothing we can do. if (!self.mouse.over_link) return false; - const pos = try self.rt_surface.getCursorPos(); + + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); if (try self.linkAtPos(pos)) |link_info| { const url_text = switch (link_info[0]) { .open => url_text: { @@ -5438,6 +5441,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ), .select_all => { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const sel = self.io.terminal.screens.active.selectAll(); if (sel) |s| { try self.setSelection(s); From d09621fa11b1470f9859a2e3201741acd2ab5ec9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Dec 2025 08:41:16 -0800 Subject: [PATCH 39/49] ci: cancel prior test runs for the same git ref This should save on CI quite a bit. This will cancel our GHA runs when you push to the same ref, except for `main`, where I want to make sure every commit is tested. --- .github/workflows/nix.yml | 5 +++++ .github/workflows/test.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 825cf52f5..f928ed5a5 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -1,5 +1,10 @@ on: [push, pull_request] name: Nix + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name != 'main' && github.ref || github.run_id }} + cancel-in-progress: true + jobs: required: name: "Required Checks: Nix" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 916745f58..20f674bab 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,11 @@ on: name: Test +# We only want the latest commit to test for any non-main ref. +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name != 'main' && github.ref || github.run_id }} + cancel-in-progress: true + jobs: required: name: "Required Checks: Test" From aa0afa2d0225bfe3566a7508057569f3bc410aac Mon Sep 17 00:00:00 2001 From: voideanvalue Date: Sat, 6 Dec 2025 22:17:33 +0000 Subject: [PATCH 40/49] fix C ABI compat for ghostty_quick_terminal_size_tag_e --- src/config/Config.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 82e81a01f..20256e951 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -7987,7 +7987,8 @@ pub const QuickTerminalSize = struct { tag: Tag, value: Value, - pub const Tag = enum(u8) { none, percentage, pixels }; + /// c_int because it needs to be extern compatible + pub const Tag = enum(c_int) { none, percentage, pixels }; pub const Value = extern union { percentage: f32, From 6e081b2c81ca807eb3ea0b87491d98e4900254d9 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 7 Dec 2025 00:15:51 +0000 Subject: [PATCH 41/49] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 993904aec..20cf44141 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz", - .hash = "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 9ca70c410..cb827e238 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -49,10 +49,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN": { + "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz", - "hash": "sha256-5mmXW7d9SkesHyIwUBlWmyGtOWf6wu0S6zkHe93FVLM=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + "hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 2563f5411..0ec137c70 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -163,11 +163,11 @@ in }; } { - name = "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN"; + name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz"; - hash = "sha256-5mmXW7d9SkesHyIwUBlWmyGtOWf6wu0S6zkHe93FVLM="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz"; + hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 4362c5d36..6b19df24e 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -29,7 +29,7 @@ https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz -https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz +https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 672fd7a5f..9563f9622 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -61,9 +61,9 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz", - "dest": "vendor/p/N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN", - "sha256": "e669975bb77d4a47ac1f22305019569b21ad3967fac2ed12eb39077bddc554b3" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + "dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", + "sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149" }, { "type": "archive", From aa504b27842f8a740a3a4f6c32dcd7b0908d2eaf Mon Sep 17 00:00:00 2001 From: voideanvalue Date: Sun, 7 Dec 2025 00:51:37 +0000 Subject: [PATCH 42/49] add assertionFailure for unexpected QuickTerminalSize tag --- macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift index 9f86a7c2b..08bbcb8d9 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift @@ -33,6 +33,7 @@ struct QuickTerminalSize { case GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS: self = .pixels(cStruct.value.pixels) default: + assertionFailure() return nil } } From 90ab79445744688c90120563105efddf3880825c Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Sun, 7 Dec 2025 12:04:50 +0800 Subject: [PATCH 43/49] CONTRIBUTING: tighten AI assistance disclosure requirements --- CONTRIBUTING.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de7df4b71..0bc74af5d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,10 +23,20 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well. If you are using any kind of AI assistance while contributing to Ghostty, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used (e.g. docs only vs. code generation). -If PR responses are being generated by an AI, disclose that as well. As a small exception, trivial tab-completion doesn't need to be disclosed, so long as it is limited to single keywords or short phrases. +The submitter must have also tested the pull request on all impacted +platforms, and it's **highly discouraged** to code for an unfamiliar platform +with AI assistance alone: if you only have a macOS machine, do **not** ask AI +to write the equivalent GTK code, and vice versa — someone else with more +expertise will eventually get to it and do it for you. + +Even though using AI to generate responses on a PR is allowed when properly +disclosed, **we do not encourage you to do so**. Often, the positive impact +of genuine, responsive human interaction more than makes up for any language +barrier. ❤️ + An example disclosure: > This PR was written primarily by Claude Code. @@ -36,6 +46,11 @@ Or a more detailed disclosure: > I consulted ChatGPT to understand the codebase but the solution > was fully authored manually by myself. +An example of a **problematic** disclosure (not having tested all platforms): + +> I used Amp to code both macOS and GTK UIs, but I have not yet tested +> the GTK UI as I don't have a Linux setup. + Failure to disclose this is first and foremost rude to the human operators on the other end of the pull request, but it also makes it difficult to determine how much scrutiny to apply to the contribution. @@ -45,10 +60,12 @@ work than any human. That isn't the world we live in today, and in most cases it's generating slop. I say this despite being a fan of and using them successfully myself (with heavy supervision)! -When using AI assistance, we expect contributors to understand the code +When using AI assistance, we expect a fairly high level of accountability +and responsibility from contributors, and expect them to understand the code that is produced and be able to answer critical questions about it. It isn't a maintainers job to review a PR so broken that it requires -significant rework to be acceptable. +significant rework to be acceptable, and we **reserve the right to close +these PRs without hesitation**. Please be respectful to maintainers and disclose AI assistance. From c9655eefe527b74b86da7516825792df6ab1ed38 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Sun, 7 Dec 2025 12:36:22 +0800 Subject: [PATCH 44/49] CONTRIBUTING: clarify discussion categories & discord channels --- CONTRIBUTING.md | 67 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0bc74af5d..b4285f42f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,22 +91,47 @@ submission. ### I have a bug! / Something isn't working -1. Search the issue tracker and discussions for similar issues. Tip: also - search for [closed issues] and [discussions] — your issue might have already - been fixed! -2. If your issue hasn't been reported already, open an ["Issue Triage" discussion] - and make sure to fill in the template **completely**. They are vital for - maintainers to figure out important details about your setup. Because of - this, please make sure that you _only_ use the "Issue Triage" category for - reporting bugs — thank you! +First, search the issue tracker and discussions for similar issues. Tip: also +search for [closed issues] and [discussions] — your issue might have already +been fixed! + +> [!NOTE] +> +> If there is an _open_ issue or discussion that matches your problem, +> **please do not comment on it unless you have valuable insight to add**. +> +> GitHub has a very _noisy_ set of default notification settings which +> sends an email to _every participant_ in an issue/discussion every time +> someone adds a comment. Instead, use the handy upvote button for discussions, +> and/or emoji reactions on both discussions and issues, which are a visible +> yet non-disruptive way to show your support. + +If your issue hasn't been reported already, open an ["Issue Triage"] discussion +and make sure to fill in the template **completely**. They are vital for +maintainers to figure out important details about your setup. + +> [!WARNING] +> +> A _very_ common mistake is to file a bug report either as a Q&A or a Feature +> Request. **Please don't do this.** Otherwise, maintainers would have to ask +> for your system information again manually, and sometimes they will even ask +> you to create a new discussion because of how few detailed information is +> required for other discussion types compared to Issue Triage. +> +> Because of this, please make sure that you _only_ use the "Issue Triage" +> category for reporting bugs — thank you! [closed issues]: https://github.com/ghostty-org/ghostty/issues?q=is%3Aissue%20state%3Aclosed [discussions]: https://github.com/ghostty-org/ghostty/discussions?discussions_q=is%3Aclosed -["Issue Triage" discussion]: https://github.com/ghostty-org/ghostty/discussions/new?category=issue-triage +["Issue Triage"]: https://github.com/ghostty-org/ghostty/discussions/new?category=issue-triage ### I have an idea for a feature -Open a discussion in the ["Feature Requests, Ideas" category](https://github.com/ghostty-org/ghostty/discussions/new?category=feature-requests-ideas). +Like bug reports, first search through both issues and discussions and try to +find if your feature has already been requested. Otherwise, open a discussion +in the ["Feature Requests, Ideas"] category. + +["Feature Requests, Ideas"]: https://github.com/ghostty-org/ghostty/discussions/new?category=feature-requests-ideas ### I've implemented a feature @@ -115,10 +140,28 @@ Open a discussion in the ["Feature Requests, Ideas" category](https://github.com 3. If you want to live dangerously, open a pull request and [hope for the best](#pull-requests-implement-an-issue). -### I have a question +### I have a question which is neither a bug report nor a feature request Open an [Q&A discussion], or join our [Discord Server] and ask away in the -`#help` channel. +`#help` forum channel. + +Do not use the `#terminals` or `#development` channels to ask for help — +those are for general discussion about terminals and Ghostty development +respectively. If you do ask a question there, you will be redirected to +`#help` instead. + +> [!NOTE] +> If your question is about a missing feature, please open a discussion under +> the ["Feature Requests, Ideas"] category. If Ghostty is behaving +> unexpectedly, use the ["Issue Triage"] category. +> +> The "Q&A" category is strictly for other kinds of discussions and do not +> require detailed information unlike the two other categories, meaning that +> maintainers would have to spend the extra effort to ask for basic information +> if you submit a bug report under this category. +> +> Therefore, please **pay attention to the category** before opening +> discussions to save us all some time and energy. Thank you! [Q&A discussion]: https://github.com/ghostty-org/ghostty/discussions/new?category=q-a [Discord Server]: https://discord.gg/ghostty From 9d4f96381a8211f79594b9f448954a3d98de8aa4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 14:14:03 -0800 Subject: [PATCH 45/49] make our quirks assert use `std.debug.assert` in debug builds This fixes an issue I have on both macOS and Linux (ARM and x86_64) where stack traces are broken for inlined functions. They don't point to the proper location in the source code, making debugging difficult. Release builds use the same previous function. --- src/quirks.zig | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/quirks.zig b/src/quirks.zig index 5129923d2..db2760141 100644 --- a/src/quirks.zig +++ b/src/quirks.zig @@ -7,6 +7,7 @@ //! [1]: https://github.com/WebKit/WebKit/blob/main/Source/WebCore/page/Quirks.cpp const std = @import("std"); +const builtin = @import("builtin"); const font = @import("font/main.zig"); @@ -41,6 +42,16 @@ pub fn disableDefaultFontFeatures(face: *const font.Face) bool { /// is negligible, but we have some asserts inside tight loops and hotpaths /// that cause significant overhead (as much as 15-20%) when they don't get /// optimized out. -pub inline fn inlineAssert(ok: bool) void { - if (!ok) unreachable; -} +pub const inlineAssert: fn (bool) void = switch (builtin.mode) { + // In debug builds we just use std.debug.assert because this + // fixes up stack traces. `inline` causes broken stack traces. This + // is probably a Zig compiler bug but until it is fixed we have to + // do this for development sanity. + .Debug => std.debug.assert, + + .ReleaseSmall, .ReleaseSafe, .ReleaseFast => (struct { + inline fn assert(ok: bool) void { + if (!ok) unreachable; + } + }).assert, +}; From 5131998eda852206c1cf936c28499562928bea4d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 00:06:17 +0000 Subject: [PATCH 46/49] build(deps): bump peter-evans/create-pull-request from 7.0.9 to 7.0.11 Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 7.0.9 to 7.0.11. - [Release notes](https://github.com/peter-evans/create-pull-request/releases) - [Commits](https://github.com/peter-evans/create-pull-request/compare/84ae59a2cdc2258d6fa0732dd66352dddae2a412...22a9089034f40e5a961c8808d113e2c98fb63676) --- updated-dependencies: - dependency-name: peter-evans/create-pull-request dependency-version: 7.0.11 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/update-colorschemes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index b9ff89c35..ca65c2a21 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -62,7 +62,7 @@ jobs: run: nix build .#ghostty - name: Create pull request - uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 with: title: Update iTerm2 colorschemes base: main From ed1d77d518f58ca97895ff789c69b54d3a3bf1b4 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Sun, 7 Dec 2025 13:35:52 +0800 Subject: [PATCH 47/49] os: fix off-by-one error in ShellEscapeWriter I am truly not sure why the tests never caught this, but I just fell for the oldest trick in the book --- src/os/shell.zig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/os/shell.zig b/src/os/shell.zig index a6f23e843..7f3254d87 100644 --- a/src/os/shell.zig +++ b/src/os/shell.zig @@ -5,8 +5,6 @@ const Writer = std.Io.Writer; /// Writer that escapes characters that shells treat specially to reduce the /// risk of injection attacks or other such weirdness. Specifically excludes /// linefeeds so that they can be used to delineate lists of file paths. -/// -/// T should be a Zig type that follows the `std.Io.Writer` interface. pub const ShellEscapeWriter = struct { writer: Writer, child: *Writer, @@ -33,7 +31,7 @@ pub const ShellEscapeWriter = struct { var count: usize = 0; for (data[0 .. data.len - 1]) |chunk| try self.writeEscaped(chunk, &count); - for (0..splat) |_| try self.writeEscaped(data[data.len], &count); + for (0..splat) |_| try self.writeEscaped(data[data.len - 1], &count); return count; } From 6da2f0e3e76336ab0c761fccae267efc20301ac7 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Mon, 8 Dec 2025 12:50:04 +0800 Subject: [PATCH 48/49] os/shell: actually run tests --- src/os/main.zig | 1 + src/os/shell.zig | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/os/main.zig b/src/os/main.zig index 2d269e412..c105f6143 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -69,6 +69,7 @@ test { _ = i18n; _ = path; _ = uri; + _ = shell; if (comptime builtin.os.tag == .linux) { _ = kernel_info; diff --git a/src/os/shell.zig b/src/os/shell.zig index 7f3254d87..9fce3e385 100644 --- a/src/os/shell.zig +++ b/src/os/shell.zig @@ -65,7 +65,7 @@ pub const ShellEscapeWriter = struct { test "shell escape 1" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("abc"); try testing.expectEqualStrings("abc", writer.buffered()); } @@ -73,7 +73,7 @@ test "shell escape 1" { test "shell escape 2" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a c"); try testing.expectEqualStrings("a\\ c", writer.buffered()); } @@ -81,7 +81,7 @@ test "shell escape 2" { test "shell escape 3" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a?c"); try testing.expectEqualStrings("a\\?c", writer.buffered()); } @@ -89,7 +89,7 @@ test "shell escape 3" { test "shell escape 4" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a\\c"); try testing.expectEqualStrings("a\\\\c", writer.buffered()); } @@ -97,7 +97,7 @@ test "shell escape 4" { test "shell escape 5" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a|c"); try testing.expectEqualStrings("a\\|c", writer.buffered()); } @@ -105,7 +105,7 @@ test "shell escape 5" { test "shell escape 6" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a\"c"); try testing.expectEqualStrings("a\\\"c", writer.buffered()); } @@ -113,7 +113,7 @@ test "shell escape 6" { test "shell escape 7" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a(1)"); try testing.expectEqualStrings("a\\(1\\)", writer.buffered()); } From 2ac9e03c52b713d4f8aecc6fab46f213c580dfc3 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Mon, 8 Dec 2025 13:10:05 +0800 Subject: [PATCH 49/49] quirks: remove type signature for inlineAssert Functions with different calling conventions are not compatible with each other Fixes all release builds + CI --- src/quirks.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quirks.zig b/src/quirks.zig index db2760141..ecef74600 100644 --- a/src/quirks.zig +++ b/src/quirks.zig @@ -42,7 +42,7 @@ pub fn disableDefaultFontFeatures(face: *const font.Face) bool { /// is negligible, but we have some asserts inside tight loops and hotpaths /// that cause significant overhead (as much as 15-20%) when they don't get /// optimized out. -pub const inlineAssert: fn (bool) void = switch (builtin.mode) { +pub const inlineAssert = switch (builtin.mode) { // In debug builds we just use std.debug.assert because this // fixes up stack traces. `inline` causes broken stack traces. This // is probably a Zig compiler bug but until it is fixed we have to