diff --git a/src/Surface.zig b/src/Surface.zig index e8bbb885f..255412507 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -252,6 +252,7 @@ const DerivedConfig = struct { window_padding_balance: bool, title: ?[:0]const u8, links: []Link, + search_provider: configpkg.SearchProvider, const Link = struct { regex: oni.Regex, @@ -312,6 +313,7 @@ const DerivedConfig = struct { .window_padding_balance = config.@"window-padding-balance", .title = config.title, .links = links, + .search_provider = try config.@"search-provider".clone(alloc), // Assignments happen sequentially so we have to do this last // so that the memory is captured from allocs above. @@ -3647,6 +3649,26 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .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| { // Max delta is somewhat arbitrary. 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_bold_ttf = @embedFile("font/res/JetBrainsMono-Bold.ttf"); pub const face_emoji_ttf = @embedFile("font/res/NotoColorEmoji.ttf"); diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 94fae8015..f24771cb0 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -57,6 +57,10 @@ menu: ?*c.GMenu = null, /// The shared context menu. 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. 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_down", .{ .new_split = .down }); 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.reset", .{ .reset = {} }); } @@ -1271,12 +1276,6 @@ fn initMenu(self: *App) void { 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; } @@ -1284,7 +1283,21 @@ fn initContextMenu(self: *App) void { const menu = c.g_menu_new(); 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(); @@ -1305,18 +1318,33 @@ fn initContextMenu(self: *App) void { self.context_menu = menu; } -fn createContextMenuCopyPasteSection(menu: ?*c.GMenu, has_selection: bool) void { - const section = c.g_menu_new(); - defer c.g_object_unref(section); - c.g_menu_prepend_section(menu, null, @ptrCast(@alignCast(section))); - // 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"); - c.g_menu_append(section, "Paste", "win.paste"); -} +pub fn refreshContextMenu(self: *App, window: ?*c.GtkWindow, selection: ?[:0]const u8) void { + var buf: [128]u8 = undefined; -pub fn refreshContextMenu(self: *App, has_selection: bool) void { - c.g_menu_remove(self.context_menu, 0); - createContextMenuCopyPasteSection(self.context_menu, has_selection); + const snippet_size = 16; + + const enabled: c.gboolean, const search_label: [:0]const u8 = + 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); + } + + { + c.g_menu_remove_all(self.context_menu_search); + 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 { diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 73837d11d..43e80b22f 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -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); - 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))); } diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index ff8735ff9..cfa4f5390 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -367,6 +367,7 @@ fn initActions(self: *Window) void { .{ "toggle_inspector", >kActionToggleInspector }, .{ "copy", >kActionCopy }, .{ "paste", >kActionPaste }, + .{ "search", >kActionSearch }, .{ "reset", >kActionReset }, }; @@ -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( _: *c.GSimpleAction, _: *c.GVariant, diff --git a/src/config.zig b/src/config.zig index b9f214fc9..83b8fce4b 100644 --- a/src/config.zig +++ b/src/config.zig @@ -26,6 +26,7 @@ pub const RepeatableString = Config.RepeatableString; pub const RepeatablePath = Config.RepeatablePath; pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures; pub const WindowPaddingColor = Config.WindowPaddingColor; +pub const SearchProvider = Config.SearchProvider; // Alternate APIs pub const CAPI = @import("config/CAPI.zig"); diff --git a/src/config/Config.zig b/src/config/Config.zig index efa741307..42772d8be 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1599,6 +1599,31 @@ term: []const u8 = "xterm-ghostty", /// Changing this value at runtime works after a small delay. @"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:` +/// +/// 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=`, replacing any +/// previous query string. +/// +/// The default is `duckduckgo`. +@"search-provider": SearchProvider = .{ .duckduckgo = {} }, + /// This is set by the CLI parser for deinit. _arena: ?ArenaAllocator = null, @@ -4749,3 +4774,72 @@ test "test entryFormatter" { 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); } + +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) }, + } + } +}; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index f9921a87e..a68dda37c 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -232,6 +232,12 @@ pub const Action = union(enum) { paste_from_clipboard: 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_font_size: f32, decrease_font_size: f32, @@ -600,6 +606,7 @@ pub const Action = union(enum) { .toggle_window_decorations, .toggle_secure_input, .crash, + .search_with_selection, => .surface, // These are less obvious surface actions. They're surface