Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,7 @@ GHOSTTY_API void ghostty_surface_mouse_scroll(ghostty_surface_t,
double,
double,
ghostty_input_scroll_mods_t);
GHOSTTY_API void ghostty_surface_scroll_to_offset(ghostty_surface_t, double);
GHOSTTY_API void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double);
GHOSTTY_API void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*);
GHOSTTY_API void ghostty_surface_request_close(ghostty_surface_t);
Expand Down
51 changes: 51 additions & 0 deletions src/Surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,7 @@ fn selectionScrollTick(self: *Surface) !void {
}

// Scroll the viewport as required
self.renderer_state.resetSmoothScrollOffset();
t.scrollViewport(.{ .delta = delta });

// Next, trigger our drag behavior
Expand Down Expand Up @@ -3491,6 +3492,53 @@ const ScrollAmount = struct {
}
};

/// Scroll the viewport to a fractional row offset from the top of scrollback.
/// The terminal model stays row based; the renderer shifts the current frame
/// by the remaining pixel fraction.
pub fn scrollToOffset(self: *Surface, offset: f64) !void {
// Crash metadata in case we crash in here
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;

{
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();

const t: *terminal.Terminal = self.renderer_state.terminal;
const scrollbar = t.screens.active.pages.scrollbar();
const max_offset: usize = if (scrollbar.total > scrollbar.len)
scrollbar.total - scrollbar.len
else
0;
const max_offset_float: f64 = @floatFromInt(max_offset);
const clamped_offset = @min(@max(offset, 0.0), max_offset_float);
const row_offset_float = @floor(clamped_offset);
const row_offset: usize = @intFromFloat(row_offset_float);
const fractional_offset = clamped_offset - row_offset_float;

t.screens.active.scroll(.{ .row = row_offset });
self.mouse.pending_scroll_x = 0;
self.mouse.pending_scroll_y = 0;
self.renderer_state.resetSmoothScrollOffset();

const cell_height_float: f64 = @floatFromInt(self.size.cell.height);
if (fractional_offset > 0 and cell_height_float > 0) {
const pixel_offset = @trunc(-fractional_offset * cell_height_float);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 @trunc quantises the pixel offset to whole pixels, so smooth-scroll animations will visibly step in 1-pixel increments instead of moving continuously. Because smooth_scroll_offset is a f32 uniform the shader can already accept fractional pixels; drop the truncation to preserve sub-pixel precision.

Suggested change
const pixel_offset = @trunc(-fractional_offset * cell_height_float);
const pixel_offset = -fractional_offset * cell_height_float;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Do not truncate the computed pixel offset here; truncation removes sub-pixel smooth-scroll movement and causes visible stair-stepping.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/Surface.zig, line 3526:

<comment>Do not truncate the computed pixel offset here; truncation removes sub-pixel smooth-scroll movement and causes visible stair-stepping.</comment>

<file context>
@@ -3491,6 +3492,53 @@ const ScrollAmount = struct {
+
+        const cell_height_float: f64 = @floatFromInt(self.size.cell.height);
+        if (fractional_offset > 0 and cell_height_float > 0) {
+            const pixel_offset = @trunc(-fractional_offset * cell_height_float);
+            self.renderer_state.smooth_scroll_offset = @floatCast(pixel_offset);
+
</file context>
Suggested change
const pixel_offset = @trunc(-fractional_offset * cell_height_float);
const pixel_offset = -fractional_offset * cell_height_float;

self.renderer_state.smooth_scroll_offset = @floatCast(pixel_offset);

const extra_rows_float = @ceil(@abs(pixel_offset) / cell_height_float);
const max_u16_float: f64 = @floatFromInt(std.math.maxInt(u16));
const extra_rows: u16 = if (extra_rows_float >= max_u16_float)
std.math.maxInt(u16)
else
@intFromFloat(extra_rows_float);
self.renderer_state.image_scroll_offset = .{ extra_rows, extra_rows };
Comment on lines +3524 to +3535
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the pixel offset fractional.

Line 3526 truncates the requested smooth-scroll displacement to a whole pixel, so small offset updates are dropped and the new API will visibly stair-step instead of tracking the embedder’s fractional position.

Proposed fix
         const cell_height_float: f64 = `@floatFromInt`(self.size.cell.height);
         if (fractional_offset > 0 and cell_height_float > 0) {
-            const pixel_offset = `@trunc`(-fractional_offset * cell_height_float);
+            const pixel_offset = -fractional_offset * cell_height_float;
             self.renderer_state.smooth_scroll_offset = `@floatCast`(pixel_offset);

             const extra_rows_float = `@ceil`(`@abs`(pixel_offset) / cell_height_float);
             const max_u16_float: f64 = `@floatFromInt`(std.math.maxInt(u16));
             const extra_rows: u16 = if (extra_rows_float >= max_u16_float)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const cell_height_float: f64 = @floatFromInt(self.size.cell.height);
if (fractional_offset > 0 and cell_height_float > 0) {
const pixel_offset = @trunc(-fractional_offset * cell_height_float);
self.renderer_state.smooth_scroll_offset = @floatCast(pixel_offset);
const extra_rows_float = @ceil(@abs(pixel_offset) / cell_height_float);
const max_u16_float: f64 = @floatFromInt(std.math.maxInt(u16));
const extra_rows: u16 = if (extra_rows_float >= max_u16_float)
std.math.maxInt(u16)
else
@intFromFloat(extra_rows_float);
self.renderer_state.image_scroll_offset = .{ extra_rows, extra_rows };
const cell_height_float: f64 = `@floatFromInt`(self.size.cell.height);
if (fractional_offset > 0 and cell_height_float > 0) {
const pixel_offset = -fractional_offset * cell_height_float;
self.renderer_state.smooth_scroll_offset = `@floatCast`(pixel_offset);
const extra_rows_float = `@ceil`(`@abs`(pixel_offset) / cell_height_float);
const max_u16_float: f64 = `@floatFromInt`(std.math.maxInt(u16));
const extra_rows: u16 = if (extra_rows_float >= max_u16_float)
std.math.maxInt(u16)
else
`@intFromFloat`(extra_rows_float);
self.renderer_state.image_scroll_offset = .{ extra_rows, extra_rows };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Surface.zig` around lines 3524 - 3535, The code currently truncates
pixel_offset to an integer which drops sub-pixel fractional scroll and causes
visible stair-stepping; change pixel_offset to remain a float (set pixel_offset
= -fractional_offset * cell_height_float as f64), assign that float into
renderer_state.smooth_scroll_offset (no `@trunc`), then compute extra_rows_float =
`@ceil`(`@abs`(pixel_offset) / cell_height_float) and clamp/convert to u16 the same
way (using maxInt(u16) and `@intFromFloat`) before storing into
renderer_state.image_scroll_offset; update references to pixel_offset,
smooth_scroll_offset, and extra_rows_float accordingly so fractional offsets are
preserved.

}
}

try self.queueRender();
}

/// Mouse scroll event. Negative is down, left. Positive is up, right.
///
/// "Natural scrolling" is a macOS term for inverting the scroll direction.
Expand Down Expand Up @@ -3665,6 +3713,7 @@ pub fn scrollCallback(
}

if (y.delta != 0) {
self.renderer_state.resetSmoothScrollOffset();
// 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.
Expand Down Expand Up @@ -5511,6 +5560,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
const t: *terminal.Terminal = self.renderer_state.terminal;
self.renderer_state.resetSmoothScrollOffset();
t.screens.active.scroll(.{ .row = n });
}

Expand All @@ -5523,6 +5573,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
defer self.renderer_state.mutex.unlock();
const sel = self.io.terminal.screens.active.selection orelse return false;
const tl = sel.topLeft(self.io.terminal.screens.active);
self.renderer_state.resetSmoothScrollOffset();
self.io.terminal.screens.active.scroll(.{ .pin = tl });
}

Expand Down
9 changes: 9 additions & 0 deletions src/apprt/embedded.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1978,6 +1978,15 @@ pub const CAPI = struct {
);
}

export fn ghostty_surface_scroll_to_offset(
surface: *Surface,
offset: f64,
) void {
surface.core_surface.scrollToOffset(offset) catch |err| {
log.err("error scrolling surface to offset offset={} err={}", .{ offset, err });
};
}

export fn ghostty_surface_mouse_pressure(
surface: *Surface,
stage_raw: u32,
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/State.zig
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ preedit: ?Preedit = null,
/// need about the mouse.
mouse: Mouse = .{},

/// Fractional vertical scroll offset in pixels. This lets embedders drive
/// scrollback at sub-row precision while the terminal viewport remains row based.
smooth_scroll_offset: f32 = 0,

/// Extra image-row range required when a fractional scroll exposes content
/// from an adjacent row.
image_scroll_offset: [2]u16 = .{ 0, 0 },

pub fn resetSmoothScrollOffset(self: *@This()) void {
self.smooth_scroll_offset = 0;
self.image_scroll_offset = .{ 0, 0 };
}

pub const Mouse = struct {
/// The point on the viewport where the mouse currently is. We use
/// viewport points to avoid the complexity of mapping the mouse to
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/generic.zig
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.cell_size = undefined,
.grid_size = undefined,
.grid_padding = undefined,
.smooth_scroll_offset = 0,
.image_scroll_offset = .{ 0, 0 },
.screen_size = undefined,
.padding_extend = .{},
.min_contrast = options.config.min_contrast,
Expand Down Expand Up @@ -1212,6 +1214,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.last_bottom_y = br.y;

// Scroll
state.resetSmoothScrollOffset();
state.terminal.scrollViewport(.bottom);
}

Expand All @@ -1230,6 +1233,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// can be expensive) and also makes it so we don't need another
// cross-thread mailbox message within the IO path.
const scrollbar = state.terminal.screens.active.pages.scrollbar();
self.uniforms.smooth_scroll_offset = state.smooth_scroll_offset;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Write self.uniforms fields only while draw_mutex is held; updating them under state.mutex introduces a race with draw-time uniform reads.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/renderer/generic.zig, line 1236:

<comment>Write `self.uniforms` fields only while `draw_mutex` is held; updating them under `state.mutex` introduces a race with draw-time uniform reads.</comment>

<file context>
@@ -1230,6 +1233,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
                 // can be expensive) and also makes it so we don't need another
                 // cross-thread mailbox message within the IO path.
                 const scrollbar = state.terminal.screens.active.pages.scrollbar();
+                self.uniforms.smooth_scroll_offset = state.smooth_scroll_offset;
+                self.uniforms.image_scroll_offset = state.image_scroll_offset;
 
</file context>

self.uniforms.image_scroll_offset = state.image_scroll_offset;
Comment on lines +1236 to +1237
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Move uniform writes under draw_mutex.

self.uniforms is draw-time shared state; writing it here under only state.mutex can race with drawFrame reads and violates the file’s own lock contract.

🔧 Suggested fix
 const Critical = struct {
     links: terminal.RenderState.CellSet,
     mouse: renderer.State.Mouse,
     preedit: ?renderer.State.Preedit,
     scrollbar: terminal.Scrollbar,
+    smooth_scroll_offset: f32,
+    image_scroll_offset: [2]u16,
     overlay_features: []const Overlay.Feature,
 };
 ...
-const scrollbar = state.terminal.screens.active.pages.scrollbar();
-self.uniforms.smooth_scroll_offset = state.smooth_scroll_offset;
-self.uniforms.image_scroll_offset = state.image_scroll_offset;
+const scrollbar = state.terminal.screens.active.pages.scrollbar();
+const smooth_scroll_offset = state.smooth_scroll_offset;
+const image_scroll_offset = state.image_scroll_offset;
 ...
     break :critical .{
         .links = links,
         .mouse = state.mouse,
         .preedit = preedit,
         .scrollbar = scrollbar,
+        .smooth_scroll_offset = smooth_scroll_offset,
+        .image_scroll_offset = image_scroll_offset,
         .overlay_features = overlay_features,
     };
 };
 ...
 self.draw_mutex.lock();
 defer self.draw_mutex.unlock();
+self.uniforms.smooth_scroll_offset = critical.smooth_scroll_offset;
+self.uniforms.image_scroll_offset = critical.image_scroll_offset;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/generic.zig` around lines 1236 - 1237, The two assignments to
self.uniforms (self.uniforms.smooth_scroll_offset and
self.uniforms.image_scroll_offset) are under state.mutex and can race with
drawFrame readers; move these uniform writes so they occur while holding
draw_mutex instead of state.mutex. Specifically, after updating any state under
state.mutex keep the new values and then acquire draw_mutex before writing into
self.uniforms (and release it immediately after), ensuring drawFrame only reads
uniforms while draw_mutex is held; reference the symbols self.uniforms,
state.mutex, draw_mutex, and drawFrame when locating where to move the writes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 image_scroll_offset uniform is never read by any shader

image_scroll_offset is stored in the GPU uniform buffer here, but neither the GLSL image.v.glsl, the Metal image_vertex function, nor any fragment shader ever reads it. The kittyUpdate CPU path also doesn't receive the value to expand the image draw range. In practice, a Kitty image whose top row is just above the fractional-scroll boundary will be omitted from the draw list, leaving a blank strip where the partially-visible image row should appear.


// Get our preedit state
const preedit: ?renderer.State.Preedit = preedit: {
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/metal/shaders.zig
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ pub const Uniforms = extern struct {
/// top, right, bottom, left.
grid_padding: [4]f32 align(16),

/// Fractional vertical scroll offset in pixels.
smooth_scroll_offset: f32 align(4),

/// Extra image-row range to draw while smooth scrolling.
image_scroll_offset: [2]u16 align(4),

/// Bit mask defining which directions to
/// extend cell colors in to the padding.
/// Order, LSB first: left, right, up, down
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/opengl/shaders.zig
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ pub const Uniforms = extern struct {
/// top, right, bottom, left.
grid_padding: [4]f32 align(16),

/// Fractional vertical scroll offset in pixels.
smooth_scroll_offset: f32 align(4),

/// Extra image-row range to draw while smooth scrolling.
image_scroll_offset: [2]u16 align(4),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: image_scroll_offset is wired into uniforms but never consumed by OpenGL shaders, so the extra image-row range for fractional scrolling is not actually applied.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/renderer/opengl/shaders.zig, line 185:

<comment>`image_scroll_offset` is wired into uniforms but never consumed by OpenGL shaders, so the extra image-row range for fractional scrolling is not actually applied.</comment>

<file context>
@@ -178,6 +178,12 @@ pub const Uniforms = extern struct {
+    smooth_scroll_offset: f32 align(4),
+
+    /// Extra image-row range to draw while smooth scrolling.
+    image_scroll_offset: [2]u16 align(4),
+
     /// Bit mask defining which directions to
</file context>


/// Bit mask defining which directions to
/// extend cell colors in to the padding.
/// Order, LSB first: left, right, up, down
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/shaders/glsl/cell_bg.f.glsl
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ layout(binding = 1, std430) readonly buffer bg_cells {

vec4 cell_bg() {
uvec2 grid_size = unpack2u16(grid_size_packed_2u16);
ivec2 grid_pos = ivec2(floor((gl_FragCoord.xy - grid_padding.wx) / cell_size));
vec2 scroll_offset = vec2(0.0, smooth_scroll_offset);
ivec2 grid_pos = ivec2(floor((gl_FragCoord.xy - grid_padding.wx - scroll_offset) / cell_size));
bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0;

vec4 bg = vec4(0.0);
Expand Down
1 change: 1 addition & 0 deletions src/renderer/shaders/glsl/cell_text.v.glsl
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ void main() {

// Convert the grid x, y into world space x, y by accounting for cell size
vec2 cell_pos = cell_size * vec2(grid_pos);
cell_pos.y += smooth_scroll_offset;

int vid = gl_VertexID;

Expand Down
2 changes: 2 additions & 0 deletions src/renderer/shaders/glsl/common.glsl
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ layout(binding = 1, std140) uniform Globals {
uniform vec2 cell_size;
uniform uint grid_size_packed_2u16;
uniform vec4 grid_padding;
uniform float smooth_scroll_offset;
uniform uint image_scroll_offset_packed_2u16;
uniform uint padding_extend;
uniform float min_contrast;
uniform uint cursor_pos_packed_2u16;
Expand Down
1 change: 1 addition & 0 deletions src/renderer/shaders/glsl/image.v.glsl
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ void main() {
// The position of our image starts at the top-left of the grid cell and
// adds the source rect width/height components.
vec2 image_pos = (cell_size * grid_pos) + cell_offset;
image_pos.y += smooth_scroll_offset;
image_pos += dest_size * corner;

gl_Position = projection_matrix * vec4(image_pos.xy, 1.0, 1.0);
Expand Down
8 changes: 6 additions & 2 deletions src/renderer/shaders/shaders.metal
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ struct Uniforms {
float2 cell_size;
ushort2 grid_size;
float4 grid_padding;
float smooth_scroll_offset;
ushort2 image_scroll_offset;
uint8_t padding_extend;
float min_contrast;
ushort2 cursor_pos;
Expand Down Expand Up @@ -453,7 +455,8 @@ fragment float4 cell_bg_fragment(
constant Uniforms& uniforms [[buffer(1)]],
constant uchar4 *cells [[buffer(2)]]
) {
int2 grid_pos = int2(floor((in.position.xy - uniforms.grid_padding.wx) / uniforms.cell_size));
float2 scroll_offset = float2(0.0, uniforms.smooth_scroll_offset);
int2 grid_pos = int2(floor((in.position.xy - uniforms.grid_padding.wx - scroll_offset) / uniforms.cell_size));

float4 bg = float4(0.0);

Expand Down Expand Up @@ -561,6 +564,7 @@ vertex CellTextVertexOut cell_text_vertex(
) {
// Convert the grid x, y into world space x, y by accounting for cell size
float2 cell_pos = uniforms.cell_size * float2(in.grid_pos);
cell_pos.y += uniforms.smooth_scroll_offset;

// We use a triangle strip with 4 vertices to render quads,
// so we determine which corner of the cell this vertex is in
Expand Down Expand Up @@ -821,6 +825,7 @@ vertex ImageVertexOut image_vertex(
// The position of our image starts at the top-left of the grid cell and
// adds the source rect width/height components.
float2 image_pos = (uniforms.cell_size * in.grid_pos) + in.cell_offset;
image_pos.y += uniforms.smooth_scroll_offset;
image_pos += in.dest_size * corner;

out.position =
Expand Down Expand Up @@ -850,4 +855,3 @@ fragment float4 image_fragment(

return rgba;
}

2 changes: 2 additions & 0 deletions src/termio/Termio.zig
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,7 @@ pub fn scrollViewport(
) void {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
self.renderer_state.resetSmoothScrollOffset();
self.terminal.scrollViewport(scroll);
}

Expand All @@ -698,6 +699,7 @@ pub fn jumpToPrompt(self: *Termio, delta: isize) !void {
{
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
self.renderer_state.resetSmoothScrollOffset();
self.terminal.screens.active.scroll(.{ .delta_prompt = delta });
}

Expand Down