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.
This commit is contained in:
Aaron Ruan
2026-01-06 22:15:19 +08:00
parent c5bc6bb2ce
commit 9b6a3be993
14 changed files with 134 additions and 5 deletions

View File

@@ -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;

View File

@@ -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)

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24412" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24506" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24412"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24506"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
@@ -58,6 +58,7 @@
<outlet property="menuSelectSplitBelow" destination="QDz-d9-CBr" id="FsH-Dq-jij"/>
<outlet property="menuSelectSplitLeft" destination="cTK-oy-KuV" id="Jpr-5q-dqz"/>
<outlet property="menuSelectSplitRight" destination="upj-mc-L7X" id="nLY-o1-lky"/>
<outlet property="menuSelectionForSearch" destination="TDN-42-Bu7" id="M04-1K-vze"/>
<outlet property="menuServices" destination="aQe-vS-j8Q" id="uWQ-Wo-T1L"/>
<outlet property="menuSplitDown" destination="UDZ-4y-6xL" id="ptr-mj-Azh"/>
<outlet property="menuSplitLeft" destination="Ppv-GP-lQU" id="Xd5-Cd-Jut"/>
@@ -262,6 +263,12 @@
<action selector="find:" target="-1" id="PeY-3u-IxC"/>
</connections>
</menuItem>
<menuItem title="Selection for Find" id="TDN-42-Bu7">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="selectionForFind:" target="-1" id="rhL-7g-XQQ"/>
</connections>
</menuItem>
<menuItem title="Find Next" id="XqU-X8-q32">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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,
};
}
};

View File

@@ -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 } },

View File

@@ -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,

View File

@@ -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",