summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig208
1 files changed, 203 insertions, 5 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 76f3bcd2..aa753277 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1857,6 +1857,137 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" {
try std.testing.expectEqualStrings("DefinitelyNotABuiltin\r", sent.items);
}
+test "an untouched tagline ends where its layout column's widest one does" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {} // initial shell spawn/geometry
+
+ // a second shell BELOW the first — what Alt-n does — sitting in a
+ // directory too long to leave tag_right_pad columns free
+ const below_id = p.freeSlot().?;
+ const below = try p.newShell(below_id, "");
+ const f = p.layoutFindTerm(p.active).?;
+ p.layoutInsert(f.col, f.idx + 1, below_id);
+ p.splitBelow(p.active, below);
+ p.setCwd(below_id, "/a/deep/directory/whose/name/eats/the/right/pad/off/the/end/of/its/own/tagline");
+ p.sync();
+ while (p.nextEffect()) |_| {}
+
+ const above = p.panes[0].?;
+ try std.testing.expectEqual(p.rects[0].x, p.rects[below_id].x);
+ const above_tag = try p.tagText(p.scratch.allocator(), above);
+ const below_tag = try p.tagText(p.scratch.allocator(), below);
+ // one column, so the tails end together — and past the pad, at the long
+ // path, which is the whole point (equal at tw - tag_right_pad would prove
+ // nothing: that is where both sat before)
+ try std.testing.expectEqual(below_tag.len, above_tag.len);
+ try std.testing.expectEqualStrings(" New Del", above_tag[above_tag.len - " New Del".len ..]);
+ try std.testing.expect(above_tag.len > @as(usize, p.rects[0].w) - config.GUTTER - config.tag_right_pad);
+ try std.testing.expect(above_tag.len <= @as(usize, p.rects[0].w) - config.GUTTER);
+
+ // A voter too wide for the pane is counted AT the pane's edge, not
+ // dropped: dropping it is a threshold, and one column of resize either
+ // side of the fit would move every tagline in the column by the whole pad
+ // while you drag the window edge. One column in, one column out.
+ p.update(.{ .resize = .{ .cols = 88, .rows = 30 } });
+ while (p.nextEffect()) |_| {}
+ const fits = (try p.tagText(p.scratch.allocator(), above)).len;
+ p.update(.{ .resize = .{ .cols = 87, .rows = 30 } });
+ while (p.nextEffect()) |_| {}
+ try std.testing.expectEqual(fits - 1, (try p.tagText(p.scratch.allocator(), above)).len);
+ p.update(.{ .resize = .{ .cols = 100, .rows = 30 } });
+ while (p.nextEffect()) |_| {}
+
+ // Touching the widest tag freezes ITS gap and must move nobody: it goes on
+ // voting with the end it was frozen at, however much is typed after the
+ // builtins. (A plain click seeds the tail, so the alternative is every
+ // other tagline in the column snapping left the moment you click one.)
+ p.seedTail(below);
+ try below.tag_tail.appendSlice(gpa, " lots and lots of typing out here");
+ try std.testing.expectEqual(above_tag.len, (try p.tagText(p.scratch.allocator(), above)).len);
+
+ // A pane squeezed off the bottom is not drawn, so it stops voting and the
+ // column falls back to the pad — 2 rows is one tagline and no room for the
+ // second pane at all.
+ p.update(.{ .resize = .{ .cols = 100, .rows = 2 } });
+ while (p.nextEffect()) |_| {}
+ try std.testing.expectEqual(@as(u16, 0), p.rects[below_id].h);
+ try std.testing.expectEqual(
+ @as(usize, p.rects[0].w) - config.GUTTER - config.tag_right_pad,
+ (try p.tagText(p.scratch.allocator(), above)).len,
+ );
+}
+
+test "tagPrefixLen agrees with tagPrefix for every pane kind" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ const same = struct {
+ fn f(pp: *Pardes, pane: *Pane) !void {
+ const s = try pp.tagPrefix(pane);
+ try std.testing.expectEqual(s.len, Pardes.tagPrefixLen(pane));
+ }
+ }.f;
+
+ // terminal: whatever boot gave it, then empty, then a changed cwd
+ const term = p.panes[0].?;
+ try same(p, term);
+ p.setCwd(0, "");
+ try same(p, term);
+ p.setCwd(0, "/some/where/deep/enough/to/matter");
+ try same(p, term);
+ p.setCwd(0, "/caf\u{e9}/\u{5b50}"); // multibyte: BYTES, both sides
+ try same(p, term);
+
+ // an output buffer (+Help) — a file pane whose path the table names
+ const help_id = p.freeSlot().?;
+ _ = try p.newDocPane(help_id);
+ output_pane.openHelp(p, help_id, "");
+ try same(p, p.panes[help_id].?);
+
+ // an image
+ const img_id = p.freeSlot().?;
+ const img = try p.newDocPane(img_id);
+ img.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-parity/pic.ppm") };
+ try same(p, img);
+
+ // an ordinary file pane (this one replaces pane 0, so it goes last)
+ const file = try p.hxOpenFileContent("hello\n");
+ try same(p, file);
+}
+
+test "tagPrefixLen agrees with tagPrefix for a pdf pane" {
+ if (!pdf_enabled or platform == .web) return;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const fixture = try pdf_impl.makeOutlineTestPdf(gpa);
+ defer gpa.free(fixture);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path});
+
+ const p = try Pardes.init(gpa, .{ .file = path, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pdf_pane = p.panes[0].?;
+ try std.testing.expect(pdf_pane.pdf != null);
+ const s0 = try p.tagPrefix(pdf_pane);
+ try std.testing.expectEqual(s0.len, Pardes.tagPrefixLen(pdf_pane));
+ // ...and on a later page / another fit / another tint, where the digits
+ // and the @tagName words change width
+ pdf_pane.pdf.?.page = 9;
+ pdf_pane.pdf.?.page_count = 100;
+ const s1 = try p.tagPrefix(pdf_pane);
+ try std.testing.expectEqual(s1.len, Pardes.tagPrefixLen(pdf_pane));
+ pdf_pane.pdf.?.page = 99;
+ const s2 = try p.tagPrefix(pdf_pane);
+ try std.testing.expectEqual(s2.len, Pardes.tagPrefixLen(pdf_pane));
+}
+
test "legacy default tag tails upgrade while custom tails remain owned" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{});
@@ -4494,6 +4625,9 @@ pub const Pardes = struct {
// ---- tag + selection text (chord sources) ----
+ // shared by tagPrefix and tagPrefixLen below, which have to agree
+ const pdf_tag_fmt = "pdf {d}/{d} {s} PdfFit {s} PdfTint PdfSections {s}";
+
/// the live tag prefix: the pane's cwd/path, and nothing else (an image
/// still names its kind — the renderer toggles it used to spell out are
/// builtins now, under SPC t). The mode used to lead this as a word; it is
@@ -4508,7 +4642,7 @@ pub const Pardes = struct {
const arena = p.scratch.allocator();
if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint(
arena,
- "pdf {d}/{d} {s} PdfFit {s} PdfTint PdfSections {s}",
+ pdf_tag_fmt,
.{ pv.page + 1, pv.page_count, @tagName(pv.fit), @tagName(pv.tint), pv.path },
);
if (pane.image) |iv| return std.fmt.allocPrint(arena, config.tag_image ++ " {s}", .{iv.path});
@@ -4516,6 +4650,20 @@ pub const Pardes = struct {
return arena.dupe(u8, pane.cwdSlice());
}
+ /// the same prefix's LENGTH, without the allocation. tagGap measures every
+ /// pane in a layout column, once per pane per frame, and has no arena —
+ /// formatting a path just to ask how wide it is would put the whole column
+ /// on the render hot path. Mirror any change to tagPrefix here.
+ fn tagPrefixLen(pane: *const Pane) usize {
+ if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.count(
+ pdf_tag_fmt,
+ .{ pv.page + 1, pv.page_count, @tagName(pv.fit), @tagName(pv.tint), pv.path },
+ );
+ if (pane.image) |iv| return config.tag_image.len + 1 + iv.path.len;
+ if (pane.file) |f| return f.path.len;
+ return pane.cwdSlice().len;
+ }
+
/// the editable tail: the user's edited buffer once touched, else defaults
/// (a buffer with nothing to Save gets the plain tail — the table decides)
fn curTail(pane: *Pane) []const u8 {
@@ -4547,13 +4695,63 @@ pub const Pardes = struct {
/// tag_tail and belong to you — recomputing would both double them and
/// slide the commands sideways under your cursor as you type. So an
/// untouched tag reflows with the pane and an edited one stays put.
+ ///
+ /// The end column is shared by the whole LAYOUT COLUMN — every DRAWN pane
+ /// at the same x and width, which is exactly the set whose taglines sit
+ /// above one another on screen (`h == 0` is a pane squeezed off the bottom
+ /// by a shrunk window: renderPane returns early on it, so it has no words
+ /// to line up with and gets no vote). `tag_right_pad` sets the end, but a
+ /// path too long to fit inside that pad used to collapse only ITS pane's
+ /// gap to zero, which left one row's commands jammed against the path while
+ /// the row below kept its out at the pad. Now the column moves out
+ /// together, so the words stay in a line and a click walks down them.
+ ///
+ /// Bytes, not display columns — like every other tag coordinate (see
+ /// Pane.tag_col). A multibyte path renders narrower than it measures, so
+ /// the line is straight for ASCII paths and drifts by a column per wide
+ /// character otherwise. That is the same approximation the plain
+ /// right-align always made; fixing it is a job for the whole tag
+ /// coordinate space, not for this scan.
+ ///
+ /// Two rules make up the "when possible":
+ ///
+ /// - a voter that does not FIT is counted at the pane's right edge rather
+ /// than dropped. Dropping it would leave the rest of the column its
+ /// typing room, but it is a threshold: one column of resize either side
+ /// of the fit moves every tagline in the column by the whole pad, and
+ /// dragging a window edge across that width snaps the words back and
+ /// forth under the pointer. Clamping buys a crossing one column wide
+ /// and monotone, and the price is a column of taglines that can end
+ /// hard against the right edge with nothing left over to type in.
+ /// - a TOUCHED tag votes with the end it was FROZEN at, not with its live
+ /// tail: the gap is baked into tag_tail as leading spaces, and counting
+ /// what you type after the builtins would drag the column sideways on
+ /// every keystroke. It still takes no gap of its own (above) — but it
+ /// has to keep voting, or clicking the widest tagline in a column would
+ /// snap every other one left, out from under the next click.
fn tagGap(p: *const Pardes, pane: *const Pane, used: usize) usize {
if (pane.tag_init) return 0;
const id = p.paneIdOf(pane) orelse return 0;
- const w = p.rects[id].w;
- if (w <= config.GUTTER) return 0;
- const tw: usize = w - config.GUTTER;
- return (tw -| config.tag_right_pad) -| used;
+ const r = p.rects[id];
+ if (r.w <= config.GUTTER) return 0;
+ const tw: usize = r.w - config.GUTTER;
+ var end: usize = tw -| config.tag_right_pad;
+ for (p.panes, 0..) |slot, qid| if (slot) |q| {
+ const qr = p.rects[qid];
+ if (qr.h == 0 or qr.x != r.x or qr.w != r.w) continue;
+ // prefix ++ the spaces in front of the words ++ the words: the
+ // frozen gap for a touched tail, the default's own single leading
+ // space for an untouched one (which is why there is no +1 here)
+ const words = if (q.file) |f|
+ (if (output_pane.fileTraits(f.output).saves) file_pane_tail else pane_tail)
+ else
+ pane_tail;
+ const laid = if (q.tag_init) q.tag_tail.items else words;
+ const lead = laid.len - std.mem.trimStart(u8, laid, " ").len;
+ const q_end = tagPrefixLen(q) + lead + std.mem.trimStart(u8, words, " ").len;
+ end = @max(end, @min(q_end, tw));
+ };
+ return end -| used;
}
/// the tag exactly as it is rendered: prefix ++ gap ++ tail. THE text