gtk: Scrollbars

pull/9245/head
Mitchell Hashimoto 2025-10-16 15:49:30 -07:00
parent 58699c7992
commit ed443bc6ed
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
7 changed files with 467 additions and 2 deletions

View File

@ -46,6 +46,7 @@ pub const blueprints: []const Blueprint = &.{
.{ .major = 1, .minor = 5, .name = "split-tree" },
.{ .major = 1, .minor = 5, .name = "split-tree-split" },
.{ .major = 1, .minor = 2, .name = "surface" },
.{ .major = 1, .minor = 5, .name = "surface-scrolled-window" },
.{ .major = 1, .minor = 5, .name = "surface-title-dialog" },
.{ .major = 1, .minor = 3, .name = "surface-child-exited" },
.{ .major = 1, .minor = 5, .name = "tab" },

View File

@ -709,6 +709,8 @@ pub const Application = extern struct {
.ring_bell => Action.ringBell(target),
.scrollbar => Action.scrollbar(target, value),
.set_title => Action.setTitle(target, value),
.show_child_exited => return Action.showChildExited(target, value),
@ -728,7 +730,6 @@ pub const Application = extern struct {
.command_finished => return Action.commandFinished(target, value),
// Unimplemented
.scrollbar,
.secure_input,
.close_all_windows,
.float_window,
@ -2328,6 +2329,16 @@ const Action = struct {
}
}
pub fn scrollbar(
target: apprt.Target,
value: apprt.Action.Value(.scrollbar),
) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.surface.setScrollbar(value),
}
}
pub fn setTitle(
target: apprt.Target,
value: apprt.action.SetTitle,

View File

@ -22,6 +22,7 @@ const Config = @import("config.zig").Config;
const Application = @import("application.zig").Application;
const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog;
const Surface = @import("surface.zig").Surface;
const SurfaceScrolledWindow = @import("surface_scrolled_window.zig").SurfaceScrolledWindow;
const log = std.log.scoped(.gtk_ghostty_split_tree);
@ -874,7 +875,9 @@ pub const SplitTree = extern struct {
current: Surface.Tree.Node.Handle,
) *gtk.Widget {
return switch (tree.nodes[current.idx()]) {
.leaf => |v| v.as(gtk.Widget),
.leaf => |v| gobject.ext.newInstance(SurfaceScrolledWindow, .{
.surface = v,
}).as(gtk.Widget),
.split => |s| SplitTreeSplit.new(
current,
&s,

View File

@ -40,12 +40,16 @@ pub const Surface = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.Bin;
pub const Implements = [_]type{gtk.Scrollable};
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttySurface",
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
.implements = &.{
gobject.ext.implement(gtk.Scrollable, .{}),
},
});
/// A SplitTree implementation that stores surfaces.
@ -301,6 +305,62 @@ pub const Surface = extern struct {
},
);
};
pub const hadjustment = struct {
pub const name = "hadjustment";
const impl = gobject.ext.defineProperty(
name,
Self,
?*gtk.Adjustment,
.{
.accessor = .{
.getter = getHAdjustmentValue,
.setter = setHAdjustmentValue,
},
},
);
};
pub const vadjustment = struct {
pub const name = "vadjustment";
const impl = gobject.ext.defineProperty(
name,
Self,
?*gtk.Adjustment,
.{
.accessor = .{
.getter = getVAdjustmentValue,
.setter = setVAdjustmentValue,
},
},
);
};
pub const @"hscroll-policy" = struct {
pub const name = "hscroll-policy";
const impl = gobject.ext.defineProperty(
name,
Self,
gtk.ScrollablePolicy,
.{
.default = .natural,
.accessor = C.privateShallowFieldAccessor("hscroll_policy"),
},
);
};
pub const @"vscroll-policy" = struct {
pub const name = "vscroll-policy";
const impl = gobject.ext.defineProperty(
name,
Self,
gtk.ScrollablePolicy,
.{
.default = .natural,
.accessor = C.privateShallowFieldAccessor("vscroll_policy"),
},
);
};
};
pub const signals = struct {
@ -548,6 +608,13 @@ pub const Surface = extern struct {
action_group: ?*gio.SimpleActionGroup = null,
// Gtk.Scrollable interface adjustments
hadj: ?*gtk.Adjustment = null,
vadj: ?*gtk.Adjustment = null,
hscroll_policy: gtk.ScrollablePolicy = .natural,
vscroll_policy: gtk.ScrollablePolicy = .natural,
vadj_signal_group: ?*gobject.SignalGroup = null,
// Template binds
child_exited_overlay: *ChildExited,
context_menu: *gtk.PopoverMenu,
@ -714,6 +781,47 @@ pub const Surface = extern struct {
return priv.im_context.as(gtk.IMContext).activateOsk(event) != 0;
}
/// Set the scrollbar state for this surface. This will setup the
/// properties for our Gtk.Scrollable interface properly.
pub fn setScrollbar(self: *Self, scrollbar: terminal.Scrollbar) void {
// Update existing adjustment in-place. If we don't have an
// adjustment then we do nothing because we're not part of a
// scrolled window.
const vadj = self.getVAdjustment() orelse return;
// Check if values match existing adjustment and skip update if so
const value: f64 = @floatFromInt(scrollbar.offset);
const upper: f64 = @floatFromInt(scrollbar.total);
const page_size: f64 = @floatFromInt(scrollbar.len);
if (std.math.approxEqAbs(f64, vadj.getValue(), value, 0.001) and
std.math.approxEqAbs(f64, vadj.getUpper(), upper, 0.001) and
std.math.approxEqAbs(f64, vadj.getPageSize(), page_size, 0.001))
{
return;
}
// If we have a vadjustment we MUST have the signal group since
// it is setup in the prop handler.
const priv = self.private();
const group = priv.vadj_signal_group.?;
// During manual scrollbar changes from Ghostty core we don't
// want to emit value-changed signals so we block them. This would
// cause a waste of resources at best and infinite loops at worst.
group.block();
defer group.unblock();
vadj.configure(
value, // value: current scroll position
0, // lower: minimum value
upper, // upper: maximum value (total scrollable area)
1, // step_increment: amount to scroll on arrow click
page_size, // page_increment: amount to scroll on page up/down
page_size, // page_size: size of visible area
);
}
/// Set the current progress report state.
pub fn setProgressReport(
self: *Self,
@ -1519,6 +1627,7 @@ pub const Surface = extern struct {
priv.mouse_hidden = false;
priv.focused = true;
priv.size = .{ .width = 0, .height = 0 };
priv.vadj_signal_group = null;
// If our configuration is null then we get the configuration
// from the application.
@ -1583,6 +1692,22 @@ pub const Surface = extern struct {
priv.config = null;
}
if (priv.vadj_signal_group) |group| {
group.setTarget(null);
group.as(gobject.Object).unref();
priv.vadj_signal_group = null;
}
if (priv.hadj) |v| {
v.as(gobject.Object).unref();
priv.hadj = null;
}
if (priv.vadj) |v| {
v.as(gobject.Object).unref();
priv.vadj = null;
}
if (priv.progress_bar_timer) |timer| {
if (glib.Source.remove(timer) == 0) {
log.warn("unable to remove progress bar timer", .{});
@ -1996,6 +2121,43 @@ pub const Surface = extern struct {
self.as(gtk.Widget).setCursorFromName(name.ptr);
}
fn vadjValueChanged(adj: *gtk.Adjustment, self: *Self) callconv(.c) void {
// This will trigger for every single pixel change in the adjustment,
// but our core surface handles the noise from this so that identical
// rows are cheap.
const core_surface = self.core() orelse return;
const row: usize = @intFromFloat(@round(adj.getValue()));
_ = core_surface.performBindingAction(.{ .scroll_to_row = row }) catch |err| {
log.err("error performing scroll_to_row action err={}", .{err});
};
}
fn propVAdjustment(
self: *Self,
_: *gobject.ParamSpec,
_: ?*anyopaque,
) callconv(.c) void {
const priv = self.private();
// When vadjustment is first set, we setup the signal group lazily.
// This makes it so that if we don't use scrollbars, we never
// pay the memory cost of this.
const group: *gobject.SignalGroup = priv.vadj_signal_group orelse group: {
const group = gobject.SignalGroup.new(gtk.Adjustment.getGObjectType());
group.connect(
"value-changed",
@ptrCast(&vadjValueChanged),
self,
);
priv.vadj_signal_group = group;
break :group group;
};
// Setup our signal group target
group.setTarget(if (priv.vadj) |v| v.as(gobject.Object) else null);
}
/// Handle bell features that need to happen every time a BEL is received
/// Currently this is audio and system but this could change in the future.
fn ringBell(self: *Self) void {
@ -2060,6 +2222,66 @@ pub const Surface = extern struct {
}
}
//---------------------------------------------------------------
// Gtk.Scrollable interface implementation
pub fn getHAdjustment(self: *Self) ?*gtk.Adjustment {
return self.private().hadj;
}
pub fn setHAdjustment(self: *Self, adj_: ?*gtk.Adjustment) void {
self.as(gobject.Object).freezeNotify();
defer self.as(gobject.Object).thawNotify();
self.as(gobject.Object).notifyByPspec(properties.hadjustment.impl.param_spec);
const priv = self.private();
if (priv.hadj) |old| {
old.as(gobject.Object).unref();
priv.hadj = null;
}
const adj = adj_ orelse return;
_ = adj.as(gobject.Object).ref();
priv.hadj = adj;
}
fn getHAdjustmentValue(self: *Self, value: *gobject.Value) void {
gobject.ext.Value.set(value, self.getHAdjustment());
}
fn setHAdjustmentValue(self: *Self, value: *const gobject.Value) void {
self.setHAdjustment(gobject.ext.Value.get(value, ?*gtk.Adjustment));
}
pub fn getVAdjustment(self: *Self) ?*gtk.Adjustment {
return self.private().vadj;
}
pub fn setVAdjustment(self: *Self, adj_: ?*gtk.Adjustment) void {
self.as(gobject.Object).freezeNotify();
defer self.as(gobject.Object).thawNotify();
self.as(gobject.Object).notifyByPspec(properties.vadjustment.impl.param_spec);
const priv = self.private();
if (priv.vadj) |old| {
old.as(gobject.Object).unref();
priv.vadj = null;
}
const adj = adj_ orelse return;
_ = adj.as(gobject.Object).ref();
priv.vadj = adj;
}
fn getVAdjustmentValue(self: *Self, value: *gobject.Value) void {
gobject.ext.Value.set(value, self.getVAdjustment());
}
fn setVAdjustmentValue(self: *Self, value: *const gobject.Value) void {
self.setVAdjustment(gobject.ext.Value.get(value, ?*gtk.Adjustment));
}
//---------------------------------------------------------------
// Signal Handlers
@ -3013,6 +3235,7 @@ pub const Surface = extern struct {
class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl);
class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden);
class.bindTemplateCallback("notify_mouse_shape", &propMouseShape);
class.bindTemplateCallback("notify_vadjustment", &propVAdjustment);
class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown);
class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown);
@ -3034,6 +3257,12 @@ pub const Surface = extern struct {
properties.@"title-override".impl,
properties.zoom.impl,
properties.@"is-split".impl,
// For Gtk.Scrollable
properties.hadjustment.impl,
properties.vadjustment.impl,
properties.@"hscroll-policy".impl,
properties.@"vscroll-policy".impl,
});
// Signals

View File

@ -0,0 +1,209 @@
const std = @import("std");
const assert = std.debug.assert;
const adw = @import("adw");
const gobject = @import("gobject");
const gtk = @import("gtk");
const gresource = @import("../build/gresource.zig");
const Common = @import("../class.zig").Common;
const Surface = @import("surface.zig").Surface;
const Config = @import("config.zig").Config;
const log = std.log.scoped(.gtk_ghostty_surface_scrolled_window);
/// A wrapper widget that embeds a Surface inside a GtkScrolledWindow.
/// This provides scrollbar functionality for the terminal surface.
/// The surface property can be set during initialization or changed
/// dynamically via the surface property.
pub const SurfaceScrolledWindow = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.Bin;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhostttySurfaceScrolledWindow",
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
});
pub const properties = struct {
pub const config = struct {
pub const name = "config";
const impl = gobject.ext.defineProperty(
name,
Self,
?*Config,
.{
.accessor = C.privateObjFieldAccessor("config"),
},
);
};
pub const surface = struct {
pub const name = "surface";
const impl = gobject.ext.defineProperty(
name,
Self,
?*Surface,
.{
.accessor = .{
.getter = getSurfaceValue,
.setter = setSurfaceValue,
},
},
);
};
};
const Private = struct {
config: ?*Config = null,
config_binding: ?*gobject.Binding = null,
surface: ?*Surface = null,
pub var offset: c_int = 0;
};
fn init(self: *Self, _: *Class) callconv(.c) void {
gtk.Widget.initTemplate(self.as(gtk.Widget));
}
fn dispose(self: *Self) callconv(.c) void {
const priv = self.private();
if (priv.config_binding) |binding| {
binding.as(gobject.Object).unref();
priv.config_binding = null;
}
if (priv.config) |v| {
v.unref();
priv.config = null;
}
gtk.Widget.disposeTemplate(
self.as(gtk.Widget),
getGObjectType(),
);
gobject.Object.virtual_methods.dispose.call(
Class.parent,
self.as(Parent),
);
}
fn finalize(self: *Self) callconv(.c) void {
gobject.Object.virtual_methods.finalize.call(
Class.parent,
self.as(Parent),
);
}
fn getSurfaceValue(self: *Self, value: *gobject.Value) void {
gobject.ext.Value.set(
value,
self.private().surface,
);
}
fn setSurfaceValue(self: *Self, value: *const gobject.Value) void {
self.setSurface(gobject.ext.Value.get(
value,
?*Surface,
));
}
pub fn getSurface(self: *Self) ?*Surface {
return self.private().surface;
}
pub fn setSurface(self: *Self, surface_: ?*Surface) void {
const priv = self.private();
if (surface_ == priv.surface) return;
self.as(gobject.Object).freezeNotify();
defer self.as(gobject.Object).thawNotify();
self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec);
priv.surface = surface_;
}
fn closureScrollbarPolicy(
_: *Self,
config_: ?*Config,
) callconv(.c) gtk.PolicyType {
const config = if (config_) |c| c.get() else return .automatic;
return switch (config.scrollbar) {
.never => .never,
.system => .automatic,
};
}
fn propSurface(
self: *Self,
_: *gobject.ParamSpec,
_: ?*anyopaque,
) callconv(.c) void {
const priv = self.private();
const child: *gtk.Widget = self.as(Parent).getChild().?;
const scrolled_window = gobject.ext.cast(gtk.ScrolledWindow, child).?;
scrolled_window.setChild(if (priv.surface) |s| s.as(gtk.Widget) else null);
// Unbind old config binding if it exists
if (priv.config_binding) |binding| {
binding.as(gobject.Object).unref();
priv.config_binding = null;
}
// Bind config from surface to our config property
if (priv.surface) |surface| {
priv.config_binding = surface.as(gobject.Object).bindProperty(
properties.config.name,
self.as(gobject.Object),
properties.config.name,
.{ .sync_create = true },
);
}
}
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 = 5,
.name = "surface-scrolled-window",
}),
);
// Bindings
class.bindTemplateCallback("scrollbar_policy", &closureScrollbarPolicy);
class.bindTemplateCallback("notify_surface", &propSurface);
// Properties
gobject.ext.registerProperties(class, &.{
properties.config.impl,
properties.surface.impl,
});
// Virtual methods
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
gobject.Object.virtual_methods.finalize.implement(class, &finalize);
}
pub const as = C.Class.as;
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
};
};

View File

@ -174,6 +174,7 @@ template $GhosttySurface: Adw.Bin {
notify::mouse-hover-url => $notify_mouse_hover_url();
notify::mouse-hidden => $notify_mouse_hidden();
notify::mouse-shape => $notify_mouse_shape();
notify::vadjustment => $notify_vadjustment();
// Some history: we used to use a Stack here and swap between the
// terminal and error pages as needed. But a Stack doesn't play nice
// with our SplitTree and Gtk.Paned usage[^1]. Replacing this with

View File

@ -0,0 +1,11 @@
using Gtk 4.0;
using Adw 1;
template $GhostttySurfaceScrolledWindow: Adw.Bin {
notify::surface => $notify_surface();
Gtk.ScrolledWindow {
hscrollbar-policy: never;
vscrollbar-policy: bind $scrollbar_policy(template.config) as <Gtk.PolicyType>;
}
}