diff --git a/src/render/renderer.zig b/src/render/renderer.zig index fe90fe4..387844a 100644 --- a/src/render/renderer.zig +++ b/src/render/renderer.zig @@ -286,7 +286,7 @@ pub fn render( }; }; - renderSessionOverlays(renderer, session, &views[i], cell_rect, i == anim_state.focused_session, true, current_time, true, theme, ui_scale); + renderSessionOverlays(renderer, session, &views[i], cell_rect, i == anim_state.focused_session, true, current_time, true, theme, ui_scale, font.cell_height); } }, } @@ -310,7 +310,7 @@ fn renderSession( ui_scale: f32, ) RenderError!void { try renderSessionContent(renderer, session, view, rect, scale, is_focused, font, term_cols, term_rows, current_time_ms, theme, ui_scale); - renderSessionOverlays(renderer, session, view, rect, is_focused, apply_effects, current_time_ms, is_grid_view, theme, ui_scale); + renderSessionOverlays(renderer, session, view, rect, is_focused, apply_effects, current_time_ms, is_grid_view, theme, ui_scale, font.cell_height); cache_entry.presented_epoch = session.render_epoch; } @@ -369,18 +369,39 @@ fn renderSessionContent( if (drawable_w <= 0 or drawable_h <= 0) return; const origin_x: c_int = rect.x + padding; - const origin_y: c_int = rect.y + padding; + const origin_y_base: c_int = rect.y + padding; + + // Pixel-precision scroll: shift content by sub-line pixel offset + const pixel_offset: f32 = if (view.is_viewing_scrollback) view.scroll_pixel_offset else 0.0; + const pixel_offset_int: c_int = @intFromFloat(pixel_offset); + const origin_y: c_int = origin_y_base - pixel_offset_int; const max_cols_fit: usize = @intCast(@max(0, @divFloor(drawable_w, cell_width_actual))); const max_rows_fit: usize = @intCast(@max(0, @divFloor(drawable_h, cell_height_actual))); const visible_cols: usize = @min(@as(usize, term_cols), max_cols_fit); - const visible_rows: usize = @min(@as(usize, term_rows), max_rows_fit); + // Render one extra row when pixel offset is non-zero to fill the gap at the bottom. + // The extra row may not have data (getCell returns null), in which case it's skipped + // and the background color (already filled) provides a seamless appearance. + const extra_row: usize = if (pixel_offset_int > 0) 1 else 0; + const render_rows: usize = @min(@as(usize, term_rows) + extra_row, max_rows_fit + extra_row); + + // Set clip rect to prevent overflow from pixel offset + const content_clip = c.SDL_Rect{ + .x = rect.x + padding, + .y = origin_y_base, + .w = drawable_w, + .h = drawable_h, + }; + var prev_clip: c.SDL_Rect = undefined; + _ = c.SDL_GetRenderClipRect(renderer, &prev_clip); + _ = c.SDL_SetRenderClipRect(renderer, &content_clip); + defer _ = c.SDL_SetRenderClipRect(renderer, &prev_clip); const default_fg = c.SDL_Color{ .r = theme.foreground.r, .g = theme.foreground.g, .b = theme.foreground.b, .a = 255 }; const active_selection = screen.selection; var row: usize = 0; - while (row < visible_rows) : (row += 1) { + while (row < render_rows) : (row += 1) { const eff_cw = cell_width_actual; const eff_ch = cell_height_actual; @@ -609,7 +630,7 @@ fn renderSessionContent( const message = "[Process completed]"; const message_row: usize = @intCast(cursor.y); - if (message_row < visible_rows) { + if (message_row < render_rows) { const message_x: c_int = origin_x; const message_y: c_int = origin_y + @as(c_int, @intCast(message_row)) * cell_height_actual; const fg_color = c.SDL_Color{ .r = 92, .g = 99, .b = 112, .a = 255 }; @@ -634,6 +655,7 @@ fn renderSessionOverlays( is_grid_view: bool, theme: *const colors.Theme, ui_scale: f32, + cell_height: c_int, ) void { const has_attention = is_grid_view and view.attention; const border_thickness: c_int = dpi.scale(attention_thickness, ui_scale); @@ -715,7 +737,7 @@ fn renderSessionOverlays( _ = c.SDL_RenderFillRect(renderer, &tint_rect); } - renderTerminalScrollbar(renderer, session, view, rect, theme, ui_scale); + renderTerminalScrollbar(renderer, session, view, rect, theme, ui_scale, cell_height); } fn renderTerminalScrollbar( @@ -725,6 +747,7 @@ fn renderTerminalScrollbar( rect: Rect, theme: *const colors.Theme, ui_scale: f32, + cell_height: c_int, ) void { if (!session.spawned) { view.terminal_scrollbar.hideNow(); @@ -739,9 +762,12 @@ fn renderTerminalScrollbar( return; }; const bar = terminal.screens.active.pages.scrollbar(); + // Add fractional offset from pixel-precision scrolling for smooth scrollbar thumb + const cell_h_f: f32 = @floatFromInt(@max(1, cell_height)); + const fractional_offset = if (view.is_viewing_scrollback) view.scroll_pixel_offset / cell_h_f else 0.0; const metrics = scrollbar.Metrics.init( @as(f32, @floatFromInt(bar.total)), - @as(f32, @floatFromInt(bar.offset)), + @as(f32, @floatFromInt(bar.offset)) + fractional_offset, @as(f32, @floatFromInt(bar.len)), ); const layout = scrollbar.computeLayout(content_rect, ui_scale, metrics) orelse { @@ -842,7 +868,7 @@ fn renderGridSessionCached( const local_rect = Rect{ .x = 0, .y = 0, .w = rect.w, .h = rect.h }; try renderSessionContent(renderer, session, view, local_rect, scale, is_focused, font, term_cols, term_rows, current_time_ms, theme, ui_scale); if (any_waving and render_overlays) { - renderSessionOverlays(renderer, session, view, local_rect, is_focused, apply_effects, current_time_ms, true, theme, ui_scale); + renderSessionOverlays(renderer, session, view, local_rect, is_focused, apply_effects, current_time_ms, true, theme, ui_scale, font.cell_height); } cache_entry.cache_epoch = session.render_epoch; _ = c.SDL_SetRenderTarget(renderer, null); @@ -859,7 +885,7 @@ fn renderGridSessionCached( }; _ = c.SDL_RenderTexture(renderer, tex, null, &dest_rect); if (render_overlays) { - renderSessionOverlays(renderer, session, view, rect, is_focused, apply_effects, current_time_ms, true, theme, ui_scale); + renderSessionOverlays(renderer, session, view, rect, is_focused, apply_effects, current_time_ms, true, theme, ui_scale, font.cell_height); } } cache_entry.presented_epoch = session.render_epoch; diff --git a/src/ui/components/session_interaction.zig b/src/ui/components/session_interaction.zig index 9554070..ea6733b 100644 --- a/src/ui/components/session_interaction.zig +++ b/src/ui/components/session_interaction.zig @@ -443,7 +443,7 @@ pub const SessionInteractionComponent = struct { } if (!forwarded) { - scrollSession(session, view, scroll_delta, host.now_ms); + scrollSession(session, view, scroll_delta, self.font.cell_height, host.now_ms); if (event.wheel.which == c.SDL_TOUCH_MOUSEID) { view.scroll_inertia_allowed = false; } @@ -475,7 +475,7 @@ pub const SessionInteractionComponent = struct { const delta_time_s: f32 = @as(f32, @floatFromInt(delta_ms)) / 1000.0; for (self.sessions, 0..) |session, idx| { const view = &self.views[idx]; - updateScrollInertia(session, view, delta_time_s, host.now_ms); + updateScrollInertia(session, view, delta_time_s, self.font.cell_height, host.now_ms); view.terminal_scrollbar.update(host.now_ms); if (view.wave_start_time > 0) { const wave_elapsed = host.now_ms - view.wave_start_time; @@ -617,6 +617,7 @@ pub const SessionInteractionComponent = struct { ctx.view.is_viewing_scrollback = (pages.viewport != .active); ctx.view.scroll_velocity = 0.0; ctx.view.scroll_remainder = 0.0; + ctx.view.scroll_pixel_offset = 0.0; ctx.view.scroll_inertia_allowed = false; ctx.view.last_scroll_time = now_ms; ctx.view.terminal_scrollbar.noteActivity(now_ms); @@ -1069,31 +1070,61 @@ fn getLinkAtPin(allocator: std.mem.Allocator, terminal: *ghostty_vt.Terminal, pi return null; } -fn scrollSession(session: *SessionState, view: *SessionViewState, delta: isize, now: i64) void { +fn scrollSession(session: *SessionState, view: *SessionViewState, delta: isize, cell_height: c_int, now: i64) void { if (!session.spawned) return; + if (cell_height <= 0) return; view.last_scroll_time = now; view.scroll_remainder = 0.0; view.scroll_inertia_allowed = true; + const pixel_delta = @as(f32, @floatFromInt(delta)) * @as(f32, @floatFromInt(cell_height)); + applyPixelScroll(session, view, pixel_delta, cell_height, now); + + const sensitivity: f32 = 0.08; + view.scroll_velocity += @as(f32, @floatFromInt(delta)) * sensitivity; + view.scroll_velocity = std.math.clamp(view.scroll_velocity, -max_scroll_velocity, max_scroll_velocity); +} + +fn applyPixelScroll(session: *SessionState, view: *SessionViewState, pixel_delta: f32, cell_height: c_int, now: i64) void { + const cell_h_f: f32 = @floatFromInt(cell_height); if (session.terminal) |*terminal| { var pages = &terminal.screens.active.pages; - pages.scroll(.{ .delta_row = delta }); + + // Scrolling up (negative delta = towards older content): increase pixel offset + // Scrolling down (positive delta = towards newer content): decrease pixel offset + view.scroll_pixel_offset -= pixel_delta; + + // Flush whole lines to ghostty-vt + if (view.scroll_pixel_offset >= cell_h_f) { + const lines_f = @floor(view.scroll_pixel_offset / cell_h_f); + const lines: isize = @intFromFloat(lines_f); + pages.scroll(.{ .delta_row = -lines }); + view.scroll_pixel_offset -= lines_f * cell_h_f; + } else if (view.scroll_pixel_offset < 0.0) { + const lines_f = @ceil(-view.scroll_pixel_offset / cell_h_f); + const lines: isize = @intFromFloat(lines_f); + pages.scroll(.{ .delta_row = lines }); + view.scroll_pixel_offset += lines_f * cell_h_f; + } + + // When we've scrolled back to the active viewport, snap pixel offset to 0 view.is_viewing_scrollback = (pages.viewport != .active); + if (!view.is_viewing_scrollback) { + view.scroll_pixel_offset = 0.0; + } + view.terminal_scrollbar.noteActivity(now); session.markDirty(); } - - const sensitivity: f32 = 0.08; - view.scroll_velocity += @as(f32, @floatFromInt(delta)) * sensitivity; - view.scroll_velocity = std.math.clamp(view.scroll_velocity, -max_scroll_velocity, max_scroll_velocity); } -fn updateScrollInertia(session: *SessionState, view: *SessionViewState, delta_time_s: f32, now_ms: i64) void { +fn updateScrollInertia(session: *SessionState, view: *SessionViewState, delta_time_s: f32, cell_height: c_int, now_ms: i64) void { if (!session.spawned) return; if (!view.scroll_inertia_allowed) return; if (view.scroll_velocity == 0.0) return; if (view.last_scroll_time == 0) return; + if (cell_height <= 0) return; const decay_constant: f32 = 7.5; const decay_factor = std.math.exp(-decay_constant * delta_time_s); @@ -1102,26 +1133,40 @@ fn updateScrollInertia(session: *SessionState, view: *SessionViewState, delta_ti if (@abs(view.scroll_velocity) < velocity_threshold) { view.scroll_velocity = 0.0; view.scroll_remainder = 0.0; + // Snap to nearest line boundary when inertia stops + if (view.scroll_pixel_offset != 0.0) { + const cell_h_f: f32 = @floatFromInt(cell_height); + if (session.terminal) |*terminal| { + var pages = &terminal.screens.active.pages; + if (view.scroll_pixel_offset >= cell_h_f * 0.5) { + // Closer to next line up: commit one more scroll line + pages.scroll(.{ .delta_row = -1 }); + } + view.is_viewing_scrollback = (pages.viewport != .active); + } + view.scroll_pixel_offset = 0.0; + if (!view.is_viewing_scrollback) { + view.scroll_pixel_offset = 0.0; + } + session.markDirty(); + } return; } const reference_fps: f32 = 60.0; + const cell_h_f: f32 = @floatFromInt(cell_height); - if (session.terminal) |*terminal| { - const scroll_amount = view.scroll_velocity * delta_time_s * reference_fps + view.scroll_remainder; - const scroll_lines: isize = @intFromFloat(scroll_amount); + // velocity is in lines/unit; convert to pixel delta + const scroll_lines = view.scroll_velocity * delta_time_s * reference_fps + view.scroll_remainder; + const pixel_delta = scroll_lines * cell_h_f; - if (scroll_lines != 0) { - var pages = &terminal.screens.active.pages; - pages.scroll(.{ .delta_row = scroll_lines }); - view.is_viewing_scrollback = (pages.viewport != .active); - view.terminal_scrollbar.noteActivity(now_ms); - session.markDirty(); - } - - view.scroll_remainder = scroll_amount - @as(f32, @floatFromInt(scroll_lines)); + if (pixel_delta != 0.0) { + applyPixelScroll(session, view, pixel_delta, cell_height, now_ms); } + // Track remainder in line-space for next frame + view.scroll_remainder = 0.0; + view.scroll_velocity *= decay_factor; } diff --git a/src/ui/session_view_state.zig b/src/ui/session_view_state.zig index 90149cc..811cbfe 100644 --- a/src/ui/session_view_state.zig +++ b/src/ui/session_view_state.zig @@ -8,6 +8,7 @@ pub const SessionViewState = struct { is_viewing_scrollback: bool = false, scroll_velocity: f32 = 0.0, scroll_remainder: f32 = 0.0, + scroll_pixel_offset: f32 = 0.0, last_scroll_time: i64 = 0, scroll_inertia_allowed: bool = true, selection_anchor: ?ghostty_vt.Pin = null, @@ -39,6 +40,7 @@ pub const SessionViewState = struct { self.is_viewing_scrollback = false; self.scroll_velocity = 0.0; self.scroll_remainder = 0.0; + self.scroll_pixel_offset = 0.0; self.last_scroll_time = 0; self.scroll_inertia_allowed = true; self.terminal_scrollbar.hideNow();