summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 20:07:26 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit022773d94dad2775c806f863a374bd945d4dc989 (patch)
tree588ac763f778a296168d7b82c4b0d2d2cd26b0ed /src
parent820ff337d156831d250ff0fcef5139fe69474e4e (diff)
downloadpardes-022773d94dad2775c806f863a374bd945d4dc989.tar.gz
pardes-022773d94dad2775c806f863a374bd945d4dc989.zip
show New in every pane tag and enforce builtin-first Exec fallback
Diffstat (limited to 'src')
-rw-r--r--src/config.zig8
-rw-r--r--src/pardes.zig164
-rw-r--r--src/tutor.txt9
3 files changed, 163 insertions, 18 deletions
diff --git a/src/config.zig b/src/config.zig
index 1c986178..41a70e4a 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -296,9 +296,11 @@ pub const tty_toggle_alt: []const Chord = &.{.{ .cp = Key.escape, .shift = true
pub const topbar_str = "New Newcol Find Grep Help Tutor Dump NextColor Debug Kill";
/// the default editable tail of a pane's tag, per kind (an output buffer has
-/// no file to Save, so it gets the plain one)
-pub const pane_builtins_str = "Del";
-pub const file_pane_builtins_str = "Save Del";
+/// no file to Save, so it gets the plain one). Save stays first on real files:
+/// `:` parks at the tail boundary, so the established `:w<Tab>` spelling still
+/// walks to Save while New remains visible on every pane.
+pub const pane_builtins_str = "New Del";
+pub const file_pane_builtins_str = "Save New Del";
/// the pane's mode, as ONE character in the layout box at its top-left — live
/// chrome, not text you own. It used to be a three-letter word leading every
diff --git a/src/pardes.zig b/src/pardes.zig
index 8cf8d2e2..bc56f966 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -98,6 +98,7 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" {
try std.testing.expect(std.mem.indexOf(u8, initial_tag, " width ") != null);
try std.testing.expect(std.mem.indexOf(u8, initial_tag, " height ") == null);
try std.testing.expect(std.mem.indexOf(u8, initial_tag, "PdfFit") != null);
+ try std.testing.expect(std.mem.endsWith(u8, initial_tag, pane_tail));
p.native_images = true;
var frame = std.heap.ArenaAllocator.init(gpa);
@@ -583,6 +584,12 @@ test "feature-off .pdf argv opens an ordinary file pane" {
const pane_tail = " " ++ config.pane_builtins_str;
const file_pane_tail = " " ++ config.file_pane_builtins_str;
+// Dumps persist the whole rendered tag. These were the two canonical tails
+// before New joined every pane; restoreTail recognizes exactly these spellings
+// as defaults so an old session upgrades, without mistaking an edited tail for
+// chrome it may replace.
+const legacy_pane_tail = " Del";
+const legacy_file_pane_tail = " Save Del";
// Builtins: executing the name (middle-click / Tab) runs it through the ONE
// dispatcher (runBuiltin, reached from execute), no matter where the name
@@ -633,6 +640,137 @@ test "startup config runs builtin lines in order and isolates bad lines" {
};
}
+test "pane-tag Exec prefers New and argument builtins before shell fallback" {
+ 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
+
+ const pane = p.panes[0].?;
+ try std.testing.expectEqualStrings(" New Del", Pardes.curTail(pane));
+ const tag_x = p.rects[0].x + config.GUTTER;
+ const tag_y = p.rects[0].y;
+
+ // A real middle-click on the canonical pane-tag word reaches Exec, which
+ // must consume New as a builtin before any write can reach the shell.
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } });
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 1, .row = tag_y } });
+ var saw_new = false;
+ while (p.nextEffect()) |effect| switch (effect) {
+ .new_file => |request| {
+ try std.testing.expectEqual(@as(u8, 0), request.pane);
+ try std.testing.expectEqual(pane.serial, request.serial);
+ saw_new = true;
+ },
+ .write => return error.NewFellThroughToPty,
+ else => {},
+ };
+ try std.testing.expect(saw_new);
+
+ // Argument-taking builtins use the same gesture path: a tag sweep is the
+ // complete command line, and Theme consumes its tail without a PTY write.
+ pane.tag_tail.clearRetainingCapacity();
+ try pane.tag_tail.appendSlice(gpa, " Theme dark");
+ pane.tag_init = true;
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } });
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .drag, .col = tag_x + 10, .row = tag_y } });
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 10, .row = tag_y } });
+ try std.testing.expectEqualStrings("dark", p.theme().name);
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.ArgumentBuiltinFellThroughToPty,
+ else => {},
+ };
+
+ // Fixed-name builtins remain exact: a tail does not turn New or Kill into
+ // a match (especially important for the destructive one).
+ try std.testing.expect(!p.executeBuiltinLine(0, "New argument"));
+ try std.testing.expect(!p.executeBuiltinLine(0, "Kill trailing-garbage"));
+ try std.testing.expect(!p.quit);
+
+ // A word absent from the builtin vocabulary takes the fallback, including
+ // the terminating carriage return expected by the shell prompt.
+ pane.tag_tail.clearRetainingCapacity();
+ try pane.tag_tail.appendSlice(gpa, " DefinitelyNotABuiltin");
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } });
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 1, .row = tag_y } });
+ var sent: std.ArrayList(u8) = .empty;
+ defer sent.deinit(gpa);
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |w| {
+ try std.testing.expectEqual(@as(u8, 0), w.pane);
+ try sent.appendSlice(gpa, w.bytes.slice());
+ },
+ .new_file => return error.UnknownCommandRanNew,
+ else => {},
+ };
+ try std.testing.expectEqualStrings("DefinitelyNotABuiltin\r", sent.items);
+}
+
+test "legacy default tag tails upgrade while custom tails remain owned" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{});
+ defer p.deinit();
+
+ const terminal = p.panes[0].?;
+ const terminal_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{
+ try p.tagPrefix(terminal),
+ legacy_pane_tail,
+ });
+ p.restoreTail(terminal, terminal_old);
+ try std.testing.expect(!terminal.tag_init);
+ try std.testing.expectEqualStrings(" New Del", Pardes.curTail(terminal));
+
+ const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{
+ try p.tagPrefix(terminal),
+ });
+ p.restoreTail(terminal, terminal_custom);
+ try std.testing.expect(terminal.tag_init);
+ try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal));
+
+ // A savable file has a distinct old default. Save remains first after the
+ // migration so the tag's established `:w<Tab>` route is unchanged.
+ const file = try p.hxOpenFileContent("");
+ const file_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{
+ try p.tagPrefix(file),
+ legacy_file_pane_tail,
+ });
+ p.restoreTail(file, file_old);
+ try std.testing.expect(!file.tag_init);
+ try std.testing.expectEqualStrings(" Save New Del", Pardes.curTail(file));
+
+ const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{
+ try p.tagPrefix(file),
+ });
+ p.restoreTail(file, file_custom);
+ try std.testing.expect(file.tag_init);
+ try std.testing.expectEqualStrings(" Save Mine Del", Pardes.curTail(file));
+}
+
+test "unknown Exec from an image writes to a terminal in the image directory" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{});
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ p.setCwd(0, "/tmp/pardes-image-dir");
+ const image_pane = try p.newDocPane(1);
+ image_pane.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") };
+ try std.testing.expectEqualStrings("/tmp/pardes-image-dir", Pardes.paneDir(image_pane));
+ try std.testing.expectEqual(@as(?usize, 0), p.execute(1, "echo image-fallback"));
+
+ var sent: std.ArrayList(u8) = .empty;
+ defer sent.deinit(gpa);
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |w| {
+ try std.testing.expectEqual(@as(u8, 0), w.pane);
+ try sent.appendSlice(gpa, w.bytes.slice());
+ },
+ .spawn => return error.ImageExecSpawnedDespiteMatchingTerminal,
+ else => {},
+ };
+ try std.testing.expectEqualStrings("echo image-fallback\r", sent.items);
+}
+
test "New completes as an empty watched file in the calling column and focuses it" {
const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 });
defer p.deinit();
@@ -4751,6 +4889,7 @@ pub const Pardes = struct {
if (pane.file) |f| return std.fs.path.dirname(f.path) orelse "/";
if (comptime pdf_enabled) if (pane.pdf) |pv|
return std.fs.path.dirname(pv.path) orelse "/";
+ if (pane.image) |iv| return std.fs.path.dirname(iv.path) orelse "/";
return pane.cwdSlice();
}
@@ -8330,14 +8469,11 @@ pub const Pardes = struct {
// Anything not in the builtin vocabulary is a command line typed at
// a shell. Startup config calls executeBuiltinLine directly and never
// reaches this fallback.
- // terminal: run in itself. file pane: run in a shell in the file's
- // directory (found by cwd, else freshly forked).
- const dst = (if (pane.file) |f|
- p.ttyForDir(std.fs.path.dirname(f.path) orelse "/")
- else if (hasPdf(pane))
- p.ttyForDir(paneDir(pane))
- else
- id) orelse return null;
+ // A terminal runs in itself. Every document kind — real/output file,
+ // image, or PDF — runs in a terminal for its directory (an existing
+ // one when possible, otherwise a freshly forked shell). In particular,
+ // never emit a PTY write addressed to an image's no-PTY pane slot.
+ const dst = (if (pane.isTerminal()) id else p.ttyForDir(paneDir(pane))) orelse return null;
term_pane.padOutputBelowEdits(p, dst);
p.emitWrite(dst, cmd);
p.emitWrite(dst, "\r");
@@ -8760,13 +8896,19 @@ pub const Pardes = struct {
return p;
}
- /// restore a custom tag tail from a saved tag: whatever follows the live
- /// prefix, unless it's just the defaults
+ /// Restore a custom tag tail from a saved tag: whatever follows the live
+ /// prefix, unless it is this build's default or either exact legacy
+ /// default from before New appeared in every pane. Legacy defaults upgrade
+ /// to the live default; genuinely edited tails remain byte-for-byte owned.
fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void {
const pfx = p.tagPrefix(pane) catch return;
if (!std.mem.startsWith(u8, saved_tag, pfx)) return;
const rest = saved_tag[pfx.len..];
- if (std.mem.eql(u8, rest, curTail(pane))) return;
+ const legacy = if (pane.file) |f|
+ if (output_pane.fileTraits(f.output).saves) legacy_file_pane_tail else legacy_pane_tail
+ else
+ legacy_pane_tail;
+ if (std.mem.eql(u8, rest, curTail(pane)) or std.mem.eql(u8, rest, legacy)) return;
pane.tag_tail.appendSlice(p.gpa, rest) catch return;
pane.tag_init = true;
}
diff --git a/src/tutor.txt b/src/tutor.txt
index cd92c8d5..1d01d626 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -110,8 +110,9 @@
THE TAG: each pane has a one-line tag: its directory or file path +
builtins. (The mode is the box at the tag's left end, not a word in the
- tag.) File panes show "Save Del" by default; Save
- writes the current file to disk, Del closes the window. Clicking a tag
+ tag.) Every pane shows "New" for an empty temporary file in its column.
+ File panes show "Save New Del" by default; Save writes the current file
+ to disk, Del closes the window. Clicking a tag
edits it in insert mode: type straight in, Enter looks / Tab executes
the word at the cursor, Esc hands focus back to the body. `:` from the
body focuses that same tag in NORMAL mode, parked at the first EDITABLE
@@ -119,7 +120,7 @@
onto "Save", Tab to execute it, and you land back in the body where you
left off (the chord keys are the same here as anywhere else; `i` if you
would rather type a command than walk to one). Terminal and image panes
- show "Del".
+ show "New Del".
In that normal mode h/j/k/l do NOT move inside the tag: a tagline is a
place in the LAYOUT, so they focus the neighbouring window and land on
ITS tagline, still in normal mode — you walk the taglines of the screen
@@ -542,7 +543,7 @@
select-then-act: middle-drag, or v/x then Tab
chords: 1-2 cut 1-3 paste (both in one hold = snarf)
2-1 = middle-exec with the left selection as argument
- file tag = mode + path + "Save Del"; other tags show Del
+ file tag = path + "Save New Del"; other tags show "New Del"
top bar = New Newcol Find Grep Help Tutor Dump ... Debug Kill
(keyboard: k off the topmost tagline)
WEB TOUCH: one-finger tap = LOOK; drag = natural scroll