summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/detached/client.zig4
-rw-r--r--src/detached/server.zig3
-rw-r--r--src/detached/wire.zig131
-rw-r--r--src/gui/gui.zig199
-rw-r--r--src/layout.zig22
-rw-r--r--src/macos.zig102
-rw-r--r--src/macos/Sources/PardesView.swift77
-rw-r--r--src/macos/pardes.h3
-rw-r--r--src/pardes.zig263
-rw-r--r--src/tag_layer.zig62
-rw-r--r--src/web.zig54
-rw-r--r--src/web/app.mjs59
12 files changed, 878 insertions, 101 deletions
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) {