diff --git a/include/ghostty.h b/include/ghostty.h index 04233287f..d81e3d19a 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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; @@ -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; //------------------------------------------------------------------- diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 2e991ecba..4fc111400 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -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. @@ -523,6 +524,17 @@ extension Ghostty { let backingSize = NSSize(width: Double(width), height: Double(height)) surfaceView.cellSize = surfaceView.convertFromBacking(backingSize) } + + static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer?, 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?, body: UnsafePointer?) { let surfaceView = self.surfaceUserdata(from: userdata) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 34b7ff01f..b17c2a255 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -147,13 +147,13 @@ extension Ghostty { // If we have a URL from hovering a link, we show that. // TODO - if (false) { + if let url = surfaceView.hoverUrl { let padding: CGFloat = 3 HStack { VStack(alignment: .leading) { Spacer() - Text(verbatim: "http://example.com") + Text(verbatim: url) .padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding)) .background(.background) } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index bfd896be1..0a9df88f8 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -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. diff --git a/src/Surface.zig b/src/Surface.zig index cae55e6f7..390eab765 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2813,14 +2813,34 @@ 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; self.renderer_state.terminal.screen.dirty.hyperlink_hover = true; try self.rt_surface.setMouseShape(.pointer); + + switch (link[0]) { + .open => {}, + + ._open_osc8 => link: { + // Show the URL in the status bar + const pin = link[1].start(); + const page = &pin.page.data; + const cell = pin.rowAndCell().cell; + const link_id = page.lookupHyperlink(cell) orelse { + log.warn("failed to find hyperlink for cell", .{}); + break :link; + }; + const entry = page.hyperlink_set.get(page.memory, link_id); + const uri = entry.uri.offset.ptr(page.memory)[0..entry.uri.len]; + 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(); } } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 113d9379a..37caf7d0f 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -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. @@ -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