summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig.zon2
-rw-r--r--docs/fs.md5
-rw-r--r--docs/tags.md9
-rw-r--r--src/CHANGELOG.md13
-rw-r--r--src/detached/wire.zig19
-rw-r--r--src/edit.zig39
-rw-r--r--src/gui/gui.zig240
-rw-r--r--src/mouse.zig242
-rw-r--r--src/ninep/tree.zig26
-rw-r--r--src/pardes.zig33
-rw-r--r--src/terminal.zig173
-rw-r--r--src/tty/tty.zig54
-rw-r--r--test/monkey9p.py12
-rw-r--r--test/panes.zig10
-rw-r--r--test/selfmount.py39
-rw-r--r--test/snapshot.zig5
-rw-r--r--test/snapshots/chordcut.snap15
-rw-r--r--test/snapshots/lspcomplete.snap20
18 files changed, 825 insertions, 131 deletions
diff --git a/build.zig.zon b/build.zig.zon
index 8283c2c2..7c3d549a 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,6 +1,6 @@
.{
.name = .pardes,
- .version = "0.0.8",
+ .version = "0.0.9",
.dependencies = .{
// 9P sessions and transports live in the published cloud9 package, so
// the manifest pins the build: `zig build` fetches this commit into
diff --git a/docs/fs.md b/docs/fs.md
index 864bfd12..985e36a1 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -156,7 +156,10 @@ effect, as acme's ctl does. Blank lines are skipped. A mount cuts a big
write into pieces of at most one message (msize 64 KiB, 65536 negotiated,
less the header), and
each line runs once it is whole. A write that does not fill its message is
-whole, so its last line runs even without a newline (`printf Save > exec`). An `Edit` whose `{` or
+whole, so its last line runs even without a newline (`printf Save > exec`),
+unless it is a multiple of 4096 bytes: that is where a writer's buffer (stdio,
+a mount's page cache) filled and cut a line, so its tail waits for the next
+write or the close. An `Edit` whose `{` or
`a`/`c`/`i` text is still open waits for the next write on that open, and
fails at the close if it never ends (``unmatched `{'``). A line held past
1 MiB is refused.
diff --git a/docs/tags.md b/docs/tags.md
index 993e8c0d..51977390 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -30,6 +30,15 @@ A tag word runs as acme's does, in the pane's directory with no file named:
or select the name and middle-click `wc`, which takes a held selection as its
argument.
+A program that tracks the mouse gets it; hold Shift for pardes's own. In
+raw terminal input, a program that asked for the mouse (xterm's 1000, 1002
+or 1003: htop, vim with `mouse=a`, codex) gets the clicks, drags and wheel
+over its grid, reported in the format it chose and with its modifiers.
+Shift-click selects, executes and looks as ever, and Shift-wheel scrolls
+pardes's scrollback. A full-screen program that does not track the mouse
+gets the wheel as arrow keys (xterm's alternate scroll, 1007). Tags, grips
+and gutters stay pardes's.
+
`Repl python` in a terminal's tag binds it as that language's REPL
([fs.md](fs.md#repls)). `Repl` takes the languages a code fence names: ada,
bash, c, c_sharp, clojure, cpp, css, elixir, erlang, fortran, go, haskell,
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index d6b4846b..72607fd2 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -1,7 +1,15 @@
# Changelog
-## Unreleased
+## 0.0.9
+- A terminal's program that tracks the mouse gets its clicks, drags and
+ motion as well as its wheel, in the format it asked for; hold Shift for
+ pardes's own select, execute, look and scrollback. Mouse reports carry
+ Shift, Alt and Ctrl.
+- A program that asked for focus reports hears when its pane or the window
+ gains or loses focus. Every paste into a terminal is bracketed when the
+ program asked, with control bytes turned to spaces. The GUI's F1-F24,
+ Insert, Print Screen, Scroll Lock, Pause and Menu reach terminals.
- A diff reads as the code it changes: a `.diff` or `.patch` file, and a
command's output that is a diff (`git diff`), colour each hunk in its
file's language, its old and new side each parsed whole, and tint added
@@ -11,6 +19,9 @@
on a `@@` line the new file at the hunk, and on a hunk line's `+`, `-` or
space the new file at that line (a removed line: where it was). Paths are
the repository's; a file not here says so and opens nothing.
+- A write through a mount that fills a writer's buffer (a multiple of 4096
+ bytes) keeps its cut last line for the next write, so `seq … > exec`
+ runs every line once.
## 0.0.8
diff --git a/src/detached/wire.zig b/src/detached/wire.zig
index 051e9317..c9d9b578 100644
--- a/src/detached/wire.zig
+++ b/src/detached/wire.zig
@@ -12,7 +12,7 @@ const ShaderAnimation = pardes.config.Runtime.ShaderAnimation;
/// to be refused, which is what this number is for.
/// 8: one layer type: a tag band carries all its rows (not a layer per
/// line), and a frame carries the core's placed regions after its layers.
-pub const version: u16 = 8;
+pub const version: u16 = 10;
/// The Chrome palette is carried field by field as its declaration lists
/// them, so any change to it changes the wire: bump `version`, then this.
@@ -58,6 +58,7 @@ pub const ClientTag = enum(u8) {
pinch = 0x1b,
touch_scroll = 0x1c,
pointer_leave = 0x1d,
+ focus = 0x1e,
};
// Session control precedes display-local effects.
@@ -1052,6 +1053,8 @@ pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 {
try w.putU16(m.col);
try w.putU16(m.row);
try w.putBool(m.ctrl);
+ try w.putBool(m.shift);
+ try w.putBool(m.alt);
try putBodyHit(&w, m.body_hit);
try putTagHit(&w, m.tag_hit);
},
@@ -1074,6 +1077,7 @@ pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 {
.pinch => |v| try w.putF32(v),
.touch_scroll => |v| try w.putF32(v),
.pointer_leave => {},
+ .focus => |focused| try w.putBool(focused),
// See `clientTag`: no tag, so nothing to encode.
.output, .eof, .exited, .lsp_resp, .pipe_resp, .file_changed, .tick => unreachable,
},
@@ -1115,6 +1119,8 @@ pub fn decodeClient(tag: u8, payload: []const u8) Error!ClientMsg {
.col = try r.getU16(),
.row = try r.getU16(),
.ctrl = try r.getBool(),
+ .shift = try r.getBool(),
+ .alt = try r.getBool(),
.body_hit = try getBodyHit(&r),
.tag_hit = try getTagHit(&r),
} } },
@@ -1134,6 +1140,7 @@ pub fn decodeClient(tag: u8, payload: []const u8) Error!ClientMsg {
.pinch => .{ .event = .{ .pinch = try r.getF32() } },
.touch_scroll => .{ .event = .{ .touch_scroll = try r.getF32() } },
.pointer_leave => .{ .event = .pointer_leave },
+ .focus => .{ .event = .{ .focus = try r.getBool() } },
};
try r.end();
return msg;
@@ -1218,7 +1225,7 @@ pub fn clientBound(msg: ClientMsg) usize {
return msg_slack + switch (msg) {
.hello, .bye => 0,
.event => |ev| switch (ev) {
- .mouse, .resize, .pdf_scroll, .pinch, .touch_scroll, .pointer_leave => 0,
+ .mouse, .resize, .pdf_scroll, .pinch, .touch_scroll, .pointer_leave, .focus => 0,
.key => |k| k.text.len,
.paste => |b| b.len,
.command => |line| line.len,
@@ -1357,13 +1364,15 @@ test "detached wire: every Event variant round-trips" {
// the only place a value can be mistranslated and still decode.
for (std.enums.values(pardes.Mouse.Button)) |button| {
for (std.enums.values(pardes.Mouse.Kind)) |kind| {
- const m: pardes.Mouse = .{ .button = button, .kind = kind, .col = 4200, .row = 7, .ctrl = true };
+ const m: pardes.Mouse = .{ .button = button, .kind = kind, .col = 4200, .row = 7, .ctrl = true, .shift = true, .alt = true };
const got = (try roundClient(&buf, .{ .event = .{ .mouse = m } })).event.mouse;
try testing.expectEqual(m.button, got.button);
try testing.expectEqual(m.kind, got.kind);
try testing.expectEqual(m.col, got.col);
try testing.expectEqual(m.row, got.row);
try testing.expectEqual(m.ctrl, got.ctrl);
+ try testing.expectEqual(m.shift, got.shift);
+ try testing.expectEqual(m.alt, got.alt);
}
}
}
@@ -1449,7 +1458,7 @@ test "detached wire: a session is never told to do a frontend's remembering" {
// Adding a name here is meant to be an ARGUMENT, not a formality, and the
// bar is one sentence: A HUMAN DID IT. A keystroke, a click, a pinch, a
// window resized, a paste, a command line executed, a pointer leaving the
- // window — plus the two session words that say who is speaking. A pty's
+ // window, the window's focus — plus the two session words that say who is speaking. A pty's
// output, a worker's answer, a watched file's new bytes and an animation
// tick all fail that bar the same way: nobody did them, a machine reported
// them, and the machine that reports them is the one already holding the
@@ -1462,7 +1471,7 @@ test "detached wire: a session is never told to do a frontend's remembering" {
"resize", "paste",
"command", "pdf_scroll",
"pinch", "touch_scroll",
- "pointer_leave",
+ "pointer_leave", "focus",
};
// One: the tag set is exactly that, named rather than counted, so
diff --git a/src/edit.zig b/src/edit.zig
index 98c60efb..1305d66f 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -111,19 +111,12 @@ pub fn clipRequest(p: *Pardes, id: usize, mode: @FieldType(ClipRequest, "mode"))
p.emit(.read_clipboard);
}
+/// Text pasted, yanked or sent into the program at `id`, as xterm pastes
+/// (terminal.encodePaste).
pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) void {
if (text.len == 0) return;
- if (panes.terminal.bracketedPaste(pane)) {
- p.emitWrite(id, "\x1b[200~");
- p.emitWrite(id, text);
- p.emitWrite(id, "\x1b[201~");
- return;
- }
const cp = p.scratch.allocator().dupe(u8, text) catch return;
- for (cp) |*ch| {
- if (ch.* == '\n') ch.* = '\r';
- }
- p.emitWrite(id, cp);
+ for (panes.terminal.encodePaste(pane, cp)) |part| if (part.len > 0) p.emitWrite(id, part);
}
pub fn applyPaste(p: *Pardes, bytes: []const u8) void {
@@ -2405,3 +2398,29 @@ test "a typed edit's known end is where the texts' comparison would find it" {
try std.testing.expectEqual(by_scan.col, t.last_edit.?.col);
}
}
+
+test "a paste into a terminal is xterm's: bracketed when the program asked, control bytes spaces, so no ESC[201~ ends the brackets early" {
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 16, .tty_only = true });
+ defer p.deinit();
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(gpa);
+ const take = struct {
+ fn f(pp: *Pardes, o: *std.ArrayList(u8)) ![]const u8 {
+ o.clearRetainingCapacity();
+ while (pp.nextEffect()) |effect| {
+ if (effect == .write and effect.write.pane == 0) try o.appendSlice(pp.gpa, effect.write.bytes.slice());
+ }
+ return o.items;
+ }
+ }.f;
+ _ = try take(p, &out);
+ try std.testing.expectEqual(.tty, p.panes[0].?.body.mode);
+ p.update(.{ .paste = "echo a\nb\x03\tc" });
+ try std.testing.expectEqualStrings("echo a\rb \tc", try take(p, &out));
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } });
+ _ = try take(p, &out);
+ p.update(.{ .paste = "x\x1b[201~y\n" });
+ try std.testing.expectEqualStrings("\x1b[200~x [201~y\n\x1b[201~", try take(p, &out));
+}
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 045f22d9..1e58d3ba 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -3097,6 +3097,9 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
const StdinFeed = struct {
parser: vaxis.Parser = .{},
+ /// Inside the driver's bracketed paste: its keys are the paste's text.
+ in_paste: bool = false,
+ paste: std.ArrayList(u8) = .empty,
cache: vaxis.GraphemeCache = .{},
buf: [1024]u8 = undefined,
fill: usize = 0,
@@ -3139,8 +3142,8 @@ const StdinFeed = struct {
var seq_start: usize = 0;
while (seq_start < len) {
- // ESC]777;mouse;<down|up|motion>;<SDL button, 0 for none>;<x>;<y>BEL
- // ESC]777;mouse;wheel;<amount>;<x>;<y>BEL
+ // ESC]777;mouse;<down|up|motion>;<SDL button, 0 for none>;<x>;<y>[;<mods>]BEL
+ // ESC]777;mouse;wheel;<amount>;<x>;<y>[;<mods>]BEL
// A mouse event at window pixels, built as SDL would and run
// through dispatch, so a driver exercises the real pointer path
// (pixel to cell, tag and body hits). SGR mouse carries only cells.
@@ -3159,6 +3162,9 @@ const StdinFeed = struct {
const button = std.fmt.parseInt(u8, second, 10) catch 0;
const x = std.fmt.parseFloat(f32, parts.next() orelse "") catch 0;
const y = std.fmt.parseFloat(f32, parts.next() orelse "") catch 0;
+ // An optional fifth field holds modifiers as xterm
+ // counts them: shift 4, meta (alt) 8, ctrl 16.
+ const held = std.fmt.parseInt(u8, parts.next() orelse "0", 10) catch 0;
var sev = std.mem.zeroes(c.SDL_Event);
if (std.mem.eql(u8, kind, "wheel")) {
// ESC]777;mouse;wheel;<y, SDL's sign: up positive>;<x>;<y>BEL:
@@ -3169,6 +3175,9 @@ const StdinFeed = struct {
sev.wheel.integer_y = @intFromFloat(@trunc(amount));
sev.wheel.mouse_x = x;
sev.wheel.mouse_y = y;
+ } else if (std.mem.eql(u8, kind, "focus")) {
+ // ESC]777;mouse;focus;<1 gained, 0 lost>;0;0BEL
+ sev.type = if (button != 0) c.SDL_EVENT_WINDOW_FOCUS_GAINED else c.SDL_EVENT_WINDOW_FOCUS_LOST;
} else if (std.mem.eql(u8, kind, "motion")) {
sev.type = c.SDL_EVENT_MOUSE_MOTION;
sev.motion.x = x;
@@ -3181,7 +3190,14 @@ const StdinFeed = struct {
sev.button.x = x;
sev.button.y = y;
}
+ const before = c.SDL_GetModState();
+ var mods: c.SDL_Keymod = 0;
+ if (held & 4 != 0) mods |= c.SDL_KMOD_LSHIFT;
+ if (held & 8 != 0) mods |= c.SDL_KMOD_LALT;
+ if (held & 16 != 0) mods |= c.SDL_KMOD_LCTRL;
+ c.SDL_SetModState(mods);
dispatch(gp, in, &sev);
+ c.SDL_SetModState(before);
out.n_events += 1;
}
seq_start += mouse_prefix.len + end + 1;
@@ -3228,10 +3244,24 @@ const StdinFeed = struct {
seq_start += result.n;
const event = result.event orelse continue;
switch (event) {
- .key_press => |key| {
+ .key_press => |key| if (f.in_paste) {
+ // A newline reads as Ctrl-J, as the tty's paste has it.
+ const text = key.text orelse if (key.codepoint == vaxis.Key.enter or (key.mods.ctrl and key.codepoint == 'j')) "\n" else if (key.codepoint == vaxis.Key.tab) "\t" else "";
+ f.paste.appendSlice(gpa, text) catch {};
+ } else {
postKey(in, key, if (key.text) |t| f.cache.put(t) else "");
out.n_events += 1;
},
+ .paste_start => {
+ f.in_paste = true;
+ f.paste.clearRetainingCapacity();
+ },
+ .paste_end => {
+ f.in_paste = false;
+ if (f.paste.items.len > 0) in.post(.{ .paste = f.paste.items });
+ f.paste.clearAndFree(gpa);
+ out.n_events += 1;
+ },
.mouse => |m| {
const button: ?pardes.Mouse.Button = switch (m.button) {
.left => .left,
@@ -3256,6 +3286,8 @@ const StdinFeed = struct {
.col = @intCast(@max(m.col, 0)),
.row = @intCast(@max(m.row, 0)),
.ctrl = m.mods.ctrl,
+ .shift = m.mods.shift,
+ .alt = m.mods.alt,
} });
out.n_events += 1;
}
@@ -3412,6 +3444,47 @@ test "the GUI's stdin keys reach a terminal pane as the tty's do: F5, Insert and
try std.testing.expectEqualStrings("\x1b[15~\x1b[2~\x1b[A", written.written());
}
+test "the window's function and editing keys reach a terminal as its cursor keys' mode and keyboard flags say" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
+ defer core.deinit();
+ core.panes[0].?.body.mode = .tty;
+ while (core.nextEffect()) |_| {}
+ var in: Input = .{ .core = core };
+ var written: std.Io.Writer.Allocating = .init(gpa);
+ defer written.deinit();
+ const Case = struct { modes: []const u8, sym: c.SDL_Keycode, mod: c.SDL_Keymod = 0, sent: []const u8 };
+ const cases = [_]Case{
+ .{ .modes = "", .sym = c.SDLK_UP, .sent = "\x1b[A" },
+ .{ .modes = "", .sym = c.SDLK_HOME, .sent = "\x1b[H" },
+ .{ .modes = "", .sym = c.SDLK_F1, .sent = "\x1bOP" },
+ .{ .modes = "", .sym = c.SDLK_F5, .sent = "\x1b[15~" },
+ .{ .modes = "", .sym = c.SDLK_F12, .mod = c.SDL_KMOD_LSHIFT, .sent = "\x1b[24;2~" },
+ .{ .modes = "", .sym = c.SDLK_INSERT, .sent = "\x1b[2~" },
+ // DECCKM: less, vim, htop.
+ .{ .modes = "\x1b[?1h", .sym = c.SDLK_UP, .sent = "\x1bOA" },
+ .{ .modes = "", .sym = c.SDLK_END, .sent = "\x1bOF" },
+ .{ .modes = "", .sym = c.SDLK_LEFT, .mod = c.SDL_KMOD_LCTRL, .sent = "\x1b[1;5D" },
+ // codex's kitty flags (>7u): Ctrl-C and Alt-a are unambiguous.
+ // (A bare Escape at a shell prompt is pardes's way out of raw input.)
+ .{ .modes = "\x1b[?1l\x1b[>7u", .sym = 'c', .mod = c.SDL_KMOD_LCTRL, .sent = "\x1b[99;5u" },
+ .{ .modes = "", .sym = 'a', .mod = c.SDL_KMOD_LALT, .sent = "\x1b[97;3u" },
+ // With event types asked for (2), an arrow says it is a press, as
+ // ghostty says it.
+ .{ .modes = "", .sym = c.SDLK_UP, .sent = "\x1b[1;1:1A" },
+ };
+ for (cases) |case| {
+ core.update(.{ .output = .{ .pane = 0, .bytes = case.modes } });
+ while (core.nextEffect()) |_| {}
+ keyDown(&in, case.sym, case.mod);
+ written.clearRetainingCapacity();
+ while (core.nextEffect()) |effect| {
+ if (effect == .write) try written.writer.writeAll(effect.write.bytes.slice());
+ }
+ try std.testing.expectEqualStrings(case.sent, written.written());
+ }
+}
+
const Input = struct {
core: ?*pardes.Pardes = null,
client: ?*detached_client.Client = null,
@@ -3457,6 +3530,8 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
}
switch (sev.type) {
c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => in.close(),
+ c.SDL_EVENT_WINDOW_FOCUS_GAINED => in.post(.{ .focus = true }),
+ c.SDL_EVENT_WINDOW_FOCUS_LOST => in.post(.{ .focus = false }),
c.SDL_EVENT_WINDOW_MOUSE_LEAVE => {
g.pointer_present = false;
g.pointer_mapped = false;
@@ -3526,18 +3601,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
};
g.pointer_mapped = true;
g.pointer_cell = mc;
- in.post(.{
- .mouse = .{
- .button = button,
- .kind = if (b.down) .press else .release,
- .col = mc.col,
- .row = mc.row,
- .tag_hit = mc.tag_hit,
- .body_hit = mc.body_hit,
- .rule_above = mc.rule_above,
- .ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0,
- },
- });
+ in.post(.{ .mouse = pointerMouse(button, if (b.down) .press else .release, mc, pointerMods()) });
},
c.SDL_EVENT_MOUSE_MOTION => {
const m = sev.motion;
@@ -3559,14 +3623,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
};
g.pointer_mapped = true;
g.pointer_cell = mc;
- in.post(.{ .mouse = .{
- .button = held orelse .none,
- .kind = if (held != null) .drag else .motion,
- .col = mc.col,
- .row = mc.row,
- .tag_hit = mc.tag_hit,
- .body_hit = mc.body_hit,
- } });
+ in.post(.{ .mouse = pointerMouse(held orelse .none, if (held != null) .drag else .motion, mc, pointerMods()) });
},
c.SDL_EVENT_MOUSE_WHEEL => {
const w = sev.wheel;
@@ -3581,7 +3638,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
};
g.pointer_mapped = true;
g.pointer_cell = mc;
- wheelAt(g, in, .{ .x = w.x, .y = w.y, .integer_y = w.integer_y }, mc);
+ wheelAt(g, in, .{ .x = w.x, .y = w.y, .integer_y = w.integer_y, .mods = pointerMods() }, mc);
},
c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => {
const f = sev.tfinger;
@@ -3767,6 +3824,13 @@ fn keyDown(in: *Input, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void {
c.SDLK_PAGEUP => pardes.Key.page_up,
c.SDLK_PAGEDOWN => pardes.Key.page_down,
c.SDLK_DELETE => pardes.Key.delete,
+ c.SDLK_INSERT => pardes.Key.insert,
+ c.SDLK_PRINTSCREEN => pardes.Key.print_screen,
+ c.SDLK_SCROLLLOCK => pardes.Key.scroll_lock,
+ c.SDLK_PAUSE => pardes.Key.pause,
+ c.SDLK_APPLICATION => pardes.Key.menu,
+ c.SDLK_F1...c.SDLK_F12 => pardes.Key.f1 + @as(u21, @intCast(sym - c.SDLK_F1)),
+ c.SDLK_F13...c.SDLK_F24 => pardes.Key.f1 + 12 + @as(u21, @intCast(sym - c.SDLK_F13)),
else => blk: {
if (!(ctrl or alt or gui_mod)) break :blk 0;
if (sym < 128 and sym >= ' ') break :blk @intCast(sym);
@@ -3913,16 +3977,16 @@ fn wheelAt(g: *Gui, in: *Input, w: WheelTurn, mc: MouseCell) void {
.pane = @intCast(id),
.delta_pixels = -w.y * @as(f32, @floatFromInt(g.cell_h)),
} });
- } else if (pane.isTerminal() and pardes.panes.terminal.ownsWheel(pane)) {
+ } else if (!w.mods.shift and pane.isTerminal() and pardes.panes.terminal.ownsWheel(pane)) {
// The program's (codex, htop, less): every whole
// turn goes to it, no glide, for nothing of ours
- // moves.
+ // moves. Shift keeps the wheel pardes's.
resetScroll(g);
g.program_wheel = accumulateWheelDelta(g.program_wheel, w.y);
while (g.program_wheel >= 1) : (g.program_wheel -= 1)
- in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } });
+ in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, .ctrl = w.mods.ctrl, .shift = w.mods.shift, .alt = w.mods.alt } });
while (g.program_wheel <= -1) : (g.program_wheel += 1)
- in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } });
+ in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, .ctrl = w.mods.ctrl, .shift = w.mods.shift, .alt = w.mods.alt } });
} else {
g.scroll_pane = id;
g.scroll_col = mc.col;
@@ -3938,9 +4002,9 @@ fn wheelAt(g: *Gui, in: *Input, w: WheelTurn, mc: MouseCell) void {
} else {
g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
while (g.scroll_delta >= 1) : (g.scroll_delta -= 1)
- in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } });
+ in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, .ctrl = w.mods.ctrl, .shift = w.mods.shift, .alt = w.mods.alt } });
while (g.scroll_delta <= -1) : (g.scroll_delta += 1)
- in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } });
+ in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, .ctrl = w.mods.ctrl, .shift = w.mods.shift, .alt = w.mods.alt } });
}
}
if (w.x != 0) in.post(.{ .mouse = .{
@@ -3950,10 +4014,37 @@ fn wheelAt(g: *Gui, in: *Input, w: WheelTurn, mc: MouseCell) void {
.row = mc.row,
.tag_hit = mc.tag_hit,
.body_hit = mc.body_hit,
+ .ctrl = w.mods.ctrl,
+ .shift = w.mods.shift,
+ .alt = w.mods.alt,
} });
}
-const WheelTurn = struct { x: f32 = 0, y: f32 = 0, integer_y: i32 = 0 };
+const WheelTurn = struct { x: f32 = 0, y: f32 = 0, integer_y: i32 = 0, mods: PointerMods = .{} };
+
+/// The modifiers held with a pointer event, as the core's Mouse carries them.
+const PointerMods = struct { ctrl: bool = false, shift: bool = false, alt: bool = false };
+
+/// The core's Mouse for an SDL press, release or move over cell `mc`.
+fn pointerMouse(button: pardes.Mouse.Button, kind: pardes.Mouse.Kind, mc: MouseCell, mods: PointerMods) pardes.Mouse {
+ return .{
+ .button = button,
+ .kind = kind,
+ .col = mc.col,
+ .row = mc.row,
+ .tag_hit = mc.tag_hit,
+ .body_hit = mc.body_hit,
+ .rule_above = (kind == .press or kind == .release) and mc.rule_above,
+ .ctrl = mods.ctrl,
+ .shift = mods.shift,
+ .alt = mods.alt,
+ };
+}
+
+fn pointerMods() PointerMods {
+ const m = c.SDL_GetModState();
+ return .{ .ctrl = (m & c.SDL_KMOD_CTRL) != 0, .shift = (m & c.SDL_KMOD_SHIFT) != 0, .alt = (m & c.SDL_KMOD_ALT) != 0 };
+}
fn mouseCellWithGeometry(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32, geometry: WindowGeometry) ?MouseCell {
const physical = windowPointToPixels(geometry, x, y);
@@ -4794,6 +4885,39 @@ test "the headless grid host round-trips a yank back as a paste" {
try std.testing.expectEqual(before + yanked.len, pane.file.?.content.len);
}
+test "the clipboard pasted into a terminal reaches its pty as xterm pastes: bracketed when asked, no ESC[201~ smuggled through" {
+ if (comptime !pardes.panes.terminal.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 60, .rows = 16, .tty_only = true });
+ defer core.deinit();
+ var shell: Shell = undefined;
+ shell.core = core;
+ var sent: std.ArrayList(u8) = .empty;
+ defer sent.deinit(gpa);
+ const take = struct {
+ fn f(core_: *pardes.Pardes, out: *std.ArrayList(u8)) ![]const u8 {
+ out.clearRetainingCapacity();
+ while (core_.nextEffect()) |effect| {
+ if (effect == .write and effect.write.pane == 0) try out.appendSlice(std.testing.allocator, effect.write.bytes.slice());
+ }
+ return out.items;
+ }
+ }.f;
+ _ = try take(core, &sent);
+ // The clipboard is SDL's own with no display to hold one.
+ _ = c.SDL_SetHint(c.SDL_HINT_VIDEO_DRIVER, "dummy");
+ defer _ = c.SDL_ResetHint(c.SDL_HINT_VIDEO_DRIVER);
+ if (!c.SDL_InitSubSystem(c.SDL_INIT_VIDEO)) return error.SkipZigTest;
+ defer c.SDL_QuitSubSystem(c.SDL_INIT_VIDEO);
+ putClipboard(gpa, "ls\x1b[201~; rm -rf ~\nx");
+ readClipboard(&shell);
+ try std.testing.expectEqualStrings("ls [201~; rm -rf ~\rx", try take(core, &sent));
+ core.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } });
+ _ = try take(core, &sent);
+ readClipboard(&shell);
+ try std.testing.expectEqualStrings("\x1b[200~ls [201~; rm -rf ~\nx\x1b[201~", try take(core, &sent));
+}
+
fn openLink(ctx: ?*anyopaque, url: []const u8) void {
_ = ctx;
look.openLink(url); // desktop browser
@@ -4918,6 +5042,46 @@ test "a wheel's notch is whole lines, a touchpad's fraction is not" {
try std.testing.expectEqual(@as(?i32, null), notchOf(std.math.nan(f32), 1));
}
+test "a click over codex in a terminal reaches its pty with its modifiers; with Shift it is pardes's" {
+ if (comptime !pardes.panes.terminal.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 60, .rows = 16, .tty_only = true });
+ defer core.deinit();
+ var frame: std.heap.ArenaAllocator = .init(gpa);
+ defer frame.deinit();
+ _ = try core.render(frame.allocator());
+ var in: Input = .{ .core = core };
+ var sent: std.ArrayList(u8) = .empty;
+ defer sent.deinit(gpa);
+ const take = struct {
+ fn f(core_: *pardes.Pardes, out: *std.ArrayList(u8)) ![]const u8 {
+ out.clearRetainingCapacity();
+ while (core_.nextEffect()) |effect| {
+ if (effect == .write and effect.write.pane == 0) try out.appendSlice(std.testing.allocator, effect.write.bytes.slice());
+ }
+ return out.items;
+ }
+ }.f;
+ _ = try take(core, &sent);
+ const pane = core.panes[0].?;
+ const r = core.rects[0];
+ const mc: MouseCell = .{ .col = r.x + config.GUTTER + 5, .row = core.bodyTop(pane, r) + 3 };
+ const next: MouseCell = .{ .col = mc.col + 1, .row = mc.row };
+ core.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h" } });
+ _ = try take(core, &sent);
+ in.post(.{ .mouse = pointerMouse(.none, .motion, mc, .{}) });
+ try std.testing.expectEqualStrings("\x1b[<35;6;4M", try take(core, &sent));
+ in.post(.{ .mouse = pointerMouse(.left, .press, mc, .{ .ctrl = true }) });
+ try std.testing.expectEqualStrings("\x1b[<16;6;4M", try take(core, &sent));
+ in.post(.{ .mouse = pointerMouse(.left, .drag, next, .{}) });
+ try std.testing.expectEqualStrings("\x1b[<32;7;4M", try take(core, &sent));
+ in.post(.{ .mouse = pointerMouse(.left, .release, next, .{}) });
+ try std.testing.expectEqualStrings("\x1b[<0;7;4m", try take(core, &sent));
+ in.post(.{ .mouse = pointerMouse(.middle, .press, mc, .{ .shift = true }) });
+ in.post(.{ .mouse = pointerMouse(.middle, .release, mc, .{ .shift = true }) });
+ try std.testing.expectEqualStrings("", try take(core, &sent));
+}
+
test "a wheel over codex in a terminal reaches its pty as the mouse reports it asked for" {
if (comptime !pardes.panes.terminal.enabled) return error.SkipZigTest;
const gpa = std.testing.allocator;
@@ -4934,6 +5098,7 @@ test "a wheel over codex in a terminal reaches its pty as the mouse reports it a
g.scroll_notch = 0;
g.program_wheel = 0;
g.glide = .{};
+ defer g.glide.deinit(gpa);
g.cell_h = 20;
var in: Input = .{ .core = core };
var sent: std.ArrayList(u8) = .empty;
@@ -4967,6 +5132,15 @@ test "a wheel over codex in a terminal reaches its pty as the mouse reports it a
try std.testing.expectEqualStrings("", try Sent.take(core, &sent));
wheelAt(&g, &in, .{ .y = 0.5 }, mc);
try std.testing.expectEqualStrings("\x1b[<64;6;4M", try Sent.take(core, &sent));
+ // Alt is meta, 8; Shift keeps the turn pardes's own, a glide.
+ wheelAt(&g, &in, .{ .y = 1, .integer_y = 1, .mods = .{ .alt = true } }, mc);
+ try std.testing.expectEqualStrings("\x1b[<72;6;4M", try Sent.take(core, &sent));
+ wheelAt(&g, &in, .{ .y = 1, .integer_y = 1, .mods = .{ .shift = true } }, mc);
+ try std.testing.expectEqualStrings("", try Sent.take(core, &sent));
+ try std.testing.expectEqual(@as(i32, -1), g.scroll_notch);
+ stepGlide(&g, core, gpa);
+ try std.testing.expectEqualStrings("", try Sent.take(core, &sent));
+ resetScroll(&g);
// Its sign-in screen: mouse off, alternate scroll on, so arrow keys.
core.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1007h" } });
_ = try Sent.take(core, &sent);
@@ -5171,7 +5345,8 @@ fn stepGlide(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
const down = left > 0;
left += if (down) -1 else 1;
const was = pane.scroll();
- core.update(.{ .mouse = .{ .button = if (down) .wheel_down else .wheel_up, .kind = .press, .col = g.scroll_col, .row = g.scroll_row } });
+ // Shift: pardes's own scroll, never a program's wheel.
+ core.update(.{ .mouse = .{ .button = if (down) .wheel_down else .wheel_up, .kind = .press, .col = g.scroll_col, .row = g.scroll_row, .shift = true } });
if (pane.scroll() == was) break;
moved = true;
}
@@ -5344,6 +5519,7 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
.kind = .press,
.col = g.scroll_col,
.row = g.scroll_row,
+ .shift = true, // pardes's own scroll, never a program's wheel
} });
if (pane.scroll() == was) {
resetScroll(g);
diff --git a/src/mouse.zig b/src/mouse.zig
index a0ec6e47..ecb9bb57 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -313,20 +313,126 @@ pub fn sameBodyCell(a: ?Mouse.BodyHit, b: ?Mouse.BodyHit) bool {
/// A wheel over a terminal whose program owns it (`terminal.ownsWheel`:
/// codex, htop, less) goes to the program as the program asked, one turn
/// per line, instead of scrolling a scrollback the alternate screen does
-/// not have. False when pardes scrolls.
-fn programWheel(p: *Pardes, id: usize, wheel: panes.terminal.Wheel, col: u16, row: u16, ctrl: bool) bool {
+/// not have. Shift keeps it pardes's, as xterm's Shift does. False when
+/// pardes scrolls.
+fn programWheel(p: *Pardes, id: usize, m: Mouse, col: u16, row: u16) bool {
+ if (m.shift) return false;
const pane = p.panes[id] orelse return false;
if (!pane.isTerminal() or !panes.terminal.ownsWheel(pane)) return false;
const r = p.rects[id];
const body_y = p.bodyTop(pane, r);
const gcol = std.math.clamp(col -| (r.x + config.GUTTER), 0, pane.cols -| 1);
const grow = std.math.clamp(row -| body_y, 0, pane.rows -| 1);
- var buf: [32]u8 = undefined;
- const bytes = panes.terminal.wheelBytes(pane, &buf, wheel, gcol, grow, ctrl) orelse return true;
+ var buf: [64]u8 = undefined;
+ const wheel: panes.terminal.MouseButton = switch (m.button) {
+ .wheel_up => .wheel_up,
+ .wheel_down => .wheel_down,
+ .wheel_left => .wheel_left,
+ else => .wheel_right,
+ };
+ const bytes = panes.terminal.wheelBytes(pane, &buf, wheel, gcol, grow, .{ .alt = m.alt, .ctrl = m.ctrl }) orelse return true;
for (0..@intCast(config.wheel_rows)) |_| p.emitWrite(id, bytes);
return true;
}
+pub const Grab = struct { id: usize, serial: u32, held: u3 = 0 };
+
+/// The 0-based cell of a terminal program's grid under screen cell
+/// `col`,`row`, or null over chrome (tag, gutter, scrollbar, anything past
+/// the grid).
+fn programCell(p: *const Pardes, id: usize, col: u16, row: u16) ?struct { col: i32, row: i32 } {
+ const pane = p.panes[id] orelse return null;
+ const r = p.rects[id];
+ const x0 = r.x + config.GUTTER;
+ const y0 = p.bodyTop(pane, r);
+ const h = @min(r.h -| pane.tag_rows, pane.rows);
+ if (col < x0 or col >= r.x + r.w or col - x0 >= pane.cols or row < y0 or row - y0 >= h) return null;
+ return .{ .col = col - x0, .row = row - y0 };
+}
+
+/// A pane whose program tracks the mouse (1000, 1002, 1003, or X10's 9)
+/// and has the keyboard's raw input: its clicks are the program's.
+fn tracksMouse(pane: *const Pane) bool {
+ return pane.isTerminal() and pane.body.mode == .tty and panes.terminal.reportsMouse(pane);
+}
+
+/// A program that tracks the mouse gets it over its grid, as xterm gives
+/// it: presses, releases, drags with a button down (1002) and, under 1003,
+/// every move, once per cell. A press it took keeps its drag and release
+/// wherever the pointer goes. Tags, gutters and the rest of pardes's
+/// chrome are never its, and Shift keeps pardes's own select, execute and
+/// look. True when the event was the program's.
+fn programMouse(p: *Pardes, m: Mouse) bool {
+ const button: ?panes.terminal.MouseButton = switch (m.button) {
+ .left => .left,
+ .middle => .middle,
+ .right => .right,
+ .none => null,
+ else => return false,
+ };
+ const bit: u3 = if (button) |b| @as(u3, 1) << @intCast(@intFromEnum(b)) else 0;
+ const at = p.presentation.pointer(p.screen_w, p.screen_h, @min(m.col, p.screen_w -| 1), @min(m.row, p.screen_h -| 1));
+ const mods: panes.terminal.MouseMods = .{ .shift = m.shift, .alt = m.alt, .ctrl = m.ctrl };
+ var buf: [64]u8 = undefined;
+ if (p.mouse_grab) |*grab| {
+ const pane = p.panes[grab.id] orelse {
+ p.mouse_grab = null;
+ return false;
+ };
+ if (pane.serial != grab.serial or !pane.isTerminal()) {
+ p.mouse_grab = null;
+ return false;
+ }
+ // Off the grid the cell runs on past its edge, and the encoder
+ // clamps it there.
+ const r = p.rects[grab.id];
+ const col: i32 = if (at) |a| @as(i32, a.col) - @as(i32, r.x + config.GUTTER) else 0;
+ const row: i32 = if (at) |a| @as(i32, a.row) - @as(i32, p.bodyTop(pane, r)) else 0;
+ const action: panes.terminal.MouseAction = switch (m.kind) {
+ .press => .press,
+ .release => .release,
+ .drag, .motion => .motion,
+ };
+ if (m.kind == .press) grab.held |= bit;
+ if (m.kind == .release) grab.held &= ~bit;
+ const bytes = panes.terminal.mouseBytes(pane, &buf, action, button, mods, col, row, grab.held != 0);
+ if (bytes.len > 0) p.emitWrite(grab.id, bytes);
+ if (grab.held == 0) p.mouse_grab = null;
+ return true;
+ }
+ if (m.shift or p.drag != .none or p.header_drag or p.header_sweep != null) return false;
+ const mapped = at orelse return false;
+ const id = paneAt(p, mapped.col, mapped.row) orelse return false;
+ const pane = p.panes[id].?;
+ if (!tracksMouse(pane)) return false;
+ const cell = programCell(p, id, mapped.col, mapped.row) orelse return false;
+ switch (m.kind) {
+ .press => {
+ if (id != p.active) if (p.panes[p.active]) |previous| {
+ previous.focus = .body;
+ previous.closePrompt();
+ };
+ p.active = id;
+ pane.focus = .body;
+ pane.closePrompt();
+ p.mouse_grab = .{ .id = id, .serial = pane.serial, .held = bit };
+ const bytes = panes.terminal.mouseBytes(pane, &buf, .press, button, mods, cell.col, cell.row, true);
+ if (bytes.len > 0) p.emitWrite(id, bytes);
+ return true;
+ },
+ // A release or drag of a press pardes never passed on stays
+ // pardes's.
+ .release, .drag => return false,
+ .motion => {
+ const bytes = panes.terminal.mouseBytes(pane, &buf, .motion, null, mods, cell.col, cell.row, false);
+ if (bytes.len > 0) p.emitWrite(id, bytes);
+ // Under 1003 the move is the program's; below that pardes
+ // hovers as ever.
+ return panes.terminal.tracksAllMotion(pane);
+ },
+ }
+}
+
pub fn handleMouse(p: *Pardes, m: Mouse) void {
// A click goes somewhere else: the question it leaves unanswered is
// taken back, and the pane stays. A wheel only looks around.
@@ -376,6 +482,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
}
}
p.pointer_inside = true;
+ if (programMouse(p, m)) return;
if (m.button == .none and m.kind == .motion) {
p.raw_hover_intent = true;
look.refreshLookHoverFromRaw(p);
@@ -525,7 +632,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
const pane = p.panes[id].?;
if (pane.hasPdf())
panes.Pdf.verticalWheel(p, pane, if (m.button == .wheel_up) -1 else 1)
- else if (!programWheel(p, id, if (m.button == .wheel_up) .up else .down, mcol, mrow, m.ctrl))
+ else if (!programWheel(p, id, m, mcol, mrow))
pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows);
},
.wheel_left, .wheel_right => {
@@ -533,7 +640,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
if (!config.wheelTick(&p.wheel_guard, false)) return;
const id = hovered orelse return;
const pane = p.panes[id].?;
- if (programWheel(p, id, if (m.button == .wheel_right) .right else .left, mcol, mrow, m.ctrl)) {
+ if (programWheel(p, id, m, mcol, mrow)) {
// the program's
} else if (pane.hasPdf()) {
panes.Pdf.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1);
@@ -1250,16 +1357,13 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
const y = p.registers.text(p.gpa, pardes.Registers.default) orelse return;
if (y.len == 0) return;
if (panes.terminal.reportsMouse(pane)) {
- const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999));
- const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, pane.tag_rows) + 1, 1, 9999));
- var mb: [32]u8 = undefined;
- if (panes.terminal.mouseFormatSgr(pane)) {
- p.emitWrite(s.id, std.fmt.bufPrint(&mb, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ col, row, col, row }) catch return);
- } else {
- // ponytail: legacy X10 bytes; add utf8/urxvt formats if an app ever wants them
- const cb: u8 = @intCast(32 + @min(col, 222));
- const rb: u8 = @intCast(32 + @min(row, 222));
- p.emitWrite(s.id, &[_]u8{ 0x1b, '[', 'M', 32, cb, rb, 0x1b, '[', 'M', 35, cb, rb });
+ // A click where the text goes, so the program's cursor is there.
+ const col = @max(0, pane.sel[sel_slot].c1);
+ const row = @max(0, pane.sel[sel_slot].r1 - @as(i32, pane.tag_rows));
+ var mb: [64]u8 = undefined;
+ for ([_]panes.terminal.MouseAction{ .press, .release }) |action| {
+ const bytes = panes.terminal.mouseBytes(pane, &mb, action, .left, .{}, col, row, action == .press);
+ if (bytes.len > 0) p.emitWrite(s.id, bytes);
}
}
edit.typeToTty(p, s.id, pane, y);
@@ -1838,11 +1942,16 @@ test "a wheel over a program that owns it is the program's, in the form it asked
// Grid cell 6,4 (1-based), as a program counts.
const col = r.x + config.GUTTER + 5;
const row = p.bodyTop(pane, r) + 3;
- const Case = struct { modes: []const u8, button: Mouse.Button, ctrl: bool = false, sent: []const u8 };
+ const Case = struct { modes: []const u8, button: Mouse.Button, ctrl: bool = false, alt: bool = false, shift: bool = false, sent: []const u8 };
const cases = [_]Case{
// codex's chat (0.158+): the alternate screen, any-event tracking, SGR.
.{ .modes = "\x1b[?2004h\x1b[>7u\x1b[?1004h\x1b[?1049h\x1b[?1007l\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h", .button = .wheel_up, .sent = "\x1b[<64;6;4M" },
.{ .modes = "", .button = .wheel_down, .ctrl = true, .sent = "\x1b[<81;6;4M" },
+ // Meta is 8, both 24; Shift keeps the wheel pardes's, which has
+ // nothing to scroll on the alternate screen.
+ .{ .modes = "", .button = .wheel_up, .alt = true, .sent = "\x1b[<72;6;4M" },
+ .{ .modes = "", .button = .wheel_up, .alt = true, .ctrl = true, .sent = "\x1b[<88;6;4M" },
+ .{ .modes = "", .button = .wheel_up, .shift = true, .sent = "" },
// htop's legacy bytes (1000 alone), and 1015's.
.{ .modes = "\x1b[?1006l", .button = .wheel_down, .sent = "\x1b[M\x61\x26\x24" },
.{ .modes = "\x1b[?1015h", .button = .wheel_up, .sent = "\x1b[96;6;4M" },
@@ -1857,7 +1966,7 @@ test "a wheel over a program that owns it is the program's, in the form it asked
for (cases) |case| {
p.update(.{ .output = .{ .pane = 0, .bytes = case.modes } });
_ = try ptyWrites(p, &out);
- p.update(.{ .mouse = .{ .button = case.button, .kind = .press, .col = col, .row = row, .ctrl = case.ctrl } });
+ p.update(.{ .mouse = .{ .button = case.button, .kind = .press, .col = col, .row = row, .ctrl = case.ctrl, .alt = case.alt, .shift = case.shift } });
try std.testing.expectEqualStrings(case.sent, try ptyWrites(p, &out));
}
// A shell on the main screen: the wheel scrolls its scrollback, and
@@ -1873,4 +1982,101 @@ test "a wheel over a program that owns it is the program's, in the form it asked
p.update(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = col, .row = row } });
try std.testing.expectEqualStrings("", try ptyWrites(p, &out));
try std.testing.expect(pane.scroll() != was);
+ // A program tracking the mouse on the main screen: Shift and the
+ // wheel scroll the scrollback, the program hears nothing.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1000h\x1b[?1006h" } });
+ _ = try ptyWrites(p, &out);
+ const tracked = pane.scroll();
+ p.update(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = col, .row = row, .shift = true } });
+ try std.testing.expectEqualStrings("", try ptyWrites(p, &out));
+ try std.testing.expect(pane.scroll() != tracked);
+ p.update(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = col, .row = row } });
+ try std.testing.expectEqualStrings("\x1b[<64;6;4M", try ptyWrites(p, &out));
+}
+
+test "a program tracking the mouse gets its clicks, drags and moves over its grid as xterm reports them; Shift and chrome stay pardes's" {
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 16, .tty_only = true });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(gpa);
+ _ = try ptyWrites(p, &out);
+ const pane = p.panes[0].?;
+ try std.testing.expectEqual(.tty, pane.body.mode);
+ const r = p.rects[0];
+ const col = r.x + config.GUTTER + 5;
+ const row = p.bodyTop(pane, r) + 3;
+ const Ev = struct { button: Mouse.Button, kind: Mouse.Kind, dx: i32 = 0, dy: i32 = 0, shift: bool = false, alt: bool = false, ctrl: bool = false };
+ const send = struct {
+ fn f(pp: *Pardes, o: *std.ArrayList(u8), c: u16, rw: u16, e: Ev) ![]const u8 {
+ pp.update(.{ .mouse = .{
+ .button = e.button,
+ .kind = e.kind,
+ .col = @intCast(@as(i32, c) + e.dx),
+ .row = @intCast(@as(i32, rw) + e.dy),
+ .shift = e.shift,
+ .alt = e.alt,
+ .ctrl = e.ctrl,
+ } });
+ return ptyWrites(pp, o);
+ }
+ }.f;
+ // htop's modes: clicks in SGR, a release its own `m`.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049h\x1b[?1h\x1b[?1006;1000h" } });
+ _ = try ptyWrites(p, &out);
+ try std.testing.expectEqualStrings("\x1b[<0;6;4M", try send(p, &out, col, row, .{ .button = .left, .kind = .press }));
+ try std.testing.expect(p.drag == .none);
+ // 1000 has no drags; the release is reported where it lands.
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .drag, .dx = 1 }));
+ try std.testing.expectEqualStrings("\x1b[<0;7;4m", try send(p, &out, col, row, .{ .button = .left, .kind = .release, .dx = 1 }));
+ // Right with meta and ctrl: 2 + 8 + 16.
+ try std.testing.expectEqualStrings("\x1b[<26;6;4M", try send(p, &out, col, row, .{ .button = .right, .kind = .press, .alt = true, .ctrl = true }));
+ try std.testing.expectEqualStrings("\x1b[<2;6;4m", try send(p, &out, col, row, .{ .button = .right, .kind = .release }));
+ // Moves are no 1000 program's business: pardes hovers.
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .none, .kind = .motion }));
+ // Shift: pardes's own sweep, the program hears nothing of it.
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .press, .shift = true }));
+ try std.testing.expect(p.drag == .select);
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .drag, .dx = 2 }));
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .release, .dx = 2 }));
+ try std.testing.expect(p.drag == .none);
+ // Chrome is never the program's: the tag, and the gutter.
+ const tag_row = p.tagTop(pane, r);
+ try std.testing.expectEqualStrings("", try send(p, &out, col, tag_row, .{ .button = .right, .kind = .press }));
+ try std.testing.expectEqualStrings("", try send(p, &out, col, tag_row, .{ .button = .right, .kind = .release }));
+ try std.testing.expectEqualStrings("", try send(p, &out, r.x, row, .{ .button = .left, .kind = .press }));
+ try std.testing.expectEqualStrings("", try send(p, &out, r.x, row, .{ .button = .left, .kind = .release }));
+
+ // codex's any-event tracking: a drag once per cell, carried past the
+ // grid's edge and clamped there, and bare moves.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1003h" } });
+ _ = try ptyWrites(p, &out);
+ try std.testing.expectEqualStrings("\x1b[<35;9;4M", try send(p, &out, col, row, .{ .button = .none, .kind = .motion, .dx = 3 }));
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .none, .kind = .motion, .dx = 3 }));
+ try std.testing.expectEqualStrings("\x1b[<0;6;4M", try send(p, &out, col, row, .{ .button = .left, .kind = .press }));
+ try std.testing.expectEqualStrings("\x1b[<32;7;4M", try send(p, &out, col, row, .{ .button = .left, .kind = .drag, .dx = 1 }));
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .drag, .dx = 1 }));
+ // Up over the tag: row 1, the grid's first.
+ try std.testing.expectEqualStrings("\x1b[<32;7;1M", try send(p, &out, col + 1, tag_row, .{ .button = .left, .kind = .drag }));
+ try std.testing.expectEqualStrings("\x1b[<0;7;1m", try send(p, &out, col + 1, tag_row, .{ .button = .left, .kind = .release }));
+ try std.testing.expect(p.mouse_grab == null);
+ // Over the tag a bare move is pardes's.
+ try std.testing.expectEqualStrings("", try send(p, &out, col, tag_row, .{ .button = .none, .kind = .motion }));
+
+ // Legacy X10 bytes: a release is button 3. (Turning 1003 off turns
+ // tracking off, as xterm's one tracking mode goes.)
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1003l\x1b[?1006l\x1b[?1000h" } });
+ _ = try ptyWrites(p, &out);
+ try std.testing.expectEqualStrings("\x1b[M &$", try send(p, &out, col, row, .{ .button = .left, .kind = .press }));
+ try std.testing.expectEqualStrings("\x1b[M#&$", try send(p, &out, col, row, .{ .button = .left, .kind = .release }));
+
+ // A pane out of the program's raw input (Esc to normal mode) clicks as
+ // pardes clicks.
+ pane.body.mode = .normal;
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .press }));
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .release }));
}
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 17941e10..322728da 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -1169,7 +1169,12 @@ fn write(p: *Pardes, req: Req, target: Target) Reply {
// and its failure is this write's, as acme takes each write whole. Held
// only when it is not whole yet -- an open `{` block, an `a`/`c`/`i`
// text awaiting its `.` -- or the write filled its Twrite and may go on.
- const cut = p.fs.write_room != 0 and req.data.len >= p.fs.write_room;
+ // So may one cut where a writer's buffer filled: stdio flushes, and a
+ // mount's page cache writes back, in multiples of 4096 bytes. `seq ...
+ // > exec` through a mount arrived in 4096-byte writes, and the line cut
+ // at each end ran as two commands (the half `Msg` refused, failing the
+ // write and the rest of the burst).
+ const cut = (p.fs.write_room != 0 and req.data.len >= p.fs.write_room) or req.data.len % 4096 == 0;
if (end < o.pending.items.len and !cut) {
o.pending.append(p.gpa, '\n') catch return Reply.fail(req.tag, E.NOMEM);
const whole = ctl.completeEnd(p, o.pending.items);
@@ -1826,6 +1831,25 @@ test "look and exec read back what their own open's write touched; a fresh open
for ([_]u32{ mine, theirs, fresh }) |hh| _ = call(p, .{ .tag = 10, .op = .release, .node = exec, .handle = hh, .opened = true });
}
+test "a write cut where a writer's 4096-byte buffer filled holds its partial line for the next write" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const exec_node = Node.of(serialOf(p), .exec);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = exec_node, .omode = 1 }).reply.handle;
+ // 4096 bytes: whole `Msg first` lines, then a line cut after `Msg`.
+ var buf: [4096]u8 = undefined;
+ const line = "Msg first\n";
+ var n: usize = 0;
+ while (n + line.len <= buf.len - 3) : (n += line.len) @memcpy(buf[n..][0..line.len], line);
+ @memset(buf[n..], ' ');
+ @memcpy(buf[buf.len - 3 ..], "Msg");
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = exec_node, .handle = h, .data = &buf }).reply.status);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 3, .op = .write, .node = exec_node, .handle = h, .data = " last\n" }).reply.status);
+ _ = call(p, .{ .tag = 4, .op = .release, .node = exec_node, .handle = h, .opened = true });
+ try testing.expect(th.logHas(p, "last"));
+ try testing.expect(!th.logHas(p, "wrong #args"));
+}
+
test "no placement leaves a pane shorter than its tag and two rows; a full column refuses the next" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/pardes.zig b/src/pardes.zig
index 04854a37..402b3cd8 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -3482,6 +3482,9 @@ pub const Mouse = struct {
col: u16,
row: u16,
ctrl: bool = false,
+ shift: bool = false,
+ /// Alt, which xterm's mouse reports call meta.
+ alt: bool = false,
body_hit: ?BodyHit = null,
tag_hit: ?TagHit = null,
/// A pixel frontend's press on the rule it draws at the top of this row,
@@ -3575,6 +3578,8 @@ pub const Event = union(enum) {
/// The host pointer left the drawable area. Unlike an out-of-range motion,
/// this must not clamp onto and preview the final grid cell.
pointer_leave,
+ /// The window gained (true) or lost the host's keyboard focus.
+ focus: bool,
tick,
};
@@ -4346,6 +4351,13 @@ pub const Pardes = struct {
unattended: bool = false,
haptic: HapticSlot = if (platform == .macos) .none else {},
drag: mouse.Drag = .none,
+ /// A press a terminal's program took (mouse.programMouse): its drags
+ /// and release are the program's too, wherever the pointer goes.
+ mouse_grab: ?mouse.Grab = null,
+ /// Whether the host window has the keyboard (Event.focus), and the pane
+ /// whose program was last told it has it (reportFocus).
+ window_focused: bool = true,
+ focus_told: ?struct { id: usize, serial: u32 } = null,
hover_col: u16 = 0,
hover_row: u16 = 0,
pointer_raw_col: u16 = 0,
@@ -5047,6 +5059,25 @@ pub const Pardes = struct {
}
/// The column the keyboard is in: its tag's, or its pane's.
+ /// DECSET 1004: a program that asked is told `CSI I` when its pane
+ /// takes the keyboard (the active pane, its body, the window focused)
+ /// and `CSI O` when it gives it up, to another pane, a tag or header,
+ /// or another window.
+ fn reportFocus(p: *Pardes) void {
+ const want: @FieldType(Pardes, "focus_told") = blk: {
+ if (!p.window_focused or p.header_focus) break :blk null;
+ const pane = p.panes[p.active] orelse break :blk null;
+ if (pane.focus != .body) break :blk null;
+ break :blk .{ .id = p.active, .serial = pane.serial };
+ };
+ if (std.meta.eql(want, p.focus_told)) return;
+ if (p.focus_told) |old| if (p.panes[old.id]) |pane| {
+ if (pane.serial == old.serial and panes.terminal.reportsFocus(pane)) p.emitWrite(old.id, "\x1b[O");
+ };
+ if (want) |new| if (panes.terminal.reportsFocus(p.panes[new.id].?)) p.emitWrite(new.id, "\x1b[I");
+ p.focus_told = want;
+ }
+
pub fn noteTyped(p: *Pardes) void {
if (p.header_focus) {
if (p.header_column) |c| p.setActiveColumn(c);
@@ -5707,6 +5738,7 @@ pub const Pardes = struct {
}
pub fn update(p: *Pardes, ev: Event) void {
+ defer p.reportFocus();
// A tick with nothing animating is the one event that cannot change
// the screen.
p.needs_frame = p.needs_frame or ev != .tick;
@@ -5839,6 +5871,7 @@ pub const Pardes = struct {
.command => |line| _ = p.executeBuiltinLine(p.active, line),
.pinch => |scale| p.ov_pinch_scale = scale,
.touch_scroll => |delta| p.ov_touch_scroll_delta = delta,
+ .focus => |focused| p.window_focused = focused,
.pointer_leave => {
p.pointer_inside = false;
p.pointer_body_hit = null;
diff --git a/src/terminal.zig b/src/terminal.zig
index 7874ca89..c0341d00 100644
--- a/src/terminal.zig
+++ b/src/terminal.zig
@@ -124,6 +124,9 @@ pub const State = struct {
replay: Replay,
reply: [256]u8 = undefined,
reply_len: u16 = 0,
+ /// The grid cell the program last heard the mouse at, so motion is
+ /// reported once per cell.
+ mouse_cell: if (enabled) ?ghostty_vt.Coordinate else void = if (enabled) null else {},
};
const GColor = ghostty_vt.color;
@@ -374,8 +377,36 @@ pub fn bracketedPaste(pane: *const Pane) bool {
pub fn reportsMouse(pane: *const Pane) bool {
if (comptime !enabled) return false;
const state = pane.terminal orelse return false;
- const m = &state.vt.modes;
- return m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any);
+ return state.vt.flags.mouse_event != .none;
+}
+
+/// A paste as xterm makes one (ghostty's encoder): the control bytes a
+/// paste could smuggle a command in by (ESC, so no ESC[201~ can end
+/// 2004's brackets early, NUL, ^C and the rest of xterm's list) become
+/// spaces, then the text goes inside the brackets when the program set
+/// 2004, else with its newlines as Returns. `text` is rewritten in place.
+pub fn encodePaste(pane: *const Pane, text: []u8) [3][]const u8 {
+ if (comptime !enabled) {
+ for (text) |*ch| if (ch.* == '\n') {
+ ch.* = '\r';
+ };
+ return .{ "", text, "" };
+ }
+ return ghostty_vt.input.encodePaste(text, .{ .bracketed = bracketedPaste(pane) });
+}
+
+/// DECSET 1004: the program wants to hear when it gains and loses focus.
+pub fn reportsFocus(pane: *const Pane) bool {
+ if (comptime !enabled) return false;
+ const state = pane.terminal orelse return false;
+ return state.vt.modes.get(.focus_event);
+}
+
+/// ...every move of it, a button down or not (1003).
+pub fn tracksAllMotion(pane: *const Pane) bool {
+ if (comptime !enabled) return false;
+ const state = pane.terminal orelse return false;
+ return state.vt.flags.mouse_event == .any;
}
/// ...and wants them in SGR (1006) rather than the legacy X10 bytes.
@@ -395,43 +426,71 @@ pub fn ownsWheel(pane: *const Pane) bool {
(state.vt.screens.active_key == .alternate and state.vt.modes.get(.mouse_alternate_scroll));
}
-pub const Wheel = enum(u8) { up = 64, down = 65, left = 66, right = 67 };
+pub const MouseMods = struct { shift: bool = false, alt: bool = false, ctrl: bool = false };
+pub const MouseButton = enum { left, middle, right, wheel_up, wheel_down, wheel_left, wheel_right };
+pub const MouseAction = enum { press, release, motion };
+
+/// One mouse event for the program, as xterm reports it (ctlseqs; ghostty's
+/// encoder): the tracking mode it set (1000 clicks, 1002 drags, 1003 all
+/// motion) decides whether it hears of it, the format it set (1006 SGR,
+/// 1015 urxvt, 1005 UTF-8, else X10) how, modifiers as shift+4, meta+8,
+/// ctrl+16. `col`,`row` are the 0-based grid cell; a drag that left the
+/// grid is reported clamped to its edge, motion only when the cell
+/// changed. Empty when the program does not hear of it.
+pub fn mouseBytes(pane: *Pane, buf: *[64]u8, action: MouseAction, button: ?MouseButton, mods: MouseMods, col: i32, row: i32, any_pressed: bool) []const u8 {
+ if (comptime !enabled) return &.{};
+ const state = pane.terminal orelse return &.{};
+ const input = ghostty_vt.input;
+ var writer = std.Io.Writer.fixed(buf);
+ var opts: input.MouseEncodeOptions = .{
+ .event = state.vt.flags.mouse_event,
+ .format = state.vt.flags.mouse_format,
+ .size = .{
+ .screen = .{ .width = state.vt.cols, .height = state.vt.rows },
+ .cell = .{ .width = 1, .height = 1 },
+ .padding = .{},
+ },
+ .any_button_pressed = any_pressed,
+ .last_cell = &state.mouse_cell,
+ };
+ // A release is always reported; the rest only over the grid, or held.
+ if (action == .release) opts.any_button_pressed = true;
+ input.encodeMouse(&writer, .{
+ .action = switch (action) {
+ .press => .press,
+ .release => .release,
+ .motion => .motion,
+ },
+ .button = if (button) |b| switch (b) {
+ .left => .left,
+ .middle => .middle,
+ .right => .right,
+ .wheel_up => .four,
+ .wheel_down => .five,
+ .wheel_left => .six,
+ .wheel_right => .seven,
+ } else null,
+ .mods = .{ .shift = mods.shift, .alt = mods.alt, .ctrl = mods.ctrl },
+ .pos = .{ .x = @as(f32, @floatFromInt(col)) + 0.5, .y = @as(f32, @floatFromInt(row)) + 0.5 },
+ }, opts) catch return &.{};
+ return writer.buffered();
+}
/// The bytes a wheel turn at grid cell `col`,`row` (0-based) sends the
-/// program, as xterm sends them: its mouse report in the format it asked
-/// for, else, on the alternate screen, an arrow key (none for a sideways
-/// turn). Null when the wheel is not the program's (`ownsWheel`) or the
-/// cell cannot be said in the legacy format.
-pub fn wheelBytes(pane: *const Pane, buf: *[32]u8, wheel: Wheel, col: u16, row: u16, ctrl: bool) ?[]const u8 {
+/// program: its mouse report while it tracks the mouse, else, on the
+/// alternate screen, an arrow key in the cursor keys' mode (none for a
+/// sideways turn). Null when the wheel is not the program's (`ownsWheel`).
+pub fn wheelBytes(pane: *Pane, buf: *[64]u8, wheel: MouseButton, col: u16, row: u16, mods: MouseMods) ?[]const u8 {
if (comptime !enabled) return null;
const state = pane.terminal orelse return null;
if (!ownsWheel(pane)) return null;
const m = &state.vt.modes;
if (!reportsMouse(pane)) return switch (wheel) {
- .up => if (m.get(.cursor_keys)) "\x1bOA" else "\x1b[A",
- .down => if (m.get(.cursor_keys)) "\x1bOB" else "\x1b[B",
- .left, .right => null,
+ .wheel_up => if (m.get(.cursor_keys)) "\x1bOA" else "\x1b[A",
+ .wheel_down => if (m.get(.cursor_keys)) "\x1bOB" else "\x1b[B",
+ else => null,
};
- const code = @intFromEnum(wheel) + @as(u8, if (ctrl) 16 else 0);
- const x = @as(u32, col) + 1;
- const y = @as(u32, row) + 1;
- if (m.get(.mouse_format_sgr)) return std.fmt.bufPrint(buf, "\x1b[<{d};{d};{d}M", .{ code, x, y }) catch null;
- if (m.get(.mouse_format_urxvt)) return std.fmt.bufPrint(buf, "\x1b[{d};{d};{d}M", .{ 32 + @as(u32, code), x, y }) catch null;
- buf[0..3].* = "\x1b[M".*;
- buf[3] = 32 + code;
- var n: usize = 4;
- for ([_]u32{ x, y }) |v| {
- const c = 32 + v;
- if (m.get(.mouse_format_utf8)) {
- if (c > 2047) return null;
- n += std.unicode.utf8Encode(@intCast(c), buf[n..]) catch return null;
- } else {
- if (c > 255) return null;
- buf[n] = @intCast(c);
- n += 1;
- }
- }
- return buf[0..n];
+ return mouseBytes(pane, buf, .press, wheel, mods, col, row, false);
}
/// The whole scrollback as plain text, `gpa`-owned: what a terminal's body
@@ -2706,3 +2765,55 @@ const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo(
pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs {
return .{};
}
+
+/// Every byte the core wrote to each pane's pty since the last call, `\n`
+/// between panes' shares: "0:<bytes>\n1:<bytes>\n" for panes that got any.
+fn ptyWritesByPane(p: *Pardes, out: *std.ArrayList(u8)) ![]const u8 {
+ out.clearRetainingCapacity();
+ var per: [pardes.MAX_PANES]std.ArrayList(u8) = @splat(.empty);
+ defer for (&per) |*b| b.deinit(p.gpa);
+ while (p.nextEffect()) |effect| {
+ if (effect == .write) try per[effect.write.pane].appendSlice(p.gpa, effect.write.bytes.slice());
+ }
+ for (per, 0..) |b, id| if (b.items.len > 0) try out.print(p.gpa, "{d}:{s}\n", .{ id, b.items });
+ return out.items;
+}
+
+test "a program that asked for focus reports hears CSI I and CSI O as its pane takes and gives up the keyboard, and as the window does" {
+ if (comptime !enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 90, .rows = 30, .shells = 3 });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(gpa);
+ _ = try ptyWritesByPane(p, &out);
+ p.active = 0;
+ // Asking while its pane has the keyboard: told at once that it has.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1004h" } });
+ try std.testing.expectEqualStrings("0:\x1b[I\n", try ptyWritesByPane(p, &out));
+ p.update(.{ .output = .{ .pane = 1, .bytes = "\x1b[?1004h" } });
+ try std.testing.expectEqualStrings("", try ptyWritesByPane(p, &out));
+ // A click into the other pane moves the keyboard: out of one, into the
+ // other.
+ const r = p.rects[1];
+ const at_col = r.x + config.GUTTER + 2;
+ const at_row = p.bodyTop(p.panes[1].?, r) + 1;
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = at_col, .row = at_row } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = at_col, .row = at_row } });
+ try std.testing.expectEqual(@as(usize, 1), p.active);
+ try std.testing.expectEqualStrings("0:\x1b[O\n1:\x1b[I\n", try ptyWritesByPane(p, &out));
+ // The window: away and back.
+ p.update(.{ .focus = false });
+ try std.testing.expectEqualStrings("1:\x1b[O\n", try ptyWritesByPane(p, &out));
+ p.update(.{ .focus = false });
+ try std.testing.expectEqualStrings("", try ptyWritesByPane(p, &out));
+ p.update(.{ .focus = true });
+ try std.testing.expectEqualStrings("1:\x1b[I\n", try ptyWritesByPane(p, &out));
+ // A program that did not ask hears nothing: pane 2.
+ p.active = 2;
+ p.update(.tick);
+ try std.testing.expectEqualStrings("1:\x1b[O\n", try ptyWritesByPane(p, &out));
+}
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 1d7ff415..eabbbe22 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -1505,8 +1505,11 @@ const Shell = struct {
core.quit = true;
return true;
},
- .focus_in => {},
- .focus_out => core.update(.pointer_leave),
+ .focus_in => core.update(.{ .focus = true }),
+ .focus_out => {
+ core.update(.pointer_leave);
+ core.update(.{ .focus = false });
+ },
.winsize => |ws| {
s.vx.resize(s.gpa, s.tty.writer(), ws) catch {};
if (comptime pardes.pdf_enabled)
@@ -2244,6 +2247,44 @@ test "a terminal pane gets function keys as their sequences and lone modifiers n
try std.testing.expectEqualStrings("\x1bOR\x1b[15~\x1b[2~\x1bOR\x1b[Ah", written.written());
}
+test "the terminal's keys reach a program as its cursor keys' mode and keyboard flags say" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
+ defer core.deinit();
+ core.panes[0].?.body.mode = .tty;
+ while (core.nextEffect()) |_| {}
+ var written: std.Io.Writer.Allocating = .init(gpa);
+ defer written.deinit();
+ // What the outer terminal sends, what the program asked for, and what
+ // it should read.
+ const Case = struct { modes: []const u8, in: []const u8, sent: []const u8 };
+ const cases = [_]Case{
+ .{ .modes = "", .in = "\x1b[A\x1b[H\x1bOP\x1b[15~", .sent = "\x1b[A\x1b[H\x1bOP\x1b[15~" },
+ .{ .modes = "\x1b[?1h", .in = "\x1b[A\x1b[F\x1b[1;5D", .sent = "\x1bOA\x1bOF\x1b[1;5D" },
+ .{ .modes = "\x1b[?1l\x1b[>7u", .in = "\x1b[99;5u\x1b[97;3u\x1b[A", .sent = "\x1b[99;5u\x1b[97;3u\x1b[1;1:1A" },
+ };
+ for (cases) |case| {
+ core.update(.{ .output = .{ .pane = 0, .bytes = case.modes } });
+ while (core.nextEffect()) |_| {}
+ var parser: vaxis.Parser = .{};
+ var bytes = case.in;
+ while (bytes.len > 0) {
+ const result = try parser.parse(bytes, gpa);
+ if (result.n == 0) break;
+ bytes = bytes[result.n..];
+ if (result.event) |event| switch (event) {
+ .key_press => |key| core.update(vaxis_input.keyEvent(key)),
+ else => {},
+ };
+ }
+ written.clearRetainingCapacity();
+ while (core.nextEffect()) |effect| {
+ if (effect == .write) try written.writer.writeAll(effect.write.bytes.slice());
+ }
+ try std.testing.expectEqualStrings(case.sent, written.written());
+ }
+}
+
test "keypad Enter matches Return for editor input terminal writes and paste" {
for ([_]u21{ vaxis.Key.enter, vaxis.Key.kp_enter }) |codepoint| {
const key: vaxis.Key = .{ .codepoint = codepoint };
@@ -2297,6 +2338,8 @@ fn mouseEvent(m: vaxis.Mouse) ?pardes.Event {
.col = @intCast(m.col),
.row = @intCast(m.row),
.ctrl = m.mods.ctrl,
+ .shift = m.mods.shift,
+ .alt = m.mods.alt,
} };
}
@@ -2708,8 +2751,11 @@ const Attach = struct {
switch (event) {
.nop, .wake, .fs_ready, .lsp_done, .lsp_status, .pipe_done, .pty_read, .pty_eof, .files_changed => {},
.quit => return .none,
- .focus_in => {},
- .focus_out => return a.send(.pointer_leave),
+ .focus_in => return a.send(.{ .focus = true }),
+ .focus_out => {
+ if (a.send(.pointer_leave)) |end| return end;
+ return a.send(.{ .focus = false });
+ },
.winsize => |ws| {
a.vx.resize(a.gpa, a.tty.writer(), ws) catch {};
a.dirty = true;
diff --git a/test/monkey9p.py b/test/monkey9p.py
index 4d190bbc..87e386b3 100644
--- a/test/monkey9p.py
+++ b/test/monkey9p.py
@@ -674,8 +674,9 @@ def served_lines(data, sizes, room):
(fs.md, tree.zig write): each ended line, and the unended tail of a piece
shorter than a Twrite holds (`room`, msize less 24), which is whole in
itself, as acme takes each write; a piece that fills its Twrite may go
- on, and its tail waits for the next. A line written a byte a Twrite is
- so as many lines as bytes."""
+ on, and its tail waits for the next, and so does one of a multiple of
+ 4096 bytes, where a writer's buffer filled. A line written a byte a
+ Twrite is so as many lines as bytes."""
lines, pending, at = [], b'', 0
for size in sizes:
piece = data[at:at + size]
@@ -683,7 +684,7 @@ def served_lines(data, sizes, room):
pending += piece
*done, pending = pending.split(b'\n')
lines += done
- if pending and len(piece) < room:
+ if pending and len(piece) < room and len(piece) % 4096 != 0:
lines.append(pending)
pending = b''
if pending:
@@ -1615,7 +1616,10 @@ def chunked_equals_whole(ctx, res):
whole_n, chunk_n = dir_names(whole), dir_names(chunked)
if whole_n != chunk_n:
return f'{path}: whole {whole_n} != chunked({size}) {chunk_n}'
- elif whole != chunked:
+ elif whole != chunked and w.read_all(fid) != chunked:
+ # A live file (a tag says `running`, then `exit 0`) may change
+ # between the two reads: only chunks that no whole read matches,
+ # read again, are a chunking fault.
return f'{path}: whole {whole[:200]!r} != chunked({size}) {chunked[:200]!r}'
finally:
w.clunk(fid)
diff --git a/test/panes.zig b/test/panes.zig
index d7fa3514..6f321abf 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -981,10 +981,12 @@ const TtySelectionTests = struct {
const target = p.rects[1];
const tx = target.x + config.GUTTER + 2;
const ty = bodyY(p, target);
- p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = tx, .row = ty } });
- p.update(.{ .mouse = .{ .button = .right, .kind = .press, .col = tx, .row = ty } });
- p.update(.{ .mouse = .{ .button = .right, .kind = .release, .col = tx, .row = ty } });
- p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = tx, .row = ty } });
+ // Shift: over a program that tracks the mouse the chord is
+ // pardes's only so, and elsewhere Shift changes nothing.
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = tx, .row = ty, .shift = true } });
+ p.update(.{ .mouse = .{ .button = .right, .kind = .press, .col = tx, .row = ty, .shift = true } });
+ p.update(.{ .mouse = .{ .button = .right, .kind = .release, .col = tx, .row = ty, .shift = true } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = tx, .row = ty, .shift = true } });
var written: std.Io.Writer.Allocating = .init(p.gpa);
defer written.deinit();
while (p.nextEffect()) |effect| {
diff --git a/test/selfmount.py b/test/selfmount.py
index 6534aa25..86c32fa8 100644
--- a/test/selfmount.py
+++ b/test/selfmount.py
@@ -20,6 +20,7 @@ import signal
import struct
import sys
import termios
+import threading
import time
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -127,12 +128,42 @@ def run(binary):
return exits[-1].split()[2]
time.sleep(.1)
return 'timeout'
+ # Followed from before the burst: a fresh open of the log reads
+ # only what its 64 KiB ring still holds, which a burst this size
+ # outgrows, and a follower the ring outruns reads `lost N`, so
+ # every line is either heard once, in order, or a loss is said.
+ heard = []
+
+ def follow():
+ with Client(sock) as follower:
+ fid = follower.open('/log', 2)
+ start = len(follower.read_fid(fid))
+ follower.rpc(118, struct.pack('<IQI', fid, 0, len(b'follow new\n')) + b'follow new\n')
+ ready.set()
+ deadline = time.monotonic() + 60
+ while time.monotonic() < deadline:
+ record = follower.read_fid(fid, start).decode()
+ heard.append(record)
+ if 'mnt-0999' in record:
+ return
+ ready = threading.Event()
+ reader = threading.Thread(target=follow, daemon=True)
+ reader.start()
+ check(ready.wait(10), 'a follower of the log is waiting')
got = run(f"seq -f 'Msg mnt-%04g' 0 999 > {mount}/exec\n".encode())
check(got == '0', f'a 1000-line burst written through the mount ({got!r})')
- log = client.read('/log').decode()
- said = [line.split(' ', 2)[2] for line in log.splitlines() if line.startswith('msg ') and 'mnt-' in line]
- check(said == ['mnt-%04d' % i for i in range(1000)], f'each line of it ran once, whole ({len(said)} said)')
- check('exit 127' not in log, 'no piece of a line ran as a command')
+ reader.join(60)
+ for r in heard:
+ if r.startswith(('err ', 'exit ')):
+ print(' heard: ' + r.rstrip())
+ said = [r.rstrip('\n').split(' ', 2)[2] for r in heard if r.startswith('msg ') and 'mnt-' in r]
+ lost = [r for r in heard if r.startswith('lost ')]
+ numbers = [int(s[4:]) for s in said]
+ check(numbers == list(range(numbers[0] if numbers else 0, 1000)),
+ f'each line it heard ran once, whole, in order, through the last ({len(said)} heard)')
+ check(len(said) == 1000 or (lost and heard.index(lost[0]) < heard.index(next(r for r in heard if 'mnt-' in r))),
+ f'no line was lost unsaid ({len(said)} heard, {lost!r})')
+ check(not any('exit 127' in r for r in heard), 'no piece of a line ran as a command')
scratch = int(client.read('/pane/new'))
maker = "import sys; sys.stdout.write('Edit ,a\\n' + ''.join('line %05d of the block\\n' % i for i in range(2500)) + '.\\n')"
check(run(f'python3 -c "{maker}" > {mount}/pane/{scratch}/ctl\n'.encode()) == '0', 'a 50 KB Edit block written through the mount')
diff --git a/test/snapshot.zig b/test/snapshot.zig
index a176da7a..5522fbb9 100644
--- a/test/snapshot.zig
+++ b/test/snapshot.zig
@@ -1489,6 +1489,11 @@ fn buttonCode(name: []const u8) ?u16 {
const base = buttonCode(name["ctrl-".len..]) orelse return null;
return base + 16;
}
+ // Shift: over a program that tracks the mouse, pardes's own gesture.
+ if (std.mem.startsWith(u8, name, "shift-")) {
+ const base = buttonCode(name["shift-".len..]) orelse return null;
+ return base + 4;
+ }
if (std.mem.eql(u8, name, "left")) return 0;
if (std.mem.eql(u8, name, "middle")) return 1;
if (std.mem.eql(u8, name, "right")) return 2;
diff --git a/test/snapshots/chordcut.snap b/test/snapshots/chordcut.snap
index 1baef7ec..fddd9e92 100644
--- a/test/snapshots/chordcut.snap
+++ b/test/snapshots/chordcut.snap
@@ -124,9 +124,10 @@ snapstyle clickdismiss
press middle 59 23
release middle 59 23
stable 700 15000
-# tty paste chord: a mouse-listening app gets the click forwarded (SGR,
-# body-relative) then the register between bracketed-paste markers —
-# cat -v prints the received bytes visibly
+# tty paste chord: over a mouse-listening app the chord is pardes's with
+# Shift held; the app gets a click where the text goes (SGR, body-relative)
+# then the register between bracketed-paste markers — cat -v prints the
+# received bytes visibly
press left 35 23
release left 35 23
stable 400 5000
@@ -135,10 +136,10 @@ stable 600 8000
text stty -icanon -echo; printf '\x1b[?2004h\x1b[?1000h\x1b[?1006h'; cat -v
key enter
stable 700 8000
-press left 40 25
-press right 40 25
-release right 40 25
-release left 40 25
+press shift-left 40 25
+press shift-right 40 25
+release shift-right 40 25
+release shift-left 40 25
stable 700 8000
snap ttyprobe
# a plain prompt (mouse modes off) gets no click bytes — just the paste;
diff --git a/test/snapshots/lspcomplete.snap b/test/snapshots/lspcomplete.snap
index d9e165a1..eaa26c61 100644
--- a/test/snapshots/lspcomplete.snap
+++ b/test/snapshots/lspcomplete.snap
@@ -1,4 +1,8 @@
-# Tab after a dot in insert mode: "what could go here". The answer is a
+# Tab after a dot in insert mode: "what could go here".
+#
+# Each wait for zls's answer allows a minute: it returns as soon as the answer
+# is drawn (2 s alone), and a zls slowed by a loaded machine (the parallel
+# snap run) took over 15 s, which failed the old wait. The answer is a
# `+Search` buffer of zls's candidates, one row each at the cursor (a
# completion item names no declaration) — nothing is inserted and nothing pops
# up.
@@ -62,8 +66,8 @@ key tab
# The rows that prove it: `verdigris` and `cinnabar` can only have come from
# resolving the switch's condition to `Shade` and asking what it declares.
# Line 8 still reads `.` and nothing else — a completion INSERTS NOTHING.
-wait 15000 +Search
-stable 700 15000
+wait 60000 +Search
+stable 700 30000
snap completion
# Two more Tabs. Every one of them answers, and every answer REFILLS the one
# `+Search` rather than opening its twin. The SECOND press is already the whole
@@ -78,7 +82,7 @@ settle 150
key tab
settle 150
key tab
-stable 700 20000
+stable 700 30000
snap refill
# The pane is still in insert mode, which is right — the query answered beside
# the cursor and typing goes on — so walking the answer means leaving it first,
@@ -127,8 +131,8 @@ key end
text .
stable 400 5000
key tab
-wait 15000 +Search
-stable 700 15000
+wait 60000 +Search
+stable 700 30000
snap nosemi
# ...and a construct still hanging OPEN. The switch has no closing brace at
# all — the one `}` in the file closes it and leaves the function unclosed —
@@ -148,6 +152,6 @@ key end
text .
stable 400 5000
key tab
-wait 15000 +Search
-stable 700 15000
+wait 60000 +Search
+stable 700 30000
snap nobrace