From 009e4ab8779bc7adc7b626814d36e62231d7a0ea Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 04:48:12 -0300 Subject: A chorded exec an event reader holds is told its argument, and runs with it With event open, a 2-1 chord's exec went out as one record with flag 8 and nothing after it: the argument was freed unsaid. As acme does (exec.c:150-190), two records follow it, the argument's text and where it came from (file:#q0,#q1, empty when not known), and the pane keeps the argument, so the record written back runs with it (acme's own write-back, xfid.c:842, passes none; the reader was told, so it runs as the click would have). Co-Authored-By: Claude Opus 5.5 --- src/exec.zig | 22 ++++++++++++++++++++++ src/mouse.zig | 51 +++++++++++++++++++++++++++++++++++++++++++++++---- src/ninep/events.zig | 23 ++++++++++++++++++++++- src/ninep/pane.zig | 5 +++++ 4 files changed, 96 insertions(+), 5 deletions(-) diff --git a/src/exec.zig b/src/exec.zig index d7a88ecc..73413910 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -217,6 +217,28 @@ pub fn heldSelection(p: *Pardes, first: usize) ?[]const u8 { return null; } +/// Where the selection `heldSelection` would give is, as acme's argument +/// location (exec.c:162, getbytearg): `path:#q0,#q1` of a file pane's dot. +pub fn heldSelectionPlace(p: *Pardes, first: usize, buf: []u8) ?[]const u8 { + var k: usize = 0; + while (k < p.panes.len + 2) : (k += 1) { + const i = switch (k) { + 0 => first, + 1 => p.active, + else => k - 2, + }; + if (k > 0 and (i == first or (k > 1 and i == p.active))) continue; + const t = p.panes[i] orelse continue; + if (t.sel[sel_slot].state == .done) return null; // a pointer selection: no byte range kept + if ((t.body.vsel.active and t.body.vsel.explicit) or t.body.msel.active) { + const f = t.file orelse return null; + const d = @import("ninep/pane.zig").dotOf(t); + return std.fmt.bufPrint(buf, "{s}:#{d},#{d}", .{ f.path, d.q0, d.q1 }) catch null; + } + } + return null; +} + /// A clicked word and the selection held for it, as one command line. pub fn withArg(p: *Pardes, txt: []const u8, arg: ?[]const u8) []const u8 { const a = std.mem.trim(u8, arg orelse return txt, " \t\r\n"); diff --git a/src/mouse.zig b/src/mouse.zig index 6b58a489..9f054e2e 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -711,7 +711,7 @@ fn reportGesture( text: []const u8, on_tag: bool, operand: exec.PointerOperand, - chorded: bool, + chord_arg: ?[]const u8, ) bool { const scripted = p.panes[id] orelse return false; if (scripted.fs.readers == 0) return false; @@ -726,8 +726,21 @@ fn reportGesture( if (range.q0 == range.q1 and text.len > 0) flag |= ctlfs.events.flag_expansion; if (is_look) { if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= ctlfs.events.flag_filename; - } else if (chorded) flag |= ctlfs.events.flag_chorded; - return ctlfs.events.noteAction(p, id, action, range.q0, range.q1, flag, text); + } else if (chord_arg != null) flag |= ctlfs.events.flag_chorded; + if (!ctlfs.events.noteAction(p, id, action, range.q0, range.q1, flag, text)) return false; + // A chorded exec's argument follows as two records, its text and where + // it came from (`file:#q0,#q1`, empty when that is not known), as acme + // sends them (exec.c:182-188); the pane keeps it for the write-back. + if (chord_arg) |a| { + ctlfs.events.noteFollow(p, id, action, a); + var where: [512]u8 = undefined; + ctlfs.events.noteFollow(p, id, action, exec.heldSelectionPlace(p, id, &where) orelse ""); + const pf = &scripted.fs; + if (pf.chord_arg) |old| p.gpa.free(old); + pf.chord_arg = p.gpa.dupe(u8, a) catch null; + pf.chord_at = range; + } + return true; } fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: exec.PointerOperand) ctlfs.pane.State.Range { @@ -790,7 +803,7 @@ fn dispatchPointerBuiltin( // A click that takes no text does nothing, and tells an event reader // nothing, as acme's look3 and execute return on an empty expansion. const operand = text orelse return; - if (gesture) |g| if (reportGesture(p, id, cmd, operand, g.on_tag, g.operand, arg != null)) return; + if (gesture) |g| if (reportGesture(p, id, cmd, operand, g.on_tag, g.operand, arg)) return; // A middle click on a body is the exec a bound REPL may take. exec.runBuiltinFrom(p, cmd, id, "", exec.withArg(p, operand, arg), gesture != null and !gesture.?.on_tag); } @@ -1635,3 +1648,33 @@ test "mouse thumb buttons navigate once and clear selection gestures" { try std.testing.expect(!pane.body.vsel.active); try std.testing.expectEqual(.none, p.drag); } + +test "a chorded exec an event reader holds is told with its argument, and written back runs with it" { + const th = @import("ninep/testing.zig"); + const p = try th.withFile(std.testing.allocator, "Msg said\narg-text\n"); + defer p.deinit(); + const serial = p.panes[0].?.serial; + const event = ctlfs.Node.of(serial, .event); + const h = th.call(p, .{ .tag = 1, .op = .open, .node = event, .omode = 2 }).reply.handle; + // The argument: a selection held in the pane (its second line). + ctlfs.pane.setDot(p.panes[0].?, .{ .q0 = 9, .q1 = 17 }); + p.chord_arg = try p.gpa.dupe(u8, "arg-text"); + dispatchPointerBuiltin(p, 0, config.exec_cmd, "Msg", .{ .on_tag = false, .operand = .{ .row = 0, .col = 1, .text = "Msg" } }); + var records: [3][]const u8 = undefined; + var got: [3][64]u8 = undefined; + for (&records, &got) |*r, *g| { + const bytes = th.call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 64 }).bytes; + @memcpy(g[0..bytes.len], bytes); + r.* = g[0..bytes.len]; + } + try std.testing.expect(std.mem.endsWith(u8, records[0], " 11 3 Msg\n")); // builtin|expansion|chorded + try std.testing.expectEqualStrings("X0 0 0 8 arg-text\n", records[1][1..]); + try std.testing.expect(std.mem.endsWith(u8, records[2], "test.txt:#9,#17\n")); + // Written back whole, it runs with its argument. + var back: [64]u8 = undefined; + const line = try std.fmt.bufPrint(&back, "M{s}", .{records[0][1..]}); + try std.testing.expectEqual(ctlfs.Status.ok, th.call(p, .{ .tag = 3, .op = .write, .node = event, .handle = h, .data = line }).reply.status); + const pane = p.panes[0].?; + try std.testing.expectEqualStrings("arg-text", pane.msg[0..pane.msg_len]); + _ = th.call(p, .{ .tag = 4, .op = .release, .node = event, .handle = h }); +} diff --git a/src/ninep/events.zig b/src/ninep/events.zig index a12cf86d..d75c399b 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -508,6 +508,16 @@ pub fn noteAction( return true; } +/// A record that follows another with no place of its own, `0 0 0 +/// `: a chorded exec's argument and its location (acme exec.c:182). +pub fn noteFollow(p: *Pardes, id: usize, action: Action, text: []const u8) void { + const pane = p.panes[id] orelse return; + if (pane.fs.readers == 0) return; + var buf: [max_record_text + 64]u8 = undefined; + pane.fs.events.push(p.gpa, formatRecord(&buf, p.fs.origin, action, 0, 0, 0, text)); + p.fs.news = true; +} + pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void { if (id >= MAX_PANES or bytes.len == 0) return; const pf = &(p.panes[id] orelse return).fs; @@ -599,7 +609,18 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply { // been (acme's xfideventwrite, xfid.c:842-844): a REPL bound for // it takes it. One that came from a write to exec (`F`) was // never a click, and runs as the command it was. - .body_exec => _ = exec.executeFrom(p, live, text, r.origin != 'F'), + // The chorded exec a reader was told of runs with its argument + // (mouse.reportGesture), when it is that range written back. + .body_exec => { + const pf = &pane.fs; + const arg = if (pf.chord_arg) |a| (if (pf.chord_at.q0 == r.q0 and pf.chord_at.q1 == r.q1) a else null) else null; + _ = exec.executeFrom(p, live, exec.withArg(p, text, arg), r.origin != 'F'); + if (arg != null) if (p.paneBySerial(serial)) |still| { + const spf = &p.panes[still].?.fs; + if (spf.chord_arg) |a| p.gpa.free(a); + spf.chord_arg = null; + }; + }, .tag_exec => _ = exec.execute(p, live, text), .body_look, .tag_look => look.lookAt(p, live, text), else => unreachable, diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 791cb797..8cac55d6 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -77,10 +77,15 @@ pub const State = struct { /// Installed during this update; /log hears about it once the update ends /// and the pane has its file, name and place. unannounced: bool = false, + /// A chorded exec's argument an event reader was told of (flag 8), kept + /// for the record's write-back at `chord_at`, which runs with it. + chord_arg: ?[]u8 = null, + chord_at: Range = .{}, pub const Range = struct { q0: u32 = 0, q1: u32 = 0 }; pub fn deinit(pf: *State, gpa: std.mem.Allocator) void { + if (pf.chord_arg) |a| gpa.free(a); pf.events.deinit(gpa); pf.pty_out.deinit(gpa); pf.tag_snap.deinit(gpa); -- cgit v1.3