gtk: add two-finger left/right scroll to switch tab pages (#10575)

This adds the ability to use two fingers on a touchpad to scroll left or
right on a Ghostty window to change tab pages. Uses the same basic
machinery as scrolling up and down the scrollback buffer. Scrolling
pages does not wrap around at the start or end of the tabs.
This commit is contained in:
Jeffrey C. Ollie
2026-02-05 09:02:37 -06:00
committed by GitHub
3 changed files with 114 additions and 20 deletions

View File

@@ -697,6 +697,13 @@ pub const Surface = extern struct {
/// Whether primary paste (middle-click paste) is enabled.
gtk_enable_primary_paste: bool = true,
/// How much pending horizontal scroll do we have?
pending_horizontal_scroll: f64 = 0.0,
/// Timer to reset the amount of horizontal scroll if the user
/// stops scrolling.
pending_horizontal_scroll_reset: ?c_uint = null,
pub var offset: c_int = 0;
};
@@ -1880,6 +1887,13 @@ pub const Surface = extern struct {
priv.idle_rechild = null;
}
if (priv.pending_horizontal_scroll_reset) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove pending horizontal scroll reset source", .{});
}
priv.pending_horizontal_scroll_reset = null;
}
// This works around a GTK double-free bug where if you bind
// to a top-level template child, it frees twice if the widget is
// also the root child of the template. By unsetting the child here,
@@ -2879,27 +2893,27 @@ pub const Surface = extern struct {
}
}
fn ecMouseScrollPrecisionBegin(
fn ecMouseScrollVerticalPrecisionBegin(
_: *gtk.EventControllerScroll,
self: *Self,
) callconv(.c) void {
self.private().precision_scroll = true;
}
fn ecMouseScrollPrecisionEnd(
fn ecMouseScrollVerticalPrecisionEnd(
_: *gtk.EventControllerScroll,
self: *Self,
) callconv(.c) void {
self.private().precision_scroll = false;
}
fn ecMouseScroll(
fn ecMouseScrollVertical(
_: *gtk.EventControllerScroll,
x: f64,
y: f64,
self: *Self,
) callconv(.c) c_int {
const priv = self.private();
const priv: *Private = self.private();
const surface = priv.core_surface orelse return 0;
// Multiply precision scrolls by 10 to get a better response from
@@ -2926,6 +2940,57 @@ pub const Surface = extern struct {
return 1;
}
fn ecMouseScrollHorizontal(
ec: *gtk.EventControllerScroll,
x: f64,
_: f64,
self: *Self,
) callconv(.c) c_int {
const priv: *Private = self.private();
switch (ec.getUnit()) {
.surface => {},
.wheel => return @intFromBool(false),
else => return @intFromBool(false),
}
priv.pending_horizontal_scroll += x;
if (@abs(priv.pending_horizontal_scroll) < 120) {
if (priv.pending_horizontal_scroll_reset) |v| {
_ = glib.Source.remove(v);
priv.pending_horizontal_scroll_reset = null;
}
priv.pending_horizontal_scroll_reset = glib.timeoutAdd(500, ecMouseScrollHorizontalReset, self);
return @intFromBool(true);
}
_ = self.as(gtk.Widget).activateAction(
if (priv.pending_horizontal_scroll < 0.0)
"tab.next-page"
else
"tab.previous-page",
null,
);
if (priv.pending_horizontal_scroll_reset) |v| {
_ = glib.Source.remove(v);
priv.pending_horizontal_scroll_reset = null;
}
priv.pending_horizontal_scroll = 0.0;
return @intFromBool(true);
}
fn ecMouseScrollHorizontalReset(ud: ?*anyopaque) callconv(.c) c_int {
const self: *Self = @ptrCast(@alignCast(ud orelse return @intFromBool(glib.SOURCE_REMOVE)));
const priv: *Private = self.private();
priv.pending_horizontal_scroll = 0.0;
priv.pending_horizontal_scroll_reset = null;
return @intFromBool(glib.SOURCE_REMOVE);
}
fn imPreeditStart(
_: *gtk.IMMulticontext,
self: *Self,
@@ -3464,9 +3529,10 @@ pub const Surface = extern struct {
class.bindTemplateCallback("mouse_up", &gcMouseUp);
class.bindTemplateCallback("mouse_motion", &ecMouseMotion);
class.bindTemplateCallback("mouse_leave", &ecMouseLeave);
class.bindTemplateCallback("scroll", &ecMouseScroll);
class.bindTemplateCallback("scroll_begin", &ecMouseScrollPrecisionBegin);
class.bindTemplateCallback("scroll_end", &ecMouseScrollPrecisionEnd);
class.bindTemplateCallback("scroll_vertical", &ecMouseScrollVertical);
class.bindTemplateCallback("scroll_vertical_begin", &ecMouseScrollVerticalPrecisionBegin);
class.bindTemplateCallback("scroll_vertical_end", &ecMouseScrollVerticalPrecisionEnd);
class.bindTemplateCallback("scroll_horizontal", &ecMouseScrollHorizontal);
class.bindTemplateCallback("drop", &dtDrop);
class.bindTemplateCallback("gl_realize", &glareaRealize);
class.bindTemplateCallback("gl_unrealize", &glareaUnrealize);

View File

@@ -202,6 +202,8 @@ pub const Tab = extern struct {
const actions = [_]ext.actions.Action(Self){
.init("close", actionClose, s_param_type),
.init("ring-bell", actionRingBell, null),
.init("next-page", actionNextPage, null),
.init("previous-page", actionPreviousPage, null),
};
_ = ext.actions.addAsGroup(Self, self, "tab", &actions);
@@ -235,12 +237,17 @@ pub const Tab = extern struct {
return tree.getNeedsConfirmQuit();
}
/// Get the tab page holding this tab, if any.
fn getTabPage(self: *Self) ?*adw.TabPage {
const tab_view = ext.getAncestor(
/// Get the tab view holding this tab, if any.
fn getTabView(self: *Self) ?*adw.TabView {
return ext.getAncestor(
adw.TabView,
self.as(gtk.Widget),
) orelse return null;
);
}
/// Get the tab page holding this tab, if any.
fn getTabPage(self: *Self) ?*adw.TabPage {
const tab_view = self.getTabView() orelse return null;
return tab_view.getPage(self.as(gtk.Widget));
}
@@ -325,11 +332,7 @@ pub const Tab = extern struct {
var str: ?[*:0]const u8 = null;
param.get("&s", &str);
const tab_view = ext.getAncestor(
adw.TabView,
self.as(gtk.Widget),
) orelse return;
const tab_view = self.getTabView() orelse return;
const page = tab_view.getPage(self.as(gtk.Widget));
const mode = std.meta.stringToEnum(
@@ -372,6 +375,26 @@ pub const Tab = extern struct {
page.setNeedsAttention(@intFromBool(true));
}
/// Select the next tab page.
fn actionNextPage(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Self,
) callconv(.c) void {
const tab_view = self.getTabView() orelse return;
_ = tab_view.selectNextPage();
}
/// Select the previous tab page.
fn actionPreviousPage(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Self,
) callconv(.c) void {
const tab_view = self.getTabView() orelse return;
_ = tab_view.selectPreviousPage();
}
fn closureComputedTitle(
_: *Self,
config_: ?*Config,

View File

@@ -53,10 +53,15 @@ Overlay terminal_page {
}
EventControllerScroll {
scroll => $scroll();
scroll-begin => $scroll_begin();
scroll-end => $scroll_end();
flags: both_axes;
scroll => $scroll_vertical();
scroll-begin => $scroll_vertical_begin();
scroll-end => $scroll_vertical_end();
flags: vertical;
}
EventControllerScroll {
scroll => $scroll_horizontal();
flags: horizontal;
}
EventControllerMotion {