From 475cc49ccbe9e81f8ec8aa2371c20fc632d110b9 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 9 Aug 2026 00:15:38 -0300 Subject: tagline: align the command words across the panes of a column --- src/pardes.zig | 208 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 203 insertions(+), 5 deletions(-) (limited to 'src') 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 -- cgit v1.3