diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-30 14:12:51 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:17 -0300 |
| commit | b546b578baadbd8fdd15b42988ad4d66c9c8ff21 (patch) | |
| tree | bbbcaa94d63b0b695f09f2d389ca287377880d9a /src | |
| parent | 3f6733c805d873a7af6a6064ace0cf5916f481da (diff) | |
| download | pardes-b546b578baadbd8fdd15b42988ad4d66c9c8ff21.tar.gz pardes-b546b578baadbd8fdd15b42988ad4d66c9c8ff21.zip | |
A program that tracks the mouse gets its clicks, drags and moves over its grid, in the format it chose; Shift keeps pardes's select, execute and look, and chrome is never the program's
Presses and releases under 1000, drags with a button down under 1002,
every move once per cell under 1003, X10 presses under 9, in raw terminal
input only. A press the program took keeps its drag and release, reported
clamped to the grid's edge wherever the pointer goes. The press also makes
the pane the active one. docs/tags.md says so.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/gui/gui.zig | 85 | ||||
| -rw-r--r-- | src/mouse.zig | 203 | ||||
| -rw-r--r-- | src/pardes.zig | 3 | ||||
| -rw-r--r-- | src/terminal.zig | 7 |
4 files changed, 261 insertions, 37 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 4ce554f3..590d749d 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -3538,21 +3538,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { }; g.pointer_mapped = true; g.pointer_cell = mc; - const mods = pointerMods(); - 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 = mods.ctrl, - .shift = mods.shift, - .alt = mods.alt, - }, - }); + in.post(.{ .mouse = pointerMouse(button, if (b.down) .press else .release, mc, pointerMods()) }); }, c.SDL_EVENT_MOUSE_MOTION => { const m = sev.motion; @@ -3574,18 +3560,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { }; g.pointer_mapped = true; g.pointer_cell = mc; - const mods = pointerMods(); - 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, - .ctrl = mods.ctrl, - .shift = mods.shift, - .alt = mods.alt, - } }); + in.post(.{ .mouse = pointerMouse(held orelse .none, if (held != null) .drag else .motion, mc, pointerMods()) }); }, c.SDL_EVENT_MOUSE_WHEEL => { const w = sev.wheel; @@ -3980,6 +3955,22 @@ const WheelTurn = struct { x: f32 = 0, y: f32 = 0, integer_y: i32 = 0, mods: Poi /// 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 }; @@ -4948,6 +4939,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; diff --git a/src/mouse.zig b/src/mouse.zig index e80ea7f8..ecb9bb57 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -335,6 +335,104 @@ fn programWheel(p: *Pardes, id: usize, m: Mouse, col: u16, row: u16) bool { 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. @@ -384,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); @@ -1258,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); @@ -1897,3 +1993,90 @@ 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("\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/pardes.zig b/src/pardes.zig index d6533b3b..dcf0bf55 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4319,6 +4319,9 @@ 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, hover_col: u16 = 0, hover_row: u16 = 0, pointer_raw_col: u16 = 0, diff --git a/src/terminal.zig b/src/terminal.zig index 4d16bef2..63c5479f 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -380,6 +380,13 @@ pub fn reportsMouse(pane: *const Pane) bool { return state.vt.flags.mouse_event != .none; } +/// ...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. pub fn mouseFormatSgr(pane: *const Pane) bool { if (comptime !enabled) return false; |
