summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--main.zig109
-rw-r--r--tests.zig112
2 files changed, 195 insertions, 26 deletions
diff --git a/main.zig b/main.zig
index 205896d2..582fc133 100644
--- a/main.zig
+++ b/main.zig
@@ -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;
diff --git a/tests.zig b/tests.zig
index eb3ccf79..701b153a 100644
--- a/tests.zig
+++ b/tests.zig
@@ -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)");
+ }
}