From 832883b600dcbd9435a48174e638a19bdb205626 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Nov 2025 14:45:20 -0800 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 09/15] 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 10/15] 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 11/15] 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 12/15] 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 13/15] 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 14/15] 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 15/15] 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; }