apprt/gtk-ng: hovered url overlay (#8010)

I was going to do more overlays but this introduced enough new
complexity that I want to PR this on its own.

Valgrind clean!
This commit is contained in:
Mitchell Hashimoto
2025-07-21 14:02:10 -07:00
committed by GitHub
7 changed files with 242 additions and 13 deletions

View File

@ -1316,9 +1316,7 @@ fn mouseRefreshLinks(
break :link .{ null, false }; break :link .{ null, false };
}; };
break :link .{ break :link .{
.{ .{ .url = try alloc.dupeZ(u8, uri) },
.url = uri,
},
self.config.link_previews != .false, self.config.link_previews != .false,
}; };
}, },

View File

@ -507,7 +507,7 @@ pub const MouseVisibility = enum(c_int) {
}; };
pub const MouseOverLink = struct { pub const MouseOverLink = struct {
url: []const u8, url: [:0]const u8,
// Sync with: ghostty_action_mouse_over_link_s // Sync with: ghostty_action_mouse_over_link_s
pub const C = extern struct { pub const C = extern struct {

View File

@ -16,6 +16,9 @@ pub const app_id = "com.mitchellh.ghostty";
/// minimum adwaita version. /// minimum adwaita version.
pub const ui_path = "src/apprt/gtk-ng/ui"; pub const ui_path = "src/apprt/gtk-ng/ui";
/// The path to the CSS files.
pub const css_path = "src/apprt/gtk-ng/css";
/// The possible icon sizes we'll embed into the gresource file. /// The possible icon sizes we'll embed into the gresource file.
/// If any size doesn't exist then it will be an error. We could /// If any size doesn't exist then it will be an error. We could
/// infer this completely from available files but we wouldn't be /// infer this completely from available files but we wouldn't be
@ -36,6 +39,14 @@ pub const blueprints: []const Blueprint = &.{
.{ .major = 1, .minor = 5, .name = "window" }, .{ .major = 1, .minor = 5, .name = "window" },
}; };
/// CSS files in css_path
pub const css = [_][]const u8{
"style.css",
// "style-dark.css",
// "style-hc.css",
// "style-hc-dark.css",
};
pub const Blueprint = struct { pub const Blueprint = struct {
major: u16, major: u16,
minor: u16, minor: u16,
@ -45,7 +56,7 @@ pub const Blueprint = struct {
/// The list of filepaths that we depend on. Used for the build /// The list of filepaths that we depend on. Used for the build
/// system to have proper caching. /// system to have proper caching.
pub const file_inputs = deps: { pub const file_inputs = deps: {
const total = (icon_sizes.len * 2) + blueprints.len; const total = (icon_sizes.len * 2) + blueprints.len + css.len;
var deps: [total][]const u8 = undefined; var deps: [total][]const u8 = undefined;
var index: usize = 0; var index: usize = 0;
for (icon_sizes) |size| { for (icon_sizes) |size| {
@ -62,6 +73,10 @@ pub const file_inputs = deps: {
}); });
index += 1; index += 1;
} }
for (css) |name| {
deps[index] = std.fmt.comptimePrint("{s}/{s}", .{ css_path, name });
index += 1;
}
break :deps deps; break :deps deps;
}; };
@ -120,6 +135,7 @@ pub fn main() !void {
\\ \\
); );
try genRoot(writer);
try genIcons(writer); try genIcons(writer);
try genUi(alloc, writer, &ui_files); try genUi(alloc, writer, &ui_files);
@ -173,6 +189,34 @@ fn genIcons(writer: anytype) !void {
); );
} }
/// Generate the resources at the root prefix.
fn genRoot(writer: anytype) !void {
try writer.print(
\\ <gresource prefix="{s}">
\\
, .{prefix});
const cwd = std.fs.cwd();
inline for (css) |name| {
const source = std.fmt.comptimePrint(
"{s}/{s}",
.{ css_path, name },
);
try cwd.access(source, .{});
try writer.print(
\\ <file compressed="true" alias="{s}">{s}</file>
\\
,
.{ name, source },
);
}
try writer.writeAll(
\\ </gresource>
\\
);
}
/// Generate all the UI resources. This works by looking up all the /// Generate all the UI resources. This works by looking up all the
/// blueprint files in `${ui_path}/{major}.{minor}/{name}.blp` and /// blueprint files in `${ui_path}/{major}.{minor}/{name}.blp` and
/// assuming these will be /// assuming these will be

View File

@ -463,6 +463,7 @@ pub const Application = extern struct {
value.config, value.config,
), ),
.mouse_over_link => Action.mouseOverLink(target, value),
.mouse_shape => Action.mouseShape(target, value), .mouse_shape => Action.mouseShape(target, value),
.mouse_visibility => Action.mouseVisibility(target, value), .mouse_visibility => Action.mouseVisibility(target, value),
@ -484,6 +485,8 @@ pub const Application = extern struct {
.set_title => Action.setTitle(target, value), .set_title => Action.setTitle(target, value),
.show_gtk_inspector => Action.showGtkInspector(),
// Unimplemented but todo on gtk-ng branch // Unimplemented but todo on gtk-ng branch
.close_window, .close_window,
.toggle_maximize, .toggle_maximize,
@ -499,12 +502,10 @@ pub const Application = extern struct {
.open_config, .open_config,
.reload_config, .reload_config,
.inspector, .inspector,
.show_gtk_inspector,
.desktop_notification, .desktop_notification,
.present_terminal, .present_terminal,
.initial_size, .initial_size,
.size_limit, .size_limit,
.mouse_over_link,
.toggle_tab_overview, .toggle_tab_overview,
.toggle_split_zoom, .toggle_split_zoom,
.toggle_window_decorations, .toggle_window_decorations,
@ -1014,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( pub fn mouseShape(
target: apprt.Target, target: apprt.Target,
shape: terminal.MouseShape, shape: terminal.MouseShape,
@ -1115,6 +1135,10 @@ const Action = struct {
}, },
} }
} }
pub fn showGtkInspector() void {
gtk.Window.setInteractiveDebugging(@intFromBool(true));
}
}; };
/// This sets various GTK-related environment variables as necessary /// This sets various GTK-related environment variables as necessary

View File

@ -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 pwd = struct {
pub const name = "pwd"; pub const name = "pwd";
pub const get = impl.get; 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. /// Whether the mouse should be hidden or not as requested externally.
mouse_hidden: bool = false, 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, /// The current working directory. This has to be reported externally,
/// usually by shell integration which then talks to libghostty /// usually by shell integration which then talks to libghostty
/// which triggers this property. /// which triggers this property.
@ -170,10 +190,18 @@ pub const Surface = extern struct {
/// The title of this surface, if any has been set. /// The title of this surface, if any has been set.
title: ?[:0]const u8 = null, 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 /// The GLAarea that renders the actual surface. This is a binding
/// to the template so it doesn't have to be unrefed manually. /// to the template so it doesn't have to be unrefed manually.
gl_area: *gtk.GLArea = undefined, 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. /// The apprt Surface.
rt_surface: ApprtSurface = undefined, rt_surface: ApprtSurface = undefined,
@ -809,6 +837,13 @@ pub const Surface = extern struct {
); );
// Some property signals // Some property signals
_ = gobject.Object.signals.notify.connect(
self,
?*anyopaque,
&propMouseHoverUrl,
null,
.{ .detail = "mouse-hover-url" },
);
_ = gobject.Object.signals.notify.connect( _ = gobject.Object.signals.notify.connect(
self, self,
?*anyopaque, ?*anyopaque,
@ -823,6 +858,41 @@ pub const Surface = extern struct {
null, null,
.{ .detail = "mouse-shape" }, .{ .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 { fn dispose(self: *Self) callconv(.C) void {
@ -863,6 +933,10 @@ pub const Surface = extern struct {
priv.core_surface = null; priv.core_surface = null;
} }
if (priv.mouse_hover_url) |v| {
glib.free(@constCast(@ptrCast(v)));
priv.mouse_hover_url = null;
}
if (priv.pwd) |v| { if (priv.pwd) |v| {
glib.free(@constCast(@ptrCast(v))); glib.free(@constCast(@ptrCast(v)));
priv.pwd = null; priv.pwd = null;
@ -886,6 +960,16 @@ pub const Surface = extern struct {
return self.private().title; 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( fn propMouseHidden(
self: *Self, self: *Self,
_: *gobject.ParamSpec, _: *gobject.ParamSpec,
@ -1539,6 +1623,26 @@ pub const Surface = extern struct {
priv.core_surface = surface; 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); const C = Common(Self, Private);
pub const as = C.as; pub const as = C.as;
pub const ref = C.ref; pub const ref = C.ref;
@ -1561,13 +1665,17 @@ pub const Surface = extern struct {
); );
// Bindings // Bindings
class.bindTemplateChildPrivate("overlay", .{});
class.bindTemplateChildPrivate("gl_area", .{}); class.bindTemplateChildPrivate("gl_area", .{});
class.bindTemplateChildPrivate("url_left", .{});
class.bindTemplateChildPrivate("url_right", .{});
// Properties // Properties
gobject.ext.registerProperties(class, &.{ gobject.ext.registerProperties(class, &.{
properties.config.impl, properties.config.impl,
properties.@"mouse-shape".impl, properties.@"mouse-shape".impl,
properties.@"mouse-hidden".impl, properties.@"mouse-hidden".impl,
properties.@"mouse-hover-url".impl,
properties.pwd.impl, properties.pwd.impl,
properties.title.impl, properties.title.impl,
}); });

View File

@ -0,0 +1,24 @@
/* Application CSS that applies to the entire application.
*
* This is automatically loaded by AdwApplication:
* https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1.3/styles-and-appearance.html#custom-styles
*/
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;
}

View File

@ -2,12 +2,43 @@ using Gtk 4.0;
using Adw 1; using Adw 1;
template $GhosttySurface: Adw.Bin { template $GhosttySurface: Adw.Bin {
Overlay { // We need to wrap our Overlay one more time because if you bind a
GLArea gl_area { // direct child of your widget to a property, it will double free:
hexpand: true; // https://gitlab.gnome.org/GNOME/gtk/-/blob/847571a1e314aba79260e4ef282e2ed9ba91a0d9/gtk/gtkwidget.c#L11423-11425
vexpand: true; Adw.Bin {
focusable: true; Overlay overlay {
focus-on-click: true; focusable: false;
focus-on-click: false;
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;
}