summaryrefslogtreecommitdiff
path: root/src/gui/gui.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/gui/gui.zig')
-rw-r--r--src/gui/gui.zig381
1 files changed, 375 insertions, 6 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 092bb3bf..449cae15 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -1866,6 +1866,10 @@ const Gui = struct {
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,
+ /// G6: a wheel's notches, whole lines, scroll the core at once and the
+ /// picture follows on a spring (Glide), never the other way round.
+ scroll_notch: i32 = 0,
+ glide: Glide = .{},
/// The cursor's blink (blinkNow): when the frame drawn last arrived,
/// a redraw owed for an edge, and this frame's cursor mode and alpha.
@@ -2574,6 +2578,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran
defer c.ui_font_free(g.font);
defer gpa.free(g.scroll_edge); // grown on demand by stepScroll
+ defer g.glide.deinit(gpa);
resetGlyphAtlas(&g);
if (attach) |requested| return attachRequested(gpa, &g, requested);
@@ -3104,6 +3109,7 @@ const StdinFeed = struct {
var seq_start: usize = 0;
while (seq_start < len) {
// ESC]777;mouse;<down|up|motion>;<SDL button, 0 for none>;<x>;<y>BEL
+ // ESC]777;mouse;wheel;<amount>;<x>;<y>BEL
// A mouse event at window pixels, built as SDL would and run
// through dispatch, so a driver exercises the real pointer path
// (pixel to cell, tag and body hits). SGR mouse carries only cells.
@@ -3118,11 +3124,21 @@ const StdinFeed = struct {
if (g) |gp| {
var parts = std.mem.splitScalar(u8, body[0..end], ';');
const kind = parts.next() orelse "";
- const button = std.fmt.parseInt(u8, parts.next() orelse "", 10) catch 0;
+ const second = parts.next() orelse "";
+ const button = std.fmt.parseInt(u8, second, 10) catch 0;
const x = std.fmt.parseFloat(f32, parts.next() orelse "") catch 0;
const y = std.fmt.parseFloat(f32, parts.next() orelse "") catch 0;
var sev = std.mem.zeroes(c.SDL_Event);
- if (std.mem.eql(u8, kind, "motion")) {
+ if (std.mem.eql(u8, kind, "wheel")) {
+ // ESC]777;mouse;wheel;<y, SDL's sign: up positive>;<x>;<y>BEL:
+ // a whole number is a mouse's notches, else a touchpad's.
+ const amount = std.fmt.parseFloat(f32, second) catch 0;
+ sev.type = c.SDL_EVENT_MOUSE_WHEEL;
+ sev.wheel.y = amount;
+ sev.wheel.integer_y = @intFromFloat(@trunc(amount));
+ sev.wheel.mouse_x = x;
+ sev.wheel.mouse_y = y;
+ } else if (std.mem.eql(u8, kind, "motion")) {
sev.type = c.SDL_EVENT_MOUSE_MOTION;
sev.motion.x = x;
sev.motion.y = y;
@@ -3533,7 +3549,12 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
g.scroll_pane = id;
g.scroll_col = mc.col;
g.scroll_row = mc.row;
- g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
+ // A mouse wheel's notch is a whole line and
+ // glides (G6); a touchpad's fraction moves the
+ // picture by itself, under the finger.
+ if (notchOf(w.y, w.integer_y)) |lines| {
+ g.scroll_notch +|= lines;
+ } else g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
}
}
} else {
@@ -4213,7 +4234,7 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
} else for (s.ptys) |slot| {
if (slot) |pt| if (pt.kill_at != 0) break true;
} else false;
- const polls = g.scroll_lag != 0 or g.scroll_delta != 0 or g.gamepad != null or s.test_mode or core.needs_frame or retiring or
+ const polls = g.scroll_lag != 0 or g.scroll_delta != 0 or g.scroll_notch != 0 or g.glide.moving() or g.gamepad != null or s.test_mode or core.needs_frame or retiring or
g.post.animating(core.settings.shader_animation);
const minimized = !s.test_mode and c.SDL_GetWindowFlags(g.window) & (c.SDL_WINDOW_MINIMIZED | c.SDL_WINDOW_OCCLUDED) != 0;
// A latency trace is fed on stdin, whose poll below paces the loop:
@@ -4797,14 +4818,356 @@ test "fractional scroll reversals preserve signed distance" {
try std.testing.expect(reverse.lag >= 0 and reverse.lag < 1);
}
+/// A mouse wheel's step as whole lines (down positive), or null for a
+/// touchpad's fraction, which follows the finger as it is.
+fn notchOf(raw_y: f32, integer_y: i32) ?i32 {
+ if (!std.math.isFinite(raw_y) or integer_y == 0 or raw_y != @round(raw_y)) return null;
+ return @intFromFloat(std.math.clamp(-raw_y, -64, 64));
+}
+
+test "a wheel's notch is whole lines, a touchpad's fraction is not" {
+ try std.testing.expectEqual(@as(?i32, 1), notchOf(-1, -1));
+ try std.testing.expectEqual(@as(?i32, -3), notchOf(3, 3));
+ try std.testing.expectEqual(@as(?i32, null), notchOf(-0.25, 0));
+ try std.testing.expectEqual(@as(?i32, null), notchOf(-1.5, -1));
+ try std.testing.expectEqual(@as(?i32, null), notchOf(std.math.nan(f32), 1));
+}
+
+/// G6 (docs/render-pipeline.md ยง9.1): a wheel's lines scroll the core at
+/// once, so every key and click acts on where the text is, and the picture
+/// glides there on the Motion flavour's spring (critically damped: text
+/// never passes its mark). How far the rows moved is measured on the next
+/// frame against the last, a line wrapped over several rows or a terminal's
+/// output included, and the rows that left are kept to draw in the gap.
+const Glide = struct {
+ spring: pardes.animation.Spring = .{},
+ /// The picture's offset from the core's rows now (rows; negative, the
+ /// picture still shows what is above).
+ offset: f32 = 0,
+ now_ns: u64 = 0,
+ pane: usize = 0,
+ rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 },
+ body_y: u16 = 0,
+ body_h: u16 = 0,
+ /// A scroll made and not yet measured: its direction, and the body's
+ /// rows before it.
+ pending: bool = false,
+ dir: i8 = 0,
+ before: []pardes.Cell = &.{},
+ /// The rows past the body's top (`above`) and bottom (`below`), nearest
+ /// first, `bw` cells each: what the gap shows while the picture lags.
+ above: []pardes.Cell = &.{},
+ below: []pardes.Cell = &.{},
+ nabove: u16 = 0,
+ nbelow: u16 = 0,
+ /// The Motion flavour the last loop step read.
+ motion: pardes.animation.Motion = .{},
+
+ fn moving(glide: *const Glide) bool {
+ return !glide.spring.settled or glide.pending;
+ }
+
+ fn stop(glide: *Glide) void {
+ glide.spring = .{};
+ glide.offset = 0;
+ glide.pending = false;
+ glide.nabove = 0;
+ glide.nbelow = 0;
+ }
+
+ fn deinit(glide: *Glide, gpa: std.mem.Allocator) void {
+ gpa.free(glide.before);
+ gpa.free(glide.above);
+ gpa.free(glide.below);
+ }
+
+ fn bw(glide: *const Glide) u16 {
+ return glide.rect.w -| config.GUTTER;
+ }
+
+ /// The core's rows moved by `rows` (down positive) since the last
+ /// frame: the picture now lags by as many, carrying its speed.
+ fn kick(glide: *Glide, rows: f32, now_ns: u64, motion: pardes.animation.Motion) void {
+ // Follows input: the flavour's pace, half again, never overshooting.
+ var pace = motion;
+ pace.omega = motion.omega * 1.5;
+ glide.spring.carry(-rows, now_ns, pace);
+ // Never more than a body's rows behind: a flick past that jumps.
+ const limit: f32 = @floatFromInt(glide.body_h);
+ const now = glide.spring.value(now_ns);
+ if (@abs(now) > limit) glide.spring.carry(std.math.clamp(now, -limit, limit) - now, now_ns, pace);
+ glide.offset = glide.spring.value(now_ns);
+ }
+
+ /// Finds how far the pane's body rows moved, `dir` rows at a time, by
+ /// the rows of `surface` against `before`; keeps the rows that left.
+ fn measure(glide: *Glide, gpa: std.mem.Allocator, surface: *const pardes.Surface) void {
+ glide.pending = false;
+ const w = glide.bw();
+ const h = glide.body_h;
+ const x0 = glide.rect.x + config.GUTTER;
+ if (w == 0 or h == 0 or x0 + w > surface.cols or glide.body_y + h > surface.rows or glide.before.len < @as(usize, w) * h) return glide.stop();
+ const now = struct {
+ fn row(s: *const pardes.Surface, x: u16, y: u16, n: u16) []const pardes.Cell {
+ return s.cells[@as(usize, y) * s.cols + x ..][0..n];
+ }
+ };
+ // The least shift under which the rows agree, but for a few: the
+ // cursor's line and its number's weight move with the scroll.
+ const shift: u16 = found: for (1..h) |k| {
+ const kk: u16 = @intCast(k);
+ const allowed = @max(2, (h - kk) / 8);
+ var misses: usize = 0;
+ for (0..h - kk) |r| {
+ const rr: u16 = @intCast(r);
+ const old_row = glide.before[@as(usize, if (glide.dir > 0) rr + kk else rr) * w ..][0..w];
+ const new_row = now.row(surface, x0, glide.body_y + if (glide.dir > 0) rr else rr + kk, w);
+ if (!sameRow(old_row, new_row)) {
+ misses += 1;
+ if (misses > allowed) continue :found;
+ }
+ }
+ // Too few rows left to tell: no glide.
+ if (h - kk < 3) break :found 0;
+ break :found kk;
+ } else 0;
+ if (shift == 0) return glide.stop();
+ // The rows that left: past the top going down, the bottom going up.
+ if (!glide.ensure(gpa)) return glide.stop();
+ const keep = if (glide.dir > 0) &glide.above else &glide.below;
+ const count = if (glide.dir > 0) &glide.nabove else &glide.nbelow;
+ const kept = @min(count.*, h - shift);
+ std.mem.copyBackwards(pardes.Cell, keep.*[@as(usize, shift) * w ..][0 .. @as(usize, kept) * w], keep.*[0 .. @as(usize, kept) * w]);
+ for (0..shift) |i| {
+ const src_row: usize = if (glide.dir > 0) shift - 1 - i else h - shift + i;
+ @memcpy(keep.*[i * w ..][0..w], glide.before[src_row * w ..][0..w]);
+ }
+ count.* = kept + shift;
+ // What came into view is no longer past the other edge.
+ const other = if (glide.dir > 0) &glide.below else &glide.above;
+ const other_n = if (glide.dir > 0) &glide.nbelow else &glide.nabove;
+ const dropped = @min(other_n.*, shift);
+ std.mem.copyForwards(pardes.Cell, other.*[0 .. @as(usize, other_n.* - dropped) * w], other.*[@as(usize, dropped) * w ..][0 .. @as(usize, other_n.* - dropped) * w]);
+ other_n.* -= dropped;
+ glide.kick(@as(f32, @floatFromInt(shift)) * @as(f32, @floatFromInt(glide.dir)), glide.now_ns, glide.motion);
+ }
+
+ fn ensure(glide: *Glide, gpa: std.mem.Allocator) bool {
+ const n = @as(usize, glide.bw()) * glide.body_h;
+ inline for (.{ &glide.above, &glide.below }) |buffer| if (buffer.len < n) {
+ const grown = gpa.realloc(buffer.*, n) catch return false;
+ buffer.* = grown;
+ };
+ return true;
+ }
+};
+
+fn sameRow(a: []const pardes.Cell, b: []const pardes.Cell) bool {
+ for (a, b) |x, y| {
+ if (x.default != y.default) return false;
+ if (x.default) continue;
+ if (!std.mem.eql(u8, x.grapheme(), y.grapheme()) or !std.meta.eql(x.style, y.style)) return false;
+ }
+ return true;
+}
+
+/// Feeds a wheel's notches to the core, snapshotting the body first so the
+/// next frame can measure the move (Glide.measure); advances the glide and
+/// asks for frames while it moves.
+fn stepGlide(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
+ const glide = &g.glide;
+ // The glide runs on the core's continuous path: frames every loop, and
+ // a virtual clock carried on.
+ defer core.shell_continuous = glide.moving();
+ glide.now_ns = core.now_ns;
+ glide.motion = pardes.animation.Motion.of(core.settings.motion);
+ if (!glide.spring.settled) {
+ _ = glide.spring.step(core.now_ns);
+ core.needs_frame = true;
+ if (glide.spring.settled) {
+ glide.offset = 0;
+ glide.nabove = 0;
+ glide.nbelow = 0;
+ }
+ }
+ const notch = g.scroll_notch;
+ if (notch == 0) return;
+ g.scroll_notch = 0;
+ const id = g.scroll_pane orelse return;
+ const pane = core.panes[id] orelse return resetScroll(g);
+ const r = core.rects[id];
+ if (r.h <= pane.tag_rows or r.w <= config.GUTTER) return resetScroll(g);
+ const body_y = core.bodyTop(pane, r);
+ const body_h = r.h -| pane.tag_rows;
+ // A new pane or place: what was kept belongs to another picture.
+ if (glide.pane != id or !std.meta.eql(glide.rect, r) or glide.body_y != body_y or glide.body_h != body_h or glide.pending) glide.stop();
+ glide.pane = id;
+ glide.rect = r;
+ glide.body_y = body_y;
+ glide.body_h = body_h;
+ // The body as the last frame drew it, which the move is measured from.
+ const s = &core.surface;
+ const w = r.w - config.GUTTER;
+ const x0 = r.x + config.GUTTER;
+ const animate = !glide.motion.instant and x0 + w <= s.cols and body_y + body_h <= s.rows and s.cells.len != 0;
+ if (animate) {
+ const n = @as(usize, w) * body_h;
+ if (glide.before.len < n) glide.before = gpa.realloc(glide.before, n) catch &.{};
+ if (glide.before.len >= n) {
+ for (0..body_h) |row| @memcpy(glide.before[row * w ..][0..w], s.cells[(@as(usize, body_y) + row) * s.cols + x0 ..][0..w]);
+ }
+ }
+ var left = notch;
+ var moved = false;
+ while (left != 0) {
+ const down = left > 0;
+ left += if (down) -1 else 1;
+ const was = pane.scroll();
+ core.update(.{ .mouse = .{ .button = if (down) .wheel_down else .wheel_up, .kind = .press, .col = g.scroll_col, .row = g.scroll_row } });
+ if (pane.scroll() == was) break;
+ moved = true;
+ }
+ if (!moved) return;
+ core.needs_frame = true;
+ if (animate and glide.before.len >= @as(usize, w) * body_h) {
+ glide.pending = true;
+ glide.dir = @intCast(std.math.sign(notch));
+ }
+}
+
+/// The glide's picture: the pane's body rows moved by the offset, and in
+/// the gap it opens the rows that left, as `emitScrollRows` draws a
+/// touchpad's fraction.
+fn emitGlideRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32, page: Ground) u32 {
+ const glide = &g.glide;
+ for (surface.panelTracks()) |track|
+ if (track.active() and track.pane == glide.pane) return 0;
+ const r = glide.rect;
+ const bh = glide.body_h;
+ const bw = glide.bw();
+ const x0 = r.x + config.GUTTER;
+ const y0 = glide.body_y;
+ if (bw == 0 or bh == 0 or x0 + bw > surface.cols or y0 + bh > surface.rows) return 0;
+ // The scissor the shifted rows are drawn in is the scroll's.
+ g.scroll_rect = r;
+ g.scroll_body_y = y0;
+ g.scroll_body_h = bh;
+ var shifted = layout;
+ shifted.y_off -= glide.offset * layout.h;
+ const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else std.math.maxInt(u32);
+ const cursor_bar = if (surface.cursor) |cu| cu.bar else false;
+ var n: u32 = 0;
+ var row = y0;
+ while (row < y0 + bh) : (row += 1) {
+ const line = surface.cells[@as(usize, row) * surface.cols ..][0..surface.cols];
+ const row_cursor: ?usize = if (surface.cursor) |cu| if (cu.y == row) cu.x else null else null;
+ var col = x0;
+ while (col < x0 + bw) : (col += 1) {
+ const sidx: u32 = @as(u32, row) * surface.cols + col;
+ emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, cellFontRole(surface.at(col, row)), line, col, row_cursor, false, sidx == cursor_idx and !cursor_bar, page);
+ n += 1;
+ }
+ }
+ // The gap: rows past the top while the picture lags a move down, past
+ // the bottom for one up; as many as it opens.
+ const gap: u16 = @intFromFloat(@min(@ceil(@abs(glide.offset)), @as(f32, @floatFromInt(bh))));
+ const kept = if (glide.offset < 0) glide.nabove else glide.nbelow;
+ const rows = if (glide.offset < 0) glide.above else glide.below;
+ for (0..@min(gap, kept)) |i| {
+ const cells = rows[i * bw ..][0..bw];
+ // Row -1 - i above the body, or bh + i below it, in grid rows.
+ const at_row: i32 = if (glide.offset < 0) @as(i32, y0) - 1 - @as(i32, @intCast(i)) else @as(i32, y0) + bh + @as(i32, @intCast(i));
+ var at = shifted;
+ // emitInstance takes an unsigned row: move the layout instead.
+ at.y_off += @as(f32, @floatFromInt(at_row - @as(i32, y0))) * layout.h;
+ for (0..bw) |c_i| {
+ emitInstance(g, instances, base + n, x0 + @as(u16, @intCast(c_i)), y0, at, win_w, win_h, null, cellFontRole(&cells[c_i]), cells, c_i, null, false, false, page);
+ n += 1;
+ }
+ }
+ return n;
+}
+
+test "a notch scrolls the core at once and the picture glides after it, a wrapped line's rows at a time" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ // Lines of two rows each at this width, numbered so no two rows match.
+ var text: std.ArrayList(u8) = .empty;
+ defer text.deinit(gpa);
+ for (0..60) |i| {
+ try text.print(gpa, "{d:0>4} ", .{i});
+ try text.appendNTimes(gpa, 'a' + @as(u8, @intCast(i % 26)), 100);
+ try text.append(gpa, '\n');
+ }
+ _ = try core.setTestFile(text.items);
+ core.settings.motion = .smooth;
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ const ms = std.time.ns_per_ms;
+ var now: u64 = std.time.ns_per_s;
+ core.advance(now);
+ var s = try core.render(arena.allocator());
+ var g: Gui = undefined;
+ g.glide = .{};
+ defer g.glide.deinit(gpa);
+ g.scroll_delta = 0;
+ g.scroll_lag = 0;
+ g.scroll_edge_len = 0;
+ const r = core.rects[core.active];
+ g.scroll_pane = core.active;
+ g.scroll_col = r.x + 10;
+ g.scroll_row = r.y + r.h - 2;
+ const body_y = core.bodyTop(core.panes[core.active].?, r);
+ const row_before = try gpa.dupe(pardes.Cell, s.cells[@as(usize, body_y + 1) * s.cols + r.x + config.GUTTER ..][0 .. r.w - config.GUTTER]);
+ defer gpa.free(row_before);
+ // One notch down: the core is a line on at once.
+ g.scroll_notch = 1;
+ const scrolled = core.panes[core.active].?.scroll();
+ stepGlide(&g, core, gpa);
+ try std.testing.expectEqual(scrolled + 1, core.panes[core.active].?.scroll());
+ try std.testing.expect(g.glide.pending);
+ // On the core's continuous path while it moves: frames every loop.
+ try std.testing.expect(core.shell_continuous and core.continuous());
+ _ = arena.reset(.retain_capacity);
+ s = try core.render(arena.allocator());
+ g.glide.measure(gpa, s);
+ // Its two rows: the picture two rows behind, the rows that left kept,
+ // nearest first.
+ try std.testing.expectApproxEqAbs(@as(f32, -2), g.glide.offset, 1e-4);
+ try std.testing.expectEqual(@as(u16, 2), g.glide.nabove);
+ try std.testing.expect(sameRow(g.glide.above[0 .. r.w - config.GUTTER], row_before));
+ // Frame by frame it comes back, never past, and settles.
+ var frames: usize = 0;
+ while (g.glide.moving()) : (frames += 1) {
+ now += 7 * ms;
+ core.advance(now);
+ stepGlide(&g, core, gpa);
+ try std.testing.expect(g.glide.spring.value(now) <= 1e-4);
+ if (frames > 200) return error.NeverSettled;
+ }
+ try std.testing.expect(frames > 5);
+ try std.testing.expectEqual(@as(f32, 0), g.glide.offset);
+ try std.testing.expect(!core.shell_continuous);
+ // Motion off: a notch lands at once.
+ core.settings.motion = .off;
+ g.scroll_notch = 1;
+ stepGlide(&g, core, gpa);
+ try std.testing.expect(!g.glide.pending and !g.glide.moving());
+}
+
+
fn resetScroll(g: *Gui) void {
g.scroll_pane = null;
g.scroll_delta = 0;
g.scroll_lag = 0;
g.scroll_edge_len = 0;
+ g.scroll_notch = 0;
+ g.glide.stop();
}
fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
+ stepGlide(g, core, gpa);
const id = g.scroll_pane orelse return;
const pane = core.panes[id] orelse {
resetScroll(g);
@@ -4813,7 +5176,7 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
const queued_delta = g.scroll_delta;
g.scroll_delta = 0;
if (queued_delta == 0) {
- if (g.scroll_lag == 0) resetScroll(g);
+ if (g.scroll_lag == 0 and g.scroll_notch == 0 and !g.glide.moving()) resetScroll(g);
return;
}
const r = core.rects[id];
@@ -4868,6 +5231,7 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
}
fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32, page: Ground) u32 {
+ if (g.glide.offset != 0) return emitGlideRows(g, instances, base, surface, layout, win_w, win_h, page);
const scroll_pane = g.scroll_pane orelse return 0;
if (g.scroll_lag == 0) return 0;
for (surface.panelTracks()) |track|
@@ -5434,6 +5798,10 @@ fn renderFrame(
defer if (building) build_zone.end();
const layout = fixedCellLayout(g);
const chrome = &surface.chrome;
+ // G6: what the scroll just done moved the pane's rows by, measured on
+ // this frame against the last (the glide follows it from there).
+ if (g.glide.pending) g.glide.measure(gpa, surface);
+ if (g.glide.moving()) g.glide.offset = g.glide.spring.value(g.glide.now_ns);
cell_decor = CellDecor.of(chrome);
// Plaques need an opaque window: with WindowOpacity under 100 the cells
// are drawn as they are, and the plaques not at all.
@@ -5570,7 +5938,8 @@ fn renderFrame(
}
const decor_len: u32 = @intCast(g.decor.items.len);
if (cell_total != 0 or decor_len != 0) {
- const capacity = std.math.add(u32, cell_total, cells + decor_len) catch return error.GpuCapacity;
+ // The shifted rows of a scroll: its body, and a glide's rows past it.
+ const capacity = std.math.add(u32, cell_total, 2 * cells + decor_len) catch return error.GpuCapacity;
try ensureVbuf(g, capacity);
const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse {