From 5a042570c84c759d1ab653d2376ddc53297fdf0f Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:40:36 -0500 Subject: [PATCH] feat: select entire URL on double-click When double-clicking text, first check if the position is part of a URL using the default URL regex pattern. If a URL is detected, select the entire URL instead of just the word. This follows the feedback from PR #2324 to modify the selection behavior rather than introducing a separate selectLink function. The implementation uses the existing URL regex from config/url.zig which already handles various URL schemes (http, https, ftp, ssh, etc.) and file paths. The URL detection runs before the normal word selection, falling back to selectWord if no URL is found at the clicked position. --- src/Surface.zig | 44 ++++++++++--- src/terminal/StringMap.zig | 128 +++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 9 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 1f3e4da8b..11fa42e35 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4141,9 +4141,18 @@ pub fn mouseButtonCallback( } }, - // Double click, select the word under our mouse + // Double click, select the word under our mouse. + // First try to detect if we're clicking on a URL to select the entire URL. 2 => { - const sel_ = self.io.terminal.screens.active.selectWord(pin.*); + const sel_ = sel: { + // Try link detection without requiring modifier keys + const link_result = self.linkAtPin(pin.*, null) catch null; + if (link_result) |result| { + // Only select URLs (links with .open action) + if (result[0] == .open) break :sel result[1]; + } + break :sel self.io.terminal.screens.active.selectWord(pin.*); + }; if (sel_) |sel| { try self.io.terminal.screens.active.select(sel); try self.queueRender(); @@ -4364,11 +4373,26 @@ fn linkAtPos( return .{ ._open_osc8, sel }; } - // If we have no OSC8 links then we fallback to regex-based URL detection. - // If we have no configured links we can save a lot of work going forward. + // Fall back to regex-based link detection + return self.linkAtPin(mouse_pin, mouse_mods); +} + +/// Core link detection at a pin position using regex patterns. +/// When mouse_mods is null, skips highlight/modifier checks (for double-click). +/// +/// Requires the renderer state mutex is held. +fn linkAtPin( + self: *Surface, + mouse_pin: terminal.Pin, + mouse_mods: ?input.Mods, +) !?struct { + input.Link.Action, + terminal.Selection, +} { + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; + if (self.config.links.len == 0) return null; - // Get the line we're hovering over. const line = screen.selectLine(.{ .pin = mouse_pin, .whitespace = null, @@ -4383,11 +4407,13 @@ fn linkAtPos( })); defer strmap.deinit(self.alloc); - // Go through each link and see if we clicked it for (self.config.links) |link| { - switch (link.highlight) { - .always, .hover => {}, - .always_mods, .hover_mods => |v| if (!v.equal(mouse_mods)) continue, + // Skip highlight/mods check when mouse_mods is null (double-click mode) + if (mouse_mods) |mods| { + switch (link.highlight) { + .always, .hover => {}, + .always_mods, .hover_mods => |v| if (!v.equal(mods)) continue, + } } var it = strmap.searchIterator(link.regex); diff --git a/src/terminal/StringMap.zig b/src/terminal/StringMap.zig index 4ac47eeab..f7d88d1c8 100644 --- a/src/terminal/StringMap.zig +++ b/src/terminal/StringMap.zig @@ -147,3 +147,131 @@ test "StringMap searchIterator" { try testing.expect(try it.next() == null); } + +test "StringMap searchIterator URL detection" { + if (comptime !build_options.oniguruma) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + const url = @import("../config/url.zig"); + + // Initialize URL regex + try oni.testing.ensureInit(); + var re = try oni.Regex.init( + url.regex, + .{}, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + // Initialize our screen with text containing a URL + var s = try Screen.init(alloc, .{ .cols = 40, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello https://example.com/path world"); + + // Get the line + const line = s.selectLine(.{ + .pin = s.pages.pin(.{ .active = .{ + .x = 10, + .y = 0, + } }).?, + }).?; + var map: StringMap = undefined; + const sel_str = try s.selectionString(alloc, .{ + .sel = line, + .trim = false, + .map = &map, + }); + alloc.free(sel_str); + defer map.deinit(alloc); + + // Search for URL match + var it = map.searchIterator(re); + { + var match = (try it.next()).?; + defer match.deinit(); + + const sel = match.selection(); + // URL should start at x=6 ("https://example.com/path" starts after "hello ") + try testing.expectEqual(point.Point{ .screen = .{ + .x = 6, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + // URL should end at x=29 (end of "/path") + try testing.expectEqual(point.Point{ .screen = .{ + .x = 29, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + try testing.expect(try it.next() == null); +} + +test "StringMap searchIterator URL with click position" { + if (comptime !build_options.oniguruma) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + const url = @import("../config/url.zig"); + + // Initialize URL regex + try oni.testing.ensureInit(); + var re = try oni.Regex.init( + url.regex, + .{}, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + // Initialize our screen with text containing a URL + var s = try Screen.init(alloc, .{ .cols = 40, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello https://example.com world"); + + // Simulate clicking on "example" (x=14) + const click_pin = s.pages.pin(.{ .active = .{ + .x = 14, + .y = 0, + } }).?; + + // Get the line + const line = s.selectLine(.{ + .pin = click_pin, + }).?; + var map: StringMap = undefined; + const sel_str = try s.selectionString(alloc, .{ + .sel = line, + .trim = false, + .map = &map, + }); + alloc.free(sel_str); + defer map.deinit(alloc); + + // Search for URL match and verify click position is within URL + var it = map.searchIterator(re); + var found_url = false; + while (true) { + var match = (try it.next()) orelse break; + defer match.deinit(); + + const sel = match.selection(); + if (sel.contains(&s, click_pin)) { + found_url = true; + // Verify URL bounds + try testing.expectEqual(point.Point{ .screen = .{ + .x = 6, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 24, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + break; + } + } + try testing.expect(found_url); +}