diff options
| -rw-r--r-- | build.zig.zon | 2 | ||||
| -rw-r--r-- | docs/fs.md | 5 | ||||
| -rw-r--r-- | docs/tags.md | 9 | ||||
| -rw-r--r-- | src/CHANGELOG.md | 13 | ||||
| -rw-r--r-- | src/detached/wire.zig | 19 | ||||
| -rw-r--r-- | src/edit.zig | 39 | ||||
| -rw-r--r-- | src/gui/gui.zig | 240 | ||||
| -rw-r--r-- | src/mouse.zig | 242 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 26 | ||||
| -rw-r--r-- | src/pardes.zig | 33 | ||||
| -rw-r--r-- | src/terminal.zig | 173 | ||||
| -rw-r--r-- | src/tty/tty.zig | 54 | ||||
| -rw-r--r-- | test/monkey9p.py | 12 | ||||
| -rw-r--r-- | test/panes.zig | 10 | ||||
| -rw-r--r-- | test/selfmount.py | 39 | ||||
| -rw-r--r-- | test/snapshot.zig | 5 | ||||
| -rw-r--r-- | test/snapshots/chordcut.snap | 15 | ||||
| -rw-r--r-- | test/snapshots/lspcomplete.snap | 20 |
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 @@ -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 |
