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); +}