diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/animation.zig | 2 | ||||
| -rw-r--r-- | src/draw.zig | 231 | ||||
| -rw-r--r-- | src/gui/gui.zig | 54 | ||||
| -rw-r--r-- | src/pardes.zig | 4 |
4 files changed, 250 insertions, 41 deletions
diff --git a/src/animation.zig b/src/animation.zig index 840412f3..19f7ce0b 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -1002,7 +1002,7 @@ pub const CursorGlide = struct { } } - fn centreAt(glide: *const CursorGlide, now_ns: u64) [2]f32 { + pub fn centreAt(glide: *const CursorGlide, now_ns: u64) [2]f32 { var c: [2]f32 = .{ 0, 0 }; for (glide.corners) |corner| for (&c, corner) |*sum, axis| { sum.* += axis.value(now_ns) / 4; diff --git a/src/draw.zig b/src/draw.zig index b0b16e12..15411770 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -212,6 +212,14 @@ fn focusedCursorBox(p: *const Pardes, s: *const Surface) ?animation.Box { /// at the Motion flavour's pace, a jump gliding and a step or a keystroke in /// insert mode landing at once; blinking unless typing. A pixel shell's: a /// grid's cursor is the terminal's own. +/// +/// The springs hold the cursor where the pane's final layout puts it, and +/// the quad is drawn through the pane's frame (paneFrame): while the pane +/// slides, zooms or is dragged, the cursor rides with its text. A pane that +/// moved in the layout carries the springs with it (they keep their place +/// in the pane), so with no transition the cursor lands with the text. A +/// focus change glides from where the cursor is drawn to the new pane's +/// cursor, wherever that pane is drawn now. fn glideCursor(p: *Pardes, s: *Surface) void { s.chrome.cursor_glide = 0; s.chrome.cursor_alpha = 1; @@ -220,26 +228,59 @@ fn glideCursor(p: *Pardes, s: *Surface) void { const box = focusedCursorBox(p, s) orelse return; const motion = animation.Motion.of(p.settings.motion); var snap = if (s.cursor) |cursor| cursor.bar else false; + var frame: PaneFrame = .{}; + // In a column's or the workspace's tag the cursor is not the pane's, + // and does not move with it. + var inside = false; if (p.panes[p.active]) |pane| { - // A focus change lands at once: a glide between panes would be - // squashed against the new one's edge. - if (p.cursor_view.serial != pane.serial) snap = true; - // Typing never trails: insert mode lands every move. - if (pane.body.mode == .insert) snap = true; - // Scrolled (a file's view, a terminal's output): the quad moves with - // the text and lands, never gliding against text that jumped (every - // Enter at a prompt scrolls one and moves the other). + const rect = p.rects[p.active]; + const cx = box.x + box.w / 2; + const cy = box.y + box.h / 2; + inside = cx >= @as(f32, @floatFromInt(rect.x)) and cx <= @as(f32, @floatFromInt(rect.x + rect.w)) and + cy >= @as(f32, @floatFromInt(rect.y)) and cy <= @as(f32, @floatFromInt(rect.y + rect.h)); + if (inside) frame = paneFrame(s, pane.serial, rect); + const origin: [2]f32 = .{ @floatFromInt(rect.x), @floatFromInt(rect.y) }; + const view = p.cursor_view; const line: i64 = if (pane.file) |f| @intCast(f.scroll) else if (pane.isTerminal()) panes.Terminal.gridOffset(pane) else 0; - if (p.cursor_view.serial == pane.serial and line != p.cursor_view.line) { - p.cursor_glide.shift(0, @floatFromInt(p.cursor_view.line - line)); - snap = true; + if (view.serial == pane.serial and view.inside == inside) { + // The pane moved in the layout: the springs move with it (its + // own cursor's; one in a column's tag stays). + if (inside) p.cursor_glide.shift(origin[0] - view.origin[0], origin[1] - view.origin[1]); + // Typing never trails: insert mode lands every move. + if (pane.body.mode == .insert) snap = true; + // Scrolled (a file's view, a terminal's output): the quad moves + // with the text and lands, never gliding against text that + // jumped (every Enter at a prompt scrolls one and moves the + // other). + if (inside and line != view.line) { + p.cursor_glide.shift(0, @floatFromInt(view.line - line)); + snap = true; + } + } else if (view.serial != 0 and p.cursor_glide.placed) { + // Focus moved (to another pane, or between a pane and a + // column's or the workspace's tag): the glide starts where the + // cursor is drawn, in the old place's frame, and heads for the + // new cursor through the new one's. + const old: PaneFrame = if (!view.inside) .{} else for (p.panes, 0..) |slot, id| { + const other = slot orelse continue; + if (other.serial != view.serial) continue; + const r = p.rects[id]; + p.cursor_glide.shift(@as(f32, @floatFromInt(r.x)) - view.origin[0], @as(f32, @floatFromInt(r.y)) - view.origin[1]); + break paneFrame(s, view.serial, r); + } else .{}; + const at = p.cursor_glide.centreAt(p.now_ns); + const seen = frame.unplace(old.place(at)); + p.cursor_glide.shift(seen[0] - at[0], seen[1] - at[1]); + p.cursor_crossing = true; } - p.cursor_view = .{ .serial = pane.serial, .line = line }; + p.cursor_view = .{ .serial = pane.serial, .line = line, .origin = origin, .inside = inside }; } p.cursor_glide.aim(box, p.now_ns, motion, snap); _ = p.cursor_glide.step(p.now_ns); const moving = p.cursor_glide.moving(); - s.chrome.cursor_glide = 2 | @as(u8, @intFromBool(moving)); + if (!moving) p.cursor_crossing = false; + // Drawn as the quad while it glides and while its pane moves. + s.chrome.cursor_glide = 2 | @as(u8, @intFromBool(moving or frame.moving())); // The shell blinks it, from how long ago the last key or click was. if (p.settings.cursor_blink) s.chrome.cursor_idle_ms = @intCast(@min(std.math.maxInt(u32) - 1, (p.now_ns -| p.input_ns) / std.time.ns_per_ms)); @@ -249,13 +290,51 @@ fn glideCursor(p: *Pardes, s: *Surface) void { const see_through: f32 = if (motion.zeta < 1) 0.7 else 0.85; s.chrome.cursor_alpha = if (moving) see_through + (1 - see_through) * std.math.clamp(1 - remaining / 0.5, 0, 1) else 1; const quad = p.cursor_glide.sample(p.now_ns, motion); - // Never outside the focused pane, however far a flavour overshoots or - // a scroll carried it: it squashes against the pane's edges. - const r = p.rects[p.active]; - for (quad, 0..) |point, i| { - s.chrome.cursor_quad[i * 2] = std.math.clamp(point[0], @as(f32, @floatFromInt(r.x)), @as(f32, @floatFromInt(r.x + r.w))); - s.chrome.cursor_quad[i * 2 + 1] = std.math.clamp(point[1], @as(f32, @floatFromInt(r.y)), @as(f32, @floatFromInt(r.y + r.h))); + // Never outside the focused pane as drawn, however far a flavour + // overshoots or a scroll carried it: it squashes against the pane's + // edges. Crossing to another pane, it may pass over any of the grid. + const bounds: animation.Box = if (p.cursor_crossing or !inside) + .{ .w = @floatFromInt(s.cols), .h = @floatFromInt(s.rows) } + else + frame.at; + for (quad, 0..) |corner, i| { + const point = frame.place(corner); + s.chrome.cursor_quad[i * 2] = std.math.clamp(point[0], bounds.x, bounds.x + bounds.w); + s.chrome.cursor_quad[i * 2 + 1] = std.math.clamp(point[1], bounds.y, bounds.y + bounds.h); + } +} + +/// Where a pane is drawn against where the layout puts it: its transition +/// track's presented box against the track's target, or the layout's box +/// both ways when it is still. Maps grid points between the two. +const PaneFrame = struct { + to: animation.Box = .{ .w = 1, .h = 1 }, + at: animation.Box = .{ .w = 1, .h = 1 }, + + fn moving(f: PaneFrame) bool { + return !std.meta.eql(f.to, f.at); + } + + fn place(f: PaneFrame, point: [2]f32) [2]f32 { + return .{ f.at.x + (point[0] - f.to.x) * scale(f.at.w, f.to.w), f.at.y + (point[1] - f.to.y) * scale(f.at.h, f.to.h) }; + } + + fn unplace(f: PaneFrame, point: [2]f32) [2]f32 { + return .{ f.to.x + (point[0] - f.at.x) * scale(f.to.w, f.at.w), f.to.y + (point[1] - f.at.y) * scale(f.to.h, f.at.h) }; + } + + fn scale(num: f32, den: f32) f32 { + return if (den > 0.001) num / den else 1; + } +}; + +fn paneFrame(s: *const Surface, serial: u32, rect: Rect) PaneFrame { + const still = layout.panelBox(rect); + for (s.panelTracks()) |track| { + if (track.serial != serial or track.phase == .closing or !track.active()) continue; + return .{ .to = track.to, .at = track.visualBox() }; } + return .{ .to = still, .at = still }; } /// The Lift style as drawn: `auto` is a shadow on a light page, and on a @@ -667,8 +746,10 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { } } }; - glideCursor(p, s); + // After the tracks are submitted: the cursor is drawn through its + // pane's. try p.presentation.submit(p, s); + glideCursor(p, s); return composeAsciiTransitions(p, arena, s); } @@ -1706,11 +1787,13 @@ test "a dim leaves a pair already under 4.5 alone, and reads a theme's own sixte try std.testing.expect(reverse.bg.rgb[0] < 255); } -test "with no lift and no dim a focus switch asks for no frames" { +test "with no lift, no dim and no motion a focus switch asks for no frames" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); _ = try p.setTestFile("text\n"); p.newScratchBelow(0); + // With motion the cursor glides to the other pane (glideCursor). + p.settings.motion = .off; var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); p.advance(std.time.ns_per_s); @@ -2059,3 +2142,109 @@ test "a body with context rows is painted once, and the grid's rows are what a p try std.testing.expect(layer.rows > body.rect.h); } } + +/// Two columns, the file in the left one and a shell in the right, the +/// right one focused with its cursor settled. +fn twoColumns(p: *Pardes, arena: *std.heap.ArenaAllocator) !void { + _ = try p.setTestFile("some text on a line\n" ** 6); + _ = try p.newShell(1, ""); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); + p.sync(); + p.advance(std.time.ns_per_s); + const boot = try p.render(arena.allocator()); + p.acknowledgePanelPresentation(boot.panelTracks()); + p.advance(2 * std.time.ns_per_s); + _ = arena.reset(.retain_capacity); + const settled = try p.render(arena.allocator()); + p.acknowledgePanelPresentation(settled.panelTracks()); + try std.testing.expect(!p.cursor_glide.moving()); +} + +test "while its pane slides the cursor rides with it, at the same cell of the pane every frame" { + if (!builtins.capabilities.lift) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 }); + defer p.deinit(); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + p.settings.motion = .bouncy; + try twoColumns(p, &arena); + p.active = 1; + p.advance(3 * std.time.ns_per_s); + _ = arena.reset(.retain_capacity); + p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); + p.advance(4 * std.time.ns_per_s); + p.settings.panel_transition = .slide; + // The right column's pane moves right. + p.col_weight[0] = layout.column_weight_unit * 2; + p.col_weight[1] = layout.column_weight_unit; + p.sync(); + var now: u64 = 4 * std.time.ns_per_s; + var riding: usize = 0; + for (0..40) |_| { + now += 16 * std.time.ns_per_ms; + p.advance(now); + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + p.acknowledgePanelPresentation(s.panelTracks()); + const frame = paneFrame(s, p.panes[1].?.serial, p.rects[1]); + const box = focusedCursorBox(p, s).?; + const want = frame.place(.{ box.x, box.y }); + try std.testing.expectApproxEqAbs(want[0], s.chrome.cursor_quad[0], 0.01); + try std.testing.expectApproxEqAbs(want[1], s.chrome.cursor_quad[1], 0.01); + if (frame.moving()) { + riding += 1; + // Drawn as the quad while the pane moves. + try std.testing.expect(s.chrome.cursor_glide & 1 != 0); + } + } + try std.testing.expect(riding >= 3); + + // With no transition, the cursor lands with the text at once. + p.settings.panel_transition = .off; + p.col_weight[0] = layout.column_weight_unit; + p.col_weight[1] = layout.column_weight_unit * 2; + p.sync(); + now += 16 * std.time.ns_per_ms; + p.advance(now); + _ = arena.reset(.retain_capacity); + const landed = try p.render(arena.allocator()); + const box = focusedCursorBox(p, landed).?; + try std.testing.expectApproxEqAbs(box.x, landed.chrome.cursor_quad[0], 0.01); + try std.testing.expect(!p.cursor_glide.moving()); +} + +test "focus moved to another column glides from where the cursor was drawn" { + if (!builtins.capabilities.lift) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 }); + defer p.deinit(); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + p.settings.motion = .smooth; + try twoColumns(p, &arena); + p.active = 0; + p.advance(3 * std.time.ns_per_s); + _ = arena.reset(.retain_capacity); + const before = try p.render(arena.allocator()); + try std.testing.expect(!p.cursor_glide.moving()); + const was: [2]f32 = .{ before.chrome.cursor_quad[0], before.chrome.cursor_quad[1] }; + + p.active = 1; + _ = arena.reset(.retain_capacity); + const start = try p.render(arena.allocator()); + // It sets off from where it was drawn, in the left column... + try std.testing.expectApproxEqAbs(was[0], start.chrome.cursor_quad[0], 0.01); + try std.testing.expectApproxEqAbs(was[1], start.chrome.cursor_quad[1], 0.01); + try std.testing.expect(p.cursor_glide.moving() and start.chrome.cursor_glide & 1 != 0); + // ...crosses the grid, not squashed against the right one's edge... + p.advance(3 * std.time.ns_per_s + 40 * std.time.ns_per_ms); + _ = arena.reset(.retain_capacity); + const mid = try p.render(arena.allocator()); + try std.testing.expect(mid.chrome.cursor_quad[0] > was[0] and mid.chrome.cursor_quad[0] < @as(f32, @floatFromInt(p.rects[1].x)) + 1); + // ...and lands on the right one's cursor. + p.advance(5 * std.time.ns_per_s); + _ = arena.reset(.retain_capacity); + const landed = try p.render(arena.allocator()); + const box = focusedCursorBox(p, landed).?; + try std.testing.expectApproxEqAbs(box.x, landed.chrome.cursor_quad[0], 0.01); + try std.testing.expect(!p.cursor_glide.moving() and !p.cursor_crossing); +} diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 2d31416b..18a1c8cb 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -5882,9 +5882,10 @@ fn renderFrame( } } var groups = makeGroups(surface.panelTracks(), surface.hasPanelDiff()); - // Cursors stand still while panes move, as they always have; while the - // focused one glides or blinks, it is the overlay's quad instead. One - // or the other every frame (none only at a blink's off half). + // While panes move the cells' cursors are not drawn; the focused one is + // the overlay's quad, riding with its pane (draw.glideCursor), as it is + // while it glides or blinks. One or the other every frame (none only at + // a blink's off half). const blink_alpha: f32 = if (blinkNow(g, chrome)) |b| b.alpha else 1; const quad_mode = chrome.cursor_glide & 2 != 0 and (chrome.cursor_glide & 1 != 0 or blink_alpha < 1); g.cursor_quad_alpha = if (quad_mode) chrome.cursor_alpha * blink_alpha else 0; @@ -6706,9 +6707,12 @@ fn emitSurfaceCell( // this underlay at tagline height leaves page-colored gutters between // adjacent workspace, column and pane tags at TaglineSize < 100. emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, (&band_base)[0..1], 0, null, old_layer, false, page); - // Over a theme's plaque (not a grip's button) the underlay lets it - // show (tagPlaques). - if (cell_decor.nplaques != 0 and !here.grip and cell_decor.inPlaque((@as(f32, @floatFromInt(col)) + 0.5) * body_layout.w + body_layout.x_off, @as(f32, @floatFromInt(row)) * body_layout.h + body_layout.y_off + 0.5)) + // Over a theme's plaque the underlay lets it show (tagPlaques), + // in a grip's cell too where the plaque reaches into it past the + // button (which is drawn over the cells). + const cell_x = @as(f32, @floatFromInt(col)) * body_layout.w + body_layout.x_off; + const cell_y = @as(f32, @floatFromInt(row)) * body_layout.h + body_layout.y_off + 0.5; + if (cell_decor.nplaques != 0 and cell_decor.inPlaque(if (here.grip) cell_x + body_layout.w - 0.5 else cell_x + 0.5, cell_y)) instances[next.*].effect |= clear_bg_bit; next.* += 1; // Tag glyphs keep their font size inside a full physical-width grip. @@ -7145,10 +7149,13 @@ test "lapis plaques stay in their tag's band, clear of every grip; only the focu const band = plaqueBox(&g, &s.chrome, s.regionList(), region, layout, 1280, 768) orelse continue; if (r[0] >= band.band[0] - 0.01 and r[1] >= band.band[1] - band.border - 0.01 and r[2] <= band.band[2] + 0.01 and r[3] <= band.band[3] + 0.01) break region; } else return error.PlaqueOutsideItsBand; + // Clear of every grip's button: a plaque starts flush against it, + // in the gap between it and the text. for (s.regionList()) |grip| { if (grip.kind != .grip and grip.kind != .column_grip) continue; - const gx0 = @as(f32, @floatFromInt(grip.rect.x)) * 16; - const gx1 = @as(f32, @floatFromInt(grip.rect.x + grip.rect.w)) * 16; + const column = scrollColumn(&g, &s.chrome, grip.rect.x, layout); + const gx0 = column[0]; + const gx1 = column[1]; const gy0 = @as(f32, @floatFromInt(grip.rect.y)) * 32; const gy1 = @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * 32; try std.testing.expect(r[2] <= gx0 + 0.01 or r[0] >= gx1 - 0.01 or r[3] <= gy0 + 0.01 or r[1] >= gy1 - 0.01); @@ -7173,6 +7180,11 @@ test "lapis plaques stay in their tag's band, clear of every grip; only the focu if (tag.kind != .tag or tag.rect.y != column.rect.y + column.rect.h or tag.rect.x + tag.rect.w != column.rect.x + column.rect.w) continue; const lower = plaqueBox(&g, &s.chrome, s.regionList(), tag, layout, 1280, 768).?; try std.testing.expectEqual(upper.box[0], lower.box[0]); + // Flush against the grips' buttons, which line up too. + const grip = for (s.regionList()) |part| { + if (part.kind == .grip and part.owner == tag.owner) break part; + } else unreachable; + try std.testing.expectEqual(scrollColumn(&g, &s.chrome, grip.rect.x, layout)[1], lower.box[0]); try std.testing.expectEqual(upper.box[2], lower.box[2]); try std.testing.expectEqual(upper.band[3], upper.box[3]); try std.testing.expectEqual(lower.band[1] - lower.border, lower.box[1]); @@ -9382,14 +9394,15 @@ fn plaqueBox(g: *const Gui, chrome: *const pardes.Chrome, regions: []const parde const pane = tag.kind == .tag; const first = tag.rect.y; const last = tag.rect.y + tag.rect.h - 1; - // From its start, right of its grip: the gap cell before its text is - // the plaque's margin, so its frame is never under the first letter. A - // column's text sits a cell further in than a pane's; its plaque starts - // where the pane's under it does, at its grip's end. - const start = if (tag.kind == .column_tag) for (regions) |grip| { - if (grip.kind == .column_grip and grip.owner == tag.owner) break grip.rect.x + grip.rect.w; - } else tag.rect.x else tag.rect.x; - const x0 = @as(f32, @floatFromInt(@min(start, tag.rect.x))) * layout.w; + // Flush against its grip's button, so the two read as one piece: the + // gap between button and text (the scroll gap) is the plaque's, and so + // is the cell before its text, so its frame is never under the first + // letter. A column's plaque starts where the pane's under it does. + const grip_kind: pardes.Region.Kind = if (tag.kind == .column_tag) .column_grip else .grip; + const x0 = for (regions) |grip| { + if (tag.kind != .workspace_tag and grip.kind == grip_kind and grip.owner == tag.owner) + break scrollColumn(g, chrome, grip.rect.x, layout)[1]; + } else @as(f32, @floatFromInt(tag.rect.x)) * layout.w; const x1_cells = @as(f32, @floatFromInt(tag.rect.x + tag.rect.w)) * layout.w; const window_edge = win_w - x1_cells < layout.w; const x1 = if (window_edge) @max(x1_cells, win_w) else x1_cells; @@ -9454,7 +9467,13 @@ fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, ce const left = @as(f32, @floatFromInt(grip.rect.x)) * layout.w; const right = @as(f32, @floatFromInt(grip.rect.x + grip.rect.w)) * layout.w; try addDecor(g, gpa, left, button[1], button[0], button[3], band, 1, track, win_w, win_h); - try addDecor(g, gpa, button[2], button[1], right, button[3], band, 1, track, win_w, win_h); + // Right of it, a theme's plaque starts flush against it (plaqueBox). + const plaqued = chrome.decor_box_border != null and !cell_decor.plaques_off; + if (!plaqued) try addDecor(g, gpa, button[2], button[1], right, button[3], band, 1, track, win_w, win_h); + // Its cells over the plaque's reach are let through (emitInstance): + // under the button's row, the band goes on as far as the plaque. + if (plaqued and grip.rect.h > 1) + try addDecor(g, gpa, left, button[3], button[2], @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * layout.h, band, 1, track, win_w, win_h); if (grip.kind != .grip) continue; // acme's button ring, two pixels (ButtonBorder) inside the button: // the focused one's in its own fill. An unfocused dirty one keeps a @@ -10160,7 +10179,6 @@ fn buildOverlay( fn appendCursorGlide(g: *Gui, surface: *const pardes.Surface, builder: *OverlayBuilder) void { const chrome = &surface.chrome; if (g.cursor_quad_alpha <= 0.001) return; - if (surface.panelTracks().len != 0) return; const layout = fixedCellLayout(g); var px: [4][2]f32 = undefined; var centre: [2]f32 = .{ 0, 0 }; diff --git a/src/pardes.zig b/src/pardes.zig index d6385e6f..2ee4e4ce 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4347,7 +4347,9 @@ pub const Pardes = struct { /// scroll, G6): the core then keeps the continuous path running, so the /// shell draws every loop and a virtual clock moves on, as for a lift. shell_continuous: bool = false, - cursor_view: struct { serial: u32 = 0, line: i64 = 0 } = .{}, + cursor_view: struct { serial: u32 = 0, line: i64 = 0, origin: [2]f32 = .{ 0, 0 }, inside: bool = true } = .{}, + /// The cursor glides from one pane to another: it may cross the grid. + cursor_crossing: bool = false, input_ns: u64 = 0, animate_theme_changes: bool = false, native_images: bool = false, |
