//! 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. if (layer.kind != .body) 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; 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); }