From b87d57f029c196f6d808e1a97fbe28692d826706 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 11:44:16 -0800 Subject: [PATCH 01/36] macos: search overlay --- macos/Sources/Ghostty/SurfaceView.swift | 112 ++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 0358f765b..f7cc455fc 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -198,6 +198,9 @@ extension Ghostty { } #endif + // Search overlay + SurfaceSearchOverlay() + // Show bell border if enabled if (ghostty.config.bellFeatures.contains(.border)) { BellBorderOverlay(bell: surfaceView.bell) @@ -382,6 +385,115 @@ extension Ghostty { } } + /// Search overlay view that displays a search bar with input field and navigation buttons. + struct SurfaceSearchOverlay: View { + @State private var searchText: String = "" + @State private var corner: Corner = .topRight + @State private var dragOffset: CGSize = .zero + @State private var barSize: CGSize = .zero + + private let padding: CGFloat = 8 + + var body: some View { + GeometryReader { geo in + HStack(spacing: 8) { + TextField("Search", text: $searchText) + .textFieldStyle(.plain) + .frame(width: 180) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.1)) + .cornerRadius(6) + + Button(action: {}) { + Image(systemName: "chevron.up") + } + .buttonStyle(.borderless) + + Button(action: {}) { + Image(systemName: "chevron.down") + } + .buttonStyle(.borderless) + + Button(action: {}) { + Image(systemName: "xmark") + } + .buttonStyle(.borderless) + } + .padding(8) + .background(.background) + .cornerRadius(8) + .shadow(radius: 4) + .background( + GeometryReader { barGeo in + Color.clear.onAppear { + barSize = barGeo.size + } + } + ) + .padding(padding) + .offset(dragOffset) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: corner.alignment) + .gesture( + DragGesture() + .onChanged { value in + dragOffset = value.translation + } + .onEnded { value in + let centerPos = centerPosition(for: corner, in: geo.size, barSize: barSize) + let newCenter = CGPoint( + x: centerPos.x + value.translation.width, + y: centerPos.y + value.translation.height + ) + corner = closestCorner(to: newCenter, in: geo.size) + dragOffset = .zero + } + ) + .animation(.easeOut(duration: 0.2), value: corner) + } + } + + enum Corner { + case topLeft, topRight, bottomLeft, bottomRight + + var alignment: Alignment { + switch self { + case .topLeft: return .topLeading + case .topRight: return .topTrailing + case .bottomLeft: return .bottomLeading + case .bottomRight: return .bottomTrailing + } + } + } + + private func centerPosition(for corner: Corner, in containerSize: CGSize, barSize: CGSize) -> CGPoint { + let halfWidth = barSize.width / 2 + padding + let halfHeight = barSize.height / 2 + padding + + switch corner { + case .topLeft: + return CGPoint(x: halfWidth, y: halfHeight) + case .topRight: + return CGPoint(x: containerSize.width - halfWidth, y: halfHeight) + case .bottomLeft: + return CGPoint(x: halfWidth, y: containerSize.height - halfHeight) + case .bottomRight: + return CGPoint(x: containerSize.width - halfWidth, y: containerSize.height - halfHeight) + } + } + + private func closestCorner(to point: CGPoint, in containerSize: CGSize) -> Corner { + let midX = containerSize.width / 2 + let midY = containerSize.height / 2 + + if point.x < midX { + return point.y < midY ? .topLeft : .bottomLeft + } else { + return point.y < midY ? .topRight : .bottomRight + } + } + } + /// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn /// and interacted with. The word "surface" is used because a surface may represent a window, a tab, /// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it. From aeaa8d4ead6727fe9ee63c9785b5a72f7aeb4c7b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 11:57:34 -0800 Subject: [PATCH 02/36] add start_search binding and apprt action --- include/ghostty.h | 7 +++++++ src/Surface.zig | 11 +++++++++++ src/apprt/action.zig | 19 +++++++++++++++++++ src/input/Binding.zig | 5 +++++ src/input/command.zig | 6 ++++++ 5 files changed, 48 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 9b7a918ec..8c4455564 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -747,6 +747,11 @@ typedef struct { uint64_t duration; } ghostty_action_command_finished_s; +// apprt.action.StartSearch.C +typedef struct { + const char* needle; +} ghostty_action_start_search_s; + // terminal.Scrollbar typedef struct { uint64_t total; @@ -811,6 +816,7 @@ typedef enum { GHOSTTY_ACTION_PROGRESS_REPORT, GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, GHOSTTY_ACTION_COMMAND_FINISHED, + GHOSTTY_ACTION_START_SEARCH, } ghostty_action_tag_e; typedef union { @@ -844,6 +850,7 @@ typedef union { ghostty_surface_message_childexited_s child_exited; ghostty_action_progress_report_s progress_report; ghostty_action_command_finished_s command_finished; + ghostty_action_start_search_s start_search; } ghostty_action_u; typedef struct { diff --git a/src/Surface.zig b/src/Surface.zig index 4323291be..1e1363229 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4877,6 +4877,17 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.terminal.fullReset(); }, + .start_search => if (self.search == null) { + // To save resources, we don't actually start a search here, + // we just notify teh apprt. The real thread will start when + // the first needles are set. + _ = try self.rt_app.performAction( + .{ .surface = self }, + .start_search, + .{ .needle = "" }, + ); + } else return false, + .search => |text| search: { const s: *Search = if (self.search) |*s| s else init: { // If we're stopping the search and we had no prior search, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 11186f059..45fa8aca0 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -301,6 +301,9 @@ pub const Action = union(Key) { /// A command has finished, command_finished: CommandFinished, + /// Start the search overlay with an optional initial needle. + start_search: StartSearch, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -358,6 +361,7 @@ pub const Action = union(Key) { progress_report, show_on_screen_keyboard, command_finished, + start_search, }; /// Sync with: ghostty_action_u @@ -770,3 +774,18 @@ pub const CommandFinished = struct { }; } }; + +pub const StartSearch = struct { + needle: [:0]const u8, + + // Sync with: ghostty_action_start_search_s + pub const C = extern struct { + needle: [*:0]const u8, + }; + + pub fn cval(self: StartSearch) C { + return .{ + .needle = self.needle.ptr, + }; + } +}; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index ce60ea0e0..636f343e3 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -340,6 +340,10 @@ pub const Action = union(enum) { /// is not performed. navigate_search: NavigateSearch, + /// Start a search if it isn't started already. This doesn't set any + /// search terms, but opens the UI for searching. + start_search, + /// Clear the screen and all scrollback. clear_screen, @@ -1167,6 +1171,7 @@ pub const Action = union(enum) { .cursor_key, .search, .navigate_search, + .start_search, .reset, .copy_to_clipboard, .copy_url_to_clipboard, diff --git a/src/input/command.zig b/src/input/command.zig index a3df0e858..37dc08fb4 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -163,6 +163,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Paste the contents of the selection clipboard.", }}, + .start_search => comptime &.{.{ + .action = .start_search, + .title = "Start Search", + .description = "Start a search if one isn't already active.", + }}, + .navigate_search => comptime &.{ .{ .action = .{ .navigate_search = .next }, .title = "Next Search Result", From bc44b187d6b1ab5436691c9d7a2848a0b11be81d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:02:27 -0800 Subject: [PATCH 03/36] macos: hook up start_search apprt action to open search --- macos/Sources/Ghostty/Ghostty.Action.swift | 12 +++++++ macos/Sources/Ghostty/Ghostty.App.swift | 26 +++++++++++++++ macos/Sources/Ghostty/SurfaceView.swift | 33 ++++++++++++++++--- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 ++ 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 9d389a8c2..8fce2199d 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -115,6 +115,18 @@ extension Ghostty.Action { len = c.len } } + + struct StartSearch { + let needle: String? + + init(c: ghostty_action_start_search_s) { + if let needleCString = c.needle { + self.needle = String(cString: needleCString) + } else { + self.needle = nil + } + } + } } // Putting the initializer in an extension preserves the automatic one. diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 9c19199e8..5c62e7040 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -606,6 +606,9 @@ extension Ghostty { case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: closeAllWindows(app, target: target) + case GHOSTTY_ACTION_START_SEARCH: + startSearch(app, target: target, v: action.action.start_search) + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: @@ -1641,6 +1644,29 @@ extension Ghostty { } } + private static func startSearch( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_start_search_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("start_search does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let startSearch = Ghostty.Action.StartSearch(c: v) + DispatchQueue.main.async { + surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch) + } + + default: + assertionFailure() + } + } + private static func configReload( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index f7cc455fc..dabfb4a57 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -197,10 +197,12 @@ extension Ghostty { SecureInputOverlay() } #endif - + // Search overlay - SurfaceSearchOverlay() - + if surfaceView.searchState != nil { + SurfaceSearchOverlay(searchState: $surfaceView.searchState) + } + // Show bell border if enabled if (ghostty.config.bellFeatures.contains(.border)) { BellBorderOverlay(bell: surfaceView.bell) @@ -387,10 +389,12 @@ extension Ghostty { /// Search overlay view that displays a search bar with input field and navigation buttons. struct SurfaceSearchOverlay: View { + @Binding var searchState: SurfaceView.SearchState? @State private var searchText: String = "" @State private var corner: Corner = .topRight @State private var dragOffset: CGSize = .zero @State private var barSize: CGSize = .zero + @FocusState private var isSearchFieldFocused: Bool private let padding: CGFloat = 8 @@ -404,6 +408,7 @@ extension Ghostty { .padding(.vertical, 6) .background(Color.primary.opacity(0.1)) .cornerRadius(6) + .focused($isSearchFieldFocused) Button(action: {}) { Image(systemName: "chevron.up") @@ -415,7 +420,9 @@ extension Ghostty { } .buttonStyle(.borderless) - Button(action: {}) { + Button(action: { + searchState = nil + }) { Image(systemName: "xmark") } .buttonStyle(.borderless) @@ -424,6 +431,12 @@ extension Ghostty { .background(.background) .cornerRadius(8) .shadow(radius: 4) + .onAppear { + if let needle = searchState?.needle { + searchText = needle + } + isSearchFieldFocused = true + } .background( GeometryReader { barGeo in Color.clear.onAppear { @@ -770,3 +783,15 @@ extension FocusedValues { typealias Value = OSSize } } + +// MARK: Search State + +extension Ghostty.SurfaceView { + class SearchState: ObservableObject { + @Published var needle: String = "" + + init(from startSearch: Ghostty.Action.StartSearch) { + self.needle = startSearch.needle ?? "" + } + } +} diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 6e3597fd3..19054b6c3 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -64,6 +64,9 @@ extension Ghostty { // The currently active key sequence. The sequence is not active if this is empty. @Published var keySequence: [KeyboardShortcut] = [] + // The current search state. When non-nil, the search overlay should be shown. + @Published var searchState: SearchState? = nil + // The time this surface last became focused. This is a ContinuousClock.Instant // on supported platforms. @Published var focusInstant: ContinuousClock.Instant? = nil From b084889782d1e282dc776cd21deec3b9262fa4cc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:11:40 -0800 Subject: [PATCH 04/36] config: cmd+f on macos start_search default --- src/config/Config.zig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 753a2d697..04b2c19e3 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6403,6 +6403,14 @@ pub const Keybinds = struct { .{ .jump_to_prompt = 1 }, ); + // Search + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true } }, + .start_search, + .{ .performable = true }, + ); + // Inspector, matching Chromium try self.set.put( alloc, From b7e70ce534bc53b9cef6b1b8c10e116e2f5f447c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:13:57 -0800 Subject: [PATCH 05/36] apprt: end_search --- include/ghostty.h | 1 + macos/Sources/Ghostty/Ghostty.App.swift | 24 ++++++++++++++++++++++++ src/Surface.zig | 9 ++++++++- src/apprt/action.zig | 4 ++++ src/config/Config.zig | 6 ++++++ src/input/command.zig | 7 ++++++- 6 files changed, 49 insertions(+), 2 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 8c4455564..f90833020 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -817,6 +817,7 @@ typedef enum { GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, GHOSTTY_ACTION_COMMAND_FINISHED, GHOSTTY_ACTION_START_SEARCH, + GHOSTTY_ACTION_END_SEARCH, } ghostty_action_tag_e; typedef union { diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 5c62e7040..8b6bf8608 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -609,6 +609,9 @@ extension Ghostty { case GHOSTTY_ACTION_START_SEARCH: startSearch(app, target: target, v: action.action.start_search) + case GHOSTTY_ACTION_END_SEARCH: + endSearch(app, target: target) + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: @@ -1667,6 +1670,27 @@ extension Ghostty { } } + private static func endSearch( + _ app: ghostty_app_t, + target: ghostty_target_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("end_search does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + DispatchQueue.main.async { + surfaceView.searchState = nil + } + + default: + assertionFailure() + } + } + private static func configReload( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/src/Surface.zig b/src/Surface.zig index 1e1363229..380300a84 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4892,7 +4892,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool const s: *Search = if (self.search) |*s| s else init: { // If we're stopping the search and we had no prior search, // then there is nothing to do. - if (text.len == 0) break :search; + if (text.len == 0) return false; // We need to assign directly to self.search because we need // a stable pointer back to the thread state. @@ -4922,6 +4922,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool if (text.len == 0) { s.deinit(); self.search = null; + + // Notify apprt search has ended. + _ = try self.rt_app.performAction( + .{ .surface = self }, + .end_search, + {}, + ); break :search; } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 45fa8aca0..e627ce803 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -304,6 +304,9 @@ pub const Action = union(Key) { /// Start the search overlay with an optional initial needle. start_search: StartSearch, + /// End the search overlay, clearing the search state and hiding it. + end_search, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -362,6 +365,7 @@ pub const Action = union(Key) { show_on_screen_keyboard, command_finished, start_search, + end_search, }; /// Sync with: ghostty_action_u diff --git a/src/config/Config.zig b/src/config/Config.zig index 04b2c19e3..85e777349 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6410,6 +6410,12 @@ pub const Keybinds = struct { .start_search, .{ .performable = true }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .escape } }, + .{ .search = "" }, + .{ .performable = true }, + ); // Inspector, matching Chromium try self.set.put( diff --git a/src/input/command.zig b/src/input/command.zig index 37dc08fb4..9f1d4d3d5 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -179,6 +179,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Navigate to the previous search result, if any.", } }, + .search => comptime &.{.{ + .action = .{ .search = "" }, + .title = "End Search", + .description = "End a search if one is active.", + }}, + .increase_font_size => comptime &.{.{ .action = .{ .increase_font_size = 1 }, .title = "Increase Font Size", @@ -620,7 +626,6 @@ fn actionCommands(action: Action.Key) []const Command { .csi, .esc, .cursor_key, - .search, .set_font_size, .scroll_to_row, .scroll_page_fractional, From c61d28a3a4a964051f35a2027266842cc925e905 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:20:01 -0800 Subject: [PATCH 06/36] macos: esc returns focus back to surface --- macos/Sources/Ghostty/SurfaceView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index dabfb4a57..00eb957ec 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -200,7 +200,7 @@ extension Ghostty { // Search overlay if surfaceView.searchState != nil { - SurfaceSearchOverlay(searchState: $surfaceView.searchState) + SurfaceSearchOverlay(surfaceView: surfaceView, searchState: $surfaceView.searchState) } // Show bell border if enabled @@ -389,6 +389,7 @@ extension Ghostty { /// Search overlay view that displays a search bar with input field and navigation buttons. struct SurfaceSearchOverlay: View { + let surfaceView: SurfaceView @Binding var searchState: SurfaceView.SearchState? @State private var searchText: String = "" @State private var corner: Corner = .topRight @@ -409,6 +410,9 @@ extension Ghostty { .background(Color.primary.opacity(0.1)) .cornerRadius(6) .focused($isSearchFieldFocused) + .onExitCommand { + Ghostty.moveFocus(to: surfaceView) + } Button(action: {}) { Image(systemName: "chevron.up") From 56d4a7f58e2a5622447191b0ebc779f2db26e07d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:24:04 -0800 Subject: [PATCH 07/36] macos: start_search refocuses the search input --- macos/Sources/Ghostty/Ghostty.App.swift | 6 +++++- macos/Sources/Ghostty/Package.swift | 3 +++ macos/Sources/Ghostty/SurfaceView.swift | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 8b6bf8608..42b146754 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -1662,7 +1662,11 @@ extension Ghostty { let startSearch = Ghostty.Action.StartSearch(c: v) DispatchQueue.main.async { - surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch) + if surfaceView.searchState != nil { + NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView) + } else { + surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch) + } } default: diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index f36b486ba..7ee815caa 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -396,6 +396,9 @@ extension Notification.Name { /// Notification sent when scrollbar updates static let ghosttyDidUpdateScrollbar = Notification.Name("com.mitchellh.ghostty.didUpdateScrollbar") static let ScrollbarKey = ghosttyDidUpdateScrollbar.rawValue + ".scrollbar" + + /// Focus the search field + static let ghosttySearchFocus = Notification.Name("com.mitchellh.ghostty.searchFocus") } // NOTE: I am moving all of these to Notification.Name extensions over time. This diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 00eb957ec..7cd37acb7 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -441,6 +441,10 @@ extension Ghostty { } isSearchFieldFocused = true } + .onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in + guard notification.object as? SurfaceView === surfaceView else { return } + isSearchFieldFocused = true + } .background( GeometryReader { barGeo in Color.clear.onAppear { From 081d73d850f1cf679207a2a2e1efa5b96133421e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:26:52 -0800 Subject: [PATCH 08/36] macos: changes to SearchState trigger calls to internals --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 19054b6c3..50b0e8597 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -65,7 +65,17 @@ extension Ghostty { @Published var keySequence: [KeyboardShortcut] = [] // The current search state. When non-nil, the search overlay should be shown. - @Published var searchState: SearchState? = nil + @Published var searchState: SearchState? = nil { + didSet { + // If the search state becomes nil, we need to make sure we're stopping + // the search internally. + if searchState == nil { + guard let surface = self.surface else { return } + let action = "search:" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + } + } + } // The time this surface last became focused. This is a ContinuousClock.Instant // on supported platforms. From 5ee000f58f957d8aa6eb1467e3a2e03aab53857b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:34:46 -0800 Subject: [PATCH 09/36] macos: search input starts the search up --- macos/Sources/Ghostty/SurfaceView.swift | 9 ++++----- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 15 ++++++++++++--- src/Surface.zig | 2 +- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 7cd37acb7..023d0475e 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -391,7 +391,6 @@ extension Ghostty { struct SurfaceSearchOverlay: View { let surfaceView: SurfaceView @Binding var searchState: SurfaceView.SearchState? - @State private var searchText: String = "" @State private var corner: Corner = .topRight @State private var dragOffset: CGSize = .zero @State private var barSize: CGSize = .zero @@ -402,7 +401,10 @@ extension Ghostty { var body: some View { GeometryReader { geo in HStack(spacing: 8) { - TextField("Search", text: $searchText) + TextField("Search", text: Binding( + get: { searchState?.needle ?? "" }, + set: { searchState?.needle = $0 } + )) .textFieldStyle(.plain) .frame(width: 180) .padding(.horizontal, 8) @@ -436,9 +438,6 @@ extension Ghostty { .cornerRadius(8) .shadow(radius: 4) .onAppear { - if let needle = searchState?.needle { - searchText = needle - } isSearchFieldFocused = true } .onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 50b0e8597..9cc8aa284 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import SwiftUI import CoreText import UserNotifications @@ -67,15 +68,23 @@ extension Ghostty { // The current search state. When non-nil, the search overlay should be shown. @Published var searchState: SearchState? = nil { didSet { - // If the search state becomes nil, we need to make sure we're stopping - // the search internally. - if searchState == nil { + if let searchState { + searchNeedleCancellable = searchState.$needle.sink { [weak self] needle in + guard let surface = self?.surface else { return } + let action = "search:\(needle)" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + } + } else { + searchNeedleCancellable = nil guard let surface = self.surface else { return } let action = "search:" ghostty_surface_binding_action(surface, action, UInt(action.count)) } } } + + // Cancellable for search state needle changes + private var searchNeedleCancellable: AnyCancellable? // The time this surface last became focused. This is a ContinuousClock.Instant // on supported platforms. diff --git a/src/Surface.zig b/src/Surface.zig index 380300a84..2163ce0e4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4879,7 +4879,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .start_search => if (self.search == null) { // To save resources, we don't actually start a search here, - // we just notify teh apprt. The real thread will start when + // we just notify the apprt. The real thread will start when // the first needles are set. _ = try self.rt_app.performAction( .{ .surface = self }, From ad8a6e0642da4770d2e058ec7f3bd931f519c15b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:43:23 -0800 Subject: [PATCH 10/36] search thread needs to take an allocated needle --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 1 + src/Surface.zig | 5 +- src/apprt/surface.zig | 4 +- src/datastruct/main.zig | 1 + src/datastruct/message_data.zig | 124 ++++++++++++++++++ src/terminal/search/Thread.zig | 17 ++- src/termio.zig | 1 - src/termio/message.zig | 122 +---------------- 8 files changed, 147 insertions(+), 128 deletions(-) create mode 100644 src/datastruct/message_data.zig diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 9cc8aa284..d4cf61b69 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -71,6 +71,7 @@ extension Ghostty { if let searchState { searchNeedleCancellable = searchState.$needle.sink { [weak self] needle in guard let surface = self?.surface else { return } + guard needle.count > 1 else { return } let action = "search:\(needle)" ghostty_surface_binding_action(surface, action, UInt(action.count)) } diff --git a/src/Surface.zig b/src/Surface.zig index 2163ce0e4..3f6884997 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4933,7 +4933,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool } _ = s.state.mailbox.push( - .{ .change_needle = text }, + .{ .change_needle = try .init( + self.alloc, + text, + ) }, .forever, ); s.state.wakeup.notify() catch {}; diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index b71bf1e6e..9e44a35d0 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -6,15 +6,15 @@ const build_config = @import("../build_config.zig"); const App = @import("../App.zig"); const Surface = @import("../Surface.zig"); const renderer = @import("../renderer.zig"); -const termio = @import("../termio.zig"); const terminal = @import("../terminal/main.zig"); const Config = @import("../config.zig").Config; +const MessageData = @import("../datastruct/main.zig").MessageData; /// The message types that can be sent to a single surface. pub const Message = union(enum) { /// Represents a write request. Magic number comes from the max size /// we want this union to be. - pub const WriteReq = termio.MessageData(u8, 255); + pub const WriteReq = MessageData(u8, 255); /// Set the title of the surface. /// TODO: we should change this to a "WriteReq" style structure in diff --git a/src/datastruct/main.zig b/src/datastruct/main.zig index 14ee0e504..64a29269e 100644 --- a/src/datastruct/main.zig +++ b/src/datastruct/main.zig @@ -13,6 +13,7 @@ pub const BlockingQueue = blocking_queue.BlockingQueue; pub const CacheTable = cache_table.CacheTable; pub const CircBuf = circ_buf.CircBuf; pub const IntrusiveDoublyLinkedList = intrusive_linked_list.DoublyLinkedList; +pub const MessageData = @import("message_data.zig").MessageData; pub const SegmentedPool = segmented_pool.SegmentedPool; pub const SplitTree = split_tree.SplitTree; diff --git a/src/datastruct/message_data.zig b/src/datastruct/message_data.zig new file mode 100644 index 000000000..3e5cdae66 --- /dev/null +++ b/src/datastruct/message_data.zig @@ -0,0 +1,124 @@ +const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; + +/// Creates a union that can be used to accommodate data that fit within an array, +/// are a stable pointer, or require deallocation. This is helpful for thread +/// messaging utilities. +pub fn MessageData(comptime Elem: type, comptime small_size: comptime_int) type { + return union(enum) { + pub const Self = @This(); + + pub const Small = struct { + pub const Max = small_size; + pub const Array = [Max]Elem; + pub const Len = std.math.IntFittingRange(0, small_size); + data: Array = undefined, + len: Len = 0, + }; + + pub const Alloc = struct { + alloc: Allocator, + data: []Elem, + }; + + pub const Stable = []const Elem; + + /// A small write where the data fits into this union size. + small: Small, + + /// A stable pointer so we can just pass the slice directly through. + /// This is useful i.e. for const data. + stable: Stable, + + /// Allocated and must be freed with the provided allocator. This + /// should be rarely used. + alloc: Alloc, + + /// Initializes the union for a given data type. This will + /// attempt to fit into a small value if possible, otherwise + /// will allocate and put into alloc. + /// + /// This can't and will never detect stable pointers. + pub fn init(alloc: Allocator, data: anytype) !Self { + switch (@typeInfo(@TypeOf(data))) { + .pointer => |info| { + assert(info.size == .slice); + assert(info.child == Elem); + + // If it fits in our small request, do that. + if (data.len <= Small.Max) { + var buf: Small.Array = undefined; + @memcpy(buf[0..data.len], data); + return Self{ + .small = .{ + .data = buf, + .len = @intCast(data.len), + }, + }; + } + + // Otherwise, allocate + const buf = try alloc.dupe(Elem, data); + errdefer alloc.free(buf); + return Self{ + .alloc = .{ + .alloc = alloc, + .data = buf, + }, + }; + }, + + else => unreachable, + } + } + + pub fn deinit(self: Self) void { + switch (self) { + .small, .stable => {}, + .alloc => |v| v.alloc.free(v.data), + } + } + + /// Returns a const slice of the data pointed to by this request. + pub fn slice(self: *const Self) []const Elem { + return switch (self.*) { + .small => |*v| v.data[0..v.len], + .stable => |v| v, + .alloc => |v| v.data, + }; + } + }; +} + +test "MessageData init small" { + const testing = std.testing; + const alloc = testing.allocator; + + const Data = MessageData(u8, 10); + const input = "hello!"; + const io = try Data.init(alloc, @as([]const u8, input)); + try testing.expect(io == .small); +} + +test "MessageData init alloc" { + const testing = std.testing; + const alloc = testing.allocator; + + const Data = MessageData(u8, 10); + const input = "hello! " ** 100; + const io = try Data.init(alloc, @as([]const u8, input)); + try testing.expect(io == .alloc); + io.alloc.alloc.free(io.alloc.data); +} + +test "MessageData small fits non-u8 sized data" { + const testing = std.testing; + const alloc = testing.allocator; + + const len = 500; + const Data = MessageData(u8, len); + const input: []const u8 = "X" ** len; + const io = try Data.init(alloc, input); + try testing.expect(io == .small); +} diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index e6094b8e5..f76af29fd 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -17,6 +17,7 @@ const Mutex = std.Thread.Mutex; const xev = @import("../../global.zig").xev; const internal_os = @import("../../os/main.zig"); const BlockingQueue = @import("../../datastruct/main.zig").BlockingQueue; +const MessageData = @import("../../datastruct/main.zig").MessageData; const point = @import("../point.zig"); const FlattenedHighlight = @import("../highlight.zig").Flattened; const UntrackedHighlight = @import("../highlight.zig").Untracked; @@ -242,7 +243,10 @@ fn drainMailbox(self: *Thread) !void { while (self.mailbox.pop()) |message| { log.debug("mailbox message={}", .{message}); switch (message) { - .change_needle => |v| try self.changeNeedle(v), + .change_needle => |v| { + defer v.deinit(); + try self.changeNeedle(v.slice()); + }, .select => |v| try self.select(v), } } @@ -414,10 +418,14 @@ pub const Mailbox = BlockingQueue(Message, 64); /// The messages that can be sent to the thread. pub const Message = union(enum) { + /// Represents a write request. Magic number comes from the max size + /// we want this union to be. + pub const WriteReq = MessageData(u8, 255); + /// Change the search term. If no prior search term is given this /// will start a search. If an existing search term is given this will /// stop the prior search and start a new one. - change_needle: []const u8, + change_needle: WriteReq, /// Select a search result. select: ScreenSearch.Select, @@ -820,7 +828,10 @@ test { // Start our search _ = thread.mailbox.push( - .{ .change_needle = "world" }, + .{ .change_needle = try .init( + alloc, + @as([]const u8, "world"), + ) }, .forever, ); try thread.wakeup.notify(); diff --git a/src/termio.zig b/src/termio.zig index c69785b25..b16885109 100644 --- a/src/termio.zig +++ b/src/termio.zig @@ -30,7 +30,6 @@ pub const Backend = backend.Backend; pub const DerivedConfig = Termio.DerivedConfig; pub const Mailbox = mailbox.Mailbox; pub const Message = message.Message; -pub const MessageData = message.MessageData; pub const StreamHandler = stream_handler.StreamHandler; test { diff --git a/src/termio/message.zig b/src/termio/message.zig index de7ea16cb..23b9f2545 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -5,6 +5,7 @@ const apprt = @import("../apprt.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); +const MessageData = @import("../datastruct/main.zig").MessageData; /// The messages that can be sent to an IO thread. /// @@ -97,95 +98,6 @@ pub const Message = union(enum) { }; }; -/// Creates a union that can be used to accommodate data that fit within an array, -/// are a stable pointer, or require deallocation. This is helpful for thread -/// messaging utilities. -pub fn MessageData(comptime Elem: type, comptime small_size: comptime_int) type { - return union(enum) { - pub const Self = @This(); - - pub const Small = struct { - pub const Max = small_size; - pub const Array = [Max]Elem; - pub const Len = std.math.IntFittingRange(0, small_size); - data: Array = undefined, - len: Len = 0, - }; - - pub const Alloc = struct { - alloc: Allocator, - data: []Elem, - }; - - pub const Stable = []const Elem; - - /// A small write where the data fits into this union size. - small: Small, - - /// A stable pointer so we can just pass the slice directly through. - /// This is useful i.e. for const data. - stable: Stable, - - /// Allocated and must be freed with the provided allocator. This - /// should be rarely used. - alloc: Alloc, - - /// Initializes the union for a given data type. This will - /// attempt to fit into a small value if possible, otherwise - /// will allocate and put into alloc. - /// - /// This can't and will never detect stable pointers. - pub fn init(alloc: Allocator, data: anytype) !Self { - switch (@typeInfo(@TypeOf(data))) { - .pointer => |info| { - assert(info.size == .slice); - assert(info.child == Elem); - - // If it fits in our small request, do that. - if (data.len <= Small.Max) { - var buf: Small.Array = undefined; - @memcpy(buf[0..data.len], data); - return Self{ - .small = .{ - .data = buf, - .len = @intCast(data.len), - }, - }; - } - - // Otherwise, allocate - const buf = try alloc.dupe(Elem, data); - errdefer alloc.free(buf); - return Self{ - .alloc = .{ - .alloc = alloc, - .data = buf, - }, - }; - }, - - else => unreachable, - } - } - - pub fn deinit(self: Self) void { - switch (self) { - .small, .stable => {}, - .alloc => |v| v.alloc.free(v.data), - } - } - - /// Returns a const slice of the data pointed to by this request. - pub fn slice(self: *const Self) []const Elem { - return switch (self.*) { - .small => |*v| v.data[0..v.len], - .stable => |v| v, - .alloc => |v| v.data, - }; - } - }; -} - test { std.testing.refAllDecls(@This()); } @@ -195,35 +107,3 @@ test { const testing = std.testing; try testing.expectEqual(@as(usize, 40), @sizeOf(Message)); } - -test "MessageData init small" { - const testing = std.testing; - const alloc = testing.allocator; - - const Data = MessageData(u8, 10); - const input = "hello!"; - const io = try Data.init(alloc, @as([]const u8, input)); - try testing.expect(io == .small); -} - -test "MessageData init alloc" { - const testing = std.testing; - const alloc = testing.allocator; - - const Data = MessageData(u8, 10); - const input = "hello! " ** 100; - const io = try Data.init(alloc, @as([]const u8, input)); - try testing.expect(io == .alloc); - io.alloc.alloc.free(io.alloc.data); -} - -test "MessageData small fits non-u8 sized data" { - const testing = std.testing; - const alloc = testing.allocator; - - const len = 500; - const Data = MessageData(u8, len); - const input: []const u8 = "X" ** len; - const io = try Data.init(alloc, input); - try testing.expect(io == .small); -} From 15f00a9cd1368642a14bc105c76831f720b27286 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 15:29:24 -0800 Subject: [PATCH 11/36] renderer: setup proper dirty state on search selection changing --- src/Surface.zig | 4 ++++ src/renderer/generic.zig | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 3f6884997..55a96c02e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1391,6 +1391,10 @@ fn searchCallback_( // When we quit, tell our renderer to reset any search state. .quit => { + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = null }, + .forever, + ); _ = self.renderer_thread.mailbox.push( .{ .search_viewport_matches = .{ .arena = .init(self.alloc), diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index bddda7ef0..df36c4a7e 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1217,8 +1217,21 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (self.search_matches_dirty or self.terminal_state.dirty != .false) { self.search_matches_dirty = false; - for (self.terminal_state.row_data.items(.highlights)) |*highlights| { - highlights.clearRetainingCapacity(); + // Clear the prior highlights + const row_data = self.terminal_state.row_data.slice(); + var any_dirty: bool = false; + for ( + row_data.items(.highlights), + row_data.items(.dirty), + ) |*highlights, *dirty| { + if (highlights.items.len > 0) { + highlights.clearRetainingCapacity(); + dirty.* = true; + any_dirty = true; + } + } + if (any_dirty and self.terminal_state.dirty == .false) { + self.terminal_state.dirty = .partial; } // NOTE: The order below matters. Highlights added earlier @@ -1228,7 +1241,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.terminal_state.updateHighlightsFlattened( self.alloc, @intFromEnum(HighlightTag.search_match_selected), - (&m.match)[0..1], + &.{m.match}, ) catch |err| { // Not a critical error, we just won't show highlights. log.warn("error updating search selected highlight err={}", .{err}); From 3ce19a02ba5afa560e06aa0b4b0b22c50f05800f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 15:33:33 -0800 Subject: [PATCH 12/36] macos: hook up the next/prev search buttons --- macos/Sources/Ghostty/SurfaceView.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 023d0475e..d8fc68a47 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -416,12 +416,20 @@ extension Ghostty { Ghostty.moveFocus(to: surfaceView) } - Button(action: {}) { + Button(action: { + guard let surface = surfaceView.surface else { return } + let action = "navigate_search:next" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + }) { Image(systemName: "chevron.up") } .buttonStyle(.borderless) - Button(action: {}) { + Button(action: { + guard let surface = surfaceView.surface else { return } + let action = "navigate_search:previous" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + }) { Image(systemName: "chevron.down") } .buttonStyle(.borderless) From 72708b8253227a9c835599596f81ae793a40bc08 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 15:40:44 -0800 Subject: [PATCH 13/36] search: do not restart search if needle doesn't change --- src/terminal/search/Thread.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index f76af29fd..275af6d93 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -279,6 +279,9 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { // Stop the previous search if (self.search) |*s| { + // If our search is unchanged, do nothing. + if (std.ascii.eqlIgnoreCase(s.viewport.needle(), needle)) return; + s.deinit(); self.search = null; From efc05523e051b992014e91db3ef4aa4e887f3839 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 17:21:04 -0800 Subject: [PATCH 14/36] macos: enter goes to next result --- macos/Sources/Ghostty/SurfaceView.swift | 5 +++++ macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index d8fc68a47..6a0f369c9 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -412,6 +412,11 @@ extension Ghostty { .background(Color.primary.opacity(0.1)) .cornerRadius(6) .focused($isSearchFieldFocused) + .onSubmit { + guard let surface = surfaceView.surface else { return } + let action = "navigate_search:next" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + } .onExitCommand { Ghostty.moveFocus(to: surfaceView) } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d4cf61b69..e67a85349 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -69,7 +69,7 @@ extension Ghostty { @Published var searchState: SearchState? = nil { didSet { if let searchState { - searchNeedleCancellable = searchState.$needle.sink { [weak self] needle in + searchNeedleCancellable = searchState.$needle.removeDuplicates().sink { [weak self] needle in guard let surface = self?.surface else { return } guard needle.count > 1 else { return } let action = "search:\(needle)" From cfbc219f5c8f5ccff2215b3e3716bb343eace181 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 17:25:01 -0800 Subject: [PATCH 15/36] macos: enter and shift+enter move the results --- macos/Sources/Ghostty/SurfaceView.swift | 35 ++++++++++++++----------- macos/Sources/Helpers/Backport.swift | 24 +++++++++++++++++ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 6a0f369c9..6d0cc21be 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -405,21 +405,24 @@ extension Ghostty { get: { searchState?.needle ?? "" }, set: { searchState?.needle = $0 } )) - .textFieldStyle(.plain) - .frame(width: 180) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(Color.primary.opacity(0.1)) - .cornerRadius(6) - .focused($isSearchFieldFocused) - .onSubmit { - guard let surface = surfaceView.surface else { return } - let action = "navigate_search:next" - ghostty_surface_binding_action(surface, action, UInt(action.count)) - } - .onExitCommand { - Ghostty.moveFocus(to: surfaceView) - } + .textFieldStyle(.plain) + .frame(width: 180) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.1)) + .cornerRadius(6) + .focused($isSearchFieldFocused) + .onExitCommand { + Ghostty.moveFocus(to: surfaceView) + } + .backport.onKeyPress(.return) { modifiers in + guard let surface = surfaceView.surface else { return .ignored } + let action = modifiers.contains(.shift) + ? "navigate_search:previous" + : "navigate_search:next" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + return .handled + } Button(action: { guard let surface = surfaceView.surface else { return } @@ -526,7 +529,7 @@ extension Ghostty { } } } - + /// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn /// and interacted with. The word "surface" is used because a surface may represent a window, a tab, /// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it. diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index a28be15ae..8c43652e4 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -18,6 +18,12 @@ extension Backport where Content: Scene { // None currently } +/// Result type for backported onKeyPress handler +enum BackportKeyPressResult { + case handled + case ignored +} + extension Backport where Content: View { func pointerVisibility(_ v: BackportVisibility) -> some View { #if canImport(AppKit) @@ -42,6 +48,24 @@ extension Backport where Content: View { return content #endif } + + /// Backported onKeyPress that works on macOS 14+ and is a no-op on macOS 13. + func onKeyPress(_ key: KeyEquivalent, action: @escaping (EventModifiers) -> BackportKeyPressResult) -> some View { + #if canImport(AppKit) + if #available(macOS 14, *) { + return content.onKeyPress(key, phases: .down, action: { keyPress in + switch action(keyPress.modifiers) { + case .handled: return .handled + case .ignored: return .ignored + } + }) + } else { + return content + } + #else + return content + #endif + } } enum BackportVisibility { From 5b2d66e26186b58b1a84a4b76a8dfe9a10bc8400 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 17:34:58 -0800 Subject: [PATCH 16/36] apprt/gtk: disable search apprt actions --- src/apprt/gtk/class/application.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index eac88f9cf..05c6adc2b 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -743,6 +743,8 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, + .start_search, + .end_search, => { log.warn("unimplemented action={}", .{action}); return false; From 949a8ea53fbf5b319743cd378e73b5dc58623877 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:05:48 -0800 Subject: [PATCH 17/36] macos: dummy search state for iOS --- macos/Sources/Ghostty/SurfaceView.swift | 2 ++ macos/Sources/Ghostty/SurfaceView_UIKit.swift | 3 +++ 2 files changed, 5 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 6d0cc21be..1718aeead 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -413,7 +413,9 @@ extension Ghostty { .cornerRadius(6) .focused($isSearchFieldFocused) .onExitCommand { + #if canImport(AppKit) Ghostty.moveFocus(to: surfaceView) + #endif } .backport.onKeyPress(.return) { modifiers in guard let surface = surfaceView.surface else { return .ignored } diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 29364d4a5..09c41c0b5 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -40,6 +40,9 @@ extension Ghostty { /// True when the bell is active. This is set inactive on focus or event. @Published var bell: Bool = false + + // The current search state. When non-nil, the search overlay should be shown. + @Published var searchState: SearchState? = nil // Returns sizing information for the surface. This is the raw C // structure because I'm lazy. From 3f7cfca4b467ff04a3a71ea91f2d95088033ee55 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:20:11 -0800 Subject: [PATCH 18/36] macos: add find menu item --- macos/Sources/App/macOS/AppDelegate.swift | 8 +++++ macos/Sources/App/macOS/MainMenu.xib | 34 +++++++++++++++++-- .../Terminal/BaseTerminalController.swift | 12 +++++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 24 +++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index b05351bfd..763a387ed 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -44,6 +44,10 @@ class AppDelegate: NSObject, @IBOutlet private var menuPaste: NSMenuItem? @IBOutlet private var menuPasteSelection: NSMenuItem? @IBOutlet private var menuSelectAll: NSMenuItem? + @IBOutlet private var menuFindParent: NSMenuItem? + @IBOutlet private var menuFind: NSMenuItem? + @IBOutlet private var menuFindNext: NSMenuItem? + @IBOutlet private var menuFindPrevious: NSMenuItem? @IBOutlet private var menuToggleVisibility: NSMenuItem? @IBOutlet private var menuToggleFullScreen: NSMenuItem? @@ -553,6 +557,7 @@ class AppDelegate: NSObject, self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line") self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line") self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.filled.on.square") + self.menuFindParent?.setImageIfDesired(systemSymbolName: "text.page.badge.magnifyingglass") } /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. @@ -581,6 +586,9 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) + syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind) + syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext) + syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious) syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index c97ed7c61..ce6f5a0cb 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -26,6 +26,10 @@ + + + + @@ -245,6 +249,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 552f864ee..d0cea43f5 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1112,6 +1112,18 @@ class BaseTerminalController: NSWindowController, @IBAction func toggleCommandPalette(_ sender: Any?) { commandPaletteIsShowing.toggle() } + + @IBAction func find(_ sender: Any) { + focusedSurface?.find(sender) + } + + @IBAction func findNext(_ sender: Any) { + focusedSurface?.findNext(sender) + } + + @IBAction func findPrevious(_ sender: Any) { + focusedSurface?.findNext(sender) + } @objc func resetTerminal(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index e67a85349..d70cc9654 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1470,6 +1470,30 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } + + @IBAction func find(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "start_search" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + @IBAction func findNext(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "search:next" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + @IBAction func findPrevious(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "search:previous" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } @IBAction func splitRight(_ sender: Any) { guard let surface = self.surface else { return } From 240d5e0fc56d1b24fa9795335a3e38365190661a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:29:54 -0800 Subject: [PATCH 19/36] config: default search keybindings for macos --- src/Surface.zig | 10 +++++++++- src/config/Config.zig | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index 55a96c02e..0e91b4083 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4896,7 +4896,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool const s: *Search = if (self.search) |*s| s else init: { // If we're stopping the search and we had no prior search, // then there is nothing to do. - if (text.len == 0) return false; + if (text.len == 0) { + // So GUIs can hide visible search widgets. + _ = try self.rt_app.performAction( + .{ .surface = self }, + .end_search, + {}, + ); + return false; + } // We need to assign directly to self.search because we need // a stable pointer back to the thread state. diff --git a/src/config/Config.zig b/src/config/Config.zig index 85e777349..e34666ecb 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6410,12 +6410,30 @@ pub const Keybinds = struct { .start_search, .{ .performable = true }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .shift = true } }, + .{ .search = "" }, + .{ .performable = true }, + ); try self.set.putFlags( alloc, .{ .key = .{ .physical = .escape } }, .{ .search = "" }, .{ .performable = true }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'g' }, .mods = .{ .super = true } }, + .{ .navigate_search = .next }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'g' }, .mods = .{ .super = true, .shift = true } }, + .{ .navigate_search = .previous }, + .{ .performable = true }, + ); // Inspector, matching Chromium try self.set.put( From 7835ad0ea43cc90c711517b43a107477b86a70f8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:34:38 -0800 Subject: [PATCH 20/36] macos: more menu items --- macos/Sources/App/macOS/AppDelegate.swift | 1 + macos/Sources/App/macOS/MainMenu.xib | 8 ++++++++ .../Terminal/BaseTerminalController.swift | 16 ++++++++++++++++ .../Features/Terminal/TerminalController.swift | 6 +++--- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 11 +++++++++++ 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 763a387ed..da20c2124 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -48,6 +48,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuFind: NSMenuItem? @IBOutlet private var menuFindNext: NSMenuItem? @IBOutlet private var menuFindPrevious: NSMenuItem? + @IBOutlet private var menuHideFindBar: NSMenuItem? @IBOutlet private var menuToggleVisibility: NSMenuItem? @IBOutlet private var menuToggleFullScreen: NSMenuItem? diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index ce6f5a0cb..3e1084cd7 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -31,6 +31,7 @@ + @@ -271,6 +272,13 @@ + + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index d0cea43f5..9104e61ff 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1124,6 +1124,10 @@ class BaseTerminalController: NSWindowController, @IBAction func findPrevious(_ sender: Any) { focusedSurface?.findNext(sender) } + + @IBAction func findHide(_ sender: Any) { + focusedSurface?.findHide(sender) + } @objc func resetTerminal(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } @@ -1148,3 +1152,15 @@ class BaseTerminalController: NSWindowController, } } } + +extension BaseTerminalController: NSMenuItemValidation { + func validateMenuItem(_ item: NSMenuItem) -> Bool { + switch item.action { + case #selector(findHide): + return focusedSurface?.searchState != nil + + default: + return true + } + } +} diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 4de0336ce..e1a98e598 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1403,8 +1403,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // MARK: NSMenuItemValidation -extension TerminalController: NSMenuItemValidation { - func validateMenuItem(_ item: NSMenuItem) -> Bool { +extension TerminalController { + override func validateMenuItem(_ item: NSMenuItem) -> Bool { switch item.action { case #selector(returnToDefaultSize): guard let window else { return false } @@ -1433,7 +1433,7 @@ extension TerminalController: NSMenuItemValidation { return true default: - return true + return super.validateMenuItem(item) } } } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d70cc9654..f431fdf6d 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1494,6 +1494,14 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } + + @IBAction func findHide(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "search:" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } @IBAction func splitRight(_ sender: Any) { guard let surface = self.surface else { return } @@ -1967,6 +1975,9 @@ extension Ghostty.SurfaceView: NSMenuItemValidation { let pb = NSPasteboard.ghosttySelection guard let str = pb.getOpinionatedStringContents() else { return false } return !str.isEmpty + + case #selector(findHide): + return searchState != nil default: return true From d4a2f3db716cc5dc738789098835c528572f0cc3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:38:14 -0800 Subject: [PATCH 21/36] macos: search overlay shows search progress --- macos/Sources/Ghostty/SurfaceView.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 1718aeead..47532c96a 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -426,6 +426,14 @@ extension Ghostty { return .handled } + if let selected = searchState?.selected { + let totalText = searchState?.total.map { String($0) } ?? "?" + Text("\(selected)/\(totalText)") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + } + Button(action: { guard let surface = surfaceView.surface else { return } let action = "navigate_search:next" @@ -814,6 +822,8 @@ extension FocusedValues { extension Ghostty.SurfaceView { class SearchState: ObservableObject { @Published var needle: String = "" + @Published var selected: UInt? = nil + @Published var total: UInt? = nil init(from startSearch: Ghostty.Action.StartSearch) { self.needle = startSearch.needle ?? "" From 2ee2d000f5e3400d728c99821cb2c236192b85d2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:42:55 -0800 Subject: [PATCH 22/36] apprt actions for search progress --- include/ghostty.h | 14 +++++++++++ src/apprt/action.zig | 38 +++++++++++++++++++++++++++++ src/apprt/gtk/class/application.zig | 2 ++ 3 files changed, 54 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index f90833020..6cafe8773 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -752,6 +752,16 @@ typedef struct { const char* needle; } ghostty_action_start_search_s; +// apprt.action.SearchTotal +typedef struct { + ssize_t total; +} ghostty_action_search_total_s; + +// apprt.action.SearchSelected +typedef struct { + ssize_t selected; +} ghostty_action_search_selected_s; + // terminal.Scrollbar typedef struct { uint64_t total; @@ -818,6 +828,8 @@ typedef enum { GHOSTTY_ACTION_COMMAND_FINISHED, GHOSTTY_ACTION_START_SEARCH, GHOSTTY_ACTION_END_SEARCH, + GHOSTTY_ACTION_SEARCH_TOTAL, + GHOSTTY_ACTION_SEARCH_SELECTED, } ghostty_action_tag_e; typedef union { @@ -852,6 +864,8 @@ typedef union { ghostty_action_progress_report_s progress_report; ghostty_action_command_finished_s command_finished; ghostty_action_start_search_s start_search; + ghostty_action_search_total_s search_total; + ghostty_action_search_selected_s search_selected; } ghostty_action_u; typedef struct { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index e627ce803..00bf8685a 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -307,6 +307,12 @@ pub const Action = union(Key) { /// End the search overlay, clearing the search state and hiding it. end_search, + /// The total number of matches found by the search. + search_total: SearchTotal, + + /// The currently selected search match index (1-based). + search_selected: SearchSelected, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -366,6 +372,8 @@ pub const Action = union(Key) { command_finished, start_search, end_search, + search_total, + search_selected, }; /// Sync with: ghostty_action_u @@ -793,3 +801,33 @@ pub const StartSearch = struct { }; } }; + +pub const SearchTotal = struct { + total: ?usize, + + // Sync with: ghostty_action_search_total_s + pub const C = extern struct { + total: isize, + }; + + pub fn cval(self: SearchTotal) C { + return .{ + .total = if (self.total) |t| @intCast(t) else -1, + }; + } +}; + +pub const SearchSelected = struct { + selected: ?usize, + + // Sync with: ghostty_action_search_selected_s + pub const C = extern struct { + selected: isize, + }; + + pub fn cval(self: SearchSelected) C { + return .{ + .selected = if (self.selected) |s| @intCast(s) else -1, + }; + } +}; diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 05c6adc2b..9c22782c7 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -745,6 +745,8 @@ pub const Application = extern struct { .redo, .start_search, .end_search, + .search_total, + .search_selected, => { log.warn("unimplemented action={}", .{action}); return false; From c20af77f98b2a33b8e151ef1dd7d7074f188fa90 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:44:19 -0800 Subject: [PATCH 23/36] macos: handle search progress/total apprt actions --- macos/Sources/Ghostty/Ghostty.App.swift | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 42b146754..9c1acd1a8 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -612,6 +612,12 @@ extension Ghostty { case GHOSTTY_ACTION_END_SEARCH: endSearch(app, target: target) + case GHOSTTY_ACTION_SEARCH_TOTAL: + searchTotal(app, target: target, v: action.action.search_total) + + case GHOSTTY_ACTION_SEARCH_SELECTED: + searchSelected(app, target: target, v: action.action.search_selected) + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: @@ -1695,6 +1701,52 @@ extension Ghostty { } } + private static func searchTotal( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_search_total_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("search_total does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let total: UInt? = v.total >= 0 ? UInt(v.total) : nil + DispatchQueue.main.async { + surfaceView.searchState?.total = total + } + + default: + assertionFailure() + } + } + + private static func searchSelected( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_search_selected_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("search_selected does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let selected: UInt? = v.selected >= 0 ? UInt(v.selected) : nil + DispatchQueue.main.async { + surfaceView.searchState?.selected = selected + } + + default: + assertionFailure() + } + } + private static func configReload( _ app: ghostty_app_t, target: ghostty_target_s, From 7320b234b48c7c078840a12813b5ff4261fde41b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:47:06 -0800 Subject: [PATCH 24/36] core: surface sends search total/progress to apprt --- src/Surface.zig | 57 ++++++++++++++++++++++++++++++++++++++++--- src/apprt/surface.zig | 6 +++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 0e91b4083..87cbd05b9 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -804,6 +804,14 @@ pub fn close(self: *Surface) void { self.rt_surface.close(self.needsConfirmQuit()); } +/// Returns a mailbox that can be used to send messages to this surface. +inline fn surfaceMailbox(self: *Surface) Mailbox { + return .{ + .surface = self, + .app = .{ .rt_app = self.rt_app, .mailbox = &self.app.mailbox }, + }; +} + /// Forces the surface to render. This is useful for when the surface /// is in the middle of animation (such as a resize, etc.) or when /// the render timer is managed manually by the apprt. @@ -1069,6 +1077,22 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { log.warn("apprt failed to notify command finish={}", .{err}); }; }, + + .search_total => |v| { + _ = try self.rt_app.performAction( + .{ .surface = self }, + .search_total, + .{ .total = v }, + ); + }, + + .search_selected => |v| { + _ = try self.rt_app.performAction( + .{ .surface = self }, + .search_selected, + .{ .selected = v }, + ); + }, } } @@ -1378,17 +1402,36 @@ fn searchCallback_( } }, .forever, ); + + // Send the selected index to the surface mailbox + _ = self.surfaceMailbox().push( + .{ .search_selected = sel.idx }, + .forever, + ); } else { // Reset our selected match _ = self.renderer_thread.mailbox.push( .{ .search_selected_match = null }, .forever, ); + + // Reset the selected index + _ = self.surfaceMailbox().push( + .{ .search_selected = null }, + .forever, + ); } try self.renderer_thread.wakeup.notify(); }, + .total_matches => |total| { + _ = self.surfaceMailbox().push( + .{ .search_total = total }, + .forever, + ); + }, + // When we quit, tell our renderer to reset any search state. .quit => { _ = self.renderer_thread.mailbox.push( @@ -1403,12 +1446,20 @@ fn searchCallback_( .forever, ); try self.renderer_thread.wakeup.notify(); + + // Reset search totals in the surface + _ = self.surfaceMailbox().push( + .{ .search_total = null }, + .forever, + ); + _ = self.surfaceMailbox().push( + .{ .search_selected = null }, + .forever, + ); }, // Unhandled, so far. - .total_matches, - .complete, - => {}, + .complete => {}, } } diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 9e44a35d0..45a847493 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -107,6 +107,12 @@ pub const Message = union(enum) { /// The scrollbar state changed for the surface. scrollbar: terminal.Scrollbar, + /// Search progress update + search_total: ?usize, + + /// Selected search index change + search_selected: ?usize, + pub const ReportTitleStyle = enum { csi_21_t, From 0e974f85edfdc3fe60e28aa7b0539ddea3f22ee5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:52:26 -0800 Subject: [PATCH 25/36] macos: fix iOS build --- macos/Sources/Ghostty/SurfaceView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 47532c96a..4c9fecaee 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -412,11 +412,11 @@ extension Ghostty { .background(Color.primary.opacity(0.1)) .cornerRadius(6) .focused($isSearchFieldFocused) +#if canImport(AppKit) .onExitCommand { - #if canImport(AppKit) Ghostty.moveFocus(to: surfaceView) - #endif } +#endif .backport.onKeyPress(.return) { modifiers in guard let surface = surfaceView.surface else { return .ignored } let action = modifiers.contains(.shift) From 93656fca5abe7e24ef5c413cdc3c69269fad6acb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:58:33 -0800 Subject: [PATCH 26/36] macos: show progerss correctly for search --- macos/Sources/Ghostty/SurfaceView.swift | 30 ++++++++++++++----------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 4c9fecaee..6d17258d8 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -199,8 +199,12 @@ extension Ghostty { #endif // Search overlay - if surfaceView.searchState != nil { - SurfaceSearchOverlay(surfaceView: surfaceView, searchState: $surfaceView.searchState) + if let searchState = surfaceView.searchState { + SurfaceSearchOverlay( + surfaceView: surfaceView, + searchState: searchState, + onClose: { surfaceView.searchState = nil } + ) } // Show bell border if enabled @@ -390,7 +394,8 @@ extension Ghostty { /// Search overlay view that displays a search bar with input field and navigation buttons. struct SurfaceSearchOverlay: View { let surfaceView: SurfaceView - @Binding var searchState: SurfaceView.SearchState? + @ObservedObject var searchState: SurfaceView.SearchState + let onClose: () -> Void @State private var corner: Corner = .topRight @State private var dragOffset: CGSize = .zero @State private var barSize: CGSize = .zero @@ -401,10 +406,7 @@ extension Ghostty { var body: some View { GeometryReader { geo in HStack(spacing: 8) { - TextField("Search", text: Binding( - get: { searchState?.needle ?? "" }, - set: { searchState?.needle = $0 } - )) + TextField("Search", text: $searchState.needle) .textFieldStyle(.plain) .frame(width: 180) .padding(.horizontal, 8) @@ -426,9 +428,13 @@ extension Ghostty { return .handled } - if let selected = searchState?.selected { - let totalText = searchState?.total.map { String($0) } ?? "?" - Text("\(selected)/\(totalText)") + if let selected = searchState.selected { + Text("\(selected + 1)/\(searchState.total, default: "?")") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + } else if let total = searchState.total { + Text("-/\(total)") .font(.caption) .foregroundColor(.secondary) .monospacedDigit() @@ -452,9 +458,7 @@ extension Ghostty { } .buttonStyle(.borderless) - Button(action: { - searchState = nil - }) { + Button(action: onClose) { Image(systemName: "xmark") } .buttonStyle(.borderless) From 48acc90983afb25ef81f07aface3d31c6add6753 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 21:40:16 -0800 Subject: [PATCH 27/36] terminal: search should reload active area if dirty --- src/terminal/search/Thread.zig | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 275af6d93..1ffb420f0 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -643,8 +643,20 @@ const Search = struct { // found the viewport/active area dirty, so we should mark it as // dirty in our viewport searcher so it forces a re-search. if (t.flags.search_viewport_dirty) { - self.viewport.active_dirty = true; t.flags.search_viewport_dirty = false; + + // Mark our viewport dirty so it researches the active + self.viewport.active_dirty = true; + + // Reload our active area for our active screen + if (self.screens.getPtr(t.screens.active_key)) |screen_search| { + screen_search.reloadActive() catch |err| switch (err) { + error.OutOfMemory => log.warn( + "error reloading active area for screen key={} err={}", + .{ t.screens.active_key, err }, + ), + }; + } } // Check our viewport for changes. From 1bb2d4f1c23e5686192b7ef36dd579e4aa4ffda7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 21:42:05 -0800 Subject: [PATCH 28/36] macos: only end search if we previously had one --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index f431fdf6d..e2feb79c4 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -75,7 +75,7 @@ extension Ghostty { let action = "search:\(needle)" ghostty_surface_binding_action(surface, action, UInt(action.count)) } - } else { + } else if oldValue != nil { searchNeedleCancellable = nil guard let surface = self.surface else { return } let action = "search:" From ad755b0e3d987af71c7adbb870d771aa0100c716 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 21:46:13 -0800 Subject: [PATCH 29/36] core: always send start_search for refocus --- src/Surface.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 87cbd05b9..c3740fd71 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4932,16 +4932,16 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.terminal.fullReset(); }, - .start_search => if (self.search == null) { + .start_search => { // To save resources, we don't actually start a search here, // we just notify the apprt. The real thread will start when // the first needles are set. - _ = try self.rt_app.performAction( + return try self.rt_app.performAction( .{ .surface = self }, .start_search, .{ .needle = "" }, ); - } else return false, + }, .search => |text| search: { const s: *Search = if (self.search) |*s| s else init: { From 330ce07d48261cf37e1aa0cb05a1a08cbcb866a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 21:51:54 -0800 Subject: [PATCH 30/36] terminal: fix moving selection on history changing --- src/terminal/search/screen.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index bd5aa80a5..ac03dd65a 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -493,6 +493,10 @@ pub const ScreenSearch = struct { // in our history (fast path) if (results.items.len == 0) break :history; + // The number added to our history. Needed for updating + // our selection if we have one. + const added_len = results.items.len; + // Matches! Reverse our list then append all the remaining // history items that didn't start on our original node. std.mem.reverse(FlattenedHighlight, results.items); @@ -505,7 +509,7 @@ pub const ScreenSearch = struct { if (self.selected) |*m| selected: { const active_len = self.active_results.items.len; if (m.idx < active_len) break :selected; - m.idx += results.items.len; + m.idx += added_len; // Moving the idx should not change our targeted result // since the history is immutable. From f252db1f1cdc1ca28cc5f9679f541f542844589e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 22:10:44 -0800 Subject: [PATCH 31/36] terminal: handle pruning history for when active area removes it --- src/terminal/search/screen.zig | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index ac03dd65a..97784e97e 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -518,6 +518,26 @@ pub const ScreenSearch = struct { assert(m.highlight.start.eql(hl.startPin())); } } + } else { + // No history node means we have no history + if (self.history) |*h| { + h.deinit(self.screen); + self.history = null; + for (self.history_results.items) |*hl| hl.deinit(alloc); + self.history_results.clearRetainingCapacity(); + } + + // If we have a selection in the history area, we need to + // move it to the end of the active area. + if (self.selected) |*m| selected: { + const active_len = self.active_results.items.len; + if (m.idx < active_len) break :selected; + m.deinit(self.screen); + self.selected = null; + _ = self.select(.prev) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; + } } // Figure out if we need to fixup our selection later because From f91080a1650162060cf2fb2eae6af690ea6d773f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 06:52:16 -0800 Subject: [PATCH 32/36] terminal: fix single-character search crashes --- src/terminal/search/sliding_window.zig | 112 ++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 2 deletions(-) diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index 66f7bc70c..0d853b3a0 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -222,10 +222,17 @@ pub const SlidingWindow = struct { ); } + // Special case 1-lengthed needles to delete the entire buffer. + if (self.needle.len == 1) { + self.clearAndRetainCapacity(); + self.assertIntegrity(); + return null; + } + // No match. We keep `needle.len - 1` bytes available to // handle the future overlap case. - var meta_it = self.meta.iterator(.reverse); prune: { + var meta_it = self.meta.iterator(.reverse); var saved: usize = 0; while (meta_it.next()) |meta| { const needed = self.needle.len - 1 - saved; @@ -606,7 +613,7 @@ pub const SlidingWindow = struct { assert(data_len == self.data.len()); // Integrity check: verify our data offset is within bounds. - assert(self.data_offset < self.data.len()); + assert(self.data.len() == 0 or self.data_offset < self.data.len()); } }; @@ -709,6 +716,52 @@ test "SlidingWindow single append case insensitive ASCII" { try testing.expect(w.next() == null); try testing.expect(w.next() == null); } + +test "SlidingWindow single append single char" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "b"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // We want to test single-page cases. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + + // We should be able to find two matches. + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + test "SlidingWindow single append no match" { const testing = std.testing; const alloc = testing.allocator; @@ -788,6 +841,61 @@ test "SlidingWindow two pages" { try testing.expect(w.next() == null); } +test "SlidingWindow two pages single char" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "b"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should find two matches + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + test "SlidingWindow two pages match across boundary" { const testing = std.testing; const alloc = testing.allocator; From 339abf97f74b40e6fa10f21cb7b49a5a7ce71bd9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 06:53:38 -0800 Subject: [PATCH 33/36] macos: can allow single char searches now --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index e2feb79c4..cd8c7ccb5 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -71,7 +71,7 @@ extension Ghostty { if let searchState { searchNeedleCancellable = searchState.$needle.removeDuplicates().sink { [weak self] needle in guard let surface = self?.surface else { return } - guard needle.count > 1 else { return } + guard needle.count > 0 else { return } let action = "search:\(needle)" ghostty_surface_binding_action(surface, action, UInt(action.count)) } From f7b14a0142093af5b6e53d8e51c1ad7bc4dbd5a4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 06:59:16 -0800 Subject: [PATCH 34/36] macos: debounce search requests with length less than 3 --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index cd8c7ccb5..071131b42 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -69,12 +69,28 @@ extension Ghostty { @Published var searchState: SearchState? = nil { didSet { if let searchState { - searchNeedleCancellable = searchState.$needle.removeDuplicates().sink { [weak self] needle in - guard let surface = self?.surface else { return } - guard needle.count > 0 else { return } - let action = "search:\(needle)" - ghostty_surface_binding_action(surface, action, UInt(action.count)) - } + // I'm not a Combine expert so if there is a better way to do this I'm + // all ears. What we're doing here is grabbing the latest needle. If the + // needle is less than 3 chars, we debounce it for a few hundred ms to + // avoid kicking off expensive searches. + searchNeedleCancellable = searchState.$needle + .removeDuplicates() + .filter { $0.count > 0 } + .map { needle -> AnyPublisher in + if needle.count >= 3 { + return Just(needle).eraseToAnyPublisher() + } else { + return Just(needle) + .delay(for: .milliseconds(300), scheduler: DispatchQueue.main) + .eraseToAnyPublisher() + } + } + .switchToLatest() + .sink { [weak self] needle in + guard let surface = self?.surface else { return } + let action = "search:\(needle)" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + } } else if oldValue != nil { searchNeedleCancellable = nil guard let surface = self.surface else { return } From c51170da9c260309235d0240338f7e29c95c9f3c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 07:05:52 -0800 Subject: [PATCH 35/36] add end_search binding --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 +- src/Surface.zig | 30 +++++++++---------- src/config/Config.zig | 2 +- src/input/Binding.zig | 10 ++++++- src/input/command.zig | 6 ++++ 5 files changed, 31 insertions(+), 20 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 071131b42..8aa108f3f 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -75,9 +75,8 @@ extension Ghostty { // avoid kicking off expensive searches. searchNeedleCancellable = searchState.$needle .removeDuplicates() - .filter { $0.count > 0 } .map { needle -> AnyPublisher in - if needle.count >= 3 { + if needle.isEmpty || needle.count >= 3 { return Just(needle).eraseToAnyPublisher() } else { return Just(needle) diff --git a/src/Surface.zig b/src/Surface.zig index c3740fd71..698d1844b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4943,19 +4943,24 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ); }, + .end_search => { + if (self.search) |*s| { + s.deinit(); + self.search = null; + } + + return try self.rt_app.performAction( + .{ .surface = self }, + .end_search, + {}, + ); + }, + .search => |text| search: { const s: *Search = if (self.search) |*s| s else init: { // If we're stopping the search and we had no prior search, // then there is nothing to do. - if (text.len == 0) { - // So GUIs can hide visible search widgets. - _ = try self.rt_app.performAction( - .{ .surface = self }, - .end_search, - {}, - ); - return false; - } + if (text.len == 0) return false; // We need to assign directly to self.search because we need // a stable pointer back to the thread state. @@ -4985,13 +4990,6 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool if (text.len == 0) { s.deinit(); self.search = null; - - // Notify apprt search has ended. - _ = try self.rt_app.performAction( - .{ .surface = self }, - .end_search, - {}, - ); break :search; } diff --git a/src/config/Config.zig b/src/config/Config.zig index e34666ecb..e6f7fb173 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6413,7 +6413,7 @@ pub const Keybinds = struct { try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .shift = true } }, - .{ .search = "" }, + .end_search, .{ .performable = true }, ); try self.set.putFlags( diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 636f343e3..1e7db3592 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -333,7 +333,11 @@ pub const Action = union(enum) { set_font_size: f32, /// Start a search for the given text. If the text is empty, then - /// the search is canceled. If a previous search is active, it is replaced. + /// the search is canceled. A canceled search will not disable any GUI + /// elements showing search. For that, the explicit end_search binding + /// should be used. + /// + /// If a previous search is active, it is replaced. search: []const u8, /// Navigate the search results. If there is no active search, this @@ -344,6 +348,9 @@ pub const Action = union(enum) { /// search terms, but opens the UI for searching. start_search, + /// End the current search if any and hide any GUI elements. + end_search, + /// Clear the screen and all scrollback. clear_screen, @@ -1172,6 +1179,7 @@ pub const Action = union(enum) { .search, .navigate_search, .start_search, + .end_search, .reset, .copy_to_clipboard, .copy_url_to_clipboard, diff --git a/src/input/command.zig b/src/input/command.zig index 9f1d4d3d5..7cbff405a 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -169,6 +169,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Start a search if one isn't already active.", }}, + .end_search => comptime &.{.{ + .action = .end_search, + .title = "End Search", + .description = "End the current search if any and hide any GUI elements.", + }}, + .navigate_search => comptime &.{ .{ .action = .{ .navigate_search = .next }, .title = "Next Search Result", From 5b4394d211b9a4d4ce0460ff55a1a6345e2fe939 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 08:54:48 -0800 Subject: [PATCH 36/36] macos: end_search for ending search --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 4 ++-- src/Surface.zig | 9 ++++++++- src/config/Config.zig | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 8aa108f3f..83e66ab81 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -93,7 +93,7 @@ extension Ghostty { } else if oldValue != nil { searchNeedleCancellable = nil guard let surface = self.surface else { return } - let action = "search:" + let action = "end_search" ghostty_surface_binding_action(surface, action, UInt(action.count)) } } @@ -1512,7 +1512,7 @@ extension Ghostty { @IBAction func findHide(_ sender: Any?) { guard let surface = self.surface else { return } - let action = "search:" + let action = "end_search" if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { AppDelegate.logger.warning("action failed action=\(action)") } diff --git a/src/Surface.zig b/src/Surface.zig index 698d1844b..d0866e901 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4944,16 +4944,23 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .end_search => { + // We only return that this was performed if we actually + // stopped a search, but we also send the apprt end_search so + // that GUIs can clean up stale stuff. + const performed = self.search != null; + if (self.search) |*s| { s.deinit(); self.search = null; } - return try self.rt_app.performAction( + _ = try self.rt_app.performAction( .{ .surface = self }, .end_search, {}, ); + + return performed; }, .search => |text| search: { diff --git a/src/config/Config.zig b/src/config/Config.zig index e6f7fb173..18412ff0e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6419,7 +6419,7 @@ pub const Keybinds = struct { try self.set.putFlags( alloc, .{ .key = .{ .physical = .escape } }, - .{ .search = "" }, + .end_search, .{ .performable = true }, ); try self.set.putFlags(