diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-02 20:42:29 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | a93b796a3389acaf45775d302f559e01c19ddbfc (patch) | |
| tree | 2cffc0c4e6960302e9da271a30b2ef3ddc81744e | |
| parent | 99766966fff7cf9581fe1432beb0220cc96c421f (diff) | |
| download | pardes-a93b796a3389acaf45775d302f559e01c19ddbfc.tar.gz pardes-a93b796a3389acaf45775d302f559e01c19ddbfc.zip | |
make SDL scrolling continuous and linear
| -rw-r--r-- | build.zig | 5 | ||||
| -rw-r--r-- | src/gui/gui.zig | 257 |
2 files changed, 145 insertions, 117 deletions
@@ -787,8 +787,9 @@ pub fn build(b: *std.Build) void { // is a second thing to keep in step. const core_test = b.addTest(.{ .root_module = hx_core_mod }); unit_step.dependOn(&b.addRunArtifact(core_test).step); - // gui.zig's own inline tests (the smooth-scroll ease). Only reachable - // with -Dplatform=gui, and it links the SAME module the gui exe does + // gui.zig's own inline tests (the fractional-scroll motion). + // Only reachable with -Dplatform=gui, and it links the SAME module + // the gui exe does // for the same reason core_test does: SDL, the shaders and the font // are already wired onto it, and a second wiring is a second thing to // keep in step. diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 0069164d..efba7bda 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -115,13 +115,6 @@ const touch_scroll_tick: f32 = 0.02; const touch_tap_slop_px: f32 = 12.0; const max_overlay_vertices: usize = touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); -// Smooth scroll: the fraction of the distance still to travel that the -// picture covers each frame (the loop runs at ~60fps, so a wheel row lands in -// about six), and the distance under which it simply arrives instead of -// halving forever. See stepScroll. -const scroll_ease: f32 = 0.35; -const scroll_ease_min: f32 = 0.05; - // Ctrl+ / Ctrl-: how far one press moves g.px, and the two sizes it stops at. // ONE size for the whole window, not one per pane: the core lays every pane // out on a single uniform cell grid and Surface is one flat cols×rows array, @@ -203,21 +196,22 @@ const NativeImage = struct { texture: *c.SDL_GPUTexture, }; -fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32) void { +fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32) bool { if (comptime pardes.pdf_enabled) { const cell_px_w: u16 = @intCast(@min(@max(1, cell_w), std.math.maxInt(u16))); const cell_px_h: u16 = @intCast(@min(@max(1, cell_h), std.math.maxInt(u16))); if (cols == core.screen_w and rows == core.screen_h and - cell_px_w == core.cell_pixels.w and cell_px_h == core.cell_pixels.h) return; + cell_px_w == core.cell_pixels.w and cell_px_h == core.cell_pixels.h) return false; core.update(.{ .resize = .{ .cols = cols, .rows = rows, .cell_pixels = .{ .w = cell_px_w, .h = cell_px_h }, } }); } else { - if (cols == core.screen_w and rows == core.screen_h) return; + if (cols == core.screen_w and rows == core.screen_h) return false; core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); } + return true; } // ---- touch: per-finger tracking + shared scroll/tap machines ---- @@ -892,22 +886,21 @@ const Gui = struct { live_ctrl: bool = false, live_alt: bool = false, - // Smooth scroll, one pane at a time. The core still scrolls in whole - // rows and knows nothing about any of this — it just hears about them a - // frame or two late while the renderer draws the pane at the sub-row - // offset in between. See stepScroll. + // Fractional wheel scroll, one pane at a time. SDL's exact floating-point + // distance is batched until the next render. The core still moves only at + // whole-row boundaries; scroll_lag retains the sub-row picture position. // - // ponytail: one queue, so exactly one pane can be sliding and only the + // ponytail: one accumulator, so exactly one pane can be offset and only the // MOUSE wheel fills it — the deck's left stick and a two-finger touch // scroll still hand the core their whole rows on the spot (they have // their own sub-tick accumulators, and neither aims well enough to miss - // the animation). Both are one call site each: point them at - // scroll_pending the way the wheel arm of dispatch does. + // the fractional rendering). Both are one call site each: point them at + // scroll_delta the way the wheel arm of dispatch does. scroll_pane: ?usize = null, scroll_rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, - scroll_col: u16 = 0, // where the wheel turned: the owed rows are delivered + scroll_col: u16 = 0, // where the wheel turned: crossed rows are delivered scroll_row: u16 = 0, // THERE, not wherever the pointer has drifted to since - scroll_pending: i32 = 0, // whole rows the core has not been told about yet + scroll_delta: f32 = 0, // finite raw distance waiting for the render batch scroll_lag: f32 = 0, // picture position - core position, in rows scroll_edge: []pardes.Cell = &.{}, // the row that just left the pane scroll_edge_len: u16 = 0, @@ -1322,8 +1315,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))); const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); - updateCoreResize(core, cols, rows, g.cell_w, g.cell_h); - // 7. render — the scroll animation steps LAST before it, while + if (updateCoreResize(core, cols, rows, g.cell_w, g.cell_h)) resetScroll(&g); + // 7. render — apply the wheel batch LAST before it, while // core.surface still holds the frame the last pass drew stepScroll(&g, core, gpa); _ = frame_arena.reset(.retain_capacity); @@ -1606,8 +1599,8 @@ fn webFrame(arg: ?*anyopaque) callconv(.c) void { _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))); const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); - updateCoreResize(core, cols, rows, g.cell_w, g.cell_h); - // 4. render — scroll animation last before it, see runNative + if (updateCoreResize(core, cols, rows, g.cell_w, g.cell_h)) resetScroll(g); + // 4. render — apply the wheel batch last, see runNative stepScroll(g, core, st.gpa); _ = st.frame_arena.reset(.retain_capacity); const surface = core.render(st.frame_arena.allocator()) catch return; @@ -2091,45 +2084,29 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { g.mouse_x = w.mouse_x; g.mouse_y = w.mouse_y; const mc = mouseCell(g, core, w.mouse_x, w.mouse_y); - if (w.y != 0) { - // Still exactly one core row per tick, whatever the wheel's - // resolution — only queued, so stepScroll can hand the rows - // over across the next few frames and slide the pane in - // between. The pane is hit-tested here (the core repeats the - // test for the event it eventually gets) because the queue - // belongs to ONE pane: rows already owed are flushed the - // moment the wheel turns over another one, and a tick that - // hits no pane at all is not an animation, just an event. + if (w.y != 0 and std.math.isFinite(w.y)) { + // Preserve SDL's floating-point distance. Input events drained + // in this loop naturally form one render batch; stepScroll + // applies their exact sum and tells the core only about whole + // row boundaries. One accumulator belongs to one pane, so a + // wheel event over another pane first retires the old offset. const hit: ?usize = for (core.panes, 0..) |slot, i| { if (slot == null) continue; const r = core.rects[i]; if (mc.col >= r.x and mc.col < r.x + r.w and mc.row >= r.y and mc.row < r.y + r.h) break i; } else null; if (g.scroll_pane) |old| if (hit == null or hit.? != old) { - while (g.scroll_pending != 0) { - const down = g.scroll_pending > 0; - g.scroll_pending += if (down) -1 else 1; - core.update(.{ .mouse = .{ - .button = if (down) .wheel_down else .wheel_up, - .kind = .press, - .col = g.scroll_col, - .row = g.scroll_row, - } }); - } - g.scroll_lag = 0; - g.scroll_pane = null; + // There is one fractional overlay, not one per pane. Retire + // the old one at its already boundary-rounded core state; + // carrying its lag into `hit` would move the wrong pane. + resetScroll(g); }; if (hit) |id| { g.scroll_pane = id; g.scroll_col = mc.col; g.scroll_row = mc.row; - g.scroll_pending += if (w.y > 0) -1 else 1; - } else core.update(.{ .mouse = .{ - .button = if (w.y > 0) .wheel_up else .wheel_down, - .kind = .press, - .col = mc.col, - .row = mc.row, - } }); + g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); + } } if (w.x != 0) core.update(.{ .mouse = .{ .button = if (w.x > 0) .wheel_right else .wheel_left, @@ -2583,26 +2560,38 @@ fn openLinkWeb(gpa: std.mem.Allocator, url: []const u8) void { } // ===================================================================== -// smooth scroll: whole rows for the core, sub-row offsets for the picture +// fractional scroll: whole rows for the core, sub-row offsets for the picture // ===================================================================== -/// One frame of the ease, as pure arithmetic: where the picture lands and how -/// many whole rows that owes the core (positive = down). Split out of -/// stepScroll only because it is the half a test can hold still. +/// Add one raw SDL vertical-wheel value to this render batch. SDL calls up +/// positive; the picture coordinate below calls down positive. Non-finite +/// input, including an addition that overflows, cannot enter persistent state. +fn accumulateWheelDelta(pending: f32, raw_y: f32) f32 { + if (!std.math.isFinite(raw_y)) return pending; + const next = pending - raw_y; + return if (std.math.isFinite(next)) next else pending; +} + +/// The old surface can supply at most one body-height of historical rows. +/// Clamp only pathological per-frame batches to that renderable range; normal +/// SDL deltas pass through unchanged. Including lag in the bound guarantees +/// applyScrollDelta cannot cross more than `body_rows` core boundaries. +fn boundScrollDelta(lag: f32, delta: f32, body_rows: u16) f32 { + const limit: f32 = @floatFromInt(body_rows); + return std.math.clamp(lag + delta, -limit, limit) - lag; +} + +/// Apply an exact picture displacement and return the whole core rows it +/// crosses (positive = down) plus the retained sub-row picture/core offset. /// -/// The rule that makes the whole thing work is in the loop: every row the -/// picture crosses goes to the core IMMEDIATELY, so the core is always -/// rounded one step AHEAD of the picture, in the direction of travel, and the -/// lag it comes back with always points backwards. That is what makes the -/// strip of content the offset exposes a row from the PAST — the surface the -/// last frame painted still has it — instead of one from the future, which -/// nothing anywhere has. -fn scrollStep(pending: i32, lag: f32) struct { lag: f32, rows: i32 } { - const remaining = @as(f32, @floatFromInt(pending)) - lag; - const travel = if (@abs(remaining) <= scroll_ease_min) remaining else remaining * scroll_ease; - var l = lag + travel; +/// Crossing in the direction of travel rounds the core one row ahead of the +/// picture and leaves lag pointing back at it. Therefore the exposed strip is +/// always the one historical edge row which the previous surface still owns; +/// the renderer never needs a row from the future. +fn applyScrollDelta(lag: f32, delta: f32) struct { lag: f32, rows: i32 } { + var l = lag + delta; var rows: i32 = 0; - while ((travel > 0 and l > 0) or (travel < 0 and l < 0)) { + while ((delta > 0 and l > 0) or (delta < 0 and l < 0)) { const down = l > 0; l += if (down) -1 else 1; rows += if (down) 1 else -1; @@ -2610,49 +2599,94 @@ fn scrollStep(pending: i32, lag: f32) struct { lag: f32, rows: i32 } { return .{ .lag = l, .rows = rows }; } -test "smooth scroll eases in, and the core is always a whole row ahead" { - // one wheel row: the core gets its row on the very first frame and the - // picture trails behind by less than a row, then closes on it - var s = scrollStep(1, 0); +test "fractional wheel delta moves the picture by the same fraction" { + const delta = accumulateWheelDelta(0, -0.125); + const s = applyScrollDelta(0, delta); + const picture = @as(f32, @floatFromInt(s.rows)) + s.lag; + try std.testing.expectEqual(@as(f32, 0.125), delta); + try std.testing.expectEqual(@as(f32, 0.125), picture); try std.testing.expectEqual(@as(i32, 1), s.rows); - try std.testing.expect(s.lag > -1 and s.lag < 0); - var frames: u32 = 0; - while (s.lag != 0 and frames < 30) : (frames += 1) { - const next = scrollStep(0, s.lag); - try std.testing.expectEqual(@as(i32, 0), next.rows); // no second crossing - try std.testing.expect(@abs(next.lag) < @abs(s.lag)); // always closing - s = next; + try std.testing.expectEqual(@as(f32, -0.875), s.lag); +} + +test "repeated fractional deltas cross exactly one core row" { + var core_row: i32 = 0; + var lag: f32 = 0; + var crossed: u32 = 0; + for (0..8) |_| { + const s = applyScrollDelta(lag, 0.125); + core_row += s.rows; + crossed += @intCast(@abs(s.rows)); + lag = s.lag; } - try std.testing.expectEqual(@as(f32, 0), s.lag); // and it arrives, exactly - // a fast spin hands over several rows at once and still parks the picture - // inside one row of the core — the exposed strip is never further back - // than the surface the last frame painted - s = scrollStep(-4, 0); - try std.testing.expect(s.rows <= -1 and s.rows >= -4); - try std.testing.expect(s.lag >= 0 and s.lag < 1); - // reversing mid-slide re-rounds the core the other way instead of letting - // the lag point forwards - s = scrollStep(-1, -0.65); - try std.testing.expectEqual(@as(i32, -1), s.rows); - try std.testing.expect(s.lag > 0 and s.lag < 1); + try std.testing.expectEqual(@as(u32, 1), crossed); + try std.testing.expectEqual(@as(i32, 1), core_row); + try std.testing.expectEqual(@as(f32, 0), lag); } -/// Advance the animation one frame: ease the picture, hand the core the whole -/// rows the picture crossed, and keep the row those rows pushed out of the -/// pane. Called from both shells right before core.render() — which is the -/// only moment core.surface still holds what the LAST frame drew. +test "wheel magnitude is preserved without quantization" { + const delta = accumulateWheelDelta(0, -2.75); + const s = applyScrollDelta(0, delta); + const picture = @as(f32, @floatFromInt(s.rows)) + s.lag; + try std.testing.expectEqual(@as(f32, 2.75), delta); + try std.testing.expectEqual(@as(f32, 2.75), picture); + try std.testing.expectEqual(@as(i32, 3), s.rows); + try std.testing.expectEqual(@as(f32, -0.25), s.lag); + try std.testing.expectEqual(@as(f32, 0), accumulateWheelDelta(0, std.math.inf(f32))); + + // A finite but nonsensical device value is bounded before the reducer, + // avoiding an unbounded loop while keeping the largest renderable move. + const bounded = boundScrollDelta(0, 3.0e38, 24); + const safe = applyScrollDelta(0, bounded); + try std.testing.expectEqual(@as(f32, 24), bounded); + try std.testing.expectEqual(@as(i32, 24), safe.rows); +} + +test "fractional scroll reversals preserve signed distance" { + var core_row: i32 = 0; + const forward = applyScrollDelta(0, 0.375); + core_row += forward.rows; + try std.testing.expectEqual(@as(f32, 0.375), @as(f32, @floatFromInt(core_row)) + forward.lag); + + const reverse = applyScrollDelta(forward.lag, -0.125); + core_row += reverse.rows; + try std.testing.expectEqual(@as(i32, -1), reverse.rows); + try std.testing.expectEqual(@as(f32, 0.25), @as(f32, @floatFromInt(core_row)) + reverse.lag); + try std.testing.expect(reverse.lag >= 0 and reverse.lag < 1); +} + +fn resetScroll(g: *Gui) void { + g.scroll_pane = null; + g.scroll_delta = 0; + g.scroll_lag = 0; + g.scroll_edge_len = 0; +} + +/// Apply this frame's exact wheel batch, hand the core every whole row it +/// crossed, and retain the one historical row exposed by the residual offset. +/// Called immediately before core.render(), while core.surface still holds +/// what the previous frame drew. fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { const id = g.scroll_pane orelse return; const pane = core.panes[id] orelse { - g.scroll_pane = null; - g.scroll_pending = 0; - g.scroll_lag = 0; + resetScroll(g); return; }; + const queued_delta = g.scroll_delta; + g.scroll_delta = 0; + if (queued_delta == 0) { + if (g.scroll_lag == 0) resetScroll(g); + return; + } // the rect the last frame was painted through — what the snapshot below // indexes. Nothing between here and render() moves it. const r = core.rects[id]; - const st = scrollStep(g.scroll_pending, g.scroll_lag); + if (r.h <= pardes.BOX_H) { + resetScroll(g); + return; + } + const delta = boundScrollDelta(g.scroll_lag, queued_delta, r.h - pardes.BOX_H); + const st = applyScrollDelta(g.scroll_lag, delta); var left = st.rows; while (left != 0) { const down = left > 0; @@ -2668,13 +2702,10 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { // terminal): there is no travel left to draw, so stop dead rather // than slide the pane against a view that is not moving if (pane.scroll() == was) { - g.scroll_pane = null; - g.scroll_pending = 0; - g.scroll_lag = 0; + resetScroll(g); return; } } - g.scroll_pending -= st.rows; g.scroll_lag = st.lag; g.scroll_rect = r; if (st.rows != 0) { @@ -2702,10 +2733,10 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { } } } - if (g.scroll_pending == 0 and g.scroll_lag == 0) g.scroll_pane = null; + if (g.scroll_lag == 0) resetScroll(g); } -/// The animated pane's body, emitted a SECOND time at its sub-row offset, +/// The fractional pane body, emitted a SECOND time at its sub-row offset, /// plus the one row of history that fills the gap the offset opens. Writes /// instances at `base` and returns how many; the caller draws them scissored /// to the body, which is the whole of the clipping — the shell draws the grid @@ -3016,7 +3047,7 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on const image_count = prepareNativeImages(g, cmd, surface, sw, sh); const cells: u32 = @as(u32, surface.cols) * surface.rows; - // ponytail: ×2, because the smooth-scroll pane is drawn a second time, + // ponytail: ×2, because the fractional-scroll pane is drawn a second time, // offset, out of the tail of the same buffer — and a pane is never bigger // than the grid. The ceiling is one grid's worth of vertex memory nobody // uses when nothing is sliding; sizing it to the actual pane instead means @@ -3260,21 +3291,17 @@ fn refitFont(g: *Gui, core: *pardes.Pardes) void { _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))); const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); - updateCoreResize(core, cols, rows, g.cell_w, g.cell_h); + _ = updateCoreResize(core, cols, rows, g.cell_w, g.cell_h); - // ...and a smooth scroll in flight is measured in the OLD grid: scroll_rect + // ...and a fractional scroll is measured in the OLD grid: scroll_rect // is a rect of the pane the last frame drew, and scroll_edge is a saved row // of exactly that rect's body WIDTH. The resize above moves both under it, // and emitScrollRows only checks that the old rect still FITS inside the new // surface — which it does whenever the font got smaller — so the next frame // would paint last frame's strip over cells that are no longer the same - // text. Stop the slide instead; the wheel refills all of it on the next - // tick. ponytail: the rows still owed the core (scroll_pending) are dropped - // rather than flushed, because a font resize mid-flick is not a moment - // anyone is counting rows in; flush them the day someone notices. - g.scroll_pane = null; - g.scroll_pending = 0; - g.scroll_lag = 0; + // text. Retire the offset instead; the next wheel event starts in the new + // grid. + resetScroll(g); } fn ensureGlyph(g: *Gui, cp: u32) Slot { @@ -3500,7 +3527,7 @@ fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, debu _ = c.SDL_GL_SwapWindow(g.window); return; } - // ×2 for the smooth-scroll pane's offset copy, as in renderFrame + // ×2 for the fractional-scroll pane's offset copy, as in renderFrame if (g.gl_instances.len < cells * 2) { gpa.free(g.gl_instances); g.gl_instances = try gpa.alloc(CellInstance, cells * 2); |
