summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/File.zig26
-rw-r--r--src/Layer.zig201
-rw-r--r--src/Macro.zig176
-rw-r--r--src/Messages.zig88
-rw-r--r--src/Output.zig2
-rw-r--r--src/Pipe.zig69
-rw-r--r--src/Presentation.zig735
-rw-r--r--src/Registers.zig106
-rw-r--r--src/Terminal.zig12
-rw-r--r--src/Text.zig68
-rw-r--r--src/animation.zig739
-rw-r--r--src/body_layer.zig738
-rw-r--r--src/builtins.zig2
-rw-r--r--src/colors.zig9
-rw-r--r--src/config.zig292
-rw-r--r--src/detached/client.zig32
-rw-r--r--src/detached/server.zig25
-rw-r--r--src/detached/wire.zig143
-rw-r--r--src/draw.zig1264
-rw-r--r--src/edit.zig629
-rw-r--r--src/effect_sources.zig17
-rw-r--r--src/esp32p4.zig15
-rw-r--r--src/exec.zig8
-rw-r--r--src/gui/Post.zig576
-rw-r--r--src/gui/crt.zig101
-rw-r--r--src/gui/gui.zig1988
-rw-r--r--src/host_io.zig17
-rw-r--r--src/image.zig5
-rw-r--r--src/layout.zig1445
-rw-r--r--src/look.zig15
-rw-r--r--src/macos.zig99
-rw-r--r--src/macos/Sources/PardesView.swift2
-rw-r--r--src/macos/Sources/ScenePostprocessor.swift48
-rw-r--r--src/macos/pardes.h5
-rw-r--r--src/memory.zig4
-rw-r--r--src/modal.zig176
-rw-r--r--src/mouse.zig50
-rw-r--r--src/ninep/ctl.zig4
-rw-r--r--src/ninep/screen.zig2
-rw-r--r--src/normal.zig428
-rw-r--r--src/panes.zig8
-rw-r--r--src/pardes.zig779
-rw-r--r--src/pdf_view.zig26
-rw-r--r--src/surface.zig135
-rw-r--r--src/syntax.zig237
-rw-r--r--src/tag_layer.zig87
-rw-r--r--src/tagline.zig4
-rw-r--r--src/tty/panel_compositor.zig27
-rw-r--r--src/tty/tty.zig110
-rw-r--r--src/tutor.txt2
-rw-r--r--src/web.zig48
-rw-r--r--src/web/app.mjs74
52 files changed, 7586 insertions, 4312 deletions
diff --git a/src/File.zig b/src/File.zig
index 8ba58fbc..b8368482 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -6,6 +6,7 @@ const vaxis = @import("vaxis");
const pardes = @import("pardes.zig");
const config = @import("config.zig");
const Pardes = pardes.Pardes;
+const Surface = pardes.Surface;
const modal = @import("modal.zig");
const filesystem = @import("fs.zig");
const syntax = @import("syntax.zig");
@@ -69,9 +70,19 @@ pub const State = struct {
tree_context: bool = false,
context_declarations: []syntax.ContextDeclaration = &.{},
context_revision: ?u32 = null,
+ /// The whole file's parse for the keys that walk its nodes (Alt-o and
+ /// kin), made on the first of them after an edit and kept until the next
+ /// one: `syntax.parseTree`'s, opaque here.
+ node_tree: ?*anyopaque = null,
+ node_tree_revision: u32 = 0,
+ /// helix's object_selections: what Alt-o grew from, for Alt-i to go back
+ /// to; gpa-owned ranges and the primary's index.
+ node_history: std.ArrayList(NodeStep) = .empty,
history: *History,
};
+pub const NodeStep = struct { ranges: []modal.Selection, primary: usize };
+
pub fn dumpPane(
arena: std.mem.Allocator,
pane: *const Pane,
@@ -816,6 +827,9 @@ pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void {
if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights);
locations.freeRows(p.gpa, file.location_rows);
if (file.context_declarations.len > 0) p.tree_sitter_gpa.free(file.context_declarations);
+ if (file.node_tree) |tree| syntax.freeTree(tree);
+ for (file.node_history.items) |step| p.gpa.free(step.ranges);
+ file.node_history.deinit(p.gpa);
for (file.history.undo[0..file.history.undo_len]) |snap| p.gpa.free(snap.content);
for (file.history.redo[0..file.history.redo_len]) |snap| p.gpa.free(snap.content);
p.gpa.destroy(file.history);
@@ -1124,8 +1138,7 @@ fn fillBody(dst: ?[]u8, pane: *Pane, f: *State, width: usize, record_wrap: bool)
return written;
}
-pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void {
- const s = &p.surface;
+pub fn drawGutter(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void {
const prefix_width = gutterWidth(pane);
const goff = pane.scroll();
const gcur = Terminal.gridCursor(pane);
@@ -1176,11 +1189,10 @@ fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle {
/// syntax colors: recolor each content cell from its tree-sitter style byte;
/// content starts after the lineno gutter
-pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void {
+pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void {
const highlights = if (f.mini) |mini| mini.colors else f.highlights;
const highlight_start = if (f.mini != null) 0 else f.highlight_start;
if (highlights.len == 0 and f.output == null) return;
- const s = &p.surface;
const prefix_width = gutterWidth(pane);
const tz_recolor = tracy.zone(@src(), "synRecolor");
defer tz_recolor.end();
@@ -1255,6 +1267,7 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16
pub fn drawWrapMarkers(
p: *Pardes,
+ s: *Surface,
pane: *const Pane,
r: pardes.Rect,
tx: u16,
@@ -1268,7 +1281,7 @@ pub fn drawWrapMarkers(
var row: u16 = 0;
while (row + 1 < pane.wrap_n and row + 1 < body_h) : (row += 1) {
if (pane.wrap_line[row + 1] != pane.wrap_line[row]) continue;
- p.surface.set(tx + tw - 1, body_y + row, config.wrap_marker, .{
+ s.set(tx + tw - 1, body_y + row, config.wrap_marker, .{
.fg = .{ .rgb = marker_fg },
.bg = pane_bg,
});
@@ -1277,6 +1290,7 @@ pub fn drawWrapMarkers(
pub fn paintWordSelection(
p: *Pardes,
+ s: *Surface,
pane: *Pane,
r: pardes.Rect,
row: i32,
@@ -1301,7 +1315,7 @@ pub fn paintWordSelection(
const c1 = @as(i32, prefix_width) + displayEndOffset(pane, row, here.at, hi - 1);
var col = @max(@as(i32, prefix_width), c0);
while (col <= c1 and col < @as(i32, tw)) : (col += 1) {
- const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr)));
+ const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr)));
cell.default = false;
cell.style.bg = .{ .rgb = bg };
}
diff --git a/src/Layer.zig b/src/Layer.zig
new file mode 100644
index 00000000..a9827304
--- /dev/null
+++ b/src/Layer.zig
@@ -0,0 +1,201 @@
+//! A band of cells a pixel shell draws over the grid at its own pitch: a
+//! pane's, a column's or the workspace's tag rows, a notice chip, or a file
+//! body whose compact tree-sitter context rows outnumber its grid rows. The
+//! grid under it holds a copy of what fits there (draw.zig joins them), so a
+//! terminal client, which has only the grid, loses nothing but the pitch.
+//!
+//! Its viewport is in grid cells, so compact text never moves a hit target.
+//! A tag's rows are each one grid row, their glyphs at the tagline's size; a
+//! body's first `context_rows` rows are stacked at the tagline's height, the
+//! rest at the body's. `rows == 0` is no layer.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const config = @import("config.zig");
+const limits = @import("memory.zig").limits;
+
+const Layer = @This();
+
+/// `notice` is a pane's transient line -- a builtin's name, a pending leader
+/// chord -- carried as a tagline-shaped band so it gets the tagline's height,
+/// small-font metrics, band offset and border instead of being a body-grid row
+/// wearing a tagline font.
+pub const Kind = enum(u8) { workspace, column, pane, notice, body };
+
+pub const Metrics = struct {
+ body_w: u16 = 1,
+ body_h: u16 = 1,
+ tagline_w: u16 = 1,
+ tagline_h: u16 = 1,
+};
+
+/// A point on a tag, notice or header layer: which row of it and which
+/// column at the tagline's pitch.
+pub const TagHit = struct {
+ kind: Kind,
+ id: u16,
+ serial: u32,
+ col: u16,
+ /// Which row of a taller tag.
+ line: u16 = 0,
+ pixel_x: f32,
+ pixel_y: f32,
+ metrics: Metrics,
+};
+
+kind: Kind = .body,
+/// The pane slot, or the column, it belongs to.
+id: u16 = 0,
+serial: u32 = 0,
+viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 },
+cols: u16 = 0,
+rows: u16 = 0,
+/// A body's compact rows, from its top.
+context_rows: u16 = 0,
+context_separators: std.bit_set.StaticBitSet(limits.wrap_rows) = .initEmpty(),
+/// `rows * cols`, row after row.
+cells: []pardes.Cell = &.{},
+cursor: ?struct { x: u16, y: u16, bar: bool = false } = null,
+/// A band's fill, where its cells run out.
+bg: [3]u8 = .{ 0, 0, 0 },
+/// Vertical offset in rows (negative: above its viewport), for a band
+/// that is moving into place. A pixel shell draws it offset and clipped
+/// to the rows below the viewport's top; the grid has no such thing.
+slide: f32 = 0,
+/// How present a notice band is (1 = fully) while it fades up or
+/// dissolves. `bg` and the cells are already blended by it; a pixel
+/// shell uses it for what it draws around the band, like its rule.
+fade: f32 = 1,
+
+/// Tag columns a viewport this wide holds at the tagline's pitch.
+pub fn capacity(layer: *const Layer, metrics: Metrics) u16 {
+ return @intCast(@min(std.math.maxInt(u16), @max(1, @as(u32, layer.viewport.w) * @max(1, metrics.body_w) / @max(1, metrics.tagline_w))));
+}
+
+pub fn setContextLines(layer: *Layer, lines: []const i32) void {
+ layer.context_separators = .initEmpty();
+ const count = @min(lines.len, layer.context_rows);
+ var index: usize = 0;
+ while (index + 1 < count and index < limits.wrap_rows) : (index += 1) {
+ if (lines[index + 1] != lines[index] +| 1) layer.context_separators.set(index);
+ }
+}
+
+pub fn hasContextBorderAfter(layer: *const Layer, index: usize) bool {
+ if (index >= layer.context_rows or index >= limits.wrap_rows) return false;
+ return index + 1 == layer.context_rows or layer.context_separators.isSet(index);
+}
+
+/// Pixel top of row `index`: a body's compact rows stacked at the tagline's
+/// height, every other row a grid row.
+pub fn rowTop(layer: *const Layer, index: u16, body_h: f32, tagline_h: f32) f32 {
+ const body = @max(1, body_h);
+ const tag = std.math.clamp(tagline_h, 1, body);
+ return @as(f32, @floatFromInt(layer.viewport.y)) * body +
+ @as(f32, @floatFromInt(@min(index, layer.context_rows))) * tag +
+ @as(f32, @floatFromInt(index -| layer.context_rows)) * body;
+}
+
+pub fn rowHeight(layer: *const Layer, index: u16, body_h: f32, tagline_h: f32) f32 {
+ return if (index < layer.context_rows) std.math.clamp(tagline_h, 1, @max(1, body_h)) else @max(1, body_h);
+}
+
+const Point = struct { index: u16, col: u16, metrics: Metrics };
+
+/// The row and column a window pixel lands on, each row at its own pitch:
+/// a tag's at the tagline's width, a body's compact rows too.
+fn pointAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?Point {
+ if (layer.rows == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null;
+ // A band's hit is refused outright on metrics no host could have; a
+ // body's has always been floored to a pixel instead, and both refuse
+ // what is no number or past a u16 (a host's floats: web's exports).
+ for ([_]f32{ body_w, body_h, tagline_w, tagline_h }) |metric| {
+ if (!std.math.isFinite(metric) or metric > std.math.maxInt(u16)) return null;
+ if (layer.kind != .body and metric < 1) return null;
+ }
+ const bw = @max(1, body_w);
+ const bh = @max(1, body_h);
+ const tw = std.math.clamp(tagline_w, 1, bw);
+ const th = std.math.clamp(tagline_h, 1, bh);
+ const dx = x - @as(f32, @floatFromInt(layer.viewport.x)) * bw;
+ const dy = y - @as(f32, @floatFromInt(layer.viewport.y)) * bh;
+ if (dx < 0 or dy < 0 or dx >= @as(f32, @floatFromInt(layer.viewport.w)) * bw or dy >= @as(f32, @floatFromInt(layer.viewport.h)) * bh) return null;
+ const headers = @as(f32, @floatFromInt(layer.context_rows)) * th;
+ const index: u16 = @intFromFloat(if (dy < headers) @floor(dy / th) else @as(f32, @floatFromInt(layer.context_rows)) + @floor((dy - headers) / bh));
+ if (index >= layer.rows) return null;
+ const pitch = if (layer.kind != .body or index < layer.context_rows) tw else bw;
+ return .{
+ .index = index,
+ .col = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols -| 1)), @floor(dx / pitch))),
+ .metrics = .{ .body_w = @intFromFloat(bw), .body_h = @intFromFloat(bh), .tagline_w = @intFromFloat(tw), .tagline_h = @intFromFloat(th) },
+ };
+}
+
+/// A body layer's pixel as the body cell under it (grid columns, from the
+/// pane's text edge).
+pub fn bodyHit(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?pardes.Mouse.BodyHit {
+ if (layer.kind != .body) return null;
+ const point = layer.pointAt(x, y, body_w, body_h, tagline_w, tagline_h) orelse return null;
+ return .{ .serial = layer.serial, .row = point.index, .col = point.col + config.GUTTER, .pixel_x = x, .pixel_y = y, .metrics = point.metrics };
+}
+
+/// A band's pixel as the tag cell under it.
+pub fn tagHit(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?TagHit {
+ if (layer.kind == .body) return null;
+ const point = layer.pointAt(x, y, body_w, body_h, tagline_w, tagline_h) orelse return null;
+ return .{ .kind = layer.kind, .id = layer.id, .serial = layer.serial, .line = point.index, .col = point.col, .pixel_x = x, .pixel_y = y, .metrics = point.metrics };
+}
+
+test "compact body layer rows fill reclaimed height and invert exact boundaries" {
+ const layer: Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 40, .h = 10 }, .cols = 40, .rows = 15, .context_rows = 5 };
+ try std.testing.expectEqual(@as(f32, 120), layer.rowTop(5, 20, 12));
+ try std.testing.expectEqual(@as(f32, 240), layer.rowTop(11, 20, 12));
+ try std.testing.expectEqual(@as(f32, 12), layer.rowHeight(4, 20, 12));
+ try std.testing.expectEqual(@as(f32, 20), layer.rowHeight(5, 20, 12));
+ for (0..12) |index| {
+ const r: u16 = @intCast(index);
+ const y = layer.rowTop(r, 20, 12) + layer.rowHeight(r, 20, 12) / 2;
+ const width: f32 = if (r < 5) 6 else 10;
+ const hit = layer.bodyHit(40 + 3.5 * width, y, 10, 20, 6, 12).?;
+ try std.testing.expectEqual(r, hit.row);
+ try std.testing.expectEqual(@as(u16, 3 + config.GUTTER), hit.col);
+ }
+ try std.testing.expectEqual(@as(u16, 4), layer.bodyHit(45, 119.99, 10, 20, 6, 12).?.row);
+ try std.testing.expectEqual(@as(u16, 5), layer.bodyHit(45, 120, 10, 20, 6, 12).?.row);
+ try std.testing.expectEqual(@as(u16, 11), layer.bodyHit(45, 259.99, 10, 20, 6, 12).?.row);
+ try std.testing.expect(layer.bodyHit(45, 260, 10, 20, 6, 12) == null);
+ try std.testing.expect(layer.bodyHit(39.99, 80, 10, 20, 6, 12) == null);
+ try std.testing.expect(layer.bodyHit(std.math.nan(f32), 80, 10, 20, 6, 12) == null);
+}
+
+test "compact context borders separate source gaps and preserve multiline headers" {
+ var layer: Layer = .{ .context_rows = 6 };
+ layer.setContextLines(&.{ 3, 4, 8, 9, 10, 17 });
+ const expected = [_]bool{ false, true, false, false, true, true, false };
+ for (expected, 0..) |border, index| try std.testing.expectEqual(border, layer.hasContextBorderAfter(index));
+ layer.context_rows = 2;
+ layer.setContextLines(&.{ 9, 10 });
+ try std.testing.expect(!layer.hasContextBorderAfter(0));
+ try std.testing.expect(layer.hasContextBorderAfter(1));
+ try std.testing.expect(!layer.hasContextBorderAfter(4));
+ try std.testing.expect(!layer.context_separators.isSet(4));
+}
+
+test "tag text capacity uses its own pitch and excludes the physical grip" {
+ const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 1 }, .cols = 90, .rows = 1 };
+ try std.testing.expectEqual(@as(u16, 63), layer.capacity(.{ .body_w = 10, .tagline_w = 6 }));
+ try std.testing.expect(layer.tagHit(119.99, 85, 10, 20, 6, 12) == null);
+ try std.testing.expectEqual(@as(u16, 0), layer.tagHit(120, 85, 10, 20, 6, 12).?.col);
+ try std.testing.expectEqual(@as(u16, 50), layer.tagHit(423, 85, 10, 20, 6, 12).?.col);
+ try std.testing.expect(layer.tagHit(500, 85, 10, 20, 6, 12) == null);
+ try std.testing.expect(layer.tagHit(120, 85, std.math.nan(f32), 20, 6, 12) == null);
+ try std.testing.expect(layer.tagHit(120, 85, 10, 20, 0, 12) == null);
+}
+
+test "a taller tag's rows are grid rows, each hit on its own line" {
+ const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 3 }, .cols = 90, .rows = 3 };
+ try std.testing.expectEqual(@as(u16, 0), layer.tagHit(120, 80, 10, 20, 6, 12).?.line);
+ try std.testing.expectEqual(@as(u16, 1), layer.tagHit(120, 100, 10, 20, 6, 12).?.line);
+ try std.testing.expectEqual(@as(u16, 2), layer.tagHit(120, 139.99, 10, 20, 6, 12).?.line);
+ try std.testing.expect(layer.tagHit(120, 140, 10, 20, 6, 12) == null);
+ try std.testing.expect(layer.bodyHit(120, 100, 10, 20, 6, 12) == null);
+}
diff --git a/src/Macro.zig b/src/Macro.zig
new file mode 100644
index 00000000..7475992e
--- /dev/null
+++ b/src/Macro.zig
@@ -0,0 +1,176 @@
+//! Keys kept to be typed again. `Q` records the keys that follow into a
+//! register until the next `Q`, and `q` types them again (helix macros);
+//! `.` types again the last insert session together with the normal command
+//! that began it (helix repeat_last_insert). A macro is kept in its register
+//! as helix writes one, in key notation (`xt,S=<ret>_<A-(>`), so it can be
+//! pasted, edited and yanked back like any text.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const Key = pardes.Key;
+
+const Macro = @This();
+
+/// The register `Q` is recording into, 0 when it is not.
+recording: u21 = 0,
+recorded: std.ArrayList(Stored) = .empty,
+/// The keys of the normal command under way (a count, `"<reg>`, a prefix
+/// and the key that completes it), dropped when it completes without
+/// entering insert mode.
+command: std.ArrayList(Stored) = .empty,
+/// `.`'s: the command that began the last insert session and every key
+/// typed in it, the one that left it included.
+last_insert: std.ArrayList(Stored) = .empty,
+/// Where in `last_insert` the typed keys begin, after the command's.
+typed_from: usize = 0,
+/// Keys are going into `last_insert`.
+inserting: bool = false,
+/// How deep macros are being typed again; `.`'s replay is `repeating`.
+replaying: u8 = 0,
+repeating: bool = false,
+
+/// A key as it can be typed again: what it was, not the text it came with.
+pub const Stored = struct {
+ cp: u21,
+ ctrl: bool = false,
+ alt: bool = false,
+ shift: bool = false,
+
+ pub fn of(key: Key) Stored {
+ return .{ .cp = key.cp, .ctrl = key.ctrl, .alt = key.alt, .shift = key.shift };
+ }
+
+ /// The key again, its text made from its codepoint into `buf`.
+ pub fn typed(s: Stored, buf: *[4]u8) Key {
+ var k: Key = .{ .cp = s.cp, .ctrl = s.ctrl, .alt = s.alt, .shift = s.shift };
+ if (!s.ctrl and !s.alt and s.cp >= 0x20 and s.cp != Key.backspace and s.cp < 0xF0000) {
+ const n = std.unicode.utf8Encode(s.cp, buf) catch return k;
+ k.text = buf[0..n];
+ }
+ return k;
+ }
+};
+
+pub fn deinit(m: *Macro, gpa: std.mem.Allocator) void {
+ m.recorded.deinit(gpa);
+ m.command.deinit(gpa);
+ m.last_insert.deinit(gpa);
+ m.* = .{};
+}
+
+/// A typed key, each of its characters one key: what a host delivers as
+/// one key event with a run of text is that run typed.
+pub fn append(list: *std.ArrayList(Stored), gpa: std.mem.Allocator, key: Key) void {
+ const view = std.unicode.Utf8View.init(key.text) catch null;
+ if (key.text.len == 0 or key.ctrl or key.alt or view == null) {
+ list.append(gpa, .of(key)) catch {};
+ return;
+ }
+ var it = view.?.iterator();
+ while (it.nextCodepoint()) |cp| list.append(gpa, .{ .cp = cp, .shift = key.shift }) catch {};
+}
+
+const names = [_]struct { cp: u21, name: []const u8 }{
+ .{ .cp = Key.escape, .name = "esc" },
+ .{ .cp = Key.enter, .name = "ret" },
+ .{ .cp = Key.tab, .name = "tab" },
+ .{ .cp = Key.backspace, .name = "backspace" },
+ .{ .cp = Key.delete, .name = "del" },
+ .{ .cp = ' ', .name = "space" },
+ .{ .cp = '<', .name = "lt" },
+ .{ .cp = '>', .name = "gt" },
+ .{ .cp = '-', .name = "minus" },
+ .{ .cp = Key.up, .name = "up" },
+ .{ .cp = Key.down, .name = "down" },
+ .{ .cp = Key.left, .name = "left" },
+ .{ .cp = Key.right, .name = "right" },
+ .{ .cp = Key.home, .name = "home" },
+ .{ .cp = Key.end, .name = "end" },
+ .{ .cp = Key.page_up, .name = "pageup" },
+ .{ .cp = Key.page_down, .name = "pagedown" },
+};
+
+/// Keys in helix's notation: a plain character as itself, anything else
+/// in angle brackets with its modifiers (`<C-r>`, `<A-(>`, `<ret>`).
+pub fn encode(gpa: std.mem.Allocator, keys: []const Stored) ![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(gpa);
+ for (keys) |k| {
+ var name: ?[]const u8 = null;
+ for (names) |n| if (n.cp == k.cp) {
+ name = n.name;
+ };
+ // `-` is only spelled out after a modifier, where helix needs it
+ if (k.cp == '-' and !k.ctrl and !k.alt) name = null;
+ var buf: [4]u8 = undefined;
+ const bare = if (name) |n| n else buf[0..(std.unicode.utf8Encode(k.cp, &buf) catch continue)];
+ if (!k.ctrl and !k.alt and name == null) {
+ try out.appendSlice(gpa, bare);
+ continue;
+ }
+ try out.append(gpa, '<');
+ if (k.ctrl) try out.appendSlice(gpa, "C-");
+ if (k.alt) try out.appendSlice(gpa, "A-");
+ try out.appendSlice(gpa, bare);
+ try out.append(gpa, '>');
+ }
+ return out.toOwnedSlice(gpa);
+}
+
+/// The keys helix notation names; what it cannot read ends the list.
+pub fn decode(gpa: std.mem.Allocator, text: []const u8) ![]Stored {
+ var out: std.ArrayList(Stored) = .empty;
+ errdefer out.deinit(gpa);
+ var i: usize = 0;
+ while (i < text.len) {
+ const len = std.unicode.utf8ByteSequenceLength(text[i]) catch break;
+ if (i + len > text.len) break;
+ if (text[i] != '<') {
+ try out.append(gpa, .{ .cp = std.unicode.utf8Decode(text[i..][0..len]) catch break });
+ i += len;
+ continue;
+ }
+ const close = std.mem.indexOfScalarPos(u8, text, i + 1, '>') orelse break;
+ var name = text[i + 1 .. close];
+ i = close + 1;
+ var k: Stored = .{ .cp = 0 };
+ while (name.len > 2 and name[1] == '-') : (name = name[2..]) switch (name[0]) {
+ 'C' => k.ctrl = true,
+ 'A' => k.alt = true,
+ 'S' => k.shift = true,
+ else => break,
+ };
+ for (names) |n| if (std.mem.eql(u8, n.name, name)) {
+ k.cp = n.cp;
+ };
+ if (k.cp == 0) k.cp = std.unicode.utf8Decode(name) catch break;
+ try out.append(gpa, k);
+ }
+ return out.toOwnedSlice(gpa);
+}
+
+test "a macro's keys go into helix notation and come back the same" {
+ const gpa = std.testing.allocator;
+ const keys = [_]Stored{
+ .{ .cp = 'x' },
+ .{ .cp = 't' },
+ .{ .cp = ',' },
+ .{ .cp = 'S' },
+ .{ .cp = '=' },
+ .{ .cp = Key.enter },
+ .{ .cp = '_' },
+ .{ .cp = '(', .alt = true },
+ .{ .cp = 'r', .ctrl = true },
+ .{ .cp = '<' },
+ .{ .cp = ' ' },
+ .{ .cp = '-' },
+ .{ .cp = '-', .alt = true },
+ .{ .cp = 'λ' },
+ .{ .cp = Key.escape },
+ };
+ const text = try encode(gpa, &keys);
+ defer gpa.free(text);
+ try std.testing.expectEqualStrings("xt,S=<ret>_<A-(><C-r><lt><space>-<A-minus>λ<esc>", text);
+ const back = try decode(gpa, text);
+ defer gpa.free(back);
+ try std.testing.expectEqualSlices(Stored, &keys, back);
+}
diff --git a/src/Messages.zig b/src/Messages.zig
index 66261eb4..ea62041f 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -5,6 +5,7 @@ const pardes = @import("pardes.zig");
const memory = @import("memory.zig");
const std = @import("std");
const layout = @import("layout.zig");
+const animation = @import("animation.zig");
const ctlfs = @import("ninep/tree.zig");
const panes = @import("panes.zig");
const CellStyle = @import("surface.zig").CellStyle;
@@ -302,11 +303,35 @@ fn advanceLine(p: *const Pardes, life: *Pane.MessageLife) bool {
return false;
}
-/// Is any message line moving (and so owed ticks)?
-pub fn messagesAnimating(pane: *const Pane) bool {
- if (pane.msg_len > 0 and pane.msg_life.phase != .shown) return true;
- for (pane.msg_older[0..pane.msg_older_len]) |line| if (line.life.phase != .shown) return true;
- return false;
+/// Move every lingering message line `frames` along its linger at once:
+/// how `advance` jumps a wait instead of stepping it frame by frame.
+pub fn holdMessages(p: *Pardes, frames: u64) void {
+ const n: u16 = @intCast(@min(frames, std.math.maxInt(u16)));
+ for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ if (pane.msg_len > 0 and pane.msg_life.phase == .lingering) pane.msg_life.frame +|= n;
+ for (pane.msg_older[0..pane.msg_older_len]) |*line| {
+ if (line.life.phase == .lingering) line.life.frame +|= n;
+ }
+ }
+}
+
+/// Frames until this pane's message lines next change: 1 while a line
+/// falls in or dissolves, the rest of its linger while one only waits, null
+/// when every line just shows.
+pub fn messageWake(p: *const Pardes, pane: *const Pane) ?u64 {
+ var soonest: ?u64 = null;
+ const linger = messageFrames(p.settings.message_linger_ms);
+ for (0..@as(usize, pane.msg_older_len) + 1) |i| {
+ const life = if (i < pane.msg_older_len) pane.msg_older[i].life else if (pane.msg_len > 0) pane.msg_life else continue;
+ const frames: u64 = switch (life.phase) {
+ .shown => continue,
+ .entering, .leaving => 1,
+ .lingering => @max(1, linger -| life.frame),
+ };
+ soonest = if (soonest) |n| @min(n, frames) else frames;
+ }
+ return soonest;
}
/// How present a message is (1 = fully) and how far above its row it
@@ -316,7 +341,7 @@ pub const MessageMotion = struct { alpha: f32 = 1, slide: f32 = 0 };
/// Animation frames a message timing lasts; at least one.
pub fn messageFrames(ms: u16) u16 {
- return @intCast(@max(1, (@as(u32, ms) + layout.Animation.frame_ms - 1) / layout.Animation.frame_ms));
+ return @intCast(@max(1, (@as(u32, ms) + animation.frame_ms - 1) / animation.frame_ms));
}
pub fn messageMotion(p: *const Pardes, life: Pane.MessageLife) MessageMotion {
@@ -811,7 +836,7 @@ test "a builtin announces itself on the message row, and Verbose silences it" {
_ = try p.render(frame.allocator());
const r = p.rects[p.active];
const band = for (p.surface.tagLayers()) |*layer| {
- if (layer.cols != 0 and layer.kind == .notice) break layer;
+ if (layer.rows != 0 and layer.kind == .notice) break layer;
} else return error.NoNoticeBand;
try std.testing.expectEqual(@as(u16, 1), band.viewport.h);
try std.testing.expectEqual(if (p.settings.tag_bottom) r.y else r.y + BOX_H, band.viewport.y);
@@ -858,7 +883,7 @@ test "notices stack over the body's top rows without moving them" {
var seen: usize = 0;
for (p.surface.tagLayers()) |*layer| {
- if (layer.cols == 0 or layer.kind != .notice) continue;
+ if (layer.rows == 0 or layer.kind != .notice) continue;
try std.testing.expectEqual(@as(u16, 1), layer.viewport.h);
// Sized to its own message, right up against the pane's right edge --
// not the width of the pane.
@@ -878,5 +903,50 @@ test "notices stack over the body's top rows without moving them" {
_ = frame.reset(.retain_capacity);
_ = try p.render(frame.allocator());
for (p.surface.tagLayers()) |*layer|
- try std.testing.expect(layer.cols == 0 or layer.kind != .notice);
+ try std.testing.expect(layer.rows == 0 or layer.kind != .notice);
+}
+
+test "an idle core wants no wake and a lingering message sleeps to its linger's end" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const frame = animation.frame_ns;
+ p.advance(1_000_000);
+ try std.testing.expectEqual(@as(?u64, null), p.nextWake());
+ const pane = p.panes[p.active].?;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello"));
+ // Falling in: a frame from now.
+ try std.testing.expectEqual(@as(?u64, p.stepped_ns + frame), p.nextWake());
+ const fall = messageFrames(p.settings.message_fall_ms);
+ p.advance(p.stepped_ns + fall * frame);
+ try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
+ try std.testing.expectEqual(@as(?u64, null), p.nextWake());
+ // A key starts the 800 ms linger: the core sleeps to its end, not a
+ // frame at a time, and the frames it jumps draw nothing.
+ p.update(.{ .key = .{ .cp = 'x' } });
+ try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
+ const linger = messageFrames(p.settings.message_linger_ms);
+ const due = p.stepped_ns + linger * frame;
+ try std.testing.expectEqual(@as(?u64, due), p.nextWake());
+ p.needs_frame = false;
+ p.advance(due - frame);
+ try std.testing.expectEqual(@as(?u64, due), p.nextWake());
+ try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
+ p.advance(due);
+ try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase);
+ try std.testing.expectEqual(@as(?u64, due + frame), p.nextWake());
+ p.advance(due + 10 * std.time.ns_per_s);
+ try std.testing.expectEqual(@as(usize, 0), pane.msg_len);
+ try std.testing.expectEqual(@as(?u64, null), p.nextWake());
+
+ // A long linger ends when it says, jumped in one step, not stretched by
+ // the cap on catching up with moving frames.
+ try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 5000"));
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg again"));
+ p.advance(p.stepped_ns + fall * frame);
+ p.update(.{ .key = .{ .cp = 'x' } });
+ const long_due = p.stepped_ns + messageFrames(5000) * frame;
+ try std.testing.expectEqual(@as(?u64, long_due), p.nextWake());
+ p.advance(long_due);
+ try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase);
}
diff --git a/src/Output.zig b/src/Output.zig
index 6e1765a7..201b5491 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -749,7 +749,7 @@ pub fn resetBody(p: *Pardes, pane: *Pane) void {
pane.body.select = false;
pane.sel = @splat(.{});
pane.body.sticky_col = -1;
- pane.body.append_at = null;
+ pane.body.restore_cursor = false;
pane.body.normal.clear();
pane.look_at = null;
if (p.look_hover_wait) |wait| if (wait.serial == pane.serial) {
diff --git a/src/Pipe.zig b/src/Pipe.zig
index 1ad7b826..48e98b7b 100644
--- a/src/Pipe.zig
+++ b/src/Pipe.zig
@@ -35,6 +35,7 @@ pub fn pipeMarker(how: modal.Normal.PipeBehavior) []const u8 {
.ignore => config.pipe_marker_to,
.insert => config.pipe_marker_insert,
.append => config.pipe_marker_append,
+ .keep => config.pipe_marker_keep,
};
}
@@ -54,10 +55,10 @@ pub const PendingPipe = struct {
cwd: []u8,
inputs: []selection_pipe.Input,
ranges: [panes.Text.max_selections]modal.Selection,
- primary: u8,
+ primary: u16,
explicit: bool,
how: modal.Normal.PipeBehavior,
- nranges: u8,
+ nranges: u16,
pub fn deinit(wait: *PendingPipe, gpa: std.mem.Allocator) void {
gpa.free(wait.command);
@@ -117,7 +118,13 @@ pub fn submitPipe(p: *Pardes, id: usize) void {
p.gpa.free(inputs);
return;
}
- const command_copy = p.gpa.dupe(u8, command) catch {
+ // `$` wants a verdict per selection where the runner's is all or
+ // nothing: the command runs in a subshell whose exit status becomes the
+ // output, so the runner always succeeds and the answer says which kept
+ const command_copy = (if (how == .keep)
+ std.fmt.allocPrint(p.gpa, "(\n{s}\n) >/dev/null; echo $?", .{command})
+ else
+ p.gpa.dupe(u8, command)) catch {
for (inputs) |input| p.gpa.free(@constCast(input.bytes));
p.gpa.free(inputs);
return;
@@ -202,7 +209,7 @@ fn pipeCut(wait: *const PendingPipe, content: []const u8, i: usize) ?struct { lo
const hi = @max(range.anchor, range.head);
if (hi > content.len) return null;
return switch (wait.how) {
- .replace, .ignore => .{ .lo = lo, .hi = hi },
+ .replace, .ignore, .keep => .{ .lo = lo, .hi = hi },
.insert => .{ .lo = lo, .hi = lo },
.append => .{ .lo = hi, .hi = hi },
};
@@ -260,6 +267,21 @@ pub fn pipeResponse(
if (changed) return p.reportError(wait.pane, "pipe", error.FileChangedWhileFiltering);
if (wait.how == .ignore) return;
+ if (wait.how == .keep) {
+ // helix shell_keep_pipe: the primary stays if kept, else the last
+ // kept range takes over; keeping none changes nothing
+ var kept: [panes.Text.max_selections]modal.Selection = undefined;
+ var m: usize = 0;
+ var pri: ?usize = null;
+ for (0..wait.nranges) |i| {
+ if (!std.mem.eql(u8, std.mem.trim(u8, outputs[i], " \n"), "0")) continue;
+ if (i == wait.primary) pri = m;
+ kept[m] = wait.ranges[i];
+ m += 1;
+ }
+ if (m == 0) return;
+ return t.setRanges(content, kept[0..m], &.{}, pri orelse m - 1, wait.explicit);
+ }
const n = wait.nranges;
var total_output: usize = 0;
@@ -366,6 +388,43 @@ test "| in a tag pipes the tag's own text, and undo there brings it back" {
try std.testing.expect(std.mem.startsWith(u8, pardes.tagline.curTail(pane), " Save "));
}
+test "$ keeps the selections the command exits 0 on, and a real shell gives that verdict" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = try p.setTestFile("aa bb cc\n");
+ const ranges = [_]modal.Selection{
+ .{ .anchor = 0, .head = 2 },
+ .{ .anchor = 3, .head = 5 },
+ .{ .anchor = 6, .head = 8 },
+ };
+ pane.body.setRanges(pane.file.?.content, &ranges, &.{}, 1, true);
+ p.update(.{ .key = .{ .cp = '$' } });
+ try std.testing.expectEqualStrings(config.pipe_marker_keep, pane.promptText().?);
+ p.update(.{ .key = .{ .cp = 'g', .text = "grep -v b" } });
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ const id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
+ const request = p.pipe.pipeRequest(id) orelse return error.MissingPipeRequest;
+ try std.testing.expectEqual(@as(usize, 3), request.inputs.len);
+ // the wrapper turns each run's status into its output, as the runner sees it
+ const job = try selection_pipe.Job.copy(gpa, request);
+ defer job.deinit(gpa);
+ var response = selection_pipe.runJob(gpa, std.testing.io, job);
+ defer response.deinit(gpa);
+ try std.testing.expect(response.success);
+ try std.testing.expectEqualStrings("0\n", response.outputs[0]);
+ try std.testing.expectEqualStrings("1\n", response.outputs[1]);
+ p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = response.outputs } });
+ try std.testing.expectEqualStrings("aa bb cc\n", pane.file.?.content);
+ var out: [panes.Text.max_selections]modal.Selection = undefined;
+ const got = pane.body.ranges(pane.file.?.content, 0, &out);
+ try std.testing.expectEqual(@as(usize, 2), got.n);
+ try std.testing.expectEqual(@as(usize, 6), out[1].anchor);
+ // the primary was dropped: the last kept range takes over
+ try std.testing.expectEqual(@as(usize, 1), got.pri);
+}
+
test "selection pipe replaces all ranges atomically and undo restores them" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true });
@@ -394,7 +453,7 @@ test "selection pipe replaces all ranges atomically and undo restores them" {
p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } });
try std.testing.expectEqualSlices(u8, "AA bb \n", pane.file.?.content);
try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len);
- try std.testing.expectEqual(@as(u8, 1), pane.body.nsel);
+ try std.testing.expectEqual(@as(u16, 1), pane.body.nsel);
try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col);
try std.testing.expectEqual(@as(i32, 6), pane.body.sels[0].col);
try std.testing.expect(!denied.has_induced_failure);
diff --git a/src/Presentation.zig b/src/Presentation.zig
new file mode 100644
index 00000000..3b5bb3e0
--- /dev/null
+++ b/src/Presentation.zig
@@ -0,0 +1,735 @@
+//! Panel presentation: which pane geometry the shells have been shown, the
+//! transition tracks that move it there, and the frozen previous grid the
+//! character and dissolve effects read. Only acknowledged draws advance the
+//! shown geometry; committed and submitted may be ahead of it.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const layout = @import("layout.zig");
+const animation = @import("animation.zig");
+const Pardes = pardes.Pardes;
+const MAX_PANES = pardes.MAX_PANES;
+const Box = animation.Box;
+const Track = animation.Track;
+const Phase = animation.Phase;
+const Transition = animation.Transition;
+const CellArea = animation.CellArea;
+const CharSource = animation.CharSource;
+const charSource = animation.charSource;
+const dissolveRevealed = animation.dissolveRevealed;
+const openingBox = animation.openingBox;
+const closingBox = animation.closingBox;
+const paintOrder = animation.paintOrder;
+const panelBox = layout.panelBox;
+
+const Presentation = @This();
+
+pub const Snapshot = struct { serial: u32, box: Box };
+
+// Only acknowledged draws advance shown geometry; committed and submitted may be ahead.
+committed: [MAX_PANES]?Snapshot = @splat(null),
+initialized: bool = false,
+enabled: bool = false,
+snap_once: bool = false,
+tracks: [MAX_PANES]?Track = @splat(null),
+closing: [MAX_PANES]Track = undefined,
+closing_len: usize = 0,
+submitted: [MAX_PANES]?Snapshot = @splat(null),
+submitted_ready: bool = false,
+shown: [MAX_PANES]?Snapshot = @splat(null),
+shown_tracks: [MAX_PANES]?Track = @splat(null),
+shown_closing: [MAX_PANES]Track = undefined,
+shown_closing_len: usize = 0,
+acknowledged: bool = false,
+pending: bool = false,
+previous_cells: []pardes.Cell = &.{},
+previous_body_layers: [MAX_PANES]pardes.Layer = @splat(.{}),
+previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}),
+previous_regions: [pardes.MAX_REGIONS]pardes.Region = undefined,
+previous_nregions: u16 = 0,
+previous_cols: u16 = 0,
+previous_rows: u16 = 0,
+previous_valid: bool = false,
+previous_layout: [MAX_PANES]?Snapshot = @splat(null),
+diffs: []pardes.PanelCellDiff = &.{},
+diff_state: enum { none, requested, ready } = .none,
+
+pub const CellPosition = struct { col: u16, row: u16 };
+
+pub fn deinit(self: *Presentation, gpa: std.mem.Allocator) void {
+ if (self.previous_cells.len > 0) gpa.free(self.previous_cells);
+ for (&self.previous_body_layers) |*layer| gpa.free(layer.cells);
+ for (&self.previous_tag_layers) |*layer| gpa.free(layer.cells);
+ if (self.diffs.len > 0) gpa.free(self.diffs);
+}
+
+pub fn sync(self: *Presentation, p: *Pardes) void {
+ const initializing = !self.initialized or !self.enabled;
+ if (initializing or self.snap_once) {
+ const had_layout = self.initialized;
+ self.committed = @splat(null);
+ self.tracks = @splat(null);
+ self.closing_len = 0;
+ self.diff_state = .none;
+ if (initializing) {
+ self.shown_tracks = @splat(null);
+ self.shown_closing_len = 0;
+ }
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
+ }
+ self.initialized = true;
+ self.snap_once = false;
+ if (had_layout and self.acknowledged)
+ self.pending = true;
+ return;
+ }
+
+ const effect = p.settings.panel_transition;
+ if (effect.needsPreviousGrid() and self.diff_state != .none) {
+ var second_change = false;
+ for (p.panes, self.committed, 0..) |slot, snapshot, id| {
+ const pane = slot orelse {
+ second_change = second_change or snapshot != null;
+ continue;
+ };
+ const target = panelBox(p.rects[id]);
+ second_change = second_change or snapshot == null or
+ snapshot.?.serial != pane.serial or !snapshot.?.box.eql(target);
+ }
+ if (second_change) {
+ p.abandonPanelAnimations();
+ self.committed = @splat(null);
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
+ }
+ return;
+ }
+ }
+ var changed = false;
+ var animated_change = false;
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse {
+ if (self.committed[id]) |old| {
+ changed = true;
+ if (effect.lifecycleOnly()) {
+ if (self.appendClosing(.{
+ .serial = old.serial,
+ .pane = @intCast(id),
+ .phase = .closing,
+ .effect = effect,
+ .from = old.box,
+ .to = closingBox(effect, old.box),
+ })) animated_change = true;
+ }
+ }
+ self.committed[id] = null;
+ self.tracks[id] = null;
+ // Never let pixels from a dead pane address a reused slot.
+ self.shown_tracks[id] = null;
+ continue;
+ };
+ const target = panelBox(p.rects[id]);
+ const previous = self.committed[id];
+ self.committed[id] = .{ .serial = pane.serial, .box = target };
+
+ if (effect == .off) {
+ changed = changed or previous == null or previous.?.serial != pane.serial or
+ !previous.?.box.eql(target);
+ self.tracks[id] = null;
+ continue;
+ }
+ if (previous) |old| {
+ if (old.serial == pane.serial and old.box.eql(target)) continue;
+ const same_lifetime = old.serial == pane.serial;
+ const prior = if (self.tracks[id]) |track|
+ if (track.serial == pane.serial and track.active()) track else null
+ else
+ null;
+ if (effect.lifecycleOnly() and same_lifetime) {
+ if (prior) |active| if (active.phase == .opening) {
+ var next = active;
+ next.from = openingBox(effect, target, p.screen_w);
+ next.to = target;
+ self.tracks[id] = next;
+ changed = true;
+ animated_change = true;
+ continue;
+ };
+ self.tracks[id] = null;
+ changed = true;
+ continue;
+ }
+ if (effect.lifecycleOnly() and !same_lifetime) {
+ _ = self.appendClosing(.{
+ .serial = old.serial,
+ .pane = @intCast(id),
+ .phase = .closing,
+ .effect = effect,
+ .from = old.box,
+ .to = closingBox(effect, old.box),
+ });
+ }
+ const shown = if (self.shown[id]) |snapshot|
+ if (snapshot.serial == pane.serial) snapshot.box else null
+ else
+ null;
+ const from = if (!same_lifetime)
+ openingBox(effect, target, p.screen_w)
+ else if (shown) |box|
+ box
+ else if (self.acknowledged and prior != null)
+ prior.?.from
+ else
+ old.box;
+ const next: Track = .{
+ .serial = pane.serial,
+ .pane = @intCast(id),
+ .phase = if (!same_lifetime or
+ (prior != null and prior.?.phase == .opening)) .opening else .moving,
+ .effect = effect,
+ .from = from,
+ .to = target,
+ };
+ self.tracks[id] = next;
+ changed = true;
+ animated_change = true;
+ } else {
+ const next: Track = .{
+ .serial = pane.serial,
+ .pane = @intCast(id),
+ .phase = .opening,
+ .effect = effect,
+ .from = openingBox(effect, target, p.screen_w),
+ .to = target,
+ };
+ self.tracks[id] = next;
+ changed = true;
+ animated_change = true;
+ }
+ }
+ if (animated_change and effect.needsPreviousGrid()) {
+ self.diff_state = .requested;
+ }
+ if (changed and self.acknowledged) self.pending = true;
+}
+
+fn appendClosing(self: *Presentation, track: Track) bool {
+ std.debug.assert(track.phase == .closing);
+ const baseline = self.previous_layout[track.pane] orelse return false;
+ if (!self.previous_valid or baseline.serial != track.serial or
+ !baseline.box.eql(track.from)) return false;
+ if (self.closing_len == self.closing.len) {
+ std.mem.copyForwards(
+ Track,
+ self.closing[0 .. self.closing.len - 1],
+ self.closing[1..],
+ );
+ self.closing_len -= 1;
+ }
+ self.closing[self.closing_len] = track;
+ self.closing_len += 1;
+ return true;
+}
+
+pub fn advance(self: *Presentation) void {
+ for (&self.tracks) |*slot| {
+ const track = if (slot.*) |*track| track else continue;
+ track.frame +|= 1;
+ if (!track.active()) slot.* = null;
+ }
+ var out: usize = 0;
+ for (self.closing[0..self.closing_len]) |value| {
+ var track = value;
+ track.frame +|= 1;
+ if (!track.active()) continue;
+ self.closing[out] = track;
+ out += 1;
+ }
+ self.closing_len = out;
+}
+
+pub fn acknowledge(self: *Presentation, p: *Pardes, tracks: []const Track) void {
+ var presented: [MAX_PANES]?Track = @splat(null);
+ var presented_closing: [MAX_PANES]Track = undefined;
+ var nclosing: usize = 0;
+ var presented_layout: [MAX_PANES]?Snapshot = if (self.submitted_ready)
+ self.submitted
+ else
+ @splat(null);
+ if (!self.submitted_ready) for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ presented_layout[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
+ };
+ for (&presented_layout, 0..) |*snapshot, id| if (snapshot.*) |saved| {
+ const pane = p.panes[id] orelse {
+ snapshot.* = null;
+ continue;
+ };
+ if (pane.serial != saved.serial) snapshot.* = null;
+ };
+ for (tracks) |track| {
+ if (track.phase == .closing) {
+ const current = for (self.closing[0..self.closing_len]) |candidate| {
+ if (candidate.serial == track.serial and candidate.pane == track.pane and
+ candidate.effect == track.effect) break true;
+ } else false;
+ if (!current or !track.active() or nclosing == presented_closing.len) continue;
+ presented_closing[nclosing] = track;
+ nclosing += 1;
+ continue;
+ }
+ const id: usize = track.pane;
+ if (id >= p.panes.len or !track.active()) continue;
+ const pane = p.panes[id] orelse continue;
+ if (pane.serial != track.serial) continue;
+ presented[id] = track;
+ presented_layout[id] = .{ .serial = track.serial, .box = track.visualBox() };
+ }
+ self.shown_tracks = presented;
+ self.shown_closing = presented_closing;
+ self.shown_closing_len = nclosing;
+ self.shown = presented_layout;
+ self.acknowledged = true;
+ self.pending = false;
+ if (tracks.len == 0) {
+ self.tracks = @splat(null);
+ self.closing_len = 0;
+ self.diff_state = .none;
+ // The frozen grid exists for transitions alone: with them off, an
+ // idle frame copies nothing.
+ if (p.settings.panel_transition != .off) {
+ self.capturePrevious(p.gpa, &p.surface);
+ } else {
+ self.previous_valid = false;
+ self.previous_layout = @splat(null);
+ }
+ }
+}
+
+pub fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) void {
+ self.diff_state = .none;
+ const cells = surface.cells;
+ if (cells.len == 0) {
+ self.previous_valid = false;
+ self.previous_layout = @splat(null);
+ return;
+ }
+ if (self.previous_cells.len != cells.len) {
+ const next = gpa.alloc(pardes.Cell, cells.len) catch {
+ self.previous_valid = false;
+ self.previous_layout = @splat(null);
+ return;
+ };
+ if (self.previous_cells.len > 0) gpa.free(self.previous_cells);
+ self.previous_cells = next;
+ }
+ for (&self.previous_body_layers, surface.bodyLayers()) |*old, *current| {
+ const count = if (current.rows > 0) current.cells.len else 0;
+ const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch {
+ self.previous_valid = false;
+ self.previous_layout = @splat(null);
+ return;
+ };
+ if (count > 0) @memcpy(storage, current.cells);
+ old.* = current.*;
+ old.cells = storage;
+ }
+ for (&self.previous_tag_layers, surface.tagLayers()) |*old, *current| {
+ const count = if (current.rows > 0) current.cells.len else 0;
+ const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch {
+ self.previous_valid = false;
+ self.previous_layout = @splat(null);
+ return;
+ };
+ if (count > 0) @memcpy(storage, current.cells);
+ old.* = current.*;
+ old.cells = storage;
+ }
+ @memcpy(self.previous_cells, cells);
+ @memcpy(self.previous_regions[0..surface.nregions], surface.regionList());
+ self.previous_nregions = surface.nregions;
+ self.previous_cols = surface.cols;
+ self.previous_rows = surface.rows;
+ self.previous_valid = true;
+ self.previous_layout = if (self.submitted_ready)
+ self.submitted
+ else
+ @splat(null);
+}
+
+pub fn cellDiff(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) pardes.PanelCellDiff {
+ if (self.diff_state != .ready or col >= cols or row >= rows or
+ self.diffs.len != @as(usize, cols) * rows) return .unchanged;
+ return self.diffs[@as(usize, row) * cols + col];
+}
+
+pub const Point = struct { x: f32, y: f32 };
+
+/// Coordinates stay fractional until the host resolves its body font grid.
+pub fn pointerFractional(self: *const Presentation, cols: u16, rows: u16, x: f32, y: f32) ?Point {
+ if (self.pending or !std.math.isFinite(x) or !std.math.isFinite(y) or
+ x < 0 or y < 0 or x >= @as(f32, @floatFromInt(cols)) or y >= @as(f32, @floatFromInt(rows))) return null;
+ var closing = self.shown_closing_len;
+ while (closing > 0) {
+ closing -= 1;
+ const track = self.shown_closing[closing];
+ if (track.active() and boxContainsPoint(track.contentBox(), x, y) and
+ boxContainsPoint(track.presented(), x, y)) return null;
+ }
+ for ([_]Phase{ .opening, .moving }) |phase| {
+ var index = self.shown_tracks.len;
+ while (index > 0) {
+ index -= 1;
+ const track = self.shown_tracks[index] orelse continue;
+ if (!track.active() or track.phase != phase) continue;
+ switch (track.effect) {
+ .slide, .zoom, .vertical => {
+ const target = track.contentBox();
+ if (track.effect == .vertical and !boxContainsPoint(target, x, y)) continue;
+ const shown = track.presented();
+ if (!boxContainsPoint(shown, x, y)) {
+ if (track.effect == .vertical) return null;
+ continue;
+ }
+ return .{
+ .x = target.x + (x - shown.x) / shown.w * target.w,
+ .y = target.y + (y - shown.y) / shown.h * target.h,
+ };
+ },
+ else => {
+ if (!boxContainsPoint(track.to, x, y)) continue;
+ _ = self.pointer(cols, rows, @intFromFloat(@floor(x)), @intFromFloat(@floor(y))) orelse return null;
+ return .{ .x = x, .y = y };
+ },
+ }
+ }
+ }
+ _ = self.pointer(cols, rows, @intFromFloat(@floor(x)), @intFromFloat(@floor(y))) orelse return null;
+ return .{ .x = x, .y = y };
+}
+
+fn boxContainsPoint(box: Box, x: f32, y: f32) bool {
+ return box.w > 0 and box.h > 0 and x >= box.x and y >= box.y and x < box.x + box.w and y < box.y + box.h;
+}
+
+pub fn pointer(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) ?CellPosition {
+ if (self.pending) return null;
+ var closing = self.shown_closing_len;
+ while (closing > 0) {
+ closing -= 1;
+ const track = self.shown_closing[closing];
+ if (!track.active()) continue;
+ if (boxContainsCell(track.contentBox(), col, row) and
+ boxContainsCell(track.presented(), col, row)) return null;
+ }
+ const phases = [_]Phase{ .opening, .moving };
+ for (phases) |phase| {
+ // Backends paint pane slots forward within a phase. Probe them in
+ // reverse so overlapping transition quads address the top pixel.
+ var index = self.shown_tracks.len;
+ while (index > 0) {
+ index -= 1;
+ const track = self.shown_tracks[index] orelse continue;
+ if (!track.active() or track.phase != phase) continue;
+ switch (track.effect) {
+ .slide, .zoom => {
+ const shown = track.presented();
+ if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue;
+ const x = @as(f32, @floatFromInt(col)) + 0.5;
+ const y = @as(f32, @floatFromInt(row)) + 0.5;
+ const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999);
+ const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999);
+ const logical_x: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w));
+ const logical_y: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h));
+ return .{
+ .col = @intCast(std.math.clamp(logical_x, 0, @as(i32, cols -| 1))),
+ .row = @intCast(std.math.clamp(logical_y, 0, @as(i32, rows -| 1))),
+ };
+ },
+ .vertical => {
+ const clip = track.contentBox();
+ if (!boxContainsCell(clip, col, row)) continue;
+ const shown = track.presented();
+ if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row))
+ return null;
+ const x = @as(f32, @floatFromInt(col)) + 0.5;
+ const y = @as(f32, @floatFromInt(row)) + 0.5;
+ const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999);
+ const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999);
+ return .{
+ .col = @intFromFloat(@floor(clip.x + u * clip.w)),
+ .row = @intFromFloat(@floor(clip.y + v * clip.h)),
+ };
+ },
+ .dissolve, .ascii => {
+ if (!boxContainsCell(track.to, col, row)) continue;
+ const cell_diff = self.cellDiff(cols, rows, col, row);
+ if (!cell_diff.changed())
+ return .{ .col = col, .row = row };
+ const relative_col: u16 = @intFromFloat(@floor(
+ @as(f32, @floatFromInt(col)) + 0.5 - track.to.x,
+ ));
+ const relative_row: u16 = @intFromFloat(@floor(
+ @as(f32, @floatFromInt(row)) + 0.5 - track.to.y,
+ ));
+ const visible = switch (track.effect) {
+ .dissolve => dissolveRevealed(
+ track.serial,
+ relative_col,
+ relative_row,
+ track.amount(),
+ ),
+ .ascii => switch (cell_diff) {
+ .ascii => |diff| diff.complete(track.frame),
+ .unchanged, .visual => true,
+ },
+ else => unreachable,
+ };
+ return if (visible) .{ .col = col, .row = row } else null;
+ },
+ .edges, .fall, .wave, .curtain, .scramble, .typewriter => {
+ if (!boxContainsCell(track.to, col, row)) continue;
+ const area = CellArea.of(track.to);
+ const settled = std.meta.eql(
+ charSource(
+ track,
+ col -| area.x0,
+ row -| area.y0,
+ area,
+ ),
+ CharSource.settled,
+ );
+ return if (settled) .{ .col = col, .row = row } else null;
+ },
+ .off => {},
+ }
+ }
+ }
+ for (self.shown_tracks) |maybe| {
+ const track = maybe orelse continue;
+ if (!track.active() or (track.effect != .slide and track.effect != .zoom and
+ track.effect != .vertical)) continue;
+ if (boxContainsCell(track.to, col, row)) return null;
+ }
+ return .{ .col = col, .row = row };
+}
+
+fn boxContainsCell(box: Box, col: u16, row: u16) bool {
+ const x = @as(f32, @floatFromInt(col)) + 0.5;
+ const y = @as(f32, @floatFromInt(row)) + 0.5;
+ return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h;
+}
+
+pub fn cancel(self: *Presentation) void {
+ self.tracks = @splat(null);
+ self.closing_len = 0;
+ self.shown_tracks = @splat(null);
+ self.shown_closing_len = 0;
+ self.diff_state = .none;
+ self.pending = self.acknowledged;
+}
+
+fn prepareDiff(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) !bool {
+ const count = surface.cells.len;
+ if (!self.previous_valid or
+ self.previous_cols != surface.cols or
+ self.previous_rows != surface.rows or
+ self.previous_cells.len != count) return false;
+ if (self.diffs.len != count) {
+ const next = try gpa.alloc(pardes.PanelCellDiff, count);
+ if (self.diffs.len > 0) gpa.free(self.diffs);
+ self.diffs = next;
+ }
+ for (self.diffs, self.previous_cells, surface.cells) |*diff, *old, *new|
+ diff.* = pardes.PanelCellDiff.between(old, new);
+ for (&self.tracks) |*slot| {
+ const track = if (slot.*) |*track| track else continue;
+ if (track.effect != .ascii) continue;
+ var longest: u16 = 1;
+ var row: u16 = 0;
+ while (row < surface.rows) : (row += 1) {
+ var col: u16 = 0;
+ while (col < surface.cols) : (col += 1) {
+ if (!boxContainsCell(track.to, col, row)) continue;
+ const index = @as(usize, row) * surface.cols + col;
+ switch (self.diffs[index]) {
+ .ascii => |diff| longest = @max(longest, diff.frameCount()),
+ .unchanged, .visual => {},
+ }
+ }
+ }
+ track.frame_count = @max(track.frame_count, longest);
+ }
+ self.diff_state = .ready;
+ return true;
+}
+
+pub fn submit(self: *Presentation, p: *Pardes, s: *pardes.Surface) !void {
+ self.submitted = @splat(null);
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ self.submitted[id] = .{
+ .serial = pane.serial,
+ .box = panelBox(p.rects[id]),
+ };
+ }
+ self.submitted_ready = true;
+ if (self.diff_state != .none and !try self.prepareDiff(p.gpa, s)) {
+ for (&self.tracks) |*slot| {
+ const track = slot.* orelse continue;
+ if (track.effect.needsPreviousGrid()) slot.* = null;
+ }
+ self.closing_len = 0;
+ self.diff_state = .none;
+ }
+ if (self.diff_state == .ready) {
+ s.previous_cells = self.previous_cells;
+ s.previous_body_layers = &self.previous_body_layers;
+ s.previous_tag_layers = &self.previous_tag_layers;
+ s.previous_regions = self.previous_regions[0..self.previous_nregions];
+ s.cell_diffs = self.diffs;
+ }
+ s.npanel_tracks = paintOrder(
+ &self.tracks,
+ self.closing[0..self.closing_len],
+ &s.panel_tracks,
+ );
+}
+
+pub fn animating(self: *const Presentation) bool {
+ for (self.tracks) |track| if (track != null and track.?.active()) return true;
+ for (self.closing[0..self.closing_len]) |track|
+ if (track.active()) return true;
+ return false;
+}
+
+test "presentation cache allocation failures retain ownership and allow retry" {
+ var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{});
+ const gpa = allocator.allocator();
+ var state: Presentation = .{};
+ defer state.deinit(gpa);
+ var cells: [3]pardes.Cell = @splat(.{});
+ cells[0].default = false;
+ cells[0].text[0] = 'a';
+ var surface: pardes.Surface = .{ .cols = 2, .rows = 1, .cells = cells[0..2] };
+ state.submitted[0] = .{ .serial = 7, .box = .{ .x = 0, .y = 0, .w = 2, .h = 1 } };
+ state.submitted_ready = true;
+ state.capturePrevious(gpa, &surface);
+ try std.testing.expect(state.previous_valid);
+ try std.testing.expectEqual(@as(u32, 7), state.previous_layout[0].?.serial);
+ const previous = state.previous_cells.ptr;
+ const allocated = allocator.allocations;
+ allocator.fail_index = allocator.alloc_index;
+ state.capturePrevious(gpa, &surface);
+ try std.testing.expect(state.previous_valid);
+ try std.testing.expectEqual(allocated, allocator.allocations);
+ try std.testing.expect(!allocator.has_induced_failure);
+
+ surface.cols = 3;
+ surface.cells = &cells;
+ state.submitted[0].?.box.w = 3;
+ state.diff_state = .ready;
+ state.capturePrevious(gpa, &surface);
+ try std.testing.expect(allocator.has_induced_failure);
+ try std.testing.expect(!state.previous_valid);
+ try std.testing.expectEqual(previous, state.previous_cells.ptr);
+ try std.testing.expectEqual(@as(usize, 2), state.previous_cells.len);
+ try std.testing.expectEqual(@as(u8, 'a'), state.previous_cells[0].text[0]);
+ try std.testing.expect(state.previous_layout[0] == null);
+ try std.testing.expectEqual(.none, state.diff_state);
+
+ allocator.fail_index = std.math.maxInt(usize);
+ state.capturePrevious(gpa, &surface);
+ try std.testing.expect(state.previous_valid);
+ try std.testing.expectEqual(@as(usize, 3), state.previous_cells.len);
+ try std.testing.expectEqual(@as(f32, 3), state.previous_layout[0].?.box.w);
+ state.diff_state = .requested;
+ allocator.fail_index = allocator.alloc_index;
+ try std.testing.expectError(error.OutOfMemory, state.prepareDiff(gpa, &surface));
+ try std.testing.expectEqual(.requested, state.diff_state);
+ try std.testing.expectEqual(@as(usize, 0), state.diffs.len);
+ allocator.fail_index = std.math.maxInt(usize);
+ try std.testing.expect(try state.prepareDiff(gpa, &surface));
+ try std.testing.expectEqual(.ready, state.diff_state);
+ try std.testing.expectEqual(@as(usize, 3), state.diffs.len);
+ for (state.diffs) |diff| try std.testing.expectEqual(.unchanged, diff);
+}
+
+test "presentation submission failure preserves shown geometry until retry is acknowledged" {
+ var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{});
+ const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ var frame: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer frame.deinit();
+ // The frozen grid is kept only while a transition is chosen.
+ p.settings.panel_transition = .slide;
+ _ = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(&.{});
+ const shown = p.presentation.shown;
+ const previous = p.presentation.previous_cells.ptr;
+ p.presentation.diff_state = .requested;
+ p.presentation.pending = true;
+ allocator.fail_index = allocator.alloc_index;
+ try std.testing.expectError(error.OutOfMemory, p.presentation.submit(p, &p.surface));
+ try std.testing.expectEqualDeep(shown, p.presentation.shown);
+ try std.testing.expectEqual(previous, p.presentation.previous_cells.ptr);
+ try std.testing.expectEqual(.requested, p.presentation.diff_state);
+ try std.testing.expect(p.presentation.pending);
+ try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 5, 3) == null);
+
+ allocator.fail_index = std.math.maxInt(usize);
+ try p.presentation.submit(p, &p.surface);
+ try std.testing.expectEqualDeep(shown, p.presentation.shown);
+ try std.testing.expect(p.presentation.pending);
+ p.acknowledgePanelPresentation(&.{});
+ try std.testing.expect(!p.presentation.pending);
+ try std.testing.expectEqual(CellPosition{ .col = 5, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 5, 3).?);
+}
+
+test "fractional panel inverse preserves compact header rows through slide and zoom" {
+ const layer: pardes.Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 };
+ for ([_]Transition{ .slide, .zoom, .vertical }) |effect| {
+ var presentation: Presentation = .{};
+ const track: Track = .{ .serial = 7, .effect = effect, .frame = 4, .frame_count = 10, .from = .{ .x = 2, .y = 2, .w = 20, .h = 8 }, .to = .{ .x = 3, .y = 2, .w = 32, .h = 12 } };
+ presentation.shown_tracks[0] = track;
+ const shown = track.presented();
+ for (0..4) |index| {
+ const row: u16 = @intCast(index);
+ const source_x: f32 = 5.3;
+ const source_y = (layer.rowTop(row, 20, 10) + 5) / 20;
+ const x = shown.x + (source_x - track.to.x) / track.to.w * shown.w;
+ const y = shown.y + (source_y - track.to.y) / track.to.h * shown.h;
+ const point = presentation.pointerFractional(80, 30, x, y).?;
+ try std.testing.expectApproxEqAbs(source_x, point.x, 0.0001);
+ try std.testing.expectApproxEqAbs(source_y, point.y, 0.0001);
+ const body_hit = layer.bodyHit(point.x * 10, point.y * 20, 10, 20, 8, 10).?;
+ try std.testing.expectEqual(row, body_hit.row);
+ }
+ }
+}
+
+test "presentation snapshots own compact body and tag rows across later edits" {
+ var state: Presentation = .{};
+ defer state.deinit(std.testing.allocator);
+ var cells: [1]pardes.Cell = .{.{}};
+ var body: [2]pardes.Cell = .{ .{}, .{} };
+ body[0].text[0] = 'A';
+ var surface: pardes.Surface = .{ .cols = 1, .rows = 1, .cells = &cells };
+ surface.body_layers[0] = .{ .serial = 9, .cols = 1, .rows = 2, .context_rows = 1, .cells = &body };
+ var tag: [1]pardes.Cell = .{.{}};
+ tag[0].text[0] = 'T';
+ surface.tag_layers[0] = .{ .kind = .pane, .serial = 9, .cols = 1, .rows = 1, .cells = &tag };
+ state.capturePrevious(std.testing.allocator, &surface);
+ try std.testing.expect(state.previous_valid);
+ tag[0].text[0] = 'U';
+ try std.testing.expectEqual(@as(u8, 'T'), state.previous_tag_layers[0].cells[0].text[0]);
+ body[0].text[0] = 'B';
+ surface.body_layers[0].context_rows = 0;
+ try std.testing.expectEqual(@as(u8, 'A'), state.previous_body_layers[0].cells[0].text[0]);
+ try std.testing.expectEqual(@as(u16, 1), state.previous_body_layers[0].context_rows);
+ state.capturePrevious(std.testing.allocator, &surface);
+ try std.testing.expectEqual(@as(u8, 'B'), state.previous_body_layers[0].cells[0].text[0]);
+ try std.testing.expectEqual(@as(u8, 'U'), state.previous_tag_layers[0].cells[0].text[0]);
+}
diff --git a/src/Registers.zig b/src/Registers.zig
new file mode 100644
index 00000000..d2a42434
--- /dev/null
+++ b/src/Registers.zig
@@ -0,0 +1,106 @@
+//! helix's registers: named stores of text, each holding one VALUE PER RANGE
+//! of the selection that wrote it. `"` is the default, `+` the one the
+//! system clipboard is copied from, `/` the last s/S pattern, `@` the last
+//! macro; any other character names a register of its own. The registers
+//! computed from the editor rather than stored (`_`, `#`, `.`, `%`) and the
+//! clipboard's paste side are read in edit.zig, which has the pane at hand.
+const std = @import("std");
+
+const Registers = @This();
+
+list: std.ArrayList(Reg) = .empty,
+
+pub const Reg = struct {
+ name: u21,
+ /// gpa-owned, one per range, in document order
+ values: [][]u8,
+ /// the values joined by newlines, for the readers that take one text
+ /// (the acme chords, a paste into a terminal, the clipboard); gpa-owned,
+ /// made on first read
+ joined: ?[]u8 = null,
+};
+
+pub const default = '"';
+
+pub fn deinit(r: *Registers, gpa: std.mem.Allocator) void {
+ for (r.list.items) |*reg| freeReg(gpa, reg);
+ r.list.deinit(gpa);
+ r.* = .{};
+}
+
+fn freeReg(gpa: std.mem.Allocator, reg: *Reg) void {
+ for (reg.values) |v| gpa.free(v);
+ gpa.free(reg.values);
+ if (reg.joined) |j| gpa.free(j);
+}
+
+pub fn get(r: *Registers, name: u21) ?*Reg {
+ for (r.list.items) |*reg| if (reg.name == name) return reg;
+ return null;
+}
+
+/// Value `i` of register `name`: a register holding fewer values than there
+/// are ranges repeats its last (helix paste_impl).
+pub fn value(r: *Registers, name: u21, i: usize) ?[]const u8 {
+ const reg = r.get(name) orelse return null;
+ if (reg.values.len == 0) return null;
+ return reg.values[@min(i, reg.values.len - 1)];
+}
+
+/// Store value `i` of `n`. `fresh` starts the register over with `n` empty
+/// values; the replay across ranges passes it on its first pass only, so
+/// every range fills its own slot.
+pub fn put(r: *Registers, gpa: std.mem.Allocator, name: u21, bytes: []const u8, i: usize, n: usize, fresh: bool) void {
+ const copy = gpa.dupe(u8, bytes) catch return;
+ const reg = r.get(name) orelse blk: {
+ r.list.append(gpa, .{ .name = name, .values = &.{} }) catch {
+ gpa.free(copy);
+ return;
+ };
+ break :blk &r.list.items[r.list.items.len - 1];
+ };
+ if (fresh or reg.values.len != n) {
+ const values = gpa.alloc([]u8, n) catch {
+ gpa.free(copy);
+ return;
+ };
+ for (values) |*v| v.* = &.{};
+ freeReg(gpa, reg);
+ reg.* = .{ .name = name, .values = values };
+ }
+ gpa.free(reg.values[i]);
+ reg.values[i] = copy;
+ if (reg.joined) |j| gpa.free(j);
+ reg.joined = null;
+}
+
+/// The whole register as one text: its values joined by newlines, no
+/// newline added after a value that already ends in one.
+pub fn text(r: *Registers, gpa: std.mem.Allocator, name: u21) ?[]const u8 {
+ const reg = r.get(name) orelse return null;
+ if (reg.joined) |j| return j;
+ var out: std.ArrayList(u8) = .empty;
+ for (reg.values, 0..) |v, i| {
+ if (i > 0 and !std.mem.endsWith(u8, reg.values[i - 1], "\n")) out.append(gpa, '\n') catch return null;
+ out.appendSlice(gpa, v) catch return null;
+ }
+ reg.joined = out.toOwnedSlice(gpa) catch return null;
+ return reg.joined;
+}
+
+test "a register holds a value per range and joins them for one-text readers" {
+ const gpa = std.testing.allocator;
+ var r: Registers = .{};
+ defer r.deinit(gpa);
+ try std.testing.expect(r.value(default, 0) == null);
+ // a replay fills the last range first
+ r.put(gpa, default, "two", 1, 2, true);
+ r.put(gpa, default, "one\n", 0, 2, false);
+ try std.testing.expectEqualStrings("one\n", r.value(default, 0).?);
+ try std.testing.expectEqualStrings("two", r.value(default, 1).?);
+ try std.testing.expectEqualStrings("two", r.value(default, 5).?);
+ try std.testing.expectEqualStrings("one\ntwo", r.text(gpa, default).?);
+ r.put(gpa, 'a', "x", 0, 1, true);
+ try std.testing.expectEqualStrings("x", r.text(gpa, 'a').?);
+ try std.testing.expectEqualStrings("one\ntwo", r.text(gpa, default).?);
+}
diff --git a/src/Terminal.zig b/src/Terminal.zig
index e204b44c..91c23305 100644
--- a/src/Terminal.zig
+++ b/src/Terminal.zig
@@ -5,6 +5,7 @@ const std = @import("std");
const pardes = @import("pardes.zig");
const config = @import("config.zig");
const Pardes = pardes.Pardes;
+const Surface = pardes.Surface;
const modal = @import("modal.zig");
const tracy = @import("tracy.zig");
const dump = @import("dump.zig");
@@ -717,7 +718,7 @@ pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void {
if (comptime !enabled) return;
if (pane.terminal == null) return;
const has_positions = pane.ovl != null or pane.body.cur_pinned or pane.body.vsel.active or
- pane.body.msel.active or pane.body.nsel > 0 or pane.body.append_at != null or pane.look_at != null or
+ pane.body.msel.active or pane.body.nsel > 0 or pane.body.last_edit != null or pane.look_at != null or
pane.body.ed_undo_len > 0 or pane.body.ed_redo_len > 0 or pointerRowCount(pane) > 0;
// Sticky bottom, as every other terminal does it: output drags the
// viewport down only when the viewport was already on the last row, so
@@ -843,7 +844,7 @@ fn ingestWithPositions(pane: *Pane, bytes: []const u8) void {
extra[nextra + 1] = &selection.arow;
nextra += 2;
}
- if (pane.body.append_at) |*at| {
+ if (pane.body.last_edit) |*at| {
extra[nextra] = &at.row;
nextra += 1;
}
@@ -1124,7 +1125,7 @@ pub fn enterTty(p: *Pardes, id: usize) void {
// keeps its modal endpoint for the next return to editor mode.
pane.body.cur_pinned = selected;
pane.body.select = false;
- pane.body.append_at = null;
+ pane.body.restore_cursor = false;
pane.body.sticky_col = -1;
pane.body.normal.clear();
}
@@ -1386,7 +1387,7 @@ pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText {
/// Consume a rewritten overlay, freeing the terminal edit text it replaces.
pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void {
- const overlay = if (pane.ovl) |*value| value else return p.gpa.free(new);
+ const overlay = if (pane.ovl) |*value| value else return pardes.edit.retireEditText(p, new);
for (0..pane.sel.len) |button| {
pane.clearPointerSelection(button);
pane.sel[button].state = .none;
@@ -1763,11 +1764,10 @@ const Anchor = struct {
}
};
-pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void {
+pub fn recolorAnsi(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void {
// No emulator, no ANSI cells: the whole pass — and the 256-colour theme
// projection behind it — is compiled out.
if (comptime !enabled) return;
- const s = &p.surface;
if (pane.terminal == null) return;
const body_y = p.bodyTop(pane, r);
var filtered_storage: FilteredColors = undefined;
diff --git a/src/Text.zig b/src/Text.zig
index 43dd8a95..ab23bf8e 100644
--- a/src/Text.zig
+++ b/src/Text.zig
@@ -11,6 +11,7 @@ const std = @import("std");
const panes = @import("panes.zig");
const modal = @import("modal.zig");
const config = @import("config.zig");
+const memory = @import("memory.zig");
const Pane = panes.Pane;
const File = panes.File;
const Terminal = panes.Terminal;
@@ -36,7 +37,7 @@ pub const CharSel = struct {
explicit: bool = false,
};
-pub const max_selections = 64;
+pub const max_selections = memory.limits.selections;
pub const SelRange = struct {
row: i32,
@@ -59,13 +60,26 @@ cur_row: i32 = 0,
cur_col: i32 = 0,
msel: LineSel = .{},
vsel: CharSel = .{},
-sels: [max_selections - 1]SelRange = undefined,
-nsel: u8 = 0,
+/// The ranges beside the primary, `nsel` of them: room for max_selections - 1
+/// made when a second range appears and given back when the selection is one
+/// range again, so a single cursor holds nothing here.
+sels: []SelRange = &.{},
+nsel: u16 = 0,
+/// What a column's or the workspace's tag allocates its ranges from; a text
+/// with a pane uses the pane's.
+gpa: ?std.mem.Allocator = null,
select: bool = false,
/// sticky goal column for j/k runs (helix old_visual_position): any
/// non-vertical range write resets it to -1.
sticky_col: i32 = -1,
-append_at: ?struct { row: i32, col: i32 } = null,
+/// The insert session began with `a`: on Esc every range gives back the
+/// one character it was stretched by (helix restore_cursor).
+restore_cursor: bool = false,
+/// Where the last edit ended, a deletion's start or an insertion's end, in
+/// surface rows: for `g.` (helix last_edit_pos).
+last_edit: ?struct { row: i32, col: i32 } = null,
+/// insert `Ctrl-r` was pressed: the next key names the register to insert
+reg_wait: bool = false,
normal: modal.Normal.State = .{},
/// last f/F/t/T motion, for Alt-. repeat
find_op: u8 = 0,
@@ -93,10 +107,39 @@ pub fn pane(t: *Text) ?*Pane {
/// Free what a text owns: its characters and the buffers its undo keeps.
pub fn deinit(t: *Text, gpa: std.mem.Allocator) void {
+ if (t.sels.len > 0) gpa.free(t.sels);
if (t.own) |own| gpa.free(own);
for (t.ed_undo[0..t.ed_undo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text);
for (t.ed_redo[0..t.ed_redo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text);
- t.* = .{ .what = t.what };
+ t.* = .{ .what = t.what, .gpa = t.gpa };
+}
+
+fn allocator(t: *Text) ?std.mem.Allocator {
+ return if (t.pane()) |w| w.gpa else t.gpa;
+}
+
+/// Room for the ranges beside the primary; false when there is none to be had.
+pub fn reserveSels(t: *Text) bool {
+ if (t.sels.len > 0) return true;
+ const gpa = t.allocator() orelse return false;
+ t.sels = gpa.alloc(SelRange, max_selections - 1) catch return false;
+ return true;
+}
+
+/// The ranges beside the primary as they were (a refused edit's undoing).
+pub fn restoreSels(t: *Text, kept: []const SelRange) void {
+ if (kept.len == 0) return t.dropSels();
+ if (!t.reserveSels()) return;
+ @memcpy(t.sels[0..kept.len], kept);
+ t.nsel = @intCast(kept.len);
+}
+
+/// Back to one range: the room for the others goes.
+pub fn dropSels(t: *Text) void {
+ t.nsel = 0;
+ if (t.sels.len == 0) return;
+ if (t.allocator()) |gpa| gpa.free(t.sels);
+ t.sels = &.{};
}
/// Bring the cursor into view (acme's textshow). Only a body scrolls to it;
@@ -118,7 +161,8 @@ pub fn fromModalCursor(t: *Text, c: modal.Cursor) void {
pub fn insertVerticalCursor(lines: []const []const u8, c: modal.Cursor, down: bool) modal.Cursor {
if (lines.len == 0) return c;
- const row = if (down) @min(c.row + 1, lines.len - 1) else c.row -| 1;
+ // A cursor past the lines (its text changed under it) moves from the last.
+ const row = if (down) @min(c.row + 1, lines.len - 1) else @min(c.row, lines.len) -| 1;
const target = lines[row];
if (target.len == 0) return .{ .row = row, .col = 0 };
const source = if (c.row < lines.len) lines[c.row] else "";
@@ -177,7 +221,7 @@ pub fn setRange(t: *Text, text: []const u8, row0: i32, r0: modal.Selection, expl
.explicit = explicit or t.select,
};
t.msel.active = false;
- t.nsel = 0; // writing ONE range means the selection IS that range
+ t.dropSels(); // writing ONE range means the selection IS that range
t.cur_pinned = true;
t.sticky_col = -1;
t.normal.clear();
@@ -254,6 +298,7 @@ pub fn setRanges(t: *Text, text: []const u8, in: []const modal.Selection, sticky
}
n = k + 1;
setRange(t, text, 0, r[pri], explicit);
+ if (n > 1 and !t.reserveSels()) return; // no room: the primary alone
t.sticky_col = st[pri];
var w: usize = 0;
for (r[0..n], 0..) |x, idx| {
@@ -323,6 +368,15 @@ pub fn multiSelAction(t: *Text, text: []const u8, kind: modal.Normal.Multi, cnt:
}
return setRanges(t, text, out[0..m], &.{}, pri, expl);
}
+ if (kind == .ensure_forward) {
+ // helix ensure_selections_forward: every range points forward
+ for (rs[0..n]) |*r| {
+ const from = @min(r.anchor, r.head);
+ r.head = @max(r.anchor, r.head);
+ r.anchor = from;
+ }
+ return setRanges(t, text, rs[0..n], &.{}, got.pri, expl);
+ }
if (kind == .split_newline) {
// helix selection::split_on_newline — one range per line the
// selection covers, the newlines themselves left out
diff --git a/src/animation.zig b/src/animation.zig
new file mode 100644
index 00000000..948a6679
--- /dev/null
+++ b/src/animation.zig
@@ -0,0 +1,739 @@
+//! Animation curves and the panel transition records every shell reads:
+//! easing presets, transition kinds and their tracks, the fractional boxes
+//! they move through, the character effects' per-cell sources, and the
+//! generic displayed-value Transition the chrome colours use.
+const std = @import("std");
+
+pub const ascii_max_movement_frames: u16 = 12;
+
+pub const Easing = enum(u8) {
+ linear,
+ smooth,
+ /// Quintic ease-in-out. It creeps at both ends and crosses the middle of
+ /// the distance fast, inside the same frame count a linear walk would use.
+ smoother,
+ in_cubic,
+ out_cubic,
+ out_back,
+};
+
+pub const Transition = enum(u8) {
+ // Numeric values are shared with the GUI shader ABI.
+ off = 0,
+ slide = 1,
+ zoom = 2,
+ dissolve = 3,
+ ascii = 4,
+ vertical = 5,
+ edges = 6,
+ fall = 7,
+ wave = 8,
+ curtain = 9,
+ scramble = 10,
+ typewriter = 11,
+
+ pub fn easing(effect: Transition) Easing {
+ return switch (effect) {
+ .off, .dissolve, .wave => .smooth,
+ .slide, .vertical, .edges => .out_cubic,
+ .zoom => .out_back,
+ // Character walks and per-cell locks read best with a slow start,
+ // a fast middle, and a slow settle over their fixed frame count.
+ .ascii, .fall, .scramble => .smoother,
+ // A sweep and a typewriter are constant-rate by definition: easing
+ // their head would make the pass visibly hesitate mid-pane.
+ .curtain, .typewriter => .linear,
+ };
+ }
+
+ pub fn frames(effect: Transition) u16 {
+ return switch (effect) {
+ .off => 0,
+ .slide => 12,
+ .zoom => 14,
+ .dissolve => 10,
+ .ascii => ascii_max_movement_frames + 1,
+ .vertical => 12,
+ .edges, .curtain, .scramble => 12,
+ // Travelling motion needs a couple more samples than a lock or a
+ // rigid slide before it stops reading as a jump.
+ .fall, .wave, .typewriter => 14,
+ };
+ }
+
+ pub fn composedByCore(effect: Transition) bool {
+ return switch (effect) {
+ .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => true,
+ .off, .slide, .zoom, .dissolve, .vertical => false,
+ };
+ }
+
+ pub fn needsPreviousGrid(effect: Transition) bool {
+ return effect == .dissolve or effect == .vertical or effect.composedByCore();
+ }
+
+ pub fn lifecycleOnly(effect: Transition) bool {
+ return effect == .vertical;
+ }
+};
+
+pub const Phase = enum(u8) {
+ opening = 0,
+ moving = 1,
+ /// Presentation-only content whose pane lifetime has already ended.
+ /// It is never a valid input target.
+ closing = 2,
+};
+
+pub const Box = extern struct {
+ x: f32 = 0,
+ y: f32 = 0,
+ w: f32 = 0,
+ h: f32 = 0,
+
+ pub fn eql(a: Box, b: Box) bool {
+ return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h;
+ }
+
+ // A cell belongs to a fractional box when its center lies inside.
+ pub fn contains(box: Box, col: u16, row: u16) bool {
+ const x: f32 = @floatFromInt(col);
+ const y: f32 = @floatFromInt(row);
+ return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h;
+ }
+};
+
+// Drawing and hit testing share this moving/opening/closing order.
+pub fn paintOrder(live: []const ?Track, closing: []const Track, out: []Track) usize {
+ var len: usize = 0;
+ for ([_]Phase{ .moving, .opening }) |phase| for (live) |maybe| {
+ const track = maybe orelse continue;
+ if (!track.active() or track.phase != phase) continue;
+ if (len == out.len) return len;
+ out[len] = track;
+ len += 1;
+ };
+ for (closing) |track| {
+ if (!track.active()) continue;
+ if (len == out.len) return len;
+ out[len] = track;
+ len += 1;
+ }
+ return len;
+}
+
+/// One POD record is enough for every backend. `from` and `to` are logical
+/// cell boxes; frontends convert them to pixels only at their render edge.
+pub const Track = extern struct {
+ serial: u32 = 0,
+ pane: u8 = 0,
+ phase: Phase = .moving,
+ effect: Transition = .off,
+ _padding: u8 = 0,
+ frame: u16 = 0,
+ frame_count: u16 = 0,
+ from: Box = .{},
+ to: Box = .{},
+
+ pub fn active(track: Track) bool {
+ return track.effect != .off and track.frame < track.frames();
+ }
+
+ pub fn frames(track: Track) u16 {
+ return if (track.frame_count != 0) track.frame_count else track.effect.frames();
+ }
+
+ pub fn amount(track: Track) f32 {
+ // Opening rises quickly and settles; closing reverses that motion and
+ // accelerates down out of the fixed clip.
+ if (track.phase == .closing and track.effect == .vertical)
+ return progressEased(.in_cubic, track.frames(), track.frame);
+ return progressEased(track.effect.easing(), track.frames(), track.frame);
+ }
+
+ pub fn presented(track: Track) Box {
+ return lerpBox(track.from, track.to, track.amount());
+ }
+
+ pub fn visualBox(track: Track) Box {
+ return switch (track.effect) {
+ .slide, .zoom, .vertical => track.presented(),
+ .off, .dissolve => track.to,
+ // Every character effect stays inside the pane's final rectangle.
+ .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => track.to,
+ };
+ }
+
+ pub fn contentBox(track: Track) Box {
+ return if (track.phase == .closing) track.from else track.to;
+ }
+};
+
+pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box {
+ return switch (effect) {
+ .slide => blk: {
+ var from = target;
+ const middle = target.x + target.w * 0.5;
+ from.x = if (middle < @as(f32, @floatFromInt(screen_width)) * 0.5)
+ -target.w
+ else
+ @floatFromInt(screen_width);
+ break :blk from;
+ },
+ .zoom => .{
+ .x = target.x + target.w * 0.5,
+ .y = target.y + target.h * 0.5,
+ .w = 0,
+ .h = 0,
+ },
+ .vertical => blk: {
+ var from = target;
+ from.y += target.h;
+ break :blk from;
+ },
+ .off, .dissolve => target,
+ // Character effects own the glyphs inside a fixed rectangle, so their
+ // panel opens at exactly its final geometry.
+ .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => target,
+ };
+}
+
+pub fn closingBox(effect: Transition, source: Box) Box {
+ return switch (effect) {
+ .vertical => blk: {
+ var to = source;
+ to.y += source.h;
+ break :blk to;
+ },
+ else => source,
+ };
+}
+
+pub fn sample(easing: Easing, raw: f32) f32 {
+ const t = std.math.clamp(raw, 0.0, 1.0);
+ return switch (easing) {
+ .linear => t,
+ .smooth => t * t * (3.0 - 2.0 * t),
+ .smoother => t * t * t * (t * (6.0 * t - 15.0) + 10.0),
+ .in_cubic => t * t * t,
+ .out_cubic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t),
+ // Robert Penner's ease-out-back polynomial. It intentionally travels
+ // a little past one before settling exactly on the endpoint.
+ .out_back => blk: {
+ const c1: f32 = 1.70158;
+ const c3 = c1 + 1.0;
+ const u = t - 1.0;
+ break :blk 1.0 + c3 * u * u * u + c1 * u * u;
+ },
+ };
+}
+
+pub fn progress(effect: Transition, frame: u16) f32 {
+ return progressEased(effect.easing(), effect.frames(), frame);
+}
+
+fn progressEased(easing: Easing, frames: u16, frame: u16) f32 {
+ if (frames <= 1 or frame >= frames - 1) return 1.0;
+ return sample(easing, @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1)));
+}
+
+pub fn lerpBox(from: Box, to: Box, t: f32) Box {
+ const u = @max(0.0, t);
+ return .{
+ .x = from.x + (to.x - from.x) * u,
+ .y = from.y + (to.y - from.y) * u,
+ .w = @max(0.0, from.w + (to.w - from.w) * u),
+ .h = @max(0.0, from.h + (to.h - from.h) * u),
+ };
+}
+
+/// Stable cell noise shared by the TTY reveal and shader ports. Integer-only
+/// hashing means resizing or repainting a frame does not make cells flicker.
+pub fn cellNoise(serial: u32, col: u16, row: u16) f32 {
+ var x = serial ^ (@as(u32, col) *% 0x9e37_79b9) ^ (@as(u32, row) *% 0x85eb_ca6b);
+ x ^= x >> 16;
+ x *%= 0x7feb_352d;
+ x ^= x >> 15;
+ x *%= 0x846c_a68b;
+ x ^= x >> 16;
+ return @as(f32, @floatFromInt(x & 0xffff)) / 65535.0;
+}
+
+/// Whether a changed dissolve cell has crossed from the frozen old grid to
+/// the new one. Exact endpoints are part of the presentation contract.
+pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool {
+ const t = std.math.clamp(raw_progress, 0.0, 1.0);
+ if (t <= 0) return false;
+ if (t >= 1) return true;
+ return cellNoise(serial, col, row) < t;
+}
+
+pub const CellArea = struct {
+ x0: u16 = 0,
+ y0: u16 = 0,
+ cols: u16 = 1,
+ rows: u16 = 1,
+
+ pub fn of(box: Box) CellArea {
+ return .{
+ .x0 = floorCell(box.x),
+ .y0 = floorCell(box.y),
+ .cols = ceilCell(box.w),
+ .rows = ceilCell(box.h),
+ };
+ }
+};
+
+fn floorCell(value: f32) u16 {
+ return @intFromFloat(std.math.clamp(@floor(value), 0.0, @as(f32, std.math.maxInt(u16))));
+}
+
+fn ceilCell(value: f32) u16 {
+ return @intFromFloat(std.math.clamp(@ceil(value), 1.0, @as(f32, std.math.maxInt(u16))));
+}
+
+pub const CharSource = union(enum) {
+ /// Nothing has arrived here yet: keep the frozen old cell.
+ old,
+ at: Offset,
+ /// Paint this printable byte in the destination cell's own style, whatever
+ /// that cell holds — a caret marching over empty space is still a caret.
+ byte: u8,
+ churn: u8,
+
+ pub const Offset = struct { cols: i32 = 0, rows: i32 = 0 };
+
+ pub const settled: CharSource = .{ .at = .{} };
+};
+
+pub fn charSource(track: Track, col: u16, row: u16, area: CellArea) CharSource {
+ const t = track.amount();
+ if (t >= 1.0) return .settled;
+ const w: f32 = @floatFromInt(area.cols);
+ const h: f32 = @floatFromInt(area.rows);
+ const c: f32 = @floatFromInt(col);
+ const r: f32 = @floatFromInt(row);
+ const remaining = 1.0 - t;
+ return switch (track.effect) {
+ .edges => blk: {
+ const travel = cellsOf(remaining * (w + 1.0));
+ break :blk .{ .at = .{ .cols = if (row % 2 == 0) travel else -travel } };
+ },
+ // Columns rain down, each with its own stable head start, so the pane
+ // fills from the top and the last glyphs land at the bottom.
+ .fall => blk: {
+ const local = staggered(t, cellNoise(track.serial, col, 0) * 0.4);
+ if (local <= 0.0) break :blk .old;
+ break :blk .{ .at = .{ .rows = cellsOf((1.0 - local) * (h + 1.0)) } };
+ },
+ // A vertical ripple travels left to right and its amplitude decays, so
+ // the pane settles out of a wave instead of a fade.
+ .wave => .{ .at = .{
+ .rows = cellsOf(remaining * @min(4.0, h) * @sin(c * 0.55 - t * 9.0)),
+ } },
+ // A curtain of glyphs marches in from the right, column by column, left
+ // to right; each column still has a short slide of its own.
+ .curtain => blk: {
+ const lead = t * (w + 1.0) - c;
+ if (lead <= 0.0) break :blk .old;
+ break :blk .{ .at = .{ .cols = -cellsOf(@max(0.0, 3.0 - lead)) } };
+ },
+ // Every cell churns through printable ASCII and locks onto its final
+ // glyph at its own stable threshold: the pane resolves out of noise.
+ .scramble => blk: {
+ if (t >= cellNoise(track.serial, col, row) * 0.8) break :blk .settled;
+ const churn = cellNoise(
+ track.serial ^ (@as(u32, track.frame) *% 0x27d4_eb2f),
+ col,
+ row,
+ );
+ break :blk .{ .churn = @intCast(33 + @min(93, @as(u32, @intFromFloat(churn * 94.0)))) };
+ },
+ // Reading-order reveal with a caret sitting on the write head.
+ .typewriter => blk: {
+ const head = t * w * h;
+ const index = r * w + c;
+ if (index + 1.0 <= head) break :blk .settled;
+ if (index <= head) break :blk .{ .byte = '_' };
+ break :blk .old;
+ },
+ // PanelAscii walks its own byte distance per cell, and the geometry
+ // effects never reach this path at all.
+ .off, .slide, .zoom, .dissolve, .vertical, .ascii => .settled,
+ };
+}
+
+fn cellsOf(distance: f32) i32 {
+ return @intFromFloat(@round(std.math.clamp(distance, -65535.0, 65535.0)));
+}
+
+/// Remap track progress into one cell's own window. A stagger delays a glyph
+/// without making the effect as a whole end after its last frame.
+fn staggered(t: f32, delay: f32) f32 {
+ if (delay >= 1.0) return t;
+ return (t - delay) / (1.0 - delay);
+}
+
+test "easing presets have exact endpoints and intended shapes" {
+ inline for (std.enums.values(Easing)) |easing| {
+ try std.testing.expectEqual(@as(f32, 0), sample(easing, 0));
+ try std.testing.expectEqual(@as(f32, 1), sample(easing, 1));
+ }
+ try std.testing.expectEqual(@as(f32, 0.5), sample(.linear, 0.5));
+ try std.testing.expect(sample(.in_cubic, 0.5) < sample(.linear, 0.5));
+ try std.testing.expect(sample(.out_cubic, 0.5) > sample(.linear, 0.5));
+ try std.testing.expect(sample(.out_back, 0.8) > 1.0);
+ // Slow at both ends, fast through the middle, and symmetric about the
+ // halfway point: the same curve the integer byte walk reproduces.
+ try std.testing.expectEqual(@as(f32, 0.5), sample(.smoother, 0.5));
+ try std.testing.expect(sample(.smoother, 0.15) < sample(.smooth, 0.15));
+ try std.testing.expect(sample(.smoother, 0.85) > sample(.smooth, 0.85));
+ try std.testing.expect(sample(.smoother, 0.6) - sample(.smoother, 0.4) >
+ sample(.linear, 0.6) - sample(.linear, 0.4));
+}
+
+test "transition progress completes exactly" {
+ inline for (std.enums.values(Transition)) |effect| {
+ try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames()));
+ if (effect.frames() > 0)
+ try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames() - 1));
+ try std.testing.expectEqual(@as(f32, 1), progress(effect, std.math.maxInt(u16)));
+ }
+ try std.testing.expectEqual(@as(f32, 1), progress(.off, 0));
+
+ const shrinking = lerpBox(.{ .w = 100, .h = 40 }, .{}, sample(.out_back, 0.8));
+ try std.testing.expectEqual(@as(f32, 0), shrinking.w);
+ try std.testing.expectEqual(@as(f32, 0), shrinking.h);
+ const opening = lerpBox(.{}, .{ .w = 100, .h = 40 }, sample(.out_back, 0.8));
+ try std.testing.expect(opening.w > 100);
+ try std.testing.expect(opening.h > 40);
+}
+
+test "character effects are core-composed and settle on the canonical glyph" {
+ const box: Box = .{ .x = 4, .y = 2, .w = 20, .h = 6 };
+ const area: CellArea = .of(box);
+ try std.testing.expectEqual(@as(u16, 4), area.x0);
+ try std.testing.expectEqual(@as(u16, 2), area.y0);
+ try std.testing.expectEqual(@as(u16, 20), area.cols);
+ try std.testing.expectEqual(@as(u16, 6), area.rows);
+
+ inline for (std.enums.values(Transition)) |effect| {
+ if (comptime !effect.composedByCore()) continue;
+ // Core composition needs the frozen old grid for every glyph which has
+ // not arrived, so no character effect may animate without it.
+ try std.testing.expect(effect.needsPreviousGrid());
+ if (comptime effect == .ascii) continue; // owns its own per-cell byte walk
+
+ const last: Track = .{ .effect = effect, .frame = effect.frames() - 1, .to = box };
+ const first: Track = .{ .effect = effect, .frame = 0, .to = box };
+ var moving = false;
+ var row: u16 = 0;
+ while (row < area.rows) : (row += 1) {
+ var col: u16 = 0;
+ while (col < area.cols) : (col += 1) {
+ // The last active sample is the exact canonical grid: no cell
+ // is displaced, churning, or still frozen.
+ try std.testing.expectEqual(CharSource.settled, charSource(last, col, row, area));
+ if (!std.meta.eql(CharSource.settled, charSource(first, col, row, area)))
+ moving = true;
+ }
+ }
+ try std.testing.expect(moving);
+ }
+}
+
+test "each character effect moves glyphs along its own axis" {
+ const box: Box = .{ .w = 30, .h = 8 };
+ const area: CellArea = .of(box);
+
+ // Rows alternate which screen edge they come from, and every glyph in a row
+ // travels as one rigid slide: one offset, no vertical component.
+ var edges: Track = .{ .effect = .edges, .frame = 2, .to = box };
+ const even = charSource(edges, 5, 0, area).at;
+ const odd = charSource(edges, 5, 1, area).at;
+ try std.testing.expect(even.cols > 0);
+ try std.testing.expectEqual(-even.cols, odd.cols);
+ try std.testing.expectEqual(@as(i32, 0), even.rows);
+ try std.testing.expectEqual(even, charSource(edges, 17, 0, area).at);
+ edges.frame = 5;
+ try std.testing.expect(charSource(edges, 5, 0, area).at.cols < even.cols);
+
+ // Falling columns are vertical only, staggered, and sample from below the
+ // destination because the new text is still above the pane.
+ const fall: Track = .{ .effect = .fall, .frame = 4, .to = box };
+ var falling = false;
+ var col: u16 = 0;
+ while (col < area.cols) : (col += 1) switch (charSource(fall, col, 0, area)) {
+ .old => {},
+ .byte, .churn => return error.FallShouldNotChurn,
+ .at => |offset| {
+ try std.testing.expectEqual(@as(i32, 0), offset.cols);
+ try std.testing.expect(offset.rows >= 0);
+ if (offset.rows > 0) falling = true;
+ },
+ };
+ try std.testing.expect(falling);
+
+ // The wave displaces rows both ways as it travels, and only rows.
+ const wave: Track = .{ .effect = .wave, .frame = 1, .to = box };
+ var above = false;
+ var below = false;
+ col = 0;
+ while (col < area.cols) : (col += 1) {
+ const offset = charSource(wave, col, 3, area).at;
+ try std.testing.expectEqual(@as(i32, 0), offset.cols);
+ if (offset.rows < 0) above = true;
+ if (offset.rows > 0) below = true;
+ }
+ try std.testing.expect(above and below);
+
+ // The curtain has a head: columns behind it hold the old grid, columns the
+ // head has passed are settled, and the head itself is still sliding.
+ const curtain: Track = .{ .effect = .curtain, .frame = 5, .to = box };
+ try std.testing.expectEqual(CharSource.settled, charSource(curtain, 0, 0, area));
+ try std.testing.expectEqual(CharSource{ .old = {} }, charSource(curtain, 29, 0, area));
+ var sliding = false;
+ col = 0;
+ while (col < area.cols) : (col += 1) switch (charSource(curtain, col, 0, area)) {
+ .at => |offset| if (offset.cols < 0) {
+ sliding = true;
+ },
+ .old, .byte, .churn => {},
+ };
+ try std.testing.expect(sliding);
+
+ var scramble: Track = .{ .effect = .scramble, .frame = 3, .to = box };
+ var churning: usize = 0;
+ var locked: usize = 0;
+ var changed = false;
+ col = 0;
+ while (col < area.cols) : (col += 1) switch (charSource(scramble, col, 0, area)) {
+ .churn => |byte| {
+ try std.testing.expect(byte >= ' ' and byte <= '~');
+ churning += 1;
+ scramble.frame = 4;
+ switch (charSource(scramble, col, 0, area)) {
+ .churn => |next| changed = changed or next != byte,
+ .old, .at, .byte => {},
+ }
+ scramble.frame = 3;
+ },
+ .at => locked += 1,
+ .old, .byte => return error.ScrambleShouldNotFreeze,
+ };
+ try std.testing.expect(churning > 0 and locked > 0 and changed);
+
+ // The typewriter writes in reading order with a caret on its head.
+ const typewriter: Track = .{ .effect = .typewriter, .frame = 7, .to = box };
+ try std.testing.expectEqual(CharSource.settled, charSource(typewriter, 0, 0, area));
+ try std.testing.expectEqual(
+ CharSource{ .old = {} },
+ charSource(typewriter, area.cols - 1, area.rows - 1, area),
+ );
+ var carets: usize = 0;
+ var row: u16 = 0;
+ while (row < area.rows) : (row += 1) {
+ col = 0;
+ while (col < area.cols) : (col += 1) switch (charSource(typewriter, col, row, area)) {
+ .byte => |byte| {
+ try std.testing.expectEqual(@as(u8, '_'), byte);
+ carets += 1;
+ },
+ .old, .at, .churn => {},
+ };
+ }
+ try std.testing.expectEqual(@as(usize, 1), carets);
+}
+
+test "opening presets separate geometry and content transitions" {
+ const target: Box = .{ .x = 30, .y = 2, .w = 20, .h = 8 };
+ try std.testing.expectEqual(target, openingBox(.ascii, target, 80));
+ try std.testing.expectEqual(@as(f32, 0), openingBox(.zoom, target, 80).w);
+ try std.testing.expectEqual(@as(f32, 80), openingBox(.slide, target, 80).x);
+ try std.testing.expectEqual(@as(f32, target.y + target.h), openingBox(.vertical, target, 80).y);
+ try std.testing.expectEqual(@as(f32, target.y + target.h), closingBox(.vertical, target).y);
+
+ var track: Track = .{ .effect = .slide, .from = target, .to = target };
+ try std.testing.expect(track.active());
+ track.frame = track.effect.frames();
+ try std.testing.expect(!track.active());
+
+ track = .{ .effect = .dissolve, .frame = 3, .from = .{}, .to = target };
+ try std.testing.expectEqual(target, track.visualBox());
+
+ var closing: Track = .{
+ .phase = .closing,
+ .effect = .vertical,
+ .from = target,
+ .to = closingBox(.vertical, target),
+ };
+ try std.testing.expectEqual(target, closing.contentBox());
+ closing.frame = 2;
+ try std.testing.expect(closing.amount() < progress(.vertical, closing.frame));
+}
+
+test "dissolve has exact stable endpoints" {
+ for (0..64) |col| {
+ const x: u16 = @intCast(col);
+ try std.testing.expect(!dissolveRevealed(42, x, 7, 0));
+ try std.testing.expect(dissolveRevealed(42, x, 7, 1));
+ if (dissolveRevealed(42, x, 7, 0.25))
+ try std.testing.expect(dissolveRevealed(42, x, 7, 0.75));
+ }
+}
+
+pub const frame_ms: u32 = 16;
+pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms;
+pub const transition_steps: u16 = 10;
+
+/// A displayed value that fades from one target to the next over
+/// `transition_steps` frames; the chrome colours are one.
+pub fn Fade(comptime Value: type) type {
+ return struct {
+ const Self = @This();
+
+ from: Value,
+ to: Value,
+ displayed: Value,
+ step: u16 = transition_steps,
+
+ pub fn init(value: Value) Self {
+ return .{ .from = value, .to = value, .displayed = value };
+ }
+
+ pub fn isActive(a: *const Self) bool {
+ return a.step < transition_steps;
+ }
+
+ pub fn retarget(a: *Self, target: Value) void {
+ a.from = a.displayed;
+ a.to = target;
+ a.step = if (std.meta.eql(a.from, target)) transition_steps else 0;
+ if (a.step == transition_steps) a.displayed = target;
+ }
+
+ pub fn advance(a: *Self) void {
+ if (!a.isActive()) return;
+ a.step += 1;
+ a.displayed = if (a.step == transition_steps)
+ a.to
+ else
+ Value.interpolate(a.from, a.to, a.step, transition_steps);
+ }
+
+ pub fn snap(a: *Self, value: Value) void {
+ a.* = init(value);
+ }
+ };
+}
+
+pub fn Immediate(comptime Value: type) type {
+ return struct {
+ const Self = @This();
+
+ displayed: Value,
+
+ pub fn init(value: Value) Self {
+ return .{ .displayed = value };
+ }
+
+ pub fn isActive(_: *const Self) bool {
+ return false;
+ }
+
+ pub fn retarget(a: *Self, target: Value) void {
+ a.displayed = target;
+ }
+
+ pub fn advance(_: *Self) void {}
+
+ pub fn snap(a: *Self, value: Value) void {
+ a.displayed = value;
+ }
+ };
+}
+
+pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 {
+ if (step == 0) return from;
+ if (step >= steps) return to;
+ var out: [3]u8 = undefined;
+ for (&out, from, to) |*dst, a, b| {
+ const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step;
+ dst.* = @intCast((numerator + steps / 2) / steps);
+ }
+ return out;
+}
+
+const TestColor = struct {
+ rgb: [3]u8,
+
+ pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor {
+ return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) };
+ }
+};
+
+test "Immediate lands where a completed Transition lands" {
+ const from: TestColor = .{ .rgb = .{ 240, 3, 90 } };
+ const to: TestColor = .{ .rgb = .{ 5, 222, 90 } };
+
+ var faded = Fade(TestColor).init(from);
+ faded.retarget(to);
+ for (0..transition_steps) |_| faded.advance();
+
+ var instant = Immediate(TestColor).init(from);
+ try std.testing.expect(!instant.isActive());
+ instant.retarget(to);
+ try std.testing.expectEqual(faded.displayed, instant.displayed);
+
+ // Never active, so a frontend that renders only while something is animating stops immediately
+ // rather than spending ten frames discovering there is nothing to draw.
+ try std.testing.expect(!instant.isActive());
+ instant.advance();
+ try std.testing.expectEqual(to, instant.displayed);
+
+ instant.snap(from);
+ try std.testing.expectEqual(from, instant.displayed);
+}
+
+test "fixed-step interpolation has exact monotonic endpoints" {
+ const Tween = Fade(TestColor);
+ const from: TestColor = .{ .rgb = .{ 240, 3, 90 } };
+ const to: TestColor = .{ .rgb = .{ 5, 222, 90 } };
+ var tween = Tween.init(from);
+ tween.retarget(to);
+ try std.testing.expectEqual(from, tween.displayed);
+
+ var previous = tween.displayed;
+ for (0..transition_steps) |_| {
+ tween.advance();
+ try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]);
+ try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]);
+ try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]);
+ previous = tween.displayed;
+ }
+ try std.testing.expect(!tween.isActive());
+ try std.testing.expectEqual(to, tween.displayed);
+ tween.advance();
+ try std.testing.expectEqual(to, tween.displayed);
+}
+
+test "retarget starts at the currently displayed value" {
+ const Tween = Fade(TestColor);
+ const first: TestColor = .{ .rgb = .{ 0, 40, 200 } };
+ const second: TestColor = .{ .rgb = .{ 200, 140, 0 } };
+ const third: TestColor = .{ .rgb = .{ 20, 10, 250 } };
+ var tween = Tween.init(first);
+ tween.retarget(second);
+ tween.advance();
+ tween.advance();
+ tween.advance();
+ const on_screen = tween.displayed;
+
+ tween.retarget(third);
+ try std.testing.expectEqual(on_screen, tween.from);
+ try std.testing.expectEqual(on_screen, tween.displayed);
+ try std.testing.expect(tween.isActive());
+ for (0..transition_steps) |_| tween.advance();
+ try std.testing.expectEqual(third, tween.displayed);
+}
+
diff --git a/src/body_layer.zig b/src/body_layer.zig
index 42b9c082..a0195fb8 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -1,9 +1,6 @@
-//! A pane body as the surface shows it. A file body's logical rows can
-//! outnumber its physical grid rows: hosts place the compact declaration rows
-//! first and clip the remaining body rows. After the layer types come the
-//! functions that draw a body into the surface: its text, colours, selections
-//! and cursor; then the tags drawn beside it, a pane's and the column and
-//! workspace ones, each line a tag layer of its own.
+//! Drawing a pane body into a surface: its text, colours, selections and
+//! cursor, and joining a body with compact context rows through its layer
+//! (Layer.zig).
const std = @import("std");
const pardes = @import("pardes.zig");
const normal = @import("normal.zig");
@@ -29,7 +26,7 @@ const tagline = @import("tagline.zig");
const Messages = @import("Messages.zig");
const Text = panes.Text;
const MAX_PANES = pardes.MAX_PANES;
-const TagKind = @import("tag_layer.zig").Kind;
+const TagKind = @import("Layer.zig").Kind;
const MAX_TAG_ROWS = pardes.MAX_TAG_ROWS;
const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE;
const TAG_LINE_LAYER_BASE = pardes.TAG_LINE_LAYER_BASE;
@@ -40,112 +37,11 @@ const COLBAR_H = pardes.COLBAR_H;
const TAG_GAP = pardes.TAG_GAP;
const wordBoundsAtCol = pardes.wordBoundsAtCol;
-pub const Metrics = struct {
- body_w: u16 = 1,
- body_h: u16 = 1,
- tagline_w: u16 = 1,
- tagline_h: u16 = 1,
-};
-
-pub const Layer = struct {
- pane: u16 = 0,
- serial: u32 = 0,
- viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 },
- cols: u16 = 0,
- rows: u16 = 0,
- context_rows: u16 = 0,
- context_separators: std.bit_set.StaticBitSet(limits.wrap_rows) = .initEmpty(),
- cells: []pardes.Cell = &.{},
- cursor: ?struct { x: u16, y: u16, bar: bool = false } = null,
-
- pub fn setContextLines(layer: *Layer, lines: []const i32) void {
- layer.context_separators = .initEmpty();
- const count = @min(lines.len, layer.context_rows);
- var row: usize = 0;
- while (row + 1 < count and row < limits.wrap_rows) : (row += 1) {
- if (lines[row + 1] != lines[row] +| 1) layer.context_separators.set(row);
- }
- }
-
- pub fn hasContextBorderAfter(layer: *const Layer, row: usize) bool {
- if (row >= layer.context_rows or row >= limits.wrap_rows) return false;
- return row + 1 == layer.context_rows or layer.context_separators.isSet(row);
- }
-
- pub fn rowTop(layer: *const Layer, row: u16, body_h: f32, tagline_h: f32) f32 {
- const body = @max(1, body_h);
- const tag = std.math.clamp(tagline_h, 1, body);
- return @as(f32, @floatFromInt(layer.viewport.y)) * body +
- @as(f32, @floatFromInt(@min(row, layer.context_rows))) * tag +
- @as(f32, @floatFromInt(row -| layer.context_rows)) * body;
- }
-
- pub fn rowHeight(layer: *const Layer, row: u16, body_h: f32, tagline_h: f32) f32 {
- return if (row < layer.context_rows) std.math.clamp(tagline_h, 1, @max(1, body_h)) else @max(1, body_h);
- }
-
- pub fn hitAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?pardes.Mouse.BodyHit {
- if (layer.rows == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null;
- const bw = @max(1, body_w);
- const bh = @max(1, body_h);
- const tw = std.math.clamp(tagline_w, 1, bw);
- const th = std.math.clamp(tagline_h, 1, bh);
- const dx = x - @as(f32, @floatFromInt(layer.viewport.x)) * bw;
- const dy = y - @as(f32, @floatFromInt(layer.viewport.y)) * bh;
- if (dx < 0 or dy < 0 or dx >= @as(f32, @floatFromInt(layer.viewport.w)) * bw or dy >= @as(f32, @floatFromInt(layer.viewport.h)) * bh) return null;
- const headers = @as(f32, @floatFromInt(layer.context_rows)) * th;
- const row: u16 = @intFromFloat(if (dy < headers) @floor(dy / th) else @as(f32, @floatFromInt(layer.context_rows)) + @floor((dy - headers) / bh));
- if (row >= layer.rows) return null;
- const col: u16 = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols -| 1)), @floor(dx / (if (row < layer.context_rows) tw else bw))));
- return .{
- .serial = layer.serial,
- .row = row,
- .col = col + config.GUTTER,
- .pixel_x = x,
- .pixel_y = y,
- .metrics = .{ .body_w = @intFromFloat(bw), .body_h = @intFromFloat(bh), .tagline_w = @intFromFloat(tw), .tagline_h = @intFromFloat(th) },
- };
- }
-};
-
-test "compact body layer rows fill reclaimed height and invert exact boundaries" {
- const layer: Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 40, .h = 10 }, .cols = 40, .rows = 15, .context_rows = 5 };
- try std.testing.expectEqual(@as(f32, 120), layer.rowTop(5, 20, 12));
- try std.testing.expectEqual(@as(f32, 240), layer.rowTop(11, 20, 12));
- try std.testing.expectEqual(@as(f32, 12), layer.rowHeight(4, 20, 12));
- try std.testing.expectEqual(@as(f32, 20), layer.rowHeight(5, 20, 12));
- for (0..12) |index| {
- const row: u16 = @intCast(index);
- const y = layer.rowTop(row, 20, 12) + layer.rowHeight(row, 20, 12) / 2;
- const width: f32 = if (row < 5) 6 else 10;
- const hit = layer.hitAt(40 + 3.5 * width, y, 10, 20, 6, 12).?;
- try std.testing.expectEqual(row, hit.row);
- try std.testing.expectEqual(@as(u16, 3 + config.GUTTER), hit.col);
- }
- try std.testing.expectEqual(@as(u16, 4), layer.hitAt(45, 119.99, 10, 20, 6, 12).?.row);
- try std.testing.expectEqual(@as(u16, 5), layer.hitAt(45, 120, 10, 20, 6, 12).?.row);
- try std.testing.expectEqual(@as(u16, 11), layer.hitAt(45, 259.99, 10, 20, 6, 12).?.row);
- try std.testing.expect(layer.hitAt(45, 260, 10, 20, 6, 12) == null);
- try std.testing.expect(layer.hitAt(39.99, 80, 10, 20, 6, 12) == null);
- try std.testing.expect(layer.hitAt(std.math.nan(f32), 80, 10, 20, 6, 12) == null);
-}
-
-test "compact context borders separate source gaps and preserve multiline headers" {
- var layer: Layer = .{ .context_rows = 6 };
- layer.setContextLines(&.{ 3, 4, 8, 9, 10, 17 });
- const expected = [_]bool{ false, true, false, false, true, true, false };
- for (expected, 0..) |border, row| try std.testing.expectEqual(border, layer.hasContextBorderAfter(row));
- layer.context_rows = 2;
- layer.setContextLines(&.{ 9, 10 });
- try std.testing.expect(!layer.hasContextBorderAfter(0));
- try std.testing.expect(layer.hasContextBorderAfter(1));
- try std.testing.expect(!layer.hasContextBorderAfter(4));
- try std.testing.expect(!layer.context_separators.isSet(4));
-}
+const Layer = @import("Layer.zig");
// ---- drawing a pane body into the surface ----
-fn paintTerminalSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void {
+fn paintTerminalSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void {
const body = bodyText(p, p.scratch.allocator(), pane) catch return;
const raw = pane.body.mode == .tty;
const tx = r.x + config.GUTTER;
@@ -178,7 +74,7 @@ fn paintTerminalSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.P
var col = panes.File.rawDisplayCol(line, lo);
const end = panes.File.rawDisplayCol(line, hi);
while (col < end and col < width) : (col += 1) {
- const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible)));
+ const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible)));
cell.default = false;
cell.style.bg = .{ .rgb = bg };
if (fg) |ink| cell.style.fg = .{ .rgb = ink };
@@ -187,8 +83,8 @@ fn paintTerminalSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.P
}
}
-fn paintSourceSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void {
- if (pane.isTerminal()) return paintTerminalSelection(p, pane, r, rows, bg, fg);
+fn paintSourceSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void {
+ if (pane.isTerminal()) return paintTerminalSelection(p, s, pane, r, rows, bg, fg);
const terminal_lines = if (pane.file == null) panes.Terminal.cursorLines(p, pane) catch return else &.{};
const tx = r.x + config.GUTTER;
const width = r.w -| config.GUTTER;
@@ -209,7 +105,7 @@ fn paintSourceSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.Poi
var col = prefix + @as(i32, @intCast(panes.File.rawDisplayCol(line[start..], lo - start)));
const last = prefix + @as(i32, @intCast(panes.File.rawDisplayCol(line[start..], hi - start)));
while (col < last and col < width) : (col += 1) {
- const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible)));
+ const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible)));
cell.default = false;
cell.style.bg = .{ .rgb = bg };
if (fg) |ink| cell.style.fg = .{ .rgb = ink };
@@ -220,6 +116,7 @@ fn paintSourceSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.Poi
pub fn paintPointerSelection(
p: *Pardes,
+ s: *Surface,
pane: *Pane,
r: Rect,
tx: u16,
@@ -227,18 +124,19 @@ pub fn paintPointerSelection(
tag_y: u16,
body_y: u16,
sl: Pane.Sel,
+ /// Rows of the selection above this are left alone.
+ from_row: u16,
bg: [3]u8,
fg: ?[3]u8,
) void {
- if (pane.pointerSelection(sl)) |rows| return paintSourceSelection(p, pane, r, rows, bg, fg);
- const s = &p.surface;
+ if (pane.pointerSelection(sl)) |rows| return paintSourceSelection(p, s, pane, r, rows, bg, fg);
const r0 = @min(sl.r0, sl.r1);
const r1 = @max(sl.r0, sl.r1);
const c0 = @max(0, @min(sl.c0, sl.c1));
const c1 = @max(0, @max(sl.c0, sl.c1));
var row: u16 = 0;
while (row < r.h) : (row += 1) {
- if (@as(i32, row) < r0 or @as(i32, row) > r1) continue;
+ if (@as(i32, row) < r0 or @as(i32, row) > r1 or row < from_row) continue;
const sy = if (row < pane.tag_rows) tag_y + row else body_y + row - pane.tag_rows;
// File line numbers occupy a gutter only in the body. The tag is
// row zero in Sel space and starts at its real first text cell.
@@ -255,15 +153,33 @@ pub fn paintPointerSelection(
}
}
-pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize) !void {
- if (!p.settings.tree_context_tag_style or pane.context_rows == 0 or pane.file == null or pane.collapsed) return;
- if (r.w <= config.GUTTER or r.h <= pane.tag_rows) return;
+/// The selection colour of each pointer button's sweep: the select button's
+/// and the look and exec buttons', each tinted toward its own accent.
+pub fn buttonTints(th: *const colors.Theme) [3][3]u8 {
+ const accent2 = if (std.mem.eql(u8, &th.num, &th.str)) th.comment else th.num;
+ return .{
+ colors.mix(th.sel_bg, colors.mix(th.sel_bg, th.kw)),
+ colors.mix(th.sel_bg, colors.mix(th.sel_bg, th.str)),
+ colors.mix(th.sel_bg, colors.mix(th.sel_bg, accent2)),
+ };
+}
+
+/// A pane's body, painted once. A file body with compact tree-sitter
+/// context rows is painted into its body layer -- more rows than the grid
+/// has, the context rows at the tagline's height -- and the grid's rows are
+/// copies of that layer's first ones, at body height. Any other body has no
+/// rows the grid cannot show, and is painted straight onto the grid. The
+/// rail is the grid's alone.
+pub fn joinBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, rail: pardes.Region) !void {
+ const file = if (pane.file) |*f| f else return renderBody(p, s, arena, pane, r, id, active, rail, null);
+ if (!p.settings.tree_context_tag_style or pane.collapsed or r.h <= pane.tag_rows)
+ return renderBody(p, s, arena, pane, r, id, active, rail, null);
+ // Which rows are context rows is decided by the text the body shows.
+ const text = try panes.File.bodyText(arena, pane, file, p.settings.wrap);
const viewport_rows = r.h - pane.tag_rows;
- if (viewport_rows == 0) return;
- // The canonical fixed grid remains available to terminal clients. The
- // separate body layer reserves enough rows for any compact font size.
const logical_rows = @min(@as(usize, pane.rows) + pane.context_rows, pane.wrap_line.len);
- if (logical_rows <= pane.context_rows) return;
+ if (pane.context_rows == 0 or logical_rows <= pane.context_rows)
+ return renderBody(p, s, arena, pane, r, id, active, rail, text);
pane.body_rows = @intCast(logical_rows);
const bh: usize = @max(1, p.row_metrics.body_h);
const th: usize = std.math.clamp(p.row_metrics.tagline_h, 1, bh);
@@ -272,27 +188,24 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec
const context_pixels = @as(usize, pane.context_row_limit.?) * th;
pane.body_visible_rows = @intCast(@min(logical_rows, pane.context_row_limit.? + @max(1, (body_pixels -| context_pixels) / bh)));
const width = r.w - config.GUTTER;
- const layer = &p.surface.body_layers[id];
+ const layer = &s.body_layers[id];
const count = logical_rows * width;
if (layer.cells.len != count) layer.cells = try p.gpa.realloc(layer.cells, count);
const temporary_cells = try arena.alloc(Cell, @as(usize, r.w) * (logical_rows + pane.tag_rows));
@memset(temporary_cells, .{});
var temporary: Surface = .{ .cols = r.w, .rows = @intCast(logical_rows + pane.tag_rows), .cells = temporary_cells };
- std.mem.swap(Surface, &p.surface, &temporary);
const synthetic: Rect = .{ .x = 0, .y = 0, .w = r.w, .h = @intCast(logical_rows + pane.tag_rows) };
- renderBody(p, arena, pane, synthetic, id, id == p.active, false) catch |err| {
- std.mem.swap(Surface, &p.surface, &temporary);
- return err;
- };
- std.mem.swap(Surface, &p.surface, &temporary);
+ try renderBody(p, &temporary, arena, pane, synthetic, id, active, null, null);
const first_row: usize = if (p.settings.tag_bottom) 0 else pane.tag_rows;
for (0..logical_rows) |row| {
const start = (row + first_row) * r.w + config.GUTTER;
@memcpy(layer.cells[row * width ..][0..width], temporary_cells[start..][0..width]);
}
- layer.pane = @intCast(id);
+ layer.kind = .body;
+ layer.id = @intCast(id);
layer.serial = pane.serial;
- layer.viewport = .{ .x = r.x + config.GUTTER, .y = p.bodyTop(pane, r), .w = width, .h = viewport_rows };
+ const body = s.region(.body, id).?;
+ layer.viewport = .{ .x = body.rect.x, .y = body.rect.y, .w = body.rect.w - body.clip, .h = body.rect.h };
layer.cols = width;
layer.rows = @intCast(logical_rows);
layer.context_rows = pane.context_rows;
@@ -302,10 +215,29 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec
if (cursor.x >= config.GUTTER and cursor.y >= first_row)
layer.cursor = .{ .x = cursor.x - config.GUTTER, .y = @intCast(cursor.y - first_row), .bar = cursor.bar };
}
+ // The grid: the layer's first rows, as many as the body has, each a
+ // body row high; its caret where one of them has it. (pane.rows is the
+ // body's height after sync, and context rows are only prepared while
+ // the rows fit the wrap table, so the layer always has these rows.)
+ std.debug.assert(layer.rows >= viewport_rows);
+ for (0..viewport_rows) |row| {
+ const grid_row = s.cells[(body.rect.y + row) * s.cols ..][body.rect.x..][0..width];
+ @memcpy(grid_row, layer.cells[row * width ..][0..width]);
+ }
+ if (layer.cursor) |cursor| if (cursor.y < viewport_rows) {
+ s.cursor = .{ .x = body.rect.x + cursor.x, .y = body.rect.y + cursor.y, .bar = cursor.bar };
+ };
+ const chrome = p.chromeTheme();
+ const theme = p.theme();
+ const pane_bg: Color = if (theme.bg) |c| .{ .rgb = c } else .default;
+ if (rail.rect.h > 0) {
+ s.fill(rail.rect.x, rail.rect.y, 1, rail.rect.h, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ s.fill(rail.rect.x + 1, rail.rect.y, 1, rail.rect.h, .{ .bg = pane_bg });
+ s.fill(rail.rect.x, rail.rect.y + rail.thumb_y, 1, rail.thumb_h, .{ .bg = .{ .rgb = chrome.scroll_thumb } });
+ }
}
-pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, scrollbar: bool) !void {
- const s = &p.surface;
+pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, rail: ?pardes.Region, built: ?[]const u8) !void {
const th = p.theme();
const chrome = p.chromeTheme();
const tx = r.x + config.GUTTER;
@@ -325,8 +257,14 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
.bg = pane_bg,
};
const tz_body = tracy.zone(@src(), "bodyText");
- const body = try bodyText(p, arena, pane);
+ const body = built orelse try bodyText(p, arena, pane);
tz_body.end();
+ // A select sweep begun on the tag and dragged into the body marks the
+ // body rows it crossed only where no text is: it is laid down before
+ // the rows, which paint over it.
+ const sweep = pane.sel[pardes.sel_slot];
+ if (sweep.state != .none and (sweep.state != .done or pardes.sel_slot == 0) and @min(sweep.r0, sweep.r1) < pane.tag_rows)
+ paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sweep, pane.tag_rows, th.sel_bg, th.sel_fg);
{
// The rows themselves. `bodyText` above is only the string BUILD;
// this is what writes it into the surface.
@@ -343,32 +281,25 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
const tz_color = tracy.zone(@src(), "paneRecolor");
// File layout is independent of whether its producer requests colors.
if (pane.file) |*f| {
- panes.File.drawGutter(p, pane, r, tx, tw, body_h, active);
+ panes.File.drawGutter(p, s, pane, r, tx, tw, body_h, active);
if (p.settings.colors and pane.colorAlgo() != .none)
- panes.File.recolorSyntax(p, pane, f, r, tx, tw, body_h);
- panes.File.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg);
+ panes.File.recolorSyntax(p, s, pane, f, r, tx, tw, body_h);
+ panes.File.drawWrapMarkers(p, s, pane, r, tx, tw, body_h, pane_bg);
} else if (pane.isTerminal() and p.settings.colors) {
- panes.Terminal.recolorAnsi(p, pane, r, tx, tw, body_h, body);
+ panes.Terminal.recolorAnsi(p, s, pane, r, tx, tw, body_h, body);
}
tz_color.end();
- const accent2 = if (std.mem.eql(u8, &th.num, &th.str)) th.comment else th.num;
- const sel_btn = [3][3]u8{
- colors.mix(th.sel_bg, colors.mix(th.sel_bg, th.kw)),
- colors.mix(th.sel_bg, colors.mix(th.sel_bg, th.str)),
- colors.mix(th.sel_bg, colors.mix(th.sel_bg, accent2)),
- };
+ const sel_btn = buttonTints(th);
if (p.look_hover_preview) |preview| {
if (preview.pane == id and preview.serial == pane.serial) {
// Preserve body ink, then let every real user selection below
- // win over this quiet affordance. mark_hover carries the
- // cells this paints out to the hosts as the affordance rect —
- // macOS composes its glass overlay over exactly these.
+ // win over this quiet affordance.
const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg));
if (preview.file_word) |word|
- panes.File.paintWordSelection(p, pane, r, word.row, word.lo, word.hi, preview_bg)
+ panes.File.paintWordSelection(p, s, pane, r, word.row, word.lo, word.hi, preview_bg)
else if (preview.sel) |sel|
- paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null);
+ paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, pane.tag_rows, preview_bg, null);
}
}
const modal_hover = if (p.look_hover_preview) |preview|
@@ -381,7 +312,7 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
// A select sweep over the tag is the tag's selection, painted there
// in the tag's own selection colours.
if (b == pardes.sel_slot and @min(sl.r0, sl.r1) < pane.tag_rows) continue;
- paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg);
+ paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, pane.tag_rows, sel_btn[b], th.sel_fg);
}
// modal line selection (helix `x`): whole body rows, normal mode only
if ((pane.body.mode == .normal or modal_hover) and pane.body.msel.active) {
@@ -477,6 +408,29 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
}
}
+ // `gw`'s labels, two letters over the start of each word
+ if (pane.jump.items.len > 0) labels: {
+ const flat = edit.flatSurface(p, &pane.body) catch break :labels;
+ const letters = config.jump_label_alphabet;
+ const style: CellStyle = .{ .fg = .{ .rgb = th.sel_fg }, .bg = .{ .rgb = th.sel_bg }, .bold = true };
+ for (pane.jump.items, 0..) |word, i| {
+ if (pane.jump_first) |first| if (i / letters.len != first) continue;
+ var at = @min(word.anchor, word.head);
+ for ([_]u8{ letters[i / letters.len], letters[i % letters.len] }) |letter| {
+ const pos = modal.positionAt(flat, at);
+ const row: i32 = @intCast(pos.row);
+ const wp = pane.wrapRow(row, @intCast(pos.col));
+ const cx = if (pane.file != null)
+ @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, wp.at, @intCast(pos.col))
+ else
+ panes.File.lineDisplayOffset(modal.lineSlice(flat, pos.row), @intCast(@max(0, wp.at)), pos.col);
+ if (wp.row >= 0 and wp.row < body_h and cx >= 0 and cx < tw)
+ s.set(tx + @as(u16, @intCast(cx)), body_y + @as(u16, @intCast(wp.row)), &.{letter}, style);
+ at = modal.nextGrapheme(flat, at);
+ }
+ }
+ }
+
// cursor: tracks the shell cursor until pinned by a click or a key
// (the tag's or a prompt's cursor wins while that is being typed into)
if (active and pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side or pane.prompt == .repl_choice)) {
@@ -507,34 +461,14 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
}
}
- if (scrollbar and body_h > 0) {
+ if (rail) |bar| if (bar.rect.h > 0) {
// The rail covers the notice bands too: nothing else paints those
// two gutter columns, so a gap there keeps the frame-wide border
// fill and reads as a notch cut out of the scrollbar.
- const rail_h = r.h -| pane.tag_rows;
- s.fill(r.x, body_y, 1, rail_h, .{ .bg = .{ .rgb = chrome.scroll_track } });
- s.fill(r.x + 1, body_y, 1, rail_h, .{ .bg = pane_bg });
- const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{
- .total = panes.File.nlines(p.gpa, f),
- .offset = f.scroll,
- .len = pane.rows,
- } else if (pane.pdfPage()) |page| .{
- .total = if (comptime pdf_enabled) pane.pdf.?.page_count else 1,
- .offset = page,
- .len = 1,
- } else blk: {
- const gsb = panes.Terminal.scrollbar(pane);
- break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len };
- };
- const track_h: usize = body_h;
- const total = if (sb.total == 0) 1 else sb.total;
- const len = @max(1, (track_h * @max(1, sb.len)) / total);
- const pos = (track_h * sb.offset) / total;
- var sy: usize = pos;
- while (sy < track_h and sy < pos + len) : (sy += 1) {
- s.fill(r.x, body_y + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = chrome.scroll_thumb } });
- }
- }
+ s.fill(bar.rect.x, bar.rect.y, 1, bar.rect.h, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ s.fill(bar.rect.x + 1, bar.rect.y, 1, bar.rect.h, .{ .bg = pane_bg });
+ s.fill(bar.rect.x, bar.rect.y + bar.thumb_y, 1, bar.thumb_h, .{ .bg = .{ .rgb = chrome.scroll_thumb } });
+ };
}
pub fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
@@ -555,453 +489,3 @@ pub fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
if (pane.file) |*f| return panes.File.bodyText(arena, pane, f, p.settings.wrap);
return panes.Terminal.bodyText(arena, pane);
}
-
-/// The workspace and column tags as the grid shows them: their text, the
-/// word under the pointer and, in the one being edited, its selection and
-/// caret. Their tag layers are drawn by renderTagLayers.
-pub fn paintHeaders(p: *Pardes) !void {
- const s = &p.surface;
- const chrome = p.chromeTheme();
- const th = p.theme();
- // global tagbar: full width, top rows — unless a native shell has
- // taken its commands over, in which case the rows are not ours to draw
- tagline.revealHeader(p);
- const global = tagline.topbar(p);
- const global_rows = p.topBarHeight();
- const global_scroll = if (p.header_focus and p.header_column == null) p.header_scroll else 0;
- const global_top = if (p.header_focus and p.header_column == null) p.header_top else 0;
- if (p.settings.workspace_tag) {
- s.fill(0, 0, s.cols, global_rows, .{
- .bg = .{ .rgb = chrome.tag_bg },
- .font_role = .tagline,
- });
- for (0..global_rows) |row| {
- const line = modal.lineSlice(global, row + global_top);
- _ = s.print(0, @intCast(row), s.cols, line[panes.File.rawAtDisplay(line, global_scroll)..], .{
- .fg = .{ .rgb = chrome.tag_fg },
- .bg = .{ .rgb = chrome.tag_bg },
- .font_role = .tagline,
- });
- }
- }
- if (p.pointer_inside and p.hover_row < global_rows) {
- const bar = modal.lineSlice(global, p.hover_row + global_top);
- if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| {
- var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll;
- const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll;
- while (col < hi and col < s.cols) : (col += 1) {
- const cell = s.at(@intCast(col), p.hover_row);
- cell.default = false;
- cell.style.bg = .{ .rgb = th.sel_bg };
- cell.style.fg = .{ .rgb = th.sel_fg };
- }
- }
- }
- const column_rows = p.columnBarHeight();
- if (column_rows > 0) for (0..p.ncol) |c| {
- const grip = @min(config.GUTTER, p.col_w[c]);
- const inset = @min(TAG_TEXT_INSET, p.col_w[c]);
- const x = p.col_x[c] + inset;
- const w = p.col_w[c] - inset;
- const y0 = global_rows;
- if (p.col_w[c] == 0 or p.col_x[c] >= s.cols) continue;
- const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false;
- const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
- const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg;
- // A grip is muted until it is grabbed; then it lights, as a pane's
- // box does for the active pane, so the drag shows what it holds.
- const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c;
- const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim;
- const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10;
- const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 };
- const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline };
- s.fill(p.col_x[c], y0, grip, column_rows, grip_style);
- s.fill(p.col_x[c] + grip, y0, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
-
- s.fill(x, y0, w, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
- const bar_scroll = if (p.header_focus and p.header_column == c) p.header_scroll else 0;
- const bar_top = if (p.header_focus and p.header_column == c) p.header_top else 0;
- const tag = tagline.columnTag(p, c);
- for (0..column_rows) |row| {
- const line = modal.lineSlice(tag, row + bar_top);
- _ = s.print(x, y0 + @as(u16, @intCast(row)), w, line[panes.File.rawAtDisplay(line, bar_scroll)..], .{
- .fg = .{ .rgb = fg },
- .bg = .{ .rgb = bg },
- .font_role = .tagline,
- });
- }
- // A carried column's pointer is aiming at a place, not a word.
- if (p.pointer_inside and p.drag != .column_move and p.hover_row >= y0 and p.hover_row < y0 + column_rows and p.hover_col >= x and p.hover_col < x + w) {
- const bar = modal.lineSlice(tag, p.hover_row - y0 + bar_top);
- if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| {
- var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll;
- const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll;
- while (col < hi and col < w) : (col += 1) {
- const cell = s.at(x + @as(u16, @intCast(col)), p.hover_row);
- cell.style.bg = .{ .rgb = th.sel_bg };
- cell.style.fg = .{ .rgb = th.sel_fg };
- }
- }
- }
- };
- if (tagline.focusedHeader(p)) |t| {
- const text = try edit.flatSurface(p, t);
- const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0;
- const w = if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else s.cols;
- const y: u16 = if (p.header_column != null) global_rows else 0;
- const rows = if (p.header_column != null) column_rows else global_rows;
- for (0..rows) |row| {
- const bar = modal.lineSlice(text, row + p.header_top);
- const span = tagline.headerSelection(t, text, row + p.header_top) orelse continue;
- var col = panes.File.rawDisplayCol(bar, span.lo) -| p.header_scroll;
- const hi = panes.File.rawDisplayCol(bar, span.hi) -| p.header_scroll;
- while (col < hi and col < w) : (col += 1) {
- const cell = s.at(x + @as(u16, @intCast(col)), y + @as(u16, @intCast(row)));
- cell.style.fg = .{ .rgb = th.sel_fg };
- cell.style.bg = .{ .rgb = th.sel_bg };
- }
- }
- // A prompt answering for the header has the caret on its band.
- const asking = if (p.panes[p.active]) |active| active.prompt != .none else false;
- const cur: usize = @intCast(@max(0, t.cur_row));
- if (!asking and cur >= p.header_top and cur < p.header_top + rows) {
- const bar = modal.lineSlice(text, cur);
- const caret = panes.File.rawDisplayCol(bar, @min(@as(usize, @intCast(@max(0, t.cur_col))), bar.len)) -| p.header_scroll;
- if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y + @as(u16, @intCast(cur - p.header_top)), .bar = t.mode == .insert };
- }
- }
-}
-
-fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } {
- if (pane.isTerminal()) {
- // `tag` is the first row; a prefix longer than it ran to a second.
- var words = std.mem.tokenizeAny(u8, tag[@min(prefix_len, tag.len)..], " \t\r\n");
- while (words.next()) |word| {
- if (!std.mem.eql(u8, word, "Tty") and !std.mem.startsWith(u8, word, "Tty+")) continue;
- const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr);
- return .{ .lo = lo, .hi = lo + word.len };
- }
- return null;
- }
- var start: usize = 0;
- const name = if (pane.file) |file|
- if (pane.prompt == .name) pane.input.own orelse file.path else file.path
- else if (pane.image) |state| blk: {
- // Image tags put renderer controls before the path.
- start = std.mem.lastIndexOf(u8, tag[0..@min(prefix_len, tag.len)], state.path) orelse return null;
- break :blk state.path;
- } else if (comptime pdf_enabled) blk: {
- break :blk if (pane.pdf) |state| state.path else return null;
- } else return null;
- const basename = if (std.mem.lastIndexOfScalar(u8, name, '/')) |slash| slash + 1 else 0;
- if (basename == name.len) return null;
- return .{ .lo = start + basename, .hi = start + name.len };
-}
-
-pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void {
- const chrome = p.chromeTheme();
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- const r = p.rects[id];
- if (r.w <= TAG_TEXT_INSET or r.h == 0) continue;
- const tag = try tagline.tagText(p, arena, pane);
- const rows = pane.tag_rows;
- // As wide as the widest line it shows, and never narrower than
- // its viewport.
- var widest: usize = 0;
- var split = std.mem.splitScalar(u8, tag, '\n');
- for (0..pane.tag_top) |_| _ = split.next();
- for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse ""));
- const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), widest + 1)));
- const pitch = @as(usize, columns) + TAG_TEXT_INSET;
- const storage = try arena.alloc(Cell, pitch * rows);
- @memset(storage, .{});
- var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage };
- std.mem.swap(Surface, &p.surface, &temporary);
- paintPaneTag(p, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| {
- std.mem.swap(Surface, &p.surface, &temporary);
- return err;
- };
- std.mem.swap(Surface, &p.surface, &temporary);
- // A layer to a line: the first in the pane's own slot, the rest
- // in the slots kept for the lines of taller tags.
- for (0..rows) |line| {
- const layer = &p.surface.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1];
- if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns);
- @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]);
- layer.kind = .pane;
- layer.id = @intCast(id);
- layer.serial = pane.serial;
- layer.line = @intCast(line);
- layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H };
- layer.cols = columns;
- layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
- layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor|
- (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null)
- else
- null;
- }
- }
- // With the workspace tag row handed to native chrome there is no
- // workspace layer to render; the menu bar carries its commands.
- if (p.settings.workspace_tag) {
- for (0..p.topBarHeight()) |line| {
- const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1;
- const top = if (p.header_focus and p.header_column == null) p.header_top else 0;
- try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = 0, .y = @intCast(line), .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line + top), chrome.tag_bg, chrome.tag_fg);
- }
- }
- // Each notice is its own tagline-shaped chip over the top of the body.
- // Going through the same layer machinery the pane and column tags use
- // is what gives it the tagline's height, its small-font metrics, the
- // band offset and the border -- none of which a body-grid row can have
- // just by wearing a tagline font. The geometry is the one the grid
- // pass already chose and recorded, so the two cannot disagree and the
- // GUI's "skip the cells a tag layer covers" leaves no stub behind.
- // A prompt is one of them too: left on the grid, a pixel shell drew
- // its tagline glyphs one to a BODY cell, spaced out like a banner.
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- const r = p.rects[id];
- if (r.w <= TAG_TEXT_INSET or r.h <= pane.tag_rows) continue;
- const first = p.bodyTop(pane, r);
- var leader_buf: [16]u8 = undefined;
- for (pane.notices.slice(), 0..) |kind, i| {
- const text = Messages.noticeText(p, pane, i, &leader_buf);
- if (text.len == 0) continue;
- const cx = pane.notices.left[i];
- if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue;
- const chip = r.x + r.w - cx;
- // Right aligned inside the chip, a blank cell short of its
- // edge: the same one the grid pass leaves for a prompt caret.
- // Wider than the chip, the text loses its head, as on the grid.
- const room = p.tagCapacity(chip) -| 1;
- const shown = panes.File.displayWidth(text);
- var kept = text;
- if (shown > room) {
- var start = panes.File.rawAtDisplay(text, shown - room);
- if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1);
- kept = text[start..];
- }
- const pad = room -| panes.File.displayWidth(kept);
- const line = try arena.alloc(u8, pad + kept.len);
- @memset(line[0..pad], ' ');
- @memcpy(line[pad..], kept);
- // A pixel shell slides the message in as it fades up, and
- // fades it on the way out.
- const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{};
- const fade = motion.alpha;
- const page = p.theme().bg orelse chrome.tag_bg;
- const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i;
- try renderHeaderLayer(p, arena, layer_index, .notice, @intCast(id), 0, .{
- .x = cx,
- .y = first + @as(u16, pane.notices.row[i]),
- .w = chip,
- .h = BOX_H,
- }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(if (kind == .message and pane.msg_sent) chrome.tag_name_fg else chrome.tag_fg, page, fade));
- p.surface.tag_layers[layer_index].slide = motion.slide;
- p.surface.tag_layers[layer_index].fade = fade;
- // The caret rides the band, where the prompt's glyphs are,
- // at the same place in the text the grid pass puts it.
- if (kind != .prompt or id != p.active or pane.prompt == .del_side or pane.prompt == .repl_choice) continue;
- const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
- const dropped = shown - panes.File.displayWidth(kept);
- if (typed < dropped) continue;
- p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert };
- }
- }
- if (p.columnBarHeight() > 0) for (0..p.ncol) |column| {
- if (p.col_w[column] == 0) continue;
- const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false;
- const top = if (p.header_focus and p.header_column == column) p.header_top else 0;
- for (0..p.columnBarHeight()) |line| {
- const index = if (line == 0) MAX_PANES + column else HEADER_LINE_LAYER_BASE + column * (MAX_TAG_ROWS - 1) + line - 1;
- try renderHeaderLayer(p, arena, index, .column, @intCast(column), @intCast(line), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight() + @as(u16, @intCast(line)), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, modal.lineSlice(tagline.columnTag(p, column), line + top), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg);
- }
- };
-}
-
-/// One row of a header or a notice band as a tag layer: `text` is line
-/// `line` of what it shows.
-fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void {
- _ = arena;
- const layer = &p.surface.tag_layers[index];
- if (viewport.w == 0) {
- layer.cols = 0;
- return;
- }
- const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1)));
- if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns);
- layer.kind = kind;
- layer.id = id;
- layer.line = line;
- layer.serial = if (kind == .column) p.col_serial[id] else 0;
- layer.viewport = viewport;
- layer.cols = columns;
- layer.bg = bg;
- layer.cursor = null;
- layer.slide = 0;
- layer.fade = 1;
- var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells };
- // A notice band carries a PANE id, not a column index, so it must not
- // answer to the column being edited -- that would scroll and select
- // its text with the column tag's offsets and give it a second caret.
- const editing: ?*Text = if (p.header_focus and switch (kind) {
- .workspace => p.header_column == null,
- .column => p.header_column == id,
- .pane, .notice => false,
- }) tagline.focusedHeader(p) else null;
- const scroll = if (editing != null) p.header_scroll else 0;
- surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
- _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline });
- if (editing) |t| {
- if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| {
- var col = panes.File.rawDisplayCol(text, span.lo) -| scroll;
- const end = panes.File.rawDisplayCol(text, span.hi) -| scroll;
- while (col < end and col < columns) : (col += 1) {
- surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg };
- surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg };
- }
- }
- const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll;
- const asking = if (p.panes[p.active]) |active| active.prompt != .none else false;
- if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert };
- } else if (p.pointer_inside) {
- const tag_hit = p.pointer_tag_hit;
- const col: ?usize = if (tag_hit) |pointer|
- (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null)
- else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w)
- p.hover_col - viewport.x
- else
- null;
- if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| {
- var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll;
- const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll;
- while (cell < end and cell < columns) : (cell += 1) {
- surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg };
- surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg };
- }
- };
- }
-}
-
-pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void {
- const s = &p.surface;
- const th = p.theme();
- const chrome = p.chromeTheme();
- const tx = r.x + @min(TAG_TEXT_INSET, r.w);
- const tw = r.w -| TAG_TEXT_INSET;
- const tag_y = p.tagTop(pane, r);
- const body_y = p.bodyTop(pane, r);
- const rows = pane.tag_rows;
- const page_rgb = th.bg orelse th.tag_bg;
- // Focus is visible along the tag without changing its live text grid.
- const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
- const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg;
- const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline };
- // Padding belongs to the same font row but keeps its default ink: a
- // role must not manufacture a foreground color for blank cells.
- s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline });
- s.fill(tx, tag_y, tw, rows, .{
- .bg = .{ .rgb = tag_bg },
- .font_role = .tagline,
- });
- const whole = try tagline.tagText(p, arena, pane);
- const tail = tagline.curTail(pane);
- const prefix_len = whole.len - tail.len;
- // A row per line shown, from `tag_top`: the tag's first line carries
- // the prefix, the rest are the tag's own text alone.
- const top = pane.tag_top;
- var lines: [MAX_TAG_ROWS][]const u8 = @splat("");
- var split = std.mem.splitScalar(u8, whole, '\n');
- for (0..top) |_| _ = split.next();
- for (lines[0..rows]) |*line| line.* = split.next() orelse "";
- // The keyboard's caret, as a row and a byte offset in that row's
- // line: in the tag's own text when the tag has the keyboard, in the
- // path while a name is drafted.
- const Caret = struct { row: usize, at: usize };
- const caret: ?Caret = if (!active)
- null
- else if (pane.prompt == .name)
- .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) }
- else if (pane.focus == .tag and pane.prompt == .none and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: {
- const row: usize = @intCast(pane.tag.cur_row - top);
- break :caret .{ .row = row, .at = @min(lines[row].len, @as(usize, @intCast(@max(0, pane.tag.cur_col)))) };
- } else null;
- if (caret) |at| {
- const line = lines[at.row];
- const col = panes.File.rawDisplayCol(line, at.at);
- var scroll: usize = pane.tag_scroll;
- if (col < scroll) scroll = col;
- if (col >= scroll + capacity) scroll = col - capacity + 1;
- var raw = panes.File.rawAtDisplay(line, scroll);
- scroll = panes.File.rawDisplayCol(line, raw);
- if (col >= scroll + capacity) {
- raw = modal.nextGrapheme(line, raw);
- scroll = panes.File.rawDisplayCol(line, raw);
- }
- pane.tag_scroll = @intCast(scroll);
- } else if (pane.focus != .tag) pane.tag_scroll = 0;
- for (lines[0..rows], 0..) |line, row| {
- const start = panes.File.rawAtDisplay(line, pane.tag_scroll);
- _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style);
- }
- const tag = lines[0];
- if (top == 0) if (tagTintRange(pane, tag, prefix_len)) |name| {
- const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll));
- const hi = @min(name.hi, tag.len);
- if (lo < hi) {
- var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll;
- const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll;
- const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg;
- while (col < end and col < tw) : (col += 1)
- s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg };
- }
- };
- if (p.look_hover_preview) |preview| {
- if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) {
- const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg));
- paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null);
- };
- }
- // The tag's own selections, while it has the keyboard, a line at a
- // time. A range of one grapheme is the block cursor, not a selection.
- if (pane.focus == .tag) {
- var ranges: [Text.max_selections]modal.Selection = undefined;
- const got = pane.tag.ranges(whole, 0, &ranges);
- for (ranges[0..got.n]) |range| {
- const lo = @min(range.anchor, range.head);
- const hi = @max(range.anchor, range.head);
- if (modal.nextGrapheme(whole, lo) >= hi) continue;
- const first = modal.positionAt(whole, lo);
- const last = modal.positionAt(whole, hi);
- var row = @max(first.row, top);
- while (row <= last.row and row < top + rows) : (row += 1) {
- const line = lines[row - top];
- const from = if (row == first.row) first.col else 0;
- const to = @min(line.len, if (row == last.row) last.col else line.len);
- var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll;
- const end = panes.File.rawDisplayCol(line, to);
- const y = tag_y + @as(u16, @intCast(row - top));
- while (col + pane.tag_scroll < end and col < tw) : (col += 1) {
- const cell = s.at(tx + @as(u16, @intCast(col)), y);
- cell.default = false;
- cell.style.bg = .{ .rgb = th.sel_bg };
- cell.style.fg = .{ .rgb = th.sel_fg };
- }
- }
- }
- }
- // the caret: bar while typing, block in normal mode (same rule as a body)
- if (caret) |at| {
- const col = panes.File.rawDisplayCol(lines[at.row], at.at) -| pane.tag_scroll;
- const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode;
- if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y + @as(u16, @intCast(at.row)), .bar = mode == .insert };
- }
-
- for (pane.sel, 0..) |selection, button| {
- if (selection.state == .none or (selection.state == .done and button != 0)) continue;
- if (@min(selection.r0, selection.r1) >= rows) continue;
- paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg);
- }
-}
diff --git a/src/builtins.zig b/src/builtins.zig
index 2f34c647..23d853c8 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -170,7 +170,7 @@ pub const registry = struct {
if (@intFromEnum(b) == i) return switch (setting.action) {
// a switch flips bare, and DumpDir bare is the default
.toggle, .transition, .scene, .dump_dir => false,
- .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms => true,
+ .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation => true,
};
unreachable;
}
diff --git a/src/colors.zig b/src/colors.zig
index 8e608590..aeff7b00 100644
--- a/src/colors.zig
+++ b/src/colors.zig
@@ -5,6 +5,7 @@ const tagline = @import("tagline.zig");
const panes = @import("panes.zig");
const std = @import("std");
const layout = @import("layout.zig");
+const animation = @import("animation.zig");
const Color = @import("surface.zig").Color;
const FontRole = @import("surface.zig").FontRole;
const config = @import("config.zig");
@@ -185,15 +186,15 @@ pub const ChromeTheme = struct {
pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme {
var out: ChromeTheme = undefined;
inline for (@typeInfo(ChromeTheme).@"struct".fields) |field|
- @field(out, field.name) = layout.Animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps);
+ @field(out, field.name) = animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps);
return out;
}
};
pub const ChromeAnimation = if (theme_animation)
- layout.Animation.Transition(ChromeTheme)
+ animation.Fade(ChromeTheme)
else
- layout.Animation.Immediate(ChromeTheme);
+ animation.Immediate(ChromeTheme);
pub const initial_chrome = ChromeTheme.fromTheme(&themes[0]);
@@ -426,7 +427,7 @@ test "runtime theme changes animate chrome and retarget without a jump" {
try std.testing.expectEqualStrings("acme", p.theme().name);
try std.testing.expectEqual(midflight, p.chromeTheme().*);
try std.testing.expect(p.animationActive());
- for (0..layout.Animation.transition_steps) |_| p.update(.tick);
+ for (0..animation.transition_steps) |_| p.update(.tick);
try std.testing.expect(!p.animationActive());
try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*);
// Extra ticks are inert at the exact endpoint.
diff --git a/src/config.zig b/src/config.zig
index 9ad9cc7e..186b29a1 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -1,6 +1,7 @@
const std = @import("std");
const builtin = @import("builtin");
const layout = @import("layout.zig");
+const animation = @import("animation.zig");
const limits = @import("memory.zig").limits;
const pardes = @import("pardes.zig");
const builtins = @import("builtins.zig");
@@ -129,8 +130,8 @@ pub const leader_path = paths: {
}
if (builtins.capabilities.scene_shaders) {
table.set(.Crt, "tr");
- table.set(.Ripple, "tR");
- table.set(.Glitch, "tg");
+ table.set(.Shader, null);
+ table.set(.ShaderAnimation, null);
}
if (builtins.EffectCode.enabled) table.set(.EffectCode, null);
if (builtins.TreeContext.enabled) table.set(.TreeContext, null);
@@ -266,10 +267,15 @@ pub const pipe_selection: []const Chord = &.{.{ .cp = '|' }};
pub const pipe_selection_to: []const Chord = &.{.{ .cp = '|', .alt = true }};
pub const insert_output: []const Chord = &.{.{ .cp = '!' }};
pub const append_output: []const Chord = &.{.{ .cp = '!', .alt = true }};
+pub const keep_pipe: []const Chord = &.{.{ .cp = '$' }};
+pub const macro_record: []const Chord = &.{.{ .cp = 'Q' }};
+pub const macro_replay: []const Chord = &.{.{ .cp = 'q' }};
+pub const repeat_insert: []const Chord = &.{.{ .cp = '.' }};
// Armed inputs are parsed from their distinct, nonempty tag markers.
pub const pipe_marker_to = " |-";
pub const pipe_marker_insert = " !";
pub const pipe_marker_append = " !+";
+pub const pipe_marker_keep = " $";
pub const search_submit: []const Chord = &.{.{ .cp = Key.enter }};
// Del from the keyboard on a pane with open panes both above and below it
@@ -409,6 +415,8 @@ pub const symbol_marker = " WsSymbols /";
pub const save_marker = " Save ";
pub const select_marker = " Select /";
pub const split_marker = " Split /";
+pub const keep_marker = " Keep /";
+pub const remove_marker = " Remove /";
pub const pipe_marker = " |";
pub const search_buffer = "+Search";
@@ -433,6 +441,7 @@ pub const errors_buffer = "+Errors";
// Modal bindings below are checked against Helix by hxdiff and hxparity.
pub const goto_prefix = 'g';
pub const view_prefix = 'z';
+pub const view_prefix_sticky = 'Z';
pub const match_prefix = 'm';
pub const replace_prefix = 'r';
pub const next_prefix = ']';
@@ -454,7 +463,7 @@ pub const next_long_word_start: []const Chord = &.{.{ .cp = 'W' }};
pub const prev_long_word_start: []const Chord = &.{.{ .cp = 'B' }};
pub const next_long_word_end: []const Chord = &.{.{ .cp = 'E' }};
pub const line_start: []const Chord = &.{ .{ .cp = '0' }, .{ .cp = Key.home } };
-pub const line_end: []const Chord = &.{ .{ .cp = '$' }, .{ .cp = Key.end } };
+pub const line_end: []const Chord = &.{.{ .cp = Key.end }};
pub const line_first_nonws: []const Chord = &.{.{ .cp = '^' }};
// Bare G does nothing; ge reaches the last line.
pub const goto_line: []const Chord = &.{.{ .cp = 'G' }};
@@ -466,6 +475,7 @@ pub const page_up: []const Chord = &.{ .{ .cp = 'b', .ctrl = true }, .{ .cp = Ke
pub const view_top: []const Chord = &.{.{ .cp = 't' }};
pub const view_center: []const Chord = &.{ .{ .cp = 'z' }, .{ .cp = 'c' } };
pub const view_bottom: []const Chord = &.{.{ .cp = 'b' }};
+pub const view_middle: []const Chord = &.{.{ .cp = 'm' }};
pub const view_scroll_down: []const Chord = &.{ .{ .cp = 'j' }, .{ .cp = Key.down } };
pub const view_scroll_up: []const Chord = &.{ .{ .cp = 'k' }, .{ .cp = Key.up } };
@@ -480,6 +490,10 @@ pub const goto_column: []const Chord = &.{.{ .cp = '|' }};
pub const goto_view_top: []const Chord = &.{.{ .cp = 't' }};
pub const goto_view_center: []const Chord = &.{.{ .cp = 'c' }};
pub const goto_view_bottom: []const Chord = &.{.{ .cp = 'b' }};
+pub const goto_last_modification: []const Chord = &.{.{ .cp = '.' }};
+pub const goto_word: []const Chord = &.{.{ .cp = 'w' }};
+/// the jump labels' letters, helix's jump_label_alphabet
+pub const jump_label_alphabet = "abcdefghijklmnopqrstuvwxyz";
pub const goto_definition: []const Chord = &.{.{ .cp = 'd' }};
pub const goto_declaration: []const Chord = &.{.{ .cp = 'D' }};
pub const goto_type_definition: []const Chord = &.{.{ .cp = 'y' }};
@@ -492,11 +506,19 @@ pub const match_around = 'a';
pub const surround_add = 's';
pub const surround_replace = 'r';
pub const surround_delete = 'd';
+pub const register_prefix = '"';
pub const goto_paragraph: []const Chord = &.{.{ .cp = 'p' }};
pub const add_newline: []const Chord = &.{.{ .cp = ' ' }};
pub const goto_diagnostic: []const Chord = &.{.{ .cp = 'd' }};
pub const goto_diagnostic_end: []const Chord = &.{.{ .cp = 'D' }};
+pub const goto_function: []const Chord = &.{.{ .cp = 'f' }};
+pub const goto_class: []const Chord = &.{.{ .cp = 't' }};
+pub const goto_parameter: []const Chord = &.{.{ .cp = 'a' }};
+pub const goto_comment: []const Chord = &.{.{ .cp = 'c' }};
+pub const goto_test: []const Chord = &.{.{ .cp = 'T' }};
+pub const goto_entry: []const Chord = &.{.{ .cp = 'e' }};
+pub const goto_xml_element: []const Chord = &.{.{ .cp = 'x' }};
pub const insert: []const Chord = &.{.{ .cp = 'i' }};
pub const append: []const Chord = &.{.{ .cp = 'a' }};
@@ -526,6 +548,19 @@ pub const trim_sels: []const Chord = &.{.{ .cp = '_' }};
pub const select_regex: []const Chord = &.{.{ .cp = 's' }};
pub const split_regex: []const Chord = &.{.{ .cp = 'S' }};
+pub const keep_regex: []const Chord = &.{.{ .cp = 'K' }};
+pub const remove_regex: []const Chord = &.{.{ .cp = 'K', .alt = true }};
+pub const ensure_forward: []const Chord = &.{.{ .cp = ':', .alt = true }};
+// helix's tree-sitter selection; Alt-n stays the new terminal, so the next
+// sibling is Alt-right alone
+pub const expand_node: []const Chord = &.{ .{ .cp = 'o', .alt = true }, .{ .cp = Key.up, .alt = true } };
+pub const shrink_node: []const Chord = &.{ .{ .cp = 'i', .alt = true }, .{ .cp = Key.down, .alt = true } };
+pub const prev_sibling: []const Chord = &.{ .{ .cp = 'p', .alt = true }, .{ .cp = Key.left, .alt = true } };
+pub const next_sibling: []const Chord = &.{.{ .cp = Key.right, .alt = true }};
+pub const all_siblings: []const Chord = &.{.{ .cp = 'a', .alt = true }};
+pub const all_children: []const Chord = &.{.{ .cp = 'I', .alt = true }};
+pub const parent_node_end: []const Chord = &.{.{ .cp = 'e', .alt = true }};
+pub const parent_node_start: []const Chord = &.{.{ .cp = 'b', .alt = true }};
pub const delete: []const Chord = &.{.{ .cp = 'd' }};
pub const delete_noyank: []const Chord = &.{.{ .cp = 'd', .alt = true }};
@@ -538,13 +573,19 @@ pub const switch_case: []const Chord = &.{.{ .cp = '~' }};
pub const to_lowercase: []const Chord = &.{.{ .cp = '`' }};
pub const to_uppercase: []const Chord = &.{.{ .cp = '`', .alt = true }};
pub const join_lines: []const Chord = &.{.{ .cp = 'J' }};
+pub const join_select_space: []const Chord = &.{.{ .cp = 'J', .alt = true }};
+pub const rotate_contents_fwd: []const Chord = &.{.{ .cp = ')', .alt = true }};
+pub const rotate_contents_back: []const Chord = &.{.{ .cp = '(', .alt = true }};
+pub const align_selections: []const Chord = &.{.{ .cp = '&' }};
pub const indent: []const Chord = &.{.{ .cp = '>' }};
pub const unindent: []const Chord = &.{.{ .cp = '<' }};
pub const format: []const Chord = &.{.{ .cp = '=' }};
pub const increment: []const Chord = &.{.{ .cp = 'a', .ctrl = true }};
pub const decrement: []const Chord = &.{.{ .cp = 'x', .ctrl = true }};
-pub const undo: []const Chord = &.{.{ .cp = 'u' }};
-pub const redo: []const Chord = &.{.{ .cp = 'U' }};
+// pardes's history is linear, so helix's earlier/later (Alt-u, Alt-U) are
+// undo and redo themselves
+pub const undo: []const Chord = &.{ .{ .cp = 'u' }, .{ .cp = 'u', .alt = true } };
+pub const redo: []const Chord = &.{ .{ .cp = 'U' }, .{ .cp = 'U', .alt = true } };
pub const comment_toggle: []const Chord = &.{.{ .cp = 'c', .ctrl = true }};
// Body-normal Esc runs Last; other modes cancel input or leave insert mode.
@@ -553,6 +594,8 @@ pub const escape: []const Chord = &.{.{ .cp = Key.escape }};
pub const insert_backspace_alias: []const Chord = &.{.{ .cp = 'h', .ctrl = true }};
pub const insert_enter_alias: []const Chord = &.{.{ .cp = 'j', .ctrl = true }};
pub const insert_delete_alias: []const Chord = &.{.{ .cp = 'd', .ctrl = true }};
+pub const insert_register: []const Chord = &.{.{ .cp = 'r', .ctrl = true }};
+pub const insert_checkpoint: []const Chord = &.{.{ .cp = 's', .ctrl = true }};
pub const delete_word_backward: []const Chord = &.{ .{ .cp = 'w', .ctrl = true }, .{ .cp = Key.backspace, .alt = true } };
pub const delete_word_forward: []const Chord = &.{ .{ .cp = 'd', .alt = true }, .{ .cp = Key.delete, .alt = true } };
pub const kill_to_line_start: []const Chord = &.{.{ .cp = 'u', .ctrl = true }};
@@ -611,10 +654,92 @@ pub const Runtime = struct {
tagline_percent: u8 = 100,
} = .{},
- panel_transition: layout.Transition = .off,
- scene_effects: layout.SceneEffect = .{},
+ panel_transition: animation.Transition = .off,
+ /// The post chain (docs/render-pipeline.md §6), in the order it runs.
+ post: Post = .{},
+ /// When the chain redraws on its own, past the frames it post-processes:
+ /// never, while the window has the focus, or always (ghostty's
+ /// custom-shader-animation).
+ shader_animation: ShaderAnimation = .on,
- pub fn toggleTransition(state: *Runtime, effect: layout.Transition) void {
+ pub const ShaderAnimation = enum { off, on, always };
+
+ /// Each pass the bundled Crt or a Shadertoy file.
+ pub const Post = struct {
+ pub const max = 8;
+ pub const Entry = struct {
+ scene: ?Scene = null,
+ path: Text(limits.host_path_cap) = .{},
+ /// A bundled pass's strength, 1 to `max_level`; 0 for a file.
+ level: u8 = 0,
+ };
+ pub const max_level = 3;
+ /// What `on` and a bare word turn a bundled pass on at.
+ pub const default_level = 2;
+ entries: [max]Entry = @splat(.{}),
+ len: u8 = 0,
+ /// Bumped on every change: a shell compiles the chain again when it
+ /// moves.
+ generation: u32 = 0,
+
+ pub fn list(post: *const Post) []const Entry {
+ return post.entries[0..post.len];
+ }
+
+ pub fn has(post: *const Post, scene: Scene) bool {
+ return post.level(scene) != 0;
+ }
+
+ /// A bundled pass's level; 0 when it is not in the chain.
+ pub fn level(post: *const Post, scene: Scene) u8 {
+ for (post.list()) |entry| if (entry.scene == scene) return entry.level;
+ return 0;
+ }
+
+ /// Sets a bundled pass's level: 0 takes it out, anything else puts it
+ /// in at the end or changes it where it is.
+ pub fn setLevel(post: *Post, scene: Scene, to: u8) bool {
+ std.debug.assert(to <= max_level);
+ if (to == 0) return post.set(scene, "", false);
+ for (post.entries[0..post.len]) |*entry| if (entry.scene == scene) {
+ if (entry.level != to) post.generation +%= 1;
+ entry.level = to;
+ return true;
+ };
+ if (!post.set(scene, "", true)) return false;
+ post.entries[post.len - 1].level = to;
+ return true;
+ }
+
+ /// Puts the pass in the chain, at its end, or takes it out; `want`
+ /// null flips it. False when the chain is full or the path too long.
+ pub fn set(post: *Post, scene: ?Scene, path: []const u8, want: ?bool) bool {
+ const at: ?usize = for (post.list(), 0..) |entry, i| {
+ if (entry.scene == scene and std.mem.eql(u8, entry.path.get(), path)) break i;
+ } else null;
+ const on = want orelse (at == null);
+ if (on == (at != null)) return true;
+ post.generation +%= 1;
+ if (at) |i| {
+ std.mem.copyForwards(Entry, post.entries[i .. post.len - 1], post.entries[i + 1 .. post.len]);
+ post.len -= 1;
+ return true;
+ }
+ if (post.len == max) return false;
+ post.entries[post.len] = .{ .scene = scene };
+ if (!post.entries[post.len].path.set(path)) return false;
+ post.len += 1;
+ return true;
+ }
+
+ pub fn clear(post: *Post) void {
+ if (post.len == 0) return;
+ post.len = 0;
+ post.generation +%= 1;
+ }
+ };
+
+ pub fn toggleTransition(state: *Runtime, effect: animation.Transition) void {
std.debug.assert(effect != .off);
state.panel_transition = if (state.panel_transition == effect) .off else effect;
}
@@ -691,7 +816,8 @@ pub const Runtime = struct {
pub const MessageTime = enum { linger, fall, dissolve };
pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures };
- pub const Scene = std.meta.FieldEnum(layout.SceneEffect);
+ /// The bundled post pass, a Shadertoy file in shaders/post/.
+ pub const Scene = enum { crt };
pub const Action = union(enum) {
toggle: Toggle,
@@ -703,8 +829,12 @@ pub const Runtime = struct {
window_blur,
message_ms: MessageTime,
dump_dir,
- transition: layout.Transition,
+ transition: animation.Transition,
scene: Scene,
+ /// A Shadertoy file into the post chain or out of it; `off` empties
+ /// it of files.
+ shader,
+ shader_animation,
};
pub const Setting = struct {
@@ -755,8 +885,8 @@ pub const Runtime = struct {
.{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions },
.{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions },
.{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders },
- .{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders },
- .{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders },
+ .{ .word = "Shader", .action = .shader, .availability = .scene_shaders },
+ .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders },
};
pub fn find(name: []const u8) ?Setting {
@@ -790,7 +920,7 @@ pub const Runtime = struct {
@compileError("window blur must use the window-blur capability"),
.transition => if (setting.availability != .panel_transitions)
@compileError("panel effects must use the panel-transition capability"),
- .scene => if (setting.availability != .scene_shaders)
+ .scene, .shader, .shader_animation => if (setting.availability != .scene_shaders)
@compileError("scene effects must use the scene-shader capability"),
// A toggle is plain state any shell can hold, except one for
// what only some shells draw: that one exists where they do.
@@ -808,7 +938,7 @@ pub const Runtime = struct {
if (actionCount(.shell) != 1 or actionCount(.theme) != 1 or actionCount(.font) != 1 or
actionCount(.tagline_size) != 1 or actionCount(.window_opacity) != 1 or actionCount(.window_blur) != 1)
@compileError("Shell, Theme, Font, TaglineSize, WindowOpacity, and WindowBlur actions must each occur exactly once");
- for (std.enums.values(layout.Transition)) |effect| {
+ for (std.enums.values(animation.Transition)) |effect| {
const expected: comptime_int = @intFromBool(effect != .off);
if (actionCount(.{ .transition = effect }) != expected)
@compileError("non-off panel transition must occur exactly once: " ++ @tagName(effect));
@@ -816,9 +946,9 @@ pub const Runtime = struct {
for (std.enums.values(Scene)) |effect| {
if (actionCount(.{ .scene = effect }) != 1)
@compileError("scene effect must occur exactly once: " ++ @tagName(effect));
- if (@FieldType(layout.SceneEffect, @tagName(effect)) != bool)
- @compileError("scene effect fields must be booleans: " ++ @tagName(effect));
}
+ if (actionCount(.shader) != 1 or actionCount(.shader_animation) != 1)
+ @compileError("Shader and ShaderAnimation must each occur exactly once");
}
pub fn apply(state: *Runtime, setting: Setting, argument: ?[]const u8) bool {
@@ -884,13 +1014,36 @@ pub const Runtime = struct {
}
},
.scene => |effect| {
- const want = switchedTo(argument) catch return false;
- switch (effect) {
- inline else => |field| {
- const value = &@field(state.scene_effects, @tagName(field));
- value.* = want orelse !value.*;
- },
+ // A level, 0 (off) to 3; `on` is the default level, `off` 0,
+ // and bare flips between the two.
+ const text = std.mem.trim(u8, argument orelse "", " \t\r\n");
+ const to: u8 = if (text.len == 0)
+ (if (state.post.has(effect)) 0 else Post.default_level)
+ else if (std.mem.eql(u8, text, "on"))
+ Post.default_level
+ else if (std.mem.eql(u8, text, "off"))
+ 0
+ else
+ std.fmt.parseInt(u8, text, 10) catch return false;
+ if (to > Post.max_level) return false;
+ return state.post.setLevel(effect, to);
+ },
+ .shader => {
+ const path = std.mem.trim(u8, argument orelse return false, " \t\r\n");
+ if (path.len == 0) return false;
+ if (std.mem.eql(u8, path, "off")) {
+ var i: usize = 0;
+ while (i < state.post.len) {
+ const entry = state.post.entries[i];
+ if (entry.scene == null) _ = state.post.set(null, entry.path.get(), false) else i += 1;
+ }
+ return true;
}
+ return state.post.set(null, path, null);
+ },
+ .shader_animation => {
+ const text = std.mem.trim(u8, argument orelse return false, " \t\r\n");
+ state.shader_animation = std.meta.stringToEnum(ShaderAnimation, text) orelse return false;
},
.theme, .font => return false,
}
@@ -931,7 +1084,7 @@ pub const Runtime = struct {
return if (text.len == 0) "(none)" else text;
}
- fn transitionSettingName(transition: layout.Transition) []const u8 {
+ fn transitionSettingName(transition: animation.Transition) []const u8 {
if (transition == .off) return "off";
return findAction(.{ .transition = transition }).?.word;
}
@@ -1042,11 +1195,25 @@ pub const Runtime = struct {
try out.print("{s} unsupported\n", .{setting.word});
continue;
}
- const enabled = switch (effect) {
- inline else => |field| @field(state.scene_effects, @tagName(field)),
+ const level = state.post.level(effect);
+ if (level == 0) try out.print("{s} off\n", .{setting.word}) else try out.print("{s} {d}\n", .{ setting.word, level });
+ },
+ .shader => {
+ if (!setting.enabled(context.capabilities)) {
+ try out.print("{s} unsupported\n", .{setting.word});
+ continue;
+ }
+ var files: usize = 0;
+ for (state.post.list()) |entry| if (entry.scene == null) {
+ try out.print("{s} {s}\n", .{ setting.word, entry.path.get() });
+ files += 1;
};
- try out.print("{s} {s}\n", .{ setting.word, onOff(enabled) });
+ if (files == 0) try out.print("{s} off\n", .{setting.word});
},
+ .shader_animation => if (setting.enabled(context.capabilities))
+ try out.print("{s} {s}\n", .{ setting.word, @tagName(state.shader_animation) })
+ else
+ try out.print("{s} unsupported\n", .{setting.word}),
};
if (context.startup_config_path) |path|
@@ -1082,20 +1249,18 @@ pub const Runtime = struct {
const slide = findAction(.{ .transition = .slide }).?;
const zoom = findAction(.{ .transition = .zoom }).?;
try std.testing.expect(apply(&state, slide, "on"));
- try std.testing.expectEqual(layout.Transition.slide, state.panel_transition);
+ try std.testing.expectEqual(animation.Transition.slide, state.panel_transition);
try std.testing.expect(apply(&state, zoom, "off"));
- try std.testing.expectEqual(layout.Transition.slide, state.panel_transition);
+ try std.testing.expectEqual(animation.Transition.slide, state.panel_transition);
try std.testing.expect(apply(&state, slide, "on"));
- try std.testing.expectEqual(layout.Transition.slide, state.panel_transition);
+ try std.testing.expectEqual(animation.Transition.slide, state.panel_transition);
try std.testing.expect(apply(&state, slide, null));
- try std.testing.expectEqual(layout.Transition.off, state.panel_transition);
+ try std.testing.expectEqual(animation.Transition.off, state.panel_transition);
try std.testing.expect(!apply(&state, slide, "yes"));
const scene = findAction(.{ .scene = std.enums.values(Scene)[0] }).?;
try std.testing.expect(apply(&state, scene, "on"));
try std.testing.expect(apply(&state, scene, "on"));
- switch (std.enums.values(Scene)[0]) {
- inline else => |f| try std.testing.expect(@field(state.scene_effects, @tagName(f))),
- }
+ try std.testing.expect(state.post.has(std.enums.values(Scene)[0]));
}
test "a toggle flips when bare and sets when told on or off" {
@@ -1128,11 +1293,56 @@ pub const Runtime = struct {
try std.testing.expectEqualStrings("fish", state.shell.requested.get());
try std.testing.expect(state.shell.pending);
try std.testing.expect(apply(&state, find("PanelAscii").?, null));
- try std.testing.expectEqual(layout.Transition.ascii, state.panel_transition);
+ try std.testing.expectEqual(animation.Transition.ascii, state.panel_transition);
try std.testing.expect(apply(&state, find("PanelAscii").?, null));
- try std.testing.expectEqual(layout.Transition.off, state.panel_transition);
+ try std.testing.expectEqual(animation.Transition.off, state.panel_transition);
try std.testing.expect(apply(&state, find("Crt").?, null));
- try std.testing.expect(state.scene_effects.crt);
+ try std.testing.expect(state.post.has(.crt));
+ }
+
+ test "the post chain runs in the order its passes were put in, and Shader off empties it of files" {
+ var state: Runtime = .{};
+ try std.testing.expect(apply(&state, find("Shader").?, " ~/a.glsl\n"));
+ try std.testing.expect(apply(&state, find("Crt").?, "on"));
+ try std.testing.expect(apply(&state, find("Shader").?, "/b.glsl"));
+ const chain = state.post.list();
+ try std.testing.expectEqual(@as(usize, 3), chain.len);
+ try std.testing.expectEqualStrings("~/a.glsl", chain[0].path.get());
+ try std.testing.expectEqual(Scene.crt, chain[1].scene.?);
+ try std.testing.expectEqualStrings("/b.glsl", chain[2].path.get());
+ const moved = state.post.generation;
+ // A file named again leaves; the others keep their order.
+ try std.testing.expect(apply(&state, find("Shader").?, "~/a.glsl"));
+ try std.testing.expect(state.post.generation != moved);
+ try std.testing.expectEqual(Scene.crt, state.post.list()[0].scene.?);
+ try std.testing.expect(apply(&state, find("Shader").?, "off"));
+ try std.testing.expectEqual(@as(usize, 1), state.post.len);
+ try std.testing.expect(!apply(&state, find("Shader").?, null));
+ try std.testing.expect(apply(&state, find("ShaderAnimation").?, "always"));
+ try std.testing.expectEqual(ShaderAnimation.always, state.shader_animation);
+ try std.testing.expect(!apply(&state, find("ShaderAnimation").?, "sometimes"));
+ }
+
+ test "a bundled pass takes a level: on is the default, 0 and off take it out, and the old words still flip" {
+ var state: Runtime = .{};
+ const crt = find("Crt").?;
+ try std.testing.expect(apply(&state, crt, "on"));
+ try std.testing.expectEqual(@as(u8, Post.default_level), state.post.level(.crt));
+ try std.testing.expect(apply(&state, find("Shader").?, "/after.glsl"));
+ const moved = state.post.generation;
+ try std.testing.expect(apply(&state, crt, "3"));
+ try std.testing.expectEqual(@as(u8, 3), state.post.level(.crt));
+ // A new level keeps its place in the chain.
+ try std.testing.expectEqual(Scene.crt, state.post.list()[0].scene.?);
+ try std.testing.expect(state.post.generation != moved);
+ try std.testing.expect(!apply(&state, crt, "4"));
+ try std.testing.expect(!apply(&state, crt, "loud"));
+ try std.testing.expect(apply(&state, crt, "0"));
+ try std.testing.expect(!state.post.has(.crt));
+ try std.testing.expect(apply(&state, crt, null));
+ try std.testing.expectEqual(@as(u8, Post.default_level), state.post.level(.crt));
+ try std.testing.expect(apply(&state, crt, "off"));
+ try std.testing.expectEqual(@as(u8, 0), state.post.level(.crt));
}
test "tagline size validates before mutating live state" {
@@ -1253,6 +1463,8 @@ pub const Runtime = struct {
.window_blur => "40",
.message_ms => "1500",
.dump_dir => "~/dumps",
+ .shader => "~/crt.glsl",
+ .shader_animation => "always",
else => null,
};
try std.testing.expect(apply(&state, setting, argument));
@@ -1299,10 +1511,10 @@ pub const Runtime = struct {
.typewriter => "Panel transition PanelType\n",
},
.scene => |effect| switch (effect) {
- .crt => "Crt on\n",
- .ripple => "Ripple on\n",
- .glitch => "Glitch on\n",
+ .crt => "Crt 2\n",
},
+ .shader => "Shader ~/crt.glsl\n",
+ .shader_animation => "ShaderAnimation always\n",
.theme, .font => unreachable,
};
try std.testing.expect(std.mem.indexOf(u8, report, expected) != null);
@@ -1370,8 +1582,6 @@ pub const Runtime = struct {
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested") == null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition off\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt unsupported\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple unsupported\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch unsupported\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize unsupported\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null);
diff --git a/src/detached/client.zig b/src/detached/client.zig
index ba533751..8c9a2560 100644
--- a/src/detached/client.zig
+++ b/src/detached/client.zig
@@ -103,8 +103,12 @@ pub const Client = struct {
grid: std.ArrayListUnmanaged(pardes.Cell) = .empty,
cursor: ?wire.Cursor = null,
pointer_shape: pardes.Surface.PointerShape = .arrow,
- body_layers: [pardes.MAX_PANES]pardes.BodyLayer = @splat(.{}),
- tag_layers: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}),
+ body_layers: [pardes.MAX_PANES]pardes.Layer = @splat(.{}),
+ tag_layers: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}),
+ /// The session's placed regions, as its last frame gave them.
+ regions: std.ArrayListUnmanaged(pardes.Region) = .empty,
+ /// The palette its chrome is drawn in, as its last frame gave it.
+ chrome: pardes.Chrome = .{},
in: std.ArrayListUnmanaged(u8) = .empty,
out: std.ArrayListUnmanaged(u8) = .empty,
/// Bytes of `in` belonging to the message `next` returned last. Compacted at
@@ -182,6 +186,7 @@ pub const Client = struct {
layer.* = .{};
}
for (&c.tag_layers) |*layer| c.gpa.free(layer.cells);
+ c.regions.deinit(c.gpa);
c.in.deinit(c.gpa);
c.out.deinit(c.gpa);
}
@@ -319,7 +324,7 @@ pub const Client = struct {
try c.reshape(f.cols, f.rows);
}
try f.apply(c.grid.items);
- f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers) catch |err| return switch (err) {
+ f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers, &c.regions, &c.chrome) catch |err| return switch (err) {
error.OutOfMemory => error.Closed,
else => @as(wire.Error, @errorCast(err)),
};
@@ -996,6 +1001,12 @@ test "detached session: a frontend that dies takes nothing with it" {
}
test "detached session: a frontend speaking another protocol is refused, loudly" {
+ // The protocol before this one (v7 carried one-row tags and no regions)
+ // and the one after.
+ for ([_]u16{ wire.version - 1, wire.version + 1 }) |claimed| try refusedHello(claimed);
+}
+
+fn refusedHello(claimed: u16) !void {
var h: Harness = undefined;
try h.init(60, 16);
defer h.deinit();
@@ -1007,7 +1018,7 @@ test "detached session: a frontend speaking another protocol is refused, loudly"
defer _ = libc.close(fd);
var buf: [64]u8 = undefined;
const hello = try wire.encodeClient(&buf, .{
- .hello = .{ .version = wire.version + 1, .cols = 60, .rows = 16 },
+ .hello = .{ .version = claimed, .cols = 60, .rows = 16 },
});
try testing.expectEqual(@as(isize, @intCast(hello.len)), libc.send(fd, hello.ptr, hello.len, nosignal));
@@ -1025,6 +1036,17 @@ test "detached session: a frontend speaking another protocol is refused, loudly"
try testing.expect(!h.core.quit);
}
+test "detached frontend: a session speaking another protocol is not attached to" {
+ for ([_]u16{ wire.version - 1, wire.version + 1 }) |claimed| {
+ var c: Client = .{ .gpa = testing.allocator };
+ defer c.deinit();
+ var buf: [64]u8 = undefined;
+ try c.in.appendSlice(testing.allocator, try wire.encodeServer(&buf, .{ .welcome = .{ .version = claimed, .slot = 0, .cols = 60, .rows = 16 } }));
+ try testing.expectError(error.Ungreeted, c.next());
+ try testing.expect(!c.attached());
+ }
+}
+
test "detached session: a peer that sends garbage is dropped, not obeyed" {
var h: Harness = undefined;
try h.init(60, 16);
@@ -1282,7 +1304,7 @@ fn pumpUntilNotice(h: *Harness, c: *Client, want: []const u8) !bool {
try c.wait(5);
while (try c.next()) |_| {}
for (&c.tag_layers) |*layer| {
- if (layer.cols == 0 or layer.kind != .notice) continue;
+ if (layer.rows == 0 or layer.kind != .notice) continue;
var n: usize = 0;
for (layer.cells[0..layer.cols]) |cell| {
const g = cell.grapheme();
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 8fc7b96e..5374bf73 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -150,6 +150,8 @@ pub const Session = struct {
worker_gpa: std.mem.Allocator,
io: std.Io,
core: *pardes.Pardes,
+ /// PARDES_TEST_CLOCK's virtual time (host_io.testClock).
+ test_clock: ?u64 = null,
listener: c_int = -1,
path_buf: [sun_path_len]u8 = undefined,
path_len: usize = 0,
@@ -355,8 +357,14 @@ pub const Session = struct {
return @ptrCast(@alignCast(ctx.?));
}
+ fn clockNow(ctx: ?*anyopaque) u64 {
+ const s = of(ctx);
+ return s.test_clock orelse host_io.monotonicNs();
+ }
+
const vtable: host_io.Host.VTable = .{
.wait_input = waitInput,
+ .now = clockNow,
.present = present,
.poll_frame = pollFrame,
.spawn = spawn,
@@ -767,7 +775,7 @@ pub const Session = struct {
fn sendFrame(s: *Session, c: *Client, surface: *const pardes.Surface) void {
const cells = surface.cells;
- const want = wire.frameBound(surface.cols, surface.rows) + wire.layersBound(&surface.body_layers, &surface.tag_layers);
+ const want = wire.frameBound(surface.cols, surface.rows) + wire.layersBound(&surface.body_layers, &surface.tag_layers, surface.regionList());
s.scratch.ensureTotalCapacity(s.gpa, want) catch return s.close(c, .oom);
const prev: []const pardes.Cell = if (c.need_full or c.mirror.items.len != cells.len)
&.{}
@@ -787,6 +795,8 @@ pub const Session = struct {
prev,
&surface.body_layers,
&surface.tag_layers,
+ surface.regionList(),
+ surface.chrome,
) catch |err| {
log.debug("frame {d}x{d} not encodable: {t}", .{ surface.cols, surface.rows, err });
return;
@@ -867,8 +877,9 @@ pub const Session = struct {
pardes.turn.rest();
nap(if (timeout_ms == 0) 16 else timeout_ms);
pardes.turn.wake();
- // An animating frame's wait is the frame: tick it.
- if (timeout_ms != 0) s.core.update(.tick);
+ if (timeout_ms != 0) if (s.test_clock) |*virtual| {
+ virtual.* = @max(virtual.*, s.core.nextWake() orelse virtual.*);
+ };
return;
}
var timeout: c_int = if (timeout_ms == 0) -1 else @intCast(@min(timeout_ms, std.math.maxInt(c_int)));
@@ -878,9 +889,10 @@ pub const Session = struct {
const ready = libc.poll(&fds, @intCast(n), timeout);
pardes.turn.wake();
if (ready > 0) s.dispatch(fds[0..n], src[0..n]);
- // Animating, the frame's time is up: advance it. There is no other
- // tick in a detached session, and without it an animation never ends.
- if (timeout_ms != 0 and monotonicMs() -| now >= timeout_ms) s.core.update(.tick);
+ // The core's wake ran out; the pump advances it from `now`.
+ if (timeout_ms != 0 and monotonicMs() -| now >= timeout_ms) if (s.test_clock) |*virtual| {
+ virtual.* = @max(virtual.*, s.core.nextWake() orelse virtual.*);
+ };
_ = s.drainCompletions(true);
s.takeExits();
s.expire(monotonicMs());
@@ -1212,6 +1224,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void
.cols = options.cols,
.rows = options.rows,
.prompt_rcs = host_io.Shell.prepare(),
+ .test_clock = host_io.testClock(),
};
defer session.core.deinit();
defer session.deinit();
diff --git a/src/detached/wire.zig b/src/detached/wire.zig
index 0854ce3e..b1b81eda 100644
--- a/src/detached/wire.zig
+++ b/src/detached/wire.zig
@@ -7,7 +7,9 @@ const limits = @import("../memory.zig").limits;
/// 7: tag layers gained the `.notice` kind, so a frame can carry a tag value
/// an older frontend would reject mid-session. The greeting is where that has
/// to be refused, which is what this number is for.
-pub const version: u16 = 7;
+/// 8: one layer type: a tag band carries all its rows (not a layer per
+/// line), and a frame carries the core's placed regions after its layers.
+pub const version: u16 = 8;
pub const Error = error{ Truncated, Overlong, BadTag, BadValue, Trailing, NoSpace };
@@ -167,7 +169,9 @@ pub const Frame = struct {
nruns: u32,
runs: []const u8,
- pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.BodyLayer, tags: *[pardes.MAX_TAG_LAYERS]pardes.TagLayer) !void {
+ pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.Layer, tags: *[pardes.MAX_TAG_LAYERS]pardes.Layer, regions: *std.ArrayListUnmanaged(pardes.Region), chrome: *pardes.Chrome) !void {
+ regions.clearRetainingCapacity();
+ chrome.* = .{};
for (layers) |*layer| {
gpa.free(layer.cells);
layer.* = .{};
@@ -185,19 +189,22 @@ pub const Frame = struct {
gpa.free(layer.cells);
layer.* = .{};
}
+ regions.clearRetainingCapacity();
+ chrome.* = .{};
}
if (f.layers.len == 0) return;
var r: Reader = .init(f.layers);
const count = try r.getU16();
if (count > layers.len) return error.Overlong;
for (layers[0..count]) |*layer| {
- layer.pane = try r.getU16();
+ layer.kind = .body;
+ layer.id = try r.getU16();
layer.serial = try r.getU32();
layer.viewport = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() };
layer.cols = try r.getU16();
layer.rows = try r.getU16();
layer.context_rows = try r.getU16();
- if (layer.pane >= pardes.MAX_PANES or layer.cols == 0 or layer.cols > f.cols or
+ if (layer.id >= pardes.MAX_PANES or layer.cols == 0 or layer.cols > f.cols or
layer.rows == 0 or layer.rows > 2 * max_rows or layer.context_rows > layer.rows or layer.context_rows > limits.wrap_rows or
layer.viewport.x > f.cols or layer.viewport.w > f.cols - layer.viewport.x or
layer.viewport.y > f.rows or layer.viewport.h > f.rows - layer.viewport.y) return error.BadValue;
@@ -217,19 +224,49 @@ pub const Frame = struct {
layer.serial = try r.getU32();
layer.viewport = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() };
layer.cols = try r.getU16();
+ layer.rows = try r.getU16();
for (&layer.bg) |*channel| channel.* = try r.getByte();
- if (layer.cols == 0 or layer.viewport.w == 0 or layer.viewport.h != 1 or
+ if (layer.cols == 0 or layer.viewport.w == 0 or layer.rows == 0 or layer.rows > pardes.MAX_TAG_ROWS or
+ layer.viewport.h != layer.rows or
layer.viewport.x > f.cols or layer.viewport.w > f.cols - layer.viewport.x or
- layer.viewport.y >= f.rows or (switch (layer.kind) {
+ layer.viewport.y >= f.rows or layer.viewport.h > f.rows - layer.viewport.y or (switch (layer.kind) {
.workspace => layer.id != 0,
.column => layer.id >= pardes.MAX_COLS,
.pane, .notice => layer.id >= pardes.MAX_PANES,
+ .body => true,
})) return error.BadValue;
- const cursor = try getCursor(&r, layer.cols, 1);
- layer.cursor = if (cursor) |c| .{ .col = c.x, .bar = c.bar } else null;
- layer.cells = try gpa.alloc(pardes.Cell, layer.cols);
+ const cursor = try getCursor(&r, layer.cols, layer.rows);
+ layer.cursor = if (cursor) |c| .{ .x = c.x, .y = c.y, .bar = c.bar } else null;
+ layer.cells = try gpa.alloc(pardes.Cell, @as(usize, layer.cols) * layer.rows);
for (layer.cells) |*cell| cell.* = try decodeCell(&r);
}
+ const region_count = try r.getU16();
+ if (region_count > pardes.MAX_REGIONS) return error.Overlong;
+ try regions.ensureTotalCapacity(gpa, region_count);
+ for (0..region_count) |_| {
+ var region: pardes.Region = .{ .kind = try r.getTag(pardes.Region.Kind), .rect = undefined };
+ region.owner = try r.getByte();
+ region.serial = try r.getU32();
+ region.rect = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() };
+ region.active = try r.getBool();
+ region.thumb_y = try r.getU16();
+ region.thumb_h = try r.getU16();
+ region.notice = try r.getByte();
+ region.clip = try r.getU16();
+ if (region.rect.x > f.cols or region.rect.w > f.cols - region.rect.x or
+ region.rect.y > f.rows or region.rect.h > f.rows - region.rect.y or
+ region.clip > region.rect.w or region.thumb_y > region.rect.h or region.thumb_h > region.rect.h - region.thumb_y) return error.BadValue;
+ regions.appendAssumeCapacity(region);
+ }
+ inline for (@typeInfo(pardes.Chrome).@"struct".fields) |field| {
+ const rgb = &@field(chrome, field.name);
+ if (field.type == ?[3]u8) {
+ if (try r.getBool()) {
+ rgb.* = .{ 0, 0, 0 };
+ for (&rgb.*.?) |*channel| channel.* = try r.getByte();
+ }
+ } else for (rgb) |*channel| channel.* = try r.getByte();
+ }
try r.end();
}
@@ -672,21 +709,26 @@ pub fn encodeFramePointer(
cells: []const pardes.Cell,
prev: []const pardes.Cell,
) Error![]const u8 {
- return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{});
+ return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}, &.{}, .{});
}
-pub fn layersBound(layers: []const pardes.BodyLayer, tags: []const pardes.TagLayer) usize {
- var n: usize = 4;
+pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region) usize {
+ var n: usize = 6;
for (layers) |layer| if (layer.rows != 0) {
n += 26 + layer.context_rows + layer.cells.len * cell_max;
};
- for (tags) |layer| if (layer.cols != 0) {
- n += 26 + layer.cells.len * cell_max;
+ for (tags) |layer| if (layer.rows != 0) {
+ n += 28 + layer.cells.len * cell_max;
};
- return n;
+ return n + regions.len * region_len + chrome_len;
}
-pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.BodyLayer, tags: []const pardes.TagLayer) Error![]const u8 {
+const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2;
+/// The palette: each colour three bytes, a theme's optional ones a flag
+/// before them.
+const chrome_len = @typeInfo(pardes.Chrome).@"struct".fields.len * 4;
+
+pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region, chrome: pardes.Chrome) Error![]const u8 {
// The protocol's ceiling, enforced by the ENCODER too, and BEFORE the
// assert below so a caller can be told rather than tripped. `max_payload`
// is derived from these two, so a larger grid is a frame this decoder
@@ -708,7 +750,7 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point
const layers_len_at = w.n;
try w.putU32(0);
const layers_start = w.n;
- if (layers.len != 0 or tags.len != 0) {
+ if (layers.len != 0 or tags.len != 0 or regions.len != 0) {
var count: u16 = 0;
for (layers) |layer| if (layer.rows != 0) {
count += 1;
@@ -717,7 +759,7 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point
for (layers) |layer| {
if (layer.rows == 0) continue;
if (layer.context_rows > limits.wrap_rows or layer.context_rows > layer.rows) return error.BadValue;
- try w.putU16(layer.pane);
+ try w.putU16(layer.id);
try w.putU32(layer.serial);
try w.putU16(layer.viewport.x);
try w.putU16(layer.viewport.y);
@@ -731,13 +773,13 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point
for (layer.cells) |*cell| try putCell(&w, cell);
}
var tag_count: u16 = 0;
- for (tags) |layer| if (layer.cols != 0) {
+ for (tags) |layer| if (layer.rows != 0) {
tag_count += 1;
};
try w.putU16(tag_count);
for (tags) |layer| {
- if (layer.cols == 0) continue;
- if (layer.cells.len != layer.cols) return error.BadValue;
+ if (layer.rows == 0) continue;
+ if (layer.cells.len != @as(usize, layer.cols) * layer.rows) return error.BadValue;
try w.putByte(@intFromEnum(layer.kind));
try w.putU16(layer.id);
try w.putU32(layer.serial);
@@ -746,10 +788,33 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point
try w.putU16(layer.viewport.w);
try w.putU16(layer.viewport.h);
try w.putU16(layer.cols);
+ try w.putU16(layer.rows);
for (layer.bg) |channel| try w.putByte(channel);
- try putCursor(&w, if (layer.cursor) |c| .{ .x = c.col, .y = 0, .bar = c.bar } else null, layer.cols, 1);
+ try putCursor(&w, if (layer.cursor) |c| .{ .x = c.x, .y = c.y, .bar = c.bar } else null, layer.cols, layer.rows);
for (layer.cells) |*cell| try putCell(&w, cell);
}
+ try w.putU16(@intCast(regions.len));
+ for (regions) |region| {
+ try w.putByte(@intFromEnum(region.kind));
+ try w.putByte(region.owner);
+ try w.putU32(region.serial);
+ try w.putU16(region.rect.x);
+ try w.putU16(region.rect.y);
+ try w.putU16(region.rect.w);
+ try w.putU16(region.rect.h);
+ try w.putBool(region.active);
+ try w.putU16(region.thumb_y);
+ try w.putU16(region.thumb_h);
+ try w.putByte(region.notice);
+ try w.putU16(region.clip);
+ }
+ inline for (@typeInfo(pardes.Chrome).@"struct".fields) |field| {
+ const rgb = @field(chrome, field.name);
+ if (field.type == ?[3]u8) {
+ try w.putBool(rgb != null);
+ if (rgb) |value| for (value) |channel| try w.putByte(channel);
+ } else for (rgb) |channel| try w.putByte(channel);
+ }
}
std.mem.writeInt(u32, w.buf[layers_len_at..][0..4], @intCast(w.n - layers_start), .little);
const nruns_at = w.n;
@@ -1820,22 +1885,29 @@ test "detached wire carries compact body rows and exact pointer metrics" {
var cells: [8]pardes.Cell = @splat(.{});
var logical: [12]pardes.Cell = @splat(.{});
logical[10] = .{ .text = "z".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false };
- var layer: pardes.BodyLayer = .{ .pane = 0, .serial = 42, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 4 }, .cols = 2, .rows = 6, .context_rows = 2, .cells = &logical, .cursor = .{ .x = 0, .y = 5, .bar = true } };
+ var layer: pardes.Layer = .{ .id = 0, .serial = 42, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 4 }, .cols = 2, .rows = 6, .context_rows = 2, .cells = &logical, .cursor = .{ .x = 0, .y = 5, .bar = true } };
layer.context_separators.set(0);
var tag_cells: [3]pardes.Cell = @splat(.{});
tag_cells[2] = logical[10];
- const tag: pardes.TagLayer = .{ .kind = .column, .id = 1, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 1 }, .cols = 3, .bg = .{ 11, 22, 33 }, .cells = &tag_cells, .cursor = .{ .col = 2, .bar = true } };
- const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}));
+ const tag: pardes.Layer = .{ .kind = .column, .id = 1, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 1 }, .cols = 3, .rows = 1, .bg = .{ 11, 22, 33 }, .cells = &tag_cells, .cursor = .{ .x = 2, .y = 0, .bar = true } };
+ const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .thumb_y = 1, .thumb_h = 2, .clip = 1 }};
+ const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}, &placed));
defer gpa.free(out);
- const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag});
+ const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 } };
+ const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed, palette);
const packet = (try framed(encoded)).?;
const frame = (try decodeServer(packet.tag, packet.payload)).frame;
- var decoded: [pardes.MAX_PANES]pardes.BodyLayer = @splat(.{});
- var tags: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{});
+ var decoded: [pardes.MAX_PANES]pardes.Layer = @splat(.{});
+ var tags: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{});
defer for (&tags) |*item| gpa.free(item.cells);
defer for (&decoded) |*item| gpa.free(item.cells);
try frame.apply(&cells);
- try frame.applyLayers(gpa, &decoded, &tags);
+ var regions: std.ArrayListUnmanaged(pardes.Region) = .empty;
+ defer regions.deinit(gpa);
+ var chrome: pardes.Chrome = .{};
+ try frame.applyLayers(gpa, &decoded, &tags, &regions, &chrome);
+ try std.testing.expectEqualDeep(placed[0..], regions.items);
+ try std.testing.expectEqualDeep(palette, chrome);
try std.testing.expectEqual(@as(u16, 6), decoded[0].rows);
try std.testing.expect(decoded[0].context_separators.isSet(0));
try std.testing.expect(!decoded[0].context_separators.isSet(1));
@@ -1847,10 +1919,10 @@ test "detached wire carries compact body rows and exact pointer metrics" {
try std.testing.expectEqual(tag.id, tags[0].id);
try std.testing.expectEqual(tag.bg, tags[0].bg);
try std.testing.expectEqual(tag.cols, tags[0].cols);
- try std.testing.expectEqual(@as(u16, 2), tags[0].cursor.?.col);
+ try std.testing.expectEqual(@as(u16, 2), tags[0].cursor.?.x);
try std.testing.expectEqualStrings("z", tags[0].cells[2].grapheme());
- const pointer = layer.hitAt(4, 27, 8, 16, 6, 8).?;
- const tag_pointer = tag.hitAt(11, 8, 8, 16, 4, 8).?;
+ const pointer = layer.bodyHit(4, 27, 8, 16, 6, 8).?;
+ const tag_pointer = tag.tagHit(11, 8, 8, 16, 4, 8).?;
const message: ClientMsg = .{ .event = .{ .mouse = .{ .col = 0, .row = 1, .button = .left, .kind = .press, .body_hit = pointer, .tag_hit = tag_pointer } } };
var mouse_bytes: [256]u8 = undefined;
const mouse_encoded = try encodeClient(&mouse_bytes, message);
@@ -1861,19 +1933,20 @@ test "detached wire carries compact body rows and exact pointer metrics" {
try std.testing.expect(mouse_encoded.len <= clientBound(message));
var truncated = frame;
truncated.layers = frame.layers[0 .. frame.layers.len - 1];
- try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags));
+ try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags, &regions, &chrome));
for (decoded) |item| {
try std.testing.expectEqual(@as(u16, 0), item.rows);
try std.testing.expectEqual(@as(usize, 0), item.cells.len);
try std.testing.expectEqual(@as(usize, 0), item.context_separators.count());
}
for (tags) |item| {
- try std.testing.expectEqual(@as(u16, 0), item.cols);
+ try std.testing.expectEqual(@as(u16, 0), item.rows);
try std.testing.expectEqual(@as(usize, 0), item.cells.len);
}
+ try std.testing.expectEqual(@as(usize, 0), regions.items.len);
const no_layers = try encodeFramePointer(out, 2, 4, null, .arrow, &cells, &cells);
const cleared_packet = (try framed(no_layers)).?;
const cleared = (try decodeServer(cleared_packet.tag, cleared_packet.payload)).frame;
- try cleared.applyLayers(gpa, &decoded, &tags);
+ try cleared.applyLayers(gpa, &decoded, &tags, &regions, &chrome);
try std.testing.expectEqual(@as(u16, 0), decoded[0].rows);
}
diff --git a/src/draw.zig b/src/draw.zig
new file mode 100644
index 00000000..2d757635
--- /dev/null
+++ b/src/draw.zig
@@ -0,0 +1,1264 @@
+//! The frame: how the core builds the canonical Surface every shell draws.
+//! `render` is the whole order, top to bottom: the page, each pane (its
+//! grip, tag and body, and the body layer of its compact rows), the notice
+//! chips, the workspace and column tags, the tag layers, drag overlays, the
+//! debug box, then the presentation's tracks and the character effects.
+const std = @import("std");
+const vaxis = @import("vaxis");
+const pardes = @import("pardes.zig");
+const layout = @import("layout.zig");
+const animation = @import("animation.zig");
+const config = @import("config.zig");
+const panes = @import("panes.zig");
+const modal = @import("modal.zig");
+const tracy = @import("tracy.zig");
+const colors = @import("colors.zig");
+const edit = @import("edit.zig");
+const mouse = @import("mouse.zig");
+const tagline = @import("tagline.zig");
+const body_layer = @import("body_layer.zig");
+const Messages = @import("Messages.zig");
+const Color = @import("surface.zig").Color;
+const FontRole = @import("surface.zig").FontRole;
+const CellStyle = @import("surface.zig").CellStyle;
+const Cell = @import("surface.zig").Cell;
+const Surface = @import("surface.zig").Surface;
+const Pardes = pardes.Pardes;
+const Pane = panes.Pane;
+const Text = panes.Text;
+const Rect = layout.Rect;
+const Region = @import("surface.zig").Region;
+const TagKind = @import("Layer.zig").Kind;
+const pdf_enabled = panes.Pdf.enabled;
+const wordBoundsAtCol = pardes.wordBoundsAtCol;
+const BOX_H = pardes.BOX_H;
+const TAG_GAP = pardes.TAG_GAP;
+const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET;
+const TOPBAR_H = pardes.TOPBAR_H;
+const COLBAR_H = pardes.COLBAR_H;
+const MAX_PANES = pardes.MAX_PANES;
+const MAX_COLS = pardes.MAX_COLS;
+const MAX_TAG_ROWS = pardes.MAX_TAG_ROWS;
+const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE;
+
+/// Where every part of the frame goes, decided once before anything is
+/// painted: the page; each pane's grip, tag, body, rail (with its thumb)
+/// and notice chips; each column's grip and tag; the workspace tag. The
+/// painters below and every shell read these rectangles.
+pub fn place(p: *Pardes, s: *Surface) void {
+ s.nregions = 0;
+ s.addRegion(.{ .kind = .page, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = s.rows } });
+ for (&p.panes, 0..) |*slot, id| {
+ const pane = slot.* orelse continue;
+ Messages.collectNotices(p, pane, p.rects[id], id);
+ const r = p.rects[id];
+ if (r.w <= config.GUTTER or r.h == 0) continue;
+ const owner: u8 = @intCast(id);
+ const active = id == p.active;
+ const tag_y = p.tagTop(pane, r);
+ const body_y = p.bodyTop(pane, r);
+ s.addRegion(.{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } });
+ s.addRegion(.{ .kind = .tag, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = tag_y, .w = r.w - config.GUTTER, .h = pane.tag_rows } });
+ // A folded pane is its tag rows and nothing else.
+ if (pane.collapsed) continue;
+ const body_h = r.h -| pane.tag_rows;
+ s.addRegion(.{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } });
+ var rail: Region = .{ .kind = .rail, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = body_y, .w = config.GUTTER, .h = body_h } };
+ // An image's rail has no thumb; a native PDF's is measured as its
+ // pages are placed (Pdf.draw); every other pane's is here.
+ if (pane.image == null and body_h > 0) {
+ const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{
+ .total = panes.File.nlines(p.gpa, f),
+ .offset = f.scroll,
+ .len = pane.rows,
+ } else if (pane.pdfPage()) |page| .{
+ .total = if (comptime pdf_enabled) pane.pdf.?.page_count else 1,
+ .offset = page,
+ .len = 1,
+ } else blk: {
+ const gsb = panes.Terminal.scrollbar(pane);
+ break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len };
+ };
+ const track_h: usize = body_h;
+ const total = if (sb.total == 0) 1 else sb.total;
+ const len = @max(1, (track_h * @max(1, sb.len)) / total);
+ const pos = (track_h * sb.offset) / total;
+ rail.thumb_y = @intCast(@min(pos, track_h));
+ rail.thumb_h = @intCast(@min(len, track_h - rail.thumb_y));
+ }
+ s.addRegion(rail);
+ // Each notice a chip at the right edge, one to a row in the order
+ // collectNotices chose, as wide as its own message and no wider, so
+ // the text beneath the rest of the row keeps reading as the file.
+ if (r.h <= pane.tag_rows) continue;
+ const tx = r.x + config.GUTTER;
+ const tw = r.w - config.GUTTER;
+ var leader_buf: [16]u8 = undefined;
+ for (0..pane.notices.len) |i| {
+ const text = Messages.noticeText(p, pane, i, &leader_buf);
+ const chip = Messages.noticeCols(p, text, tw);
+ if (chip == 0) continue;
+ pane.notices.left[i] = tx + tw - chip;
+ s.addRegion(.{ .kind = .notice, .owner = owner, .serial = pane.serial, .active = active, .notice = @intCast(i), .rect = .{ .x = tx + tw - chip, .y = body_y + @as(u16, pane.notices.row[i]), .w = chip, .h = BOX_H } });
+ }
+ }
+ const column_rows = p.columnBarHeight();
+ if (column_rows > 0) for (0..p.ncol) |c| {
+ if (p.col_w[c] == 0) continue;
+ const active = if (layout.findPane(p, p.active)) |found| found.col == c else false;
+ const inset = @min(TAG_TEXT_INSET, p.col_w[c]);
+ s.addRegion(.{ .kind = .column_grip, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c], .y = p.topBarHeight(), .w = @min(config.GUTTER, p.col_w[c]), .h = column_rows } });
+ s.addRegion(.{ .kind = .column_tag, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c] + inset, .y = p.topBarHeight(), .w = p.col_w[c] - inset, .h = column_rows } });
+ };
+ const active_col = if (layout.findPane(p, p.active)) |found| found.col else null;
+ for (0..p.ncol) |c| {
+ if (p.col_w[c] == 0) continue;
+ s.addRegion(.{ .kind = .column, .owner = @intCast(c), .serial = p.col_serial[c], .active = active_col == c, .rect = .{ .x = p.col_x[c], .y = p.topBarHeight(), .w = p.col_w[c], .h = s.rows -| p.topBarHeight() } });
+ }
+ if (p.settings.workspace_tag)
+ s.addRegion(.{ .kind = .workspace_tag, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = p.topBarHeight() } });
+ // A column carried to the window's right edge shows where it lands as a
+ // rail in the last cell: the layers under it stop a cell short.
+ if (p.drag == .column_move) rail: {
+ const d = p.drag.column_move;
+ const source = layout.columnBySerial(p, d.serial) orelse break :rail;
+ const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) {
+ .stay => break :rail,
+ .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target],
+ .edge => |x| x,
+ };
+ if (edge != s.cols) break :rail;
+ for (s.regions[0..s.nregions]) |*region| switch (region.kind) {
+ .body, .tag, .notice, .column_tag => if (region.rect.x + region.rect.w == s.cols) {
+ region.clip = 1;
+ },
+ else => {},
+ };
+ }
+}
+
+pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
+ panes.File.refreshHighlights(p);
+ const s = &p.surface;
+ const ncells = @as(usize, p.screen_w) * p.screen_h;
+ if (s.cells.len != ncells) {
+ const cells = try p.gpa.alloc(Cell, ncells);
+ p.gpa.free(s.cells);
+ s.cells = cells;
+ }
+ s.cols = p.screen_w;
+ s.rows = p.screen_h;
+ s.cursor = null;
+ s.pointer_shape = .arrow;
+ if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| {
+ if (preview.probe.link != null and preview.pane < p.panes.len) {
+ if (p.panes[preview.pane]) |pane| {
+ if (pane.serial == preview.serial and panes.Pdf.paneNativeReady(p, pane))
+ s.pointer_shape = .link;
+ }
+ }
+ };
+ // A look-hover preview means the pointed word is a real Look or Exec
+ // operand: the same plumbing a click would act on. Hosts that model
+ // the pointer per-text (the macOS I-beam) read this as "target" and
+ // afford it the arrow instead.
+ if (p.look_hover_preview != null) s.pointer_shape = .target;
+ for (&s.body_layers) |*layer| layer.rows = 0;
+ for (&s.tag_layers) |*layer| layer.rows = 0;
+ s.nimages = 0;
+ s.npanel_tracks = 0;
+ s.previous_cells = &.{};
+ s.previous_body_layers = &.{};
+ s.previous_tag_layers = &.{};
+ s.previous_regions = &.{};
+ s.cell_diffs = &.{};
+ const chrome = p.chromeTheme();
+ s.chrome = .{
+ .page = p.theme().bg,
+ .fg = p.theme().fg,
+ .border = chrome.border,
+ .topbar_rule = config.gui_topbar_pane_border_rgb orelse chrome.border,
+ .tag_bg = chrome.tag_bg,
+ .tag_focus_bg = if (p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg,
+ .tag_fg = chrome.tag_fg,
+ .scroll_track = chrome.scroll_track,
+ .scroll_thumb = chrome.scroll_thumb,
+ };
+ place(p, s);
+ // Separators have their own role; a scrollbar need not frame the page.
+ const whole = s.regions[0].rect;
+ s.fill(whole.x, whole.y, whole.w, whole.h, .{ .bg = .{ .rgb = chrome.border } });
+
+ for (&p.panes, 0..) |*slot, id| {
+ const pane = slot.* orelse continue;
+ pane.body_rows = 0;
+ pane.body_visible_rows = 0;
+ pane.context_row_limit = null;
+ s.body_layers[id].rows = 0;
+ try renderPane(p, s, arena, pane, p.rects[id], id, id == p.active);
+ }
+ // Each notice once, as a tag layer, and the grid's chip a copy of it.
+ for (s.regionList()) |region| {
+ if (region.kind == .notice) try joinNotice(p, s, arena, region);
+ }
+
+ try paintHeaders(p, s, arena);
+
+ switch (p.drag) {
+ .border_v => |d| {
+ if (d.cur_x < s.cols) {
+ var row: u16 = p.topBarHeight();
+ while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎");
+ s.addRegion(.{ .kind = .guide, .rect = .{ .x = d.cur_x, .y = p.topBarHeight(), .w = 1, .h = s.rows -| p.topBarHeight() } });
+ }
+ if (d.corner) |k| if (d.cur_y < s.rows) {
+ const first = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1;
+ const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1;
+ var col = first;
+ while (col <= end and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
+ if (col > first) s.addRegion(.{ .kind = .guide, .rect = .{ .x = first, .y = d.cur_y, .w = col - first, .h = 1 } });
+ };
+ },
+ // A column that went away mid-drag has nothing to guide.
+ .border_h => |d| if (d.cur_y < s.rows and d.col < p.ncol and p.col_x[d.col] < s.cols) {
+ const end = @min(s.cols, p.col_x[d.col] + p.col_w[d.col]);
+ var col = p.col_x[d.col];
+ while (col < end) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
+ s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[d.col], .y = d.cur_y, .w = end - p.col_x[d.col], .h = 1 } });
+ },
+ .column_move => |d| rail: {
+ const source = layout.columnBySerial(p, d.serial) orelse break :rail;
+ if (s.cols == 0) break :rail;
+ // Where the column's left edge would land, reordered or not.
+ const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) {
+ .stay => break :rail,
+ .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target],
+ .edge => |x| x,
+ };
+ // The layers under an outside rail stop a cell short of it:
+ // `place` clipped them.
+ // The rail runs in the seam cell left of the edge, where a
+ // border drag's does: the edge cell itself is a gutter, which
+ // the GUI paints as a scroll rail that swallowed the dashes.
+ // At the window's left edge there is no seam, so it runs just
+ // past that scroll rail instead. It takes the lit grip's
+ // color: this is where the grabbed column lands.
+ const x = @min(if (edge == 0) config.GUTTER - 1 else edge - 1, s.cols -| 1);
+ var row: u16 = p.topBarHeight();
+ // A guide region for each run of it the tags do not break.
+ var run: u16 = row;
+ while (row < s.rows) : (row += 1) {
+ // Past the left edge's scroll rail the rail would cut
+ // through column 0's grip and pane boxes: leave them whole.
+ if (edge == 0 and s.at(x, row).style.font_role == .tagline) {
+ if (row > run) s.addRegion(.{ .kind = .guide, .rect = .{ .x = x, .y = run, .w = 1, .h = row - run } });
+ run = row + 1;
+ continue;
+ }
+ s.overlayDash(x, row, "╎");
+ s.at(x, row).style.fg = .{ .rgb = chrome.column_box };
+ }
+ if (row > run) s.addRegion(.{ .kind = .guide, .rect = .{ .x = x, .y = run, .w = 1, .h = row - run } });
+ },
+ .move => |d| if (d.cur_x < s.cols) {
+ if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| if (placement.row < s.rows and p.col_x[placement.preview_col] + p.col_w[placement.preview_col] <= s.cols) {
+ var col: u16 = p.col_x[placement.preview_col];
+ while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) {
+ s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } });
+ }
+ s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } });
+ s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[placement.preview_col], .y = placement.row, .w = p.col_w[placement.preview_col], .h = 1 } });
+ s.addRegion(.{ .kind = .guide, .rect = .{ .x = d.cur_x, .y = placement.row, .w = 1, .h = 1 } });
+ };
+ },
+ // A hover left over from a bigger window points past this one.
+ .none, .select => if (p.pointer_inside and p.hover_row < s.rows and p.hover_col < s.cols) {
+ // hover hints on a pane's own trailing edge
+ var c: usize = 0;
+ while (c + 1 < p.ncol) : (c += 1) {
+ if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) {
+ var row: u16 = p.topBarHeight();
+ while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎");
+ s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.hover_col, .y = p.topBarHeight(), .w = 1, .h = s.rows -| p.topBarHeight() } });
+ }
+ }
+ for (0..p.ncol) |cc| {
+ if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue;
+ // the same seam the h-handle hit test picks (see there)
+ if (mouse.seamIdxAt(p, cc, p.hover_row) != null) {
+ var col = p.col_x[cc];
+ while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
+ s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[cc], .y = p.hover_row, .w = p.col_w[cc], .h = 1 } });
+ }
+ }
+ for (0..p.ncol -| 1) |cn| {
+ if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue;
+ if (mouse.seamIdxAt(p, cn, p.hover_row) != null) continue;
+ if (mouse.seamIdxAt(p, cn + 1, p.hover_row) == null) continue;
+ var col = p.col_x[cn + 1];
+ while (col < p.col_x[cn + 1] + p.col_w[cn + 1]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
+ s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[cn + 1], .y = p.hover_row, .w = p.col_w[cn + 1], .h = 1 } });
+ }
+ },
+ }
+
+ // debug overlay: a small stats box drawn last, top-right ("Debug")
+ if (p.settings.debug) if (p.panes[p.active]) |at| {
+ var nlive: usize = 0;
+ for (p.panes) |slot| {
+ if (slot != null) nlive += 1;
+ }
+ var sb_off: usize = undefined;
+ var sb_total: usize = undefined;
+ if (at.file) |*f| {
+ sb_off = f.scroll;
+ sb_total = panes.File.nlines(p.gpa, f);
+ } else if (at.pdfPage()) |page| {
+ sb_off = page;
+ sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0;
+ } else {
+ const sb = panes.Terminal.scrollbar(at);
+ sb_off = sb.offset;
+ sb_total = sb.total;
+ }
+ const effective_font = p.settings.font.effective_name.get();
+ const font_name = if (effective_font.len == 0) "default" else effective_font;
+ var ov_buf: [256]u8 = undefined;
+ const text = std.fmt.bufPrint(
+ &ov_buf,
+ "pardes resizes={d}\ntheme {s}\nfont {s}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}",
+ .{ p.resize_count, p.theme().name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta },
+ ) catch "";
+ // wide enough for the longest theme name compiled in, which is the
+ // only row here whose width is not ours to choose
+ const ow: u16 = 38;
+ const oh: u16 = 10;
+ const ox: u16 = if (s.cols > ow) s.cols - ow else 0;
+ if (ox + ow <= s.cols and p.topBarHeight() + oh <= s.rows) {
+ s.addRegion(.{ .kind = .debug, .rect = .{ .x = ox, .y = p.topBarHeight(), .w = ow, .h = oh } });
+ // vaxis single_rounded border ring, default style
+ s.set(ox, p.topBarHeight(), "╭", .{});
+ s.set(ox + ow - 1, p.topBarHeight(), "╮", .{});
+ s.set(ox, p.topBarHeight() + oh - 1, "╰", .{});
+ s.set(ox + ow - 1, p.topBarHeight() + oh - 1, "╯", .{});
+ var bx = ox + 1;
+ while (bx < ox + ow - 1) : (bx += 1) {
+ s.set(bx, p.topBarHeight(), "─", .{});
+ s.set(bx, p.topBarHeight() + oh - 1, "─", .{});
+ }
+ var by = p.topBarHeight() + 1;
+ while (by < p.topBarHeight() + oh - 1) : (by += 1) {
+ s.set(ox, by, "│", .{});
+ s.set(ox + ow - 1, by, "│", .{});
+ }
+ s.fill(ox + 1, p.topBarHeight() + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ var lines = std.mem.splitScalar(u8, text, '\n');
+ var ly: u16 = p.topBarHeight() + 1;
+ while (lines.next()) |line| : (ly += 1) {
+ if (ly >= p.topBarHeight() + oh - 1) break;
+ _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = chrome.tag_fg } });
+ }
+ }
+ };
+ try p.presentation.submit(p, s);
+ return composeAsciiTransitions(p, arena, s);
+}
+
+pub fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface {
+ _ = p;
+ if (!canonical.hasPanelDiff()) return canonical;
+ var presented: ?*Surface = null;
+ for (canonical.panelTracks()) |track| {
+ if (!track.effect.composedByCore() or track.phase == .closing) continue;
+ const area = animation.CellArea.of(track.to);
+ const col_end = @min(canonical.cols, area.x0 +| area.cols);
+ const row_end = @min(canonical.rows, area.y0 +| area.rows);
+ var row: u16 = area.y0;
+ while (row < row_end) : (row += 1) {
+ var col: u16 = area.x0;
+ while (col < col_end) : (col += 1) {
+ const index = @as(usize, row) * canonical.cols + col;
+ const composed = composedCell(canonical, track, area, col, row, index) orelse
+ continue;
+ if (composed.visuallyEqual(&canonical.cells[index])) continue;
+ if (presented == null) {
+ const next = try arena.create(Surface);
+ next.* = canonical.*;
+ next.cells = try arena.dupe(Cell, canonical.cells);
+ presented = next;
+ }
+ presented.?.cells[index] = composed;
+ }
+ }
+ }
+ return presented orelse canonical;
+}
+
+fn composedCell(
+ canonical: *const Surface,
+ track: animation.Track,
+ area: animation.CellArea,
+ col: u16,
+ row: u16,
+ index: usize,
+) ?Cell {
+ if (track.effect == .ascii) {
+ const diff = switch (canonical.cell_diffs[index]) {
+ .ascii => |diff| diff,
+ .unchanged, .visual => return null,
+ };
+ const byte = diff.byteAt(track.frame);
+ if (byte == diff.to) return null;
+ if (track.frame == 0) return canonical.previous_cells[index];
+ return withByte(canonical.cells[index], byte);
+ }
+ return switch (animation.charSource(track, col - area.x0, row - area.y0, area)) {
+ .old => canonical.previous_cells[index],
+ .byte => |byte| withByte(canonical.cells[index], byte),
+ // Churn belongs on the glyph, not on the pane's empty space, and it
+ // borrows whichever side of the diff actually has one.
+ .churn => |byte| blk: {
+ const new_cell = &canonical.cells[index];
+ if (hasGlyph(new_cell)) break :blk withByte(new_cell.*, byte);
+ const old_cell = &canonical.previous_cells[index];
+ if (hasGlyph(old_cell)) break :blk withByte(old_cell.*, byte);
+ break :blk old_cell.*;
+ },
+ .at => |offset| blk: {
+ const source_col = @as(i32, col) + offset.cols;
+ const source_row = @as(i32, row) + offset.rows;
+ // A glyph whose source lies outside the pane has not arrived
+ // yet: the frozen old cell is the only truthful thing under it.
+ if (source_col < area.x0 or source_row < area.y0 or
+ source_col >= @as(i32, area.x0) + area.cols or
+ source_row >= @as(i32, area.y0) + area.rows or
+ source_col >= canonical.cols or source_row >= canonical.rows)
+ break :blk canonical.previous_cells[index];
+ const source = @as(usize, @intCast(source_row)) * canonical.cols +
+ @as(usize, @intCast(source_col));
+ break :blk canonical.cells[source];
+ },
+ };
+}
+
+fn withByte(cell: Cell, byte: u8) Cell {
+ var out = cell;
+ out.text[0] = byte;
+ out.len = 1;
+ out.default = false;
+ return out;
+}
+
+/// Whether a cell shows a glyph a character effect can work on. An unpainted
+/// cell and a space are both blank space, whatever style they carry.
+fn hasGlyph(cell: *const Cell) bool {
+ return !cell.default and !(cell.len == 1 and cell.text[0] == ' ');
+}
+
+fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void {
+ const tz = tracy.zone(@src(), "renderPane");
+ defer tz.end();
+ if (r.w <= config.GUTTER or r.h == 0) return;
+ const th = p.theme();
+ const chrome = p.chromeTheme();
+ const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter
+ const tw = r.w - config.GUTTER;
+ const tag_y = p.tagTop(pane, r);
+ const body_y = p.bodyTop(pane, r);
+ const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default;
+
+ // text area resets to terminal-default cells (vaxis clear semantics);
+ // light themes paint the page over it.
+ {
+ // Two full passes over every cell in the pane, every frame.
+ const tz_clear = tracy.zone(@src(), "paneClear");
+ defer tz_clear.end();
+ s.clearRect(tx, r.y, tw, r.h);
+ if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } });
+ }
+
+ const box_bg = if (active) chrome.box else chrome.box_dim;
+ const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10;
+ const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff };
+ const box_style: CellStyle = .{
+ .fg = .{ .rgb = box_ink },
+ .bg = .{ .rgb = box_bg },
+ // Grips keep physical grid dimensions, independently of tag text.
+ .font_role = .tagline,
+ };
+ s.fill(r.x, tag_y, config.GUTTER, pane.tag_rows, box_style);
+ s.set(r.x, tag_y, switch (pane.body.mode) {
+ .normal => config.tag_normal,
+ .insert => config.tag_insert,
+ .tty => config.tag_tty,
+ }, box_style);
+
+ try joinPaneTag(p, s, arena, pane, r, id, active);
+
+ // Folded panes retain their body state, but neither body pixels nor
+ // its cursor belong outside the one-row tag rectangle.
+ if (pane.collapsed) return;
+
+ const rail = s.region(.rail, id).?;
+ if (comptime pdf_enabled)
+ if (pane.hasPdf() and panes.Pdf.draw(p, s, pane, r, id, tx, tw, rail)) return;
+
+ if (pane.image) |*iv| {
+ // Below the notice chips: a picture is drawn after the cells (the
+ // GUI's image pass, kitty's z-order), so a chip over it would be
+ // painted out. Text gets the overlay; a picture gives up the rows.
+ const shown = pane.notices.span();
+ const image_h = (r.h -| pane.tag_rows) -| shown;
+ panes.Image.draw(p, s, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h);
+ // thumbless, but the same one column as the real scrollbar below —
+ // that is the whole point of drawing it, and like that one it runs
+ // past the notice bands so the gutter has no notch in it
+ s.fill(rail.rect.x, rail.rect.y, 1, rail.rect.h, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ s.fill(rail.rect.x + 1, rail.rect.y, 1, rail.rect.h, .{ .bg = pane_bg });
+ return;
+ }
+
+ try body_layer.joinBody(p, s, arena, pane, r, id, active, rail.*);
+}
+
+/// The workspace and column tags: each line a tag layer, and the grid's
+/// rows copies of those; the column grips, which are the grid's alone.
+pub fn paintHeaders(p: *Pardes, s: *Surface, arena: std.mem.Allocator) !void {
+ const chrome = p.chromeTheme();
+ // global tagbar: full width, top rows — unless a native shell has
+ // taken its commands over, in which case the rows are not ours to draw
+ tagline.revealHeader(p);
+ if (s.region(.workspace_tag, Region.none)) |region| {
+ const top = if (p.header_focus and p.header_column == null) p.header_top else 0;
+ const layer = &s.tag_layers[MAX_PANES + MAX_COLS];
+ try renderHeaderLayer(p, arena, layer, .workspace, 0, region.rect, tagline.topbar(p), top, chrome.tag_bg, chrome.tag_fg);
+ joinHeaderRows(s, layer, chrome.tag_fg);
+ }
+ const column_rows = p.columnBarHeight();
+ if (column_rows > 0) for (0..p.ncol) |c| {
+ const region = s.region(.column_tag, c) orelse continue;
+ const active = if (layout.findPane(p, p.active)) |found| found.col == c and p.settings.focus_tint else false;
+ const bg = if (active) chrome.tag_active_bg else chrome.tag_bg;
+ const fg = if (active) chrome.tag_active_fg else chrome.tag_fg;
+ if (p.col_x[c] < s.cols) {
+ // A grip is muted until it is grabbed; then it lights, as a
+ // pane's box does for the active pane, so the drag shows what it
+ // holds.
+ const grip = @min(config.GUTTER, p.col_w[c]);
+ const inset = @min(TAG_TEXT_INSET, p.col_w[c]);
+ const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c;
+ const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim;
+ const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10;
+ const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 };
+ s.fill(p.col_x[c], region.rect.y, grip, column_rows, .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline });
+ s.fill(p.col_x[c] + grip, region.rect.y, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
+ }
+ const top = if (p.header_focus and p.header_column == c) p.header_top else 0;
+ const layer = &s.tag_layers[MAX_PANES + c];
+ try renderHeaderLayer(p, arena, layer, .column, @intCast(c), region.rect, tagline.columnTag(p, c), top, bg, fg);
+ if (p.col_x[c] < s.cols) joinHeaderRows(s, layer, fg);
+ // Laid out at its full width; only shown short of the rail.
+ layer.viewport.w -|= region.clip;
+ };
+}
+
+/// Copy a header's layer into the grid under it, row by row, and its caret.
+fn joinHeaderRows(s: *Surface, layer: *const pardes.Layer, fg: [3]u8) void {
+ const v = layer.viewport;
+ const w = @min(v.w, s.cols -| v.x);
+ for (0..layer.rows) |line| joinCells(s, v.x, v.y + @as(u16, @intCast(line)), layer.cells[line * layer.cols ..][0..layer.cols], 0, w, .{ .rgb = fg });
+ if (layer.cursor) |caret| {
+ if (caret.x < v.w) s.cursor = .{ .x = v.x + caret.x, .y = v.y + caret.y, .bar = caret.bar };
+ }
+}
+
+/// Copy `w` cells of a layer row, from `from`, into the grid at (x, y) --
+/// the one place the grid gets a tag's, a header's or a notice's cells. A
+/// wide glyph cut by either end is left as the band's blank, the way the
+/// grid's own print never drew half of one: no head without its spacer,
+/// no spacer without its head. `ink` is the band's text colour, which a
+/// blank cell does not carry.
+fn joinCells(s: *Surface, x: u16, y: u16, cells: []const Cell, from: usize, w: u16, ink: Color) void {
+ // A tagline wider than the body's cells can lay out fewer than `w`.
+ const n: u16 = @intCast(@min(w, cells.len -| from));
+ if (n == 0 or y >= s.rows) return;
+ const row = s.cells[@as(usize, y) * s.cols ..][0..s.cols];
+ @memcpy(row[x..][0..n], cells[from..][0..n]);
+ const blank = struct {
+ fn of(cell: *Cell, band_ink: Color) void {
+ cell.text[0] = ' ';
+ cell.len = 1;
+ if (std.meta.eql(cell.style.fg, band_ink)) cell.style.fg = .default;
+ }
+ }.of;
+ const last = from + n - 1;
+ if (last + 1 < cells.len and !cells[last + 1].default and cells[last + 1].len == 0) blank(&row[x + n - 1], ink);
+ if (!cells[from].default and cells[from].len == 0) blank(&row[x], ink);
+}
+
+fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } {
+ if (pane.isTerminal()) {
+ // `tag` is the first row; a prefix longer than it ran to a second.
+ var words = std.mem.tokenizeAny(u8, tag[@min(prefix_len, tag.len)..], " \t\r\n");
+ while (words.next()) |word| {
+ if (!std.mem.eql(u8, word, "Tty") and !std.mem.startsWith(u8, word, "Tty+")) continue;
+ const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr);
+ return .{ .lo = lo, .hi = lo + word.len };
+ }
+ return null;
+ }
+ var start: usize = 0;
+ const name = if (pane.file) |file|
+ if (pane.prompt == .name) pane.input.own orelse file.path else file.path
+ else if (pane.image) |state| blk: {
+ // Image tags put renderer controls before the path.
+ start = std.mem.lastIndexOf(u8, tag[0..@min(prefix_len, tag.len)], state.path) orelse return null;
+ break :blk state.path;
+ } else if (comptime pdf_enabled) blk: {
+ break :blk if (pane.pdf) |state| state.path else return null;
+ } else return null;
+ const basename = if (std.mem.lastIndexOfScalar(u8, name, '/')) |slash| slash + 1 else 0;
+ if (basename == name.len) return null;
+ return .{ .lo = start + basename, .hi = start + name.len };
+}
+
+/// A header or a notice band as a tag layer: `viewport.h` rows, line `top`
+/// of `text` and on, laid out at the tagline's pitch -- as wide as its
+/// widest line and never narrower than its viewport.
+fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer, kind: TagKind, id: u16, viewport: Rect, text: []const u8, top: usize, bg: [3]u8, fg: [3]u8) !void {
+ _ = arena;
+ if (viewport.w == 0 or viewport.h == 0) {
+ // No layer, and nothing of an earlier frame's left in it.
+ layer.rows = 0;
+ layer.viewport = viewport;
+ layer.cursor = null;
+ return;
+ }
+ var widest: usize = 0;
+ for (0..viewport.h) |line| widest = @max(widest, panes.File.displayWidth(modal.lineSlice(text, line + top)));
+ const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), widest + 1)));
+ const count = @as(usize, columns) * viewport.h;
+ if (layer.cells.len != count) layer.cells = try p.gpa.realloc(layer.cells, count);
+ layer.kind = kind;
+ layer.id = id;
+ layer.serial = if (kind == .column) p.col_serial[id] else 0;
+ layer.viewport = viewport;
+ layer.cols = columns;
+ layer.rows = viewport.h;
+ layer.context_rows = 0;
+ layer.bg = bg;
+ layer.cursor = null;
+ layer.slide = 0;
+ layer.fade = 1;
+ // A notice band carries a PANE id, not a column index, so it must not
+ // answer to the column being edited -- that would scroll and select
+ // its text with the column tag's offsets and give it a second caret.
+ const editing: ?*Text = if (p.header_focus and switch (kind) {
+ .workspace => p.header_column == null,
+ .column => p.header_column == id,
+ .pane, .notice, .body => false,
+ }) tagline.focusedHeader(p) else null;
+ const scroll = if (editing != null) p.header_scroll else 0;
+ for (0..viewport.h) |index| {
+ const line: u16 = @intCast(index);
+ const shown = modal.lineSlice(text, index + top);
+ var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells[index * columns ..][0..columns] };
+ surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
+ // The scroll is the caret's line's: a shorter line shows nothing.
+ _ = surface.print(0, 0, columns, shown[@min(shown.len, panes.File.rawAtDisplay(shown, scroll))..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline });
+ // The word under the pointer, a Look or Exec target: on a header even
+ // while it is edited, on a notice, and on the grid's copy of either
+ // (joined from this); not on a column a carried column's pointer is
+ // aiming at a place over.
+ const hovered = p.pointer_inside and !(kind == .column and p.drag == .column_move);
+ if (hovered) {
+ const tag_hit = p.pointer_tag_hit;
+ const col: ?usize = if (tag_hit) |pointer|
+ (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null)
+ else if (p.hover_row == viewport.y + line and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w)
+ p.hover_col - viewport.x
+ else
+ null;
+ if (col) |hover| if (wordBoundsAtCol(shown, panes.File.rawAtDisplay(shown, hover + scroll))) |bounds| {
+ var cell = panes.File.rawDisplayCol(shown, bounds.lo) -| scroll;
+ const end = panes.File.rawDisplayCol(shown, bounds.hi) -| scroll;
+ while (cell < end and cell < columns) : (cell += 1) {
+ surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg };
+ surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg };
+ }
+ };
+ }
+ if (editing) |t| {
+ if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| {
+ var col = panes.File.rawDisplayCol(shown, span.lo) -| scroll;
+ const end = panes.File.rawDisplayCol(shown, span.hi) -| scroll;
+ while (col < end and col < columns) : (col += 1) {
+ surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg };
+ surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg };
+ }
+ }
+ const caret = panes.File.rawDisplayCol(shown, @min(@as(usize, @intCast(@max(0, t.cur_col))), shown.len)) -| scroll;
+ const asking = if (p.panes[p.active]) |active| active.prompt != .none else false;
+ if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .x = @intCast(caret), .y = line, .bar = t.mode == .insert };
+ }
+ }
+}
+
+/// A pane's tag rows, painted once into their layer, a row to a line, and
+/// copied into the grid; the grid's caret where the layer has it.
+fn joinPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void {
+ const capacity = p.tagCapacity(r.w -| TAG_TEXT_INSET);
+ // Too narrow for a layer: only the grid's gap cell.
+ if (r.w <= TAG_TEXT_INSET) return paintPaneTag(p, s, arena, pane, r, id, active, capacity);
+ const chrome = p.chromeTheme();
+ const region = s.region(.tag, id).?;
+ const tag = try tagline.tagText(p, arena, pane);
+ const rows = pane.tag_rows;
+ // As wide as the widest line it shows, and never narrower than its
+ // viewport.
+ var widest: usize = 0;
+ var split = std.mem.splitScalar(u8, tag, '\n');
+ for (0..pane.tag_top) |_| _ = split.next();
+ for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse ""));
+ const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(capacity, widest + 1)));
+ const pitch = @as(usize, columns) + TAG_TEXT_INSET;
+ const storage = try arena.alloc(Cell, pitch * rows);
+ @memset(storage, .{});
+ var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage };
+ try paintPaneTag(p, &temporary, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, active, capacity);
+ const tag_fg: Color = .{ .rgb = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg };
+ // One layer, a row to each line the tag shows.
+ const layer = &s.tag_layers[id];
+ const count = @as(usize, columns) * rows;
+ if (layer.cells.len != count) layer.cells = try p.gpa.realloc(layer.cells, count);
+ layer.kind = .pane;
+ layer.id = @intCast(id);
+ layer.serial = pane.serial;
+ layer.viewport = .{ .x = region.rect.x + TAG_GAP, .y = region.rect.y, .w = region.rect.w - TAG_GAP - region.clip, .h = rows };
+ layer.cols = columns;
+ layer.rows = rows;
+ layer.context_rows = 0;
+ layer.bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
+ layer.slide = 0;
+ layer.fade = 1;
+ layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor|
+ .{ .x = cursor.x - TAG_TEXT_INSET, .y = cursor.y, .bar = cursor.bar }
+ else
+ null;
+ for (0..rows) |line| {
+ @memcpy(layer.cells[line * columns ..][0..columns], storage[line * pitch + TAG_TEXT_INSET ..][0..columns]);
+ // The grid's row: the gap cell and the text, as far as the pane.
+ joinCells(s, region.rect.x, region.rect.y + @as(u16, @intCast(line)), storage[line * pitch ..][0..pitch], config.GUTTER, region.rect.w, tag_fg);
+ }
+ if (temporary.cursor) |cursor| if (cursor.x - TAG_TEXT_INSET < r.w - TAG_TEXT_INSET) {
+ s.cursor = .{ .x = r.x + cursor.x, .y = region.rect.y + cursor.y, .bar = cursor.bar };
+ };
+}
+
+/// A notice chip, painted once as a tag layer at the tagline's pitch, and the
+/// grid's chip a copy of its right end, where its text is.
+fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) !void {
+ const chrome = p.chromeTheme();
+ const th = p.theme();
+ const id: usize = region.owner;
+ const pane = p.panes[id].?;
+ const i: usize = region.notice;
+ const kind = pane.notices.kinds[i];
+ const r = p.rects[id];
+ const chip = region.rect.w;
+ var leader_buf: [16]u8 = undefined;
+ const text = Messages.noticeText(p, pane, i, &leader_buf);
+ // A message coming or going blends toward the page it sits on; a grid
+ // cannot move it by less than a row, a pixel shell slides it too.
+ const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{};
+ const page = th.bg orelse chrome.tag_bg;
+ const bg = Messages.blendRgb(chrome.tag_bg, page, motion.alpha);
+ // Where an exec was sent to a REPL reads in the tag's name tint, set
+ // apart from what the editor says.
+ const ink = if (kind == .message and pane.msg_sent) chrome.tag_name_fg else chrome.tag_fg;
+ const fg = Messages.blendRgb(ink, page, motion.alpha);
+ // Nothing to say, or too narrow for a layer: a blank chip on the grid.
+ if (text.len == 0 or r.w <= TAG_TEXT_INSET) {
+ s.fill(region.rect.x, region.rect.y, chip, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
+ if (text.len == 0) return;
+ const printed = Messages.printRight(s, region.rect.x, region.rect.y, chip -| 1, text, .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline });
+ if (kind != .prompt or id != p.active or pane.prompt == .del_side or pane.prompt == .repl_choice) return;
+ const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
+ if (prompt_col < printed.dropped) return;
+ s.cursor = .{ .x = @intCast(printed.left + (prompt_col - printed.dropped)), .y = region.rect.y, .bar = pane.input.mode == .insert };
+ return;
+ }
+ // Right aligned inside the chip, a blank cell short of its edge: a
+ // prompt's caret sits AFTER its last character the moment it opens, and
+ // flush right would put it past the pane and off the grid. Wider than
+ // the chip, the text loses its head: the end is the part that says
+ // something.
+ const capacity = p.tagCapacity(chip);
+ const room = capacity -| 1;
+ const shown = panes.File.displayWidth(text);
+ var kept = text;
+ if (shown > room) {
+ var start = panes.File.rawAtDisplay(text, shown - room);
+ if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1);
+ kept = text[start..];
+ }
+ const pad = room -| panes.File.displayWidth(kept);
+ const line = try arena.alloc(u8, pad + kept.len);
+ @memset(line[0..pad], ' ');
+ @memcpy(line[pad..], kept);
+ const layer = &s.tag_layers[NOTICE_LAYER_BASE + id * Pane.Notices.max + i];
+ try renderHeaderLayer(p, arena, layer, .notice, @intCast(id), region.rect, line, 0, bg, fg);
+ layer.slide = motion.slide;
+ layer.fade = motion.alpha;
+ // The caret rides the band, where the prompt's glyphs are.
+ if (kind == .prompt and id == p.active and pane.prompt != .del_side and pane.prompt != .repl_choice) caret: {
+ const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
+ const dropped = shown - panes.File.displayWidth(kept);
+ if (typed < dropped) break :caret;
+ layer.cursor = .{ .x = @intCast(pad + typed - dropped), .y = 0, .bar = pane.input.mode == .insert };
+ }
+ // The grid shows the chip's last cells of the band, where the text ends
+ // (all of it where the tagline's pitch is the grid's).
+ const skip = capacity -| chip;
+ joinCells(s, region.rect.x, region.rect.y, layer.cells[0..layer.cols], skip, chip, .{ .rgb = fg });
+ // The band's padding before the text is the chip's fill, which carries
+ // no ink of its own (the layer prints it as spaces in the band's ink).
+ // A notice wider than its band has less padding than the band hides.
+ for (skip..@max(skip, @min(pad, skip + chip))) |col| s.at(region.rect.x + @as(u16, @intCast(col - skip)), region.rect.y).style.fg = .default;
+ if (layer.cursor) |caret| {
+ if (caret.x >= skip) s.cursor = .{ .x = region.rect.x + caret.x - @as(u16, @intCast(skip)), .y = region.rect.y, .bar = caret.bar };
+ }
+ // Laid out at its full width; only shown short of the rail.
+ layer.viewport.w -|= region.clip;
+}
+
+pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void {
+ const th = p.theme();
+ const chrome = p.chromeTheme();
+ const tx = r.x + @min(TAG_TEXT_INSET, r.w);
+ const tw = r.w -| TAG_TEXT_INSET;
+ const tag_y = p.tagTop(pane, r);
+ const body_y = p.bodyTop(pane, r);
+ const rows = pane.tag_rows;
+ const page_rgb = th.bg orelse th.tag_bg;
+ // Focus is visible along the tag without changing its live text grid.
+ const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
+ const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg;
+ const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline };
+ // Padding belongs to the same font row but keeps its default ink: a
+ // role must not manufacture a foreground color for blank cells.
+ s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline });
+ s.fill(tx, tag_y, tw, rows, .{
+ .bg = .{ .rgb = tag_bg },
+ .font_role = .tagline,
+ });
+ const whole = try tagline.tagText(p, arena, pane);
+ const tail = tagline.curTail(pane);
+ const prefix_len = whole.len - tail.len;
+ // A row per line shown, from `tag_top`: the tag's first line carries
+ // the prefix, the rest are the tag's own text alone.
+ const top = pane.tag_top;
+ var lines: [MAX_TAG_ROWS][]const u8 = @splat("");
+ var split = std.mem.splitScalar(u8, whole, '\n');
+ for (0..top) |_| _ = split.next();
+ for (lines[0..rows]) |*line| line.* = split.next() orelse "";
+ // The keyboard's caret, as a row and a byte offset in that row's
+ // line: in the tag's own text when the tag has the keyboard, in the
+ // path while a name is drafted.
+ const Caret = struct { row: usize, at: usize };
+ const caret: ?Caret = if (!active)
+ null
+ else if (pane.prompt == .name)
+ .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) }
+ else if (pane.focus == .tag and pane.prompt == .none and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: {
+ const row: usize = @intCast(pane.tag.cur_row - top);
+ break :caret .{ .row = row, .at = @min(lines[row].len, @as(usize, @intCast(@max(0, pane.tag.cur_col)))) };
+ } else null;
+ if (caret) |at| {
+ const line = lines[at.row];
+ const col = panes.File.rawDisplayCol(line, at.at);
+ var scroll: usize = pane.tag_scroll;
+ if (col < scroll) scroll = col;
+ if (col >= scroll + capacity) scroll = col - capacity + 1;
+ var raw = panes.File.rawAtDisplay(line, scroll);
+ scroll = panes.File.rawDisplayCol(line, raw);
+ if (col >= scroll + capacity) {
+ raw = modal.nextGrapheme(line, raw);
+ scroll = panes.File.rawDisplayCol(line, raw);
+ }
+ // A line past a u16 of columns keeps its caret off the band.
+ pane.tag_scroll = @intCast(@min(scroll, std.math.maxInt(u16)));
+ } else if (pane.focus != .tag) pane.tag_scroll = 0;
+ for (lines[0..rows], 0..) |line, row| {
+ // The scroll is the caret's line's: a shorter row shows nothing.
+ const start = @min(line.len, panes.File.rawAtDisplay(line, pane.tag_scroll));
+ _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style);
+ }
+ const tag = lines[0];
+ if (top == 0) if (tagTintRange(pane, tag, prefix_len)) |name| {
+ const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll));
+ const hi = @min(name.hi, tag.len);
+ if (lo < hi) {
+ var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll;
+ const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll;
+ const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg;
+ while (col < end and col < tw) : (col += 1)
+ s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg };
+ }
+ };
+ if (p.look_hover_preview) |preview| {
+ if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) {
+ const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg));
+ body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, 0, preview_bg, null);
+ };
+ }
+ // The tag's own selections, while it has the keyboard, a line at a
+ // time. A range of one grapheme is the block cursor, not a selection.
+ if (pane.focus == .tag) {
+ var ranges: [Text.max_selections]modal.Selection = undefined;
+ const got = pane.tag.ranges(whole, 0, &ranges);
+ for (ranges[0..got.n]) |range| {
+ const lo = @min(range.anchor, range.head);
+ const hi = @max(range.anchor, range.head);
+ if (modal.nextGrapheme(whole, lo) >= hi) continue;
+ const first = modal.positionAt(whole, lo);
+ const last = modal.positionAt(whole, hi);
+ var row = @max(first.row, top);
+ while (row <= last.row and row < top + rows) : (row += 1) {
+ const line = lines[row - top];
+ const from = if (row == first.row) first.col else 0;
+ const to = @min(line.len, if (row == last.row) last.col else line.len);
+ var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll;
+ const end = panes.File.rawDisplayCol(line, to);
+ const y = tag_y + @as(u16, @intCast(row - top));
+ while (col + pane.tag_scroll < end and col < tw) : (col += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(col)), y);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = th.sel_bg };
+ cell.style.fg = .{ .rgb = th.sel_fg };
+ }
+ }
+ }
+ }
+ // the caret: bar while typing, block in normal mode (same rule as a body)
+ if (caret) |at| {
+ const col = panes.File.rawDisplayCol(lines[at.row], at.at) -| pane.tag_scroll;
+ const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode;
+ if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y + @as(u16, @intCast(at.row)), .bar = mode == .insert };
+ }
+
+ // Pointer sweeps over the tag rows. A select sweep is the tag's own, in
+ // its selection colours; the look-hover affordance goes over it, and a
+ // middle or right sweep over both, tinted by its button the way the
+ // body tints it.
+ for (pane.sel, 0..) |selection, button| {
+ if (selection.state == .none or (selection.state == .done and button != 0)) continue;
+ if (button != pardes.sel_slot or @min(selection.r0, selection.r1) >= rows) continue;
+ body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, th.sel_bg, th.sel_fg);
+ }
+ if (p.look_hover_preview) |preview| {
+ if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) {
+ const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg));
+ body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, 0, preview_bg, null);
+ };
+ }
+ const sel_btn = body_layer.buttonTints(th);
+ for (pane.sel, 0..) |selection, button| {
+ if (selection.state == .none or (selection.state == .done and button != 0)) continue;
+ if (button == pardes.sel_slot or @min(selection.r0, selection.r1) >= rows) continue;
+ body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, sel_btn[button], th.sel_fg);
+ }
+}
+
+test "place puts every part where the painters and layers put it, in every layout" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ _ = try p.setTestFile("placed\n" ** 80);
+ p.newScratchColumn(0);
+ p.newScratchBelow(0);
+ p.sync();
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ for (0..16) |step| {
+ p.settings.tag_bottom = step & 1 != 0;
+ p.settings.column_tags = step & 2 != 0;
+ if (step & 4 != 0) try std.testing.expect(p.executeBuiltinLine(p.active, "Msg a placed notice"));
+ if (step == 8) try std.testing.expect(p.executeBuiltinLine(1, "Collapse"));
+ if (step == 12) try std.testing.expect(p.executeBuiltinLine(1, "Collapse"));
+ if (step == 10) {
+ const pane = p.panes[p.active].?;
+ pane.tag.own = try std.testing.allocator.dupe(u8, "Second\nthird");
+ }
+ p.sync();
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ try std.testing.expectEqual(Region.Kind.page, s.regions[0].kind);
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const r = p.rects[id];
+ if (r.w <= config.GUTTER or r.h == 0) continue;
+ const grip = s.region(.grip, id).?;
+ try std.testing.expectEqual(Rect{ .x = r.x, .y = p.tagTop(pane, r), .w = config.GUTTER, .h = pane.tag_rows }, grip.rect);
+ // The grid's grip cell is the grip's.
+ try std.testing.expectEqual(FontRole.tagline, s.at(grip.rect.x, grip.rect.y).style.font_role);
+ const tag = s.region(.tag, id).?;
+ try std.testing.expectEqual(Rect{ .x = r.x + config.GUTTER, .y = p.tagTop(pane, r), .w = r.w - config.GUTTER, .h = pane.tag_rows }, tag.rect);
+ // One layer, a row to each line of the tag.
+ if (r.w > TAG_TEXT_INSET) {
+ try std.testing.expectEqual(Rect{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r), .w = r.w - TAG_TEXT_INSET, .h = pane.tag_rows }, s.tag_layers[id].viewport);
+ try std.testing.expectEqual(pane.tag_rows, s.tag_layers[id].rows);
+ }
+ if (pane.collapsed) {
+ try std.testing.expect(s.region(.body, id) == null);
+ try std.testing.expect(s.region(.rail, id) == null);
+ continue;
+ }
+ const body = s.region(.body, id).?;
+ try std.testing.expectEqual(Rect{ .x = r.x + config.GUTTER, .y = p.bodyTop(pane, r), .w = r.w - config.GUTTER, .h = r.h - pane.tag_rows }, body.rect);
+ const rail = s.region(.rail, id).?;
+ // The painted thumb is exactly the placed one.
+ for (0..rail.rect.h) |row| {
+ const y = rail.rect.y + @as(u16, @intCast(row));
+ const thumb = row >= rail.thumb_y and row < rail.thumb_y + rail.thumb_h;
+ const want = if (thumb) p.chromeTheme().scroll_thumb else p.chromeTheme().scroll_track;
+ try std.testing.expectEqual(Color{ .rgb = want }, s.at(rail.rect.x, y).style.bg);
+ }
+ var notices: usize = 0;
+ for (s.regionList()) |region| {
+ if (region.kind != .notice or region.owner != id) continue;
+ notices += 1;
+ try std.testing.expectEqual(pane.notices.left[region.notice], region.rect.x);
+ try std.testing.expectEqual(r.x + r.w, region.rect.x + region.rect.w);
+ try std.testing.expectEqual(p.bodyTop(pane, r) + pane.notices.row[region.notice], region.rect.y);
+ try std.testing.expectEqual(region.rect, s.tag_layers[NOTICE_LAYER_BASE + id * Pane.Notices.max + region.notice].viewport);
+ }
+ try std.testing.expectEqual(@as(usize, pane.notices.len), notices);
+ }
+ for (0..p.ncol) |c| {
+ const region = s.region(.column_tag, c);
+ if (p.columnBarHeight() == 0) {
+ try std.testing.expect(region == null);
+ continue;
+ }
+ try std.testing.expectEqual(Rect{ .x = p.col_x[c] + TAG_TEXT_INSET, .y = p.topBarHeight(), .w = p.col_w[c] - TAG_TEXT_INSET, .h = p.columnBarHeight() }, region.?.rect);
+ }
+ }
+}
+
+test "the word under the pointer on a notice is lit in its layer and in the grid's copy" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 41, .rows = 12 });
+ defer p.deinit();
+ _ = try p.setTestFile("text\n");
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg Look here"));
+ p.sync();
+ var s = try p.render(arena.allocator());
+ const notice = for (s.regionList()) |region| {
+ if (region.kind == .notice) break region;
+ } else return error.TestExpectedNotice;
+ const row = s.cells[@as(usize, notice.rect.y) * s.cols ..][0..s.cols];
+ // "here", the chip's last word, before its blank edge.
+ const here = notice.rect.x + notice.rect.w - 5;
+ try std.testing.expectEqualStrings("h", row[here].grapheme());
+ p.pointer_inside = true;
+ p.hover_col = here + 1;
+ p.hover_row = notice.rect.y;
+ _ = arena.reset(.retain_capacity);
+ s = try p.render(arena.allocator());
+ const lit = s.cells[@as(usize, notice.rect.y) * s.cols ..][0..s.cols];
+ const sel: Color = .{ .rgb = p.theme().sel_bg };
+ for (lit[here..][0..4]) |cell| try std.testing.expectEqual(sel, cell.style.bg);
+ try std.testing.expect(!std.meta.eql(sel, lit[here - 2].style.bg));
+ const layer = for (s.tagLayers()) |*layer| {
+ if (layer.rows != 0 and layer.kind == .notice) break layer;
+ } else return error.TestExpectedNotice;
+ const lit_in_layer = for (layer.cells) |cell| {
+ if (std.meta.eql(sel, cell.style.bg)) break true;
+ } else false;
+ try std.testing.expect(lit_in_layer);
+}
+
+test "a notice wider than its band, as a shader's compile error is, draws its end and nothing past it" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20 });
+ defer p.deinit();
+ // A gui-shaped core: its tagline is narrower than the body, so a notice's
+ // band holds more columns than its chip has cells.
+ p.update(.{ .resize = .{ .cols = 80, .rows = 20, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } });
+ _ = try p.setTestFile("text\n");
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const long = "Shader /home/someone/shaders/test_shadertoy_invalid.glsl: error: Linking fragment stage: No function definition (body) found: " ** 3;
+ for ([_][]const u8{ "Msg " ++ long, "" }) |line| {
+ // The Msg builtin, and the path a failed compile takes (Post.zig).
+ if (line.len > 0) try std.testing.expect(p.executeBuiltinLine(p.active, line)) else p.setMessage(p.active, long);
+ p.sync();
+ for (0..30) |_| p.update(.tick);
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ const notice = for (s.regionList()) |region| {
+ if (region.kind == .notice) break region;
+ } else return error.TestExpectedNotice;
+ // Text up to the chip's blank last cell, and no padding ink.
+ const row = s.cells[@as(usize, notice.rect.y) * s.cols ..][notice.rect.x..][0..notice.rect.w];
+ try std.testing.expectEqualStrings(" ", row[row.len - 1].grapheme());
+ try std.testing.expect(!std.mem.eql(u8, " ", row[row.len - 2].grapheme()));
+ }
+}
+
+test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics never panics" {
+ // The snapshot harness runs the tty, whose tagline pitch is the body's,
+ // so it never reaches what a narrower tagline does to a notice's band:
+ // this drives the core at a gui's metrics instead. PARDES_FUZZ_STEPS
+ // runs it longer (the suite's run is short).
+ const steps: usize = if (std.c.getenv("PARDES_FUZZ_STEPS")) |v| std.fmt.parseInt(usize, std.mem.span(v), 10) catch 400 else 400;
+ const seed: u64 = if (std.c.getenv("PARDES_FUZZ_SEED")) |v| std.fmt.parseInt(u64, std.mem.span(v), 10) catch 7 else 7;
+ var prng = std.Random.DefaultPrng.init(seed);
+ const random = prng.random();
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ _ = try p.setTestFile("fuzz\n\u{754c}\u{754c} wide\n" ** 20);
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const words = [_][]const u8{ "error:", "\u{754c}", "x", "Shader /tmp/a.glsl:", "e\u{301}", "\t", "long-" ** 20, " " };
+ const commands = [_][]const u8{ "New", "Newcol", "Collapse", "Tagbottom", "ColumnTags", "TreeContext", "Wrap", "Find", "Del", "FocusTint", "Debug" };
+ var text: std.ArrayListUnmanaged(u8) = .empty;
+ defer text.deinit(std.testing.allocator);
+ for (0..steps) |_| {
+ switch (random.uintLessThan(u8, 8)) {
+ // A resize, down to a cell, at a gui's metrics: a tagline no
+ // wider and no taller than the body's cell, often narrower.
+ 0 => {
+ const body_w = random.intRangeAtMost(u16, 6, 12);
+ const body_h = random.intRangeAtMost(u16, 12, 24);
+ const cols = if (random.boolean()) random.intRangeAtMost(u16, 1, 12) else random.intRangeAtMost(u16, 13, 140);
+ const rows = if (random.boolean()) random.intRangeAtMost(u16, 1, 6) else random.intRangeAtMost(u16, 7, 50);
+ p.update(.{ .resize = .{ .cols = cols, .rows = rows, .row_metrics = .{ .body_w = body_w, .body_h = body_h, .tagline_w = random.intRangeAtMost(u16, 2, body_w), .tagline_h = random.intRangeAtMost(u16, 6, body_h) } } });
+ },
+ // A message, as long as a compile error or longer.
+ 1, 2 => {
+ text.clearRetainingCapacity();
+ for (0..random.uintAtMost(usize, 60)) |_| try text.appendSlice(std.testing.allocator, words[random.uintLessThan(usize, words.len)]);
+ if (random.boolean()) p.setMessage(p.active, text.items) else {
+ const line = try std.fmt.allocPrint(arena.allocator(), "Msg {s}", .{text.items});
+ _ = p.executeBuiltinLine(p.active, line);
+ }
+ },
+ 3 => _ = p.executeBuiltinLine(p.active, commands[random.uintLessThan(usize, commands.len)]),
+ // Typing, escaping and entering, into a prompt when one is up.
+ 4 => p.update(.{ .key = .{ .cp = switch (random.uintLessThan(u8, 9)) {
+ 0 => pardes.Key.escape,
+ 1 => pardes.Key.enter,
+ 2 => 0x754c,
+ 3 => pardes.Key.end,
+ 4 => pardes.Key.home,
+ 5 => pardes.Key.up,
+ 6 => pardes.Key.down,
+ else => 'a' + random.uintLessThan(u21, 26),
+ } } }),
+ // A tag of several rows.
+ 5 => if (p.panes[p.active]) |pane| {
+ text.clearRetainingCapacity();
+ for (0..random.uintAtMost(usize, 5)) |_| try text.appendSlice(std.testing.allocator, if (random.boolean()) " Look\nmore \u{754c}" else "\n" ++ "x" ** 150);
+ if (pane.tag.own) |own| std.testing.allocator.free(own);
+ pane.tag.own = try std.testing.allocator.dupe(u8, text.items);
+ },
+ // Hover, and clicks: into a tag, a header, a body, a grip.
+ 6 => {
+ const col = random.uintAtMost(u16, p.screen_w);
+ const row = random.uintAtMost(u16, p.screen_h);
+ if (random.boolean()) p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = col, .row = row } }) else {
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = col, .row = row } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = col, .row = row } });
+ }
+ },
+ else => for (0..random.uintAtMost(usize, 20)) |_| p.update(.tick),
+ }
+ p.sync();
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ p.acknowledgePanelPresentation(s.panelTracks());
+ }
+}
+
+test "a wide glyph cut by a tag's or a notice's edge is joined into the grid as blank, never half drawn" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 41, .rows = 12 });
+ defer p.deinit();
+ _ = try p.setTestFile("wide\n");
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const pane = p.panes[p.active].?;
+ // Both alignments, so one of them puts a wide glyph across each edge.
+ for ([_][]const u8{ "\u{754c}" ** 30, "x" ++ "\u{754c}" ** 30 }) |wide| {
+ if (pane.tag.own) |own| std.testing.allocator.free(own);
+ pane.tag.own = try std.testing.allocator.dupe(u8, wide);
+ var message: [200]u8 = undefined;
+ try std.testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&message, "Msg {s}", .{wide})));
+ p.sync();
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ for (s.regionList()) |region| {
+ if (region.kind != .tag and region.kind != .notice) continue;
+ const row = s.cells[@as(usize, region.rect.y) * s.cols ..][region.rect.x..][0..region.rect.w];
+ for (row, 0..) |cell, i| {
+ const spacer = !cell.default and cell.len == 0;
+ const head = !cell.default and cell.len > 1 and i + 1 < row.len and !row[i + 1].default and row[i + 1].len == 0;
+ // A spacer has its head before it; a head its spacer after.
+ if (spacer) try std.testing.expect(i > 0 and row[i - 1].len > 1);
+ if (i + 1 == row.len) try std.testing.expect(!head and !(cell.len > 1 and vaxis.gwidth.gwidth(cell.grapheme(), .unicode) == 2));
+ }
+ // The right edge of each is the band, blank where a glyph was cut.
+ try std.testing.expectEqual(FontRole.tagline, row[row.len - 1].style.font_role);
+ }
+ }
+}
+
+test "a body with context rows is painted once, and the grid's rows are what a plain paint of it gives" {
+ if (comptime !@import("syntax.zig").enabled) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 16 });
+ defer p.deinit();
+ const pane = try p.setTestFile("pub const Box = struct {\n pub fn run() void {\n" ++ (" // body line\n" ** 30) ++ " }\n};\n");
+ p.gpa.free(pane.file.?.path);
+ pane.file.?.path = try p.gpa.dupe(u8, "/test.zig");
+ if (!panes.File.supportsContext(pane)) return error.SkipZigTest;
+ pane.file.?.tree_context = true;
+ pane.file.?.scroll = 8;
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ for ([_]bool{ false, true }) |bottom| {
+ p.settings.tag_bottom = bottom;
+ // A selection and the cursor on body rows, painted in the one pass.
+ pane.body.cur_row = 12;
+ pane.body.cur_col = 3;
+ pane.body.vsel = .{ .active = true, .row = 10, .col = 2 };
+ pane.body.mode = .normal;
+ p.sync();
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ const layer = &s.body_layers[0];
+ try std.testing.expect(layer.context_rows > 0);
+ const body = s.region(.body, 0).?;
+ // The plain paint the grid used to take, of the same frame.
+ const cells = try arena.allocator().alloc(Cell, s.cells.len);
+ @memset(cells, .{});
+ var plain: Surface = .{ .cols = s.cols, .rows = s.rows, .cells = cells };
+ const saved = .{ pane.body_rows, pane.body_visible_rows, pane.context_row_limit };
+ pane.body_rows = 0;
+ pane.body_visible_rows = 0;
+ pane.context_row_limit = null;
+ try body_layer.renderBody(p, &plain, arena.allocator(), pane, p.rects[0], 0, true, null, null);
+ pane.body_rows, pane.body_visible_rows, pane.context_row_limit = saved;
+ for (0..body.rect.h) |row| {
+ const y = body.rect.y + row;
+ for (0..body.rect.w) |col| {
+ const x = body.rect.x + col;
+ const got = s.cells[y * s.cols + x];
+ const want = plain.cells[y * s.cols + x];
+ try std.testing.expect(got.visuallyEqual(&want));
+ }
+ }
+ try std.testing.expectEqual(plain.cursor, s.cursor);
+ // The layer's own rows run past the grid's.
+ try std.testing.expect(layer.rows > body.rect.h);
+ }
+}
diff --git a/src/edit.zig b/src/edit.zig
index 3cbbc28c..84bbb292 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -13,11 +13,12 @@ const body_layer = @import("body_layer.zig");
const config = @import("config.zig");
const pdf_enabled = panes.Pdf.enabled;
const lsp = @import("lsp/lsp.zig");
+const syntax = @import("syntax.zig");
const platform = pardes.platform;
const Pane = panes.Pane;
const Text = panes.Text;
const tag_layer = @import("tag_layer.zig");
-const TagHit = tag_layer.Hit;
+const TagHit = pardes.TagHit;
const BOX_H = pardes.BOX_H;
const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET;
const Mouse = pardes.Mouse;
@@ -26,23 +27,75 @@ const hit = pardes.hit;
const sel_slot = pardes.sel_slot;
const Effect = pardes.Effect;
const Pardes = pardes.Pardes;
+const Registers = pardes.Registers;
+/// Write `text` as this range's value of the register the running command
+/// names (`"<reg>`), the default one otherwise. `_` swallows it, and the
+/// registers computed from the editor (`#`, `.`, `%`) cannot be written.
+/// `+` and `*` are the system clipboard: the value is sent there too.
pub fn setYank(p: *Pardes, text: []const u8) void {
- if (p.multi_on and !p.multi_first) {
- const old = p.yank orelse "";
- const sep: []const u8 = if (text.len > 0 and text[text.len - 1] == '\n') "" else "\n";
- const joined = std.fmt.allocPrint(p.gpa, "{s}{s}{s}", .{ text, sep, old }) catch return;
- if (p.yank) |y| p.gpa.free(y);
- p.yank = joined;
- return;
+ var name: u21 = if (p.register != 0) p.register else Registers.default;
+ switch (name) {
+ '_', '#', '.', '%' => return,
+ '*' => name = '+',
+ else => {},
}
- if (p.yank) |y| p.gpa.free(y);
- p.yank = p.gpa.dupe(u8, text) catch null;
+ const one = !p.multi_on;
+ p.registers.put(p.gpa, name, text, if (one) 0 else p.multi_index, if (one) 1 else p.multi_count, one or p.multi_first);
+ if (name == '+' and (one or p.multi_first)) p.emit(.{ .set_clipboard = {} });
}
pub fn setClipboard(p: *Pardes, text: []const u8) void {
+ const named = p.register;
+ p.register = '+';
+ defer p.register = named;
setYank(p, text);
- if (!p.multi_on or p.multi_first) p.emit(.{ .set_clipboard = {} });
+}
+
+/// Value `i` of register `name` as the command at range `i` reads it, the
+/// computed registers included: `#` is the range's number from 1, `.` its
+/// text, `%` the file's name, `_` nothing. Scratch- or register-owned.
+pub fn registerValue(p: *Pardes, t: *Text, name: u21) ?[]const u8 {
+ const i = if (p.multi_on) p.multi_index else 0;
+ return switch (name) {
+ '_' => null,
+ '#' => std.fmt.allocPrint(p.scratch.allocator(), "{d}", .{i + 1}) catch null,
+ '.' => selText(p, t),
+ '%' => if (t.what == .body) if (t.pane()) |pane| (if (pane.file) |f| f.path else null) else null else null,
+ else => p.registers.value(name, i),
+ };
+}
+
+/// the text of the range under the cursor: the selection, or the cursor's
+/// own character
+fn selText(p: *Pardes, t: *Text) []const u8 {
+ if (t.msel.active) return yankRows(p, t, @min(t.msel.r0, t.msel.r1), @max(t.msel.r0, t.msel.r1));
+ const save = t.vsel;
+ defer t.vsel = save;
+ if (!t.vsel.active) t.vsel = .{ .active = true, .row = t.cur_row, .col = t.cur_col, .explicit = false };
+ return vselText(p, t);
+}
+
+/// `p` / `P` / `R` from the register the command names: this range's value
+/// of it. The clipboard's is asked of the shell and pasted when it answers,
+/// once, at the primary.
+pub fn pasteRegister(p: *Pardes, t: *Text, how: enum { after, before, replace }, count: usize) void {
+ const name: u21 = if (p.register != 0) p.register else Registers.default;
+ if (name == '+' or name == '*') {
+ if (!normal.multiOnce(p)) return;
+ const pane = t.pane() orelse return;
+ return clipRequest(p, p.paneIdOf(pane) orelse return, switch (how) {
+ .after => .after,
+ .before => .before,
+ .replace => .replace,
+ });
+ }
+ const y = registerValue(p, t, name) orelse return;
+ switch (how) {
+ .after => pasteText(p, t, y, false, count),
+ .before => pasteText(p, t, y, true, count),
+ .replace => replaceWithText(p, t, y),
+ }
}
/// Where a `SPC p` / `SPC P` / `SPC R` goes once the shell answers.
@@ -96,7 +149,7 @@ pub fn applyPaste(p: *Pardes, bytes: []const u8) void {
n += 1;
};
if (n > 0) insertKey(p, &pane.input, .{ .cp = line[0], .text = line[0..n] });
- if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.split);
+ if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.mode);
return;
}
if (pane.focus == .tag) return pasteInto(p, &pane.tag, bytes);
@@ -114,10 +167,16 @@ fn pasteInto(p: *Pardes, t: *Text, bytes: []const u8) void {
if (bytes.len == 0) return;
const typed: pardes.Key = .{ .cp = bytes[0], .text = bytes };
if (t.what == .tag and t.mode == .insert) if (t.pane()) |pane| if (tagline.typedIntoName(p, pane, typed)) return;
- const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel };
+ const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel };
+ var kept_sels: [Text.max_selections - 1]Text.SelRange = undefined;
+ const kept_n = t.nsel;
+ @memcpy(kept_sels[0..kept_n], t.sels[0..kept_n]);
t.refused = false;
if (t.mode == .insert) insertKey(p, t, typed) else pasteText(p, t, bytes, false, 1);
- if (t.refused) t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept;
+ if (t.refused) {
+ t.cur_row, t.cur_col, t.vsel, t.msel = kept;
+ t.restoreSels(kept_sels[0..kept_n]);
+ }
}
pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void {
@@ -125,6 +184,10 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void {
if (pv.selection_text.len > 0) setClipboard(p, pv.selection_text);
return;
};
+ // helix's `"+y`: the clipboard register, and only it
+ const named = p.register;
+ p.register = '+';
+ defer p.register = named;
if (pane.body.nsel > 0 and !main_only) {
normal.replaySels(p, &pane.body, .{ .normal = .{ .edit = .{ .kind = .yank, .count = 1 } } });
} else {
@@ -133,7 +196,6 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void {
normalYank(p, &pane.body);
pane.body.nsel = others;
}
- p.emit(.{ .set_clipboard = {} });
}
/// tag + '\n' + body: the full selectable pane text, a row to a screen row
@@ -286,30 +348,24 @@ pub fn currentSelText(p: *Pardes, pane: *Pane) ?[]const u8 {
pub fn exitInsert(p: *Pardes, t: *Text) void {
t.mode = .normal;
+ t.reg_wait = false;
t.msel.active = false;
t.normal.clear();
- if (t.append_at) |aa| {
- t.append_at = null;
- const text = flatSurface(p, t) catch return;
- const gap = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) });
- const a_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) });
- const back = @max(a_off, modal.prevGrapheme(text, gap));
- const bc = modal.positionAt(text, back);
- t.cur_row = @intCast(bc.row);
- t.cur_col = @intCast(bc.col);
- t.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false };
- t.cur_pinned = true;
- t.show();
- for (t.sels[0..t.nsel]) |*s| {
- const sgap = modal.offsetAt(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) });
- const b2 = if (back == gap) sgap else modal.prevGrapheme(text, sgap);
- const bp = modal.positionAt(text, b2);
- s.row = @intCast(bp.row);
- s.col = @intCast(bp.col);
- s.arow = s.row;
- s.acol = s.col;
- }
+ if (!t.restore_cursor) return;
+ t.restore_cursor = false;
+ // helix enter_normal_mode: every range, from its start to the char
+ // before its end when it points forward, else to its end
+ const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active;
+ const text = flatSurface(p, t) catch return;
+ var rs: [Text.max_selections]modal.Selection = undefined;
+ const got = t.ranges(text, 0, &rs);
+ for (rs[0..got.n]) |*r| {
+ const from = @min(r.anchor, r.head);
+ const to = @max(r.anchor, r.head);
+ const head = if (r.head > r.anchor) @max(from, modal.prevGrapheme(text, to)) else to;
+ r.* = .{ .anchor = from, .head = head };
}
+ t.setRanges(text, rs[0..got.n], &.{}, got.pri, expl);
}
pub fn paneCursorLines(p: *Pardes, t: *Text) ![]const []const u8 {
@@ -415,7 +471,9 @@ pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void {
if (t.what == .tag) {
// A tag is edited as it is shown, but only what follows its
// prefix is its own: an edit that changed the prefix is refused.
- defer p.gpa.free(new);
+ // The shown text stays alive until the next such edit, since every
+ // caller reads the new text back, as a body's is kept.
+ defer retireEditText(p, new);
const pane = t.pane() orelse return;
const prefix = tagline.pathPrefix(p.scratch.allocator(), pane) catch return;
if (!std.mem.startsWith(u8, new, prefix)) {
@@ -432,11 +490,25 @@ pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void {
t.own = new;
return;
}
- const pane = t.pane() orelse return p.gpa.free(new);
+ const pane = t.pane() orelse return retireEditText(p, new);
+ // what changed ended where the texts agree again from their ends
+ const old: []const u8, const row0: i32 = if (pane.file) |f| .{ f.content, 0 } else if (pane.ovl) |o| .{ o.text, o.row } else .{ "", 0 };
+ const front = std.mem.indexOfDiff(u8, old, new) orelse @min(old.len, new.len);
+ var back: usize = 0;
+ while (back < @min(old.len, new.len) - front and old[old.len - 1 - back] == new[new.len - 1 - back]) back += 1;
+ const end = modal.positionAt(new, new.len - back);
+ t.last_edit = .{ .row = @as(i32, @intCast(end.row)) + row0, .col = @intCast(end.col) };
if (pane.file) |*f| return panes.File.setContent(p, f, new);
panes.Terminal.setEditText(p, pane, new);
}
+/// Take a text an edit made that nothing keeps, holding it until the next
+/// one: the edit's caller still reads it to place the cursor.
+pub fn retireEditText(p: *Pardes, new: []u8) void {
+ if (p.retired_edit) |old| p.gpa.free(old);
+ p.retired_edit = new;
+}
+
pub const InsertAt = enum { at, append, line_start, line_end, open_below, open_above };
/// Files insert only inside existing rows and at grapheme boundaries.
@@ -462,7 +534,7 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void {
// `u` undoes the whole session and restores what was selected
pushUndo(p, t);
t.select = false;
- t.append_at = null;
+ t.restore_cursor = false;
t.sticky_col = -1;
const text = flatSurface(p, t) catch {
t.mode = .insert;
@@ -488,12 +560,25 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void {
const lo_col = if (b) |bb| bb.lo_col else t.cur_col;
const hi_row = if (b) |bb| bb.hi_row else t.cur_row;
const hi_col = if (b) |bb| bb.hi_col else t.cur_col;
- t.append_at = .{ .row = lo_row, .col = lo_col };
- const gap = modal.nextGrapheme(text, modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) }));
- const gc = modal.positionAt(text, gap);
+ t.restore_cursor = true;
+ const hi = modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) });
+ const gap = modal.nextGrapheme(text, hi);
+ // helix append_mode: a range that reaches the end of the text
+ // gets a newline after it to append before (a terminal's text
+ // is not the editor's to grow)
+ const terminal = t.what == .body and if (t.pane()) |pane| pane.isTerminal() else false;
+ var now = text;
+ if (hi < text.len and gap >= text.len and !terminal) {
+ const grown = std.mem.concat(p.gpa, u8, &.{ text, "\n" }) catch return;
+ setEditText(p, t, grown);
+ now = grown; // what setEditText keeps; `text` may be gone
+ }
+ const gc = modal.positionAt(now, gap);
t.cur_row = @intCast(gc.row);
t.cur_col = @intCast(gc.col);
- t.vsel = .{ .active = b != null, .row = lo_row, .col = lo_col, .explicit = false };
+ // helix append_mode: the range reaches one past its end, which
+ // is where typing goes; a bare cursor becomes a range too
+ t.vsel = .{ .active = true, .row = lo_row, .col = lo_col, .explicit = false };
t.cur_pinned = true;
},
.line_start => {
@@ -565,6 +650,87 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
if (hit(key, config.insert_backspace_alias)) return insertKey(p, t, .{ .cp = Key.backspace });
if (hit(key, config.insert_enter_alias)) return insertKey(p, t, .{ .cp = Key.enter });
if (hit(key, config.insert_delete_alias)) return insertKey(p, t, .{ .cp = Key.delete });
+ // Ctrl-r <reg>: type this range's value of the register (helix
+ // insert_register)
+ if (t.reg_wait) {
+ t.reg_wait = false;
+ if (key.ctrl or key.alt or key.cp >= 0xF0000) return;
+ const y = registerValue(p, t, key.cp) orelse return;
+ if (y.len > 0) insertKey(p, t, .{ .cp = key.cp, .text = y });
+ return;
+ }
+ if (hit(key, config.insert_register)) {
+ t.reg_wait = true;
+ return;
+ }
+ // helix commit_undo_checkpoint: `u` after the session goes back to here
+ if (hit(key, config.insert_checkpoint)) return pushUndo(p, t);
+ // helix maps every range through each insert-mode edit (Range::map):
+ // the edits below work on the bare cursor, and the range the cursor
+ // heads is carried through what they did to the text afterwards
+ if (!t.vsel.active) return insertEdit(p, t, key);
+ const old = flatSurface(p, t) catch return insertEdit(p, t, key);
+ const r = t.primaryRange(old, 0);
+ const c = modal.selectionCursor(old, r);
+ const old_len = old.len;
+ const explicit = t.vsel.explicit;
+ t.vsel.active = false;
+ insertEdit(p, t, key);
+ if (t.mode != .insert) return;
+ const new = flatSurface(p, t) catch return;
+ const c2 = modal.offsetAt(new, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) });
+ // every insert-mode edit happens at the cursor: an insertion there, or
+ // a deletion that ends there (backward) or starts there (forward)
+ if (key.cp == Key.enter) {
+ // helix insert_newline: an appending range stretches by what the
+ // cursor moved, any other slides by it
+ const d = @as(isize, @intCast(c2)) - @as(isize, @intCast(c));
+ const head: usize = @intCast(@as(isize, @intCast(r.head)) + d);
+ const anchor: usize = if (c > r.anchor) r.anchor else @intCast(@as(isize, @intCast(r.anchor)) + d);
+ return setInsertRange(t, new, .{ .anchor = anchor, .head = head }, explicit);
+ }
+ var at: usize = c;
+ var del: usize = 0;
+ var ins: usize = 0;
+ if (new.len > old_len and c2 == c + (new.len - old_len)) {
+ ins = new.len - old_len;
+ } else if (new.len < old_len and c2 + (old_len - new.len) == c) {
+ at = c2;
+ del = old_len - new.len;
+ } else if (new.len < old_len and c2 == c) {
+ del = old_len - new.len;
+ } else if (new.len != old_len or c2 != c) {
+ return; // a motion: the range collapses onto the cursor (helix)
+ }
+ // helix Assoc: the head sticks after an insertion at it when the range
+ // points backward or is empty, the anchor when it points forward
+ const fwd = r.anchor < r.head;
+ setInsertRange(t, new, .{
+ .anchor = mapThroughEdit(r.anchor, at, del, ins, !fwd and r.anchor != r.head),
+ .head = mapThroughEdit(r.head, at, del, ins, fwd),
+ }, explicit);
+}
+
+fn setInsertRange(t: *Text, new: []const u8, mapped: modal.Selection, explicit: bool) void {
+ const cells = Text.rangeCells(new, mapped);
+ const cc = modal.positionAt(new, cells.cur);
+ const ac = modal.positionAt(new, cells.anc);
+ t.cur_row = @intCast(cc.row);
+ t.cur_col = @intCast(cc.col);
+ t.vsel = .{ .active = cells.anc != cells.cur, .row = @intCast(ac.row), .col = @intCast(ac.col), .explicit = explicit };
+}
+
+/// A position through one edit at `at` that deleted `del` bytes and put
+/// `ins` in (one of the two is 0). A position exactly at an insertion stays
+/// before it when `before` is set, else moves past it.
+fn mapThroughEdit(pos: usize, at: usize, del: usize, ins: usize, before: bool) usize {
+ if (pos < at) return pos;
+ if (del > 0) return if (pos < at + del) at else pos - del;
+ if (pos == at and before) return pos;
+ return pos + ins;
+}
+
+fn insertEdit(p: *Pardes, t: *Text, key: Key) void {
t.vsel.active = false;
if (!t.cur_pinned) pinPaneCursor(p, t);
_ = clampFileCursor(t);
@@ -679,12 +845,29 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
switch (key.cp) {
Key.enter => {
const line = modal.lineSlice(text, c.row);
+ const before = line[0..@min(c.col, line.len)];
+ // helix insert_newline: after nothing but blanks, the line
+ // itself moves down, blanks and all; else the blanks before the
+ // cursor go and the new line takes the indent
+ if (std.mem.indexOfNone(u8, before, " \t") == null) {
+ const new = modal.insertAt(p.gpa, text, .{ .row = c.row, .col = 0 }, "\n") catch return;
+ setEditText(p, t, new);
+ t.cur_row += 1;
+ t.cur_pinned = true;
+ t.show();
+ return;
+ }
const indent = modal.newlineIndentWidth(line, c.col);
const arena = p.scratch.allocator();
const block = arena.alloc(u8, 1 + indent) catch return;
block[0] = '\n';
@memset(block[1..], ' ');
- const new = modal.insertAt(p.gpa, text, c, block) catch return;
+ // a tag keeps its blanks: they part its words, and the last
+ // may be its read-only prefix's
+ const kept = if (t.what == .body) std.mem.trimEnd(u8, before, " \t").len else c.col;
+ const cut = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = kept }, c) catch return;
+ defer p.gpa.free(cut);
+ const new = modal.insertAt(p.gpa, cut, .{ .row = c.row, .col = kept }, block) catch return;
setEditText(p, t, new);
t.cur_row += 1;
t.cur_col = @intCast(indent);
@@ -694,8 +877,19 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
Key.backspace => {
if (t.cur_col > 0) {
const line = modal.lineSlice(text, c.row);
- const prev = modal.prevGrapheme(line, c.col);
- const new = modal.deleteChar(p.gpa, text, .{ .row = c.row, .col = prev }) catch return;
+ var prev = modal.prevGrapheme(line, c.col);
+ // in leading blanks, back to the previous indent stop (helix
+ // dedent): a whole unit when on one, else the part past it
+ const lead = line[0..@min(c.col, line.len)];
+ if (std.mem.indexOfNone(u8, lead, " \t") == null and prev < line.len and line[prev] != '\t') {
+ var width: usize = 0;
+ for (lead) |ch| width += if (ch == '\t') modal.INDENT_W else 1;
+ var drop = width % modal.INDENT_W;
+ if (drop == 0) drop = modal.INDENT_W;
+ prev = c.col;
+ while (drop > 0 and prev > 0 and line[prev - 1] == ' ') : (drop -= 1) prev -= 1;
+ }
+ const new = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = prev }, c) catch return;
setEditText(p, t, new);
t.cur_col = @intCast(prev);
t.cur_pinned = true;
@@ -742,6 +936,17 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
p.lspRequest(p.active, .completion, "");
if (p.lsp_seq != seq) return;
}
+ // helix smart_tab: after text, Tab leaves the syntax node the
+ // cursor is in; only where the file has a grammar, since the
+ // reference pins smart tab off, which a file without one agrees
+ // with. Shift-Tab always types the tab.
+ const after_text = std.mem.indexOfNone(u8, ln[0..@min(c.col, ln.len)], " \t") != null;
+ if (!key.shift and after_text and t.what == .body) smart: {
+ const pane = t.pane() orelse break :smart;
+ const f = pane.file orelse break :smart;
+ if (!syntax.supportsPath(f.path)) break :smart;
+ return normal.nodeSelect(p, pane, t, .parent_end);
+ }
insertTab(p, t);
},
else => {},
@@ -1121,31 +1326,45 @@ pub fn normalReplaceChar(p: *Pardes, t: *Text, ch: u21) void {
/// `R`: replace the selection (or the cursor char) with the DEFAULT
/// register. `SPC R` is the same verb over the system clipboard.
-pub fn normalReplaceYank(p: *Pardes, t: *Text) void {
- replaceWithText(p, t, p.yank orelse return);
+/// The acme chords' replace: the default register as one text.
+pub fn chordReplace(p: *Pardes, t: *Text) void {
+ replaceWithText(p, t, p.registers.text(p.gpa, Registers.default) orelse return);
}
-/// The pasted text becomes the selection, head on its last char (which for
-/// a trailing-newline `y` is the '\n' cell of the last full line).
+/// The pasted text becomes the selection, in the direction the replaced one
+/// had: a forward head lands on its last char (which for a trailing-newline
+/// `y` is the '\n' cell of the last full line), a backward one on its first.
fn replaceWithText(p: *Pardes, t: *Text, y: []const u8) void {
if (y.len == 0) return;
t.select = false;
const eb = editTextEol(p, t, selRows(t)) orelse return;
const r = selRange(t, eb.text, eb.row0);
+ const back = t.vsel.active and (t.cur_row < t.vsel.row or (t.cur_row == t.vsel.row and t.cur_col < t.vsel.col));
pushUndo(p, t);
const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, y) catch return;
setEditText(p, t, new);
t.msel.active = false;
- t.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false };
const end = modal.advanceBy(r.a, y);
+ var last_row: i32 = undefined;
+ var last_col: i32 = undefined;
if (end.col > 0) {
- t.cur_row = @as(i32, @intCast(end.row)) + eb.row0;
- t.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(new, end.row), end.col));
+ last_row = @as(i32, @intCast(end.row)) + eb.row0;
+ last_col = @intCast(modal.prevGrapheme(modal.lineSlice(new, end.row), end.col));
} else {
- // the yank ended in '\n': the cursor lands ON that newline
- t.cur_row = @as(i32, @intCast(end.row -| 1)) + eb.row0;
- t.cur_col = @intCast(modal.lineSlice(new, end.row -| 1).len);
+ // the yank ended in '\n': its last cell is that newline
+ last_row = @as(i32, @intCast(end.row -| 1)) + eb.row0;
+ last_col = @intCast(modal.lineSlice(new, end.row -| 1).len);
}
+ const first_row = @as(i32, @intCast(r.a.row)) + eb.row0;
+ const first_col: i32 = @intCast(r.a.col);
+ t.vsel = .{
+ .active = modal.nextGrapheme(y, 0) < y.len,
+ .row = if (back) last_row else first_row,
+ .col = if (back) last_col else first_col,
+ .explicit = false,
+ };
+ t.cur_row = if (back) first_row else last_row;
+ t.cur_col = if (back) first_col else last_col;
t.cur_pinned = true;
t.sticky_col = -1;
t.show();
@@ -1201,53 +1420,213 @@ fn applyChanges(p: *Pardes, text: []const u8, chs: []const TextChange) ![]u8 {
return out;
}
-pub fn normalJoin(p: *Pardes, t: *Text) void {
+/// `J` / `Alt-J`: join every range's lines with the next, in ONE edit
+/// (helix join_selections): the newline and the next line's leading blanks
+/// become one space, or nothing when that line is blank, and a line two
+/// ranges share is joined once. `Alt-J` then selects the spaces it put in.
+pub fn normalJoin(p: *Pardes, t: *Text, select_space: bool) void {
+ const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active;
// a join always eats the newline of its last line, so the buffer has
- // to reach one row PAST the selection
- const sr = selRows(t);
- const eb = editText(p, t, sr.lo_row, sr.hi_row + 1, -1) orelse return;
+ // to reach one row PAST every range
+ var b = selRows(t);
+ for (t.sels[0..t.nsel]) |s| {
+ b.lo_row = @min(b.lo_row, @min(s.row, s.arow));
+ b.hi_row = @max(b.hi_row, @max(s.row, s.arow));
+ }
+ const eb = editText(p, t, b.lo_row, b.hi_row + 1, -1) orelse return;
const text = eb.text;
- const range = t.primaryRange(text, eb.row0);
- const span = Text.rangeLineSpan(text, range);
+ var rs: [Text.max_selections]modal.Selection = undefined;
+ const got = t.ranges(text, eb.row0, &rs);
const nlines = modal.cursorLineCount(text);
- var end = span.end;
- if (span.start == end) end = @min(end + 1, nlines - 1);
- if (end <= span.start) return;
const arena = p.scratch.allocator();
- const chs = arena.alloc(TextChange, end - span.start) catch return;
+ const chs = arena.alloc(TextChange, nlines) catch return;
var chs_len: usize = 0;
- var l = span.start;
- while (l < end) : (l += 1) {
- const from = modal.lineEndOffset(text, l);
- var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1);
- while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1;
- const sep: []const u8 = if (to == modal.lineEndOffset(text, @min(l + 1, nlines - 1))) "" else " ";
- chs[chs_len] = .{ .from = from, .to = to, .ins = sep };
- chs_len += 1;
+ var next: usize = 0; // lines below this one are joined already
+ for (rs[0..got.n]) |r| {
+ const span = Text.rangeLineSpan(text, r);
+ var end = span.end;
+ if (span.start == end) end = @min(end + 1, nlines - 1);
+ var l = @max(span.start, next);
+ while (l < end) : (l += 1) {
+ const from = modal.lineEndOffset(text, l);
+ var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1);
+ while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1;
+ const sep: []const u8 = if (to == modal.lineEndOffset(text, @min(l + 1, nlines - 1))) "" else " ";
+ chs[chs_len] = .{ .from = from, .to = to, .ins = sep };
+ chs_len += 1;
+ }
+ next = @max(next, end);
}
if (chs_len == 0) return;
pushUndo(p, t);
- const cur_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row - eb.row0)), .col = @intCast(@max(0, t.cur_col)) });
- const anc_off = if (t.vsel.active)
- modal.offsetAt(text, .{ .row = @intCast(@max(0, t.vsel.row - eb.row0)), .col = @intCast(@max(0, t.vsel.col)) })
- else
- cur_off;
const new = applyChanges(p, text, chs[0..chs_len]) catch return;
- const nc = mapThroughChanges(chs[0..chs_len], cur_off);
- const na = mapThroughChanges(chs[0..chs_len], anc_off);
+ // every range's cells ride through the edit; Alt-J replaces them with
+ // the spaces, when it put any in
+ var cells: [Text.max_selections]Text.SelRange = undefined;
+ var n: usize = 0;
+ var pri = got.pri;
+ if (select_space) {
+ var delta: i64 = 0;
+ for (chs[0..chs_len]) |ch| {
+ const at: usize = @intCast(@as(i64, @intCast(ch.from)) + delta);
+ delta += @as(i64, @intCast(ch.ins.len)) - @as(i64, @intCast(ch.to - ch.from));
+ if (ch.ins.len == 0 or n == cells.len) continue;
+ const pos = modal.positionAt(new, at);
+ cells[n] = .{ .row = @as(i32, @intCast(pos.row)) + eb.row0, .col = @intCast(pos.col), .arow = @as(i32, @intCast(pos.row)) + eb.row0, .acol = @intCast(pos.col) };
+ n += 1;
+ }
+ pri = 0;
+ }
+ if (n == 0) {
+ for (rs[0..got.n]) |r| {
+ const c = Text.rangeCells(text, r);
+ const cc = modal.positionAt(new, mapThroughChanges(chs[0..chs_len], c.cur));
+ const ac = modal.positionAt(new, mapThroughChanges(chs[0..chs_len], c.anc));
+ cells[n] = .{
+ .row = @as(i32, @intCast(cc.row)) + eb.row0,
+ .col = @intCast(cc.col),
+ .arow = @as(i32, @intCast(ac.row)) + eb.row0,
+ .acol = @intCast(ac.col),
+ };
+ n += 1;
+ }
+ pri = got.pri;
+ }
setEditText(p, t, new);
- const cc = modal.positionAt(new, nc);
- const ac = modal.positionAt(new, na);
- t.cur_row = @as(i32, @intCast(cc.row)) + eb.row0;
- t.cur_col = @intCast(cc.col);
- if (t.vsel.active) {
- t.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0;
- t.vsel.col = @intCast(ac.col);
+ const t2 = flatSurface(p, t) catch return;
+ for (cells[0..n], 0..) |s, i| rs[i] = Text.cellRange(t2, s.arow, s.acol, s.row, s.col);
+ t.setRanges(t2, rs[0..n], &.{}, pri, expl);
+}
+
+/// `Alt-)` / `Alt-(`: rotate the TEXT of the selections forward / back by
+/// one range (count ranges), in one edit (helix reorder_selection_contents).
+/// Each range comes back over the text it now holds, in its own direction,
+/// and the primary moves with the text it had.
+pub fn normalRotateContents(p: *Pardes, t: *Text, forward: bool, cnt: usize) void {
+ const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active;
+ var b = selRows(t);
+ for (t.sels[0..t.nsel]) |s| {
+ b.lo_row = @min(b.lo_row, @min(s.row, s.arow));
+ b.hi_row = @max(b.hi_row, @max(s.row, s.arow));
}
- t.msel.active = false;
- t.cur_pinned = true;
- t.sticky_col = -1;
- t.show();
+ const eb = editTextEol(p, t, b) orelse return;
+ const text = eb.text;
+ var rs: [Text.max_selections]modal.Selection = undefined;
+ const got = t.ranges(text, eb.row0, &rs);
+ const n = got.n;
+ if (n < 2) return;
+ const by = @min(cnt, n);
+ var chs: [Text.max_selections]TextChange = undefined;
+ for (rs[0..n], 0..) |r, i| {
+ const src = rs[if (forward) (i + n - by) % n else (i + by) % n];
+ chs[i] = .{
+ .from = @min(r.anchor, r.head),
+ .to = @max(r.anchor, r.head),
+ .ins = text[@min(src.anchor, src.head)..@max(src.anchor, src.head)],
+ };
+ }
+ pushUndo(p, t);
+ const new = applyChanges(p, text, chs[0..n]) catch return;
+ var cells: [Text.max_selections]Text.SelRange = undefined;
+ var delta: i64 = 0;
+ for (chs[0..n], rs[0..n], 0..) |ch, r, i| {
+ const from: usize = @intCast(@as(i64, @intCast(ch.from)) + delta);
+ delta += @as(i64, @intCast(ch.ins.len)) - @as(i64, @intCast(ch.to - ch.from));
+ const to = from + ch.ins.len;
+ const c = Text.rangeCells(new, if (r.anchor > r.head) .{ .anchor = to, .head = from } else .{ .anchor = from, .head = to });
+ const cc = modal.positionAt(new, c.cur);
+ const ac = modal.positionAt(new, c.anc);
+ cells[i] = .{
+ .row = @as(i32, @intCast(cc.row)) + eb.row0,
+ .col = @intCast(cc.col),
+ .arow = @as(i32, @intCast(ac.row)) + eb.row0,
+ .acol = @intCast(ac.col),
+ };
+ }
+ setEditText(p, t, new);
+ const t2 = flatSurface(p, t) catch return;
+ for (cells[0..n], 0..) |s, i| rs[i] = Text.cellRange(t2, s.arow, s.acol, s.row, s.col);
+ t.setRanges(t2, rs[0..n], &.{}, if (forward) (got.pri + by) % n else (got.pri + n - by) % n, expl);
+}
+
+/// `&`: pad the selections into columns, in one edit (helix
+/// align_selections). The k-th range of every line is a column; spaces go in
+/// before each range so its head lands on the column's widest head, measured
+/// in display cells. A range over several lines aligns nothing.
+pub fn normalAlign(p: *Pardes, t: *Text) void {
+ const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active;
+ var b = selRows(t);
+ for (t.sels[0..t.nsel]) |s| {
+ b.lo_row = @min(b.lo_row, @min(s.row, s.arow));
+ b.hi_row = @max(b.hi_row, @max(s.row, s.arow));
+ }
+ const eb = editTextEol(p, t, b) orelse return;
+ const text = eb.text;
+ var rs: [Text.max_selections]modal.Selection = undefined;
+ const got = t.ranges(text, eb.row0, &rs);
+ const n = got.n;
+ var row: [Text.max_selections]usize = undefined;
+ var col: [Text.max_selections]usize = undefined;
+ var widths: [Text.max_selections]usize = @splat(0);
+ var line: usize = std.math.maxInt(usize);
+ var k: usize = 0;
+ var run: usize = 0;
+ for (rs[0..n], 0..) |r, i| {
+ row[i] = modal.lineAtOffset(text, r.head);
+ if (modal.lineAtOffset(text, r.anchor) != row[i]) return;
+ const ls = modal.lineStartOffset(text, row[i]);
+ col[i] = panes.File.rawDisplayCol(text[ls..modal.lineEndOffset(text, row[i])], r.head - ls);
+ if (row[i] != line) {
+ line = row[i];
+ k = 0;
+ run = 0;
+ }
+ widths[k] = @max(widths[k], col[i] - run);
+ run = col[i];
+ k += 1;
+ }
+ t.select = false; // helix exit_select_mode
+ // each column's head position, left to right
+ for (1..n) |i| widths[i] += widths[i - 1];
+ const arena = p.scratch.allocator();
+ var chs: [Text.max_selections]TextChange = undefined;
+ var m: usize = 0;
+ line = std.math.maxInt(usize);
+ for (rs[0..n], 0..) |r, i| {
+ if (row[i] != line) {
+ line = row[i];
+ k = 0;
+ run = 0;
+ }
+ const pad = widths[k] - col[i] - run;
+ k += 1;
+ run += pad;
+ if (pad == 0) continue;
+ const spaces = arena.alloc(u8, pad) catch return;
+ @memset(spaces, ' ');
+ const at = @min(r.anchor, r.head);
+ chs[m] = .{ .from = at, .to = at, .ins = spaces };
+ m += 1;
+ }
+ if (m == 0) return;
+ pushUndo(p, t);
+ const new = applyChanges(p, text, chs[0..m]) catch return;
+ var cells: [Text.max_selections]Text.SelRange = undefined;
+ for (rs[0..n], 0..) |r, i| {
+ const c = Text.rangeCells(text, r);
+ const cc = modal.positionAt(new, mapThroughChanges(chs[0..m], c.cur));
+ const ac = modal.positionAt(new, mapThroughChanges(chs[0..m], c.anc));
+ cells[i] = .{
+ .row = @as(i32, @intCast(cc.row)) + eb.row0,
+ .col = @intCast(cc.col),
+ .arow = @as(i32, @intCast(ac.row)) + eb.row0,
+ .acol = @intCast(ac.col),
+ };
+ }
+ setEditText(p, t, new);
+ const t2 = flatSurface(p, t) catch return;
+ for (cells[0..n], 0..) |s, i| rs[i] = Text.cellRange(t2, s.arow, s.acol, s.row, s.col);
+ t.setRanges(t2, rs[0..n], &.{}, got.pri, expl);
}
pub fn normalIndent(p: *Pardes, t: *Text, cnt: usize, add: bool) void {
@@ -1439,9 +1818,12 @@ pub fn normalAdjustNumber(p: *Pardes, t: *Text, delta: i64) void {
/// `]Space` / `[Space`: add blank lines below/above, the cursor staying
/// on its text line (the selection rides along)
pub fn addNewline(p: *Pardes, t: *Text, below: bool, cnt: usize) void {
- const eb = editText(p, t, t.cur_row, t.cur_row, -1) orelse return;
+ // above the selection's first line, below its last (helix line_range)
+ const sr = selRows(t);
+ const at = if (below) sr.hi_row else sr.lo_row;
+ const eb = editText(p, t, at, at, -1) orelse return;
pushUndo(p, t);
- const row: usize = @intCast(@max(0, t.cur_row - eb.row0));
+ const row: usize = @intCast(@max(0, at - eb.row0));
const arena = p.scratch.allocator();
const nl = arena.alloc(u8, cnt) catch return;
@memset(nl, '\n');
@@ -1449,10 +1831,14 @@ pub fn addNewline(p: *Pardes, t: *Text, below: bool, cnt: usize) void {
const new = modal.insertAt(p.gpa, eb.text, .{ .row = row, .col = if (below) llen else 0 }, nl) catch return;
setEditText(p, t, new);
if (!below) {
- t.cur_row += @intCast(cnt); // the line moved down; stay on it
- if (t.vsel.active and t.vsel.row >= @as(i32, @intCast(row))) t.vsel.row += @intCast(cnt);
- } else if (t.vsel.active and t.vsel.row > @as(i32, @intCast(row))) {
- t.vsel.row += @intCast(cnt);
+ // the whole selection moved down; stay on it
+ const n: i32 = @intCast(cnt);
+ t.cur_row += n;
+ if (t.vsel.active) t.vsel.row += n;
+ if (t.msel.active) {
+ t.msel.r0 += n;
+ t.msel.r1 += n;
+ }
}
t.cur_pinned = true;
t.show();
@@ -1472,6 +1858,25 @@ fn pairFor(ch: u21) ?struct { o: u8, c: u8 } {
pub fn textobjectSelect(p: *Pardes, t: *Text, text: []const u8, obj: u21, around: bool) void {
const range = t.primaryRange(text, 0);
+ // helix's tree-sitter objects, from the grammar's textobjects query
+ const object: ?modal.Normal.Object = switch (obj) {
+ 'f' => .function,
+ 't' => .class,
+ 'a' => .parameter,
+ 'c' => .comment,
+ 'T' => .@"test",
+ 'e' => .entry,
+ 'x' => .xml_element,
+ else => null,
+ };
+ if (object) |o| {
+ if (t.what != .body) return;
+ const pane = t.pane() orelse return;
+ const f = if (pane.file) |*file| file else return;
+ const tree = normal.fileTree(p, f) orelse return;
+ const s = syntax.objectAt(f.path, tree, text, o, around, modal.selectionCursor(text, range)) orelse return;
+ return t.setRange(text, 0, .{ .anchor = s.from, .head = s.to }, false);
+ }
switch (obj) {
'w', 'W' => {
const r = modal.selectWord(text, range, around, obj == 'W');
@@ -1512,16 +1917,22 @@ pub fn surroundAdd(p: *Pardes, t: *Text, ch: u21) void {
t.select = false;
const eb = editTextEol(p, t, selRows(t)) orelse return;
const r = selRange(t, eb.text, eb.row0);
+ // the wrapped range keeps the direction it had (helix with_direction)
+ const back = t.vsel.active and (t.cur_row < t.vsel.row or (t.cur_row == t.vsel.row and t.cur_col < t.vsel.col));
pushUndo(p, t);
const close_col = modal.nextGrapheme(modal.lineSlice(eb.text, r.b.row), r.b.col);
var new = modal.insertAt(p.gpa, eb.text, .{ .row = r.b.row, .col = close_col }, &[1]u8{pr.c}) catch return;
setEditText(p, t, new);
new = modal.insertAt(p.gpa, new, .{ .row = r.a.row, .col = r.a.col }, &[1]u8{pr.o}) catch return;
setEditText(p, t, new);
+ const open_row = @as(i32, @intCast(r.a.row)) + eb.row0;
+ const open_col: i32 = @intCast(r.a.col);
+ const close_row = @as(i32, @intCast(r.b.row)) + eb.row0;
+ const close_at: i32 = @intCast(close_col + @as(usize, if (r.a.row == r.b.row) 1 else 0));
t.msel.active = false;
- t.vsel = .{ .active = true, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false };
- t.cur_row = @as(i32, @intCast(r.b.row)) + eb.row0;
- t.cur_col = @intCast(close_col + @as(usize, if (r.a.row == r.b.row) 1 else 0));
+ t.vsel = .{ .active = true, .row = if (back) close_row else open_row, .col = if (back) close_at else open_col, .explicit = false };
+ t.cur_row = if (back) open_row else close_row;
+ t.cur_col = if (back) open_col else close_at;
t.cur_pinned = true;
t.sticky_col = -1;
t.show();
@@ -1760,16 +2171,20 @@ test "only the SPC clipboard commands cross to the system clipboard" {
_ = drainedEffect(p, .set_clipboard);
p.update(.{ .key = .{ .cp = 'y' } });
- try std.testing.expect(p.yank != null and p.yank.?.len > 0);
+ const plain = p.registers.text(gpa, Registers.default) orelse return error.MissingYank;
+ try std.testing.expect(plain.len > 0);
try std.testing.expect(!drainedEffect(p, .set_clipboard));
p.update(.{ .key = .{ .cp = 'd' } });
try std.testing.expect(!drainedEffect(p, .set_clipboard));
- // `SPC y` is the one that does, and it puts the SAME text there that `y`
- // put in the register — it is the ordinary yank path plus the mirror.
+ // `SPC y` is the one that does: helix's `"+y`, the ordinary yank into
+ // the clipboard register instead of the default one.
p.update(.{ .key = .{ .cp = ' ' } });
p.update(.{ .key = .{ .cp = 'y' } });
- const yanked = p.yank orelse return error.MissingYank;
+ const yanked = try gpa.dupe(u8, p.registers.text(gpa, Registers.default) orelse "");
+ defer gpa.free(yanked);
+ const clip: []const u8 = p.registers.text(gpa, '+') orelse "";
+ try std.testing.expect(clip.len > 0);
try std.testing.expect(drainedEffect(p, .set_clipboard));
const before = pane.file.?.content.len;
@@ -1783,14 +2198,14 @@ test "only the SPC clipboard commands cross to the system clipboard" {
try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "PASTED") != null);
// ...and it did NOT land in the register on its way past, nor echo back
// out to the clipboard it came from.
- try std.testing.expectEqualStrings(yanked, p.yank orelse "");
+ try std.testing.expectEqualStrings(yanked, p.registers.text(gpa, Registers.default) orelse "");
try std.testing.expect(!drainedEffect(p, .set_clipboard));
// An UNSOLICITED paste — the outer terminal's bracketed paste, a Cmd-V —
// is the same event with no request behind it, and means paste after.
p.update(.{ .paste = "UNASKED" });
try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "UNASKED") != null);
- try std.testing.expectEqualStrings(yanked, p.yank orelse "");
+ try std.testing.expectEqualStrings(yanked, p.registers.text(gpa, Registers.default) orelse "");
// A request the shell never answers dies at the next keystroke rather
// than firing late into whatever pane is focused by then.
diff --git a/src/effect_sources.zig b/src/effect_sources.zig
index 08680860..83bd1313 100644
--- a/src/effect_sources.zig
+++ b/src/effect_sources.zig
@@ -11,8 +11,9 @@ const ui_vertex_path = shader_dir ++ "ui.vert.glsl";
const ui_fragment_path = shader_dir ++ "ui.frag.glsl";
const image_vertex_path = shader_dir ++ "image.vert.glsl";
const image_fragment_path = shader_dir ++ "image.frag.glsl";
-const scene_vertex_path = shader_dir ++ "crt.vert.glsl";
-const scene_fragment_path = shader_dir ++ "crt.frag.glsl";
+const post_vertex_path = shader_dir ++ "post.vert.glsl";
+// The bundled post pass as compiled: the prefix and the pass together.
+const post_crt_path = shader_dir ++ "post-crt.frag.glsl";
pub const Backend = @TypeOf(build_config.platform);
pub const backend: Backend = build_config.platform;
@@ -45,13 +46,13 @@ pub const files = switch (backend) {
},
.gui => [_]filesystem.Source{
.{ .path = "src/gui/gui.zig", .contents = @embedFile("gui/gui.zig") },
- .{ .path = "src/gui/crt.zig", .contents = @embedFile("gui/crt.zig") },
+ .{ .path = "src/gui/Post.zig", .contents = @embedFile("gui/Post.zig") },
.{ .path = ui_vertex_path, .contents = @embedFile("effect-source-ui.vert.glsl") },
.{ .path = ui_fragment_path, .contents = @embedFile("effect-source-ui.frag.glsl") },
.{ .path = image_vertex_path, .contents = @embedFile("effect-source-image.vert.glsl") },
.{ .path = image_fragment_path, .contents = @embedFile("effect-source-image.frag.glsl") },
- .{ .path = scene_vertex_path, .contents = @embedFile("effect-source-crt.vert.glsl") },
- .{ .path = scene_fragment_path, .contents = @embedFile("effect-source-crt.frag.glsl") },
+ .{ .path = post_vertex_path, .contents = @embedFile("effect-source-post.vert.glsl") },
+ .{ .path = post_crt_path, .contents = @embedFile("effect-source-post-crt.frag.glsl") },
},
.macos => [_]filesystem.Source{
.{ .path = "src/macos/Sources/PardesView.swift", .contents = @embedFile("macos/Sources/PardesView.swift") },
@@ -69,8 +70,10 @@ pub fn forSetting(setting: config.Runtime.Setting) ?[]const []const u8 {
.macos => &.{ "src/layout.zig", "src/pardes.zig", "src/macos/Sources/PardesView.swift", "src/macos/Sources/ScenePostprocessor.swift", "shaders/crt.ci.metal" },
.web, .esp32p4 => null,
},
- .scene => switch (backend) {
- .gui => &.{ "src/gui/crt.zig", "src/gui/gui.zig", scene_vertex_path, scene_fragment_path },
+ .scene => |effect| switch (backend) {
+ .gui => switch (effect) {
+ .crt => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_crt_path },
+ },
.macos => &.{ "src/macos/Sources/PardesView.swift", "src/macos/Sources/ScenePostprocessor.swift", "shaders/crt.ci.metal" },
.tty, .web, .esp32p4 => null,
},
diff --git a/src/esp32p4.zig b/src/esp32p4.zig
index 45126a74..b7725b81 100644
--- a/src/esp32p4.zig
+++ b/src/esp32p4.zig
@@ -572,10 +572,9 @@ export fn pardes_esp32p4_tick(now_ms: u64) callconv(.c) void {
dirty = true;
}
}
- if (c.animationActive()) {
- c.update(.tick);
- dirty = true;
- }
+ // The core steps its own animations by this clock (`Host.now`).
+ c.advance(now_ms * std.time.ns_per_ms);
+ if (c.needs_frame) dirty = true;
}
/// How long a lone ESC waits for a second byte before it counts as the Escape key.
@@ -590,7 +589,7 @@ var esc_held_at: ?u64 = null;
export fn pardes_esp32p4_wants_frame() callconv(.c) bool {
const c = core orelse return false;
- return dirty or c.animationActive();
+ return dirty or c.needs_frame;
}
export fn pardes_esp32p4_render() callconv(.c) u32 {
@@ -607,7 +606,11 @@ export fn pardes_esp32p4_quit() callconv(.c) bool {
// ------------------------------------------------------------------------------------ the host
-const pardes_host: pardes.Host.VTable = .{ .present = present, .gpio_toggle = gpioToggle };
+const pardes_host: pardes.Host.VTable = .{ .now = boardNow, .present = present, .gpio_toggle = gpioToggle };
+
+fn boardNow(_: ?*anyopaque) u64 {
+ return last_now_ms * std.time.ns_per_ms;
+}
/// The `Gpio` word's one seam to the board. Nothing here knows what a pad is; it forwards, and
/// answers false when the firmware brought none, which is what puts "gpio: no pads" on the message
diff --git a/src/exec.zig b/src/exec.zig
index eb685c05..ee7c31e9 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -771,6 +771,10 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]
if (p.settings.column_tags != previous_column_tags) {
if (p.header_column != null) tagline.exitHeader(p);
}
+ // Idle frames keep no frozen grid while transitions are off; the one
+ // just drawn is the baseline the first transition starts from.
+ if (transition_changed and previous_transition == .off)
+ p.presentation.capturePrevious(p.gpa, &p.surface);
if (transition_changed or tagline_metrics_changed or p.settings.column_tags != previous_column_tags) {
p.abandonPanelAnimations();
}
@@ -835,7 +839,7 @@ test "the acme chords act once per selection, not once on the primary" {
.{ .anchor = 7, .head = 13 },
};
pane.body.setRanges(pane.file.?.content, &ranges, &.{}, 0, true);
- try std.testing.expectEqual(@as(u8, 1), pane.body.nsel);
+ try std.testing.expectEqual(@as(u16, 1), pane.body.nsel);
const before = p.ncol;
p.update(.{ .key = .{ .cp = Key.tab } }); // config.exec_key: Exec
@@ -843,7 +847,7 @@ test "the acme chords act once per selection, not once on the primary" {
// other cursor, so one keystroke over two cursors made one column.
try std.testing.expectEqual(before + 2, p.ncol);
// ...and the chord consumed the selection exactly as it does with one.
- try std.testing.expectEqual(@as(u8, 0), p.panes[0].?.body.nsel);
+ try std.testing.expectEqual(@as(u16, 0), p.panes[0].?.body.nsel);
}
test "a switch clicked with a selection held flips unless the selection says on or off" {
diff --git a/src/gui/Post.zig b/src/gui/Post.zig
new file mode 100644
index 00000000..f715965f
--- /dev/null
+++ b/src/gui/Post.zig
@@ -0,0 +1,576 @@
+//! The post chain (docs/render-pipeline.md §6): Shadertoy passes over the
+//! finished frame, each reading the one before it (the frame first) and the
+//! last one writing the window. A pass is the bundled Crt (shaders/post/,
+//! compiled with the build) or a user's file (Shader <path>),
+//! compiled when it joins the chain by the build's own glslc invocation on a
+//! thread of its own, never on the frame path. A file whose compile fails
+//! keeps its last good pipeline, and glslc missing is said once.
+//!
+//! The uniforms are ghostty's (Uniforms), so a shader written for ghostty runs
+//! here. Their clock is the shell's and moves only when the chain draws:
+//! with a frame of the core's, or on its own while ShaderAnimation asks for
+//! it (redraw level A, §5.5: the chain alone, over the retained frame).
+const std = @import("std");
+const libc = std.c;
+const ghostty_vt = @import("ghostty-vt");
+const pardes = @import("../pardes.zig");
+const filesystem = @import("../fs.zig");
+const gui = @import("gui.zig");
+const c = gui.c;
+
+const Post = @This();
+const Scene = pardes.config.Runtime.Scene;
+const Chain = pardes.config.Runtime.Post;
+const Pipeline = c.SDL_GPUGraphicsPipeline;
+pub const max = Chain.max;
+
+const prefix = @embedFile("post-prefix.glsl");
+const vert_spv = @embedFile("post.vert.spv");
+const bundled_spv = std.EnumArray(Scene, []const u8).init(.{
+ .crt = @embedFile("post-crt.frag.spv"),
+});
+
+/// ghostty's Shadertoy uniform block, member for member in the GLSL block's
+/// order and std140 layout (shaders/post/prefix.glsl). Filled by the GLSL
+/// names: ghostty's own Zig mirror calls the second-to-last slot
+/// selection_background, so there the two selection colours arrive swapped
+/// (docs §6, open point 1); here each is what its GLSL name says.
+pub const Uniforms = extern struct {
+ resolution: [3]f32 align(16) = .{ 0, 0, 1 },
+ time: f32 align(4) = 0,
+ time_delta: f32 align(4) = 0,
+ frame_rate: f32 align(4) = 60,
+ frame: i32 align(4) = 0,
+ channel_time: [4][4]f32 align(16) = @splat(@splat(0)),
+ channel_resolution: [4][4]f32 align(16) = @splat(@splat(0)),
+ mouse: [4]f32 align(16) = @splat(0),
+ date: [4]f32 align(16) = @splat(0),
+ sample_rate: f32 align(4) = 44100,
+ current_cursor: [4]f32 align(16) = @splat(0),
+ previous_cursor: [4]f32 align(16) = @splat(0),
+ current_cursor_color: [4]f32 align(16) = @splat(0),
+ previous_cursor_color: [4]f32 align(16) = @splat(0),
+ current_cursor_style: i32 align(4) = 0,
+ previous_cursor_style: i32 align(4) = 0,
+ cursor_visible: i32 align(4) = 0,
+ cursor_change_time: f32 align(4) = 0,
+ time_focus: f32 align(4) = 0,
+ focus: i32 align(4) = 1,
+ palette: [256][4]f32 align(16) = @splat(@splat(0)),
+ background_color: [4]f32 align(16) = @splat(0),
+ foreground_color: [4]f32 align(16) = @splat(0),
+ cursor_color: [4]f32 align(16) = @splat(0),
+ cursor_text: [4]f32 align(16) = @splat(0),
+ selection_foreground_color: [4]f32 align(16) = @splat(0),
+ selection_background_color: [4]f32 align(16) = @splat(0),
+};
+
+pub const Pass = struct {
+ scene: ?Scene = null,
+ /// A bundled pass's strength (pardesLevel); 0 for a file.
+ level: u8 = 0,
+ /// A user's file, owned; empty for a bundled pass.
+ path: []u8 = &.{},
+ pipeline: ?*Pipeline = null,
+};
+
+/// One compile of a chain's new files, on its own thread.
+const Job = struct {
+ const Result = union(enum) { none, spirv: []u8, failed: []u8, missing };
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ paths: [max][]u8 = @splat(&.{}),
+ results: [max]Result = @splat(.none),
+ len: usize = 0,
+ done: std.atomic.Value(bool) = .init(false),
+ thread: std.Thread = undefined,
+};
+
+passes: [max]Pass = @splat(.{}),
+len: usize = 0,
+/// The chain generation `passes` follow; null before the first.
+generation: ?u32 = null,
+bundled: std.EnumArray(Scene, ?*Pipeline) = .initFill(null),
+job: ?*Job = null,
+/// Said once: without glslc, files never compile.
+missing_said: bool = false,
+ping: [2]?*c.SDL_GPUTexture = .{ null, null },
+ping_w: u32 = 0,
+ping_h: u32 = 0,
+uniforms: Uniforms = .{},
+started_ns: ?u64 = null,
+last_ns: u64 = 0,
+
+/// Brings the passes to the chain the core holds, and takes in a finished
+/// compile. Called each loop step, off the frame path.
+pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, core: *pardes.Pardes) void {
+ if (post.job) |job| {
+ if (!job.done.load(.acquire)) return;
+ job.thread.join();
+ post.job = null;
+ post.collect(gpa, device, format, core, job);
+ }
+ const chain = &core.settings.post;
+ if (post.generation == chain.generation) return;
+ post.generation = chain.generation;
+ var next: [max]Pass = @splat(.{});
+ for (chain.list(), 0..) |entry, i| {
+ if (entry.scene) |scene| {
+ if (post.bundled.get(scene) == null) post.bundled.set(scene, makePipeline(device, format, bundled_spv.get(scene)) catch null);
+ next[i] = .{ .scene = scene, .level = entry.level, .pipeline = post.bundled.get(scene) };
+ continue;
+ }
+ // A file already in the chain keeps its pipeline and its path.
+ for (post.passes[0..post.len]) |*pass| {
+ if (pass.scene != null or !std.mem.eql(u8, pass.path, entry.path.get())) continue;
+ next[i] = pass.*;
+ pass.* = .{};
+ break;
+ } else next[i] = .{ .path = gpa.dupe(u8, entry.path.get()) catch &.{} };
+ }
+ for (post.passes[0..post.len]) |pass| {
+ if (pass.scene == null) if (pass.pipeline) |pipeline| c.SDL_ReleaseGPUGraphicsPipeline(device, pipeline);
+ gpa.free(pass.path);
+ }
+ post.passes = next;
+ post.len = chain.len;
+ // An emptied chain's clock starts again with the next pass put in it.
+ if (post.len == 0) {
+ post.started_ns = null;
+ post.last_ns = 0;
+ post.uniforms.frame = 0;
+ }
+ // The new files compile together, on a thread of their own.
+ const job = gpa.create(Job) catch return;
+ job.* = .{ .io = io, .gpa = gpa };
+ for (post.passes[0..post.len]) |pass| {
+ if (pass.scene != null or pass.pipeline != null or pass.path.len == 0) continue;
+ job.paths[job.len] = gpa.dupe(u8, pass.path) catch continue;
+ job.len += 1;
+ }
+ if (job.len == 0) return gpa.destroy(job);
+ job.thread = std.Thread.spawn(.{}, compile, .{job}) catch {
+ for (job.paths[0..job.len]) |path| gpa.free(path);
+ return gpa.destroy(job);
+ };
+ post.job = job;
+}
+
+fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, core: *pardes.Pardes, job: *Job) void {
+ defer gpa.destroy(job);
+ for (job.paths[0..job.len], job.results[0..job.len]) |path, result| {
+ defer gpa.free(path);
+ const pass = for (post.passes[0..post.len]) |*pass| {
+ if (pass.scene == null and std.mem.eql(u8, pass.path, path)) break pass;
+ } else null;
+ var buf: [512]u8 = undefined;
+ switch (result) {
+ .none => {},
+ .missing => if (!post.missing_said) {
+ post.missing_said = true;
+ core.setMessage(core.active, "Shader: glslc is not on PATH, so Shadertoy files do not run (Crt does)");
+ },
+ .failed => |text| {
+ defer gpa.free(text);
+ // glslc's first line says where: in the file's own lines.
+ const first = std.mem.sliceTo(text, '\n');
+ core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: {s}", .{ path, first[0..@min(first.len, 400)] }) catch "Shader: compile failed");
+ },
+ .spirv => |spirv| {
+ defer gpa.free(spirv);
+ const target = pass orelse continue;
+ const made = makePipeline(device, format, spirv) catch {
+ core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: the GPU refused it", .{path}) catch "Shader: the GPU refused it");
+ continue;
+ };
+ if (target.pipeline) |old| c.SDL_ReleaseGPUGraphicsPipeline(device, old);
+ target.pipeline = made;
+ },
+ }
+ }
+}
+
+/// The compile thread: each file behind the prefix, through glslc with the
+/// build's own flags (build.zig compileGlsl), source on stdin, SPIR-V on
+/// stdout.
+fn compile(job: *Job) void {
+ defer job.done.store(true, .release);
+ defer {
+ var event = std.mem.zeroes(c.SDL_Event);
+ event.type = c.SDL_EVENT_USER;
+ _ = c.SDL_PushEvent(&event);
+ }
+ for (job.paths[0..job.len], 0..) |path, i| job.results[i] = compileOne(job.gpa, job.io, path);
+}
+
+fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8) Job.Result {
+ var home_buf: [4096]u8 = undefined;
+ const expanded = if (std.mem.startsWith(u8, path, "~/")) if (libc.getenv("HOME")) |home|
+ std.fmt.bufPrint(&home_buf, "{s}/{s}", .{ std.mem.span(home), path[2..] }) catch path
+ else
+ path else path;
+ const body = filesystem.readFile(gpa, expanded) catch |err|
+ return .{ .failed = std.fmt.allocPrint(gpa, "cannot read it ({t})", .{err}) catch return .none };
+ defer gpa.free(body);
+ // The prefix and the file go to glslc on its stdin: no file of ours is
+ // written anywhere. std's spawn does every allocation before fork and
+ // searches PATH on the stack after it (Io/Threaded.zig spawnPosix,
+ // posixExecv), so a child forked from this thread cannot wedge on a
+ // lock another thread held (the forkShell hazard).
+ var child = std.process.spawn(io, .{
+ .argv = &.{ "glslc", "-fshader-stage=fragment", "-o", "-", "-" },
+ .stdin = .pipe,
+ .stdout = .pipe,
+ .stderr = .pipe,
+ }) catch |err| return switch (err) {
+ error.FileNotFound => .missing,
+ else => .{ .failed = std.fmt.allocPrint(gpa, "glslc did not run ({t})", .{err}) catch return .none },
+ };
+ defer child.kill(io);
+ // glslc reads all of its input before it writes a byte, so writing it
+ // first cannot deadlock against a full output pipe.
+ {
+ var stdin = child.stdin.?;
+ child.stdin = null;
+ defer stdin.close(io);
+ var buf: [4096]u8 = undefined;
+ var writer = stdin.writer(io, &buf);
+ writer.interface.writeAll(prefix) catch {};
+ writer.interface.writeAll(body) catch {};
+ writer.interface.flush() catch {};
+ }
+ var streams: std.Io.File.MultiReader.Buffer(2) = undefined;
+ var reader: std.Io.File.MultiReader = undefined;
+ reader.init(gpa, io, streams.toStreams(), &.{ child.stdout.?, child.stderr.? });
+ defer reader.deinit();
+ while (reader.fill(64, .none)) |_| {
+ if (reader.reader(0).buffered().len > 16 << 20 or reader.reader(1).buffered().len > 64 << 10)
+ return .{ .failed = gpa.dupe(u8, "glslc said too much") catch return .none };
+ } else |err| switch (err) {
+ error.EndOfStream => {},
+ else => return .{ .failed = gpa.dupe(u8, "reading glslc failed") catch return .none },
+ }
+ const term = child.wait(io) catch return .{ .failed = gpa.dupe(u8, "glslc did not finish") catch return .none };
+ const clean = switch (term) {
+ .exited => |code| code == 0,
+ else => false,
+ };
+ if (clean) return .{ .spirv = reader.toOwnedSlice(0) catch return .none };
+ const said = reader.toOwnedSlice(1) catch return .none;
+ // glslc calls its input <stdin>; the file is what a person knows.
+ defer gpa.free(said);
+ const named = std.mem.replaceOwned(u8, gpa, said, "<stdin>", path) catch return .{ .failed = gpa.dupe(u8, "glslc failed") catch return .none };
+ return .{ .failed = named };
+}
+
+fn makePipeline(device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, frag: []const u8) !*Pipeline {
+ const vs = try gui.makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0);
+ defer c.SDL_ReleaseGPUShader(device, vs);
+ const fs = try gui.makeShader(device, frag, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 2);
+ defer c.SDL_ReleaseGPUShader(device, fs);
+ var target = c.SDL_GPUColorTargetDescription{ .format = format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) };
+ var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo);
+ info.vertex_shader = vs;
+ info.fragment_shader = fs;
+ info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST;
+ info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL;
+ info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE;
+ info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ info.target_info = .{ .color_target_descriptions = &target, .num_color_targets = 1 };
+ return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
+}
+
+/// A pass is ready to draw.
+pub fn ready(post: *const Post) bool {
+ for (post.passes[0..post.len]) |pass| if (pass.pipeline != null) return true;
+ return false;
+}
+
+/// The chain redraws on its own (level A) as ShaderAnimation says.
+pub fn animating(post: *const Post, mode: pardes.config.Runtime.ShaderAnimation) bool {
+ return post.ready() and switch (mode) {
+ .off => false,
+ .on => post.uniforms.focus != 0,
+ .always => true,
+ };
+}
+
+/// What a frame of the core's tells the passes: where its cursor is drawn
+/// and in what, and the theme's colours.
+pub const Frame = struct {
+ pub const Cursor = struct { x0: f32, y0: f32, x1: f32, y1: f32, rgb: [3]u8, text: [3]u8, bar: bool };
+ cursor: ?Cursor,
+ background: [3]u8,
+ foreground: [3]u8,
+ selection_foreground: [3]u8,
+ selection_background: [3]u8,
+};
+
+fn rgba(rgb: [3]u8) [4]f32 {
+ return .{ @as(f32, @floatFromInt(rgb[0])) / 255, @as(f32, @floatFromInt(rgb[1])) / 255, @as(f32, @floatFromInt(rgb[2])) / 255, 1 };
+}
+
+/// Takes in a frame of the core's. The cursor's rectangle is (left, BOTTOM
+/// edge, width, height) in framebuffer pixels, y down; a move or a new
+/// colour makes the old one the previous and stamps the change.
+pub fn describe(post: *Post, frame: Frame) void {
+ const u = &post.uniforms;
+ u.background_color = rgba(frame.background);
+ u.foreground_color = rgba(frame.foreground);
+ u.selection_foreground_color = rgba(frame.selection_foreground);
+ u.selection_background_color = rgba(frame.selection_background);
+ if (u.palette[1][3] == 0) for (&u.palette, 0..) |*slot, i| {
+ const color = ghostty_vt.color.default[i];
+ slot.* = rgba(.{ color.r, color.g, color.b });
+ };
+ u.cursor_visible = @intFromBool(frame.cursor != null);
+ const cursor = frame.cursor orelse return;
+ const rect: [4]f32 = .{ cursor.x0, cursor.y1, cursor.x1 - cursor.x0, cursor.y1 - cursor.y0 };
+ const style: i32 = if (cursor.bar) 2 else 0;
+ const color = rgba(cursor.rgb);
+ u.cursor_color = color;
+ u.cursor_text = rgba(cursor.text);
+ if (std.meta.eql(rect, u.current_cursor) and std.meta.eql(color, u.current_cursor_color) and style == u.current_cursor_style) return;
+ u.previous_cursor = u.current_cursor;
+ u.previous_cursor_color = u.current_cursor_color;
+ u.previous_cursor_style = u.current_cursor_style;
+ u.current_cursor = rect;
+ u.current_cursor_color = color;
+ u.current_cursor_style = style;
+ u.cursor_change_time = u.time;
+}
+
+/// The window gained or lost the focus.
+pub fn focus(post: *Post, focused: bool) void {
+ if (focused and post.uniforms.focus == 0) post.uniforms.time_focus = post.uniforms.time;
+ post.uniforms.focus = @intFromBool(focused);
+}
+
+/// The pointer, in framebuffer pixels: Shadertoy's iMouse, xy where it is,
+/// zw where the last press was, negative once released.
+pub fn mouse(post: *Post, x: f32, y: f32, press: ?bool) void {
+ const m = &post.uniforms.mouse;
+ m[0] = x;
+ m[1] = y;
+ if (press) |down| {
+ if (down) {
+ m[2] = x;
+ m[3] = y;
+ } else {
+ m[2] = -@abs(m[2]);
+ m[3] = -@abs(m[3]);
+ }
+ }
+}
+
+/// Runs the passes over `source`, the finished frame, into `target`: each
+/// reads the one before, the ones between write two ping-pong textures that
+/// start cleared, and iTime and iFrame move by this one draw.
+pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, sampler: *c.SDL_GPUSampler, format: c.SDL_GPUTextureFormat, source: *c.SDL_GPUTexture, target: *c.SDL_GPUTexture, w: u32, h: u32, now_ns: u64, opaque_window: bool) !void {
+ var live: usize = 0;
+ for (post.passes[0..post.len]) |pass| live += @intFromBool(pass.pipeline != null);
+ if (live == 0) return;
+ if (live > 1 and (post.ping[0] == null or post.ping_w != w or post.ping_h != h)) {
+ for (&post.ping) |*texture| {
+ if (texture.*) |old| c.SDL_ReleaseGPUTexture(device, old);
+ var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo);
+ info.type = c.SDL_GPU_TEXTURETYPE_2D;
+ info.format = format;
+ info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER;
+ info.width = w;
+ info.height = h;
+ info.layer_count_or_depth = 1;
+ info.num_levels = 1;
+ info.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ texture.* = c.SDL_CreateGPUTexture(device, &info) orelse return error.GpuCreate;
+ }
+ post.ping_w = w;
+ post.ping_h = h;
+ }
+ const u = &post.uniforms;
+ const started = post.started_ns orelse now_ns;
+ post.started_ns = started;
+ const elapsed = @as(f64, @floatFromInt(now_ns -| started)) / std.time.ns_per_s;
+ const delta: f32 = if (post.last_ns == 0) 0 else @floatCast(@as(f64, @floatFromInt(now_ns -| post.last_ns)) / std.time.ns_per_s);
+ post.last_ns = now_ns;
+ u.time = @floatCast(elapsed);
+ u.time_delta = delta;
+ u.frame_rate = if (delta > 0) 1 / delta else 60;
+ u.resolution = .{ @floatFromInt(w), @floatFromInt(h), 1 };
+ u.channel_resolution[0] = .{ @floatFromInt(w), @floatFromInt(h), 1, 0 };
+ // UTC: the year, the month from 0, the day, the second of the day.
+ var wall: libc.timespec = undefined;
+ if (libc.clock_gettime(.REALTIME, &wall) != 0) wall.sec = 0;
+ const epoch: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(0, wall.sec)) };
+ const day = epoch.getEpochDay().calculateYearDay();
+ const month = day.calculateMonthDay();
+ u.date = .{ @floatFromInt(day.year), @floatFromInt(month.month.numeric() - 1), @floatFromInt(month.day_index + 1), @floatFromInt(epoch.getDaySeconds().secs) };
+
+ var input = source;
+ var index: usize = 0;
+ for (post.passes[0..post.len]) |pass| {
+ const pipeline = pass.pipeline orelse continue;
+ index += 1;
+ const last = index == live;
+ const output = if (last) target else post.ping[index % 2].?;
+ var info = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
+ info.texture = output;
+ info.load_op = if (last) c.SDL_GPU_LOADOP_DONT_CARE else c.SDL_GPU_LOADOP_CLEAR;
+ info.store_op = c.SDL_GPU_STOREOP_STORE;
+ const pass_rp = c.SDL_BeginGPURenderPass(cmd, &info, 1, null);
+ c.SDL_BindGPUGraphicsPipeline(pass_rp, pipeline);
+ const binding = c.SDL_GPUTextureSamplerBinding{ .texture = input, .sampler = sampler };
+ c.SDL_BindGPUFragmentSamplers(pass_rp, 0, &binding, 1);
+ c.SDL_PushGPUFragmentUniformData(cmd, 0, u, @sizeOf(Uniforms));
+ const own: [4]f32 = .{ @floatFromInt(@intFromBool(opaque_window)), @floatFromInt(pass.level), 0, 0 };
+ c.SDL_PushGPUFragmentUniformData(cmd, 1, &own, @sizeOf(@TypeOf(own)));
+ c.SDL_DrawGPUPrimitives(pass_rp, 3, 1, 0, 0);
+ c.SDL_EndGPURenderPass(pass_rp);
+ input = output;
+ }
+ u.frame +%= 1;
+}
+
+pub fn deinit(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice) void {
+ if (post.job) |job| {
+ job.thread.join();
+ for (job.paths[0..job.len], job.results[0..job.len]) |path, result| {
+ gpa.free(path);
+ switch (result) {
+ .spirv, .failed => |bytes| gpa.free(bytes),
+ .none, .missing => {},
+ }
+ }
+ gpa.destroy(job);
+ }
+ for (post.passes[0..post.len]) |pass| {
+ if (pass.scene == null) if (pass.pipeline) |pipeline| c.SDL_ReleaseGPUGraphicsPipeline(device, pipeline);
+ gpa.free(pass.path);
+ }
+ for (post.bundled.values) |pipeline| if (pipeline) |p| c.SDL_ReleaseGPUGraphicsPipeline(device, p);
+ for (post.ping) |texture| if (texture) |t| c.SDL_ReleaseGPUTexture(device, t);
+}
+
+/// ghostty 1.3.2's Uniforms (src/renderer/shadertoy.zig), copied: the offsets
+/// ours must match. Its last two names are swapped against its own GLSL.
+const GhosttyUniforms = extern struct {
+ resolution: [3]f32 align(16),
+ time: f32 align(4),
+ time_delta: f32 align(4),
+ frame_rate: f32 align(4),
+ frame: i32 align(4),
+ channel_time: [4][4]f32 align(16),
+ channel_resolution: [4][4]f32 align(16),
+ mouse: [4]f32 align(16),
+ date: [4]f32 align(16),
+ sample_rate: f32 align(4),
+ current_cursor: [4]f32 align(16),
+ previous_cursor: [4]f32 align(16),
+ current_cursor_color: [4]f32 align(16),
+ previous_cursor_color: [4]f32 align(16),
+ current_cursor_style: i32 align(4),
+ previous_cursor_style: i32 align(4),
+ cursor_visible: i32 align(4),
+ cursor_change_time: f32 align(4),
+ time_focus: f32 align(4),
+ focus: i32 align(4),
+ palette: [256][4]f32 align(16),
+ background_color: [4]f32 align(16),
+ foreground_color: [4]f32 align(16),
+ cursor_color: [4]f32 align(16),
+ cursor_text: [4]f32 align(16),
+ selection_background_color: [4]f32 align(16),
+ selection_foreground_color: [4]f32 align(16),
+};
+
+test "the uniform block is ghostty's, offset for offset, and the prefix names it in that order" {
+ const ours = @typeInfo(Uniforms).@"struct".fields;
+ const theirs = @typeInfo(GhosttyUniforms).@"struct".fields;
+ try std.testing.expectEqual(theirs.len, ours.len);
+ try std.testing.expectEqual(@sizeOf(GhosttyUniforms), @sizeOf(Uniforms));
+ inline for (ours, theirs) |a, b| {
+ try std.testing.expectEqual(@offsetOf(GhosttyUniforms, b.name), @offsetOf(Uniforms, a.name));
+ try std.testing.expectEqual(b.type, a.type);
+ }
+ // By NAME: iSelectionForegroundColor sits where ghostty's Zig says
+ // selection_background, which is where its GLSL puts the foreground.
+ try std.testing.expectEqual(@offsetOf(GhosttyUniforms, "selection_background_color"), @offsetOf(Uniforms, "selection_foreground_color"));
+ // The prefix's block, member by member, in ghostty's GLSL names and types.
+ const glsl = [_]struct { []const u8, []const u8 }{
+ .{ "vec3", "iResolution" }, .{ "float", "iTime" }, .{ "float", "iTimeDelta" },
+ .{ "float", "iFrameRate" }, .{ "int", "iFrame" }, .{ "float", "iChannelTime[4]" },
+ .{ "vec3", "iChannelResolution[4]" }, .{ "vec4", "iMouse" }, .{ "vec4", "iDate" },
+ .{ "float", "iSampleRate" }, .{ "vec4", "iCurrentCursor" }, .{ "vec4", "iPreviousCursor" },
+ .{ "vec4", "iCurrentCursorColor" }, .{ "vec4", "iPreviousCursorColor" }, .{ "int", "iCurrentCursorStyle" },
+ .{ "int", "iPreviousCursorStyle" }, .{ "int", "iCursorVisible" }, .{ "float", "iTimeCursorChange" },
+ .{ "float", "iTimeFocus" }, .{ "int", "iFocus" }, .{ "vec3", "iPalette[256]" },
+ .{ "vec3", "iBackgroundColor" }, .{ "vec3", "iForegroundColor" }, .{ "vec3", "iCursorColor" },
+ .{ "vec3", "iCursorText" }, .{ "vec3", "iSelectionForegroundColor" }, .{ "vec3", "iSelectionBackgroundColor" },
+ };
+ try std.testing.expectEqual(ours.len, glsl.len);
+ const block = prefix[std.mem.indexOf(u8, prefix, "uniform Globals {").?..];
+ var lines = std.mem.tokenizeScalar(u8, block[0..std.mem.indexOfScalar(u8, block, '}').?], '\n');
+ _ = lines.next();
+ for (glsl) |member| {
+ var words = std.mem.tokenizeAny(u8, lines.next().?, " ;");
+ try std.testing.expectEqualStrings("uniform", words.next().?);
+ try std.testing.expectEqualStrings(member[0], words.next().?);
+ try std.testing.expectEqualStrings(member[1], words.next().?);
+ }
+ try std.testing.expect(lines.next() == null);
+}
+
+test "a moved cursor becomes the previous one, stamped with the time of the move" {
+ var post: Post = .{};
+ const frame: Frame = .{ .cursor = .{ .x0 = 10, .y0 = 20, .x1 = 11, .y1 = 40, .rgb = .{ 255, 0, 0 }, .text = .{ 0, 0, 0 }, .bar = true }, .background = .{ 0, 0, 0 }, .foreground = .{ 255, 255, 255 }, .selection_foreground = .{ 1, 2, 3 }, .selection_background = .{ 4, 5, 6 } };
+ post.uniforms.time = 1.5;
+ post.describe(frame);
+ // Left, BOTTOM edge, width, height, in framebuffer pixels, y down.
+ try std.testing.expectEqual([4]f32{ 10, 40, 1, 20 }, post.uniforms.current_cursor);
+ try std.testing.expectEqual(@as(i32, 2), post.uniforms.current_cursor_style);
+ try std.testing.expectEqual(@as(f32, 1.5), post.uniforms.cursor_change_time);
+ post.uniforms.time = 2;
+ post.describe(frame);
+ try std.testing.expectEqual(@as(f32, 1.5), post.uniforms.cursor_change_time);
+ var moved = frame;
+ moved.cursor.?.x0 = 20;
+ moved.cursor.?.x1 = 21;
+ post.describe(moved);
+ try std.testing.expectEqual([4]f32{ 10, 40, 1, 20 }, post.uniforms.previous_cursor);
+ try std.testing.expectEqual(@as(f32, 2), post.uniforms.cursor_change_time);
+ try std.testing.expectEqual([4]f32{ 1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 1 }, post.uniforms.selection_foreground_color);
+}
+
+test "ghostty's test shaders compile behind the prefix, and its invalid one fails with glslc's words" {
+ const gpa = std.testing.allocator;
+ const io = std.testing.io;
+ // ghostty's own test shaders, from its package in zig-pkg.
+ var pkg = std.Io.Dir.cwd().openDir(io, "zig-pkg", .{ .iterate = true }) catch return error.SkipZigTest;
+ defer pkg.close(io);
+ var it = pkg.iterate();
+ const ghostty = while (it.next(io) catch null) |entry| {
+ if (std.mem.startsWith(u8, entry.name, "ghostty-")) break entry.name;
+ } else return error.SkipZigTest;
+ var buf: [512]u8 = undefined;
+ for ([_][]const u8{ "crt", "focus", "invalid" }) |name| {
+ const path = try std.fmt.bufPrint(&buf, "zig-pkg/{s}/src/renderer/shaders/test_shadertoy_{s}.glsl", .{ ghostty, name });
+ const result = compileOne(gpa, io, path);
+ switch (result) {
+ .missing => return error.SkipZigTest, // no glslc here
+ .spirv => |spirv| {
+ defer gpa.free(spirv);
+ try std.testing.expect(!std.mem.eql(u8, name, "invalid"));
+ // SPIR-V's magic number, little-endian.
+ try std.testing.expectEqual(@as(u32, 0x07230203), std.mem.readInt(u32, spirv[0..4], .little));
+ },
+ .failed => |text| {
+ defer gpa.free(text);
+ try std.testing.expectEqualStrings("invalid", name);
+ // Named by the file, not by glslc's <stdin>.
+ try std.testing.expect(std.mem.indexOf(u8, text, path) != null);
+ try std.testing.expect(std.mem.indexOf(u8, text, "<stdin>") == null);
+ },
+ .none => return error.TestUnexpectedResult,
+ }
+ }
+}
diff --git a/src/gui/crt.zig b/src/gui/crt.zig
deleted file mode 100644
index 2a5ea7e4..00000000
--- a/src/gui/crt.zig
+++ /dev/null
@@ -1,101 +0,0 @@
-//! The Crt builtin's screen geometry, shared between the gui shell's input
-//! mapping and its shader: crt.frag.glsl shows the scene point
-//! uv' = cc*(1 + barrel*r2) at screen point cc, so a physical mouse position
-//! must pass through the SAME forward transform before it becomes a cell.
-//! Pure std, so the unit-test step can pin it against the shader formula.
-
-const std = @import("std");
-
-/// MUST match BARREL in shaders/crt.frag.glsl.
-pub const barrel: f32 = 0.016;
-
-pub const Point = struct { x: f32, y: f32 };
-pub const Effects = struct { crt: bool = false, ripple: bool = false, glitch: bool = false };
-pub const Frame = struct { effects: Effects = .{}, time_seconds: f32 = 0 };
-
-fn fract(x: f32) f32 {
- return x - @floor(x);
-}
-
-fn smoothstep(lo: f32, hi: f32, value: f32) f32 {
- const t = std.math.clamp((value - lo) / (hi - lo), 0, 1);
- return t * t * (3 - 2 * t);
-}
-
-/// Match `sceneUv` in crt.frag.glsl. This is a forward screen→sample mapping:
-/// a physical pointer is sent to the logical scene pixel displayed under it.
-pub fn mapScene(x: f32, y: f32, w: f32, h: f32, effects: Effects, time_seconds: f32) ?Point {
- const safe_w = @max(1, w);
- const safe_h = @max(1, h);
- const ccx = x / safe_w * 2.0 - 1.0;
- const ccy = y / safe_h * 2.0 - 1.0;
- const s = 1.0 + barrel * (ccx * ccx + ccy * ccy) * @as(f32, @floatFromInt(@intFromBool(effects.crt)));
- var u = ccx * s * 0.5 + 0.5;
- var v = ccy * s * 0.5 + 0.5;
- // The CRT shader paints no scene when this barrel-mapped coordinate lies
- // on or beyond the tube edge. There is no logical edge cell under those
- // black pixels, so input must be inert there rather than clamped.
- if (effects.crt and (u <= 0 or u >= 1 or v <= 0 or v >= 1)) return null;
- if (effects.ripple) {
- const cx = u - 0.5;
- const cy = v - 0.5;
- const radius = @sqrt(cx * cx + cy * cy);
- const envelope = 1.0 - smoothstep(0.04, 0.72, radius);
- const wave = @sin(radius * 42.0 - time_seconds * 5.2);
- const len = @max(radius, 0.00001);
- u += cx / len * wave * envelope * 0.0032;
- v += cy / len * wave * envelope * 0.0032;
- }
- if (effects.glitch) {
- const band = @floor(v * 96.0);
- const tick = @floor(time_seconds * 13.0);
- const seed = noiseAt(band, tick);
- const tear = smoothstep(0.955, 1.0, seed);
- u += (noiseAt(seed, band) - 0.5) * tear * 0.020;
- }
- return .{
- .x = std.math.clamp(u * safe_w, 0, safe_w - 1),
- .y = std.math.clamp(v * safe_h, 0, safe_h - 1),
- };
-}
-
-fn noiseAt(x: f32, y: f32) f32 {
- var qx = fract(x * 0.1031);
- var qy = fract(y * 0.1031);
- var qz = fract(x * 0.1031);
- const d = qx * (qy + 33.33) + qy * (qz + 33.33) + qz * (qx + 33.33);
- qx += d;
- qy += d;
- qz += d;
- return fract((qx + qy) * qz);
-}
-
-test "map matches the shader's barrel formula" {
- // center is a fixed point
- const center = mapScene(500, 250, 1000, 500, .{ .crt = true }, 0).?;
- try std.testing.expectApproxEqAbs(@as(f32, 500), center.x, 0.001);
- try std.testing.expectApproxEqAbs(@as(f32, 250), center.y, 0.001);
- // Corners barrel beyond the tube and are black/inert in the shader.
- try std.testing.expect(mapScene(0, 0, 1000, 500, .{ .crt = true }, 0) == null);
- try std.testing.expect(mapScene(1000, 500, 1000, 500, .{ .crt = true }, 0) == null);
- // (100,100) of 1000x500: cc=(-0.8,-0.6), r2=1.0, s=1.016 ->
- // cc'=(-0.8128,-0.6096) -> (93.6, 97.6)
- const near_edge = mapScene(100, 100, 1000, 500, .{ .crt = true }, 0).?;
- try std.testing.expectApproxEqAbs(@as(f32, 93.6), near_edge.x, 0.01);
- try std.testing.expectApproxEqAbs(@as(f32, 97.6), near_edge.y, 0.01);
- // (900,250): cc=(0.8,0), r2=0.64, s=1.01024 -> x'=904.096
- const side = mapScene(900, 250, 1000, 500, .{ .crt = true }, 0).?;
- try std.testing.expectApproxEqAbs(@as(f32, 904.096), side.x, 0.01);
-}
-
-test "scene mapping composes ripple and deterministic glitch" {
- const plain = mapScene(300, 200, 800, 600, .{}, 2.5).?;
- try std.testing.expectApproxEqAbs(@as(f32, 300), plain.x, 0.001);
- try std.testing.expectApproxEqAbs(@as(f32, 200), plain.y, 0.001);
- const ripple = mapScene(300, 200, 800, 600, .{ .ripple = true }, 2.5).?;
- try std.testing.expect(ripple.x != plain.x or ripple.y != plain.y);
- try std.testing.expectEqual(
- mapScene(300, 200, 800, 600, .{ .glitch = true }, 2.5).?,
- mapScene(300, 200, 800, 600, .{ .glitch = true }, 2.5).?,
- );
-}
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index cf708a0e..5bc1ea6d 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -12,7 +12,7 @@ const look = @import("../look.zig");
const message = pardes.Messages.Message;
const file_watch = @import("../file_watch.zig");
const deck = @import("deck.zig");
-const crt = @import("crt.zig");
+const Post = @import("Post.zig");
const pet = @import("pet.zig");
const fonts = @import("../fonts.zig"); // the Font builtin's half of the seam
const selection_pipe = @import("../selection_pipe.zig");
@@ -21,6 +21,7 @@ const c_heap = @import("c_heap");
const ninep_io = @import("../9p_io.zig");
const detached_client = @import("../detached/client.zig");
+const tracy = @import("../tracy.zig");
pub const c = @cImport({
@cDefine("SDL_DISABLE_OLD_NAMES", "1");
@@ -37,12 +38,11 @@ const log = std.log.scoped(.gui);
const font_ttf = @embedFile("AdwaitaMono-Regular.ttf");
const vert_spv = @embedFile("ui.vert.spv");
const frag_spv = @embedFile("ui.frag.spv");
+const decor_frag_spv = @embedFile("decor.frag.spv");
const overlay_vert_spv = @embedFile("overlay.vert.spv");
const overlay_frag_spv = @embedFile("overlay.frag.spv");
const image_vert_spv = @embedFile("image.vert.spv");
const image_frag_spv = @embedFile("image.frag.spv");
-const crt_vert_spv = @embedFile("crt.vert.spv");
-const crt_frag_spv = @embedFile("crt.frag.spv");
const bg_default = [3]u8{ 18, 18, 18 };
const fg_default = [3]u8{ 204, 204, 204 };
@@ -200,55 +200,17 @@ const touch_click_flash_max_frames: u8 = 14;
const touch_click_flash_vertices: usize = 1400;
const touch_scroll_tick: f32 = 0.02;
-const AnimationClock = struct {
- next_ns: u64 = 0,
-
- fn due(clock: *AnimationClock, active: bool, now_ns: u64) bool {
- if (!active) {
- clock.next_ns = 0;
- return false;
- }
- if (clock.next_ns == 0) {
- clock.next_ns = now_ns +| pardes.layout.Animation.frame_ns;
- return false;
- }
- if (now_ns < clock.next_ns) return false;
- clock.next_ns = now_ns +| pardes.layout.Animation.frame_ns;
- return true;
- }
-};
-
-fn finishPresentedAnimationFrame(
- clock: *AnimationClock,
- core: *pardes.Pardes,
- tracks: []const pardes.layout.Track,
- now_ns: u64,
-) void {
- core.acknowledgePanelPresentation(tracks);
- if (clock.due(core.animationActive(), now_ns)) core.update(.tick);
-}
-
-test "GUI animation clock is active-only and cadence gated" {
- var clock: AnimationClock = .{};
- try std.testing.expect(!clock.due(false, 100));
- try std.testing.expect(!clock.due(true, 100));
- try std.testing.expect(!clock.due(true, 100 + pardes.layout.Animation.frame_ns - 1));
- try std.testing.expect(clock.due(true, 100 + pardes.layout.Animation.frame_ns));
- try std.testing.expect(!clock.due(false, 100 + 2 * pardes.layout.Animation.frame_ns));
- try std.testing.expectEqual(@as(u64, 0), clock.next_ns);
-}
-
-test "a persistent scene presents a new panel's frame zero before advancing it" {
+test "a new panel's frame zero is presented before it advances" {
const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer core.deinit();
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
- var clock: AnimationClock = .{};
+ const frame = pardes.animation.frame_ns;
- core.settings.scene_effects.crt = true;
- const scene_frame = try core.render(arena.allocator());
- finishPresentedAnimationFrame(&clock, core, scene_frame.panelTracks(), 100);
- try std.testing.expectEqual(@as(u64, 100 + pardes.layout.Animation.frame_ns), clock.next_ns);
+ core.advance(100);
+ const idle = try core.render(arena.allocator());
+ core.acknowledgePanelPresentation(idle.panelTracks());
+ try std.testing.expectEqual(@as(?u64, null), core.nextWake());
core.settings.panel_transition = .slide;
core.update(.{ .command = "Newcol" });
@@ -258,22 +220,21 @@ test "a persistent scene presents a new panel's frame zero before advancing it"
try std.testing.expect(track_count > 0);
for (first.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 0), track.frame);
- finishPresentedAnimationFrame(
- &clock,
- core,
- first.panelTracks(),
- 100 + pardes.layout.Animation.frame_ns,
- );
+ core.acknowledgePanelPresentation(first.panelTracks());
+ // Just short of a frame is no frame at all.
+ core.advance(100 + frame - 1);
+ _ = arena.reset(.retain_capacity);
+ for ((try core.render(arena.allocator())).panelTracks()) |track| try std.testing.expectEqual(@as(u16, 0), track.frame);
+ core.advance(100 + frame);
_ = arena.reset(.retain_capacity);
const second = try core.render(arena.allocator());
try std.testing.expectEqual(track_count, second.panelTracks().len);
for (second.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 1), track.frame);
}
-// Per pane: background, rule, two grip strokes, two square rails, and the
-// rule under each of its notice chips.
-const pane_chrome_vertices: usize = 6 * (6 + pardes.Pane.Notices.max);
-const max_overlay_vertices: usize = 24 + pet.max_vertices + pardes.MAX_COLS * 6 + pardes.MAX_PANES * pane_chrome_vertices + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices);
+// The triangle overlay holds only the pet and the touch view: the chrome is
+// decor.
+const max_overlay_vertices: usize = pet.max_vertices + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices);
const font_px_step: f32 = 2.0;
const font_px_min: f32 = 8.0;
@@ -480,6 +441,11 @@ const CellInstance = extern struct {
progress_bits: u32 = @bitCast(@as(f32, 1)),
serial: u32 = 0,
cell_coord: u32 = 0,
+ /// A clip rectangle in target pixels; x1 < x0 is none (ui.vert.glsl).
+ clip_x0: f32 = 0,
+ clip_y0: f32 = 0,
+ clip_x1: f32 = -1,
+ clip_y1: f32 = -1,
};
const OverlayVertex = extern struct {
@@ -557,8 +523,8 @@ const SavedImagePlace = struct {
const PreparedImage = struct {
place: SavedImagePlace,
texture: *c.SDL_GPUTexture,
- track: ?pardes.layout.Track = null,
- clip: ?pardes.layout.Box = null,
+ track: ?pardes.animation.Track = null,
+ clip: ?pardes.animation.Box = null,
old_layer: bool = false,
};
@@ -582,7 +548,8 @@ test "saved native placement retains identity and geometry but no producer bytes
}
test "shader instance ABI carries aligned transition vectors" {
- try std.testing.expectEqual(@as(usize, 104), @sizeOf(CellInstance));
+ try std.testing.expectEqual(@as(usize, 120), @sizeOf(CellInstance));
+ try std.testing.expectEqual(@as(usize, 104), @offsetOf(CellInstance, "clip_x0"));
try std.testing.expectEqual(@as(usize, 56), @offsetOf(CellInstance, "panel_x0"));
try std.testing.expectEqual(@as(usize, 72), @offsetOf(CellInstance, "present_x0"));
try std.testing.expectEqual(@as(usize, 88), @offsetOf(CellInstance, "effect"));
@@ -592,43 +559,52 @@ test "shader instance ABI carries aligned transition vectors" {
try std.testing.expectEqual(@as(usize, 64), @offsetOf(ImageInstance, "effect"));
try std.testing.expectEqual(@as(usize, 32), @sizeOf([8]f32));
- const tracks = [_]pardes.layout.Track{
+ const tracks = [_]pardes.animation.Track{
.{ .pane = 0, .phase = .opening, .effect = .slide },
.{ .pane = 1, .phase = .moving, .effect = .slide },
.{ .pane = 2, .phase = .moving, .effect = .slide },
};
- const plan = makePaintPlan(&tracks, true);
- try std.testing.expectEqual(@as(usize, 4), plan.len);
- try std.testing.expect(plan.batches[0].track == null);
- try std.testing.expectEqual(@as(u8, 1), plan.batches[1].track.?.pane);
- try std.testing.expectEqual(@as(u8, 2), plan.batches[2].track.?.pane);
- try std.testing.expectEqual(@as(u8, 0), plan.batches[3].track.?.pane);
+ // Tier 0, the tracks as the core ordered them, then tiers 4 and 5.
+ const groups = makeGroups(&tracks, true);
+ try std.testing.expectEqual(@as(usize, 7), groups.len);
+ try std.testing.expect(groups.items[0].track == null);
+ try std.testing.expectEqual(@as(u8, 0), groups.items[1].track.?.pane);
+ try std.testing.expectEqual(@as(u8, 1), groups.items[2].track.?.pane);
+ try std.testing.expectEqual(@as(u8, 2), groups.items[3].track.?.pane);
+ try std.testing.expectEqual(@as(usize, 4), groups.notices);
+ try std.testing.expectEqual(@as(usize, 5), groups.guides);
+ try std.testing.expectEqual(@as(usize, 6), groups.ink);
+ for ([_]usize{ groups.notices, groups.guides, groups.ink }) |index| try std.testing.expect(groups.items[index].track == null);
}
test "GUI paint plan snaps history effects without a frozen grid" {
- const tracks = [_]pardes.layout.Track{
+ const tracks = [_]pardes.animation.Track{
.{ .pane = 0, .phase = .opening, .effect = .ascii },
.{ .pane = 1, .phase = .closing, .effect = .vertical },
};
- try std.testing.expectEqual(@as(usize, 1), makePaintPlan(&tracks, false).len);
- const ready = makePaintPlan(&tracks, true);
- try std.testing.expectEqual(@as(usize, 3), ready.len);
- try std.testing.expectEqual(pardes.layout.Phase.opening, ready.batches[1].track.?.phase);
- try std.testing.expectEqual(pardes.layout.Phase.closing, ready.batches[2].track.?.phase);
+ try std.testing.expectEqual(@as(usize, 0), makeGroups(&tracks, false).tracks);
+ const ready = makeGroups(&tracks, true);
+ try std.testing.expectEqual(@as(usize, 2), ready.tracks);
+ try std.testing.expectEqual(pardes.animation.Phase.opening, ready.items[1].track.?.phase);
+ try std.testing.expectEqual(pardes.animation.Phase.closing, ready.items[2].track.?.phase);
}
test "closing tombstone overlays but never owns canonical cells" {
- const closing: pardes.layout.Track = .{
+ const closing: pardes.animation.Track = .{
.pane = 0,
.phase = .closing,
.effect = .vertical,
.from = .{ .x = 2, .y = 3, .w = 10, .h = 4 },
.to = .{ .x = 2, .y = -1, .w = 10, .h = 4 },
};
- const plan = makePaintPlan(&.{closing}, true);
- try std.testing.expectEqual(@as(usize, 0), paintBatchAt(&plan, 4, 4));
- try std.testing.expectEqual(@as(usize, 2), plan.len);
- try std.testing.expectEqual(pardes.layout.Phase.closing, plan.batches[1].track.?.phase);
+ const groups = makeGroups(&.{closing}, true);
+ try std.testing.expectEqual(@as(usize, 0), groupAt(&groups, 4, 4));
+ // A still pane's notice stays under the tombstone sliding over it.
+ const notice: pardes.Layer = .{ .kind = .notice, .viewport = .{ .x = 4, .y = 4, .w = 3, .h = 1 }, .rows = 1 };
+ try std.testing.expectEqual(@as(usize, 0), noticeGroup(&groups, &notice));
+ try std.testing.expectEqual(makeGroups(&.{}, false).notices, noticeGroup(&makeGroups(&.{}, false), &notice));
+ try std.testing.expectEqual(@as(usize, 1), groups.tracks);
+ try std.testing.expectEqual(pardes.animation.Phase.closing, groups.items[1].track.?.phase);
}
fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32, tag_w: u32, tag_h: u32) bool {
@@ -1791,9 +1767,9 @@ const Gui = struct {
device: *c.SDL_GPUDevice,
swapchain_format: c.SDL_GPUTextureFormat,
pipeline: *c.SDL_GPUGraphicsPipeline,
+ decor_pipeline: *c.SDL_GPUGraphicsPipeline,
overlay_pipeline: *c.SDL_GPUGraphicsPipeline,
image_pipeline: *c.SDL_GPUGraphicsPipeline,
- crt_pipeline: *c.SDL_GPUGraphicsPipeline,
atlas_tex: *c.SDL_GPUTexture,
atlas_sampler: *c.SDL_GPUSampler,
linear_sampler: *c.SDL_GPUSampler,
@@ -1810,9 +1786,11 @@ const Gui = struct {
native_images: std.AutoHashMapUnmanaged(pardes.ImageCacheKey, *c.SDL_GPUTexture) = .empty,
presented_images: std.ArrayListUnmanaged(SavedImagePlace) = .empty,
prepared_images: std.ArrayListUnmanaged(PreparedImage) = .empty,
- /// renderFrame's cover of the grid by layers (coverLayers): this frame's
- /// layers, then the layers a panel transition is leaving.
- layer_cover: std.ArrayListUnmanaged(bool) = .empty,
+ /// renderFrame's cover of the grid (coverFrame): this frame's, then the
+ /// frame a panel transition is leaving.
+ cover: std.ArrayListUnmanaged(Cover) = .empty,
+ /// renderFrame's decor instances, group by group (buildDecor).
+ decor: std.ArrayListUnmanaged(CellInstance) = .empty,
font: *c.UIFont,
font_bytes: []u8 = &.{},
@@ -1825,8 +1803,8 @@ const Gui = struct {
tagline_percent: u8,
tagline_width: u32,
tagline_height: u32,
- attached_layers: ?*const [pardes.MAX_PANES]pardes.BodyLayer = null,
- attached_tag_layers: ?*const [pardes.MAX_TAG_LAYERS]pardes.TagLayer = null,
+ attached_layers: ?*const [pardes.MAX_PANES]pardes.Layer = null,
+ attached_tag_layers: ?*const [pardes.MAX_TAG_LAYERS]pardes.Layer = null,
cell_w: u32,
cell_h: u32,
ascent: i32,
@@ -1864,7 +1842,8 @@ const Gui = struct {
scene_tex_h: u32 = 0,
scene_failures: u8 = 0,
scene_target_failed: bool = false,
- presented_scene: crt.Frame = .{},
+ /// The post chain, run over the frame in scene_tex.
+ post: Post = .{},
pointer_present: bool = false,
arrow_cursor: ?*c.SDL_Cursor = null,
link_cursor: ?*c.SDL_Cursor = null,
@@ -2003,7 +1982,9 @@ fn windowCells(g: *const Gui) GridCells {
};
}
-fn windowPointToPixels(geometry: WindowGeometry, x: f32, y: f32) crt.Point {
+const Point = struct { x: f32, y: f32 };
+
+fn windowPointToPixels(geometry: WindowGeometry, x: f32, y: f32) Point {
return .{
.x = x * geometry.pixel_w / geometry.window_w,
.y = y * geometry.pixel_h / geometry.window_h,
@@ -2017,7 +1998,7 @@ test "high-density window coordinates map once to render pixels" {
.pixel_w = 1120,
.pixel_h = 720,
};
- try std.testing.expectEqual(crt.Point{ .x = 700, .y = 240 }, windowPointToPixels(geometry, 350, 120));
+ try std.testing.expectEqual(Point{ .x = 700, .y = 240 }, windowPointToPixels(geometry, 350, 120));
}
fn fixedCellLayout(g: *const Gui) CellLayout {
@@ -2040,64 +2021,152 @@ fn taglineLayoutForCell(g: *const Gui, col: u16) CellLayout {
return compactTaglineLayout(g, @floatFromInt(col));
}
-const boxContains = pardes.layout.Box.contains;
+const boxContains = pardes.animation.Box.contains;
-const PaintBatch = struct {
- track: ?pardes.layout.Track = null,
+/// What one tier draws, or one track in tiers 2 and 3 (docs/render-pipeline.md
+/// §3.4), in this order: its cells, its images, then its decor.
+const Group = struct {
+ track: ?pardes.animation.Track = null,
cell_start: u32 = 0,
cell_count: u32 = 0,
image_start: u32 = 0,
image_count: u32 = 0,
+ decor_start: u32 = 0,
+ decor_count: u32 = 0,
};
-const PaintPlan = struct {
- batches: [pardes.MAX_PANES * 2 + 1]PaintBatch = @splat(.{}),
- len: usize = 1,
+/// The frame's draw groups, bottom to top: tier 0 (the page and the panes
+/// standing still), a group for each track in paint order (tier 2 moving and
+/// opening, tier 3 closing), tier 4 (floating: the notices, then the guides
+/// and the debug box, which the core paints over them) and tier 5 (ink: the
+/// bar cursors; the pet and the touch overlay draw after it). Tier 1 is the
+/// focus lift, which does not exist yet.
+const Groups = struct {
+ items: [pardes.MAX_PANES * 2 + 4]Group = @splat(.{}),
+ /// Groups 1 through `tracks` are the tracks'.
+ tracks: usize = 0,
+ notices: usize = 1,
+ guides: usize = 2,
+ ink: usize = 3,
+ len: usize = 4,
};
-fn makePaintPlan(tracks: []const pardes.layout.Track, has_diff: bool) PaintPlan {
- var plan: PaintPlan = .{};
- for ([_]pardes.layout.Phase{ .moving, .opening, .closing }) |phase| {
- for (tracks) |track| {
- if (!track.active() or track.phase != phase) continue;
- if (track.effect.needsPreviousGrid() and !has_diff) continue;
- std.debug.assert(plan.len < plan.batches.len);
- plan.batches[plan.len].track = track;
- plan.len += 1;
- }
+fn makeGroups(tracks: []const pardes.animation.Track, has_diff: bool) Groups {
+ var groups: Groups = .{};
+ for (tracks) |track| {
+ if (!track.active()) continue;
+ if (track.effect.needsPreviousGrid() and !has_diff) continue;
+ std.debug.assert(groups.tracks < pardes.MAX_PANES * 2);
+ groups.tracks += 1;
+ groups.items[groups.tracks].track = track;
}
- return plan;
+ groups.notices = groups.tracks + 1;
+ groups.guides = groups.tracks + 2;
+ groups.ink = groups.tracks + 3;
+ groups.len = groups.tracks + 4;
+ return groups;
}
-fn paintBatchAt(plan: *const PaintPlan, col: u16, row: u16) usize {
- for (plan.batches[1..plan.len], 1..) |batch, index| {
- if (batch.track.?.phase == .closing) continue;
- if (boxContains(batch.track.?.to, col, row)) return index;
+/// The group of the track whose pane covers this cell, else tier 0.
+fn groupAt(groups: *const Groups, col: u16, row: u16) usize {
+ for (groups.items[1..][0..groups.tracks], 1..) |group, index| {
+ if (group.track.?.phase == .closing) continue;
+ if (boxContains(group.track.?.to, col, row)) return index;
}
return 0;
}
-fn paintBatchForSerial(plan: *const PaintPlan, serial: u32) usize {
- for (plan.batches[1..plan.len], 1..) |batch, index|
- if (batch.track.?.serial == serial) return index;
+fn groupOf(groups: *const Groups, serial: u32) usize {
+ for (groups.items[1..][0..groups.tracks], 1..) |group, index|
+ if (group.track.?.serial == serial) return index;
return 0;
}
-fn panelCellCoord(track: pardes.layout.Track, col: u16, row: u16) u32 {
+/// A notice floats over the panes and its pane's picture (tier 4) while
+/// nothing moves; while anything does it stays in its own pane's group, under
+/// whatever pane slides, opens or closes over it.
+fn noticeGroup(groups: *const Groups, layer: *const pardes.Layer) usize {
+ if (groups.tracks > 0) return groupAt(groups, layer.viewport.x, layer.viewport.y);
+ return groups.notices;
+}
+
+/// What draws a grid cell, marked once a frame from the layers and the
+/// regions (coverFrame), so the cell loops read it rather than search.
+const Cover = packed struct(u16) {
+ /// A layer draws it; the grid's copy is not drawn.
+ layer: bool = false,
+ /// Under a guide or the debug box: drawn from the grid over the panes.
+ floating: bool = false,
+ /// In a pane's grip, `grip_offset` cells from its left: the grip's
+ /// glyph is the cell that far to the left.
+ grip: bool = false,
+ grip_offset: u4 = 0,
+ /// In the focused pane's or column's tag rows.
+ focus: bool = false,
+ /// At a column's left edge, where a tag's anchor is inset.
+ anchor: bool = false,
+ _: u7 = 0,
+};
+
+/// Marks the grid cells a body or tag layer covers, which the layers paint
+/// themselves, and what the regions say of the rest. Filling each rectangle
+/// once replaces scanning every layer for every cell, which was a third of
+/// the gui's CPU on a full screen of text.
+fn coverFrame(cover: []Cover, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer, regions: []const pardes.Region) void {
+ @memset(cover, .{});
+ const mark = struct {
+ fn rect(into: []Cover, width: u16, height: u16, r: pardes.Rect, comptime field: []const u8, value: anytype) void {
+ const right = @min(width, @as(u32, r.x) + r.w);
+ if (r.x >= right) return;
+ var y: u32 = r.y;
+ while (y < @min(height, @as(u32, r.y) + r.h)) : (y += 1)
+ for (into[y * width ..][r.x..right]) |*cell| {
+ @field(cell, field) = value;
+ };
+ }
+ };
+ for (body_layers) |*layer| {
+ if (layer.rows != 0) mark.rect(cover, cols, rows, layer.viewport, "layer", true);
+ }
+ for (tag_layers) |*layer| {
+ if (layer.rows != 0) mark.rect(cover, cols, rows, .{ .x = layer.viewport.x, .y = layer.viewport.y, .w = layer.viewport.w, .h = layer.rows }, "layer", true);
+ }
+ // The column bar's rows, which the focused column's tint spans.
+ var bar: ?pardes.Rect = null;
+ for (regions) |region| {
+ if (region.kind == .column_grip) bar = region.rect;
+ }
+ for (regions) |region| switch (region.kind) {
+ .grip => {
+ mark.rect(cover, cols, rows, region.rect, "grip", true);
+ for (0..@min(region.rect.w, 16)) |offset| mark.rect(cover, cols, rows, .{ .x = region.rect.x +| @as(u16, @intCast(offset)), .y = region.rect.y, .w = 1, .h = region.rect.h }, "grip_offset", @as(u4, @intCast(offset)));
+ if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true);
+ },
+ .tag => if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true),
+ .column => {
+ mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = 0, .w = 1, .h = rows }, "anchor", true);
+ if (region.active) if (bar) |b| mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = b.y, .w = region.rect.w, .h = b.h }, "focus", true);
+ },
+ .guide, .debug => mark.rect(cover, cols, rows, region.rect, "floating", true),
+ else => {},
+ };
+}
+
+fn panelCellCoord(track: pardes.animation.Track, col: u16, row: u16) u32 {
const source = track.contentBox();
const x0: u16 = @intFromFloat(@max(0.0, @floor(source.x)));
const y0: u16 = @intFromFloat(@max(0.0, @floor(source.y)));
return @as(u32, row -| y0) << 16 | @as(u32, col -| x0);
}
-fn panelGridSize(track: pardes.layout.Track) u32 {
+fn panelGridSize(track: pardes.animation.Track) u32 {
const source = track.contentBox();
const cols: u16 = @intFromFloat(@min(@as(f32, std.math.maxInt(u16)), @max(1.0, @ceil(source.w))));
const rows: u16 = @intFromFloat(@min(@as(f32, std.math.maxInt(u16)), @max(1.0, @ceil(source.h))));
return @as(u32, rows) << 16 | @as(u32, cols);
}
-fn ndcBox(box: pardes.layout.Box, layout: CellLayout, win_w: f32, win_h: f32) [4]f32 {
+fn ndcBox(box: pardes.animation.Box, layout: CellLayout, win_w: f32, win_h: f32) [4]f32 {
const px0 = layout.x_off + box.x * layout.w;
const py0 = layout.y_off + box.y * layout.h;
const px1 = px0 + box.w * layout.w;
@@ -2112,7 +2181,7 @@ fn ndcBox(box: pardes.layout.Box, layout: CellLayout, win_w: f32, win_h: f32) [4
fn setTransitionFields(
instance: anytype,
- track: ?pardes.layout.Track,
+ track: ?pardes.animation.Track,
layout: CellLayout,
win_w: f32,
win_h: f32,
@@ -2147,6 +2216,15 @@ fn setTransitionFields(
instance.progress_bits = @bitCast(active.amount());
instance.serial = active.serial;
instance.cell_coord = cell_coord;
+ // A vertical transition shows its pane only inside the box it grows
+ // into, in whole pixels.
+ if (@hasField(@TypeOf(instance.*), "clip_x0") and active.effect == .vertical) {
+ const box = active.contentBox();
+ instance.clip_x0 = std.math.clamp(@floor(box.x * layout.w), 0, win_w);
+ instance.clip_y0 = std.math.clamp(@floor(box.y * layout.h), 0, win_h);
+ instance.clip_x1 = std.math.clamp(@ceil((box.x + box.w) * layout.w), instance.clip_x0, win_w);
+ instance.clip_y1 = std.math.clamp(@ceil((box.y + box.h) * layout.h), instance.clip_y0, win_h);
+ }
}
pub const run = runNative;
@@ -2322,10 +2400,10 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
var ax_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = atlas_w * atlas_h, .props = 0 };
const atlas_xfer = c.SDL_CreateGPUTransferBuffer(device, &ax_info) orelse return error.GpuCreate;
- const pipeline = try makePipeline(device, swapchain_format);
+ const pipeline = try makePipeline(device, swapchain_format, frag_spv, 1, std.mem.zeroes(c.SDL_GPUColorTargetBlendState));
+ const decor_pipeline = try makePipeline(device, swapchain_format, decor_frag_spv, 0, backgroundLayerBlend());
const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format);
const image_pipeline = try makeImagePipeline(device, swapchain_format);
- const crt_pipeline = try makeCrtPipeline(device, swapchain_format);
const overlay_buffer_size: u32 = @intCast(max_overlay_vertices * @sizeOf(OverlayVertex));
var ovb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = overlay_buffer_size, .props = 0 };
@@ -2352,9 +2430,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
.device = device,
.swapchain_format = swapchain_format,
.pipeline = pipeline,
+ .decor_pipeline = decor_pipeline,
.overlay_pipeline = overlay_pipeline,
.image_pipeline = image_pipeline,
- .crt_pipeline = crt_pipeline,
.atlas_tex = atlas_tex,
.atlas_sampler = atlas_sampler,
.linear_sampler = linear_sampler,
@@ -2389,8 +2467,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
// regrown to; the image ones are released below.
defer {
c.SDL_ReleaseGPUGraphicsPipeline(device, g.pipeline);
+ c.SDL_ReleaseGPUGraphicsPipeline(device, g.decor_pipeline);
c.SDL_ReleaseGPUGraphicsPipeline(device, g.overlay_pipeline);
- c.SDL_ReleaseGPUGraphicsPipeline(device, g.crt_pipeline);
c.SDL_ReleaseGPUTexture(device, g.atlas_tex);
c.SDL_ReleaseGPUSampler(device, g.atlas_sampler);
c.SDL_ReleaseGPUSampler(device, g.linear_sampler);
@@ -2414,7 +2492,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
g.native_images.deinit(gpa);
g.presented_images.deinit(gpa);
g.prepared_images.deinit(gpa);
- g.layer_cover.deinit(gpa);
+ g.cover.deinit(gpa);
+ g.decor.deinit(gpa);
+ g.post.deinit(gpa, device);
}
defer c.SDL_ReleaseGPUTransferBuffer(device, g.image_vxfer);
defer c.SDL_ReleaseGPUBuffer(device, g.image_vbuf);
@@ -2525,6 +2605,7 @@ fn localSession(
.watches = &watches,
.fs = fs,
.test_mode = test_mode,
+ .test_clock = if (test_mode) host_io.testClock() else null,
};
defer shell.shutdownPtys();
const host = shell.host();
@@ -2716,25 +2797,12 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client
}
fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Client) void {
- var surface: pardes.Surface = .{
- .cols = client.cols,
- .rows = client.rows,
- .cells = client.grid.items,
- .cursor = if (client.cursor) |cu| .{ .x = cu.x, .y = cu.y, .bar = cu.bar } else null,
- .pointer_shape = client.pointer_shape,
- .body_layers = client.body_layers,
- .tag_layers = client.tag_layers,
- };
- const chrome = frameChromeBg(&surface);
+ var surface = attachedSurface(client);
_ = renderFrame(
g,
gpa,
null,
&surface,
- null,
- chrome,
- chrome,
- .{},
false,
) catch |err| blk: {
log.err("render: {t}", .{err});
@@ -2742,17 +2810,67 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien
};
}
-fn frameChromeBg(surface: *const pardes.Surface) [3]u8 {
- if (surface.rows == 0 or surface.cols == 0) return bg_default;
- for (surface.cells[0..surface.cols]) |cell| {
- if (cell.default or cell.style.font_role != .tagline) continue;
- return switch (cell.style.bg) {
- .default => bg_default,
- .index => |i| palColor(i),
- .rgb => |rgb| rgb,
- };
- }
- return bg_default;
+/// The session's frame as a surface: its cells, layers, regions and palette,
+/// so its chrome is drawn from them as a local one's is.
+fn attachedSurface(client: *const detached_client.Client) pardes.Surface {
+ var surface: pardes.Surface = .{
+ .cols = client.cols,
+ .rows = client.rows,
+ .cells = client.grid.items,
+ .cursor = if (client.cursor) |cu| .{ .x = cu.x, .y = cu.y, .bar = cu.bar } else null,
+ .pointer_shape = client.pointer_shape,
+ .body_layers = client.body_layers,
+ .tag_layers = client.tag_layers,
+ .chrome = client.chrome,
+ };
+ const placed = client.regions.items[0..@min(client.regions.items.len, surface.regions.len)];
+ @memcpy(surface.regions[0..placed.len], placed);
+ surface.nregions = @intCast(placed.len);
+ return surface;
+}
+
+test "an attached frontend draws the session's chrome from its frame alone" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ // A gui-shaped core, whose tags and notices are layers.
+ core.update(.{ .resize = .{ .cols = 80, .rows = 24, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } });
+ _ = try core.newShell(1, "");
+ try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false));
+ core.settings.focus_tint = true;
+ try std.testing.expect(core.executeBuiltinLine(0, "Msg a notice with a rule under it"));
+ pardes.test_api.sync(core);
+ // Past its slide into place, where its rule shows.
+ for (0..30) |_| core.update(.tick);
+ const s = try core.render(core.scratch.allocator());
+
+ const wire = @import("../detached/wire.zig");
+ var client: detached_client.Client = .{ .gpa = gpa };
+ defer client.deinit();
+ var welcome: [64]u8 = undefined;
+ try client.in.appendSlice(gpa, try wire.encodeServer(&welcome, .{ .welcome = .{ .slot = 0, .cols = s.cols, .rows = s.rows } }));
+ const out = try gpa.alloc(u8, wire.frameBound(s.cols, s.rows) + wire.layersBound(&s.body_layers, &s.tag_layers, s.regionList()));
+ defer gpa.free(out);
+ try client.in.appendSlice(gpa, try wire.encodeFrameLayers(out, s.cols, s.rows, null, s.pointer_shape, s.cells, &.{}, &s.body_layers, &s.tag_layers, s.regionList(), s.chrome));
+ _ = try client.next();
+ _ = try client.next();
+ const attached = attachedSurface(&client);
+
+ var g: Gui = undefined;
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.tagline_width = 6;
+ g.tagline_height = 12;
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true);
+ const local = try gpa.dupe(CellInstance, g.decor.items);
+ defer gpa.free(local);
+ try std.testing.expect(local.len > 8);
+ try std.testing.expect(groups.items[groups.notices].decor_count != 0);
+ try buildDecor(&g, gpa, &attached, &groups, fixedCellLayout(&g), 800, 480, true);
+ try std.testing.expectEqualSlices(u8, std.mem.sliceAsBytes(local), std.mem.sliceAsBytes(g.decor.items));
}
fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
@@ -2955,23 +3073,14 @@ const StdinFeed = struct {
f.fill = len - seq_start;
break;
};
- if (parseFinger(body[0..end])) |finger_in| {
- var finger = finger_in;
+ if (parseFinger(body[0..end])) |finger| {
if (g) |gp| {
var wpw: c_int = 0;
var wph: c_int = 0;
_ = c.SDL_GetWindowSizeInPixels(gp.window, &wpw, &wph);
const win_w: f32 = @floatFromInt(@max(wpw, 1));
const win_h: f32 = @floatFromInt(@max(wph, 1));
- if (mapScenePoint(gp, finger.x * win_w, finger.y * win_h, win_w, win_h)) |m| {
- finger.x = m.x / win_w;
- finger.y = m.y / win_h;
- handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width));
- } else {
- finger.kind = .cancel;
- handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width));
- in.post(.pointer_leave);
- }
+ handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width));
} else if (in.core) |core| {
handleFinger(&grid_touch, in, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1, 1);
}
@@ -3178,6 +3287,20 @@ const Input = struct {
};
fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
+ // What the post chain sees of the pointer and the focus.
+ switch (sev.type) {
+ c.SDL_EVENT_MOUSE_MOTION => {
+ const at = windowPointToPixels(windowGeometry(g.window), sev.motion.x, sev.motion.y);
+ g.post.mouse(at.x, at.y, null);
+ },
+ c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => {
+ const at = windowPointToPixels(windowGeometry(g.window), sev.button.x, sev.button.y);
+ g.post.mouse(at.x, at.y, sev.button.down);
+ },
+ c.SDL_EVENT_WINDOW_FOCUS_GAINED => g.post.focus(true),
+ c.SDL_EVENT_WINDOW_FOCUS_LOST => g.post.focus(false),
+ else => {},
+ }
switch (sev.type) {
c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => in.close(),
c.SDL_EVENT_WINDOW_MOUSE_LEAVE => {
@@ -3350,7 +3473,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
},
c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => {
const f = sev.tfinger;
- var finger: Finger = .{
+ const finger: Finger = .{
.kind = switch (sev.type) {
c.SDL_EVENT_FINGER_DOWN => .down,
c.SDL_EVENT_FINGER_MOTION => .motion,
@@ -3369,15 +3492,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
const win_h: f32 = @floatFromInt(@max(ph, 1));
const fcw: f32 = @floatFromInt(g.cell_w);
const fch: f32 = @floatFromInt(g.cell_h);
- if (mapScenePoint(g, finger.x * win_w, finger.y * win_h, win_w, win_h)) |m| {
- finger.x = m.x / win_w;
- finger.y = m.y / win_h;
- handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width));
- } else {
- finger.kind = .cancel;
- handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width));
- in.post(.pointer_leave);
- }
+ handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width));
},
c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => in.post(.{ .pinch = sev.pinch.scale }),
c.SDL_EVENT_GAMEPAD_ADDED => {
@@ -3567,7 +3682,7 @@ fn gridCellAtDimensions(
var point: MouseCell = .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / @max(body_w, 1)), 10_000)), .row = row };
if (core) |p| if (p.presentation.pointerFractional(p.screen_w, p.screen_h, x / @max(body_w, 1), y / @max(body_h, 1))) |mapped| {
for (p.surface.tagLayers()) |*layer| {
- if (layer.hitAt(mapped.x * body_w, mapped.y * body_h, body_w, body_h, tagline_w, @floatFromInt(p.row_metrics.tagline_h))) |hit| {
+ if (layer.tagHit(mapped.x * body_w, mapped.y * body_h, body_w, body_h, tagline_w, @floatFromInt(p.row_metrics.tagline_h))) |hit| {
point.tag_hit = hit;
break;
}
@@ -3579,19 +3694,19 @@ fn gridCellAtDimensions(
fn gridCellAtPixels(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) MouseCell {
var point = gridCellAtDimensions(core, x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width));
if (core) |p| if (p.presentation.pointerFractional(p.screen_w, p.screen_h, x / @as(f32, @floatFromInt(g.cell_w)), y / @as(f32, @floatFromInt(g.cell_h)))) |mapped| for (p.surface.bodyLayers()) |*layer| {
- if (layer.hitAt(mapped.x * @as(f32, @floatFromInt(g.cell_w)), mapped.y * @as(f32, @floatFromInt(g.cell_h)), @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |hit| {
+ if (layer.bodyHit(mapped.x * @as(f32, @floatFromInt(g.cell_w)), mapped.y * @as(f32, @floatFromInt(g.cell_h)), @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |hit| {
point.body_hit = hit;
break;
}
};
if (core == null) if (g.attached_layers) |layers| for (layers) |*layer| {
- if (layer.hitAt(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |body_hit| {
+ if (layer.bodyHit(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |body_hit| {
point.body_hit = body_hit;
break;
}
};
if (core == null) if (g.attached_tag_layers) |layers| for (layers) |*layer| {
- if (layer.hitAt(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |hit| {
+ if (layer.tagHit(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |hit| {
point.tag_hit = hit;
break;
}
@@ -3637,14 +3752,30 @@ test "tagline pointer mapping retains physical grips and carries logical text co
try std.testing.expectEqual(pardes.TagKind.workspace, topbar.tag_hit.?.kind);
}
-fn mapScenePoint(g: *const Gui, x: f32, y: f32, w: f32, h: f32) ?crt.Point {
- return crt.mapScene(x, y, w, h, g.presented_scene.effects, g.presented_scene.time_seconds);
+test "a click at the window's corner lands in the corner cell with a post chain on" {
+ const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ try std.testing.expect(core.settings.post.set(.crt, "", true));
+ var g: Gui = undefined;
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.tagline_width = 6;
+ g.tagline_height = 12;
+ const geometry: WindowGeometry = .{ .window_w = 400, .window_h = 240, .pixel_w = 800, .pixel_h = 480 };
+ const last = mouseCellWithGeometry(&g, core, 399.9, 239.9, geometry).?;
+ try std.testing.expectEqual(@as(u16, 79), last.col);
+ try std.testing.expectEqual(@as(u16, 23), last.row);
+ const first = mouseCellWithGeometry(&g, core, 0, 0, geometry).?;
+ try std.testing.expectEqual(@as(u16, 0), first.col);
+ try std.testing.expectEqual(@as(u16, 0), first.row);
}
+/// Where a window point lands: the cell drawn under it. Input is identity:
+/// no post pass moves a pixel (the bundled Crt has no barrel), so a click
+/// is exactly where it looks.
fn mouseCellWithGeometry(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32, geometry: WindowGeometry) ?MouseCell {
const physical = windowPointToPixels(geometry, x, y);
- const mapped = mapScenePoint(g, physical.x, physical.y, geometry.pixel_w, geometry.pixel_h) orelse return null;
- return gridCellAtPixels(g, core, mapped.x, mapped.y);
+ return gridCellAtPixels(g, core, physical.x, physical.y);
}
fn mouseCell(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) ?MouseCell {
@@ -3755,10 +3886,11 @@ const Shell = struct {
presented: bool = false,
skipped_presents: u8 = 0,
surface: ?*pardes.Surface = null,
- animation_clock: AnimationClock = .{},
saw_event: bool = false,
dump_err: ?anyerror = null,
retired_shells: [pardes.MAX_PANES]RetiredShell = @splat(.{}),
+ /// PARDES_TEST_CLOCK's virtual time (host_io.testClock), in test mode.
+ test_clock: ?u64 = null,
fn reap(s: *Shell) void {
const now = shellClock();
@@ -3845,6 +3977,7 @@ const Shell = struct {
const vtable: pardes.Host.VTable = .{
.wait_input = waitInput,
+ .now = guiNow,
.present = present,
.post_present = postPresent,
.poll_frame = pollFrame,
@@ -3870,6 +4003,7 @@ const Shell = struct {
const grid_vtable: pardes.Host.VTable = vt: {
var v = vtable;
v.wait_input = null;
+ v.now = null;
v.poll_frame = gridPollFrame;
v.present = gridPresent;
v.post_present = gridPostPresent;
@@ -3981,13 +4115,38 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
var in: Input = .{ .core = core };
if (s.gui) |g| {
var sev = std.mem.zeroes(c.SDL_Event);
+ // Idle is asleep until an event: SDL's own, and the user events the
+ // shell's queue raises for a terminal's output, a completion, a file
+ // system request. What the shell moves or times itself polls every
+ // frame: a smooth scroll, a gamepad's sticks, the test stdin feed,
+ // a post chain that moves on its own (ShaderAnimation),
+ // a retired shell waiting to be killed, a present to retry. A
+ // minimized window sleeps through the core's animation too: nothing
+ // shows it, and it is caught up at once when the window is back.
+ const retiring = for (s.retired_shells) |child| {
+ if (child.kill_at != 0) break true;
+ } 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
+ 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:
// waiting here too would make every tick cost two frames.
- const ms: c_int = if (latency_fd >= 0) 0 else if (timeout_ms != 0) @intCast(timeout_ms) else 16;
+ // A chain that moves on its own wakes once a display refresh.
+ const poll_ms: c_int = if (g.post.animating(core.settings.shader_animation))
+ @intCast(@max(1, refreshNs(g) / std.time.ns_per_ms))
+ else
+ 16;
+ const ms: c_int = if (latency_fd >= 0) 0 else if (minimized) -1 else if (timeout_ms != 0) @min(@as(c_int, @intCast(@min(timeout_ms, std.math.maxInt(c_int)))), if (polls) poll_ms else std.math.maxInt(c_int)) else if (polls) poll_ms else -1;
// The wait is the 9P connections' turn with the core.
pardes.turn.rest();
const got = c.SDL_WaitEventTimeout(&sev, ms);
pardes.turn.wake();
+ // The core's wake ran out: a virtual clock moves exactly there.
+ if (!got and timeout_ms != 0) if (s.test_clock) |*virtual| {
+ virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*);
+ };
if (got) {
// A run of queued motions is one move to its last position (the
// handler reads only absolute x/y and held buttons): acting on
@@ -4060,6 +4219,20 @@ fn pollFrame(ctx: ?*anyopaque) void {
const geom = windowCells(g);
if (updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h, g.tagline_width, g.tagline_height)) resetScroll(g);
stepScroll(g, core, s.gpa);
+ g.post.sync(s.gpa, s.io, g.device, g.swapchain_format, core);
+ // Between the core's frames, a chain that moves on its own redraws
+ // alone, once a refresh of the display the window is on (a millisecond
+ // early is on time: the wait that paces it is in whole milliseconds).
+ if (g.post.animating(core.settings.shader_animation) and !core.needs_frame and s.presented and
+ c.SDL_GetTicksNS() -| g.post.last_ns + std.time.ns_per_ms >= refreshNs(g)) redrawPost(g, s.gpa);
+}
+
+/// One refresh of the display the window is on, in ns; a frame of the
+/// core's where the display does not say.
+fn refreshNs(g: *const Gui) u64 {
+ const mode = c.SDL_GetCurrentDisplayMode(c.SDL_GetDisplayForWindow(g.window)) orelse return pardes.animation.frame_ns;
+ if (!(mode.*.refresh_rate > 1)) return pardes.animation.frame_ns;
+ return @intFromFloat(@as(f64, std.time.ns_per_s) / mode.*.refresh_rate);
}
fn finishWindowOpacity(core: *pardes.Pardes, applied: *u8, failure: ?[]const u8) void {
@@ -4119,18 +4292,13 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
frame_started_ns = monoNs();
const frame = @constCast(surface);
s.surface = frame;
- const scene_requested = core.settings.scene_effects.crt or
- core.settings.scene_effects.ripple or core.settings.scene_effects.glitch;
+ const scene_requested = g.post.ready();
if (!scene_requested) g.scene_failures = 0;
s.presented = renderFrame(
g,
s.gpa,
core,
frame,
- core.theme().bg,
- config.gui_topbar_pane_border_rgb orelse core.chromeTheme().border,
- core.chromeTheme().tag_bg,
- core.settings.scene_effects,
core.settings.debug,
) catch |err| blk: {
log.err("render: {t}", .{err});
@@ -4153,20 +4321,16 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
fn postPresent(ctx: ?*anyopaque) void {
const s = shellOf(ctx);
const g = s.gui orelse return;
- if (!s.presented) {
- // No image (a minimized window): time still passes, so a lingering
- // message or a hover wait runs out instead of redrawing forever.
- if (s.animation_clock.due(s.core.animationActive(), c.SDL_GetTicksNS())) s.core.update(.tick);
- return;
- }
+ // No image (a minimized window) shows no tracks; time still passes and
+ // the pump advances it all the same.
+ if (!s.presented) return;
const frame = s.surface orelse return;
refreshPresentedPointer(g, s.core);
- finishPresentedAnimationFrame(
- &s.animation_clock,
- s.core,
- frame.panelTracks(),
- c.SDL_GetTicksNS(),
- );
+ s.core.acknowledgePanelPresentation(frame.panelTracks());
+}
+
+fn guiNow(ctx: ?*anyopaque) u64 {
+ return shellOf(ctx).test_clock orelse c.SDL_GetTicksNS();
}
fn gridPollFrame(ctx: ?*anyopaque) void {
@@ -4176,8 +4340,10 @@ fn gridPollFrame(ctx: ?*anyopaque) void {
if (f.tick().count != 0) s.saw_event = true;
}
pollCwds(s.core, s.ptys);
- if (s.core.animationActive()) {
- s.core.update(.tick);
+ // Grid time is virtual: each poll moves it straight to the core's
+ // next wake, a frame at a time while anything moves.
+ if (s.core.nextWake()) |due| {
+ s.core.advance(due);
s.saw_event = true;
}
}
@@ -4374,7 +4540,7 @@ test "the headless grid host round-trips a yank back as a paste" {
core.update(.{ .key = .{ .cp = ' ' } });
core.update(.{ .key = .{ .cp = 'y' } });
while (core.nextEffect()) |e| core.perform(e);
- const yanked = core.yank orelse return error.MissingYank;
+ const yanked = core.registers.text(core.gpa, '+') orelse return error.MissingYank;
try std.testing.expect(yanked.len > 0);
const before = pane.file.?.content.len;
@@ -4685,7 +4851,7 @@ fn snapshotContainsPlacement(g: *const Gui, current: pardes.ImagePlace) bool {
return false;
}
-fn placeIntersectsBox(place: SavedImagePlace, box: pardes.layout.Box) bool {
+fn placeIntersectsBox(place: SavedImagePlace, box: pardes.animation.Box) bool {
const x0: f32 = @floatFromInt(place.x);
const y0: f32 = @floatFromInt(place.y);
const x1: f32 = @floatFromInt(@as(u32, place.x) + place.w);
@@ -4857,8 +5023,8 @@ fn appendPreparedImage(
g: *Gui,
gpa: std.mem.Allocator,
place: SavedImagePlace,
- track: ?pardes.layout.Track,
- clip: ?pardes.layout.Box,
+ track: ?pardes.animation.Track,
+ clip: ?pardes.animation.Box,
old_layer: bool,
) void {
const texture = g.native_images.get(place.key) orelse return;
@@ -4933,7 +5099,7 @@ fn prepareNativeImages(
gpa: std.mem.Allocator,
cmd: *c.SDL_GPUCommandBuffer,
surface: *pardes.Surface,
- plan: *PaintPlan,
+ groups: *Groups,
sw: u32,
sh: u32,
) bool {
@@ -4959,9 +5125,9 @@ fn prepareNativeImages(
}
g.prepared_images.clearRetainingCapacity();
- for (plan.batches[0..plan.len], 0..) |*batch, batch_index| {
- batch.image_start = std.math.cast(u32, g.prepared_images.items.len) orelse return false;
- const track = batch.track;
+ for (groups.items[0..groups.len], 0..) |*group, group_index| {
+ group.image_start = std.math.cast(u32, g.prepared_images.items.len) orelse return false;
+ const track = group.track;
if (track) |active| {
if (active.phase == .closing) {
@@ -4981,7 +5147,7 @@ fn prepareNativeImages(
for (surface.images[0..surface.nimages]) |maybe| {
const current = maybe orelse continue;
if (validImageBytes(current) == null or
- paintBatchForSerial(plan, current.serial) != batch_index) continue;
+ groupOf(groups, current.serial) != group_index) continue;
const saved = SavedImagePlace.from(current);
const current_track = if (track) |active| switch (active.effect) {
.dissolve => if (snapshotContainsPlacement(g, current)) null else active,
@@ -4990,7 +5156,7 @@ fn prepareNativeImages(
appendPreparedImage(g, gpa, saved, current_track, null, false);
}
}
- if (batch_index == 0) for (plan.batches[1..plan.len]) |tracked| {
+ if (group_index == 0) for (groups.items[1..][0..groups.tracks]) |tracked| {
const active = tracked.track.?;
if (active.effect != .vertical or active.phase != .opening) continue;
for (g.presented_images.items) |saved| {
@@ -4999,7 +5165,7 @@ fn prepareNativeImages(
}
};
const image_end = std.math.cast(u32, g.prepared_images.items.len) orelse return false;
- batch.image_count = image_end - batch.image_start;
+ group.image_count = image_end - group.image_start;
}
const capacity = std.math.cast(u32, g.prepared_images.items.len) orelse return false;
@@ -5047,7 +5213,7 @@ fn imageScissor(
};
}
-fn panelBoxScissor(g: *const Gui, box: pardes.layout.Box, max_w: u32, max_h: u32) c.SDL_Rect {
+fn panelBoxScissor(g: *const Gui, box: pardes.animation.Box, max_w: u32, max_h: u32) c.SDL_Rect {
const x0: i32 = @intFromFloat(@floor(box.x * @as(f32, @floatFromInt(g.cell_w))));
const y0: i32 = @intFromFloat(@floor(box.y * @as(f32, @floatFromInt(g.cell_h))));
const x1: i32 = @intFromFloat(@ceil((box.x + box.w) * @as(f32, @floatFromInt(g.cell_w))));
@@ -5068,11 +5234,10 @@ fn drawNativeImagesGpu(
g: *Gui,
rp: ?*c.SDL_GPURenderPass,
surface: *pardes.Surface,
- plan: *const PaintPlan,
- batch_index: usize,
+ batch: Group,
+ whole: c.SDL_Rect,
) void {
const pass = rp orelse return;
- const batch = plan.batches[batch_index];
c.SDL_BindGPUGraphicsPipeline(pass, g.image_pipeline);
const max_w = @as(u32, surface.cols) * g.cell_w;
const max_h = @as(u32, surface.rows) * g.cell_h;
@@ -5091,12 +5256,8 @@ fn drawNativeImagesGpu(
idx += 1;
}
std.debug.assert(idx == batch.image_start + batch.image_count);
- const whole = c.SDL_Rect{
- .x = 0,
- .y = 0,
- .w = @intCast(@as(u32, surface.cols) * g.cell_w),
- .h = @intCast(@as(u32, surface.rows) * g.cell_h),
- };
+ // Back to the whole target: what draws after a picture may run on past
+ // the grid, as a rule and a band do.
c.SDL_SetGPUScissor(pass, &whole);
}
@@ -5105,10 +5266,6 @@ fn renderFrame(
gpa: std.mem.Allocator,
core: ?*const pardes.Pardes,
surface: *pardes.Surface,
- theme_bg: ?[3]u8,
- topbar_pane_border_rgb: [3]u8,
- tagline_rgb: [3]u8,
- scene_effects: pardes.layout.SceneEffect,
debug_on: bool,
) !bool {
if (surface.pointer_shape != g.pointer_shape) {
@@ -5123,93 +5280,58 @@ fn renderFrame(
defer if (!command_consumed) {
_ = c.SDL_SubmitGPUCommandBuffer(cmd);
};
- var sw: u32 = 0;
- var sh: u32 = 0;
- var target: *c.SDL_GPUTexture = undefined;
- if (g.capture) {
- sw = @as(u32, surface.cols) * g.cell_w + g.capture_pad;
- sh = @as(u32, surface.rows) * g.cell_h + g.capture_pad;
- if (sw == 0 or sh == 0) {
- command_consumed = true;
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return false;
- }
- try ensureCaptureTexture(g, sw, sh);
- target = g.capture_tex.?;
- } else if (g.soft_present) {
- var pw: c_int = 0;
- var ph: c_int = 0;
- if (!c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph) or pw <= 0 or ph <= 0) {
- command_consumed = true;
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return false;
- }
- sw = @intCast(pw);
- sh = @intCast(ph);
- try ensureCaptureTexture(g, sw, sh);
- target = g.capture_tex.?;
- } else {
- var swap_tex: ?*c.SDL_GPUTexture = null;
- if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) {
- command_consumed = true;
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return false;
- }
- target = swap_tex orelse { // swapchain busy: skip the frame
- command_consumed = true;
- _ = c.SDL_SubmitGPUCommandBuffer(cmd);
- return false;
- };
- }
+ const frame_target = try acquireTarget(g, cmd, @as(u32, surface.cols) * g.cell_w + g.capture_pad, @as(u32, surface.rows) * g.cell_h + g.capture_pad) orelse {
+ command_consumed = true;
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return false;
+ };
+ const target = frame_target.texture;
+ const sw = frame_target.w;
+ const sh = frame_target.h;
const win_w: f32 = @floatFromInt(sw);
const win_h: f32 = @floatFromInt(sh);
- var scene_on = scene_effects.crt or scene_effects.ripple or scene_effects.glitch;
+ // The post chain runs over a local frame, from the scene texture.
+ var scene_on = core != null and g.post.ready();
if (scene_on) ensureSceneTexture(g, sw, sh) catch {
g.scene_target_failed = true;
scene_on = false;
};
const scene: *c.SDL_GPUTexture = if (scene_on) g.scene_tex.? else target;
+ // The frame's CPU side: groups, decor, cover and every instance, up to
+ // their upload.
+ const build_zone = tracy.zone(@src(), "gui frame build");
+ var building = true;
+ defer if (building) build_zone.end();
const layout = fixedCellLayout(g);
- var paint_plan = makePaintPlan(surface.panelTracks(), surface.hasPanelDiff());
- var overlay_count = buildOverlay(
- g,
- core,
- surface,
- layout,
- sw,
- sh,
- topbar_pane_border_rgb,
- tagline_rgb,
- paint_plan.len == 1,
- debug_on,
- );
+ const chrome = &surface.chrome;
+ var groups = makeGroups(surface.panelTracks(), surface.hasPanelDiff());
+ // Cursors stand still while panes move, as they always have.
+ const cursors_shown = groups.tracks == 0;
+ var overlay_count = buildOverlay(g, core, surface, sw, sh, debug_on);
if (overlay_count != 0 and !uploadOverlayGpu(g, cmd, overlay_count))
overlay_count = 0;
- if (!prepareNativeImages(g, gpa, cmd, surface, &paint_plan, sw, sh)) {
+ if (!prepareNativeImages(g, gpa, cmd, surface, &groups, sw, sh)) {
g.prepared_images.clearRetainingCapacity();
- for (paint_plan.batches[0..paint_plan.len]) |*batch| {
- batch.image_start = 0;
- batch.image_count = 0;
+ for (groups.items[0..groups.len]) |*group| {
+ group.image_start = 0;
+ group.image_count = 0;
}
}
const cells: u32 = @as(u32, surface.cols) * surface.rows;
- try g.layer_cover.resize(gpa, @as(usize, cells) * 2);
- const covered = g.layer_cover.items[0..cells];
- coverLayers(covered, surface.cols, surface.rows, surface.bodyLayers(), surface.tagLayers());
+ try g.cover.resize(gpa, @as(usize, cells) * 2);
+ const covered = g.cover.items[0..cells];
+ coverFrame(covered, surface.cols, surface.rows, surface.bodyLayers(), surface.tagLayers(), surface.regionList());
// Only a panel transition paints the grid under the layers it leaves.
- const previous_covered = g.layer_cover.items[cells..];
- if (paint_plan.len > 1)
- coverLayers(previous_covered, surface.cols, surface.rows, surface.previous_body_layers, surface.previous_tag_layers);
+ const previous_covered = g.cover.items[cells..];
+ if (groups.tracks != 0)
+ coverFrame(previous_covered, surface.cols, surface.rows, surface.previous_body_layers, surface.previous_tag_layers, surface.previous_regions);
+ try buildDecor(g, gpa, surface, &groups, layout, win_w, win_h, cursors_shown);
- const page = ground(theme_bg, g.transparent);
+ const page = ground(chrome.page, g.transparent);
const background_opacity = backgroundOpacity(g.applied_window_opacity);
- const tagline_base = pardes.Cell{
- .style = .{ .bg = .{ .rgb = tagline_rgb }, .font_role = .tagline },
- .default = false,
- };
var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
color_target.texture = scene;
color_target.clear_color = if (page.clear) .{ .r = 0, .g = 0, .b = 0, .a = 0 } else .{
@@ -5226,28 +5348,21 @@ fn renderFrame(
while (row < surface.rows) : (row += 1) {
var col: u16 = 0;
while (col < surface.cols) : (col += 1) {
- if (covered[@as(usize, row) * surface.cols + col]) continue;
- const batch_index = paintBatchAt(&paint_plan, col, row);
- const track = paint_plan.batches[batch_index].track;
- const data_effect = if (track) |active|
- active.effect == .dissolve
- else
- false;
- const data_diff = data_effect and gripCellChanged(surface, col, row);
- const logical_idx: u32 = @as(u32, row) * surface.cols + col;
+ const at = @as(usize, row) * surface.cols + col;
+ if (covered[at].layer and !covered[at].floating) continue;
+ const group_index = if (covered[at].floating) groups.guides else groupAt(&groups, col, row);
+ const track = groups.items[group_index].track;
+ const data_effect = if (track) |active| active.effect == .dissolve else false;
+ const data_diff = data_effect and gripCellChanged(surface, covered, previous_covered, col, row);
const instance_count = if (data_diff)
- cellInstanceCount(&surface.previous_cells[logical_idx]) + cellInstanceCount(surface.at(col, row))
+ cellInstanceCount(&surface.previous_cells[at]) + cellInstanceCount(&surface.cells[at])
else
- cellInstanceCount(surface.at(col, row));
- paint_plan.batches[batch_index].cell_count = std.math.add(
- u32,
- paint_plan.batches[batch_index].cell_count,
- instance_count,
- ) catch return error.GpuCapacity;
+ cellInstanceCount(&surface.cells[at]);
+ groups.items[group_index].cell_count = std.math.add(u32, groups.items[group_index].cell_count, instance_count) catch return error.GpuCapacity;
}
}
- for (paint_plan.batches[1..paint_plan.len]) |*batch| {
- const track = batch.track.?;
+ for (groups.items[1..][0..groups.tracks]) |*group| {
+ const track = group.track.?;
const duplicate_under = track.effect == .vertical and track.phase == .opening;
if (!duplicate_under and track.phase != .closing) continue;
row = 0;
@@ -5255,33 +5370,29 @@ fn renderFrame(
var col: u16 = 0;
while (col < surface.cols) : (col += 1) {
if (!boxContains(track.contentBox(), col, row)) continue;
- if (previous_covered[@as(usize, row) * surface.cols + col]) continue;
- const destination = if (duplicate_under) &paint_plan.batches[0] else batch;
- const logical_idx: u32 = @as(u32, row) * surface.cols + col;
- destination.cell_count = std.math.add(
- u32,
- destination.cell_count,
- cellInstanceCount(&surface.previous_cells[logical_idx]),
- ) catch return error.GpuCapacity;
+ const at = @as(usize, row) * surface.cols + col;
+ if (previous_covered[at].layer) continue;
+ const destination = if (duplicate_under) &groups.items[0] else group;
+ destination.cell_count = std.math.add(u32, destination.cell_count, cellInstanceCount(&surface.previous_cells[at])) catch return error.GpuCapacity;
}
}
}
for (surface.bodyLayers()) |*layer| {
if (layer.rows == 0) continue;
- const batch = &paint_plan.batches[paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y)];
+ const group = &groups.items[groupAt(&groups, layer.viewport.x, layer.viewport.y)];
const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h;
for (0..layer.rows) |lr| {
if (layer.rowTop(@intCast(lr), layout.h, @floatFromInt(g.tagline_height)) >= bottom) break;
- batch.cell_count += layer.cols + @as(u32, if (lr < layer.context_rows) 1 else 0) + @as(u32, @intFromBool(layer.hasContextBorderAfter(lr)));
+ group.cell_count += layer.cols + @as(u32, if (lr < layer.context_rows) 1 else 0) + @as(u32, @intFromBool(layer.hasContextBorderAfter(lr)));
}
}
- for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| {
- const track = batch.track.?;
+ for (groups.items[1..][0..groups.tracks], 1..) |group, index| {
+ const track = group.track.?;
const under = track.effect == .vertical and track.phase == .opening;
if (!under and track.phase != .closing and track.effect != .dissolve) continue;
for (surface.previous_body_layers) |*layer| {
if (layer.rows == 0 or layer.serial != track.serial) continue;
- const destination = if (under) &paint_plan.batches[0] else &paint_plan.batches[batch_index];
+ const destination = &groups.items[if (under) 0 else index];
const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h;
for (0..layer.rows) |lr| {
if (layer.rowTop(@intCast(lr), layout.h, @floatFromInt(g.tagline_height)) >= bottom) break;
@@ -5290,29 +5401,29 @@ fn renderFrame(
}
}
for (surface.tagLayers()) |*layer| {
- if (layer.cols == 0) continue;
- const batch = &paint_plan.batches[paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y)];
- batch.cell_count += tagLayerCellCount(g, layer) + 1;
+ if (layer.rows == 0) continue;
+ const group_index = if (layer.kind == .notice) noticeGroup(&groups, layer) else groupAt(&groups, layer.viewport.x, layer.viewport.y);
+ groups.items[group_index].cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows;
}
- for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| {
- const track = batch.track.?;
+ for (groups.items[1..][0..groups.tracks], 1..) |group, index| {
+ const track = group.track.?;
const under = track.effect == .vertical and track.phase == .opening;
if (!under and track.phase != .closing and track.effect != .dissolve) continue;
for (surface.previous_tag_layers) |*layer| {
- if (layer.cols == 0 or layer.kind != .pane or layer.serial != track.serial) continue;
- const destination = if (under) &paint_plan.batches[0] else &paint_plan.batches[batch_index];
- destination.cell_count += tagLayerCellCount(g, layer) + 1;
+ if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue;
+ groups.items[if (under) 0 else index].cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows;
}
}
- var cell_next: [pardes.MAX_PANES * 2 + 1]u32 = @splat(0);
+ var cell_next: [pardes.MAX_PANES * 2 + 4]u32 = @splat(0);
var cell_total: u32 = 0;
- for (paint_plan.batches[0..paint_plan.len], 0..) |*batch, batch_index| {
- batch.cell_start = cell_total;
- cell_next[batch_index] = cell_total;
- cell_total = std.math.add(u32, cell_total, batch.cell_count) catch return error.GpuCapacity;
+ for (groups.items[0..groups.len], 0..) |*group, index| {
+ group.cell_start = cell_total;
+ cell_next[index] = cell_total;
+ cell_total = std.math.add(u32, cell_total, group.cell_count) catch return error.GpuCapacity;
}
- if (cell_total != 0) {
- const capacity = std.math.add(u32, cell_total, cells) catch return error.GpuCapacity;
+ 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;
try ensureVbuf(g, capacity);
const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse {
@@ -5326,45 +5437,40 @@ fn renderFrame(
row = 0;
while (row < surface.rows) : (row += 1) {
const line = surface.cells[@as(usize, row) * surface.cols ..][0..surface.cols];
+ const line_cover = covered[@as(usize, row) * surface.cols ..][0..surface.cols];
const line_cursor: ?usize = if (surface.cursor) |cu| if (cu.y == row) cu.x else null else null;
var col: u16 = 0;
while (col < surface.cols) : (col += 1) {
- if (covered[@as(usize, row) * surface.cols + col]) continue;
- const batch_index = paintBatchAt(&paint_plan, col, row);
- const track = paint_plan.batches[batch_index].track;
- const logical_idx: u32 = @as(u32, row) * surface.cols + col;
- const data_effect = if (track) |active|
- active.effect == .dissolve
- else
- false;
- const data_diff = data_effect and gripCellChanged(surface, col, row);
- if (data_diff) {
- emitSurfaceCell(
- g,
- core,
- instances,
- &cell_next[batch_index],
- col,
- row,
- layout,
- win_w,
- win_h,
- track,
- surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols],
- col,
- null,
- &tagline_base,
- paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row),
- true,
- false,
- page,
- );
- }
+ const at = @as(usize, row) * surface.cols + col;
+ if (covered[at].layer and !covered[at].floating) continue;
+ const group_index = if (covered[at].floating) groups.guides else groupAt(&groups, col, row);
+ const track = groups.items[group_index].track;
+ const data_effect = if (track) |active| active.effect == .dissolve else false;
+ const data_diff = data_effect and gripCellChanged(surface, covered, previous_covered, col, row);
+ if (data_diff) emitSurfaceCell(
+ g,
+ chrome,
+ instances,
+ &cell_next[group_index],
+ col,
+ row,
+ layout,
+ win_w,
+ win_h,
+ track,
+ surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols],
+ previous_covered[@as(usize, row) * surface.cols ..][0..surface.cols],
+ col,
+ null,
+ true,
+ false,
+ page,
+ );
emitSurfaceCell(
g,
- core,
+ chrome,
instances,
- &cell_next[batch_index],
+ &cell_next[group_index],
col,
row,
layout,
@@ -5372,12 +5478,11 @@ fn renderFrame(
win_h,
if (data_effect and !data_diff) null else track,
line,
+ line_cover,
col,
line_cursor,
- &tagline_base,
- paneGripCell(surface.tagLayers(), surface.cells, surface.cols, col, row),
false,
- paint_plan.len == 1 and logical_idx == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null,
+ cursors_shown and at == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null,
page,
);
}
@@ -5386,36 +5491,36 @@ fn renderFrame(
// is a tag layer, and instances paint in emission order.
for (surface.bodyLayers()) |*layer| {
if (layer.rows == 0) continue;
- const batch_index = paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y);
- emitBodyLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false, topbar_pane_border_rgb);
+ const group_index = groupAt(&groups, layer.viewport.x, layer.viewport.y);
+ emitBodyLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[group_index].track, page, cursors_shown, false, chrome.topbar_rule);
}
for (surface.tagLayers()) |*layer| {
- if (layer.cols == 0) continue;
- const batch_index = paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y);
- emitTagLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false);
+ if (layer.rows == 0) continue;
+ const track_index = groupAt(&groups, layer.viewport.x, layer.viewport.y);
+ // A notice floats over the panes, moving with its own.
+ const group_index = if (layer.kind == .notice) noticeGroup(&groups, layer) else track_index;
+ emitTagLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[track_index].track, page, cursors_shown, false);
}
- for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| {
- const track = batch.track.?;
+ for (groups.items[1..][0..groups.tracks], 1..) |group, index| {
+ const track = group.track.?;
const under = track.effect == .vertical and track.phase == .opening;
if (!under and track.phase != .closing and track.effect != .dissolve) continue;
for (surface.previous_tag_layers) |*layer| {
- if (layer.cols == 0 or layer.kind != .pane or layer.serial != track.serial) continue;
- const destination: usize = if (under) 0 else batch_index;
- emitTagLayer(g, instances, &cell_next[destination], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve);
+ if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue;
+ emitTagLayer(g, instances, &cell_next[if (under) 0 else index], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve);
}
}
- for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| {
- const track = batch.track.?;
+ for (groups.items[1..][0..groups.tracks], 1..) |group, index| {
+ const track = group.track.?;
const under = track.effect == .vertical and track.phase == .opening;
if (!under and track.phase != .closing and track.effect != .dissolve) continue;
for (surface.previous_body_layers) |*layer| {
if (layer.rows == 0 or layer.serial != track.serial) continue;
- const destination: usize = if (under) 0 else batch_index;
- emitBodyLayer(g, instances, &cell_next[destination], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve, topbar_pane_border_rgb);
+ emitBodyLayer(g, instances, &cell_next[if (under) 0 else index], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve, chrome.topbar_rule);
}
}
- for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| {
- const track = batch.track.?;
+ for (groups.items[1..][0..groups.tracks], 1..) |group, index| {
+ const track = group.track.?;
const duplicate_under = track.effect == .vertical and track.phase == .opening;
if (!duplicate_under and track.phase != .closing) continue;
row = 0;
@@ -5423,13 +5528,12 @@ fn renderFrame(
var col: u16 = 0;
while (col < surface.cols) : (col += 1) {
if (!boxContains(track.contentBox(), col, row)) continue;
- if (previous_covered[@as(usize, row) * surface.cols + col]) continue;
- const destination: usize = if (duplicate_under) 0 else batch_index;
+ if (previous_covered[@as(usize, row) * surface.cols + col].layer) continue;
emitSurfaceCell(
g,
- core,
+ chrome,
instances,
- &cell_next[destination],
+ &cell_next[if (duplicate_under) 0 else index],
col,
row,
layout,
@@ -5437,10 +5541,9 @@ fn renderFrame(
win_h,
if (duplicate_under) null else track,
surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols],
+ previous_covered[@as(usize, row) * surface.cols ..][0..surface.cols],
col,
null,
- &tagline_base,
- paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row),
false,
false,
page,
@@ -5448,119 +5551,141 @@ fn renderFrame(
}
}
}
- for (paint_plan.batches[0..paint_plan.len], 0..) |batch, batch_index|
- std.debug.assert(cell_next[batch_index] == batch.cell_start + batch.cell_count);
+ for (groups.items[0..groups.len], 0..) |group, index|
+ std.debug.assert(cell_next[index] == group.cell_start + group.cell_count);
shifted = emitScrollRows(g, instances, cell_total, surface, layout, win_w, win_h, page);
+ @memcpy(instances[cell_total + shifted ..][0..decor_len], g.decor.items);
c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?);
if (g.atlas_dirty_top < g.atlas_dirty_bottom) uploadAtlas(g, cmd);
const copy = c.SDL_BeginGPUCopyPass(cmd);
const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.vxfer.?, .offset = 0 };
- const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = (cell_total + shifted) * @sizeOf(CellInstance) };
+ const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = (cell_total + shifted + decor_len) * @sizeOf(CellInstance) };
c.SDL_UploadToGPUBuffer(copy, &src, &dst, false);
c.SDL_EndGPUCopyPass(copy);
}
+ build_zone.end();
+ building = false;
const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null);
pushLayerOpacity(cmd, rp, background_opacity);
const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) };
- for (paint_plan.batches[0..paint_plan.len], 0..) |batch, batch_index| {
- const has_shifted = batch_index == 0 and shifted != 0;
- if ((batch.cell_count != 0 or has_shifted) and g.vbuf != null) {
+ const decor_base = cell_total + shifted;
+ for (groups.items[0..groups.len], 0..) |group, index| {
+ const has_shifted = index == 0 and shifted != 0;
+ if ((group.cell_count != 0 or has_shifted) and g.vbuf != null) {
c.SDL_BindGPUGraphicsPipeline(rp, g.pipeline);
const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.atlas_tex, .sampler = g.atlas_sampler };
c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1);
- if (batch.cell_count != 0) {
- const panel_clipped = if (batch.track) |active| active.effect == .vertical else false;
- if (panel_clipped) {
- const clip = panelBoxScissor(g, batch.track.?.contentBox(), sw, sh);
- c.SDL_SetGPUScissor(rp, &clip);
- }
- const binding = c.SDL_GPUBufferBinding{
- .buffer = g.vbuf.?,
- .offset = batch.cell_start * @sizeOf(CellInstance),
- };
+ if (group.cell_count != 0) {
+ const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = group.cell_start * @sizeOf(CellInstance) };
c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1);
- c.SDL_DrawGPUPrimitives(rp, 6, batch.cell_count, 0, 0);
- if (panel_clipped) c.SDL_SetGPUScissor(rp, &whole);
+ c.SDL_DrawGPUPrimitives(rp, 6, group.cell_count, 0, 0);
}
if (has_shifted) {
const clip = scrollScissor(g, layout, sw, sh);
c.SDL_SetGPUScissor(rp, &clip);
- const binding = c.SDL_GPUBufferBinding{
- .buffer = g.vbuf.?,
- .offset = cell_total * @sizeOf(CellInstance),
- };
+ const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = cell_total * @sizeOf(CellInstance) };
c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1);
c.SDL_DrawGPUPrimitives(rp, 6, shifted, 0, 0);
c.SDL_SetGPUScissor(rp, &whole);
}
}
- if (batch.image_count != 0)
- drawNativeImagesGpu(g, rp, surface, &paint_plan, batch_index);
+ if (group.image_count != 0) drawNativeImagesGpu(g, rp, surface, group, whole);
+ if (group.decor_count != 0 and g.vbuf != null) {
+ c.SDL_BindGPUGraphicsPipeline(rp, g.decor_pipeline);
+ // Ink keeps its colour at any WindowOpacity.
+ pushLayerOpacity(cmd, rp, if (index == groups.ink) 1 else background_opacity);
+ const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = (decor_base + group.decor_start) * @sizeOf(CellInstance) };
+ c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1);
+ c.SDL_DrawGPUPrimitives(rp, 6, group.decor_count, 0, 0);
+ }
}
drawOverlayGpu(g, cmd, rp, overlay_count);
c.SDL_EndGPURenderPass(rp);
- var rendered_scene: crt.Frame = .{};
if (scene_on) {
- var crt_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
- crt_target.texture = target;
- crt_target.load_op = c.SDL_GPU_LOADOP_DONT_CARE;
- crt_target.store_op = c.SDL_GPU_STOREOP_STORE;
- const crt_pass = c.SDL_BeginGPURenderPass(cmd, &crt_target, 1, null);
- c.SDL_BindGPUGraphicsPipeline(crt_pass, g.crt_pipeline);
- const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.scene_tex.?, .sampler = g.linear_sampler };
- c.SDL_BindGPUFragmentSamplers(crt_pass, 0, &samp_binding, 1);
- const now_ns = c.SDL_GetTicksNS();
- const time_wrap_ns: u64 = 4096 * std.time.ns_per_s;
- const time_seconds: f32 = @floatCast(
- @as(f64, @floatFromInt(now_ns % time_wrap_ns)) / @as(f64, std.time.ns_per_s),
- );
- rendered_scene = .{
- .effects = .{
- .crt = scene_effects.crt,
- .ripple = scene_effects.ripple,
- .glitch = scene_effects.glitch,
- },
- .time_seconds = time_seconds,
- };
- const crt_uniforms = [8]f32{
- time_seconds,
- @floatFromInt((now_ns / (std.time.ns_per_s / 60)) % 65_536),
- @floatFromInt(sw),
- @floatFromInt(sh),
- @floatFromInt(@intFromBool(scene_effects.crt)),
- @floatFromInt(@intFromBool(scene_effects.ripple)),
- @floatFromInt(@intFromBool(scene_effects.glitch)),
- 0,
+ if (core) |p| {
+ var boxes: [max_cursors]CursorBox = undefined;
+ const cursors = if (cursors_shown) cursorBoxes(g, surface, layout, win_h, &boxes) else 0;
+ g.post.describe(.{
+ .cursor = if (cursors > 0) boxes[0] else null,
+ .background = chrome.page orelse bg_default,
+ .foreground = chrome.fg orelse fg_default,
+ .selection_foreground = p.theme().sel_fg,
+ .selection_background = p.theme().sel_bg,
+ });
+ }
+ const chain_zone = tracy.zone(@src(), "post chain");
+ defer chain_zone.end();
+ g.post.draw(g.device, cmd, g.linear_sampler, g.swapchain_format, g.scene_tex.?, target, sw, sh, c.SDL_GetTicksNS(), background_opacity >= 1) catch {
+ g.scene_target_failed = true;
};
- c.SDL_PushGPUFragmentUniformData(cmd, 0, &crt_uniforms, @sizeOf(@TypeOf(crt_uniforms)));
- c.SDL_DrawGPUPrimitives(crt_pass, 3, 1, 0, 0);
- c.SDL_EndGPURenderPass(crt_pass);
}
+ command_consumed = true;
+ const shown = try finishFrame(g, gpa, cmd, frame_target, if (groups.tracks != 0) groups.items[1].track.?.frame else null);
+ if (shown and groups.tracks == 0) rememberPresentedImages(g, gpa, surface);
+ return shown;
+}
+
+const Target = struct { texture: *c.SDL_GPUTexture, w: u32, h: u32 };
+
+/// What this frame draws into: the capture texture in test mode (the grid's
+/// size plus PARDES_TEST_PAD) or with a soft presenter, else the swapchain's
+/// image; null when there is none to be had this time.
+fn acquireTarget(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, capture_w: u32, capture_h: u32) !?Target {
if (g.capture) {
- command_consumed = true;
- try captureFrame(g, gpa, cmd, target, sw, sh);
- g.presented_scene = rendered_scene;
- if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface);
+ if (capture_w == 0 or capture_h == 0) return null;
+ try ensureCaptureTexture(g, capture_w, capture_h);
+ return .{ .texture = g.capture_tex.?, .w = capture_w, .h = capture_h };
+ }
+ if (g.soft_present) {
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ if (!c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph) or pw <= 0 or ph <= 0) return null;
+ try ensureCaptureTexture(g, @intCast(pw), @intCast(ph));
+ return .{ .texture = g.capture_tex.?, .w = @intCast(pw), .h = @intCast(ph) };
+ }
+ var swap_tex: ?*c.SDL_GPUTexture = null;
+ var sw: u32 = 0;
+ var sh: u32 = 0;
+ if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) return null;
+ // A busy swapchain skips the frame.
+ return .{ .texture = swap_tex orelse return null, .w = sw, .h = sh };
+}
+
+/// Submits the frame: captured, handed to the soft presenter, or presented.
+fn finishFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: Target, transition: ?u16) !bool {
+ if (g.capture) {
+ try captureFrame(g, gpa, cmd, target.texture, target.w, target.h, transition);
return true;
}
if (g.soft_present) {
- command_consumed = true;
- try softPresentFrame(g, cmd, target, sw, sh);
- g.presented_scene = rendered_scene;
- if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface);
+ try softPresentFrame(g, cmd, target.texture, target.w, target.h);
return true;
}
- command_consumed = true;
- const submitted = c.SDL_SubmitGPUCommandBuffer(cmd);
- if (submitted) {
- g.presented_scene = rendered_scene;
- if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface);
+ return c.SDL_SubmitGPUCommandBuffer(cmd);
+}
+
+/// Redraw level A (docs §5.5): the post chain alone, over the frame the core
+/// last drew, for a pass that moves on its own. No core render, no cell.
+fn redrawPost(g: *Gui, gpa: std.mem.Allocator) void {
+ const scene = g.scene_tex orelse return;
+ const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return;
+ const target = (acquireTarget(g, cmd, g.scene_tex_w, g.scene_tex_h) catch null) orelse {
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return;
+ };
+ // A window the retained frame no longer fits waits for the core's.
+ if (target.w != g.scene_tex_w or target.h != g.scene_tex_h) {
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return;
}
- return submitted;
+ const zone = tracy.zone(@src(), "post chain redraw");
+ g.post.draw(g.device, cmd, g.linear_sampler, g.swapchain_format, scene, target.texture, target.w, target.h, c.SDL_GetTicksNS(), backgroundOpacity(g.applied_window_opacity) >= 1) catch {};
+ zone.end();
+ _ = finishFrame(g, gpa, cmd, target, null) catch {};
}
const ResolvedCell = struct {
@@ -5574,24 +5699,6 @@ const ResolvedCell = struct {
const topbarPaneBorderPixels = pardes.topbarPaneBorderPixels;
const taglineBandOffset = pardes.taglineBandOffset;
-fn topbarPaneBorderHeight(surface: *const pardes.Surface, top: u16, cell_h: u32, tagline_h: u32) ?u32 {
- if (top == 0 or surface.rows <= top) return null;
- const height = topbarPaneBorderPixels(cell_h, tagline_h);
- if (height == 0) return null;
- const base = @as(usize, top) * surface.cols;
- for (surface.cells[base..][0..surface.cols]) |cell|
- if (!cell.default and cell.style.font_role == .tagline) return height;
- return null;
-}
-
-fn bottomTaglinePresent(surface: *const pardes.Surface) bool {
- if (surface.rows == 0) return false;
- const base = @as(usize, surface.rows - 1) * surface.cols;
- for (surface.cells[base..][0..surface.cols]) |cell|
- if (!cell.default and cell.style.font_role == .tagline) return true;
- return false;
-}
-
test "tagline bands center in their cells and Tagbottom faces the window edge" {
const cell_h: u32 = 20;
const tagline_h: u32 = 16;
@@ -5674,75 +5781,18 @@ fn cellInstanceCount(cell: *const pardes.Cell) u32 {
return if (cellFontRole(cell) == .tagline) 2 else 1;
}
-fn taglineBaseRgb(core: *const pardes.Pardes, col: u16, row: u16) [3]u8 {
- const chrome = core.chromeTheme();
- if (!core.settings.focus_tint) return chrome.tag_bg;
- if (row >= core.topBarHeight() and row < core.topBarHeight() + core.columnBarHeight()) {
- for (0..core.ncol) |column| for (core.col_panes[column][0..core.col_n[column]]) |id| {
- if (id != core.active) continue;
- const x = core.col_x[column];
- if (col >= x and col - x < core.col_w[column]) return chrome.tag_active_bg;
- };
- }
- const r = core.rects[core.active];
- const pane = core.panes[core.active] orelse return chrome.tag_bg;
- const tag_y = core.tagTop(pane, r);
- return if (r.h > 0 and row >= tag_y and row < tag_y + pane.tag_rows and col >= r.x and col - r.x < r.w) chrome.tag_active_bg else chrome.tag_bg;
-}
-
-test "compact column and pane underlays share focus tint without tinting the global tag" {
- const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 24 });
- defer core.deinit();
- _ = try core.newShell(1, "");
- try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false));
- pardes.test_api.sync(core);
- core.active = 1;
- const active = core.rects[1];
- const inactive = core.rects[0];
- const chrome = core.chromeTheme();
- try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, active.x, 0));
- try std.testing.expectEqual(chrome.tag_active_bg, taglineBaseRgb(core, active.x, pardes.TOPBAR_H));
- try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, inactive.x, pardes.TOPBAR_H));
- try std.testing.expectEqual(chrome.tag_active_bg, taglineBaseRgb(core, active.x, active.y));
- core.settings.focus_tint = false;
- try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, active.x, pardes.TOPBAR_H));
-}
-
-fn tagLayerIn(layers: []const pardes.TagLayer, col: u16, row: u16) ?*const pardes.TagLayer {
+fn tagLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.Layer {
for (layers) |*layer| {
const r = layer.viewport;
- if (layer.cols > 0 and row == r.y and col >= r.x and col - r.x < r.w) return layer;
+ if (layer.rows > 0 and row >= r.y and row - r.y < layer.rows and col >= r.x and col - r.x < r.w) return layer;
}
return null;
}
-/// Marks the grid cells a body or tag layer covers, which the layers paint
-/// themselves: the grid's cell loops skip them. Filling each layer's rectangle
-/// once replaces scanning every layer for every cell, which was a third of the
-/// gui's CPU on a full screen of text. It marks exactly the cells bodyLayerIn
-/// and tagLayerIn find a layer for: a tag layer covers only its viewport's
-/// first row, and a viewport past the grid covers the grid's part of it.
-fn coverLayers(cover: []bool, cols: u16, rows: u16, body_layers: []const pardes.BodyLayer, tag_layers: []const pardes.TagLayer) void {
- @memset(cover, false);
- for (body_layers) |*layer| {
- const r = layer.viewport;
- const right = @min(cols, @as(u32, r.x) + r.w);
- if (layer.rows == 0 or r.x >= right) continue;
- var y: u32 = r.y;
- while (y < @min(rows, @as(u32, r.y) + r.h)) : (y += 1) @memset(cover[y * cols ..][r.x..right], true);
- }
- for (tag_layers) |*layer| {
- const r = layer.viewport;
- const right = @min(cols, @as(u32, r.x) + r.w);
- if (layer.cols == 0 or r.y >= rows or r.x >= right) continue;
- @memset(cover[@as(usize, r.y) * cols ..][r.x..right], true);
- }
-}
-
-fn expectCoverIsScan(cover: []const bool, cols: u16, rows: u16, body_layers: []const pardes.BodyLayer, tag_layers: []const pardes.TagLayer) !void {
+fn expectCoverIsScan(cover: []const Cover, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) !void {
for (0..rows) |row| for (0..cols) |col| {
const scan = bodyLayerIn(body_layers, @intCast(col), @intCast(row)) != null or tagLayerIn(tag_layers, @intCast(col), @intCast(row)) != null;
- try std.testing.expectEqual(scan, cover[row * cols + col]);
+ try std.testing.expectEqual(scan, cover[row * cols + col].layer);
};
}
@@ -5756,7 +5806,7 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand
try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false));
pardes.layout.insert(core, 1, 1, 2);
pardes.layout.insert(core, 0, 1, 3);
- var cover: [97 * 31]bool = undefined;
+ var cover: [97 * 31]Cover = undefined;
var notices = false;
for (0..32) |step| {
core.settings.tag_bottom = step & 1 != 0;
@@ -5769,10 +5819,21 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand
}
const s = try core.render(core.scratch.allocator());
try std.testing.expectEqual(@as(usize, cover.len), @as(usize, s.cols) * s.rows);
- @memset(&cover, true); // what an earlier frame left
- coverLayers(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers());
+ @memset(&cover, .{ .layer = true, .floating = true, .grip = true, .focus = true }); // what an earlier frame left
+ coverFrame(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers(), s.regionList());
try expectCoverIsScan(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers());
- for (s.tagLayers()) |layer| notices = notices or (layer.cols != 0 and layer.kind == .notice);
+ // A grip's cells know their way to its glyph, and the focused
+ // pane's tag rows are the focused ones.
+ for (s.regionList()) |region| {
+ if (region.kind != .grip) continue;
+ for (0..region.rect.w) |offset| {
+ const cell = cover[@as(usize, region.rect.y) * s.cols + region.rect.x + offset];
+ try std.testing.expect(cell.grip);
+ try std.testing.expectEqual(offset, cell.grip_offset);
+ try std.testing.expectEqual(region.owner == core.active, cell.focus);
+ }
+ }
+ for (s.tagLayers()) |layer| notices = notices or (layer.rows != 0 and layer.kind == .notice);
}
try std.testing.expect(notices);
@@ -5789,30 +5850,30 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand
};
var prng = std.Random.DefaultPrng.init(0x6c61796572);
const random = prng.random();
- var body: [pardes.MAX_PANES]pardes.BodyLayer = undefined;
- var tag: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = undefined;
- var grid: [64 * 40]bool = undefined;
+ var body: [pardes.MAX_PANES]pardes.Layer = undefined;
+ var tag: [pardes.MAX_TAG_LAYERS]pardes.Layer = undefined;
+ var grid: [64 * 40]Cover = undefined;
for (0..3000) |_| {
const cols = random.intRangeAtMost(u16, 1, 64);
const rows = random.intRangeAtMost(u16, 1, 40);
const empty = random.intRangeAtMost(u8, 2, 5); // about one layer in `empty` is empty
for (&body) |*layer| layer.* = .{ .rows = @intFromBool(random.uintLessThan(u8, empty) != 0), .viewport = gen.rect(random, cols, rows) };
- for (&tag) |*layer| layer.* = .{ .cols = @intFromBool(random.uintLessThan(u8, empty) != 0), .viewport = gen.rect(random, cols, rows) };
+ for (&tag) |*layer| layer.* = .{ .rows = @intFromBool(random.uintLessThan(u8, empty) != 0), .viewport = gen.rect(random, cols, rows) };
const bodies = body[0..random.uintAtMost(usize, body.len)];
const tags = tag[0..random.uintAtMost(usize, tag.len)];
const cells = grid[0 .. @as(usize, cols) * rows];
- @memset(cells, random.boolean());
- coverLayers(cells, cols, rows, bodies, tags);
+ @memset(cells, .{ .layer = random.boolean() });
+ coverFrame(cells, cols, rows, bodies, tags, &.{});
try expectCoverIsScan(cells, cols, rows, bodies, tags);
}
}
-fn tagLayerCellCount(g: *const Gui, layer: *const pardes.TagLayer) u32 {
+fn tagLayerCellCount(g: *const Gui, layer: *const pardes.Layer) u32 {
const pixels = @as(u32, layer.viewport.w) * g.cell_w;
return @min(layer.cols, (pixels + g.tagline_width - 1) / g.tagline_width);
}
-fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.TagLayer, win_w: f32, win_h: f32, track: ?pardes.layout.Track, page: Ground, show_cursor: bool, old_layer: bool) void {
+fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.Layer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool) void {
const bw: f32 = @floatFromInt(g.cell_w);
const bh: f32 = @floatFromInt(g.cell_h);
const tw: f32 = @floatFromInt(g.tagline_width);
@@ -5822,20 +5883,25 @@ fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const p
// A band sliding into place is drawn offset and cut at its row's top, so
// it comes out from under whatever is above it.
const slide = layer.slide * bh;
- const top = @as(f32, @floatFromInt(layer.viewport.y)) * bh;
// A window is rarely a whole number of cells wide: a band that ends at
// the last column runs on through the leftover pixels to the edge.
const band_right = if (win_w - right < bw) @max(right, win_w) else right;
- emitInstance(g, instances, next.*, 0, layer.viewport.y, .{ .w = band_right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, (&background)[0..1], 0, null, old_layer, false, page);
- if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h);
- next.* += 1;
- const line_cursor: ?usize = if (layer.cursor) |cu| cu.col else null;
- for (0..tagLayerCellCount(g, layer)) |col| {
- const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.col == col else false;
- emitInstance(g, instances, next.*, @intCast(col), layer.viewport.y, .{ .w = tw, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .tagline, layer.cells[0..layer.cols], col, line_cursor, old_layer, cursor, page);
- clipInstanceRight(&instances[next.*], right, win_w);
+ // A row to each line, each a grid row with the tagline's glyphs.
+ for (0..layer.rows) |index| {
+ const y = layer.viewport.y + @as(u16, @intCast(index));
+ const top = @as(f32, @floatFromInt(y)) * bh;
+ emitInstance(g, instances, next.*, 0, y, .{ .w = band_right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, (&background)[0..1], 0, null, old_layer, false, page);
if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h);
next.* += 1;
+ const cells = layer.cells[index * layer.cols ..][0..layer.cols];
+ const line_cursor: ?usize = if (layer.cursor) |cu| (if (cu.y == index) cu.x else null) else null;
+ for (0..tagLayerCellCount(g, layer)) |col| {
+ const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.x == col and cu.y == index else false;
+ emitInstance(g, instances, next.*, @intCast(col), y, .{ .w = tw, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .tagline, cells, col, line_cursor, old_layer, cursor, page);
+ clipInstanceRight(&instances[next.*], right, win_w);
+ if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h);
+ next.* += 1;
+ }
}
}
@@ -5847,11 +5913,11 @@ fn clipInstanceTop(instance: *CellInstance, top: f32, win_h: f32) void {
instance.y0 = @max(instance.y1, limit);
}
-fn bodyLayerAt(surface: *const pardes.Surface, col: u16, row: u16) ?*const pardes.BodyLayer {
+fn bodyLayerAt(surface: *const pardes.Surface, col: u16, row: u16) ?*const pardes.Layer {
return bodyLayerIn(surface.bodyLayers(), col, row);
}
-fn bodyLayerIn(layers: []const pardes.BodyLayer, col: u16, row: u16) ?*const pardes.BodyLayer {
+fn bodyLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.Layer {
for (layers) |*layer| {
const r = layer.viewport;
if (layer.rows > 0 and col >= r.x and col - r.x < r.w and row >= r.y and row - r.y < r.h) return layer;
@@ -5875,7 +5941,7 @@ fn clipBodyInstance(instance: *CellInstance, bottom: f32, win_h: f32) void {
instance.y1 = limit;
}
-fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.BodyLayer, win_w: f32, win_h: f32, track: ?pardes.layout.Track, page: Ground, show_cursor: bool, old_layer: bool, border_rgb: [3]u8) void {
+fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.Layer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool, border_rgb: [3]u8) void {
const bw: f32 = @floatFromInt(g.cell_w);
const bh: f32 = @floatFromInt(g.cell_h);
const tw: f32 = @floatFromInt(g.tagline_width);
@@ -5925,42 +5991,23 @@ fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const
}
}
-const GripCell = struct { cell: *const pardes.Cell, offset: u16 };
-
-fn gripCellChanged(surface: *const pardes.Surface, col: u16, row: u16) bool {
+/// A dissolving cell changes with its grip's glyph, which is the cell its
+/// grip offset to the left, in this frame's grip or the last one's.
+fn gripCellChanged(surface: *const pardes.Surface, covered: []const Cover, previous_covered: []const Cover, col: u16, row: u16) bool {
if (surface.panelCellChanged(col, row)) return true;
- if (paneGripCell(surface.tagLayers(), surface.cells, surface.cols, col, row)) |grip| {
- if (surface.panelCellChanged(col - grip.offset, row)) return true;
- }
- if (paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row)) |grip| {
- if (surface.panelCellChanged(col - grip.offset, row)) return true;
- }
+ const at = @as(usize, row) * surface.cols + col;
+ for ([_]Cover{ covered[at], previous_covered[at] }) |cover|
+ if (cover.grip and surface.panelCellChanged(col -| cover.grip_offset, row)) return true;
return false;
}
-fn paneGripCell(layers: []const pardes.TagLayer, cells: []const pardes.Cell, cols: u16, col: u16, row: u16) ?GripCell {
- const at = @as(usize, row) * cols + col;
- if (at >= cells.len or cells[at].default or cells[at].style.font_role != .tagline) return null;
- for (layers) |layer| {
- if (layer.cols == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET or layer.viewport.y != row) continue;
- const x = layer.viewport.x - pardes.TAG_TEXT_INSET;
- if (col < x or col - x >= config.GUTTER) continue;
- const index = @as(usize, row) * cols + x;
- if (index >= cells.len) return null;
- const cell = &cells[index];
- if (cell.default or cell.printableAscii() == ' ' or cell.style.invisible) return null;
- return .{ .cell = cell, .offset = col - x };
- }
- return null;
-}
-
fn anchorInset(g: *const Gui, row: u16, win_h: f32) u32 {
return taglineBandOffset(row, win_h, g.cell_h, g.tagline_height);
}
fn emitSurfaceCell(
g: *Gui,
- core: ?*const pardes.Pardes,
+ chrome: *const pardes.Chrome,
instances: [*]CellInstance,
next: *u32,
col: u16,
@@ -5968,20 +6015,22 @@ fn emitSurfaceCell(
body_layout: CellLayout,
win_w: f32,
win_h: f32,
- track: ?pardes.layout.Track,
+ track: ?pardes.animation.Track,
line: []const pardes.Cell,
+ line_cover: []const Cover,
at: usize,
cursor: ?usize,
- tagline_base: *const pardes.Cell,
- grip: ?GripCell,
old_layer: bool,
is_cursor: bool,
page: Ground,
) void {
const role = cellFontRole(&line[at]);
if (role == .tagline) {
- var band_base = tagline_base.*;
- if (core) |p| band_base.style.bg = .{ .rgb = taglineBaseRgb(p, col, row) };
+ const here = line_cover[at];
+ const band_base: pardes.Cell = .{
+ .style = .{ .bg = .{ .rgb = if (here.focus) chrome.tag_focus_bg else chrome.tag_bg }, .font_role = .tagline },
+ .default = false,
+ };
// The smaller font changes the ink, not the chrome's coverage. Painting
// this underlay at tagline height leaves page-colored gutters between
// adjacent workspace, column and pane tags at TaglineSize < 100.
@@ -5989,27 +6038,32 @@ fn emitSurfaceCell(
next.* += 1;
// Tag glyphs keep their font size inside a full physical-width grip.
emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page);
+ // A grip's glyph is the cell at its left edge, and a blank one is
+ // no glyph: its marks are decor.
+ const glyph = &line[at - @min(here.grip_offset, at)];
+ const grip = here.grip and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible;
+ const offset: u16 = if (grip) @min(here.grip_offset, at) else 0;
// An anchor starts at its column's rule: its left edge is inset by
// its top margin so the band frames it on the left as above and
// below, and its mark moves right by half that to stay centered.
- const inset: u32 = if (core) |p| if (std.mem.indexOfScalar(u16, p.col_x[0..p.ncol], col -| (if (grip) |indicator| indicator.offset else 0)) != null) anchorInset(g, row, win_h) else 0 else 0;
- if (grip) |indicator| {
+ const inset: u32 = if (line_cover[at - offset].anchor) anchorInset(g, row, win_h) else 0;
+ if (grip) {
// A grip is one glyph centered across the gutter, not text: it is
// looked up by its codepoint's glyph, never shaped.
- const drawn = glyphForCodepoint(g, cellCodepoint(indicator.cell));
- const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(indicator.cell), .centered = true });
+ const drawn = glyphForCodepoint(g, cellCodepoint(glyph));
+ const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(glyph), .centered = true });
const slot = cached orelse g.space_slot;
- const offset: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, indicator.offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0;
- const resolved = resolveCell(g, indicator.cell[0..1], 0, null, .tagline, false, page);
+ const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0;
+ const resolved = resolveCell(g, glyph[0..1], 0, null, .tagline, false, page);
instances[next.*].fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255;
instances[next.*].fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255;
instances[next.*].fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255;
- instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + offset) / atlas_w;
+ instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + shift) / atlas_w;
instances[next.*].v0 = @as(f32, @floatFromInt(slot.v)) / atlas_h;
instances[next.*].v1 = @as(f32, @floatFromInt(slot.v + g.tagline_height)) / atlas_h;
}
instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w;
- if (inset > 0 and (grip == null or grip.?.offset == 0)) {
+ if (inset > 0 and (!grip or offset == 0)) {
instances[next.*].x0 += @as(f32, @floatFromInt(inset)) / win_w * 2;
instances[next.*].u0 += @as(f32, @floatFromInt(inset)) / atlas_w;
}
@@ -6021,8 +6075,7 @@ fn emitSurfaceCell(
}
test "small tagline fonts leave no background gutters between adjacent tags" {
- const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
- defer core.deinit();
+ const chrome: pardes.Chrome = .{};
var g: Gui = undefined;
g.words = .{};
g.cell_w = 10;
@@ -6035,7 +6088,7 @@ test "small tagline fonts leave no background gutters between adjacent tags" {
var next: u32 = 0;
for (0..3) |row| {
// Column 1: past the anchor's inset edge at the column's left.
- emitSurfaceCell(&g, core, &instances, &next, 1, @intCast(row), fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, false, false, Ground.opaqueRgb(bg_default));
+ emitSurfaceCell(&g, &chrome, &instances, &next, 1, @intCast(row), fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], &.{.{}}, 0, null, false, false, Ground.opaqueRgb(bg_default));
const base = instances[row * 2];
const ink = instances[row * 2 + 1];
try std.testing.expectApproxEqAbs(@as(f32, 20.0 / 480.0 * 2), base.y0 - base.y1, 0.0001);
@@ -6048,12 +6101,12 @@ test "small tagline fonts leave no background gutters between adjacent tags" {
// Column 0 is an anchor's edge: its ink is inset by the band's margin
// (2 px here) and its underlay still covers the whole cell.
next = 0;
- emitSurfaceCell(&g, core, &instances, &next, 0, 1, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, false, false, Ground.opaqueRgb(bg_default));
+ emitSurfaceCell(&g, &chrome, &instances, &next, 0, 1, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], &.{.{ .anchor = true }}, 0, null, false, false, Ground.opaqueRgb(bg_default));
try std.testing.expectApproxEqAbs(@as(f32, 10.0 / 800.0 * 2), instances[0].x1 - instances[0].x0, 0.0001);
try std.testing.expectApproxEqAbs(@as(f32, 8.0 / 800.0 * 2), instances[1].x1 - instances[1].x0, 0.0001);
try std.testing.expectApproxEqAbs(@as(f32, 8) / atlas_w, instances[1].u1 - instances[1].u0, 0.0001);
next = 0;
- emitSurfaceCell(&g, null, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, true, false, Ground.opaqueRgb(bg_default));
+ emitSurfaceCell(&g, &chrome, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], &.{.{}}, 0, null, true, false, Ground.opaqueRgb(bg_default));
try std.testing.expectEqual(@as(u32, 2), next);
try std.testing.expectApproxEqAbs(@as(f32, 16.0 / 480.0 * 2), instances[1].y0 - instances[1].y1, 0.0001);
}
@@ -6067,7 +6120,7 @@ fn emitInstance(
layout: CellLayout,
win_w: f32,
win_h: f32,
- track: ?pardes.layout.Track,
+ track: ?pardes.animation.Track,
role: pardes.FontRole,
line: []const pardes.Cell,
at: usize,
@@ -6167,24 +6220,25 @@ test "insert cursor overlays without replacing the character beneath it" {
};
try std.testing.expectEqual(@as(u32, 'x'), cellCodepoint(&cell));
- var vertices: [6]OverlayVertex = undefined;
- var builder: OverlayBuilder = .{
- .vertices = &vertices,
- .win_w = 100,
- .win_h = 100,
- };
- addCursorBar(&builder, 2, 3, true, 8, .{
- .x_off = 0,
- .y_off = 0,
- .w = 10,
- .h = 20,
- }, .{ 32, 48, 64 });
- try std.testing.expectEqual(@as(usize, 6), builder.len);
- try std.testing.expectApproxEqAbs(@as(f32, -0.32), vertices[0].y, 0.0001);
- try std.testing.expectApproxEqAbs(@as(f32, -0.48), vertices[2].y, 0.0001);
- try std.testing.expectApproxEqAbs(@as(f32, 32.0 / 255.0), vertices[0].r, 0.0001);
- for (vertices) |vertex| try std.testing.expectEqual(@as(u32, 1), vertex.ink);
- try std.testing.expect(!builder.ink);
+ // A bar at a tag cell: tagline-tall, centered in its row, in the
+ // cell's ink, drawn as decor over it.
+ var cells: [25]pardes.Cell = @splat(.{});
+ cells[3 * 5 + 2] = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 32, 48, 64 } }, .font_role = .tagline } };
+ const surface: pardes.Surface = .{ .cols = 5, .rows = 5, .cells = &cells, .cursor = .{ .x = 2, .y = 3, .bar = true } };
+ var g: Gui = undefined;
+ g.decor = .empty;
+ defer g.decor.deinit(std.testing.allocator);
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.tagline_width = 8;
+ g.tagline_height = 8;
+ try cursorDecor(&g, std.testing.allocator, &surface, fixedCellLayout(&g), 100, 100);
+ try std.testing.expectEqual(@as(usize, 1), g.decor.items.len);
+ const bar = g.decor.items[0];
+ try std.testing.expectApproxEqAbs(@as(f32, -0.32), bar.y0, 0.0001);
+ try std.testing.expectApproxEqAbs(@as(f32, -0.48), bar.y1, 0.0001);
+ try std.testing.expectApproxEqAbs(@as(f32, 32.0 / 255.0), bar.br, 0.0001);
+ try std.testing.expectEqual(@as(f32, 1), bar.fr);
}
fn palColor(idx: u8) [3]u8 {
@@ -6694,17 +6748,24 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" {
cells[0].text[0] = '$';
var previous = cells;
previous[0].text[0] = '^';
- var layers = [_]pardes.TagLayer{.{ .kind = .pane, .serial = 1, .viewport = .{ .x = pardes.TAG_TEXT_INSET, .y = 0, .w = 3, .h = 1 }, .cols = 3 }};
var diffs: [12]pardes.PanelCellDiff = @splat(.unchanged);
diffs[0] = pardes.PanelCellDiff.between(&previous[0], &cells[0]);
- var surface: pardes.Surface = .{ .cols = 6, .rows = 2, .cells = &cells, .previous_cells = &previous, .previous_tag_layers = &layers, .cell_diffs = &diffs };
- surface.tag_layers[0] = layers[0];
- try std.testing.expect(gripCellChanged(&surface, 0, 0));
- try std.testing.expect(gripCellChanged(&surface, 1, 0));
- try std.testing.expect(!gripCellChanged(&surface, 2, 0));
- try std.testing.expect(paneGripCell(surface.tagLayers(), &cells, 6, 2, 0) == null);
- layers[0].kind = .column;
- try std.testing.expect(paneGripCell(&layers, &cells, 6, 0, 0) == null);
+ const surface: pardes.Surface = .{ .cols = 6, .rows = 2, .cells = &cells, .previous_cells = &previous, .cell_diffs = &diffs };
+ // A grip two cells wide at the top left, in both frames.
+ const grip = [_]pardes.Region{.{ .kind = .grip, .serial = 1, .rect = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }};
+ var cover: [12]Cover = undefined;
+ var previous_cover: [12]Cover = undefined;
+ coverFrame(&cover, 6, 2, &.{}, &.{}, &grip);
+ coverFrame(&previous_cover, 6, 2, &.{}, &.{}, &grip);
+ try std.testing.expect(gripCellChanged(&surface, &cover, &previous_cover, 0, 0));
+ try std.testing.expect(gripCellChanged(&surface, &cover, &previous_cover, 1, 0));
+ try std.testing.expect(!gripCellChanged(&surface, &cover, &previous_cover, 2, 0));
+ try std.testing.expect(!cover[2].grip);
+ // A column's grip has no glyph of a pane's.
+ coverFrame(&cover, 6, 2, &.{}, &.{}, &.{.{ .kind = .column_grip, .rect = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }});
+ try std.testing.expect(!cover[0].grip);
+ coverFrame(&cover, 6, 2, &.{}, &.{}, &grip);
+ const chrome: pardes.Chrome = .{};
const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?;
defer c.ui_font_free(font);
@@ -6728,7 +6789,7 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" {
var instances: [4]CellInstance = undefined;
var next: u32 = 0;
for (0..2) |col| {
- emitSurfaceCell(&g, null, &instances, &next, @intCast(col), 0, fixedCellLayout(&g), 100, 100, null, cells[0..6], col, null, &cells[2], .{ .cell = &cells[0], .offset = @intCast(col) }, old, false, Ground.opaqueRgb(bg_default));
+ emitSurfaceCell(&g, &chrome, &instances, &next, @intCast(col), 0, fixedCellLayout(&g), 100, 100, null, cells[0..6], cover[0..6], col, null, old, false, Ground.opaqueRgb(bg_default));
try std.testing.expectEqual(@as(f32, 0), instances[col * 2 + 1].u0);
try std.testing.expectEqual(old, instances[col * 2 + 1].effect & old_layer_bit != 0);
}
@@ -6883,10 +6944,12 @@ fn ensureVbuf(g: *Gui, cells: u32) !void {
g.vbuf_cells = capacity;
}
-fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {
+/// A pipeline over cell instances: the cells' own (ui.frag, the atlas, no
+/// blending) or the decor's (decor.frag, blended like the overlay).
+fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat, frag: []const u8, samplers: u32, blend: c.SDL_GPUColorTargetBlendState) !*c.SDL_GPUGraphicsPipeline {
const vs = try makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0);
defer c.SDL_ReleaseGPUShader(device, vs);
- const fs = try makeShader(device, frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1);
+ const fs = try makeShader(device, frag, c.SDL_GPU_SHADERSTAGE_FRAGMENT, samplers, 1);
defer c.SDL_ReleaseGPUShader(device, fs);
var vbuf_desc = c.SDL_GPUVertexBufferDescription{
@@ -6903,8 +6966,9 @@ fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat)
.{ .location = 4, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "panel_x0") },
.{ .location = 5, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "present_x0") },
.{ .location = 6, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_UINT4, .offset = @offsetOf(CellInstance, "effect") },
+ .{ .location = 7, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "clip_x0") },
};
- var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) };
+ var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend };
var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo);
info.vertex_shader = vs;
@@ -7021,25 +7085,7 @@ fn makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFor
return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
}
-fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {
- const vs = try makeShader(device, crt_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0);
- defer c.SDL_ReleaseGPUShader(device, vs);
- const fs = try makeShader(device, crt_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1);
- defer c.SDL_ReleaseGPUShader(device, fs);
-
- var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) };
- var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo);
- info.vertex_shader = vs;
- info.fragment_shader = fs;
- info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST;
- info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL;
- info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE;
- info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
- info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 };
- return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
-}
-
-fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32, n_uniform_buffers: u32) !*c.SDL_GPUShader {
+pub fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32, n_uniform_buffers: u32) !*c.SDL_GPUShader {
var info = std.mem.zeroes(c.SDL_GPUShaderCreateInfo);
info.code = code.ptr;
info.code_size = code.len;
@@ -7216,7 +7262,10 @@ fn latencyNote(comptime fmt: []const u8, args: anytype) void {
_ = libc.write(latency_fd, line.ptr, line.len);
}
-fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void {
+/// Writes the frame to latest.ppm in the capture directory, and a panel
+/// transition's frame `transition` to transition-<frame>.ppm too, so a test
+/// can hold one partway (test/gui_golden.py).
+fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32, transition: ?u16) !void {
const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1);
if (size == 0) {
_ = c.SDL_SubmitGPUCommandBuffer(cmd);
@@ -7249,10 +7298,12 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t
const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap;
defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer);
const pixels: [*]const u8 = @ptrCast(mapped);
- try writeCapturePpm(g, gpa, pixels[0..size], sw, sh);
+ try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, "latest");
+ var name: [24]u8 = undefined;
+ if (transition) |frame| try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "transition-{d}", .{frame}) catch unreachable);
}
-fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) !void {
+fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32, name: []const u8) !void {
// A latency trace times frames, and an 8 MB file write per frame would
// stall the loop it is timing.
if (g.capture_dir.len == 0 or latency_fd >= 0) return;
@@ -7266,8 +7317,8 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u
var tmp_buf: [4096]u8 = undefined;
var final_buf: [4096]u8 = undefined;
- const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/latest.ppm.tmp", .{g.capture_dir}, 0) catch return error.CapturePathTooLong;
- const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/latest.ppm", .{g.capture_dir}, 0) catch return error.CapturePathTooLong;
+ const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/{s}.ppm.tmp", .{ g.capture_dir, name }, 0) catch return error.CapturePathTooLong;
+ const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/{s}.ppm", .{ g.capture_dir, name }, 0) catch return error.CapturePathTooLong;
const fd = libc.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
if (fd < 0) return error.CaptureWriteFailed;
@@ -7293,128 +7344,308 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u
if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed;
}
-fn addCursorBar(
- builder: *OverlayBuilder,
- x: u16,
- y: u16,
- bar: bool,
- visual_height: f32,
- layout: CellLayout,
- rgb: [3]u8,
-) void {
- if (!bar) return;
- const previous_ink = builder.ink;
- builder.ink = true;
- defer builder.ink = previous_ink;
- const x0 = layout.x_off + @as(f32, @floatFromInt(x)) * layout.w;
- const height = std.math.clamp(visual_height, 1.0, layout.h);
- const y0 = layout.y_off + @as(f32, @floatFromInt(y)) * layout.h +
- @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, @intFromFloat(layout.h), @intFromFloat(height))));
- builder.addRect(
- x0,
- y0,
- x0 + @max(1.0, layout.w / 8.0),
- y0 + height,
- .{
- .r = @as(f32, @floatFromInt(rgb[0])) / 255.0,
- .g = @as(f32, @floatFromInt(rgb[1])) / 255.0,
- .b = @as(f32, @floatFromInt(rgb[2])) / 255.0,
- .a = 1.0,
- },
- );
+/// One flat, hard-edged rectangle of decor, in window pixels: clipped to the
+/// window and dropped when empty, as the triangle overlay drew it, and moved
+/// by `track` as its pane's cells are.
+fn addDecor(g: *Gui, gpa: std.mem.Allocator, x0_in: f32, y0_in: f32, x1_in: f32, y1_in: f32, rgb: [3]u8, alpha: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void {
+ const x0 = std.math.clamp(@min(x0_in, x1_in), 0.0, win_w);
+ const x1 = std.math.clamp(@max(x0_in, x1_in), 0.0, win_w);
+ const y0 = std.math.clamp(@min(y0_in, y1_in), 0.0, win_h);
+ const y1 = std.math.clamp(@max(y0_in, y1_in), 0.0, win_h);
+ if (x1 <= x0 or y1 <= y0) return;
+ // decor.frag.glsl reads the colour from the ground and the coverage
+ // from the ink's red.
+ var instance: CellInstance = .{
+ .x0 = (x0 / win_w) * 2.0 - 1.0,
+ .y0 = 1.0 - (y0 / win_h) * 2.0,
+ .x1 = (x1 / win_w) * 2.0 - 1.0,
+ .y1 = 1.0 - (y1 / win_h) * 2.0,
+ .u0 = 0,
+ .v0 = 0,
+ .u1 = 0,
+ .v1 = 0,
+ .fr = alpha,
+ .fg = 0,
+ .fb = 0,
+ .br = @as(f32, @floatFromInt(rgb[0])) / 255.0,
+ .bg = @as(f32, @floatFromInt(rgb[1])) / 255.0,
+ .bb = @as(f32, @floatFromInt(rgb[2])) / 255.0,
+ };
+ setTransitionFields(&instance, track, fixedCellLayout(g), win_w, win_h, 0);
+ try g.decor.append(gpa, instance);
}
-fn buildOverlay(
- g: *Gui,
- core: ?*const pardes.Pardes,
- surface: *const pardes.Surface,
- layout: CellLayout,
- sw: u32,
- sh: u32,
- topbar_pane_border_rgb: [3]u8,
- tagline_rgb: [3]u8,
- transient_on: bool,
- debug_on: bool,
-) u32 {
- if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0;
- const win_w: f32 = @floatFromInt(sw);
- const win_h: f32 = @floatFromInt(sh);
- var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h };
- appendWorkspacePet(g, core, surface, &builder);
- // The workspace tag grows a row per line, so the border follows its foot.
- const top_rows: u16 = if (core) |p| p.topBarHeight() else pardes.TOPBAR_H;
- if (topbarPaneBorderHeight(surface, top_rows, g.cell_h, g.tagline_height)) |height| {
- const y0: f32 = @floatFromInt(top_rows * g.cell_h - height / 2);
- const rgb = topbar_pane_border_rgb;
- builder.addRect(0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), .{
- .r = @as(f32, @floatFromInt(rgb[0])) / 255.0,
- .g = @as(f32, @floatFromInt(rgb[1])) / 255.0,
- .b = @as(f32, @floatFromInt(rgb[2])) / 255.0,
- .a = 1.0,
- });
+/// The frame's decor, group by group (docs/render-pipeline.md §3.4), all of it
+/// read from the regions and the palette: the page's rules and the still
+/// panes' rails, rules and grip marks in tier 0, a moving or closing pane's
+/// in its track's group, notice rules in tier 4, bar cursors in tier 5.
+fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, groups: *Groups, layout: CellLayout, win_w: f32, win_h: f32, cursors_shown: bool) !void {
+ g.decor.clearRetainingCapacity();
+ const chrome = &surface.chrome;
+ const regions = surface.regionList();
+ for (groups.items[0..groups.len], 0..) |*group, index| {
+ group.decor_start = @intCast(g.decor.items.len);
+ defer group.decor_count = @as(u32, @intCast(g.decor.items.len)) - group.decor_start;
+ if (index == 0) {
+ const top = for (regions) |region| {
+ if (region.kind == .workspace_tag) break region.rect.h;
+ } else 0;
+ // The workspace tag is ruled off from the tags right under it.
+ const tag_under = for (regions) |region| switch (region.kind) {
+ .grip, .tag, .column_grip, .column_tag => if (region.rect.y == top) break true,
+ else => {},
+ } else false;
+ const height = topbarPaneBorderPixels(g.cell_h, g.tagline_height);
+ if (top != 0 and top < surface.rows and tag_under and height != 0) {
+ const y0: f32 = @floatFromInt(top * g.cell_h - height / 2);
+ try addDecor(g, gpa, 0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), chrome.topbar_rule, 1, null, win_w, win_h);
+ }
+ // The column bar is ruled off from the panes under it.
+ for (regions) |bar| {
+ if (bar.kind != .column_grip) continue;
+ const y = @as(f32, @floatFromInt(bar.rect.y + bar.rect.h)) * layout.h;
+ try addDecor(g, gpa, 0, y - 1, win_w, y, chrome.border, 1, null, win_w, win_h);
+ break;
+ }
+ // A tag on the last row runs on through the pixels under the grid.
+ const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h;
+ const tag_last = for (regions) |region| switch (region.kind) {
+ .grip, .tag, .column_grip, .column_tag, .workspace_tag => if (region.rect.h != 0 and region.rect.y + region.rect.h == surface.rows) break true,
+ else => {},
+ } else false;
+ if (grid_bottom < win_h and tag_last) try addDecor(g, gpa, 0, grid_bottom, win_w, win_h, chrome.tag_bg, 1, null, win_w, win_h);
+ }
+ if (index <= groups.tracks) {
+ const track = group.track;
+ // A closing pane is gone: its chrome is the last frame's.
+ const closing = if (track) |active| active.phase == .closing else false;
+ const placed = if (closing) surface.previous_regions else regions;
+ const grid = if (closing) surface.previous_cells else surface.cells;
+ try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track);
+ // A single spine runs down each column, through every tag and body.
+ if (index == 0) for (regions) |column| {
+ if (column.kind != .column) continue;
+ const x = @as(f32, @floatFromInt(column.rect.x)) * layout.w;
+ const y0 = @as(f32, @floatFromInt(column.rect.y)) * layout.h;
+ const grid_y1 = @as(f32, @floatFromInt(surface.rows)) * layout.h;
+ const y1 = if (win_h - grid_y1 < layout.h) @max(grid_y1, win_h) else grid_y1;
+ if (y1 > y0) try addDecor(g, gpa, x, y0, x + 1, y1, chrome.border, 1, null, win_w, win_h);
+ };
+ try gripDecor(g, gpa, placed, grid, groups, index, layout, surface.cols, surface.rows, win_w, win_h, track);
+ }
+ // A notice chip is tagline hung over the body's top rows, so it is
+ // ruled off from the text beneath it the way a tag is: the same
+ // colour and thickness along its bottom, running to the window's
+ // edge when it does, fading up and dissolving with its band.
+ for (surface.tagLayers()) |*layer| {
+ if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue;
+ const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w;
+ const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w;
+ const rule_x1 = if (win_w - x1 < layout.w) @max(x1, win_w) else x1;
+ const top = @as(f32, @floatFromInt(layer.viewport.y)) * layout.h;
+ // A band still falling into place is cut at its row's top; its
+ // rule comes with it, and shows only once it is below that cut.
+ const rule_y = top + (1 + layer.slide) * layout.h;
+ if (rule_y - 1 < top) continue;
+ const track = groups.items[groupAt(groups, layer.viewport.x, layer.viewport.y)].track;
+ try addDecor(g, gpa, x0, rule_y - 1, rule_x1, rule_y, chrome.border, layer.fade, track, win_w, win_h);
+ }
+ if (index == groups.ink and cursors_shown) try cursorDecor(g, gpa, surface, layout, win_w, win_h);
+ }
+}
+
+/// Each pane's rule between its tag and body, and its scroll rail: a page
+/// coloured gutter with a thin track and its thumb, running down through the
+/// pixels past the last whole row. Only the panes of group `index`.
+fn paneDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, groups: *const Groups, index: usize, chrome: *const pardes.Chrome, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void {
+ const page = chrome.page orelse bg_default;
+ for (regions) |rail| {
+ if (rail.kind != .rail or rail.rect.h == 0 or groupOf(groups, rail.serial) != index) continue;
+ const tag = for (regions) |region| {
+ if (region.kind == .tag and region.owner == rail.owner) break region;
+ } else continue;
+ if (@as(u32, tag.rect.x) + tag.rect.w > cols or @as(u32, tag.rect.y) + tag.rect.h > rows or
+ @as(u32, rail.rect.y) + rail.rect.h > rows) continue;
+ const x0 = @as(f32, @floatFromInt(rail.rect.x)) * layout.w;
+ const x1 = @as(f32, @floatFromInt(tag.rect.x + tag.rect.w)) * layout.w;
+ // Like the band above it, the rule of a pane at the right edge runs
+ // on through the pixels past the last whole cell. Under a tag on
+ // top, over one below its body.
+ const rule_x1 = if (win_w - x1 < layout.w) @max(x1, win_w) else x1;
+ const tag_bottom = tag.rect.y > rail.rect.y;
+ const rule_y = @as(f32, @floatFromInt(if (tag_bottom) tag.rect.y else tag.rect.y + tag.rect.h)) * layout.h;
+ try addDecor(g, gpa, x0, if (tag_bottom) rule_y else rule_y - 1, rule_x1, if (tag_bottom) rule_y + 1 else rule_y, chrome.border, 1, track, win_w, win_h);
+
+ const y0 = @as(f32, @floatFromInt(rail.rect.y)) * layout.h;
+ const body_end = rail.rect.y + rail.rect.h;
+ const body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h;
+ const y1 = if (body_end == rows and win_h - body_y1 < layout.h) @max(body_y1, win_h) else body_y1;
+ try addDecor(g, gpa, x0, y0, x0 + layout.w, y1, page, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x0, y0, x0 + @min(layout.w, 2), y1, chrome.scroll_track, 1, track, win_w, win_h);
+ if (rail.thumb_h != 0) {
+ const thumb_y0 = @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y)) * layout.h;
+ const thumb_y1 = if (rail.thumb_y + rail.thumb_h == rail.rect.h) y1 else @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y + rail.thumb_h)) * layout.h;
+ try addDecor(g, gpa, x0, thumb_y0, x0 + @min(layout.w, @max(2, @floor(layout.w * 0.5))), thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h);
+ }
}
- if (core) |p| if (p.columnBarHeight() > 0) {
- const y = @as(f32, @floatFromInt(top_rows + p.columnBarHeight())) * layout.h;
- builder.addRect(0, y - 1, win_w, y, overlayRgb(p.chromeTheme().border));
+}
+
+/// The two short bars on each row of a grip with no mode glyph there,
+/// centered in its anchor. Only the panes of group `index`.
+fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, cells: []const pardes.Cell, groups: *const Groups, index: usize, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void {
+ if (g.tagline_height < 6 or g.tagline_width < 3) return;
+ for (regions) |grip| {
+ if (grip.kind != .grip or groupOf(groups, grip.serial) != index) continue;
+ const x = grip.rect.x;
+ for (0..grip.rect.h) |line| {
+ const y = grip.rect.y + @as(u16, @intCast(line));
+ if (x >= cols or y >= rows) continue;
+ const cell = &cells[@as(usize, y) * cols + x];
+ if (cell.default or cell.printableAscii() != ' ') continue;
+ const rgb = switch (cell.style.fg) {
+ .rgb => |rgb| rgb,
+ else => continue,
+ };
+ const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, win_h) / 2));
+ const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2;
+ const half_w = @max(1, @min(3, layout.w - 2));
+ try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), rgb, 1, track, win_w, win_h);
+ try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), rgb, 1, track, win_w, win_h);
+ }
+ }
+}
+
+test "decor is read from the regions, each pane's in its own group" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ _ = try core.newShell(1, "");
+ try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false));
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ var g: Gui = undefined;
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.tagline_width = 6;
+ g.tagline_height = 12;
+ const layout = fixedCellLayout(&g);
+ const ndc = struct {
+ fn x(px: f32) f32 {
+ return px / 800 * 2 - 1;
+ }
};
- const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h;
- if (grid_bottom < win_h and bottomTaglinePresent(surface)) {
- const rgb = tagline_rgb;
- builder.addRect(0, grid_bottom, win_w, win_h, .{
- .r = @as(f32, @floatFromInt(rgb[0])) / 255.0,
- .g = @as(f32, @floatFromInt(rgb[1])) / 255.0,
- .b = @as(f32, @floatFromInt(rgb[2])) / 255.0,
- .a = 1.0,
- });
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, layout, 800, 480, true);
+ // A spine one pixel wide at each column's left edge, down from under the
+ // workspace tag; a rail two pixels wide at each pane's.
+ for (core.col_x[0..core.ncol]) |x| {
+ const left = ndc.x(@as(f32, @floatFromInt(x)) * 10);
+ const spine = for (g.decor.items) |item| {
+ if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 1)) < 0.0001) break item;
+ } else return error.TestExpectedSpine;
+ try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), spine.y0, 0.0001);
+ try std.testing.expectEqual(@as(f32, -1), spine.y1);
+ const rail = for (g.decor.items) |item| {
+ if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001) break item;
+ } else return error.TestExpectedRail;
+ try std.testing.expectApproxEqAbs(@as(f32, s.chrome.scroll_track[0]) / 255, rail.br, 0.0001);
}
- if (transient_on) {
- if (core) |p| appendPaneChrome(p, surface, layout, &builder);
- appendTagGrips(g, surface, layout, &builder);
- if (surface.cursor) |cursor| {
- if (cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) {
- const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x];
- const role = cellFontRole(&cell);
- const visual_height: f32 = if (role == .tagline)
- @floatFromInt(g.tagline_height)
- else
- layout.h;
- const cursor_layout = if (role == .tagline)
- taglineLayoutForCell(g, cursor.x)
- else
- layout;
- addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, cursor_layout, cursorColor(core, &cell, role));
- }
+ try std.testing.expectEqual(@as(u32, 0), groups.items[groups.notices].decor_count);
+ const still = groups.items[0].decor_count;
+
+ // Pane 1 moving: its rule and rail leave tier 0 for its track's group,
+ // which moves them with it.
+ const serial = core.panes[1].?.serial;
+ const moving: pardes.animation.Track = .{ .pane = 1, .serial = serial, .phase = .moving, .effect = .slide, .from = .{ .x = 0, .y = 1, .w = 40, .h = 23 }, .to = .{ .x = 40, .y = 1, .w = 40, .h = 23 } };
+ groups = makeGroups(&.{moving}, true);
+ try buildDecor(&g, gpa, s, &groups, layout, 800, 480, groups.tracks == 0);
+ const tracked = groups.items[1];
+ try std.testing.expect(tracked.decor_count >= 4);
+ try std.testing.expectEqual(still, groups.items[0].decor_count + tracked.decor_count);
+ for (g.decor.items[tracked.decor_start..][0..tracked.decor_count]) |item|
+ try std.testing.expectEqual(@as(u32, @intFromEnum(pardes.animation.Transition.slide)), item.effect);
+ // Cursors stand still while panes move.
+ try std.testing.expectEqual(@as(u32, 0), groups.items[groups.ink].decor_count);
+}
+
+const CursorBox = Post.Frame.Cursor;
+const max_cursors = 1 + pardes.MAX_PANES + pardes.MAX_TAG_LAYERS;
+
+/// Every cursor on the frame where it is drawn, in window pixels: the grid's
+/// where no layer has its cell, and each layer's at that layer's pitch. A bar
+/// is an eighth of its cell wide, a block the whole cell.
+fn cursorBoxes(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout, win_h: f32, out: *[max_cursors]CursorBox) usize {
+ const chrome = &surface.chrome;
+ const page = chrome.page orelse bg_default;
+ var n: usize = 0;
+ if (surface.cursor) |cursor| {
+ if (cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) {
+ const cell = &surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x];
+ const role = cellFontRole(cell);
+ const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w;
+ const height = std.math.clamp(if (role == .tagline) @as(f32, @floatFromInt(g.tagline_height)) else layout.h, 1.0, layout.h);
+ const x0 = @as(f32, @floatFromInt(cursor.x)) * layout.w;
+ const y0 = @as(f32, @floatFromInt(cursor.y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(cursor.y, win_h, @intFromFloat(layout.h), @intFromFloat(height))));
+ out[n] = .{ .x0 = x0, .y0 = y0, .x1 = x0 + if (cursor.bar) @max(1.0, width / 8.0) else width, .y1 = y0 + height, .rgb = cursorColor(chrome, cell, role), .text = page, .bar = cursor.bar };
+ n += 1;
}
}
- if (transient_on) for (surface.bodyLayers()) |*layer| {
+ for (surface.bodyLayers()) |*layer| {
if (layer.rows == 0) continue;
+ const cursor = layer.cursor orelse continue;
+ if (cursor.y >= layer.rows or cursor.x >= layer.cols) continue;
const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w;
const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h;
- if (layer.cursor) |cursor| {
- if (!cursor.bar or cursor.y >= layer.rows or cursor.x >= layer.cols) continue;
- const top = layer.rowTop(cursor.y, layout.h, @floatFromInt(g.tagline_height));
- if (top >= bottom) continue;
- const height = layer.rowHeight(cursor.y, layout.h, @floatFromInt(g.tagline_height));
- const role: pardes.FontRole = if (cursor.y < layer.context_rows) .tagline else .body;
- const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w;
- const x = left + @as(f32, @floatFromInt(cursor.x)) * width;
- const cell = &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x];
- builder.addRect(x, top, x + @max(1, width / 8), @min(bottom, top + height), overlayRgb(cursorColor(core, cell, role)));
- }
- };
- if (transient_on) for (surface.tagLayers()) |*layer| {
- if (layer.cols == 0) continue;
+ const top = layer.rowTop(cursor.y, layout.h, @floatFromInt(g.tagline_height));
+ if (top >= bottom) continue;
+ const height = layer.rowHeight(cursor.y, layout.h, @floatFromInt(g.tagline_height));
+ const role: pardes.FontRole = if (cursor.y < layer.context_rows) .tagline else .body;
+ const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w;
+ const x = left + @as(f32, @floatFromInt(cursor.x)) * width;
+ const cell = &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x];
+ out[n] = .{ .x0 = x, .y0 = top, .x1 = x + if (cursor.bar) @max(1, width / 8) else width, .y1 = @min(bottom, top + height), .rgb = cursorColor(chrome, cell, role), .text = page, .bar = cursor.bar };
+ n += 1;
+ }
+ for (surface.tagLayers()) |*layer| {
+ if (layer.rows == 0) continue;
const cursor = layer.cursor orelse continue;
- if (!cursor.bar or cursor.col >= layer.cols) continue;
+ if (cursor.x >= layer.cols or cursor.y >= layer.rows) continue;
const width: f32 = @floatFromInt(g.tagline_width);
const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w;
const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w;
- const x = left + @as(f32, @floatFromInt(cursor.col)) * width;
+ const x = left + @as(f32, @floatFromInt(cursor.x)) * width;
if (x >= right) continue;
- const top = @as(f32, @floatFromInt(layer.viewport.y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(layer.viewport.y, builder.win_h, g.cell_h, g.tagline_height)));
- builder.addRect(x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), overlayRgb(cursorColor(core, &layer.cells[cursor.col], .tagline)));
- };
- if (transient_on) appendTouchOverlay(g, &builder, debug_on);
+ const y = layer.viewport.y + cursor.y;
+ const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height)));
+ out[n] = .{ .x0 = x, .y0 = top, .x1 = @min(right, x + if (cursor.bar) @max(1, width / 8) else width), .y1 = top + @as(f32, @floatFromInt(g.tagline_height)), .rgb = cursorColor(chrome, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline), .text = page, .bar = cursor.bar };
+ n += 1;
+ }
+ return n;
+}
+
+/// The bar cursors as decor; a block cursor is its cell's own colours.
+fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32) !void {
+ var boxes: [max_cursors]CursorBox = undefined;
+ for (boxes[0..cursorBoxes(g, surface, layout, win_h, &boxes)]) |box|
+ if (box.bar) try addDecor(g, gpa, box.x0, box.y0, box.x1, box.y1, box.rgb, 1, null, win_w, win_h);
+}
+
+/// The triangle overlay: the workspace pet and the touch debug view, over
+/// everything.
+fn buildOverlay(
+ g: *Gui,
+ core: ?*const pardes.Pardes,
+ surface: *const pardes.Surface,
+ sw: u32,
+ sh: u32,
+ debug_on: bool,
+) u32 {
+ if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0;
+ var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = @floatFromInt(sw), .win_h = @floatFromInt(sh) };
+ appendWorkspacePet(g, core, surface, &builder);
+ appendTouchOverlay(g, &builder, debug_on);
return @intCast(builder.len);
}
@@ -7489,33 +7720,11 @@ test "pet overlay stays beyond workspace text and vanishes while its tag is edit
try std.testing.expectEqual(@as(usize, 0), builder.len);
}
-fn cursorColor(core: ?*const pardes.Pardes, cell: *const pardes.Cell, role: pardes.FontRole) [3]u8 {
- if (core) |p| return if (role == .tagline) switch (cell.style.fg) {
+fn cursorColor(chrome: *const pardes.Chrome, cell: *const pardes.Cell, role: pardes.FontRole) [3]u8 {
+ return if (role == .tagline) switch (cell.style.fg) {
.rgb => |rgb| rgb,
- else => p.chromeTheme().tag_fg,
- } else p.theme().fg orelse fg_default;
- return switch (cell.style.fg) {
- .rgb => |rgb| rgb,
- .index => |index| palColor(index),
- // Detached frames carry explicit page backgrounds but blank cells need
- // not carry foreground ink. Keep their insertion caret visible too.
- .default => switch (cell.style.bg) {
- .rgb => |rgb| if ((@as(u16, rgb[0]) * 3 + @as(u16, rgb[1]) * 6 + rgb[2]) / 10 > 140)
- .{ 32, 32, 32 }
- else
- fg_default,
- else => fg_default,
- },
- };
-}
-
-test "detached insertion caret contrasts with blank light and dark pages" {
- var cell: pardes.Cell = .{ .default = false, .style = .{ .bg = .{ .rgb = .{ 248, 246, 237 } } } };
- try std.testing.expectEqual([3]u8{ 32, 32, 32 }, cursorColor(null, &cell, .body));
- cell.style.bg = .{ .rgb = .{ 24, 28, 32 } };
- try std.testing.expectEqual(fg_default, cursorColor(null, &cell, .body));
- cell.style.fg = .{ .rgb = .{ 122, 188, 160 } };
- try std.testing.expectEqual([3]u8{ 122, 188, 160 }, cursorColor(null, &cell, .body));
+ else => chrome.tag_fg,
+ } else chrome.fg orelse fg_default;
}
fn overlayRgb(rgb: [3]u8) OverlayColor {
@@ -7527,181 +7736,6 @@ fn overlayRgb(rgb: [3]u8) OverlayColor {
};
}
-fn cellBackgroundIs(cell: *const pardes.Cell, rgb: [3]u8) bool {
- return !cell.default and switch (cell.style.bg) {
- .rgb => |bg| std.mem.eql(u8, &bg, &rgb),
- else => false,
- };
-}
-
-// The canonical surface still owns scrollbar positions and hit targets. Only
-// their pixel ink is inset here; no layout, selection or mouse coordinates move.
-fn appendPaneChrome(
- core: *const pardes.Pardes,
- surface: *const pardes.Surface,
- layout: CellLayout,
- builder: *OverlayBuilder,
-) void {
- const chrome = core.chromeTheme();
- const page = overlayRgb(core.theme().bg orelse bg_default);
- for (core.panes, core.rects) |slot, r| {
- const pane = slot orelse continue;
- if (r.w <= config.GUTTER or r.h <= pane.tag_rows) continue;
- if (@as(u32, r.x) + r.w > surface.cols or @as(u32, r.y) + r.h > surface.rows) continue;
- const tag_y = core.tagTop(pane, r);
- const body_y = core.bodyTop(pane, r);
- const body_end = body_y + (r.h -| pane.tag_rows);
- const x0 = @as(f32, @floatFromInt(r.x)) * layout.w;
- const x1 = x0 + @as(f32, @floatFromInt(r.w)) * layout.w;
- const tag_cells = surface.cells[@as(usize, tag_y) * surface.cols + r.x + pardes.TAG_TEXT_INSET ..][0 .. r.w - pardes.TAG_TEXT_INSET];
- const tag_visible = for (tag_cells) |*cell| {
- if (cellFontRole(cell) != .tagline) break false;
- } else true;
- if (tag_visible) {
- const rule_y = @as(f32, @floatFromInt(tag_y + (if (core.settings.tag_bottom) 0 else pane.tag_rows))) * layout.h;
- // Like the band above it, the rule of a pane at the right edge
- // runs on through the pixels past the last whole cell.
- const rule_x1 = if (builder.win_w - x1 < layout.w) @max(x1, builder.win_w) else x1;
- builder.addRect(x0, rule_y - @as(f32, if (core.settings.tag_bottom) 0 else 1), rule_x1, rule_y + @as(f32, if (core.settings.tag_bottom) 1 else 0), overlayRgb(chrome.border));
- }
-
- // Do not paint over resize previews or debug text occupying the rail.
- var thumb_start: ?u16 = null;
- var thumb_end: u16 = body_y;
- var row = body_y;
- while (row < body_end) : (row += 1) {
- const cell = &surface.cells[@as(usize, row) * surface.cols + r.x];
- if (cell.printableAscii() != ' ') break;
- if (cellBackgroundIs(cell, chrome.scroll_thumb)) {
- if (thumb_start == null) thumb_start = row;
- thumb_end = row + 1;
- } else if (!cellBackgroundIs(cell, chrome.scroll_track)) break;
- }
- if (row != body_end) continue;
- const y0 = @as(f32, @floatFromInt(body_y)) * layout.h;
- const body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h;
- const y1 = if (body_end == surface.rows and builder.win_h - body_y1 < layout.h) @max(body_y1, builder.win_h) else body_y1;
- builder.addRect(x0, y0, x0 + layout.w, y1, page);
- appendSquareRail(builder, x0, y0, y1, layout.w, false, overlayRgb(chrome.scroll_track));
- if (thumb_start) |first| {
- const thumb_y0 = @as(f32, @floatFromInt(first)) * layout.h;
- const thumb_y1 = if (thumb_end == body_end) y1 else @as(f32, @floatFromInt(thumb_end)) * layout.h;
- appendSquareRail(builder, x0, thumb_y0, thumb_y1, layout.w, true, overlayRgb(chrome.scroll_thumb));
- }
- }
- // A notice chip is tagline hung over the body's top rows, so it is ruled
- // off from the text beneath it the way a tagline is: the same colour and
- // thickness along its bottom, running to the window edge when it does.
- for (surface.tagLayers()) |*layer| {
- if (layer.cols == 0 or layer.kind != .notice) continue;
- const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w;
- const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w;
- const rule_x1 = if (builder.win_w - x1 < layout.w) @max(x1, builder.win_w) else x1;
- const top = @as(f32, @floatFromInt(layer.viewport.y)) * layout.h;
- // A band still falling into place is cut at its row's top; its rule
- // comes with it, and shows only once it is below that cut.
- const rule_y = top + (1 + layer.slide) * layout.h;
- if (rule_y - 1 < top) continue;
- // ...and fades up and dissolves with it rather than standing alone.
- var rule = overlayRgb(chrome.border);
- rule.a = layer.fade;
- builder.addRect(x0, rule_y - 1, rule_x1, rule_y, rule);
- }
- appendColumnSpines(core, surface.rows, layout, builder);
-}
-
-// Tag handles use the canonical physical cells, including detached frames.
-// Their tag text begins at the layer viewport and may use a narrower font.
-fn appendTagGrips(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout, builder: *OverlayBuilder) void {
- if (g.tagline_height < 6 or g.tagline_width < 3) return;
- for (surface.tagLayers()) |layer| {
- if (layer.cols == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET) continue;
- const x = layer.viewport.x - pardes.TAG_TEXT_INSET;
- const y = layer.viewport.y;
- if (x >= surface.cols or y >= surface.rows) continue;
- const cell = &surface.cells[@as(usize, y) * surface.cols + x];
- if (cell.default or cell.printableAscii() != ' ') continue;
- const rgb = switch (cell.style.fg) {
- .rgb => |rgb| rgb,
- else => continue,
- };
- const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, builder.win_h) / 2));
- const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2;
- const half_w = @max(1, @min(3, layout.w - 2));
- builder.addRect(@floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), overlayRgb(rgb));
- builder.addRect(@floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), overlayRgb(rgb));
- }
-}
-
-test "column grips stay unmarked while pane marks retain physical centering" {
- var g: Gui = undefined;
- g.tagline_height = 12;
- g.tagline_width = 6;
- g.cell_h = 20;
- var cells: [80]pardes.Cell = @splat(.{ .default = false, .style = .{ .fg = .{ .rgb = .{ 100, 120, 140 } } } });
- var surface: pardes.Surface = .{ .cols = 20, .rows = 4, .cells = &cells };
- surface.tag_layers[0] = .{ .kind = .column, .viewport = .{ .x = 8, .y = 1, .w = 13, .h = 1 }, .cols = 20 };
- surface.tag_layers[1] = .{ .kind = .pane, .viewport = .{ .x = 8, .y = 3, .w = 13, .h = 1 }, .cols = 20 };
- var vertices: [24]OverlayVertex = undefined;
- var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 200, .win_h = 80 };
- appendTagGrips(&g, &surface, .{ .w = 10, .h = 20, .x_off = 0, .y_off = 0 }, &builder);
- try std.testing.expectEqual(@as(usize, 12), builder.len);
- // Centered in the anchor, whose left edge is inset by the band's offset
- // (8 px on this last row): the mark moves right by half of it.
- try std.testing.expectApproxEqAbs(@as(f32, 61.0 / 200.0 * 2.0 - 1.0), vertices[0].x, 0.0001);
-}
-
-fn appendSquareRail(builder: *OverlayBuilder, x: f32, y0: f32, y1: f32, cell_w: f32, thumb: bool, color: OverlayColor) void {
- const width = @min(cell_w, if (thumb) @max(2, @floor(cell_w * 0.5)) else 2);
- builder.addRect(x, y0, x + width, y1, color);
-}
-
-fn appendColumnSpines(core: *const pardes.Pardes, rows: u16, layout: CellLayout, builder: *OverlayBuilder) void {
- const y0 = @as(f32, @floatFromInt(core.topBarHeight())) * layout.h;
- const grid_y1 = @as(f32, @floatFromInt(rows)) * layout.h;
- // Down through the pixels past the last whole row, as tags run right.
- const y1 = if (builder.win_h - grid_y1 < layout.h) @max(grid_y1, builder.win_h) else grid_y1;
- if (y1 <= y0) return;
- // A single boundary spans all panes, including their tags. Its left edge
- // agrees with the square scroll markers and existing move-box hit area.
- for (core.col_x[0..core.ncol], core.col_w[0..core.ncol]) |col, width| {
- if (width == 0) continue;
- const x = @as(f32, @floatFromInt(col)) * layout.w;
- builder.addRect(x, y0, x + 1, y1, overlayRgb(core.chromeTheme().border));
- }
-}
-
-test "square scroll markers share the column edge and have no rounded end inset" {
- var vertices: [12]OverlayVertex = undefined;
- var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 800, .win_h = 480 };
- appendSquareRail(&builder, 400, 40, 480, 10, false, overlayRgb(.{ 40, 40, 40 }));
- appendSquareRail(&builder, 400, 80, 160, 10, true, overlayRgb(.{ 100, 100, 100 }));
- try std.testing.expectEqual(@as(usize, 12), builder.len);
- try std.testing.expectEqual(vertices[0].x, vertices[6].x);
- try std.testing.expectApproxEqAbs(@as(f32, 1 - 80.0 / 480.0 * 2), vertices[6].y, 0.0001);
- try std.testing.expectApproxEqAbs(@as(f32, 1 - 160.0 / 480.0 * 2), vertices[8].y, 0.0001);
- try std.testing.expectApproxEqAbs(@as(f32, 5.0 / 800.0 * 2), vertices[7].x - vertices[6].x, 0.0001);
-}
-
-test "column spines remain continuous across every pane tag and body" {
- const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
- defer core.deinit();
- _ = try core.newShell(1, "");
- try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false));
- pardes.test_api.sync(core);
- var vertices: [12]OverlayVertex = undefined;
- var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 800, .win_h = 480 };
- appendColumnSpines(core, 24, .{ .w = 10, .h = 20, .x_off = 0, .y_off = 0 }, &builder);
- try std.testing.expectEqual(@as(usize, 12), builder.len);
- for (0..2) |col| {
- const first = vertices[col * 6];
- const bottom = vertices[col * 6 + 2];
- try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), first.y, 0.0001);
- try std.testing.expectEqual(@as(f32, -1), bottom.y);
- try std.testing.expectApproxEqAbs(@as(f32, @floatFromInt(core.col_x[col])) * 10.0 / 800.0 * 2 - 1, first.x, 0.0001);
- }
-}
-
fn appendTouchOverlay(g: *Gui, builder: *OverlayBuilder, debug_on: bool) void {
if (!debug_on) {
g.touch.click_flash_frames = 0;
@@ -8014,7 +8048,7 @@ test "compact body layer rendering fills recovered rows and clips the final glyp
g.tagline_height = 12;
g.space_slot = .{ .u = 0, .v = 0 };
var cells: [24]pardes.Cell = @splat(.{});
- var layer: pardes.BodyLayer = .{
+ var layer: pardes.Layer = .{
.viewport = .{ .x = 3, .y = 2, .w = 2, .h = 5 },
.cols = 2,
.rows = 12,
@@ -8054,7 +8088,7 @@ test "compact tag layers fill available width and clip at their physical edge" {
g.tagline_height = 12;
g.space_slot = .{ .u = 0, .v = 0 };
var cells: [6]pardes.Cell = @splat(.{});
- const layer: pardes.TagLayer = .{ .kind = .pane, .id = 0, .serial = 7, .viewport = .{ .x = 2, .y = 3, .w = 3, .h = 1 }, .cols = 6, .cells = &cells, .bg = .{ 20, 30, 40 } };
+ const layer: pardes.Layer = .{ .kind = .pane, .id = 0, .serial = 7, .viewport = .{ .x = 2, .y = 3, .w = 3, .h = 1 }, .cols = 6, .rows = 1, .cells = &cells, .bg = .{ 20, 30, 40 } };
var instances: [8]CellInstance = undefined;
var next: u32 = 0;
emitTagLayer(&g, &instances, &next, &layer, 100, 100, null, Ground.opaqueRgb(bg_default), false, false);
diff --git a/src/host_io.zig b/src/host_io.zig
index 340513df..a019e15d 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -12,6 +12,9 @@ pub const Host = struct {
pub const VTable = struct {
wait_input: ?*const fn (ctx: ?*anyopaque, timeout_ms: u32) void = null,
+ /// The shell's monotonic clock in ns: the only time the core sees.
+ /// Without one, core animation moves only by `.tick` events.
+ now: ?*const fn (ctx: ?*anyopaque) u64 = null,
present: ?*const fn (ctx: ?*anyopaque, surface: *const pardes.Surface) void = null,
post_present: ?*const fn (ctx: ?*anyopaque) void = null,
poll_frame: ?*const fn (ctx: ?*anyopaque) void = null,
@@ -39,6 +42,20 @@ pub const Host = struct {
};
};
+/// The shells' monotonic clock in ns, the one `Host.now` answers with.
+/// PARDES_TEST_CLOCK makes a shell answer a virtual clock instead, moved
+/// only when a timed wait runs out -- straight to the core's next wake -- so
+/// the snapshot harness sees the same frames whatever the machine's load.
+pub fn monotonicNs() u64 {
+ var ts: libc.timespec = undefined;
+ if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0;
+ return @as(u64, @intCast(ts.sec)) * std.time.ns_per_s + @as(u64, @intCast(ts.nsec));
+}
+
+pub fn testClock() ?u64 {
+ return if (libc.getenv("PARDES_TEST_CLOCK")) |v| (if (std.mem.span(v).len != 0) 0 else null) else null;
+}
+
pub const Fallback = struct {
pub const dump_path = "pardes.dump.zon";
diff --git a/src/image.zig b/src/image.zig
index 09f049ca..071a2e2a 100644
--- a/src/image.zig
+++ b/src/image.zig
@@ -941,6 +941,7 @@ fn ensureGrid(p: *pardes.Pardes, state: *State, cols: u16, rows: u16) void {
pub fn draw(
p: *pardes.Pardes,
+ s: *pardes.Surface,
state: *State,
pane_id: u8,
serial: u32,
@@ -982,7 +983,7 @@ pub fn draw(
};
const sx = x + @as(u16, @intCast(offx + cx));
const sy = y + @as(u16, @intCast(offy + cy));
- if (sx < p.surface.cols and sy < p.surface.rows)
- p.surface.set(sx, sy, cell.glyph[0..cell.glyph_len], .{ .fg = fg, .bg = bg });
+ if (sx < s.cols and sy < s.rows)
+ s.set(sx, sy, cell.glyph[0..cell.glyph_len], .{ .fg = fg, .bg = bg });
};
}
diff --git a/src/layout.zig b/src/layout.zig
index 855a3eb9..f0e6fd16 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -4,666 +4,16 @@ const tagline = @import("tagline.zig");
const dump = @import("dump.zig");
const config = @import("config.zig");
const panes = @import("panes.zig");
+const animation = @import("animation.zig");
+const Box = animation.Box;
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
pub const Rect = struct { x: u16 = 0, y: u16 = 0, w: u16 = 0, h: u16 = 0 };
-pub const Snapshot = struct { serial: u32, box: Box };
const MAX_COLS = pardes.MAX_COLS;
const MAX_PANES = pardes.MAX_PANES;
const BOX_H = pardes.BOX_H;
-pub const Presentation = struct {
- // Only acknowledged draws advance shown geometry; committed and submitted may be ahead.
- committed: [MAX_PANES]?Snapshot = @splat(null),
- initialized: bool = false,
- enabled: bool = false,
- snap_once: bool = false,
- tracks: [MAX_PANES]?Track = @splat(null),
- closing: [MAX_PANES]Track = undefined,
- closing_len: usize = 0,
- submitted: [MAX_PANES]?Snapshot = @splat(null),
- submitted_ready: bool = false,
- shown: [MAX_PANES]?Snapshot = @splat(null),
- shown_tracks: [MAX_PANES]?Track = @splat(null),
- shown_closing: [MAX_PANES]Track = undefined,
- shown_closing_len: usize = 0,
- acknowledged: bool = false,
- pending: bool = false,
- previous_cells: []pardes.Cell = &.{},
- previous_body_layers: [MAX_PANES]pardes.BodyLayer = @splat(.{}),
- previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}),
- previous_cols: u16 = 0,
- previous_rows: u16 = 0,
- previous_valid: bool = false,
- previous_layout: [MAX_PANES]?Snapshot = @splat(null),
- diffs: []pardes.PanelCellDiff = &.{},
- diff_state: enum { none, requested, ready } = .none,
-
- pub const CellPosition = struct { col: u16, row: u16 };
-
- pub fn deinit(self: *Presentation, gpa: std.mem.Allocator) void {
- if (self.previous_cells.len > 0) gpa.free(self.previous_cells);
- for (&self.previous_body_layers) |*layer| gpa.free(layer.cells);
- for (&self.previous_tag_layers) |*layer| gpa.free(layer.cells);
- if (self.diffs.len > 0) gpa.free(self.diffs);
- }
-
- pub fn sync(self: *Presentation, p: *Pardes) void {
- const initializing = !self.initialized or !self.enabled;
- if (initializing or self.snap_once) {
- const had_layout = self.initialized;
- self.committed = @splat(null);
- self.tracks = @splat(null);
- self.closing_len = 0;
- self.diff_state = .none;
- if (initializing) {
- self.shown_tracks = @splat(null);
- self.shown_closing_len = 0;
- }
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
- }
- self.initialized = true;
- self.snap_once = false;
- if (had_layout and self.acknowledged)
- self.pending = true;
- return;
- }
-
- const effect = p.settings.panel_transition;
- if (effect.needsPreviousGrid() and self.diff_state != .none) {
- var second_change = false;
- for (p.panes, self.committed, 0..) |slot, snapshot, id| {
- const pane = slot orelse {
- second_change = second_change or snapshot != null;
- continue;
- };
- const target = panelBox(p.rects[id]);
- second_change = second_change or snapshot == null or
- snapshot.?.serial != pane.serial or !snapshot.?.box.eql(target);
- }
- if (second_change) {
- p.abandonPanelAnimations();
- self.committed = @splat(null);
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
- }
- return;
- }
- }
- var changed = false;
- var animated_change = false;
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse {
- if (self.committed[id]) |old| {
- changed = true;
- if (effect.lifecycleOnly()) {
- if (self.appendClosing(.{
- .serial = old.serial,
- .pane = @intCast(id),
- .phase = .closing,
- .effect = effect,
- .from = old.box,
- .to = closingBox(effect, old.box),
- })) animated_change = true;
- }
- }
- self.committed[id] = null;
- self.tracks[id] = null;
- // Never let pixels from a dead pane address a reused slot.
- self.shown_tracks[id] = null;
- continue;
- };
- const target = panelBox(p.rects[id]);
- const previous = self.committed[id];
- self.committed[id] = .{ .serial = pane.serial, .box = target };
-
- if (effect == .off) {
- changed = changed or previous == null or previous.?.serial != pane.serial or
- !previous.?.box.eql(target);
- self.tracks[id] = null;
- continue;
- }
- if (previous) |old| {
- if (old.serial == pane.serial and old.box.eql(target)) continue;
- const same_lifetime = old.serial == pane.serial;
- const prior = if (self.tracks[id]) |track|
- if (track.serial == pane.serial and track.active()) track else null
- else
- null;
- if (effect.lifecycleOnly() and same_lifetime) {
- if (prior) |active| if (active.phase == .opening) {
- var next = active;
- next.from = openingBox(effect, target, p.screen_w);
- next.to = target;
- self.tracks[id] = next;
- changed = true;
- animated_change = true;
- continue;
- };
- self.tracks[id] = null;
- changed = true;
- continue;
- }
- if (effect.lifecycleOnly() and !same_lifetime) {
- _ = self.appendClosing(.{
- .serial = old.serial,
- .pane = @intCast(id),
- .phase = .closing,
- .effect = effect,
- .from = old.box,
- .to = closingBox(effect, old.box),
- });
- }
- const shown = if (self.shown[id]) |snapshot|
- if (snapshot.serial == pane.serial) snapshot.box else null
- else
- null;
- const from = if (!same_lifetime)
- openingBox(effect, target, p.screen_w)
- else if (shown) |box|
- box
- else if (self.acknowledged and prior != null)
- prior.?.from
- else
- old.box;
- const next: Track = .{
- .serial = pane.serial,
- .pane = @intCast(id),
- .phase = if (!same_lifetime or
- (prior != null and prior.?.phase == .opening)) .opening else .moving,
- .effect = effect,
- .from = from,
- .to = target,
- };
- self.tracks[id] = next;
- changed = true;
- animated_change = true;
- } else {
- const next: Track = .{
- .serial = pane.serial,
- .pane = @intCast(id),
- .phase = .opening,
- .effect = effect,
- .from = openingBox(effect, target, p.screen_w),
- .to = target,
- };
- self.tracks[id] = next;
- changed = true;
- animated_change = true;
- }
- }
- if (animated_change and effect.needsPreviousGrid()) {
- self.diff_state = .requested;
- }
- if (changed and self.acknowledged) self.pending = true;
- }
-
- fn appendClosing(self: *Presentation, track: Track) bool {
- std.debug.assert(track.phase == .closing);
- const baseline = self.previous_layout[track.pane] orelse return false;
- if (!self.previous_valid or baseline.serial != track.serial or
- !baseline.box.eql(track.from)) return false;
- if (self.closing_len == self.closing.len) {
- std.mem.copyForwards(
- Track,
- self.closing[0 .. self.closing.len - 1],
- self.closing[1..],
- );
- self.closing_len -= 1;
- }
- self.closing[self.closing_len] = track;
- self.closing_len += 1;
- return true;
- }
-
- pub fn advance(self: *Presentation) void {
- for (&self.tracks) |*slot| {
- const track = if (slot.*) |*track| track else continue;
- track.frame +|= 1;
- if (!track.active()) slot.* = null;
- }
- var out: usize = 0;
- for (self.closing[0..self.closing_len]) |value| {
- var track = value;
- track.frame +|= 1;
- if (!track.active()) continue;
- self.closing[out] = track;
- out += 1;
- }
- self.closing_len = out;
- }
-
- pub fn acknowledge(self: *Presentation, p: *Pardes, tracks: []const Track) void {
- var presented: [MAX_PANES]?Track = @splat(null);
- var presented_closing: [MAX_PANES]Track = undefined;
- var nclosing: usize = 0;
- var presented_layout: [MAX_PANES]?Snapshot = if (self.submitted_ready)
- self.submitted
- else
- @splat(null);
- if (!self.submitted_ready) for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- presented_layout[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
- };
- for (&presented_layout, 0..) |*snapshot, id| if (snapshot.*) |saved| {
- const pane = p.panes[id] orelse {
- snapshot.* = null;
- continue;
- };
- if (pane.serial != saved.serial) snapshot.* = null;
- };
- for (tracks) |track| {
- if (track.phase == .closing) {
- const current = for (self.closing[0..self.closing_len]) |candidate| {
- if (candidate.serial == track.serial and candidate.pane == track.pane and
- candidate.effect == track.effect) break true;
- } else false;
- if (!current or !track.active() or nclosing == presented_closing.len) continue;
- presented_closing[nclosing] = track;
- nclosing += 1;
- continue;
- }
- const id: usize = track.pane;
- if (id >= p.panes.len or !track.active()) continue;
- const pane = p.panes[id] orelse continue;
- if (pane.serial != track.serial) continue;
- presented[id] = track;
- presented_layout[id] = .{ .serial = track.serial, .box = track.visualBox() };
- }
- self.shown_tracks = presented;
- self.shown_closing = presented_closing;
- self.shown_closing_len = nclosing;
- self.shown = presented_layout;
- self.acknowledged = true;
- self.pending = false;
- if (tracks.len == 0) {
- self.tracks = @splat(null);
- self.closing_len = 0;
- self.diff_state = .none;
- self.capturePrevious(p.gpa, &p.surface);
- }
- }
-
- fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) void {
- self.diff_state = .none;
- const cells = surface.cells;
- if (cells.len == 0) {
- self.previous_valid = false;
- self.previous_layout = @splat(null);
- return;
- }
- if (self.previous_cells.len != cells.len) {
- const next = gpa.alloc(pardes.Cell, cells.len) catch {
- self.previous_valid = false;
- self.previous_layout = @splat(null);
- return;
- };
- if (self.previous_cells.len > 0) gpa.free(self.previous_cells);
- self.previous_cells = next;
- }
- for (&self.previous_body_layers, surface.bodyLayers()) |*old, *current| {
- const count = if (current.rows > 0) current.cells.len else 0;
- const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch {
- self.previous_valid = false;
- self.previous_layout = @splat(null);
- return;
- };
- if (count > 0) @memcpy(storage, current.cells);
- old.* = current.*;
- old.cells = storage;
- }
- for (&self.previous_tag_layers, surface.tagLayers()) |*old, *current| {
- const count = if (current.cols > 0) current.cells.len else 0;
- const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch {
- self.previous_valid = false;
- self.previous_layout = @splat(null);
- return;
- };
- if (count > 0) @memcpy(storage, current.cells);
- old.* = current.*;
- old.cells = storage;
- }
- @memcpy(self.previous_cells, cells);
- self.previous_cols = surface.cols;
- self.previous_rows = surface.rows;
- self.previous_valid = true;
- self.previous_layout = if (self.submitted_ready)
- self.submitted
- else
- @splat(null);
- }
-
- pub fn cellDiff(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) pardes.PanelCellDiff {
- if (self.diff_state != .ready or col >= cols or row >= rows or
- self.diffs.len != @as(usize, cols) * rows) return .unchanged;
- return self.diffs[@as(usize, row) * cols + col];
- }
-
- pub const Point = struct { x: f32, y: f32 };
-
- /// Coordinates stay fractional until the host resolves its body font grid.
- pub fn pointerFractional(self: *const Presentation, cols: u16, rows: u16, x: f32, y: f32) ?Point {
- if (self.pending or !std.math.isFinite(x) or !std.math.isFinite(y) or
- x < 0 or y < 0 or x >= @as(f32, @floatFromInt(cols)) or y >= @as(f32, @floatFromInt(rows))) return null;
- var closing = self.shown_closing_len;
- while (closing > 0) {
- closing -= 1;
- const track = self.shown_closing[closing];
- if (track.active() and boxContainsPoint(track.contentBox(), x, y) and
- boxContainsPoint(track.presented(), x, y)) return null;
- }
- for ([_]Phase{ .opening, .moving }) |phase| {
- var index = self.shown_tracks.len;
- while (index > 0) {
- index -= 1;
- const track = self.shown_tracks[index] orelse continue;
- if (!track.active() or track.phase != phase) continue;
- switch (track.effect) {
- .slide, .zoom, .vertical => {
- const target = track.contentBox();
- if (track.effect == .vertical and !boxContainsPoint(target, x, y)) continue;
- const shown = track.presented();
- if (!boxContainsPoint(shown, x, y)) {
- if (track.effect == .vertical) return null;
- continue;
- }
- return .{
- .x = target.x + (x - shown.x) / shown.w * target.w,
- .y = target.y + (y - shown.y) / shown.h * target.h,
- };
- },
- else => {
- if (!boxContainsPoint(track.to, x, y)) continue;
- _ = self.pointer(cols, rows, @intFromFloat(@floor(x)), @intFromFloat(@floor(y))) orelse return null;
- return .{ .x = x, .y = y };
- },
- }
- }
- }
- _ = self.pointer(cols, rows, @intFromFloat(@floor(x)), @intFromFloat(@floor(y))) orelse return null;
- return .{ .x = x, .y = y };
- }
-
- fn boxContainsPoint(box: Box, x: f32, y: f32) bool {
- return box.w > 0 and box.h > 0 and x >= box.x and y >= box.y and x < box.x + box.w and y < box.y + box.h;
- }
-
- pub fn pointer(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) ?CellPosition {
- if (self.pending) return null;
- var closing = self.shown_closing_len;
- while (closing > 0) {
- closing -= 1;
- const track = self.shown_closing[closing];
- if (!track.active()) continue;
- if (boxContainsCell(track.contentBox(), col, row) and
- boxContainsCell(track.presented(), col, row)) return null;
- }
- const phases = [_]Phase{ .opening, .moving };
- for (phases) |phase| {
- // Backends paint pane slots forward within a phase. Probe them in
- // reverse so overlapping transition quads address the top pixel.
- var index = self.shown_tracks.len;
- while (index > 0) {
- index -= 1;
- const track = self.shown_tracks[index] orelse continue;
- if (!track.active() or track.phase != phase) continue;
- switch (track.effect) {
- .slide, .zoom => {
- const shown = track.presented();
- if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue;
- const x = @as(f32, @floatFromInt(col)) + 0.5;
- const y = @as(f32, @floatFromInt(row)) + 0.5;
- const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999);
- const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999);
- const logical_x: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w));
- const logical_y: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h));
- return .{
- .col = @intCast(std.math.clamp(logical_x, 0, @as(i32, cols -| 1))),
- .row = @intCast(std.math.clamp(logical_y, 0, @as(i32, rows -| 1))),
- };
- },
- .vertical => {
- const clip = track.contentBox();
- if (!boxContainsCell(clip, col, row)) continue;
- const shown = track.presented();
- if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row))
- return null;
- const x = @as(f32, @floatFromInt(col)) + 0.5;
- const y = @as(f32, @floatFromInt(row)) + 0.5;
- const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999);
- const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999);
- return .{
- .col = @intFromFloat(@floor(clip.x + u * clip.w)),
- .row = @intFromFloat(@floor(clip.y + v * clip.h)),
- };
- },
- .dissolve, .ascii => {
- if (!boxContainsCell(track.to, col, row)) continue;
- const cell_diff = self.cellDiff(cols, rows, col, row);
- if (!cell_diff.changed())
- return .{ .col = col, .row = row };
- const relative_col: u16 = @intFromFloat(@floor(
- @as(f32, @floatFromInt(col)) + 0.5 - track.to.x,
- ));
- const relative_row: u16 = @intFromFloat(@floor(
- @as(f32, @floatFromInt(row)) + 0.5 - track.to.y,
- ));
- const visible = switch (track.effect) {
- .dissolve => dissolveRevealed(
- track.serial,
- relative_col,
- relative_row,
- track.amount(),
- ),
- .ascii => switch (cell_diff) {
- .ascii => |diff| diff.complete(track.frame),
- .unchanged, .visual => true,
- },
- else => unreachable,
- };
- return if (visible) .{ .col = col, .row = row } else null;
- },
- .edges, .fall, .wave, .curtain, .scramble, .typewriter => {
- if (!boxContainsCell(track.to, col, row)) continue;
- const area = CellArea.of(track.to);
- const settled = std.meta.eql(
- charSource(
- track,
- col -| area.x0,
- row -| area.y0,
- area,
- ),
- CharSource.settled,
- );
- return if (settled) .{ .col = col, .row = row } else null;
- },
- .off => {},
- }
- }
- }
- for (self.shown_tracks) |maybe| {
- const track = maybe orelse continue;
- if (!track.active() or (track.effect != .slide and track.effect != .zoom and
- track.effect != .vertical)) continue;
- if (boxContainsCell(track.to, col, row)) return null;
- }
- return .{ .col = col, .row = row };
- }
-
- fn boxContainsCell(box: Box, col: u16, row: u16) bool {
- const x = @as(f32, @floatFromInt(col)) + 0.5;
- const y = @as(f32, @floatFromInt(row)) + 0.5;
- return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h;
- }
-
- pub fn cancel(self: *Presentation) void {
- self.tracks = @splat(null);
- self.closing_len = 0;
- self.shown_tracks = @splat(null);
- self.shown_closing_len = 0;
- self.diff_state = .none;
- self.pending = self.acknowledged;
- }
-
- fn prepareDiff(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) !bool {
- const count = surface.cells.len;
- if (!self.previous_valid or
- self.previous_cols != surface.cols or
- self.previous_rows != surface.rows or
- self.previous_cells.len != count) return false;
- if (self.diffs.len != count) {
- const next = try gpa.alloc(pardes.PanelCellDiff, count);
- if (self.diffs.len > 0) gpa.free(self.diffs);
- self.diffs = next;
- }
- for (self.diffs, self.previous_cells, surface.cells) |*diff, *old, *new|
- diff.* = pardes.PanelCellDiff.between(old, new);
- for (&self.tracks) |*slot| {
- const track = if (slot.*) |*track| track else continue;
- if (track.effect != .ascii) continue;
- var longest: u16 = 1;
- var row: u16 = 0;
- while (row < surface.rows) : (row += 1) {
- var col: u16 = 0;
- while (col < surface.cols) : (col += 1) {
- if (!boxContainsCell(track.to, col, row)) continue;
- const index = @as(usize, row) * surface.cols + col;
- switch (self.diffs[index]) {
- .ascii => |diff| longest = @max(longest, diff.frameCount()),
- .unchanged, .visual => {},
- }
- }
- }
- track.frame_count = @max(track.frame_count, longest);
- }
- self.diff_state = .ready;
- return true;
- }
-
- pub fn submit(self: *Presentation, p: *Pardes, s: *pardes.Surface) !void {
- self.submitted = @splat(null);
- for (p.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- self.submitted[id] = .{
- .serial = pane.serial,
- .box = panelBox(p.rects[id]),
- };
- }
- self.submitted_ready = true;
- if (self.diff_state != .none and !try self.prepareDiff(p.gpa, s)) {
- for (&self.tracks) |*slot| {
- const track = slot.* orelse continue;
- if (track.effect.needsPreviousGrid()) slot.* = null;
- }
- self.closing_len = 0;
- self.diff_state = .none;
- }
- if (self.diff_state == .ready) {
- s.previous_cells = self.previous_cells;
- s.previous_body_layers = &self.previous_body_layers;
- s.previous_tag_layers = &self.previous_tag_layers;
- s.cell_diffs = self.diffs;
- }
- s.npanel_tracks = paintOrder(
- &self.tracks,
- self.closing[0..self.closing_len],
- &s.panel_tracks,
- );
- }
-
- pub fn animating(self: *const Presentation) bool {
- for (self.tracks) |track| if (track != null and track.?.active()) return true;
- for (self.closing[0..self.closing_len]) |track|
- if (track.active()) return true;
- return false;
- }
-
- test "presentation cache allocation failures retain ownership and allow retry" {
- var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{});
- const gpa = allocator.allocator();
- var state: Presentation = .{};
- defer state.deinit(gpa);
- var cells: [3]pardes.Cell = @splat(.{});
- cells[0].default = false;
- cells[0].text[0] = 'a';
- var surface: pardes.Surface = .{ .cols = 2, .rows = 1, .cells = cells[0..2] };
- state.submitted[0] = .{ .serial = 7, .box = .{ .x = 0, .y = 0, .w = 2, .h = 1 } };
- state.submitted_ready = true;
- state.capturePrevious(gpa, &surface);
- try std.testing.expect(state.previous_valid);
- try std.testing.expectEqual(@as(u32, 7), state.previous_layout[0].?.serial);
- const previous = state.previous_cells.ptr;
- const allocated = allocator.allocations;
- allocator.fail_index = allocator.alloc_index;
- state.capturePrevious(gpa, &surface);
- try std.testing.expect(state.previous_valid);
- try std.testing.expectEqual(allocated, allocator.allocations);
- try std.testing.expect(!allocator.has_induced_failure);
-
- surface.cols = 3;
- surface.cells = &cells;
- state.submitted[0].?.box.w = 3;
- state.diff_state = .ready;
- state.capturePrevious(gpa, &surface);
- try std.testing.expect(allocator.has_induced_failure);
- try std.testing.expect(!state.previous_valid);
- try std.testing.expectEqual(previous, state.previous_cells.ptr);
- try std.testing.expectEqual(@as(usize, 2), state.previous_cells.len);
- try std.testing.expectEqual(@as(u8, 'a'), state.previous_cells[0].text[0]);
- try std.testing.expect(state.previous_layout[0] == null);
- try std.testing.expectEqual(.none, state.diff_state);
-
- allocator.fail_index = std.math.maxInt(usize);
- state.capturePrevious(gpa, &surface);
- try std.testing.expect(state.previous_valid);
- try std.testing.expectEqual(@as(usize, 3), state.previous_cells.len);
- try std.testing.expectEqual(@as(f32, 3), state.previous_layout[0].?.box.w);
- state.diff_state = .requested;
- allocator.fail_index = allocator.alloc_index;
- try std.testing.expectError(error.OutOfMemory, state.prepareDiff(gpa, &surface));
- try std.testing.expectEqual(.requested, state.diff_state);
- try std.testing.expectEqual(@as(usize, 0), state.diffs.len);
- allocator.fail_index = std.math.maxInt(usize);
- try std.testing.expect(try state.prepareDiff(gpa, &surface));
- try std.testing.expectEqual(.ready, state.diff_state);
- try std.testing.expectEqual(@as(usize, 3), state.diffs.len);
- for (state.diffs) |diff| try std.testing.expectEqual(.unchanged, diff);
- }
-
- test "presentation submission failure preserves shown geometry until retry is acknowledged" {
- var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{});
- const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 });
- defer p.deinit();
- var frame: std.heap.ArenaAllocator = .init(std.testing.allocator);
- defer frame.deinit();
- _ = try p.render(frame.allocator());
- p.acknowledgePanelPresentation(&.{});
- const shown = p.presentation.shown;
- const previous = p.presentation.previous_cells.ptr;
- p.presentation.diff_state = .requested;
- p.presentation.pending = true;
- allocator.fail_index = allocator.alloc_index;
- try std.testing.expectError(error.OutOfMemory, p.presentation.submit(p, &p.surface));
- try std.testing.expectEqualDeep(shown, p.presentation.shown);
- try std.testing.expectEqual(previous, p.presentation.previous_cells.ptr);
- try std.testing.expectEqual(.requested, p.presentation.diff_state);
- try std.testing.expect(p.presentation.pending);
- try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 5, 3) == null);
-
- allocator.fail_index = std.math.maxInt(usize);
- try p.presentation.submit(p, &p.surface);
- try std.testing.expectEqualDeep(shown, p.presentation.shown);
- try std.testing.expect(p.presentation.pending);
- p.acknowledgePanelPresentation(&.{});
- try std.testing.expect(!p.presentation.pending);
- try std.testing.expectEqual(CellPosition{ .col = 5, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 5, 3).?);
- }
-};
-
pub const column_weight_unit: u64 = 1 << 32;
pub const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS;
@@ -947,7 +297,7 @@ pub fn removePane(p: *Pardes, id: usize) void {
p.col_serial[j] = p.col_serial[j + 1];
}
p.ncol -= 1;
- p.col_tags[p.ncol] = .{ .what = .column };
+ p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa };
p.col_serial[p.ncol] = 0;
}
}
@@ -971,7 +321,7 @@ pub fn joinCol(p: *Pardes) void {
p.col_serial[j] = p.col_serial[j + 1];
}
p.ncol -= 1;
- p.col_tags[p.ncol] = .{ .what = .column };
+ p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa };
p.col_serial[p.ncol] = 0;
}
@@ -1021,7 +371,7 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool {
p.col_serial[j] = p.col_serial[j - 1];
}
p.col_weight[c] = new_weight;
- p.col_tags[c] = .{ .what = .column };
+ p.col_tags[c] = .{ .what = .column, .gpa = p.gpa };
p.col_serial[c] = 0;
p.col_panes[c][0] = id;
p.col_n[c] = 1;
@@ -1254,8 +604,6 @@ pub fn compute(p: *Pardes) void {
}
}
-pub const ascii_max_movement_frames: u16 = 12;
-
test "collapse transfers height upward and restores the original layout" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 });
defer p.deinit();
@@ -1461,789 +809,6 @@ test "collapse dump restores folded state and retained expansion weight" {
try std.testing.expectEqual(restored.screen_h - restored.rects[0].y, restored.rects[0].h);
}
-pub const Easing = enum(u8) {
- linear,
- smooth,
- /// Quintic ease-in-out. It creeps at both ends and crosses the middle of
- /// the distance fast, inside the same frame count a linear walk would use.
- smoother,
- in_cubic,
- out_cubic,
- out_back,
-};
-
-pub const Transition = enum(u8) {
- // Numeric values are shared with the GUI shader ABI.
- off = 0,
- slide = 1,
- zoom = 2,
- dissolve = 3,
- ascii = 4,
- vertical = 5,
- edges = 6,
- fall = 7,
- wave = 8,
- curtain = 9,
- scramble = 10,
- typewriter = 11,
-
- pub fn easing(effect: Transition) Easing {
- return switch (effect) {
- .off, .dissolve, .wave => .smooth,
- .slide, .vertical, .edges => .out_cubic,
- .zoom => .out_back,
- // Character walks and per-cell locks read best with a slow start,
- // a fast middle, and a slow settle over their fixed frame count.
- .ascii, .fall, .scramble => .smoother,
- // A sweep and a typewriter are constant-rate by definition: easing
- // their head would make the pass visibly hesitate mid-pane.
- .curtain, .typewriter => .linear,
- };
- }
-
- pub fn frames(effect: Transition) u16 {
- return switch (effect) {
- .off => 0,
- .slide => 12,
- .zoom => 14,
- .dissolve => 10,
- .ascii => ascii_max_movement_frames + 1,
- .vertical => 12,
- .edges, .curtain, .scramble => 12,
- // Travelling motion needs a couple more samples than a lock or a
- // rigid slide before it stops reading as a jump.
- .fall, .wave, .typewriter => 14,
- };
- }
-
- pub fn composedByCore(effect: Transition) bool {
- return switch (effect) {
- .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => true,
- .off, .slide, .zoom, .dissolve, .vertical => false,
- };
- }
-
- pub fn needsPreviousGrid(effect: Transition) bool {
- return effect == .dissolve or effect == .vertical or effect.composedByCore();
- }
-
- pub fn lifecycleOnly(effect: Transition) bool {
- return effect == .vertical;
- }
-};
-
-pub const SceneEffect = struct {
- crt: bool = false,
- ripple: bool = false,
- glitch: bool = false,
-};
-
-pub const Phase = enum(u8) {
- opening = 0,
- moving = 1,
- /// Presentation-only content whose pane lifetime has already ended.
- /// It is never a valid input target.
- closing = 2,
-};
-
-pub const Box = extern struct {
- x: f32 = 0,
- y: f32 = 0,
- w: f32 = 0,
- h: f32 = 0,
-
- pub fn eql(a: Box, b: Box) bool {
- return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h;
- }
-
- // A cell belongs to a fractional box when its center lies inside.
- pub fn contains(box: Box, col: u16, row: u16) bool {
- const x: f32 = @floatFromInt(col);
- const y: f32 = @floatFromInt(row);
- return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h;
- }
-};
-
-// Drawing and hit testing share this moving/opening/closing order.
-pub fn paintOrder(live: []const ?Track, closing: []const Track, out: []Track) usize {
- var len: usize = 0;
- for ([_]Phase{ .moving, .opening }) |phase| for (live) |maybe| {
- const track = maybe orelse continue;
- if (!track.active() or track.phase != phase) continue;
- if (len == out.len) return len;
- out[len] = track;
- len += 1;
- };
- for (closing) |track| {
- if (!track.active()) continue;
- if (len == out.len) return len;
- out[len] = track;
- len += 1;
- }
- return len;
-}
-
-/// One POD record is enough for every backend. `from` and `to` are logical
-/// cell boxes; frontends convert them to pixels only at their render edge.
-pub const Track = extern struct {
- serial: u32 = 0,
- pane: u8 = 0,
- phase: Phase = .moving,
- effect: Transition = .off,
- _padding: u8 = 0,
- frame: u16 = 0,
- frame_count: u16 = 0,
- from: Box = .{},
- to: Box = .{},
-
- pub fn active(track: Track) bool {
- return track.effect != .off and track.frame < track.frames();
- }
-
- pub fn frames(track: Track) u16 {
- return if (track.frame_count != 0) track.frame_count else track.effect.frames();
- }
-
- pub fn amount(track: Track) f32 {
- // Opening rises quickly and settles; closing reverses that motion and
- // accelerates down out of the fixed clip.
- if (track.phase == .closing and track.effect == .vertical)
- return progressEased(.in_cubic, track.frames(), track.frame);
- return progressEased(track.effect.easing(), track.frames(), track.frame);
- }
-
- pub fn presented(track: Track) Box {
- return lerpBox(track.from, track.to, track.amount());
- }
-
- pub fn visualBox(track: Track) Box {
- return switch (track.effect) {
- .slide, .zoom, .vertical => track.presented(),
- .off, .dissolve => track.to,
- // Every character effect stays inside the pane's final rectangle.
- .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => track.to,
- };
- }
-
- pub fn contentBox(track: Track) Box {
- return if (track.phase == .closing) track.from else track.to;
- }
-};
-
-pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box {
- return switch (effect) {
- .slide => blk: {
- var from = target;
- const middle = target.x + target.w * 0.5;
- from.x = if (middle < @as(f32, @floatFromInt(screen_width)) * 0.5)
- -target.w
- else
- @floatFromInt(screen_width);
- break :blk from;
- },
- .zoom => .{
- .x = target.x + target.w * 0.5,
- .y = target.y + target.h * 0.5,
- .w = 0,
- .h = 0,
- },
- .vertical => blk: {
- var from = target;
- from.y += target.h;
- break :blk from;
- },
- .off, .dissolve => target,
- // Character effects own the glyphs inside a fixed rectangle, so their
- // panel opens at exactly its final geometry.
- .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => target,
- };
-}
-
-pub fn closingBox(effect: Transition, source: Box) Box {
- return switch (effect) {
- .vertical => blk: {
- var to = source;
- to.y += source.h;
- break :blk to;
- },
- else => source,
- };
-}
-
-pub fn sample(easing: Easing, raw: f32) f32 {
- const t = std.math.clamp(raw, 0.0, 1.0);
- return switch (easing) {
- .linear => t,
- .smooth => t * t * (3.0 - 2.0 * t),
- .smoother => t * t * t * (t * (6.0 * t - 15.0) + 10.0),
- .in_cubic => t * t * t,
- .out_cubic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t),
- // Robert Penner's ease-out-back polynomial. It intentionally travels
- // a little past one before settling exactly on the endpoint.
- .out_back => blk: {
- const c1: f32 = 1.70158;
- const c3 = c1 + 1.0;
- const u = t - 1.0;
- break :blk 1.0 + c3 * u * u * u + c1 * u * u;
- },
- };
-}
-
-pub fn progress(effect: Transition, frame: u16) f32 {
- return progressEased(effect.easing(), effect.frames(), frame);
-}
-
-fn progressEased(easing: Easing, frames: u16, frame: u16) f32 {
- if (frames <= 1 or frame >= frames - 1) return 1.0;
- return sample(easing, @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1)));
-}
-
-pub fn lerpBox(from: Box, to: Box, t: f32) Box {
- const u = @max(0.0, t);
- return .{
- .x = from.x + (to.x - from.x) * u,
- .y = from.y + (to.y - from.y) * u,
- .w = @max(0.0, from.w + (to.w - from.w) * u),
- .h = @max(0.0, from.h + (to.h - from.h) * u),
- };
-}
-
-/// Stable cell noise shared by the TTY reveal and shader ports. Integer-only
-/// hashing means resizing or repainting a frame does not make cells flicker.
-pub fn cellNoise(serial: u32, col: u16, row: u16) f32 {
- var x = serial ^ (@as(u32, col) *% 0x9e37_79b9) ^ (@as(u32, row) *% 0x85eb_ca6b);
- x ^= x >> 16;
- x *%= 0x7feb_352d;
- x ^= x >> 15;
- x *%= 0x846c_a68b;
- x ^= x >> 16;
- return @as(f32, @floatFromInt(x & 0xffff)) / 65535.0;
-}
-
-/// Whether a changed dissolve cell has crossed from the frozen old grid to
-/// the new one. Exact endpoints are part of the presentation contract.
-pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool {
- const t = std.math.clamp(raw_progress, 0.0, 1.0);
- if (t <= 0) return false;
- if (t >= 1) return true;
- return cellNoise(serial, col, row) < t;
-}
-
-pub const CellArea = struct {
- x0: u16 = 0,
- y0: u16 = 0,
- cols: u16 = 1,
- rows: u16 = 1,
-
- pub fn of(box: Box) CellArea {
- return .{
- .x0 = floorCell(box.x),
- .y0 = floorCell(box.y),
- .cols = ceilCell(box.w),
- .rows = ceilCell(box.h),
- };
- }
-};
-
-fn floorCell(value: f32) u16 {
- return @intFromFloat(std.math.clamp(@floor(value), 0.0, @as(f32, std.math.maxInt(u16))));
-}
-
-fn ceilCell(value: f32) u16 {
- return @intFromFloat(std.math.clamp(@ceil(value), 1.0, @as(f32, std.math.maxInt(u16))));
-}
-
-pub const CharSource = union(enum) {
- /// Nothing has arrived here yet: keep the frozen old cell.
- old,
- at: Offset,
- /// Paint this printable byte in the destination cell's own style, whatever
- /// that cell holds — a caret marching over empty space is still a caret.
- byte: u8,
- churn: u8,
-
- pub const Offset = struct { cols: i32 = 0, rows: i32 = 0 };
-
- pub const settled: CharSource = .{ .at = .{} };
-};
-
-pub fn charSource(track: Track, col: u16, row: u16, area: CellArea) CharSource {
- const t = track.amount();
- if (t >= 1.0) return .settled;
- const w: f32 = @floatFromInt(area.cols);
- const h: f32 = @floatFromInt(area.rows);
- const c: f32 = @floatFromInt(col);
- const r: f32 = @floatFromInt(row);
- const remaining = 1.0 - t;
- return switch (track.effect) {
- .edges => blk: {
- const travel = cellsOf(remaining * (w + 1.0));
- break :blk .{ .at = .{ .cols = if (row % 2 == 0) travel else -travel } };
- },
- // Columns rain down, each with its own stable head start, so the pane
- // fills from the top and the last glyphs land at the bottom.
- .fall => blk: {
- const local = staggered(t, cellNoise(track.serial, col, 0) * 0.4);
- if (local <= 0.0) break :blk .old;
- break :blk .{ .at = .{ .rows = cellsOf((1.0 - local) * (h + 1.0)) } };
- },
- // A vertical ripple travels left to right and its amplitude decays, so
- // the pane settles out of a wave instead of a fade.
- .wave => .{ .at = .{
- .rows = cellsOf(remaining * @min(4.0, h) * @sin(c * 0.55 - t * 9.0)),
- } },
- // A curtain of glyphs marches in from the right, column by column, left
- // to right; each column still has a short slide of its own.
- .curtain => blk: {
- const lead = t * (w + 1.0) - c;
- if (lead <= 0.0) break :blk .old;
- break :blk .{ .at = .{ .cols = -cellsOf(@max(0.0, 3.0 - lead)) } };
- },
- // Every cell churns through printable ASCII and locks onto its final
- // glyph at its own stable threshold: the pane resolves out of noise.
- .scramble => blk: {
- if (t >= cellNoise(track.serial, col, row) * 0.8) break :blk .settled;
- const churn = cellNoise(
- track.serial ^ (@as(u32, track.frame) *% 0x27d4_eb2f),
- col,
- row,
- );
- break :blk .{ .churn = @intCast(33 + @min(93, @as(u32, @intFromFloat(churn * 94.0)))) };
- },
- // Reading-order reveal with a caret sitting on the write head.
- .typewriter => blk: {
- const head = t * w * h;
- const index = r * w + c;
- if (index + 1.0 <= head) break :blk .settled;
- if (index <= head) break :blk .{ .byte = '_' };
- break :blk .old;
- },
- // PanelAscii walks its own byte distance per cell, and the geometry
- // effects never reach this path at all.
- .off, .slide, .zoom, .dissolve, .vertical, .ascii => .settled,
- };
-}
-
-fn cellsOf(distance: f32) i32 {
- return @intFromFloat(@round(std.math.clamp(distance, -65535.0, 65535.0)));
-}
-
-/// Remap track progress into one cell's own window. A stagger delays a glyph
-/// without making the effect as a whole end after its last frame.
-fn staggered(t: f32, delay: f32) f32 {
- if (delay >= 1.0) return t;
- return (t - delay) / (1.0 - delay);
-}
-
-test "easing presets have exact endpoints and intended shapes" {
- inline for (std.enums.values(Easing)) |easing| {
- try std.testing.expectEqual(@as(f32, 0), sample(easing, 0));
- try std.testing.expectEqual(@as(f32, 1), sample(easing, 1));
- }
- try std.testing.expectEqual(@as(f32, 0.5), sample(.linear, 0.5));
- try std.testing.expect(sample(.in_cubic, 0.5) < sample(.linear, 0.5));
- try std.testing.expect(sample(.out_cubic, 0.5) > sample(.linear, 0.5));
- try std.testing.expect(sample(.out_back, 0.8) > 1.0);
- // Slow at both ends, fast through the middle, and symmetric about the
- // halfway point: the same curve the integer byte walk reproduces.
- try std.testing.expectEqual(@as(f32, 0.5), sample(.smoother, 0.5));
- try std.testing.expect(sample(.smoother, 0.15) < sample(.smooth, 0.15));
- try std.testing.expect(sample(.smoother, 0.85) > sample(.smooth, 0.85));
- try std.testing.expect(sample(.smoother, 0.6) - sample(.smoother, 0.4) >
- sample(.linear, 0.6) - sample(.linear, 0.4));
-}
-
-test "transition progress completes exactly" {
- inline for (std.enums.values(Transition)) |effect| {
- try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames()));
- if (effect.frames() > 0)
- try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames() - 1));
- try std.testing.expectEqual(@as(f32, 1), progress(effect, std.math.maxInt(u16)));
- }
- try std.testing.expectEqual(@as(f32, 1), progress(.off, 0));
-
- const shrinking = lerpBox(.{ .w = 100, .h = 40 }, .{}, sample(.out_back, 0.8));
- try std.testing.expectEqual(@as(f32, 0), shrinking.w);
- try std.testing.expectEqual(@as(f32, 0), shrinking.h);
- const opening = lerpBox(.{}, .{ .w = 100, .h = 40 }, sample(.out_back, 0.8));
- try std.testing.expect(opening.w > 100);
- try std.testing.expect(opening.h > 40);
-}
-
-test "character effects are core-composed and settle on the canonical glyph" {
- const box: Box = .{ .x = 4, .y = 2, .w = 20, .h = 6 };
- const area: CellArea = .of(box);
- try std.testing.expectEqual(@as(u16, 4), area.x0);
- try std.testing.expectEqual(@as(u16, 2), area.y0);
- try std.testing.expectEqual(@as(u16, 20), area.cols);
- try std.testing.expectEqual(@as(u16, 6), area.rows);
-
- inline for (std.enums.values(Transition)) |effect| {
- if (comptime !effect.composedByCore()) continue;
- // Core composition needs the frozen old grid for every glyph which has
- // not arrived, so no character effect may animate without it.
- try std.testing.expect(effect.needsPreviousGrid());
- if (comptime effect == .ascii) continue; // owns its own per-cell byte walk
-
- const last: Track = .{ .effect = effect, .frame = effect.frames() - 1, .to = box };
- const first: Track = .{ .effect = effect, .frame = 0, .to = box };
- var moving = false;
- var row: u16 = 0;
- while (row < area.rows) : (row += 1) {
- var col: u16 = 0;
- while (col < area.cols) : (col += 1) {
- // The last active sample is the exact canonical grid: no cell
- // is displaced, churning, or still frozen.
- try std.testing.expectEqual(CharSource.settled, charSource(last, col, row, area));
- if (!std.meta.eql(CharSource.settled, charSource(first, col, row, area)))
- moving = true;
- }
- }
- try std.testing.expect(moving);
- }
-}
-
-test "each character effect moves glyphs along its own axis" {
- const box: Box = .{ .w = 30, .h = 8 };
- const area: CellArea = .of(box);
-
- // Rows alternate which screen edge they come from, and every glyph in a row
- // travels as one rigid slide: one offset, no vertical component.
- var edges: Track = .{ .effect = .edges, .frame = 2, .to = box };
- const even = charSource(edges, 5, 0, area).at;
- const odd = charSource(edges, 5, 1, area).at;
- try std.testing.expect(even.cols > 0);
- try std.testing.expectEqual(-even.cols, odd.cols);
- try std.testing.expectEqual(@as(i32, 0), even.rows);
- try std.testing.expectEqual(even, charSource(edges, 17, 0, area).at);
- edges.frame = 5;
- try std.testing.expect(charSource(edges, 5, 0, area).at.cols < even.cols);
-
- // Falling columns are vertical only, staggered, and sample from below the
- // destination because the new text is still above the pane.
- const fall: Track = .{ .effect = .fall, .frame = 4, .to = box };
- var falling = false;
- var col: u16 = 0;
- while (col < area.cols) : (col += 1) switch (charSource(fall, col, 0, area)) {
- .old => {},
- .byte, .churn => return error.FallShouldNotChurn,
- .at => |offset| {
- try std.testing.expectEqual(@as(i32, 0), offset.cols);
- try std.testing.expect(offset.rows >= 0);
- if (offset.rows > 0) falling = true;
- },
- };
- try std.testing.expect(falling);
-
- // The wave displaces rows both ways as it travels, and only rows.
- const wave: Track = .{ .effect = .wave, .frame = 1, .to = box };
- var above = false;
- var below = false;
- col = 0;
- while (col < area.cols) : (col += 1) {
- const offset = charSource(wave, col, 3, area).at;
- try std.testing.expectEqual(@as(i32, 0), offset.cols);
- if (offset.rows < 0) above = true;
- if (offset.rows > 0) below = true;
- }
- try std.testing.expect(above and below);
-
- // The curtain has a head: columns behind it hold the old grid, columns the
- // head has passed are settled, and the head itself is still sliding.
- const curtain: Track = .{ .effect = .curtain, .frame = 5, .to = box };
- try std.testing.expectEqual(CharSource.settled, charSource(curtain, 0, 0, area));
- try std.testing.expectEqual(CharSource{ .old = {} }, charSource(curtain, 29, 0, area));
- var sliding = false;
- col = 0;
- while (col < area.cols) : (col += 1) switch (charSource(curtain, col, 0, area)) {
- .at => |offset| if (offset.cols < 0) {
- sliding = true;
- },
- .old, .byte, .churn => {},
- };
- try std.testing.expect(sliding);
-
- var scramble: Track = .{ .effect = .scramble, .frame = 3, .to = box };
- var churning: usize = 0;
- var locked: usize = 0;
- var changed = false;
- col = 0;
- while (col < area.cols) : (col += 1) switch (charSource(scramble, col, 0, area)) {
- .churn => |byte| {
- try std.testing.expect(byte >= ' ' and byte <= '~');
- churning += 1;
- scramble.frame = 4;
- switch (charSource(scramble, col, 0, area)) {
- .churn => |next| changed = changed or next != byte,
- .old, .at, .byte => {},
- }
- scramble.frame = 3;
- },
- .at => locked += 1,
- .old, .byte => return error.ScrambleShouldNotFreeze,
- };
- try std.testing.expect(churning > 0 and locked > 0 and changed);
-
- // The typewriter writes in reading order with a caret on its head.
- const typewriter: Track = .{ .effect = .typewriter, .frame = 7, .to = box };
- try std.testing.expectEqual(CharSource.settled, charSource(typewriter, 0, 0, area));
- try std.testing.expectEqual(
- CharSource{ .old = {} },
- charSource(typewriter, area.cols - 1, area.rows - 1, area),
- );
- var carets: usize = 0;
- var row: u16 = 0;
- while (row < area.rows) : (row += 1) {
- col = 0;
- while (col < area.cols) : (col += 1) switch (charSource(typewriter, col, row, area)) {
- .byte => |byte| {
- try std.testing.expectEqual(@as(u8, '_'), byte);
- carets += 1;
- },
- .old, .at, .churn => {},
- };
- }
- try std.testing.expectEqual(@as(usize, 1), carets);
-}
-
-test "opening presets separate geometry and content transitions" {
- const target: Box = .{ .x = 30, .y = 2, .w = 20, .h = 8 };
- try std.testing.expectEqual(target, openingBox(.ascii, target, 80));
- try std.testing.expectEqual(@as(f32, 0), openingBox(.zoom, target, 80).w);
- try std.testing.expectEqual(@as(f32, 80), openingBox(.slide, target, 80).x);
- try std.testing.expectEqual(@as(f32, target.y + target.h), openingBox(.vertical, target, 80).y);
- try std.testing.expectEqual(@as(f32, target.y + target.h), closingBox(.vertical, target).y);
-
- var track: Track = .{ .effect = .slide, .from = target, .to = target };
- try std.testing.expect(track.active());
- track.frame = track.effect.frames();
- try std.testing.expect(!track.active());
-
- track = .{ .effect = .dissolve, .frame = 3, .from = .{}, .to = target };
- try std.testing.expectEqual(target, track.visualBox());
-
- var closing: Track = .{
- .phase = .closing,
- .effect = .vertical,
- .from = target,
- .to = closingBox(.vertical, target),
- };
- try std.testing.expectEqual(target, closing.contentBox());
- closing.frame = 2;
- try std.testing.expect(closing.amount() < progress(.vertical, closing.frame));
-}
-
-test "dissolve has exact stable endpoints" {
- for (0..64) |col| {
- const x: u16 = @intCast(col);
- try std.testing.expect(!dissolveRevealed(42, x, 7, 0));
- try std.testing.expect(dissolveRevealed(42, x, 7, 1));
- if (dissolveRevealed(42, x, 7, 0.25))
- try std.testing.expect(dissolveRevealed(42, x, 7, 0.75));
- }
-}
-
-pub const Animation = struct {
- pub const frame_ms: u32 = 16;
- pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms;
- pub const transition_steps: u16 = 10;
-
- pub fn Transition(comptime Value: type) type {
- return struct {
- const Self = @This();
-
- from: Value,
- to: Value,
- displayed: Value,
- step: u16 = transition_steps,
-
- pub fn init(value: Value) Self {
- return .{ .from = value, .to = value, .displayed = value };
- }
-
- pub fn isActive(a: *const Self) bool {
- return a.step < transition_steps;
- }
-
- pub fn retarget(a: *Self, target: Value) void {
- a.from = a.displayed;
- a.to = target;
- a.step = if (std.meta.eql(a.from, target)) transition_steps else 0;
- if (a.step == transition_steps) a.displayed = target;
- }
-
- pub fn advance(a: *Self) void {
- if (!a.isActive()) return;
- a.step += 1;
- a.displayed = if (a.step == transition_steps)
- a.to
- else
- Value.interpolate(a.from, a.to, a.step, transition_steps);
- }
-
- pub fn snap(a: *Self, value: Value) void {
- a.* = init(value);
- }
- };
- }
-
- pub fn Immediate(comptime Value: type) type {
- return struct {
- const Self = @This();
-
- displayed: Value,
-
- pub fn init(value: Value) Self {
- return .{ .displayed = value };
- }
-
- pub fn isActive(_: *const Self) bool {
- return false;
- }
-
- pub fn retarget(a: *Self, target: Value) void {
- a.displayed = target;
- }
-
- pub fn advance(_: *Self) void {}
-
- pub fn snap(a: *Self, value: Value) void {
- a.displayed = value;
- }
- };
- }
-
- pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 {
- if (step == 0) return from;
- if (step >= steps) return to;
- var out: [3]u8 = undefined;
- for (&out, from, to) |*dst, a, b| {
- const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step;
- dst.* = @intCast((numerator + steps / 2) / steps);
- }
- return out;
- }
-
- const TestColor = struct {
- rgb: [3]u8,
-
- pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor {
- return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) };
- }
- };
-
- test "Immediate lands where a completed Transition lands" {
- const from: TestColor = .{ .rgb = .{ 240, 3, 90 } };
- const to: TestColor = .{ .rgb = .{ 5, 222, 90 } };
-
- var faded = Animation.Transition(TestColor).init(from);
- faded.retarget(to);
- for (0..transition_steps) |_| faded.advance();
-
- var instant = Immediate(TestColor).init(from);
- try std.testing.expect(!instant.isActive());
- instant.retarget(to);
- try std.testing.expectEqual(faded.displayed, instant.displayed);
-
- // Never active, so a frontend that renders only while something is animating stops immediately
- // rather than spending ten frames discovering there is nothing to draw.
- try std.testing.expect(!instant.isActive());
- instant.advance();
- try std.testing.expectEqual(to, instant.displayed);
-
- instant.snap(from);
- try std.testing.expectEqual(from, instant.displayed);
- }
-
- test "fixed-step interpolation has exact monotonic endpoints" {
- const Tween = Animation.Transition(TestColor);
- const from: TestColor = .{ .rgb = .{ 240, 3, 90 } };
- const to: TestColor = .{ .rgb = .{ 5, 222, 90 } };
- var tween = Tween.init(from);
- tween.retarget(to);
- try std.testing.expectEqual(from, tween.displayed);
-
- var previous = tween.displayed;
- for (0..transition_steps) |_| {
- tween.advance();
- try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]);
- try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]);
- try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]);
- previous = tween.displayed;
- }
- try std.testing.expect(!tween.isActive());
- try std.testing.expectEqual(to, tween.displayed);
- tween.advance();
- try std.testing.expectEqual(to, tween.displayed);
- }
-
- test "retarget starts at the currently displayed value" {
- const Tween = Animation.Transition(TestColor);
- const first: TestColor = .{ .rgb = .{ 0, 40, 200 } };
- const second: TestColor = .{ .rgb = .{ 200, 140, 0 } };
- const third: TestColor = .{ .rgb = .{ 20, 10, 250 } };
- var tween = Tween.init(first);
- tween.retarget(second);
- tween.advance();
- tween.advance();
- tween.advance();
- const on_screen = tween.displayed;
-
- tween.retarget(third);
- try std.testing.expectEqual(on_screen, tween.from);
- try std.testing.expectEqual(on_screen, tween.displayed);
- try std.testing.expect(tween.isActive());
- for (0..transition_steps) |_| tween.advance();
- try std.testing.expectEqual(third, tween.displayed);
- }
-};
-
-test "fractional panel inverse preserves compact header rows through slide and zoom" {
- const layer: pardes.BodyLayer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 };
- for ([_]Transition{ .slide, .zoom, .vertical }) |effect| {
- var presentation: Presentation = .{};
- const track: Track = .{ .serial = 7, .effect = effect, .frame = 4, .frame_count = 10, .from = .{ .x = 2, .y = 2, .w = 20, .h = 8 }, .to = .{ .x = 3, .y = 2, .w = 32, .h = 12 } };
- presentation.shown_tracks[0] = track;
- const shown = track.presented();
- for (0..4) |index| {
- const row: u16 = @intCast(index);
- const source_x: f32 = 5.3;
- const source_y = (layer.rowTop(row, 20, 10) + 5) / 20;
- const x = shown.x + (source_x - track.to.x) / track.to.w * shown.w;
- const y = shown.y + (source_y - track.to.y) / track.to.h * shown.h;
- const point = presentation.pointerFractional(80, 30, x, y).?;
- try std.testing.expectApproxEqAbs(source_x, point.x, 0.0001);
- try std.testing.expectApproxEqAbs(source_y, point.y, 0.0001);
- const body_hit = layer.hitAt(point.x * 10, point.y * 20, 10, 20, 8, 10).?;
- try std.testing.expectEqual(row, body_hit.row);
- }
- }
-}
-
-test "presentation snapshots own compact body and tag rows across later edits" {
- var state: Presentation = .{};
- defer state.deinit(std.testing.allocator);
- var cells: [1]pardes.Cell = .{.{}};
- var body: [2]pardes.Cell = .{ .{}, .{} };
- body[0].text[0] = 'A';
- var surface: pardes.Surface = .{ .cols = 1, .rows = 1, .cells = &cells };
- surface.body_layers[0] = .{ .serial = 9, .cols = 1, .rows = 2, .context_rows = 1, .cells = &body };
- var tag: [1]pardes.Cell = .{.{}};
- tag[0].text[0] = 'T';
- surface.tag_layers[0] = .{ .kind = .pane, .serial = 9, .cols = 1, .cells = &tag };
- state.capturePrevious(std.testing.allocator, &surface);
- try std.testing.expect(state.previous_valid);
- tag[0].text[0] = 'U';
- try std.testing.expectEqual(@as(u8, 'T'), state.previous_tag_layers[0].cells[0].text[0]);
- body[0].text[0] = 'B';
- surface.body_layers[0].context_rows = 0;
- try std.testing.expectEqual(@as(u8, 'A'), state.previous_body_layers[0].cells[0].text[0]);
- try std.testing.expectEqual(@as(u16, 1), state.previous_body_layers[0].context_rows);
- state.capturePrevious(std.testing.allocator, &surface);
- try std.testing.expectEqual(@as(u8, 'B'), state.previous_body_layers[0].cells[0].text[0]);
- try std.testing.expectEqual(@as(u8, 'U'), state.previous_tag_layers[0].cells[0].text[0]);
-}
-
test "column reorder moves only crossed state and preserves weights" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 173, .rows = 20 });
defer p.deinit();
diff --git a/src/look.zig b/src/look.zig
index 136a9ede..2e27a001 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -8,6 +8,7 @@ const pdf_enabled = @import("pardes_config").mupdf;
const fs = @import("fs.zig");
const platform_has_fs = fs.platform_has_fs;
const layout = @import("layout.zig");
+const animation = @import("animation.zig");
const modal = @import("modal.zig");
const filesystem = @import("fs.zig");
const panes = @import("panes.zig");
@@ -20,7 +21,7 @@ const pdf = panes.Pdf.pdf;
const Pane = panes.Pane;
const MAX_PANES = pardes.MAX_PANES;
const tag_layer = @import("tag_layer.zig");
-const TagHit = tag_layer.Hit;
+const TagHit = pardes.TagHit;
const BOX_H = pardes.BOX_H;
const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET;
const pathUnder = pardes.pathUnder;
@@ -502,7 +503,11 @@ const SearchStart = enum { top, cursor };
pub fn submitSearch(p: *Pardes, id: usize) void {
const pane = p.panes[id] orelse return;
- if (normal.selRegexArmed(pane)) |a| return if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.split);
+ if (normal.selRegexArmed(pane)) |a| {
+ // helix keeps the pattern in `/`
+ if (a.pat.len > 0) p.registers.put(p.gpa, '/', a.pat, 0, 1, true);
+ return if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.mode);
+ }
const armed = pane.promptText() orelse return;
const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return;
if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]);
@@ -1012,7 +1017,7 @@ pub fn clearNavigationSelection(p: *Pardes, pane: *Pane) void {
pane.body.nsel = 0;
pane.body.select = false;
pane.body.normal.clear();
- pane.body.append_at = null;
+ pane.body.restore_cursor = false;
pane.body.sticky_col = -1;
for (0..pane.sel.len) |slot| pane.clearPointerSelection(slot);
pane.sel = @splat(.{});
@@ -1374,13 +1379,13 @@ test "stationary Look hover follows only acknowledged panel samples" {
defer p.deinit();
const pane = p.panes[0].?;
const rect = p.rects[0];
- const target: layout.Box = .{
+ const target: animation.Box = .{
.x = @floatFromInt(rect.x),
.y = @floatFromInt(rect.y),
.w = @floatFromInt(rect.w),
.h = @floatFromInt(rect.h),
};
- var track: layout.Track = .{
+ var track: animation.Track = .{
.serial = pane.serial,
.pane = 0,
.phase = .moving,
diff --git a/src/macos.zig b/src/macos.zig
index 70469618..b472e6cc 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -12,6 +12,7 @@ const colors = @import("colors.zig");
const look = @import("look.zig");
const message = pardes.Messages.Message;
const layout = @import("layout.zig");
+const animation = @import("animation.zig");
const file_watch = @import("file_watch.zig");
const image = if (pardes.pdf_enabled) @import("image.zig") else struct {};
const host_io = @import("host_io.zig");
@@ -72,8 +73,8 @@ pub const Scene = extern struct {
frame: u32 = 0,
};
-pub const PanelBox = layout.Box;
-pub const PanelTrack = layout.Track;
+pub const PanelBox = animation.Box;
+pub const PanelTrack = animation.Track;
pub const Image = extern struct {
serial: u32,
@@ -111,13 +112,11 @@ const cell_flag_default: u8 = 1;
const cell_flag_tagline: u8 = 2;
const cell_flag_hover: u8 = 4;
const scene_flag_crt: u32 = 1 << 0;
-const scene_flag_ripple: u32 = 1 << 1;
-const scene_flag_glitch: u32 = 1 << 2;
const scene_frame_hz: u32 = 60;
const scene_wrap_ns: u64 = 4096 * std.time.ns_per_s;
-const max_tick_catch_up_ns: u64 = 4 * pardes.layout.Animation.frame_ns;
+const max_tick_catch_up_ns: u64 = 4 * pardes.animation.frame_ns;
const theme_watch_pane: u8 = @intCast(pardes.MAX_PANES);
const watch_slot_count = pardes.MAX_PANES + 1;
@@ -873,16 +872,16 @@ export fn pardes_should_quit() bool {
return st.core.quit;
}
-fn encodeSceneEffects(effects: layout.SceneEffect) u32 {
+// The Metal shell keeps its own CRT kernel: the GUI's bundled post pass, as
+// a flag.
+fn encodeSceneEffects(post: *const pardes.config.Runtime.Post) u32 {
var flags: u32 = 0;
- if (effects.crt) flags |= scene_flag_crt;
- if (effects.ripple) flags |= scene_flag_ripple;
- if (effects.glitch) flags |= scene_flag_glitch;
+ if (post.has(.crt)) flags |= scene_flag_crt;
return flags;
}
fn currentSceneFlags(st: *const State) u32 {
- return encodeSceneEffects(st.core.settings.scene_effects);
+ return encodeSceneEffects(&st.core.settings.post);
}
fn advanceSceneClock(st: *State, elapsed_ns: u64) void {
@@ -893,18 +892,18 @@ const TickSpend = struct { elapsed_ns: u64, steps: u32, bank_ns: u64 };
fn spendTickTime(previous_ns: u64, now_ns: u64, bank_ns: u64) TickSpend {
const measured = if (previous_ns == 0 or now_ns <= previous_ns)
- pardes.layout.Animation.frame_ns
+ pardes.animation.frame_ns
else
now_ns - previous_ns;
const elapsed = @min(measured, max_tick_catch_up_ns);
var bank = bank_ns +| elapsed;
var steps: u32 = 0;
- while (bank >= pardes.layout.Animation.frame_ns) : (steps += 1) bank -= pardes.layout.Animation.frame_ns;
+ while (bank >= pardes.animation.frame_ns) : (steps += 1) bank -= pardes.animation.frame_ns;
return .{ .elapsed_ns = elapsed, .steps = steps, .bank_ns = bank };
}
test "the animation clock spends real time, not callbacks" {
- const frame = pardes.layout.Animation.frame_ns;
+ const frame = pardes.animation.frame_ns;
const expectEqual = std.testing.expectEqual;
const first = spendTickTime(0, 999 * std.time.ns_per_s, 0);
@@ -1904,25 +1903,22 @@ fn clearFrame(st: *State) void {
st.panel_diff_len = 0;
}
-fn blankTagCells(cells: []Cell, cols: u16, rows: u16, source: []const pardes.Cell, layers: []const pardes.TagLayer) void {
+fn blankTagCells(cells: []Cell, cols: u16, rows: u16, source: []const pardes.Cell, layers: []const pardes.Layer) void {
for (layers) |layer| {
- if (layer.cols == 0 or layer.viewport.y >= rows or layer.viewport.x >= cols) continue;
+ if (layer.rows == 0 or layer.viewport.y >= rows or layer.viewport.x >= cols) continue;
const start = @as(usize, layer.viewport.y) * cols + layer.viewport.x;
const count = @min(layer.viewport.w, cols - layer.viewport.x);
- for (cells[start..][0..count], source[start..][0..count]) |*cell, was| {
- // The tag text is the layer's to draw, but a look-hover on a tag
- // word is a canonical fact: carry the hover bit through so the
- // glass overlay still finds the affordance rect.
- cell.* = encodeCell(.{});
- cell.flags |= @as(u8, @intFromBool(was.hover)) * cell_flag_hover;
- }
+ // The tag text is the layer's to draw. (The look-hover's glass
+ // rect went with the core's hover bit, stage 8's follow-up.)
+ _ = source;
+ for (cells[start..][0..count]) |*cell| cell.* = encodeCell(.{});
}
}
fn tagCursorCovered(core: *const pardes.Pardes) bool {
const cursor = core.surface.cursor orelse return false;
for (core.surface.tag_layers) |layer| {
- if (layer.cols != 0 and cursor.y == layer.viewport.y and cursor.x >= layer.viewport.x and
+ if (layer.rows != 0 and cursor.y == layer.viewport.y and cursor.x >= layer.viewport.x and
cursor.x - layer.viewport.x < layer.viewport.w) return true;
}
return false;
@@ -2164,7 +2160,7 @@ export fn pardes_tag_layer_value(index: u32, field: u32) u32 {
5 => layer.viewport.y,
6 => layer.viewport.w,
7 => layer.viewport.h,
- 8 => if (layer.cursor) |c| c.col else 65535,
+ 8 => if (layer.cursor) |c| c.x else 65535,
9 => if (layer.cursor) |c| @intFromBool(c.bar) else 0,
10 => @as(u32, layer.bg[0]) << 16 | @as(u32, layer.bg[1]) << 8 | layer.bg[2],
11 => @intFromBool(layer.kind == .pane and st.core.settings.tag_bottom),
@@ -2182,7 +2178,7 @@ export fn pardes_tag_layer_cells(index: u32) ?[*]const Cell {
const st = &(state orelse return null);
if (index >= pardes.MAX_TAG_LAYERS) return null;
const layer = &st.core.surface.tag_layers[index];
- if (layer.cols == 0) return null;
+ if (layer.rows == 0) return null;
const cells = &st.tag_cells[index];
if (cells.len != layer.cells.len) cells.* = st.gpa.realloc(cells.*, layer.cells.len) catch return null;
for (layer.cells, cells.*) |source, *out| out.* = encodeCell(source);
@@ -2619,7 +2615,7 @@ fn encodeCell(cell: pardes.Cell) Cell {
.fg = encodeColor(cell.style.fg),
.bg = encodeColor(cell.style.bg),
.attrs = encodeAttrs(cell.style),
- .flags = encodeCellFlags(cell.default, cell.style.font_role, cell.hover),
+ .flags = encodeCellFlags(cell.default, cell.style.font_role, false),
.len = if (cell.default) 1 else cell.len,
};
if (cell.default)
@@ -2888,23 +2884,21 @@ test "pardes.h matches the Zig boundary" {
try expectEqual(@as(u8, c.PARDES_CELL_HOVER), cell_flag_hover);
try expectEqual(@as(u16, c.PARDES_TOPBAR_H), pardes.TOPBAR_H);
try expectEqual(@as(u32, c.PARDES_SCENE_CRT), scene_flag_crt);
- try expectEqual(@as(u32, c.PARDES_SCENE_RIPPLE), scene_flag_ripple);
- try expectEqual(@as(u32, c.PARDES_SCENE_GLITCH), scene_flag_glitch);
- try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(layout.Phase.opening));
- try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(layout.Phase.moving));
- try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(layout.Phase.closing));
- try expectEqual(@as(u8, c.PARDES_PANEL_OFF), @intFromEnum(layout.Transition.off));
- try expectEqual(@as(u8, c.PARDES_PANEL_SLIDE), @intFromEnum(layout.Transition.slide));
- try expectEqual(@as(u8, c.PARDES_PANEL_ZOOM), @intFromEnum(layout.Transition.zoom));
- try expectEqual(@as(u8, c.PARDES_PANEL_DISSOLVE), @intFromEnum(layout.Transition.dissolve));
- try expectEqual(@as(u8, c.PARDES_PANEL_ASCII), @intFromEnum(layout.Transition.ascii));
- try expectEqual(@as(u8, c.PARDES_PANEL_VERTICAL), @intFromEnum(layout.Transition.vertical));
- try expectEqual(@as(u8, c.PARDES_PANEL_EDGES), @intFromEnum(layout.Transition.edges));
- try expectEqual(@as(u8, c.PARDES_PANEL_FALL), @intFromEnum(layout.Transition.fall));
- try expectEqual(@as(u8, c.PARDES_PANEL_WAVE), @intFromEnum(layout.Transition.wave));
- try expectEqual(@as(u8, c.PARDES_PANEL_CURTAIN), @intFromEnum(layout.Transition.curtain));
- try expectEqual(@as(u8, c.PARDES_PANEL_SCRAMBLE), @intFromEnum(layout.Transition.scramble));
- try expectEqual(@as(u8, c.PARDES_PANEL_TYPEWRITER), @intFromEnum(layout.Transition.typewriter));
+ try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(animation.Phase.opening));
+ try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(animation.Phase.moving));
+ try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(animation.Phase.closing));
+ try expectEqual(@as(u8, c.PARDES_PANEL_OFF), @intFromEnum(animation.Transition.off));
+ try expectEqual(@as(u8, c.PARDES_PANEL_SLIDE), @intFromEnum(animation.Transition.slide));
+ try expectEqual(@as(u8, c.PARDES_PANEL_ZOOM), @intFromEnum(animation.Transition.zoom));
+ try expectEqual(@as(u8, c.PARDES_PANEL_DISSOLVE), @intFromEnum(animation.Transition.dissolve));
+ try expectEqual(@as(u8, c.PARDES_PANEL_ASCII), @intFromEnum(animation.Transition.ascii));
+ try expectEqual(@as(u8, c.PARDES_PANEL_VERTICAL), @intFromEnum(animation.Transition.vertical));
+ try expectEqual(@as(u8, c.PARDES_PANEL_EDGES), @intFromEnum(animation.Transition.edges));
+ try expectEqual(@as(u8, c.PARDES_PANEL_FALL), @intFromEnum(animation.Transition.fall));
+ try expectEqual(@as(u8, c.PARDES_PANEL_WAVE), @intFromEnum(animation.Transition.wave));
+ try expectEqual(@as(u8, c.PARDES_PANEL_CURTAIN), @intFromEnum(animation.Transition.curtain));
+ try expectEqual(@as(u8, c.PARDES_PANEL_SCRAMBLE), @intFromEnum(animation.Transition.scramble));
+ try expectEqual(@as(u8, c.PARDES_PANEL_TYPEWRITER), @intFromEnum(animation.Transition.typewriter));
try expectEqual(@as(u21, c.PARDES_KEY_ENTER), pardes.Key.enter);
try expectEqual(@as(u21, c.PARDES_KEY_ESCAPE), pardes.Key.escape);
@@ -2960,14 +2954,10 @@ test "pardes.h matches the Zig boundary" {
test "scene effect flags and display clock are compact and independent" {
const expectEqual = std.testing.expectEqual;
- try expectEqual(@as(u32, 0), encodeSceneEffects(.{}));
- try expectEqual(scene_flag_crt, encodeSceneEffects(.{ .crt = true }));
- try expectEqual(scene_flag_ripple, encodeSceneEffects(.{ .ripple = true }));
- try expectEqual(scene_flag_glitch, encodeSceneEffects(.{ .glitch = true }));
- try expectEqual(
- scene_flag_crt | scene_flag_ripple | scene_flag_glitch,
- encodeSceneEffects(.{ .crt = true, .ripple = true, .glitch = true }),
- );
+ var post: pardes.config.Runtime.Post = .{};
+ try expectEqual(@as(u32, 0), encodeSceneEffects(&post));
+ _ = post.set(.crt, "", true);
+ try expectEqual(scene_flag_crt, encodeSceneEffects(&post));
var st: State = undefined;
st.scene_ns = scene_wrap_ns - (std.time.ns_per_ms * 5);
@@ -3155,7 +3145,7 @@ test "mac tag layer ABI preserves logical capacity and physical grip" {
logical[11].text[0] = 'Z';
logical[11].len = 1;
logical[11].default = false;
- core.surface.tag_layers[0] = .{ .kind = .pane, .id = 1, .serial = 42, .viewport = .{ .x = 2, .y = 1, .w = 6, .h = 1 }, .cols = 12, .cells = &logical, .bg = .{ 0x12, 0x34, 0x56 }, .cursor = .{ .col = 11, .bar = true } };
+ core.surface.tag_layers[0] = .{ .kind = .pane, .id = 1, .serial = 42, .viewport = .{ .x = 2, .y = 1, .w = 6, .h = 1 }, .cols = 12, .rows = 1, .cells = &logical, .bg = .{ 0x12, 0x34, 0x56 }, .cursor = .{ .x = 11, .y = 0, .bar = true } };
defer core.surface.tag_layers[0] = .{};
try std.testing.expectEqual(@as(u32, 12), pardes_tag_layer_value(0, 0));
try std.testing.expectEqual(@as(u32, 0x123456), pardes_tag_layer_value(0, 10));
@@ -3176,16 +3166,11 @@ test "mac tag layer ABI preserves logical capacity and physical grip" {
try std.testing.expectEqual(@as(u32, 11), pardes_tag_layer_value(0, 8));
try std.testing.expectEqual(@as(u8, 'Z'), pardes_tag_layer_cells(0).?[11].text[0]);
var canonical: [24]Cell = @splat(encodeCell(.{ .text = .{'G'} ++ .{0} ** 6, .len = 1, .default = false }));
- // A look-hover under the layer is a canonical fact and survives the blank;
- // the glyph it marks does not, because the layer redraws that text itself.
var source: [24]pardes.Cell = @splat(.{});
- source[11].hover = true;
blankTagCells(&canonical, 8, 3, &source, &core.surface.tag_layers);
try std.testing.expectEqual(@as(u8, 'G'), canonical[9].text[0]);
try std.testing.expectEqual(@as(u8, 'G'), canonical[18].text[0]);
try std.testing.expectEqual(encodeCell(.{}).len, canonical[10].len);
- try std.testing.expectEqual(@as(u8, 0), canonical[10].flags & cell_flag_hover);
- try std.testing.expectEqual(cell_flag_hover, canonical[11].flags & cell_flag_hover);
}
test "mac window blur builtin reaches the ABI without changing opacity" {
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift
index 70f467e3..8ba9c84b 100644
--- a/src/macos/Sources/PardesView.swift
+++ b/src/macos/Sources/PardesView.swift
@@ -684,7 +684,7 @@ final class PardesView: NSView {
private var latchedButton: pardes_mouse_button_e?
private var latchedCell: GridPoint?
/// Last physical point delivered by AppKit and its mapping through the
- /// accepted scene. Ripple/glitch can move a source cell under a stationary
+ /// accepted scene. The CRT's barrel moves a source cell under a stationary
/// hand, so successful scene presentations reconcile this point again.
private var pointerPoint: CGPoint?
private var pointerCell: GridPoint?
diff --git a/src/macos/Sources/ScenePostprocessor.swift b/src/macos/Sources/ScenePostprocessor.swift
index cf7892b3..41d54e34 100644
--- a/src/macos/Sources/ScenePostprocessor.swift
+++ b/src/macos/Sources/ScenePostprocessor.swift
@@ -205,11 +205,9 @@ final class ScenePostprocessor {
if scene.flags != 0 {
let crt: Float = scene.flags & UInt32(PARDES_SCENE_CRT) != 0 ? 1 : 0
- let ripple: Float = scene.flags & UInt32(PARDES_SCENE_RIPPLE) != 0 ? 1 : 0
- let glitch: Float = scene.flags & UInt32(PARDES_SCENE_GLITCH) != 0 ? 1 : 0
// SDL samples the scene with CLAMP_TO_EDGE. A finite Core Image
// instead supplies transparent black outside its extent, which
- // would make ripple/glitch tear dark seams at the window edge.
+ // would tear dark seams at the window edge.
// Clamp the input only; the kernel still emits true black before
// sampling when a destination lies outside the CRT tube.
let sceneInput = filtered.clampedToExtent()
@@ -218,7 +216,7 @@ final class ScenePostprocessor {
roiCallback: { _, _ in extent },
arguments: [
sceneInput, scene.time_seconds, Float(scene.frame),
- crt, ripple, glitch,
+ crt,
CIVector(x: extent.width, y: extent.height),
CIVector(x: extent.minX, y: extent.minY),
])
@@ -291,7 +289,7 @@ final class ScenePostprocessor {
/// The scene kernel is a destination-to-source mapping. Pointer input must
/// apply that same mapping before the grid and its panel tracks see a cell,
- /// otherwise a visible word under ripple/glitch is not the word Look gets.
+ /// otherwise a visible word under the barrel is not the word Look gets.
static func sourcePoint(
_ point: CGPoint,
size: CGSize,
@@ -311,27 +309,8 @@ final class ScenePostprocessor {
ccY *= barrel
uvX = ccX * 0.5 + 0.5
uvY = ccY * 0.5 + 0.5
- // Only CRT has a black tube. Ripple/glitch without CRT sample through
- // the texture's clamp-to-edge policy and remain clickable there.
+ // The CRT's black tube has no cell under it.
if crt > 0.5 && min(min(uvX, uvY), min(1 - uvX, 1 - uvY)) <= 0 { return nil }
-
- let time = scene.time_seconds
- if scene.flags & UInt32(PARDES_SCENE_RIPPLE) != 0 {
- let centeredX = uvX - 0.5
- let centeredY = uvY - 0.5
- let radius = sqrtf(centeredX * centeredX + centeredY * centeredY)
- let envelope = 1 - smoothstep(0.04, 0.72, radius)
- let wave = sinf(radius * 42 - time * 5.2)
- let travel = wave * envelope * 0.0032 / max(radius, 0.00001)
- uvX += centeredX * travel
- uvY += centeredY * travel
- }
- if scene.flags & UInt32(PARDES_SCENE_GLITCH) != 0 {
- let band = floorf(uvY * 96)
- let seed = noise(band, floorf(time * 13))
- let tear = smoothstep(0.955, 1, seed)
- uvX += (noise(seed, band) - 0.5) * tear * 0.020
- }
uvX = min(max(uvX, 0), max(0, 1 - 1 / Float(size.width)))
uvY = min(max(uvY, 0), max(0, 1 - 1 / Float(size.height)))
return CGPoint(
@@ -339,23 +318,4 @@ final class ScenePostprocessor {
y: CGFloat(1 - uvY) * size.height)
}
- private static func smoothstep(_ edge0: Float, _ edge1: Float, _ x: Float) -> Float {
- let t = min(max((x - edge0) / (edge1 - edge0), 0), 1)
- return t * t * (3 - 2 * t)
- }
-
- /// Scalar spelling of pardesNoise in crt.ci.metal. Float arithmetic is
- /// deliberate: the shader's band decision must not move at a Double round.
- private static func noise(_ x: Float, _ y: Float) -> Float {
- var qx = fract(x * 0.1031)
- var qy = fract(y * 0.1031)
- var qz = fract(x * 0.1031)
- let d = qx * (qy + 33.33) + qy * (qz + 33.33) + qz * (qx + 33.33)
- qx += d
- qy += d
- qz += d
- return fract((qx + qy) * qz)
- }
-
- private static func fract(_ x: Float) -> Float { x - floorf(x) }
}
diff --git a/src/macos/pardes.h b/src/macos/pardes.h
index f74330e5..29f8a37d 100644
--- a/src/macos/pardes.h
+++ b/src/macos/pardes.h
@@ -82,8 +82,7 @@ typedef struct {
// from the background colour, which a real selection shares.
#define PARDES_CELL_HOVER 0x04u
-// One coherent snapshot for the full-window shader pass. The bitmask lets the
-// host run CRT, ripple and glitch in one kernel instead of stacking filters;
+// One coherent snapshot for the full-window shader pass (CRT);
// time/frame advance only on pardes_animation_tick's display clock.
typedef struct {
uint32_t flags;
@@ -92,8 +91,6 @@ typedef struct {
} pardes_scene_s;
#define PARDES_SCENE_CRT 0x01u
-#define PARDES_SCENE_RIPPLE 0x02u
-#define PARDES_SCENE_GLITCH 0x04u
// One backend-neutral pane transition. Boxes are logical cell rectangles;
// the native shader converts them to backing pixels using the same measured
diff --git a/src/memory.zig b/src/memory.zig
index 7b6ee2c0..abfd40f6 100644
--- a/src/memory.zig
+++ b/src/memory.zig
@@ -17,6 +17,9 @@ pub const limits = struct {
pub const wrap_rows = if (board) 128 else 256;
pub const undo_max = if (board) 16 else 256;
pub const message_log = if (board) 16 else 128;
+ /// ranges one selection holds; helix has no limit, and a `%s` over a
+ /// file's words wants hundreds
+ pub const selections = if (board) 64 else 1024;
/// /log's ring, which records whether or not anyone reads it.
pub const log_bytes: usize = if (board) 4 * KiB else 64 * KiB;
pub const max_tag_tail: usize = if (board) 512 else 4096;
@@ -187,6 +190,7 @@ test "memory limits preserve desktop capacities" {
try std.testing.expectEqual(256, limits.wrap_rows);
try std.testing.expectEqual(256, limits.undo_max);
try std.testing.expectEqual(128, limits.message_log);
+ try std.testing.expectEqual(1024, limits.selections);
try std.testing.expectEqual(@as(usize, 4096), limits.max_tag_tail);
try std.testing.expectEqual(@as(usize, 4095), limits.host_path_cap);
try std.testing.expectEqual(config.embed_sources, limits.embedded_sources);
diff --git a/src/modal.zig b/src/modal.zig
index 3827d11f..cc7f1ebd 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -15,6 +15,8 @@ pub const Normal = struct {
prefix_replace,
prefix_next,
prefix_prev,
+ prefix_register,
+ prefix_view_sticky,
goto_file_start,
goto_last_line,
@@ -27,6 +29,8 @@ pub const Normal = struct {
goto_view_top,
goto_view_center,
goto_view_bottom,
+ goto_last_modification,
+ goto_word,
goto_definition,
goto_declaration,
goto_type_definition,
@@ -36,6 +40,7 @@ pub const Normal = struct {
view_top,
view_center,
view_bottom,
+ view_middle,
view_scroll_down,
view_scroll_up,
@@ -49,6 +54,13 @@ pub const Normal = struct {
add_newline,
goto_diagnostic,
goto_diagnostic_end,
+ goto_function,
+ goto_class,
+ goto_parameter,
+ goto_comment,
+ goto_test,
+ goto_entry,
+ goto_xml_element,
move_left,
move_right,
@@ -96,6 +108,17 @@ pub const Normal = struct {
trim_sels,
select_regex,
split_regex,
+ keep_regex,
+ remove_regex,
+ ensure_forward,
+ expand_node,
+ shrink_node,
+ next_sibling,
+ prev_sibling,
+ all_siblings,
+ all_children,
+ parent_node_end,
+ parent_node_start,
delete,
delete_noyank,
@@ -108,6 +131,10 @@ pub const Normal = struct {
to_lowercase,
to_uppercase,
join_lines,
+ join_select_space,
+ rotate_contents_fwd,
+ rotate_contents_back,
+ align_selections,
indent,
unindent,
format,
@@ -123,6 +150,10 @@ pub const Normal = struct {
pipe_selection_to,
insert_output,
append_output,
+ keep_pipe,
+ macro_record,
+ macro_replay,
+ repeat_insert,
search,
search_next,
search_prev,
@@ -156,6 +187,7 @@ pub const Normal = struct {
replace,
next,
prev,
+ register,
};
pub const MatchSub = enum(u8) {
@@ -172,9 +204,14 @@ pub const Normal = struct {
prefix: Prefix = .none,
match_sub: MatchSub = .none,
held_char: u21 = 0,
+ /// `"<reg>`: the register the next command names, 0 for none
+ register: u21 = 0,
+ /// `Z`: the view keys stay armed until Esc (helix's sticky view)
+ sticky: bool = false,
+ /// Drop what is pending; sticky view mode stays armed through it.
pub fn clear(state: *State) void {
- state.* = .{};
+ state.* = if (state.sticky) .{ .sticky = true, .prefix = .view } else .{};
}
};
@@ -188,13 +225,22 @@ pub const Normal = struct {
insert,
/// `A-!` — no stdin, and the output is appended AFTER each selection.
append,
+ /// `$` — stdin is the selection; the output is discarded, and only the
+ /// selections the command exits 0 on are kept.
+ keep,
/// Do the selections become stdin? helix's `pipe` flag.
pub fn pipes(b: PipeBehavior) bool {
- return b == .replace or b == .ignore;
+ return b == .replace or b == .ignore or b == .keep;
}
};
+ /// helix's tree-sitter selection keys: where a range goes in the tree
+ /// helix's tree-sitter textobjects, by what their queries call them
+ pub const Object = enum { function, class, parameter, comment, @"test", entry, xml_element };
+ pub const NodeWalk = enum { expand, shrink, next_sibling, prev_sibling, all_siblings, all_children, parent_end, parent_start };
+ /// what the s/S/K/Alt-K prompt does with its pattern's matches
+ pub const RegexMode = enum { select, split, keep, remove };
pub const Scope = enum { once, per_selection };
pub const Direction = enum { backward, forward };
pub const Motion = enum {
@@ -221,8 +267,9 @@ pub const Normal = struct {
view_top,
view_center,
view_bottom,
+ last_modification,
};
- pub const View = enum { top, center, bottom, scroll_down, scroll_up };
+ pub const View = enum { top, center, bottom, middle, scroll_down, scroll_up };
pub const Find = enum { forward, backward, till_forward, till_backward };
pub const Line = enum { start, end, first_nonws };
pub const Page = enum { half_down, half_up, down, up };
@@ -247,6 +294,7 @@ pub const Normal = struct {
merge,
merge_consecutive,
trim,
+ ensure_forward,
};
pub const Edit = enum {
delete,
@@ -260,6 +308,10 @@ pub const Normal = struct {
lowercase,
uppercase,
join_lines,
+ join_select_space,
+ rotate_contents_forward,
+ rotate_contents_backward,
+ align_selections,
indent,
unindent,
comment_toggle,
@@ -295,7 +347,7 @@ pub const Normal = struct {
insert: struct { kind: Insert, count: u32 },
select: struct { kind: Select, count: u32 },
multi: struct { kind: Multi, count: u32 },
- select_regex: bool, // false = select, true = split
+ select_regex: RegexMode,
edit: struct { kind: Edit, count: u32 },
lsp: Lsp,
adjust_number: i64,
@@ -304,6 +356,12 @@ pub const Normal = struct {
pipe_selection: PipeBehavior,
search,
search_step: Direction,
+ goto_word,
+ macro_record,
+ macro_replay: u32,
+ node: NodeWalk,
+ object_jump: struct { object: Object, direction: Direction, count: u32 },
+ repeat_insert: u32,
pub fn scope(value: Action) Scope {
return switch (value) {
@@ -315,9 +373,22 @@ pub const Normal = struct {
.pipe_selection,
.search,
.search_step,
+ .macro_record,
+ .macro_replay,
+ .node,
+ .goto_word,
+ .repeat_insert,
=> .once,
.edit => |edit| switch (edit.kind) {
- .comment_toggle, .undo, .redo => .once,
+ .join_lines,
+ .join_select_space,
+ .rotate_contents_forward,
+ .rotate_contents_backward,
+ .align_selections,
+ .comment_toggle,
+ .undo,
+ .redo,
+ => .once,
else => .per_selection,
},
else => .per_selection,
@@ -354,7 +425,10 @@ pub const Normal = struct {
pub fn parse(state: *State, key: Input) Result {
if (key.has(.escape)) {
- state.clear();
+ // Esc out of sticky view mode leaves that mode and nothing else
+ const sticky = state.sticky;
+ state.* = .{};
+ if (sticky) return .ignored;
return resultAction(.escape);
}
@@ -386,6 +460,8 @@ pub const Normal = struct {
if (key.has(.goto_view_top)) return resultAction(.{ .goto = .{ .target = .view_top, .count = count, .explicit_count = counted.explicit } });
if (key.has(.goto_view_center)) return resultAction(.{ .goto = .{ .target = .view_center, .count = count, .explicit_count = counted.explicit } });
if (key.has(.goto_view_bottom)) return resultAction(.{ .goto = .{ .target = .view_bottom, .count = count, .explicit_count = counted.explicit } });
+ if (key.has(.goto_word)) return resultAction(.goto_word);
+ if (key.has(.goto_last_modification)) return resultAction(.{ .goto = .{ .target = .last_modification, .count = count, .explicit_count = counted.explicit } });
if (key.has(.goto_definition)) return resultAction(.{ .lsp = .definition });
if (key.has(.goto_declaration)) return resultAction(.{ .lsp = .declaration });
if (key.has(.goto_type_definition)) return resultAction(.{ .lsp = .type_definition });
@@ -394,8 +470,9 @@ pub const Normal = struct {
return .ignored;
},
.view => {
- state.prefix = .none;
+ if (!state.sticky) state.prefix = .none;
if (key.has(.view_top)) return resultAction(.{ .view = .top });
+ if (key.has(.view_middle)) return resultAction(.{ .view = .middle });
if (key.has(.view_center)) return resultAction(.{ .view = .center });
if (key.has(.view_bottom)) return resultAction(.{ .view = .bottom });
if (key.has(.view_scroll_down)) return resultAction(.{ .view = .scroll_down });
@@ -473,14 +550,28 @@ pub const Normal = struct {
if (key.has(.add_newline)) return resultAction(.{ .add_newline = .{ .direction = direction, .count = count } });
if (key.has(.goto_diagnostic)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = false } });
if (key.has(.goto_diagnostic_end)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = true } });
+ inline for (.{ .{ .goto_function, .function }, .{ .goto_class, .class }, .{ .goto_parameter, .parameter }, .{ .goto_comment, .comment }, .{ .goto_test, .@"test" }, .{ .goto_entry, .entry }, .{ .goto_xml_element, .xml_element } }) |pair|
+ if (key.has(pair[0])) return resultAction(.{ .object_jump = .{ .object = pair[1], .direction = direction, .count = count } });
return .ignored;
},
+ .register => {
+ state.prefix = .none;
+ const char = key.literal() orelse return .ignored;
+ state.register = char;
+ // the count typed before `"` is still the command's
+ state.count = if (counted.explicit) count else 0;
+ return .pending;
+ },
.none => {},
}
// Prefix setters retain the count for their continuation.
if (key.has(.prefix_goto)) return armPrefix(state, .goto, if (counted.explicit) count else 0);
if (key.has(.prefix_view)) return armPrefix(state, .view, if (counted.explicit) count else 0);
+ if (key.has(.prefix_view_sticky)) {
+ state.sticky = true;
+ return armPrefix(state, .view, 0);
+ }
if (key.has(.prefix_find_fwd)) return armPrefix(state, .find_fwd, if (counted.explicit) count else 0);
if (key.has(.prefix_find_back)) return armPrefix(state, .find_back, if (counted.explicit) count else 0);
if (key.has(.prefix_till_fwd)) return armPrefix(state, .till_fwd, if (counted.explicit) count else 0);
@@ -489,6 +580,7 @@ pub const Normal = struct {
if (key.has(.prefix_next)) return armPrefix(state, .next, if (counted.explicit) count else 0);
if (key.has(.prefix_prev)) return armPrefix(state, .prev, if (counted.explicit) count else 0);
if (key.has(.prefix_match)) return armPrefix(state, .match, 0);
+ if (key.has(.prefix_register)) return armPrefix(state, .register, if (counted.explicit) count else 0);
if (key.has(.move_left)) return resultAction(.{ .move = .{ .motion = .left, .count = count } });
if (key.has(.move_right)) return resultAction(.{ .move = .{ .motion = .right, .count = count } });
@@ -535,8 +627,19 @@ pub const Normal = struct {
if (key.has(.merge_sels)) return resultAction(.{ .multi = .{ .kind = .merge, .count = count } });
if (key.has(.merge_consecutive_sels)) return resultAction(.{ .multi = .{ .kind = .merge_consecutive, .count = count } });
if (key.has(.trim_sels)) return resultAction(.{ .multi = .{ .kind = .trim, .count = count } });
- if (key.has(.select_regex)) return resultAction(.{ .select_regex = false });
- if (key.has(.split_regex)) return resultAction(.{ .select_regex = true });
+ if (key.has(.select_regex)) return resultAction(.{ .select_regex = .select });
+ if (key.has(.split_regex)) return resultAction(.{ .select_regex = .split });
+ if (key.has(.keep_regex)) return resultAction(.{ .select_regex = .keep });
+ if (key.has(.remove_regex)) return resultAction(.{ .select_regex = .remove });
+ if (key.has(.ensure_forward)) return resultAction(.{ .multi = .{ .kind = .ensure_forward, .count = count } });
+ if (key.has(.expand_node)) return resultAction(.{ .node = .expand });
+ if (key.has(.shrink_node)) return resultAction(.{ .node = .shrink });
+ if (key.has(.next_sibling)) return resultAction(.{ .node = .next_sibling });
+ if (key.has(.prev_sibling)) return resultAction(.{ .node = .prev_sibling });
+ if (key.has(.all_siblings)) return resultAction(.{ .node = .all_siblings });
+ if (key.has(.all_children)) return resultAction(.{ .node = .all_children });
+ if (key.has(.parent_node_end)) return resultAction(.{ .node = .parent_end });
+ if (key.has(.parent_node_start)) return resultAction(.{ .node = .parent_start });
if (key.has(.delete)) return resultAction(.{ .edit = .{ .kind = .delete, .count = count } });
if (key.has(.delete_noyank)) return resultAction(.{ .edit = .{ .kind = .delete_noyank, .count = count } });
@@ -549,6 +652,10 @@ pub const Normal = struct {
if (key.has(.to_lowercase)) return resultAction(.{ .edit = .{ .kind = .lowercase, .count = count } });
if (key.has(.to_uppercase)) return resultAction(.{ .edit = .{ .kind = .uppercase, .count = count } });
if (key.has(.join_lines)) return resultAction(.{ .edit = .{ .kind = .join_lines, .count = count } });
+ if (key.has(.join_select_space)) return resultAction(.{ .edit = .{ .kind = .join_select_space, .count = count } });
+ if (key.has(.rotate_contents_fwd)) return resultAction(.{ .edit = .{ .kind = .rotate_contents_forward, .count = count } });
+ if (key.has(.rotate_contents_back)) return resultAction(.{ .edit = .{ .kind = .rotate_contents_backward, .count = count } });
+ if (key.has(.align_selections)) return resultAction(.{ .edit = .{ .kind = .align_selections, .count = count } });
if (key.has(.indent)) return resultAction(.{ .edit = .{ .kind = .indent, .count = count } });
if (key.has(.unindent)) return resultAction(.{ .edit = .{ .kind = .unindent, .count = count } });
if (key.has(.format)) return resultAction(.{ .lsp = .format });
@@ -564,6 +671,10 @@ pub const Normal = struct {
if (key.has(.pipe_selection_to)) return resultAction(.{ .pipe_selection = .ignore });
if (key.has(.insert_output)) return resultAction(.{ .pipe_selection = .insert });
if (key.has(.append_output)) return resultAction(.{ .pipe_selection = .append });
+ if (key.has(.keep_pipe)) return resultAction(.{ .pipe_selection = .keep });
+ if (key.has(.macro_record)) return resultAction(.macro_record);
+ if (key.has(.macro_replay)) return resultAction(.{ .macro_replay = count });
+ if (key.has(.repeat_insert)) return resultAction(.{ .repeat_insert = count });
if (key.has(.search)) return resultAction(.search);
if (key.has(.search_next)) return resultAction(.{ .search_step = .forward });
if (key.has(.search_prev)) return resultAction(.{ .search_step = .backward });
@@ -1287,6 +1398,53 @@ fn wmIsPrev(t: WordTarget) bool {
}
/// w/b/e/W/B/E: helix word_move — each step selects the traversed span.
+/// helix jump_to_word's candidates: the words of two or more word
+/// characters between `start` and `end`, nearest the cursor first, taking one
+/// forward and one back in turn, each over its word pointing the way it was
+/// found, as many as `out` holds.
+pub fn jumpWords(text: []const u8, cursor: usize, start: usize, end: usize, out: []Selection) usize {
+ var fwd: Selection = .{ .anchor = cursor, .head = cursor };
+ var rev = fwd;
+ if (cursor < text.len and !isWordWhitespace(wordClassAt(text, cursor))) {
+ const word_end = moveWord(text, fwd, 1, .next_word_end);
+ if (word_end.anchor == cursor) fwd = word_end; // a word of one grapheme
+ const word_start = moveWord(text, rev, 1, .prev_word_start);
+ if (word_start.anchor == nextGrapheme(text, cursor)) rev = word_start;
+ }
+ var n: usize = 0;
+ while (n < out.len) {
+ var changed = false;
+ while (fwd.head < end) {
+ fwd = moveWord(text, fwd, 1, .next_word_end);
+ // the two graphemes before the head are word characters
+ const one = prevGrapheme(text, fwd.head);
+ if (fwd.head == 0 or one == 0 or wordClassAt(text, one) != .word or wordClassAt(text, prevGrapheme(text, one)) != .word) continue;
+ changed = true;
+ var from = fwd.anchor;
+ while (from < fwd.head and wordClassAt(text, from) != .word) from = nextGrapheme(text, from);
+ fwd.anchor = from;
+ out[n] = fwd;
+ n += 1;
+ break;
+ }
+ if (n == out.len) break;
+ while (rev.head > start) {
+ rev = moveWord(text, rev, 1, .prev_word_start);
+ // the two graphemes from the head are word characters
+ if (wordClassAt(text, rev.head) != .word or wordClassAt(text, nextGrapheme(text, rev.head)) != .word) continue;
+ changed = true;
+ var to = rev.anchor;
+ while (to > rev.head and wordClassAt(text, prevGrapheme(text, to)) != .word) to = prevGrapheme(text, to);
+ rev.anchor = to;
+ out[n] = rev;
+ n += 1;
+ break;
+ }
+ if (!changed) break;
+ }
+ return n;
+}
+
pub fn moveWord(text: []const u8, r0: Selection, count: usize, target: WordTarget) Selection {
const is_prev = wmIsPrev(target);
if ((is_prev and r0.head == 0) or (!is_prev and r0.head == text.len)) return r0;
diff --git a/src/mouse.zig b/src/mouse.zig
index 1cfe6dbb..22eb3270 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -7,6 +7,8 @@ const exec = @import("exec.zig");
const look = @import("look.zig");
const std = @import("std");
const layout = @import("layout.zig");
+const Presentation = @import("Presentation.zig");
+const animation = @import("animation.zig");
const modal = @import("modal.zig");
const ctlfs = @import("ninep/tree.zig");
const panes = @import("panes.zig");
@@ -745,7 +747,7 @@ fn mirrorTtySelection(p: *Pardes, pane: *Pane) void {
pane.body.vsel.active = false;
pane.body.nsel = 0;
pane.body.select = false;
- pane.body.append_at = null;
+ pane.body.restore_cursor = false;
pane.body.sticky_col = -1;
pane.body.normal.clear();
edit.setYank(p, text);
@@ -857,7 +859,7 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
pane.body.vsel.active = false;
pane.body.nsel = 0; // a click says WHERE the one cursor is
pane.body.select = false;
- pane.body.append_at = null;
+ pane.body.restore_cursor = false;
pane.body.sticky_col = -1;
if (s.ctrl) p.lspRequest(s.id, .definition, "");
}
@@ -961,8 +963,8 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
if (cut) {
if (t.vsel.active) edit.normalDelete(p, t, true);
} else if (t.vsel.active) {
- edit.normalReplaceYank(p, t);
- } else edit.pasteText(p, t, p.yank orelse return, true, 1);
+ edit.chordReplace(p, t);
+ } else edit.pasteText(p, t, p.registers.text(p.gpa, pardes.Registers.default) orelse return, true, 1);
return;
}
if (pane.body.mode == .tty) {
@@ -971,7 +973,7 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
pane.sel[sel_slot].state = .none; // drop the sweep highlight
}
if (cut) return;
- const y = p.yank orelse return;
+ const y = p.registers.text(p.gpa, pardes.Registers.default) orelse return;
if (y.len == 0) return;
if (panes.Terminal.reportsMouse(pane)) {
const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999));
@@ -1014,11 +1016,11 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
if (cut) {
if (pane.body.vsel.active) edit.normalDelete(p, &pane.body, true);
} else if (pane.body.vsel.active) {
- edit.normalReplaceYank(p, &pane.body);
+ edit.chordReplace(p, &pane.body);
} else if (pane.file) |*f| {
// bare cursor: splice the register in literally (no linewise
// magic) so a paste right after a cut restores the text exactly
- const y = p.yank orelse return;
+ const y = p.registers.text(p.gpa, pardes.Registers.default) orelse return;
if (y.len == 0) return;
const at = modal.Cursor{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) };
edit.pushUndo(p, &pane.body);
@@ -1037,7 +1039,7 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
pane.body.cur_pinned = true;
pane.body.sticky_col = -1;
pane.ensureCursorVisible();
- } else edit.pasteText(p, &pane.body, p.yank orelse return, true, 1);
+ } else edit.pasteText(p, &pane.body, p.registers.text(p.gpa, pardes.Registers.default) orelse return, true, 1);
}
test "plain left click clears explicit modal selection" {
@@ -1233,7 +1235,7 @@ test "pointer coordinates follow presented panel geometry" {
defer p.deinit();
const pane = p.panes[0].?;
- const moving: layout.Track = .{
+ const moving: animation.Track = .{
.serial = pane.serial,
.pane = 0,
.phase = .moving,
@@ -1244,13 +1246,13 @@ test "pointer coordinates follow presented panel geometry" {
p.acknowledgePanelPresentation(&.{moving});
// One shared physical-to-logical map feeds all pointer gestures. The
// selected sample is 26.25% across and 35% down the presented rectangle.
- try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 21, .row = 7 }, p.presentation.pointer(p.screen_w, p.screen_h, 30, 8).?);
+ try std.testing.expectEqual(Presentation.CellPosition{ .col = 21, .row = 7 }, p.presentation.pointer(p.screen_w, p.screen_h, 30, 8).?);
// Canonical cells covered only by the not-yet-arrived target are inert.
try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 2, 2) == null);
// Unrelated screen space remains in the ordinary grid coordinate system.
- try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 100, .row = 10 }, p.presentation.pointer(p.screen_w, p.screen_h, 100, 10).?);
+ try std.testing.expectEqual(Presentation.CellPosition{ .col = 100, .row = 10 }, p.presentation.pointer(p.screen_w, p.screen_h, 100, 10).?);
- p.acknowledgePanelPresentation(&.{layout.Track{
+ p.acknowledgePanelPresentation(&.{animation.Track{
.serial = pane.serial,
.pane = 0,
.phase = .opening,
@@ -1262,7 +1264,7 @@ test "pointer coordinates follow presented panel geometry" {
// Tracks paint in pane-slot order inside a phase. The later slot is the
// top quad and therefore owns an overlap, even though both are moving.
- const overlap = [_]layout.Track{ .{
+ const overlap = [_]animation.Track{ .{
.serial = pane.serial,
.pane = 0,
.phase = .moving,
@@ -1280,7 +1282,7 @@ test "pointer coordinates follow presented panel geometry" {
p.presentation.shown_tracks = @splat(null);
p.presentation.shown_tracks[0] = overlap[0];
p.presentation.shown_tracks[1] = overlap[1];
- try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 65, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 25, 3).?);
+ try std.testing.expectEqual(Presentation.CellPosition{ .col = 65, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 25, 3).?);
}
test "queued pointer input is inert until a changed layout is presented" {
@@ -1296,7 +1298,7 @@ test "queued pointer input is inert until a changed layout is presented" {
try std.testing.expect(p.presentation.pending);
try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null);
- var tracks: [MAX_PANES]layout.Track = undefined;
+ var tracks: [MAX_PANES]animation.Track = undefined;
var len: usize = 0;
for (p.presentation.tracks) |maybe| if (maybe) |track| {
tracks[len] = track;
@@ -1313,7 +1315,7 @@ test "pointer rejects panel content cells which have not materialized" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12, .tty_only = true });
defer p.deinit();
const pane = p.panes[0].?;
- const target: layout.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 };
+ const target: animation.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 };
p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h);
@memset(p.presentation.diffs, .unchanged);
for (2..6) |row| {
@@ -1328,9 +1330,9 @@ test "pointer rejects panel content cells which have not materialized" {
}
p.presentation.diff_state = .ready;
- for ([_]layout.Transition{ .dissolve, .ascii }) |effect| {
+ for ([_]animation.Transition{ .dissolve, .ascii }) |effect| {
const frame: u16 = if (effect == .dissolve) 3 else 5;
- const track: layout.Track = .{
+ const track: animation.Track = .{
.serial = pane.serial,
.pane = 0,
.phase = .opening,
@@ -1349,7 +1351,7 @@ test "pointer rejects panel content cells which have not materialized" {
const col: u16 = @intCast(4 + relative_col);
const row: u16 = @intCast(2 + relative_row);
const visible = switch (effect) {
- .dissolve => layout.dissolveRevealed(
+ .dissolve => animation.dissolveRevealed(
pane.serial,
rcol,
rrow,
@@ -1364,7 +1366,7 @@ test "pointer rejects panel content cells which have not materialized" {
const mapped = p.presentation.pointer(p.screen_w, p.screen_h, col, row);
try std.testing.expectEqual(visible, mapped != null);
if (mapped) |point|
- try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = col, .row = row }, point);
+ try std.testing.expectEqual(Presentation.CellPosition{ .col = col, .row = row }, point);
saw_visible = saw_visible or visible;
saw_hidden = saw_hidden or !visible;
};
@@ -1380,9 +1382,9 @@ test "unchanged content cells remain clickable through data effects" {
p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h);
@memset(p.presentation.diffs, .unchanged);
p.presentation.diff_state = .ready;
- const box: layout.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 };
+ const box: animation.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 };
- for ([_]layout.Transition{ .dissolve, .ascii }) |effect| {
+ for ([_]animation.Transition{ .dissolve, .ascii }) |effect| {
p.acknowledgePanelPresentation(&.{.{
.serial = pane.serial,
.pane = 0,
@@ -1391,7 +1393,7 @@ test "unchanged content cells remain clickable through data effects" {
.from = box,
.to = box,
}});
- try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 8, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 8, 3).?);
+ try std.testing.expectEqual(Presentation.CellPosition{ .col = 8, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 8, 3).?);
}
}
@@ -1400,7 +1402,7 @@ test "held drag follows acknowledged panels and balances an invisible release" {
defer p.deinit();
const pane = p.panes[0].?;
const target = layout.panelBox(p.rects[0]);
- var track: layout.Track = .{
+ var track: animation.Track = .{
.serial = pane.serial,
.pane = 0,
.phase = .moving,
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 680a09b0..a64521e7 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -302,7 +302,9 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void {
// Bare, it names the default directory.
.dump_dir => if (s.dump_dir.len > 0) try w.print("{s} {s}\n", .{ word, s.dump_dir.get() }) else try w.print("{s}\n", .{word}),
.transition => |effect| try w.print("{s} {s}\n", .{ word, if (s.panel_transition == effect) "on" else "off" }),
- .scene => |effect| try w.print("{s} {s}\n", .{ word, if (@field(s.scene_effects, @tagName(effect))) "on" else "off" }),
+ .scene => |effect| if (s.post.level(effect) == 0) try w.print("{s} off\n", .{word}) else try w.print("{s} {d}\n", .{ word, s.post.level(effect) }),
+ .shader => for (s.post.list()) |entry| if (entry.scene == null) try w.print("{s} {s}\n", .{ word, entry.path.get() }),
+ .shader_animation => try w.print("{s} {s}\n", .{ word, @tagName(s.shader_animation) }),
}
}
try w.writeAll("LocationsConfig ");
diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig
index bc2788ec..88947d0d 100644
--- a/src/ninep/screen.zig
+++ b/src/ninep/screen.zig
@@ -245,7 +245,7 @@ test "screen snapshots preserve rendered cells and styles until their handle is
test "screen inspection preserves acknowledged presentation and the next real frame" {
const gpa = testing.allocator;
- for (std.enums.values(pardes.layout.Transition)) |transition| {
+ for (std.enums.values(pardes.animation.Transition)) |transition| {
errdefer std.debug.print("screen inspection during {s}\n", .{@tagName(transition)});
const control = try withFile(gpa, "const value = 1;\n");
defer control.deinit();
diff --git a/src/normal.zig b/src/normal.zig
index da707e26..17ebaac6 100644
--- a/src/normal.zig
+++ b/src/normal.zig
@@ -7,6 +7,7 @@ const exec = @import("exec.zig");
const look = @import("look.zig");
const std = @import("std");
const regexp = @import("regexp.zig");
+const syntax = @import("syntax.zig");
const modal = @import("modal.zig");
const panes = @import("panes.zig");
const edit = @import("edit.zig");
@@ -18,6 +19,7 @@ const Key = pardes.Key;
const hit = pardes.hit;
const sel_slot = pardes.sel_slot;
const Pardes = pardes.Pardes;
+const Macro = pardes.Macro;
fn isPrefix(key: Key, cp: u21) bool {
return key.cp == cp and !key.ctrl and !key.alt;
@@ -27,6 +29,7 @@ fn roleBindingName(comptime role: modal.Normal.Role) []const u8 {
return switch (role) {
.prefix_goto => "goto_prefix",
.prefix_view => "view_prefix",
+ .prefix_view_sticky => "view_prefix_sticky",
.prefix_match => "match_prefix",
.prefix_find_fwd => "find_char_fwd",
.prefix_find_back => "find_char_back",
@@ -35,6 +38,7 @@ fn roleBindingName(comptime role: modal.Normal.Role) []const u8 {
.prefix_replace => "replace_prefix",
.prefix_next => "next_prefix",
.prefix_prev => "prev_prefix",
+ .prefix_register => "register_prefix",
else => @tagName(role),
};
}
@@ -67,27 +71,38 @@ pub fn multiOnce(p: *Pardes) bool {
return p.multi_first;
}
-fn startSelRegex(p: *Pardes, pane: *Pane, t: *Text, split: bool) void {
+fn startSelRegex(p: *Pardes, pane: *Pane, t: *Text, mode: modal.Normal.RegexMode) void {
const text = edit.flatSurface(p, t) catch return;
const got = t.ranges(text, 0, &pane.sel_snap);
pane.nsel_snap = @intCast(got.n);
pane.sel_snap_pri = @intCast(got.pri);
pane.sel_snap_expl = (t.vsel.active and t.vsel.explicit) or t.msel.active;
- p.startPrompt(pane, t, .{ .search = if (split) config.split_marker else config.select_marker });
+ p.startPrompt(pane, t, .{ .search = regexMarker(mode) });
}
-pub fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } {
+fn regexMarker(mode: modal.Normal.RegexMode) []const u8 {
+ return switch (mode) {
+ .select => config.select_marker,
+ .split => config.split_marker,
+ .keep => config.keep_marker,
+ .remove => config.remove_marker,
+ };
+}
+
+pub fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, mode: modal.Normal.RegexMode } {
if (pane.prompt != .search or pane.nsel_snap == 0) return null;
const armed = pane.input.own orelse "";
- const split = std.mem.startsWith(u8, armed, config.split_marker);
- if (!split and !std.mem.startsWith(u8, armed, config.select_marker)) return null;
+ const mode: modal.Normal.RegexMode = for (std.enums.values(modal.Normal.RegexMode)) |m| {
+ if (std.mem.startsWith(u8, armed, regexMarker(m))) break m;
+ } else return null;
const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return null;
- return .{ .pat = armed[slash + 1 ..], .split = split };
+ return .{ .pat = armed[slash + 1 ..], .mode = mode };
}
-/// Select (or split on) `pat` inside the selections `t` had when s/S was
-/// pressed; the prompt's snapshot of them lives on `pane`.
-pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split: bool) void {
+/// Select (or split on, keep or remove by) `pat` inside the selections `t`
+/// had when s/S/K/Alt-K was pressed; the prompt's snapshot of them lives on
+/// `pane`.
+pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, mode: modal.Normal.RegexMode) void {
const text = edit.flatSurface(p, t) catch return;
const snap = pane.sel_snap[0..pane.nsel_snap];
var out: [Text.max_selections]modal.Selection = undefined;
@@ -106,6 +121,17 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split:
const from = @min(r.anchor, r.head);
const to = @min(@max(r.anchor, r.head), text.len);
if (from >= to) continue;
+ if (mode == .keep or mode == .remove) {
+ // helix keep_or_remove_matches: the range stays when a match
+ // starts inside it (K), or when none does (Alt-K)
+ const hit_at = re.find(hay_all, from, to, to) catch null;
+ const found = if (hit_at) |h| h.start < to else false;
+ if (found == (mode == .keep) and m < Text.max_selections) {
+ out[m] = r;
+ m += 1;
+ }
+ continue;
+ }
// Searched as sam searches, the way addr does (regexp.zig): the
// text around the selection still says where its lines begin.
var at = from;
@@ -113,7 +139,7 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split:
while (at < to and m < Text.max_selections) {
// A search too slow to finish keeps what it found so far.
const hit_at = (re.find(hay_all, at, to, to) catch break) orelse break;
- if (split) {
+ if (mode == .split) {
out[m] = .{ .anchor = piece, .head = hit_at.start };
m += 1;
piece = hit_at.end;
@@ -124,7 +150,7 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split:
// an empty match would otherwise never advance
at = if (hit_at.end > hit_at.start) hit_at.end else hit_at.end + 1;
}
- if (split and piece < to and m < Text.max_selections) {
+ if (mode == .split and piece < to and m < Text.max_selections) {
out[m] = .{ .anchor = piece, .head = to };
m += 1;
}
@@ -142,13 +168,158 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split:
t.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO)
}
+/// The file's parse, made on the first key that walks it after an edit and
+/// kept until the next: never made again for a key that changed nothing.
+pub fn fileTree(p: *Pardes, f: *panes.File.State) ?*anyopaque {
+ if (f.node_tree == null or f.node_tree_revision != f.revision) {
+ if (f.node_tree) |old| syntax.freeTree(old);
+ f.node_tree = syntax.parseTree(f.path, f.content);
+ f.node_tree_revision = f.revision;
+ for (f.node_history.items) |step| p.gpa.free(step.ranges);
+ f.node_history.clearRetainingCapacity();
+ }
+ return f.node_tree;
+}
+
+/// `]f`/`[f` and kin (helix goto_ts_object): to the next object starting
+/// after the cursor or the previous one ending before it, count times; select
+/// mode stretches the range to it.
+fn objectJump(p: *Pardes, pane: *Pane, t: *Text, text: []const u8, range: modal.Selection, object: modal.Normal.Object, forward: bool, count: u32) void {
+ if (t.what != .body) return;
+ const f = if (pane.file) |*file| file else return;
+ const tree = fileTree(p, f) orelse return;
+ var last = range;
+ for (0..count) |_| {
+ const s = syntax.objectNext(f.path, tree, text, object, forward, modal.selectionCursor(text, last)) orelse break;
+ const next: modal.Selection = .{ .anchor = s.from, .head = s.to };
+ if (std.meta.eql(next, last)) break;
+ last = next;
+ }
+ if (std.meta.eql(last, range)) return;
+ if (t.select) {
+ const head = if (last.head < range.anchor) last.anchor else last.head;
+ return t.setRange(text, 0, .{ .anchor = range.anchor, .head = head }, false);
+ }
+ const out: modal.Selection = if (forward) last else .{ .anchor = last.head, .head = last.anchor };
+ t.setRange(text, 0, out, false);
+}
+
+/// helix's tree-sitter selection keys: every range walks the file's parse,
+/// which is made on the first of these keys after an edit and kept, never
+/// made again for a key that changed nothing. A range the tree has nothing
+/// to say about stays as it was; a text without a grammar, all of them.
+pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk) void {
+ if (t.what != .body) return;
+ const f = if (pane.file) |*file| file else return;
+ const tree = fileTree(p, f) orelse return;
+ const text = f.content;
+ const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active;
+ const arena = p.scratch.allocator();
+ const rs = arena.alloc(modal.Selection, Text.max_selections) catch return;
+ const got = t.ranges(text, 0, rs[0..Text.max_selections]);
+ if (walk == .shrink) if (f.node_history.pop()) |prev| {
+ defer p.gpa.free(prev.ranges);
+ // back to what Alt-o grew from, while the selection still holds it
+ const holds = for (prev.ranges) |pr| {
+ const pf = @min(pr.anchor, pr.head);
+ const pt = @max(pr.anchor, pr.head);
+ if (for (rs[0..got.n]) |r| {
+ if (@min(r.anchor, r.head) <= pf and pt <= @max(r.anchor, r.head)) break true;
+ } else false) continue;
+ break false;
+ } else true;
+ if (holds) return t.setRanges(text, prev.ranges, &.{}, prev.primary, expl);
+ for (f.node_history.items) |step| p.gpa.free(step.ranges);
+ f.node_history.clearRetainingCapacity();
+ };
+ const out = arena.alloc(modal.Selection, Text.max_selections) catch return;
+ const spans = arena.alloc(syntax.Span, Text.max_selections) catch return;
+ var m: usize = 0;
+ for (rs[0..got.n]) |r| {
+ if (m == out.len) break;
+ const from = @min(r.anchor, r.head);
+ const to = @max(r.anchor, r.head);
+ const back = r.head < r.anchor;
+ const n = syntax.walkNodes(tree, walk, from, to, modal.selectionCursor(text, r), spans[0 .. out.len - m]);
+ if (n == 0) {
+ out[m] = r;
+ m += 1;
+ continue;
+ }
+ for (spans[0..n]) |s| {
+ out[m] = switch (walk) {
+ .next_sibling => .{ .anchor = s.from, .head = s.to },
+ .prev_sibling => .{ .anchor = s.to, .head = s.from },
+ // helix move_parent_node_end: a one-character range on the
+ // bound, or stretched to it in select mode
+ .parent_end, .parent_start => bound: {
+ const head = s.from;
+ if (t.select) {
+ const anchor = r.anchor;
+ break :bound .{ .anchor = anchor, .head = if (head >= anchor) modal.nextGrapheme(text, head) else head };
+ }
+ const next = modal.nextGrapheme(text, head);
+ break :bound if (back) .{ .anchor = next, .head = head } else .{ .anchor = head, .head = next };
+ },
+ else => if (back) .{ .anchor = s.to, .head = s.from } else .{ .anchor = s.from, .head = s.to },
+ };
+ m += 1;
+ }
+ }
+ const same = m == got.n and std.mem.eql(u8, std.mem.sliceAsBytes(out[0..m]), std.mem.sliceAsBytes(rs[0..m]));
+ if (walk == .expand and !same) remember: {
+ const ranges = p.gpa.dupe(modal.Selection, rs[0..got.n]) catch break :remember;
+ f.node_history.append(p.gpa, .{ .ranges = ranges, .primary = got.pri }) catch p.gpa.free(ranges);
+ }
+ t.setRanges(text, out[0..m], &.{}, @min(got.pri, m - 1), expl);
+}
+
+/// `gw` (helix goto_word): label the words in view, nearest the cursor first,
+/// with two letters each; the next two keys choose one (`jumpKey`).
+fn startJump(p: *Pardes, pane: *Pane, t: *Text) void {
+ if (t.what != .body) return;
+ const text = edit.flatSurface(p, t) catch return;
+ const cursor = modal.selectionCursor(text, t.primaryRange(text, 0));
+ const top: usize = @intCast(@max(0, pane.scroll()));
+ const start = @min(modal.lineStartOffset(text, top), text.len);
+ const end = @min(modal.lineStartOffset(text, top + pane.rows), text.len);
+ const letters = config.jump_label_alphabet.len;
+ pane.jump.resize(p.gpa, letters * letters) catch return;
+ pane.jump.shrinkRetainingCapacity(modal.jumpWords(text, cursor, start, end, pane.jump.items));
+ pane.jump_first = null;
+}
+
+/// A key while `gw`'s labels are up: a label's first letter, then its
+/// second, which selects that word; anything else takes the labels away.
+pub fn jumpKey(p: *Pardes, pane: *Pane, t: *Text, key: Key) void {
+ const letters = config.jump_label_alphabet;
+ const at = if (key.ctrl or key.alt or key.cp > 0x7f) null else std.mem.indexOfScalar(u8, letters, @intCast(key.cp));
+ const i = at orelse return pane.jump.clearRetainingCapacity();
+ const first = pane.jump_first orelse {
+ if (i * letters.len > pane.jump.items.len) return pane.jump.clearRetainingCapacity();
+ pane.jump_first = @intCast(i);
+ return;
+ };
+ const chosen = first * letters.len + i;
+ defer pane.jump.clearRetainingCapacity();
+ if (chosen >= pane.jump.items.len) return;
+ const r = pane.jump.items[chosen];
+ const text = edit.flatSurface(p, t) catch return;
+ if (!t.select) return t.setRange(text, 0, .{ .anchor = @min(r.anchor, r.head), .head = @max(r.anchor, r.head) }, true);
+ // select mode stretches the primary to the word (helix Movement::Extend)
+ const pr = t.primaryRange(text, 0);
+ const anchor = if (r.anchor < r.head) @min(r.anchor, @min(pr.anchor, pr.head)) else @max(r.anchor, @max(pr.anchor, pr.head));
+ t.setRange(text, 0, .{ .anchor = anchor, .head = r.head }, true);
+}
+
const KeyState = struct {
mode: Text.Mode,
select: bool,
normal: modal.Normal.State,
find_op: u8,
find_ch: u21,
- append_at: @FieldType(Text, "append_at"),
+ restore_cursor: bool,
+ reg_wait: bool,
fn of(t: *Text) KeyState {
return .{
@@ -157,7 +328,8 @@ const KeyState = struct {
.normal = t.normal,
.find_op = t.find_op,
.find_ch = t.find_ch,
- .append_at = t.append_at,
+ .restore_cursor = t.restore_cursor,
+ .reg_wait = t.reg_wait,
};
}
@@ -167,7 +339,8 @@ const KeyState = struct {
t.normal = s.normal;
t.find_op = s.find_op;
t.find_ch = s.find_ch;
- t.append_at = s.append_at;
+ t.restore_cursor = s.restore_cursor;
+ t.reg_wait = s.reg_wait;
}
};
@@ -227,6 +400,8 @@ pub fn replaySels(p: *Pardes, t: *Text, what: Replay) void {
while (i > 0) {
i -= 1;
p.multi_first = passes == 0;
+ p.multi_index = i;
+ p.multi_count = n;
passes += 1;
saved.into(t);
t.nsel = 0; // the handler underneath sees ONE selection
@@ -284,11 +459,16 @@ pub fn replaySels(p: *Pardes, t: *Text, what: Replay) void {
}
pub fn handleNormal(p: *Pardes, t: *Text, key: Key) void {
+ // `"<reg>` is held by the parser until the command it names arrives
+ const named = t.normal.register;
const parsed = modal.Normal.parse(&t.normal, normalInput(key));
const semantic = switch (parsed) {
.pending, .ignored, .unbound => return,
.action => |value| value,
};
+ t.normal.register = 0;
+ p.register = named;
+ defer p.register = 0;
if (t.nsel == 0 or semantic.scope() == .once)
return executeNormalAction(p, t, semantic);
replaySels(p, t, .{ .normal = semantic });
@@ -313,6 +493,8 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void
switch (semantic) {
.escape => {
t.select = false;
+ // typed again by a macro, Esc is only helix's
+ if (p.macro.replaying > 0) return;
return exec.runBuiltin(p, .Last, p.active, "", null);
},
.goto => |go| switch (go.target) {
@@ -346,6 +528,10 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void
.view_top => return t.gotoWindow(text, range, .top, go.count),
.view_center => return t.gotoWindow(text, range, .center, go.count),
.view_bottom => return t.gotoWindow(text, range, .bottom, go.count),
+ .last_modification => {
+ const at = t.last_edit orelse return;
+ return t.pointMove(text, range, modal.offsetAt(text, .{ .row = @intCast(@max(0, at.row)), .col = @intCast(@max(0, at.col)) }));
+ },
},
.view => |view| if (t.what == .body) switch (view) {
.top => {
@@ -360,6 +546,15 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void
pane.scrollBy(t.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1));
pane.ensureCursorVisible();
},
+ .middle => {
+ // helix align_view_middle: the cursor's column to the middle
+ // of an unwrapped view
+ if (pane.body_wrapped) return;
+ const line = text[modal.lineStartOffset(text, modal.lineAtOffset(text, cur))..modal.lineEndOffset(text, modal.lineAtOffset(text, cur))];
+ const w: usize = @intCast(@max(1, @as(i32, pane.cols) - @as(i32, panes.File.gutterWidth(pane))));
+ const col = panes.File.rawDisplayCol(line, @intCast(@max(0, t.cur_col)));
+ pane.hscroll = @intCast(panes.File.rawAtDisplay(line, col -| w / 2));
+ },
.scroll_down => return t.scrollViewMove(text, range, 1),
.scroll_up => return t.scrollViewMove(text, range, -1),
},
@@ -520,27 +715,28 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void
.all => return t.setRange(text, 0, .{ .anchor = 0, .head = text.len }, false),
},
.multi => |multi| {
- if (multi.kind == .keep_primary) {
- t.nsel = 0;
- return;
- }
+ if (multi.kind == .keep_primary) return t.dropSels();
return t.multiSelAction(text, multi.kind, multi.count);
},
// s, S and | answer for the text they are pressed in; `/` searches
// the body wherever it is pressed, as acme's Look from a tag does.
- .select_regex => |split| return startSelRegex(p, pane, t, split),
+ .select_regex => |mode| return startSelRegex(p, pane, t, mode),
.edit => |op| switch (op.kind) {
.delete => return edit.normalDelete(p, t, true),
.delete_noyank => return edit.normalDelete(p, t, false),
.change => return edit.normalChange(p, t),
.yank => return edit.normalYank(p, t),
- .replace_with_yank => return edit.normalReplaceYank(p, t),
- .paste_after => return edit.pasteText(p, t, p.yank orelse return, false, op.count),
- .paste_before => return edit.pasteText(p, t, p.yank orelse return, true, op.count),
+ .replace_with_yank => return edit.pasteRegister(p, t, .replace, op.count),
+ .paste_after => return edit.pasteRegister(p, t, .after, op.count),
+ .paste_before => return edit.pasteRegister(p, t, .before, op.count),
.switch_case => return edit.normalCase(p, t, .toggle),
.lowercase => return edit.normalCase(p, t, .lower),
.uppercase => return edit.normalCase(p, t, .upper),
- .join_lines => return edit.normalJoin(p, t),
+ .join_lines => return edit.normalJoin(p, t, false),
+ .join_select_space => return edit.normalJoin(p, t, true),
+ .rotate_contents_forward => return edit.normalRotateContents(p, t, true, op.count),
+ .rotate_contents_backward => return edit.normalRotateContents(p, t, false, op.count),
+ .align_selections => return edit.normalAlign(p, t),
.indent => return edit.normalIndent(p, t, op.count, true),
.unindent => return edit.normalIndent(p, t, op.count, false),
.comment_toggle => return edit.normalToggleComment(p, t),
@@ -556,7 +752,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void
.format => .format,
}, ""),
.adjust_number => |delta| return edit.normalAdjustNumber(p, t, delta),
- .leader => {
+ .leader => if (p.macro.replaying == 0) {
p.leader_on = true;
p.leader_n = 0;
},
@@ -573,6 +769,46 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void
p,
if (direction == .forward) @as(i32, 1) else -1,
),
+ .node => |walk| return nodeSelect(p, pane, t, walk),
+ .goto_word => return startJump(p, pane, t),
+ .object_jump => |jump| return objectJump(p, pane, t, text, range, jump.object, jump.direction == .forward, jump.count),
+ .macro_record => {
+ const m = &p.macro;
+ if (m.recording == 0) {
+ m.recording = if (p.register != 0) p.register else '@';
+ m.recorded.clearRetainingCapacity();
+ return;
+ }
+ // the keys of this command (a count, `"<reg>`, the `Q`) are not
+ // the macro's
+ m.recorded.shrinkRetainingCapacity(m.recorded.items.len -| (m.command.items.len + 1));
+ const keys = Macro.encode(p.gpa, m.recorded.items) catch return;
+ defer p.gpa.free(keys);
+ p.registers.put(p.gpa, m.recording, keys, 0, 1, true);
+ m.recording = 0;
+ },
+ .macro_replay => |count| {
+ const name: u21 = if (p.register != 0) p.register else '@';
+ const notation = p.registers.value(name, 0) orelse return;
+ const keys = Macro.decode(p.scratch.allocator(), notation) catch return;
+ p.register = 0;
+ for (0..count) |_| p.replayKeys(keys);
+ },
+ .repeat_insert => |count| {
+ const m = &p.macro;
+ if (m.last_insert.items.len == 0 or m.repeating) return;
+ if (m.inserting) return; // the session is still open
+ const keys = p.scratch.allocator().dupe(Macro.Stored, m.last_insert.items) catch return;
+ m.repeating = true;
+ defer m.repeating = false;
+ // helix repeat_last_insert: the command once, what was typed
+ // count times, and out of insert mode the way it was left
+ const typed = @min(m.typed_from, keys.len);
+ p.replayKeys(keys[0..typed]);
+ if (keys.len == typed) return;
+ for (0..count) |_| p.replayKeys(keys[typed .. keys.len - 1]);
+ p.replayKeys(keys[keys.len - 1 ..]);
+ },
}
}
@@ -713,10 +949,11 @@ test "flat text movement and selection replay need no scratch rows" {
pane.body.cur_row = 1024;
pane.body.cur_col = 0;
pane.body.nsel = 1;
+ _ = pane.body.reserveSels();
pane.body.sels[0] = .{ .row = 3072, .col = 0, .arow = 3072, .acol = 0 };
handleNormal(p, &pane.body, .{ .cp = 'l' });
handleNormal(p, &pane.body, .{ .cp = 'j' });
- try std.testing.expectEqual(@as(u8, 1), pane.body.nsel);
+ try std.testing.expectEqual(@as(u16, 1), pane.body.nsel);
try std.testing.expectEqual(@as(i32, 1025), pane.body.cur_row);
try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col);
try std.testing.expectEqual(@as(i32, 3073), pane.body.sels[0].row);
@@ -740,20 +977,20 @@ test "s and S search a multi-line selection as addr does: ^ and $ per line, . wi
defer p.deinit();
const text = "alpha x\nbeta alpha\nalphabet\n";
const pane = try p.setTestFile(text);
- const Case = struct { pat: []const u8, split: bool, want: []const [2]usize };
+ const Case = struct { pat: []const u8, mode: modal.Normal.RegexMode, want: []const [2]usize };
for ([_]Case{
// ^ at each line's start, not only the selection's
- .{ .pat = "^alpha", .split = false, .want = &.{ .{ 0, 5 }, .{ 19, 24 } } },
+ .{ .pat = "^alpha", .mode = .select, .want = &.{ .{ 0, 5 }, .{ 19, 24 } } },
// $ at each line's end
- .{ .pat = "alpha$", .split = false, .want = &.{.{ 13, 18 }} },
+ .{ .pat = "alpha$", .mode = .select, .want = &.{.{ 13, 18 }} },
// . stops at the newline: one selection per line, not one for all
- .{ .pat = "a.*", .split = false, .want = &.{ .{ 0, 7 }, .{ 11, 18 }, .{ 19, 27 } } },
+ .{ .pat = "a.*", .mode = .select, .want = &.{ .{ 0, 7 }, .{ 11, 18 }, .{ 19, 27 } } },
// S splits on a line's end
- .{ .pat = "$", .split = true, .want = &.{ .{ 0, 7 }, .{ 7, 18 }, .{ 18, 27 }, .{ 27, 28 } } },
+ .{ .pat = "$", .mode = .split, .want = &.{ .{ 0, 7 }, .{ 7, 18 }, .{ 18, 27 }, .{ 27, 28 } } },
}) |c| {
pane.sel_snap[0] = .{ .anchor = 0, .head = text.len };
pane.nsel_snap = 1;
- applySelRegex(p, pane, &pane.body, c.pat, c.split);
+ applySelRegex(p, pane, &pane.body, c.pat, c.mode);
var out: [Text.max_selections]modal.Selection = undefined;
const got = pane.body.ranges(text, 0, &out);
try std.testing.expectEqual(c.want.len, got.n);
@@ -763,3 +1000,132 @@ test "s and S search a multi-line selection as addr does: ^ and $ per line, . wi
}
}
}
+
+test "tree-sitter selection keys do in a JSON file what hx 25.07.1 does" {
+ if (!syntax.supportsPath("a.json")) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ const text = "{\"a\": [1, 2, 3], \"b\": true}\n";
+ // each checked against the installed hx through a pty: the keys, then
+ // the text it saved
+ const Case = struct { keys: []const u8, want: []const u8 };
+ for ([_]Case{
+ .{ .keys = "10l\x1bo" ++ "d", .want = "{\"a\": , \"b\": true}\n" },
+ .{ .keys = "10l\x1bo\x1bod", .want = "{, \"b\": true}\n" },
+ .{ .keys = "10l\x1bo\x1bo\x1bod", .want = "\n" },
+ .{ .keys = "10l\x1bo\x1bIix\x1b", .want = "{\"a\": [x1, x2, x3], \"b\": true}\n" },
+ .{ .keys = "10l\x1bo\x1bo\x1b>d", .want = "{\"a\": [1, 2, 3] \"b\": true}\n" },
+ .{ .keys = "10l\x1bpd", .want = "{\"a\": [1 2, 3], \"b\": true}\n" },
+ .{ .keys = "10l\x1baix\x1b", .want = "{\"a\": [x1, x2, x3], \"b\": true}\n" },
+ .{ .keys = "10l\x1bo\x1bo\x1bid", .want = "{\"a\": , \"b\": true}\n" },
+ .{ .keys = "6lx\x1bid", .want = "\n" },
+ .{ .keys = "10l\x1beiX\x1b", .want = "{\"a\": [1, 2X, 3], \"b\": true}\n" },
+ .{ .keys = "10l\x1bb\x1bbiX\x1b", .want = "{X\"a\": [1, 2, 3], \"b\": true}\n" },
+ // insert Tab after text leaves the node (smart tab)
+ .{ .keys = "8li\tX\x1b", .want = "{\"a\": [1, 2, 3]X, \"b\": true}\n" },
+ }) |c| {
+ const pane = try p.setTestFile(text);
+ p.gpa.free(pane.file.?.path);
+ pane.file.?.path = try p.gpa.dupe(u8, "/test.json");
+ // ESC before a key is Alt on it; a lone ESC is Esc; `>` is Alt-right
+ var i: usize = 0;
+ while (i < c.keys.len) : (i += 1) {
+ const ch = c.keys[i];
+ if (ch == 0x1b and i + 1 < c.keys.len) {
+ i += 1;
+ const k = c.keys[i];
+ p.update(.{ .key = if (k == '>') .{ .cp = Key.right, .alt = true } else .{ .cp = k, .alt = true } });
+ } else if (ch == 0x1b) {
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ } else if (ch == '\t') {
+ p.update(.{ .key = .{ .cp = Key.tab } });
+ } else p.update(.{ .key = .{ .cp = ch, .text = c.keys[i..][0..1] } });
+ while (p.nextEffect()) |_| {}
+ }
+ std.testing.expectEqualStrings(c.want, pane.file.?.content) catch |err| {
+ std.debug.print("keys {s}\n", .{c.keys});
+ return err;
+ };
+ }
+}
+
+test "one cursor holds no room for other ranges; a second range makes it and one again gives it back" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 12 });
+ defer p.deinit();
+ const pane = try p.setTestFile("alpha beta\nalpha beta\n");
+ for ("wxe%;gg") |cp| handleNormal(p, &pane.body, .{ .cp = cp });
+ try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len);
+ handleNormal(p, &pane.body, .{ .cp = 'C' });
+ try std.testing.expectEqual(@as(u16, 1), pane.body.nsel);
+ try std.testing.expect(pane.body.sels.len > 0);
+ handleNormal(p, &pane.body, .{ .cp = 'l' }); // replayed at both, still two
+ try std.testing.expectEqual(@as(u16, 1), pane.body.nsel);
+ handleNormal(p, &pane.body, .{ .cp = ',' });
+ try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len);
+ handleNormal(p, &pane.body, .{ .cp = 'C' });
+ handleNormal(p, &pane.body, .{ .cp = ';' });
+ handleNormal(p, &pane.body, .{ .cp = 'x' }); // x at both: still two lines
+ handleNormal(p, &pane.body, .{ .cp = '_', .alt = true }); // merged to one
+ try std.testing.expectEqual(@as(u16, 0), pane.body.nsel);
+ try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len);
+}
+
+test "tree-sitter textobject keys do in Python, JSON and Zig what hx 25.07.1 does" {
+ if (!syntax.supportsPath("a.py") or !syntax.supportsPath("a.json") or !syntax.supportsPath("a.zig")) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ // each checked against the installed hx through a pty: the keys, then
+ // the text it saved
+ const Case = struct { path: []const u8, text: []const u8, keys: []const u8, want: []const u8 };
+ for ([_]Case{
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]fd", .want = "def alpha(a, b):\n return a + b\n\n\n\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]f]fd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "2]fd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "Gk[fd", .want = "ef alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "jmifd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "jmafd", .want = "\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "10lmiad", .want = "def alpha(, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "10lmaad", .want = "def alpha( b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]Td", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]cd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n \n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "v]fd", .want = "\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]td", .want = "ef alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]ad", .want = "def alpha( b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "]ed", .want = "{, \"b\": [1, 2]}\n" },
+ .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "]e]ed", .want = "{\"a\": 1, }\n" },
+ .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "8lmaed", .want = "{\"a\": 1,\"b\": [1, 2]}\n" },
+ // Zig: the query is helix's, ported to pardes's zig grammar. No case
+ // walks call arguments: the reference helix counts them as
+ // parameters and the installed 25.07.1's query does not.
+ .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]fd", .want = "const std = @import(\"std\");\n\n/// adds\n\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" },
+ .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "5jmifd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 \n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" },
+ .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "5jmafd", .want = "const std = @import(\"std\");\n\n/// adds\n\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" },
+ .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]td", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\n\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" },
+ .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "9jmitd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" },
+ .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "9jmatd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\n\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" },
+ .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]Td", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\n\n" },
+ .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "16jmiTd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" \n" },
+ .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "3j11lmiad", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" },
+ .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "3j11lmaad", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add( b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" },
+ .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]cd", .want = "const std = @import(\"std\");\n\n\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" },
+ .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "4jmacd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" },
+ .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "16jf2[ad", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, ) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" },
+ .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "16j[fd", .want = "const std = @import(\"std\");\n\n/// adds\n\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" },
+ .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]t]td", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\n\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" },
+ }) |c| {
+ const pane = try p.setTestFile(c.text);
+ p.gpa.free(pane.file.?.path);
+ pane.file.?.path = try p.gpa.dupe(u8, c.path);
+ for (c.keys, 0..) |ch, i| {
+ p.update(.{ .key = .{ .cp = ch, .text = c.keys[i..][0..1] } });
+ while (p.nextEffect()) |_| {}
+ }
+ std.testing.expectEqualStrings(c.want, pane.file.?.content) catch |err| {
+ std.debug.print("keys {s} on {s}\n", .{ c.keys, c.path });
+ return err;
+ };
+ }
+}
diff --git a/src/panes.zig b/src/panes.zig
index 29fe0cb0..8049da4e 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -157,9 +157,13 @@ pub const Pane = struct {
search_pane: ?usize = null,
search_row: ?usize = null,
look_at: ?LookSpot = null,
+ /// `gw`'s words, labelled in order while it waits for a label to be
+ /// typed (gpa-owned); `jump_first` is the label's first letter once typed.
+ jump: std.ArrayList(modal.Selection) = .empty,
+ jump_first: ?u8 = null,
sel_snap: [Text.max_selections]modal.Selection = undefined,
- nsel_snap: u8 = 0,
- sel_snap_pri: u8 = 0,
+ nsel_snap: u16 = 0,
+ sel_snap_pri: u16 = 0,
sel_snap_expl: bool = false,
/// The tag: what the user keeps after the computed prefix (the path, the
/// dirty marker, a PDF's page), its cursor, selections and undo. The
diff --git a/src/pardes.zig b/src/pardes.zig
index 91f0f9f4..f559b125 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1,5 +1,7 @@
const std = @import("std");
pub const layout = @import("layout.zig");
+pub const animation = @import("animation.zig");
+pub const Presentation = @import("Presentation.zig");
const uucode = @import("uucode");
const vaxis = @import("vaxis");
pub const modal = @import("modal.zig");
@@ -16,7 +18,10 @@ pub const mouse = @import("mouse.zig");
pub const normal = @import("normal.zig");
pub const edit = @import("edit.zig");
pub const body_layer = @import("body_layer.zig");
+pub const draw = @import("draw.zig");
pub const Messages = @import("Messages.zig");
+pub const Registers = @import("Registers.zig");
+pub const Macro = @import("Macro.zig");
pub const Pipe = @import("Pipe.zig");
pub const colors = @import("colors.zig");
pub const Theme = colors.Theme;
@@ -35,6 +40,9 @@ pub const NativePlacement = @import("surface.zig").NativePlacement;
pub const ImagePatch = @import("surface.zig").ImagePatch;
pub const ImagePlace = @import("surface.zig").ImagePlace;
pub const Surface = @import("surface.zig").Surface;
+pub const Region = @import("surface.zig").Region;
+pub const Chrome = @import("surface.zig").Chrome;
+pub const MAX_REGIONS = @import("surface.zig").MAX_REGIONS;
pub const builtins = @import("builtins.zig");
const limits = memory.limits;
const selection_pipe = @import("selection_pipe.zig");
@@ -243,21 +251,17 @@ pub const Pane = panes.Pane;
const Text = panes.Text;
pub const MAX_PANES = 16;
-pub const BodyLayer = @import("body_layer.zig").Layer;
-pub const RowMetrics = @import("body_layer.zig").Metrics;
-pub const TagLayer = @import("tag_layer.zig").Layer;
-pub const TagHit = @import("tag_layer.zig").Hit;
-pub const TagKind = @import("tag_layer.zig").Kind;
-/// Panes, then columns, then the workspace bar, then each pane's notice bands.
+pub const Layer = @import("Layer.zig");
+pub const RowMetrics = Layer.Metrics;
+pub const TagHit = Layer.TagHit;
+pub const TagKind = Layer.Kind;
/// A tag shows at most this many lines; past them it is cut, like a line
/// too long for its width.
pub const MAX_TAG_ROWS = 8;
-pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max + (MAX_PANES + MAX_COLS + 1) * (MAX_TAG_ROWS - 1);
+/// Panes, then columns, then the workspace bar, then each pane's notice
+/// bands: a band to a tag, however many lines it has.
+pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max;
pub const NOTICE_LAYER_BASE = MAX_PANES + MAX_COLS + 1;
-/// Where the layers for a pane tag's second and later lines start.
-pub const TAG_LINE_LAYER_BASE = NOTICE_LAYER_BASE + MAX_PANES * Pane.Notices.max;
-/// ...and those of a column's (by index) or the workspace's (after them).
-pub const HEADER_LINE_LAYER_BASE = TAG_LINE_LAYER_BASE + MAX_PANES * (MAX_TAG_ROWS - 1);
pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px;
pub const MAX_COLS = 6;
const column_weight_unit = layout.column_weight_unit;
@@ -284,7 +288,7 @@ pub fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 {
return @min(@as(u32, config.gui_topbar_pane_border_px), spare * 2);
}
-pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?layout.Track) f32 {
+pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?animation.Track) f32 {
if (row < p.topBarHeight()) return 0;
if (row < p.topBarHeight() + p.columnBarHeight()) {
for (p.col_x[0..p.ncol], p.col_w[0..p.ncol]) |x, w|
@@ -315,7 +319,7 @@ pub fn taglineOriginColForFrame(p: *const Pardes, col: u16, row: u16) f32 {
}
test "paint order is moving, then opening, then closing tombstones on top" {
- const Track = layout.Track;
+ const Track = animation.Track;
// Deliberately interleaved on the way in: the phases are what order the
// output, not the slot they happened to occupy.
const live = [_]?Track{
@@ -329,7 +333,7 @@ test "paint order is moving, then opening, then closing tombstones on top" {
.{ .serial = 16, .pane = 2, .phase = .closing, .effect = .vertical },
};
var out: [8]Track = undefined;
- const len = layout.paintOrder(&live, &closing, &out);
+ const len = animation.paintOrder(&live, &closing, &out);
var serials: [8]u32 = undefined;
for (out[0..len], 0..) |track, i| serials[i] = track.serial;
@@ -338,7 +342,7 @@ test "paint order is moving, then opening, then closing tombstones on top" {
// A host's array is fixed-size and the core's is not its business: writing
// past it would be a buffer overrun in whichever shell had the smaller one.
var tight: [2]Track = undefined;
- try std.testing.expectEqual(@as(usize, 2), layout.paintOrder(&live, &closing, &tight));
+ try std.testing.expectEqual(@as(usize, 2), animation.paintOrder(&live, &closing, &tight));
try std.testing.expectEqual(@as(u32, 12), tight[0].serial);
try std.testing.expectEqual(@as(u32, 15), tight[1].serial);
}
@@ -453,11 +457,14 @@ test {
_ = @import("regexp.zig");
_ = @import("edit.zig");
_ = @import("Messages.zig");
+ _ = @import("Registers.zig");
+ _ = @import("Macro.zig");
_ = @import("selection_pipe.zig");
_ = @import("Pipe.zig");
_ = @import("colors.zig");
_ = @import("surface.zig");
_ = @import("body_layer.zig");
+ _ = @import("draw.zig");
_ = @import("tag_layer.zig");
}
@@ -581,18 +588,10 @@ test "startup config runs builtin lines in order and isolates bad lines" {
};
}
-test "scene effects keep the idle frontend animation clock active" {
+test "a post chain keeps no core clock running: the shell animates it" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
- try std.testing.expect(!p.animationActive());
-
- p.settings.scene_effects.crt = true;
- try std.testing.expect(p.animationActive());
- p.settings.scene_effects = .{ .ripple = true };
- try std.testing.expect(p.animationActive());
- p.settings.scene_effects = .{ .glitch = true };
- try std.testing.expect(p.animationActive());
- p.settings.scene_effects = .{};
+ try std.testing.expect(p.settings.post.set(.crt, "", true));
try std.testing.expect(!p.animationActive());
}
@@ -784,7 +783,7 @@ test "TreeContext tag style preserves message rows and its opt-out survives rest
try std.testing.expectEqual(full_height, p.surface.body_layers[0].viewport.h);
const r = p.rects[0];
const band = for (p.surface.tagLayers()) |*layer| {
- if (layer.cols != 0 and layer.kind == .notice) break layer;
+ if (layer.rows != 0 and layer.kind == .notice) break layer;
} else return error.NoNoticeBand;
try std.testing.expectEqual(p.bodyTop(pane, r), band.viewport.y);
try std.testing.expectEqual(p.surface.body_layers[0].viewport.y, band.viewport.y);
@@ -2463,7 +2462,7 @@ test "a host with no methods at all is a complete in-process pardes" {
try std.testing.expectEqual(before, p.panes[0].?.terminal.?.vt.screens.active.cursor.y);
// the clipboard round-trips through the in-process one
- p.yank = try gpa.dupe(u8, "copied");
+ p.registers.put(gpa, '+', "copied", 0, 1, true);
p.perform(.set_clipboard);
try std.testing.expectEqualStrings("copied", p.fallback.clipboard.items);
@@ -2526,7 +2525,7 @@ test "Font keeps requested and acknowledged faces as separate plain state" {
try std.testing.expectEqualStrings(installed[0].path, request);
try std.testing.expect(p.settings.font.pending);
try std.testing.expect(p.takeFontRequest() == null);
- const fake_track: layout.Track = .{
+ const fake_track: animation.Track = .{
.serial = p.panes[p.active].?.serial,
.pane = @intCast(p.active),
.effect = .slide,
@@ -2632,7 +2631,7 @@ test "restored sessions animate layout changes after bootstrap" {
source.dump_out.?,
);
defer restored.deinit();
- try std.testing.expectEqual(layout.Transition.slide, restored.settings.panel_transition);
+ try std.testing.expectEqual(animation.Transition.slide, restored.settings.panel_transition);
try std.testing.expect(!restored.animationActive());
var frame: std.heap.ArenaAllocator = .init(std.testing.allocator);
@@ -3256,7 +3255,7 @@ test "s in a header selects in the header's own text" {
const text = try edit.flatSurface(p, &p.global_tag);
const got = p.global_tag.ranges(text, 0, &ranges);
try std.testing.expectEqual(@as(usize, 3), got.n); // Newcol, Joincol, NextColor
- try std.testing.expectEqual(@as(u8, 0), pane.body.nsel);
+ try std.testing.expectEqual(@as(u16, 0), pane.body.nsel);
}
test "Enter in a header runs the word under its cursor, as the execute key does" {
@@ -3597,8 +3596,8 @@ test "layout commits publish finite tracks only for changed panes" {
try std.testing.expect(p.presentation.tracks[1] == null);
try std.testing.expect(p.presentation.tracks[0] != null);
try std.testing.expect(p.presentation.tracks[2] != null);
- try std.testing.expectEqual(layout.Phase.moving, p.presentation.tracks[0].?.phase);
- try std.testing.expectEqual(layout.Phase.opening, p.presentation.tracks[2].?.phase);
+ try std.testing.expectEqual(animation.Phase.moving, p.presentation.tracks[0].?.phase);
+ try std.testing.expectEqual(animation.Phase.opening, p.presentation.tracks[2].?.phase);
try std.testing.expectEqual(untouched, p.rects[1]);
try std.testing.expect(p.animationActive());
@@ -3607,7 +3606,7 @@ test "layout commits publish finite tracks only for changed panes" {
const surface = try p.render(frame.allocator());
try std.testing.expectEqual(@as(usize, 2), surface.panelTracks().len);
- for (0..layout.Transition.slide.frames()) |_| p.update(.tick);
+ for (0..animation.Transition.slide.frames()) |_| p.update(.tick);
try std.testing.expect(!p.animationActive());
_ = frame.reset(.retain_capacity);
try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len);
@@ -3646,9 +3645,9 @@ test "vertical close samples only a canonical baseline containing that pane" {
defer p.deinit();
var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
defer frame.deinit();
+ p.settings.panel_transition = .vertical;
const boot = try p.render(frame.allocator());
p.acknowledgePanelPresentation(boot.panelTracks());
- p.settings.panel_transition = .vertical;
_ = try p.newShell(1, "");
try std.testing.expect(layout.splitColumn(p, 0, 1, false));
@@ -3669,14 +3668,14 @@ test "vertical close samples only a canonical baseline containing that pane" {
defer p.deinit();
var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
defer frame.deinit();
+ p.settings.panel_transition = .vertical;
const boot = try p.render(frame.allocator());
p.acknowledgePanelPresentation(boot.panelTracks());
- p.settings.panel_transition = .vertical;
_ = try p.newShell(1, "");
try std.testing.expect(layout.splitColumn(p, 0, 1, false));
p.sync();
- for (0..layout.Transition.vertical.frames()) |_| p.update(.tick);
+ for (0..animation.Transition.vertical.frames()) |_| p.update(.tick);
_ = frame.reset(.retain_capacity);
const canonical = try p.render(frame.allocator());
try std.testing.expectEqual(@as(usize, 0), canonical.panelTracks().len);
@@ -3695,9 +3694,9 @@ test "previous-grid animation refreshes its mask and snaps overlapping layout ch
defer p.deinit();
var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
defer frame.deinit();
+ p.settings.panel_transition = .vertical;
const boot = try p.render(frame.allocator());
p.acknowledgePanelPresentation(boot.panelTracks());
- p.settings.panel_transition = .vertical;
_ = try p.newShell(1, "");
try std.testing.expect(layout.splitColumn(p, 0, 1, false));
@@ -3765,7 +3764,7 @@ test "canonical fallback and transition toggle retire unpresented tracks" {
p.acknowledgePanelPresentation(moving.panelTracks());
try std.testing.expect(moving.panelTracks().len > 0);
exec.applySettingBuiltin(p, config.Runtime.find("PanelZoom").?, null);
- try std.testing.expectEqual(layout.Transition.zoom, p.settings.panel_transition);
+ try std.testing.expectEqual(animation.Transition.zoom, p.settings.panel_transition);
try std.testing.expect(p.presentation.pending);
try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null);
for (p.presentation.tracks) |track| try std.testing.expect(track == null);
@@ -3819,11 +3818,11 @@ test "back-to-back layout commits retarget from the last presented boxes" {
const target_c = p.presentation.tracks[0] orelse return error.MissingSecondRetarget;
try std.testing.expect(target_c.from.eql(shown_a));
try std.testing.expect(target_c.to.eql(layout.panelBox(p.rects[0])));
- try std.testing.expectEqual(layout.Phase.moving, target_c.phase);
+ try std.testing.expectEqual(animation.Phase.moving, target_c.phase);
_ = frame.reset(.retain_capacity);
const latest = try p.render(frame.allocator());
- var submitted: ?layout.Track = null;
+ var submitted: ?animation.Track = null;
for (latest.panelTracks()) |track| if (track.pane == 0) {
submitted = track;
break;
@@ -3846,13 +3845,13 @@ test "an unpresented opening pane remains in the opening paint phase" {
try std.testing.expect(layout.splitColumn(p, 0, 1, false));
p.sync();
const first = p.presentation.tracks[1] orelse return error.MissingOpeningTrack;
- try std.testing.expectEqual(layout.Phase.opening, first.phase);
+ try std.testing.expectEqual(animation.Phase.opening, first.phase);
_ = try p.newShell(2, "");
try std.testing.expect(layout.splitColumn(p, 1, 2, true));
p.sync();
const retargeted = p.presentation.tracks[1] orelse return error.MissingOpeningRetarget;
- try std.testing.expectEqual(layout.Phase.opening, retargeted.phase);
+ try std.testing.expectEqual(animation.Phase.opening, retargeted.phase);
try std.testing.expect(retargeted.from.eql(first.from));
}
@@ -3890,7 +3889,7 @@ test "core composes ASCII bytes while visual-only cells pass through" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
- const presented = try p.composeAsciiTransitions(arena.allocator(), &surface);
+ const presented = try draw.composeAsciiTransitions(p, arena.allocator(), &surface);
try std.testing.expect(presented != &surface);
try std.testing.expectEqualStrings("C", presented.at(0, 0).grapheme());
try std.testing.expectEqualStrings("x", presented.at(1, 0).grapheme());
@@ -3925,7 +3924,7 @@ test "core composes character motion out of the new grid, not a fade" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
- const presented = try p.composeAsciiTransitions(arena.allocator(), &surface);
+ const presented = try draw.composeAsciiTransitions(p, arena.allocator(), &surface);
try std.testing.expect(presented != &surface);
var seen: [8]u8 = undefined;
for (&seen, 0..) |*byte, col| byte.* = presented.at(@intCast(col), 0).text[0];
@@ -3933,8 +3932,8 @@ test "core composes character motion out of the new grid, not a fade" {
try std.testing.expectEqualStrings("a", surface.at(0, 0).grapheme());
// The last active sample is the untouched canonical grid, with no copy.
- surface.panel_tracks[0].frame = layout.Transition.edges.frames() - 1;
- try std.testing.expect(try p.composeAsciiTransitions(arena.allocator(), &surface) == &surface);
+ surface.panel_tracks[0].frame = animation.Transition.edges.frames() - 1;
+ try std.testing.expect(try draw.composeAsciiTransitions(p, arena.allocator(), &surface) == &surface);
}
test "repeated non-dyadic column splits preserve every unrelated boundary" {
@@ -4088,7 +4087,7 @@ pub const Pardes = struct {
rects: [MAX_PANES]Rect = @splat(.{}),
col_x: [MAX_COLS]u16 = undefined,
col_w: [MAX_COLS]u16 = undefined,
- presentation: layout.Presentation = .{},
+ presentation: Presentation = .{},
active: usize = 0,
jumps: [MAX_JUMPS]Loc = undefined,
@@ -4140,6 +4139,14 @@ pub const Pardes = struct {
multi_on: bool = false,
multi_first: bool = false,
multi_stop: bool = false,
+ /// While a key is replayed across the ranges: which range this pass is,
+ /// in document order, and how many there are. A register value belongs
+ /// to its range by this index.
+ multi_index: usize = 0,
+ multi_count: usize = 1,
+ /// The register the running normal command names (`"<reg>`); 0 for the
+ /// default one.
+ register: u21 = 0,
leader_on: bool = false,
leader_keys: [4]u8 = undefined,
leader_n: u8 = 0,
@@ -4192,6 +4199,13 @@ pub const Pardes = struct {
/// Set by a shell whose present was skipped (no swapchain image): the
/// frame is still owed, and the next pump draws it again.
present_skipped: bool = false,
+ /// Core time. `now_ns` is the shell's monotonic clock as `advance` was
+ /// last given it; `stepped_ns` is how far animation has been stepped
+ /// along it, in whole frames of `animation.frame_ns`. Idle, the two are
+ /// the same instant.
+ now_ns: u64 = 0,
+ stepped_ns: u64 = 0,
+ clock_started: bool = false,
/// Builtins announce themselves on the message row. Off while startup
/// configuration is applied: those lines are nobody clicking, and should
/// not leave the row holding the name of the last one.
@@ -4220,7 +4234,10 @@ pub const Pardes = struct {
in_head: usize = 0,
in_len: usize = 0,
- yank: ?[]u8 = null,
+ registers: Registers = .{},
+ macro: Macro = .{},
+ /// an edited text nothing kept (a tag's shown text): see edit.retireEditText
+ retired_edit: ?[]u8 = null,
clip_pending: ?edit.ClipRequest = null,
/// the last serialized dump (gpa-owned), read by the write_dump effect
dump_out: ?[]u8 = null,
@@ -4266,6 +4283,8 @@ pub const Pardes = struct {
.pdf_gpa = pdf_gpa,
.tree_sitter_gpa = tree_sitter_gpa,
.opts = opts,
+ .col_tags = @splat(.{ .what = .column, .gpa = gpa }),
+ .global_tag = .{ .what = .workspace, .gpa = gpa },
.fs = .{},
.screen_w = opts.cols,
.screen_h = opts.rows,
@@ -4391,7 +4410,9 @@ pub const Pardes = struct {
const gpa = p.gpa;
for (&p.col_tags) |*t| t.deinit(gpa);
p.global_tag.deinit(gpa);
- if (p.yank) |y| gpa.free(y);
+ p.registers.deinit(gpa);
+ p.macro.deinit(gpa);
+ if (p.retired_edit) |r| gpa.free(r);
if (p.dump_out) |d| gpa.free(d);
p.fallback.deinit();
if (p.last_dump) |d| gpa.free(d);
@@ -4513,6 +4534,7 @@ pub const Pardes = struct {
pane.body.deinit(p.gpa);
pane.tag.deinit(p.gpa);
pane.input.deinit(p.gpa);
+ pane.jump.deinit(p.gpa);
panes.Terminal.deinitEmulator(pane, p.gpa);
pane.clearCwd();
pane.fs.deinit(p.gpa);
@@ -4970,7 +4992,7 @@ pub const Pardes = struct {
}
},
.set_clipboard => {
- const text = p.yank orelse "";
+ const text = p.registers.text(p.gpa, '+') orelse "";
if (v.set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text);
},
// No desktop to ask: answer from the in-process clipboard at once,
@@ -5011,7 +5033,17 @@ pub const Pardes = struct {
pub fn pump(p: *Pardes, h: Host) !void {
p.host = h;
const v = h.vtable;
- if (v.wait_input) |f| f(h.ctx, if (p.animationActive()) layout.Animation.frame_ms else 0);
+ if (v.wait_input) |f| {
+ // Asleep until input or until core animation next needs a
+ // frame; 0 is no timeout at all.
+ const timeout: u32 = if (p.nextWake()) |due| wake: {
+ const now = if (v.now) |clock| clock(h.ctx) else p.now_ns;
+ const ms = ((due -| now) + std.time.ns_per_ms - 1) / std.time.ns_per_ms;
+ break :wake @intCast(std.math.clamp(ms, 1, std.math.maxInt(u32)));
+ } else 0;
+ f(h.ctx, timeout);
+ }
+ if (v.now) |clock| p.advance(clock(h.ctx));
while (p.nextQueued()) |ev| p.update(ev);
while (p.nextEffect()) |e| p.perform(e);
turn.settle();
@@ -5021,7 +5053,9 @@ pub const Pardes = struct {
// Nothing to draw: do not spend a frame, and above all do not block on
// the display for one. This is what keeps the frame rate governing
// rendering and nothing else.
- if (!p.needs_frame and !p.animationActive()) return;
+ // A shell with a clock draws what `advance` stepped; one without
+ // steps its own `.tick`s and draws while anything animates.
+ if (!p.needs_frame and (v.now != null or !p.animationActive())) return;
_ = p.frame_arena.reset(.retain_capacity);
const surface = try p.render(p.frame_arena.allocator());
if (v.present) |f| f(h.ctx, surface);
@@ -5261,7 +5295,21 @@ pub const Pardes = struct {
} else panes.Terminal.enterTty(p, id);
}
+ /// Type `keys` again (a macro, `.`): through the modal handling as
+ /// typed, except that no key leaves the text for pardes's own acts --
+ /// Esc does not hop panes, and Enter and Tab neither look nor execute.
+ pub fn replayKeys(p: *Pardes, keys: []const Macro.Stored) void {
+ if (p.macro.replaying >= 8) return; // a macro typing itself
+ p.macro.replaying += 1;
+ defer p.macro.replaying -= 1;
+ for (keys) |stored| {
+ var buf: [4]u8 = undefined;
+ p.handleKey(stored.typed(&buf));
+ }
+ }
+
fn handleKey(p: *Pardes, key: Key) void {
+ if (p.macro.recording != 0 and p.macro.replaying == 0) Macro.append(&p.macro.recorded, p.gpa, key);
// A question waiting on a pane takes the next key, whatever mode the
// pane or a header edit is in: that key is its answer.
for (p.panes, 0..) |slot, id| {
@@ -5316,7 +5364,7 @@ pub const Pardes = struct {
// in it the chord is the program's -- vim's visual block,
// readline's quoted-insert -- and swallowing it would make
// Ctrl-V a black hole in every full-screen application.
- if (p.yank) |text| return edit.typeToTty(p, p.active, pane, text);
+ if (p.registers.text(p.gpa, Registers.default)) |text| return edit.typeToTty(p, p.active, pane, text);
}
return panes.Terminal.forwardKey(p, p.active, key);
}
@@ -5393,7 +5441,7 @@ pub const Pardes = struct {
.search => if (submit)
look.submitSearch(p, p.active)
else if (normal.selRegexArmed(pane)) |_| if (target) |answered|
- normal.applySelRegex(p, pane, answered, "", false),
+ normal.applySelRegex(p, pane, answered, "", .select),
.none, .del_side, .repl_choice, .name => unreachable,
}
pane.closePrompt();
@@ -5404,10 +5452,11 @@ pub const Pardes = struct {
return;
}
edit.handleInsert(p, &pane.input, key);
- if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.split);
+ if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.mode);
return;
}
- if (t.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) {
+ if (t == &pane.body and pane.jump.items.len > 0) return normal.jumpKey(p, pane, t, key);
+ if (t.mode == .normal and p.macro.replaying == 0 and (hit(key, config.look_key) or hit(key, config.exec_key))) {
// A header's words are the workspace's and column's commands, so
// Enter runs them there, as the execute key does.
const header_word = t.what == .column or t.what == .workspace;
@@ -5454,19 +5503,45 @@ pub const Pardes = struct {
if (t.what == .tag and t.mode == .insert and tagline.typedIntoName(p, pane, key)) return;
// A tag's prefix is read-only: a key whose edit reached into it
// changes nothing, not even where the cursor is.
- const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel };
+ const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel };
+ var kept_sels: [Text.max_selections - 1]Text.SelRange = undefined;
+ const kept_n = t.nsel;
+ @memcpy(kept_sels[0..kept_n], t.sels[0..kept_n]);
t.refused = false;
defer if (t.refused) {
- t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept;
+ t.cur_row, t.cur_col, t.vsel, t.msel = kept;
+ t.restoreSels(kept_sels[0..kept_n]);
};
+ const was = t.mode;
switch (t.mode) {
.normal => normal.handleNormal(p, t, key),
- .insert => {
- if (hit(key, config.escape)) return edit.exitInsert(p, t);
- edit.handleInsert(p, t, key);
- },
+ .insert => if (hit(key, config.escape) and !t.reg_wait)
+ edit.exitInsert(p, t)
+ else
+ edit.handleInsert(p, t, key),
.tty => panes.Terminal.forwardKey(p, p.active, key),
}
+ // `.`'s log: the keys of the normal command that entered insert
+ // mode, then everything typed until it is left
+ if (p.macro.repeating) return;
+ const m = &p.macro;
+ switch (was) {
+ .normal => {
+ Macro.append(&m.command, p.gpa, key);
+ if (t.mode == .insert) {
+ m.last_insert.clearRetainingCapacity();
+ m.last_insert.appendSlice(p.gpa, m.command.items) catch {};
+ m.typed_from = m.last_insert.items.len;
+ m.inserting = true;
+ m.command.clearRetainingCapacity();
+ } else if (std.meta.eql(t.normal, modal.Normal.State{})) m.command.clearRetainingCapacity();
+ },
+ .insert => if (m.inserting) {
+ Macro.append(&m.last_insert, p.gpa, key);
+ if (t.mode != .insert) m.inserting = false;
+ },
+ .tty => {},
+ }
}
fn leaderKey(p: *Pardes, key: Key) void {
@@ -5801,7 +5876,7 @@ pub const Pardes = struct {
// ---- dumb undo/redo: whole-state snapshots, one per edit op ----
- pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const layout.Track) void {
+ pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const animation.Track) void {
p.presentation.acknowledge(p, tracks);
if (p.pointer_inside and p.drag != .none) {
if (p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row)) |mapped| {
@@ -5818,10 +5893,10 @@ pub const Pardes = struct {
look.cancelLookHover(p);
}
- /// A native scene pass proved unavailable. Config reads these exact bits,
- /// and the idle-clock predicate stops immediately once they are clear.
+ /// A native post pass proved unavailable: the chain is emptied, which
+ /// Config then reports.
pub fn disableSceneEffects(p: *Pardes) void {
- p.settings.scene_effects = .{};
+ p.settings.post.clear();
}
// ---- doc panes ----
@@ -6129,18 +6204,83 @@ pub const Pardes = struct {
return &p.chrome_animation.displayed;
}
- /// Whether an idle frontend should schedule another tick. All frame-based
- /// core work belongs in this one predicate so TTY and GUI mature it alike.
- pub fn animationActive(p: *const Pardes) bool {
- if (p.chrome_animation.isActive() or p.look_hover_wait != null) return true;
+ /// When core animation next needs a frame, on the `stepped_ns`
+ /// timeline: a frame from now while anything moves, the end of the
+ /// wait while something only waits (a message lingering, a look-hover
+ /// delay), null when nothing is animating. Every frame-based animation
+ /// the core owns is in this one list, which is what lets every shell
+ /// sleep exactly as long as nothing changes.
+ pub fn nextWake(p: *const Pardes) ?u64 {
+ const next = p.stepped_ns + animation.frame_ns;
+ if (p.chrome_animation.isActive() or p.presentation.animating()) return next;
+ var frames: ?u64 = null;
+ if (p.look_hover_wait) |waiting| {
+ const delay = config.look_preview_delay_frames orelse return next;
+ frames = @max(1, delay -| waiting.frames);
+ }
for (p.panes) |slot| if (slot) |pane| {
- if (Messages.messagesAnimating(pane)) return true;
+ const wait = Messages.messageWake(p, pane) orelse continue;
+ frames = if (frames) |soonest| @min(soonest, wait) else wait;
};
- const scene = p.settings.scene_effects;
- if (scene.crt or scene.ripple or scene.glitch) return true;
- return p.presentation.animating();
+ return if (frames) |n| p.stepped_ns + n * animation.frame_ns else null;
+ }
+
+ /// For shells that only need a yes or no: macOS and the board, which
+ /// keep their own frame banks.
+ pub fn animationActive(p: *const Pardes) bool {
+ return p.nextWake() != null;
+ }
+
+ /// Bring core animation up to `now_ns`, the shell's monotonic clock: one
+ /// `.tick` for every whole frame that has passed since it last stood
+ /// still, so an animation takes as long on a 144 Hz display, over ssh or
+ /// after a slow frame as it does anywhere. A wait (a message lingering,
+ /// the look-hover delay) is jumped to its end in one step and drawn
+ /// once. Idle, time is not banked: the next animation starts from now.
+ pub fn advance(p: *Pardes, now_ns: u64) void {
+ // The first time a shell's clock is seen, core time starts there.
+ const started = p.clock_started;
+ p.clock_started = true;
+ p.now_ns = now_ns;
+ if (!started) p.stepped_ns = now_ns;
+ const frame = animation.frame_ns;
+ var moving: u32 = 0;
+ while (p.nextWake()) |due| {
+ if (due > now_ns) break;
+ // Only waiting until `due`: its counters move there at once.
+ const held = (due - p.stepped_ns) / frame;
+ if (held > 1) {
+ Messages.holdMessages(p, held - 1);
+ if (p.look_hover_wait) |*waiting| waiting.frames +|= @intCast(@min(held - 1, std.math.maxInt(u16)));
+ p.stepped_ns += (held - 1) * frame;
+ }
+ // Back from a stall, the moving frames it missed are not all
+ // run at once: the rest are stepped from now, at their pace.
+ if (moving == max_catch_up_frames) {
+ p.stepped_ns = now_ns;
+ return;
+ }
+ moving += 1;
+ p.stepped_ns += frame;
+ p.update(.tick);
+ // A stepped frame is a frame to draw. (A `.tick` does not ask
+ // for one itself: the last frame of a fade used never to be
+ // drawn.)
+ p.needs_frame = true;
+ } else {
+ p.stepped_ns = now_ns;
+ return;
+ }
+ // A 60 Hz display frame is 16.67 ms and ours 16: without this, every
+ // 24th display frame would take two steps, a hitch in every fade.
+ // An overshoot this small is let go; a larger one (144 Hz lands 4.8
+ // ms past) is kept, so the average rate stays right.
+ if (moving > 0 and now_ns - p.stepped_ns < vsync_slack_ns) p.stepped_ns = now_ns;
}
+ pub const vsync_slack_ns = 1500 * std.time.ns_per_us;
+ pub const max_catch_up_frames = 240;
+
pub fn noteHaptic(p: *Pardes, pulse: Haptic) void {
if (comptime platform != .macos) return;
if (p.haptic == .look) return;
@@ -6157,15 +6297,14 @@ pub const Pardes = struct {
pub fn reprojectTagHit(p: *const Pardes, tag_hit: TagHit) ?TagHit {
for (p.surface.tagLayers()) |*layer| {
- if (layer.cols == 0 or layer.kind != tag_hit.kind or layer.id != tag_hit.id or layer.serial != tag_hit.serial) continue;
+ if (layer.rows == 0 or layer.kind != tag_hit.kind or layer.id != tag_hit.id or layer.serial != tag_hit.serial) continue;
if (layer.kind == .pane) {
if (layer.id >= p.panes.len) return null;
const pane = p.panes[layer.id] orelse return null;
if (pane.serial != layer.serial) return null;
}
if (layer.kind == .column and (layer.id >= p.ncol or p.col_serial[layer.id] != layer.serial)) return null;
- // A taller tag has a layer per line; the point is on one of them.
- return layer.hitAt(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)) orelse continue;
+ return layer.tagHit(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)) orelse continue;
}
return null;
}
@@ -6183,446 +6322,21 @@ pub const Pardes = struct {
}
pub fn tagCapacity(p: *const Pardes, physical_width: u16) u16 {
- const layer: TagLayer = .{ .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } };
+ const layer: Layer = .{ .kind = .pane, .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } };
return layer.capacity(p.row_metrics);
}
pub fn reprojectBodyHit(p: *const Pardes, body_hit: Mouse.BodyHit) ?Mouse.BodyHit {
for (&p.surface.body_layers) |*layer| {
if (layer.rows == 0 or layer.serial != body_hit.serial) continue;
- return layer.hitAt(body_hit.pixel_x, body_hit.pixel_y, @floatFromInt(body_hit.metrics.body_w), @floatFromInt(body_hit.metrics.body_h), @floatFromInt(body_hit.metrics.tagline_w), @floatFromInt(body_hit.metrics.tagline_h));
+ return layer.bodyHit(body_hit.pixel_x, body_hit.pixel_y, @floatFromInt(body_hit.metrics.body_w), @floatFromInt(body_hit.metrics.body_h), @floatFromInt(body_hit.metrics.tagline_w), @floatFromInt(body_hit.metrics.tagline_h));
}
return null;
}
- // ---- render: build the canonical surface ----
-
- pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
- panes.File.refreshHighlights(p);
- const s = &p.surface;
- const ncells = @as(usize, p.screen_w) * p.screen_h;
- if (s.cells.len != ncells) {
- const cells = try p.gpa.alloc(Cell, ncells);
- p.gpa.free(s.cells);
- s.cells = cells;
- }
- s.cols = p.screen_w;
- s.rows = p.screen_h;
- s.cursor = null;
- s.pointer_shape = .arrow;
- if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| {
- if (preview.probe.link != null and preview.pane < p.panes.len) {
- if (p.panes[preview.pane]) |pane| {
- if (pane.serial == preview.serial and panes.Pdf.paneNativeReady(p, pane))
- s.pointer_shape = .link;
- }
- }
- };
- // A look-hover preview means the pointed word is a real Look or Exec
- // operand: the same plumbing a click would act on. Hosts that model
- // the pointer per-text (the macOS I-beam) read this as "target" and
- // afford it the arrow instead.
- if (p.look_hover_preview != null) s.pointer_shape = .target;
- // Stale hover bits must not survive into a frame whose pointer has
- // moved on: the ordinary content paint does not clear fields it
- // never set.
- for (s.cells) |*c| c.hover = false;
- for (&s.body_layers) |*layer| layer.rows = 0;
- for (&s.tag_layers) |*layer| layer.cols = 0;
- s.nimages = 0;
- s.npanel_tracks = 0;
- s.previous_cells = &.{};
- s.previous_body_layers = &.{};
- s.previous_tag_layers = &.{};
- s.cell_diffs = &.{};
- const chrome = p.chromeTheme();
- const th = p.theme();
- // Separators have their own role; a scrollbar need not frame the page.
- s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.border } });
-
- for (&p.panes, 0..) |*slot, id| {
- const pane = slot.* orelse continue;
- pane.body_rows = 0;
- pane.body_visible_rows = 0;
- pane.context_row_limit = null;
- s.body_layers[id].rows = 0;
- Messages.collectNotices(p, pane, p.rects[id], id);
- try p.renderPane(arena, pane, p.rects[id], id, id == p.active);
- try body_layer.renderBodyLayer(p, arena, pane, p.rects[id], id);
- }
- for (&p.panes, 0..) |*slot, id| {
- const pane = slot.* orelse continue;
- if (pane.notices.len == 0) continue;
- const r = p.rects[id];
- const tx = r.x + config.GUTTER;
- const tw = r.w - config.GUTTER;
- // Painted the way the sticky context headers above them are: the
- // tagline font and the tagline's own colours, so a notice reads as
- // a band belonging to the pane rather than as a line of body text
- // that happens to sit at the bottom of it.
- const msg_style: CellStyle = .{
- .fg = .{ .rgb = chrome.tag_fg },
- .bg = .{ .rgb = chrome.tag_bg },
- .font_role = .tagline,
- };
- // An OVERLAY over the body's top rows, at the right edge, one
- // chip to a row in the order collectNotices chose -- not a row
- // taken out of the body the way a tree-sitter context header is.
- // The chip is as wide as its own message and no wider, so the text
- // beneath the rest of the row keeps reading as the file it is.
- const first = p.bodyTop(pane, r);
- var leader_buf: [16]u8 = undefined;
- for (pane.notices.slice(), 0..) |kind, i| {
- const row = first + @as(u16, pane.notices.row[i]);
- // Every notice is painted on the canonical grid as well as
- // emitted as a tag layer: the grid is what a terminal client
- // draws, and a band it cannot see is a message it never gets.
- // The GUI skips grid cells a tag layer covers, so nothing is
- // drawn twice there.
- const text = Messages.noticeText(p, pane, i, &leader_buf);
- const chip = Messages.noticeCols(p, text, tw);
- if (chip == 0) continue;
- const cx = tx + tw - chip;
- pane.notices.left[i] = cx;
- // A message coming or going blends toward the page it sits
- // on; a grid cannot move it by less than a row.
- var style = msg_style;
- // Where an exec was sent to a REPL reads in the tag's name
- // tint, set apart from what the editor says.
- const ink = if (kind == .message and pane.msg_sent) chrome.tag_name_fg else chrome.tag_fg;
- if (kind == .message and pane.msg_sent) style.fg = .{ .rgb = ink };
- if (Messages.noticeLife(pane, i)) |life| {
- // No page colour to fade to (a terminal's own default):
- // the text dissolves into its chip instead.
- const alpha = Messages.messageMotion(p, life).alpha;
- const page = th.bg orelse chrome.tag_bg;
- style.fg = .{ .rgb = Messages.blendRgb(ink, page, alpha) };
- style.bg = .{ .rgb = Messages.blendRgb(chrome.tag_bg, page, alpha) };
- }
- s.fill(cx, row, chip, 1, .{ .bg = style.bg, .font_role = .tagline });
- // One column short of the chip's right edge: a prompt's caret
- // sits AFTER its last character the moment it opens, and flush
- // right would put it past the pane, off the grid, and past what
- // the detached wire will encode -- which drops every frame for
- // as long as the prompt is up.
- const printed = Messages.printRight(s, cx, row, chip -| 1, text, style);
- if (kind != .prompt or id != p.active or pane.prompt == .del_side or pane.prompt == .repl_choice) continue;
- // The cursor follows the text to wherever it landed; a caret
- // in the part a narrow band dropped has nowhere to be.
- const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
- if (prompt_col < printed.dropped) continue;
- const caret = printed.left + (prompt_col - printed.dropped);
- if (caret < tx + tw)
- s.cursor = .{ .x = @intCast(caret), .y = row, .bar = pane.input.mode == .insert };
- }
- }
+ // ---- render: build the canonical surface (draw.zig) ----
- try body_layer.paintHeaders(p);
- try body_layer.renderTagLayers(p, arena);
-
- switch (p.drag) {
- .border_v => |d| {
- if (d.cur_x < s.cols) {
- var row: u16 = p.topBarHeight();
- while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎");
- }
- if (d.corner) |k| if (d.cur_y < s.rows) {
- var col = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1;
- const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1;
- while (col <= end and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
- };
- },
- .border_h => |d| if (d.cur_y < s.rows) {
- var col = p.col_x[d.col];
- while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
- },
- .column_move => |d| rail: {
- const source = layout.columnBySerial(p, d.serial) orelse break :rail;
- // Where the column's left edge would land, reordered or not.
- const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) {
- .stay => break :rail,
- .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target],
- .edge => |x| x,
- };
- // Leave the outside insertion rail visible above compact
- // text layers. This clips presentation only, never reflows.
- if (edge == s.cols) {
- for (&s.body_layers) |*layer| if (layer.rows > 0 and layer.viewport.x + layer.viewport.w == s.cols) {
- layer.viewport.w -|= 1;
- };
- for (&s.tag_layers) |*layer| if (layer.cols > 0 and layer.viewport.x + layer.viewport.w == s.cols and layer.viewport.y >= p.topBarHeight()) {
- layer.viewport.w -|= 1;
- };
- }
- // The rail runs in the seam cell left of the edge, where a
- // border drag's does: the edge cell itself is a gutter, which
- // the GUI paints as a scroll rail that swallowed the dashes.
- // At the window's left edge there is no seam, so it runs just
- // past that scroll rail instead. It takes the lit grip's
- // color: this is where the grabbed column lands.
- const x = @min(if (edge == 0) config.GUTTER - 1 else edge - 1, s.cols -| 1);
- var row: u16 = p.topBarHeight();
- while (row < s.rows) : (row += 1) {
- // Past the left edge's scroll rail the rail would cut
- // through column 0's grip and pane boxes: leave them whole.
- if (edge == 0 and s.at(x, row).style.font_role == .tagline) continue;
- s.overlayDash(x, row, "╎");
- s.at(x, row).style.fg = .{ .rgb = chrome.column_box };
- }
- },
- .move => |d| if (d.cur_x < s.cols) {
- if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| {
- var col: u16 = p.col_x[placement.preview_col];
- while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) {
- s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } });
- }
- s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } });
- }
- },
- .none, .select => if (p.pointer_inside) {
- // hover hints on a pane's own trailing edge
- var c: usize = 0;
- while (c + 1 < p.ncol) : (c += 1) {
- if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) {
- var row: u16 = p.topBarHeight();
- while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎");
- }
- }
- for (0..p.ncol) |cc| {
- if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue;
- // the same seam the h-handle hit test picks (see there)
- if (mouse.seamIdxAt(p, cc, p.hover_row) != null) {
- var col = p.col_x[cc];
- while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
- }
- }
- for (0..p.ncol -| 1) |cn| {
- if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue;
- if (mouse.seamIdxAt(p, cn, p.hover_row) != null) continue;
- if (mouse.seamIdxAt(p, cn + 1, p.hover_row) == null) continue;
- var col = p.col_x[cn + 1];
- while (col < p.col_x[cn + 1] + p.col_w[cn + 1]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
- }
- },
- }
-
- // debug overlay: a small stats box drawn last, top-right ("Debug")
- if (p.settings.debug) if (p.panes[p.active]) |at| {
- var nlive: usize = 0;
- for (p.panes) |slot| {
- if (slot != null) nlive += 1;
- }
- var sb_off: usize = undefined;
- var sb_total: usize = undefined;
- if (at.file) |*f| {
- sb_off = f.scroll;
- sb_total = panes.File.nlines(p.gpa, f);
- } else if (at.pdfPage()) |page| {
- sb_off = page;
- sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0;
- } else {
- const sb = panes.Terminal.scrollbar(at);
- sb_off = sb.offset;
- sb_total = sb.total;
- }
- const effective_font = p.settings.font.effective_name.get();
- const font_name = if (effective_font.len == 0) "default" else effective_font;
- var ov_buf: [256]u8 = undefined;
- const text = std.fmt.bufPrint(
- &ov_buf,
- "pardes resizes={d}\ntheme {s}\nfont {s}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}",
- .{ p.resize_count, p.theme().name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta },
- ) catch "";
- // wide enough for the longest theme name compiled in, which is the
- // only row here whose width is not ours to choose
- const ow: u16 = 38;
- const oh: u16 = 10;
- const ox: u16 = if (s.cols > ow) s.cols - ow else 0;
- if (ox + ow <= s.cols and p.topBarHeight() + oh <= s.rows) {
- // vaxis single_rounded border ring, default style
- s.set(ox, p.topBarHeight(), "╭", .{});
- s.set(ox + ow - 1, p.topBarHeight(), "╮", .{});
- s.set(ox, p.topBarHeight() + oh - 1, "╰", .{});
- s.set(ox + ow - 1, p.topBarHeight() + oh - 1, "╯", .{});
- var bx = ox + 1;
- while (bx < ox + ow - 1) : (bx += 1) {
- s.set(bx, p.topBarHeight(), "─", .{});
- s.set(bx, p.topBarHeight() + oh - 1, "─", .{});
- }
- var by = p.topBarHeight() + 1;
- while (by < p.topBarHeight() + oh - 1) : (by += 1) {
- s.set(ox, by, "│", .{});
- s.set(ox + ow - 1, by, "│", .{});
- }
- s.fill(ox + 1, p.topBarHeight() + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = chrome.scroll_track } });
- var lines = std.mem.splitScalar(u8, text, '\n');
- var ly: u16 = p.topBarHeight() + 1;
- while (lines.next()) |line| : (ly += 1) {
- if (ly >= p.topBarHeight() + oh - 1) break;
- _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = chrome.tag_fg } });
- }
- }
- };
- try p.presentation.submit(p, s);
- return p.composeAsciiTransitions(arena, s);
- }
-
- fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface {
- _ = p;
- if (!canonical.hasPanelDiff()) return canonical;
- var presented: ?*Surface = null;
- for (canonical.panelTracks()) |track| {
- if (!track.effect.composedByCore() or track.phase == .closing) continue;
- const area = layout.CellArea.of(track.to);
- const col_end = @min(canonical.cols, area.x0 +| area.cols);
- const row_end = @min(canonical.rows, area.y0 +| area.rows);
- var row: u16 = area.y0;
- while (row < row_end) : (row += 1) {
- var col: u16 = area.x0;
- while (col < col_end) : (col += 1) {
- const index = @as(usize, row) * canonical.cols + col;
- const composed = composedCell(canonical, track, area, col, row, index) orelse
- continue;
- if (composed.visuallyEqual(&canonical.cells[index])) continue;
- if (presented == null) {
- const next = try arena.create(Surface);
- next.* = canonical.*;
- next.cells = try arena.dupe(Cell, canonical.cells);
- presented = next;
- }
- presented.?.cells[index] = composed;
- }
- }
- }
- return presented orelse canonical;
- }
-
- fn composedCell(
- canonical: *const Surface,
- track: layout.Track,
- area: layout.CellArea,
- col: u16,
- row: u16,
- index: usize,
- ) ?Cell {
- if (track.effect == .ascii) {
- const diff = switch (canonical.cell_diffs[index]) {
- .ascii => |diff| diff,
- .unchanged, .visual => return null,
- };
- const byte = diff.byteAt(track.frame);
- if (byte == diff.to) return null;
- if (track.frame == 0) return canonical.previous_cells[index];
- return withByte(canonical.cells[index], byte);
- }
- return switch (layout.charSource(track, col - area.x0, row - area.y0, area)) {
- .old => canonical.previous_cells[index],
- .byte => |byte| withByte(canonical.cells[index], byte),
- // Churn belongs on the glyph, not on the pane's empty space, and it
- // borrows whichever side of the diff actually has one.
- .churn => |byte| blk: {
- const new_cell = &canonical.cells[index];
- if (hasGlyph(new_cell)) break :blk withByte(new_cell.*, byte);
- const old_cell = &canonical.previous_cells[index];
- if (hasGlyph(old_cell)) break :blk withByte(old_cell.*, byte);
- break :blk old_cell.*;
- },
- .at => |offset| blk: {
- const source_col = @as(i32, col) + offset.cols;
- const source_row = @as(i32, row) + offset.rows;
- // A glyph whose source lies outside the pane has not arrived
- // yet: the frozen old cell is the only truthful thing under it.
- if (source_col < area.x0 or source_row < area.y0 or
- source_col >= @as(i32, area.x0) + area.cols or
- source_row >= @as(i32, area.y0) + area.rows or
- source_col >= canonical.cols or source_row >= canonical.rows)
- break :blk canonical.previous_cells[index];
- const source = @as(usize, @intCast(source_row)) * canonical.cols +
- @as(usize, @intCast(source_col));
- break :blk canonical.cells[source];
- },
- };
- }
-
- fn withByte(cell: Cell, byte: u8) Cell {
- var out = cell;
- out.text[0] = byte;
- out.len = 1;
- out.default = false;
- return out;
- }
-
- /// Whether a cell shows a glyph a character effect can work on. An unpainted
- /// cell and a space are both blank space, whatever style they carry.
- fn hasGlyph(cell: *const Cell) bool {
- return !cell.default and !(cell.len == 1 and cell.text[0] == ' ');
- }
-
- fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void {
- const tz = tracy.zone(@src(), "renderPane");
- defer tz.end();
- if (r.w <= config.GUTTER or r.h == 0) return;
- const s = &p.surface;
- const th = p.theme();
- const chrome = p.chromeTheme();
- const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter
- const tw = r.w - config.GUTTER;
- const tag_y = p.tagTop(pane, r);
- const body_y = p.bodyTop(pane, r);
- const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default;
-
- // text area resets to terminal-default cells (vaxis clear semantics);
- // light themes paint the page over it.
- {
- // Two full passes over every cell in the pane, every frame.
- const tz_clear = tracy.zone(@src(), "paneClear");
- defer tz_clear.end();
- s.clearRect(tx, r.y, tw, r.h);
- if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } });
- }
-
- const box_bg = if (active) chrome.box else chrome.box_dim;
- const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10;
- const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff };
- const box_style: CellStyle = .{
- .fg = .{ .rgb = box_ink },
- .bg = .{ .rgb = box_bg },
- // Grips keep physical grid dimensions, independently of tag text.
- .font_role = .tagline,
- };
- s.fill(r.x, tag_y, config.GUTTER, pane.tag_rows, box_style);
- s.set(r.x, tag_y, switch (pane.body.mode) {
- .normal => config.tag_normal,
- .insert => config.tag_insert,
- .tty => config.tag_tty,
- }, box_style);
-
- try body_layer.paintPaneTag(p, arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET));
-
- // Folded panes retain their body state, but neither body pixels nor
- // its cursor belong outside the one-row tag rectangle.
- if (pane.collapsed) return;
-
- if (comptime pdf_enabled)
- if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return;
-
- if (pane.image) |*iv| {
- // Below the notice chips: a picture is drawn after the cells (the
- // GUI's image pass, kitty's z-order), so a chip over it would be
- // painted out. Text gets the overlay; a picture gives up the rows.
- const shown = pane.notices.span();
- const image_h = (r.h -| pane.tag_rows) -| shown;
- panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h);
- // thumbless, but the same one column as the real scrollbar below —
- // that is the whole point of drawing it, and like that one it runs
- // past the notice bands so the gutter has no notch in it
- s.fill(r.x, body_y, 1, r.h -| pane.tag_rows, .{ .bg = .{ .rgb = chrome.scroll_track } });
- s.fill(r.x + 1, body_y, 1, r.h -| pane.tag_rows, .{ .bg = pane_bg });
- return;
- }
-
- try body_layer.renderBody(p, arena, pane, r, id, active, true);
- }
+ pub const render = draw.render;
pub fn appendImagePlace(p: *Pardes, place: ImagePlace) bool {
const s = &p.surface;
@@ -6861,12 +6575,12 @@ test "a prompt chip is a band with its caret, still on the grid, and only the ch
// A pixel shell draws the prompt from its band, in the tagline's pitch,
// with the caret right after the marker's last glyph.
const band = for (p.surface.tagLayers()) |*layer| {
- if (layer.cols != 0 and layer.kind == .notice) break layer;
+ if (layer.rows != 0 and layer.kind == .notice) break layer;
} else return error.NoPromptBand;
try std.testing.expectEqual(row, band.viewport.y);
const caret = band.cursor orelse return error.MissingBandCaret;
try std.testing.expect(caret.bar);
- try std.testing.expectEqualStrings("/", band.cells[caret.col - 1].grapheme());
+ try std.testing.expectEqualStrings("/", band.cells[caret.x - 1].grapheme());
// The chip covers only its own columns. Left of it is ordinary body, and
// a sweep there still selects.
@@ -6912,11 +6626,11 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro
try std.testing.expect(p.surface.tag_layers[0].cursor == null);
// The band carries the answer and the caret, one cell back from its end.
const band = for (p.surface.tagLayers()) |*layer| {
- if (layer.cols != 0 and layer.kind == .notice) break layer;
+ if (layer.rows != 0 and layer.kind == .notice) break layer;
} else return error.NoPromptBand;
const caret = band.cursor orelse return error.MissingBandCaret;
- try std.testing.expectEqualStrings("t", band.cells[caret.col - 1].grapheme());
- try std.testing.expectEqualStrings("w", band.cells[caret.col].grapheme());
+ try std.testing.expectEqualStrings("t", band.cells[caret.x - 1].grapheme());
+ try std.testing.expectEqualStrings("w", band.cells[caret.x].grapheme());
const grid = p.surface.cursor orelse return error.MissingPromptCursor;
try std.testing.expectEqual(p.bodyTop(pane, p.rects[0]), grid.y);
@@ -7642,9 +7356,9 @@ test "board heap: transition grids fit the budget and the surface borrows its sn
defer p.deinit();
var frame: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer frame.deinit();
+ p.settings.panel_transition = .vertical;
const boot = try p.render(frame.allocator());
p.acknowledgePanelPresentation(boot.panelTracks());
- p.settings.panel_transition = .vertical;
_ = try p.newShell(1, "");
try std.testing.expect(layout.splitColumn(p, 0, 1, false));
p.sync();
@@ -7719,7 +7433,7 @@ test "jump history clears selections and lands at the recorded cursor" {
try std.testing.expectEqual(@as(usize, 2), p.njumps);
try std.testing.expectEqual(@as(u32, 3), p.jumps[0].line);
try std.testing.expect(!pane.body.vsel.active and !pane.body.msel.active and !pane.body.select);
- try std.testing.expectEqual(@as(u8, 0), pane.body.nsel);
+ try std.testing.expectEqual(@as(u16, 0), pane.body.nsel);
try std.testing.expectEqual(.none, pane.sel[0].state);
try std.testing.expect(pane.pointer_selections[0] == null);
try std.testing.expectEqual(@as(i32, if (index == 0) 2 else 0), pane.body.cur_row);
@@ -7755,3 +7469,42 @@ test "jump history terminal rectangle follows output and clears on reflow" {
try std.testing.expect(pane.pointerSelection(pane.sel[0]) == null);
try std.testing.expectEqual(.none, pane.sel[0].state);
}
+
+test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ const frame = animation.frame_ns;
+ // Something that animates for as long as the test wants: the theme
+ // fade, held at its start before every step.
+ for ([_]u64{ 60, 120, 144 }) |hz| {
+ const start: u64 = 5 * std.time.ns_per_s;
+ p.clock_started = false;
+ p.advance(start);
+ var steps: u64 = 0;
+ var at: u64 = 1;
+ while (at <= hz) : (at += 1) {
+ const before = p.stepped_ns;
+ p.chrome_animation.step = 0;
+ p.advance(start + at * std.time.ns_per_s / hz);
+ const taken = (p.stepped_ns - before + frame / 2) / frame;
+ // Never two steps in one display frame: that is a visible hitch.
+ try std.testing.expect(taken <= 1);
+ steps += taken;
+ }
+ switch (hz) {
+ // One step a display frame, every display frame.
+ 60 => try std.testing.expectEqual(@as(u64, 60), steps),
+ 120 => try std.testing.expectEqual(@as(u64, 60), steps),
+ // The frame's own rate: 62 a second, give or take one.
+ else => try std.testing.expect(steps >= 61 and steps <= 63),
+ }
+ }
+ // A stall of minutes is not minutes of frames.
+ const before = p.stepped_ns;
+ p.chrome_animation.step = 0;
+ p.advance(before + 600 * std.time.ns_per_s);
+ try std.testing.expectEqual(before + 600 * std.time.ns_per_s, p.stepped_ns);
+ p.chrome_animation.step = animation.transition_steps;
+ p.advance(p.stepped_ns + frame);
+ try std.testing.expectEqual(@as(?u64, null), p.nextWake());
+}
diff --git a/src/pdf_view.zig b/src/pdf_view.zig
index b2fd5866..13c02231 100644
--- a/src/pdf_view.zig
+++ b/src/pdf_view.zig
@@ -2542,11 +2542,13 @@ pub fn horizontalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8)
pub fn draw(
core: *pardes.Pardes,
+ s: *pardes.Surface,
pane: *pardes.Pane,
rect: pardes.Rect,
pane_id: usize,
text_x: u16,
text_width: u16,
+ rail: *pardes.Region,
) bool {
if (comptime !enabled) return false;
if (!core.native_images or rect.h <= pane.tag_rows) return false;
@@ -2616,16 +2618,12 @@ pub fn draw(
// This frame has spent the motion used to choose its raster band.
state.scroll_travel = 0;
- const body_y = core.bodyTop(pane, rect);
- const body_h = rect.h -| pane.tag_rows;
const chrome = core.chromeTheme();
const theme = core.theme();
const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default;
- // The rail runs past the notice bands; only the thumb tracks the text.
- core.surface.fill(rect.x, body_y, 1, body_h, .{ .bg = .{ .rgb = chrome.scroll_track } });
- core.surface.fill(rect.x + 1, body_y, 1, body_h, .{ .bg = pane_bg });
-
- const track_h: usize = body_h;
+ // The rail runs past the notice bands; only the thumb tracks the text,
+ // and it is measured here, in document pixels, for `place`'s region.
+ const track_h: usize = rail.rect.h;
const total = @max(@as(u64, 1), state.document_height);
const len = @max(
@as(usize, 1),
@@ -2639,15 +2637,11 @@ pub fn draw(
@as(u64, track_h -| 1),
@as(u64, track_h) * offset / total,
));
- var y = pos;
- while (y < track_h and y < pos + len) : (y += 1)
- core.surface.fill(
- rect.x,
- body_y + @as(u16, @intCast(y)),
- 1,
- 1,
- .{ .bg = .{ .rgb = chrome.scroll_thumb } },
- );
+ rail.thumb_y = @intCast(pos);
+ rail.thumb_h = @intCast(@min(len, track_h -| pos));
+ s.fill(rail.rect.x, rail.rect.y, 1, rail.rect.h, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ s.fill(rail.rect.x + 1, rail.rect.y, 1, rail.rect.h, .{ .bg = pane_bg });
+ s.fill(rail.rect.x, rail.rect.y + rail.thumb_y, 1, rail.thumb_h, .{ .bg = .{ .rgb = chrome.scroll_thumb } });
return true;
}
diff --git a/src/surface.zig b/src/surface.zig
index a9e69cca..b6f41853 100644
--- a/src/surface.zig
+++ b/src/surface.zig
@@ -3,6 +3,7 @@
const pardes = @import("pardes.zig");
const std = @import("std");
const layout = @import("layout.zig");
+const animation = @import("animation.zig");
const uucode = @import("uucode");
const vaxis = @import("vaxis");
const panes = @import("panes.zig");
@@ -10,8 +11,7 @@ const config = @import("config.zig");
const pdf_enabled = panes.Pdf.enabled;
const image = @import("image.zig");
const MAX_PANES = pardes.MAX_PANES;
-const BodyLayer = @import("body_layer.zig").Layer;
-const TagLayer = @import("tag_layer.zig").Layer;
+const Layer = @import("Layer.zig");
const MAX_TAG_LAYERS = pardes.MAX_TAG_LAYERS;
const CellPixels = pardes.CellPixels;
@@ -38,12 +38,6 @@ pub const Cell = struct {
len: u8 = 1,
style: CellStyle = .{},
default: bool = true,
- /// The look-hover affordance is on this cell: the word under the pointer
- /// is a real Look or Exec operand, and hosts that draw the quiet tint as
- /// more than a flat fill — macOS renders it as a glass overlay — read
- /// this instead of guessing from the background colour, which a real
- /// selection shares.
- hover: bool = false,
pub fn grapheme(c: *const Cell) []const u8 {
return c.text[0..c.len];
@@ -70,7 +64,7 @@ pub const AsciiDiff = struct {
/// Long printable-byte walks complete in about the same time as the other
/// panel effects. Extra distance is crossed by eased character skips.
- pub const max_movement_frames = layout.ascii_max_movement_frames;
+ pub const max_movement_frames = animation.ascii_max_movement_frames;
pub fn between(old: *const Cell, new: *const Cell) ?AsciiDiff {
const from = old.printableAscii() orelse return null;
@@ -323,40 +317,133 @@ test "pixel attachment fit and pan do not invalidate cached pixels" {
try std.testing.expect(key.eql(place.cacheKey()));
}
+/// One part of the frame and where it is, placed once by `draw.place`
+/// before anything is painted: every painter and every shell reads the
+/// geometry here instead of working it out again. `rect` is in grid cells
+/// and is the hit-test geometry.
+pub const Region = struct {
+ pub const Kind = enum(u8) {
+ /// The frame-wide border fill every other part is painted over.
+ page,
+ /// A pane's grip: the mode mark at the head of its tag rows.
+ grip,
+ /// A pane's tag rows, right of its grip.
+ tag,
+ /// A pane's body, right of its rail: absent while it is collapsed.
+ body,
+ /// The scroll rail down a pane's left edge, beside its body.
+ rail,
+ /// A transient band over the body's top rows (a message, a prompt).
+ notice,
+ column_grip,
+ column_tag,
+ workspace_tag,
+ /// A column below the workspace tag, its bar and panes: its left
+ /// edge is the spine a pixel shell rules down it.
+ column,
+ /// A drag's guide or a seam's hover hint, drawn over the panes.
+ guide,
+ /// The debug stats box, drawn over the panes.
+ debug,
+ };
+ pub const none = std.math.maxInt(u8);
+
+ kind: Kind,
+ /// The pane slot, or the column, the part belongs to; `none` for the
+ /// page and the workspace tag.
+ owner: u8 = none,
+ serial: u32 = 0,
+ rect: layout.Rect,
+ /// The pane or column has the keyboard's focus.
+ active: bool = false,
+ /// A rail's thumb: rows from the rail's top, and how many. Zero rows is
+ /// a rail with no thumb (an image). A native PDF's thumb is measured in
+ /// document pixels and filled in by Pdf.draw while it paints, so read
+ /// regions once render has returned.
+ thumb_y: u16 = 0,
+ thumb_h: u16 = 0,
+ /// A notice's index in its pane's `notices`.
+ notice: u8 = 0,
+ /// Cells the column-move rail takes off the right edge while it runs
+ /// there: layers and pixel shells stop short of them, the grid does not.
+ clip: u16 = 0,
+};
+
+/// Guides a frame can draw: a run of a column-move rail per pane it skips
+/// the tags of, and a handful of border and hover dashes.
+pub const MAX_GUIDES = MAX_PANES + 4;
+pub const MAX_REGIONS = 1 + MAX_PANES * (4 + panes.Pane.Notices.max) + pardes.MAX_COLS * 3 + 1 + MAX_GUIDES + 1;
+
+/// The colours a pixel shell draws its chrome in (rules, rails, grips, the
+/// tag underlay), resolved by the core each frame, so a frontend attached
+/// to a detached session draws them from the frame alone.
+pub const Chrome = struct {
+ /// The theme's ground and ink; null where it leaves them to the shell.
+ page: ?[3]u8 = null,
+ fg: ?[3]u8 = null,
+ /// Rules and column spines.
+ border: [3]u8 = .{ 0, 0, 0 },
+ /// The rule under the workspace tag.
+ topbar_rule: [3]u8 = .{ 0, 0, 0 },
+ tag_bg: [3]u8 = .{ 0, 0, 0 },
+ /// The focused pane's and column's tag underlay: `tag_bg` unless
+ /// FocusTint is on.
+ tag_focus_bg: [3]u8 = .{ 0, 0, 0 },
+ tag_fg: [3]u8 = .{ 0, 0, 0 },
+ scroll_track: [3]u8 = .{ 0, 0, 0 },
+ scroll_thumb: [3]u8 = .{ 0, 0, 0 },
+};
+
/// The canonical interface: what a frame of pardes IS. The tty shell writes
/// these cells to vaxis one-to-one; the SDL shells rasterize them.
pub const Surface = struct {
pub const PointerShape = enum(u8) { arrow, link, target };
pointer_shape: PointerShape = .arrow,
- /// Set only for the duration of the look-hover paint, so the cells that
- /// paint the quiet affordance can carry the hover bit through the ABI
- /// without every selection painter learning a new parameter.
- mark_hover: bool = false,
- body_layers: [MAX_PANES]BodyLayer = @splat(.{}),
- tag_layers: [MAX_TAG_LAYERS]TagLayer = @splat(.{}),
+ body_layers: [MAX_PANES]Layer = @splat(.{}),
+ tag_layers: [MAX_TAG_LAYERS]Layer = @splat(.{}),
cols: u16 = 0,
rows: u16 = 0,
+ regions: [MAX_REGIONS]Region = undefined,
+ nregions: u16 = 0,
+ chrome: Chrome = .{},
cells: []Cell = &.{},
/// bar: draw an insert-style thin cursor instead of the block
cursor: ?struct { x: u16, y: u16, bar: bool = false } = null,
images: []?ImagePlace = &.{},
nimages: usize = 0,
- panel_tracks: [MAX_PANES * 2]layout.Track = undefined,
+ panel_tracks: [MAX_PANES * 2]animation.Track = undefined,
npanel_tracks: usize = 0,
previous_cells: []const Cell = &.{},
- previous_body_layers: []const BodyLayer = &.{},
- previous_tag_layers: []const TagLayer = &.{},
+ previous_body_layers: []const Layer = &.{},
+ previous_tag_layers: []const Layer = &.{},
+ previous_regions: []const Region = &.{},
cell_diffs: []const PanelCellDiff = &.{},
- pub fn tagLayers(s: *const Surface) []const TagLayer {
+ pub fn tagLayers(s: *const Surface) []const Layer {
return &s.tag_layers;
}
- pub fn bodyLayers(s: *const Surface) []const BodyLayer {
+ pub fn bodyLayers(s: *const Surface) []const Layer {
return &s.body_layers;
}
- pub fn panelTracks(s: *const Surface) []const layout.Track {
+ pub fn regionList(s: *const Surface) []const Region {
+ return s.regions[0..s.nregions];
+ }
+
+ pub fn addRegion(s: *Surface, r: Region) void {
+ std.debug.assert(s.nregions < s.regions.len);
+ s.regions[s.nregions] = r;
+ s.nregions += 1;
+ }
+
+ /// The first part of `kind` that `owner` has: its tag, its rail.
+ pub fn region(s: *Surface, kind: Region.Kind, owner: usize) ?*Region {
+ for (s.regions[0..s.nregions]) |*r| if (r.kind == kind and r.owner == owner) return r;
+ return null;
+ }
+
+ pub fn panelTracks(s: *const Surface) []const animation.Track {
return s.panel_tracks[0..s.npanel_tracks];
}
@@ -377,11 +464,7 @@ pub const Surface = struct {
pub fn at(s: *Surface, x: u16, y: u16) *Cell {
std.debug.assert(x < s.cols and y < s.rows);
- const cell = &s.cells[@as(usize, y) * s.cols + x];
- // The look-hover paint runs with mark_hover set, and its cells are
- // exactly the ones this marks — see render's preview block.
- if (s.mark_hover) cell.hover = true;
- return cell;
+ return &s.cells[@as(usize, y) * s.cols + x];
}
pub fn set(s: *Surface, x: u16, y: u16, text: []const u8, style: CellStyle) void {
diff --git a/src/syntax.zig b/src/syntax.zig
index 9930db20..8ed00bfa 100644
--- a/src/syntax.zig
+++ b/src/syntax.zig
@@ -3,6 +3,7 @@ const config = @import("pardes_config");
const tracy = @import("tracy.zig");
const grammar_manifest = @import("grammar_manifest.zig");
const look = @import("look.zig");
+const regexp = @import("regexp.zig");
const c_heap = @import("c_heap");
pub const enabled = config.syntax_highlighting;
@@ -27,6 +28,10 @@ const Spec = struct {
exts: []const []const u8,
language: *const fn () callconv(.c) *const ts.Language,
query_src: []const u8,
+ /// helix's textobjects.scm for the grammar (vendor/queries), "" for none
+ objects_src: []const u8 = "",
+ objects: ?*ts.Query = null,
+ objects_failed: bool = false,
selected: ?Selected = null,
capture_styles: [256]u8 = undefined,
};
@@ -131,6 +136,7 @@ fn initSpecs() [specCount()]Spec {
.exts = g.exts,
.language = @extern(LanguageFn, .{ .name = "tree_sitter_" ++ g.name }),
.query_src = querySrc(g),
+ .objects_src = @field(ts_queries, g.name ++ "_textobjects"),
};
i += 1;
}
@@ -173,6 +179,9 @@ pub fn stop() void {
selected.query.destroy();
}
spec.selected = null;
+ if (spec.objects) |q| q.destroy();
+ spec.objects = null;
+ spec.objects_failed = false;
}
ts_set_allocator(null, null, null, null);
syntax_started = false;
@@ -310,6 +319,234 @@ pub fn supportsPath(path: []const u8) bool {
return false;
}
+/// A whole file's parse, kept by the file for the keys that walk its nodes
+/// (helix's Alt-o and kin). Opaque outside this file; null without a grammar.
+pub fn parseTree(path: []const u8, content: []const u8) ?*anyopaque {
+ if (!enabled) return null;
+ const selected = (forExt(std.fs.path.extension(path)) catch return null) orelse return null;
+ const tree = selected.parser.parseString(content, null) orelse return null;
+ return @ptrCast(tree);
+}
+
+pub fn freeTree(tree: *anyopaque) void {
+ if (!enabled) return;
+ const t: *ts.Tree = @ptrCast(@alignCast(tree));
+ t.destroy();
+}
+
+/// Where a node walk goes from a range (helix object.rs and movement.rs).
+pub const NodeWalk = @import("modal.zig").Normal.NodeWalk;
+pub const Span = struct { from: usize, to: usize };
+
+/// The spans `walk` takes the byte range [from, to) to, into `out`; `at` is
+/// the range's cursor. None when the tree has nothing to say there.
+pub fn walkNodes(tree_ptr: *anyopaque, walk: NodeWalk, from: usize, to: usize, at: usize, out: []Span) usize {
+ if (!enabled or out.len == 0) return 0;
+ const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr));
+ const lo: u32 = @intCast(@min(from, std.math.maxInt(u32)));
+ const hi: u32 = @intCast(@min(to, std.math.maxInt(u32)));
+ if (walk == .parent_end or walk == .parent_start) {
+ var node = tree.rootNode().namedDescendantForByteRange(lo, hi) orelse return 0;
+ if (walk == .parent_end) {
+ out[0] = .{ .from = node.endByte(), .to = node.endByte() };
+ return 1;
+ }
+ // already at the node's lo: its first ancestor that starts earlier
+ if (node.startByte() == at) {
+ const first = node.startByte();
+ while (node.startByte() >= first or !node.isNamed()) node = node.parent() orelse break;
+ }
+ out[0] = .{ .from = node.startByte(), .to = node.startByte() };
+ return 1;
+ }
+ var cursor = tree.walk();
+ defer cursor.destroy();
+ // helix TreeCursor.reset_to_byte_range: the smallest node holding the range
+ while (true) {
+ const node = cursor.node();
+ if (lo < node.startByte() or hi > node.endByte()) {
+ _ = cursor.gotoParent();
+ break;
+ }
+ if (cursor.gotoFirstChildForByte(lo) == null) break;
+ }
+ switch (walk) {
+ .expand => while (cursor.node().startByte() == lo and cursor.node().endByte() == hi) {
+ if (!cursor.gotoParent()) break;
+ },
+ .shrink => _ = cursor.gotoFirstChild(),
+ .next_sibling => while (!cursor.gotoNextSibling()) {
+ if (!cursor.gotoParent()) break;
+ },
+ .prev_sibling => while (!cursor.gotoPreviousSibling()) {
+ if (!cursor.gotoParent()) break;
+ },
+ .all_siblings, .all_children => {
+ if (walk == .all_siblings) {
+ while (true) {
+ if (!cursor.gotoParent()) return 0;
+ if (cursor.node().childCount() > 1) break;
+ }
+ }
+ // the named children, or nothing to say
+ var n: usize = 0;
+ if (!cursor.gotoFirstChild()) return 0;
+ while (true) {
+ const child = cursor.node();
+ if (child.isNamed() and n < out.len) {
+ out[n] = .{ .from = child.startByte(), .to = child.endByte() };
+ n += 1;
+ }
+ if (!cursor.gotoNextSibling()) break;
+ }
+ return n;
+ },
+ .parent_end, .parent_start => unreachable,
+ }
+ const node = cursor.node();
+ out[0] = .{ .from = node.startByte(), .to = node.endByte() };
+ return 1;
+}
+
+
+/// helix's textobjects, which its `]f`, `mif` and kin select: the name a
+/// query's captures begin with (`function.around`, `test.inside`).
+pub const Object = @import("modal.zig").Normal.Object;
+
+fn objectName(o: Object) []const u8 {
+ return if (o == .xml_element) "xml-element" else @tagName(o);
+}
+
+/// The grammar's textobject query for `path`, compiled the first time; null
+/// when the grammar has none.
+fn objectQuery(path: []const u8) ?*ts.Query {
+ const ext = std.fs.path.extension(path);
+ for (&specs) |*spec| for (spec.exts) |choice| {
+ if (!std.ascii.eqlIgnoreCase(ext, choice)) continue;
+ if (spec.objects) |q| return q;
+ if (spec.objects_src.len == 0 or spec.objects_failed) return null;
+ var error_offset: u32 = 0;
+ spec.objects = ts.Query.create(spec.language(), spec.objects_src, &error_offset) catch {
+ spec.objects_failed = true;
+ return null;
+ };
+ return spec.objects;
+ };
+ return null;
+}
+
+fn captureId(q: *const ts.Query, name: []const u8) ?u32 {
+ for (0..q.captureCount()) |i| if (std.mem.eql(u8, q.captureNameForId(@intCast(i)) orelse "", name)) return @intCast(i);
+ return null;
+}
+
+/// A match's `capture`, as one span over all its nodes (helix's grouped
+/// capture), when the match's `#eq?` and `#match?` hold.
+fn matchSpan(q: *const ts.Query, match: anytype, capture: u32, text: []const u8) ?Span {
+ var span: ?Span = null;
+ for (match.captures) |c| if (c.index == capture) {
+ const s: Span = .{ .from = c.node.startByte(), .to = c.node.endByte() };
+ span = if (span) |old| .{ .from = @min(old.from, s.from), .to = @max(old.to, s.to) } else s;
+ };
+ var found = span orelse return null;
+ // a node that takes its line's newline (this zig grammar's comments)
+ // is the line without it, as helix's grammars have it
+ if (found.to > found.from + 1 and found.to <= text.len and text[found.to - 1] == '\n') found.to -= 1;
+ const steps = q.predicatesForPattern(match.pattern_index);
+ var i: usize = 0;
+ while (i < steps.len) {
+ var end = i;
+ while (end < steps.len and steps[end].type != .done) end += 1;
+ const pred = steps[i..end];
+ i = end + 1;
+ if (pred.len != 3 or pred[0].type != .string or pred[1].type != .capture) continue;
+ const op = q.stringValueForId(pred[0].value_id) orelse continue;
+ const subject = for (match.captures) |c| {
+ if (c.index == pred[1].value_id) break text[@min(c.node.startByte(), text.len)..@min(c.node.endByte(), text.len)];
+ } else continue;
+ const want = if (pred[2].type == .string) q.stringValueForId(pred[2].value_id) orelse continue else for (match.captures) |c| {
+ if (c.index == pred[2].value_id) break text[@min(c.node.startByte(), text.len)..@min(c.node.endByte(), text.len)];
+ } else continue;
+ if (std.mem.eql(u8, op, "eq?")) {
+ if (!std.mem.eql(u8, subject, want)) return null;
+ } else if (std.mem.eql(u8, op, "match?")) {
+ var re = regexp.Regex.compile(want) catch continue;
+ if ((re.find(subject, 0, subject.len, subject.len) catch null) == null) return null;
+ }
+ }
+ return found;
+}
+
+/// `mi`/`ma`: the smallest `object.inside` (or `.around`) holding byte `at`
+/// (helix textobject_treesitter). None without a query or such an object.
+pub fn objectAt(path: []const u8, tree_ptr: *anyopaque, text: []const u8, object: Object, around: bool, at: usize) ?Span {
+ if (!enabled) return null;
+ const q = objectQuery(path) orelse return null;
+ var buf: [32]u8 = undefined;
+ const want = std.fmt.bufPrint(&buf, "{s}.{s}", .{ objectName(object), if (around) "around" else "inside" }) catch return null;
+ const capture = captureId(q, want) orelse return null;
+ const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr));
+ const cursor = ts.QueryCursor.create();
+ defer cursor.destroy();
+ cursor.exec(q, tree.rootNode());
+ var best: ?Span = null;
+ while (cursor.nextMatch()) |match| {
+ const s = matchSpan(q, match, capture, text) orelse continue;
+ if (!(s.from <= at and at < s.to)) continue;
+ if (best == null or s.to - s.from < best.?.to - best.?.from) best = s;
+ }
+ const s = best orelse return null;
+ if (s.from >= text.len or s.to >= text.len) return null;
+ return s;
+}
+
+/// `]f`/`[f` and kin: the next object starting after byte `at`, or the
+/// previous one ending before it (helix goto_treesitter_object), from the
+/// first of `.movement`, `.around`, `.inside` the query names.
+pub fn objectNext(path: []const u8, tree_ptr: *anyopaque, text: []const u8, object: Object, forward: bool, at: usize) ?Span {
+ if (!enabled) return null;
+ const q = objectQuery(path) orelse return null;
+ var buf: [32]u8 = undefined;
+ const capture = for ([_][]const u8{ "movement", "around", "inside" }) |kind| {
+ const want = std.fmt.bufPrint(&buf, "{s}.{s}", .{ objectName(object), kind }) catch return null;
+ if (captureId(q, want)) |id| break id;
+ } else return null;
+ const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr));
+ const cursor = ts.QueryCursor.create();
+ defer cursor.destroy();
+ cursor.exec(q, tree.rootNode());
+ var best: ?Span = null;
+ while (cursor.nextMatch()) |match| {
+ const s = matchSpan(q, match, capture, text) orelse continue;
+ if (forward) {
+ if (s.from <= at) continue;
+ if (best == null or s.from < best.?.from or (s.from == best.?.from and s.to > best.?.to)) best = s;
+ } else {
+ if (s.to >= at) continue;
+ if (best == null or s.to > best.?.to or (s.to == best.?.to and s.from < best.?.from)) best = s;
+ }
+ }
+ const s = best orelse return null;
+ if (s.from >= text.len or s.to >= text.len) return null;
+ return s;
+}
+
+test "every vendored textobject query compiles against its grammar" {
+ if (!enabled) return;
+ var failed: usize = 0;
+ for (&specs) |*spec| {
+ if (spec.objects_src.len == 0) continue;
+ var error_offset: u32 = 0;
+ const q = ts.Query.create(spec.language(), spec.objects_src, &error_offset) catch {
+ std.debug.print("textobjects for {s} do not compile (offset {d})\n", .{ spec.name, error_offset });
+ failed += 1;
+ continue;
+ };
+ q.destroy();
+ }
+ try std.testing.expectEqual(@as(usize, 0), failed);
+}
+
/// Owned source analysis. Both slices use the allocator passed to analyzeSource.
/// The parsed tree is released before returning; callers can cache this result.
pub const SourceAnalysis = struct {
diff --git a/src/tag_layer.zig b/src/tag_layer.zig
index 7cfe5bbf..213ebc07 100644
--- a/src/tag_layer.zig
+++ b/src/tag_layer.zig
@@ -1,83 +1,10 @@
-//! Tag text has its own horizontal pitch. Its viewport stays in physical
-//! body-grid coordinates so compact text never changes pane hit targets.
+//! Pointer hits on tag bands (Layer.TagHit): comparing two, and bringing one
+//! that left its band back onto it.
const std = @import("std");
const pardes = @import("pardes.zig");
-
-/// `notice` is a pane's transient line -- a builtin's name, a pending leader
-/// chord -- carried as a tagline-shaped band so it gets the tagline's height,
-/// small-font metrics, band offset and border instead of being a body-grid row
-/// wearing a tagline font.
-pub const Kind = enum(u8) { workspace, column, pane, notice };
-pub const Hit = struct {
- kind: Kind,
- id: u16,
- serial: u32,
- col: u16,
- /// Which line of a taller tag the layer draws.
- line: u16 = 0,
- pixel_x: f32,
- pixel_y: f32,
- metrics: pardes.RowMetrics,
-};
-pub const Layer = struct {
- kind: Kind = .workspace,
- id: u16 = 0,
- serial: u32 = 0,
- /// A pane's tag taller than a line is drawn a layer to a line; this is
- /// which one, counted from its first.
- line: u16 = 0,
- viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 },
- cols: u16 = 0,
- bg: [3]u8 = .{ 0, 0, 0 },
- cells: []pardes.Cell = &.{},
- cursor: ?struct { col: u16, bar: bool = false } = null,
- /// Vertical offset in rows (negative: above its viewport), for a band
- /// that is moving into place. A pixel shell draws it offset and clipped
- /// to the rows below the viewport's top; the grid has no such thing.
- slide: f32 = 0,
- /// How present a notice band is (1 = fully) while it fades up or
- /// dissolves. `bg` and the cells are already blended by it; a pixel
- /// shell uses it for what it draws around the band, like its rule.
- fade: f32 = 1,
-
- pub fn capacity(layer: *const Layer, metrics: pardes.RowMetrics) u16 {
- return @intCast(@min(std.math.maxInt(u16), @max(1, @as(u32, layer.viewport.w) * @max(1, metrics.body_w) / @max(1, metrics.tagline_w))));
- }
-
- pub fn hitAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?Hit {
- if (layer.cols == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null;
- for ([_]f32{ body_w, body_h, tagline_w, tagline_h }) |metric|
- if (!std.math.isFinite(metric) or metric < 1 or metric > std.math.maxInt(u16)) return null;
- const bw = @max(1, body_w);
- const bh = @max(1, body_h);
- const tw = std.math.clamp(tagline_w, 1, bw);
- const th = std.math.clamp(tagline_h, 1, bh);
- const dx = x - @as(f32, @floatFromInt(layer.viewport.x)) * bw;
- const dy = y - @as(f32, @floatFromInt(layer.viewport.y)) * bh;
- if (dx < 0 or dy < 0 or dx >= @as(f32, @floatFromInt(layer.viewport.w)) * bw or dy >= @as(f32, @floatFromInt(layer.viewport.h)) * bh) return null;
- return .{
- .kind = layer.kind,
- .id = layer.id,
- .serial = layer.serial,
- .line = layer.line,
- .col = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols - 1)), @floor(dx / tw))),
- .pixel_x = x,
- .pixel_y = y,
- .metrics = .{ .body_w = @intFromFloat(bw), .body_h = @intFromFloat(bh), .tagline_w = @intFromFloat(tw), .tagline_h = @intFromFloat(th) },
- };
- }
-};
-
-test "tag text capacity uses its own pitch and excludes the physical grip" {
- const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 1 }, .cols = 90 };
- try std.testing.expectEqual(@as(u16, 63), layer.capacity(.{ .body_w = 10, .tagline_w = 6 }));
- try std.testing.expect(layer.hitAt(119.99, 85, 10, 20, 6, 12) == null);
- try std.testing.expectEqual(@as(u16, 0), layer.hitAt(120, 85, 10, 20, 6, 12).?.col);
- try std.testing.expectEqual(@as(u16, 50), layer.hitAt(423, 85, 10, 20, 6, 12).?.col);
- try std.testing.expect(layer.hitAt(500, 85, 10, 20, 6, 12) == null);
- try std.testing.expect(layer.hitAt(120, 85, std.math.nan(f32), 20, 6, 12) == null);
- try std.testing.expect(layer.hitAt(120, 85, 10, 20, 0, 12) == null);
-}
+const Layer = @import("Layer.zig");
+const Hit = Layer.TagHit;
+const Kind = Layer.Kind;
/// Whether two pointer hits name the same cell of the same tag line.
pub fn sameCell(a: ?Hit, b: ?Hit) bool {
@@ -92,13 +19,13 @@ pub fn columnAt(p: *const pardes.Pardes, kind: Kind, id: usize, supplied: ?Hit,
var point = supplied orelse return null;
if (point.kind != kind or point.id != id) return null;
if (clamp) for (p.surface.tagLayers()) |*layer| {
- if (layer.cols == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial or layer.line != line) continue;
+ if (layer.rows == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial or line >= layer.rows) continue;
const bw: f32 = @floatFromInt(point.metrics.body_w);
const bh: f32 = @floatFromInt(point.metrics.body_h);
const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw;
const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw;
point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001));
- point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * bh;
+ point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y + line)) + 0.5) * bh;
break;
};
return if (p.reprojectTagHit(point)) |mapped| mapped.col else null;
diff --git a/src/tagline.zig b/src/tagline.zig
index 6d5992f1..59c51b4d 100644
--- a/src/tagline.zig
+++ b/src/tagline.zig
@@ -226,7 +226,7 @@ pub fn enterTag(p: *Pardes, pane: *Pane) void {
t.msel.active = false;
t.nsel = 0;
t.select = false;
- t.append_at = null;
+ t.restore_cursor = false;
t.sticky_col = -1;
t.mode = .normal;
t.normal.clear();
@@ -332,7 +332,7 @@ pub fn enterHeader(p: *Pardes, column: ?usize) void {
t.msel.active = false;
t.nsel = 0;
t.select = false;
- t.append_at = null;
+ t.restore_cursor = false;
t.mode = .normal;
t.normal.clear();
}
diff --git a/src/tty/panel_compositor.zig b/src/tty/panel_compositor.zig
index 9363841a..67d3a6e6 100644
--- a/src/tty/panel_compositor.zig
+++ b/src/tty/panel_compositor.zig
@@ -8,9 +8,10 @@
const std = @import("std");
const pardes = @import("../pardes.zig");
const layout = @import("../layout.zig");
+const animation = @import("../animation.zig");
-const Box = layout.Box;
-const Track = layout.Track;
+const Box = animation.Box;
+const Track = animation.Track;
/// Kitty placements cannot be resampled through the character-grid transform.
/// Keep their transmitted pixels cached, but omit the placement while its pane
@@ -77,7 +78,7 @@ pub fn compose(
// Stable layout motion first, newly opening panels above it, and closing
// tombstones last. A closing pane no longer owns input or canonical cells,
// but its frozen old content remains the top visual until it slides out.
- for ([_]layout.Phase{ .moving, .opening, .closing }) |phase| {
+ for ([_]animation.Phase{ .moving, .opening, .closing }) |phase| {
for (tracks) |track| {
if (!drawable(source, track) or track.phase != phase) continue;
switch (track.effect) {
@@ -139,7 +140,7 @@ fn dissolve(out: *pardes.Surface, source: *const pardes.Surface, track: Track) v
var x = area.x0;
while (x < area.x1) : (x += 1) {
if (!source.panelCellChanged(x, y)) continue;
- if (layout.dissolveRevealed(
+ if (animation.dissolveRevealed(
track.serial,
x - area.x0,
y - area.y0,
@@ -339,7 +340,7 @@ test "TTY content effects never touch cells outside the published diff" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
- for ([_]layout.Transition{ .ascii, .dissolve }) |effect| {
+ for ([_]animation.Transition{ .ascii, .dissolve }) |effect| {
const track: Track = .{
.serial = 17,
.effect = effect,
@@ -366,7 +367,7 @@ test "content transition without a diff snaps to canonical surface" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
- for ([_]layout.Transition{ .dissolve, .ascii, .vertical }) |effect| {
+ for ([_]animation.Transition{ .dissolve, .ascii, .vertical }) |effect| {
const track: Track = .{
.effect = effect,
.phase = .opening,
@@ -391,7 +392,7 @@ test "exact transition endpoint preserves the canonical cursor" {
};
const track: Track = .{
.effect = .ascii,
- .frame = layout.Transition.ascii.frames() - 1,
+ .frame = animation.Transition.ascii.frames() - 1,
.from = .{ .w = 1, .h = 1 },
.to = .{ .w = 1, .h = 1 },
};
@@ -431,7 +432,7 @@ test "vertical opening rises through a fixed old-grid clip" {
.serial = 5,
.phase = .opening,
.effect = .vertical,
- .from = layout.openingBox(.vertical, target, 3),
+ .from = animation.openingBox(.vertical, target, 3),
.to = target,
};
@@ -485,7 +486,7 @@ test "vertical closing drops frozen content over canonical cells" {
.phase = .closing,
.effect = .vertical,
.from = old_box,
- .to = layout.closingBox(.vertical, old_box),
+ .to = animation.closingBox(.vertical, old_box),
};
const first = try compose(arena.allocator(), &surface, &.{track}, null);
@@ -530,13 +531,13 @@ test "closing content paints after opening content regardless of track order" {
.phase = .closing,
.effect = .vertical,
.from = box,
- .to = layout.closingBox(.vertical, box),
+ .to = animation.closingBox(.vertical, box),
};
const opening: Track = .{
.serial = 2,
.phase = .opening,
.effect = .ascii,
- .frame = layout.Transition.ascii.frames() - 1,
+ .frame = animation.Transition.ascii.frames() - 1,
.from = box,
.to = box,
};
@@ -691,8 +692,8 @@ test "active panel transition hides only its own native attachment" {
const tracks = [_]Track{
.{ .serial = 41, .effect = .slide, .frame = 0 },
.{ .serial = 42, .effect = .ascii, .frame = 0 },
- .{ .serial = 43, .effect = .zoom, .frame = layout.Transition.zoom.frames() - 1 },
- .{ .serial = 44, .effect = .zoom, .frame = layout.Transition.zoom.frames() },
+ .{ .serial = 43, .effect = .zoom, .frame = animation.Transition.zoom.frames() - 1 },
+ .{ .serial = 44, .effect = .zoom, .frame = animation.Transition.zoom.frames() },
.{ .serial = 45, .phase = .opening, .effect = .vertical, .frame = 0 },
};
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index f8f8cdc5..822c2d7f 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -76,7 +76,8 @@ pub const Command = struct {
pub var value: union(enum) {
nop,
quit,
- tick,
+ /// A timed wait ran out: the core's next wake has come.
+ wake,
key_press: vaxis.Key,
pty_read: struct { id: usize, gen: u32, bytes: []u8 },
pty_eof: struct { id: usize, gen: u32 },
@@ -737,6 +738,7 @@ fn localSession(
.paste_buf = paste_buf,
.inotify_fd = file_watch.init(false),
.fs = fs,
+ .test_clock = host_io.testClock(),
};
pardes.lsp.setStatusSink(&sh, lspStatusSink);
host_io.setExitWake(loop, wakeForExit);
@@ -786,7 +788,7 @@ fn localSession(
try startInput(loop, input_cache);
defer if (attached.* == null) stopInput(loop);
(try std.Thread.spawn(.{}, winchWatch, .{ loop, vx, tty })).detach();
- (try std.Thread.spawn(.{}, tickWatch, .{ io, loop })).detach();
+ (try std.Thread.spawn(.{}, wakeWatch, .{ io, loop })).detach();
if (fs) |f| try f.watch(loop, wakeFs);
try vx.queryTerminalSend(tty.writer());
@@ -911,7 +913,11 @@ const Shell = struct {
caps_pending: bool = true,
check_files: bool = false,
in_paste: bool = false,
- tracks: []const pardes.layout.Track = &.{},
+ tracks: []const pardes.animation.Track = &.{},
+ /// PARDES_TEST_CLOCK's virtual time (host_io.testClock), and where the
+ /// running-out wait moves it: the core's next wake.
+ test_clock: ?u64 = null,
+ wake_to: u64 = 0,
fn of(ctx: ?*anyopaque) *Shell {
return @ptrCast(@alignCast(ctx.?));
@@ -923,12 +929,14 @@ const Shell = struct {
const isolated_vtable: host_io.Host.VTable = .{
.wait_input = waitInput,
+ .now = clockNow,
.present = present,
.post_present = postPresent,
};
const vtable: host_io.Host.VTable = .{
.wait_input = waitInput,
+ .now = clockNow,
.present = present,
.post_present = postPresent,
.poll_frame = pollFrame,
@@ -951,12 +959,20 @@ const Shell = struct {
.pipe = pipe,
};
+ fn clockNow(ctx: ?*anyopaque) u64 {
+ const s = of(ctx);
+ return s.test_clock orelse host_io.monotonicNs();
+ }
+
fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
const s = of(ctx);
var batch: usize = 0;
- // The wait is the 9P connections' turn with the core. Animating, the
- // frame's tick arrives through the same queue (`tickWatch`).
- if (timeout_ms != 0) tick_request.set(s.io);
+ // The wait is the 9P connections' turn with the core. A timeout (the
+ // core's next wake) arrives through the same queue (`wakeWatch`).
+ if (timeout_ms != 0) {
+ s.wake_to = s.core.nextWake() orelse 0;
+ requestWake(s.io, timeout_ms);
+ }
pardes.turn.rest();
const first = s.loop.nextEvent() catch {
pardes.turn.wake();
@@ -999,7 +1015,9 @@ const Shell = struct {
defer tz_event.end();
switch (event) {
.nop => {},
- .tick => core.update(.tick),
+ .wake => if (s.test_clock) |*virtual| {
+ virtual.* = @max(virtual.*, s.wake_to);
+ },
.quit => {
core.quit = true;
return true;
@@ -1565,21 +1583,77 @@ fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void {
}
}
-/// Set by an animating frame's wait: post one `.tick` a frame from now. The
-/// tick joins the input queue, so a key or a click that lands inside the
-/// frame is handled at once instead of after a blind sleep.
-var tick_request: std.Io.Event = .unset;
+/// A wait with a timeout: post one `.wake` when it runs out. The wake joins
+/// the input queue, so a key or a click that lands first is handled at once
+/// instead of after a blind sleep; vaxis's queue has no timed wait. Each
+/// request bumps `wake_gen`, which cuts short a sleep still timing an
+/// earlier, longer request (an 800 ms linger interrupted by a key that
+/// starts a 16 ms fall) and times the new one instead.
+var wake_gen: std.atomic.Value(u32) = .init(0);
+var wake_ms: std.atomic.Value(u32) = .init(0);
-fn tickWatch(io: std.Io, loop: *Loop) void {
+fn requestWake(io: std.Io, ms: u32) void {
+ wake_ms.store(ms, .release);
+ _ = wake_gen.fetchAdd(1, .acq_rel);
+ io.futexWake(u32, &wake_gen.raw, 1);
+}
+
+fn wakeWatch(io: std.Io, loop: *Loop) void {
+ var seen = wake_gen.load(.acquire);
while (true) {
- tick_request.waitUncancelable(io);
- tick_request.reset();
- var ts: libc.timespec = .{ .sec = 0, .nsec = @as(isize, pardes.layout.Animation.frame_ms) * std.time.ns_per_ms };
- _ = libc.nanosleep(&ts, null);
- loop.postEvent(.tick) catch {};
+ // Asleep until a request.
+ while (wake_gen.load(.acquire) == seen) io.futexWaitUncancelable(u32, &wake_gen.raw, seen);
+ seen = wake_gen.load(.acquire);
+ if (timeWake(io, &seen)) loop.postEvent(.wake) catch {};
}
}
+/// Sleep out the latest request; true when it ran its course, false when
+/// none is pending. A newer request met on the way restarts the timing.
+fn timeWake(io: std.Io, seen: *u32) bool {
+ while (true) {
+ const ms = wake_ms.load(.acquire);
+ const deadline = host_io.monotonicNs() + @as(u64, ms) * std.time.ns_per_ms;
+ while (true) {
+ const now = host_io.monotonicNs();
+ if (now >= deadline) return true;
+ io.futexWaitTimeout(u32, &wake_gen.raw, seen.*, .{ .duration = .{
+ .raw = .fromNanoseconds(@intCast(deadline - now)),
+ .clock = .awake,
+ } }) catch {};
+ const gen = wake_gen.load(.acquire);
+ if (gen != seen.*) {
+ seen.* = gen;
+ break;
+ }
+ }
+ }
+}
+
+test "a short wake request cuts short a long one still sleeping" {
+ const io = std.testing.io;
+ var seen = wake_gen.load(.acquire);
+ requestWake(io, 800);
+ seen = wake_gen.load(.acquire);
+ const Timer = struct {
+ fn run(timer_io: std.Io, start_seen: u32, done: *std.atomic.Value(u64)) void {
+ var local = start_seen;
+ _ = timeWake(timer_io, &local);
+ done.store(host_io.monotonicNs(), .release);
+ }
+ };
+ var done: std.atomic.Value(u64) = .init(0);
+ const start = host_io.monotonicNs();
+ const thread = try std.Thread.spawn(.{}, Timer.run, .{ io, seen, &done });
+ var pause: std.c.timespec = .{ .sec = 0, .nsec = 50 * std.time.ns_per_ms };
+ _ = std.c.nanosleep(&pause, null);
+ requestWake(io, 16);
+ thread.join();
+ // The 16 ms request lands about 66 ms in: well before the 800 ms one
+ // would have, however loaded the machine.
+ try std.testing.expect(done.load(.acquire) - start < 700 * std.time.ns_per_ms);
+}
+
fn wakeFs(ctx: ?*anyopaque) void {
const loop: *Loop = @ptrCast(@alignCast(ctx.?));
_ = loop.tryPostEvent(.fs_ready) catch {};
@@ -1864,7 +1938,7 @@ const Attach = struct {
fn apply(a: *Attach, event: @TypeOf(Command.value)) ?AttachEnd {
switch (event) {
- .nop, .tick, .fs_ready, .lsp_done, .lsp_status, .pipe_done, .pty_read, .pty_eof, .files_changed => {},
+ .nop, .wake, .fs_ready, .lsp_done, .lsp_status, .pipe_done, .pty_read, .pty_eof, .files_changed => {},
.quit => return .none,
.focus_in => {},
.focus_out => return a.send(.pointer_leave),
diff --git a/src/tutor.txt b/src/tutor.txt
index 507ce40e..0893415e 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -461,7 +461,7 @@ typed
N frontends share ONE screen at the smallest common grid
the default 9P socket works in a daemon too
- KEYS h j k l w b e 0 $ ^ gg ge f F t T v x d c y p
+ KEYS h j k l w b e 0 gl ^ gg ge f F t T v x d c y p
i a I A o O u undo U redo
motions SELECT, so `i` types at the selection's start and
`;` collapses first
diff --git a/src/web.zig b/src/web.zig
index b77d07a6..ac0ecbbf 100644
--- a/src/web.zig
+++ b/src/web.zig
@@ -1,7 +1,7 @@
//! Freestanding WebAssembly boundary for the DOM backend.
//!
//! JavaScript owns the clock and nothing else: it calls `pardes_tick` (one
-//! `Pardes.pump`) and reads the packed cell buffer `present` left behind. The
+//! `Pardes.pump`, at requestAnimationFrame's timestamp) and reads the packed cell buffer `present` left behind. The
//! host vtable below is only what a page genuinely has — pixels, the
//! clipboard, links, downloads. Processes, a real filesystem, a language
//! backend and subprocess pipes are left null, so the core answers them itself
@@ -106,6 +106,9 @@ const State = struct {
frame_len: usize = 0,
frame_cols: u16 = 0,
frame_rows: u16 = 0,
+ /// requestAnimationFrame's timestamp as the last tick gave it, in ns:
+ /// the core's clock (`Host.now`).
+ now_ns: u64 = 0,
fn init(cols: u16, rows: u16) !State {
const allocs = pardes.memory.init(gpa);
@@ -221,13 +224,13 @@ export fn pardes_mouse_pixel(button_arg: u32, kind_arg: u32, col_arg: u32, row_a
var hit: ?pardes.Mouse.BodyHit = null;
for (&s.core.surface.body_layers) |*layer| {
const point = source orelse break;
- hit = layer.hitAt(point.x * bw, point.y * bh, bw, bh, tw, th);
+ hit = layer.bodyHit(point.x * bw, point.y * bh, bw, bh, tw, th);
if (hit != null) break;
}
var tag_hit: ?pardes.TagHit = null;
for (&s.core.surface.tag_layers) |*layer| {
const point = source orelse break;
- tag_hit = layer.hitAt(point.x * bw, point.y * bh, bw, bh, tw, th);
+ tag_hit = layer.tagHit(point.x * bw, point.y * bh, bw, bh, tw, th);
if (tag_hit != null) break;
}
mouseWithHit(button_arg, kind_arg, col_arg, row_arg, mods, hit, tag_hit);
@@ -276,30 +279,19 @@ export fn pardes_pointer_leave() void {
s.core.update(.pointer_leave);
}
-/// One iteration of the core's own loop: queued input, effects, render,
-/// present. JS calls it once per animation frame, after spending that frame's
-/// animation ticks.
-export fn pardes_tick() void {
+/// One iteration of the core's own loop: queued input, effects, the frames
+/// of animation `timestamp_ms` (requestAnimationFrame's clock) has brought,
+/// render, present. JS calls it once per display frame; a call with no
+/// timestamp (NaN) leaves the clock where it was.
+export fn pardes_tick(timestamp_ms: f64) void {
const s = &(state orelse return);
+ if (std.math.isFinite(timestamp_ms) and timestamp_ms >= 0)
+ s.now_ns = @intFromFloat(timestamp_ms * std.time.ns_per_ms);
s.core.pump(host(s)) catch |err| {
last_error = @intFromError(err);
};
}
-/// Spend one fixed 60 Hz animation step, the only writer of animated time in
-/// this host. Separate from the pump for the reason the AppKit shell keeps them
-/// separate: if every input pump also advanced the transition, a burst of keys
-/// would collapse a ten-frame fade into one display frame.
-export fn pardes_animation_tick() void {
- const s = &(state orelse return);
- s.core.update(.tick);
-}
-
-export fn pardes_animating() u32 {
- const s = &(state orelse return 0);
- return @intFromBool(s.core.animationActive());
-}
-
/// The Exit builtin, or the last pane closing. JS stops its frame loop.
export fn pardes_should_quit() u32 {
const s = &(state orelse return 0);
@@ -349,6 +341,7 @@ export fn pardes_tag_layer_value(index: u32, field: u32) u32 {
const s = &(state orelse return 0);
if (index >= pardes.MAX_TAG_LAYERS) return 0;
const layer = &s.core.surface.tag_layers[index];
+ if (layer.rows == 0) return 0;
return switch (field) {
0 => layer.cols,
1 => @intFromEnum(layer.kind),
@@ -358,9 +351,12 @@ export fn pardes_tag_layer_value(index: u32, field: u32) u32 {
5 => layer.viewport.y,
6 => layer.viewport.w,
7 => layer.viewport.h,
- 8 => if (layer.cursor) |c| c.col else 65535,
+ 8 => if (layer.cursor) |c| c.x else 65535,
9 => if (layer.cursor) |c| @intFromBool(c.bar) else 0,
10 => @as(u32, layer.bg[0]) << 16 | @as(u32, layer.bg[1]) << 8 | layer.bg[2],
+ // A band has a row to each line of its tag: `cols * rows` cells.
+ 11 => layer.rows,
+ 12 => if (layer.cursor) |c| c.y else 65535,
else => 0,
};
}
@@ -369,7 +365,7 @@ export fn pardes_tag_layer_cells(index: u32) ?[*]const WebCell {
const s = &(state orelse return null);
if (index >= pardes.MAX_TAG_LAYERS) return null;
const layer = &s.core.surface.tag_layers[index];
- if (layer.cols == 0) return null;
+ if (layer.rows == 0) return null;
const cells = &s.tag_cells[index];
if (cells.len != layer.cells.len) cells.* = gpa.realloc(cells.*, layer.cells.len) catch return null;
for (layer.cells, cells.*) |source, *out| out.* = encodeWebCell(source);
@@ -462,7 +458,13 @@ extern "pardes" fn host_download(
bytes_len: usize,
) callconv(.c) void;
+fn now(ctx: ?*anyopaque) u64 {
+ const s: *State = @ptrCast(@alignCast(ctx.?));
+ return s.now_ns;
+}
+
const vtable: pardes.Host.VTable = .{
+ .now = now,
.present = present,
.write_file = writeFile,
.write_dump = writeDump,
diff --git a/src/web/app.mjs b/src/web/app.mjs
index 11d8f228..03829178 100644
--- a/src/web/app.mjs
+++ b/src/web/app.mjs
@@ -1,7 +1,5 @@
const CELL_SIZE = 20;
const ATTR_TAGLINE = 1 << 7;
-const ANIMATION_FRAME_MS = 1000 / 60;
-const MAX_ANIMATION_TICKS = 4;
const DEFAULT_FG = "#cccccc";
const DEFAULT_BG = "#121212";
const decoder = new TextDecoder();
@@ -66,13 +64,16 @@ export class DomRenderer {
if (!val(0)) continue;
const gripCols = this.wasm.pardes_grip_columns?.() ?? 2;
const textInset = this.wasm.pardes_tag_text_inset?.() ?? gripCols;
- if (val(1) !== 0 && val(4) >= textInset && this.cellHeight) {
- const offset = this.wasm.pardes_tagline_band_offset(val(5),rows*this.cellHeight*256,Math.round(this.cellHeight*256),Math.round(this.taglineHeight*256))/256;
- for (let x = val(4)-textInset; x < val(4); x++) tagPrefixBands.set(val(5)*cols+x,offset);
- if (val(1) === 2) paneIndicators.add(val(5)*cols+val(4)-textInset);
- }
- for (let x = val(4); x < Math.min(cols, val(4) + val(6)); x++) {
- if (val(5) < rows) tagCovered[val(5) * cols + x] = 1;
+ // A row to each line of a taller tag.
+ for (let y = val(5); y < val(5) + Math.max(1, val(11)); y++) {
+ if (val(1) !== 0 && val(4) >= textInset && this.cellHeight) {
+ const offset = this.wasm.pardes_tagline_band_offset(y,rows*this.cellHeight*256,Math.round(this.cellHeight*256),Math.round(this.taglineHeight*256))/256;
+ for (let x = val(4)-textInset; x < val(4); x++) tagPrefixBands.set(y*cols+x,offset);
+ if (val(1) === 2) paneIndicators.add(y*cols+val(4)-textInset);
+ }
+ for (let x = val(4); x < Math.min(cols, val(4) + val(6)); x++) {
+ if (y < rows) tagCovered[y * cols + x] = 1;
+ }
}
}
}
@@ -120,27 +121,35 @@ export class DomRenderer {
const nodes = [];
const bw = this.cellWidth, bh = this.cellHeight, tw = this.taglineWidth, th = this.taglineHeight;
for (let i = 0; i < this.wasm.pardes_tag_layer_limit(); i++) {
- const values = Array.from({length:11}, (_,field) => this.wasm.pardes_tag_layer_value(i,field));
- const cols = values[0];
+ const layerValues = Array.from({length:13}, (_,field) => this.wasm.pardes_tag_layer_value(i,field));
+ const cols = layerValues[0];
if (!cols) continue;
const ptr = this.wasm.pardes_tag_layer_cells(i);
if (!ptr) continue;
- const bytes = new Uint8Array(this.wasm.memory.buffer,ptr,cols*CELL_SIZE);
+ // A row to each line of a taller tag, each drawn as a band of its own.
+ for (let line = 0; line < Math.max(1, layerValues[11]); line++) {
+ const key = i*64+line;
+ const values = layerValues.slice(0,11);
+ values[5] = layerValues[5] + line;
+ values[7] = 1;
+ values[8] = layerValues[12] === line ? layerValues[8] : 65535;
+ const bytes = new Uint8Array(this.wasm.memory.buffer,ptr+line*cols*CELL_SIZE,cols*CELL_SIZE);
const offset = this.wasm.pardes_tagline_band_offset(values[5],this.rowCount*bh*256,Math.round(bh*256),Math.round(th*256))/256;
const gripCols = this.wasm.pardes_grip_columns?.() ?? 2;
const gripX = values[4] - (this.wasm.pardes_tag_text_inset?.() ?? gripCols);
const gripCell = canonical[values[5]*this.cols+gripX];
const grip = values[1] === 2 && gripX >= 0 && gripCell && th >= 1 && tw >= 1;
const geometry = [bw,bh,tw,th,offset,...values,grip,gripCell?.fg,gripCell?.bg,gripCell?.attrs,gripCell?.text,globalThis.devicePixelRatio].join(":");
- const cached = this.tagNodes.get(i);
+ const cached = this.tagNodes.get(key);
if (cached && cached.geometry === geometry && cached.bytes.length === bytes.length && cached.bytes.every((byte,index)=>byte===bytes[index])) {
nodes.push(cached.node);
if (cached.grip) nodes.push(cached.grip);
continue;
}
- const view = new DataView(this.wasm.memory.buffer,ptr,cols*CELL_SIZE);
+ const view = new DataView(this.wasm.memory.buffer,ptr+line*cols*CELL_SIZE,cols*CELL_SIZE);
const layer = this.root.ownerDocument.createElement("div");
layer.dataset.tagLayer = String(i);
+ layer.dataset.tagLine = String(line);
layer.style.cssText = `position:absolute;overflow:hidden;white-space:pre;left:${values[4]*bw}px;top:${values[5]*bh+offset}px;width:${values[6]*bw}px;height:${th}px;background:${color(values[10],DEFAULT_BG)}`;
for (let col = 0; col < cols; col++) {
const off = col*CELL_SIZE;
@@ -173,9 +182,10 @@ export class DomRenderer {
gripNode.append(mark);
}
}
- this.tagNodes.set(i,{geometry,bytes:bytes.slice(),node:layer,grip:gripNode});
+ this.tagNodes.set(key,{geometry,bytes:bytes.slice(),node:layer,grip:gripNode});
nodes.push(layer);
if (gripNode) nodes.push(gripNode);
+ }
}
if (nodes.length !== this.tagRoot.childNodes.length || nodes.some((node,index)=>node!==this.tagRoot.childNodes[index])) this.tagRoot.replaceChildren(...nodes);
}
@@ -325,7 +335,6 @@ export class PardesRuntime {
this.running = false;
this.pointer = null;
this.frameHandle = 0;
- this.animationClock = { last: null, lag: 0 };
this.bindEvents();
}
@@ -338,7 +347,6 @@ export class PardesRuntime {
this.wasm.pardes_tick();
this.renderer.render();
this.running = true;
- this.animationClock = { last: null, lag: 0 };
this.frameHandle = requestAnimationFrame((timestamp) => this.frame(timestamp));
this.root.focus({ preventScroll: true });
}
@@ -355,13 +363,9 @@ export class PardesRuntime {
this.resize();
this.root.style.setProperty("--pardes-tagline-font-size", `${this.measuredTaglinePercent}%`);
}
- // JS owns the animation clock; the pump that follows applies those ticks,
- // runs the core's effects and presents one frame however fast the display
- // is. Both halves are needed: an idle core still has input and effects.
- for (let ticks = animationTicks(this.animationClock, timestamp); ticks > 0; ticks -= 1) {
- this.wasm.pardes_animation_tick();
- }
- this.wasm.pardes_tick();
+ // The core's clock is this timestamp: it steps its own animations
+ // by it, however fast the display refreshes.
+ this.wasm.pardes_tick(timestamp);
if (this.wasm.pardes_should_quit()) this.running = false;
this.renderer.render();
this.frameHandle = requestAnimationFrame((nextTimestamp) => this.frame(nextTimestamp));
@@ -559,28 +563,6 @@ export class PardesRuntime {
}
}
-/// Spend monotonic requestAnimationFrame time as fixed 60 Hz core ticks. A
-/// high-refresh display therefore renders repeated states rather than making
-/// frame-count transitions run twice as fast. Long suspension is capped so a
-/// returning tab cannot execute an unbounded loop before drawing.
-export function animationTicks(clock, timestamp) {
- if (!Number.isFinite(timestamp)) return 0;
- if (clock.last === null || timestamp < clock.last) {
- clock.last = timestamp;
- clock.lag = 0;
- return 0;
- }
- const elapsed = Math.min(timestamp - clock.last, ANIMATION_FRAME_MS * MAX_ANIMATION_TICKS);
- clock.last = timestamp;
- clock.lag += elapsed;
- const ticks = Math.min(MAX_ANIMATION_TICKS, Math.floor((clock.lag + 1e-7) / ANIMATION_FRAME_MS));
- clock.lag -= ticks * ANIMATION_FRAME_MS;
- return ticks;
-}
-
-/// The four things the core asks the page for, as the module's imports. The
-/// holder exists because the import object has to be built before the instance
-/// whose memory those imports read.
export function browserHost() {
const host = { wasm: null };
const view = (ptr, len) => new Uint8Array(host.wasm.memory.buffer, ptr, len);