diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-15 16:28:57 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-15 17:24:42 -0300 |
| commit | 1ab4c071be58033d5452f69797ca8e2c32240cff (patch) | |
| tree | afa108a1c372276da1d2c62b6c05d8f167cc81b8 | |
| parent | c6ab9db0fdbcb8d16f666749194bb111218b96a8 (diff) | |
| download | pardes-1ab4c071be58033d5452f69797ca8e2c32240cff.tar.gz pardes-1ab4c071be58033d5452f69797ca8e2c32240cff.zip | |
Separate tag text geometry from physical pane grips
| -rw-r--r-- | docs/config.md | 7 | ||||
| -rw-r--r-- | docs/detached.md | 9 | ||||
| -rw-r--r-- | src/detached/client.zig | 4 | ||||
| -rw-r--r-- | src/detached/server.zig | 3 | ||||
| -rw-r--r-- | src/detached/wire.zig | 131 | ||||
| -rw-r--r-- | src/gui/gui.zig | 199 | ||||
| -rw-r--r-- | src/layout.zig | 22 | ||||
| -rw-r--r-- | src/macos.zig | 102 | ||||
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 77 | ||||
| -rw-r--r-- | src/macos/pardes.h | 3 | ||||
| -rw-r--r-- | src/pardes.zig | 263 | ||||
| -rw-r--r-- | src/tag_layer.zig | 62 | ||||
| -rw-r--r-- | src/web.zig | 54 | ||||
| -rw-r--r-- | src/web/app.mjs | 59 | ||||
| -rw-r--r-- | test/panes.zig | 145 | ||||
| -rw-r--r-- | test/web-snapshots/touch.mjs | 20 |
16 files changed, 1052 insertions, 108 deletions
diff --git a/docs/config.md b/docs/config.md index d0bf63da..2a1825ce 100644 --- a/docs/config.md +++ b/docs/config.md @@ -349,9 +349,10 @@ Taglines use a distinct face size in both native GUI renderers. Execute accepted range is 1 through 100 and the default comes from `gui_tagline_font_percent` in `src/config.zig` (82). The native renderer remeasures both the glyph and its visible tag band while retaining body-grid -pane geometry. The SDL GUI uses the smaller face's measured monospace advance -as a real per-pane tagline grid, including pointer hit testing; it does not pad -each smaller glyph back out to a body-width cell. The 100% ceiling is +pane geometry. Graphical tag text uses the smaller face's measured monospace +advance and fills the available pixel width, including workspace and column +tags. Text selection and scrolling use that same capacity; drag grips retain +their physical body-grid width and position. The 100% ceiling is deliberate: a tagline remains exactly one logical grid row, so a larger face or band would overlap its pane body or a neighbour instead of leaving the body grid stable. `Config` reports the active percentage. The browser applies the diff --git a/docs/detached.md b/docs/detached.md index af81547a..2111e3dc 100644 --- a/docs/detached.md +++ b/docs/detached.md @@ -49,11 +49,12 @@ write session files or install file watches. `src/detached/wire.zig` owns the versioned frontend protocol. Frames are full grids or changes relative to each frontend's last queued frame. A new attachment receives a full grid. Output queues and per-poll work are bounded; -a lagging frontend cannot hold the session's event loop. Protocol version 5 -also carries the pointer shape, compact source-context body layers and their -source-gap separators. Pointer +a lagging frontend cannot hold the session's event loop. Protocol version 6 +also carries the pointer shape, compact source-context body layers, source-gap +separators and independently sized tag text. Pointer updates work even when no grid cells change, and graphical clients use the -same compact row geometry for drawing and mouse input. Terminal clients retain +same compact row and tag geometry for drawing and mouse input. Tag text positions +are separate from physical pane and drag-handle coordinates. Terminal clients retain the ordinary fixed grid. `zig build unit-test` covers encoding, session ownership, real frontend diff --git a/src/detached/client.zig b/src/detached/client.zig index 42404bca..9481cd54 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -103,6 +103,7 @@ pub const Client = struct { cursor: ?wire.Cursor = null, pointer_shape: pardes.Surface.PointerShape = .arrow, body_layers: [pardes.MAX_PANES]pardes.BodyLayer = @splat(.{}), + tag_layers: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}), in: std.ArrayListUnmanaged(u8) = .empty, out: std.ArrayListUnmanaged(u8) = .empty, /// Bytes of `in` belonging to the message `next` returned last. Compacted at @@ -179,6 +180,7 @@ pub const Client = struct { c.gpa.free(layer.cells); layer.* = .{}; } + for (&c.tag_layers) |*layer| c.gpa.free(layer.cells); c.in.deinit(c.gpa); c.out.deinit(c.gpa); } @@ -316,7 +318,7 @@ pub const Client = struct { try c.reshape(f.cols, f.rows); } try f.apply(c.grid.items); - f.applyLayers(c.gpa, &c.body_layers) catch |err| return switch (err) { + f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers) catch |err| return switch (err) { error.OutOfMemory => error.Closed, else => @as(wire.Error, @errorCast(err)), }; diff --git a/src/detached/server.zig b/src/detached/server.zig index 82abc432..6b82cd7b 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) + wire.layersBound(&surface.body_layers); + const want = wire.frameBound(surface.cols, surface.rows) + wire.layersBound(&surface.body_layers, &surface.tag_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) &.{} @@ -726,6 +726,7 @@ pub const Session = struct { cells, prev, &surface.body_layers, + &surface.tag_layers, ) catch |err| { log.debug("frame {d}x{d} not encodable: {t}", .{ surface.cols, surface.rows, err }); return; diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 416330e8..245bc9f4 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -4,7 +4,7 @@ const std = @import("std"); const pardes = @import("../pardes.zig"); const limits = @import("../memory.zig").limits; -pub const version: u16 = 5; +pub const version: u16 = 6; pub const Error = error{ Truncated, Overlong, BadTag, BadValue, Trailing, NoSpace }; @@ -164,15 +164,25 @@ 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 { + pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.BodyLayer, tags: *[pardes.MAX_TAG_LAYERS]pardes.TagLayer) !void { for (layers) |*layer| { gpa.free(layer.cells); layer.* = .{}; } - errdefer for (layers) |*layer| { + for (tags) |*layer| { gpa.free(layer.cells); layer.* = .{}; - }; + } + errdefer { + for (layers) |*layer| { + gpa.free(layer.cells); + layer.* = .{}; + } + for (tags) |*layer| { + gpa.free(layer.cells); + layer.* = .{}; + } + } if (f.layers.len == 0) return; var r: Reader = .init(f.layers); const count = try r.getU16(); @@ -196,6 +206,27 @@ pub const Frame = struct { layer.cells = try gpa.alloc(pardes.Cell, @as(usize, layer.cols) * layer.rows); for (layer.cells) |*cell| cell.* = try decodeCell(&r); } + const tag_count = try r.getU16(); + if (tag_count > tags.len) return error.Overlong; + for (tags[0..tag_count]) |*layer| { + layer.kind = try r.getTag(pardes.TagKind); + layer.id = try r.getU16(); + layer.serial = try r.getU32(); + layer.viewport = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() }; + layer.cols = try r.getU16(); + for (&layer.bg) |*channel| channel.* = try r.getByte(); + if (layer.cols == 0 or layer.viewport.w == 0 or layer.viewport.h != 1 or + layer.viewport.x > f.cols or layer.viewport.w > f.cols - layer.viewport.x or + layer.viewport.y >= f.rows or (switch (layer.kind) { + .workspace => layer.id != 0, + .column => layer.id >= pardes.MAX_COLS, + .pane => layer.id >= pardes.MAX_PANES, + })) return error.BadValue; + const cursor = try getCursor(&r, layer.cols, 1); + layer.cursor = if (cursor) |c| .{ .col = c.x, .bar = c.bar } else null; + layer.cells = try gpa.alloc(pardes.Cell, layer.cols); + for (layer.cells) |*cell| cell.* = try decodeCell(&r); + } try r.end(); } @@ -638,18 +669,21 @@ pub fn encodeFramePointer( cells: []const pardes.Cell, prev: []const pardes.Cell, ) Error![]const u8 { - return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}); + return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}); } -pub fn layersBound(layers: []const pardes.BodyLayer) usize { - var n: usize = 2; +pub fn layersBound(layers: []const pardes.BodyLayer, tags: []const pardes.TagLayer) usize { + var n: usize = 4; for (layers) |layer| if (layer.rows != 0) { n += 26 + layer.context_rows + layer.cells.len * cell_max; }; + for (tags) |layer| if (layer.cols != 0) { + n += 26 + layer.cells.len * cell_max; + }; 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 { +pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.BodyLayer, tags: []const pardes.TagLayer) Error![]const u8 { // 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 @@ -671,7 +705,7 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point const layers_len_at = w.n; try w.putU32(0); const layers_start = w.n; - if (layers.len != 0) { + if (layers.len != 0 or tags.len != 0) { var count: u16 = 0; for (layers) |layer| if (layer.rows != 0) { count += 1; @@ -693,6 +727,26 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point 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); } + var tag_count: u16 = 0; + for (tags) |layer| if (layer.cols != 0) { + tag_count += 1; + }; + try w.putU16(tag_count); + for (tags) |layer| { + if (layer.cols == 0) continue; + if (layer.cells.len != layer.cols) return error.BadValue; + try w.putByte(@intFromEnum(layer.kind)); + try w.putU16(layer.id); + 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); + for (layer.bg) |channel| try w.putByte(channel); + try putCursor(&w, if (layer.cursor) |c| .{ .x = c.col, .y = 0, .bar = c.bar } else null, layer.cols, 1); + 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; @@ -810,6 +864,34 @@ fn getBodyHit(r: *Reader) Error!?pardes.Mouse.BodyHit { return hit; } +fn putTagHit(w: *Writer, hit: ?pardes.TagHit) Error!void { + try w.putBool(hit != null); + if (hit) |h| { + try w.putByte(@intFromEnum(h.kind)); + try w.putU16(h.id); + try w.putU32(h.serial); + try w.putU16(h.col); + try w.putF32(h.pixel_x); + try w.putF32(h.pixel_y); + try putMetrics(w, h.metrics); + } +} + +fn getTagHit(r: *Reader) Error!?pardes.TagHit { + if (!try r.getBool()) return null; + const hit: pardes.TagHit = .{ + .kind = try r.getTag(pardes.TagKind), + .id = try r.getU16(), + .serial = try r.getU32(), + .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))); @@ -839,6 +921,7 @@ pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 { try w.putU16(m.row); try w.putBool(m.ctrl); try putBodyHit(&w, m.body_hit); + try putTagHit(&w, m.tag_hit); }, .resize => |rs| { try w.putU16(rs.cols); @@ -901,6 +984,7 @@ pub fn decodeClient(tag: u8, payload: []const u8) Error!ClientMsg { .row = try r.getU16(), .ctrl = try r.getBool(), .body_hit = try getBodyHit(&r), + .tag_hit = try getTagHit(&r), } } }, .resize => blk: { var ev: pardes.Event = .{ .resize = .{ .cols = try r.getCols(), .rows = try r.getRows() } }; @@ -1735,15 +1819,20 @@ test "detached wire carries compact body rows and exact pointer metrics" { logical[10] = .{ .text = "z".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false }; var layer: pardes.BodyLayer = .{ .pane = 0, .serial = 42, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 4 }, .cols = 2, .rows = 6, .context_rows = 2, .cells = &logical, .cursor = .{ .x = 0, .y = 5, .bar = true } }; layer.context_separators.set(0); - const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer})); + var tag_cells: [3]pardes.Cell = @splat(.{}); + tag_cells[2] = logical[10]; + const tag: pardes.TagLayer = .{ .kind = .column, .id = 1, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 1 }, .cols = 3, .bg = .{ 11, 22, 33 }, .cells = &tag_cells, .cursor = .{ .col = 2, .bar = true } }; + const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag})); defer gpa.free(out); - const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}); + const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}); const packet = (try framed(encoded)).?; const frame = (try decodeServer(packet.tag, packet.payload)).frame; var decoded: [pardes.MAX_PANES]pardes.BodyLayer = @splat(.{}); + var tags: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}); + defer for (&tags) |*item| gpa.free(item.cells); defer for (&decoded) |*item| gpa.free(item.cells); try frame.apply(&cells); - try frame.applyLayers(gpa, &decoded); + try frame.applyLayers(gpa, &decoded, &tags); try std.testing.expectEqual(@as(u16, 6), decoded[0].rows); try std.testing.expect(decoded[0].context_separators.isSet(0)); try std.testing.expect(!decoded[0].context_separators.isSet(1)); @@ -1751,25 +1840,37 @@ test "detached wire carries compact body rows and exact pointer metrics" { try std.testing.expect(decoded[0].hasContextBorderAfter(1)); try std.testing.expectEqualStrings("z", decoded[0].cells[10].grapheme()); try std.testing.expectEqual(@as(u16, 5), decoded[0].cursor.?.y); + try std.testing.expectEqual(tag.kind, tags[0].kind); + try std.testing.expectEqual(tag.id, tags[0].id); + try std.testing.expectEqual(tag.bg, tags[0].bg); + try std.testing.expectEqual(tag.cols, tags[0].cols); + try std.testing.expectEqual(@as(u16, 2), tags[0].cursor.?.col); + try std.testing.expectEqualStrings("z", tags[0].cells[2].grapheme()); 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 } } }; + const tag_pointer = tag.hitAt(11, 8, 8, 16, 4, 8).?; + const message: ClientMsg = .{ .event = .{ .mouse = .{ .col = 0, .row = 1, .button = .left, .kind = .press, .body_hit = pointer, .tag_hit = tag_pointer } } }; var mouse_bytes: [256]u8 = undefined; const mouse_encoded = try encodeClient(&mouse_bytes, message); 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.expectEqualDeep(tag_pointer, mouse.tag_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)); + try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags)); for (decoded) |item| { try std.testing.expectEqual(@as(u16, 0), item.rows); try std.testing.expectEqual(@as(usize, 0), item.cells.len); try std.testing.expectEqual(@as(usize, 0), item.context_separators.count()); } + for (tags) |item| { + try std.testing.expectEqual(@as(u16, 0), item.cols); + try std.testing.expectEqual(@as(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 cleared.applyLayers(gpa, &decoded, &tags); try std.testing.expectEqual(@as(u16, 0), decoded[0].rows); } diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 0c0a5651..f4c87ed1 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -766,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, .body_hit = cell.body_hit } }); + in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row, .tag_hit = cell.tag_hit, .body_hit = cell.body_hit } }); in.post(.{ .touch_scroll = delta_y }); } @@ -775,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, .body_hit = cell.body_hit } }); - in.post(.{ .mouse = .{ .button = button, .kind = .release, .col = cell.col, .row = cell.row, .body_hit = cell.body_hit } }); + in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row, .tag_hit = cell.tag_hit, .body_hit = cell.body_hit } }); + in.post(.{ .mouse = .{ .button = button, .kind = .release, .col = cell.col, .row = cell.row, .tag_hit = cell.tag_hit, .body_hit = cell.body_hit } }); } fn normCell(norm: f32, win: f32, cell: f32) u16 { @@ -1766,6 +1766,7 @@ const Gui = struct { tagline_width: u32, tagline_height: u32, attached_layers: ?*const [pardes.MAX_PANES]pardes.BodyLayer = null, + attached_tag_layers: ?*const [pardes.MAX_TAG_LAYERS]pardes.TagLayer = null, cell_w: u32, cell_h: u32, ascent: i32, @@ -1894,7 +1895,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, body_hit: ?pardes.Mouse.BodyHit = null }; +const MouseCell = struct { col: u16, row: u16, body_hit: ?pardes.Mouse.BodyHit = null, tag_hit: ?pardes.TagHit = null }; const WindowGeometry = struct { window_w: f32, window_h: f32, @@ -1961,16 +1962,9 @@ fn compactTaglineLayout(g: *const Gui, origin_col: f32) CellLayout { }; } -fn taglineLayoutForCell( - g: *const Gui, - core: ?*const pardes.Pardes, - col: u16, - row: u16, - track: ?pardes.layout.Track, -) CellLayout { - if (row < pardes.TOPBAR_H) return compactTaglineLayout(g, 0); - const p = core orelse return compactTaglineLayout(g, @floatFromInt(col)); - return compactTaglineLayout(g, pardes.taglineOriginCol(p, col, row, track)); +fn taglineLayoutForCell(g: *const Gui, col: u16) CellLayout { + // Canonical tag cells are physical grips; text is drawn by TagLayer. + return compactTaglineLayout(g, @floatFromInt(col)); } const boxContains = pardes.layout.Box.contains; @@ -2546,6 +2540,7 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client var in: Input = .{ .client = client }; g.attached_layers = &client.body_layers; + g.attached_tag_layers = &client.tag_layers; var dirty = false; var geom = windowCells(g); try attachedResize(client, g, geom.cols, geom.rows); @@ -2597,6 +2592,7 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien .cursor = if (client.cursor) |cu| .{ .x = cu.x, .y = cu.y, .bar = cu.bar } else null, .pointer_shape = client.pointer_shape, .body_layers = client.body_layers, + .tag_layers = client.tag_layers, }; const chrome = frameChromeBg(&surface); _ = renderFrame( @@ -3069,6 +3065,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { .kind = .release, .col = last.col, .row = last.row, + .tag_hit = last.tag_hit, .body_hit = last.body_hit, } }); in.post(.pointer_leave); @@ -3082,6 +3079,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, + .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, .ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0, }, @@ -3112,6 +3110,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, + .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, } }); }, @@ -3159,9 +3158,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, .body_hit = mc.body_hit } }); + in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .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, .body_hit = mc.body_hit } }); + in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } }); } } if (w.x != 0) in.post(.{ .mouse = .{ @@ -3169,6 +3168,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { .kind = .press, .col = mc.col, .row = mc.row, + .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, } }); }, @@ -3267,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, + .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, } }); }, @@ -3284,6 +3285,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { .kind = .release, .col = last.col, .row = last.row, + .tag_hit = last.tag_hit, .body_hit = last.body_hit, } }); in.post(.pointer_leave); @@ -3296,6 +3298,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, + .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, } }); }, @@ -3316,6 +3319,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { .kind = .press, .col = mc.col, .row = mc.row, + .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, } }); } @@ -3384,7 +3388,16 @@ fn gridCellAtDimensions( tagline_w: f32, ) MouseCell { const row: u16 = @intFromFloat(@min(@floor(@max(y, 0) / @max(body_h, 1)), 10_000)); - return .{ .col = pardes.gridColAt(core, x, row, body_w, tagline_w), .row = row }; + var point: MouseCell = .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / @max(body_w, 1)), 10_000)), .row = row }; + if (core) |p| if (p.presentation.pointerFractional(p.screen_w, p.screen_h, x / @max(body_w, 1), y / @max(body_h, 1))) |mapped| { + for (p.surface.tagLayers()) |*layer| { + if (layer.hitAt(mapped.x * body_w, mapped.y * body_h, body_w, body_h, tagline_w, @floatFromInt(p.row_metrics.tagline_h))) |hit| { + point.tag_hit = hit; + break; + } + } + }; + return point; } fn gridCellAtPixels(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) MouseCell { @@ -3401,31 +3414,51 @@ fn gridCellAtPixels(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) break; } }; + if (core == null) if (g.attached_tag_layers) |layers| for (layers) |*layer| { + if (layer.hitAt(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |hit| { + point.tag_hit = hit; + break; + } + }; return point; } -test "tagline pointer mapping follows the smaller pane-anchored grid" { +test "tagline pointer mapping retains physical grips and carries logical text coordinates" { const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer core.deinit(); core.update(.{ .resize = .{ .cols = 60, .rows = 12 } }); core.update(.{ .command = "Newcol" }); + core.presentation.enabled = false; + core.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 8, .tagline_h = 12 }; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + _ = try core.render(arena.allocator()); const r = core.rects[core.active]; const body_w: f32 = 10; const body_h: f32 = 20; const tag_w: f32 = 8; const tag_y = r.y; - const x = @as(f32, @floatFromInt(r.x)) * body_w + 3.5 * tag_w; + const x = @as(f32, @floatFromInt(r.x + config.GUTTER)) * body_w + 3.5 * tag_w; const tag = gridCellAtDimensions(core, x, (@as(f32, @floatFromInt(tag_y)) + 0.5) * body_h, body_w, body_h, tag_w); - try std.testing.expectEqual(MouseCell{ .col = r.x + 3, .row = tag_y }, tag); + try std.testing.expectEqual(pixelCell(x, 10), tag.col); + try std.testing.expectEqual(tag_y, tag.row); + try std.testing.expectEqual(@as(u16, 3), tag.tag_hit.?.col); + try std.testing.expectEqual(pardes.TagKind.pane, tag.tag_hit.?.kind); + const grip = gridCellAtDimensions(core, @as(f32, @floatFromInt(r.x + config.GUTTER)) * body_w - 0.1, (@as(f32, @floatFromInt(tag_y)) + 0.5) * body_h, body_w, body_h, tag_w); + try std.testing.expect(grip.tag_hit == null); + try std.testing.expectEqual(r.x + config.GUTTER - 1, grip.col); const body = gridCellAtDimensions(core, x, (@as(f32, @floatFromInt(tag_y + 1)) + 0.5) * body_h, body_w, body_h, tag_w); try std.testing.expectEqual(pixelCell(x, 10), body.col); try std.testing.expectEqual(tag_y + 1, body.row); + try std.testing.expect(body.tag_hit == null); const topbar = gridCellAtDimensions(core, 3.5 * tag_w, 0.5 * body_h, body_w, body_h, tag_w); - try std.testing.expectEqual(MouseCell{ .col = 3, .row = 0 }, topbar); + try std.testing.expectEqual(@as(u16, 2), topbar.col); + try std.testing.expectEqual(@as(u16, 3), topbar.tag_hit.?.col); + try std.testing.expectEqual(pardes.TagKind.workspace, topbar.tag_hit.?.kind); } fn mapScenePoint(g: *const Gui, x: f32, y: f32, w: f32, h: f32) ?crt.Point { @@ -3468,6 +3501,7 @@ fn refreshPresentedPointer(g: *Gui, core: *pardes.Pardes) void { .kind = if (held != null) .drag else .motion, .col = mc.col, .row = mc.row, + .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, } }); } @@ -3504,6 +3538,7 @@ fn pollGamepad(g: *Gui, in: *Input) void { .kind = if (held != null) .drag else .motion, .col = mc.col, .row = mc.row, + .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, } }); } @@ -3515,12 +3550,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, .body_hit = mc.body_hit } }); + in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .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, .body_hit = mc.body_hit } }); + in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } }); } } @@ -4891,7 +4926,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; + if (bodyLayerAt(surface, col, row) != null or tagLayerIn(surface.tagLayers(), 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| @@ -4920,7 +4955,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; + if (bodyLayerIn(surface.previous_body_layers, col, row) != null or tagLayerIn(surface.previous_tag_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( @@ -4954,6 +4989,21 @@ fn renderFrame( } } } + for (surface.tagLayers()) |*layer| { + if (layer.cols == 0) continue; + const batch = &paint_plan.batches[paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y)]; + batch.cell_count += tagLayerCellCount(g, layer) + 1; + } + 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_tag_layers) |*layer| { + if (layer.cols == 0 or layer.kind != .pane or layer.serial != track.serial) continue; + const destination = if (under) &paint_plan.batches[0] else &paint_plan.batches[batch_index]; + destination.cell_count += tagLayerCellCount(g, layer) + 1; + } + } 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| { @@ -4977,7 +5027,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; + if (bodyLayerAt(surface, col, row) != null or tagLayerIn(surface.tagLayers(), 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; @@ -5024,6 +5074,21 @@ fn renderFrame( ); } } + for (surface.tagLayers()) |*layer| { + if (layer.cols == 0) continue; + const batch_index = paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y); + emitTagLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false); + } + 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_tag_layers) |*layer| { + if (layer.cols == 0 or layer.kind != .pane or layer.serial != track.serial) continue; + const destination: usize = if (under) 0 else batch_index; + emitTagLayer(g, instances, &cell_next[destination], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve); + } + } for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; const batch_index = paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y); @@ -5048,7 +5113,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; + if (bodyLayerIn(surface.previous_body_layers, col, row) != null or tagLayerIn(surface.previous_tag_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( @@ -5337,6 +5402,42 @@ 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 tagLayerIn(layers: []const pardes.TagLayer, col: u16, row: u16) ?*const pardes.TagLayer { + for (layers) |*layer| { + const r = layer.viewport; + if (layer.cols > 0 and row == r.y and col >= r.x and col - r.x < r.w) return layer; + } + return null; +} + +fn tagLayerCellCount(g: *const Gui, layer: *const pardes.TagLayer) u32 { + const pixels = @as(u32, layer.viewport.w) * g.cell_w; + return @min(layer.cols, (pixels + g.tagline_width - 1) / g.tagline_width); +} + +fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.TagLayer, win_w: f32, win_h: f32, track: ?pardes.layout.Track, page: Ground, show_cursor: bool, old_layer: bool) void { + const bw: f32 = @floatFromInt(g.cell_w); + const bh: f32 = @floatFromInt(g.cell_h); + const tw: f32 = @floatFromInt(g.tagline_width); + const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw; + const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * bw; + const background: pardes.Cell = .{ .default = false, .style = .{ .bg = .{ .rgb = layer.bg } } }; + emitInstance(g, instances, next.*, 0, layer.viewport.y, .{ .w = right - left, .h = bh, .x_off = left, .y_off = 0 }, win_w, win_h, track, .body, &background, old_layer, false, page); + next.* += 1; + for (0..tagLayerCellCount(g, layer)) |col| { + const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.col == col else false; + emitInstance(g, instances, next.*, @intCast(col), layer.viewport.y, .{ .w = tw, .h = bh, .x_off = left, .y_off = 0 }, win_w, win_h, track, .tagline, &layer.cells[col], old_layer, cursor, page); + const instance = &instances[next.*]; + const edge = right / win_w * 2 - 1; + if (instance.x1 > edge) { + const fraction = (edge - instance.x0) / (instance.x1 - instance.x0); + instance.u1 = instance.u0 + (instance.u1 - instance.u0) * fraction; + instance.x1 = edge; + } + next.* += 1; + } +} + fn bodyLayerAt(surface: *const pardes.Surface, col: u16, row: u16) ?*const pardes.BodyLayer { return bodyLayerIn(surface.bodyLayers(), col, row); } @@ -5427,7 +5528,7 @@ fn emitSurfaceCell( // adjacent workspace, column and pane tags at TaglineSize < 100. emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, &band_base, old_layer, false, page); next.* += 1; - const tag_layout = taglineLayoutForCell(g, core, col, row, track); + const tag_layout = taglineLayoutForCell(g, col); emitInstance(g, instances, next.*, col, row, tag_layout, win_w, win_h, track, .tagline, cell, old_layer, is_cursor, page); next.* += 1; return; @@ -6236,7 +6337,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 and bodyLayerAt(surface, cursor.x, cursor.y) == null) { + if (cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) { const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x]; const role = drawnFontRole(core, &cell, cursor.y); const visual_height: f32 = if (role == .tagline) @@ -6244,7 +6345,7 @@ fn buildOverlay( else layout.h; const cursor_layout = if (role == .tagline) - taglineLayoutForCell(g, core, cursor.x, cursor.y, null) + taglineLayoutForCell(g, cursor.x) else layout; addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, cursor_layout, cursorColor(core, &cell, role)); @@ -6267,6 +6368,18 @@ fn buildOverlay( builder.addRect(x, top, x + @max(1, width / 8), @min(bottom, top + height), overlayRgb(cursorColor(core, cell, role))); } }; + if (transient_on) for (surface.tagLayers()) |*layer| { + if (layer.cols == 0) continue; + const cursor = layer.cursor orelse continue; + if (!cursor.bar or cursor.col >= layer.cols) continue; + const width: f32 = @floatFromInt(g.tagline_width); + const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; + const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w; + const x = left + @as(f32, @floatFromInt(cursor.col)) * width; + if (x >= right) continue; + const top = @as(f32, @floatFromInt(layer.viewport.y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(layer.viewport.y, builder.win_h, g.cell_h, g.tagline_height))); + builder.addRect(x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), overlayRgb(cursorColor(core, &layer.cells[cursor.col], .tagline))); + }; if (transient_on) appendTouchOverlay(g, &builder, debug_on); return @intCast(builder.len); } @@ -6419,7 +6532,7 @@ fn appendPaneChrome( // A quiet grip gives the normal-mode move box a purpose at rest. // Insert and terminal badges retain their existing visible glyphs. if (tag_cell.printableAscii() == ' ' and g.tagline_height >= 6 and g.tagline_width >= 3) { - const tag_layout = taglineLayoutForCell(g, core, r.x, tag_y, null); + const tag_layout = fixedCellLayout(g); const center_x = tag_layout.x_off + (@as(f32, @floatFromInt(r.x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * tag_layout.w; const center_y = @as(f32, @floatFromInt(tag_y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(tag_y, builder.win_h, g.cell_h, g.tagline_height))) + @@ -6843,3 +6956,27 @@ test "compact body layer rendering fills recovered rows and clips the final glyp try std.testing.expectApproxEqAbs(@as(f32, -0.4), instances[19].y1, 0.0001); try std.testing.expectApproxEqAbs(instances[19].v0 + @as(f32, 12) / atlas_h, instances[19].v1, 0.0001); } + +test "compact tag layers fill available width and clip at their physical edge" { + 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: [6]pardes.Cell = @splat(.{}); + const layer: pardes.TagLayer = .{ .kind = .pane, .id = 0, .serial = 7, .viewport = .{ .x = 2, .y = 3, .w = 3, .h = 1 }, .cols = 6, .cells = &cells, .bg = .{ 20, 30, 40 } }; + var instances: [8]CellInstance = undefined; + var next: u32 = 0; + emitTagLayer(&g, &instances, &next, &layer, 100, 100, null, Ground.opaqueRgb(bg_default), false, false); + // Five six-pixel cells fit in the three body-column viewport. + try std.testing.expectEqual(@as(u32, 6), next); + try std.testing.expectApproxEqAbs(@as(f32, -0.6), instances[1].x0, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 0), instances[5].x1, 0.0001); + g.tagline_width = 8; + next = 0; + emitTagLayer(&g, &instances, &next, &layer, 100, 100, null, Ground.opaqueRgb(bg_default), false, false); + try std.testing.expectEqual(@as(u32, 5), next); + try std.testing.expectApproxEqAbs(@as(f32, 0), instances[4].x1, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 6) / atlas_w, instances[4].u1 - instances[4].u0, 0.0001); +} diff --git a/src/layout.zig b/src/layout.zig index 1c43bbd4..f789ba02 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -30,6 +30,7 @@ pub const Presentation = struct { pending: bool = false, previous_cells: []pardes.Cell = &.{}, previous_body_layers: [MAX_PANES]pardes.BodyLayer = @splat(.{}), + previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}), previous_cols: u16 = 0, previous_rows: u16 = 0, previous_valid: bool = false, @@ -42,6 +43,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); + for (&self.previous_tag_layers) |*layer| gpa.free(layer.cells); if (self.diffs.len > 0) gpa.free(self.diffs); } @@ -312,6 +314,17 @@ pub const Presentation = struct { old.* = current.*; old.cells = storage; } + for (&self.previous_tag_layers, surface.tagLayers()) |*old, *current| { + const count = if (current.cols > 0) current.cells.len else 0; + const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch { + self.previous_valid = false; + self.previous_layout = @splat(null); + return; + }; + if (count > 0) @memcpy(storage, current.cells); + old.* = current.*; + old.cells = storage; + } @memcpy(self.previous_cells, cells); self.previous_cols = surface.cols; self.previous_rows = surface.rows; @@ -550,6 +563,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.previous_tag_layers = &self.previous_tag_layers; s.cell_diffs = self.diffs; } s.npanel_tracks = paintOrder( @@ -2103,7 +2117,7 @@ test "fractional panel inverse preserves compact header rows through slide and z } } -test "presentation snapshots own compact body rows across later edits" { +test "presentation snapshots own compact body and tag rows across later edits" { var state: Presentation = .{}; defer state.deinit(std.testing.allocator); var cells: [1]pardes.Cell = .{.{}}; @@ -2111,12 +2125,18 @@ test "presentation snapshots own compact body rows across later edits" { body[0].text[0] = 'A'; var surface: pardes.Surface = .{ .cols = 1, .rows = 1, .cells = &cells }; surface.body_layers[0] = .{ .serial = 9, .cols = 1, .rows = 2, .context_rows = 1, .cells = &body }; + var tag: [1]pardes.Cell = .{.{}}; + tag[0].text[0] = 'T'; + surface.tag_layers[0] = .{ .kind = .pane, .serial = 9, .cols = 1, .cells = &tag }; state.capturePrevious(std.testing.allocator, &surface); try std.testing.expect(state.previous_valid); + tag[0].text[0] = 'U'; + try std.testing.expectEqual(@as(u8, 'T'), state.previous_tag_layers[0].cells[0].text[0]); body[0].text[0] = 'B'; surface.body_layers[0].context_rows = 0; try std.testing.expectEqual(@as(u8, 'A'), state.previous_body_layers[0].cells[0].text[0]); try std.testing.expectEqual(@as(u16, 1), state.previous_body_layers[0].context_rows); state.capturePrevious(std.testing.allocator, &surface); try std.testing.expectEqual(@as(u8, 'B'), state.previous_body_layers[0].cells[0].text[0]); + try std.testing.expectEqual(@as(u8, 'U'), state.previous_tag_layers[0].cells[0].text[0]); } diff --git a/src/macos.zig b/src/macos.zig index 3a1926b5..5406fcb4 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -637,6 +637,7 @@ const State = struct { core: *pardes.Pardes, started: bool = false, runtime: Runtime, + tag_cells: [pardes.MAX_TAG_LAYERS][]Cell = @splat(&.{}), layer_cells: [pardes.MAX_PANES][]Cell = @splat(&.{}), cells: []Cell = &.{}, previous_cells: []Cell = &.{}, @@ -780,6 +781,7 @@ export fn pardes_deinit() void { 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); + for (st.tag_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); @@ -1482,7 +1484,7 @@ 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); + mouseWithHit(button_arg, kind_arg, col, row, mods, null, 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 { @@ -1494,10 +1496,16 @@ export fn pardes_mouse_pixel(button_arg: c_int, kind_arg: c_int, col: u16, row: 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); + var tag_hit: ?pardes.TagHit = null; + for (&st.core.surface.tag_layers) |*layer| { + const point = source orelse break; + tag_hit = layer.hitAt(point.x * bw, point.y * bh, bw, bh, tw, th); + if (tag_hit != null) break; + } + mouseWithHit(button_arg, kind_arg, col, row, mods, hit, tag_hit); } -fn mouseWithHit(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, mods: u32, hit: ?pardes.Mouse.BodyHit) void { +fn mouseWithHit(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, mods: u32, hit: ?pardes.Mouse.BodyHit, tag_hit: ?pardes.TagHit) void { const st = &(state orelse return); const button: pardes.Mouse.Button = switch (button_arg) { 0 => .left, @@ -1521,6 +1529,7 @@ fn mouseWithHit(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, mods: u3 }; st.core.update(.{ .mouse = .{ .body_hit = hit, + .tag_hit = tag_hit, .button = button, .kind = kind, .col = col, @@ -1666,6 +1675,24 @@ fn clearFrame(st: *State) void { st.panel_diff_len = 0; } +fn blankTagCells(cells: []Cell, cols: u16, rows: u16, layers: []const pardes.TagLayer) void { + for (layers) |layer| { + if (layer.cols == 0 or layer.viewport.y >= rows or layer.viewport.x >= cols) continue; + const start = @as(usize, layer.viewport.y) * cols + layer.viewport.x; + const count = @min(layer.viewport.w, cols - layer.viewport.x); + for (cells[start..][0..count]) |*cell| cell.* = encodeCell(.{}); + } +} + +fn tagCursorCovered(core: *const pardes.Pardes) bool { + const cursor = core.surface.cursor orelse return false; + for (core.surface.tag_layers) |layer| { + if (layer.cols != 0 and cursor.y == layer.viewport.y and cursor.x >= layer.viewport.x and + cursor.x - layer.viewport.x < layer.viewport.w) return true; + } + return false; +} + fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void { const st = hostState(ctx); const tz = tracy.zone(@src(), "presentFrame"); @@ -1692,6 +1719,7 @@ fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void { defer tz_cells.end(); for (surface.cells, st.cells[0..count]) |cell, *out| out.* = encodeCell(cell); } + blankTagCells(st.cells[0..count], surface.cols, surface.rows, &surface.tag_layers); collectPanelDiff(st, surface, count); collectImages(st, surface); collectPanelTracks(st, surface); @@ -1722,6 +1750,7 @@ fn collectPanelDiff(st: *State, surface: *const pardes.Surface, count: usize) vo } for (surface.previous_cells, st.previous_cells[0..count]) |cell, *out| out.* = encodeCell(cell); + blankTagCells(st.previous_cells[0..count], surface.cols, surface.rows, surface.previous_tag_layers); for (surface.cell_diffs, st.changed_cells[0..count]) |diff, *out| out.* = encodeChanged(diff); st.panel_diff_len = count; @@ -1843,9 +1872,45 @@ export fn pardes_frame_rows() u16 { export fn pardes_cursor_x() i32 { const st = &(state orelse return -1); + if (tagCursorCovered(st.core)) return -1; return if (st.core.surface.cursor) |c| c.x else -1; } +export fn pardes_tag_layer_limit() u32 { + return pardes.MAX_TAG_LAYERS; +} + +export fn pardes_tag_layer_value(index: u32, field: u32) u32 { + const st = &(state orelse return 0); + if (index >= pardes.MAX_TAG_LAYERS) return 0; + const layer = &st.core.surface.tag_layers[index]; + return switch (field) { + 0 => layer.cols, + 1 => @intFromEnum(layer.kind), + 2 => layer.id, + 3 => layer.serial, + 4 => layer.viewport.x, + 5 => layer.viewport.y, + 6 => layer.viewport.w, + 7 => layer.viewport.h, + 8 => if (layer.cursor) |c| c.col else 65535, + 9 => if (layer.cursor) |c| @intFromBool(c.bar) else 0, + 10 => @as(u32, layer.bg[0]) << 16 | @as(u32, layer.bg[1]) << 8 | layer.bg[2], + else => 0, + }; +} + +export fn pardes_tag_layer_cells(index: u32) ?[*]const Cell { + const st = &(state orelse return null); + if (index >= pardes.MAX_TAG_LAYERS) return null; + const layer = &st.core.surface.tag_layers[index]; + if (layer.cols == 0) return null; + const cells = &st.tag_cells[index]; + if (cells.len != layer.cells.len) cells.* = st.gpa.realloc(cells.*, layer.cells.len) catch return null; + for (layer.cells, cells.*) |source, *out| out.* = encodeCell(source); + return cells.ptr; +} + export fn pardes_body_layer_value(index: u32, field: u32) u32 { const st = &(state orelse return 0); if (index >= pardes.MAX_PANES) return 0; @@ -1893,6 +1958,7 @@ export fn pardes_pointer_shape() u32 { export fn pardes_cursor_y() i32 { const st = &(state orelse return -1); + if (tagCursorCovered(st.core)) return -1; return if (st.core.surface.cursor) |c| c.y else -1; } @@ -2348,6 +2414,9 @@ test "pardes.h declares every export the way it is defined" { 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_tag_layer_limit), @TypeOf(pardes_tag_layer_limit)); + try expectSameAbi(@TypeOf(c.pardes_tag_layer_value), @TypeOf(pardes_tag_layer_value)); + try expectSameAbi(@TypeOf(c.pardes_tag_layer_cells), @TypeOf(pardes_tag_layer_cells)); 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)); @@ -2647,3 +2716,30 @@ test "mac row metrics ABI preserves PDF cell dimensions and the unrendered grid" 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); } + +test "mac tag layer ABI preserves logical capacity and physical grip" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 8, .rows = 3 }); + defer core.deinit(); + state = .{ .gpa = gpa, .threaded = undefined, .io = std.testing.io, .core = core, .runtime = .{}, .config_arena = .init(gpa), .prompt_rcs = .{} }; + defer { + for (state.?.tag_cells) |cells| gpa.free(cells); + state.?.config_arena.deinit(); + state = null; + } + var logical: [12]pardes.Cell = @splat(.{}); + logical[11].text[0] = 'Z'; + logical[11].len = 1; + logical[11].default = false; + core.surface.tag_layers[0] = .{ .kind = .pane, .id = 1, .serial = 42, .viewport = .{ .x = 2, .y = 1, .w = 6, .h = 1 }, .cols = 12, .cells = &logical, .bg = .{ 0x12, 0x34, 0x56 }, .cursor = .{ .col = 11, .bar = true } }; + defer core.surface.tag_layers[0] = .{}; + try std.testing.expectEqual(@as(u32, 12), pardes_tag_layer_value(0, 0)); + try std.testing.expectEqual(@as(u32, 0x123456), pardes_tag_layer_value(0, 10)); + try std.testing.expectEqual(@as(u32, 11), pardes_tag_layer_value(0, 8)); + try std.testing.expectEqual(@as(u8, 'Z'), pardes_tag_layer_cells(0).?[11].text[0]); + var canonical: [24]Cell = @splat(encodeCell(.{ .text = .{'G'} ++ .{0} ** 6, .len = 1, .default = false })); + blankTagCells(&canonical, 8, 3, &core.surface.tag_layers); + try std.testing.expectEqual(@as(u8, 'G'), canonical[9].text[0]); + try std.testing.expectEqual(@as(u8, 'G'), canonical[18].text[0]); + try std.testing.expectEqual(encodeCell(.{}).len, canonical[10].len); +} diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index 2d43cc38..0b16458e 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -911,6 +911,7 @@ final class PardesView: NSView { if !animatedPanels { presentedImages = frameImages presentedBodyLayers = snapshotBodyLayers() + presentedTagLayers = snapshotTagLayers() } presentationSerial &+= 1 let animationWoke = pardes_frame_presented(animatedPanels) @@ -1167,7 +1168,13 @@ final class PardesView: NSView { } if frozenImages == nil { drawBodyLayers(ctx, hideCursor:hideCursor) + drawTagLayers(ctx, hideCursor:hideCursor) } else { + for layer in presentedTagLayers { + layer.cells.withUnsafeBufferPointer { cells in + if let base = cells.baseAddress { drawTagLayer(ctx, values:layer.values, cells:base, hideCursor:true) } + } + } for layer in presentedBodyLayers { layer.cells.withUnsafeBufferPointer { cells in if let base = cells.baseAddress { drawBodyLayer(ctx, values:layer.values, cells:base, hideCursor:true) } @@ -1176,6 +1183,67 @@ final class PardesView: NSView { } } + private var presentedTagLayers: [FrozenBodyLayer] = [] + + private func snapshotTagLayers() -> [FrozenBodyLayer] { + var result: [FrozenBodyLayer] = [] + for index in 0..<pardes_tag_layer_limit() { + let values = (0...10).map { Int(pardes_tag_layer_value(index, UInt32($0))) } + if values[0] == 0 { continue } + if let cells = pardes_tag_layer_cells(index) { + result.append(FrozenBodyLayer(values: values, cells: Array(UnsafeBufferPointer(start: cells, count: values[0])))) + } + } + return result + } + + private func drawTagLayers(_ ctx: CGContext, hideCursor: Bool) { + for index in 0..<pardes_tag_layer_limit() { + let values = (0...10).map { Int(pardes_tag_layer_value(index, UInt32($0))) } + if values[0] == 0 { continue } + if let cells = pardes_tag_layer_cells(index) { drawTagLayer(ctx, values: values, cells: cells, hideCursor: hideCursor) } + } + } + + private func drawTagLayer(_ ctx: CGContext, values: [Int], cells: UnsafePointer<pardes_cell_s>, hideCursor: Bool) { + let left = CGFloat(values[4]) * cellWidth + let y = CGFloat(values[5]) * cellHeight + taglines.top(row: values[5], canvasHeight: bounds.height) + let viewport = CGRect(x: left, y: y, width: CGFloat(values[6]) * cellWidth, height: taglines.height) + ctx.saveGState() + ctx.clip(to: viewport) + fill(ctx, viewport, UInt32(values[10]), 1) + for pass in 0...1 { + for col in 0..<values[0] { + var cell = cells[col] + let isCursor = !hideCursor && col == values[8] + let style = resolve(cell, block: isCursor && values[9] == 0, ground: UInt32(values[10]), clearGround: false) + let x = left + CGFloat(col) * taglines.width + if pass == 0 { + fill(ctx, CGRect(x: x, y: y, width: taglines.width, height: taglines.height), style.bg, 1) + continue + } + let text = withUnsafeBytes(of: &cell.text) { String(decoding: $0.prefix(Int(cell.len)), as: UTF8.self) } + if style.visible && !text.isEmpty && text != " " { + let face = Face(bold: cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0, italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0) + ctx.saveGState() + ctx.translateBy(x: 0, y: bounds.height) + ctx.scaleBy(x: 1, y: -1) + let baseline = bounds.height - y - taglines.ascent + drawRules(ctx, cell, style, x: x, width: taglines.width, baseline: baseline) + setFill(ctx, style.fg, style.alpha) + ctx.textPosition = CGPoint(x: x, y: baseline) + let line = NSAttributedString(string: text, attributes: [fontAttribute: taglines.fonts[face.rawValue]]) + CTLineDraw(CTLineCreateWithAttributedString(line as CFAttributedString), ctx) + ctx.restoreGState() + } + if isCursor && values[9] != 0 { + fill(ctx, CGRect(x: x, y: y, width: max(1, taglines.width / 8), height: taglines.height), style.fg, 1) + } + } + } + ctx.restoreGState() + } + private struct FrozenBodyLayer { let values: [Int] let cells: [pardes_cell_s] @@ -2012,14 +2080,7 @@ final class PardesView: NSView { } sampledBodyPointer = sampled let row = min(max(Int(sampled.y / cellHeight), 0), rows - 1) - // The column comes from the core, because a tag row's glyphs step at - // the tagline face's narrower pitch and a body-pitch click drifts one - // word further right for every word along the row. The SDL shell asks - // the identical rule (`pardes.gridColAt`). Points on both sides: only - // the ratio of x to the two widths is read. - let col = min(max(Int(pardes_grid_col_at( - Float(sampled.x), UInt16(clamping: row), - Float(cellWidth), Float(taglines.width))), 0), cols - 1) + let col = min(max(Int(sampled.x / cellWidth), 0), cols - 1) return GridPoint(col: UInt16(col), row: UInt16(row)) } diff --git a/src/macos/pardes.h b/src/macos/pardes.h index 9f9aef60..e72f134a 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -341,6 +341,9 @@ void pardes_mouse_pixel(pardes_mouse_button_e button, pardes_mouse_kind_e kind, uint16_t col, uint16_t row, uint32_t mods, float x, float y, float body_w, float body_h, float tagline_w, float tagline_h); void pardes_row_metrics(uint16_t body_w, uint16_t body_h, uint16_t tagline_w, uint16_t tagline_h); +uint32_t pardes_tag_layer_limit(void); +uint32_t pardes_tag_layer_value(uint32_t index, uint32_t field); +const pardes_cell_s *pardes_tag_layer_cells(uint32_t index); uint32_t pardes_body_layer_limit(void); uint32_t pardes_body_layer_value(uint32_t index, uint32_t field); const pardes_cell_s *pardes_body_layer_cells(uint32_t index); diff --git a/src/pardes.zig b/src/pardes.zig index 294e6812..56f95040 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -71,6 +71,10 @@ 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 TagLayer = @import("tag_layer.zig").Layer; +pub const TagHit = @import("tag_layer.zig").Hit; +pub const TagKind = @import("tag_layer.zig").Kind; +pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1; pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px; pub const MAX_COLS = 6; const column_weight_unit = layout.column_weight_unit; @@ -257,6 +261,7 @@ pub const Loc = struct { test { _ = @import("body_layer.zig"); + _ = @import("tag_layer.zig"); } const pane_tail = " " ++ config.pane_builtins_str; @@ -3609,6 +3614,7 @@ pub const Surface = struct { pub const PointerShape = enum(u8) { arrow, link }; pointer_shape: PointerShape = .arrow, body_layers: [MAX_PANES]BodyLayer = @splat(.{}), + tag_layers: [MAX_TAG_LAYERS]TagLayer = @splat(.{}), cols: u16 = 0, rows: u16 = 0, cells: []Cell = &.{}, @@ -3620,8 +3626,13 @@ pub const Surface = struct { npanel_tracks: usize = 0, previous_cells: []const Cell = &.{}, previous_body_layers: []const BodyLayer = &.{}, + previous_tag_layers: []const TagLayer = &.{}, cell_diffs: []const PanelCellDiff = &.{}, + pub fn tagLayers(s: *const Surface) []const TagLayer { + return &s.tag_layers; + } + pub fn bodyLayers(s: *const Surface) []const BodyLayer { return &s.body_layers; } @@ -4525,6 +4536,7 @@ pub const Mouse = struct { row: u16, ctrl: bool = false, body_hit: ?BodyHit = null, + tag_hit: ?TagHit = null, }; pub const Key = struct { @@ -4708,6 +4720,7 @@ const LookHoverWait = struct { serial: u32, frames: u16 = 0, body_hit: ?Mouse.BodyHit = null, + tag_hit: ?TagHit = null, }; const LookHoverPreview = struct { @@ -4718,6 +4731,7 @@ const LookHoverPreview = struct { sel: ?Pane.Sel, file_word: ?FileWordSpan = null, body_hit: ?Mouse.BodyHit = null, + tag_hit: ?TagHit = null, }; const FileWordSpan = struct { @@ -5913,6 +5927,7 @@ pub const Pardes = struct { pointer_raw_col: u16 = 0, pointer_raw_row: u16 = 0, pointer_body_hit: ?Mouse.BodyHit = null, + pointer_tag_hit: ?TagHit = null, raw_hover_intent: bool = false, pointer_inside: bool = false, look_hover_wait: ?LookHoverWait = null, @@ -6144,6 +6159,7 @@ pub const Pardes = struct { p.frame_arena.deinit(); gpa.free(p.surface.cells); for (&p.surface.body_layers) |*layer| gpa.free(layer.cells); + for (&p.surface.tag_layers) |*layer| gpa.free(layer.cells); p.presentation.deinit(gpa); if (p.surface.images.len > 0) gpa.free(p.surface.images); gpa.destroy(p); @@ -6201,7 +6217,7 @@ pub const Pardes = struct { return; }; const text = p.headerText(); - const width = if (p.header_column) |c| p.col_w[c] else p.screen_w; + const width = p.tagCapacity(if (p.header_column) |c| p.col_w[c] else p.screen_w); const col = panes.File.rawDisplayCol(text, @min(cur, text.len)); if (col < p.header_scroll) p.header_scroll = col; if (col >= p.header_scroll + width) p.header_scroll = col -| (width -| 1); @@ -7072,6 +7088,7 @@ pub const Pardes = struct { .pointer_leave => { p.pointer_inside = false; p.pointer_body_hit = null; + p.pointer_tag_hit = null; }, .tick => { p.chrome_animation.advance(); @@ -7303,7 +7320,7 @@ pub const Pardes = struct { pane.tag_col = @intCast(@min(edit0 + lead, end)); } if (pane.tag_saved_col == null) if (p.paneIdOf(pane)) |id| { - const width = @max(1, p.rects[id].w -| config.GUTTER); + const width = p.tagCapacity(p.rects[id].w -| config.GUTTER); // Reveal the suffix together with its commands, not merely the // first command letter at the viewport's far right edge. pane.tag_scroll = @intCast(panes.File.displayWidth(text) + 1 -| width); @@ -10980,7 +10997,7 @@ pub const Pardes = struct { return p.reprojectBodyHit(mapped_hit); } - fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16, body_hit: ?Mouse.BodyHit) ?PointerTextSelection { + fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_hit: ?TagHit) ?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; @@ -10993,7 +11010,8 @@ pub const Pardes = struct { 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) + + const tag_col = if (on_tag) p.tagColumn(.pane, id, tag_hit, false) else null; + const c: i32 = (if (tag_col) |value| @as(i32, value) else @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); return .{ @@ -11022,28 +11040,50 @@ pub const Pardes = struct { return null; } + fn sameTagCell(a: ?TagHit, b: ?TagHit) bool { + const first = a orelse return b == null; + const second = b orelse return false; + return first.kind == second.kind and first.id == second.id and first.serial == second.serial and first.col == second.col; + } + + fn tagColumn(p: *const Pardes, kind: TagKind, id: usize, supplied: ?TagHit, clamp: bool) ?u16 { + var point = supplied orelse return null; + if (point.kind != kind or point.id != id) return null; + if (clamp) for (p.surface.tagLayers()) |*layer| { + if (layer.cols == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial) continue; + const bw: f32 = @floatFromInt(point.metrics.body_w); + const bh: f32 = @floatFromInt(point.metrics.body_h); + const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw; + const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw; + point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001)); + point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * bh; + break; + }; + return if (p.reprojectTagHit(point)) |mapped| mapped.col else null; + } + 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 { + fn noteLookHover(p: *Pardes, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_hit: ?TagHit) 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, body_hit) orelse return p.cancelLookHover(); + _ = p.pointerTextSelection(id, col, row, body_hit, tag_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 and sameBodyCell(shown.body_hit, body_hit)) 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) and sameTagCell(shown.tag_hit, tag_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 and sameBodyCell(waiting.body_hit, body_hit)) 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) and sameTagCell(waiting.tag_hit, tag_hit)) return; p.cancelLookHover(); - p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial, .body_hit = body_hit }; + p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial, .body_hit = body_hit, .tag_hit = tag_hit }; } fn refreshLookHoverFromRaw(p: *Pardes) void { @@ -11056,7 +11096,7 @@ pub const Pardes = struct { 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); + p.noteLookHover(mapped.col, mapped.row, body_hit, p.pointer_tag_hit); } fn advanceLookHover(p: *Pardes) void { @@ -11066,7 +11106,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, waiting.body_hit) orelse + const pointed = p.pointerTextSelection(waiting.pane, waiting.col, waiting.row, waiting.body_hit, waiting.tag_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 @@ -11095,6 +11135,7 @@ pub const Pardes = struct { .sel = preview, .file_word = operand.file_word, .body_hit = waiting.body_hit, + .tag_hit = waiting.tag_hit, }; p.look_hover_wait = null; } @@ -11133,6 +11174,24 @@ pub const Pardes = struct { p.pointer_raw_row = raw_row; const previous_hit = p.pointer_body_hit; p.pointer_body_hit = m.body_hit; + const previous_tag_hit = p.pointer_tag_hit; + p.pointer_tag_hit = if (m.tag_hit) |point| p.reprojectTagHit(point) else null; + if (m.tag_hit != null and p.pointer_tag_hit == null and m.kind == .press) return; + if (m.tag_hit == null and (m.kind == .drag or m.kind == .release) and (p.drag == .tag or p.drag == .select or p.header_drag)) { + if (previous_tag_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_tag_hit = continued; + } + } + if ((m.kind == .drag or m.kind == .release) and (p.drag == .tag or p.drag == .select or p.header_drag)) { + if (previous_tag_hit) |previous| if (p.pointer_tag_hit) |*current| { + current.kind = previous.kind; + current.id = previous.id; + current.serial = previous.serial; + }; + } // 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) { @@ -11175,7 +11234,7 @@ pub const Pardes = struct { if (p.header_drag and m.button == config.select_button and (m.kind == .drag or m.kind == .release)) { const x = if (p.header_column) |c| p.col_x[c] else 0; const bar = p.headerText(); - p.topbar_col = @intCast(@min(bar.len, panes.File.rawAtDisplay(bar, (mcol -| x) + p.header_scroll))); + p.topbar_col = @intCast(@min(bar.len, panes.File.rawAtDisplay(bar, (p.tagColumn(if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + p.header_scroll))); if (m.kind == .release) p.header_drag = false; return; } @@ -11206,7 +11265,7 @@ pub const Pardes = struct { } p.header_column = column; const bar = p.headerText(); - const at = @min(bar.len, panes.File.rawAtDisplay(bar, (mcol -| x) + p.header_scroll)); + const at = @min(bar.len, panes.File.rawAtDisplay(bar, (p.tagColumn(if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + p.header_scroll)); if (m.button == config.select_button) { p.topbar_col = @intCast(at); p.header_anchor = @intCast(at); @@ -11328,13 +11387,13 @@ pub const Pardes = struct { pane.scrollBy(-(@as(i32, mrow) - @as(i32, body_y))); } else if (mrow >= tag_y and mrow < tag_y + BOX_H) { p.active = id; - p.enterTagEdit(pane, @as(i32, mcol) - @as(i32, r.x + config.GUTTER)); + p.enterTagEdit(pane, if (p.tagColumn(.pane, id, p.pointer_tag_hit, false)) |value| @as(i32, value) else @as(i32, mcol) - @as(i32, r.x + config.GUTTER)); pane.tag_anchor = pane.tag_col; p.drag = .{ .tag = .{ .id = id } }; } else { p.active = id; if (pane.tag_edit) exitTagEdit(pane); - const pointed = p.pointerTextSelection(id, mcol, mrow, m.body_hit) orelse return; + const pointed = p.pointerTextSelection(id, mcol, mrow, m.body_hit, p.pointer_tag_hit) orelse return; pane.clearPointerSelection(sel_slot); pane.sel[sel_slot] = pointed.sel; p.drag = .{ .select = .{ @@ -11354,7 +11413,7 @@ pub const Pardes = struct { }, .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); + if ((p.pointer_body_hit != null or p.pointer_tag_hit != null) and p.drag.button() == m.button) p.dragUpdate(mcol, mrow, p.pointer_body_hit); p.dragRelease(config.select_button); }, .motion => {}, @@ -11390,7 +11449,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, m.body_hit)) |pointed| { + } else if (p.pointerTextSelection(id, mcol, mrow, m.body_hit, p.pointer_tag_hit)) |pointed| { if (m.button == config.look_button) p.active = id; pane.clearPointerSelection(@intFromEnum(m.button)); pane.sel[@intFromEnum(m.button)] = pointed.sel; @@ -11414,7 +11473,7 @@ pub const Pardes = struct { }, .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); + if ((p.pointer_body_hit != null or p.pointer_tag_hit != null) and p.drag.button() == m.button) p.dragUpdate(mcol, mrow, p.pointer_body_hit); p.dragRelease(m.button); }, .motion => {}, @@ -11469,6 +11528,11 @@ pub const Pardes = struct { pane.sel[b].r1 = @as(i32, mapped_hit.row) + BOX_H; return; }; + if (pane.sel[b].r0 < BOX_H) if (p.tagColumn(.pane, s.id, p.pointer_tag_hit, true)) |value| { + pane.sel[b].c1 = @as(i32, value) + pane.tag_scroll; + pane.sel[b].r1 = 0; + 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; @@ -11484,7 +11548,7 @@ pub const Pardes = struct { }, .tag => |d| { const pane = p.panes[d.id] orelse return; - const c = @as(i32, mcol) - @as(i32, p.rects[d.id].x + config.GUTTER); + const c = if (p.tagColumn(.pane, d.id, p.pointer_tag_hit, true)) |value| @as(i32, value) else @as(i32, mcol) - @as(i32, p.rects[d.id].x + config.GUTTER); const text = p.tagText(p.scratch.allocator(), pane) catch return; pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @as(usize, @intCast(@max(0, c))) + pane.tag_scroll))); pane.tag_sel = pane.tag_col != pane.tag_anchor; @@ -12945,6 +13009,24 @@ pub const Pardes = struct { p.invalidateThemeDependentRasters(); } + pub fn reprojectTagHit(p: *const Pardes, tag_hit: TagHit) ?TagHit { + for (p.surface.tagLayers()) |*layer| { + if (layer.cols == 0 or layer.kind != tag_hit.kind or layer.id != tag_hit.id or layer.serial != tag_hit.serial) continue; + if (layer.kind == .pane) { + if (layer.id >= p.panes.len) return null; + const pane = p.panes[layer.id] orelse return null; + if (pane.serial != layer.serial) return null; + } + return layer.hitAt(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)); + } + return null; + } + + fn tagCapacity(p: *const Pardes, physical_width: u16) u16 { + const layer: TagLayer = .{ .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } }; + return layer.capacity(p.row_metrics); + } + pub fn reprojectBodyHit(p: *const Pardes, body_hit: Mouse.BodyHit) ?Mouse.BodyHit { for (&p.surface.body_layers) |*layer| { if (layer.rows == 0 or layer.serial != body_hit.serial) continue; @@ -12977,10 +13059,12 @@ pub const Pardes = struct { } }; for (&s.body_layers) |*layer| layer.rows = 0; + for (&s.tag_layers) |*layer| layer.cols = 0; s.nimages = 0; s.npanel_tracks = 0; s.previous_cells = &.{}; s.previous_body_layers = &.{}; + s.previous_tag_layers = &.{}; s.cell_diffs = &.{}; const chrome = p.chromeTheme(); const th = p.theme(); // the message row paints in the editor's colours @@ -13116,6 +13200,8 @@ pub const Pardes = struct { if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y, .bar = p.header_insert }; } + try p.renderTagLayers(arena); + switch (p.drag) { .border_v => |d| { if (d.cur_x < s.cols) { @@ -13428,6 +13514,90 @@ pub const Pardes = struct { } } + fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { + const chrome = p.chromeTheme(); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + const r = p.rects[id]; + if (r.w <= config.GUTTER or r.h == 0) continue; + const tag = try p.tagText(arena, pane); + const columns: u16 = @intCast(@min(std.math.maxInt(u16) - config.GUTTER, @max(p.tagCapacity(r.w - config.GUTTER), panes.File.displayWidth(tag) + 1))); + const layer = &p.surface.tag_layers[id]; + if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); + const storage = try arena.alloc(Cell, @as(usize, columns) + config.GUTTER); + @memset(storage, .{}); + var temporary: Surface = .{ .cols = columns + config.GUTTER, .rows = 1, .cells = storage }; + std.mem.swap(Surface, &p.surface, &temporary); + p.paintPaneTag(arena, pane, .{ .x = 0, .y = 0, .w = columns + config.GUTTER, .h = 1 }, id, id == p.active, p.tagCapacity(r.w - config.GUTTER)) catch |err| { + std.mem.swap(Surface, &p.surface, &temporary); + return err; + }; + std.mem.swap(Surface, &p.surface, &temporary); + @memcpy(layer.cells, storage[config.GUTTER..]); + layer.kind = .pane; + layer.id = @intCast(id); + layer.serial = pane.serial; + layer.viewport = .{ .x = r.x + config.GUTTER, .y = if (p.settings.tag_bottom) r.y + r.h - BOX_H else r.y, .w = r.w - config.GUTTER, .h = BOX_H }; + layer.cols = columns; + layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + layer.cursor = if (p.topbar_col == null and pane.promptAt() == null and temporary.cursor != null) .{ .col = temporary.cursor.?.x - config.GUTTER, .bar = temporary.cursor.?.bar } else null; + } + var text_buf: [1200]u8 = undefined; + try p.renderHeaderLayer(arena, MAX_PANES + MAX_COLS, .workspace, 0, .{ .x = 0, .y = 0, .w = p.screen_w, .h = TOPBAR_H }, p.topbar(&text_buf), chrome.tag_bg, chrome.tag_fg); + if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { + if (p.col_w[column] == 0) continue; + const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false; + try p.renderHeaderLayer(arena, MAX_PANES + column, .column, @intCast(column), .{ .x = p.col_x[column], .y = TOPBAR_H, .w = p.col_w[column], .h = COLBAR_H }, p.columnTag(column), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); + }; + } + + fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { + _ = arena; + const layer = &p.surface.tag_layers[index]; + const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1))); + if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); + layer.kind = kind; + layer.id = id; + layer.serial = 0; + layer.viewport = viewport; + layer.cols = columns; + layer.bg = bg; + layer.cursor = null; + var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; + const editing = p.topbar_col != null and (if (kind == .workspace) p.header_column == null else p.header_column == id); + const scroll = if (editing) p.header_scroll else 0; + surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); + if (editing) { + const cursor = p.topbar_col.?; + const anchor = p.header_anchor orelse cursor; + var col = panes.File.rawDisplayCol(text, @min(cursor, anchor)) -| scroll; + const end = panes.File.rawDisplayCol(text, @max(cursor, anchor)) -| scroll; + while (col < end and col < columns) : (col += 1) { + surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; + } + const caret = panes.File.rawDisplayCol(text, cursor) -| scroll; + if (caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = p.header_insert }; + } else if (p.pointer_inside) { + const tag_hit = p.pointer_tag_hit; + const col: ?usize = if (tag_hit) |pointer| + (if (pointer.kind == kind and pointer.id == id) pointer.col else null) + else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) + p.hover_col - viewport.x + else + null; + if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| { + var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll; + const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll; + while (cell < end and cell < columns) : (cell += 1) { + surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; + } + }; + } + } + 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; @@ -13491,7 +13661,6 @@ pub const Pardes = struct { 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; // text area resets to terminal-default cells (vaxis clear semantics); // light themes paint the page over it. @@ -13521,6 +13690,36 @@ pub const Pardes = struct { .tty => config.tag_tty, }, box_style); + try p.paintPaneTag(arena, pane, r, id, active, p.tagCapacity(tw)); + + // Folded panes retain their body state, but neither body pixels nor + // its cursor belong outside the one-row tag rectangle. + if (pane.collapsed) return; + + if (comptime pdf_enabled) + if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return; + + if (pane.image) |*iv| { + panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y, tw, r.h -| BOX_H); + // thumbless, but the same one column as the real scrollbar below — + // that is the whole point of drawing it + 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 }); + return; + } + + try p.renderBody(arena, pane, r, id, active, true); + } + + fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { + const s = &p.surface; + const th = p.theme(); + const chrome = p.chromeTheme(); + const tx = r.x + 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 page_rgb = th.bg orelse th.tag_bg; // Focus is visible along the tag without changing its live text grid. const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; @@ -13537,10 +13736,10 @@ pub const Pardes = struct { const caret = panes.File.rawDisplayCol(tag, pane.tag_col); var scroll: usize = pane.tag_scroll; if (caret < scroll) scroll = caret; - if (caret >= scroll + tw) scroll = caret - tw + 1; + if (caret >= scroll + capacity) scroll = caret - capacity + 1; var raw = panes.File.rawAtDisplay(tag, scroll); scroll = panes.File.rawDisplayCol(tag, raw); - if (caret >= scroll + tw) { + if (caret >= scroll + capacity) { raw = modal.nextGrapheme(tag, raw); scroll = panes.File.rawDisplayCol(tag, raw); } @@ -13588,23 +13787,11 @@ pub const Pardes = struct { if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y, .bar = pane.mode == .insert }; } - // Folded panes retain their body state, but neither body pixels nor - // its cursor belong outside the one-row tag rectangle. - if (pane.collapsed) return; - - if (comptime pdf_enabled) - if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return; - - if (pane.image) |*iv| { - panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y, tw, r.h -| BOX_H); - // thumbless, but the same one column as the real scrollbar below — - // that is the whole point of drawing it - 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 }); - return; + for (pane.sel, 0..) |selection, button| { + if (selection.state == .none or (selection.state == .done and button != 0)) continue; + if (@min(selection.r0, selection.r1) >= BOX_H) continue; + p.paintPointerSelection(pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); } - - 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 { diff --git a/src/tag_layer.zig b/src/tag_layer.zig new file mode 100644 index 00000000..65990959 --- /dev/null +++ b/src/tag_layer.zig @@ -0,0 +1,62 @@ +//! Tag text has its own horizontal pitch. Its viewport stays in physical +//! body-grid coordinates so compact text never changes pane hit targets. +const std = @import("std"); +const pardes = @import("pardes.zig"); + +pub const Kind = enum(u8) { workspace, column, pane }; +pub const Hit = struct { + kind: Kind, + id: u16, + serial: u32, + col: u16, + pixel_x: f32, + pixel_y: f32, + metrics: pardes.RowMetrics, +}; +pub const Layer = struct { + kind: Kind = .workspace, + id: u16 = 0, + serial: u32 = 0, + viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, + cols: u16 = 0, + bg: [3]u8 = .{ 0, 0, 0 }, + cells: []pardes.Cell = &.{}, + cursor: ?struct { col: u16, bar: bool = false } = null, + + pub fn capacity(layer: *const Layer, metrics: pardes.RowMetrics) u16 { + return @intCast(@min(std.math.maxInt(u16), @max(1, @as(u32, layer.viewport.w) * @max(1, metrics.body_w) / @max(1, metrics.tagline_w)))); + } + + pub fn hitAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?Hit { + if (layer.cols == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null; + for ([_]f32{ body_w, body_h, tagline_w, tagline_h }) |metric| + if (!std.math.isFinite(metric) or metric < 1 or metric > std.math.maxInt(u16)) return null; + const bw = @max(1, body_w); + const bh = @max(1, body_h); + const tw = std.math.clamp(tagline_w, 1, bw); + const th = std.math.clamp(tagline_h, 1, bh); + const dx = x - @as(f32, @floatFromInt(layer.viewport.x)) * bw; + const dy = y - @as(f32, @floatFromInt(layer.viewport.y)) * bh; + if (dx < 0 or dy < 0 or dx >= @as(f32, @floatFromInt(layer.viewport.w)) * bw or dy >= @as(f32, @floatFromInt(layer.viewport.h)) * bh) return null; + return .{ + .kind = layer.kind, + .id = layer.id, + .serial = layer.serial, + .col = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols - 1)), @floor(dx / tw))), + .pixel_x = x, + .pixel_y = y, + .metrics = .{ .body_w = @intFromFloat(bw), .body_h = @intFromFloat(bh), .tagline_w = @intFromFloat(tw), .tagline_h = @intFromFloat(th) }, + }; + } +}; + +test "tag text capacity uses its own pitch and excludes the physical grip" { + const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 1 }, .cols = 90 }; + try std.testing.expectEqual(@as(u16, 63), layer.capacity(.{ .body_w = 10, .tagline_w = 6 })); + try std.testing.expect(layer.hitAt(119.99, 85, 10, 20, 6, 12) == null); + try std.testing.expectEqual(@as(u16, 0), layer.hitAt(120, 85, 10, 20, 6, 12).?.col); + try std.testing.expectEqual(@as(u16, 50), layer.hitAt(423, 85, 10, 20, 6, 12).?.col); + try std.testing.expect(layer.hitAt(500, 85, 10, 20, 6, 12) == null); + try std.testing.expect(layer.hitAt(120, 85, std.math.nan(f32), 20, 6, 12) == null); + try std.testing.expect(layer.hitAt(120, 85, 10, 20, 0, 12) == null); +} diff --git a/src/web.zig b/src/web.zig index 46e8c3b4..a4e46276 100644 --- a/src/web.zig +++ b/src/web.zig @@ -99,6 +99,7 @@ comptime { const State = struct { core: *pardes.Pardes, + tag_cells: [pardes.MAX_TAG_LAYERS][]WebCell = @splat(&.{}), layer_cells: [pardes.MAX_PANES][]WebCell = @splat(&.{}), cells: []WebCell = &.{}, frame_len: usize = 0, @@ -129,6 +130,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); + for (s.tag_cells) |cells| gpa.free(cells); s.core.deinit(); pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.stop(); @@ -209,7 +211,7 @@ 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); + mouseWithHit(button_arg, kind_arg, col_arg, row_arg, mods, null, 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 { @@ -221,10 +223,16 @@ export fn pardes_mouse_pixel(button_arg: u32, kind_arg: u32, col_arg: u32, row_a 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); + var tag_hit: ?pardes.TagHit = null; + for (&s.core.surface.tag_layers) |*layer| { + const point = source orelse break; + tag_hit = layer.hitAt(point.x * bw, point.y * bh, bw, bh, tw, th); + if (tag_hit != null) break; + } + mouseWithHit(button_arg, kind_arg, col_arg, row_arg, mods, hit, tag_hit); } -fn mouseWithHit(button_arg: u32, kind_arg: u32, col_arg: u32, row_arg: u32, mods: u32, hit: ?pardes.Mouse.BodyHit) void { +fn mouseWithHit(button_arg: u32, kind_arg: u32, col_arg: u32, row_arg: u32, mods: u32, hit: ?pardes.Mouse.BodyHit, tag_hit: ?pardes.TagHit) void { const s = &(state orelse return); const button: pardes.Mouse.Button = switch (button_arg) { 0 => .left, @@ -248,6 +256,7 @@ fn mouseWithHit(button_arg: u32, kind_arg: u32, col_arg: u32, row_arg: u32, mods }; s.core.update(.{ .mouse = .{ .body_hit = hit, + .tag_hit = tag_hit, .button = button, .kind = kind, .col = @intCast(@min(col_arg, std.math.maxInt(u16))), @@ -323,6 +332,45 @@ export fn pardes_cursor_x() i32 { return if (s.core.surface.cursor) |cursor| cursor.x else -1; } +export fn pardes_tag_layer_limit() u32 { + return pardes.MAX_TAG_LAYERS; +} + +export fn pardes_tag_layer_value(index: u32, field: u32) u32 { + const s = &(state orelse return 0); + if (index >= pardes.MAX_TAG_LAYERS) return 0; + const layer = &s.core.surface.tag_layers[index]; + return switch (field) { + 0 => layer.cols, + 1 => @intFromEnum(layer.kind), + 2 => layer.id, + 3 => layer.serial, + 4 => layer.viewport.x, + 5 => layer.viewport.y, + 6 => layer.viewport.w, + 7 => layer.viewport.h, + 8 => if (layer.cursor) |c| c.col else 65535, + 9 => if (layer.cursor) |c| @intFromBool(c.bar) else 0, + 10 => @as(u32, layer.bg[0]) << 16 | @as(u32, layer.bg[1]) << 8 | layer.bg[2], + else => 0, + }; +} + +export fn pardes_tag_layer_cells(index: u32) ?[*]const WebCell { + const s = &(state orelse return null); + if (index >= pardes.MAX_TAG_LAYERS) return null; + const layer = &s.core.surface.tag_layers[index]; + if (layer.cols == 0) return null; + const cells = &s.tag_cells[index]; + if (cells.len != layer.cells.len) cells.* = gpa.realloc(cells.*, layer.cells.len) catch return null; + for (layer.cells, cells.*) |source, *out| out.* = encodeWebCell(source); + return cells.ptr; +} + +export fn pardes_tagline_band_offset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { + return pardes.taglineBandOffset(row, canvas_h, cell_h, tagline_h); +} + export fn pardes_body_layer_value(index: u32, field: u32) u32 { const s = &(state orelse return 0); if (index >= pardes.MAX_PANES) return 0; diff --git a/src/web/app.mjs b/src/web/app.mjs index 312627df..2a4f8a65 100644 --- a/src/web/app.mjs +++ b/src/web/app.mjs @@ -54,6 +54,16 @@ export class DomRenderer { const cursorY = this.wasm.pardes_cursor_y(); const cursorBar = this.wasm.pardes_cursor_bar() !== 0; const raw = new Array(count); + const tagCovered = new Uint8Array(count); + if (this.wasm.pardes_tag_layer_limit) { + for (let i = 0; i < this.wasm.pardes_tag_layer_limit(); i++) { + const val = field => this.wasm.pardes_tag_layer_value(i, field); + if (!val(0)) continue; + for (let x = val(4); x < Math.min(cols, val(4) + val(6)); x++) { + if (val(5) < rows) tagCovered[val(5) * cols + x] = 1; + } + } + } for (let index = 0; index < count; index += 1) { const off = index * CELL_SIZE; @@ -66,6 +76,7 @@ export class DomRenderer { const y = Math.floor(index / cols); const cursor = x === cursorX && y === cursorY; const cell = this.cells[index]; + cell.style.visibility = tagCovered[index] ? "hidden" : ""; if (cell.textContent !== text) cell.textContent = text; const styleKey = `${fg}:${bg}:${attrs}:${cursor ? 1 : 0}:${cursorBar ? 1 : 0}`; if (cell.pardesStyle !== styleKey) { @@ -75,10 +86,58 @@ export class DomRenderer { raw[index] = { text, fg, bg, attrs }; } this.renderBodyLayers(); + this.renderTagLayers(); this.snapshot = { cols, rows, cells: raw, cursorX, cursorY, cursorBar }; return this.snapshot; } + renderTagLayers() { + if (!this.wasm.pardes_tag_layer_limit || !this.cellWidth) return; + if (!this.tagRoot?.isConnected) { + this.tagRoot = this.root.ownerDocument.createElement("div"); + this.tagRoot.style.cssText = "position:absolute;inset:0;pointer-events:none;overflow:hidden"; + this.root.style.position = "relative"; + this.root.append(this.tagRoot); + } + this.tagNodes ??= new Map(); + const nodes = []; + const bw = this.cellWidth, bh = this.cellHeight, tw = this.taglineWidth, th = this.taglineHeight; + for (let i = 0; i < this.wasm.pardes_tag_layer_limit(); i++) { + const values = Array.from({length:11}, (_,field) => this.wasm.pardes_tag_layer_value(i,field)); + const cols = values[0]; + if (!cols) continue; + const ptr = this.wasm.pardes_tag_layer_cells(i); + if (!ptr) continue; + const bytes = new Uint8Array(this.wasm.memory.buffer,ptr,cols*CELL_SIZE); + const offset = this.wasm.pardes_tagline_band_offset(values[5],this.rowCount*bh*256,Math.round(bh*256),Math.round(th*256))/256; + const geometry = [bw,bh,tw,th,offset,...values].join(":"); + const cached = this.tagNodes.get(i); + if (cached && cached.geometry === geometry && cached.bytes.length === bytes.length && cached.bytes.every((byte,index)=>byte===bytes[index])) { + nodes.push(cached.node); + continue; + } + const view = new DataView(this.wasm.memory.buffer,ptr,cols*CELL_SIZE); + const layer = this.root.ownerDocument.createElement("div"); + layer.dataset.tagLayer = String(i); + layer.style.cssText = `position:absolute;overflow:hidden;white-space:pre;left:${values[4]*bw}px;top:${values[5]*bh+offset}px;width:${values[6]*bw}px;height:${th}px;background:${color(values[10],DEFAULT_BG)}`; + for (let col = 0; col < cols; col++) { + const off = col*CELL_SIZE; + const cell = this.root.ownerDocument.createElement("span"); + cell.className = "pardes-cell"; + cell.textContent = decoder.decode(bytes.subarray(off,off+view.getUint8(off+18))) || " "; + applyStyle(cell,view.getUint32(off+8,true),view.getUint32(off+12,true),view.getUint16(off+16,true),col===values[8],values[9]!==0); + cell.style.textAlign = "left"; + cell.style.width = `${tw}px`; + cell.style.height = `${th}px`; + cell.style.lineHeight = `${th}px`; + layer.append(cell); + } + this.tagNodes.set(i,{geometry,bytes:bytes.slice(),node:layer}); + nodes.push(layer); + } + if (nodes.length !== this.tagRoot.childNodes.length || nodes.some((node,index)=>node!==this.tagRoot.childNodes[index])) this.tagRoot.replaceChildren(...nodes); + } + renderBodyLayers() { if (!this.wasm.pardes_body_layer_limit || !this.cellWidth) return; if (!this.layerRoot?.isConnected) { diff --git a/test/panes.zig b/test/panes.zig index bf8a98a2..71127ea3 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -12,6 +12,150 @@ const image = pardes.image; const syntax = pardes.syntax; const filesystem = pardes.filesystem; +const TagLayerTests = struct { + const Kind = @FieldType(pardes.TagLayer, "kind"); + fn findLayer(p: *Pardes, kind: Kind, id: usize) !*pardes.TagLayer { + for (&p.surface.tag_layers) |*layer| { + if (layer.cols > 0 and layer.kind == kind and layer.id == id) return layer; + } + return error.MissingTagLayer; + } + + fn mouse(layer: *const pardes.TagLayer, x: f32, kind: pardes.Mouse.Kind) pardes.Mouse { + const y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * 20; + return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .tag_hit = layer.hitAt(x, y, 10, 20, 5, 10) }; + } + + test "compact tag layers render and edit Unicode text beyond body column capacity" { + for ([_]bool{ false, true }) |bottom| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 16 }); + defer p.deinit(); + const pane = try p.setTestFile("body\n"); + p.presentation.enabled = false; + p.settings.tag_bottom = bottom; + p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 5, .tagline_h = 10 }; + p.gpa.free(pane.file.?.path); + pane.file.?.path = try p.gpa.dupe(u8, "/x"); + const tail = " " ++ "a" ** 38 ++ "\t界 END"; + @memcpy(pane.tag_tail[0..tail.len], tail); + pane.tag_tail_len = tail.len; + pane.tag_init = true; + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const layer = try findLayer(p, .pane, 0); + const text = try p.tagText(frame.allocator(), pane); + const at = std.mem.indexOf(u8, text, "END").?; + const col = panes.File.displayWidth(text[0..at]); + try std.testing.expect(col > p.rects[0].w); + try std.testing.expect(col + 3 < layer.cols); + try std.testing.expectEqualStrings("E", layer.cells[col].grapheme()); + try std.testing.expectEqual(@as(u16, 0), pane.tag_scroll); + const x = @as(f32, @floatFromInt(layer.viewport.x)) * 10 + (@as(f32, @floatFromInt(col)) + 0.5) * 5; + var stale = mouse(layer, x, .press); + stale.tag_hit.?.serial +%= 1; + p.update(.{ .mouse = stale }); + try std.testing.expect(!pane.tag_edit); + p.update(.{ .mouse = mouse(layer, x, .press) }); + p.update(.{ .mouse = mouse(layer, x, .release) }); + try std.testing.expect(pane.tag_edit); + try std.testing.expectEqual(@as(u16, @intCast(at)), pane.tag_col); + p.update(.{ .key = .{ .cp = 'X', .text = "X" } }); + _ = try p.render(frame.allocator()); + const edited = try p.tagText(frame.allocator(), pane); + try std.testing.expect(std.mem.endsWith(u8, edited, "界 XEND")); + try std.testing.expectEqual(@as(u16, 0), pane.tag_scroll); + try std.testing.expectEqualStrings("body\n", pane.file.?.content); + p.update(.{ .key = .{ .cp = Key.escape } }); + const rect = p.rects[0]; + const track: layout.Track = .{ + .serial = pane.serial, + .pane = 0, + .effect = .zoom, + .frame = 4, + .frame_count = 10, + .from = .{ .x = @as(f32, @floatFromInt(rect.x)) + 2, .y = @floatFromInt(rect.y), .w = @floatFromInt(rect.w - 4), .h = @floatFromInt(rect.h) }, + .to = .{ .x = @floatFromInt(rect.x), .y = @floatFromInt(rect.y), .w = @floatFromInt(rect.w), .h = @floatFromInt(rect.h) }, + }; + p.presentation.shown_tracks[0] = track; + p.presentation.pending = false; + const shown = track.presented(); + const source_y = @as(f32, @floatFromInt(layer.viewport.y)) + 0.5; + const painted_x = shown.x + (x / 10 - track.to.x) / track.to.w * shown.w; + const painted_y = shown.y + (source_y - track.to.y) / track.to.h * shown.h; + for ([_]pardes.Mouse.Kind{ .press, .release }) |kind| { + var event = mouse(layer, x, kind); + // Hosts inverse the exact painted pixel into the tag envelope; + // physical routing still receives the original body-grid cell. + event.col = @intFromFloat(painted_x); + event.row = @intFromFloat(painted_y); + p.update(.{ .mouse = event }); + } + try std.testing.expectEqual(@as(u16, @intCast(at)), pane.tag_col); + } + } + + test "compact tag pointer keeps physical grips and adjacent header ownership" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("body\n"); + p.presentation.enabled = false; + p.settings.column_tags = true; + p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 5, .tagline_h = 10 }; + try std.testing.expect(p.executeBuiltinLine(0, "New")); + const second = p.active; + try std.testing.expect(layout.splitColumn(p, second, second, false)); + p.update(.tick); + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const layer = try findLayer(p, .pane, second); + const edge = @as(f32, @floatFromInt(layer.viewport.x)) * 10; + p.update(.{ .mouse = mouse(layer, edge - 0.1, .press) }); + try std.testing.expect(p.drag == .move); + try std.testing.expectEqual(second, p.drag.move.id); + p.update(.{ .mouse = mouse(layer, edge - 0.1, .release) }); + p.update(.{ .mouse = mouse(layer, edge + 0.1, .press) }); + p.update(.{ .mouse = mouse(layer, edge + 0.1, .release) }); + try std.testing.expectEqual(second, p.active); + try std.testing.expect(p.panes[second].?.tag_edit); + try std.testing.expectEqual(@as(u16, 0), p.panes[second].?.tag_col); + p.update(.{ .key = .{ .cp = Key.escape } }); + const left = p.panes[0].?; + const tail = " " ++ "a" ** 110; + @memcpy(left.tag_tail[0..tail.len], tail); + left.tag_tail_len = tail.len; + left.tag_init = true; + _ = try p.render(frame.allocator()); + const left_layer = try findLayer(p, .pane, 0); + const left_text = try p.tagText(frame.allocator(), left); + const start_x = @as(f32, @floatFromInt(left_layer.viewport.x)) * 10 + 12.5; + const beyond_x = edge + 12.5; + p.update(.{ .mouse = mouse(left_layer, start_x, .press) }); + p.update(.{ .mouse = mouse(layer, beyond_x, .drag) }); + p.update(.{ .mouse = mouse(layer, beyond_x, .release) }); + try std.testing.expectEqual(@as(usize, 0), p.active); + const end_col = left_layer.capacity(p.row_metrics) - 1; + try std.testing.expectEqual(@as(u16, @intCast(panes.File.rawAtDisplay(left_text, end_col))), left.tag_col); + try std.testing.expect(left.tag_sel); + p.update(.{ .key = .{ .cp = Key.escape } }); + const long = "a" ** 60 ++ "界END"; + try std.testing.expect(p.global_tag.set(long)); + for (p.col_tags[0..p.ncol]) |*tag| try std.testing.expect(tag.set(long)); + _ = try p.render(frame.allocator()); + for ([_]Kind{ .workspace, .column }) |kind| { + const id: usize = if (kind == .column) 1 else 0; + const header = try findLayer(p, kind, id); + const x = @as(f32, @floatFromInt(header.viewport.x)) * 10 + 62.5 * 5; + p.update(.{ .mouse = mouse(header, x, .press) }); + p.update(.{ .mouse = mouse(header, x, .release) }); + try std.testing.expectEqual(@as(?u16, 63), p.topbar_col); + if (kind == .column) try std.testing.expectEqual(@as(?usize, 1), p.header_column) else try std.testing.expect(p.header_column == null); + p.update(.{ .key = .{ .cp = Key.escape } }); + } + } +}; + const TagNavigationTests = struct { fn key(p: *Pardes, cp: u21) void { p.update(.{ .key = .{ .cp = cp } }); @@ -3776,6 +3920,7 @@ const TerminalTests = struct { test { _ = TagNavigationTests; + _ = TagLayerTests; _ = TagNameTintTests; _ = TreeContextTests; _ = BodyHitTests; diff --git a/test/web-snapshots/touch.mjs b/test/web-snapshots/touch.mjs index ecd8a9a3..e65fdb80 100644 --- a/test/web-snapshots/touch.mjs +++ b/test/web-snapshots/touch.mjs @@ -79,6 +79,22 @@ export default async function ({ grid, touch, wait, stable, delay, evaluate, mou renderer.render(); result.closedGapBorders=borders(); result.borderChangeInvalidates=renderer.layerRoot.firstChild!==withGap; + const tagValues=[2,2,0,42,1,0,1,1,1,1,0x334455]; + wasm.pardes_tag_layer_limit=()=>1; + wasm.pardes_tag_layer_value=(_,field)=>tagValues[field]; + wasm.pardes_tag_layer_cells=()=>1024; + wasm.pardes_tagline_band_offset=()=>2560; + renderer.render(); + const tag=renderer.tagRoot.firstChild; + result.tag={left:parseFloat(tag.style.left),top:parseFloat(tag.style.top),width:parseFloat(tag.style.width), + text:tag.textContent,cursor:tag.querySelector('.is-cursor')?.textContent, + gripVisible:renderer.cells[0].style.visibility,covered:renderer.cells[1].style.visibility, + background:tag.style.backgroundColor}; + renderer.render(); + result.tag.cached=renderer.tagRoot.firstChild===tag; + tagValues[10]=0x556677; + renderer.render(); + result.tag.bgInvalidates=renderer.tagRoot.firstChild!==tag; root.remove();return result; })()`); assert.deepEqual(compactGeometry.bands, [[0,10],[10,20],[30,20]], "compact headers release real height to additional body rows"); @@ -90,6 +106,10 @@ export default async function ({ grid, touch, wait, stable, delay, evaluate, mou assert.deepEqual(compactGeometry.closedGapBorders,[30]); assert(compactGeometry.borderChangeInvalidates,"separator changes invalidate an otherwise unchanged layer"); + assert.deepEqual(compactGeometry.tag, {left:10,top:10,width:10,text:"AB",cursor:"B", + gripVisible:"",covered:"hidden",background:"rgb(51, 68, 85)",cached:true,bgInvalidates:true}, + "tag text uses its own columns after a fixed body-width grip, with cursor and cached base background"); + let frame = await grid(); assert.equal(tags(frame).length, 1); styled(frame); |
