feat: select entire URL on double-click (#10132)

## Why

When double-clicking on a URL like `https://example.com`, only `https`
or `//example.com` gets selected because `:` and `/` are word
boundaries. Users expect the entire URL to be selected.

## How

Added URL detection to the double-click handler in `Surface.zig`:

1. Before falling back to `selectWord`, try to detect if the clicked
position is part of a URL
2. Uses the pre-compiled link regexes from user configuration (same
patterns used for cmd+click)
3. If a URL is found at the position, select the entire URL
4. Otherwise, fall back to normal word selection

The implementation:
- Respects user's link configuration (disabled URLs won't trigger
selection)
- Reuses pre-compiled regexes from `DerivedConfig.links` (no per-click
compilation)
- Follows the same patterns as `linkAtPos`

## What

- `src/Surface.zig`: Added `urlAtPin()` helper function and modified
double-click handler
- `src/terminal/StringMap.zig`: Added 2 tests for URL detection
following existing test patterns
This commit is contained in:
Mitchell Hashimoto
2026-01-07 10:18:12 -08:00
committed by GitHub
2 changed files with 188 additions and 27 deletions

View File

@@ -327,7 +327,7 @@ const DerivedConfig = struct {
window_width: u32,
title: ?[:0]const u8,
title_report: bool,
links: []Link,
links: []DerivedConfig.Link,
link_previews: configpkg.LinkPreviews,
scroll_to_bottom: configpkg.Config.ScrollToBottom,
notify_on_command_finish: configpkg.Config.NotifyOnCommandFinish,
@@ -347,7 +347,7 @@ const DerivedConfig = struct {
// Build all of our links
const links = links: {
var links: std.ArrayList(Link) = .empty;
var links: std.ArrayList(DerivedConfig.Link) = .empty;
defer links.deinit(alloc);
for (config.link.links.items) |link| {
var regex = try link.oniRegex();
@@ -1599,10 +1599,10 @@ fn mouseRefreshLinks(
}
const link = (try self.linkAtPos(pos)) orelse break :link .{ null, false };
switch (link[0]) {
switch (link.action) {
.open => {
const str = try self.io.terminal.screens.active.selectionString(alloc, .{
.sel = link[1],
.sel = link.selection,
.trim = false,
});
break :link .{
@@ -1613,7 +1613,7 @@ fn mouseRefreshLinks(
._open_osc8 => {
// Show the URL in the status bar
const pin = link[1].start();
const pin = link.selection.start();
const uri = self.osc8URI(pin) orelse {
log.warn("failed to get URI for OSC8 hyperlink", .{});
break :link .{ null, false };
@@ -4141,9 +4141,24 @@ 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
if (self.linkAtPin(
pin.*,
null,
)) |result_| {
if (result_) |result| {
break :sel result.selection;
}
} else |_| {
// Ignore any errors, likely regex errors.
}
break :sel self.io.terminal.screens.active.selectWord(pin.*);
};
if (sel_) |sel| {
try self.io.terminal.screens.active.select(sel);
try self.queueRender();
@@ -4331,16 +4346,18 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void {
}
}
const Link = struct {
action: input.Link.Action,
selection: terminal.Selection,
};
/// Returns the link at the given cursor position, if any.
///
/// Requires the renderer mutex is held.
fn linkAtPos(
self: *Surface,
pos: apprt.CursorPos,
) !?struct {
input.Link.Action,
terminal.Selection,
} {
) !?Link {
// Convert our cursor position to a screen point.
const screen: *terminal.Screen = self.renderer_state.terminal.screens.active;
const mouse_pin: terminal.Pin = mouse_pin: {
@@ -4361,14 +4378,27 @@ fn linkAtPos(
const cell = rac.cell;
if (!cell.hyperlink) break :hyperlink;
const sel = terminal.Selection.init(mouse_pin, mouse_pin, false);
return .{ ._open_osc8, sel };
return .{ .action = ._open_osc8, .selection = 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 configured links
return try self.linkAtPin(mouse_pin, mouse_mods);
}
/// Detects if a link is present at the given pin.
///
/// If mouse mods is null then mouse mod requirements are ignored (all
/// configured links are checked).
///
/// Requires the renderer state mutex is held.
fn linkAtPin(
self: *Surface,
mouse_pin: terminal.Pin,
mouse_mods: ?input.Mods,
) !?Link {
if (self.config.links.len == 0) return null;
// Get the line we're hovering over.
const screen: *terminal.Screen = self.renderer_state.terminal.screens.active;
const line = screen.selectLine(.{
.pin = mouse_pin,
.whitespace = null,
@@ -4383,12 +4413,12 @@ 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) {
// 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(mouse_mods)) continue,
}
.always_mods, .hover_mods => |v| if (!v.equal(mods)) continue,
};
var it = strmap.searchIterator(link.regex);
while (true) {
@@ -4396,7 +4426,10 @@ fn linkAtPos(
defer match.deinit();
const sel = match.selection();
if (!sel.contains(screen, mouse_pin)) continue;
return .{ link.action, sel };
return .{
.action = link.action,
.selection = sel,
};
}
}
@@ -4427,11 +4460,11 @@ fn mouseModsWithCapture(self: *Surface, mods: input.Mods) input.Mods {
///
/// Requires the renderer state mutex is held.
fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
const action, const sel = try self.linkAtPos(pos) orelse return false;
switch (action) {
const link = try self.linkAtPos(pos) orelse return false;
switch (link.action) {
.open => {
const str = try self.io.terminal.screens.active.selectionString(self.alloc, .{
.sel = sel,
.sel = link.selection,
.trim = false,
});
defer self.alloc.free(str);
@@ -4444,7 +4477,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
},
._open_osc8 => {
const uri = self.osc8URI(sel.start()) orelse {
const uri = self.osc8URI(link.selection.start()) orelse {
log.warn("failed to get URI for OSC8 hyperlink", .{});
return false;
};
@@ -5287,11 +5320,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
if (try self.linkAtPos(pos)) |link_info| {
const url_text = switch (link_info[0]) {
const url_text = switch (link_info.action) {
.open => url_text: {
// For regex links, get the text from selection
break :url_text (self.io.terminal.screens.active.selectionString(self.alloc, .{
.sel = link_info[1],
.sel = link_info.selection,
.trim = self.config.clipboard_trim_trailing_spaces,
})) catch |err| {
log.err("error reading url string err={}", .{err});
@@ -5301,7 +5334,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
._open_osc8 => url_text: {
// For OSC8 links, get the URI directly from hyperlink data
const uri = self.osc8URI(link_info[1].start()) orelse {
const uri = self.osc8URI(link_info.selection.start()) orelse {
log.warn("failed to get URI for OSC8 hyperlink", .{});
return false;
};

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