Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OSC8 Hyperlink Support #1928

Merged
merged 58 commits into from
Jul 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
58173c9
terminal: parse osc 8 hyperlink_start
mitchellh Jun 9, 2024
f8e74a5
terminal: parse osc8 end
mitchellh Jun 9, 2024
6c7b784
terminal: additional parse test cases
mitchellh Jun 9, 2024
25d1e86
terminal: page memory layout for uri/hyperlink data
mitchellh Jun 9, 2024
75e1655
terminal: change default hyperlink count to zero
mitchellh Jun 9, 2024
69705cb
terminal: remove the hyperlink stuff i'm starting over
mitchellh Jul 3, 2024
a71b487
terminal: add strings table to page
mitchellh Jul 3, 2024
2a7755c
terminal: hyperlink data structures beginning, alloc into page
mitchellh Jul 3, 2024
cb1caff
terminal: refcountedset passes base memory to all context funcs
mitchellh Jul 3, 2024
2e41afc
terminal: RefCountedSet has Context variant methods
mitchellh Jul 4, 2024
51c05ae
terminal: RefCountedSet doesn't need to pass base anymore
mitchellh Jul 4, 2024
d1f41e2
terminal: hyperlink start/end on screen
mitchellh Jul 4, 2024
548850e
terminal: RefCountedSet should call deleted on upsert
mitchellh Jul 4, 2024
c880bb6
terminal: test hyperlink reuse shares ID
mitchellh Jul 4, 2024
6fc9e92
terminal: hyperlink deleted callback frees string memory
mitchellh Jul 4, 2024
a3a445a
terminal: print sets hyperlink state, tests
mitchellh Jul 4, 2024
e2133cb
terminal: row needs hyperlink state, test clearing hyperlink
mitchellh Jul 4, 2024
57c5522
terminal: handle moving/swapping/clearing cells with hyperlinks
mitchellh Jul 4, 2024
96ff17a
terminal: save/restore cursor doesn't modify hyperlink state
mitchellh Jul 4, 2024
bac1307
terminal: index hyperlink tests
mitchellh Jul 4, 2024
84edaed
terminal: scrollDown with hyperlinks
mitchellh Jul 4, 2024
d9e654d
terminal: scrollUp hyperlink tests
mitchellh Jul 4, 2024
f920068
terminal: full reset clears OSC8 state
mitchellh Jul 4, 2024
245314b
termio: hook up OSC8
mitchellh Jul 4, 2024
365567b
terminal: increase std cap for now until we implement resize
mitchellh Jul 4, 2024
d7e089e
terminal: simplify hyperlink capacity
mitchellh Jul 4, 2024
961a4b6
terminal: support page oom with hyperlinks
mitchellh Jul 4, 2024
f8fe044
core: clicking OSC8 links work
mitchellh Jul 4, 2024
f777e42
terminal: page clone needs to clone strings
mitchellh Jul 4, 2024
041c779
renderer: matchSet matches OSC8
mitchellh Jul 5, 2024
925ad5b
renderer: match multiple lines for osc8
mitchellh Jul 5, 2024
8b02d34
terminal: copy hyperlinks on reflow
mitchellh Jul 5, 2024
ff9ab70
terminal: end hyperlink state when switching screens
mitchellh Jul 5, 2024
e8a8b18
core: when over a link we must set the whole screen dirty on move
mitchellh Jul 5, 2024
b0f9930
terminal: pause integrity checks in clone row until done
mitchellh Jul 5, 2024
c51682a
renderer: match no-ID OSC8 in contiguous chunks
mitchellh Jul 5, 2024
eed9c23
terminal: RefCountedSet checks for existence prior to cap check
mitchellh Jul 5, 2024
cdb838e
terminal: pause integrity checks on resize for hyperlink set
mitchellh Jul 5, 2024
4f099af
terminal: set hyperlink state on clone
mitchellh Jul 5, 2024
a6051b9
terminal: disable zombie styles integrity check
mitchellh Jul 5, 2024
251ec0c
terminal: on print, adjust page size if we need to grow for hyperlinks
mitchellh Jul 6, 2024
d79bbaa
terminal: adjustCapacity handles hyperlink state
mitchellh Jul 6, 2024
4a861a8
terminal: hyperlink capacity adjustment needs to call safe variant
mitchellh Jul 6, 2024
d5a23e7
macos: some disabled swiftui code that makes link tooltips show
mitchellh Jul 6, 2024
cb790b8
macos: show URL on OSC8 hover
mitchellh Jul 6, 2024
8ecc84b
core: helper to get osc8 URI
mitchellh Jul 6, 2024
36648ae
apprt: stubs for mouseOverLink
mitchellh Jul 6, 2024
9344676
macos: fix iOS build
mitchellh Jul 6, 2024
8858c2b
apprt/gtk: convert surface to overlay so we can support the url overlay
mitchellh Jul 6, 2024
ecdb0a7
apprt/gtk: style the overlay
mitchellh Jul 6, 2024
571182f
macos: move OSC8 URL view to right if mouse is over it
mitchellh Jul 7, 2024
c9accc5
core: show URL even for non-OSC8 hyperlnks
mitchellh Jul 7, 2024
f9e5d9c
apprt/gtk: move url hover bar when its under the mouse
mitchellh Jul 7, 2024
10a3214
apprt/gtk: forgot to remove debug code to hide overlay
mitchellh Jul 7, 2024
45d0653
apprt/gtk: add deinit for url widget
mitchellh Jul 7, 2024
b7699b9
apprt/gtk: add all event handlers to the overlay so both receive
mitchellh Jul 7, 2024
f1561a4
apprt/gtk: committed the forever status bar again
mitchellh Jul 7, 2024
a32007b
core: when mouse reporting, clear link state
mitchellh Jul 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ typedef void (*ghostty_runtime_show_desktop_notification_cb)(void*,
const char*);
typedef void (
*ghostty_runtime_update_renderer_health)(void*, ghostty_renderer_health_e);
typedef void (*ghostty_runtime_mouse_over_link_cb)(void*, const char*, size_t);

typedef struct {
void* userdata;
Expand Down Expand Up @@ -481,6 +482,7 @@ typedef struct {
ghostty_runtime_set_cell_size_cb set_cell_size_cb;
ghostty_runtime_show_desktop_notification_cb show_desktop_notification_cb;
ghostty_runtime_update_renderer_health update_renderer_health_cb;
ghostty_runtime_mouse_over_link_cb mouse_over_link_cb;
} ghostty_runtime_config_s;

//-------------------------------------------------------------------
Expand Down
15 changes: 14 additions & 1 deletion macos/Sources/Ghostty/Ghostty.App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ extension Ghostty {
set_cell_size_cb: { userdata, width, height in App.setCellSize(userdata, width: width, height: height) },
show_desktop_notification_cb: { userdata, title, body in
App.showUserNotification(userdata, title: title, body: body) },
update_renderer_health_cb: { userdata, health in App.updateRendererHealth(userdata, health: health) }
update_renderer_health_cb: { userdata, health in App.updateRendererHealth(userdata, health: health) },
mouse_over_link_cb: { userdata, ptr, len in App.mouseOverLink(userdata, uri: ptr, len: len) }
)

// Create the ghostty app.
Expand Down Expand Up @@ -290,6 +291,7 @@ extension Ghostty {
static func setCellSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {}
static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?, body: UnsafePointer<CChar>?) {}
static func updateRendererHealth(_ userdata: UnsafeMutableRawPointer?, health: ghostty_renderer_health_e) {}
static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer<CChar>?, len: Int) {}
#endif

#if os(macOS)
Expand Down Expand Up @@ -523,6 +525,17 @@ extension Ghostty {
let backingSize = NSSize(width: Double(width), height: Double(height))
surfaceView.cellSize = surfaceView.convertFromBacking(backingSize)
}

static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer<CChar>?, len: Int) {
let surfaceView = self.surfaceUserdata(from: userdata)
guard len > 0 else {
surfaceView.hoverUrl = nil
return
}

let buffer = Data(bytes: uri!, count: len)
surfaceView.hoverUrl = String(data: buffer, encoding: .utf8)
}

static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?, body: UnsafePointer<CChar>?) {
let surfaceView = self.surfaceUserdata(from: userdata)
Expand Down
40 changes: 38 additions & 2 deletions macos/Sources/Ghostty/SurfaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,12 @@ extension Ghostty {

// Maintain whether our window has focus (is key) or not
@State private var windowFocus: Bool = true


// True if we're hovering over the left URL view, so we can show it on the right.
@State private var isHoveringURLLeft: Bool = false

@EnvironmentObject private var ghostty: Ghostty.App

var body: some View {
let center = NotificationCenter.default

Expand Down Expand Up @@ -145,6 +148,39 @@ extension Ghostty {
}
.ghosttySurfaceView(surfaceView)

// If we have a URL from hovering a link, we show that.
if let url = surfaceView.hoverUrl {
let padding: CGFloat = 3
ZStack {
HStack {
VStack(alignment: .leading) {
Spacer()

Text(verbatim: url)
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.background(.background)
.opacity(isHoveringURLLeft ? 0 : 1)
.onHover(perform: { hovering in
isHoveringURLLeft = hovering
})
}
Spacer()
}

HStack {
Spacer()
VStack(alignment: .leading) {
Spacer()

Text(verbatim: url)
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.background(.background)
.opacity(isHoveringURLLeft ? 1 : 0)
}
}
}
}

// If our surface is not healthy, then we render an error view over it.
if (!surfaceView.healthy) {
Rectangle().fill(ghostty.config.backgroundColor)
Expand Down
3 changes: 3 additions & 0 deletions macos/Sources/Ghostty/SurfaceView_AppKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ extension Ghostty {

// Any error while initializing the surface.
@Published var error: Error? = nil

// The hovered URL string
@Published var hoverUrl: String? = nil

// An initial size to request for a window. This will only affect
// then the view is moved to a new window.
Expand Down
3 changes: 3 additions & 0 deletions macos/Sources/Ghostty/SurfaceView_UIKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ extension Ghostty {
// Any error while initializing the surface.
@Published var error: Error? = nil

// The hovered URL
@Published var hoverUrl: String? = nil

private(set) var surface: ghostty_surface_t?

init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
Expand Down
96 changes: 71 additions & 25 deletions src/Surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2519,16 +2519,15 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void {
}

/// Returns the link at the given cursor position, if any.
///
/// Requires the renderer mutex is held.
fn linkAtPos(
self: *Surface,
pos: apprt.CursorPos,
) !?struct {
DerivedConfig.Link,
input.Link.Action,
terminal.Selection,
} {
// If we have no configured links we can save a lot of work
if (self.config.links.len == 0) return null;

// Convert our cursor position to a screen point.
const screen = &self.renderer_state.terminal.screen;
const mouse_pin: terminal.Pin = mouse_pin: {
Expand All @@ -2543,6 +2542,19 @@ fn linkAtPos(
// Get our comparison mods
const mouse_mods = self.mouseModsWithCapture(self.mouse.mods);

// If we have the proper modifiers set then we can check for OSC8 links.
if (mouse_mods.equal(input.ctrlOrSuper(.{}))) hyperlink: {
const rac = mouse_pin.rowAndCell();
const cell = rac.cell;
if (!cell.hyperlink) break :hyperlink;
const sel = terminal.Selection.init(mouse_pin, mouse_pin, false);
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.
if (self.config.links.len == 0) return null;

// Get the line we're hovering over.
const line = screen.selectLine(.{
.pin = mouse_pin,
Expand Down Expand Up @@ -2571,7 +2583,7 @@ fn linkAtPos(
defer match.deinit();
const sel = match.selection();
if (!sel.contains(screen, mouse_pin)) continue;
return .{ link, sel };
return .{ link.action, sel };
}
}

Expand Down Expand Up @@ -2602,8 +2614,8 @@ 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 link, const sel = try self.linkAtPos(pos) orelse return false;
switch (link.action) {
const action, const sel = try self.linkAtPos(pos) orelse return false;
switch (action) {
.open => {
const str = try self.io.terminal.screen.selectionString(self.alloc, .{
.sel = sel,
Expand All @@ -2612,11 +2624,30 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
defer self.alloc.free(str);
try internal_os.open(self.alloc, str);
},

._open_osc8 => {
const uri = self.osc8URI(sel.start()) orelse {
log.warn("failed to get URI for OSC8 hyperlink", .{});
return false;
};
try internal_os.open(self.alloc, uri);
},
}

return true;
}

/// Return the URI for an OSC8 hyperlink at the given position or null
/// if there is no hyperlink.
fn osc8URI(self: *Surface, pin: terminal.Pin) ?[]const u8 {
_ = self;
const page = &pin.page.data;
const cell = pin.rowAndCell().cell;
const link_id = page.lookupHyperlink(cell) orelse return null;
const entry = page.hyperlink_set.get(page.memory, link_id);
return entry.uri.offset.ptr(page.memory)[0..entry.uri.len];
}

pub fn mousePressureCallback(
self: *Surface,
stage: input.MousePressureStage,
Expand Down Expand Up @@ -2705,9 +2736,13 @@ pub fn cursorPosCallback(

try self.mouseReport(button, .motion, self.mouse.mods, pos);

// If we were previously over a link, we need to queue a
// render to undo the link state.
if (over_link) try self.queueRender();
// If we were previously over a link, we need to undo the link state.
// We also queue a render so the renderer can undo the rendered link
// state.
if (over_link) {
self.rt_surface.mouseOverLink(null);
try self.queueRender();
}

// If we're doing mouse motion tracking, we do not support text
// selection.
Expand Down Expand Up @@ -2769,16 +2804,7 @@ pub fn cursorPosCallback(
if (self.mouse.link_point) |last_vp| {
// Mark the link's row as dirty.
if (over_link) {
// TODO: This doesn't handle soft-wrapped links. Ideally this would
// be storing the link's start and end points and marking all rows
// between and including those as dirty, instead of just the row
// containing the part the cursor is hovering. This can result in
// a bit of jank.
if (self.renderer_state.terminal.screen.pages.pin(.{
.viewport = last_vp,
})) |pin| {
pin.markDirty();
}
self.renderer_state.terminal.screen.dirty.hyperlink_hover = true;
}

// If our last link viewport point is unchanged, then don't process
Expand All @@ -2796,17 +2822,37 @@ pub fn cursorPosCallback(
}
self.mouse.link_point = pos_vp;

if (try self.linkAtPos(pos)) |_| {
if (try self.linkAtPos(pos)) |link| {
self.renderer_state.mouse.point = pos_vp;
self.mouse.over_link = true;
// Mark the new link's row as dirty.
if (self.renderer_state.terminal.screen.pages.pin(.{ .viewport = pos_vp })) |pin| {
pin.markDirty();
}
self.renderer_state.terminal.screen.dirty.hyperlink_hover = true;
try self.rt_surface.setMouseShape(.pointer);

switch (link[0]) {
.open => {
const str = try self.io.terminal.screen.selectionString(self.alloc, .{
.sel = link[1],
.trim = false,
});
defer self.alloc.free(str);
self.rt_surface.mouseOverLink(str);
},

._open_osc8 => link: {
// Show the URL in the status bar
const pin = link[1].start();
const uri = self.osc8URI(pin) orelse {
log.warn("failed to get URI for OSC8 hyperlink", .{});
break :link;
};
self.rt_surface.mouseOverLink(uri);
},
}

try self.queueRender();
} else if (over_link) {
try self.rt_surface.setMouseShape(self.io.terminal.mouse_shape);
self.rt_surface.mouseOverLink(null);
try self.queueRender();
}
}
Expand Down
18 changes: 18 additions & 0 deletions src/apprt/embedded.zig
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ pub const App = struct {

/// Called when the health of the renderer changes.
update_renderer_health: ?*const fn (SurfaceUD, renderer.Health) void = null,

/// Called when the mouse goes over a link. The link target is the
/// parameter. The link target will be null if the mouse is no longer
/// over a link.
mouse_over_link: ?*const fn (SurfaceUD, ?[*]const u8, usize) void = null,
};

/// Special values for the goto_tab callback.
Expand Down Expand Up @@ -1101,6 +1106,19 @@ pub const Surface = struct {

func(self.userdata, health);
}

pub fn mouseOverLink(self: *const Surface, uri: ?[]const u8) void {
const func = self.app.opts.mouse_over_link orelse {
log.info("runtime embedder does not support over_link", .{});
return;
};

if (uri) |v| {
func(self.userdata, v.ptr, v.len);
} else {
func(self.userdata, null, 0);
}
}
};

/// Inspector is the state required for the terminal inspector. A terminal
Expand Down
6 changes: 6 additions & 0 deletions src/apprt/glfw.zig
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,12 @@ pub const Surface = struct {
self.cursor = new;
}

pub fn mouseOverLink(self: *Surface, uri: ?[]const u8) void {
// We don't do anything in GLFW.
_ = self;
_ = uri;
}

/// Set the visibility of the mouse cursor.
pub fn setMouseVisibility(self: *Surface, visible: bool) void {
self.window.setInputModeCursor(if (visible) .normal else .hidden);
Expand Down
Loading
Loading