summaryrefslogtreecommitdiff
path: root/src/Layer.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/Layer.zig')
-rw-r--r--src/Layer.zig201
1 files changed, 201 insertions, 0 deletions
diff --git a/src/Layer.zig b/src/Layer.zig
new file mode 100644
index 00000000..a9827304
--- /dev/null
+++ b/src/Layer.zig
@@ -0,0 +1,201 @@
+//! A band of cells a pixel shell draws over the grid at its own pitch: a
+//! pane's, a column's or the workspace's tag rows, a notice chip, or a file
+//! body whose compact tree-sitter context rows outnumber its grid rows. The
+//! grid under it holds a copy of what fits there (draw.zig joins them), so a
+//! terminal client, which has only the grid, loses nothing but the pitch.
+//!
+//! Its viewport is in grid cells, so compact text never moves a hit target.
+//! A tag's rows are each one grid row, their glyphs at the tagline's size; a
+//! body's first `context_rows` rows are stacked at the tagline's height, the
+//! rest at the body's. `rows == 0` is no layer.
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const config = @import("config.zig");
+const limits = @import("memory.zig").limits;
+
+const Layer = @This();
+
+/// `notice` is a pane's transient line -- a builtin's name, a pending leader
+/// chord -- carried as a tagline-shaped band so it gets the tagline's height,
+/// small-font metrics, band offset and border instead of being a body-grid row
+/// wearing a tagline font.
+pub const Kind = enum(u8) { workspace, column, pane, notice, body };
+
+pub const Metrics = struct {
+ body_w: u16 = 1,
+ body_h: u16 = 1,
+ tagline_w: u16 = 1,
+ tagline_h: u16 = 1,
+};
+
+/// A point on a tag, notice or header layer: which row of it and which
+/// column at the tagline's pitch.
+pub const TagHit = struct {
+ kind: Kind,
+ id: u16,
+ serial: u32,
+ col: u16,
+ /// Which row of a taller tag.
+ line: u16 = 0,
+ pixel_x: f32,
+ pixel_y: f32,
+ metrics: Metrics,
+};
+
+kind: Kind = .body,
+/// The pane slot, or the column, it belongs to.
+id: u16 = 0,
+serial: u32 = 0,
+viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 },
+cols: u16 = 0,
+rows: u16 = 0,
+/// A body's compact rows, from its top.
+context_rows: u16 = 0,
+context_separators: std.bit_set.StaticBitSet(limits.wrap_rows) = .initEmpty(),
+/// `rows * cols`, row after row.
+cells: []pardes.Cell = &.{},
+cursor: ?struct { x: u16, y: u16, bar: bool = false } = null,
+/// A band's fill, where its cells run out.
+bg: [3]u8 = .{ 0, 0, 0 },
+/// Vertical offset in rows (negative: above its viewport), for a band
+/// that is moving into place. A pixel shell draws it offset and clipped
+/// to the rows below the viewport's top; the grid has no such thing.
+slide: f32 = 0,
+/// How present a notice band is (1 = fully) while it fades up or
+/// dissolves. `bg` and the cells are already blended by it; a pixel
+/// shell uses it for what it draws around the band, like its rule.
+fade: f32 = 1,
+
+/// Tag columns a viewport this wide holds at the tagline's pitch.
+pub fn capacity(layer: *const Layer, metrics: Metrics) u16 {
+ return @intCast(@min(std.math.maxInt(u16), @max(1, @as(u32, layer.viewport.w) * @max(1, metrics.body_w) / @max(1, metrics.tagline_w))));
+}
+
+pub fn setContextLines(layer: *Layer, lines: []const i32) void {
+ layer.context_separators = .initEmpty();
+ const count = @min(lines.len, layer.context_rows);
+ var index: usize = 0;
+ while (index + 1 < count and index < limits.wrap_rows) : (index += 1) {
+ if (lines[index + 1] != lines[index] +| 1) layer.context_separators.set(index);
+ }
+}
+
+pub fn hasContextBorderAfter(layer: *const Layer, index: usize) bool {
+ if (index >= layer.context_rows or index >= limits.wrap_rows) return false;
+ return index + 1 == layer.context_rows or layer.context_separators.isSet(index);
+}
+
+/// Pixel top of row `index`: a body's compact rows stacked at the tagline's
+/// height, every other row a grid row.
+pub fn rowTop(layer: *const Layer, index: u16, body_h: f32, tagline_h: f32) f32 {
+ const body = @max(1, body_h);
+ const tag = std.math.clamp(tagline_h, 1, body);
+ return @as(f32, @floatFromInt(layer.viewport.y)) * body +
+ @as(f32, @floatFromInt(@min(index, layer.context_rows))) * tag +
+ @as(f32, @floatFromInt(index -| layer.context_rows)) * body;
+}
+
+pub fn rowHeight(layer: *const Layer, index: u16, body_h: f32, tagline_h: f32) f32 {
+ return if (index < layer.context_rows) std.math.clamp(tagline_h, 1, @max(1, body_h)) else @max(1, body_h);
+}
+
+const Point = struct { index: u16, col: u16, metrics: Metrics };
+
+/// The row and column a window pixel lands on, each row at its own pitch:
+/// a tag's at the tagline's width, a body's compact rows too.
+fn pointAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?Point {
+ if (layer.rows == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null;
+ // A band's hit is refused outright on metrics no host could have; a
+ // body's has always been floored to a pixel instead, and both refuse
+ // what is no number or past a u16 (a host's floats: web's exports).
+ for ([_]f32{ body_w, body_h, tagline_w, tagline_h }) |metric| {
+ if (!std.math.isFinite(metric) or metric > std.math.maxInt(u16)) return null;
+ if (layer.kind != .body and metric < 1) return null;
+ }
+ const bw = @max(1, body_w);
+ const bh = @max(1, body_h);
+ const tw = std.math.clamp(tagline_w, 1, bw);
+ const th = std.math.clamp(tagline_h, 1, bh);
+ const dx = x - @as(f32, @floatFromInt(layer.viewport.x)) * bw;
+ const dy = y - @as(f32, @floatFromInt(layer.viewport.y)) * bh;
+ if (dx < 0 or dy < 0 or dx >= @as(f32, @floatFromInt(layer.viewport.w)) * bw or dy >= @as(f32, @floatFromInt(layer.viewport.h)) * bh) return null;
+ const headers = @as(f32, @floatFromInt(layer.context_rows)) * th;
+ const index: u16 = @intFromFloat(if (dy < headers) @floor(dy / th) else @as(f32, @floatFromInt(layer.context_rows)) + @floor((dy - headers) / bh));
+ if (index >= layer.rows) return null;
+ const pitch = if (layer.kind != .body or index < layer.context_rows) tw else bw;
+ return .{
+ .index = index,
+ .col = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols -| 1)), @floor(dx / pitch))),
+ .metrics = .{ .body_w = @intFromFloat(bw), .body_h = @intFromFloat(bh), .tagline_w = @intFromFloat(tw), .tagline_h = @intFromFloat(th) },
+ };
+}
+
+/// A body layer's pixel as the body cell under it (grid columns, from the
+/// pane's text edge).
+pub fn bodyHit(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?pardes.Mouse.BodyHit {
+ if (layer.kind != .body) return null;
+ const point = layer.pointAt(x, y, body_w, body_h, tagline_w, tagline_h) orelse return null;
+ return .{ .serial = layer.serial, .row = point.index, .col = point.col + config.GUTTER, .pixel_x = x, .pixel_y = y, .metrics = point.metrics };
+}
+
+/// A band's pixel as the tag cell under it.
+pub fn tagHit(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?TagHit {
+ if (layer.kind == .body) return null;
+ const point = layer.pointAt(x, y, body_w, body_h, tagline_w, tagline_h) orelse return null;
+ return .{ .kind = layer.kind, .id = layer.id, .serial = layer.serial, .line = point.index, .col = point.col, .pixel_x = x, .pixel_y = y, .metrics = point.metrics };
+}
+
+test "compact body layer rows fill reclaimed height and invert exact boundaries" {
+ const layer: Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 40, .h = 10 }, .cols = 40, .rows = 15, .context_rows = 5 };
+ try std.testing.expectEqual(@as(f32, 120), layer.rowTop(5, 20, 12));
+ try std.testing.expectEqual(@as(f32, 240), layer.rowTop(11, 20, 12));
+ try std.testing.expectEqual(@as(f32, 12), layer.rowHeight(4, 20, 12));
+ try std.testing.expectEqual(@as(f32, 20), layer.rowHeight(5, 20, 12));
+ for (0..12) |index| {
+ const r: u16 = @intCast(index);
+ const y = layer.rowTop(r, 20, 12) + layer.rowHeight(r, 20, 12) / 2;
+ const width: f32 = if (r < 5) 6 else 10;
+ const hit = layer.bodyHit(40 + 3.5 * width, y, 10, 20, 6, 12).?;
+ try std.testing.expectEqual(r, hit.row);
+ try std.testing.expectEqual(@as(u16, 3 + config.GUTTER), hit.col);
+ }
+ try std.testing.expectEqual(@as(u16, 4), layer.bodyHit(45, 119.99, 10, 20, 6, 12).?.row);
+ try std.testing.expectEqual(@as(u16, 5), layer.bodyHit(45, 120, 10, 20, 6, 12).?.row);
+ try std.testing.expectEqual(@as(u16, 11), layer.bodyHit(45, 259.99, 10, 20, 6, 12).?.row);
+ try std.testing.expect(layer.bodyHit(45, 260, 10, 20, 6, 12) == null);
+ try std.testing.expect(layer.bodyHit(39.99, 80, 10, 20, 6, 12) == null);
+ try std.testing.expect(layer.bodyHit(std.math.nan(f32), 80, 10, 20, 6, 12) == null);
+}
+
+test "compact context borders separate source gaps and preserve multiline headers" {
+ var layer: Layer = .{ .context_rows = 6 };
+ layer.setContextLines(&.{ 3, 4, 8, 9, 10, 17 });
+ const expected = [_]bool{ false, true, false, false, true, true, false };
+ for (expected, 0..) |border, index| try std.testing.expectEqual(border, layer.hasContextBorderAfter(index));
+ layer.context_rows = 2;
+ layer.setContextLines(&.{ 9, 10 });
+ try std.testing.expect(!layer.hasContextBorderAfter(0));
+ try std.testing.expect(layer.hasContextBorderAfter(1));
+ try std.testing.expect(!layer.hasContextBorderAfter(4));
+ try std.testing.expect(!layer.context_separators.isSet(4));
+}
+
+test "tag text capacity uses its own pitch and excludes the physical grip" {
+ const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 1 }, .cols = 90, .rows = 1 };
+ try std.testing.expectEqual(@as(u16, 63), layer.capacity(.{ .body_w = 10, .tagline_w = 6 }));
+ try std.testing.expect(layer.tagHit(119.99, 85, 10, 20, 6, 12) == null);
+ try std.testing.expectEqual(@as(u16, 0), layer.tagHit(120, 85, 10, 20, 6, 12).?.col);
+ try std.testing.expectEqual(@as(u16, 50), layer.tagHit(423, 85, 10, 20, 6, 12).?.col);
+ try std.testing.expect(layer.tagHit(500, 85, 10, 20, 6, 12) == null);
+ try std.testing.expect(layer.tagHit(120, 85, std.math.nan(f32), 20, 6, 12) == null);
+ try std.testing.expect(layer.tagHit(120, 85, 10, 20, 0, 12) == null);
+}
+
+test "a taller tag's rows are grid rows, each hit on its own line" {
+ const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 3 }, .cols = 90, .rows = 3 };
+ try std.testing.expectEqual(@as(u16, 0), layer.tagHit(120, 80, 10, 20, 6, 12).?.line);
+ try std.testing.expectEqual(@as(u16, 1), layer.tagHit(120, 100, 10, 20, 6, 12).?.line);
+ try std.testing.expectEqual(@as(u16, 2), layer.tagHit(120, 139.99, 10, 20, 6, 12).?.line);
+ try std.testing.expect(layer.tagHit(120, 140, 10, 20, 6, 12) == null);
+ try std.testing.expect(layer.bodyHit(120, 100, 10, 20, 6, 12) == null);
+}