apprt/gtk-ng: resize overlay (#8012)

This implements the resize overlay. This is implemented using a new
mostly generic `ResizeOverlay` class that can probably be renamed one
day to something like `TemporaryOverlay` since it is generic to show a
label with a duration.

The only user-facing change here is `after-first` behavior has been
changed in the config to actually mean "after a delay." The
`after-first` behavior has been problematic since we introduced it on
both macOS and Linux because a lot of windowing systems may perform
multiple resizes very quickly at startup (especially tiling ones) so its
less about being "first" and more about semantically only showing the
overlay for user-driven resizes. We should rename this, eventually.

The valgrind suppression file change is mostly to handle an alternate
machine, but its all the same stuff (GTK renderers, GPU drivers, etc.),
nothing new in our app code.
This commit is contained in:
Mitchell Hashimoto
2025-07-21 21:05:14 -07:00
committed by GitHub
8 changed files with 513 additions and 5 deletions

View File

@ -35,6 +35,7 @@ pub const icon_sizes: []const comptime_int = &.{ 16, 32, 128, 256, 512, 1024 };
pub const blueprints: []const Blueprint = &.{ pub const blueprints: []const Blueprint = &.{
.{ .major = 1, .minor = 2, .name = "close-confirmation-dialog" }, .{ .major = 1, .minor = 2, .name = "close-confirmation-dialog" },
.{ .major = 1, .minor = 2, .name = "config-errors-dialog" }, .{ .major = 1, .minor = 2, .name = "config-errors-dialog" },
.{ .major = 1, .minor = 2, .name = "resize-overlay" },
.{ .major = 1, .minor = 2, .name = "surface" }, .{ .major = 1, .minor = 2, .name = "surface" },
.{ .major = 1, .minor = 5, .name = "window" }, .{ .major = 1, .minor = 5, .name = "window" },
}; };

View File

@ -0,0 +1,302 @@
const std = @import("std");
const assert = std.debug.assert;
const adw = @import("adw");
const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
const gresource = @import("../build/gresource.zig");
const Common = @import("../class.zig").Common;
const log = std.log.scoped(.gtk_ghostty_resize_overlay);
/// The overlay that shows the current size while a surface is resizing.
/// This can be used generically to show pretty much anything with a
/// disappearing overlay, but we have no other use at this point so it
/// is named specifically for what it does.
///
/// General usage:
///
/// 1. Add it to an overlay
/// 2. Set the label with `setLabel`
/// 3. Schedule to show it with `schedule`
///
/// Set any properties to change the behavior.
pub const ResizeOverlay = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.Bin;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttyResizeOverlay",
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
});
pub const properties = struct {
pub const duration = struct {
pub const name = "duration";
const impl = gobject.ext.defineProperty(
name,
Self,
c_uint,
.{
.nick = "Duration",
.blurb = "The duration this overlay appears in milliseconds.",
.default = 750,
.minimum = 250,
.maximum = std.math.maxInt(c_uint),
.accessor = gobject.ext.privateFieldAccessor(
Self,
Private,
&Private.offset,
"duration",
),
},
);
};
pub const @"first-delay" = struct {
pub const name = "first-delay";
const impl = gobject.ext.defineProperty(
name,
Self,
c_uint,
.{
.nick = "First Delay",
.blurb = "The delay in milliseconds before any overlay is shown for the first time.",
.default = 250,
.minimum = 250,
.maximum = std.math.maxInt(c_uint),
.accessor = gobject.ext.privateFieldAccessor(
Self,
Private,
&Private.offset,
"first_delay",
),
},
);
};
pub const @"overlay-halign" = struct {
pub const name = "overlay-halign";
const impl = gobject.ext.defineProperty(
name,
Self,
gtk.Align,
.{
.nick = "halign",
.blurb = "The alignment of the label.",
.default = .center,
.accessor = gobject.ext.privateFieldAccessor(
Self,
Private,
&Private.offset,
"halign",
),
},
);
};
pub const @"overlay-valign" = struct {
pub const name = "overlay-valign";
const impl = gobject.ext.defineProperty(
name,
Self,
gtk.Align,
.{
.nick = "valign",
.blurb = "The alignment of the label.",
.default = .center,
.accessor = gobject.ext.privateFieldAccessor(
Self,
Private,
&Private.offset,
"valign",
),
},
);
};
};
const Private = struct {
/// The label with the text
label: *gtk.Label,
/// The time that the overlay appears.
duration: c_uint,
/// The first delay before any overlay is shown. Must be specified
/// during construction otherwise it has no effect.
first_delay: c_uint,
/// The idle source that we use to update the label.
idler: ?c_uint = null,
/// The timer for dismissing the overlay.
timer: ?c_uint = null,
/// The first delay timer.
delay_timer: ?c_uint = null,
/// The alignment of the label
halign: gtk.Align,
valign: gtk.Align,
pub var offset: c_int = 0;
};
fn init(self: *Self, _: *Class) callconv(.C) void {
gtk.Widget.initTemplate(self.as(gtk.Widget));
const priv = self.private();
if (priv.first_delay > 0) {
priv.delay_timer = glib.timeoutAdd(
priv.first_delay,
onDelayTimer,
self,
);
}
}
/// Set the label for the overlay. This will not show the
/// overlay if it is currently hidden; you must call schedule.
pub fn setLabel(self: *Self, label: [:0]const u8) void {
const priv = self.private();
priv.label.setText(label.ptr);
}
/// Schedule the overlay to be shown. To avoid flickering during
/// resizes we schedule the overlay to be shown on the next idle tick.
pub fn schedule(self: *Self) void {
const priv = self.private();
// If we have a delay timer then we're not showing anything
// yet so do nothing.
if (priv.delay_timer != null) return;
// When updating a widget, wait until GTK is "idle", i.e. not in the middle
// of doing any other updates. Since we are called in the middle of resizing
// GTK is doing a lot of work rearranging all of the widgets. Not doing this
// results in a lot of warnings from GTK and _horrible_ flickering of the
// resize overlay.
if (priv.idler != null) return;
priv.idler = glib.idleAdd(onIdle, self);
}
fn onIdle(ud: ?*anyopaque) callconv(.c) c_int {
const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
const priv = self.private();
// No matter what our idler is complete with this callback
priv.idler = null;
// Show ourselves
self.as(gtk.Widget).setVisible(1);
if (priv.timer) |timer| {
if (glib.Source.remove(timer) == 0) {
log.warn("unable to remove size overlay timer", .{});
}
}
priv.timer = glib.timeoutAdd(
priv.duration,
onTimer,
self,
);
return 0;
}
fn onTimer(ud: ?*anyopaque) callconv(.c) c_int {
const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
const priv = self.private();
priv.timer = null;
self.as(gtk.Widget).setVisible(0);
return 0;
}
fn onDelayTimer(ud: ?*anyopaque) callconv(.c) c_int {
const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
const priv = self.private();
priv.delay_timer = null;
return 0;
}
//---------------------------------------------------------------
// Virtual methods
fn dispose(self: *Self) callconv(.C) void {
const priv = self.private();
if (priv.idler) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove resize overlay idler", .{});
}
priv.idler = null;
}
if (priv.timer) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove resize overlay timer", .{});
}
priv.timer = null;
}
if (priv.delay_timer) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove resize overlay delay timer", .{});
}
priv.delay_timer = null;
}
gtk.Widget.disposeTemplate(
self.as(gtk.Widget),
getGObjectType(),
);
gobject.Object.virtual_methods.dispose.call(
Class.parent,
self.as(Parent),
);
}
const C = Common(Self, Private);
pub const as = C.as;
pub const ref = C.ref;
pub const unref = C.unref;
const private = C.private;
pub const Class = extern struct {
parent_class: Parent.Class,
var parent: *Parent.Class = undefined;
pub const Instance = Self;
fn init(class: *Class) callconv(.C) void {
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{
.major = 1,
.minor = 2,
.name = "resize-overlay",
}),
);
// Bindings
class.bindTemplateChildPrivate("label", .{});
// Properties
gobject.ext.registerProperties(class, &.{
properties.duration.impl,
properties.@"first-delay".impl,
properties.@"overlay-halign".impl,
properties.@"overlay-valign".impl,
});
// Virtual methods
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
}
pub const as = C.Class.as;
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
};
};

View File

@ -19,6 +19,7 @@ const ApprtSurface = @import("../Surface.zig");
const Common = @import("../class.zig").Common; const Common = @import("../class.zig").Common;
const Application = @import("application.zig").Application; const Application = @import("application.zig").Application;
const Config = @import("config.zig").Config; const Config = @import("config.zig").Config;
const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay;
const log = std.log.scoped(.gtk_ghostty_surface); const log = std.log.scoped(.gtk_ghostty_surface);
@ -202,6 +203,9 @@ pub const Surface = extern struct {
url_left: *gtk.Label = undefined, url_left: *gtk.Label = undefined,
url_right: *gtk.Label = undefined, url_right: *gtk.Label = undefined,
/// The resize overlay
resize_overlay: *ResizeOverlay = undefined,
/// The apprt Surface. /// The apprt Surface.
rt_surface: ApprtSurface = undefined, rt_surface: ApprtSurface = undefined,
@ -837,6 +841,13 @@ pub const Surface = extern struct {
); );
// Some property signals // Some property signals
_ = gobject.Object.signals.notify.connect(
self,
?*anyopaque,
&propConfig,
null,
.{ .detail = "config" },
);
_ = gobject.Object.signals.notify.connect( _ = gobject.Object.signals.notify.connect(
self, self,
?*anyopaque, ?*anyopaque,
@ -861,6 +872,16 @@ pub const Surface = extern struct {
// Some other initialization steps // Some other initialization steps
self.initUrlOverlay(); self.initUrlOverlay();
self.initResizeOverlay();
// Initialize our config
self.propConfig(undefined, null);
}
fn initResizeOverlay(self: *Self) void {
const priv = self.private();
const overlay = priv.overlay;
overlay.addOverlay(priv.resize_overlay.as(gtk.Widget));
} }
fn initUrlOverlay(self: *Self) void { fn initUrlOverlay(self: *Self) void {
@ -960,6 +981,58 @@ pub const Surface = extern struct {
return self.private().title; return self.private().title;
} }
fn propConfig(
self: *Self,
_: *gobject.ParamSpec,
_: ?*anyopaque,
) callconv(.c) void {
const priv = self.private();
const config = if (priv.config) |c| c.get() else return;
// resize-overlay-duration
{
const ms = config.@"resize-overlay-duration".asMilliseconds();
var value = gobject.ext.Value.newFrom(ms);
defer value.unset();
gobject.Object.setProperty(
priv.resize_overlay.as(gobject.Object),
"duration",
&value,
);
}
// resize-overlay-position
{
const hv: struct {
gtk.Align, // halign
gtk.Align, // valign
} = switch (config.@"resize-overlay-position") {
.center => .{ .center, .center },
.@"top-left" => .{ .start, .start },
.@"top-right" => .{ .end, .start },
.@"top-center" => .{ .center, .start },
.@"bottom-left" => .{ .start, .end },
.@"bottom-right" => .{ .end, .end },
.@"bottom-center" => .{ .center, .end },
};
var halign = gobject.ext.Value.newFrom(hv[0]);
defer halign.unset();
var valign = gobject.ext.Value.newFrom(hv[1]);
defer valign.unset();
gobject.Object.setProperty(
priv.resize_overlay.as(gobject.Object),
"overlay-halign",
&halign,
);
gobject.Object.setProperty(
priv.resize_overlay.as(gobject.Object),
"overlay-valign",
&valign,
);
}
}
fn propMouseHoverUrl( fn propMouseHoverUrl(
self: *Self, self: *Self,
_: *gobject.ParamSpec, _: *gobject.ParamSpec,
@ -1556,9 +1629,44 @@ pub const Surface = extern struct {
surface.sizeCallback(priv.size) catch |err| { surface.sizeCallback(priv.size) catch |err| {
log.warn("error in size callback err={}", .{err}); log.warn("error in size callback err={}", .{err});
}; };
// Setup our resize overlay if configured
self.resizeOverlaySchedule();
} }
} }
fn resizeOverlaySchedule(self: *Self) void {
const priv = self.private();
const surface = priv.core_surface orelse return;
// Only show the resize overlay if its enabled
const config = if (priv.config) |c| c.get() else return;
switch (config.@"resize-overlay") {
.always, .@"after-first" => {},
.never => return,
}
// If we have resize overlays enabled, setup an idler
// to show that. We do this in an idle tick because doing it
// during the resize results in flickering.
var buf: [32]u8 = undefined;
priv.resize_overlay.setLabel(text: {
const grid_size = surface.size.grid();
break :text std.fmt.bufPrintZ(
&buf,
"{d} x {d}",
.{
grid_size.columns,
grid_size.rows,
},
) catch |err| err: {
log.warn("unable to format text: {}", .{err});
break :err "";
};
});
priv.resize_overlay.schedule();
}
const RealizeError = Allocator.Error || error{ const RealizeError = Allocator.Error || error{
GLAreaError, GLAreaError,
RendererError, RendererError,
@ -1655,6 +1763,7 @@ pub const Surface = extern struct {
pub const Instance = Self; pub const Instance = Self;
fn init(class: *Class) callconv(.C) void { fn init(class: *Class) callconv(.C) void {
gobject.ext.ensureType(ResizeOverlay);
gtk.Widget.Class.setTemplateFromResource( gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class), class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{ comptime gresource.blueprint(.{
@ -1669,6 +1778,7 @@ pub const Surface = extern struct {
class.bindTemplateChildPrivate("gl_area", .{}); class.bindTemplateChildPrivate("gl_area", .{});
class.bindTemplateChildPrivate("url_left", .{}); class.bindTemplateChildPrivate("url_left", .{});
class.bindTemplateChildPrivate("url_right", .{}); class.bindTemplateChildPrivate("url_right", .{});
class.bindTemplateChildPrivate("resize_overlay", .{});
// Properties // Properties
gobject.ext.registerProperties(class, &.{ gobject.ext.registerProperties(class, &.{

View File

@ -22,3 +22,11 @@ label.url-overlay.left {
label.url-overlay.right { label.url-overlay.right {
border-radius: 6px 0px 0px 0px; border-radius: 6px 0px 0px 0px;
} }
.size-overlay label {
padding: 4px 8px 4px 8px;
border-radius: 6px 6px 6px 6px;
outline-style: solid;
outline-width: 1px;
outline-color: #555555;
}

View File

@ -0,0 +1,22 @@
using Gtk 4.0;
using Adw 1;
// We can't inherit directly from Label because its an opaque
// type in zig-gobject.
template $GhosttyResizeOverlay: Adw.Bin {
visible: false;
duration: 750;
first-delay: 250;
overlay-halign: center;
overlay-valign: center;
// See surface.blp for why we need to wrap this.
Adw.Bin {
Label label {
focusable: false;
focus-on-click: false;
justify: center;
selectable: false;
halign: bind template.overlay-halign;
valign: bind template.overlay-valign;
}
}
}

View File

@ -42,3 +42,9 @@ Label url_right {
valign: end; valign: end;
label: bind template.mouse-hover-url; label: bind template.mouse-hover-url;
} }
$GhosttyResizeOverlay resize_overlay {
styles [
"size-overlay",
]
}

View File

@ -20,6 +20,8 @@ extend-exclude = [
"*.png", "*.png",
"*.ico", "*.ico",
"*.icns", "*.icns",
# Valgrind nonsense
"valgrind.supp",
# Other # Other
"*.pdf", "*.pdf",
"*.data", "*.data",

View File

@ -34,6 +34,35 @@
... ...
} }
{
GSK GPU Rendering
Memcheck:Leak
match-leak-kinds: possible
...
fun:gsk_gpu_render_pass_op_gl_command
fun:gsk_gl_frame_submit
fun:gsk_gpu_renderer_render
fun:gsk_renderer_render
fun:gtk_widget_render
fun:surface_render
fun:_gdk_marshal_BOOLEAN__BOXEDv
fun:_g_closure_invoke_va
fun:signal_emit_valist_unlocked
fun:g_signal_emit_valist
fun:g_signal_emit
fun:gdk_surface_paint_on_clock
fun:_g_closure_invoke_va
fun:signal_emit_valist_unlocked
fun:g_signal_emit_valist
fun:g_signal_emit
fun:gdk_frame_clock_paint_idle
...
fun:g_timeout_dispatch
fun:g_main_context_dispatch_unlocked
fun:g_main_context_iterate_unlocked.isra.0
fun:g_main_context_iteration
...
}
{ {
GTK Shader Selector GTK Shader Selector
Memcheck:Leak Memcheck:Leak
@ -57,7 +86,7 @@
fun:g_object_new_internal.part.0 fun:g_object_new_internal.part.0
fun:g_object_new_valist fun:g_object_new_valist
fun:g_object_new fun:g_object_new
fun:gtk_at_spi_create_context ...
fun:gtk_at_context_create fun:gtk_at_context_create
fun:gtk_widget_init fun:gtk_widget_init
fun:g_type_create_instance fun:g_type_create_instance
@ -79,7 +108,7 @@
fun:g_object_new_internal.part.0 fun:g_object_new_internal.part.0
fun:g_object_new_valist fun:g_object_new_valist
fun:g_object_new fun:g_object_new
fun:gtk_at_spi_create_context ...
fun:gtk_at_context_create fun:gtk_at_context_create
fun:gtk_widget_init fun:gtk_widget_init
fun:g_type_create_instance fun:g_type_create_instance
@ -101,6 +130,34 @@
... ...
} }
{
Fcitx
Memcheck:Leak
match-leak-kinds: definite
...
fun:g_malloc0
fun:parser_start_element
fun:emit_start_element
fun:g_markup_parse_context_parse
fun:g_dbus_node_info_new_for_xml
fun:_fcitx_g_client_*
...
}
{
Fcitx
Memcheck:Leak
match-leak-kinds: possible
...
fun:g_closure_invoke
fun:signal_emit_unlocked_R.isra.0
fun:signal_emit_valist_unlocked
fun:g_signal_emit_valist
fun:g_signal_emit
fun:_fcitx_g_client_g_signal
...
}
{ {
GTK init GTK init
Memcheck:Leak Memcheck:Leak
@ -126,7 +183,7 @@
fun:fc_thread_func fun:fc_thread_func
fun:g_thread_proxy fun:g_thread_proxy
fun:start_thread fun:start_thread
fun:clone ...
} }
{ {
@ -211,17 +268,17 @@
{ {
Mesa Mesa
Memcheck:Leak Memcheck:Leak
match-leak-kinds: possible
... ...
fun:_mesa_* fun:_mesa_*
...
} }
{ {
Mesa Mesa
Memcheck:Leak Memcheck:Leak
match-leak-kinds: possible
... ...
fun:mesa_* fun:mesa_*
...
} }
{ {