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.
This commit is contained in:
teamchong
2026-01-01 17:40:36 -05:00
committed by Mitchell Hashimoto
parent 323d362bc1
commit 5a042570c8
2 changed files with 163 additions and 9 deletions

View File

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

View File

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