diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 235 |
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; |
