diff --git a/include/ghostty.h b/include/ghostty.h index b6ad3295c39..082ad66910d 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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); diff --git a/src/Surface.zig b/src/Surface.zig index 5255beece57..35892f5ffed 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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 @@ -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); + 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 }; + } + } + + 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. @@ -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. @@ -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 }); } @@ -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 }); } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index ac13e0165ad..1ac47a194d6 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -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, diff --git a/src/renderer/State.zig b/src/renderer/State.zig index 22cae8fe5d2..67276ea2a16 100644 --- a/src/renderer/State.zig +++ b/src/renderer/State.zig @@ -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 diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 8ddfab2d943..6c8afb12af7 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -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, @@ -1212,6 +1214,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.last_bottom_y = br.y; // Scroll + state.resetSmoothScrollOffset(); state.terminal.scrollViewport(.bottom); } @@ -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; // Get our preedit state const preedit: ?renderer.State.Preedit = preedit: { diff --git a/src/renderer/metal/shaders.zig b/src/renderer/metal/shaders.zig index 0be0235724d..81cbe7bcda3 100644 --- a/src/renderer/metal/shaders.zig +++ b/src/renderer/metal/shaders.zig @@ -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 diff --git a/src/renderer/opengl/shaders.zig b/src/renderer/opengl/shaders.zig index 68c1f36a344..77c6289913b 100644 --- a/src/renderer/opengl/shaders.zig +++ b/src/renderer/opengl/shaders.zig @@ -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), + /// Bit mask defining which directions to /// extend cell colors in to the padding. /// Order, LSB first: left, right, up, down diff --git a/src/renderer/shaders/glsl/cell_bg.f.glsl b/src/renderer/shaders/glsl/cell_bg.f.glsl index fa48c6736f3..e0544c212ee 100644 --- a/src/renderer/shaders/glsl/cell_bg.f.glsl +++ b/src/renderer/shaders/glsl/cell_bg.f.glsl @@ -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); diff --git a/src/renderer/shaders/glsl/cell_text.v.glsl b/src/renderer/shaders/glsl/cell_text.v.glsl index 7e38e2f0c09..22a065b5c1a 100644 --- a/src/renderer/shaders/glsl/cell_text.v.glsl +++ b/src/renderer/shaders/glsl/cell_text.v.glsl @@ -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; diff --git a/src/renderer/shaders/glsl/common.glsl b/src/renderer/shaders/glsl/common.glsl index 1a7ea03a7cd..474a3810a84 100644 --- a/src/renderer/shaders/glsl/common.glsl +++ b/src/renderer/shaders/glsl/common.glsl @@ -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; diff --git a/src/renderer/shaders/glsl/image.v.glsl b/src/renderer/shaders/glsl/image.v.glsl index 779fae32fb0..79dad8619eb 100644 --- a/src/renderer/shaders/glsl/image.v.glsl +++ b/src/renderer/shaders/glsl/image.v.glsl @@ -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); diff --git a/src/renderer/shaders/shaders.metal b/src/renderer/shaders/shaders.metal index 4e02b633685..cbfa2759350 100644 --- a/src/renderer/shaders/shaders.metal +++ b/src/renderer/shaders/shaders.metal @@ -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; @@ -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); @@ -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 @@ -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 = @@ -850,4 +855,3 @@ fragment float4 image_fragment( return rgba; } - diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 54f8e769767..1370ed08f67 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -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); } @@ -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 }); }