diff options
| -rw-r--r-- | main.zig | 109 | ||||
| -rw-r--r-- | tests.zig | 112 |
2 files changed, 195 insertions, 26 deletions
@@ -1551,9 +1551,14 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a }; // recolor selected cells (pane-local visual coords), one pass per button — - // later buttons win on overlap; only while actively dragging. + // later buttons win on overlap. .dragging = a live drag (any button); a left + // .done is a FINISHED selection that is kept highlighted after release (it + // clears on the next left click or a new left drag, and feeds the middle+left + // chord as the argument). middle/right .done are transient (they fire their + // action on release) so they are not kept visible. for (t.sel, 0..) |sl, b| { - if (sl.state != .dragging) continue; + if (sl.state == .none) continue; + if (sl.state == .done and b != @intFromEnum(Button.left)) continue; const r0 = @min(sl.r0, sl.r1); const r1 = @max(sl.r0, sl.r1); const c0 = @max(0, @min(sl.c0, sl.c1)); @@ -2655,6 +2660,10 @@ fn focusDir(active: *usize, terms: *const [MAX_TERMS]?*Term, rects: *const [MAX_ // .middle = "execute" (a builtin, else run the text in a shell). Shared by the // mouse release and the normal-mode Enter (look) / Tab (execute) keys. Returns // true if the app should quit (the last pane was closed). +// +// `arg` is the middle+left chord argument: a kept left selection captured by a +// left press during the middle drag. For a CLI (`.send`) it is appended as a +// trailing argument; each builtin arm decides for itself whether to use it. fn actOnSelection( io: std.Io, gpa: std.mem.Allocator, @@ -2669,6 +2678,7 @@ fn actOnSelection( button: Button, id: usize, txt: []const u8, + arg: ?[]const u8, ) bool { const t = terms[id] orelse return false; // image-pane tagline toggles (middle-click, acme execute): Petscii on/off, the @@ -2827,6 +2837,15 @@ fn actOnSelection( if (dst) |dt| { padOutputBelowEdits(dt); termWrite(dt, cmd); + // a chord argument (a kept left selection) is passed to the CLI as a + // trailing argument; builtins above decide for themselves (most ignore). + if (arg) |a| { + const at = std.mem.trim(u8, a, " \t\r\n"); + if (at.len > 0) { + termWrite(dt, " "); + termWrite(dt, at); + } + } termWrite(dt, "\r"); } }, @@ -3030,6 +3049,11 @@ pub fn main(init: std.process.Init) !void { var hover_col: u16 = 0; // last mouse position; used to tint the resize border under it var hover_row: u16 = 0; var ctrl_w_pending: bool = false; // Ctrl-w prefix for helix-style pane focus + // middle+left chord argument: a kept left selection captured by a left press + // during an active middle select-drag. gpa-owned; consumed (and freed) on the + // middle release, freed on overwrite / app exit. + var chord_arg: ?[]u8 = null; + defer if (chord_arg) |a| gpa.free(a); while (true) { const event = try loop.nextEvent(); @@ -3047,6 +3071,18 @@ pub fn main(init: std.process.Init) !void { if (mcol >= r.x and mcol < r.x + r.w and mrow >= r.y and mrow < r.y + r.h) break i; } else null; + // Any press that is NOT the middle+left chord (a left press while a + // middle select-drag is active) abandons a pending chord argument; + // free it so it can't leak or feed a later, unrelated release. + if (mouse.type == .press and + !(mouse.button == .left and drag == .select and drag.select.button == .middle)) + { + if (chord_arg) |a| { + gpa.free(a); + chord_arg = null; + } + } + if (mouse.button == .wheel_down) { if (hovered) |id| paneScrollBy(terms[id].?, 1); } else if (mouse.button == .wheel_up) { @@ -3059,11 +3095,27 @@ pub fn main(init: std.process.Init) !void { if (mouse.button == .middle) { const word = wordAtCol(topbar_str, mcol); if (word.len > 0) - if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, .middle, active, word)) break; + if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, .middle, active, word, null)) break; } } else if (mouse.type == .press and mouse.button == .left) { var handled = false; + // middle+left chord: a left press while a middle select-drag is + // active captures the KEPT left selection as the argument for the + // middle execute (fired on the middle release below). Don't start + // a new left selection — the middle drag runs until its own + // release, which dispatches the command with chord_arg. + if (drag == .select and drag.select.button == .middle) { + handled = true; + if (terms[drag.select.id]) |t| { + const sl = t.sel[@intFromEnum(Button.left)]; + if (sl.state == .done) { + if (chord_arg) |old| gpa.free(old); + chord_arg = selectionText(gpa, t, sl) catch null; + } + } + } + // vertical resize handle = the LEFT pane's last column (its own // content, not the right pane's gutter). The border check runs before // the pane hit test, so a press there resizes instead of clicking. @@ -3183,6 +3235,7 @@ pub fn main(init: std.process.Init) !void { .none => {}, } } else if (mouse.type == .release) { + var end_drag = true; switch (drag) { .border_v => |d| { const c = d.left_col; @@ -3227,17 +3280,30 @@ pub fn main(init: std.process.Init) !void { }; }, .select => |s| { - if (terms[s.id]) |t| { + // A release of a button OTHER than the one driving this + // drag is the middle+left chord's left release: ignore it + // so the middle drag keeps going until its own release + // runs the command (with chord_arg). end_drag stays false. + const released: ?Button = switch (mouse.button) { + .left => .left, + .middle => .middle, + .right => .right, + else => null, + }; + if (released == null or released.? != s.button) { + end_drag = false; + } else if (terms[s.id]) |t| { t.sel[@intFromEnum(s.button)].state = .done; if (s.button == .left) { - // left-click in the body enters NORMAL mode and - // anchors the cursor at the selection end. The row is - // stored absolute (visual + scroll off) so it rides - // the scrollback. tag clicks (row < BOX_H) don't edit. - // In tty mode we DON'T do this: the mouse stays usable - // for selecting/copying without yanking you out of the - // shell — the selection is still recorded above. + // left release: keep the selection highlighted if a + // real drag selected a range (it stays until the next + // left click / drag); a plain click (no movement) + // clears it — the selection "goes away" on a left + // click. Either way pin the cursor + enter normal + // mode on text panes (tty keeps the mouse usable). const sl = t.sel[@intFromEnum(Button.left)]; + const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1; + if (!dragged) t.sel[@intFromEnum(Button.left)].state = .none; const body_vis = sl.r1 - @as(i32, BOX_H); if (body_vis >= 0 and t.mode != .tty) { const off: i32 = paneScroll(t); @@ -3254,17 +3320,24 @@ pub fn main(init: std.process.Init) !void { } else { // middle/right: acme execute/look. A no-drag click // expands to the file-ish word under the cursor first. + // chord_arg (a kept left selection captured by a left + // press during this drag) is passed as the argument to + // the command; builtins decide what to do with it. expandSel(gpa, t, &t.sel[@intFromEnum(s.button)]); - if (selectionText(gpa, t, t.sel[@intFromEnum(s.button)])) |txt| { + const maybe_txt = selectionText(gpa, t, t.sel[@intFromEnum(s.button)]) catch null; + const arg = chord_arg; + chord_arg = null; + defer if (arg) |a| gpa.free(a); + if (maybe_txt) |txt| { defer gpa.free(txt); - if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, s.button, s.id, txt)) break; - } else |_| {} + if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, s.button, s.id, txt, arg)) break; + } } } }, .none => {}, } - drag = .none; + if (end_drag) drag = .none; } }, .key_press => |key| kpress: { @@ -3335,7 +3408,7 @@ pub fn main(init: std.process.Init) !void { if (at.tag_edit) { if (tagChordText(frame_arena.allocator(), at)) |txt| { at.tag_sel = false; - if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, btn, active, txt)) break; + if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, btn, active, txt, null)) break; } break :kpress; } @@ -3343,13 +3416,13 @@ pub fn main(init: std.process.Init) !void { if (currentSelText(frame_arena.allocator(), at)) |txt| { at.vsel.active = false; at.msel.active = false; - if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, btn, active, txt)) break; + if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, btn, active, txt, null)) break; } else { var sel = cursorWordSel(at); expandSel(gpa, at, &sel); if (selectionText(gpa, at, sel)) |word| { defer gpa.free(word); - if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, btn, active, word)) break; + if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, btn, active, word, null)) break; } else |_| {} } break :kpress; @@ -275,7 +275,7 @@ pub fn main(init: std.process.Init) !void { try hs.send("rm -rf /tmp/pardesE2E; mkdir -p /tmp/pardesE2E/ZZUNIQDIR; clear; echo /tmp/pardesE2E\r"); try hs.pump(2000); // let it run + settle try hs.send("\x1b[<2;5;3M\x1b[<2;5;3m"); // right-click the path (+1 row: global tagbar) - try hs.expectWaitFor("ZZUNIQDIR", 12000, "right-click on a directory did not open+ls it"); + try hs.expectWaitFor("ZZUNIQDIR", 20000, "right-click on a directory did not open+ls it"); // 8. Right-click a "path:line" opens a line-numbered FILE pane SCROLLED to // that line. Build a tall file whose deep marker (line 150) is only @@ -299,7 +299,7 @@ pub fn main(init: std.process.Init) !void { try hs.send("/tmp/pardesE2Efile.txt:150"); try hs.pump(800); // build the run try hs.send("\x1b[<2;70;19M\x1b[<2;70;19m"); // right-click on the typed path (+1 row: global tagbar) - try hs.expectWaitFor("ZZDEEPMARK", 12000, "right-click 'path:150' did not open the file scrolled to line 150"); + try hs.expectWaitFor("ZZDEEPMARK", 20000, "right-click 'path:150' did not open the file scrolled to line 150"); try hs.expectContains("Save Del", "file pane tag did not include the default Save command"); try hs.expectNotContains("Delcol", "Delcol is still shown in the default pane tag"); @@ -401,7 +401,7 @@ pub fn main(init: std.process.Init) !void { try hs_save.send("\x1b"); // -> normal try hs_save.pump(400); try hs_save.send("\x1b[<2;70;20M\x1b[<2;70;20m"); // right-click the path -> open file - try hs_save.expectWaitFor("ZZORIG_SAVE_SHOULD_DISAPPEAR", 12000, "save regression file did not open"); + try hs_save.expectWaitFor("ZZORIG_SAVE_SHOULD_DISAPPEAR", 20000, "save regression file did not open"); try hs_save.send("x"); // select the original line try hs_save.pump(300); try hs_save.send("c"); // clear it and enter insert @@ -573,7 +573,7 @@ pub fn main(init: std.process.Init) !void { try hs5.send("\x1b"); // -> normal try hs5.pump(400); try hs5.send("\x1b[<2;10;8M\x1b[<2;10;8m"); // right-click the typed path -> open image pane - try hs5.expectWaitFor("Petscii", 10000, "right-click on an image path did not open an image pane with the Petscii toggle"); + try hs5.expectWaitFor("Petscii", 16000, "right-click on an image path did not open an image pane with the Petscii toggle"); try hs5.pump(800); // let the petscii grid render across frames // the diagonal split renders as real block glyphs — assert the full block // shows (a blank body here would mean the grid didn't blit, e.g. a dangling @@ -605,7 +605,7 @@ pub fn main(init: std.process.Init) !void { try hs6.send("\x1b"); // -> normal try hs6.pump(400); try hs6.send("\x1b[<2;70;20M\x1b[<2;70;20m"); // right-click the path -> open file A - try hs6.expectWaitFor("ZZFILEA", 12000, "right-click did not open file A"); + try hs6.expectWaitFor("ZZFILEA", 20000, "right-click did not open file A"); // open file B likewise: a doc pane now exists, so B SPLITS it (lands below A). try hs6.send("\x1b[<0;60;22M\x1b[<0;60;22m"); // pin another blank row, right column try hs6.pump(400); @@ -616,7 +616,7 @@ pub fn main(init: std.process.Init) !void { try hs6.send("\x1b"); try hs6.pump(400); try hs6.send("\x1b[<2;70;22M\x1b[<2;70;22m"); // right-click the path -> open file B - try hs6.expectWaitFor("ZZFILEB", 12000, "right-click did not open file B"); + try hs6.expectWaitFor("ZZFILEB", 20000, "right-click did not open file B"); // both markers sit on each file's TOP line (scroll 0). The split puts B // under A -> A's row < B's row. Stack-at-top would invert this. const text = try hs6.screenText(); @@ -644,7 +644,7 @@ pub fn main(init: std.process.Init) !void { try hs7.pump(700); // add a 3rd column via the global-tagbar "Newcol" (cols 5-10, 0-based). try hs7.send("\x1b[<1;8;1M\x1b[<1;8;1m"); // middle-click "Newcol" - try hs7.pump(1600); // let the new shell come up + try hs7.pump(2500); // let the new shell come up // mark the rightmost column (now the active pane) with a unique token. try hs7.send("\x02"); // normal -> tty try hs7.pump(500); @@ -653,7 +653,7 @@ pub fn main(init: std.process.Init) !void { // split the token so it appears ONLY in the OUTPUT (at the pane's left edge), // never in the command text — else colOf would match the prompt-relative echo. try hs7.send("echo ZZFAR''COL\r"); - try hs7.expectWaitFor("ZZFARCOL", 8000, "new column's shell did not echo the marker"); + try hs7.expectWaitFor("ZZFARCOL", 12000, "new column's shell did not echo the marker"); const before = try hs7.screenText(); const x1 = colOf(before, "ZZFARCOL") orelse { gpa.free(before); @@ -720,4 +720,100 @@ pub fn main(init: std.process.Init) !void { try hs9.send("stty -icanon -echo min 1 time 0; printf '\\033[?u\\033[c'; read -r -d c -t 3 _r && printf 'ZZDA''OK\\n'; stty sane\r"); try hs9.expectWaitFor("ZZDAOK", 8000, "app blocked on its ESC[c device-attributes probe — pardes never answered primary DA (helix hangs on a blank pane)"); } + + // 20. KEPT LEFT SELECTION + MIDDLE+LEFT CHORD. A left-drag selection now PERSISTS + // after release (it clears only on a plain left click or a new left drag). The + // middle+left chord: press middle (on a command word), press left BEFORE + // releasing middle -> the KEPT left selection's text is passed as the command's + // argument. Here a script echoes CHORDARG:"$1"; left-select ZZCHORDARG, then + // chord the script -> output "CHORDARG:ZZCHORDARG" only if the kept selection + // fed the arg. Top-left pane body row 0 = SGR row 3; body col 0 = SGR col 3. + { + var hsc = try Harness.init(gpa, exe.ptr, 24, 80); + defer hsc.deinit(); + try hsc.expectWaitFor("build.zig", 8000, "chord test: fresh terminal never auto-ls'd"); + try h.writeFile("/tmp/pardesE2Echord.sh", "#!/bin/sh\necho CHORDARG:\"$1\"\n"); + try hsc.send("\x02"); // normal -> tty + try hsc.pump(500); + try hsc.send("chmod +x /tmp/pardesE2Echord.sh; clear; printf 'ZZCHORDARG\\n/tmp/pardesE2Echord.sh\\n'\r"); + try hsc.expectWaitFor("/tmp/pardesE2Echord.sh", 8000, "chord test: command path not rendered"); + // left-drag-select "ZZCHORDARG" (10 chars, body cols 0-9 = SGR cols 3-12) on + // body row 0 (SGR row 3) -> kept (.done) after release. + try hsc.send("\x1b[<0;3;3M\x1b[<32;12;3M\x1b[<0;12;3m"); + try hsc.pump(400); + // middle+left chord: middle-press on the script path (body row 1 = SGR row 4), + // left-press (captures the kept selection as the arg), middle-release (runs it). + try hsc.send("\x1b[<1;8;4M\x1b[<0;8;4M\x1b[<1;8;4m"); + try hsc.expectWaitFor("CHORDARG:ZZCHORDARG", 8000, "middle+left chord did not pass the kept left selection as the command argument"); + } + + // 21. A plain LEFT CLICK (no drag) clears the kept left selection, so a later + // middle+left chord passes NO argument. Left-select ZZCLRARG, left-click a + // blank cell (clears), then chord the script -> output "CHORDARG:" (empty), + // with no ZZCLRARG after it. + { + var hscl = try Harness.init(gpa, exe.ptr, 24, 80); + defer hscl.deinit(); + try hscl.expectWaitFor("build.zig", 8000, "clear test: fresh terminal never auto-ls'd"); + try h.writeFile("/tmp/pardesE2Echord.sh", "#!/bin/sh\necho CHORDARG:\"$1\"\n"); + try hscl.send("\x02"); + try hscl.pump(500); + try hscl.send("chmod +x /tmp/pardesE2Echord.sh; clear; printf 'ZZCLRARG\\n/tmp/pardesE2Echord.sh\\n'\r"); + try hscl.expectWaitFor("/tmp/pardesE2Echord.sh", 8000, "clear test: command path not rendered"); + // left-drag-select "ZZCLRARG" (8 chars, body cols 0-7 = SGR cols 3-10) -> kept. + try hscl.send("\x1b[<0;3;3M\x1b[<32;10;3M\x1b[<0;10;3m"); + try hscl.pump(400); + // plain left click on a blank row (body row 3 = SGR row 6), no drag -> clears. + try hscl.send("\x1b[<0;3;6M\x1b[<0;3;6m"); + try hscl.pump(400); + // chord the script; the kept selection is gone -> no arg -> "CHORDARG:" (empty). + try hscl.send("\x1b[<1;8;4M\x1b[<0;8;4M\x1b[<1;8;4m"); + try hscl.pump(900); + try hscl.expectContains("CHORDARG:", "clear test: chord did not run the script"); + try hscl.expectNotContains("CHORDARG:ZZCLRARG", "a plain left click did not clear the kept left selection (chord still passed it)"); + } + + // 22. A NEW left drag REPLACES the previous kept left selection. Left-select + // ZZTXTONE, then left-select ZZTXTTWO (replaces), then chord the script -> + // output "CHORDARG:ZZTXTTWO" (the NEW selection), NOT "CHORDARG:ZZTXTONE". + { + var hsr = try Harness.init(gpa, exe.ptr, 24, 80); + defer hsr.deinit(); + try hsr.expectWaitFor("build.zig", 8000, "replace test: fresh terminal never auto-ls'd"); + try h.writeFile("/tmp/pardesE2Echord.sh", "#!/bin/sh\necho CHORDARG:\"$1\"\n"); + try hsr.send("\x02"); + try hsr.pump(500); + try hsr.send("chmod +x /tmp/pardesE2Echord.sh; clear; printf 'ZZTXTONE\\nZZTXTTWO\\n/tmp/pardesE2Echord.sh\\n'\r"); + try hsr.expectWaitFor("/tmp/pardesE2Echord.sh", 8000, "replace test: command path not rendered"); + // left-drag-select "ZZTXTONE" on body row 0 (SGR row 3; 8 chars -> SGR cols 3-10). + try hsr.send("\x1b[<0;3;3M\x1b[<32;10;3M\x1b[<0;10;3m"); + try hsr.pump(400); + // left-drag-select "ZZTXTTWO" on body row 1 (SGR row 4) -> REPLACES the kept one. + try hsr.send("\x1b[<0;3;4M\x1b[<32;10;4M\x1b[<0;10;4m"); + try hsr.pump(400); + // chord the script on body row 2 (SGR row 5) -> arg is the NEW selection ZZTXTTWO. + try hsr.send("\x1b[<1;8;5M\x1b[<0;8;5M\x1b[<1;8;5m"); + try hsr.expectWaitFor("CHORDARG:ZZTXTTWO", 8000, "a new left drag did not replace the old kept selection (chord used the wrong arg)"); + try hsr.expectNotContains("CHORDARG:ZZTXTONE", "the old kept selection leaked into the chord arg (not replaced)"); + } + + // 23. A BUILTIN with a chord arg ignores the arg (each builtin decides; none use + // it yet). Left-select some text, then chord middle-click "Debug" in the body + // -> the Debug overlay toggles on (arg ignored), so "resizes=" appears. + { + var hsb = try Harness.init(gpa, exe.ptr, 24, 80); + defer hsb.deinit(); + try hsb.expectWaitFor("build.zig", 8000, "builtin-chord test: fresh terminal never auto-ls'd"); + try hsb.send("\x02"); + try hsb.pump(500); + try hsb.send("clear; printf 'ZZBUILTARG\\nDebug\\n'\r"); + try hsb.expectWaitFor("Debug", 8000, "builtin-chord test: 'Debug' word not rendered"); + // left-select "ZZBUILTARG" (10 chars, SGR cols 3-12) on body row 0 -> kept. + try hsb.send("\x1b[<0;3;3M\x1b[<32;12;3M\x1b[<0;12;3m"); + try hsb.pump(400); + // chord middle-click "Debug" on body row 1 (SGR row 4, body col 2 = SGR col 5). + try hsb.send("\x1b[<1;5;4M\x1b[<0;5;4M\x1b[<1;5;4m"); + try hsb.pump(900); + try hsb.expectContains("resizes=", "builtin with a chord arg did not toggle (arg broke builtin dispatch)"); + } } |
