From 40c19fd4f52d20df94c2dfa2d68102b89bd9d3f1 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 15 Sep 2026 14:46:38 -0300 Subject: Add optional compact tagline styling for source context --- src/body_layer.zig | 80 +++++++++++ src/builtins.zig | 4 + src/config.zig | 8 +- src/detached/client.zig | 9 ++ src/detached/server.zig | 6 +- src/detached/wire.zig | 164 +++++++++++++++++++++- src/dump.zig | 1 + src/gui/gui.zig | 227 ++++++++++++++++++++++++++---- src/layout.zig | 103 ++++++++++++++ src/macos.zig | 82 +++++++++++ src/macos/Sources/AppDelegate.swift | 2 + src/macos/Sources/PardesView.swift | 133 ++++++++++++++++-- src/macos/pardes.h | 7 + src/panes.zig | 13 +- src/pardes.zig | 272 +++++++++++++++++++++++++++++++++--- src/web.zig | 77 ++++++++-- src/web/app.mjs | 106 ++++++++++++-- 17 files changed, 1200 insertions(+), 94 deletions(-) create mode 100644 src/body_layer.zig (limited to 'src') 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.., 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..= 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.. 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) { -- cgit v1.3