feat: add pixel-precision scrolling for terminal output#271
feat: add pixel-precision scrolling for terminal output#271forketyfork wants to merge 1 commit intomainfrom
Conversation
Replace line-by-line scrolling with sub-pixel smooth scrolling. Instead of jumping whole lines on each scroll event, pixel offsets accumulate and flush to ghostty-vt only when a full cell height is reached. The renderer applies the fractional pixel offset with SDL clip rects and renders an extra row to fill gaps. Key changes: - Add scroll_pixel_offset field to SessionViewState - scrollSession and updateScrollInertia work in pixel space - Renderer shifts rows by pixel offset with content clipping - Scrollbar metrics include fractional offset for smooth thumb - Inertia snap-to-line when velocity decays below threshold https://claude.ai/code/session_01LdvTgG5JiFGXQcJkpa3eN6
There was a problem hiding this comment.
Pull request overview
Implements pixel-precision (sub-line) scrolling for terminal scrollback by accumulating fractional offsets and only committing whole-row scrolls to ghostty-vt, while rendering content with a per-frame pixel shift and updating scrollbar metrics to reflect fractional positions.
Changes:
- Add
scroll_pixel_offsettoSessionViewStateand reset it with other scroll state. - Convert wheel + inertia scrolling to accumulate pixel offsets and flush whole lines to
ghostty-vt, including an inertia snap-to-line behavior. - Update renderer to shift terminal rows by the pixel offset (with clipping + extra row) and include fractional offset in scrollbar thumb metrics.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| src/ui/session_view_state.zig | Adds/clears new scroll pixel offset state. |
| src/ui/components/session_interaction.zig | Routes scroll + inertia through pixel-offset accumulation and snapping. |
| src/render/renderer.zig | Applies pixel-offset rendering shift, clipping, extra-row fill, and fractional scrollbar thumb. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fn scrollSession(session: *SessionState, view: *SessionViewState, delta: isize, cell_height: c_int, now: i64) void { | ||
| if (!session.spawned) return; | ||
| if (cell_height <= 0) return; | ||
|
|
| 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); |
| // 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(); | ||
| } |
| // 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; | ||
|
|
| // 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); | ||
|
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eda9f0c80d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| if (!forwarded) { | ||
| scrollSession(session, view, scroll_delta, host.now_ms); | ||
| scrollSession(session, view, scroll_delta, self.font.cell_height, host.now_ms); |
There was a problem hiding this comment.
Pass scaled cell height into smooth scroll physics
Wheel and inertia scrolling now always use self.font.cell_height as the pixel unit, but sessions in Grid/Expanding/Collapsing are rendered with a smaller per-session cell_height_actual (renderer.zig scales rows by scale). This makes scroll_pixel_offset larger than a rendered row in those modes, so content can shift by multiple visual rows while only one extra row is rendered, causing visible jumps/blank bands when scrolling non-fullscreen tiles. Compute scroll pixel deltas with the active session’s rendered cell height (or store offsets in row fractions) instead of the unscaled font height.
Useful? React with 👍 / 👎.
| const metrics = scrollbar.Metrics.init( | ||
| @as(f32, @floatFromInt(bar.total)), | ||
| @as(f32, @floatFromInt(bar.offset)), | ||
| @as(f32, @floatFromInt(bar.offset)) + fractional_offset, |
There was a problem hiding this comment.
Keep scrollbar hit-testing aligned with fractional thumb offset
The renderer now positions the thumb using bar.offset + fractional_offset, but input-side scrollbar layout (terminalScrollbarContext) still uses only integer bar.offset. When scroll_pixel_offset is non-zero during smooth/inertial scrollback, the drawn thumb and the hover/drag geometry are computed from different offsets, so pointer hit-testing can miss or jump relative to what is on screen (most noticeable with short scrollback ranges). Use the same fractional offset term in the interaction metrics.
Useful? React with 👍 / 👎.
Replace line-by-line scrolling with sub-pixel smooth scrolling.
Instead of jumping whole lines on each scroll event, pixel offsets
accumulate and flush to ghostty-vt only when a full cell height is
reached. The renderer applies the fractional pixel offset with SDL
clip rects and renders an extra row to fill gaps.
Key changes:
https://claude.ai/code/session_01LdvTgG5JiFGXQcJkpa3eN6