core!: modify scroll behavior (#6052)

Modify the scroll behavior to better match other terminals, as well as
provide a
better overall experience. Before this PR, ghostty would scale
non-precision
scroll events dependent on the screen size. This is in line with kitty,
but no
other terminal. Ghostty also was the only terminal to send *more than
one* wheel
event.

```
 # 50% Screen height
| terminal   | arrows keys sent| wheels events sent|
|------------|-----------------|-------------------|
| alacritty  |        3        |          1        |
| foot       |        3        |          1        |
| xterm      |        5        |          1        |
| kitty      |        3        |          1        |
| ghostty    |        2        |          2        |

 # 100% Screen height
| terminal   | arrows keys sent| wheels events sent|
|------------|-----------------|-------------------|
| alacritty  |        3        |          1        |
| foot       |        3        |          1        |
| xterm      |        5        |          1        |
| kitty      |        5        |          1        |
| ghostty    |        3        |          3        |
```

This PR modifies Ghostty to behave like foot and alacritty.

For an improved user experience, we only use the configured multiplier
for
non-precision scrolls. This multiplier now *only applies* to viewport
scrolling
and alternate scroll mode. The default value has been updated to 3.0.

GTK also now supports precision scrolling.
This commit is contained in:
Mitchell Hashimoto
2025-03-02 13:15:45 -08:00
committed by GitHub
3 changed files with 55 additions and 35 deletions

View File

@ -2325,6 +2325,13 @@ const ScrollAmount = struct {
pub fn magnitude(self: ScrollAmount) usize {
return @abs(self.delta);
}
pub fn multiplied(self: ScrollAmount, multiplier: f64) ScrollAmount {
const delta_f64: f64 = @floatFromInt(self.delta);
const delta_adjusted: f64 = delta_f64 * multiplier;
const delta_isize: isize = @intFromFloat(@round(delta_adjusted));
return .{ .delta = delta_isize };
}
};
/// Mouse scroll event. Negative is down, left. Positive is up, right.
@ -2348,22 +2355,11 @@ pub fn scrollCallback(
if (self.mouse.hidden) self.showMouse();
const y: ScrollAmount = if (yoff == 0) .{} else y: {
// Non-precision scrolling is easy to calculate. We don't use
// the given offset at all and instead just treat a positive
// as a scroll up and a negative as a scroll down and scroll in
// steps.
// Non-precision scrolls don't accumulate. We cast that raw yoff to an isize and interpret
// it as the number of lines to scroll.
if (!scroll_mods.precision) {
// Calculate our magnitude of scroll. This is constant (not
// dependent on yoff).
const grid_size = self.size.grid();
const grid_rows_f64: f64 = @floatFromInt(grid_size.rows);
const y_delta_f64: f64 = @round((grid_rows_f64 * self.config.mouse_scroll_multiplier) / 15.0);
const y_delta_usize: usize = @max(1, @as(usize, @intFromFloat(y_delta_f64)));
// Calculate our direction of scroll based on the sign of yoff.
const y_sign: isize = if (yoff >= 0) 1 else -1;
const y_delta_isize: isize = y_sign * @as(isize, @intCast(y_delta_usize));
// Calculate our magnitude of scroll. This is a direct multiple of yoff
const y_delta_isize: isize = @intFromFloat(@round(yoff));
break :y .{ .delta = y_delta_isize };
}
@ -2372,7 +2368,7 @@ pub fn scrollCallback(
// tiny amount so that we can scroll by a full row when we have enough.
// Adjust our offset by the multiplier
const yoff_adjusted: f64 = yoff * self.config.mouse_scroll_multiplier;
const yoff_adjusted: f64 = yoff;
// Add our previously saved pending amount to the offset to get the
// new offset value. The signs of the pending and yoff should match
@ -2404,15 +2400,11 @@ pub fn scrollCallback(
// For detailed comments see the y calculation above.
const x: ScrollAmount = if (xoff == 0) .{} else x: {
if (!scroll_mods.precision) {
const x_delta_f64: f64 = @round(1 * self.config.mouse_scroll_multiplier);
const x_delta_usize: usize = @max(1, @as(usize, @intFromFloat(x_delta_f64)));
const x_sign: isize = if (xoff >= 0) 1 else -1;
const x_delta_isize: isize = x_sign * @as(isize, @intCast(x_delta_usize));
const x_delta_isize: isize = @intFromFloat(@round(xoff));
break :x .{ .delta = x_delta_isize };
}
const xoff_adjusted: f64 = xoff * self.config.mouse_scroll_multiplier;
const poff: f64 = self.mouse.pending_scroll_x + xoff_adjusted;
const poff: f64 = self.mouse.pending_scroll_x + xoff;
const cell_size: f64 = @floatFromInt(self.size.cell.width);
if (@abs(poff) < cell_size) {
self.mouse.pending_scroll_x = poff;
@ -2440,6 +2432,12 @@ pub fn scrollCallback(
try self.setSelection(null);
}
// We never use a multiplier for precision scrolls.
const multiplier: f64 = if (scroll_mods.precision)
1.0
else
self.config.mouse_scroll_multiplier;
// If we're in alternate screen with alternate scroll enabled, then
// we convert to cursor keys. This only happens if we're:
// (1) alt screen (2) no explicit mouse reporting and (3) alt
@ -2466,7 +2464,9 @@ pub fn scrollCallback(
.down_left => "\x1b[B",
};
};
for (0..y.magnitude()) |_| {
// We multiple by the scroll multiplier when reporting arrows
const multiplied = y.multiplied(multiplier);
for (0..multiplied.magnitude()) |_| {
self.io.queueMessage(.{ .write_stable = seq }, .locked);
}
}
@ -2502,10 +2502,12 @@ pub fn scrollCallback(
}
if (y.delta != 0) {
// We multiply by the multiplier when scrolling the viewport
const multiplied = y.multiplied(multiplier);
// Modify our viewport, this requires a lock since it affects
// rendering. We have to switch signs here because our delta
// is negative down but our viewport is positive down.
try self.io.terminal.scrollViewport(.{ .delta = y.delta * -1 });
try self.io.terminal.scrollViewport(.{ .delta = multiplied.delta * -1 });
}
}

View File

@ -304,6 +304,9 @@ cgroup_path: ?[]const u8 = null,
/// Our context menu.
context_menu: Menu(Surface, "context_menu", false),
/// True when we have a precision scroll in progress
precision_scroll: bool = false,
/// The state of the key event while we're doing IM composition.
/// See gtkKeyPressed for detailed descriptions.
pub const IMKeyEvent = enum {
@ -418,10 +421,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
c.gtk_widget_add_controller(@ptrCast(@alignCast(overlay)), ec_motion);
// Scroll events
const ec_scroll = c.gtk_event_controller_scroll_new(
c.GTK_EVENT_CONTROLLER_SCROLL_BOTH_AXES |
c.GTK_EVENT_CONTROLLER_SCROLL_DISCRETE,
);
const ec_scroll = c.gtk_event_controller_scroll_new(c.GTK_EVENT_CONTROLLER_SCROLL_BOTH_AXES);
errdefer c.g_object_unref(ec_scroll);
c.gtk_widget_add_controller(@ptrCast(overlay), ec_scroll);
@ -532,6 +532,8 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
_ = c.g_signal_connect_data(ec_motion, "motion", c.G_CALLBACK(&gtkMouseMotion), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(ec_motion, "leave", c.G_CALLBACK(&gtkMouseLeave), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(ec_scroll, "scroll", c.G_CALLBACK(&gtkMouseScroll), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(ec_scroll, "scroll-begin", c.G_CALLBACK(&gtkMouseScrollPrecisionBegin), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(ec_scroll, "scroll-end", c.G_CALLBACK(&gtkMouseScrollPrecisionEnd), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(im_context, "preedit-start", c.G_CALLBACK(&gtkInputPreeditStart), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(im_context, "preedit-changed", c.G_CALLBACK(&gtkInputPreeditChanged), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(im_context, "preedit-end", c.G_CALLBACK(&gtkInputPreeditEnd), self, null, c.G_CONNECT_DEFAULT);
@ -1523,6 +1525,22 @@ fn gtkMouseLeave(
};
}
fn gtkMouseScrollPrecisionBegin(
_: *c.GtkEventControllerScroll,
ud: ?*anyopaque,
) callconv(.C) void {
const self = userdataSelf(ud.?);
self.precision_scroll = true;
}
fn gtkMouseScrollPrecisionEnd(
_: *c.GtkEventControllerScroll,
ud: ?*anyopaque,
) callconv(.C) void {
const self = userdataSelf(ud.?);
self.precision_scroll = false;
}
fn gtkMouseScroll(
_: *c.GtkEventControllerScroll,
x: c.gdouble,
@ -1533,15 +1551,17 @@ fn gtkMouseScroll(
const scaled = self.scaledCoordinates(x, y);
// GTK doesn't support any of the scroll mods.
const scroll_mods: input.ScrollMods = .{};
const scroll_mods: input.ScrollMods = .{ .precision = self.precision_scroll };
// Multiply precision scrolls by 10 to get a better response from touchpad scrolling
const multiplier: f64 = if (self.precision_scroll) 10 else 1;
self.core_surface.scrollCallback(
// We invert because we apply natural scrolling to the values.
// This behavior has existed for years without Linux users complaining
// but I suspect we'll have to make this configurable in the future
// or read a system setting.
scaled.x * -1,
scaled.y * -1,
scaled.x * -1 * multiplier,
scaled.y * -1 * multiplier,
scroll_mods,
) catch |err| {
log.err("error in scroll callback err={}", .{err});

View File

@ -609,10 +609,8 @@ palette: Palette = .{},
/// than 0.01 or greater than 10,000 will be clamped to the nearest valid
/// value.
///
/// A value of "1" (default) scrolls the default amount. A value of "2" scrolls
/// double the default amount. A value of "0.5" scrolls half the default amount.
/// Et cetera.
@"mouse-scroll-multiplier": f64 = 1.0,
/// A value of "3" (default) scrolls 3 lines per tick.
@"mouse-scroll-multiplier": f64 = 3.0,
/// The opacity level (opposite of transparency) of the background. A value of
/// 1 is fully opaque and a value of 0 is fully transparent. A value less than 0