diff --git a/include/ghostty.h b/include/ghostty.h index 04233287f7..d81e3d19a4 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 2e991ecba7..97a4aa0daf 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. @@ -290,6 +291,7 @@ extension Ghostty { static func setCellSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {} static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?, body: UnsafePointer?) {} static func updateRendererHealth(_ userdata: UnsafeMutableRawPointer?, health: ghostty_renderer_health_e) {} + static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer?, len: Int) {} #endif #if os(macOS) @@ -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?, 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 4d755e70e6..5587d538e4 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -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 @@ -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) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index bfd896be1d..0a9df88f8a 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/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index bda16ced86..87a9afa53f 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -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) { diff --git a/src/Surface.zig b/src/Surface.zig index bb90841f82..149a2c5eb1 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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: { @@ -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, @@ -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 }; } } @@ -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, @@ -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, @@ -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. @@ -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 @@ -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(); } } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 113d9379a7..37caf7d0f4 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 diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 911eb6f5ef..81063bc696 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -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); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 1ee433db93..406c7becee 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -72,7 +72,7 @@ pub const Container = union(enum) { /// element pub fn widget(self: Elem) *c.GtkWidget { return switch (self) { - .surface => |s| @ptrCast(s.gl_area), + .surface => |s| s.primaryWidget(), .split => |s| @ptrCast(@alignCast(s.paned)), }; } @@ -208,6 +208,97 @@ pub const Container = union(enum) { } }; +/// Represents the URL hover widgets that show the hovered URL. +/// To explain a bit how this all works since its split across a few places: +/// We create a left/right pair of labels. The left label is shown by default, +/// and the right label is hidden. When the mouse enters the left label, we +/// show the right label. When the mouse leaves the left label, we hide the +/// right label. +/// +/// The hover and styling is done with a combination of GTK event controllers +/// and CSS in style.css. +pub const URLWidget = struct { + left: *c.GtkWidget, + right: *c.GtkWidget, + + pub fn init(surface: *const Surface, str: [:0]const u8) URLWidget { + // Create the left + const left = c.gtk_label_new(str.ptr); + c.gtk_widget_add_css_class(@ptrCast(left), "view"); + c.gtk_widget_add_css_class(@ptrCast(left), "url-overlay"); + c.gtk_widget_set_halign(left, c.GTK_ALIGN_START); + c.gtk_widget_set_valign(left, c.GTK_ALIGN_END); + c.gtk_widget_set_margin_bottom(left, 2); + + // Create the right + const right = c.gtk_label_new(str.ptr); + c.gtk_widget_add_css_class(@ptrCast(right), "hidden"); + c.gtk_widget_add_css_class(@ptrCast(right), "view"); + c.gtk_widget_add_css_class(@ptrCast(right), "url-overlay"); + c.gtk_widget_set_halign(right, c.GTK_ALIGN_END); + c.gtk_widget_set_valign(right, c.GTK_ALIGN_END); + c.gtk_widget_set_margin_bottom(right, 2); + + // Setup our mouse hover event for the left + const ec_motion = c.gtk_event_controller_motion_new(); + errdefer c.g_object_unref(ec_motion); + c.gtk_widget_add_controller(@ptrCast(left), ec_motion); + _ = c.g_signal_connect_data( + ec_motion, + "enter", + c.G_CALLBACK(>kLeftEnter), + right, + null, + c.G_CONNECT_DEFAULT, + ); + _ = c.g_signal_connect_data( + ec_motion, + "leave", + c.G_CALLBACK(>kLeftLeave), + right, + null, + c.G_CONNECT_DEFAULT, + ); + + // Show it + c.gtk_overlay_add_overlay(@ptrCast(surface.overlay), left); + c.gtk_overlay_add_overlay(@ptrCast(surface.overlay), right); + + return .{ + .left = left, + .right = right, + }; + } + + pub fn deinit(self: *URLWidget, overlay: *c.GtkOverlay) void { + c.gtk_overlay_remove_overlay(@ptrCast(overlay), @ptrCast(self.left)); + c.gtk_overlay_remove_overlay(@ptrCast(overlay), @ptrCast(self.right)); + } + + pub fn setText(self: *const URLWidget, str: [:0]const u8) void { + c.gtk_label_set_text(@ptrCast(self.left), str.ptr); + c.gtk_label_set_text(@ptrCast(self.right), str.ptr); + } + + fn gtkLeftEnter( + _: *c.GtkEventControllerMotion, + _: c.gdouble, + _: c.gdouble, + ud: ?*anyopaque, + ) callconv(.C) void { + const right: *c.GtkWidget = @ptrCast(@alignCast(ud orelse return)); + c.gtk_widget_remove_css_class(@ptrCast(right), "hidden"); + } + + fn gtkLeftLeave( + _: *c.GtkEventControllerMotion, + ud: ?*anyopaque, + ) callconv(.C) void { + const right: *c.GtkWidget = @ptrCast(@alignCast(ud orelse return)); + c.gtk_widget_add_css_class(@ptrCast(right), "hidden"); + } +}; + /// Whether the surface has been realized or not yet. When a surface is /// "realized" it means that the OpenGL context is ready and the core /// surface has been initialized. @@ -223,9 +314,15 @@ container: Container = .{ .none = {} }, /// The app we're part of app: *App, +/// The overlay, this is the primary widget +overlay: *c.GtkOverlay, + /// Our GTK area gl_area: *c.GtkGLArea, +/// If non-null this is the widget on the overlay that shows the URL. +url_widget: ?URLWidget = null, + /// Any active cursor we may have cursor: ?*c.GdkCursor = null, @@ -268,59 +365,66 @@ pub fn create(alloc: Allocator, app: *App, opts: Options) !*Surface { } pub fn init(self: *Surface, app: *App, opts: Options) !void { - const widget: *c.GtkWidget = c.gtk_gl_area_new(); - const gl_area: *c.GtkGLArea = @ptrCast(widget); + const gl_area = c.gtk_gl_area_new(); + + // Create an overlay so we can layer the GL area with other widgets. + const overlay = c.gtk_overlay_new(); + c.gtk_overlay_set_child(@ptrCast(overlay), gl_area); + + // Overlay is not focusable, but the GL area is. + c.gtk_widget_set_focusable(@ptrCast(overlay), 0); + c.gtk_widget_set_focus_on_click(@ptrCast(overlay), 0); - // We grab the floating reference to GL area. This lets the - // GL area be moved around i.e. between a split, a tab, etc. + // We grab the floating reference to the primary widget. This allows the + // widget tree to be moved around i.e. between a split, a tab, etc. // without having to be really careful about ordering to // prevent a destroy. // // This is unref'd in the unref() method that's called by the // self.container through Elem.deinit. - _ = c.g_object_ref_sink(@ptrCast(gl_area)); - errdefer c.g_object_unref(@ptrCast(gl_area)); + _ = c.g_object_ref_sink(@ptrCast(overlay)); + errdefer c.g_object_unref(@ptrCast(overlay)); // We want the gl area to expand to fill the parent container. - c.gtk_widget_set_hexpand(widget, 1); - c.gtk_widget_set_vexpand(widget, 1); + c.gtk_widget_set_hexpand(gl_area, 1); + c.gtk_widget_set_vexpand(gl_area, 1); // Various other GL properties - c.gtk_widget_set_cursor_from_name(@ptrCast(gl_area), "text"); - c.gtk_gl_area_set_required_version(gl_area, 3, 3); - c.gtk_gl_area_set_has_stencil_buffer(gl_area, 0); - c.gtk_gl_area_set_has_depth_buffer(gl_area, 0); - c.gtk_gl_area_set_use_es(gl_area, 0); + c.gtk_widget_set_cursor_from_name(@ptrCast(overlay), "text"); + c.gtk_gl_area_set_required_version(@ptrCast(gl_area), 3, 3); + c.gtk_gl_area_set_has_stencil_buffer(@ptrCast(gl_area), 0); + c.gtk_gl_area_set_has_depth_buffer(@ptrCast(gl_area), 0); + c.gtk_gl_area_set_use_es(@ptrCast(gl_area), 0); // Key event controller will tell us about raw keypress events. const ec_key = c.gtk_event_controller_key_new(); errdefer c.g_object_unref(ec_key); - c.gtk_widget_add_controller(widget, ec_key); - errdefer c.gtk_widget_remove_controller(widget, ec_key); + c.gtk_widget_add_controller(@ptrCast(overlay), ec_key); + errdefer c.gtk_widget_remove_controller(@ptrCast(overlay), ec_key); // Focus controller will tell us about focus enter/exit events const ec_focus = c.gtk_event_controller_focus_new(); errdefer c.g_object_unref(ec_focus); - c.gtk_widget_add_controller(widget, ec_focus); - errdefer c.gtk_widget_remove_controller(widget, ec_focus); + c.gtk_widget_add_controller(@ptrCast(overlay), ec_focus); + errdefer c.gtk_widget_remove_controller(@ptrCast(overlay), ec_focus); // Create a second key controller so we can receive the raw // key-press events BEFORE the input method gets them. const ec_key_press = c.gtk_event_controller_key_new(); errdefer c.g_object_unref(ec_key_press); - c.gtk_widget_add_controller(widget, ec_key_press); - errdefer c.gtk_widget_remove_controller(widget, ec_key_press); + c.gtk_widget_add_controller(@ptrCast(overlay), ec_key_press); + errdefer c.gtk_widget_remove_controller(@ptrCast(overlay), ec_key_press); // Clicks const gesture_click = c.gtk_gesture_click_new(); errdefer c.g_object_unref(gesture_click); c.gtk_gesture_single_set_button(@ptrCast(gesture_click), 0); - c.gtk_widget_add_controller(widget, @ptrCast(gesture_click)); + c.gtk_widget_add_controller(@ptrCast(@alignCast(overlay)), @ptrCast(gesture_click)); // Mouse movement const ec_motion = c.gtk_event_controller_motion_new(); errdefer c.g_object_unref(ec_motion); - c.gtk_widget_add_controller(widget, ec_motion); + c.gtk_widget_add_controller(@ptrCast(@alignCast(overlay)), ec_motion); // Scroll events const ec_scroll = c.gtk_event_controller_scroll_new( @@ -328,7 +432,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { c.GTK_EVENT_CONTROLLER_SCROLL_DISCRETE, ); errdefer c.g_object_unref(ec_scroll); - c.gtk_widget_add_controller(widget, ec_scroll); + c.gtk_widget_add_controller(@ptrCast(overlay), ec_scroll); // The input method context that we use to translate key events into // characters. This doesn't have an event key controller attached because @@ -337,8 +441,8 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { errdefer c.g_object_unref(im_context); // The GL area has to be focusable so that it can receive events - c.gtk_widget_set_focusable(widget, 1); - c.gtk_widget_set_focus_on_click(widget, 1); + c.gtk_widget_set_focusable(gl_area, 1); + c.gtk_widget_set_focus_on_click(gl_area, 1); // Inherit the parent's font size if we have a parent. const font_size: ?font.face.DesiredSize = font_size: { @@ -381,7 +485,8 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { self.* = .{ .app = app, .container = .{ .none = {} }, - .gl_area = gl_area, + .overlay = @ptrCast(overlay), + .gl_area = @ptrCast(gl_area), .title_text = null, .core_surface = undefined, .font_size = font_size, @@ -488,7 +593,7 @@ pub fn deinit(self: *Surface) void { // unref removes the long-held reference to the gl_area and kicks off the // deinit/destroy process for this surface. pub fn unref(self: *Surface) void { - c.g_object_unref(self.gl_area); + c.g_object_unref(self.overlay); } pub fn destroy(self: *Surface, alloc: Allocator) void { @@ -496,6 +601,10 @@ pub fn destroy(self: *Surface, alloc: Allocator) void { alloc.destroy(self); } +pub fn primaryWidget(self: *Surface) *c.GtkWidget { + return @ptrCast(@alignCast(self.overlay)); +} + fn render(self: *Surface) !void { try self.core_surface.renderer.drawFrame(self); } @@ -855,8 +964,9 @@ pub fn setMouseShape( // Set our new cursor. We only do this if the cursor we currently // have is NOT set to "none" because setting the cursor causes it // to become visible again. - if (c.gtk_widget_get_cursor(@ptrCast(self.gl_area)) != self.app.cursor_none) { - c.gtk_widget_set_cursor(@ptrCast(self.gl_area), cursor); + const overlay_widget: *c.GtkWidget = @ptrCast(@alignCast(self.overlay)); + if (c.gtk_widget_get_cursor(overlay_widget) != self.app.cursor_none) { + c.gtk_widget_set_cursor(overlay_widget, cursor); } // Free our existing cursor @@ -869,14 +979,39 @@ pub fn setMouseVisibility(self: *Surface, visible: bool) void { // Note in there that self.cursor or cursor_none may be null. That's // not a problem because NULL is a valid argument for set cursor // which means to just use the parent value. + const overlay_widget: *c.GtkWidget = @ptrCast(@alignCast(self.overlay)); if (visible) { - c.gtk_widget_set_cursor(@ptrCast(self.gl_area), self.cursor); + c.gtk_widget_set_cursor(overlay_widget, self.cursor); return; } // Set our new cursor to the app "none" cursor - c.gtk_widget_set_cursor(@ptrCast(self.gl_area), self.app.cursor_none); + c.gtk_widget_set_cursor(overlay_widget, self.app.cursor_none); +} + +pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void { + const uri = uri_ orelse { + if (self.url_widget) |*widget| { + widget.deinit(self.overlay); + self.url_widget = null; + } + + return; + }; + + // We need a null-terminated string + const alloc = self.app.core_app.alloc; + const uriZ = alloc.dupeZ(u8, uri) catch return; + defer alloc.free(uriZ); + + // If we have a URL widget already just change the text. + if (self.url_widget) |widget| { + widget.setText(uriZ); + return; + } + + self.url_widget = URLWidget.init(self, uriZ); } pub fn clipboardRequest( @@ -1036,7 +1171,7 @@ fn gtkRealize(area: *c.GtkGLArea, ud: ?*anyopaque) callconv(.C) void { // When we have a realized surface, we also attach our input method context. // We do this here instead of init because this allows us to relase the ref // to the GLArea when we unrealized. - c.gtk_im_context_set_client_widget(self.im_context, @ptrCast(self.gl_area)); + c.gtk_im_context_set_client_widget(self.im_context, @ptrCast(@alignCast(self.overlay))); } /// This is called when the underlying OpenGL resources must be released. diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index a41c72d934..32b0c78886 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -104,8 +104,7 @@ pub fn init(self: *Tab, window: *Window, parent_: ?*CoreSurface) !void { self.elem = .{ .surface = surface }; // Add Surface to the Tab - const gl_area_widget = @as(*c.GtkWidget, @ptrCast(surface.gl_area)); - c.gtk_box_append(self.box, gl_area_widget); + c.gtk_box_append(self.box, surface.primaryWidget()); // Add the notebook page (create tab). const parent_page_idx = switch (window.app.config.@"window-new-tab-position") { diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css index e69de29bb2..4f52015f49 100644 --- a/src/apprt/gtk/style.css +++ b/src/apprt/gtk/style.css @@ -0,0 +1,11 @@ +label.url-overlay { + padding: 2px; +} + +label.url-overlay:hover { + opacity: 0; +} + +label.url-overlay.hidden { + opacity: 0; +} diff --git a/src/input/Link.zig b/src/input/Link.zig index 86a9402d50..adc52a2703 100644 --- a/src/input/Link.zig +++ b/src/input/Link.zig @@ -23,6 +23,10 @@ pub const Action = union(enum) { /// Open the full matched value using the default open program. /// For example, on macOS this is "open" and on Linux this is "xdg-open". open: void, + + /// Open the OSC8 hyperlink under the mouse position. _-prefixed means + /// this can't be user-specified, it's only used internally. + _open_osc8: void, }; pub const Highlight = union(enum) { diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index fc5ad382cb..e459d36abe 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -2005,7 +2005,7 @@ fn rebuildCells( if (self.updateCell( screen, cell, - if (link_match_set.orderedContains(screen, cell)) + if (link_match_set.contains(screen, cell)) .single else null, diff --git a/src/renderer/link.zig b/src/renderer/link.zig index e6c7f6ba04..aa2db2b8d7 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -6,6 +6,7 @@ const inputpkg = @import("../input.zig"); const terminal = @import("../terminal/main.zig"); const point = terminal.point; const Screen = terminal.Screen; +const Terminal = terminal.Terminal; const log = std.log.scoped(.renderer_link); @@ -79,10 +80,206 @@ pub const Set = struct { var matches = std.ArrayList(terminal.Selection).init(alloc); defer matches.deinit(); + // If our mouse is over an OSC8 link, then we can skip the regex + // matches below since OSC8 takes priority. + try self.matchSetFromOSC8( + alloc, + &matches, + screen, + mouse_pin, + mouse_mods, + ); + + // If we have no matches then we can try the regex matches. + if (matches.items.len == 0) { + try self.matchSetFromLinks( + alloc, + &matches, + screen, + mouse_pin, + mouse_mods, + ); + } + + return .{ .matches = try matches.toOwnedSlice() }; + } + + fn matchSetFromOSC8( + self: *const Set, + alloc: Allocator, + matches: *std.ArrayList(terminal.Selection), + screen: *Screen, + mouse_pin: terminal.Pin, + mouse_mods: inputpkg.Mods, + ) !void { + _ = alloc; + + // If the right mods aren't pressed, then we can't match. + if (!mouse_mods.equal(inputpkg.ctrlOrSuper(.{}))) return; + + // Check if the cell the mouse is over is an OSC8 hyperlink + const mouse_cell = mouse_pin.rowAndCell().cell; + if (!mouse_cell.hyperlink) return; + + // Get our hyperlink entry + const page = &mouse_pin.page.data; + const link_id = page.lookupHyperlink(mouse_cell) orelse { + log.warn("failed to find hyperlink for cell", .{}); + return; + }; + const link = page.hyperlink_set.get(page.memory, link_id); + + // If our link has an implicit ID (no ID set explicitly via OSC8) + // then we use an alternate matching technique that iterates forward + // and backward until it finds boundaries. + if (link.id == .implicit) { + const uri = link.uri.offset.ptr(page.memory)[0..link.uri.len]; + return try self.matchSetFromOSC8Implicit( + matches, + mouse_pin, + uri, + ); + } + + // Go through every row and find matching hyperlinks for the given ID. + // Note the link ID is not the same as the OSC8 ID parameter. But + // we hash hyperlinks by their contents which should achieve the same + // thing so we can use the ID as a key. + var current: ?terminal.Selection = null; + var row_it = screen.pages.getTopLeft(.viewport).rowIterator(.right_down, null); + while (row_it.next()) |row_pin| { + const row = row_pin.rowAndCell().row; + + // If the row doesn't have any hyperlinks then we're done + // building our matching selection. + if (!row.hyperlink) { + if (current) |sel| { + try matches.append(sel); + current = null; + } + + continue; + } + + // We have hyperlinks, look for our own matching hyperlink. + for (row_pin.cells(.right), 0..) |*cell, x| { + const match = match: { + if (cell.hyperlink) { + if (row_pin.page.data.lookupHyperlink(cell)) |cell_link_id| { + break :match cell_link_id == link_id; + } + } + break :match false; + }; + + // If we have a match, extend our selection or start a new + // selection. + if (match) { + const cell_pin = row_pin.right(x); + if (current) |*sel| { + sel.endPtr().* = cell_pin; + } else { + current = terminal.Selection.init( + cell_pin, + cell_pin, + false, + ); + } + + continue; + } + + // No match, if we have a current selection then complete it. + if (current) |sel| { + try matches.append(sel); + current = null; + } + } + } + } + + /// Match OSC8 links around the mouse pin for an OSC8 link with an + /// implicit ID. This only matches cells with the same URI directly + /// around the mouse pin. + fn matchSetFromOSC8Implicit( + self: *const Set, + matches: *std.ArrayList(terminal.Selection), + mouse_pin: terminal.Pin, + uri: []const u8, + ) !void { + _ = self; + + // Our selection starts with just our pin. + var sel = terminal.Selection.init(mouse_pin, mouse_pin, false); + + // Expand it to the left. + var it = mouse_pin.cellIterator(.left_up, null); + while (it.next()) |cell_pin| { + const page = &cell_pin.page.data; + const rac = cell_pin.rowAndCell(); + const cell = rac.cell; + + // If this cell isn't a hyperlink then we've found a boundary + if (!cell.hyperlink) break; + + const link_id = page.lookupHyperlink(cell) orelse { + log.warn("failed to find hyperlink for cell", .{}); + break; + }; + const link = page.hyperlink_set.get(page.memory, link_id); + + // If this link has an explicit ID then we found a boundary + if (link.id != .implicit) break; + + // If this link has a different URI then we found a boundary + const cell_uri = link.uri.offset.ptr(page.memory)[0..link.uri.len]; + if (!std.mem.eql(u8, uri, cell_uri)) break; + + sel.startPtr().* = cell_pin; + } + + // Expand it to the right + it = mouse_pin.cellIterator(.right_down, null); + while (it.next()) |cell_pin| { + const page = &cell_pin.page.data; + const rac = cell_pin.rowAndCell(); + const cell = rac.cell; + + // If this cell isn't a hyperlink then we've found a boundary + if (!cell.hyperlink) break; + + const link_id = page.lookupHyperlink(cell) orelse { + log.warn("failed to find hyperlink for cell", .{}); + break; + }; + const link = page.hyperlink_set.get(page.memory, link_id); + + // If this link has an explicit ID then we found a boundary + if (link.id != .implicit) break; + + // If this link has a different URI then we found a boundary + const cell_uri = link.uri.offset.ptr(page.memory)[0..link.uri.len]; + if (!std.mem.eql(u8, uri, cell_uri)) break; + + sel.endPtr().* = cell_pin; + } + + try matches.append(sel); + } + + /// Fills matches with the matches from regex link matches. + fn matchSetFromLinks( + self: *const Set, + alloc: Allocator, + matches: *std.ArrayList(terminal.Selection), + screen: *Screen, + mouse_pin: terminal.Pin, + mouse_mods: inputpkg.Mods, + ) !void { // Iterate over all the visible lines. var lineIter = screen.lineIterator(screen.pages.pin(.{ .viewport = .{}, - }) orelse return .{}); + }) orelse return); while (lineIter.next()) |line_sel| { const strmap: terminal.StringMap = strmap: { var strmap: terminal.StringMap = undefined; @@ -141,8 +338,6 @@ pub const Set = struct { } } } - - return .{ .matches = try matches.toOwnedSlice() }; } }; @@ -160,6 +355,21 @@ pub const MatchSet = struct { alloc.free(self.matches); } + /// Checks if the matchset contains the given pin. This is slower than + /// orderedContains but is stateless and more flexible since it doesn't + /// require the points to be in order. + pub fn contains( + self: *MatchSet, + screen: *const Screen, + pin: terminal.Pin, + ) bool { + for (self.matches) |sel| { + if (sel.contains(screen, pin)) return true; + } + + return false; + } + /// Checks if the matchset contains the given pt. The points must be /// given in left-to-right top-to-bottom order. This is a stateful /// operation and giving a point out of order can cause invalid @@ -391,3 +601,66 @@ test "matchset mods no match" { .y = 2, } }).?)); } + +test "matchset osc8" { + const testing = std.testing; + const alloc = testing.allocator; + + // Initialize our terminal + var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + const s = &t.screen; + + try t.printString("ABC"); + try t.screen.startHyperlink("http://example.com", null); + try t.printString("123"); + t.screen.endHyperlink(); + + // Get a set + var set = try Set.fromConfig(alloc, &.{}); + defer set.deinit(alloc); + + // No matches over the non-link + { + var match = try set.matchSet( + alloc, + &t.screen, + .{ .x = 2, .y = 0 }, + inputpkg.ctrlOrSuper(.{}), + ); + defer match.deinit(alloc); + try testing.expectEqual(@as(usize, 0), match.matches.len); + } + + // Match over link + var match = try set.matchSet( + alloc, + &t.screen, + .{ .x = 3, .y = 0 }, + inputpkg.ctrlOrSuper(.{}), + ); + defer match.deinit(alloc); + try testing.expectEqual(@as(usize, 1), match.matches.len); + + // Test our matches + try testing.expect(!match.orderedContains(s, s.pages.pin(.{ .screen = .{ + .x = 2, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{ + .x = 3, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{ + .x = 4, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{ + .x = 5, + .y = 0, + } }).?)); + try testing.expect(!match.orderedContains(s, s.pages.pin(.{ .screen = .{ + .x = 6, + .y = 0, + } }).?)); +} diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 447c8b622f..c4a108aca0 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1162,6 +1162,30 @@ fn reflowPage( }, } + // If the source cell has a hyperlink we need to copy it + if (src_cursor.page_cell.hyperlink) { + const src_page = src_cursor.page; + const dst_page = dst_cursor.page; + + // Pause integrity checks because setHyperlink + // calls them but we're not ready yet. + dst_page.pauseIntegrityChecks(true); + defer dst_page.pauseIntegrityChecks(false); + + const id = src_page.lookupHyperlink(src_cursor.page_cell).?; + const src_link = src_page.hyperlink_set.get(src_page.memory, id); + const dst_id = try dst_page.hyperlink_set.addContext( + dst_page.memory, + try src_link.dupe(src_page, dst_page), + .{ .page = dst_page }, + ); + try dst_page.setHyperlink( + dst_cursor.page_row, + dst_cursor.page_cell, + dst_id, + ); + } + // If the source cell has a style, we need to copy it. if (src_cursor.page_cell.style_id != stylepkg.default_id) { const src_style = src_cursor.page.styles.get( @@ -1849,6 +1873,12 @@ pub const AdjustCapacity = struct { /// Adjust the number of available grapheme bytes in the page. grapheme_bytes: ?usize = null, + + /// Adjust the number of available hyperlink bytes in the page. + hyperlink_bytes: ?usize = null, + + /// Adjust the number of available string bytes in the page. + string_bytes: ?usize = null, }; /// Adjust the capcaity of the given page in the list. This should @@ -1884,6 +1914,14 @@ pub fn adjustCapacity( const aligned = try std.math.ceilPowerOfTwo(usize, v); cap.grapheme_bytes = @max(cap.grapheme_bytes, aligned); } + if (adjustment.hyperlink_bytes) |v| { + const aligned = try std.math.ceilPowerOfTwo(usize, v); + cap.hyperlink_bytes = @max(cap.hyperlink_bytes, aligned); + } + if (adjustment.string_bytes) |v| { + const aligned = try std.math.ceilPowerOfTwo(usize, v); + cap.string_bytes = @max(cap.string_bytes, aligned); + } log.info("adjusting page capacity={}", .{cap}); @@ -4040,6 +4078,49 @@ test "PageList adjustCapacity to increase graphemes" { } } +test "PageList adjustCapacity to increase hyperlinks" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write all our data so we can assert its the same after + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + } + + // Increase our graphemes + _ = try s.adjustCapacity( + s.pages.first.?, + .{ .hyperlink_bytes = @max(std_capacity.hyperlink_bytes * 2, 2048) }, + ); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(x)), + rac.cell.content.codepoint, + ); + } + } + } +} + test "PageList pageIterator single page" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 347a06ebfc..d99ca0b28b 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -15,6 +15,8 @@ const pagepkg = @import("page.zig"); const point = @import("point.zig"); const size = @import("size.zig"); const style = @import("style.zig"); +const hyperlink = @import("hyperlink.zig"); +const Offset = size.Offset; const Page = pagepkg.Page; const Row = pagepkg.Row; const Cell = pagepkg.Cell; @@ -70,6 +72,10 @@ pub const Dirty = packed struct { /// Set when the selection is set or unset, regardless of if the /// selection is changed or not. selection: bool = false, + + /// When an OSC8 hyperlink is hovered, we set the full screen as dirty + /// because links can span multiple lines. + hyperlink_hover: bool = false, }; /// The cursor position. @@ -101,11 +107,33 @@ pub const Cursor = struct { /// our style when used. style_id: style.Id = style.default_id, + /// The hyperlink ID that is currently active for the cursor. A value + /// of zero means no hyperlink is active. (Implements OSC8, saying that + /// so code search can find it.). + hyperlink_id: hyperlink.Id = 0, + + /// This is the implicit ID to use for hyperlinks that don't specify + /// an ID. We do an overflowing add to this so repeats can technically + /// happen with carefully crafted inputs but for real workloads its + /// highly unlikely -- and the fix is for the TUI program to use explicit + /// IDs. + hyperlink_implicit_id: size.OffsetInt = 0, + + /// Heap-allocated hyperlink state so that we can recreate it when + /// the cursor page pin changes. We can't get it from the old screen + /// state because the page may be cleared. This is heap allocated + /// because its most likely null. + hyperlink: ?*Hyperlink = null, + /// The pointers into the page list where the cursor is currently /// located. This makes it faster to move the cursor. page_pin: *PageList.Pin, page_row: *pagepkg.Row, page_cell: *pagepkg.Cell, + + pub fn deinit(self: *Cursor, alloc: Allocator) void { + if (self.hyperlink) |link| link.destroy(alloc); + } }; /// The visual style of the cursor. Whether or not it blinks @@ -141,6 +169,31 @@ pub const CharsetState = struct { const CharsetArray = std.EnumArray(charsets.Slots, charsets.Charset); }; +pub const Hyperlink = struct { + id: ?[]const u8, + uri: []const u8, + + pub fn create( + alloc: Allocator, + uri: []const u8, + id: ?[]const u8, + ) !*Hyperlink { + const self = try alloc.create(Hyperlink); + errdefer alloc.destroy(self); + self.id = if (id) |v| try alloc.dupe(u8, v) else null; + errdefer if (self.id) |v| alloc.free(v); + self.uri = try alloc.dupe(u8, uri); + errdefer alloc.free(self.uri); + return self; + } + + pub fn destroy(self: *Hyperlink, alloc: Allocator) void { + if (self.id) |id| alloc.free(id); + alloc.free(self.uri); + alloc.destroy(self); + } +}; + /// Initialize a new screen. /// /// max_scrollback is the amount of scrollback to keep in bytes. This @@ -179,6 +232,7 @@ pub fn init( pub fn deinit(self: *Screen) void { self.kitty_images.deinit(self.alloc, self); + self.cursor.deinit(self.alloc); self.pages.deinit(); } @@ -220,6 +274,9 @@ pub fn assertIntegrity(self: *const Screen) void { /// - Cursor location can be expensive to calculate with respect to the /// specified region. It is faster to grab the cursor from the old /// screen and then move it to the new screen. +/// - Current hyperlink cursor state has heap allocations. Since clone +/// is only for read-only operations, it is better to not have any +/// hyperlink state. Note that already-written hyperlinks are cloned. /// /// If not mentioned above, then there isn't a specific reason right now /// to not copy some data other than we probably didn't need it and it @@ -394,6 +451,19 @@ fn adjustCapacity( ) catch unreachable; } + // Re-add the hyperlink + if (self.cursor.hyperlink) |link| { + // So we don't attempt to free any memory in the replaced page. + self.cursor.hyperlink_id = 0; + self.cursor.hyperlink = null; + + // Re-add + self.startHyperlinkOnce(link.uri, link.id) catch unreachable; + + // Remove our old link + link.destroy(self.alloc); + } + // Reload the cursor information because the pin changed. // So our page row/cell and so on are all off. self.cursorReload(); @@ -896,6 +966,13 @@ pub fn clearCells( } } + // If we have hyperlinks, we need to clear those. + if (row.hyperlink) { + for (cells) |*cell| { + if (cell.hyperlink) page.clearHyperlink(row, cell); + } + } + if (row.styled) { for (cells) |*cell| { if (cell.style_id == style.default_id) continue; @@ -1313,6 +1390,176 @@ pub fn appendGrapheme(self: *Screen, cell: *Cell, cp: u21) !void { }; } +/// Start the hyperlink state. Future cells will be marked as hyperlinks with +/// this state. Note that various terminal operations may clear the hyperlink +/// state, such as switching screens (alt screen). +pub fn startHyperlink( + self: *Screen, + uri: []const u8, + id_: ?[]const u8, +) !void { + // Loop until we have enough page memory to add the hyperlink + while (true) { + if (self.startHyperlinkOnce(uri, id_)) { + return; + } else |err| switch (err) { + // An actual self.alloc OOM is a fatal error. + error.RealOutOfMemory => return error.OutOfMemory, + + // strings table is out of memory, adjust it up + error.StringsOutOfMemory => _ = try self.adjustCapacity( + self.cursor.page_pin.page, + .{ .string_bytes = self.cursor.page_pin.page.data.capacity.string_bytes * 2 }, + ), + + // hyperlink set is out of memory, adjust it up + error.SetOutOfMemory => _ = try self.adjustCapacity( + self.cursor.page_pin.page, + .{ .hyperlink_bytes = self.cursor.page_pin.page.data.capacity.hyperlink_bytes * 2 }, + ), + + // hyperlink set is too full, rehash it + error.SetNeedsRehash => _ = try self.adjustCapacity( + self.cursor.page_pin.page, + .{}, + ), + } + + self.assertIntegrity(); + } +} + +/// This is like startHyperlink but if we have to adjust page capacities +/// this returns error.PageAdjusted. This is useful so that we unwind +/// all the previous state and try again. +fn startHyperlinkOnce( + self: *Screen, + uri: []const u8, + id_: ?[]const u8, +) !void { + // End any prior hyperlink + self.endHyperlink(); + + // Create our hyperlink state. + const link = Hyperlink.create(self.alloc, uri, id_) catch |err| switch (err) { + error.OutOfMemory => return error.RealOutOfMemory, + }; + errdefer link.destroy(self.alloc); + + // Copy our URI into the page memory. + var page = &self.cursor.page_pin.page.data; + const string_alloc = &page.string_alloc; + const page_uri: Offset(u8).Slice = uri: { + const buf = string_alloc.alloc(u8, page.memory, uri.len) catch |err| switch (err) { + error.OutOfMemory => return error.StringsOutOfMemory, + }; + errdefer string_alloc.free(page.memory, buf); + @memcpy(buf, uri); + + break :uri .{ + .offset = size.getOffset(u8, page.memory, &buf[0]), + .len = uri.len, + }; + }; + errdefer string_alloc.free( + page.memory, + page_uri.offset.ptr(page.memory)[0..page_uri.len], + ); + + // Copy our ID into page memory or create an implicit ID via the counter + const page_id: hyperlink.Hyperlink.Id = if (id_) |id| explicit: { + const buf = string_alloc.alloc(u8, page.memory, id.len) catch |err| switch (err) { + error.OutOfMemory => return error.StringsOutOfMemory, + }; + errdefer string_alloc.free(page.memory, buf); + @memcpy(buf, id); + + break :explicit .{ + .explicit = .{ + .offset = size.getOffset(u8, page.memory, &buf[0]), + .len = id.len, + }, + }; + } else implicit: { + defer self.cursor.hyperlink_implicit_id += 1; + break :implicit .{ .implicit = self.cursor.hyperlink_implicit_id }; + }; + errdefer switch (page_id) { + .implicit => self.cursor.hyperlink_implicit_id -= 1, + .explicit => |slice| string_alloc.free( + page.memory, + slice.offset.ptr(page.memory)[0..slice.len], + ), + }; + + // Put our hyperlink into the hyperlink set to get an ID + const id = page.hyperlink_set.addContext( + page.memory, + .{ .id = page_id, .uri = page_uri }, + .{ .page = page }, + ) catch |err| switch (err) { + error.OutOfMemory => return error.SetOutOfMemory, + error.NeedsRehash => return error.SetNeedsRehash, + }; + errdefer page.hyperlink_set.release(page.memory, id); + + // Save it all + self.cursor.hyperlink = link; + self.cursor.hyperlink_id = id; +} + +/// End the hyperlink state so that future cells aren't part of the +/// current hyperlink (if any). This is safe to call multiple times. +pub fn endHyperlink(self: *Screen) void { + // If we have no hyperlink state then do nothing + if (self.cursor.hyperlink_id == 0) { + assert(self.cursor.hyperlink == null); + return; + } + + // Release the old hyperlink state. If there are cells using the + // hyperlink this will work because the creation creates a reference + // and all additional cells create a new reference. This release will + // just release our initial reference. + // + // If the ref count reaches zero the set will not delete the item + // immediately; it is kept around in case it is used again (this is + // how RefCountedSet works). This causes some memory fragmentation but + // is fine because if it is ever pruned the context deleted callback + // will be called. + var page = &self.cursor.page_pin.page.data; + page.hyperlink_set.release(page.memory, self.cursor.hyperlink_id); + self.cursor.hyperlink.?.destroy(self.alloc); + self.cursor.hyperlink_id = 0; + self.cursor.hyperlink = null; +} + +/// Set the current hyperlink state on the current cell. +pub fn cursorSetHyperlink(self: *Screen) !void { + assert(self.cursor.hyperlink_id != 0); + + var page = &self.cursor.page_pin.page.data; + if (page.setHyperlink( + self.cursor.page_row, + self.cursor.page_cell, + self.cursor.hyperlink_id, + )) { + // Success! + return; + } else |err| switch (err) { + // hyperlink_map is out of space, realloc the page to be larger + error.OutOfMemory => { + _ = try self.adjustCapacity( + self.cursor.page_pin.page, + .{ .hyperlink_bytes = page.capacity.hyperlink_bytes * 2 }, + ); + + // Retry + return try self.cursorSetHyperlink(); + }, + } +} + /// Set the selection to the given selection. If this is a tracked selection /// then the screen will take overnship of the selection. If this is untracked /// then the screen will convert it to tracked internally. This will automatically @@ -3306,13 +3553,6 @@ test "Screen: scrolling when viewport is pruned" { for (0..1000) |_| try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - { - // Test our contents rotated - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -7268,12 +7508,74 @@ test "Screen: lineIterator soft wrap" { // try testing.expect(iter.next() == null); } +test "Screen: hyperlink start/end" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + try testing.expect(s.cursor.hyperlink_id == 0); + { + const page = &s.cursor.page_pin.page.data; + try testing.expectEqual(0, page.hyperlink_set.count()); + } + + try s.startHyperlink("http://example.com", null); + try testing.expect(s.cursor.hyperlink_id != 0); + { + const page = &s.cursor.page_pin.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + + s.endHyperlink(); + try testing.expect(s.cursor.hyperlink_id == 0); + { + const page = &s.cursor.page_pin.page.data; + try testing.expectEqual(0, page.hyperlink_set.count()); + } +} + +test "Screen: hyperlink reuse" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + + try testing.expect(s.cursor.hyperlink_id == 0); + { + const page = &s.cursor.page_pin.page.data; + try testing.expectEqual(0, page.hyperlink_set.count()); + } + + // Use it for the first time + try s.startHyperlink("http://example.com", null); + try testing.expect(s.cursor.hyperlink_id != 0); + const id = s.cursor.hyperlink_id; + + // Reuse the same hyperlink, expect we have the same ID + try s.startHyperlink("http://example.com", null); + try testing.expectEqual(id, s.cursor.hyperlink_id); + { + const page = &s.cursor.page_pin.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + + s.endHyperlink(); + try testing.expect(s.cursor.hyperlink_id == 0); + { + const page = &s.cursor.page_pin.page.data; + try testing.expectEqual(0, page.hyperlink_set.count()); + } +} + test "Screen: adjustCapacity cursor style ref count" { const testing = std.testing; const alloc = testing.allocator; var s = try init(alloc, 5, 5, 0); defer s.deinit(); + try s.setAttribute(.{ .bold = {} }); try s.testWriteString("1ABCD"); diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 8dcb461333..af903a71d0 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -14,6 +14,7 @@ const ansi = @import("ansi.zig"); const modes = @import("modes.zig"); const charsets = @import("charsets.zig"); const csi = @import("csi.zig"); +const hyperlink = @import("hyperlink.zig"); const kitty = @import("kitty.zig"); const point = @import("point.zig"); const sgr = @import("sgr.zig"); @@ -603,7 +604,6 @@ fn printCell( // We don't need to update the style refs unless the // cell's new style will be different after writing. const style_changed = cell.style_id != self.screen.cursor.style_id; - if (style_changed) { var page = &self.screen.cursor.page_pin.page.data; @@ -614,6 +614,9 @@ fn printCell( } } + // Keep track if we had a hyperlink so we can unset it. + const had_hyperlink = cell.hyperlink; + // Write cell.* = .{ .content_tag = .codepoint, @@ -632,6 +635,20 @@ fn printCell( self.screen.cursor.page_row.styled = true; } } + + // We check for an active hyperlink first because setHyperlink + // handles clearing the old hyperlink and an optimization if we're + // overwriting the same hyperlink. + if (self.screen.cursor.hyperlink_id > 0) { + self.screen.cursorSetHyperlink() catch |err| { + log.warn("error reallocating for more hyperlink space, ignoring hyperlink err={}", .{err}); + assert(!cell.hyperlink); + }; + } else if (had_hyperlink) { + // If the previous cell had a hyperlink then we need to clear it. + var page = &self.screen.cursor.page_pin.page.data; + page.clearHyperlink(self.screen.cursor.page_row, cell); + } } fn printWrap(self: *Terminal) !void { @@ -2451,6 +2468,9 @@ pub fn alternateScreen( log.warn("cursor copy failed entering alt screen err={}", .{err}); }; + // We always end hyperlink state + self.screen.endHyperlink(); + if (options.clear_on_enter) { self.eraseDisplay(.complete, false); } @@ -2484,6 +2504,9 @@ pub fn primaryScreen( // Mark our terminal as dirty self.flags.dirty.clear = true; + // We always end hyperlink state + self.screen.endHyperlink(); + // Restore the cursor from the primary screen. This should not // fail because we should not have to allocate memory since swapping // screens does not create new cursors. @@ -2519,6 +2542,7 @@ pub fn fullReset(self: *Terminal) void { log.warn("restore cursor on primary screen failed err={}", .{err}); }; + self.screen.endHyperlink(); self.screen.charset = .{}; self.modes = .{}; self.flags = .{}; @@ -3775,6 +3799,132 @@ test "Terminal: print wide char at right margin does not create spacer head" { } } +test "Terminal: print with hyperlink" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Setup our hyperlink and print + try t.screen.startHyperlink("http://example.com", null); + try t.printString("123456"); + + // Verify all our cells have a hyperlink + for (0..6) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print and end hyperlink" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Setup our hyperlink and print + try t.screen.startHyperlink("http://example.com", null); + try t.printString("123"); + t.screen.endHyperlink(); + try t.printString("456"); + + // Verify all our cells have a hyperlink + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + for (3..6) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print and change hyperlink" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Setup our hyperlink and print + try t.screen.startHyperlink("http://one.example.com", null); + try t.printString("123"); + try t.screen.startHyperlink("http://two.example.com", null); + try t.printString("456"); + + // Verify all our cells have a hyperlink + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + for (3..6) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 2), id); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: overwrite hyperlink" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Setup our hyperlink and print + try t.screen.startHyperlink("http://one.example.com", null); + try t.printString("123"); + t.setCursorPos(1, 1); + t.screen.endHyperlink(); + try t.printString("456"); + + // Verify all our cells have a hyperlink + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const page = &list_cell.page.data; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + try testing.expect(page.lookupHyperlink(cell) == null); + try testing.expectEqual(0, page.hyperlink_set.count()); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + test "Terminal: linefeed and carriage return" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); @@ -4870,6 +5020,94 @@ test "Terminal: scrollUp simple" { } } +test "Terminal: scrollUp moves hyperlink" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.screen.startHyperlink("http://example.com", null); + try t.printString("DEF"); + t.screen.endHyperlink(); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + t.scrollUp(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("DEF\nGHI", str); + } + + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + +test "Terminal: scrollUp clears hyperlink" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.screen.startHyperlink("http://example.com", null); + try t.printString("ABC"); + t.screen.endHyperlink(); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + t.scrollUp(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("DEF\nGHI", str); + } + + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + test "Terminal: scrollUp top/bottom scroll region" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); @@ -4933,6 +5171,112 @@ test "Terminal: scrollUp left/right scroll region" { } } +test "Terminal: scrollUp left/right scroll region hyperlink" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.screen.startHyperlink("http://example.com", null); + try t.printString("DEF456"); + t.screen.endHyperlink(); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + t.scrollUp(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); + } + + // First row gets some hyperlinks + { + for (0..1) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + for (1..4) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (4..6) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + } + + // Second row preserves hyperlink where we didn't scroll + { + for (0..1) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (1..4) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + for (4..6) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + } +} + test "Terminal: scrollUp preserves pending wrap" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); @@ -5039,6 +5383,57 @@ test "Terminal: scrollDown simple" { } } +test "Terminal: scrollDown hyperlink moves" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.screen.startHyperlink("http://example.com", null); + try t.printString("ABC"); + t.screen.endHyperlink(); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + t.scrollDown(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + } + + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + test "Terminal: scrollDown outside of scroll region" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); @@ -5108,6 +5503,112 @@ test "Terminal: scrollDown left/right scroll region" { } } +test "Terminal: scrollDown left/right scroll region hyperlink" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + try t.screen.startHyperlink("http://example.com", null); + try t.printString("ABC123"); + t.screen.endHyperlink(); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + t.scrollDown(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); + } + + // First row preserves hyperlink where we didn't scroll + { + for (0..1) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (1..4) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + for (4..6) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + } + + // Second row gets some hyperlinks + { + for (0..1) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + for (1..4) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (4..6) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + } +} + test "Terminal: scrollDown outside of left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 10, .rows = 10 }); @@ -5661,6 +6162,51 @@ test "Terminal: index from the bottom" { } } +test "Terminal: index scrolling with hyperlink" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + defer t.deinit(alloc); + + t.setCursorPos(5, 1); + try t.screen.startHyperlink("http://example.com", null); + try t.print('A'); + t.screen.endHyperlink(); + t.cursorLeft(1); // undo moving right from 'A' + try t.index(); + try t.print('B'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\nA\nB", str); + } + + { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = 0, + .y = 3, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = 0, + .y = 4, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + test "Terminal: index outside of scrolling region" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 2, .rows = 5 }); @@ -5787,6 +6333,92 @@ test "Terminal: index inside scroll region" { } } +test "Terminal: index bottom of scroll region with hyperlinks" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 2); + try t.print('A'); + try t.index(); + t.carriageReturn(); + try t.screen.startHyperlink("http://example.com", null); + try t.print('B'); + t.screen.endHyperlink(); + try t.index(); + t.carriageReturn(); + try t.print('C'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nC", str); + } + + { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = 0, + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = 0, + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + +test "Terminal: index bottom of scroll region clear hyperlinks" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 2); + try t.screen.startHyperlink("http://example.com", null); + try t.print('A'); + t.screen.endHyperlink(); + try t.index(); + t.carriageReturn(); + try t.print('B'); + try t.index(); + t.carriageReturn(); + try t.print('C'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nC", str); + } + + for (0..2) |y| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = 0, + .y = @intCast(y), + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + const page = &list_cell.page.data; + try testing.expectEqual(0, page.hyperlink_set.count()); + } +} + test "Terminal: index bottom of scroll region with background SGR" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); @@ -7495,6 +8127,85 @@ test "Terminal: insertBlanks split multi-cell character from tail" { } } +test "Terminal: insertBlanks shifts hyperlinks" { + // osc "8;;http://example.com" + // printf "link" + // printf "\r" + // csi "3@" + // echo + // + // link should be preserved, blanks should not be linked + + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + try t.screen.startHyperlink("http://example.com", null); + try t.printString("ABC"); + t.setCursorPos(1, 1); + t.insertBlanks(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" ABC", str); + } + + // Verify all our cells have a hyperlink + for (2..5) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + for (0..2) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + +test "Terminal: insertBlanks pushes hyperlink off end completely" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 3, .rows = 2 }); + defer t.deinit(alloc); + + try t.screen.startHyperlink("http://example.com", null); + try t.printString("ABC"); + t.setCursorPos(1, 1); + t.insertBlanks(3); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + test "Terminal: insert mode with space" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 10, .rows = 2 }); @@ -8079,6 +8790,19 @@ test "Terminal: saveCursor protected pen" { try testing.expect(t.screen.cursor.protected); } +test "Terminal: saveCursor doesn't modify hyperlink state" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); + defer t.deinit(alloc); + + try t.screen.startHyperlink("http://example.com", null); + const id = t.screen.cursor.hyperlink_id; + t.saveCursor(); + try testing.expectEqual(id, t.screen.cursor.hyperlink_id); + try t.restoreCursor(); + try testing.expectEqual(id, t.screen.cursor.hyperlink_id); +} + test "Terminal: setProtectedMode" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 3, .rows = 3 }); @@ -9237,6 +9961,15 @@ test "Terminal: fullReset with a non-empty pen" { try testing.expectEqual(@as(style.Id, 0), t.screen.cursor.style_id); } +test "Terminal: fullReset hyperlink" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + try t.screen.startHyperlink("http://example.com", null); + t.fullReset(); + try testing.expectEqual(0, t.screen.cursor.hyperlink_id); +} + test "Terminal: fullReset with a non-empty saved cursor" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig index 550a504168..a13236217c 100644 --- a/src/terminal/bitmap_allocator.zig +++ b/src/terminal/bitmap_allocator.zig @@ -65,6 +65,9 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { /// Allocate n elements of type T. This will return error.OutOfMemory /// if there isn't enough space in the backing buffer. + /// + /// Use (size.zig).getOffset to get the base offset from the backing + /// memory for portable storage. pub fn alloc( self: *Self, comptime T: type, diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig index c9c6507845..9e47da5613 100644 --- a/src/terminal/hash_map.zig +++ b/src/terminal/hash_map.zig @@ -857,7 +857,7 @@ fn HashMapUnmanaged( /// because capacity is rounded up to the next power of two. This is /// a design requirement for this hash map implementation. pub fn layoutForCapacity(new_capacity: Size) Layout { - assert(std.math.isPowerOfTwo(new_capacity)); + assert(new_capacity == 0 or std.math.isPowerOfTwo(new_capacity)); // Pack our metadata, keys, and values. const meta_start = @sizeOf(Header); diff --git a/src/terminal/hyperlink.zig b/src/terminal/hyperlink.zig new file mode 100644 index 0000000000..f692f1f8db --- /dev/null +++ b/src/terminal/hyperlink.zig @@ -0,0 +1,159 @@ +const std = @import("std"); +const assert = std.debug.assert; +const hash_map = @import("hash_map.zig"); +const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; +const pagepkg = @import("page.zig"); +const size = @import("size.zig"); +const Offset = size.Offset; +const Cell = pagepkg.Cell; +const Page = pagepkg.Page; +const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet; +const Wyhash = std.hash.Wyhash; +const autoHash = std.hash.autoHash; +const autoHashStrat = std.hash.autoHashStrat; + +/// The unique identifier for a hyperlink. This is at most the number of cells +/// that can fit in a single terminal page. +pub const Id = size.CellCountInt; + +// The mapping of cell to hyperlink. We use an offset hash map to save space +// since its very unlikely a cell is a hyperlink, so its a waste to store +// the hyperlink ID in the cell itself. +pub const Map = AutoOffsetHashMap(Offset(Cell), Id); + +/// The main entry for hyperlinks. +pub const Hyperlink = struct { + id: Hyperlink.Id, + uri: Offset(u8).Slice, + + pub const Id = union(enum) { + /// An explicitly provided ID via the OSC8 sequence. + explicit: Offset(u8).Slice, + + /// No ID was provided so we auto-generate the ID based on an + /// incrementing counter attached to the screen. + implicit: size.OffsetInt, + }; + + /// Duplicate this hyperlink from one page to another. + pub fn dupe(self: *const Hyperlink, self_page: *const Page, dst_page: *Page) !Hyperlink { + var copy = self.*; + + // If the pages are the same then we can return a shallow copy. + if (self_page == dst_page) return copy; + + // Copy the URI + { + const uri = self.uri.offset.ptr(self_page.memory)[0..self.uri.len]; + const buf = try dst_page.string_alloc.alloc(u8, dst_page.memory, uri.len); + @memcpy(buf, uri); + copy.uri = .{ + .offset = size.getOffset(u8, dst_page.memory, &buf[0]), + .len = uri.len, + }; + } + errdefer dst_page.string_alloc.free( + dst_page.memory, + copy.uri.offset.ptr(dst_page.memory)[0..copy.uri.len], + ); + + // Copy the ID + switch (copy.id) { + .implicit => {}, // Shallow is fine + .explicit => |slice| { + const id = slice.offset.ptr(self_page.memory)[0..slice.len]; + const buf = try dst_page.string_alloc.alloc(u8, dst_page.memory, id.len); + @memcpy(buf, id); + copy.id = .{ .explicit = .{ + .offset = size.getOffset(u8, dst_page.memory, &buf[0]), + .len = id.len, + } }; + }, + } + errdefer switch (copy.id) { + .implicit => {}, + .explicit => |v| dst_page.string_alloc.free( + dst_page.memory, + v.offset.ptr(dst_page.memory)[0..v.len], + ), + }; + + return copy; + } + + pub fn hash(self: *const Hyperlink, base: anytype) u64 { + var hasher = Wyhash.init(0); + autoHash(&hasher, std.meta.activeTag(self.id)); + switch (self.id) { + .implicit => |v| autoHash(&hasher, v), + .explicit => |slice| autoHashStrat( + &hasher, + slice.offset.ptr(base)[0..slice.len], + .Deep, + ), + } + autoHashStrat( + &hasher, + self.uri.offset.ptr(base)[0..self.uri.len], + .Deep, + ); + return hasher.final(); + } + + pub fn eql(self: *const Hyperlink, base: anytype, other: *const Hyperlink) bool { + if (std.meta.activeTag(self.id) != std.meta.activeTag(other.id)) return false; + switch (self.id) { + .implicit => if (self.id.implicit != other.id.implicit) return false, + .explicit => { + const self_ptr = self.id.explicit.offset.ptr(base); + const other_ptr = other.id.explicit.offset.ptr(base); + if (!std.mem.eql( + u8, + self_ptr[0..self.id.explicit.len], + other_ptr[0..other.id.explicit.len], + )) return false; + }, + } + + return std.mem.eql( + u8, + self.uri.offset.ptr(base)[0..self.uri.len], + other.uri.offset.ptr(base)[0..other.uri.len], + ); + } +}; + +/// The set of hyperlinks. This is ref-counted so that a set of cells +/// can share the same hyperlink without duplicating the data. +pub const Set = RefCountedSet( + Hyperlink, + Id, + size.CellCountInt, + struct { + page: ?*Page = null, + + pub fn hash(self: *const @This(), link: Hyperlink) u64 { + return link.hash(self.page.?.memory); + } + + pub fn eql(self: *const @This(), a: Hyperlink, b: Hyperlink) bool { + return a.eql(self.page.?.memory, &b); + } + + pub fn deleted(self: *const @This(), link: Hyperlink) void { + const page = self.page.?; + const alloc = &page.string_alloc; + switch (link.id) { + .implicit => {}, + .explicit => |v| alloc.free( + page.memory, + v.offset.ptr(page.memory)[0..v.len], + ), + } + alloc.free( + page.memory, + link.uri.offset.ptr(page.memory)[0..link.uri.len], + ); + } + }, +); diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 857dd79f34..8807921ff8 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -6,6 +6,7 @@ const charsets = @import("charsets.zig"); const stream = @import("stream.zig"); const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); +const hyperlink = @import("hyperlink.zig"); const sgr = @import("sgr.zig"); const style = @import("style.zig"); pub const apc = @import("apc.zig"); @@ -60,5 +61,6 @@ test { // Internals _ = @import("bitmap_allocator.zig"); _ = @import("hash_map.zig"); + _ = @import("ref_counted_set.zig"); _ = @import("size.zig"); } diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index a220ea031a..c069e0d4bd 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -1,4 +1,5 @@ //! OSC (Operating System Command) related functions and types. OSC is +//! //! another set of control sequences for terminal programs that start with //! "ESC ]". Unlike CSI or standard ESC sequences, they may contain strings //! and other irregular formatting so a dedicated parser is created to handle it. @@ -133,6 +134,15 @@ pub const Command = union(enum) { body: []const u8, }, + /// Start a hyperlink (OSC 8) + hyperlink_start: struct { + id: ?[]const u8 = null, + uri: []const u8, + }, + + /// End a hyperlink (OSC 8) + hyperlink_end: void, + pub const ColorKind = union(enum) { palette: u8, foreground, @@ -239,6 +249,7 @@ pub const Parser = struct { @"7", @"77", @"777", + @"8", @"9", // OSC 10 is used to query or set the current foreground color. @@ -267,6 +278,11 @@ pub const Parser = struct { color_palette_index, color_palette_index_end, + // Hyperlinks + hyperlink_param_key, + hyperlink_param_value, + hyperlink_uri, + // Reset color palette index reset_color_palette_index, @@ -333,6 +349,7 @@ pub const Parser = struct { '4' => self.state = .@"4", '5' => self.state = .@"5", '7' => self.state = .@"7", + '8' => self.state = .@"8", '9' => self.state = .@"9", else => self.state = .invalid, }, @@ -556,6 +573,49 @@ pub const Parser = struct { else => self.state = .invalid, }, + .@"8" => switch (c) { + ';' => { + self.command = .{ .hyperlink_start = .{ + .uri = "", + } }; + + self.state = .hyperlink_param_key; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .hyperlink_param_key => switch (c) { + ';' => { + self.complete = true; + self.state = .hyperlink_uri; + self.buf_start = self.buf_idx; + }, + '=' => { + self.temp_state = .{ .key = self.buf[self.buf_start .. self.buf_idx - 1] }; + self.state = .hyperlink_param_value; + self.buf_start = self.buf_idx; + }, + else => {}, + }, + + .hyperlink_param_value => switch (c) { + ':' => { + self.endHyperlinkOptionValue(); + self.state = .hyperlink_param_key; + self.buf_start = self.buf_idx; + }, + ';' => { + self.endHyperlinkOptionValue(); + self.state = .string; + self.temp_state = .{ .str = &self.command.hyperlink_start.uri }; + self.buf_start = self.buf_idx; + }, + else => {}, + }, + + .hyperlink_uri => {}, + .rxvt_extension => switch (c) { 'a'...'z' => {}, ';' => { @@ -772,6 +832,40 @@ pub const Parser = struct { self.state = .allocable_string; } + fn endHyperlink(self: *Parser) void { + switch (self.command) { + .hyperlink_start => |*v| { + const value = self.buf[self.buf_start..self.buf_idx]; + if (v.id == null and value.len == 0) { + self.command = .{ .hyperlink_end = {} }; + return; + } + + v.uri = value; + }, + + else => unreachable, + } + } + + fn endHyperlinkOptionValue(self: *Parser) void { + const value = if (self.buf_start == self.buf_idx) + "" + else + self.buf[self.buf_start .. self.buf_idx - 1]; + + if (mem.eql(u8, self.temp_state.key, "id")) { + switch (self.command) { + .hyperlink_start => |*v| { + // We treat empty IDs as null ids so that we can + // auto-assign. + if (value.len > 0) v.id = value; + }, + else => {}, + } + } else log.info("unknown hyperlink option: {s}", .{self.temp_state.key}); + } + fn endSemanticOptionValue(self: *Parser) void { const value = self.buf[self.buf_start..self.buf_idx]; @@ -851,6 +945,7 @@ pub const Parser = struct { switch (self.state) { .semantic_exit_code => self.endSemanticExitCode(), .semantic_option_value => self.endSemanticOptionValue(), + .hyperlink_uri => self.endHyperlink(), .string => self.endString(), .allocable_string => self.endAllocableString(), else => {}, @@ -1272,3 +1367,110 @@ test "OSC: empty param" { const cmd = p.end('\x1b'); try testing.expect(cmd == null); } + +test "OSC: hyperlink" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "8;;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC: hyperlink with id set" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "8;id=foo;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqualStrings(cmd.hyperlink_start.id.?, "foo"); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC: hyperlink with empty id" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "8;id=;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqual(null, cmd.hyperlink_start.id); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC: hyperlink with incomplete key" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "8;id;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqual(null, cmd.hyperlink_start.id); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC: hyperlink with empty key" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "8;=value;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqual(null, cmd.hyperlink_start.id); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC: hyperlink with empty key and id" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "8;=value:id=foo;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqualStrings(cmd.hyperlink_start.id.?, "foo"); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC: hyperlink with empty uri" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "8;id=foo;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b'); + try testing.expect(cmd == null); +} + +test "OSC: hyperlink end" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "8;;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .hyperlink_end); +} diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 65334460f9..5b7c4a0086 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -7,6 +7,7 @@ const testing = std.testing; const posix = std.posix; const fastmem = @import("../fastmem.zig"); const color = @import("color.zig"); +const hyperlink = @import("hyperlink.zig"); const sgr = @import("sgr.zig"); const style = @import("style.zig"); const size = @import("size.zig"); @@ -34,6 +35,33 @@ const grapheme_count_default = GraphemeAlloc.bitmap_bit_size; const grapheme_bytes_default = grapheme_count_default * grapheme_chunk; const GraphemeMap = AutoOffsetHashMap(Offset(Cell), Offset(u21).Slice); +/// The allocator used for shared utf8-encoded strings within a page. +/// Note the chunk size below is the minimum size of a single allocation +/// and requires a single bit of metadata in our bitmap allocator. Therefore +/// it should be tuned carefully (too small and we waste metadata, too large +/// and we have fragmentation). We can probably use a better allocation +/// strategy in the future. +/// +/// At the time of writing this, the strings table is only used for OSC8 +/// IDs and URIs. IDs are usually short and URIs are usually longer. I chose +/// 32 bytes as a compromise between these two since it represents single +/// domain links quite well and is not too wasteful for short IDs. We can +/// continue to tune this as we see how it's used. +const string_chunk_len = 32; +const string_chunk = string_chunk_len * @sizeOf(u8); +const StringAlloc = BitmapAllocator(string_chunk); +const string_count_default = StringAlloc.bitmap_bit_size; +const string_bytes_default = string_count_default * string_chunk; + +/// Default number of hyperlinks we support. +/// +/// The cell multiplier is the number of cells per hyperlink entry that +/// we support. A hyperlink can be longer than this multiplier; the multiplier +/// just sets the total capacity to simplify adjustable size metrics. +const hyperlink_count_default = 4; +const hyperlink_bytes_default = hyperlink_count_default * @sizeOf(hyperlink.Set.Item); +const hyperlink_cell_multiplier = 16; + /// A page represents a specific section of terminal screen. The primary /// idea of a page is that it is a fully self-contained unit that can be /// serialized, copied, etc. as a convenient way to represent a section @@ -75,6 +103,11 @@ pub const Page = struct { /// first column, all cells in that row are laid out in column order. cells: Offset(Cell), + /// The string allocator for this page used for shared utf-8 encoded + /// strings. Liveness of strings and memory management is deferred to + /// the individual use case. + string_alloc: StringAlloc, + /// The multi-codepoint grapheme data for this page. This is where /// any cell that has more than one codepoint will be stored. This is /// relatively rare (typically only emoji) so this defaults to a very small @@ -91,6 +124,13 @@ pub const Page = struct { /// The available set of styles in use on this page. styles: style.Set, + /// The structures used for tracking hyperlinks within the page. + /// The map maps cell offsets to hyperlink IDs and the IDs are in + /// the ref counted set. The strings within the hyperlink structures + /// are allocated in the string allocator. + hyperlink_map: hyperlink.Map, + hyperlink_set: hyperlink.Set, + /// The offset to the first mask of dirty bits in the page. /// /// The dirty bits is a contiguous array of usize where each bit represents @@ -199,6 +239,10 @@ pub const Page = struct { l.styles_layout, .{}, ), + .string_alloc = StringAlloc.init( + buf.add(l.string_alloc_start), + l.string_alloc_layout, + ), .grapheme_alloc = GraphemeAlloc.init( buf.add(l.grapheme_alloc_start), l.grapheme_alloc_layout, @@ -207,6 +251,15 @@ pub const Page = struct { buf.add(l.grapheme_map_start), l.grapheme_map_layout, ), + .hyperlink_map = hyperlink.Map.init( + buf.add(l.hyperlink_map_start), + l.hyperlink_map_layout, + ), + .hyperlink_set = hyperlink.Set.init( + buf.add(l.hyperlink_set_start), + l.hyperlink_set_layout, + .{}, + ), .size = .{ .cols = cap.cols, .rows = cap.rows }, .capacity = cap, }; @@ -237,7 +290,6 @@ pub const Page = struct { MissingStyle, UnmarkedStyleRow, MismatchedStyleRef, - ZombieStyles, InvalidStyleCount, InvalidSpacerTailLocation, InvalidSpacerHeadLocation, @@ -452,14 +504,16 @@ pub const Page = struct { } } + // NOTE: This is currently disabled because @qwerasd says that + // certain fast paths can cause this but its okay. // Just 1 zombie style might be the cursor style, so ignore it. - if (zombies > 1) { - log.warn( - "page integrity violation zombie styles count={}", - .{zombies}, - ); - return IntegrityError.ZombieStyles; - } + // if (zombies > 1) { + // log.warn( + // "page integrity violation zombie styles count={}", + // .{zombies}, + // ); + // return IntegrityError.ZombieStyles; + // } } } @@ -563,6 +617,13 @@ pub const Page = struct { x_start: usize, x_end_req: usize, ) CloneFromError!void { + // This whole operation breaks integrity until the end. + self.pauseIntegrityChecks(true); + defer { + self.pauseIntegrityChecks(false); + self.assertIntegrity(); + } + const cell_len = @min(self.size.cols, other.size.cols); const x_end = @min(x_end_req, cell_len); assert(x_start <= x_end); @@ -571,9 +632,7 @@ pub const Page = struct { // If our destination has styles or graphemes then we need to // clear some state. - if (dst_row.grapheme or dst_row.styled) { - self.clearCells(dst_row, x_start, x_end); - } + if (dst_row.managedMemory()) self.clearCells(dst_row, x_start, x_end); // Copy all the row metadata but keep our cells offset dst_row.* = copy: { @@ -585,6 +644,7 @@ pub const Page = struct { copy.wrap = dst_row.wrap; copy.wrap_continuation = dst_row.wrap_continuation; copy.grapheme = dst_row.grapheme; + copy.hyperlink = dst_row.hyperlink; copy.styled = dst_row.styled; } @@ -596,7 +656,7 @@ pub const Page = struct { // If we have no managed memory in the source, then we can just // copy it directly. - if (!src_row.grapheme and !src_row.styled) { + if (!src_row.managedMemory()) { fastmem.copy(Cell, cells, other_cells); } else { // We have managed memory, so we have to do a slower copy to @@ -611,6 +671,26 @@ pub const Page = struct { const cps = other.lookupGrapheme(src_cell).?; for (cps) |cp| try self.appendGrapheme(dst_row, dst_cell, cp); } + if (src_cell.hyperlink) hyperlink: { + dst_row.hyperlink = true; + + // Fast-path: same page we can move it directly + if (other == self) { + self.moveHyperlink(src_cell, dst_cell); + break :hyperlink; + } + + // Slow-path: get the hyperlink from the other page, + // add it, and migrate. + const id = other.lookupHyperlink(src_cell).?; + const other_link = other.hyperlink_set.get(other.memory, id); + const dst_id = try self.hyperlink_set.addContext( + self.memory, + try other_link.dupe(other, self), + .{ .page = self }, + ); + try self.setHyperlink(dst_row, dst_cell, dst_id); + } if (src_cell.style_id != style.default_id) { dst_row.styled = true; @@ -624,8 +704,12 @@ pub const Page = struct { // Slow path: Get the style from the other // page and add it to this page's style set. - const other_style = other.styles.get(other.memory, src_cell.style_id).*; - if (try self.styles.addWithId(self.memory, other_style, src_cell.style_id)) |id| { + const other_style = other.styles.get(other.memory, src_cell.style_id); + if (try self.styles.addWithId( + self.memory, + other_style.*, + src_cell.style_id, + )) |id| { dst_cell.style_id = id; } } @@ -640,9 +724,6 @@ pub const Page = struct { last.wide = .narrow; } } - - // The final page should remain consistent - self.assertIntegrity(); } /// Get a single row. y must be valid. @@ -698,31 +779,28 @@ pub const Page = struct { // Clear our destination now matter what self.clearCells(dst_row, dst_left, dst_left + len); - // If src has no graphemes, this is very fast because we can - // just copy the cells directly because every other attribute - // is position-independent. - const src_grapheme = src_row.grapheme or grapheme: { - for (src_cells) |c| if (c.hasGrapheme()) break :grapheme true; - break :grapheme false; - }; - if (!src_grapheme) { + // If src has no managed memory, this is very fast. + if (!src_row.managedMemory()) { fastmem.copy(Cell, dst_cells, src_cells); } else { - // Source has graphemes, meaning we have to do a slower - // cell by cell copy. + // Source has graphemes or hyperlinks... for (src_cells, dst_cells) |*src, *dst| { dst.* = src.*; - if (!src.hasGrapheme()) continue; - - // Required for moveGrapheme assertions - dst.content_tag = .codepoint; - self.moveGrapheme(src, dst); - src.content_tag = .codepoint; - dst.content_tag = .codepoint_grapheme; + if (src.hasGrapheme()) { + // Required for moveGrapheme assertions + dst.content_tag = .codepoint; + self.moveGrapheme(src, dst); + src.content_tag = .codepoint; + dst.content_tag = .codepoint_grapheme; + dst_row.grapheme = true; + } + if (src.hyperlink) { + dst.hyperlink = false; + self.moveHyperlink(src, dst); + dst.hyperlink = true; + dst_row.hyperlink = true; + } } - - // The destination row must be marked - dst_row.grapheme = true; } // The destination row has styles if any of the cells are styled @@ -739,6 +817,7 @@ pub const Page = struct { @memset(@as([]u64, @ptrCast(src_cells)), 0); if (src_cells.len == self.size.cols) { src_row.grapheme = false; + src_row.hyperlink = false; src_row.styled = false; } } @@ -772,6 +851,26 @@ pub const Page = struct { } } + // Hyperlinks are keyed by cell offset. + if (src.hyperlink or dst.hyperlink) { + if (src.hyperlink and !dst.hyperlink) { + self.moveHyperlink(src, dst); + } else if (!src.hyperlink and dst.hyperlink) { + self.moveHyperlink(dst, src); + } else { + // Both had hyperlinks, so we have to manually swap + const src_offset = getOffset(Cell, self.memory, src); + const dst_offset = getOffset(Cell, self.memory, dst); + var map = self.hyperlink_map.map(self.memory); + const src_entry = map.getEntry(src_offset).?; + const dst_entry = map.getEntry(dst_offset).?; + const src_value = src_entry.value_ptr.*; + const dst_value = dst_entry.value_ptr.*; + src_entry.value_ptr.* = dst_value; + dst_entry.value_ptr.* = src_value; + } + } + // Copy the metadata. Note that we do NOT have to worry about // styles because styles are keyed by ID and we're preserving the // exact ref count and row state here. @@ -794,12 +893,19 @@ pub const Page = struct { defer self.assertIntegrity(); const cells = row.cells.ptr(self.memory)[left..end]; + if (row.grapheme) { for (cells) |*cell| { if (cell.hasGrapheme()) self.clearGrapheme(row, cell); } } + if (row.hyperlink) { + for (cells) |*cell| { + if (cell.hyperlink) self.clearHyperlink(row, cell); + } + } + if (row.styled) { for (cells) |*cell| { if (cell.style_id == style.default_id) continue; @@ -815,6 +921,77 @@ pub const Page = struct { @memset(@as([]u64, @ptrCast(cells)), 0); } + /// Returns the hyperlink ID for the given cell. + pub fn lookupHyperlink(self: *const Page, cell: *const Cell) ?hyperlink.Id { + const cell_offset = getOffset(Cell, self.memory, cell); + const map = self.hyperlink_map.map(self.memory); + return map.get(cell_offset); + } + + /// Clear the hyperlink from the given cell. + pub fn clearHyperlink(self: *Page, row: *Row, cell: *Cell) void { + defer self.assertIntegrity(); + + // Get our ID + const cell_offset = getOffset(Cell, self.memory, cell); + var map = self.hyperlink_map.map(self.memory); + const entry = map.getEntry(cell_offset) orelse return; + + // Release our usage of this, free memory, unset flag + self.hyperlink_set.release(self.memory, entry.value_ptr.*); + map.removeByPtr(entry.key_ptr); + cell.hyperlink = false; + + // Mark that we no longer have graphemes, also search the row + // to make sure its state is correct. + const cells = row.cells.ptr(self.memory)[0..self.size.cols]; + for (cells) |c| if (c.hyperlink) return; + row.hyperlink = false; + } + + /// Set the hyperlink for the given cell. If the cell already has a + /// hyperlink, then this will handle memory management for the prior + /// hyperlink. + pub fn setHyperlink(self: *Page, row: *Row, cell: *Cell, id: hyperlink.Id) !void { + defer self.assertIntegrity(); + + const cell_offset = getOffset(Cell, self.memory, cell); + var map = self.hyperlink_map.map(self.memory); + const gop = try map.getOrPut(cell_offset); + + if (gop.found_existing) { + // If the hyperlink matches then we don't need to do anything. + if (gop.value_ptr.* == id) return; + + // Different hyperlink, we need to release the old one + self.hyperlink_set.release(self.memory, gop.value_ptr.*); + } + + // Increase ref count for our new hyperlink and set it + self.hyperlink_set.use(self.memory, id); + gop.value_ptr.* = id; + cell.hyperlink = true; + row.hyperlink = true; + } + + /// Move the hyperlink from one cell to another. This can't fail + /// because we avoid any allocations since we're just moving data. + /// Destination must NOT have a hyperlink. + fn moveHyperlink(self: *Page, src: *Cell, dst: *Cell) void { + if (comptime std.debug.runtime_safety) { + assert(src.hyperlink); + assert(!dst.hyperlink); + } + + const src_offset = getOffset(Cell, self.memory, src); + const dst_offset = getOffset(Cell, self.memory, dst); + var map = self.hyperlink_map.map(self.memory); + const entry = map.getEntry(src_offset).?; + const value = entry.value_ptr.*; + map.removeByPtr(entry.key_ptr); + map.putAssumeCapacity(dst_offset, value); + } + /// Append a codepoint to the given cell as a grapheme. pub fn appendGrapheme(self: *Page, row: *Row, cell: *Cell, cp: u21) Allocator.Error!void { defer self.assertIntegrity(); @@ -977,6 +1154,12 @@ pub const Page = struct { grapheme_alloc_layout: GraphemeAlloc.Layout, grapheme_map_start: usize, grapheme_map_layout: GraphemeMap.Layout, + string_alloc_start: usize, + string_alloc_layout: StringAlloc.Layout, + hyperlink_map_start: usize, + hyperlink_map_layout: hyperlink.Map.Layout, + hyperlink_set_start: usize, + hyperlink_set_layout: hyperlink.Set.Layout, capacity: Capacity, }; @@ -1015,7 +1198,28 @@ pub const Page = struct { const grapheme_map_start = alignForward(usize, grapheme_alloc_end, GraphemeMap.base_align); const grapheme_map_end = grapheme_map_start + grapheme_map_layout.total_size; - const total_size = alignForward(usize, grapheme_map_end, std.mem.page_size); + const string_layout = StringAlloc.layout(cap.string_bytes); + const string_start = alignForward(usize, grapheme_map_end, StringAlloc.base_align); + const string_end = string_start + string_layout.total_size; + + const hyperlink_count = @divFloor(cap.hyperlink_bytes, @sizeOf(hyperlink.Set.Item)); + const hyperlink_set_layout = hyperlink.Set.layout(@intCast(hyperlink_count)); + const hyperlink_set_start = alignForward(usize, string_end, hyperlink.Set.base_align); + const hyperlink_set_end = hyperlink_set_start + hyperlink_set_layout.total_size; + + const hyperlink_map_count: u32 = count: { + if (hyperlink_count == 0) break :count 0; + const mult = std.math.cast( + u32, + hyperlink_count * hyperlink_cell_multiplier, + ) orelse break :count std.math.maxInt(u32); + break :count std.math.ceilPowerOfTwoAssert(u32, mult); + }; + const hyperlink_map_layout = hyperlink.Map.layout(hyperlink_map_count); + const hyperlink_map_start = alignForward(usize, hyperlink_set_end, hyperlink.Map.base_align); + const hyperlink_map_end = hyperlink_map_start + hyperlink_map_layout.total_size; + + const total_size = alignForward(usize, hyperlink_map_end, std.mem.page_size); return .{ .total_size = total_size, @@ -1031,6 +1235,12 @@ pub const Page = struct { .grapheme_alloc_layout = grapheme_alloc_layout, .grapheme_map_start = grapheme_map_start, .grapheme_map_layout = grapheme_map_layout, + .string_alloc_start = string_start, + .string_alloc_layout = string_layout, + .hyperlink_map_start = hyperlink_map_start, + .hyperlink_map_layout = hyperlink_map_layout, + .hyperlink_set_start = hyperlink_set_start, + .hyperlink_set_layout = hyperlink_set_layout, .capacity = cap, }; } @@ -1038,7 +1248,9 @@ pub const Page = struct { /// The standard capacity for a page that doesn't have special /// requirements. This is enough to support a very large number of cells. -/// The standard capacity is chosen as the fast-path for allocation. +/// The standard capacity is chosen as the fast-path for allocation since +/// pages of standard capacity use a pooled allocator instead of single-use +/// mmaps. pub const std_capacity: Capacity = .{ .cols = 215, .rows = 215, @@ -1061,9 +1273,18 @@ pub const Capacity = struct { /// Number of unique styles that can be used on this page. styles: usize = 16, + /// Number of bytes to allocate for hyperlink data. Note that the + /// amount of data used for hyperlinks in total is more than this because + /// hyperlinks use string data as well as a small amount of lookup metadata. + /// This number is a rough approximation. + hyperlink_bytes: usize = hyperlink_bytes_default, + /// Number of bytes to allocate for grapheme data. grapheme_bytes: usize = grapheme_bytes_default, + /// Number of bytes to allocate for strings. + string_bytes: usize = string_bytes_default, + pub const Adjustment = struct { cols: ?size.CellCountInt = null, }; @@ -1089,7 +1310,10 @@ pub const Capacity = struct { // for rows & cells (which will allow us to calculate the number of // rows we can fit at a certain column width) we need to layout the // "meta" members of the page (i.e. everything else) from the end. - const grapheme_map_start = alignBackward(usize, layout.total_size - layout.grapheme_map_layout.total_size, GraphemeMap.base_align); + const hyperlink_map_start = alignBackward(usize, layout.total_size - layout.hyperlink_map_layout.total_size, hyperlink.Map.base_align); + const hyperlink_set_start = alignBackward(usize, hyperlink_map_start - layout.hyperlink_set_layout.total_size, hyperlink.Set.base_align); + const string_alloc_start = alignBackward(usize, hyperlink_set_start - layout.string_alloc_layout.total_size, StringAlloc.base_align); + const grapheme_map_start = alignBackward(usize, string_alloc_start - layout.grapheme_map_layout.total_size, GraphemeMap.base_align); const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - layout.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align); const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, style.Set.base_align); @@ -1148,11 +1372,16 @@ pub const Row = packed struct(u64) { /// At the time of writing this, the speed difference is around 4x. styled: bool = false, + /// True if any of the cells in this row are part of a hyperlink. + /// This is similar to styled: it can have false positives but never + /// false negatives. This is used to optimize hyperlink operations. + hyperlink: bool = false, + /// The semantic prompt type for this row as specified by the /// running program, or "unknown" if it was never set. semantic_prompt: SemanticPrompt = .unknown, - _padding: u25 = 0, + _padding: u24 = 0, /// Semantic prompt type. pub const SemanticPrompt = enum(u3) { @@ -1176,6 +1405,12 @@ pub const Row = packed struct(u64) { return self == .prompt or self == .prompt_continuation or self == .input; } }; + + /// Returns true if this row has any managed memory outside of the + /// row structure (graphemes, styles, etc.) + fn managedMemory(self: Row) bool { + return self.grapheme or self.styled or self.hyperlink; + } }; /// A cell represents a single terminal grid cell. @@ -1212,7 +1447,12 @@ pub const Cell = packed struct(u64) { /// Whether this was written with the protection flag set. protected: bool = false, - _padding: u19 = 0, + /// Whether this cell is a hyperlink. If this is true then you must + /// look up the hyperlink ID in the page hyperlink_map and the ID in + /// the hyperlink_set to get the actual hyperlink data. + hyperlink: bool = false, + + _padding: u18 = 0, pub const ContentTag = enum(u2) { /// A single codepoint, could be zero to be empty cell. diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index c6cf12db52..5fe5fa5424 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -141,6 +141,16 @@ pub fn RefCountedSet( assert(cap <= @as(usize, @intCast(std.math.maxInt(Id))) + 1); + // Zero-cap set is valid, return special case + if (cap == 0) return .{ + .cap = 0, + .table_cap = 0, + .table_mask = 0, + .table_start = 0, + .items_start = 0, + .total_size = 0, + }; + const table_cap: usize = std.math.ceilPowerOfTwoAssert(usize, cap); const items_cap: usize = @intFromFloat(load_factor * @as(f64, @floatFromInt(table_cap))); @@ -205,15 +215,28 @@ pub fn RefCountedSet( /// /// If the set has no more room, then an OutOfMemory error is returned. pub fn add(self: *Self, base: anytype, value: T) AddError!Id { + return try self.addContext(base, value, self.context); + } + pub fn addContext(self: *Self, base: anytype, value: T, ctx: Context) AddError!Id { const items = self.items.ptr(base); // Trim dead items from the end of the list. while (self.next_id > 1 and items[self.next_id - 1].meta.ref == 0) { self.next_id -= 1; - self.deleteItem(base, self.next_id); + self.deleteItem(base, self.next_id, ctx); } - // If we still don't have an available ID, we can't continue. + // If the item already exists, return it. + if (self.lookup(base, value, ctx)) |id| { + // Notify the context that the value is "deleted" because + // we're reusing the existing value in the set. This allows + // callers to clean up any resources associated with the value. + if (comptime @hasDecl(Context, "deleted")) ctx.deleted(value); + items[id].meta.ref += 1; + return id; + } + + // If the item doesn't exist, we need an available ID. if (self.next_id >= self.layout.cap) { // Arbitrarily chosen, threshold for rehashing. // If less than 90% of currently allocated IDs @@ -232,15 +255,15 @@ pub fn RefCountedSet( return AddError.OutOfMemory; } - const id = self.upsert(base, value, self.next_id); + const id = self.insert(base, value, self.next_id, ctx); items[id].meta.ref += 1; + assert(items[id].meta.ref == 1); + self.living += 1; + // Its possible insert returns a different ID by reusing a + // dead item so we only need to update next id if we used it. if (id == self.next_id) self.next_id += 1; - if (items[id].meta.ref == 1) { - self.living += 1; - } - return id; } @@ -251,27 +274,30 @@ pub fn RefCountedSet( /// /// If the set has no more room, then an OutOfMemory error is returned. pub fn addWithId(self: *Self, base: anytype, value: T, id: Id) AddError!?Id { + return try self.addWithIdContext(base, value, id, self.context); + } + pub fn addWithIdContext(self: *Self, base: anytype, value: T, id: Id, ctx: Context) AddError!?Id { const items = self.items.ptr(base); if (id < self.next_id) { if (items[id].meta.ref == 0) { - self.deleteItem(base, id); + self.deleteItem(base, id, ctx); - const added_id = self.upsert(base, value, id); + const added_id = self.upsert(base, value, id, ctx); items[added_id].meta.ref += 1; self.living += 1; return if (added_id == id) null else added_id; - } else if (self.context.eql(value, items[id].value)) { + } else if (ctx.eql(value, items[id].value)) { items[id].meta.ref += 1; return null; } } - return try self.add(base, value); + return try self.addContext(base, value, ctx); } /// Increment an item's reference count by 1. @@ -377,7 +403,7 @@ pub fn RefCountedSet( /// Delete an item, removing any references from /// the table, and freeing its ID to be re-used. - fn deleteItem(self: *Self, base: anytype, id: Id) void { + fn deleteItem(self: *Self, base: anytype, id: Id, ctx: Context) void { const table = self.table.ptr(base); const items = self.items.ptr(base); @@ -390,7 +416,7 @@ pub fn RefCountedSet( if (comptime @hasDecl(Context, "deleted")) { // Inform the context struct that we're // deleting the dead item's value for good. - self.context.deleted(item.value); + ctx.deleted(item.value); } self.psl_stats[item.meta.psl] -= 1; @@ -419,11 +445,11 @@ pub fn RefCountedSet( /// Find an item in the table and return its ID. /// If the item does not exist in the table, null is returned. - fn lookup(self: *Self, base: anytype, value: T) ?Id { + fn lookup(self: *Self, base: anytype, value: T, ctx: Context) ?Id { const table = self.table.ptr(base); const items = self.items.ptr(base); - const hash: u64 = self.context.hash(value); + const hash: u64 = ctx.hash(value); for (0..self.max_psl + 1) |i| { const p: usize = @intCast((hash + i) & self.layout.table_mask); @@ -455,7 +481,7 @@ pub fn RefCountedSet( // If the item is a part of the same probe sequence, // we check if it matches the value we're looking for. if (item.meta.psl == i and - self.context.eql(value, item.value)) + ctx.eql(value, item.value)) { return id; } @@ -468,9 +494,23 @@ pub fn RefCountedSet( /// for it if not present. If a new item is added, `new_id` will /// be used as the ID. If an existing item is found, the `new_id` /// is ignored and the existing item's ID is returned. - fn upsert(self: *Self, base: anytype, value: T, new_id: Id) Id { + fn upsert(self: *Self, base: anytype, value: T, new_id: Id, ctx: Context) Id { // If the item already exists, return it. - if (self.lookup(base, value)) |id| return id; + if (self.lookup(base, value, ctx)) |id| { + // Notify the context that the value is "deleted" because + // we're reusing the existing value in the set. This allows + // callers to clean up any resources associated with the value. + if (comptime @hasDecl(Context, "deleted")) ctx.deleted(value); + return id; + } + + return self.insert(base, value, new_id, ctx); + } + + /// Insert the given value into the hash table with the given ID. + /// asserts that the value is not already present in the table. + fn insert(self: *Self, base: anytype, value: T, new_id: Id, ctx: Context) Id { + assert(self.lookup(base, value, ctx) == null); const table = self.table.ptr(base); const items = self.items.ptr(base); @@ -481,7 +521,7 @@ pub fn RefCountedSet( .meta = .{ .psl = 0, .ref = 0 }, }; - const hash: u64 = self.context.hash(value); + const hash: u64 = ctx.hash(value); var held_id: Id = new_id; var held_item: *Item = &new_item; @@ -510,7 +550,7 @@ pub fn RefCountedSet( if (comptime @hasDecl(Context, "deleted")) { // Inform the context struct that we're // deleting the dead item's value for good. - self.context.deleted(item.value); + ctx.deleted(item.value); } chosen_id = id; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 01e027ec27..6706ce1d10 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1333,6 +1333,20 @@ pub fn Stream(comptime Handler: type) type { return; } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, + + .hyperlink_start => |v| { + if (@hasDecl(T, "startHyperlink")) { + try self.handler.startHyperlink(v.uri, v.id); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .hyperlink_end => { + if (@hasDecl(T, "endHyperlink")) { + try self.handler.endHyperlink(); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, } // Fall through for when we don't have a handler. diff --git a/src/terminal/style.zig b/src/terminal/style.zig index 813411a735..430fca2145 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -6,12 +6,11 @@ const page = @import("page.zig"); const size = @import("size.zig"); const Offset = size.Offset; const OffsetBuf = size.OffsetBuf; +const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet; const Wyhash = std.hash.Wyhash; const autoHash = std.hash.autoHash; -const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet; - /// The unique identifier for a style. This is at most the number of cells /// that can fit into a terminal page. pub const Id = size.CellCountInt; diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index aeff1f0dd6..8c6212554d 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -2358,6 +2358,14 @@ const StreamHandler = struct { } } + pub fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { + try self.terminal.screen.startHyperlink(uri, id); + } + + pub fn endHyperlink(self: *StreamHandler) !void { + self.terminal.screen.endHyperlink(); + } + pub fn deviceAttributes( self: *StreamHandler, req: terminal.DeviceAttributeReq,