summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig235
1 files changed, 215 insertions, 20 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 1eae2113..b64b62d7 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -229,6 +229,193 @@ pub fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 {
return @min(@as(u32, config.gui_topbar_pane_border_px), spare * 2);
}
+/// The column a compact tagline band anchors at: the left edge of the pane
+/// whose tag row this cell sits on. ONE rule for both pixel hosts, for exactly
+/// the reason `taglineBandOffset` is one — the SDL shell reached this through
+/// its own copy of the pane walk, and the AppKit shell could not do the walk at
+/// all (pane rects are not on its C ABI), so its tag rows advanced on BODY
+/// pitch with the smaller glyph merely centred in each body cell. Same session,
+/// same percentage, visibly looser tracking in one of the two windows.
+///
+/// CELLS, and fractional on purpose: an animating panel's box is fractional,
+/// and rounding here would step a sliding pane's tag row a whole body cell at a
+/// time while the rest of the pane moved smoothly.
+///
+/// `track` is the panel track painting this cell, when one is. It is a
+/// parameter rather than something looked up here because the caller has
+/// already decided which track owns the cell — the SDL shell from its paint
+/// plan, the C ABI wrapper from the frame's track list — and two answers to
+/// that question is the drift this function exists to prevent.
+///
+/// The last resort is the cell's own column, which puts that one cell back on
+/// body pitch. That is deliberate: a stale cell whose pane has closed, or any
+/// cell of an attached window, still has to be legible, and a band anchored at
+/// a pane that no longer exists is not.
+pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?panel_animation.Track) f32 {
+ if (row < TOPBAR_H) return 0;
+ if (track) |active| {
+ const box = active.contentBox();
+ const tag_y = if (p.settings.tag_bottom) box.y + box.h - @as(f32, @floatFromInt(BOX_H)) else box.y;
+ if (@as(f32, @floatFromInt(row)) >= tag_y and
+ @as(f32, @floatFromInt(row)) < tag_y + @as(f32, @floatFromInt(BOX_H)))
+ return box.x;
+ }
+ for (p.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const r = p.rects[id];
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ if (row == tag_y and col >= r.x and col < r.x + r.w) return @floatFromInt(r.x);
+ }
+ return @floatFromInt(col);
+}
+
+/// `taglineOriginCol` for a host with no paint plan of its own: the owning
+/// track is resolved from the frame's own list. The SDL shell already knows
+/// which track is painting a cell and passes it; AppKit reaches the grid
+/// through the C ABI and does not, so the lookup belongs here rather than in
+/// the wrapper — a second answer to "which track owns this cell" is exactly
+/// the drift `taglineOriginCol` was moved into the core to stop.
+pub fn taglineOriginColForFrame(p: *const Pardes, col: u16, row: u16) f32 {
+ for (p.surface.panelTracks()) |track|
+ if (track.contentBox().contains(col, row))
+ return taglineOriginCol(p, col, row, track);
+ return taglineOriginCol(p, col, row, null);
+}
+
+test "paint order is moving, then opening, then closing tombstones on top" {
+ const Track = panel_animation.Track;
+ // Deliberately interleaved on the way in: the phases are what order the
+ // output, not the slot they happened to occupy.
+ const live = [_]?Track{
+ .{ .serial = 11, .pane = 3, .phase = .opening, .effect = .slide },
+ .{ .serial = 12, .pane = 1, .phase = .moving, .effect = .zoom },
+ null,
+ .{ .serial = 14, .pane = 5, .phase = .opening, .effect = .ascii },
+ .{ .serial = 15, .pane = 2, .phase = .moving, .effect = .dissolve },
+ };
+ const closing = [_]Track{
+ .{ .serial = 16, .pane = 2, .phase = .closing, .effect = .vertical },
+ };
+ var out: [8]Track = undefined;
+ const len = panel_animation.paintOrder(&live, &closing, &out);
+
+ var serials: [8]u32 = undefined;
+ for (out[0..len], 0..) |track, i| serials[i] = track.serial;
+ try std.testing.expectEqualSlices(u32, &.{ 12, 15, 11, 14, 16 }, serials[0..len]);
+
+ // A host's array is fixed-size and the core's is not its business: writing
+ // past it would be a buffer overrun in whichever shell had the smaller one.
+ var tight: [2]Track = undefined;
+ try std.testing.expectEqual(@as(usize, 2), panel_animation.paintOrder(&live, &closing, &tight));
+ try std.testing.expectEqual(@as(u32, 12), tight[0].serial);
+ try std.testing.expectEqual(@as(u32, 15), tight[1].serial);
+}
+
+/// The INVERSE of the two rules above: which grid column a pointer sits in,
+/// given where the glyphs actually went. Compacting a tag row without
+/// compacting the hit test is a click that lands one word to the right by the
+/// end of the row, so these two are one feature and belong in one place.
+///
+/// `x` and both widths are in whatever unit the host measures in — physical
+/// pixels for SDL, points for AppKit — because only their RATIO is used.
+///
+/// The topbar anchors at column zero, a pane tag row at its pane's left edge
+/// and is clamped to that pane's last column so a click in the slack at the
+/// right of a compacted band stays on the pane it was aimed at, and everything
+/// else is the body grid. Deliberately track-blind: the pointer is aimed at
+/// what is on screen NOW, and a mid-animation pane is somewhere its own
+/// geometry says it is not yet.
+pub fn gridColAt(p: ?*const Pardes, x: f32, row: u16, body_w: f32, tagline_w: f32) u16 {
+ const body = @max(body_w, 1);
+ const tag = @max(tagline_w, 1);
+ if (row < TOPBAR_H) return colFromSpan(x, tag);
+ if (p) |core| for (core.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const r = core.rects[id];
+ const tag_y = if (core.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ if (row != tag_y or r.w == 0) continue;
+ const left = @as(f32, @floatFromInt(r.x)) * body;
+ const right = @as(f32, @floatFromInt(r.x + r.w)) * body;
+ if (x < left or x >= right) continue;
+ const within: u16 = @intFromFloat(@min(
+ @floor(@max(0, x - left) / tag),
+ @as(f32, @floatFromInt(r.w - 1)),
+ ));
+ return r.x + within;
+ };
+ return colFromSpan(x, body);
+}
+
+fn colFromSpan(x: f32, span: f32) u16 {
+ return @intFromFloat(@min(@floor(@max(x, 0) / span), 10_000));
+}
+
+test "a compact tagline anchors at its own pane, and both shells step from the same origin" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 24 });
+ defer p.deinit();
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+
+ // Two panes side by side put two tags on ONE row, which is the case a
+ // per-row origin gets wrong and the reason this is asked per cell.
+ const left = p.rects[0];
+ const right = p.rects[1];
+ try std.testing.expect(right.x > left.x);
+ try std.testing.expectEqual(@as(f32, @floatFromInt(left.x)), taglineOriginCol(p, left.x + 3, left.y, null));
+ try std.testing.expectEqual(@as(f32, @floatFromInt(right.x)), taglineOriginCol(p, right.x + 3, right.y, null));
+
+ // The topbar's origin is column zero always — it is not a pane rect, which
+ // is why row zero is right even in a window with no core to ask.
+ try std.testing.expectEqual(@as(f32, 0), taglineOriginCol(p, 40, 0, null));
+
+ // A row no pane tags falls back to the cell's own column, which is the
+ // identity that puts that cell back on body pitch rather than sliding it
+ // somewhere a closed pane used to be.
+ const body_row = left.y + 2;
+ try std.testing.expectEqual(@as(f32, 7), taglineOriginCol(p, 7, body_row, null));
+
+ // THE CROSS-SHELL CONTRACT. gui.zig lays a compact cell out as
+ // `x_off + col * tag_w` with `x_off = origin * (body_w - tag_w)`; the
+ // AppKit shell spells the same placement as
+ // `origin * body_w + (col - origin) * tag_w`. They are the same line of
+ // algebra and this is the assertion that keeps them one: the two windows
+ // are supposed to be indistinguishable at the same percentage, and the
+ // whole bug was one of them quietly using body pitch.
+ const body_w: f32 = 10;
+ const tag_w: f32 = 8;
+ for ([_]u16{ 0, 1, 5, 40, 119 }) |col| {
+ const origin = taglineOriginCol(p, col, left.y, null);
+ const sdl = origin * (body_w - tag_w) + @as(f32, @floatFromInt(col)) * tag_w;
+ const appkit = origin * body_w + (@as(f32, @floatFromInt(col)) - origin) * tag_w;
+ try std.testing.expectEqual(sdl, appkit);
+ }
+
+ // ...and the POINTER agrees with both. Placing a glyph on a narrower pitch
+ // while still dividing clicks by the body cell is a hit that drifts one
+ // column further right for every column along the row — dead centre of the
+ // last word in a wide tag lands on empty space past its end. Forward and
+ // inverse live in different files and different languages; this is what
+ // keeps them inverses.
+ for ([_]u16{ 0, 1, 4, 9 }) |offset| {
+ const col = left.x + offset;
+ if (offset >= left.w) break;
+ const origin = taglineOriginCol(p, col, left.y, null);
+ const glyph_x = origin * body_w + (@as(f32, @floatFromInt(col)) - origin) * tag_w;
+ try std.testing.expectEqual(col, gridColAt(p, glyph_x + tag_w / 2, left.y, body_w, tag_w));
+ }
+
+ // The topbar's pointer grid is compact from column zero, with no pane to
+ // anchor to — the one tag row that is right with or without a core.
+ try std.testing.expectEqual(@as(u16, 3), gridColAt(p, 3.5 * tag_w, 0, body_w, tag_w));
+ try std.testing.expectEqual(@as(u16, 3), gridColAt(null, 3.5 * tag_w, 0, body_w, tag_w));
+
+ // A body row is untouched: still the body grid, still divided by the body
+ // cell. Only tag rows compact.
+ try std.testing.expectEqual(@as(u16, 3), gridColAt(p, 3.5 * body_w, body_row, body_w, tag_w));
+}
+
/// A place the keyboard has been: a pane AND a spot in it, which is the whole
/// upgrade over the stack of bare pane ids this replaces — Ctrl-o can now
/// rewind WITHIN a pane, and a Jumplist row can name a line.
@@ -15508,19 +15695,14 @@ pub const Pardes = struct {
s.cell_diffs = p.panel_cell_diffs;
}
// Moving panes first, then new panes, then inert closing tombstones on
- // top. Native GUI paint planners may regroup by phase, but every host
- // receives this same deterministic dense record set.
- for ([_]panel_animation.Phase{ .moving, .opening }) |phase| for (p.panel_tracks) |maybe| {
- const track = maybe orelse continue;
- if (!track.active() or track.phase != phase) continue;
- s.panel_tracks[s.npanel_tracks] = track;
- s.npanel_tracks += 1;
- };
- for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |track| {
- if (!track.active()) continue;
- s.panel_tracks[s.npanel_tracks] = track;
- s.npanel_tracks += 1;
- }
+ // top. The rule is `panel_animation.paintOrder` so that it has exactly
+ // one definition: every host receives this same deterministic dense
+ // record set and none of them needs to sort it again.
+ s.npanel_tracks = panel_animation.paintOrder(
+ &p.panel_tracks,
+ p.closing_panel_tracks[0..p.nclosing_panel_tracks],
+ &s.panel_tracks,
+ );
return p.composeAsciiTransitions(arena, s);
}
@@ -15736,8 +15918,13 @@ pub const Pardes = struct {
// text area resets to terminal-default cells (vaxis clear semantics);
// light themes paint the page over it.
- s.clearRect(tx, r.y, tw, r.h);
- if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } });
+ {
+ // Two full passes over every cell in the pane, every frame.
+ const tz_clear = tracy.zone(@src(), "paneClear");
+ defer tz_clear.end();
+ s.clearRect(tx, r.y, tw, r.h);
+ if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } });
+ }
// the layout box: the pane's MODE, one character, in the gutter cells
// of the tag row. Same box you drag a pane by — the whole GUTTER is
@@ -15870,11 +16057,17 @@ pub const Pardes = struct {
const tz_body = tracy.zone(@src(), "bodyText");
const body = try p.bodyText(arena, pane);
tz_body.end();
- var it = std.mem.splitScalar(u8, body, '\n');
- var i: u16 = 0;
- while (it.next()) |line| : (i += 1) {
- if (i >= body_h) break;
- _ = s.print(tx, body_y + i, tw, line, body_style);
+ {
+ // The rows themselves. `bodyText` above is only the string BUILD;
+ // this is what writes it into the surface.
+ const tz_rows = tracy.zone(@src(), "paneBodyRows");
+ defer tz_rows.end();
+ var it = std.mem.splitScalar(u8, body, '\n');
+ var i: u16 = 0;
+ while (it.next()) |line| : (i += 1) {
+ if (i >= body_h) break;
+ _ = s.print(tx, body_y + i, tw, line, body_style);
+ }
}
// Coloring is one algorithm per pane, chosen by title (colorAlgo): the
@@ -15883,6 +16076,7 @@ pub const Pardes = struct {
// both feed f.highlights, which refreshHighlights filled with whichever
// this same choice named. Order is load-bearing — gutter, recolor, then
// wrap markers; the selection/cursor passes below win over all three.
+ const tz_color = tracy.zone(@src(), "paneRecolor");
switch (pane.colorAlgo()) {
// Every mode, not just `.tty`: `recolorAnsi` translates a row's
// colour anchor through the same slide the edit buffer applied to
@@ -15902,6 +16096,7 @@ pub const Pardes = struct {
},
.none => {},
}
+ tz_color.end();
// mouse selections (pane-local coords), one pass per button — later
// buttons win on overlap. A left .done stays highlighted after release;