summaryrefslogtreecommitdiff
path: root/src/Layer.zig
blob: e979eab7dc9139bba8e2b2a0524b39857dc6b075 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
//! 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);
}