summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig5
-rw-r--r--src/gui/gui.zig257
2 files changed, 145 insertions, 117 deletions
diff --git a/build.zig b/build.zig
index a672b998..126f86dc 100644
--- a/build.zig
+++ b/build.zig
@@ -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);