mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-14 03:25:50 +00:00
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:
committed by
Mitchell Hashimoto
parent
323d362bc1
commit
5a042570c8
@@ -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);
|
||||
|
||||
@@ -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