mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-17 13:02:42 +00:00
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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user