gtk: add "search with selection" keybind action and context menu item

This adds the ability to launch an internet search with any selected
text on the active surface. If there is no selected text, the action
is a no-op. Search providers are configured using the `search-provider`
configuration entry and can be chosen from a set of predefined options
or a supplied custom URI.

This only implements the context menu for GTK but extending this to
macOS should be fairly easy as most of the logic is in the core. The
keybind action should work on macOS as-is although it is not assigned a
keybind by default.
This commit is contained in:
Jeffrey C. Ollie
2024-09-28 18:58:21 -05:00
parent 2e2e6d71c5
commit ac07e3a3fd
7 changed files with 226 additions and 19 deletions

View File

@ -252,6 +252,7 @@ const DerivedConfig = struct {
window_padding_balance: bool, window_padding_balance: bool,
title: ?[:0]const u8, title: ?[:0]const u8,
links: []Link, links: []Link,
search_provider: configpkg.SearchProvider,
const Link = struct { const Link = struct {
regex: oni.Regex, regex: oni.Regex,
@ -312,6 +313,7 @@ const DerivedConfig = struct {
.window_padding_balance = config.@"window-padding-balance", .window_padding_balance = config.@"window-padding-balance",
.title = config.title, .title = config.title,
.links = links, .links = links,
.search_provider = try config.@"search-provider".clone(alloc),
// Assignments happen sequentially so we have to do this last // Assignments happen sequentially so we have to do this last
// so that the memory is captured from allocs above. // so that the memory is captured from allocs above.
@ -3647,6 +3649,26 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.{ .paste = {} }, .{ .paste = {} },
), ),
.search_with_selection => {
// We can read from the renderer state without holding
// the lock because only we will write to this field.
if (self.io.terminal.screen.selection) |sel| {
const buf = self.io.terminal.screen.selectionString(self.alloc, .{
.sel = sel,
.trim = self.config.clipboard_trim_trailing_spaces,
}) catch |err| {
log.err("error reading selection string err={}", .{err});
return true;
};
defer self.alloc.free(buf);
self.searchWithSelection(buf) catch |err| {
log.err("error searching with selection err={}", .{err});
return true;
};
}
},
.increase_font_size => |delta| { .increase_font_size => |delta| {
// Max delta is somewhat arbitrary. // Max delta is somewhat arbitrary.
const clamped_delta = @max(0, @min(255, delta)); const clamped_delta = @max(0, @min(255, delta));
@ -4322,6 +4344,45 @@ fn presentSurface(self: *Surface) !void {
); );
} }
/// Launch an internet search with the given selection.
pub fn searchWithSelection(self: *Surface, selection: [:0]const u8) !void {
var arena = std.heap.ArenaAllocator.init(self.alloc);
defer arena.deinit();
const alloc = arena.allocator();
var uri = try std.Uri.parse(self.config.search_provider.uri());
uri.query = .{
.raw = query: {
if (uri.query) |q| {
const qp = try q.toRawMaybeAlloc(alloc);
const query = try alloc.alloc(
u8,
std.mem.replacementSize(u8, qp, "%s", selection),
);
const replacements = std.mem.replace(u8, qp, "%s", selection, query);
if (replacements > 0) break :query query;
}
break :query try std.fmt.allocPrint(alloc, "q={s}", .{selection});
},
};
var uri_buf = std.ArrayList(u8).init(alloc);
errdefer uri_buf.deinit();
try uri.writeToStream(.{
.scheme = true,
.authentication = true,
.authority = true,
.path = true,
.query = true,
.fragment = true,
.port = true,
}, uri_buf.writer());
try internal_os.open(self.alloc, try uri_buf.toOwnedSlice());
}
pub const face_ttf = @embedFile("font/res/JetBrainsMono-Regular.ttf"); pub const face_ttf = @embedFile("font/res/JetBrainsMono-Regular.ttf");
pub const face_bold_ttf = @embedFile("font/res/JetBrainsMono-Bold.ttf"); pub const face_bold_ttf = @embedFile("font/res/JetBrainsMono-Bold.ttf");
pub const face_emoji_ttf = @embedFile("font/res/NotoColorEmoji.ttf"); pub const face_emoji_ttf = @embedFile("font/res/NotoColorEmoji.ttf");

View File

@ -57,6 +57,10 @@ menu: ?*c.GMenu = null,
/// The shared context menu. /// The shared context menu.
context_menu: ?*c.GMenu = null, context_menu: ?*c.GMenu = null,
/// The search submenu of the context menu. Saved here so that we can easily
/// update it later in refreshContextMenu.
context_menu_search: ?*c.GMenu = null,
/// The configuration errors window, if it is currently open. /// The configuration errors window, if it is currently open.
config_errors_window: ?*ConfigErrorsWindow = null, config_errors_window: ?*ConfigErrorsWindow = null,
@ -740,6 +744,7 @@ fn syncActionAccelerators(self: *App) !void {
try self.syncActionAccelerator("win.split_right", .{ .new_split = .right }); try self.syncActionAccelerator("win.split_right", .{ .new_split = .right });
try self.syncActionAccelerator("win.split_down", .{ .new_split = .down }); try self.syncActionAccelerator("win.split_down", .{ .new_split = .down });
try self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = {} }); try self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = {} });
try self.syncActionAccelerator("win.search", .{ .search_with_selection = {} });
try self.syncActionAccelerator("win.paste", .{ .paste_from_clipboard = {} }); try self.syncActionAccelerator("win.paste", .{ .paste_from_clipboard = {} });
try self.syncActionAccelerator("win.reset", .{ .reset = {} }); try self.syncActionAccelerator("win.reset", .{ .reset = {} });
} }
@ -1271,12 +1276,6 @@ fn initMenu(self: *App) void {
c.g_menu_append(section, "About Ghostty", "win.about"); c.g_menu_append(section, "About Ghostty", "win.about");
} }
// {
// const section = c.g_menu_new();
// defer c.g_object_unref(section);
// c.g_menu_append_submenu(menu, "File", @ptrCast(@alignCast(section)));
// }
self.menu = menu; self.menu = menu;
} }
@ -1284,7 +1283,21 @@ fn initContextMenu(self: *App) void {
const menu = c.g_menu_new(); const menu = c.g_menu_new();
errdefer c.g_object_unref(menu); errdefer c.g_object_unref(menu);
createContextMenuCopyPasteSection(menu, false); {
const section = c.g_menu_new();
defer c.g_object_unref(section);
c.g_menu_prepend_section(menu, null, @ptrCast(@alignCast(section)));
c.g_menu_append(section, "Copy", "win.copy");
c.g_menu_append(section, "Paste", "win.paste");
}
{
const section = c.g_menu_new();
defer c.g_object_unref(section);
c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section)));
c.g_menu_append(section, "Search", "win.search");
self.context_menu_search = section;
}
{ {
const section = c.g_menu_new(); const section = c.g_menu_new();
@ -1305,18 +1318,33 @@ fn initContextMenu(self: *App) void {
self.context_menu = menu; self.context_menu = menu;
} }
fn createContextMenuCopyPasteSection(menu: ?*c.GMenu, has_selection: bool) void { pub fn refreshContextMenu(self: *App, window: ?*c.GtkWindow, selection: ?[:0]const u8) void {
const section = c.g_menu_new(); var buf: [128]u8 = undefined;
defer c.g_object_unref(section);
c.g_menu_prepend_section(menu, null, @ptrCast(@alignCast(section))); const snippet_size = 16;
// FIXME: Feels really hackish, but disabling sensitivity on this doesn't seems to work(?)
c.g_menu_append(section, "Copy", if (has_selection) "win.copy" else "noop"); const enabled: c.gboolean, const search_label: [:0]const u8 =
c.g_menu_append(section, "Paste", "win.paste"); if (selection) |s|
.{
1, if (s.len > snippet_size)
std.fmt.bufPrintZ(&buf, "Search for “{s}…” with {s}", .{ s[0..snippet_size], self.config.@"search-provider".name() }) catch "Search"
else
std.fmt.bufPrintZ(&buf, "Search for “{s}” with {s}", .{ s, self.config.@"search-provider".name() }) catch "Search",
}
else
.{ 0, "Search" };
{
const action: ?*c.GSimpleAction = @ptrCast(c.g_action_map_lookup_action(@ptrCast(window), "copy"));
c.g_simple_action_set_enabled(action, enabled);
} }
pub fn refreshContextMenu(self: *App, has_selection: bool) void { {
c.g_menu_remove(self.context_menu, 0); c.g_menu_remove_all(self.context_menu_search);
createContextMenuCopyPasteSection(self.context_menu, has_selection); c.g_menu_append(self.context_menu_search, search_label, "win.search");
const action: ?*c.GSimpleAction = @ptrCast(c.g_action_map_lookup_action(@ptrCast(window), "search"));
c.g_simple_action_set_enabled(action, enabled);
}
} }
fn isValidAppId(app_id: [:0]const u8) bool { fn isValidAppId(app_id: [:0]const u8) bool {

View File

@ -1112,7 +1112,9 @@ fn showContextMenu(self: *Surface, x: f32, y: f32) void {
}; };
c.gtk_popover_set_pointing_to(@ptrCast(@alignCast(window.context_menu)), &rect); c.gtk_popover_set_pointing_to(@ptrCast(@alignCast(window.context_menu)), &rect);
self.app.refreshContextMenu(self.core_surface.hasSelection()); const selection = self.core_surface.selectionString(self.app.core_app.alloc) catch null;
defer if (selection) |s| self.app.core_app.alloc.free(s);
self.app.refreshContextMenu(window.window, selection);
c.gtk_popover_popup(@ptrCast(@alignCast(window.context_menu))); c.gtk_popover_popup(@ptrCast(@alignCast(window.context_menu)));
} }

View File

@ -367,6 +367,7 @@ fn initActions(self: *Window) void {
.{ "toggle_inspector", &gtkActionToggleInspector }, .{ "toggle_inspector", &gtkActionToggleInspector },
.{ "copy", &gtkActionCopy }, .{ "copy", &gtkActionCopy },
.{ "paste", &gtkActionPaste }, .{ "paste", &gtkActionPaste },
.{ "search", &gtkActionSearch },
.{ "reset", &gtkActionReset }, .{ "reset", &gtkActionReset },
}; };
@ -810,6 +811,19 @@ fn gtkActionPaste(
}; };
} }
fn gtkActionSearch(
_: *c.GSimpleAction,
_: *c.GVariant,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *Window = @ptrCast(@alignCast(ud orelse return));
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(.{ .search_with_selection = {} }) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
}
fn gtkActionReset( fn gtkActionReset(
_: *c.GSimpleAction, _: *c.GSimpleAction,
_: *c.GVariant, _: *c.GVariant,

View File

@ -26,6 +26,7 @@ pub const RepeatableString = Config.RepeatableString;
pub const RepeatablePath = Config.RepeatablePath; pub const RepeatablePath = Config.RepeatablePath;
pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures; pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures;
pub const WindowPaddingColor = Config.WindowPaddingColor; pub const WindowPaddingColor = Config.WindowPaddingColor;
pub const SearchProvider = Config.SearchProvider;
// Alternate APIs // Alternate APIs
pub const CAPI = @import("config/CAPI.zig"); pub const CAPI = @import("config/CAPI.zig");

View File

@ -1599,6 +1599,31 @@ term: []const u8 = "xterm-ghostty",
/// Changing this value at runtime works after a small delay. /// Changing this value at runtime works after a small delay.
@"auto-update": AutoUpdate = .check, @"auto-update": AutoUpdate = .check,
/// A search provider to use when searching either from the right click context
/// menu or with a keybind. There must be text selected in the surface for the
/// search to be active. The search provider will be used to generate a URI
/// which will then be launched using OS native mechanisms (`open` on macOS or
/// `xdg-open` on Linux) so it should open up a new tab in your default browser.
///
/// * `bing` - `https://bing.com/search?q=%s`
/// * `brave` - `https://search.brave.com/search?q=%s`
/// * `duckduckgo` - `https://duckduckgo.com/?q=%s`
/// * `google` - `https://google.com/search?q=%s`
/// * `kagi` - `https://kagi.com/search?q=%s`
/// * `searx` - `https://searx.thegpm.org/?q=%s`
/// * `custom` - This allows you to specify a custom URI for searching if none
/// of the other options meet your needs. The format is:
///
/// `custom:<uri>`
///
/// A `%s` in the URI's query string will be replaced with the search string. If
/// there is no query string, or the query string does not include at least one
/// `%s` the entire query string will be set to `q=<selection>`, replacing any
/// previous query string.
///
/// The default is `duckduckgo`.
@"search-provider": SearchProvider = .{ .duckduckgo = {} },
/// This is set by the CLI parser for deinit. /// This is set by the CLI parser for deinit.
_arena: ?ArenaAllocator = null, _arena: ?ArenaAllocator = null,
@ -4749,3 +4774,72 @@ test "test entryFormatter" {
try p.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); try p.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
try std.testing.expectEqualStrings("a = 584y 49w 23h 34m 33s 709ms 551µs 615ns\n", buf.items); try std.testing.expectEqualStrings("a = 584y 49w 23h 34m 33s 709ms 551µs 615ns\n", buf.items);
} }
pub const SearchProvider = union(enum) {
const Self = @This();
bing: void,
brave: void,
duckduckgo: void,
google: void,
kagi: void,
searx: void,
custom: []const u8,
pub fn formatEntry(self: Self, formatter: anytype) !void {
switch (self) {
.bing,
.brave,
.duckduckgo,
.google,
.kagi,
.searx,
=> try formatter.formatEntry([]const u8, @tagName(self)),
.custom => |u| {
var buf: [256]u8 = undefined;
try formatter.formatEntry([]const u8, try std.fmt.bufPrint(
&buf,
"custom:{s}",
.{u},
));
},
}
}
pub fn name(self: Self) []const u8 {
return switch (self) {
.bing => "Bing",
.brave => "Brave",
.duckduckgo => "Duck Duck Go",
.google => "Google",
.kagi => "Kagi",
.searx => "SearX",
.custom => "custom URI",
};
}
pub fn uri(self: Self) []const u8 {
return switch (self) {
.bing => "https://bing.com/search?q=%s",
.brave => "https://search.brave.com/search?q=%s",
.duckduckgo => "https://duckduckgo.com/?q=%s",
.google => "https://google.com/search?q=%s",
.kagi => "https://kagi.com/search?q=%s",
.searx => "https://searx.thegpm.org/?q=%s",
.custom => |u| u,
};
}
pub fn clone(self: Self, alloc: std.mem.Allocator) !Self {
switch (self) {
.bing,
.brave,
.duckduckgo,
.google,
.kagi,
.searx,
=> return self,
.custom => |u| return .{ .custom = try alloc.dupe(u8, u) },
}
}
};

View File

@ -232,6 +232,12 @@ pub const Action = union(enum) {
paste_from_clipboard: void, paste_from_clipboard: void,
paste_from_selection: void, paste_from_selection: void,
/// Launch an internet search using the native OS mechanisms with the
/// selected text from the focused surface. This is a no-op if there is no
/// selected text in the focused surface. See the configuration variable
/// `search-provider` for information on how to select a search provider.
search_with_selection: void,
/// Increase/decrease the font size by a certain amount. /// Increase/decrease the font size by a certain amount.
increase_font_size: f32, increase_font_size: f32,
decrease_font_size: f32, decrease_font_size: f32,
@ -600,6 +606,7 @@ pub const Action = union(enum) {
.toggle_window_decorations, .toggle_window_decorations,
.toggle_secure_input, .toggle_secure_input,
.crash, .crash,
.search_with_selection,
=> .surface, => .surface,
// These are less obvious surface actions. They're surface // These are less obvious surface actions. They're surface