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
46 changes: 36 additions & 10 deletions src/render/renderer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
},
}
Expand All @@ -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;
}

Expand Down Expand Up @@ -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);

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

Expand Down Expand Up @@ -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 };
Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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();
Expand All @@ -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,

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

@as(f32, @floatFromInt(bar.len)),
);
const layout = scrollbar.computeLayout(content_rect, ui_scale, metrics) orelse {
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down
87 changes: 66 additions & 21 deletions src/ui/components/session_interaction.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);

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

if (event.wheel.which == c.SDL_TOUCH_MOUSEID) {
view.scroll_inertia_allowed = false;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Comment on lines +1073 to 1076
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 +1073 to +1086
}

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);
Expand All @@ -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();
}
Comment on lines +1136 to +1152
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;

Comment on lines +1159 to +1169
view.scroll_velocity *= decay_factor;
}

Expand Down
2 changes: 2 additions & 0 deletions src/ui/session_view_state.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
Loading