From 9b6a3be99339bcefcc49b7791b7b9761d24e6093 Mon Sep 17 00:00:00 2001 From: Aaron Ruan Date: Tue, 6 Jan 2026 22:15:19 +0800 Subject: [PATCH] macOS: Selection for Find feature Adds the `selection_for_search` action, with Cmd+E keybind by default. This action inputs the currently selected text into the search field without changing focus, matching standard macOS behavior. --- include/ghostty.h | 9 ++++- macos/Sources/App/macOS/AppDelegate.swift | 2 ++ macos/Sources/App/macOS/MainMenu.xib | 11 ++++-- .../Terminal/BaseTerminalController.swift | 6 +++- macos/Sources/Ghostty/Ghostty.Action.swift | 12 +++++++ macos/Sources/Ghostty/Ghostty.App.swift | 35 +++++++++++++++++++ macos/Sources/Ghostty/Package.swift | 1 + .../Ghostty/Surface View/SurfaceView.swift | 11 +++++- .../Surface View/SurfaceView_AppKit.swift | 8 +++++ src/Surface.zig | 9 +++++ src/apprt/action.zig | 19 ++++++++++ src/config/Config.zig | 6 ++++ src/input/Binding.zig | 4 +++ src/input/command.zig | 6 ++++ 14 files changed, 134 insertions(+), 5 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 0ad15cf69..726b368e7 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -810,6 +810,11 @@ typedef struct { ssize_t selected; } ghostty_action_search_selected_s; +// apprt.action.SelectionForSearch +typedef struct { + const char* text; +} ghostty_action_selection_for_search_s; + // terminal.Scrollbar typedef struct { uint64_t total; @@ -878,11 +883,12 @@ typedef enum { GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, GHOSTTY_ACTION_COMMAND_FINISHED, GHOSTTY_ACTION_START_SEARCH, + GHOSTTY_ACTION_SELECTION_FOR_SEARCH, GHOSTTY_ACTION_END_SEARCH, GHOSTTY_ACTION_SEARCH_TOTAL, GHOSTTY_ACTION_SEARCH_SELECTED, GHOSTTY_ACTION_READONLY, - } ghostty_action_tag_e; +} ghostty_action_tag_e; typedef union { ghostty_action_split_direction_e new_split; @@ -919,6 +925,7 @@ 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_selection_for_search_s selection_for_search; ghostty_action_search_total_s search_total; ghostty_action_search_selected_s search_selected; ghostty_action_readonly_e readonly; diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 57bfba828..c365fb935 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -46,6 +46,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuSelectAll: NSMenuItem? @IBOutlet private var menuFindParent: NSMenuItem? @IBOutlet private var menuFind: NSMenuItem? + @IBOutlet private var menuSelectionForFind: NSMenuItem? @IBOutlet private var menuFindNext: NSMenuItem? @IBOutlet private var menuFindPrevious: NSMenuItem? @IBOutlet private var menuHideFindBar: NSMenuItem? @@ -615,6 +616,7 @@ class AppDelegate: NSObject, 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: "selection_for_search", menuItem: self.menuSelectionForFind) syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext) syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index a321061dd..248063f89 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -58,6 +58,7 @@ + @@ -262,6 +263,12 @@ + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index fb86ce8f7..a4e0da7ee 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1383,7 +1383,11 @@ class BaseTerminalController: NSWindowController, @IBAction func find(_ sender: Any) { focusedSurface?.find(sender) } - + + @IBAction func selectionForFind(_ sender: Any) { + focusedSurface?.selectionForFind(sender) + } + @IBAction func findNext(_ sender: Any) { focusedSurface?.findNext(sender) } diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 91f1491dd..c04c7d958 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -128,6 +128,18 @@ extension Ghostty.Action { } } + struct SelectionForSearch { + let text: String? + + init(c: ghostty_action_selection_for_search_s) { + if let contentCString = c.text { + self.text = String(cString: contentCString) + } else { + self.text = nil + } + } + } + enum PromptTitle { case surface case tab diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 4e9166168..69788c194 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -621,6 +621,9 @@ extension Ghostty { case GHOSTTY_ACTION_START_SEARCH: startSearch(app, target: target, v: action.action.start_search) + case GHOSTTY_ACTION_SELECTION_FOR_SEARCH: + selectionForSearch(app, target: target, v: action.action.selection_for_search) + case GHOSTTY_ACTION_END_SEARCH: endSearch(app, target: target) @@ -1881,6 +1884,38 @@ extension Ghostty { } } + private static func selectionForSearch( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_selection_for_search_s + ) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("selection_for_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 selectionForSearch = Ghostty.Action.SelectionForSearch(c: v) + DispatchQueue.main.async { + if surfaceView.searchState != nil, let text = selectionForSearch.text { + NotificationCenter.default.post( + name: .ghosttySelectionForSearch, + object: surfaceView, + userInfo: [ + "text": text + ] + ) + } + } + + default: + assertionFailure() + } + } + private static func endSearch( _ app: ghostty_app_t, target: ghostty_target_s) { diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index aa62c16f7..dbe9c173b 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -406,6 +406,7 @@ extension Notification.Name { /// Focus the search field static let ghosttySearchFocus = Notification.Name("com.mitchellh.ghostty.searchFocus") + static let ghosttySelectionForSearch = Notification.Name("com.mitchellh.ghostty.selectionForSearch") } // NOTE: I am moving all of these to Notification.Name extensions over time. This diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index c224d373e..b3717d4c5 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -475,7 +475,16 @@ extension Ghostty { } .onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in guard notification.object as? SurfaceView === surfaceView else { return } - isSearchFieldFocused = true + DispatchQueue.main.async { + isSearchFieldFocused = true + } + } + .onReceive(NotificationCenter.default.publisher(for: .ghosttySelectionForSearch)) { notification in + guard notification.object as? SurfaceView === surfaceView else { return } + if let userInfo = notification.userInfo, let text = userInfo["text"] as? String { + searchState.needle = text + // We do not focus the textfield after the action to match macOS behavior + } } .background( GeometryReader { barGeo in diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 7f33df45a..1fc43ac82 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -1519,6 +1519,14 @@ extension Ghostty { } } + @IBAction func selectionForFind(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "selection_for_search" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + @IBAction func findNext(_ sender: Any?) { guard let surface = self.surface else { return } let action = "search:next" diff --git a/src/Surface.zig b/src/Surface.zig index 43ee440c2..68cf46045 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5163,6 +5163,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ); }, + .selection_for_search => { + const selection = try self.selectionString(self.alloc) orelse return false; + return try self.rt_app.performAction( + .{ .surface = self }, + .selection_for_search, + .{ .text = selection }, + ); + }, + .end_search => { // We only return that this was performed if we actually // stopped a search, but we also send the apprt end_search so diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 25fc6f08a..7fdaabf08 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -316,6 +316,9 @@ pub const Action = union(Key) { /// Start the search overlay with an optional initial needle. start_search: StartSearch, + /// Input the selected text into the search field. + selection_for_search: SelectionForSearch, + /// End the search overlay, clearing the search state and hiding it. end_search, @@ -389,6 +392,7 @@ pub const Action = union(Key) { show_on_screen_keyboard, command_finished, start_search, + selection_for_search, end_search, search_total, search_selected, @@ -914,3 +918,18 @@ pub const SearchSelected = struct { }; } }; + +pub const SelectionForSearch = struct { + text: [:0]const u8, + + // Sync with: ghostty_action_selection_for_search_s + pub const C = extern struct { + text: [*:0]const u8, + }; + + pub fn cval(self: SelectionForSearch) C { + return .{ + .text = self.text.ptr, + }; + } +}; diff --git a/src/config/Config.zig b/src/config/Config.zig index 88f3d5375..698831ec1 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6585,6 +6585,12 @@ pub const Keybinds = struct { .start_search, .{ .performable = true }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'e' }, .mods = .{ .super = true } }, + .selection_for_search, + .{ .performable = true }, + ); try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .shift = true } }, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index d5b24c61b..0ef5208bc 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -368,6 +368,9 @@ pub const Action = union(enum) { /// If a previous search is active, it is replaced. search: []const u8, + /// Input the selected text into the search field. + selection_for_search, + /// Navigate the search results. If there is no active search, this /// is not performed. navigate_search: NavigateSearch, @@ -1284,6 +1287,7 @@ pub const Action = union(enum) { .cursor_key, .search, .navigate_search, + .selection_for_search, .start_search, .end_search, .reset, diff --git a/src/input/command.zig b/src/input/command.zig index f089112db..3fc7b29f6 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -189,6 +189,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Start a search if one isn't already active.", }}, + .selection_for_search => comptime &.{.{ + .action = .selection_for_search, + .title = "Selection for Search", + .description = "Input the selected text into the search field.", + }}, + .end_search => comptime &.{.{ .action = .end_search, .title = "End Search",