From 022773d94dad2775c806f863a374bd945d4dc989 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 2 Aug 2026 20:07:26 -0300 Subject: show New in every pane tag and enforce builtin-first Exec fallback --- src/config.zig | 8 +-- src/pardes.zig | 164 +++++++++++++++++++++++++++++++++++++++++++++++++++++---- src/tutor.txt | 9 ++-- 3 files changed, 163 insertions(+), 18 deletions(-) (limited to 'src') 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` 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` 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 -- cgit v1.3