Skip to content

feat: add pixel-precision scrolling for terminal output#271

Open
forketyfork wants to merge 1 commit intomainfrom
claude/pixel-precision-scroll-qcn81
Open

feat: add pixel-precision scrolling for terminal output#271
forketyfork wants to merge 1 commit intomainfrom
claude/pixel-precision-scroll-qcn81

Conversation

@forketyfork
Copy link
Owner

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

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
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

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_offset to SessionViewState and 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.

Comment on lines +1073 to 1076
fn scrollSession(session: *SessionState, view: *SessionViewState, delta: isize, cell_height: c_int, now: i64) void {
if (!session.spawned) return;
if (cell_height <= 0) return;

Comment on lines +1073 to +1086
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);
Comment on lines +1136 to +1152
// 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();
}
Comment on lines +1159 to +1169
// 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;

Comment on lines +374 to +387
// 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);

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 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);

Choose a reason for hiding this comment

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

P1 Badge 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,

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants