summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-15 14:46:38 -0300
committerGabriel Schneider <[email protected]>2026-09-15 17:24:42 -0300
commit40c19fd4f52d20df94c2dfa2d68102b89bd9d3f1 (patch)
tree642150be8703f1d9a0766ff18516f06bfdc8801a
parent379f4fe55c7c9c7c8cc7b97dc921c83e2c24698b (diff)
downloadpardes-40c19fd4f52d20df94c2dfa2d68102b89bd9d3f1.tar.gz
pardes-40c19fd4f52d20df94c2dfa2d68102b89bd9d3f1.zip
Add optional compact tagline styling for source context
-rw-r--r--docs/config.md6
-rw-r--r--docs/detached.md8
-rw-r--r--src/body_layer.zig80
-rw-r--r--src/builtins.zig4
-rw-r--r--src/config.zig8
-rw-r--r--src/detached/client.zig9
-rw-r--r--src/detached/server.zig6
-rw-r--r--src/detached/wire.zig164
-rw-r--r--src/dump.zig1
-rw-r--r--src/gui/gui.zig227
-rw-r--r--src/layout.zig103
-rw-r--r--src/macos.zig82
-rw-r--r--src/macos/Sources/AppDelegate.swift2
-rw-r--r--src/macos/Sources/PardesView.swift133
-rw-r--r--src/macos/pardes.h7
-rw-r--r--src/panes.zig13
-rw-r--r--src/pardes.zig272
-rw-r--r--src/web.zig77
-rw-r--r--src/web/app.mjs106
-rw-r--r--test/panes.zig55
-rw-r--r--test/web-snapshots/touch.mjs39
21 files changed, 1305 insertions, 97 deletions
diff --git a/docs/config.md b/docs/config.md
index 9205539b..8a8f1d23 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -128,6 +128,12 @@ scrolling; scrolling upward reveals that source line as the headers recede.
`Dump` and `Restore` preserve the setting per pane. Customized tags retain
their text; the command can still be executed from any source pane.
+`TreeContextTagStyle` toggles the experimental tagline treatment for those
+headers and is on by default. In graphical frontends it uses the tagline font,
+line height and thin border. Turning it off restores body-sized context rows.
+`TreeContext` itself still defaults off; this appearance option does not enable
+it. `Config` reports the appearance option, and dumps preserve it.
+
Search, Grep and LSP location-result panes include `LocationsConfig` in their
default tags. Custom tags keep their edits.
diff --git a/docs/detached.md b/docs/detached.md
index 4cb3d9d6..1f56d24f 100644
--- a/docs/detached.md
+++ b/docs/detached.md
@@ -49,9 +49,11 @@ write session files or install file watches.
`src/detached/wire.zig` owns the versioned frontend protocol. Frames are full
grids or changes relative to each frontend's last queued frame. A new
attachment receives a full grid. Output queues and per-poll work are bounded;
-a lagging frontend cannot hold the session's event loop. Protocol version 3
-also carries the pointer shape, including updates when no grid cells change,
-so PDF link hover uses the same pointing-hand cue in attached graphical clients.
+a lagging frontend cannot hold the session's event loop. Protocol version 4
+also carries the pointer shape and compact source-context body layers. Pointer
+updates work even when no grid cells change, and graphical clients use the
+same compact row geometry for drawing and mouse input. Terminal clients retain
+the ordinary fixed grid.
`zig build unit-test` covers encoding, session ownership, real frontend
connections, worker completion and Restore. `zig build fs-test` drives
diff --git a/src/body_layer.zig b/src/body_layer.zig
new file mode 100644
index 00000000..3b5ee795
--- /dev/null
+++ b/src/body_layer.zig
@@ -0,0 +1,80 @@
+//! 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.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const config = @import("config.zig");
+
+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,
+ cells: []pardes.Cell = &.{},
+ cursor: ?struct { x: u16, y: u16, bar: bool = false } = null,
+
+ 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);
+}
diff --git a/src/builtins.zig b/src/builtins.zig
index 92bf6fea..18ea0198 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -329,6 +329,10 @@ pub const TreeContext = struct {
file.syntax_dirty = true;
c.pane.wrap_n = 0;
c.pane.context_rows = 0;
+ c.pane.body_rows = 0;
+ c.pane.body_visible_rows = 0;
+ c.pane.context_row_limit = null;
+ c.p.surface.body_layers[c.id].rows = 0;
}
};
diff --git a/src/config.zig b/src/config.zig
index 321e0caf..7f0a7715 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -70,6 +70,7 @@ pub const leader_path = paths: {
.FocusTint = null,
.ColumnTags = null,
.SyntaxBold = null,
+ .TreeContextTagStyle = null,
.Wrap = "tw",
.Tagbottom = "tb",
.NextColor = "tn",
@@ -532,6 +533,7 @@ pub const Runtime = struct {
colors: bool = true,
wrap: bool = true,
tag_bottom: bool = false,
+ tree_context_tag_style: bool = true,
debug: bool = false,
focus_tint: bool = true,
column_tags: bool = true,
@@ -631,7 +633,7 @@ pub const Runtime = struct {
pub const Capability = std.meta.FieldEnum(Capabilities);
- pub const Toggle = enum { colors, wrap, tag_bottom, debug, focus_tint, column_tags, syntax_bold };
+ pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold };
pub const Scene = std.meta.FieldEnum(layout.SceneEffect);
pub const Action = union(enum) {
@@ -669,6 +671,7 @@ pub const Runtime = struct {
pub const settings = [_]Setting{
.{ .word = "Colors", .action = .{ .toggle = .colors } },
.{ .word = "Wrap", .action = .{ .toggle = .wrap } },
+ .{ .word = "TreeContextTagStyle", .action = .{ .toggle = .tree_context_tag_style } },
.{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom } },
.{ .word = "Debug", .action = .{ .toggle = .debug } },
.{ .word = "FocusTint", .action = .{ .toggle = .focus_tint } },
@@ -753,6 +756,7 @@ pub const Runtime = struct {
.toggle => |field| switch (field) {
.colors => state.colors = !state.colors,
.wrap => state.wrap = !state.wrap,
+ .tree_context_tag_style => state.tree_context_tag_style = !state.tree_context_tag_style,
.tag_bottom => state.tag_bottom = !state.tag_bottom,
.debug => state.debug = !state.debug,
.focus_tint => state.focus_tint = !state.focus_tint,
@@ -824,6 +828,7 @@ pub const Runtime = struct {
const value = switch (field) {
.colors => state.colors,
.wrap => state.wrap,
+ .tree_context_tag_style => state.tree_context_tag_style,
.tag_bottom => state.tag_bottom,
.debug => state.debug,
.focus_tint => state.focus_tint,
@@ -1070,6 +1075,7 @@ pub const Runtime = struct {
.toggle => |field| switch (field) {
.colors => "Colors: off\n",
.wrap => "Wrap: off\n",
+ .tree_context_tag_style => "TreeContextTagStyle: off\n",
.tag_bottom => "Tagbottom: on\n",
.debug => "Debug: on\n",
.focus_tint => "FocusTint: off\n",
diff --git a/src/detached/client.zig b/src/detached/client.zig
index affa070e..42404bca 100644
--- a/src/detached/client.zig
+++ b/src/detached/client.zig
@@ -102,6 +102,7 @@ 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(.{}),
in: std.ArrayListUnmanaged(u8) = .empty,
out: std.ArrayListUnmanaged(u8) = .empty,
/// Bytes of `in` belonging to the message `next` returned last. Compacted at
@@ -174,6 +175,10 @@ pub const Client = struct {
if (c.fd >= 0) _ = libc.close(c.fd);
c.fd = -1;
c.grid.deinit(c.gpa);
+ for (&c.body_layers) |*layer| {
+ c.gpa.free(layer.cells);
+ layer.* = .{};
+ }
c.in.deinit(c.gpa);
c.out.deinit(c.gpa);
}
@@ -311,6 +316,10 @@ pub const Client = struct {
try c.reshape(f.cols, f.rows);
}
try f.apply(c.grid.items);
+ f.applyLayers(c.gpa, &c.body_layers) catch |err| return switch (err) {
+ error.OutOfMemory => error.Closed,
+ else => @as(wire.Error, @errorCast(err)),
+ };
c.cursor = f.cursor;
c.pointer_shape = f.pointer_shape;
},
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 9d83d3eb..82abc432 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -707,7 +707,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);
+ const want = wire.frameBound(surface.cols, surface.rows) + wire.layersBound(&surface.body_layers);
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)
&.{}
@@ -717,7 +717,7 @@ pub const Session = struct {
.{ .x = cur.x, .y = cur.y, .bar = cur.bar }
else
null;
- const bytes = wire.encodeFramePointer(
+ const bytes = wire.encodeFrameLayers(
s.scratch.allocatedSlice()[0..want],
surface.cols,
surface.rows,
@@ -725,6 +725,7 @@ pub const Session = struct {
surface.pointer_shape,
cells,
prev,
+ &surface.body_layers,
) catch |err| {
log.debug("frame {d}x{d} not encodable: {t}", .{ surface.cols, surface.rows, err });
return;
@@ -987,6 +988,7 @@ pub const Session = struct {
.resize => |r| {
c.cols = r.cols;
c.rows = r.rows;
+ if (r.row_metrics) |metrics| s.core.row_metrics = metrics;
},
else => {
s.origin = slot;
diff --git a/src/detached/wire.zig b/src/detached/wire.zig
index 5087cfcd..5d52e2df 100644
--- a/src/detached/wire.zig
+++ b/src/detached/wire.zig
@@ -3,7 +3,7 @@
const std = @import("std");
const pardes = @import("../pardes.zig");
-pub const version: u16 = 3;
+pub const version: u16 = 4;
pub const Error = error{ Truncated, Overlong, BadTag, BadValue, Trailing, NoSpace };
@@ -15,7 +15,7 @@ pub const header_len = 5; // tag:u8, payload length:u32le
const cell_max = 1 + 1 + 7 + 4 + 4 + 1 + 1 + 1;
const run_header = 4 + 2; // start:u32, count:u16
const run_max = std.math.maxInt(u16);
-const frame_head = 1 + 2 + 2 + 6 + 4;
+const frame_head = 1 + 2 + 2 + 6 + 4 + 4;
// Worst case: every cell changed, each in its own run.
pub fn frameBound(cols: u16, rows: u16) usize {
@@ -154,6 +154,7 @@ pub const Cursor = struct { x: u16, y: u16, bar: bool };
/// union is passed by value, and the receiver already owns the grid the runs
/// belong in. `apply` is the bounds-checked walk.
pub const Frame = struct {
+ layers: []const u8 = &.{},
pointer_shape: pardes.Surface.PointerShape = .arrow,
kind: FrameKind,
cols: u16,
@@ -162,6 +163,38 @@ pub const Frame = struct {
nruns: u32,
runs: []const u8,
+ pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.BodyLayer) !void {
+ for (layers) |*layer| {
+ gpa.free(layer.cells);
+ layer.* = .{};
+ }
+ errdefer for (layers) |*layer| {
+ gpa.free(layer.cells);
+ layer.* = .{};
+ };
+ 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.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
+ layer.rows == 0 or layer.rows > 2 * max_rows or layer.context_rows > layer.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;
+ 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);
+ }
+ try r.end();
+ }
+
/// Paint this frame into `grid`, which must be exactly `cols * rows` cells
/// — the receiver resizes on a geometry change before applying, and a grid
/// of the wrong size is a receiver bug, not a wire condition.
@@ -601,6 +634,18 @@ pub fn encodeFramePointer(
cells: []const pardes.Cell,
prev: []const pardes.Cell,
) Error![]const u8 {
+ return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{});
+}
+
+pub fn layersBound(layers: []const pardes.BodyLayer) usize {
+ var n: usize = 2;
+ for (layers) |layer| if (layer.rows != 0) {
+ n += 26 + layer.cells.len * cell_max;
+ };
+ return n;
+}
+
+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) 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
@@ -619,6 +664,31 @@ pub fn encodeFramePointer(
try w.putU16(cols);
try w.putU16(rows);
try putCursor(&w, cursor, cols, rows);
+ const layers_len_at = w.n;
+ try w.putU32(0);
+ const layers_start = w.n;
+ if (layers.len != 0) {
+ var count: u16 = 0;
+ for (layers) |layer| if (layer.rows != 0) {
+ count += 1;
+ };
+ try w.putU16(count);
+ for (layers) |layer| {
+ if (layer.rows == 0) continue;
+ try w.putU16(layer.pane);
+ try w.putU32(layer.serial);
+ try w.putU16(layer.viewport.x);
+ try w.putU16(layer.viewport.y);
+ try w.putU16(layer.viewport.w);
+ try w.putU16(layer.viewport.h);
+ try w.putU16(layer.cols);
+ try w.putU16(layer.rows);
+ try w.putU16(layer.context_rows);
+ 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);
+ }
+ }
+ std.mem.writeInt(u32, w.buf[layers_len_at..][0..4], @intCast(w.n - layers_start), .little);
const nruns_at = w.n;
try w.putU32(0);
@@ -698,6 +768,42 @@ fn clientTag(msg: ClientMsg) ClientTag {
/// WIRE is not.
const has_cell_pixels = @hasField(pardes.CellPixels, "w");
+fn putMetrics(w: *Writer, metrics: ?pardes.RowMetrics) Error!void {
+ try w.putBool(metrics != null);
+ if (metrics) |m| {
+ try w.putU16(m.body_w);
+ try w.putU16(m.body_h);
+ try w.putU16(m.tagline_w);
+ try w.putU16(m.tagline_h);
+ }
+}
+
+fn getMetrics(r: *Reader) Error!?pardes.RowMetrics {
+ if (!try r.getBool()) return null;
+ const m: pardes.RowMetrics = .{ .body_w = try r.getU16(), .body_h = try r.getU16(), .tagline_w = try r.getU16(), .tagline_h = try r.getU16() };
+ if (m.body_w == 0 or m.body_h == 0 or m.tagline_w == 0 or m.tagline_h == 0) return error.BadValue;
+ return m;
+}
+
+fn putBodyHit(w: *Writer, hit: ?pardes.Mouse.BodyHit) Error!void {
+ try w.putBool(hit != null);
+ if (hit) |h| {
+ try w.putU32(h.serial);
+ try w.putU16(h.row);
+ try w.putU16(h.col);
+ try w.putF32(h.pixel_x);
+ try w.putF32(h.pixel_y);
+ try putMetrics(w, h.metrics);
+ }
+}
+
+fn getBodyHit(r: *Reader) Error!?pardes.Mouse.BodyHit {
+ if (!try r.getBool()) return null;
+ const hit: pardes.Mouse.BodyHit = .{ .serial = try r.getU32(), .row = try r.getU16(), .col = try r.getU16(), .pixel_x = try r.getF32(), .pixel_y = try r.getF32(), .metrics = (try getMetrics(r)) orelse return error.BadValue };
+ if (!std.math.isFinite(hit.pixel_x) or !std.math.isFinite(hit.pixel_y)) return error.BadValue;
+ return hit;
+}
+
pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 {
var w: Writer = .init(out);
const at = try beginMessage(&w, @intFromEnum(clientTag(msg)));
@@ -726,6 +832,7 @@ pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 {
try w.putU16(m.col);
try w.putU16(m.row);
try w.putBool(m.ctrl);
+ try putBodyHit(&w, m.body_hit);
},
.resize => |rs| {
try w.putU16(rs.cols);
@@ -735,6 +842,7 @@ pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 {
// carries where it exists.
try w.putU16(if (comptime has_cell_pixels) rs.cell_pixels.w else 8);
try w.putU16(if (comptime has_cell_pixels) rs.cell_pixels.h else 16);
+ try putMetrics(&w, rs.row_metrics);
},
.paste => |b| try w.putSlice32(b),
.command => |line| try w.putSlice16(line),
@@ -786,6 +894,7 @@ pub fn decodeClient(tag: u8, payload: []const u8) Error!ClientMsg {
.col = try r.getU16(),
.row = try r.getU16(),
.ctrl = try r.getBool(),
+ .body_hit = try getBodyHit(&r),
} } },
.resize => blk: {
var ev: pardes.Event = .{ .resize = .{ .cols = try r.getCols(), .rows = try r.getRows() } };
@@ -794,6 +903,7 @@ pub fn decodeClient(tag: u8, payload: []const u8) Error!ClientMsg {
// Read either way — the bytes are on the wire — and kept only by a
// build that has somewhere to keep them.
if (comptime has_cell_pixels) ev.resize.cell_pixels = .{ .w = px_w, .h = px_h };
+ ev.resize.row_metrics = try getMetrics(&r);
break :blk .{ .event = ev };
},
.paste => .{ .event = .{ .paste = try r.getSlice32() } },
@@ -849,7 +959,7 @@ pub fn encodeServer(out: []u8, msg: ServerMsg) Error![]const u8 {
/// field-by-field count: a bound that is 32 bytes generous costs one `memcpy`
/// worth of nothing, and a bound that is one byte tight is a bug that only
/// shows up on the message nobody tested.
-const msg_slack = header_len + 32;
+const msg_slack = header_len + 64;
/// An upper bound on `encodeServer`'s output, so a caller sizes its buffer
/// once instead of guessing and retrying.
@@ -897,6 +1007,7 @@ pub fn decodeServer(tag: u8, payload: []const u8) Error!ServerMsg {
const cols = try r.getCols();
const rows = try r.getRows();
const cursor = try getCursor(&r, cols, rows);
+ const layers = try r.getSlice32();
const nruns = try r.getU32();
// One run per cell is the worst an encoder emits, so a bigger
// count cannot be describing this grid. Checked HERE rather than
@@ -906,6 +1017,7 @@ pub fn decodeServer(tag: u8, payload: []const u8) Error!ServerMsg {
const runs = r.bytes[r.i..];
r.i = r.bytes.len;
break :blk .{ .frame = .{
+ .layers = layers,
.kind = kind,
.pointer_shape = if (header == .full_link or header == .diff_link) .link else .arrow,
.cols = cols,
@@ -1287,7 +1399,7 @@ test "detached wire: the diff is worth having, in bytes, on the board's grid" {
for (cells) |*c| c.* = .{ .text = "a".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false };
const full = (try encodeFrame(buf, cols, rows, null, cells, &.{})).len;
try testing.expectEqual(@as(usize, header_len + frame_head + run_header + n * 8), full);
- try testing.expectEqual(@as(usize, 6298), full);
+ try testing.expectEqual(@as(usize, 6302), full);
const before = try gpa.dupe(pardes.Cell, cells);
defer gpa.free(before);
@@ -1298,7 +1410,7 @@ test "detached wire: the diff is worth having, in bytes, on the board's grid" {
cells[500] = .{ .text = "d".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false };
const diff = (try encodeFrame(buf, cols, rows, null, cells, before)).len;
try testing.expectEqual(@as(usize, header_len + frame_head + 2 * run_header + 3 * 8), diff);
- try testing.expectEqual(@as(usize, 56), diff);
+ try testing.expectEqual(@as(usize, 60), diff);
// The whole reason this codec exists rather than shipping the Surface.
try testing.expect(full / diff > 100);
}
@@ -1443,6 +1555,7 @@ test "detached wire: a frame that lies about its runs cannot walk out of the gri
try w.putU16(cols);
try w.putU16(rows);
try putCursor(&w, null, cols, rows);
+ try w.putU32(0); // no body layers
try w.putU32(9);
try testing.expectError(error.Overlong, decodeServer(@intFromEnum(ServerTag.frame), w.written()));
@@ -1508,6 +1621,7 @@ test "detached wire: a cursor outside the grid is refused, not painted" {
try w.putU16(cols);
try w.putU16(rows);
try putCursor(&w, .{ .x = cols - 1, .y = rows - 1, .bar = true }, cols, rows);
+ try w.putU32(0); // no body layers
try w.putU32(0);
const got = (try decodeServer(@intFromEnum(ServerTag.frame), w.written())).frame;
try testing.expectEqual(@as(u16, cols - 1), got.cursor.?.x);
@@ -1607,3 +1721,43 @@ test "detached wire: max_payload bounds every message this protocol can build" {
var small: [8]u8 = undefined;
try testing.expectError(error.NoSpace, encodeClient(&small, .{ .event = .{ .paste = "0123456789" } }));
}
+
+test "detached wire carries compact body rows and exact pointer metrics" {
+ const gpa = std.testing.allocator;
+ 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 };
+ const 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 } };
+ const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}));
+ defer gpa.free(out);
+ const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer});
+ const packet = (try framed(encoded)).?;
+ const frame = (try decodeServer(packet.tag, packet.payload)).frame;
+ var decoded: [pardes.MAX_PANES]pardes.BodyLayer = @splat(.{});
+ defer for (&decoded) |*item| gpa.free(item.cells);
+ try frame.apply(&cells);
+ try frame.applyLayers(gpa, &decoded);
+ try std.testing.expectEqual(@as(u16, 6), decoded[0].rows);
+ try std.testing.expectEqualStrings("z", decoded[0].cells[10].grapheme());
+ try std.testing.expectEqual(@as(u16, 5), decoded[0].cursor.?.y);
+ const pointer = layer.hitAt(4, 27, 8, 16, 6, 8).?;
+ const message: ClientMsg = .{ .event = .{ .mouse = .{ .col = 0, .row = 1, .button = .left, .kind = .press, .body_hit = pointer } } };
+ var mouse_bytes: [256]u8 = undefined;
+ const mouse_encoded = try encodeClient(&mouse_bytes, message);
+ const mouse_packet = (try framed(mouse_encoded)).?;
+ const mouse = (try decodeClient(mouse_packet.tag, mouse_packet.payload)).event.mouse;
+ try std.testing.expectEqualDeep(pointer, mouse.body_hit.?);
+ 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));
+ for (decoded) |item| {
+ try std.testing.expectEqual(@as(u16, 0), item.rows);
+ try std.testing.expectEqual(@as(usize, 0), item.cells.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);
+ try std.testing.expectEqual(@as(u16, 0), decoded[0].rows);
+}
diff --git a/src/dump.zig b/src/dump.zig
index 7380c1ed..e82a8ab3 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -149,6 +149,7 @@ pub const State = struct {
topbar_custom: ?[]const u8 = null,
theme: []const u8 = "dark",
locations_config: @import("locations_config.zig").Config = .{},
+ tree_context_tag_style: bool = true,
columns: []const Column = &.{},
panes: []const Pane = &.{},
mounts: []const Mount = &.{},
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index f18f527b..91176a63 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -582,20 +582,23 @@ test "closing tombstone overlays but never owns canonical cells" {
try std.testing.expectEqual(pardes.layout.Phase.closing, plan.batches[1].track.?.phase);
}
-fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32) bool {
+fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32, tag_w: u32, tag_h: u32) bool {
+ const metrics: pardes.RowMetrics = .{ .body_w = @intCast(cell_w), .body_h = @intCast(cell_h), .tagline_w = @intCast(tag_w), .tagline_h = @intCast(tag_h) };
+ const same_metrics = std.meta.eql(metrics, core.row_metrics);
if (comptime pardes.pdf_enabled) {
const cell_px_w: u16 = @intCast(@min(@max(1, cell_w), std.math.maxInt(u16)));
const cell_px_h: u16 = @intCast(@min(@max(1, cell_h), std.math.maxInt(u16)));
if (cols == core.screen_w and rows == core.screen_h and
- cell_px_w == core.cell_pixels.w and cell_px_h == core.cell_pixels.h) return false;
+ cell_px_w == core.cell_pixels.w and cell_px_h == core.cell_pixels.h and same_metrics) return false;
core.update(.{ .resize = .{
.cols = cols,
.rows = rows,
.cell_pixels = .{ .w = cell_px_w, .h = cell_px_h },
+ .row_metrics = metrics,
} });
} else {
- if (cols == core.screen_w and rows == core.screen_h) return false;
- core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+ if (cols == core.screen_w and rows == core.screen_h and same_metrics) return false;
+ core.update(.{ .resize = .{ .cols = cols, .rows = rows, .row_metrics = metrics } });
}
return true;
}
@@ -763,7 +766,7 @@ fn emitTouchScroll(in: *Input, center: TouchNormPoint, ticks: i32, delta_y: f32,
const button: pardes.Mouse.Button = if (ticks > 0) .wheel_up else .wheel_down;
var remaining: u32 = @abs(ticks);
while (remaining > 0) : (remaining -= 1)
- in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row } });
+ in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row, .body_hit = cell.body_hit } });
in.post(.{ .touch_scroll = delta_y });
}
@@ -772,8 +775,8 @@ fn emitTouchClick(t: *Touch, in: *Input, point: TouchNormPoint, button: pardes.M
t.click_button = button;
t.click_flash_frames = touch_click_flash_max_frames;
const cell = gridCellAtDimensions(in.core, point.x * win_w, point.y * win_h, cell_w, cell_h, tagline_w);
- in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row } });
- in.post(.{ .mouse = .{ .button = button, .kind = .release, .col = cell.col, .row = cell.row } });
+ in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row, .body_hit = cell.body_hit } });
+ in.post(.{ .mouse = .{ .button = button, .kind = .release, .col = cell.col, .row = cell.row, .body_hit = cell.body_hit } });
}
fn normCell(norm: f32, win: f32, cell: f32) u16 {
@@ -1762,6 +1765,7 @@ const Gui = struct {
tagline_percent: u8,
tagline_width: u32,
tagline_height: u32,
+ attached_layers: ?*const [pardes.MAX_PANES]pardes.BodyLayer = null,
cell_w: u32,
cell_h: u32,
ascent: i32,
@@ -1890,7 +1894,7 @@ fn fontForCodepoint(g: *const Gui, cp: u32) *c.UIFont {
}
const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 };
-const MouseCell = struct { col: u16, row: u16 };
+const MouseCell = struct { col: u16, row: u16, body_hit: ?pardes.Mouse.BodyHit = null };
const WindowGeometry = struct {
window_w: f32,
window_h: f32,
@@ -2527,13 +2531,24 @@ fn attachRequested(gpa: std.mem.Allocator, g: *Gui, requested: []const u8) !void
}
}
+fn attachedResize(client: *detached_client.Client, g: *const Gui, cols: u16, rows: u16) !void {
+ try client.send(.{ .event = .{ .resize = .{ .cols = cols, .rows = rows, .row_metrics = .{
+ .body_w = @intCast(g.cell_w),
+ .body_h = @intCast(g.cell_h),
+ .tagline_w = @intCast(g.tagline_width),
+ .tagline_h = @intCast(g.tagline_height),
+ } } } });
+}
+
fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client) !void {
defer client.detach();
_ = c.SDL_StartTextInput(g.window);
var in: Input = .{ .client = client };
+ g.attached_layers = &client.body_layers;
var dirty = false;
var geom = windowCells(g);
+ try attachedResize(client, g, geom.cols, geom.rows);
while (true) {
const link = client.wait(detached_client.poll_ms);
while (true) {
@@ -2566,7 +2581,7 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client
const now = windowCells(g);
if (now.cols != geom.cols or now.rows != geom.rows) {
geom = now;
- try client.resize(now.cols, now.rows);
+ try attachedResize(client, g, now.cols, now.rows);
}
if (!dirty) continue;
dirty = false;
@@ -2581,6 +2596,7 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien
.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,
};
const chrome = frameChromeBg(&surface);
_ = renderFrame(
@@ -3053,6 +3069,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
.kind = .release,
.col = last.col,
.row = last.row,
+ .body_hit = last.body_hit,
} });
in.post(.pointer_leave);
return;
@@ -3065,6 +3082,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
.kind = if (b.down) .press else .release,
.col = mc.col,
.row = mc.row,
+ .body_hit = mc.body_hit,
.ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0,
},
});
@@ -3094,6 +3112,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
.kind = if (held != null) .drag else .motion,
.col = mc.col,
.row = mc.row,
+ .body_hit = mc.body_hit,
} });
},
c.SDL_EVENT_MOUSE_WHEEL => {
@@ -3140,9 +3159,9 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
} else {
g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
while (g.scroll_delta >= 1) : (g.scroll_delta -= 1)
- in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row } });
+ in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .body_hit = mc.body_hit } });
while (g.scroll_delta <= -1) : (g.scroll_delta += 1)
- in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row } });
+ in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .body_hit = mc.body_hit } });
}
}
if (w.x != 0) in.post(.{ .mouse = .{
@@ -3150,6 +3169,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
.kind = .press,
.col = mc.col,
.row = mc.row,
+ .body_hit = mc.body_hit,
} });
},
c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => {
@@ -3247,6 +3267,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
.kind = if (held != null) .drag else .motion,
.col = mc.col,
.row = mc.row,
+ .body_hit = mc.body_hit,
} });
},
.click => |ck| {
@@ -3263,6 +3284,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
.kind = .release,
.col = last.col,
.row = last.row,
+ .body_hit = last.body_hit,
} });
in.post(.pointer_leave);
continue;
@@ -3274,6 +3296,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
.kind = if (ck.down) .press else .release,
.col = mc.col,
.row = mc.row,
+ .body_hit = mc.body_hit,
} });
},
.wheel => |ticks| {
@@ -3293,6 +3316,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
.kind = .press,
.col = mc.col,
.row = mc.row,
+ .body_hit = mc.body_hit,
} });
}
},
@@ -3364,14 +3388,20 @@ fn gridCellAtDimensions(
}
fn gridCellAtPixels(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) MouseCell {
- return gridCellAtDimensions(
- core,
- x,
- y,
- @floatFromInt(g.cell_w),
- @floatFromInt(g.cell_h),
- @floatFromInt(g.tagline_width),
- );
+ 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| {
+ 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| {
+ point.body_hit = body_hit;
+ break;
+ }
+ };
+ return point;
}
test "tagline pointer mapping follows the smaller pane-anchored grid" {
@@ -3438,6 +3468,7 @@ fn refreshPresentedPointer(g: *Gui, core: *pardes.Pardes) void {
.kind = if (held != null) .drag else .motion,
.col = mc.col,
.row = mc.row,
+ .body_hit = mc.body_hit,
} });
}
@@ -3473,6 +3504,7 @@ fn pollGamepad(g: *Gui, in: *Input) void {
.kind = if (held != null) .drag else .motion,
.col = mc.col,
.row = mc.row,
+ .body_hit = mc.body_hit,
} });
}
}
@@ -3483,12 +3515,12 @@ fn pollGamepad(g: *Gui, in: *Input) void {
while (@abs(g.pad_scroll) >= 1.0) {
const button: pardes.Mouse.Button = if (g.pad_scroll < 0) .wheel_up else .wheel_down;
g.pad_scroll += if (g.pad_scroll < 0) 1.0 else -1.0;
- in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } });
+ in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row, .body_hit = mc.body_hit } });
}
while (@abs(g.pad_scroll_h) >= 1.0) {
const button: pardes.Mouse.Button = if (g.pad_scroll_h < 0) .wheel_left else .wheel_right;
g.pad_scroll_h += if (g.pad_scroll_h < 0) 1.0 else -1.0;
- in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } });
+ in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row, .body_hit = mc.body_hit } });
}
}
@@ -3750,7 +3782,7 @@ fn pollFrame(ctx: ?*anyopaque) void {
}
pollCwds(core, s.ptys);
const geom = windowCells(g);
- if (updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h)) resetScroll(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);
}
@@ -4859,6 +4891,7 @@ fn renderFrame(
while (row < surface.rows) : (row += 1) {
var col: u16 = 0;
while (col < surface.cols) : (col += 1) {
+ if (bodyLayerAt(surface, col, row) != null) continue;
const batch_index = paintBatchAt(&paint_plan, col, row);
const track = paint_plan.batches[batch_index].track;
const data_effect = if (track) |active|
@@ -4887,6 +4920,7 @@ fn renderFrame(
var col: u16 = 0;
while (col < surface.cols) : (col += 1) {
if (!boxContains(track.contentBox(), col, row)) continue;
+ if (bodyLayerIn(surface.previous_body_layers, col, row) != null) 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(
@@ -4897,6 +4931,29 @@ fn renderFrame(
}
}
}
+ for (surface.bodyLayers()) |*layer| {
+ if (layer.rows == 0) continue;
+ const batch = &paint_plan.batches[paintBatchAt(&paint_plan, 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);
+ }
+ }
+ for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| {
+ const track = batch.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 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;
+ destination.cell_count += layer.cols + @as(u32, if (lr < layer.context_rows) 1 else 0);
+ }
+ }
+ }
var cell_next: [pardes.MAX_PANES * 2 + 1]u32 = @splat(0);
var cell_total: u32 = 0;
for (paint_plan.batches[0..paint_plan.len], 0..) |*batch, batch_index| {
@@ -4920,6 +4977,7 @@ fn renderFrame(
while (row < surface.rows) : (row += 1) {
var col: u16 = 0;
while (col < surface.cols) : (col += 1) {
+ if (bodyLayerAt(surface, col, row) != null) 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;
@@ -4961,11 +5019,26 @@ fn renderFrame(
surface.at(col, row),
&tagline_base,
false,
- paint_plan.len == 1 and logical_idx == cursor_idx and !cursor_bar,
+ paint_plan.len == 1 and logical_idx == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null,
page,
);
}
}
+ 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);
+ }
+ for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| {
+ const track = batch.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);
+ }
+ }
for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| {
const track = batch.track.?;
const duplicate_under = track.effect == .vertical and track.phase == .opening;
@@ -4975,6 +5048,7 @@ fn renderFrame(
var col: u16 = 0;
while (col < surface.cols) : (col += 1) {
if (!boxContains(track.contentBox(), col, row)) continue;
+ if (bodyLayerIn(surface.previous_body_layers, col, row) != null) continue;
const logical_idx: u32 = @as(u32, row) * surface.cols + col;
const destination: usize = if (duplicate_under) 0 else batch_index;
emitSurfaceCell(
@@ -5263,6 +5337,59 @@ test "compact column and pane underlays share focus tint without tinting the glo
try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, active.x, pardes.TOPBAR_H));
}
+fn bodyLayerAt(surface: *const pardes.Surface, col: u16, row: u16) ?*const pardes.BodyLayer {
+ return bodyLayerIn(surface.bodyLayers(), col, row);
+}
+
+fn bodyLayerIn(layers: []const pardes.BodyLayer, col: u16, row: u16) ?*const pardes.BodyLayer {
+ 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;
+ }
+ return null;
+}
+
+fn clipBodyInstance(instance: *CellInstance, bottom: f32, win_h: f32) void {
+ const limit = 1 - bottom / win_h * 2;
+ if (instance.y1 >= limit) return;
+ const fraction = (instance.y0 - limit) / (instance.y0 - instance.y1);
+ instance.v1 = instance.v0 + (instance.v1 - instance.v0) * fraction;
+ 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) void {
+ const bw: f32 = @floatFromInt(g.cell_w);
+ const bh: f32 = @floatFromInt(g.cell_h);
+ const tw: f32 = @floatFromInt(g.tagline_width);
+ const th: f32 = @floatFromInt(g.tagline_height);
+ const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw;
+ const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * bh;
+ for (0..layer.rows) |index| {
+ const row: u16 = @intCast(index);
+ const top = layer.rowTop(row, bh, th);
+ if (top >= bottom) break;
+ const height = layer.rowHeight(row, bh, th);
+ const header = row < layer.context_rows;
+ const role: pardes.FontRole = if (header) .tagline else .body;
+ const width = if (header) tw else bw;
+ if (header) {
+ var background: pardes.Cell = .{ .default = false, .style = layer.cells[index * layer.cols].style };
+ background.style.font_role = .body;
+ emitInstance(g, instances, next.*, 0, 0, .{ .w = @as(f32, @floatFromInt(layer.viewport.w)) * bw, .h = height, .x_off = left, .y_off = top }, win_w, win_h, track, .body, &background, old_layer, false, page);
+ clipBodyInstance(&instances[next.*], bottom, win_h);
+ next.* += 1;
+ }
+ for (0..layer.cols) |col| {
+ const cell = &layer.cells[index * layer.cols + col];
+ const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.x == col and cu.y == row else false;
+ const glyph_top = top - (if (header) @as(f32, @floatFromInt(taglineBandOffset(0, win_h, g.cell_h, g.tagline_height))) else 0);
+ emitInstance(g, instances, next.*, 0, 0, .{ .w = width, .h = height, .x_off = left + @as(f32, @floatFromInt(col)) * width, .y_off = glyph_top }, win_w, win_h, track, role, cell, old_layer, cursor, page);
+ clipBodyInstance(&instances[next.*], bottom, win_h);
+ next.* += 1;
+ }
+ }
+}
+
fn emitSurfaceCell(
g: *Gui,
core: ?*const pardes.Pardes,
@@ -5478,7 +5605,7 @@ fn refitFont(g: *Gui, core: ?*pardes.Pardes) void {
if (core) |p| {
const geom = windowCells(g);
- _ = updateCoreResize(p, geom.cols, geom.rows, g.cell_w, g.cell_h);
+ _ = updateCoreResize(p, geom.cols, geom.rows, g.cell_w, g.cell_h, g.tagline_width, g.tagline_height);
}
resetScroll(g);
@@ -5494,7 +5621,7 @@ fn resetGlyphAtlas(g: *Gui) void {
g.atlas_dirty = true;
}
-fn syncTaglineFont(g: *Gui, core: *const pardes.Pardes) void {
+fn syncTaglineFont(g: *Gui, core: *pardes.Pardes) void {
const percent = core.settings.font.tagline_percent;
if (percent == g.tagline_percent) return;
g.tagline_percent = percent;
@@ -5504,6 +5631,8 @@ fn syncTaglineFont(g: *Gui, core: *const pardes.Pardes) void {
g.tagline_baseline = tagline.baseline;
g.tagline_height = tagline.height;
resetGlyphAtlas(g);
+ const geom = windowCells(g);
+ _ = updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h, g.tagline_width, g.tagline_height);
}
fn ensureGlyph(g: *Gui, cp: u32, role: pardes.FontRole, decoration: GlyphDecoration) Slot {
@@ -6096,7 +6225,7 @@ fn buildOverlay(
if (transient_on) {
if (core) |p| appendPaneChrome(g, p, surface, layout, &builder);
if (surface.cursor) |cursor| {
- if (cursor.x < surface.cols and cursor.y < surface.rows) {
+ if (cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null) {
const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x];
const role = drawnFontRole(core, &cell, cursor.y);
const visual_height: f32 = if (role == .tagline)
@@ -6111,6 +6240,25 @@ fn buildOverlay(
}
}
}
+ if (transient_on) for (surface.bodyLayers()) |*layer| {
+ if (layer.rows == 0) continue;
+ const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w;
+ const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w;
+ const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h;
+ const border = layer.rowTop(layer.context_rows, layout.h, @floatFromInt(g.tagline_height));
+ if (border <= bottom) builder.addRect(left, border - 1, right, border, overlayRgb(topbar_pane_border_rgb));
+ 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) appendTouchOverlay(g, &builder, debug_on);
return @intCast(builder.len);
}
@@ -6655,3 +6803,30 @@ fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 {
const raw = env.get(name) orelse return null;
return std.fmt.parseInt(u16, raw, 10) catch null;
}
+
+test "compact body layer rendering fills recovered rows and clips the final glyph" {
+ var g: Gui = undefined;
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.tagline_width = 6;
+ g.tagline_height = 12;
+ g.space_slot = .{ .u = 0, .v = 0 };
+ var cells: [24]pardes.Cell = @splat(.{});
+ const layer: pardes.BodyLayer = .{
+ .viewport = .{ .x = 3, .y = 2, .w = 2, .h = 5 },
+ .cols = 2,
+ .rows = 12,
+ .context_rows = 4,
+ .cells = &cells,
+ };
+ var instances: [32]CellInstance = undefined;
+ var next: u32 = 0;
+ emitBodyLayer(&g, &instances, &next, &layer, 200, 200, null, Ground.opaqueRgb(bg_default), false, false);
+ // Four compact header rows (48px), then three body rows: two complete
+ // and a third clipped to the remaining12px of the100px viewport.
+ try std.testing.expectEqual(@as(u32, 18), next);
+ try std.testing.expectApproxEqAbs(@as(f32, 0.6), instances[0].y0, 0.0001);
+ try std.testing.expectApproxEqAbs(@as(f32, 0.12), instances[12].y0, 0.0001);
+ try std.testing.expectApproxEqAbs(@as(f32, -0.4), instances[17].y1, 0.0001);
+ try std.testing.expectApproxEqAbs(instances[17].v0 + @as(f32, 12) / atlas_h, instances[17].v1, 0.0001);
+}
diff --git a/src/layout.zig b/src/layout.zig
index 2df64594..1c43bbd4 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -29,6 +29,7 @@ pub const Presentation = struct {
acknowledged: bool = false,
pending: bool = false,
previous_cells: []pardes.Cell = &.{},
+ previous_body_layers: [MAX_PANES]pardes.BodyLayer = @splat(.{}),
previous_cols: u16 = 0,
previous_rows: u16 = 0,
previous_valid: bool = false,
@@ -40,6 +41,7 @@ pub const Presentation = struct {
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);
if (self.diffs.len > 0) gpa.free(self.diffs);
}
@@ -299,6 +301,17 @@ pub const Presentation = struct {
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;
+ }
@memcpy(self.previous_cells, cells);
self.previous_cols = surface.cols;
self.previous_rows = surface.rows;
@@ -315,6 +328,55 @@ pub const Presentation = struct {
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;
@@ -487,6 +549,7 @@ pub const Presentation = struct {
}
if (self.diff_state == .ready) {
s.previous_cells = self.previous_cells;
+ s.previous_body_layers = &self.previous_body_layers;
s.cell_diffs = self.diffs;
}
s.npanel_tracks = paintOrder(
@@ -2017,3 +2080,43 @@ pub const Animation = struct {
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 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 };
+ state.capturePrevious(std.testing.allocator, &surface);
+ try std.testing.expect(state.previous_valid);
+ 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]);
+}
diff --git a/src/macos.zig b/src/macos.zig
index 7b71d9d5..bb5b6bf6 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -637,6 +637,7 @@ const State = struct {
core: *pardes.Pardes,
started: bool = false,
runtime: Runtime,
+ layer_cells: [pardes.MAX_PANES][]Cell = @splat(&.{}),
cells: []Cell = &.{},
previous_cells: []Cell = &.{},
changed_cells: []u8 = &.{},
@@ -778,6 +779,7 @@ export fn pardes_deinit() void {
st.inbox.close(st.gpa, st.io);
st.file_watches.deinit(st.gpa);
if (st.cells.len > 0) st.gpa.free(st.cells);
+ for (st.layer_cells) |cells| st.gpa.free(cells);
if (st.previous_cells.len > 0) st.gpa.free(st.previous_cells);
if (st.changed_cells.len > 0) st.gpa.free(st.changed_cells);
if (st.images.len > 0) st.gpa.free(st.images);
@@ -1480,6 +1482,22 @@ export fn pardes_paste(text_ptr: ?[*]const u8, len: usize) void {
}
export fn pardes_mouse(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, mods: u32) void {
+ mouseWithHit(button_arg, kind_arg, col, row, mods, null);
+}
+
+export fn pardes_mouse_pixel(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, mods: u32, x: f32, y: f32, bw: f32, bh: f32, tw: f32, th: f32) void {
+ const st = &(state orelse return);
+ const source = st.core.presentation.pointerFractional(st.core.screen_w, st.core.screen_h, x / @max(1, bw), y / @max(1, bh));
+ var hit: ?pardes.Mouse.BodyHit = null;
+ for (&st.core.surface.body_layers) |*layer| {
+ const point = source orelse break;
+ hit = layer.hitAt(point.x * bw, point.y * bh, bw, bh, tw, th);
+ if (hit != null) break;
+ }
+ mouseWithHit(button_arg, kind_arg, col, row, mods, hit);
+}
+
+fn mouseWithHit(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, mods: u32, hit: ?pardes.Mouse.BodyHit) void {
const st = &(state orelse return);
const button: pardes.Mouse.Button = switch (button_arg) {
0 => .left,
@@ -1502,6 +1520,7 @@ export fn pardes_mouse(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, m
else => return,
};
st.core.update(.{ .mouse = .{
+ .body_hit = hit,
.button = button,
.kind = kind,
.col = col,
@@ -1827,6 +1846,46 @@ export fn pardes_cursor_x() i32 {
return if (st.core.surface.cursor) |c| c.x else -1;
}
+export fn pardes_body_layer_value(index: u32, field: u32) u32 {
+ const st = &(state orelse return 0);
+ if (index >= pardes.MAX_PANES) return 0;
+ const layer = &st.core.surface.body_layers[index];
+ return switch (field) {
+ 0 => layer.rows,
+ 1 => layer.cols,
+ 2 => layer.context_rows,
+ 3 => layer.viewport.x,
+ 4 => layer.viewport.y,
+ 5 => layer.viewport.w,
+ 6 => layer.viewport.h,
+ 7 => layer.serial,
+ 8 => if (layer.cursor) |c| c.x else 65535,
+ 9 => if (layer.cursor) |c| c.y else 65535,
+ 10 => if (layer.cursor) |c| @intFromBool(c.bar) else 0,
+ else => 0,
+ };
+}
+
+export fn pardes_body_layer_cells(index: u32) ?[*]const Cell {
+ const st = &(state orelse return null);
+ if (index >= pardes.MAX_PANES) return null;
+ const layer = &st.core.surface.body_layers[index];
+ if (layer.rows == 0) return null;
+ const cells = &st.layer_cells[index];
+ if (cells.len != layer.cells.len) cells.* = st.gpa.realloc(cells.*, layer.cells.len) catch return null;
+ for (layer.cells, cells.*) |cell, *out| out.* = encodeCell(cell);
+ return cells.ptr;
+}
+
+export fn pardes_body_layer_limit() u32 {
+ return pardes.MAX_PANES;
+}
+
+export fn pardes_row_metrics(body_w: u16, body_h: u16, tagline_w: u16, tagline_h: u16) void {
+ const st = &(state orelse return);
+ st.core.update(.{ .resize = .{ .cols = st.core.screen_w, .rows = st.core.screen_h, .cell_pixels = st.core.cell_pixels, .row_metrics = .{ .body_w = @max(1, body_w), .body_h = @max(1, body_h), .tagline_w = @max(1, tagline_w), .tagline_h = @max(1, tagline_h) } } });
+}
+
export fn pardes_pointer_shape() u32 {
const st = &(state orelse return 0);
return @intFromEnum(st.core.surface.pointer_shape);
@@ -2287,6 +2346,10 @@ test "pardes.h declares every export the way it is defined" {
try expectSameAbi(@TypeOf(c.pardes_cursor_y), @TypeOf(pardes_cursor_y));
try expectSameAbi(@TypeOf(c.pardes_cursor_bar), @TypeOf(pardes_cursor_bar));
try expectSameAbi(@TypeOf(c.pardes_pointer_shape), @TypeOf(pardes_pointer_shape));
+ try expectSameAbi(@TypeOf(c.pardes_mouse_pixel), @TypeOf(pardes_mouse_pixel));
+ try expectSameAbi(@TypeOf(c.pardes_row_metrics), @TypeOf(pardes_row_metrics));
+ try expectSameAbi(@TypeOf(c.pardes_body_layer_value), @TypeOf(pardes_body_layer_value));
+ try expectSameAbi(@TypeOf(c.pardes_body_layer_cells), @TypeOf(pardes_body_layer_cells));
try expectSameAbi(@TypeOf(c.pardes_take_haptic), @TypeOf(pardes_take_haptic));
try expectSameAbi(@TypeOf(c.pardes_font_take), @TypeOf(pardes_font_take));
try expectSameAbi(@TypeOf(c.pardes_font_observe), @TypeOf(pardes_font_observe));
@@ -2565,3 +2628,22 @@ test "a live session renders on the draw and animates only on the display clock"
_ = pardes_frame();
try std.testing.expectEqual(step + 1, st.core.chrome_animation.step);
}
+
+test "mac row metrics ABI preserves PDF cell dimensions and the unrendered grid" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer core.deinit();
+ state = .{ .gpa = gpa, .threaded = undefined, .io = std.testing.io, .core = core, .runtime = .{}, .config_arena = .init(gpa), .prompt_rcs = .{} };
+ defer {
+ state.?.config_arena.deinit();
+ state = null;
+ }
+ if (comptime pardes.pdf_enabled) core.cell_pixels = .{ .w = 22, .h = 39 };
+ const pixels = core.cell_pixels;
+ try std.testing.expectEqual(@as(u16, 0), core.surface.cols);
+ pardes_row_metrics(22, 39, 17, 30);
+ try std.testing.expectEqualDeep(pixels, core.cell_pixels);
+ try std.testing.expectEqual(@as(u16, 80), core.screen_w);
+ try std.testing.expectEqual(@as(u16, 24), core.screen_h);
+ try std.testing.expectEqualDeep(pardes.RowMetrics{ .body_w = 22, .body_h = 39, .tagline_w = 17, .tagline_h = 30 }, core.row_metrics);
+}
diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift
index eb499f1b..96f4a184 100644
--- a/src/macos/Sources/AppDelegate.swift
+++ b/src/macos/Sources/AppDelegate.swift
@@ -671,6 +671,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
grid.cols, grid.rows,
UInt16((view.cellWidth * scale).rounded()),
UInt16((view.cellHeight * scale).rounded()))
+ pardes_row_metrics(UInt16(max(1,(view.cellWidth*scale).rounded())), UInt16(max(1,(view.cellHeight*scale).rounded())),
+ UInt16(max(1,(view.taglineCellWidth*scale).rounded())), UInt16(max(1,(view.taglineBandHeight*scale).rounded())))
pump()
}
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift
index 2160047d..b14e1aa0 100644
--- a/src/macos/Sources/PardesView.swift
+++ b/src/macos/Sources/PardesView.swift
@@ -736,12 +736,15 @@ final class PardesView: NSView {
guard percent != taglines.percent else { return false }
taglines = TaglineMetrics(body: metrics, percent: percent, scale: metricsScale)
taglineGlyphCache.removeAll(keepingCapacity: true)
+ pardes_row_metrics(UInt16(max(1,(cellWidth*metricsScale).rounded())),UInt16(max(1,(cellHeight*metricsScale).rounded())),
+ UInt16(max(1,(taglines.width*metricsScale).rounded())),UInt16(max(1,(taglines.height*metricsScale).rounded())))
needsDisplay = true
return true
}
var taglinePercent: UInt8 { taglines.percent }
var taglineBandHeight: CGFloat { taglines.height }
+ var taglineCellWidth: CGFloat { taglines.width }
/// Seed runtime Config after init and update it after every successful
/// adopt/zoom. Points are the native owner's unit; the cell's backing-pixel
@@ -905,7 +908,10 @@ final class PardesView: NSView {
// Only a canonical presentation advances the attachment baseline.
// During a transition deleted panes keep their cropped CGImages alive
// here even after both the core placement and image cache entry vanish.
- if !animatedPanels { presentedImages = frameImages }
+ if !animatedPanels {
+ presentedImages = frameImages
+ presentedBodyLayers = snapshotBodyLayers()
+ }
presentationSerial &+= 1
let animationWoke = pardes_frame_presented(animatedPanels)
let pointerMoved = refreshPresentedPointer()
@@ -936,9 +942,9 @@ final class PardesView: NSView {
guard changed else { return false }
if let button = latchedButton {
latchedCell = at
- pardes_mouse(button, PARDES_MOUSE_DRAG, at.col, at.row, pointerModifiers)
+ sendMouse(button, PARDES_MOUSE_DRAG, at.col, at.row, pointerModifiers)
} else {
- pardes_mouse(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION,
+ sendMouse(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION,
at.col, at.row, pointerModifiers)
}
return true
@@ -1159,6 +1165,101 @@ final class PardesView: NSView {
width: caretWidth, height: caretHeight), fg, 1)
}
}
+ if frozenImages == nil {
+ drawBodyLayers(ctx, hideCursor:hideCursor)
+ } else {
+ for layer in presentedBodyLayers {
+ layer.cells.withUnsafeBufferPointer { cells in
+ if let base = cells.baseAddress { drawBodyLayer(ctx, values:layer.values, cells:base, hideCursor:true) }
+ }
+ }
+ }
+ }
+
+ private struct FrozenBodyLayer {
+ let values: [Int]
+ let cells: [pardes_cell_s]
+ }
+ private var presentedBodyLayers: [FrozenBodyLayer] = []
+
+ private func snapshotBodyLayers() -> [FrozenBodyLayer] {
+ var result: [FrozenBodyLayer] = []
+ for index in 0..<pardes_body_layer_limit() {
+ let values = (0..<11).map { Int(pardes_body_layer_value(index, UInt32($0))) }
+ if values[0] == 0 { continue }
+ if let cells = pardes_body_layer_cells(index) {
+ result.append(FrozenBodyLayer(values:values, cells:Array(UnsafeBufferPointer(start:cells,count:values[0]*values[1]))))
+ }
+ }
+ return result
+ }
+
+ private func drawBodyLayers(_ ctx: CGContext, hideCursor:Bool) {
+ for index in 0..<pardes_body_layer_limit() {
+ let values = (0..<11).map { Int(pardes_body_layer_value(index, UInt32($0))) }
+ if values[0] == 0 { continue }
+ if let cells = pardes_body_layer_cells(index) { drawBodyLayer(ctx, values:values, cells:cells, hideCursor:hideCursor) }
+ }
+ }
+
+ private func drawBodyLayer(_ ctx: CGContext, values: [Int], cells: UnsafePointer<pardes_cell_s>, hideCursor: Bool) {
+ func value(_ field: Int) -> Int { values[field] }
+ let rows = value(0), cols = value(1), contexts = value(2)
+ let left = CGFloat(value(3)) * cellWidth, top = CGFloat(value(4)) * cellHeight
+ let viewport = CGRect(x: left, y: top, width: CGFloat(value(5))*cellWidth,
+ height: CGFloat(value(6))*cellHeight)
+ ctx.saveGState()
+ ctx.clip(to: viewport)
+ fill(ctx, viewport, themeBG ?? pardesDefaultBG, 1)
+ for row in 0..<rows {
+ let compact = row < contexts
+ let advance = compact ? taglines.width : cellWidth
+ let height = compact ? taglines.height : cellHeight
+ let y = top + CGFloat(min(row, contexts))*taglines.height + CGFloat(max(0, row-contexts))*cellHeight
+ if y >= viewport.maxY { break }
+ let rowStyle = resolve(cells[row*cols], block: false, ground: themeBG ?? pardesDefaultBG, clearGround: false)
+ fill(ctx, CGRect(x: left, y: y, width: viewport.width, height: height), rowStyle.bg, 1)
+ for pass in 0...1 {
+ for col in 0..<cols {
+ var cell = cells[row*cols+col]
+ let isCursor = !hideCursor && col == value(8) && row == value(9)
+ let style = resolve(cell, block: isCursor && value(10) == 0,
+ ground: themeBG ?? pardesDefaultBG, clearGround: false)
+ let x = left + CGFloat(col)*advance
+ if pass == 0 {
+ fill(ctx, CGRect(x: x, y: y, width: advance, height: height), style.bg, 1)
+ continue
+ }
+ let text = withUnsafeBytes(of: &cell.text) { bytes in
+ String(decoding: bytes.prefix(Int(cell.len)), as: UTF8.self)
+ }
+ if style.visible && !text.isEmpty && text != " " {
+ let face = Face(bold: cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0,
+ italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0)
+ let font = compact ? taglines.fonts[face.rawValue] : metrics.fonts[face.rawValue]
+ ctx.saveGState()
+ ctx.translateBy(x: 0, y: bounds.height)
+ ctx.scaleBy(x: 1, y: -1)
+ let baseline = bounds.height - y - (compact ? taglines.ascent : metrics.ascent)
+ drawRules(ctx, cell, style, x: x, width: advance, baseline: baseline)
+ setFill(ctx, style.fg, style.alpha)
+ ctx.textPosition = CGPoint(x: x, y: baseline)
+ let line = NSAttributedString(string: text, attributes: [fontAttribute: font])
+ CTLineDraw(CTLineCreateWithAttributedString(line as CFAttributedString), ctx)
+ ctx.restoreGState()
+ }
+ if isCursor && value(10) != 0 {
+ fill(ctx, CGRect(x: x, y: y, width: max(1, advance/8), height: height), style.fg, 1)
+ }
+ }
+ }
+ }
+ let border = pardes_topbar_pane_border_rgb()
+ if border != UInt32(PARDES_COLOR_DEFAULT) {
+ fill(ctx, CGRect(x: left, y: top+CGFloat(contexts)*taglines.height,
+ width: viewport.width, height: 1/taglines.scale), border, 1)
+ }
+ ctx.restoreGState()
}
/// What identifies a decoded raster: the pane's lifetime, the page, and the
@@ -1523,23 +1624,36 @@ final class PardesView: NSView {
// keyboard state; the NSEvent overrides always pass the real mask. Ctrl is
// the one the core actually consults — a left press with it held is
// goto-definition — so dropping it here would silently delete a feature.
+ private var sampledBodyPointer: CGPoint?
+
+ private func sendMouse(_ button: pardes_mouse_button_e, _ kind: pardes_mouse_kind_e,
+ _ col: UInt16, _ row: UInt16, _ mods: UInt32) {
+ guard let point = sampledBodyPointer else {
+ pardes_mouse(button, kind, col, row, mods)
+ return
+ }
+ let scale = metricsScale
+ pardes_mouse_pixel(button, kind, col, row, mods, Float(point.x*scale), Float(point.y*scale),
+ Float(cellWidth*scale), Float(cellHeight*scale), Float(taglines.width*scale), Float(taglines.height*scale))
+ }
+
func press(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) {
- pardes_mouse(button, PARDES_MOUSE_PRESS, cell.col, cell.row, mods)
+ sendMouse(button, PARDES_MOUSE_PRESS, cell.col, cell.row, mods)
fed()
}
func release(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) {
- pardes_mouse(button, PARDES_MOUSE_RELEASE, cell.col, cell.row, mods)
+ sendMouse(button, PARDES_MOUSE_RELEASE, cell.col, cell.row, mods)
fed()
}
func drag(_ button: pardes_mouse_button_e, to cell: GridPoint, mods: UInt32 = 0) {
- pardes_mouse(button, PARDES_MOUSE_DRAG, cell.col, cell.row, mods)
+ sendMouse(button, PARDES_MOUSE_DRAG, cell.col, cell.row, mods)
fed()
}
func motion(to cell: GridPoint, mods: UInt32 = 0) {
- pardes_mouse(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION, cell.col, cell.row, mods)
+ sendMouse(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION, cell.col, cell.row, mods)
fed()
}
@@ -1553,7 +1667,7 @@ final class PardesView: NSView {
/// One discrete wheel notch, as opposed to the continuous travel below.
func wheel(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) {
- pardes_mouse(button, PARDES_MOUSE_PRESS, cell.col, cell.row, mods)
+ sendMouse(button, PARDES_MOUSE_PRESS, cell.col, cell.row, mods)
fed()
}
@@ -1721,7 +1835,7 @@ final class PardesView: NSView {
// before one pump notification so no frame can observe a held button
// whose pointer is already outside the displayed scene.
if let last = latchedCell {
- pardes_mouse(button, PARDES_MOUSE_RELEASE, last.col, last.row,
+ sendMouse(button, PARDES_MOUSE_RELEASE, last.col, last.row,
modifiers(event.modifierFlags))
}
latchedCell = nil
@@ -1891,6 +2005,7 @@ final class PardesView: NSView {
} else {
sampled = point
}
+ sampledBodyPointer = sampled
let row = min(max(Int(sampled.y / cellHeight), 0), rows - 1)
// The column comes from the core, because a tag row's glyphs step at
// the tagline face's narrower pitch and a body-pitch click drifts one
diff --git a/src/macos/pardes.h b/src/macos/pardes.h
index f94e37eb..9f9aef60 100644
--- a/src/macos/pardes.h
+++ b/src/macos/pardes.h
@@ -337,6 +337,13 @@ void pardes_mouse(pardes_mouse_button_e button, pardes_mouse_kind_e kind,
uint16_t col, uint16_t row, uint32_t mods);
// The pointer left the view. A clamped motion on its final cell is not the
// same event: it would keep hover previews and resize hints alive off-window.
+void pardes_mouse_pixel(pardes_mouse_button_e button, pardes_mouse_kind_e kind,
+ uint16_t col, uint16_t row, uint32_t mods, float x, float y,
+ float body_w, float body_h, float tagline_w, float tagline_h);
+void pardes_row_metrics(uint16_t body_w, uint16_t body_h, uint16_t tagline_w, uint16_t tagline_h);
+uint32_t pardes_body_layer_limit(void);
+uint32_t pardes_body_layer_value(uint32_t index, uint32_t field);
+const pardes_cell_s *pardes_body_layer_cells(uint32_t index);
void pardes_pointer_leave(void);
// Host file watchers call this after their debounce interval. `pane` is the
diff --git a/src/panes.zig b/src/panes.zig
index ede1d661..f07e63c3 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -171,6 +171,9 @@ pub const Pane = struct {
/// A row map also exists for sticky headers when soft wrapping is off.
body_wrapped: bool = false,
context_rows: u16 = 0,
+ body_rows: u16 = 0,
+ body_visible_rows: u16 = 0,
+ context_row_limit: ?u16 = null,
sel: [3]Sel = @splat(.{}),
pointer_selections: [3]?PointerSelection = @splat(null),
next_pointer_selection: u64 = 0,
@@ -372,8 +375,9 @@ pub const Pane = struct {
// Scrolloff is measured in the ordinary body, excluding sticky rows.
var margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
var last = off + @as(i32, pane.rows) - 1;
- if (pane.context_rows < pane.wrap_n) {
- const lines_shown = pane.wrap_line[pane.wrap_n - 1] - pane.wrap_line[pane.context_rows];
+ const visible_rows = if (pane.body_visible_rows > 0) @min(pane.wrap_n, pane.body_visible_rows) else pane.wrap_n;
+ if (pane.context_rows < visible_rows) {
+ const lines_shown = pane.wrap_line[visible_rows - 1] - pane.wrap_line[pane.context_rows];
last = off + lines_shown;
margin = @min(margin, @divTrunc(@max(0, lines_shown), 2));
}
@@ -1829,7 +1833,7 @@ pub const File = struct {
fn prepareContextRows(pane: *Pane, file: *State) void {
pane.context_rows = 0;
if (!file.tree_context or !supportsContext(pane) or pane.rows > pane.wrap_line.len) return;
- const capacity = pane.rows -| 1; // retain at least one ordinary body row
+ const capacity = @min(pane.rows -| 1, pane.context_row_limit orelse std.math.maxInt(u16)); // retain an ordinary body row
for (file.context_declarations) |declaration| {
if (declaration.start_line >= file.scroll) break;
if (declaration.end_line < file.scroll) continue;
@@ -1882,7 +1886,7 @@ pub const File = struct {
var body_line: usize = f.scroll;
var body_at: usize = 0;
var written: usize = 0;
- for (0..pane.rows) |i| {
+ for (0..if (pane.body_rows > 0) pane.body_rows else pane.rows) |i| {
if (i > 0) {
if (dst) |out| out[written] = '\n';
written += 1;
@@ -1950,6 +1954,7 @@ pub const File = struct {
const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(context_row)));
cell.default = false;
cell.style.bg = .{ .rgb = context_bg };
+ if (p.settings.tree_context_tag_style) cell.style.font_role = .tagline;
}
}
var vr: u16 = 0;
diff --git a/src/pardes.zig b/src/pardes.zig
index 3965c200..b6d6ed8f 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -69,6 +69,8 @@ pub const pdf_raster_policy: PdfRasterPolicy = switch (platform) {
pub const Pane = panes.Pane;
pub const MAX_PANES = 16;
+pub const BodyLayer = @import("body_layer.zig").Layer;
+pub const RowMetrics = @import("body_layer.zig").Metrics;
pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px;
pub const MAX_COLS = 6;
const column_weight_unit = layout.column_weight_unit;
@@ -253,7 +255,9 @@ pub const Loc = struct {
col: u32,
};
-test {}
+test {
+ _ = @import("body_layer.zig");
+}
const pane_tail = " " ++ config.pane_builtins_str;
const pdf_pane_tail = " " ++ config.pdf_pane_builtins_str;
@@ -1118,6 +1122,45 @@ test "LocationsConfig command reports partial updates and survives restore" {
try std.testing.expectEqual(expected, restored.locations_config);
}
+test "TreeContext tag style preserves message rows and its opt-out survives restore" {
+ if (comptime !syntax.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\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;
+ p.update(.{ .resize = .{ .cols = 60, .rows = 16, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } });
+ pane.file.?.tree_context = true;
+ pane.file.?.scroll = 8;
+ pane.msg_len = 0;
+ _ = try p.render(p.scratch.allocator());
+ try std.testing.expect(p.settings.tree_context_tag_style);
+ const full_height = p.surface.body_layers[0].viewport.h;
+ try std.testing.expect(p.surface.body_layers[0].rows > full_height);
+ try std.testing.expectEqual(FontRole.tagline, p.surface.body_layers[0].cells[0].style.font_role);
+ pane.msg[0] = '!';
+ pane.msg_len = 1;
+ _ = try p.render(p.scratch.allocator());
+ try std.testing.expectEqual(full_height - 1, p.surface.body_layers[0].viewport.h);
+ const r = p.rects[0];
+ try std.testing.expectEqualStrings("!", p.surface.at(r.x + config.GUTTER, r.y + r.h - 1).grapheme());
+ pane.msg_len = 0;
+ p.leader_on = true;
+ _ = try p.render(p.scratch.allocator());
+ try std.testing.expectEqual(full_height - 1, p.surface.body_layers[0].viewport.h);
+ p.leader_on = false;
+ try std.testing.expect(p.executeBuiltinLine(0, "TreeContextTagStyle"));
+ _ = try p.render(p.scratch.allocator());
+ try std.testing.expectEqual(@as(u16, 0), p.surface.body_layers[0].rows);
+ try std.testing.expectEqual(@as(u16, 0), pane.body_rows);
+ try std.testing.expectEqual(FontRole.body, p.surface.at(r.x + config.GUTTER, r.y + BOX_H).style.font_role);
+ try p.dumpState();
+ const restored = try Pardes.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?);
+ defer restored.deinit();
+ try std.testing.expect(!restored.settings.tree_context_tag_style);
+}
+
test "TreeContext command and custom tag survive a workspace round trip" {
if (comptime !syntax.enabled) return error.SkipZigTest;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
@@ -3496,6 +3539,7 @@ test "pixel attachment fit and pan do not invalidate cached pixels" {
pub const Surface = struct {
pub const PointerShape = enum(u8) { arrow, link };
pointer_shape: PointerShape = .arrow,
+ body_layers: [MAX_PANES]BodyLayer = @splat(.{}),
cols: u16 = 0,
rows: u16 = 0,
cells: []Cell = &.{},
@@ -3506,8 +3550,13 @@ pub const Surface = struct {
panel_tracks: [MAX_PANES * 2]layout.Track = undefined,
npanel_tracks: usize = 0,
previous_cells: []const Cell = &.{},
+ previous_body_layers: []const BodyLayer = &.{},
cell_diffs: []const PanelCellDiff = &.{},
+ pub fn bodyLayers(s: *const Surface) []const BodyLayer {
+ return &s.body_layers;
+ }
+
pub fn panelTracks(s: *const Surface) []const layout.Track {
return s.panel_tracks[0..s.npanel_tracks];
}
@@ -4388,6 +4437,16 @@ test "normal header Enter executes its command rather than searching for it" {
}
pub const Mouse = struct {
+ /// The host has inverted a compact body layer. Physical col/row still
+ /// route pane chrome; these coordinates address the pane's logical text.
+ pub const BodyHit = struct {
+ serial: u32,
+ row: u16,
+ col: u16,
+ pixel_x: f32,
+ pixel_y: f32,
+ metrics: RowMetrics,
+ };
// sel indexing relies on left/middle/right being 0/1/2
pub const Button = enum { left, middle, right, wheel_up, wheel_down, wheel_left, wheel_right, none, back, forward };
pub const Kind = enum { press, release, motion, drag };
@@ -4396,6 +4455,7 @@ pub const Mouse = struct {
col: u16,
row: u16,
ctrl: bool = false,
+ body_hit: ?BodyHit = null,
};
pub const Key = struct {
@@ -4487,6 +4547,7 @@ pub const Event = union(enum) {
cols: u16,
rows: u16,
cell_pixels: CellPixels = .{},
+ row_metrics: ?RowMetrics = null,
},
output: struct { pane: u8, bytes: []const u8 },
eof: struct { pane: u8 },
@@ -4577,6 +4638,7 @@ const LookHoverWait = struct {
pane: usize,
serial: u32,
frames: u16 = 0,
+ body_hit: ?Mouse.BodyHit = null,
};
const LookHoverPreview = struct {
@@ -4586,6 +4648,7 @@ const LookHoverPreview = struct {
serial: u32,
sel: ?Pane.Sel,
file_word: ?FileWordSpan = null,
+ body_hit: ?Mouse.BodyHit = null,
};
const FileWordSpan = struct {
@@ -5732,6 +5795,7 @@ pub const Pardes = struct {
screen_w: u16,
screen_h: u16,
cell_pixels: CellPixels = .{},
+ row_metrics: RowMetrics = .{},
resize_count: usize = 0,
panes: [MAX_PANES]?*Pane = @splat(null),
@@ -5779,6 +5843,7 @@ pub const Pardes = struct {
hover_row: u16 = 0,
pointer_raw_col: u16 = 0,
pointer_raw_row: u16 = 0,
+ pointer_body_hit: ?Mouse.BodyHit = null,
raw_hover_intent: bool = false,
pointer_inside: bool = false,
look_hover_wait: ?LookHoverWait = null,
@@ -6009,6 +6074,7 @@ pub const Pardes = struct {
p.scratch.deinit();
p.frame_arena.deinit();
gpa.free(p.surface.cells);
+ for (&p.surface.body_layers) |*layer| gpa.free(layer.cells);
p.presentation.deinit(gpa);
if (p.surface.images.len > 0) gpa.free(p.surface.images);
gpa.destroy(p);
@@ -6871,6 +6937,7 @@ pub const Pardes = struct {
switch (ev) {
.resize => |sz| {
p.presentation.snap_once = true;
+ if (sz.row_metrics) |metrics| p.row_metrics = metrics;
if (comptime pdf_enabled) {
var before: [MAX_PANES]?panes.Pdf.Viewport = @splat(null);
for (p.panes, 0..) |slot, id| {
@@ -6933,7 +7000,10 @@ pub const Pardes = struct {
.fs_req => |r| p.emit(.{ .fs_reply = filesystem.handle(p, r) }),
.pinch => |scale| p.ov_pinch_scale = scale,
.touch_scroll => |delta| p.ov_touch_scroll_delta = delta,
- .pointer_leave => p.pointer_inside = false,
+ .pointer_leave => {
+ p.pointer_inside = false;
+ p.pointer_body_hit = null;
+ },
.tick => {
p.chrome_animation.advance();
p.presentation.advance();
@@ -7594,7 +7664,8 @@ pub const Pardes = struct {
const body = try p.bodyText(p.scratch.allocator(), pane);
const terminal_lines = if (pane.file == null and !raw) try panes.Terminal.cursorLines(p, pane) else &.{};
const first = @max(0, @min(gesture.r0, gesture.r1) - BOX_H);
- const last = @min(@as(i32, pane.rows) - 1, @max(gesture.r0, gesture.r1) - BOX_H);
+ const logical_rows = if (pane.body_rows > 0) pane.body_rows else pane.rows;
+ const last = @min(@as(i32, logical_rows) - 1, @max(gesture.r0, gesture.r1) - BOX_H);
if (first > last) return;
const rows = try p.gpa.alloc(Pane.PointerRow, @intCast(last - first + 1));
errdefer p.gpa.free(rows);
@@ -10812,13 +10883,43 @@ pub const Pardes = struct {
on_tag: bool,
};
- fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16) ?PointerTextSelection {
+ fn bodyHitForPane(p: *const Pardes, id: usize, supplied: Mouse.BodyHit, clamp: bool) ?Mouse.BodyHit {
+ const pane = p.panes[id] orelse return null;
+ if (pane.file == null or (!clamp and supplied.serial != pane.serial)) return null;
+ if (!std.math.isFinite(supplied.pixel_x) or !std.math.isFinite(supplied.pixel_y) or
+ supplied.metrics.body_w == 0 or supplied.metrics.body_h == 0 or
+ supplied.metrics.tagline_w == 0 or supplied.metrics.tagline_h == 0) return null;
+ var mapped_hit = supplied;
+ if (clamp) {
+ mapped_hit.serial = pane.serial;
+ const layer = for (&p.surface.body_layers) |*layer| {
+ if (layer.rows > 0 and layer.serial == pane.serial) break layer;
+ } else return null;
+ const bw: f32 = @floatFromInt(mapped_hit.metrics.body_w);
+ const bh: f32 = @floatFromInt(mapped_hit.metrics.body_h);
+ const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * bw;
+ const y0 = @as(f32, @floatFromInt(layer.viewport.y)) * bh;
+ const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * bw;
+ const y1 = y0 + @as(f32, @floatFromInt(layer.viewport.h)) * bh;
+ mapped_hit.pixel_x = std.math.clamp(mapped_hit.pixel_x, x0, @max(x0, x1 - 0.001));
+ mapped_hit.pixel_y = std.math.clamp(mapped_hit.pixel_y, y0, @max(y0, y1 - 0.001));
+ }
+ return p.reprojectBodyHit(mapped_hit);
+ }
+
+ fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16, body_hit: ?Mouse.BodyHit) ?PointerTextSelection {
if (p.panes[id] == null) return null;
const r = p.rects[id];
if (col < r.x + config.GUTTER or col >= r.x + r.w or row < r.y or row >= r.y + r.h) return null;
const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H;
const on_tag = row >= tag_y and row < tag_y + BOX_H;
+ if (!on_tag) if (body_hit) |supplied| {
+ const mapped_hit = p.bodyHitForPane(id, supplied, false) orelse return null;
+ const c = @as(i32, mapped_hit.col) - config.GUTTER;
+ const v = @as(i32, mapped_hit.row) + BOX_H;
+ return .{ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }, .on_tag = false };
+ };
const c: i32 = @as(i32, col) - @as(i32, r.x + config.GUTTER) +
(if (on_tag) @as(i32, p.panes[id].?.tag_scroll) else 0);
const v: i32 = if (on_tag) 0 else @as(i32, row) - @as(i32, body_y) + @as(i32, BOX_H);
@@ -10848,22 +10949,28 @@ pub const Pardes = struct {
return null;
}
- fn noteLookHover(p: *Pardes, col: u16, row: u16) void {
+ fn sameBodyCell(a: ?Mouse.BodyHit, b: ?Mouse.BodyHit) bool {
+ const first = a orelse return b == null;
+ const second = b orelse return false;
+ return first.serial == second.serial and first.row == second.row and first.col == second.col;
+ }
+
+ fn noteLookHover(p: *Pardes, col: u16, row: u16, body_hit: ?Mouse.BodyHit) void {
const delay = config.look_preview_delay_frames orelse return;
_ = delay;
const id = p.paneAt(col, row) orelse return p.cancelLookHover();
const pane = p.panes[id] orelse return p.cancelLookHover();
- _ = p.pointerTextSelection(id, col, row) orelse return p.cancelLookHover();
+ _ = p.pointerTextSelection(id, col, row, body_hit) orelse return p.cancelLookHover();
if (comptime pdf_enabled) if (p.pdf_hover_preview) |shown|
if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return;
if (p.look_hover_preview) |shown|
- if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return;
+ if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and sameBodyCell(shown.body_hit, body_hit)) return;
if (p.look_hover_wait) |waiting|
- if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial) return;
+ if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and sameBodyCell(waiting.body_hit, body_hit)) return;
p.cancelLookHover();
- p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial };
+ p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial, .body_hit = body_hit };
}
fn refreshLookHoverFromRaw(p: *Pardes) void {
@@ -10872,7 +10979,11 @@ pub const Pardes = struct {
return p.cancelLookHover();
p.hover_col = mapped.col;
p.hover_row = mapped.row;
- p.noteLookHover(mapped.col, mapped.row);
+ const body_hit: ?Mouse.BodyHit = if (p.pointer_body_hit) |mapped_hit|
+ p.bodyHitForPane(p.paneAt(mapped.col, mapped.row) orelse return p.cancelLookHover(), mapped_hit, false) orelse return p.cancelLookHover()
+ else
+ null;
+ p.noteLookHover(mapped.col, mapped.row, body_hit);
}
fn advanceLookHover(p: *Pardes) void {
@@ -10882,7 +10993,7 @@ pub const Pardes = struct {
if (waiting.frames < delay) return;
const pane = p.panes[waiting.pane] orelse return p.cancelLookHover();
if (pane.serial != waiting.serial) return p.cancelLookHover();
- const pointed = p.pointerTextSelection(waiting.pane, waiting.col, waiting.row) orelse
+ const pointed = p.pointerTextSelection(waiting.pane, waiting.col, waiting.row, waiting.body_hit) orelse
return p.cancelLookHover();
if (comptime pdf_enabled) if (!pointed.on_tag and panes.Pdf.paneNativeReady(p, pane)) {
const probe = panes.Pdf.probeAt(p, pane, waiting.col, waiting.row) orelse
@@ -10910,6 +11021,7 @@ pub const Pardes = struct {
.serial = waiting.serial,
.sel = preview,
.file_word = operand.file_word,
+ .body_hit = waiting.body_hit,
};
p.look_hover_wait = null;
}
@@ -10920,7 +11032,7 @@ pub const Pardes = struct {
if (p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row)) |mapped| {
p.hover_col = mapped.col;
p.hover_row = mapped.row;
- p.dragUpdate(mapped.col, mapped.row);
+ p.dragUpdate(mapped.col, mapped.row, p.pointer_body_hit);
}
}
p.refreshLookHoverFromRaw();
@@ -10946,6 +11058,18 @@ pub const Pardes = struct {
const raw_row = @min(m.row, p.screen_h -| 1);
p.pointer_raw_col = raw_col;
p.pointer_raw_row = raw_row;
+ const previous_hit = p.pointer_body_hit;
+ p.pointer_body_hit = m.body_hit;
+ // A drag can leave the compact text viewport. Preserve its metrics so
+ // the endpoint still clamps to that body's last visible logical row.
+ if (m.body_hit == null and (m.kind == .drag or m.kind == .release) and p.drag == .select) {
+ if (previous_hit) |previous| {
+ var continued = previous;
+ continued.pixel_x = (@as(f32, @floatFromInt(raw_col)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_w));
+ continued.pixel_y = (@as(f32, @floatFromInt(raw_row)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_h));
+ p.pointer_body_hit = continued;
+ }
+ }
p.pointer_inside = true;
if (m.button == .none and m.kind == .motion) {
p.raw_hover_intent = true;
@@ -11137,10 +11261,9 @@ pub const Pardes = struct {
} else {
p.active = id;
if (pane.tag_edit) exitTagEdit(pane);
- const sc: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
- const v: i32 = @as(i32, mrow) - @as(i32, body_y) + @as(i32, BOX_H);
+ const pointed = p.pointerTextSelection(id, mcol, mrow, m.body_hit) orelse return;
pane.clearPointerSelection(sel_slot);
- pane.sel[sel_slot] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v };
+ pane.sel[sel_slot] = pointed.sel;
p.drag = .{ .select = .{
.id = id,
.button = config.select_button,
@@ -11156,8 +11279,11 @@ pub const Pardes = struct {
} };
}
},
- .drag => p.dragUpdate(mcol, mrow),
- .release => p.dragRelease(config.select_button),
+ .drag => p.dragUpdate(mcol, mrow, p.pointer_body_hit),
+ .release => {
+ if (p.pointer_body_hit != null and p.drag.button() == m.button) p.dragUpdate(mcol, mrow, p.pointer_body_hit);
+ p.dragRelease(config.select_button);
+ },
.motion => {},
},
config.exec_button, config.look_button => switch (m.kind) {
@@ -11191,7 +11317,7 @@ pub const Pardes = struct {
const local = @as(i32, mrow) - @as(i32, body_y);
if (m.button == config.look_button) p.active = id;
pane.scrollBy(if (m.button == config.look_button) local else -local);
- } else if (p.pointerTextSelection(id, mcol, mrow)) |pointed| {
+ } else if (p.pointerTextSelection(id, mcol, mrow, m.body_hit)) |pointed| {
if (m.button == config.look_button) p.active = id;
pane.clearPointerSelection(@intFromEnum(m.button));
pane.sel[@intFromEnum(m.button)] = pointed.sel;
@@ -11213,8 +11339,11 @@ pub const Pardes = struct {
};
}
},
- .drag => p.dragUpdate(mcol, mrow),
- .release => p.dragRelease(m.button),
+ .drag => p.dragUpdate(mcol, mrow, p.pointer_body_hit),
+ .release => {
+ if (p.pointer_body_hit != null and p.drag.button() == m.button) p.dragUpdate(mcol, mrow, p.pointer_body_hit);
+ p.dragRelease(m.button);
+ },
.motion => {},
},
}
@@ -11228,7 +11357,7 @@ pub const Pardes = struct {
return null;
}
- fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16) void {
+ fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) void {
switch (p.drag) {
.border_v => |*d| {
const c = d.left_col;
@@ -11261,6 +11390,12 @@ pub const Pardes = struct {
if (p.panes[s.id]) |pane| {
const r = p.rects[s.id];
const b = @intFromEnum(s.button);
+ if (pane.body_rows > 0 and pane.sel[b].r0 >= BOX_H) if (body_hit) |supplied| {
+ const mapped_hit = p.bodyHitForPane(s.id, supplied, true) orelse return;
+ pane.sel[b].c1 = @as(i32, mapped_hit.col) - config.GUTTER;
+ pane.sel[b].r1 = @as(i32, mapped_hit.row) + BOX_H;
+ return;
+ };
pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
if (p.settings.tag_bottom) {
const body_h = r.h -| BOX_H;
@@ -12095,6 +12230,7 @@ pub const Pardes = struct {
pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]const u8) void {
const previous_colors = p.settings.colors;
+ const previous_context_style = p.settings.tree_context_tag_style;
const previous_transition = p.settings.panel_transition;
const previous_tagline_percent = p.settings.font.tagline_percent;
const previous_column_tags = p.settings.column_tags;
@@ -12122,6 +12258,17 @@ pub const Pardes = struct {
if (pane.file) |*file| file.syntax_dirty = true;
}
}
+ if (p.settings.tree_context_tag_style != previous_context_style) {
+ for (p.panes, 0..) |slot, id| {
+ p.surface.body_layers[id].rows = 0;
+ const pane = slot orelse continue;
+ pane.body_rows = 0;
+ pane.body_visible_rows = 0;
+ pane.context_row_limit = null;
+ pane.wrap_n = 0;
+ }
+ p.abandonPanelAnimations();
+ }
const transition_changed = setting.action == .transition and
p.settings.panel_transition != previous_transition;
const tagline_metrics_changed = setting.action == .tagline_size and
@@ -12242,6 +12389,7 @@ pub const Pardes = struct {
.topbar_custom = if (p.global_tag.custom) p.global_tag.slice("") else null,
.theme = p.theme().name,
.locations_config = p.locations_config,
+ .tree_context_tag_style = p.settings.tree_context_tag_style,
.columns = columns[0..columns_len],
.panes = dump_panes[0..panes_len],
.mounts = mounts[0..p.fs.mounts.items.len],
@@ -12294,6 +12442,7 @@ pub const Pardes = struct {
p.lsp_seq = old.lsp_seq;
p.pipe_seq = old.pipe_seq;
p.cell_pixels = old.cell_pixels;
+ p.row_metrics = old.row_metrics;
p.native_images = old.native_images;
p.fs.socket_path = old.fs.socket_path;
p.fs.tcp_address = old.fs.tcp_address;
@@ -12306,6 +12455,7 @@ pub const Pardes = struct {
defer parsed.deinit();
const st = parsed.value;
p.locations_config = st.locations_config;
+ p.settings.tree_context_tag_style = st.tree_context_tag_style;
for (st.mounts) |mount| {
var already_mounted = false;
for (p.fs.mounts.items) |existing| {
@@ -12722,6 +12872,14 @@ pub const Pardes = struct {
p.invalidateThemeDependentRasters();
}
+ 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 null;
+ }
+
// ---- render: build the canonical surface ----
pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
@@ -12745,9 +12903,11 @@ pub const Pardes = struct {
}
}
};
+ for (&s.body_layers) |*layer| layer.rows = 0;
s.nimages = 0;
s.npanel_tracks = 0;
s.previous_cells = &.{};
+ s.previous_body_layers = &.{};
s.cell_diffs = &.{};
const chrome = p.chromeTheme();
const th = p.theme(); // the message row paints in the editor's colours
@@ -12756,7 +12916,12 @@ pub const Pardes = struct {
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 p.renderPane(arena, pane, p.rects[id], id, id == p.active);
+ try p.renderBodyLayer(arena, pane, p.rects[id], id);
}
for (&p.panes, 0..) |*slot, id| {
const pane = slot.* orelse continue;
@@ -13190,6 +13355,56 @@ pub const Pardes = struct {
}
}
+ 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 <= BOX_H) return;
+ const message = if (pane.promptAt()) |at| pane.tagSlice()[@min(at, pane.tag_tail_len)..].len > 0 else pane.msg_len > 0;
+ const reserve: u16 = if (message or (id == p.active and p.leader_on)) 1 else 0;
+ const viewport_rows = r.h - BOX_H -| reserve;
+ 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;
+ 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);
+ const body_pixels = @as(usize, viewport_rows) * bh;
+ pane.context_row_limit = @intCast(@min(pane.context_rows, (body_pixels -| bh) / th));
+ 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 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 + BOX_H));
+ @memset(temporary_cells, .{});
+ var temporary: Surface = .{ .cols = r.w, .rows = @intCast(logical_rows + BOX_H), .cells = temporary_cells };
+ std.mem.swap(Surface, &p.surface, &temporary);
+ const synthetic: Rect = .{ .x = 0, .y = 0, .w = r.w, .h = @intCast(logical_rows + BOX_H) };
+ p.renderBody(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);
+ const first_row: usize = if (p.settings.tag_bottom) 0 else BOX_H;
+ 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.serial = pane.serial;
+ layer.viewport = .{ .x = r.x + config.GUTTER, .y = if (p.settings.tag_bottom) r.y else r.y + BOX_H, .w = width, .h = viewport_rows };
+ layer.cols = width;
+ layer.rows = @intCast(logical_rows);
+ layer.context_rows = pane.context_rows;
+ layer.cursor = null;
+ if (temporary.cursor) |cursor| {
+ 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 };
+ }
+ }
+
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();
@@ -13315,6 +13530,19 @@ pub const Pardes = struct {
return;
}
+ try p.renderBody(arena, pane, r, id, active, true);
+ }
+
+ fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, scrollbar: bool) !void {
+ const s = &p.surface;
+ const th = p.theme();
+ const chrome = p.chromeTheme();
+ const tx = r.x + config.GUTTER;
+ const tw = r.w - config.GUTTER;
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H;
+ const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default;
+ const page_rgb = th.bg orelse th.tag_bg;
// body: emulator rows, prompt rows hidden outside tty mode
const body_h = if (r.h > BOX_H) r.h - BOX_H else 0;
const body_style: CellStyle = .{
@@ -13503,7 +13731,7 @@ pub const Pardes = struct {
}
}
- if (r.h > BOX_H) {
+ if (scrollbar and r.h > BOX_H) {
s.fill(r.x, body_y, 1, r.h - BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
s.fill(r.x + 1, body_y, 1, r.h - BOX_H, .{ .bg = pane_bg });
const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{
diff --git a/src/web.zig b/src/web.zig
index a1f3bad8..a944a4a5 100644
--- a/src/web.zig
+++ b/src/web.zig
@@ -99,6 +99,7 @@ comptime {
const State = struct {
core: *pardes.Pardes,
+ layer_cells: [pardes.MAX_PANES][]WebCell = @splat(&.{}),
cells: []WebCell = &.{},
frame_len: usize = 0,
frame_cols: u16 = 0,
@@ -127,6 +128,7 @@ const State = struct {
fn deinit(s: *State) void {
if (s.cells.len > 0) gpa.free(s.cells);
+ for (s.layer_cells) |cells| gpa.free(cells);
s.core.deinit();
pardes.image.stop();
if (comptime pardes.pdf_enabled) pardes.pdf.stop();
@@ -207,6 +209,22 @@ export fn pardes_paste(len_arg: u32) void {
/// Buttons and kinds are their boundary enum ordinals. Invalid values are
/// ignored rather than reaching an unchecked enum cast inside wasm.
export fn pardes_mouse(button_arg: u32, kind_arg: u32, col_arg: u32, row_arg: u32, mods: u32) void {
+ mouseWithHit(button_arg, kind_arg, col_arg, row_arg, mods, null);
+}
+
+export fn pardes_mouse_pixel(button_arg: u32, kind_arg: u32, col_arg: u32, row_arg: u32, mods: u32, x: f32, y: f32, bw: f32, bh: f32, tw: f32, th: f32) void {
+ const s = &(state orelse return);
+ const source = s.core.presentation.pointerFractional(s.core.screen_w, s.core.screen_h, x / @max(1, bw), y / @max(1, bh));
+ 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);
+ if (hit != null) break;
+ }
+ mouseWithHit(button_arg, kind_arg, col_arg, row_arg, mods, hit);
+}
+
+fn mouseWithHit(button_arg: u32, kind_arg: u32, col_arg: u32, row_arg: u32, mods: u32, hit: ?pardes.Mouse.BodyHit) void {
const s = &(state orelse return);
const button: pardes.Mouse.Button = switch (button_arg) {
0 => .left,
@@ -229,6 +247,7 @@ export fn pardes_mouse(button_arg: u32, kind_arg: u32, col_arg: u32, row_arg: u3
else => return,
};
s.core.update(.{ .mouse = .{
+ .body_hit = hit,
.button = button,
.kind = kind,
.col = @intCast(@min(col_arg, std.math.maxInt(u16))),
@@ -304,6 +323,46 @@ export fn pardes_cursor_x() i32 {
return if (s.core.surface.cursor) |cursor| cursor.x else -1;
}
+export fn pardes_body_layer_value(index: u32, field: u32) u32 {
+ const s = &(state orelse return 0);
+ if (index >= pardes.MAX_PANES) return 0;
+ const layer = &s.core.surface.body_layers[index];
+ return switch (field) {
+ 0 => layer.rows,
+ 1 => layer.cols,
+ 2 => layer.context_rows,
+ 3 => layer.viewport.x,
+ 4 => layer.viewport.y,
+ 5 => layer.viewport.w,
+ 6 => layer.viewport.h,
+ 7 => layer.serial,
+ 8 => if (layer.cursor) |c| c.x else 65535,
+ 9 => if (layer.cursor) |c| c.y else 65535,
+ 10 => if (layer.cursor) |c| @intFromBool(c.bar) else 0,
+ else => 0,
+ };
+}
+
+export fn pardes_body_layer_cells(index: u32) ?[*]const WebCell {
+ const s = &(state orelse return null);
+ if (index >= pardes.MAX_PANES) return null;
+ const layer = &s.core.surface.body_layers[index];
+ if (layer.rows == 0) return null;
+ const cells = &s.layer_cells[index];
+ if (cells.len != layer.cells.len) cells.* = gpa.realloc(cells.*, layer.cells.len) catch return null;
+ for (layer.cells, cells.*) |cell, *out| out.* = encodeWebCell(cell);
+ return cells.ptr;
+}
+
+export fn pardes_body_layer_limit() u32 {
+ return pardes.MAX_PANES;
+}
+
+export fn pardes_row_metrics(body_w: u16, body_h: u16, tagline_w: u16, tagline_h: u16) void {
+ const s = &(state orelse return);
+ s.core.update(.{ .resize = .{ .cols = s.core.screen_w, .rows = s.core.screen_h, .cell_pixels = s.core.cell_pixels, .row_metrics = .{ .body_w = @max(1, body_w), .body_h = @max(1, body_h), .tagline_w = @max(1, tagline_w), .tagline_h = @max(1, tagline_h) } } });
+}
+
export fn pardes_pointer_shape() u32 {
const s = &(state orelse return 0);
return @intFromEnum(s.core.surface.pointer_shape);
@@ -384,17 +443,13 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void {
s.frame_len = count;
s.frame_cols = surface.cols;
s.frame_rows = surface.rows;
- for (surface.cells, s.cells[0..count]) |cell, *out| {
- out.* = .{
- .text = @splat(0),
- .fg = encodeColor(cell.style.fg),
- .bg = encodeColor(cell.style.bg),
- .attrs = encodeAttrs(cell.style),
- .len = if (cell.default) 1 else cell.len,
- .flags = @intFromBool(cell.default),
- };
- if (cell.default) out.text[0] = ' ' else @memcpy(out.text[0..cell.len], cell.grapheme());
- }
+ for (surface.cells, s.cells[0..count]) |cell, *out| out.* = encodeWebCell(cell);
+}
+
+fn encodeWebCell(cell: pardes.Cell) WebCell {
+ var out: WebCell = .{ .text = @splat(0), .fg = encodeColor(cell.style.fg), .bg = encodeColor(cell.style.bg), .attrs = encodeAttrs(cell.style), .len = if (cell.default) 1 else cell.len, .flags = @intFromBool(cell.default) };
+ if (cell.default) out.text[0] = ' ' else @memcpy(out.text[0..cell.len], cell.grapheme());
+ return out;
}
fn writeFile(_: ?*anyopaque, _: u8, path: []const u8, bytes: []const u8) void {
diff --git a/src/web/app.mjs b/src/web/app.mjs
index 37d07eb7..8e08c9d4 100644
--- a/src/web/app.mjs
+++ b/src/web/app.mjs
@@ -74,10 +74,71 @@ export class DomRenderer {
}
raw[index] = { text, fg, bg, attrs };
}
+ this.renderBodyLayers();
this.snapshot = { cols, rows, cells: raw, cursorX, cursorY, cursorBar };
return this.snapshot;
}
+ renderBodyLayers() {
+ if (!this.wasm.pardes_body_layer_limit || !this.cellWidth) return;
+ if (!this.layerRoot?.isConnected) {
+ this.layerRoot = this.root.ownerDocument.createElement("div");
+ this.layerRoot.style.cssText = "position:absolute;inset:0;pointer-events:none;overflow:hidden";
+ this.root.style.position = "relative";
+ this.root.append(this.layerRoot);
+ }
+ const nodes = [];
+ this.layerNodes ??= new Map();
+ const bw = this.cellWidth, bh = this.cellHeight;
+ const tw = this.taglineWidth, th = this.taglineHeight;
+ for (let i = 0; i < this.wasm.pardes_body_layer_limit(); i++) {
+ const val = (field) => this.wasm.pardes_body_layer_value(i, field);
+ const rows = val(0), cols = val(1), contexts = val(2);
+ if (!rows) continue;
+ const ptr = this.wasm.pardes_body_layer_cells(i);
+ if (!ptr) continue;
+ const view = new DataView(this.wasm.memory.buffer, ptr, rows * cols * CELL_SIZE);
+ const bytes = new Uint8Array(this.wasm.memory.buffer);
+ const geometry = [bw,bh,tw,th,...Array.from({length:11},(_,field)=>val(field))].join(":");
+ const encoded = bytes.subarray(ptr,ptr+rows*cols*CELL_SIZE);
+ const cached = this.layerNodes.get(i);
+ if (cached && cached.geometry===geometry && cached.bytes.length===encoded.length && cached.bytes.every((byte,index)=>byte===encoded[index])) {
+ nodes.push(cached.node);
+ continue;
+ }
+ const layer = this.root.ownerDocument.createElement("div");
+ layer.style.cssText = `position:absolute;overflow:hidden;left:${val(3)*bw}px;top:${val(4)*bh}px;width:${val(5)*bw}px;height:${val(6)*bh}px;background:${DEFAULT_BG}`;
+ for (let row = 0; row < rows; row++) {
+ const compact = row < contexts;
+ const height = compact ? th : bh, width = compact ? tw : bw;
+ const top = Math.min(row, contexts)*th + Math.max(0,row-contexts)*bh;
+ if (top >= val(6)*bh) break;
+ const line = this.root.ownerDocument.createElement("div");
+ line.style.cssText = `position:absolute;left:0;right:0;top:${top}px;height:${height}px;line-height:${height}px;white-space:pre;overflow:hidden`;
+ line.style.backgroundColor = color(view.getUint32(row*cols*CELL_SIZE+12,true),DEFAULT_BG);
+ for (let col = 0; col < cols; col++) {
+ const off = (row*cols+col)*CELL_SIZE;
+ const cell = this.root.ownerDocument.createElement("span");
+ cell.className = "pardes-cell";
+ cell.textContent = decoder.decode(bytes.subarray(ptr+off,ptr+off+view.getUint8(off+18))) || " ";
+ applyStyle(cell, view.getUint32(off+8,true), view.getUint32(off+12,true), view.getUint16(off+16,true), col===val(8)&&row===val(9), val(10)!==0);
+ cell.style.textAlign = "left";
+ cell.style.width = `${width}px`;
+ cell.style.height = `${height}px`;
+ cell.style.lineHeight = `${height}px`;
+ line.append(cell);
+ }
+ layer.append(line);
+ }
+ const border = this.root.ownerDocument.createElement("div");
+ border.style.cssText = `position:absolute;left:0;right:0;top:${contexts*th}px;height:1px;background:currentColor;opacity:.45`;
+ layer.append(border);
+ this.layerNodes.set(i,{geometry,bytes:encoded.slice(),node:layer});
+ nodes.push(layer);
+ }
+ if (nodes.length!==this.layerRoot.childNodes.length || nodes.some((node,index)=>node!==this.layerRoot.childNodes[index])) this.layerRoot.replaceChildren(...nodes);
+ }
+
ensureGrid(cols, rows) {
if (cols === this.cols && rows === this.rowCount) return;
const document = this.root.ownerDocument;
@@ -148,6 +209,10 @@ export class PardesRuntime {
frame(timestamp) {
if (!this.running) return;
+ if (this.wasm.pardes_gui_tagline_font_percent() !== this.measuredTaglinePercent) {
+ 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.
@@ -169,6 +234,11 @@ export class PardesRuntime {
probe.remove();
this.cellWidth = Math.max(1, rect.width);
this.cellHeight = Math.max(1, rect.height);
+ const ratio = this.wasm.pardes_gui_tagline_font_percent() / 100;
+ this.measuredTaglinePercent = this.wasm.pardes_gui_tagline_font_percent();
+ this.taglineWidth = this.cellWidth * ratio;
+ this.taglineHeight = this.cellHeight * ratio;
+ Object.assign(this.renderer, { cellWidth:this.cellWidth, cellHeight:this.cellHeight, taglineWidth:this.taglineWidth, taglineHeight:this.taglineHeight });
}
gridSize() {
@@ -182,6 +252,7 @@ export class PardesRuntime {
this.measure();
const { cols, rows } = this.gridSize();
this.wasm.pardes_resize(cols, rows);
+ this.wasm.pardes_row_metrics?.(Math.max(1,Math.round(this.cellWidth)),Math.max(1,Math.round(this.cellHeight)),Math.max(1,Math.round(this.taglineWidth)),Math.max(1,Math.round(this.taglineHeight)));
}
writeInput(text) {
@@ -204,6 +275,7 @@ export class PardesRuntime {
point(event) {
const rect = this.root.getBoundingClientRect();
+ this.sampledPointer = { x:event.clientX-rect.left, y:event.clientY-rect.top };
return {
col: clamp(Math.floor((event.clientX - rect.left) / this.cellWidth), 0, Math.max(0, this.wasm.pardes_frame_cols() - 1)),
row: clamp(Math.floor((event.clientY - rect.top) / this.cellHeight), 0, Math.max(0, this.wasm.pardes_frame_rows() - 1)),
@@ -215,12 +287,18 @@ export class PardesRuntime {
return { x: rect.left + (col + 0.5) * this.cellWidth, y: rect.top + (row + 0.5) * this.cellHeight };
}
+ mouse(button, kind, col, row, mods) {
+ if (this.wasm.pardes_mouse_pixel && this.sampledPointer) {
+ this.wasm.pardes_mouse_pixel(button,kind,col,row,mods,this.sampledPointer.x*256,this.sampledPointer.y*256,Math.round(this.cellWidth*256),Math.round(this.cellHeight*256),Math.round(this.taglineWidth*256),Math.round(this.taglineHeight*256));
+ } else this.wasm.pardes_mouse(button,kind,col,row,mods);
+ }
+
mouseDown(event) {
event.preventDefault();
if (event.button !== 3 && event.button !== 4) return;
this.root.focus({ preventScroll: true });
const point = this.point(event);
- this.wasm.pardes_mouse(pointerButton(event.button), MOUSE.press, point.col, point.row, modifiers(event));
+ this.mouse(pointerButton(event.button), MOUSE.press, point.col, point.row, modifiers(event));
}
pointerDown(event) {
@@ -236,12 +314,12 @@ export class PardesRuntime {
const chrome = packed !== 0xffffffff;
const snapped = chrome ? { col: packed & 0xffff, row: packed >>> 16 } : point;
this.pointer = { id: event.pointerId, touch: true, chrome, startX: event.clientX, startY: event.clientY, lastX: event.clientX, lastY: event.clientY, point: snapped, scrolling: false, accum: 0 };
- if (chrome) this.wasm.pardes_mouse(BUTTON.left, MOUSE.press, snapped.col, snapped.row, modifiers(event));
+ if (chrome) this.mouse(BUTTON.left, MOUSE.press, snapped.col, snapped.row, modifiers(event));
return;
}
const button = pointerButton(event.button);
this.pointer = { id: event.pointerId, touch: false, button };
- this.wasm.pardes_mouse(button, MOUSE.press, point.col, point.row, modifiers(event));
+ this.mouse(button, MOUSE.press, point.col, point.row, modifiers(event));
}
pointerMove(event) {
@@ -249,16 +327,16 @@ export class PardesRuntime {
const point = this.point(event);
const active = this.pointer?.id === event.pointerId ? this.pointer : null;
if (!active) {
- if (event.pointerType !== "touch") this.wasm.pardes_mouse(BUTTON.none, MOUSE.motion, point.col, point.row, modifiers(event));
+ if (event.pointerType !== "touch") this.mouse(BUTTON.none, MOUSE.motion, point.col, point.row, modifiers(event));
return;
}
event.preventDefault();
if (!active.touch) {
- this.wasm.pardes_mouse(active.button, MOUSE.drag, point.col, point.row, modifiers(event));
+ this.mouse(active.button, MOUSE.drag, point.col, point.row, modifiers(event));
return;
}
if (active.chrome) {
- this.wasm.pardes_mouse(BUTTON.left, MOUSE.drag, point.col, point.row, modifiers(event));
+ this.mouse(BUTTON.left, MOUSE.drag, point.col, point.row, modifiers(event));
return;
}
const travel = Math.hypot(event.clientX - active.startX, event.clientY - active.startY);
@@ -267,11 +345,11 @@ export class PardesRuntime {
const dy = event.clientY - active.lastY;
active.accum += dy / this.cellHeight;
while (active.accum <= -1) {
- this.wasm.pardes_mouse(BUTTON.wheelDown, MOUSE.press, point.col, point.row, 0);
+ this.mouse(BUTTON.wheelDown, MOUSE.press, point.col, point.row, 0);
active.accum += 1;
}
while (active.accum >= 1) {
- this.wasm.pardes_mouse(BUTTON.wheelUp, MOUSE.press, point.col, point.row, 0);
+ this.mouse(BUTTON.wheelUp, MOUSE.press, point.col, point.row, 0);
active.accum -= 1;
}
this.wasm.pardes_touch_scroll(dy / this.cellHeight);
@@ -290,13 +368,13 @@ export class PardesRuntime {
event.preventDefault();
const point = this.point(event);
if (active.touch) {
- if (active.chrome) this.wasm.pardes_mouse(BUTTON.left, MOUSE.release, point.col, point.row, modifiers(event));
+ if (active.chrome) this.mouse(BUTTON.left, MOUSE.release, point.col, point.row, modifiers(event));
else if (!active.scrolling) {
- this.wasm.pardes_mouse(BUTTON.right, MOUSE.press, point.col, point.row, 0);
- this.wasm.pardes_mouse(BUTTON.right, MOUSE.release, point.col, point.row, 0);
+ this.mouse(BUTTON.right, MOUSE.press, point.col, point.row, 0);
+ this.mouse(BUTTON.right, MOUSE.release, point.col, point.row, 0);
}
} else {
- this.wasm.pardes_mouse(active.button, MOUSE.release, point.col, point.row, modifiers(event));
+ this.mouse(active.button, MOUSE.release, point.col, point.row, modifiers(event));
}
if (this.root.hasPointerCapture?.(event.pointerId)) this.root.releasePointerCapture(event.pointerId);
this.pointer = null;
@@ -305,8 +383,8 @@ export class PardesRuntime {
wheel(event) {
event.preventDefault();
const point = this.point(event);
- if (event.deltaY) this.wasm.pardes_mouse(event.deltaY < 0 ? BUTTON.wheelUp : BUTTON.wheelDown, MOUSE.press, point.col, point.row, modifiers(event));
- if (event.deltaX) this.wasm.pardes_mouse(event.deltaX < 0 ? BUTTON.wheelLeft : BUTTON.wheelRight, MOUSE.press, point.col, point.row, modifiers(event));
+ if (event.deltaY) this.mouse(event.deltaY < 0 ? BUTTON.wheelUp : BUTTON.wheelDown, MOUSE.press, point.col, point.row, modifiers(event));
+ if (event.deltaX) this.mouse(event.deltaX < 0 ? BUTTON.wheelLeft : BUTTON.wheelRight, MOUSE.press, point.col, point.row, modifiers(event));
}
paste(event) {
diff --git a/test/panes.zig b/test/panes.zig
index a452bcfb..16e40ee3 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -190,6 +190,60 @@ const TagNavigationTests = struct {
}
};
+const BodyHitTests = struct {
+ fn mouse(layer: *const pardes.BodyLayer, row: u16, col: u16, kind: pardes.Mouse.Kind) pardes.Mouse {
+ const width: f32 = if (row < layer.context_rows) 8 else 10;
+ const x = @as(f32, @floatFromInt(layer.viewport.x)) * 10 + (@as(f32, @floatFromInt(col)) + 0.5) * width;
+ const y = layer.rowTop(row, 20, 10) + layer.rowHeight(row, 20, 10) / 2;
+ return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .body_hit = layer.hitAt(x, y, 10, 20, 8, 10).? };
+ }
+
+ test "compact TreeContext mouse follows source rows in reclaimed space" {
+ if (!syntax.enabled) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 64, .rows = 18 });
+ defer p.deinit();
+ p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 8, .tagline_h = 10 };
+ const pane = try TreeContextTests.setZigFile(p, "const Box = struct {\n pub fn work(\n value: usize,\n ) void {\n" ++ " // body\n" ** 60 ++ " }\n};\n");
+ try std.testing.expect(p.executeBuiltinLine(0, "TreeContext"));
+ pane.scrollBy(10);
+ var frame = std.heap.ArenaAllocator.init(p.gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const layer = &p.surface.body_layers[0];
+ try std.testing.expect(layer.context_rows >= 4);
+ try std.testing.expect(layer.rows > pane.rows);
+ const first = mouse(layer, 0, 2, .press);
+ const second = mouse(layer, 1, 2, .press);
+ try std.testing.expectEqual(first.row, second.row);
+ const top = pane.scroll();
+ for ([_]u16{ 0, 1 }) |row| {
+ const expected = pane.wrapAt(row).line;
+ p.update(.{ .mouse = mouse(layer, row, 2, .press) });
+ p.update(.{ .mouse = mouse(layer, row, 2, .release) });
+ try std.testing.expectEqual(expected, pane.cur_row);
+ try std.testing.expectEqual(top, pane.scroll());
+ }
+ const last = pane.body_visible_rows - 1;
+ try std.testing.expect(last >= pane.rows);
+ const expected = pane.wrapAt(last).line;
+ const prefix = panes.File.gutterWidth(pane);
+ p.update(.{ .mouse = mouse(layer, last - 1, prefix + 11, .press) });
+ // Release at a different source row without an intermediate drag event.
+ p.update(.{ .mouse = mouse(layer, last, prefix + 14, .release) });
+ try std.testing.expectEqualStrings("body\nbody", pardes.test_api.heldSelection(p, 0).?);
+ p.update(.{ .mouse = mouse(layer, last, 10, .press) });
+ p.update(.{ .mouse = mouse(layer, last, 10, .release) });
+ try std.testing.expectEqual(expected, pane.cur_row);
+ var stale = mouse(layer, 0, 2, .press);
+ stale.body_hit.?.serial +%= 1;
+ p.update(.{ .mouse = stale });
+ try std.testing.expectEqual(expected, pane.cur_row);
+ p.update(.{ .key = .{ .cp = 'i' } });
+ p.update(.{ .key = .{ .cp = 'X', .text = "X" } });
+ try std.testing.expect(std.mem.indexOfScalar(u8, modal.lineSlice(pane.file.?.content, @intCast(expected)), 'X') != null);
+ }
+};
+
const TreeContextTests = struct {
const source = "const Box = struct {\n" ++
" pub fn work(\n" ++
@@ -3556,6 +3610,7 @@ test {
_ = TagNavigationTests;
_ = TagNameTintTests;
_ = TreeContextTests;
+ _ = BodyHitTests;
_ = TtySelectionTests;
_ = ClickCursorTests;
_ = JumpSelectionTests;
diff --git a/test/web-snapshots/touch.mjs b/test/web-snapshots/touch.mjs
index 8c34b185..4dde2fa4 100644
--- a/test/web-snapshots/touch.mjs
+++ b/test/web-snapshots/touch.mjs
@@ -42,6 +42,42 @@ function styled(frame) {
export default async function ({ grid, touch, wait, stable, delay, evaluate, mouse }) {
await stable(100, 5000);
+ const compactGeometry = await evaluate(`(() => {
+ const root = document.createElement("div");
+ root.className = "pardes";
+ root.style.cssText = "position:fixed;left:0;top:0;width:20px;height:40px";
+ document.body.append(root);
+ const memory = new WebAssembly.Memory({initial:1});
+ const bytes = new Uint8Array(memory.buffer), data = new DataView(memory.buffer);
+ for (const [base,count] of [[0,4],[1024,6]]) for(let i=0;i<count;i++) {
+ const off=base+i*20; bytes[off]=65+i; bytes[off+18]=1;
+ data.setUint32(off+8,0xffffff,true); data.setUint32(off+12,0x123456,true);
+ if(base && i<2) data.setUint16(off+16,128,true);
+ }
+ const values=[3,2,1,0,0,2,2,42,0,2,1];
+ const wasm={memory,pardes_gui_tagline_font_percent:()=>50,pardes_frame:()=>4,
+ pardes_pointer_shape:()=>0,pardes_frame_cols:()=>2,pardes_frame_rows:()=>2,
+ pardes_frame_ptr:()=>0,pardes_cursor_x:()=>65535,pardes_cursor_y:()=>65535,pardes_cursor_bar:()=>0,
+ pardes_body_layer_limit:()=>1,pardes_body_layer_value:(_,field)=>values[field],pardes_body_layer_cells:()=>1024};
+ const renderer=new pardes.runtime.renderer.constructor(root,wasm);
+ Object.assign(renderer,{cellWidth:10,cellHeight:20,taglineWidth:5,taglineHeight:10});
+ renderer.render();
+ const layer=renderer.layerRoot.firstChild, rect=layer.getBoundingClientRect();
+ const bands=[...layer.children].slice(0,3).map(node=>{
+ const box=node.getBoundingClientRect();return [box.y-rect.y,box.height];
+ });
+ const cursor=layer.querySelector('.is-cursor');
+ renderer.render();
+ const result={bands,height:rect.height,clipped:getComputedStyle(layer).overflow,
+ cursor:cursor?.textContent,cached:renderer.layerRoot.firstChild===layer};
+ root.remove();return result;
+ })()`);
+ assert.deepEqual(compactGeometry.bands, [[0,10],[10,20],[30,20]], "compact headers release real height to additional body rows");
+ assert.equal(compactGeometry.height,40);
+ assert.equal(compactGeometry.clipped,"hidden");
+ assert.equal(compactGeometry.cursor,"E");
+ assert(compactGeometry.cached,"unchanged compact layers reuse their DOM nodes");
+
let frame = await grid();
assert.equal(tags(frame).length, 1);
styled(frame);
@@ -118,6 +154,9 @@ export default async function ({ grid, touch, wait, stable, delay, evaluate, mou
runtime.wasm = { ...wasm, pardes_mouse: (...args) => {
events.push(args.slice(0, 2));
wasm.pardes_mouse(...args);
+ }, pardes_mouse_pixel: (...args) => {
+ events.push(args.slice(0, 2));
+ wasm.pardes_mouse_pixel(...args);
} };
const observe = (event) => {
if (event.type === "pointerdown") pointerDownCanceled.push(event.defaultPrevented);