diff options
Diffstat (limited to 'src/gui')
| -rw-r--r-- | src/gui/gui.zig | 381 |
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 { |
