mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-16 16:56:09 +03:00
@ -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;
|
||||
|
||||
//-------------------------------------------------------------------
|
||||
|
@ -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<CChar>?, body: UnsafePointer<CChar>?) {}
|
||||
static func updateRendererHealth(_ userdata: UnsafeMutableRawPointer?, health: ghostty_renderer_health_e) {}
|
||||
static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer<CChar>?, len: Int) {}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
@ -523,6 +525,17 @@ extension Ghostty {
|
||||
let backingSize = NSSize(width: Double(width), height: Double(height))
|
||||
surfaceView.cellSize = surfaceView.convertFromBacking(backingSize)
|
||||
}
|
||||
|
||||
static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer<CChar>?, len: Int) {
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
guard len > 0 else {
|
||||
surfaceView.hoverUrl = nil
|
||||
return
|
||||
}
|
||||
|
||||
let buffer = Data(bytes: uri!, count: len)
|
||||
surfaceView.hoverUrl = String(data: buffer, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?, body: UnsafePointer<CChar>?) {
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
|
@ -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)
|
||||
|
@ -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.
|
||||
|
@ -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) {
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
||||
// We grab the floating reference to GL area. This lets the
|
||||
// GL area be moved around i.e. between a split, a tab, etc.
|
||||
// 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 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.
|
||||
|
@ -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") {
|
||||
|
@ -0,0 +1,11 @@
|
||||
label.url-overlay {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
label.url-overlay:hover {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
label.url-overlay.hidden {
|
||||
opacity: 0;
|
||||
}
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
} }).?));
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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");
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
159
src/terminal/hyperlink.zig
Normal file
159
src/terminal/hyperlink.zig
Normal file
@ -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],
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
@ -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");
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
Reference in New Issue
Block a user