diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/CHANGELOG.md | 25 | ||||
| -rw-r--r-- | src/gui/gui.zig | 183 | ||||
| -rw-r--r-- | src/mouse.zig | 85 | ||||
| -rw-r--r-- | src/terminal.zig | 49 |
4 files changed, 291 insertions, 51 deletions
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 3dd7c996..baff96b0 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 0.0.8 + +- A wheel over a terminal whose program asked for the mouse goes to the + program, as a report in the format it chose, or as arrow keys on the + alternate screen with alternate scroll: codex, htop, less and vim scroll. +- `pardes FILE` in a pane whose session answers never starts a nested + editor: a refusal is printed and it exits 1. A tty pardes killed by + SIGTERM or SIGHUP puts its terminal back first. +- A 9P connection holds up to 128 waiting reads, and refuses the next in + words; 9ns (cloud9 705be665) no longer deadlocks a mount under many held + reads. +- A backward search along one long line takes time in its length, not its + square. A detached session no client watches draws no frames: 50 MB of + output through `pty/run` takes 11 s, not over 30. +- Save makes its file's missing directories, for its own name and `Save + path` alike. `pane/new` takes rows from another column before refusing. +- With no pane at all, the root `look` opens its file and the root `exec` + runs in the session's directory. A refused `Newcol` logs only its error. +- `Lspwhy` explains the last query in its own file and place; every `+Lsp` + answer shares one pane per directory. +- DumpConfig's information lines start with `# `, and a line starting with + `#` runs as nothing. `winsize` refuses fewer than 2 rows. `look file:3:0` + is refused. `Kill X` with nothing running is a message, not a failure. + An event write-back needs no trailing newline. + ## 0.0.7 - `lapis` draws its cells unblended and each plaque pixel once: about a diff --git a/src/gui/gui.zig b/src/gui/gui.zig index be961477..045f22d9 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1882,6 +1882,9 @@ const Gui = struct { /// G6: a wheel's notches, whole lines, scroll the core at once and the /// picture follows on a spring (Glide), never the other way round. scroll_notch: i32 = 0, + /// A touchpad's fraction toward the next turn sent to a program that + /// owns the wheel (terminal.ownsWheel). + program_wheel: f32 = 0, glide: Glide = .{}, /// G9 and G11: the selection's and the hovered word's glow blocks as /// last drawn, for their fade-in. @@ -3578,55 +3581,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { }; g.pointer_mapped = true; g.pointer_cell = mc; - if (w.y != 0 and std.math.isFinite(w.y)) { - if (in.core) |core| { - const hit: ?usize = for (core.panes, 0..) |slot, i| { - if (slot == null) continue; - const r = core.rects[i]; - if (mc.col >= r.x and mc.col < r.x + r.w and mc.row >= r.y and mc.row < r.y + r.h) break i; - } else null; - if (g.scroll_pane) |old| if (hit == null or hit.? != old) { - resetScroll(g); - }; - if (hit) |id| { - const pdf_target = if (comptime pardes.pdf_enabled) - core.native_images and core.panes[id].?.pdfPage() != null - else - false; - if (pdf_target) { - resetScroll(g); - in.post(.{ .pdf_scroll = .{ - .pane = @intCast(id), - .delta_pixels = -w.y * @as(f32, @floatFromInt(g.cell_h)), - } }); - } else { - g.scroll_pane = id; - g.scroll_col = mc.col; - g.scroll_row = mc.row; - // A mouse wheel's notch is a whole line and - // glides (G6); a touchpad's fraction moves the - // picture by itself, under the finger. - if (notchOf(w.y, w.integer_y)) |lines| { - g.scroll_notch +|= lines; - } else g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); - } - } - } 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 } }); - 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 } }); - } - } - if (w.x != 0) in.post(.{ .mouse = .{ - .button = if (w.x > 0) .wheel_right else .wheel_left, - .kind = .press, - .col = mc.col, - .row = mc.row, - .tag_hit = mc.tag_hit, - .body_hit = mc.body_hit, - } }); + wheelAt(g, in, .{ .x = w.x, .y = w.y, .integer_y = w.integer_y }, 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; @@ -3933,6 +3888,73 @@ test "a click at the window's corner lands in the corner cell with a post chain /// Where a window point lands: the cell drawn under it. Input is identity: /// no post pass moves a pixel (the bundled Crt has no barrel), so a click /// is exactly where it looks. +/// A wheel turned over grid cell `mc` (SDL's sign: up positive): the +/// part of dispatch after the pointer is placed, so a test can turn it. +fn wheelAt(g: *Gui, in: *Input, w: WheelTurn, mc: MouseCell) void { + if (w.y != 0 and std.math.isFinite(w.y)) { + if (in.core) |core| { + const hit: ?usize = for (core.panes, 0..) |slot, i| { + if (slot == null) continue; + const r = core.rects[i]; + if (mc.col >= r.x and mc.col < r.x + r.w and mc.row >= r.y and mc.row < r.y + r.h) break i; + } else null; + if (g.scroll_pane) |old| if (hit == null or hit.? != old) { + resetScroll(g); + }; + if (hit) |id| { + const pdf_target = if (comptime pardes.pdf_enabled) + core.native_images and core.panes[id].?.pdfPage() != null + else + false; + const pane = core.panes[id].?; + if (pdf_target) { + resetScroll(g); + in.post(.{ .pdf_scroll = .{ + .pane = @intCast(id), + .delta_pixels = -w.y * @as(f32, @floatFromInt(g.cell_h)), + } }); + } else if (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. + 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 } }); + 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 } }); + } else { + g.scroll_pane = id; + g.scroll_col = mc.col; + g.scroll_row = mc.row; + // A mouse wheel's notch is a whole line and + // glides (G6); a touchpad's fraction moves the + // picture by itself, under the finger. + if (notchOf(w.y, w.integer_y)) |lines| { + g.scroll_notch +|= lines; + } else g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); + } + } + } 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 } }); + 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 } }); + } + } + if (w.x != 0) in.post(.{ .mouse = .{ + .button = if (w.x > 0) .wheel_right else .wheel_left, + .kind = .press, + .col = mc.col, + .row = mc.row, + .tag_hit = mc.tag_hit, + .body_hit = mc.body_hit, + } }); +} + +const WheelTurn = struct { x: f32 = 0, y: f32 = 0, integer_y: i32 = 0 }; + fn mouseCellWithGeometry(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32, geometry: WindowGeometry) ?MouseCell { const physical = windowPointToPixels(geometry, x, y); return gridCellAtPixels(g, core, physical.x, physical.y); @@ -4896,6 +4918,69 @@ 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 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; + 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 g: Gui = undefined; + g.scroll_pane = null; + g.scroll_delta = 0; + g.scroll_lag = 0; + g.scroll_edge_len = 0; + g.scroll_notch = 0; + g.program_wheel = 0; + g.glide = .{}; + g.cell_h = 20; + var in: Input = .{ .core = core }; + var sent: std.ArrayList(u8) = .empty; + defer sent.deinit(gpa); + const Sent = struct { + fn take(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; + } + }; + _ = try Sent.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 }; + // What codex 0.159's chat sets on start. + core.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h\x1b[>7u\x1b[?1004h\x1b[?1049h\x1b[?1007l\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h" } }); + _ = try Sent.take(core, &sent); + // A notch up, then a fast turn of three down: each its report, none + // left behind for a glide. + wheelAt(&g, &in, .{ .y = 1, .integer_y = 1 }, mc); + try std.testing.expectEqualStrings("\x1b[<64;6;4M", try Sent.take(core, &sent)); + wheelAt(&g, &in, .{ .y = -3, .integer_y = -3 }, mc); + try std.testing.expectEqualStrings("\x1b[<65;6;4M" ** 3, try Sent.take(core, &sent)); + try std.testing.expectEqual(@as(i32, 0), g.scroll_notch); + try std.testing.expect(g.scroll_pane == null); + // A touchpad's fractions add up to a turn. + wheelAt(&g, &in, .{ .y = 0.5 }, mc); + 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)); + // 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); + wheelAt(&g, &in, .{ .y = -1, .integer_y = -1 }, mc); + try std.testing.expectEqualStrings("\x1b[B", try Sent.take(core, &sent)); + // Back on the main screen a shell's wheel is ours: a notch to glide, + // nothing to the pty. + core.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049l" } }); + _ = try Sent.take(core, &sent); + wheelAt(&g, &in, .{ .y = 1, .integer_y = 1 }, mc); + try std.testing.expectEqualStrings("", try Sent.take(core, &sent)); + try std.testing.expectEqual(@as(i32, -1), g.scroll_notch); +} + /// G6 (docs/render-pipeline.md §9.1): a wheel's lines scroll the core at /// once, so every key and click acts on where the text is, and the picture /// glides there on the Motion flavour's spring (critically damped: text diff --git a/src/mouse.zig b/src/mouse.zig index 2280020d..a0ec6e47 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -310,6 +310,23 @@ pub fn sameBodyCell(a: ?Mouse.BodyHit, b: ?Mouse.BodyHit) bool { return first.serial == second.serial and first.row == second.row and first.col == second.col; } +/// 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 { + 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; + for (0..@intCast(config.wheel_rows)) |_| p.emitWrite(id, bytes); + return true; +} + 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. @@ -508,7 +525,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 + else if (!programWheel(p, id, if (m.button == .wheel_up) .up else .down, mcol, mrow, m.ctrl)) pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows); }, .wheel_left, .wheel_right => { @@ -516,7 +533,9 @@ 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 (pane.hasPdf()) { + if (programWheel(p, id, if (m.button == .wheel_right) .right else .left, mcol, mrow, m.ctrl)) { + // the program's + } else if (pane.hasPdf()) { panes.Pdf.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1); // ponytail: no right clamp — overscroll shows blank and the // next cursor move or left wheel pulls it back @@ -1793,3 +1812,65 @@ test "a chorded exec an event reader holds is told with its argument, and writte } _ = th.call(p, .{ .tag = 4, .op = .release, .node = event, .handle = h }); } + +/// Every byte the core wrote to pane 0's pty since the last call. +fn ptyWrites(p: *Pardes, out: *std.ArrayList(u8)) ![]const u8 { + out.clearRetainingCapacity(); + while (p.nextEffect()) |effect| { + if (effect == .write and effect.write.pane == 0) try out.appendSlice(p.gpa, effect.write.bytes.slice()); + } + return out.items; +} + +test "a wheel over a program that owns it is the program's, in the form it asked for; a shell's scrolls" { + 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].?; + const r = p.rects[0]; + // 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 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" }, + // 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" }, + // codex's sign-in, less: the alternate screen asking for arrow keys + // (1007), in the cursor keys' mode. + .{ .modes = "\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1007h", .button = .wheel_down, .sent = "\x1b[B" }, + .{ .modes = "\x1b[?1h", .button = .wheel_up, .sent = "\x1bOA" }, + .{ .modes = "", .button = .wheel_right, .sent = "" }, + // With alternate scroll off the alternate screen keeps the wheel. + .{ .modes = "\x1b[?1007l", .button = .wheel_up, .sent = "" }, + }; + 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 } }); + try std.testing.expectEqualStrings(case.sent, try ptyWrites(p, &out)); + } + // A shell on the main screen: the wheel scrolls its scrollback, and + // the shell hears nothing. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1l\x1b[?1049l" } }); + for (0..40) |i| { + var line: [16]u8 = undefined; + p.update(.{ .output = .{ .pane = 0, .bytes = try std.fmt.bufPrint(&line, "{d}\r\n", .{i}) } }); + } + _ = try p.render(frame.allocator()); + _ = try ptyWrites(p, &out); + const was = pane.scroll(); + 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); +} diff --git a/src/terminal.zig b/src/terminal.zig index 4cac6f1d..7874ca89 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -385,6 +385,55 @@ pub fn mouseFormatSgr(pane: *const Pane) bool { return state.vt.modes.get(.mouse_format_sgr); } +/// A wheel over the program is the program's: it tracks the mouse, or it is +/// on the alternate screen, which has no scrollback, with alternate scroll +/// (DECSET 1007, on unless turned off) asking for arrow keys instead. +pub fn ownsWheel(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return reportsMouse(pane) or + (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 }; + +/// 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 { + 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, + }; + 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]; +} + /// The whole scrollback as plain text, `gpa`-owned: what a terminal's body /// reads and `Save` writes out. Logical lines: rows the terminal wrapped /// are joined again (ghostty's wrap flags, as pty/run's output is), and |
