From 58ccfb558a05e2411c80256448a3c86b8991ac10 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 21 Jul 2025 11:00:49 -0700 Subject: [PATCH] apprt/gtk-ng: mouse over link to show tooltip --- src/Surface.zig | 4 +- src/apprt/action.zig | 2 +- src/apprt/gtk-ng/class/application.zig | 21 ++++- src/apprt/gtk-ng/class/surface.zig | 108 +++++++++++++++++++++++++ src/apprt/gtk-ng/css/style.css | 19 ++++- src/apprt/gtk-ng/ui/1.2/surface.blp | 44 ++++++++-- 6 files changed, 183 insertions(+), 15 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index a9a7e239f..1ab0fc59e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1316,9 +1316,7 @@ fn mouseRefreshLinks( break :link .{ null, false }; }; break :link .{ - .{ - .url = uri, - }, + .{ .url = try alloc.dupeZ(u8, uri) }, self.config.link_previews != .false, }; }, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index da97fc04b..6f745c080 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -507,7 +507,7 @@ pub const MouseVisibility = enum(c_int) { }; pub const MouseOverLink = struct { - url: []const u8, + url: [:0]const u8, // Sync with: ghostty_action_mouse_over_link_s pub const C = extern struct { diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index b7220c375..f62f346cf 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -463,6 +463,7 @@ pub const Application = extern struct { value.config, ), + .mouse_over_link => Action.mouseOverLink(target, value), .mouse_shape => Action.mouseShape(target, value), .mouse_visibility => Action.mouseVisibility(target, value), @@ -505,7 +506,6 @@ pub const Application = extern struct { .present_terminal, .initial_size, .size_limit, - .mouse_over_link, .toggle_tab_overview, .toggle_split_zoom, .toggle_window_decorations, @@ -1015,6 +1015,25 @@ const Action = struct { } } + pub fn mouseOverLink( + target: apprt.Target, + value: apprt.action.MouseOverLink, + ) void { + switch (target) { + .app => log.warn("mouse over link to app is unexpected", .{}), + .surface => |surface| { + var v = gobject.ext.Value.new([:0]const u8); + if (value.url.len > 0) gobject.ext.Value.set(&v, value.url); + defer v.unset(); + gobject.Object.setProperty( + surface.rt_surface.gobj().as(gobject.Object), + "mouse-hover-url", + &v, + ); + }, + } + } + pub fn mouseShape( target: apprt.Target, shape: terminal.MouseShape, diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 535087d9a..6231241bb 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -94,6 +94,23 @@ pub const Surface = extern struct { ); }; + pub const @"mouse-hover-url" = struct { + pub const name = "mouse-hover-url"; + pub const get = impl.get; + pub const set = impl.set; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .nick = "Mouse Hover URL", + .blurb = "The URL the mouse is currently hovering over (if any).", + .default = null, + .accessor = C.privateStringFieldAccessor("mouse_hover_url"), + }, + ); + }; + pub const pwd = struct { pub const name = "pwd"; pub const get = impl.get; @@ -162,6 +179,9 @@ pub const Surface = extern struct { /// Whether the mouse should be hidden or not as requested externally. mouse_hidden: bool = false, + /// The URL that the mouse is currently hovering over. + mouse_hover_url: ?[:0]const u8 = null, + /// The current working directory. This has to be reported externally, /// usually by shell integration which then talks to libghostty /// which triggers this property. @@ -170,10 +190,18 @@ pub const Surface = extern struct { /// The title of this surface, if any has been set. title: ?[:0]const u8 = null, + /// The overlay we use for things such as the URL hover label + /// or resize box. Bound from the template. + overlay: *gtk.Overlay = undefined, + /// The GLAarea that renders the actual surface. This is a binding /// to the template so it doesn't have to be unrefed manually. gl_area: *gtk.GLArea = undefined, + /// The labels for the left/right sides of the URL hover tooltip. + url_left: *gtk.Label = undefined, + url_right: *gtk.Label = undefined, + /// The apprt Surface. rt_surface: ApprtSurface = undefined, @@ -809,6 +837,13 @@ pub const Surface = extern struct { ); // Some property signals + _ = gobject.Object.signals.notify.connect( + self, + ?*anyopaque, + &propMouseHoverUrl, + null, + .{ .detail = "mouse-hover-url" }, + ); _ = gobject.Object.signals.notify.connect( self, ?*anyopaque, @@ -823,6 +858,41 @@ pub const Surface = extern struct { null, .{ .detail = "mouse-shape" }, ); + + // Some other initialization steps + self.initUrlOverlay(); + } + + fn initUrlOverlay(self: *Self) void { + const priv = self.private(); + const overlay = priv.overlay; + const url_left = priv.url_left.as(gtk.Widget); + const url_right = priv.url_right.as(gtk.Widget); + + // Add the url label to the overlay + overlay.addOverlay(url_left); + overlay.addOverlay(url_right); + + // Setup a motion controller to handle moving the label + // to avoid the mouse. + const ec_motion = gtk.EventControllerMotion.new(); + errdefer ec_motion.unref(); + url_left.addController(ec_motion.as(gtk.EventController)); + errdefer url_left.removeController(ec_motion.as(gtk.EventController)); + _ = gtk.EventControllerMotion.signals.enter.connect( + ec_motion, + *Self, + ecUrlMouseEnter, + self, + .{}, + ); + _ = gtk.EventControllerMotion.signals.leave.connect( + ec_motion, + *Self, + ecUrlMouseLeave, + self, + .{}, + ); } fn dispose(self: *Self) callconv(.C) void { @@ -863,6 +933,10 @@ pub const Surface = extern struct { priv.core_surface = null; } + if (priv.mouse_hover_url) |v| { + glib.free(@constCast(@ptrCast(v))); + priv.mouse_hover_url = null; + } if (priv.pwd) |v| { glib.free(@constCast(@ptrCast(v))); priv.pwd = null; @@ -886,6 +960,16 @@ pub const Surface = extern struct { return self.private().title; } + fn propMouseHoverUrl( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + const visible = if (priv.mouse_hover_url) |v| v.len > 0 else false; + priv.url_left.as(gtk.Widget).setVisible(if (visible) 1 else 0); + } + fn propMouseHidden( self: *Self, _: *gobject.ParamSpec, @@ -1539,6 +1623,26 @@ pub const Surface = extern struct { priv.core_surface = surface; } + fn ecUrlMouseEnter( + _: *gtk.EventControllerMotion, + _: f64, + _: f64, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + const right = priv.url_right.as(gtk.Widget); + right.setVisible(1); + } + + fn ecUrlMouseLeave( + _: *gtk.EventControllerMotion, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + const right = priv.url_right.as(gtk.Widget); + right.setVisible(0); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -1561,13 +1665,17 @@ pub const Surface = extern struct { ); // Bindings + class.bindTemplateChildPrivate("overlay", .{}); class.bindTemplateChildPrivate("gl_area", .{}); + class.bindTemplateChildPrivate("url_left", .{}); + class.bindTemplateChildPrivate("url_right", .{}); // Properties gobject.ext.registerProperties(class, &.{ properties.config.impl, properties.@"mouse-shape".impl, properties.@"mouse-hidden".impl, + properties.@"mouse-hover-url".impl, properties.pwd.impl, properties.title.impl, }); diff --git a/src/apprt/gtk-ng/css/style.css b/src/apprt/gtk-ng/css/style.css index a158d790e..e26b4419b 100644 --- a/src/apprt/gtk-ng/css/style.css +++ b/src/apprt/gtk-ng/css/style.css @@ -4,6 +4,21 @@ * https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1.3/styles-and-appearance.html#custom-styles */ -label { - color: red; +label.url-overlay { + padding: 4px 8px 4px 8px; + outline-style: solid; + outline-color: #555555; + outline-width: 1px; +} + +label.url-overlay:hover { + opacity: 0; +} + +label.url-overlay.left { + border-radius: 0px 6px 0px 0px; +} + +label.url-overlay.right { + border-radius: 6px 0px 0px 0px; } diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index 41ab0ee1e..d08c21901 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -2,15 +2,43 @@ using Gtk 4.0; using Adw 1; template $GhosttySurface: Adw.Bin { - Overlay { - focusable: false; - focus-on-click: false; + // We need to wrap our Overlay one more time because if you bind a + // direct child of your widget to a property, it will double free: + // https://gitlab.gnome.org/GNOME/gtk/-/blob/847571a1e314aba79260e4ef282e2ed9ba91a0d9/gtk/gtkwidget.c#L11423-11425 + Adw.Bin { + Overlay overlay { + focusable: false; + focus-on-click: false; - GLArea gl_area { - hexpand: true; - vexpand: true; - focusable: true; - focus-on-click: true; + GLArea gl_area { + hexpand: true; + vexpand: true; + focusable: true; + focus-on-click: true; + } } } } + +// The label that shows the currently hovered URL. +Label url_left { + styles [ + "url-overlay", + ] + + visible: false; + halign: start; + valign: end; + label: bind template.mouse-hover-url; +} + +Label url_right { + styles [ + "url-overlay", + ] + + visible: false; + halign: end; + valign: end; + label: bind template.mouse-hover-url; +}